fix: polish Google Chat plugin (#1635) (thanks @iHildy)

Co-authored-by: Ian Hildebrand <ian@jedi.net>
This commit is contained in:
Peter Steinberger
2026-01-24 23:23:24 +00:00
parent 99dae0302b
commit 5570e1a946
14 changed files with 232 additions and 16 deletions

View File

@@ -25,6 +25,7 @@ Docs: https://docs.clawd.bot
- Gateway: reduce log noise for late invokes + remote node probes; debounce skills refresh. (#1607) Thanks @petter-b. - Gateway: reduce log noise for late invokes + remote node probes; debounce skills refresh. (#1607) Thanks @petter-b.
- macOS: default direct-transport `ws://` URLs to port 18789; document `gateway.remote.transport`. (#1603) Thanks @ngutman. - macOS: default direct-transport `ws://` URLs to port 18789; document `gateway.remote.transport`. (#1603) Thanks @ngutman.
- Voice Call: return stream TwiML for outbound conversation calls on initial Twilio webhook. (#1634) - Voice Call: return stream TwiML for outbound conversation calls on initial Twilio webhook. (#1634)
- Google Chat: tighten email allowlist matching, typing cleanup, media caps, and onboarding/docs/tests. (#1635) Thanks @iHildy.
## 2026.1.23-1 ## 2026.1.23-1

View File

@@ -134,7 +134,7 @@ Configure your tunnel's ingress rules to only route the webhook path:
## Targets ## Targets
Use these identifiers for delivery and allowlists: Use these identifiers for delivery and allowlists:
- Direct messages: `users/<userId>` (Clawdbot resolves to a DM space automatically). - Direct messages: `users/<userId>` or `users/<email>` (email addresses are accepted).
- Spaces: `spaces/<spaceId>`. - Spaces: `spaces/<spaceId>`.
## Config highlights ## Config highlights
@@ -162,6 +162,7 @@ Use these identifiers for delivery and allowlists:
} }
}, },
actions: { reactions: true }, actions: { reactions: true },
typingIndicator: "message",
mediaMaxMb: 20 mediaMaxMb: 20
} }
} }
@@ -172,6 +173,7 @@ Notes:
- Service account credentials can also be passed inline with `serviceAccount` (JSON string). - Service account credentials can also be passed inline with `serviceAccount` (JSON string).
- Default webhook path is `/googlechat` if `webhookPath` isnt set. - Default webhook path is `/googlechat` if `webhookPath` isnt set.
- Reactions are available via the `reactions` tool and `channels action` when `actions.reactions` is enabled. - Reactions are available via the `reactions` tool and `channels action` when `actions.reactions` is enabled.
- `typingIndicator` supports `none`, `message` (default), and `reaction` (reaction requires user OAuth).
- Attachments are downloaded through the Chat API and stored in the media pipeline (size capped by `mediaMaxMb`). - Attachments are downloaded through the Chat API and stored in the media pipeline (size capped by `mediaMaxMb`).
## Troubleshooting ## Troubleshooting

View File

@@ -1145,6 +1145,7 @@ Multi-account support lives under `channels.googlechat.accounts` (see the multi-
"spaces/AAAA": { allow: true, requireMention: true } "spaces/AAAA": { allow: true, requireMention: true }
}, },
actions: { reactions: true }, actions: { reactions: true },
typingIndicator: "message",
mediaMaxMb: 20 mediaMaxMb: 20
} }
} }
@@ -1155,7 +1156,7 @@ Notes:
- Service account JSON can be inline (`serviceAccount`) or file-based (`serviceAccountFile`). - Service account JSON can be inline (`serviceAccount`) or file-based (`serviceAccountFile`).
- Env fallbacks for the default account: `GOOGLE_CHAT_SERVICE_ACCOUNT` or `GOOGLE_CHAT_SERVICE_ACCOUNT_FILE`. - Env fallbacks for the default account: `GOOGLE_CHAT_SERVICE_ACCOUNT` or `GOOGLE_CHAT_SERVICE_ACCOUNT_FILE`.
- `audienceType` + `audience` must match the Chat apps webhook auth config. - `audienceType` + `audience` must match the Chat apps webhook auth config.
- Use `spaces/<spaceId>` or `users/<userId>` when setting delivery targets. - Use `spaces/<spaceId>` or `users/<userId|email>` when setting delivery targets.
### `channels.slack` (socket mode) ### `channels.slack` (socket mode)

