fix(googlechat): surface unsupported and unprocessed attachments (#130870)

This commit is contained in:
Peter Steinberger
2026-08-27 03:31:49 -07:00
committed by GitHub
parent 6a5c351648
commit fdf3cd69ce
3 changed files with 132 additions and 114 deletions
+1 -1
View File
@@ -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/<id>` 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 `_<Bot> 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.<space>.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.
+119 -113
View File
@@ -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,
+12
View File
@@ -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