// Google Chat platform plugin. // // Required secrets (wrangler secret put): // GOOGLE_CHAT_PROJECT_NUMBER — Cloud project number (for JWT aud claim) // // Optional (enables async replies): // GOOGLE_CHAT_SERVICE_ACCOUNT_KEY — Service account JSON (stringified) // // Google Chat sends events via HTTP POST with an Authorization: Bearer JWT. // Replies are delivered asynchronously via the Chat REST API when a // service account key is configured; otherwise the relay acknowledges // the message but cannot send replies back. import { chunkText } from "@mulmobridge/client/text"; import { hasStringProp, isRecord } from "@mulmoclaude/common"; import { PLATFORMS, type RelayMessage, type Env } from "../types.js"; import { registerPlatform, CONNECTION_MODES, type PlatformPlugin } from "../platform.js"; import { ONE_HOUR_MS, ONE_HOUR_S, TEN_SECONDS_MS, FIFTEEN_SECONDS_MS } from "../time.js"; import { parseJwt, jwtKid, verifyJwtSignature } from "./jwt.js"; import { makeRelayMessage } from "./relay-message.js"; const GOOGLE_CHAT_ISSUER = "chat@system.gserviceaccount.com"; const JWKS_URL = "https://www.googleapis.com/service_accounts/v1/jwk/chat@system.gserviceaccount.com"; const JWKS_CACHE_TTL_MS = ONE_HOUR_MS; const CHAT_API_BASE = "https://chat.googleapis.com/v1"; const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"; const CHAT_SCOPE = "https://www.googleapis.com/auth/chat.bot"; const MAX_CHAT_TEXT = 4000; // ── JWKS cache ────────────────────────────────────────────────── // Only the fields the guard below actually proves — the JWKS entry reaches crypto.subtle verbatim, extra fields (`alg`, `use`) included. interface JwkKey { kid: string; kty: string; n: string; e: string; } let cachedKeys: JwkKey[] = []; let cacheExpiresAt = 0; // Every field of JwkKey is checked here, so nothing is asserted; `e` and `kty` // are what crypto.subtle needs to build an RSA public key. export const isJwk = (key: unknown): key is JwkKey => hasStringProp(key, "kid") && hasStringProp(key, "kty") && hasStringProp(key, "n") && hasStringProp(key, "e"); async function getJwks(): Promise { if (Date.now() < cacheExpiresAt && cachedKeys.length > 0) return cachedKeys; const res = await fetch(JWKS_URL, { signal: AbortSignal.timeout(TEN_SECONDS_MS) }); if (!res.ok) return cachedKeys; const data: { keys?: unknown[] } = await res.json(); if (!Array.isArray(data.keys)) return cachedKeys; cachedKeys = data.keys.filter(isJwk); cacheExpiresAt = Date.now() + JWKS_CACHE_TTL_MS; return cachedKeys; } // ── JWT verification ──────────────────────────────────────────── // Pure claim-check — no network, safe to unit-test. Verifies the // iss / aud / exp claims before any JWKS lookup or signature work. // Callers that want signature verification run verifyGoogleJwt below, // which layers JWKS + crypto.subtle on top. export function validateGoogleChatClaims(payload: Record, projectNumber: string, nowSeconds: number): boolean { if (payload.iss !== GOOGLE_CHAT_ISSUER) return false; if (String(payload.aud) !== projectNumber) return false; // Fail closed: missing or non-numeric `exp` must reject (previously read // "number AND expired", silently accepting tokens without an exp claim). if (typeof payload.exp !== "number" || payload.exp < nowSeconds) return false; return true; } async function verifyGoogleJwt(authHeader: string | undefined, projectNumber: string): Promise { if (!authHeader?.startsWith("Bearer ")) return false; const token = authHeader.slice(7).trim(); const jwt = parseJwt(token); if (!jwt) return false; if (!validateGoogleChatClaims(jwt.payload, projectNumber, Date.now() / 1000)) return false; const keys = await getJwks(); const jwk = keys.find((key) => key.kid === jwtKid(jwt)); if (!jwk) return false; return verifyJwtSignature(jwt, jwk); } // ── Payload parsing ───────────────────────────────────────────── interface ChatMessage { spaceName: string; text: string; } function parseMessage(body: unknown): ChatMessage | null { if (!isRecord(body) || body.type !== "MESSAGE") return null; const msg = body.message; if (!isRecord(msg) || typeof msg.text !== "string") return null; const { space } = msg; if (!isRecord(space) || typeof space.name !== "string") return null; return { spaceName: space.name, text: msg.text.trim() }; } // ── Service account → access token ───────────────────────────── interface ServiceAccount { client_email: string; private_key: string; } function parseServiceAccount(raw: string): ServiceAccount | null { try { const parsed: unknown = JSON.parse(raw); if (!isRecord(parsed) || typeof parsed.client_email !== "string" || typeof parsed.private_key !== "string") return null; return { client_email: parsed.client_email, private_key: parsed.private_key }; } catch { return null; } } function b64UrlEncode(data: Uint8Array): string { return btoa(String.fromCharCode(...data)) .replace(/\+/g, "-") .replace(/\//g, "_") .replace(/=/g, ""); } async function makeServiceAccountJwt(account: ServiceAccount): Promise { const now = Math.floor(Date.now() / 1000); const header = b64UrlEncode(new TextEncoder().encode(JSON.stringify({ alg: "RS256", typ: "JWT" }))); const claims = b64UrlEncode( new TextEncoder().encode(JSON.stringify({ iss: account.client_email, scope: CHAT_SCOPE, aud: GOOGLE_TOKEN_URL, exp: now + ONE_HOUR_S, iat: now })), ); const pemContents = account.private_key.replace(/-----[^-]+-----/g, "").replace(/\s/g, ""); const keyBuffer = Uint8Array.from(atob(pemContents), (chr) => chr.charCodeAt(0)); const privateKey = await crypto.subtle.importKey("pkcs8", keyBuffer, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, false, ["sign"]); const sig = b64UrlEncode(new Uint8Array(await crypto.subtle.sign("RSASSA-PKCS1-v1_5", privateKey, new TextEncoder().encode(`${header}.${claims}`)))); return `${header}.${claims}.${sig}`; } // OAuth can answer 200 with an error body; asserting the shape instead of // checking it would send the literal string "Bearer undefined" to the Chat API. export function readAccessToken(data: unknown, status: number): string { if (!hasStringProp(data, "access_token") || !data.access_token) { throw new Error(`Google token exchange returned no access_token (${GOOGLE_TOKEN_URL}, status ${status})`); } return data.access_token; } async function getGoogleAccessToken(account: ServiceAccount): Promise { const jwt = await makeServiceAccountJwt(account); const res = await fetch(GOOGLE_TOKEN_URL, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: `grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion=${jwt}`, signal: AbortSignal.timeout(TEN_SECONDS_MS), }); if (!res.ok) throw new Error(`Google token exchange failed: ${res.status}`); return readAccessToken(await res.json(), res.status); } // ── Plugin ────────────────────────────────────────────────────── const googleChatPlugin: PlatformPlugin = { name: PLATFORMS.googleChat, mode: CONNECTION_MODES.webhook, webhookPath: "/webhook/google-chat", isConfigured(env: Env): boolean { return Boolean(env.GOOGLE_CHAT_PROJECT_NUMBER); }, async handleWebhook(request: Request, body: string, env: Env): Promise { const authHeader = request.headers.get("authorization") ?? undefined; const valid = await verifyGoogleJwt(authHeader, String(env.GOOGLE_CHAT_PROJECT_NUMBER)); if (!valid) throw new Error("Google Chat JWT verification failed"); const parsed = parseMessage(JSON.parse(body)); if (!parsed || !parsed.text) return []; return [makeRelayMessage({ platform: PLATFORMS.googleChat, senderId: parsed.spaceName, chatId: parsed.spaceName, text: parsed.text })]; }, async sendResponse(chatId: string, text: string, env: Env): Promise { const saKeyRaw = typeof env.GOOGLE_CHAT_SERVICE_ACCOUNT_KEY === "string" ? env.GOOGLE_CHAT_SERVICE_ACCOUNT_KEY : ""; if (!saKeyRaw) { console.warn(`[google-chat] reply not delivered (no service account): ${chatId}`); return; } const account = parseServiceAccount(saKeyRaw); if (!account) throw new Error("GOOGLE_CHAT_SERVICE_ACCOUNT_KEY is not valid JSON"); const accessToken = await getGoogleAccessToken(account); // chunkText respects code-point boundaries (avoids splitting emoji // surrogate pairs) and keeps parity with WhatsApp / Messenger. const chunks = chunkText(text, MAX_CHAT_TEXT); for (const chunk of chunks) { let res: Response; try { res = await fetch(`${CHAT_API_BASE}/${chatId}/messages`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${accessToken}` }, body: JSON.stringify({ text: chunk }), signal: AbortSignal.timeout(FIFTEEN_SECONDS_MS), }); } catch (err) { throw new Error(`Google Chat API network error: ${err instanceof Error ? err.message : String(err)}`); } if (!res.ok) throw new Error(`Google Chat API failed: ${res.status}`); } }, }; registerPlatform(googleChatPlugin);