Files
clawdbot/extensions/googlechat/src/api.ts
Peter Steinberger 5570e1a946 fix: polish Google Chat plugin (#1635) (thanks @iHildy)
Co-authored-by: Ian Hildebrand <ian@jedi.net>
2026-01-24 23:30:45 +00:00

260 lines
8.5 KiB
TypeScript

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<T>(
account: ResolvedGoogleChatAccount,
url: string,
init: RequestInit,
): Promise<T> {
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<void> {
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<string, unknown> = {};
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<void> {
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<GoogleChatReaction> {
const { account, messageName, emoji } = params;
const url = `${CHAT_API_BASE}/${messageName}/reactions`;
return await fetchJson<GoogleChatReaction>(account, url, {
method: "POST",
body: JSON.stringify({ emoji: { unicode: emoji } }),
});
}
export async function listGoogleChatReactions(params: {
account: ResolvedGoogleChatAccount;
messageName: string;
limit?: number;
}): Promise<GoogleChatReaction[]> {
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<void> {
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<Record<string, unknown>>(account, url.toString(), { method: "GET" });
return { ok: true };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}