Skip to content

fix(telegram): emit sent hooks for preview finals#50938

Open
xiwuqi wants to merge 2 commits intoopenclaw:mainfrom
xiwuqi:wuxi/fix-telegram-message-sent-preview-hooks
Open

fix(telegram): emit sent hooks for preview finals#50938
xiwuqi wants to merge 2 commits intoopenclaw:mainfrom
xiwuqi:wuxi/fix-telegram-message-sent-preview-hooks

Conversation

@xiwuqi
Copy link
Copy Markdown

@xiwuqi xiwuqi commented Mar 20, 2026

Summary

  • Problem: Telegram DM replies that finish by finalizing or retaining a streaming preview never emit message:sent hooks because they do not go through deliverReplies.
  • Why it matters: internal or plugin hooks that rely on message:sent miss normal Telegram DM deliveries when preview streaming is active, so logging and automations silently lose visibility.
  • What changed: exported the existing Telegram message:sent hook emitter, added a final-preview delivery callback in the lane deliverer, and invoked the hook emitter from the Telegram dispatch path whenever a final preview becomes the delivered message.
  • What did NOT change (scope boundary): no gateway protocol changes, no preview rendering behavior changes, and no changes to non-final streaming updates.

Change Type (select all)

  • Bug fix
  • Feature
  • Refactor
  • 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

User-visible / Behavior Changes

  • Telegram message:sent hooks now fire when a DM reply completes by finalizing or retaining a streaming preview instead of sending a new payload.

Security Impact (required)

  • New permissions/capabilities? (Yes/No) No
  • Secrets/tokens handling changed? (Yes/No) No
  • New/changed network calls? (Yes/No) No
  • Command/tool execution surface changed? (Yes/No) No
  • Data access scope changed? (Yes/No) No
  • If any Yes, explain risk + mitigation:

Repro + Verification

Environment

  • OS: Windows 11 host
  • Runtime/container: local repo checkout
  • Model/provider: N/A
  • Integration/channel (if any): Telegram extension DM delivery
  • Relevant config (redacted): default preview streaming path in Telegram DM delivery

Steps

  1. Enable a message:sent internal or plugin hook for Telegram.
  2. Send a Telegram DM that is delivered through the preview-finalize path.
  3. Observe whether the hook fires after the final preview becomes the delivered message.

Expected

  • message:sent fires for final preview deliveries just as it does for deliverReplies sends.

Actual

  • Before this change, preview-finalized and preview-retained DM replies never emitted message:sent.

Evidence

Attach at least one:

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

Human Verification (required)

What you personally verified (not just CI), and how:

  • Verified scenarios: final preview edit success emits the hook; retained preview after ambiguous post-connect edit failure still emits the hook; ordinary deliverReplies path still uses the same shared emitter.
  • Edge cases checked: no hook emission for non-final preview updates, no new payload send on preview-retained/finalized paths, and existing Telegram extension tests remain green.
  • What you did not verify: live Telegram delivery against a real bot token.

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/No) Yes
  • Config/env changes? (Yes/No) No
  • Migration needed? (Yes/No) No
  • If yes, exact upgrade steps:

Failure Recovery (if this breaks)

  • How to disable/revert this change quickly: revert this PR/commit.
  • Files/config to restore: extensions/telegram/src/bot-message-dispatch.ts, extensions/telegram/src/bot/delivery.replies.ts, extensions/telegram/src/lane-delivery-text-deliverer.ts
  • Known bad symptoms reviewers should watch for: duplicate hook emissions on final Telegram DM deliveries.

Risks and Mitigations

  • Risk: preview-finalized paths could emit duplicate hooks if they later also flowed through deliverReplies.
    • Mitigation: the callback only runs on final preview delivery branches where no final deliverReplies payload is sent, and the regression tests assert the final text is not re-sent via deliverReplies.

AI-assisted: yes. Context preparation used ContextForge before implementation.

@openclaw-barnacle openclaw-barnacle bot added channel: telegram Channel integration: telegram size: S labels Mar 20, 2026
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 20, 2026

Greptile Summary

This PR fixes a long-standing gap where Telegram DM replies that complete by finalising or retaining a streaming preview never emitted message:sent hooks, causing logging and automation hooks to silently miss those deliveries. The fix extracts the existing hook-emission logic into an exported emitDeliveredReplyHooks helper (refactoring the two existing call sites in deliverReplies to use it), then plumbs an onFinalPreviewDelivered callback through createLaneTextDeliverer so every terminal preview branch — edit success, message-not-modified, network-may-have-landed, missing-target-with-alternate, ambiguous error, draft-preview materialise, and the regressive-skip shortcut — emits the hook.

