import crypto from "node:crypto"; import type { ResolvedGoogleChatAccount } from "./accounts.js"; import { getGoogleChatAccessToken } from "./auth.js"; import type { GoogleChatReaction } from "./types.js"; const CHAT_API_BASE = "https://chat.googleapis.com/v1"; const CHAT_UPLOAD_BASE = "https://chat.googleapis.com/upload/v1"; async function fetchJson( account: ResolvedGoogleChatAccount, url: string, init: RequestInit, ): Promise { const token = await getGoogleChatAccessToken(account); const res = await fetch(url, { ...init, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", ...(init.headers ?? {}), }, }); if (!res.ok) { const text = await res.text().catch(() => ""); throw new Error(`Google Chat API ${res.status}: ${text || res.statusText}`); } return (await res.json()) as T; } async function fetchOk( account: ResolvedGoogleChatAccount, url: string, init: RequestInit, ): Promise { const token = await getGoogleChatAccessToken(account); const res = await fetch(url, { ...init, headers: { Authorization: `Bearer ${token}`, ...(init.headers ?? {}), }, }); if (!res.ok) { const text = await res.text().catch(() => ""); throw new Error(`Google Chat API ${res.status}: ${text || res.statusText}`); } } async function fetchBuffer( account: ResolvedGoogleChatAccount, url: string, init?: RequestInit, options?: { maxBytes?: number }, ): Promise<{ buffer: Buffer; contentType?: string }> { const token = await getGoogleChatAccessToken(account); const res = await fetch(url, { ...init, headers: { Authorization: `Bearer ${token}`, ...(init?.headers ?? {}), }, }); if (!res.ok) { const text = await res.text().catch(() => ""); throw new Error(`Google Chat API ${res.status}: ${text || res.statusText}`); } 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; return { buffer, contentType }; } export async function sendGoogleChatMessage(params: { account: ResolvedGoogleChatAccount; space: string; text?: string; thread?: string; attachments?: Array<{ attachmentUploadToken: string; contentName?: string }>; }): Promise<{ messageName?: string } | null> { const { account, space, text, thread, attachments } = params; const body: Record = {}; if (text) body.text = text; if (thread) body.thread = { name: thread }; if (attachments && attachments.length > 0) { body.attachment = attachments.map((item) => ({ attachmentDataRef: { attachmentUploadToken: item.attachmentUploadToken }, ...(item.contentName ? { contentName: item.contentName } : {}), })); } const url = `${CHAT_API_BASE}/${space}/messages`; const result = await fetchJson<{ name?: string }>(account, url, { method: "POST", body: JSON.stringify(body), }); return result ? { messageName: result.name } : null; } export async function updateGoogleChatMessage(params: { account: ResolvedGoogleChatAccount; messageName: string; text: string; }): Promise<{ messageName?: string }> { const { account, messageName, text } = params; const url = `${CHAT_API_BASE}/${messageName}?updateMask=text`; const result = await fetchJson<{ name?: string }>(account, url, { method: "PATCH", body: JSON.stringify({ text }), }); return { messageName: result.name }; } export async function deleteGoogleChatMessage(params: { account: ResolvedGoogleChatAccount; messageName: string; }): Promise { const { account, messageName } = params; const url = `${CHAT_API_BASE}/${messageName}`; await fetchOk(account, url, { method: "DELETE" }); } export async function uploadGoogleChatAttachment(params: { account: ResolvedGoogleChatAccount; space: string; filename: string; buffer: Buffer; contentType?: string; }): Promise<{ attachmentUploadToken?: string }> { const { account, space, filename, buffer, contentType } = params; const boundary = `clawdbot-${crypto.randomUUID()}`; const metadata = JSON.stringify({ filename }); const header = `--${boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n${metadata}\r\n`; const mediaHeader = `--${boundary}\r\nContent-Type: ${contentType ?? "application/octet-stream"}\r\n\r\n`; const footer = `\r\n--${boundary}--\r\n`; const body = Buffer.concat([ Buffer.from(header, "utf8"), Buffer.from(mediaHeader, "utf8"), buffer, Buffer.from(footer, "utf8"), ]); const token = await getGoogleChatAccessToken(account); const url = `${CHAT_UPLOAD_BASE}/${space}/attachments:upload?uploadType=multipart`; const res = await fetch(url, { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": `multipart/related; boundary=${boundary}`, }, body, }); if (!res.ok) { const text = await res.text().catch(() => ""); throw new Error(`Google Chat upload ${res.status}: ${text || res.statusText}`); } const payload = (await res.json()) as { attachmentDataRef?: { attachmentUploadToken?: string }; }; return { attachmentUploadToken: payload.attachmentDataRef?.attachmentUploadToken }; } export async function downloadGoogleChatMedia(params: { account: ResolvedGoogleChatAccount; resourceName: string; maxBytes?: number; }): Promise<{ buffer: Buffer; contentType?: string }> { const { account, resourceName, maxBytes } = params; const url = `${CHAT_API_BASE}/media/${resourceName}?alt=media`; return await fetchBuffer(account, url, undefined, { maxBytes }); } export async function createGoogleChatReaction(params: { account: ResolvedGoogleChatAccount; messageName: string; emoji: string; }): Promise { const { account, messageName, emoji } = params; const url = `${CHAT_API_BASE}/${messageName}/reactions`; return await fetchJson(account, url, { method: "POST", body: JSON.stringify({ emoji: { unicode: emoji } }), }); } export async function listGoogleChatReactions(params: { account: ResolvedGoogleChatAccount; messageName: string; limit?: number; }): Promise { const { account, messageName, limit } = params; const url = new URL(`${CHAT_API_BASE}/${messageName}/reactions`); if (limit && limit > 0) url.searchParams.set("pageSize", String(limit)); const result = await fetchJson<{ reactions?: GoogleChatReaction[] }>(account, url.toString(), { method: "GET", }); return result.reactions ?? []; } export async function deleteGoogleChatReaction(params: { account: ResolvedGoogleChatAccount; reactionName: string; }): Promise { const { account, reactionName } = params; const url = `${CHAT_API_BASE}/${reactionName}`; await fetchOk(account, url, { method: "DELETE" }); } export async function findGoogleChatDirectMessage(params: { account: ResolvedGoogleChatAccount; userName: string; }): Promise<{ name?: string; displayName?: string } | null> { const { account, userName } = params; const url = new URL(`${CHAT_API_BASE}/spaces:findDirectMessage`); url.searchParams.set("name", userName); return await fetchJson<{ name?: string; displayName?: string }>(account, url.toString(), { method: "GET", }); } export async function probeGoogleChat(account: ResolvedGoogleChatAccount): Promise<{ ok: boolean; status?: number; error?: string; }> { try { const url = new URL(`${CHAT_API_BASE}/spaces`); url.searchParams.set("pageSize", "1"); await fetchJson>(account, url.toString(), { method: "GET" }); return { ok: true }; } catch (err) { return { ok: false, error: err instanceof Error ? err.message : String(err) }; } }