From a779c2ca6abe909abb0ad39b0d4aa566179fd6ac Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 2 Mar 2026 01:50:16 +0000 Subject: [PATCH] fix(telegram): skip nullish final text sends (land #30969 by @haosenwang1018) Landed-from: #30969 Contributor: @haosenwang1018 Co-authored-by: Sense_wang <167664334+haosenwang1018@users.noreply.github.com> --- CHANGELOG.md | 1 + src/telegram/bot-message-dispatch.test.ts | 20 ++++++++++++++++++++ src/telegram/bot-message-dispatch.ts | 2 +- 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c62c8d9cc6..23741207ef6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -102,6 +102,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Telegram/Empty final replies: skip outbound send for null/undefined final text payloads without media so Telegram typing indicators do not linger on `text must be non-empty` errors. Landed from contributor PR #30969 by @haosenwang1018. Thanks @haosenwang1018. - Routing/Binding peer-kind parity: treat `peer.kind` `group` and `channel` as equivalent for binding scope matching (while keeping `direct` separate) so Slack/public channel bindings do not silently fall through. Landed from contributor PR #31135 by @Sid-Qin. Thanks @Sid-Qin. - Agents/FS workspace default: honor documented host file-tool default `tools.fs.workspaceOnly=false` when unset so host `write`/`edit` calls are not incorrectly workspace-restricted unless explicitly enabled. Landed from contributor PR #31128 by @SaucePackets. Thanks @SaucePackets. - Gateway/CLI session recovery: handle expired CLI session IDs gracefully by clearing stale session state and retrying without crashing gateway runs. Landed from contributor PR #31090 by @frankekn. Thanks @frankekn. diff --git a/src/telegram/bot-message-dispatch.test.ts b/src/telegram/bot-message-dispatch.test.ts index 842018b71bd..22e87c53a4f 100644 --- a/src/telegram/bot-message-dispatch.test.ts +++ b/src/telegram/bot-message-dispatch.test.ts @@ -1064,6 +1064,26 @@ describe("dispatchTelegramMessage draft streaming", () => { expect(editMessageTelegram).not.toHaveBeenCalled(); }); + it.each([undefined, null] as const)( + "skips outbound send when final payload text is %s and has no media", + async (emptyText) => { + setupDraftStreams({ answerMessageId: 999 }); + dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { + await dispatcherOptions.deliver( + { text: emptyText as unknown as string }, + { kind: "final" }, + ); + return { queuedFinal: true }; + }); + deliverReplies.mockResolvedValue({ delivered: true }); + + await dispatchWithContext({ context: createContext(), streamMode: "partial" }); + + expect(deliverReplies).not.toHaveBeenCalled(); + expect(editMessageTelegram).not.toHaveBeenCalled(); + }, + ); + it("keeps reasoning and answer streaming in separate preview lanes", async () => { const { answerDraftStream, reasoningDraftStream } = setupDraftStreams({ answerMessageId: 999, diff --git a/src/telegram/bot-message-dispatch.ts b/src/telegram/bot-message-dispatch.ts index 988894b4dad..094f9b5ffb8 100644 --- a/src/telegram/bot-message-dispatch.ts +++ b/src/telegram/bot-message-dispatch.ts @@ -548,7 +548,7 @@ export const dispatchTelegramMessage = async ({ reasoningStepState.resetForNextStep(); } const canSendAsIs = - hasMedia || typeof payload.text !== "string" || payload.text.length > 0; + hasMedia || (typeof payload.text === "string" && payload.text.length > 0); if (!canSendAsIs) { if (info.kind === "final") { await flushBufferedFinalAnswer();