Key observations:

  • The hook-emission semantics are fire-and-forget (fireAndForgetHook) throughout; this is unchanged and intentional.
  • All terminal preview branches in tryEditPreviewMessage, tryMaterializeDraftPreviewForFinal, tryUpdatePreviewForLane (regressive skip + landing-ambiguous-without-id) now call notifyFinalPreviewDelivered, which looks comprehensive.
  • The new test covers both "preview-finalized" and "preview-retained" scenarios and explicitly asserts the final text is not re-sent via deliverReplies, guarding against the duplicate-emission risk identified in the PR description.
  • success: true is hardcoded in onFinalPreviewDelivered for all retained/edited outcomes. In "ambiguous error" retained paths the old preview text remains visible to the user rather than the final text, so hook consumers receive content: <final answer>, success: true even though the exact final text may not have landed. This is a deliberate design choice (consistent with how the codebase treats "may have landed" scenarios) but worth being aware of downstream.
  • Minor: the onFinalPreviewDelivered callback is marked async despite containing no awaitemitDeliveredReplyHooks returns void.

Confidence Score: 4/5

  • Safe to merge; the change is additive (new hook emissions only) with no protocol or rendering changes, and is covered by dedicated regression tests.
  • The implementation is mechanically correct: the refactored emitDeliveredReplyHooks helper is a faithful extraction of the original logic, all terminal preview branches in the lane deliverer now call the notification helper, and the test explicitly guards against duplicate emissions via deliverReplies. The only deductions are a cosmetic async-without-await on the callback and the acknowledged semantic ambiguity of success: true on "retained" paths where the final text may not have reached the user — both are low-risk and the latter is a deliberate trade-off documented in the PR.
  • extensions/telegram/src/lane-delivery-text-deliverer.ts — verify all new notifyFinalPreviewDelivered call sites (there are 7 across multiple error branches) are in the correct terminal position relative to params.markDelivered() to avoid races if the callback ever becomes truly async in the future.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: extensions/telegram/src/bot-message-dispatch.ts
Line: 511-522

Comment:
**`async` without any `await`**

`emitDeliveredReplyHooks` returns `void` (it uses `fireAndForgetHook` internally), so marking this callback `async` adds no value — the returned Promise resolves immediately on the next microtick. Future readers may expect the hook emissions to be awaited before the caller continues, which is not what happens.

```suggestion
    onFinalPreviewDelivered: ({ text, messageId }) => {
      emitDeliveredReplyHooks({
        sessionKeyForInternalHooks: ctxPayload.SessionKey,
        chatId: String(chatId),
        accountId: route.accountId,
        content: text,
        success: true,
        messageId,
        isGroup,
        groupId: isGroup ? String(chatId) : undefined,
      });
    },
```

If the callback signature in `CreateLaneTextDelivererParams` also needs updating:

```ts
// lane-delivery-text-deliverer.ts
onFinalPreviewDelivered?: (params: {
  laneName: LaneName;
  text: string;
  messageId?: number;
}) => void;
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: extensions/telegram/src/bot/delivery.replies.ts
Line: 560-565

Comment:
**`getGlobalHookRunner()` called on every invocation**

The previous `deliverReplies` code captured `hookRunner` and `hasMessageSentHooks` once before the delivery loop. Now that `emitDeliveredReplyHooks` is the shared entry point, both `getGlobalHookRunner()` and `hasHooks("message_sent")` are re-evaluated on every call — once per reply chunk in `deliverReplies`, and once for each final-preview path in the lane deliverer. This is a minor inefficiency. If `getGlobalHookRunner()` is cheap/returns a singleton it doesn't matter, but it is worth noting for future maintainers.

No code change required unless `getGlobalHookRunner()` turns out to have observable cost, but consider a brief comment explaining the intentional fire-and-forget semantics here to prevent a well-meaning refactor from accidentally turning this into a blocking call.

How can I resolve this? If you propose a fix, please make it concise.

Last reviewed commit: "fix(telegram): emit ..."

Comment on lines +511 to +522
onFinalPreviewDelivered: async ({ text, messageId }) => {
emitDeliveredReplyHooks({
sessionKeyForInternalHooks: ctxPayload.SessionKey,
chatId: String(chatId),
accountId: route.accountId,
content: text,
success: true,
messageId,
isGroup,
groupId: isGroup ? String(chatId) : undefined,
});
},
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 async without any await

emitDeliveredReplyHooks returns void (it uses fireAndForgetHook internally), so marking this callback async adds no value — the returned Promise resolves immediately on the next microtick. Future readers may expect the hook emissions to be awaited before the caller continues, which is not what happens.

Suggested change
onFinalPreviewDelivered: async ({ text, messageId }) => {
emitDeliveredReplyHooks({
sessionKeyForInternalHooks: ctxPayload.SessionKey,
chatId: String(chatId),
accountId: route.accountId,
content: text,
success: true,
messageId,
isGroup,
groupId: isGroup ? String(chatId) : undefined,
});
},
onFinalPreviewDelivered: ({ text, messageId }) => {
emitDeliveredReplyHooks({
sessionKeyForInternalHooks: ctxPayload.SessionKey,
chatId: String(chatId),
accountId: route.accountId,
content: text,
success: true,
messageId,
isGroup,
groupId: isGroup ? String(chatId) : undefined,
});
},

If the callback signature in CreateLaneTextDelivererParams also needs updating:

// lane-delivery-text-deliverer.ts
onFinalPreviewDelivered?: (params: {
  laneName: LaneName;
  text: string;
  messageId?: number;
}) => void;
Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/telegram/src/bot-message-dispatch.ts
Line: 511-522

Comment:
**`async` without any `await`**

`emitDeliveredReplyHooks` returns `void` (it uses `fireAndForgetHook` internally), so marking this callback `async` adds no value — the returned Promise resolves immediately on the next microtick. Future readers may expect the hook emissions to be awaited before the caller continues, which is not what happens.

```suggestion
    onFinalPreviewDelivered: ({ text, messageId }) => {
      emitDeliveredReplyHooks({
        sessionKeyForInternalHooks: ctxPayload.SessionKey,
        chatId: String(chatId),
        accountId: route.accountId,
        content: text,
        success: true,
        messageId,
        isGroup,
        groupId: isGroup ? String(chatId) : undefined,
      });
    },
