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.
This commit is contained in:
Peter Steinberger
2026-04-25 10:30:41 +01:00
committed by GitHub
parent 0ee9e8188d
commit 4f91d81e1d
4 changed files with 298 additions and 136 deletions

View File

@@ -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.

View File

@@ -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<void> {
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,
});
}

View File

@@ -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" }],
});
});
});

View File

@@ -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<void> {
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({