View File

@@ -28,7 +28,12 @@
} }
}, },
"dependencies": { "dependencies": {
"clawdbot": "workspace:*",
"google-auth-library": "^10.5.0" "google-auth-library": "^10.5.0"
},
"devDependencies": {
"clawdbot": "workspace:*"
},
"peerDependencies": {
"clawdbot": ">=2026.1.24-0"
} }
} }

View File

@@ -0,0 +1,62 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ResolvedGoogleChatAccount } from "./accounts.js";
import { downloadGoogleChatMedia } from "./api.js";
vi.mock("./auth.js", () => ({
getGoogleChatAccessToken: vi.fn().mockResolvedValue("token"),
}));
const account = {
accountId: "default",
enabled: true,
credentialSource: "inline",
config: {},
} as ResolvedGoogleChatAccount;
describe("downloadGoogleChatMedia", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("rejects when content-length exceeds max bytes", async () => {
const body = new ReadableStream({
start(controller) {
controller.enqueue(new Uint8Array([1, 2, 3]));
controller.close();
},
});
const response = new Response(body, {
status: 200,
headers: { "content-length": "50", "content-type": "application/octet-stream" },
});
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response));
await expect(
downloadGoogleChatMedia({ account, resourceName: "media/123", maxBytes: 10 }),
).rejects.toThrow(/max bytes/i);
});
it("rejects when streamed payload exceeds max bytes", async () => {
const chunks = [new Uint8Array(6), new Uint8Array(6)];
let index = 0;
const body = new ReadableStream({
pull(controller) {
if (index < chunks.length) {
controller.enqueue(chunks[index++]);
} else {
controller.close();
}
},
});
const response = new Response(body, {
status: 200,
headers: { "content-type": "application/octet-stream" },
});
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response));
await expect(
downloadGoogleChatMedia({ account, resourceName: "media/123", maxBytes: 10 }),
).rejects.toThrow(/max bytes/i);
});
});

View File