```

If the callback signature in `CreateLaneTextDelivererParams` also needs updating:

```ts
// lane-delivery-text-deliverer.ts
onFinalPreviewDelivered?: (params: {
  laneName: LaneName;
  text: string;
  messageId?: number;
}) => void;
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +560 to +565
const hookRunner = getGlobalHookRunner();
emitMessageSentHooks({
hookRunner,
enabled: hookRunner?.hasHooks("message_sent") ?? false,
...params,
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 getGlobalHookRunner() called on every invocation

The previous deliverReplies code captured hookRunner and hasMessageSentHooks once before the delivery loop. Now that emitDeliveredReplyHooks is the shared entry point, both getGlobalHookRunner() and hasHooks("message_sent") are re-evaluated on every call — once per reply chunk in deliverReplies, and once for each final-preview path in the lane deliverer. This is a minor inefficiency. If getGlobalHookRunner() is cheap/returns a singleton it doesn't matter, but it is worth noting for future maintainers.

No code change required unless getGlobalHookRunner() turns out to have observable cost, but consider a brief comment explaining the intentional fire-and-forget semantics here to prevent a well-meaning refactor from accidentally turning this into a blocking call.

Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/telegram/src/bot/delivery.replies.ts
Line: 560-565

Comment:
**`getGlobalHookRunner()` called on every invocation**

The previous `deliverReplies` code captured `hookRunner` and `hasMessageSentHooks` once before the delivery loop. Now that `emitDeliveredReplyHooks` is the shared entry point, both `getGlobalHookRunner()` and `hasHooks("message_sent")` are re-evaluated on every call — once per reply chunk in `deliverReplies`, and once for each final-preview path in the lane deliverer. This is a minor inefficiency. If `getGlobalHookRunner()` is cheap/returns a singleton it doesn't matter, but it is worth noting for future maintainers.

No code change required unless `getGlobalHookRunner()` turns out to have observable cost, but consider a brief comment explaining the intentional fire-and-forget semantics here to prevent a well-meaning refactor from accidentally turning this into a blocking call.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 945994445a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +366 to +369
if (shouldSkipRegressive) {
params.markDelivered();
if (context === "final") {
await notifyFinalPreviewDelivered(laneName, text, previewMessageId);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Stop emitting final-text hooks when regressive preview is retained

When the streamed preview overshoots and the final answer is a strict prefix of lastPartialText, this branch intentionally keeps the longer preview visible and skips the edit. The new callback still emits message_sent/message:sent with the shorter final text, so any logger or automation consuming the hook will record content that the Telegram user never actually saw in this regression case.

Useful? React with 👍 / 👎.

Comment on lines +421 to +422
params.markDelivered();
await notifyFinalPreviewDelivered(laneName, text);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Don't mark unconfirmed preview retention as a successful send

This branch only knows that a preview send was attempted and the response was lost (sendMayHaveLanded()), so it keeps the existing preview specifically to avoid a duplicate. Emitting a successful sent hook with the new final text here can falsely claim delivery when Telegram may have left the old partial preview on screen, which makes downstream message_sent/message:sent consumers treat an unconfirmed send as ground truth.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b317782f84

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines 287 to +288
params.markDelivered();
await notifyFinalPreviewDelivered(args.laneName, args.text, args.messageId);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Report the surviving preview, not the missing target, as sent

When this branch keeps an alternate visible preview because the archived finalize target disappeared, the delivered message is no longer args.messageIdtryUpdatePreviewForLane() only sets retainAlternatePreviewOnMissingTarget when a different active preview is still present. Emitting notifyFinalPreviewDelivered(..., args.messageId) here makes message:sent hooks advertise the vanished archived message ID (and final text) even though users are looking at a different preview, so any hook automation that uses context.messageId to edit, pin, or react will target the wrong message.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

channel: telegram Channel integration: telegram size: S

Projects

None yet

Development

Successfully merging this pull request may close these issues.

message:sent hook not emitted when Telegram streaming preview finalizes

1 participant