Skip to content

Commit 4f91d81

Browse files
authored
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.
1 parent 0ee9e81 commit 4f91d81

4 files changed

Lines changed: 298 additions & 136 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai
1616

1717
### Fixes
1818

19+
- 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.
1920
- 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.
2021
- 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.
2122
- 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.
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import {
2+
deliverTextOrMediaReply,
3+
resolveSendableOutboundReplyParts,
4+
} from "openclaw/plugin-sdk/reply-payload";
5+
import type { OpenClawConfig } from "../runtime-api.js";
6+
import type { ResolvedGoogleChatAccount } from "./accounts.js";
7+
import {
8+
deleteGoogleChatMessage,
9+
sendGoogleChatMessage,
10+
updateGoogleChatMessage,
11+
uploadGoogleChatAttachment,
12+
} from "./api.js";
13+
import type { GoogleChatCoreRuntime, GoogleChatRuntimeEnv } from "./monitor-types.js";
14+
15+
export async function deliverGoogleChatReply(params: {
16+
payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string; replyToId?: string };
17+
account: ResolvedGoogleChatAccount;
18+
spaceId: string;
19+
runtime: GoogleChatRuntimeEnv;
20+
core: GoogleChatCoreRuntime;
21+
config: OpenClawConfig;
22+
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
23+
typingMessageName?: string;
24+
}): Promise<void> {
25+
const { payload, account, spaceId, runtime, core, config, statusSink } = params;
26+
// Clear this whenever the typing message is deleted or unavailable; otherwise
27+
// text delivery can keep retrying a dead message and drop content.
28+
let typingMessageName = params.typingMessageName;
29+
const reply = resolveSendableOutboundReplyParts(payload);
30+
const mediaCount = reply.mediaCount;
31+
const hasMedia = reply.hasMedia;
32+
const text = reply.text;
33+
let firstTextChunk = true;
34+
let suppressCaption = false;
35+
36+
if (hasMedia && typingMessageName) {
37+
try {
38+
await deleteGoogleChatMessage({
39+
account,
40+
messageName: typingMessageName,
41+
});
42+
typingMessageName = undefined;
43+
} catch (err) {
44+
runtime.error?.(`Google Chat typing cleanup failed: ${String(err)}`);
45+
if (typingMessageName) {
46+
const fallbackText = reply.hasText
47+
? text
48+
: mediaCount > 1
49+
? "Sent attachments."
50+
: "Sent attachment.";
51+
try {
52+
await updateGoogleChatMessage({
53+
account,
54+
messageName: typingMessageName,
55+
text: fallbackText,
56+
});
57+
suppressCaption = Boolean(text.trim());
58+
} catch (updateErr) {
59+
runtime.error?.(`Google Chat typing update failed: ${String(updateErr)}`);
60+
typingMessageName = undefined;
61+
}
62+
}
63+
}
64+
}
65+
66+
const chunkLimit = account.config.textChunkLimit ?? 4000;
67+
const chunkMode = core.channel.text.resolveChunkMode(config, "googlechat", account.accountId);
68+
const sendTextMessage = async (chunk: string) => {
69+
await sendGoogleChatMessage({
70+
account,
71+
space: spaceId,
72+
text: chunk,
73+
thread: payload.replyToId,
74+
});
75+
};
76+
await deliverTextOrMediaReply({
77+
payload,
78+
text: suppressCaption ? "" : reply.text,
79+
chunkText: (value) => core.channel.text.chunkMarkdownTextWithMode(value, chunkLimit, chunkMode),
80+
sendText: async (chunk) => {
81+
try {
82+
if (firstTextChunk && typingMessageName) {
83+
await updateGoogleChatMessage({
84+
account,
85+
messageName: typingMessageName,
86+
text: chunk,
87+
});
88+
} else {
89+
await sendTextMessage(chunk);
90+
}
91+
firstTextChunk = false;
92+
statusSink?.({ lastOutboundAt: Date.now() });
93+
} catch (err) {
94+
runtime.error?.(`Google Chat message send failed: ${String(err)}`);
95+
if (firstTextChunk && typingMessageName) {
96+
typingMessageName = undefined;
97+
try {
98+
await sendTextMessage(chunk);
99+
statusSink?.({ lastOutboundAt: Date.now() });
100+
} catch (fallbackErr) {
101+
runtime.error?.(`Google Chat message fallback send failed: ${String(fallbackErr)}`);
102+
} finally {
103+
firstTextChunk = false;
104+
}
105+
}
106+
}
107+
},
108+
sendMedia: async ({ mediaUrl, caption }) => {
109+
try {
110+
const loaded = await core.channel.media.fetchRemoteMedia({
111+
url: mediaUrl,
112+
maxBytes: (account.config.mediaMaxMb ?? 20) * 1024 * 1024,
113+
});
114+
const upload = await uploadAttachmentForReply({
115+
account,
116+
spaceId,
117+
buffer: loaded.buffer,
118+
contentType: loaded.contentType,
119+
filename: loaded.fileName ?? "attachment",
120+
});
121+
if (!upload.attachmentUploadToken) {
122+
throw new Error("missing attachment upload token");
123+
}
124+
await sendGoogleChatMessage({
125+
account,
126+
space: spaceId,
127+
text: caption,
128+
thread: payload.replyToId,
129+
attachments: [
130+
{ attachmentUploadToken: upload.attachmentUploadToken, contentName: loaded.fileName },
131+
],
132+
});
133+
statusSink?.({ lastOutboundAt: Date.now() });
134+
} catch (err) {
135+
runtime.error?.(`Google Chat attachment send failed: ${String(err)}`);
136+
}
137+
},
138+
});
139+
}
140+
141+
async function uploadAttachmentForReply(params: {
142+
account: ResolvedGoogleChatAccount;
143+
spaceId: string;
144+
buffer: Buffer;
145+
contentType?: string;
146+
filename: string;
147+
}) {
148+
const { account, spaceId, buffer, contentType, filename } = params;
149+
return await uploadGoogleChatAttachment({
150+
account,
151+
space: spaceId,
152+
filename,
153+
buffer,
154+
contentType,
155+
});
156+
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import type { OpenClawConfig } from "../runtime-api.js";
3+
import type { ResolvedGoogleChatAccount } from "./accounts.js";
4+
import type { GoogleChatCoreRuntime, GoogleChatRuntimeEnv } from "./monitor-types.js";
5+
6+
const mocks = vi.hoisted(() => ({
7+
deleteGoogleChatMessage: vi.fn(),
8+
sendGoogleChatMessage: vi.fn(),
9+
updateGoogleChatMessage: vi.fn(),
10+
uploadGoogleChatAttachment: vi.fn(),
11+
}));
12+
13+
vi.mock("./api.js", () => ({
14+
deleteGoogleChatMessage: mocks.deleteGoogleChatMessage,
15+
sendGoogleChatMessage: mocks.sendGoogleChatMessage,
16+
updateGoogleChatMessage: mocks.updateGoogleChatMessage,
17+
uploadGoogleChatAttachment: mocks.uploadGoogleChatAttachment,
18+
}));
19+
20+
const account = {
21+
accountId: "default",
22+
enabled: true,
23+
credentialSource: "inline",
24+
config: {},
25+
} as ResolvedGoogleChatAccount;
26+
27+
const config = {} as OpenClawConfig;
28+
29+
function createCore(params?: {
30+
chunks?: readonly string[];
31+
media?: { buffer: Buffer; contentType?: string; fileName?: string };
32+
}) {
33+
return {
34+
channel: {
35+
text: {
36+
resolveChunkMode: vi.fn(() => "markdown"),
37+
chunkMarkdownTextWithMode: vi.fn((text: string) => params?.chunks ?? [text]),
38+
},
39+
media: {
40+
fetchRemoteMedia: vi.fn(async () => params?.media ?? { buffer: Buffer.from("image") }),
41+
},
42+
},
43+
} as unknown as GoogleChatCoreRuntime;
44+
}
45+
46+
function createRuntime() {
47+
return {
48+
error: vi.fn(),
49+
log: vi.fn(),
50+
} satisfies GoogleChatRuntimeEnv;
51+
}
52+
53+
let deliverGoogleChatReply: typeof import("./monitor-reply-delivery.js").deliverGoogleChatReply;
54+
55+
beforeEach(async () => {
56+
vi.clearAllMocks();
57+
({ deliverGoogleChatReply } = await import("./monitor-reply-delivery.js"));
58+
});
59+
60+
describe("Google Chat reply delivery", () => {
61+
it("resends the first text chunk as a new message when typing update fails", async () => {
62+
const core = createCore({ chunks: ["first chunk", "second chunk"] });
63+
const runtime = createRuntime();
64+
const statusSink = vi.fn();
65+
mocks.updateGoogleChatMessage.mockRejectedValueOnce(new Error("message not found"));
66+
mocks.sendGoogleChatMessage.mockResolvedValue({ messageName: "spaces/AAA/messages/fallback" });
67+
68+
await deliverGoogleChatReply({
69+
payload: { text: "first chunk\n\nsecond chunk", replyToId: "spaces/AAA/threads/root" },
70+
account,
71+
spaceId: "spaces/AAA",
72+
runtime,
73+
core,
74+
config,
75+
statusSink,
76+
typingMessageName: "spaces/AAA/messages/typing",
77+
});
78+
79+
expect(mocks.updateGoogleChatMessage).toHaveBeenCalledWith({
80+
account,
81+
messageName: "spaces/AAA/messages/typing",
82+
text: "first chunk",
83+
});
84+
expect(mocks.sendGoogleChatMessage).toHaveBeenCalledTimes(2);
85+
expect(mocks.sendGoogleChatMessage).toHaveBeenNthCalledWith(1, {
86+
account,
87+
space: "spaces/AAA",
88+
text: "first chunk",
89+
thread: "spaces/AAA/threads/root",
90+
});
91+
expect(mocks.sendGoogleChatMessage).toHaveBeenNthCalledWith(2, {
92+
account,
93+
space: "spaces/AAA",
94+
text: "second chunk",
95+
thread: "spaces/AAA/threads/root",
96+
});
97+
expect(statusSink).toHaveBeenCalledTimes(2);
98+
expect(runtime.error).toHaveBeenCalledWith(
99+
expect.stringContaining("Google Chat message send failed"),
100+
);
101+
});
102+
103+
it("does not update a deleted typing message before sending media with a caption", async () => {
104+
const core = createCore({
105+
media: { buffer: Buffer.from("image"), contentType: "image/png", fileName: "reply.png" },
106+
});
107+
const runtime = createRuntime();
108+
mocks.deleteGoogleChatMessage.mockResolvedValue(undefined);
109+
mocks.uploadGoogleChatAttachment.mockResolvedValue({ attachmentUploadToken: "upload-token" });
110+
mocks.sendGoogleChatMessage.mockResolvedValue({ messageName: "spaces/AAA/messages/media" });
111+
112+
await deliverGoogleChatReply({
113+
payload: {
114+
text: "caption",
115+
mediaUrl: "https://example.invalid/reply.png",
116+
replyToId: "spaces/AAA/threads/root",
117+
},
118+
account,
119+
spaceId: "spaces/AAA",
120+
runtime,
121+
core,
122+
config,
123+
typingMessageName: "spaces/AAA/messages/typing",
124+
});
125+
126+
expect(mocks.deleteGoogleChatMessage).toHaveBeenCalledWith({
127+
account,
128+
messageName: "spaces/AAA/messages/typing",
129+
});
130+
expect(mocks.updateGoogleChatMessage).not.toHaveBeenCalled();
131+
expect(mocks.sendGoogleChatMessage).toHaveBeenCalledWith({
132+
account,
133+
space: "spaces/AAA",
134+
text: "caption",
135+
thread: "spaces/AAA/threads/root",
136+
attachments: [{ attachmentUploadToken: "upload-token", contentName: "reply.png" }],
137+
});
138+
});
139+
});

0 commit comments

Comments
 (0)