openclaw - ✅(Solved) Fix [Bug]: message_received plugin hook does not fire for queued/in-flight inbound messages [1 pull requests, 1 comments, 1 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
openclaw/openclaw#64525Fetched 2026-04-11 06:14:36
View on GitHub
Comments
1
Participants
1
Timeline
5
Reactions
0
Author
Participants
Timeline (top)
commented ×1cross-referenced ×1mentioned ×1referenced ×1

The message_received typed plugin hook only fires when a message triggers a new dispatch cycle via dispatchReplyFromConfig. When the target session is already active (processing a previous message), new inbound messages are queued directly into the running session without emitting message_received. This means plugins relying on this hook to observe ALL inbound messages (e.g., chat history logging) silently miss most messages.

In contrast, message_sent fires reliably for every outbound message because it is emitted from the delivery pipeline (delivery-*.js), not from the dispatch entry point.

Root Cause

The message_received hook is emitted only inside dispatchReplyFromConfig() in dispatch-*.js (line ~359):

if (hookRunner?.hasHooks("message_received"))
  fireAndForgetHook(
    hookRunner.runMessageReceived(
      toPluginMessageReceivedEvent(hookContext),
      toPluginMessageContext(hookContext)
    ),
    "dispatch-from-config: message_received plugin hook failed"
  );

When a session is already active, new messages are enqueued into the existing session without calling dispatchReplyFromConfig again. The hook emission is tied to the dispatch entry point rather than to the message ingestion point.

By contrast, message_sent is emitted from the channel-specific delivery files (e.g., delivery-*.js for Telegram), which run for every outbound message regardless of session state.

Fix Action

Fix / Workaround

The message_received typed plugin hook only fires when a message triggers a new dispatch cycle via dispatchReplyFromConfig. When the target session is already active (processing a previous message), new inbound messages are queued directly into the running session without emitting message_received. This means plugins relying on this hook to observe ALL inbound messages (e.g., chat history logging) silently miss most messages.

In contrast, message_sent fires reliably for every outbound message because it is emitted from the delivery pipeline (delivery-*.js), not from the dispatch entry point.

message_received should fire for every inbound message, regardless of whether the session is idle or already processing. The hook is documented as an observer (fire-and-forget) — it should not depend on dispatch state.

PR fix notes

PR #64559: fix(plugins): emit message_received hook for queued inbound messages (#64525)

Description (problem / solution / changelog)

Summary

  • Problem: The message_received plugin hook silently misses most inbound messages since v2026.4.2 — plugins relying on it for chat history logging, analytics, or content moderation observe near-zero inbound events while message_sent continues to fire reliably.
  • Why it matters: Plugins relying on message_received to observe ALL inbound messages are broken without any error signal.
  • What changed: Extracted hook emission into a standalone emitMessageReceivedHooks() function (following the emitPreAgentMessageHooks() pattern) and call it at the message ingestion layer in dispatchReplyFromConfig(), before the dispatch-vs-enqueue decision. Removed the old emission block that was deeper in the dispatch pipeline.
  • What did NOT change (scope boundary): The queue/drain mechanism (kickFollowupDrainIfIdle, enqueueFollowupRun, followup-runner) is not modified. The message_sent hook is untouched.

Change Type (select all)

  • Bug fix
  • Feature
  • Refactor required for the fix
  • Docs
  • Security hardening
  • Chore/infra

Scope (select all touched areas)

  • Gateway / orchestration
  • Skills / tool execution
  • Auth / tokens
  • Memory / storage
  • Integrations
  • API / contracts
  • UI / DX
  • CI/CD / infra

Linked Issue/PR

  • Closes #64525
  • This PR fixes a bug or regression

Root Cause (if applicable)

  • Root cause: The kickFollowupDrainIfIdle() mechanism (added in v2026.3.2 to fix stranded messages) creates a fast drain path through followup-runner.tsrunEmbeddedPiAgent that bypasses dispatchReplyFromConfig() — the only place where message_received was emitted. The regression became observable in v2026.4.2 when commit 622b91d04e changed queue timing, routing significantly more messages through the fast-path.
  • Missing detection / guardrail: No test asserted that message_received fires before the dispatch-vs-enqueue decision point.
  • Contributing context (if known): The original kickFollowupDrainIfIdle fix was correct — it solved a real race condition. The bug is that hook emission was architecturally coupled to the dispatch layer instead of the ingestion layer.

Regression Test Plan (if applicable)

  • Coverage level that should have caught this:
    • Unit test
    • Seam / integration test
    • End-to-end test
    • Existing coverage already sufficient
  • Target test or file: src/auto-reply/reply/dispatch-from-config.test.ts — "emits message_received hook before dispatch logic (regression #64525)"
  • Scenario the test should lock in: message_received hook fires before the reply resolver runs, ensuring it fires regardless of whether the message will be dispatched or enqueued.
  • Why this is the smallest reliable guardrail: The test verifies call ordering (hook before reply resolver) which breaks if the hook emission is moved back below the dispatch decision.
  • Existing test that already covers this (if any): The existing "emits message_received hook with originating channel metadata" test verified the hook fires but did not assert ordering relative to dispatch logic.

User-visible / Behavior Changes

  • message_received plugin hook now fires for ALL inbound messages, including those queued while a session is active. Previously these were silently missed.
  • Hook now fires slightly earlier in the pipeline (at ingestion, before routing/claim checks) — this is the correct semantic for "message received".

Diagram (if applicable)

Before:
Channel → dispatchReplyFromConfig() → [routing] → [claim checks] → message_received → session busy? → enqueue (no hook on drain)

After:
Channel → dispatchReplyFromConfig() → message_received → [routing] → [claim checks] → session busy? → enqueue (hook already fired)

Security Impact (required)

  • New permissions/capabilities? No
  • Secrets/tokens handling changed? No
  • New/changed network calls? No
  • Command/tool execution surface changed? No
  • Data access scope changed? No

Repro + Verification

Environment

  • OS: macOS
  • Runtime/container: Node 22+
  • Model/provider: N/A (hook infrastructure)
  • Integration/channel (if any): All channels affected

Steps

  1. Register a plugin with a message_received hook
  2. Send two messages in quick succession to a session
  3. Observe that the second message (queued while session is active) does not trigger the hook

Expected

  • message_received fires for both messages

Actual

  • message_received fires only for the first message; the second is silently missed

Evidence

  • Failing test/log before + passing after
  • Trace/log snippets
  • Screenshot/recording
  • Perf numbers (if relevant)

New regression test "emits message_received hook before dispatch logic (regression #64525)" in dispatch-from-config.test.ts asserts hook fires before reply resolver. 7 unit tests for emitMessageReceivedHooks() in message-received-hooks.test.ts.

Human Verification (required)

  • Verified scenarios: Hook fires for normal messages, hook fires before dispatch logic, hook skipped for duplicates, internal hook fires with session key, plugin hook uses correct event shape
  • Edge cases checked: Missing session key (internal hook skipped), missing message ID (fallback chain), missing timestamp (omitted), no registered hooks (plugin hook skipped)
  • What you did not verify: Live testing on a running OpenClaw instance with real channel adapter

Review Conversations

  • I replied to or resolved every bot review conversation I addressed in this PR.
  • I left unresolved only the conversations that still need reviewer or maintainer judgment.

Compatibility / Migration

  • Backward compatible? Yes
  • Config/env changes? No
  • Migration needed? No

Risks and Mitigations

  • Risk: Hook now fires for plugin-bound messages that previously returned early before reaching the emission point (handled/declined/error claim outcomes).
    • Mitigation: This is the correct semantic — message_received means "a message was received by the system", not "a message entered the dispatch pipeline". The hook is documented as fire-and-forget (observer).
  • Risk: Hook timing changed (slightly earlier in pipeline).
    • Mitigation: The PluginMessageReceivedEvent shape and PluginMessageContext are identical. FinalizedMsgContext fields are fully populated at the call site.

AI-assisted: Yes (Claude Code — research, root cause analysis, implementation, testing) Testing: Fully tested locally (pnpm build && pnpm check && pnpm test — all pass; pre-existing upstream failures unrelated)

Changed files

  • src/auto-reply/reply/dispatch-from-config.test.ts (modified, +42/-0)
  • src/auto-reply/reply/dispatch-from-config.ts (modified, +8/-33)
  • src/auto-reply/reply/message-received-hooks.test.ts (added, +174/-0)
  • src/auto-reply/reply/message-received-hooks.ts (added, +46/-0)

Code Example

if (hookRunner?.hasHooks("message_received"))
  fireAndForgetHook(
    hookRunner.runMessageReceived(
      toPluginMessageReceivedEvent(hookContext),
      toPluginMessageContext(hookContext)
    ),
    "dispatch-from-config: message_received plugin hook failed"
  );

---

2026-04-10T18:32:46 [plugins] [myplugin] message_received FIREDfrom=telegram:<redacted> content=hello
2026-04-10T18:34:52 [plugins] [myplugin] message_received FIREDfrom=telegram:<redacted> content=another message...
# Messages at 18:39 and 18:44NO hook fired (session was active processing replies)
RAW_BUFFERClick to expand / collapse

Summary

The message_received typed plugin hook only fires when a message triggers a new dispatch cycle via dispatchReplyFromConfig. When the target session is already active (processing a previous message), new inbound messages are queued directly into the running session without emitting message_received. This means plugins relying on this hook to observe ALL inbound messages (e.g., chat history logging) silently miss most messages.

In contrast, message_sent fires reliably for every outbound message because it is emitted from the delivery pipeline (delivery-*.js), not from the dispatch entry point.

Steps to Reproduce

  1. Install a plugin that registers a message_received hook via api.on("message_received", handler)
  2. Send a message to the bot on Telegram (or any channel)
  3. While the bot is still processing/responding, send another message
  4. Observe: the hook fires for the first message but not for the second (or any subsequent messages while the session is active)

Expected Behavior

message_received should fire for every inbound message, regardless of whether the session is idle or already processing. The hook is documented as an observer (fire-and-forget) — it should not depend on dispatch state.

Actual Behavior

  • First message to an idle session: hook fires ✅
  • Subsequent messages while session is active: hook does not fire ❌
  • message_sent (outbound): fires for every message ✅

Root Cause Analysis

The message_received hook is emitted only inside dispatchReplyFromConfig() in dispatch-*.js (line ~359):

if (hookRunner?.hasHooks("message_received"))
  fireAndForgetHook(
    hookRunner.runMessageReceived(
      toPluginMessageReceivedEvent(hookContext),
      toPluginMessageContext(hookContext)
    ),
    "dispatch-from-config: message_received plugin hook failed"
  );

When a session is already active, new messages are enqueued into the existing session without calling dispatchReplyFromConfig again. The hook emission is tied to the dispatch entry point rather than to the message ingestion point.

By contrast, message_sent is emitted from the channel-specific delivery files (e.g., delivery-*.js for Telegram), which run for every outbound message regardless of session state.

Proposed Fix

Emit message_received at the message ingestion layer (where the channel adapter receives the message), not at the dispatch layer. This ensures every inbound message triggers the hook, even when the destination session is already active.

Alternatively, add a secondary message_received emission in the message queue/enqueue path that handles in-flight sessions.

Impact

This bug affects any plugin that relies on message_received to observe inbound messages:

  • Chat history logging — inbound messages are never logged, only outbound
  • Analytics/metrics — inbound message counts are severely undercounted
  • Content moderation — messages may bypass pre-processing hooks
  • Silent observers (related: #61371) — observe mode is impossible without reliable hook emission

Related Issues

  • #31212 — message:received hook does not fire for Discord guild channel messages (same root cause, different channel)
  • #61371 — Silent/observe mode for WhatsApp groups (blocked by this bug)
  • #53341 — Opt-in blocking mode for message_received (requires this bug to be fixed first)

Environment

  • OpenClaw version: 2026.4.5
  • Channel: Telegram (but likely affects all channels)
  • Plugin: Custom extension using api.on("message_received", handler)
  • OS: macOS (arm64)

Evidence

Gateway logs showing the hook fires for the first message but not subsequent ones during an active session:

2026-04-10T18:32:46 [plugins] [myplugin] message_received FIRED — from=telegram:<redacted> content=hello
2026-04-10T18:34:52 [plugins] [myplugin] message_received FIRED — from=telegram:<redacted> content=another message...
# Messages at 18:39 and 18:44 — NO hook fired (session was active processing replies)

Note: The second FIRED at 18:34 only appeared because a gateway restart happened between messages, briefly making the session idle.

extent analysis

TL;DR

Emit the message_received hook at the message ingestion layer to ensure it fires for every inbound message, regardless of the session state.

Guidance

  • Identify the message ingestion point in the codebase where the channel adapter receives the message and add the message_received hook emission there.
  • Verify that the hook is fired for every inbound message by checking the gateway logs or adding a test case.
  • Consider adding a secondary message_received emission in the message queue/enqueue path to handle in-flight sessions.
  • Review related issues (#31212, #61371, #53341) to ensure that the fix addresses all affected areas.

Example

// Pseudo-code example of emitting the hook at the message ingestion layer
function handleMessageIngestion(message) {
  // ...
  if (hookRunner?.hasHooks("message_received")) {
    fireAndForgetHook(
      hookRunner.runMessageReceived(
        toPluginMessageReceivedEvent(hookContext),
        toPluginMessageContext(hookContext)
      ),
      "message-ingestion: message_received plugin hook failed"
    );
  }
  // ...
}

Notes

The proposed fix requires modifying the codebase to emit the message_received hook at the correct layer. This may involve updating the channel adapter or message queue/enqueue path. The example provided is pseudo-code and may need to be adapted to the actual codebase.

Recommendation

Apply the workaround by emitting the message_received hook at the message ingestion layer, as this ensures that the hook fires for every inbound message, regardless of the session state. This approach addresses the root cause of the issue and provides a reliable solution.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING