diff --git a/docs/plugins/sdk-overview.md b/docs/plugins/sdk-overview.md index ffba10c8067..a25234d8408 100644 --- a/docs/plugins/sdk-overview.md +++ b/docs/plugins/sdk-overview.md @@ -187,6 +187,12 @@ guidance remain available to non-Codex prompt surfaces for compatibility. | `api.registerNodeInvokePolicy(policy)` | Allowlist/approval policy for node-invoked commands | | `api.registerSecurityAuditCollector(collector)` | Findings collector for `openclaw security audit` | +Telegram interactive handlers can return `{ submitText }` to route text through +Telegram's normal inbound agent path after the handler succeeds. OpenClaw keeps +the callback button when inbound policy skips the text or processing fails, so +the user can retry after the blocking condition changes. This result field is +Telegram-specific; other channels keep their own interactive result contracts. + ### Host hooks for workflow plugins Host hooks are the SDK seams for plugins that need to participate in the host diff --git a/extensions/telegram/api.ts b/extensions/telegram/api.ts index 9b24a3c89a4..352127186ed 100644 --- a/extensions/telegram/api.ts +++ b/extensions/telegram/api.ts @@ -1,8 +1,5 @@ // Telegram API module exposes the plugin public contract. -export type { - Message as TelegramBotMessage, - Update as TelegramBotUpdate, -} from "grammy/types"; +export type { Message as TelegramBotMessage, Update as TelegramBotUpdate } from "grammy/types"; export { telegramPlugin } from "./src/channel.js"; export { telegramSetupPlugin } from "./src/channel.setup.js"; export { @@ -109,6 +106,7 @@ export { export type { TelegramInteractiveHandlerContext, TelegramInteractiveHandlerRegistration, + TelegramInteractiveHandlerResult, } from "./src/interactive-dispatch.js"; export { isTelegramInlineButtonsEnabled, diff --git a/extensions/telegram/contract-api.ts b/extensions/telegram/contract-api.ts index abc6fab59f0..3a542be7ea0 100644 --- a/extensions/telegram/contract-api.ts +++ b/extensions/telegram/contract-api.ts @@ -15,4 +15,5 @@ export { export type { TelegramInteractiveHandlerContext, TelegramInteractiveHandlerRegistration, + TelegramInteractiveHandlerResult, } from "./src/interactive-dispatch.js"; diff --git a/extensions/telegram/src/bot-handlers.runtime.ts b/extensions/telegram/src/bot-handlers.runtime.ts index de05ab8b54d..5ce1f7ec358 100644 --- a/extensions/telegram/src/bot-handlers.runtime.ts +++ b/extensions/telegram/src/bot-handlers.runtime.ts @@ -1743,6 +1743,75 @@ export const registerTelegramHandlers = ({ const isPermanentTelegramCallbackEditError = (err: unknown): boolean => isTelegramEditTargetMissingError(err) || isTelegramMessageHasNoTextError(err); + const TELEGRAM_PLUGIN_CALLBACK_SUBMIT_RETRY_DELAYS_MS = [250, 1000, 2500] as const; + const REPLY_SESSION_INIT_CONFLICT_MESSAGE_RE = /reply session initialization conflicted for \S+/u; + + const sleep = (ms: number): Promise => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + + const resolvePluginCallbackSubmitText = (submitText: unknown): string | undefined => { + if (typeof submitText !== "string") { + return undefined; + } + const trimmed = submitText.trim(); + return trimmed ? trimmed : undefined; + }; + + const isReplySessionInitConflictError = (err: unknown): boolean => + REPLY_SESSION_INIT_CONFLICT_MESSAGE_RE.test(String(err instanceof Error ? err.message : err)); + + const isReplySessionInitConflictResult = (result: TelegramMessageProcessingResult): boolean => + result.kind === "failed-retryable" && isReplySessionInitConflictError(result.error); + + const processPluginCallbackSubmitText = async (params: { + callbackId: string; + syntheticCtx: Parameters[0]["ctx"]; + syntheticMessage: Parameters[0]["msg"]; + storeAllowFrom: Parameters[0]["storeAllowFrom"]; + }): Promise<"completed" | "skipped"> => { + for (let attempt = 0; ; attempt++) { + try { + const result = await processMessageWithReplyChain({ + ctx: params.syntheticCtx, + msg: params.syntheticMessage, + allMedia: [], + storeAllowFrom: params.storeAllowFrom, + options: { + spooledReplay: true, + forceWasMentioned: true, + messageIdOverride: params.callbackId, + }, + }); + if (result.kind === "completed") { + return "completed"; + } + if (result.kind === "skipped") { + return "skipped"; + } + const retryDelayMs = TELEGRAM_PLUGIN_CALLBACK_SUBMIT_RETRY_DELAYS_MS[attempt]; + if (!isReplySessionInitConflictResult(result) || retryDelayMs === undefined) { + throw new TelegramRetryableCallbackError(result.error); + } + logVerbose( + `telegram plugin callback submitText hit active reply session; retrying in ${retryDelayMs}ms`, + ); + await sleep(retryDelayMs); + continue; + } catch (err) { + const retryDelayMs = TELEGRAM_PLUGIN_CALLBACK_SUBMIT_RETRY_DELAYS_MS[attempt]; + if (!isReplySessionInitConflictError(err) || retryDelayMs === undefined) { + throw err; + } + logVerbose( + `telegram plugin callback submitText hit active reply session; retrying in ${retryDelayMs}ms`, + ); + await sleep(retryDelayMs); + } + } + }; + const resolveTelegramEventAuthorizationContext = async (params: { chatId: number; isGroup: boolean; @@ -2676,6 +2745,37 @@ export const registerTelegramHandlers = ({ await deleteCallbackMessage(); }, }, + afterInvoke: async (result) => { + if (result?.handled === false) { + return; + } + const submitText = resolvePluginCallbackSubmitText(result?.submitText); + if (!submitText) { + return; + } + const { ctx: syntheticCtx, message: syntheticMessage } = + buildCallbackSyntheticTextContext({ + ctx, + callbackMessage, + callback, + text: submitText, + isForum, + }); + const submitOutcome = await processPluginCallbackSubmitText({ + callbackId: callback.id, + syntheticCtx, + syntheticMessage, + storeAllowFrom, + }); + if (submitOutcome === "skipped") { + return; + } + // The agent turn already completed. Cleanup failure must not release + // callback dedupe and replay the submitted turn. + await clearCallbackButtons().catch((err: unknown) => { + logVerbose(`telegram plugin callback button cleanup skipped: ${String(err)}`); + }); + }, }); if (pluginCallback.handled) { return; diff --git a/extensions/telegram/src/bot.test.ts b/extensions/telegram/src/bot.test.ts index 5a21821c237..5365c3f9a1f 100644 --- a/extensions/telegram/src/bot.test.ts +++ b/extensions/telegram/src/bot.test.ts @@ -18,7 +18,10 @@ import { resolveTelegramConversationBaseSessionKey, resolveTelegramConversationRoute, } from "./conversation-route.js"; -import type { TelegramInteractiveHandlerContext } from "./interactive-dispatch.js"; +import type { + TelegramInteractiveHandlerContext, + TelegramInteractiveHandlerRegistration, +} from "./interactive-dispatch.js"; import { buildTelegramOpaqueCallbackData } from "./native-command-callback-data.js"; import { clearTelegramRuntime, setTelegramRuntime } from "./runtime.js"; import type { TelegramRuntime } from "./runtime.types.js"; @@ -90,6 +93,28 @@ function waitForReplyCalls(count: number) { return done.promise; } +function setTelegramPluginStateRuntimeForTests() { + setTelegramRuntime({ + state: { + openKeyedStore: ((options) => + createPluginStateKeyedStoreForTests( + "telegram", + options, + )) as TelegramRuntime["state"]["openKeyedStore"], + openSyncKeyedStore: ((options) => + createPluginStateSyncKeyedStoreForTests( + "telegram", + options, + )) as TelegramRuntime["state"]["openSyncKeyedStore"], + }, + channel: {}, + } as TelegramRuntime); +} + +function getTelegramCallbackHandlerForTests() { + return getOnHandler("callback_query") as (ctx: Record) => Promise; +} + async function loadEnvelopeTimestampHelpers() { return await import("openclaw/plugin-sdk/channel-test-helpers"); } @@ -3959,6 +3984,360 @@ describe("createTelegramBot", () => { expect(replySpy).not.toHaveBeenCalled(); }); + it("submits plugin-owned callback text through the Telegram inbound path", async () => { + onSpy.mockClear(); + replySpy.mockClear(); + editMessageReplyMarkupSpy.mockClear(); + const replyDone = waitForReplyCalls(1); + registerPluginInteractiveHandler("smart-replies-plugin", { + channel: "telegram", + namespace: "openclaw-smart-replies", + handler: async () => ({ handled: true, submitText: "Fix a broken tool" }), + } satisfies TelegramInteractiveHandlerRegistration); + setTelegramPluginStateRuntimeForTests(); + + try { + createTelegramBot({ + token: "tok", + config: { + channels: { + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + }, + }, + }, + }); + const callbackHandler = getTelegramCallbackHandlerForTests(); + + await callbackHandler({ + callbackQuery: { + id: "cbq-smart-reply-submit", + data: "openclaw-smart-replies:v1:Rm14IGEgYnJva2VuIHRvb2w", + from: { id: 9, first_name: "Ada", username: "ada_bot" }, + message: { + chat: { id: 9, type: "private" }, + date: 1736380800, + message_id: 11, + text: "What should I help you sharpen next?", + }, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + await replyDone; + } finally { + clearTelegramRuntime(); + } + + expect(editMessageReplyMarkupSpy).toHaveBeenCalledWith(9, 11, { + reply_markup: { inline_keyboard: [] }, + }); + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = mockMsgContextArg(replySpy as unknown as MockCallSource, 0, 0, "replySpy call"); + expect(payload.Body).toContain("Fix a broken tool"); + expect(payload.SenderId).toBe("9"); + expect(payload.SenderUsername).toBe("ada_bot"); + }); + + it("does not submit plugin-owned callback text when the handler declines the callback", async () => { + onSpy.mockClear(); + replySpy.mockClear(); + editMessageReplyMarkupSpy.mockClear(); + const handler = vi.fn(async () => ({ handled: false, submitText: "Ignore this" })); + registerPluginInteractiveHandler("smart-replies-plugin", { + channel: "telegram", + namespace: "openclaw-smart-replies", + handler, + } satisfies TelegramInteractiveHandlerRegistration); + setTelegramPluginStateRuntimeForTests(); + + try { + createTelegramBot({ + token: "tok", + config: { + channels: { + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + }, + }, + }, + }); + const callbackHandler = getTelegramCallbackHandlerForTests(); + + await callbackHandler({ + callbackQuery: { + id: "cbq-smart-reply-declined-submit", + data: "openclaw-smart-replies:v1:SWdub3JlIHRoaXM", + from: { id: 9, first_name: "Ada", username: "ada_bot" }, + message: { + chat: { id: 9, type: "private" }, + date: 1736380800, + message_id: 11, + text: "Pick a direction", + }, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + } finally { + clearTelegramRuntime(); + } + + expect(handler).toHaveBeenCalledTimes(1); + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = mockMsgContextArg(replySpy as unknown as MockCallSource, 0, 0, "replySpy call"); + expect(payload.Body).toContain("callback_data: openclaw-smart-replies"); + expect(payload.Body).not.toContain("Ignore this"); + expect(editMessageReplyMarkupSpy).not.toHaveBeenCalled(); + }); + + it("does not retry plugin-owned callback text skipped by inbound policy", async () => { + onSpy.mockClear(); + replySpy.mockClear(); + editMessageReplyMarkupSpy.mockClear(); + const handler = vi.fn(async () => ({ handled: true, submitText: "Do not submit this" })); + registerPluginInteractiveHandler("smart-replies-plugin", { + channel: "telegram", + namespace: "openclaw-smart-replies", + handler, + } satisfies TelegramInteractiveHandlerRegistration); + + createTelegramBot({ + token: "tok", + config: { + channels: { + telegram: { + dmPolicy: "disabled", + capabilities: { inlineButtons: "dm" }, + }, + }, + }, + }); + const callbackHandler = getTelegramCallbackHandlerForTests(); + + const callbackContext = { + callbackQuery: { + id: "cbq-smart-reply-policy-skip", + data: "openclaw-smart-replies:v1:RG8gbm90IHN1Ym1pdCB0aGlz", + from: { id: 9, first_name: "Ada", username: "ada_bot" }, + message: { + chat: { id: 9, type: "private" }, + date: 1736380800, + message_id: 11, + text: "Pick a direction", + }, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }; + + await expect(callbackHandler(callbackContext)).resolves.toBeUndefined(); + await expect(callbackHandler(callbackContext)).resolves.toBeUndefined(); + + expect(handler).toHaveBeenCalledOnce(); + expect(replySpy).not.toHaveBeenCalled(); + expect(editMessageReplyMarkupSpy).not.toHaveBeenCalled(); + }); + + it("submits plugin-owned callback text in mention-required group topics", async () => { + onSpy.mockClear(); + replySpy.mockClear(); + editMessageReplyMarkupSpy.mockClear(); + const replyDone = waitForReplyCalls(1); + registerPluginInteractiveHandler("smart-replies-plugin", { + channel: "telegram", + namespace: "openclaw-smart-replies", + handler: async () => ({ handled: true, submitText: "Investigate topic callback" }), + } satisfies TelegramInteractiveHandlerRegistration); + setTelegramPluginStateRuntimeForTests(); + + try { + createTelegramBot({ + token: "tok", + config: { + channels: { + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + capabilities: { inlineButtons: "group" }, + groupPolicy: "open", + groups: { "*": { requireMention: true } }, + }, + }, + }, + }); + const callbackHandler = getTelegramCallbackHandlerForTests(); + + await callbackHandler({ + callbackQuery: { + id: "cbq-smart-reply-topic-submit", + data: "openclaw-smart-replies:v1:SW52ZXN0aWdhdGUgdG9waWMgY2FsbGJhY2s", + from: { id: 9, first_name: "Ada", username: "ada_bot" }, + message: { + chat: { id: -100987654321, type: "supergroup", title: "Forum Group", is_forum: true }, + date: 1736380800, + is_topic_message: true, + message_id: 11, + message_thread_id: 99, + text: "What should I help you sharpen next?", + }, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + await replyDone; + } finally { + clearTelegramRuntime(); + } + + expect(editMessageReplyMarkupSpy).toHaveBeenCalledWith(-100987654321, 11, { + reply_markup: { inline_keyboard: [] }, + }); + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = mockMsgContextArg(replySpy as unknown as MockCallSource, 0, 0, "replySpy call"); + expect(payload.Body).toContain("Investigate topic callback"); + expect(payload.MessageSid).toBe("cbq-smart-reply-topic-submit"); + expect(payload.WasMentioned).toBe(true); + expect(payload.SenderId).toBe("9"); + expect(payload.SenderUsername).toBe("ada_bot"); + }); + + it("retries plugin-owned callback text when the previous reply session is still closing", async () => { + onSpy.mockClear(); + replySpy.mockClear(); + editMessageReplyMarkupSpy.mockClear(); + let calls = 0; + replySpy.mockImplementation(async (_ctx, opts) => { + calls += 1; + await opts?.onReplyStart?.(); + if (calls === 1) { + throw new Error("reply session initialization conflicted for agent:main:telegram:9"); + } + return undefined; + }); + registerPluginInteractiveHandler("smart-replies-plugin", { + channel: "telegram", + namespace: "openclaw-smart-replies", + handler: async () => ({ handled: true, submitText: "Make Alice funnier" }), + } satisfies TelegramInteractiveHandlerRegistration); + setTelegramPluginStateRuntimeForTests(); + + try { + createTelegramBot({ + token: "tok", + config: { + channels: { + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + }, + }, + }, + }); + const callbackHandler = getTelegramCallbackHandlerForTests(); + + await callbackHandler({ + callbackQuery: { + id: "cbq-smart-reply-submit-retry", + data: "openclaw-smart-replies:v1:TWFrZSBBbGljZSBmdW5uaWVy", + from: { id: 9, first_name: "Ada", username: "ada_bot" }, + message: { + chat: { id: 9, type: "private" }, + date: 1736380800, + message_id: 11, + text: "Pick a direction", + }, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + } finally { + clearTelegramRuntime(); + } + + expect(replySpy).toHaveBeenCalledTimes(2); + expect(editMessageReplyMarkupSpy).toHaveBeenCalledWith(9, 11, { + reply_markup: { inline_keyboard: [] }, + }); + const payload = mockMsgContextArg( + replySpy as unknown as MockCallSource, + 1, + 0, + "replySpy retry call", + ); + expect(payload.Body).toContain("Make Alice funnier"); + }); + + it("releases plugin-owned callback dedupe when submitted text processing fails", async () => { + onSpy.mockClear(); + replySpy.mockClear(); + editMessageReplyMarkupSpy.mockClear(); + let calls = 0; + replySpy.mockImplementation(async (_ctx, opts) => { + calls += 1; + await opts?.onReplyStart?.(); + if (calls === 1) { + throw new Error("transient submit failure"); + } + return undefined; + }); + const handler = vi.fn(async () => ({ handled: true, submitText: "Try this later" })); + registerPluginInteractiveHandler("smart-replies-plugin", { + channel: "telegram", + namespace: "openclaw-smart-replies", + handler, + } satisfies TelegramInteractiveHandlerRegistration); + setTelegramPluginStateRuntimeForTests(); + + try { + createTelegramBot({ + token: "tok", + config: { + channels: { + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + }, + }, + }, + }); + const callbackHandler = getTelegramCallbackHandlerForTests(); + const createCallbackUpdate = (updateId: number) => ({ + update_id: updateId, + callbackQuery: { + id: "cbq-smart-reply-submit-fail", + data: "openclaw-smart-replies:v1:VHJ5IHRoaXMgbGF0ZXI", + from: { id: 9, first_name: "Ada", username: "ada_bot" }, + message: { + chat: { id: 9, type: "private" }, + date: 1736380800, + message_id: 11, + text: "Pick a direction", + }, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + await expect(callbackHandler(createCallbackUpdate(401))).rejects.toThrow( + "transient submit failure", + ); + expect(editMessageReplyMarkupSpy).not.toHaveBeenCalled(); + + await callbackHandler(createCallbackUpdate(402)); + } finally { + clearTelegramRuntime(); + } + + expect(handler).toHaveBeenCalledTimes(2); + expect(replySpy).toHaveBeenCalledTimes(2); + expect(editMessageReplyMarkupSpy).toHaveBeenCalledWith(9, 11, { + reply_markup: { inline_keyboard: [] }, + }); + }); + it("passes false command auth to Telegram plugin callbacks for non-allowlisted group senders", async () => { onSpy.mockClear(); let observedAuth: TelegramInteractiveHandlerContext["auth"] | undefined; diff --git a/extensions/telegram/src/interactive-dispatch.ts b/extensions/telegram/src/interactive-dispatch.ts index 21f38f90f29..b234b5da75a 100644 --- a/extensions/telegram/src/interactive-dispatch.ts +++ b/extensions/telegram/src/interactive-dispatch.ts @@ -48,9 +48,19 @@ export type TelegramInteractiveHandlerContext = { getCurrentConversationBinding: () => Promise; }; +export type TelegramInteractiveHandlerResult = { + handled?: boolean; + /** + * Submit text through Telegram's normal inbound path after the callback handler + * returns, so plugin buttons can act like user-authored replies. + */ + submitText?: string; +} | void; + export type TelegramInteractiveHandlerRegistration = PluginInteractiveRegistration< TelegramInteractiveHandlerContext, - "telegram" + "telegram", + TelegramInteractiveHandlerResult >; type TelegramInteractiveDispatchContext = Omit< @@ -81,12 +91,17 @@ export async function dispatchTelegramPluginInteractiveHandler(params: { deleteMessage: () => Promise; }; onMatched?: () => Promise | void; + afterInvoke?: (result: TelegramInteractiveHandlerResult) => Promise | void; }) { - return await dispatchPluginInteractiveHandler({ + return await dispatchPluginInteractiveHandler< + TelegramInteractiveHandlerRegistration, + TelegramInteractiveHandlerResult + >({ channel: "telegram", data: params.data, dedupeId: params.callbackId, onMatched: params.onMatched, + afterInvoke: params.afterInvoke, invoke: ({ registration, namespace, payload }) => { const { callbackMessage, ...handlerContext } = params.ctx; return registration.handler({ diff --git a/src/plugins/interactive.test.ts b/src/plugins/interactive.test.ts index a657f291e49..e74f8e15839 100644 --- a/src/plugins/interactive.test.ts +++ b/src/plugins/interactive.test.ts @@ -35,6 +35,7 @@ type InteractiveDispatchParams = data: string; dedupeId: string; onMatched?: () => Promise | void; + afterInvoke?: (result: { handled?: boolean } | void) => Promise | void; ctx: Omit< TelegramInteractiveHandlerContext, | "callback" @@ -57,6 +58,7 @@ type InteractiveDispatchParams = data: string; dedupeId: string; onMatched?: () => Promise | void; + afterInvoke?: (result: { handled?: boolean } | void) => Promise | void; ctx: Omit< DiscordInteractiveHandlerContext, | "interaction" @@ -78,6 +80,7 @@ type InteractiveDispatchParams = data: string; dedupeId: string; onMatched?: () => Promise | void; + afterInvoke?: (result: { handled?: boolean } | void) => Promise | void; ctx: Omit< SlackInteractiveHandlerContext, | "interaction" @@ -247,6 +250,7 @@ async function dispatchInteractiveWith( data: params.data, dedupeId: params.dedupeId, onMatched: params.onMatched, + afterInvoke: params.afterInvoke, invoke: ({ registration, namespace, payload }) => { const { callbackMessage, ...handlerContext } = params.ctx; return registration.handler({ @@ -284,6 +288,7 @@ async function dispatchInteractiveWith( data: params.data, dedupeId: params.dedupeId, onMatched: params.onMatched, + afterInvoke: params.afterInvoke, invoke: ({ registration, namespace, payload }) => registration.handler({ ...params.ctx, @@ -315,6 +320,7 @@ async function dispatchInteractiveWith( data: params.data, dedupeId: params.dedupeId, onMatched: params.onMatched, + afterInvoke: params.afterInvoke, invoke: ({ registration, namespace, payload }) => registration.handler({ ...params.ctx, @@ -901,6 +907,39 @@ describe("plugin interactive handlers", () => { expect(handler).toHaveBeenCalledTimes(2); }); + it("does not consume dedupe keys when post-handler processing throws", async () => { + const handler = vi.fn(async () => ({ handled: true })); + const afterInvoke = vi + .fn(async () => {}) + .mockRejectedValueOnce(new Error("post-handler failure")) + .mockResolvedValueOnce(undefined); + expect( + registerPluginInteractiveHandler("codex-plugin", { + channel: "telegram", + namespace: "codex", + handler, + }), + ).toEqual({ ok: true }); + + const baseParams = { + ...createTelegramDispatchParams({ + data: "codex:resume:thread-1", + callbackId: "cb-post-handler-failure", + }), + afterInvoke, + }; + + await expect(dispatchInteractive(baseParams)).rejects.toThrow("post-handler failure"); + await expect(dispatchInteractive(baseParams)).resolves.toEqual({ + matched: true, + handled: true, + duplicate: false, + }); + expect(handler).toHaveBeenCalledTimes(2); + expect(afterInvoke).toHaveBeenCalledTimes(2); + expect(afterInvoke).toHaveBeenLastCalledWith({ handled: true }); + }); + it("dedupes concurrent interactive dispatches while a handler is still running", async () => { let releaseHandler: (() => void) | undefined; const handlerGate = new Promise((resolve) => { diff --git a/src/plugins/interactive.ts b/src/plugins/interactive.ts index 8c15b3da203..27ac766ac2b 100644 --- a/src/plugins/interactive.ts +++ b/src/plugins/interactive.ts @@ -65,6 +65,7 @@ export async function dispatchPluginInteractiveHandler< dedupeId?: string; onMatched?: () => Promise | void; invoke: (match: PluginInteractiveMatch) => Promise | TResult; + afterInvoke?: (result: TResult) => Promise | void; }): Promise> { const match = resolveLivePluginInteractiveNamespaceMatch(params.channel, params.data); if (!match) { @@ -79,6 +80,9 @@ export async function dispatchPluginInteractiveHandler< try { await params.onMatched?.(); const resolved = await params.invoke(match as PluginInteractiveMatch); + // Channel post-processing stays inside the dedupe claim. Committing first + // would swallow a retry after a retryable post-handler failure. + await params.afterInvoke?.(resolved); if (dedupeKey) { commitPluginInteractiveCallbackDedupe(dedupeKey); }