From fdf3cd69ce4ce2ff2224846e722a57f45937c6a3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 27 Aug 2026 03:31:49 -0700 Subject: [PATCH] fix(googlechat): surface unsupported and unprocessed attachments (#130870) --- docs/channels/googlechat.md | 2 +- extensions/googlechat/src/monitor.test.ts | 232 +++++++++++----------- extensions/googlechat/src/monitor.ts | 12 ++ 3 files changed, 132 insertions(+), 114 deletions(-) diff --git a/docs/channels/googlechat.md b/docs/channels/googlechat.md index f70a87dc9024..fb08088ad1ef 100644 --- a/docs/channels/googlechat.md +++ b/docs/channels/googlechat.md @@ -200,7 +200,7 @@ Notes: - Native approval cards use Google Chat `cardsV2` button clicks, not reaction events. Approvers come from `allowFrom` or `defaultTo` and must be stable numeric `users/` values. - Message actions expose text `send` only. Google Chat attachment upload requires user authentication, while this plugin uses service-account authentication, so outbound file upload is not exposed. - `typingIndicator`: `message` (default) posts a `_ is typing..._` placeholder and edits it into the first reply; `none` disables it; `reaction` requires user OAuth and currently falls back to `message` with a logged error under service-account auth. -- Inbound attachments (first attachment per message) are downloaded through the Chat API into the media pipeline, capped by `mediaMaxMb` (default 20). +- Inbound attachments (first attachment per message) are downloaded through the Chat API into the media pipeline, capped by `mediaMaxMb` (default 20). Google Drive files are not downloaded; the agent receives an unavailable-attachment notice asking for a direct file upload instead. Other unsupported attachment sources receive the same upload guidance. Messages with multiple attachments include a counted notice for the additional attachments that were not processed. Oversize attachments retain their size-limit notice. - Bot-authored messages are ignored by default. With `allowBots: true`, accepted bot messages use shared [bot loop protection](/channels/bot-loop-protection): configure `channels.defaults.botLoopProtection`, then override with `channels.googlechat.botLoopProtection` or `channels.googlechat.groups..botLoopProtection`. Custom emoji listing is unavailable because Google Chat's `customEmojis.list` endpoint requires user authentication with the `chat.customemojis` or `chat.customemojis.readonly` scope. This plugin authenticates exclusively as a service account with the `chat.bot` scope, which cannot access that endpoint. diff --git a/extensions/googlechat/src/monitor.test.ts b/extensions/googlechat/src/monitor.test.ts index 89ba660ee05a..126df68d0c52 100644 --- a/extensions/googlechat/src/monitor.test.ts +++ b/extensions/googlechat/src/monitor.test.ts @@ -323,70 +323,134 @@ describe("googlechat monitor inbound space classification", () => { expect(runTurn).toHaveBeenCalledOnce(); }); - it("keeps media-only text empty and carries every native attachment fact", async () => { - const { buildContext, core, runTurn, saveMediaBuffer } = createInboundClassificationHarness(); - apiMocks.downloadGoogleChatMedia.mockResolvedValue({ - buffer: Buffer.from("image"), - contentType: "image/png", - }); - accessMocks.applyGoogleChatInboundAccessPolicy.mockResolvedValue({ - ok: true, - commandAuthorized: undefined, - effectiveWasMentioned: undefined, - groupBotLoopProtection: undefined, - groupSystemPrompt: undefined, - }); + it.each([0, 1, 9])( + "downloads only the first file and accounts for %i additional attachments", + async (additionalCount) => { + const { buildContext, core, runTurn, saveMediaBuffer } = createInboundClassificationHarness(); + apiMocks.downloadGoogleChatMedia.mockResolvedValue({ + buffer: Buffer.from("image"), + contentType: "image/png", + }); + allowGoogleChatMediaSender(); - await processGoogleChatTestEvent({ - event: { - type: "MESSAGE", - space: { name: "spaces/MEDIA", type: "DM" }, - message: { - name: "spaces/MEDIA/messages/1", - sender: { name: "users/alice", displayName: "Alice", type: "HUMAN" }, - attachment: [ + await processGoogleChatTestEvent({ + event: createGoogleChatMediaTestEvent({ + id: "downloaded", + attachments: [ { contentType: "image/png", contentName: "first.png", attachmentDataRef: { resourceName: "media/first" }, }, - { contentType: "application/pdf", contentName: "second.pdf" }, + ...Array.from({ length: additionalCount }, (_, index) => ({ + contentType: "application/pdf", + contentName: `additional-${index}.pdf`, + attachmentDataRef: { resourceName: `media/additional-${index}` }, + })), ], - }, - }, - account: { - accountId: "work", - config: { typingIndicator: "none" }, - credentialSource: "inline", - } as ResolvedGoogleChatAccount, + }), + account: googleChatMediaTestAccount, + config: {}, + runtime: { error: vi.fn(), log: vi.fn() }, + core, + mediaMaxMb: 10, + }); + + expect(accessMocks.applyGoogleChatInboundAccessPolicy).toHaveBeenCalledWith( + expect.objectContaining({ rawBody: "" }), + ); + expect(saveMediaBuffer).toHaveBeenCalledOnce(); + expect(apiMocks.downloadGoogleChatMedia).toHaveBeenCalledExactlyOnceWith({ + account: expect.objectContaining({ accountId: "work" }), + resourceName: "media/first", + maxBytes: 10 * 1024 * 1024, + }); + const expectedBody = additionalCount + ? `[Google Chat: ${additionalCount} additional ${additionalCount === 1 ? "attachment was" : "attachments were"} not processed; only the first attachment is supported]` + : ""; + expect(buildContext).toHaveBeenCalledWith( + expect.objectContaining({ + message: { + body: expectedBody, + bodyForAgent: expectedBody, + rawBody: expectedBody, + commandBody: expectedBody, + }, + media: [ + expect.objectContaining({ + path: "/tmp/googlechat-first.png", + url: "/tmp/googlechat-first.png", + contentType: "image/png", + }), + ...Array.from({ length: additionalCount }, () => + expect.objectContaining({ contentType: "application/pdf" }), + ), + ], + }), + ); + expect(readGoogleChatTestIngest(runTurn)).toMatchObject({ + rawText: expectedBody, + textForAgent: expectedBody, + textForCommands: expectedBody, + }); + }, + ); + + it.each([ + { + name: "Drive file with a caption", + text: "summarize this", + driveDataRef: { driveFileId: "private-drive-file" }, + }, + { + name: "Drive file without a caption", + text: "", + driveDataRef: { driveFileId: "private-drive-file" }, + }, + { + name: "attachment without a data reference", + text: "summarize this", + driveDataRef: undefined, + }, + ])("explains an unsupported $name without downloading it", async ({ text, driveDataRef }) => { + const { buildContext, core, runTurn, saveMediaBuffer } = createInboundClassificationHarness(); + const runtime = { error: vi.fn(), log: vi.fn() }; + allowGoogleChatMediaSender(); + + await processGoogleChatTestEvent({ + event: createGoogleChatMediaTestEvent({ + id: "unsupported", + text, + attachments: [ + { contentType: "application/pdf", contentName: "private-file.pdf", driveDataRef }, + ], + }), + account: googleChatMediaTestAccount, config: {}, - runtime: { error: vi.fn(), log: vi.fn() }, + runtime, core, mediaMaxMb: 10, }); - expect(accessMocks.applyGoogleChatInboundAccessPolicy).toHaveBeenCalledWith( - expect.objectContaining({ rawBody: "" }), - ); - expect(saveMediaBuffer).toHaveBeenCalledOnce(); + const reason = driveDataRef + ? "Google Drive files are not downloadable" + : "unsupported attachment source"; + const notice = `[Google Chat attachment unavailable: ${reason}; upload the file directly]`; + const expectedBody = text ? `${text}\n\n${notice}` : notice; + expect(apiMocks.downloadGoogleChatMedia).not.toHaveBeenCalled(); + expect(saveMediaBuffer).not.toHaveBeenCalled(); expect(buildContext).toHaveBeenCalledWith( expect.objectContaining({ - message: { body: "", bodyForAgent: "", rawBody: "", commandBody: "" }, - media: [ - expect.objectContaining({ - path: "/tmp/googlechat-first.png", - url: "/tmp/googlechat-first.png", - contentType: "image/png", - }), - expect.objectContaining({ contentType: "application/pdf" }), - ], + message: { + body: expectedBody, + bodyForAgent: expectedBody, + rawBody: expectedBody, + commandBody: expectedBody, + }, }), ); - expect(readGoogleChatTestIngest(runTurn)).toMatchObject({ - rawText: "", - textForAgent: "", - textForCommands: "", - }); + expect(runtime.error).toHaveBeenCalledExactlyOnceWith(`[work] ${notice}`); + expect(readGoogleChatTestIngest(runTurn)).toMatchObject({ textForAgent: expectedBody }); }); it.each([ @@ -434,7 +498,9 @@ describe("googlechat monitor inbound space classification", () => { }); const notice = "[Google Chat attachment too large; maximum 10 MB]"; - const expectedBody = text ? `${text}\n\n${notice}` : notice; + const additionalNotice = + "[Google Chat: 1 additional attachment was not processed; only the first attachment is supported]"; + const expectedBody = [text, notice, additionalNotice].filter(Boolean).join("\n\n"); expect(accessMocks.applyGoogleChatInboundAccessPolicy).toHaveBeenCalledWith( expect.objectContaining({ rawBody: text }), ); @@ -902,31 +968,7 @@ describe("googlechat monitor sender bot status", () => { describe("googlechat monitor direct messages", () => { it("omits thread metadata from DM reply context and typing messages", async () => { - const runTurn = vi.fn(); - const buildContext = vi.fn((payload: unknown) => payload); - const core = { - logging: { shouldLogVerbose: () => false }, - channel: { - routing: { - resolveAgentRoute: () => ({ - agentId: "agent-1", - accountId: "work", - sessionKey: "session-1", - }), - }, - session: { - resolveStorePath: () => "/tmp/openclaw-googlechat-test", - readSessionUpdatedAt: () => undefined, - recordInboundSession: vi.fn(), - }, - reply: { - resolveEnvelopeFormatOptions: () => ({}), - formatAgentEnvelope: ({ body }: { body: string }) => body, - dispatchReplyWithBufferedBlockDispatcher: vi.fn(), - }, - inbound: { buildContext, run: runTurn }, - }, - } as unknown as GoogleChatCoreRuntime; + const { buildContext, core, runTurn } = createInboundClassificationHarness(); const runtime = { error: vi.fn(), log: vi.fn() } satisfies GoogleChatRuntimeEnv; const account = { accountId: "work", @@ -947,16 +989,10 @@ describe("googlechat monitor direct messages", () => { }, } satisfies GoogleChatEvent; - accessMocks.applyGoogleChatInboundAccessPolicy.mockResolvedValue({ - ok: true, - commandAuthorized: undefined, - effectiveWasMentioned: undefined, - groupBotLoopProtection: undefined, - groupSystemPrompt: undefined, - }); apiMocks.sendGoogleChatMessage.mockResolvedValue({ messageName: "spaces/DM/messages/typing", }); + allowGoogleChatMediaSender(); await processGoogleChatTestEvent({ event, @@ -987,31 +1023,7 @@ describe("googlechat monitor direct messages", () => { }); it("drops invalid event timestamps from inbound runtime payloads", async () => { - const runTurn = vi.fn(); - const buildContext = vi.fn((payload: unknown) => payload); - const core = { - logging: { shouldLogVerbose: () => false }, - channel: { - routing: { - resolveAgentRoute: () => ({ - agentId: "agent-1", - accountId: "work", - sessionKey: "session-1", - }), - }, - session: { - resolveStorePath: () => "/tmp/openclaw-googlechat-test", - readSessionUpdatedAt: () => undefined, - recordInboundSession: vi.fn(), - }, - reply: { - resolveEnvelopeFormatOptions: () => ({}), - formatAgentEnvelope: ({ body }: { body: string }) => body, - dispatchReplyWithBufferedBlockDispatcher: vi.fn(), - }, - inbound: { buildContext, run: runTurn }, - }, - } as unknown as GoogleChatCoreRuntime; + const { buildContext, core, runTurn } = createInboundClassificationHarness(); const runtime = { error: vi.fn(), log: vi.fn() } satisfies GoogleChatRuntimeEnv; const account = { accountId: "work", @@ -1031,13 +1043,7 @@ describe("googlechat monitor direct messages", () => { }, } satisfies GoogleChatEvent; - accessMocks.applyGoogleChatInboundAccessPolicy.mockResolvedValue({ - ok: true, - commandAuthorized: undefined, - effectiveWasMentioned: undefined, - groupBotLoopProtection: undefined, - groupSystemPrompt: undefined, - }); + allowGoogleChatMediaSender(); await processGoogleChatTestEvent({ event, diff --git a/extensions/googlechat/src/monitor.ts b/extensions/googlechat/src/monitor.ts index 5b0d2062e7b5..0aea804f4c57 100644 --- a/extensions/googlechat/src/monitor.ts +++ b/extensions/googlechat/src/monitor.ts @@ -293,6 +293,13 @@ async function processMessageWithPipeline(params: { url: attachmentData.path, contentType: attachmentData.contentType ?? first.contentType, }; + } else { + const reason = first.driveDataRef + ? "Google Drive files are not downloadable" + : "unsupported attachment source"; + const notice = `[Google Chat attachment unavailable: ${reason}; upload the file directly]`; + rawBody = formatInboundMediaUnavailableText({ body: rawBody, notice }); + runtime.error?.(`[${account.accountId}] ${notice}`); } } catch (error) { if (!(error instanceof MediaFetchError) || error.code !== "max_bytes") { @@ -307,6 +314,11 @@ async function processMessageWithPipeline(params: { ); } } + const additionalCount = attachments.length - 1; + if (additionalCount > 0) { + const notice = `[Google Chat: ${additionalCount} additional ${additionalCount === 1 ? "attachment was" : "attachments were"} not processed; only the first attachment is supported]`; + rawBody = formatInboundMediaUnavailableText({ body: rawBody, notice }); + } const media = mediaInputs.length === 0 ? [] : await toInboundMediaFactsWithMetadata(mediaInputs); const fromLabel = isGroup