@@ -51,6 +51,7 @@ async function fetchBuffer(
account: ResolvedGoogleChatAccount, account: ResolvedGoogleChatAccount,
url: string, url: string,
init?: RequestInit, init?: RequestInit,
options?: { maxBytes?: number },
): Promise<{ buffer: Buffer; contentType?: string }> { ): Promise<{ buffer: Buffer; contentType?: string }> {
const token = await getGoogleChatAccessToken(account); const token = await getGoogleChatAccessToken(account);
const res = await fetch(url, { const res = await fetch(url, {
@@ -64,7 +65,34 @@ async function fetchBuffer(
const text = await res.text().catch(() => ""); const text = await res.text().catch(() => "");
throw new Error(`Google Chat API ${res.status}: ${text || res.statusText}`); throw new Error(`Google Chat API ${res.status}: ${text || res.statusText}`);
} }
const buffer = Buffer.from(await res.arrayBuffer()); const maxBytes = options?.maxBytes;
const lengthHeader = res.headers.get("content-length");
if (maxBytes && lengthHeader) {
const length = Number(lengthHeader);
if (Number.isFinite(length) && length > maxBytes) {
throw new Error(`Google Chat media exceeds max bytes (${maxBytes})`);
}
}
if (!maxBytes || !res.body) {
const buffer = Buffer.from(await res.arrayBuffer());
const contentType = res.headers.get("content-type") ?? undefined;
return { buffer, contentType };
}
const reader = res.body.getReader();
const chunks: Buffer[] = [];
let total = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
total += value.length;
if (total > maxBytes) {
await reader.cancel();
throw new Error(`Google Chat media exceeds max bytes (${maxBytes})`);
}
chunks.push(Buffer.from(value));
}
const buffer = Buffer.concat(chunks, total);
const contentType = res.headers.get("content-type") ?? undefined; const contentType = res.headers.get("content-type") ?? undefined;
return { buffer, contentType }; return { buffer, contentType };
} }
@@ -108,6 +136,15 @@ export async function updateGoogleChatMessage(params: {
return { messageName: result.name }; return { messageName: result.name };
} }
export async function deleteGoogleChatMessage(params: {
account: ResolvedGoogleChatAccount;
messageName: string;
}): Promise<void> {
const { account, messageName } = params;
const url = `${CHAT_API_BASE}/${messageName}`;
await fetchOk(account, url, { method: "DELETE" });
}
export async function uploadGoogleChatAttachment(params: { export async function uploadGoogleChatAttachment(params: {
account: ResolvedGoogleChatAccount; account: ResolvedGoogleChatAccount;
space: string; space: string;
@@ -151,10 +188,11 @@ export async function uploadGoogleChatAttachment(params: {
export async function downloadGoogleChatMedia(params: { export async function downloadGoogleChatMedia(params: {
account: ResolvedGoogleChatAccount; account: ResolvedGoogleChatAccount;
resourceName: string; resourceName: string;
maxBytes?: number;
}): Promise<{ buffer: Buffer; contentType?: string }> { }): Promise<{ buffer: Buffer; contentType?: string }> {
const { account, resourceName } = params; const { account, resourceName, maxBytes } = params;
const url = `${CHAT_API_BASE}/media/${resourceName}?alt=media`; const url = `${CHAT_API_BASE}/media/${resourceName}?alt=media`;
return await fetchBuffer(account, url); return await fetchBuffer(account, url, undefined, { maxBytes });
} }
export async function createGoogleChatReaction(params: { export async function createGoogleChatReaction(params: {

View File

@@ -42,7 +42,8 @@ const meta = getChatChannelMeta("googlechat");
const formatAllowFromEntry = (entry: string) => const formatAllowFromEntry = (entry: string) =>
entry entry
.trim() .trim()
.replace(/^(googlechat|gchat):/i, "") .replace(/^(googlechat|google-chat|gchat):/i, "")
.replace(/^user:/i, "")
.replace(/^users\//i, "") .replace(/^users\//i, "")
.toLowerCase(); .toLowerCase();

View File

@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { isSenderAllowed } from "./monitor.js";
describe("isSenderAllowed", () => {
it("matches allowlist entries with users/<email>", () => {
expect(
isSenderAllowed("users/123", "Jane@Example.com", ["users/jane@example.com"]),
).toBe(true);
});
it("matches allowlist entries with raw email", () => {
expect(isSenderAllowed("users/123", "Jane@Example.com", ["jane@example.com"])).toBe(
true,
);
});
it("still matches user id entries", () => {
expect(isSenderAllowed("users/abc", "jane@example.com", ["users/abc"])).toBe(true);
});
it("rejects non-matching emails", () => {
expect(isSenderAllowed("users/123", "jane@example.com", ["users/other@example.com"])).toBe(
false,
);
});
});

View File

@@ -8,6 +8,7 @@ import {
} from "./accounts.js"; } from "./accounts.js";
import { import {
downloadGoogleChatMedia, downloadGoogleChatMedia,
deleteGoogleChatMessage,
sendGoogleChatMessage, sendGoogleChatMessage,
updateGoogleChatMessage, updateGoogleChatMessage,
} from "./api.js"; } from "./api.js";
@@ -296,7 +297,11 @@ function normalizeUserId(raw?: string | null): string {
return trimmed.replace(/^users\//i, "").toLowerCase(); return trimmed.replace(/^users\//i, "").toLowerCase();
} }
function isSenderAllowed(senderId: string, senderEmail: string | undefined, allowFrom: string[]) { export function isSenderAllowed(
senderId: string,
senderEmail: string | undefined,
allowFrom: string[],
) {
if (allowFrom.includes("*")) return true; if (allowFrom.includes("*")) return true;
const normalizedSenderId = normalizeUserId(senderId); const normalizedSenderId = normalizeUserId(senderId);
const normalizedEmail = senderEmail?.trim().toLowerCase() ?? ""; const normalizedEmail = senderEmail?.trim().toLowerCase() ?? "";
@@ -305,8 +310,11 @@ function isSenderAllowed(senderId: string, senderEmail: string | undefined, allo
if (!normalized) return false; if (!normalized) return false;
if (normalized === normalizedSenderId) return true; if (normalized === normalizedSenderId) return true;
if (normalizedEmail && normalized === normalizedEmail) return true; if (normalizedEmail && normalized === normalizedEmail) return true;
if (normalizedEmail && normalized.replace(/^users\//i, "") === normalizedEmail) return true;
if (normalized.replace(/^users\//i, "") === normalizedSenderId) return true; if (normalized.replace(/^users\//i, "") === normalizedSenderId) return true;
if (normalized.replace(/^(googlechat|gchat):/i, "") === normalizedSenderId) return true; if (normalized.replace(/^(googlechat|google-chat|gchat):/i, "") === normalizedSenderId) {
return true;
}
return false; return false;
}); });
} }
@@ -700,7 +708,7 @@ async function downloadAttachment(
const resourceName = attachment.attachmentDataRef?.resourceName; const resourceName = attachment.attachmentDataRef?.resourceName;
if (!resourceName) return null; if (!resourceName) return null;
const maxBytes = Math.max(1, mediaMaxMb) * 1024 * 1024; const maxBytes = Math.max(1, mediaMaxMb) * 1024 * 1024;
const downloaded = await downloadGoogleChatMedia({ account, resourceName }); const downloaded = await downloadGoogleChatMedia({ account, resourceName, maxBytes });
const saved = await core.channel.media.saveMediaBuffer( const saved = await core.channel.media.saveMediaBuffer(
downloaded.buffer, downloaded.buffer,
downloaded.contentType ?? attachment.contentType, downloaded.contentType ?? attachment.contentType,
@@ -728,9 +736,35 @@ async function deliverGoogleChatReply(params: {
: []; : [];
if (mediaList.length > 0) { if (mediaList.length > 0) {
let suppressCaption = false;
if (typingMessageName) {
try {
await deleteGoogleChatMessage({
account,
messageName: typingMessageName,
});
} catch (err) {
runtime.error?.(`Google Chat typing cleanup failed: ${String(err)}`);
const fallbackText = payload.text?.trim()
? payload.text
: mediaList.length > 1
? "Sent attachments."
: "Sent attachment.";
try {
await updateGoogleChatMessage({
account,
messageName: typingMessageName,
text: fallbackText,
});
suppressCaption = Boolean(payload.text?.trim());
} catch (updateErr) {
runtime.error?.(`Google Chat typing update failed: ${String(updateErr)}`);
}
}
}
let first = true; let first = true;
for (const mediaUrl of mediaList) { for (const mediaUrl of mediaList) {
const caption = first ? payload.text : undefined; const caption = first && !suppressCaption ? payload.text : undefined;
first = false; first = false;
try { try {
const loaded = await core.channel.media.fetchRemoteMedia(mediaUrl, { const loaded = await core.channel.media.fetchRemoteMedia(mediaUrl, {

View File

@@ -136,7 +136,9 @@ async function promptCredentials(params: {
}): Promise<ClawdbotConfig> { }): Promise<ClawdbotConfig> {
const { cfg, prompter, accountId } = params; const { cfg, prompter, accountId } = params;
const envReady = const envReady =
Boolean(process.env[ENV_SERVICE_ACCOUNT]) || Boolean(process.env[ENV_SERVICE_ACCOUNT_FILE]); accountId === DEFAULT_ACCOUNT_ID &&
(Boolean(process.env[ENV_SERVICE_ACCOUNT]) ||
Boolean(process.env[ENV_SERVICE_ACCOUNT_FILE]));
if (envReady) { if (envReady) {
const useEnv = await prompter.confirm({ const useEnv = await prompter.confirm({
message: "Use GOOGLE_CHAT_SERVICE_ACCOUNT env vars?", message: "Use GOOGLE_CHAT_SERVICE_ACCOUNT env vars?",

View File

@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import {
isGoogleChatSpaceTarget,
isGoogleChatUserTarget,
normalizeGoogleChatTarget,
} from "./targets.js";
describe("normalizeGoogleChatTarget", () => {
it("normalizes provider prefixes", () => {
expect(normalizeGoogleChatTarget("googlechat:users/123")).toBe("users/123");
expect(normalizeGoogleChatTarget("google-chat:spaces/AAA")).toBe("spaces/AAA");
expect(normalizeGoogleChatTarget("gchat:user:User@Example.com")).toBe(
"users/user@example.com",
);
});
it("normalizes email targets to users/<email>", () => {
expect(normalizeGoogleChatTarget("User@Example.com")).toBe("users/user@example.com");
expect(normalizeGoogleChatTarget("users/User@Example.com")).toBe("users/user@example.com");
});
it("preserves space targets", () => {
expect(normalizeGoogleChatTarget("space:spaces/BBB")).toBe("spaces/BBB");
expect(normalizeGoogleChatTarget("spaces/CCC")).toBe("spaces/CCC");
});
});
describe("target helpers", () => {
it("detects user and space targets", () => {
expect(isGoogleChatUserTarget("users/abc")).toBe(true);
expect(isGoogleChatSpaceTarget("spaces/abc")).toBe(true);
expect(isGoogleChatUserTarget("spaces/abc")).toBe(false);
});
});

View File

@@ -4,10 +4,16 @@ import { findGoogleChatDirectMessage } from "./api.js";
export function normalizeGoogleChatTarget(raw?: string | null): string | undefined { export function normalizeGoogleChatTarget(raw?: string | null): string | undefined {
const trimmed = raw?.trim(); const trimmed = raw?.trim();
if (!trimmed) return undefined; if (!trimmed) return undefined;
const withoutPrefix = trimmed.replace(/^(googlechat|gchat):/i, ""); const withoutPrefix = trimmed.replace(/^(googlechat|google-chat|gchat):/i, "");
const normalized = withoutPrefix const normalized = withoutPrefix
.replace(/^user:/i, "users/") .replace(/^user:/i, "users/")
.replace(/^space:/i, "spaces/"); .replace(/^space:/i, "spaces/");
if (isGoogleChatUserTarget(normalized)) {
const suffix = normalized.slice("users/".length);
return suffix.includes("@") ? `users/${suffix.toLowerCase()}` : normalized;
}
if (isGoogleChatSpaceTarget(normalized)) return normalized;
if (normalized.includes("@")) return `users/${normalized.toLowerCase()}`;
return normalized; return normalized;
} }

7
pnpm-lock.yaml generated
View File

@@ -304,12 +304,13 @@ importers:
extensions/googlechat: extensions/googlechat:
dependencies: dependencies:
clawdbot:
specifier: workspace:*
version: link:../..
google-auth-library: google-auth-library:
specifier: ^10.5.0 specifier: ^10.5.0
version: 10.5.0 version: 10.5.0
devDependencies:
clawdbot:
specifier: workspace:*
version: link:../..
extensions/imessage: {} extensions/imessage: {}

View File

@@ -35,6 +35,7 @@ function detectAutoKind(input: string): ChannelResolveKind {
if (!trimmed) return "group"; if (!trimmed) return "group";
if (trimmed.startsWith("@")) return "user"; if (trimmed.startsWith("@")) return "user";
if (/^<@!?/.test(trimmed)) return "user"; if (/^<@!?/.test(trimmed)) return "user";
if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) return "user";
if ( if (
/^(user|discord|slack|matrix|msteams|teams|zalo|zalouser|googlechat|google-chat|gchat):/i.test( /^(user|discord|slack|matrix|msteams|teams|zalo|zalouser|googlechat|google-chat|gchat):/i.test(
trimmed, trimmed,