From 4f91d81e1dbc67c906bf4f7471f2c87fcbdfa1dd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 10:30:41 +0100 Subject: [PATCH] fix(googlechat): preserve reply text after typing update failures Preserve Google Chat reply text when typing indicator cleanup or update fails. - Extract Google Chat reply delivery into a focused module - Retry the failed first text chunk as a new message after placeholder update failure - Cover media caption and chunk fallback regressions Thanks @colin-lgtm. --- CHANGELOG.md | 1 + .../googlechat/src/monitor-reply-delivery.ts | 156 ++++++++++++++++++ .../src/monitor.reply-delivery.test.ts | 139 ++++++++++++++++ extensions/googlechat/src/monitor.ts | 138 +--------------- 4 files changed, 298 insertions(+), 136 deletions(-) create mode 100644 extensions/googlechat/src/monitor-reply-delivery.ts create mode 100644 extensions/googlechat/src/monitor.reply-delivery.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index edfa286feb7..615d991bc4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Google Chat: preserve reply text when a typing indicator message is deleted or can no longer be updated, so media captions and first text chunks are resent instead of silently disappearing. (#71498) Thanks @colin-lgtm. - Heartbeat: clamp oversized scheduler delays through the shared safe timer helper, preventing `every` values over Node's timeout cap from becoming a 1 ms crash loop. Fixes #71414. (#71478) Thanks @hclsys. - Control UI/chat: collapse assistant token/model context details behind an explicit Context disclosure and show full dates in message footers, making historical transcript timing clear without noisy default metadata. (#71337) Thanks @BunsDev. - Telegram: remove the startup persisted-offset `getUpdates` preflight so polling restarts do not self-conflict before the runner starts. Fixes #69304. (#69779) Thanks @chinar-amrutkar. diff --git a/extensions/googlechat/src/monitor-reply-delivery.ts b/extensions/googlechat/src/monitor-reply-delivery.ts new file mode 100644 index 00000000000..0257316cc48 --- /dev/null +++ b/extensions/googlechat/src/monitor-reply-delivery.ts @@ -0,0 +1,156 @@ +import { + deliverTextOrMediaReply, + resolveSendableOutboundReplyParts, +} from "openclaw/plugin-sdk/reply-payload"; +import type { OpenClawConfig } from "../runtime-api.js"; +import type { ResolvedGoogleChatAccount } from "./accounts.js"; +import { + deleteGoogleChatMessage, + sendGoogleChatMessage, + updateGoogleChatMessage, + uploadGoogleChatAttachment, +} from "./api.js"; +import type { GoogleChatCoreRuntime, GoogleChatRuntimeEnv } from "./monitor-types.js"; + +export async function deliverGoogleChatReply(params: { + payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string; replyToId?: string }; + account: ResolvedGoogleChatAccount; + spaceId: string; + runtime: GoogleChatRuntimeEnv; + core: GoogleChatCoreRuntime; + config: OpenClawConfig; + statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; + typingMessageName?: string; +}): Promise { + const { payload, account, spaceId, runtime, core, config, statusSink } = params; + // Clear this whenever the typing message is deleted or unavailable; otherwise + // text delivery can keep retrying a dead message and drop content. + let typingMessageName = params.typingMessageName; + const reply = resolveSendableOutboundReplyParts(payload); + const mediaCount = reply.mediaCount; + const hasMedia = reply.hasMedia; + const text = reply.text; + let firstTextChunk = true; + let suppressCaption = false; + + if (hasMedia && typingMessageName) { + try { + await deleteGoogleChatMessage({ + account, + messageName: typingMessageName, + }); + typingMessageName = undefined; + } catch (err) { + runtime.error?.(`Google Chat typing cleanup failed: ${String(err)}`); + if (typingMessageName) { + const fallbackText = reply.hasText + ? text + : mediaCount > 1 + ? "Sent attachments." + : "Sent attachment."; + try { + await updateGoogleChatMessage({ + account, + messageName: typingMessageName, + text: fallbackText, + }); + suppressCaption = Boolean(text.trim()); + } catch (updateErr) { + runtime.error?.(`Google Chat typing update failed: ${String(updateErr)}`); + typingMessageName = undefined; + } + } + } + } + + const chunkLimit = account.config.textChunkLimit ?? 4000; + const chunkMode = core.channel.text.resolveChunkMode(config, "googlechat", account.accountId); + const sendTextMessage = async (chunk: string) => { + await sendGoogleChatMessage({ + account, + space: spaceId, + text: chunk, + thread: payload.replyToId, + }); + }; + await deliverTextOrMediaReply({ + payload, + text: suppressCaption ? "" : reply.text, + chunkText: (value) => core.channel.text.chunkMarkdownTextWithMode(value, chunkLimit, chunkMode), + sendText: async (chunk) => { + try { + if (firstTextChunk && typingMessageName) { + await updateGoogleChatMessage({ + account, + messageName: typingMessageName, + text: chunk, + }); + } else { + await sendTextMessage(chunk); + } + firstTextChunk = false; + statusSink?.({ lastOutboundAt: Date.now() }); + } catch (err) { + runtime.error?.(`Google Chat message send failed: ${String(err)}`); + if (firstTextChunk && typingMessageName) { + typingMessageName = undefined; + try { + await sendTextMessage(chunk); + statusSink?.({ lastOutboundAt: Date.now() }); + } catch (fallbackErr) { + runtime.error?.(`Google Chat message fallback send failed: ${String(fallbackErr)}`); + } finally { + firstTextChunk = false; + } + } + } + }, + sendMedia: async ({ mediaUrl, caption }) => { + try { + const loaded = await core.channel.media.fetchRemoteMedia({ + url: mediaUrl, + maxBytes: (account.config.mediaMaxMb ?? 20) * 1024 * 1024, + }); + const upload = await uploadAttachmentForReply({ + account, + spaceId, + buffer: loaded.buffer, + contentType: loaded.contentType, + filename: loaded.fileName ?? "attachment", + }); + if (!upload.attachmentUploadToken) { + throw new Error("missing attachment upload token"); + } + await sendGoogleChatMessage({ + account, + space: spaceId, + text: caption, + thread: payload.replyToId, + attachments: [ + { attachmentUploadToken: upload.attachmentUploadToken, contentName: loaded.fileName }, + ], + }); + statusSink?.({ lastOutboundAt: Date.now() }); + } catch (err) { + runtime.error?.(`Google Chat attachment send failed: ${String(err)}`); + } + }, + }); +} + +async function uploadAttachmentForReply(params: { + account: ResolvedGoogleChatAccount; + spaceId: string; + buffer: Buffer; + contentType?: string; + filename: string; +}) { + const { account, spaceId, buffer, contentType, filename } = params; + return await uploadGoogleChatAttachment({ + account, + space: spaceId, + filename, + buffer, + contentType, + }); +} diff --git a/extensions/googlechat/src/monitor.reply-delivery.test.ts b/extensions/googlechat/src/monitor.reply-delivery.test.ts new file mode 100644 index 00000000000..49a38e73f1c --- /dev/null +++ b/extensions/googlechat/src/monitor.reply-delivery.test.ts @@ -0,0 +1,139 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../runtime-api.js"; +import type { ResolvedGoogleChatAccount } from "./accounts.js"; +import type { GoogleChatCoreRuntime, GoogleChatRuntimeEnv } from "./monitor-types.js"; + +const mocks = vi.hoisted(() => ({ + deleteGoogleChatMessage: vi.fn(), + sendGoogleChatMessage: vi.fn(), + updateGoogleChatMessage: vi.fn(), + uploadGoogleChatAttachment: vi.fn(), +})); + +vi.mock("./api.js", () => ({ + deleteGoogleChatMessage: mocks.deleteGoogleChatMessage, + sendGoogleChatMessage: mocks.sendGoogleChatMessage, + updateGoogleChatMessage: mocks.updateGoogleChatMessage, + uploadGoogleChatAttachment: mocks.uploadGoogleChatAttachment, +})); + +const account = { + accountId: "default", + enabled: true, + credentialSource: "inline", + config: {}, +} as ResolvedGoogleChatAccount; + +const config = {} as OpenClawConfig; + +function createCore(params?: { + chunks?: readonly string[]; + media?: { buffer: Buffer; contentType?: string; fileName?: string }; +}) { + return { + channel: { + text: { + resolveChunkMode: vi.fn(() => "markdown"), + chunkMarkdownTextWithMode: vi.fn((text: string) => params?.chunks ?? [text]), + }, + media: { + fetchRemoteMedia: vi.fn(async () => params?.media ?? { buffer: Buffer.from("image") }), + }, + }, + } as unknown as GoogleChatCoreRuntime; +} + +function createRuntime() { + return { + error: vi.fn(), + log: vi.fn(), + } satisfies GoogleChatRuntimeEnv; +} + +let deliverGoogleChatReply: typeof import("./monitor-reply-delivery.js").deliverGoogleChatReply; + +beforeEach(async () => { + vi.clearAllMocks(); + ({ deliverGoogleChatReply } = await import("./monitor-reply-delivery.js")); +}); + +describe("Google Chat reply delivery", () => { + it("resends the first text chunk as a new message when typing update fails", async () => { + const core = createCore({ chunks: ["first chunk", "second chunk"] }); + const runtime = createRuntime(); + const statusSink = vi.fn(); + mocks.updateGoogleChatMessage.mockRejectedValueOnce(new Error("message not found")); + mocks.sendGoogleChatMessage.mockResolvedValue({ messageName: "spaces/AAA/messages/fallback" }); + + await deliverGoogleChatReply({ + payload: { text: "first chunk\n\nsecond chunk", replyToId: "spaces/AAA/threads/root" }, + account, + spaceId: "spaces/AAA", + runtime, + core, + config, + statusSink, + typingMessageName: "spaces/AAA/messages/typing", + }); + + expect(mocks.updateGoogleChatMessage).toHaveBeenCalledWith({ + account, + messageName: "spaces/AAA/messages/typing", + text: "first chunk", + }); + expect(mocks.sendGoogleChatMessage).toHaveBeenCalledTimes(2); + expect(mocks.sendGoogleChatMessage).toHaveBeenNthCalledWith(1, { + account, + space: "spaces/AAA", + text: "first chunk", + thread: "spaces/AAA/threads/root", + }); + expect(mocks.sendGoogleChatMessage).toHaveBeenNthCalledWith(2, { + account, + space: "spaces/AAA", + text: "second chunk", + thread: "spaces/AAA/threads/root", + }); + expect(statusSink).toHaveBeenCalledTimes(2); + expect(runtime.error).toHaveBeenCalledWith( + expect.stringContaining("Google Chat message send failed"), + ); + }); + + it("does not update a deleted typing message before sending media with a caption", async () => { + const core = createCore({ + media: { buffer: Buffer.from("image"), contentType: "image/png", fileName: "reply.png" }, + }); + const runtime = createRuntime(); + mocks.deleteGoogleChatMessage.mockResolvedValue(undefined); + mocks.uploadGoogleChatAttachment.mockResolvedValue({ attachmentUploadToken: "upload-token" }); + mocks.sendGoogleChatMessage.mockResolvedValue({ messageName: "spaces/AAA/messages/media" }); + + await deliverGoogleChatReply({ + payload: { + text: "caption", + mediaUrl: "https://example.invalid/reply.png", + replyToId: "spaces/AAA/threads/root", + }, + account, + spaceId: "spaces/AAA", + runtime, + core, + config, + typingMessageName: "spaces/AAA/messages/typing", + }); + + expect(mocks.deleteGoogleChatMessage).toHaveBeenCalledWith({ + account, + messageName: "spaces/AAA/messages/typing", + }); + expect(mocks.updateGoogleChatMessage).not.toHaveBeenCalled(); + expect(mocks.sendGoogleChatMessage).toHaveBeenCalledWith({ + account, + space: "spaces/AAA", + text: "caption", + thread: "spaces/AAA/threads/root", + attachments: [{ attachmentUploadToken: "upload-token", contentName: "reply.png" }], + }); + }); +}); diff --git a/extensions/googlechat/src/monitor.ts b/extensions/googlechat/src/monitor.ts index 762780182d5..72a42d48a39 100644 --- a/extensions/googlechat/src/monitor.ts +++ b/extensions/googlechat/src/monitor.ts @@ -1,7 +1,3 @@ -import { - deliverTextOrMediaReply, - resolveSendableOutboundReplyParts, -} from "openclaw/plugin-sdk/reply-payload"; import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime"; import type { OpenClawConfig } from "../runtime-api.js"; import { @@ -10,15 +6,10 @@ import { resolveWebhookPath, } from "../runtime-api.js"; import { type ResolvedGoogleChatAccount } from "./accounts.js"; -import { - downloadGoogleChatMedia, - deleteGoogleChatMessage, - sendGoogleChatMessage, - uploadGoogleChatAttachment, - updateGoogleChatMessage, -} from "./api.js"; +import { downloadGoogleChatMedia, sendGoogleChatMessage } from "./api.js"; import { type GoogleChatAudienceType } from "./auth.js"; import { applyGoogleChatInboundAccessPolicy, isSenderAllowed } from "./monitor-access.js"; +import { deliverGoogleChatReply } from "./monitor-reply-delivery.js"; import { handleGoogleChatWebhookRequest, registerGoogleChatWebhookTarget, @@ -337,131 +328,6 @@ async function downloadAttachment( return { path: saved.path, contentType: saved.contentType }; } -async function deliverGoogleChatReply(params: { - payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string; replyToId?: string }; - account: ResolvedGoogleChatAccount; - spaceId: string; - runtime: GoogleChatRuntimeEnv; - core: GoogleChatCoreRuntime; - config: OpenClawConfig; - statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; - typingMessageName?: string; -}): Promise { - const { payload, account, spaceId, runtime, core, config, statusSink, typingMessageName } = - params; - const reply = resolveSendableOutboundReplyParts(payload); - const mediaCount = reply.mediaCount; - const hasMedia = reply.hasMedia; - const text = reply.text; - let firstTextChunk = true; - let suppressCaption = false; - - if (hasMedia) { - if (typingMessageName) { - try { - await deleteGoogleChatMessage({ - account, - messageName: typingMessageName, - }); - } catch (err) { - runtime.error?.(`Google Chat typing cleanup failed: ${String(err)}`); - const fallbackText = reply.hasText - ? text - : mediaCount > 1 - ? "Sent attachments." - : "Sent attachment."; - try { - await updateGoogleChatMessage({ - account, - messageName: typingMessageName, - text: fallbackText, - }); - suppressCaption = Boolean(text.trim()); - } catch (updateErr) { - runtime.error?.(`Google Chat typing update failed: ${String(updateErr)}`); - } - } - } - } - - const chunkLimit = account.config.textChunkLimit ?? 4000; - const chunkMode = core.channel.text.resolveChunkMode(config, "googlechat", account.accountId); - await deliverTextOrMediaReply({ - payload, - text: suppressCaption ? "" : reply.text, - chunkText: (value) => core.channel.text.chunkMarkdownTextWithMode(value, chunkLimit, chunkMode), - sendText: async (chunk) => { - try { - if (firstTextChunk && typingMessageName) { - await updateGoogleChatMessage({ - account, - messageName: typingMessageName, - text: chunk, - }); - } else { - await sendGoogleChatMessage({ - account, - space: spaceId, - text: chunk, - thread: payload.replyToId, - }); - } - firstTextChunk = false; - statusSink?.({ lastOutboundAt: Date.now() }); - } catch (err) { - runtime.error?.(`Google Chat message send failed: ${String(err)}`); - } - }, - sendMedia: async ({ mediaUrl, caption }) => { - try { - const loaded = await core.channel.media.fetchRemoteMedia({ - url: mediaUrl, - maxBytes: (account.config.mediaMaxMb ?? 20) * 1024 * 1024, - }); - const upload = await uploadAttachmentForReply({ - account, - spaceId, - buffer: loaded.buffer, - contentType: loaded.contentType, - filename: loaded.fileName ?? "attachment", - }); - if (!upload.attachmentUploadToken) { - throw new Error("missing attachment upload token"); - } - await sendGoogleChatMessage({ - account, - space: spaceId, - text: caption, - thread: payload.replyToId, - attachments: [ - { attachmentUploadToken: upload.attachmentUploadToken, contentName: loaded.fileName }, - ], - }); - statusSink?.({ lastOutboundAt: Date.now() }); - } catch (err) { - runtime.error?.(`Google Chat attachment send failed: ${String(err)}`); - } - }, - }); -} - -async function uploadAttachmentForReply(params: { - account: ResolvedGoogleChatAccount; - spaceId: string; - buffer: Buffer; - contentType?: string; - filename: string; -}) { - const { account, spaceId, buffer, contentType, filename } = params; - return await uploadGoogleChatAttachment({ - account, - space: spaceId, - filename, - buffer, - contentType, - }); -} - export function monitorGoogleChatProvider(options: GoogleChatMonitorOptions): () => void { const core = getGoogleChatRuntime(); const webhookPath = resolveWebhookPath({