mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:50:43 +00:00
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:
committed by
GitHub
parent
0ee9e8188d
commit
4f91d81e1d
@@ -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.
|
||||
|
||||
156
extensions/googlechat/src/monitor-reply-delivery.ts
Normal file
156
extensions/googlechat/src/monitor-reply-delivery.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
139
extensions/googlechat/src/monitor.reply-delivery.test.ts
Normal file
139
extensions/googlechat/src/monitor.reply-delivery.test.ts
Normal 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" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user