import type { IncomingMessage, ServerResponse } from "node:http"; import type { ClawdbotConfig } from "clawdbot/plugin-sdk"; import { resolveMentionGatingWithBypass } from "clawdbot/plugin-sdk"; import { type ResolvedGoogleChatAccount } from "./accounts.js"; import { downloadGoogleChatMedia, deleteGoogleChatMessage, sendGoogleChatMessage, updateGoogleChatMessage, } from "./api.js"; import { verifyGoogleChatRequest, type GoogleChatAudienceType } from "./auth.js"; import { getGoogleChatRuntime } from "./runtime.js"; import type { GoogleChatAnnotation, GoogleChatAttachment, GoogleChatEvent, GoogleChatSpace, GoogleChatMessage, GoogleChatUser, } from "./types.js"; export type GoogleChatRuntimeEnv = { log?: (message: string) => void; error?: (message: string) => void; }; export type GoogleChatMonitorOptions = { account: ResolvedGoogleChatAccount; config: ClawdbotConfig; runtime: GoogleChatRuntimeEnv; abortSignal: AbortSignal; webhookPath?: string; webhookUrl?: string; statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; }; type GoogleChatCoreRuntime = ReturnType; type WebhookTarget = { account: ResolvedGoogleChatAccount; config: ClawdbotConfig; runtime: GoogleChatRuntimeEnv; core: GoogleChatCoreRuntime; path: string; audienceType?: GoogleChatAudienceType; audience?: string; statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; mediaMaxMb: number; }; const webhookTargets = new Map(); function logVerbose(core: GoogleChatCoreRuntime, runtime: GoogleChatRuntimeEnv, message: string) { if (core.logging.shouldLogVerbose()) { runtime.log?.(`[googlechat] ${message}`); } } function normalizeWebhookPath(raw: string): string { const trimmed = raw.trim(); if (!trimmed) return "/"; const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`; if (withSlash.length > 1 && withSlash.endsWith("/")) { return withSlash.slice(0, -1); } return withSlash; } function resolveWebhookPath(webhookPath?: string, webhookUrl?: string): string | null { const trimmedPath = webhookPath?.trim(); if (trimmedPath) return normalizeWebhookPath(trimmedPath); if (webhookUrl?.trim()) { try { const parsed = new URL(webhookUrl); return normalizeWebhookPath(parsed.pathname || "/"); } catch { return null; } } return "/googlechat"; } async function readJsonBody(req: IncomingMessage, maxBytes: number) { const chunks: Buffer[] = []; let total = 0; return await new Promise<{ ok: boolean; value?: unknown; error?: string }>((resolve) => { let resolved = false; const doResolve = (value: { ok: boolean; value?: unknown; error?: string }) => { if (resolved) return; resolved = true; req.removeAllListeners(); resolve(value); }; req.on("data", (chunk: Buffer) => { total += chunk.length; if (total > maxBytes) { doResolve({ ok: false, error: "payload too large" }); req.destroy(); return; } chunks.push(chunk); }); req.on("end", () => { try { const raw = Buffer.concat(chunks).toString("utf8"); if (!raw.trim()) { doResolve({ ok: false, error: "empty payload" }); return; } doResolve({ ok: true, value: JSON.parse(raw) as unknown }); } catch (err) { doResolve({ ok: false, error: err instanceof Error ? err.message : String(err) }); } }); req.on("error", (err) => { doResolve({ ok: false, error: err instanceof Error ? err.message : String(err) }); }); }); } export function registerGoogleChatWebhookTarget(target: WebhookTarget): () => void { const key = normalizeWebhookPath(target.path); const normalizedTarget = { ...target, path: key }; const existing = webhookTargets.get(key) ?? []; const next = [...existing, normalizedTarget]; webhookTargets.set(key, next); return () => { const updated = (webhookTargets.get(key) ?? []).filter((entry) => entry !== normalizedTarget); if (updated.length > 0) { webhookTargets.set(key, updated); } else { webhookTargets.delete(key); } }; } function normalizeAudienceType(value?: string | null): GoogleChatAudienceType | undefined { const normalized = value?.trim().toLowerCase(); if (normalized === "app-url" || normalized === "app_url" || normalized === "app") { return "app-url"; } if (normalized === "project-number" || normalized === "project_number" || normalized === "project") { return "project-number"; } return undefined; } export async function handleGoogleChatWebhookRequest( req: IncomingMessage, res: ServerResponse, ): Promise { const url = new URL(req.url ?? "/", "http://localhost"); const path = normalizeWebhookPath(url.pathname); const targets = webhookTargets.get(path); if (!targets || targets.length === 0) return false; if (req.method !== "POST") { res.statusCode = 405; res.setHeader("Allow", "POST"); res.end("Method Not Allowed"); return true; } const authHeader = String(req.headers.authorization ?? ""); const bearer = authHeader.toLowerCase().startsWith("bearer ") ? authHeader.slice("bearer ".length) : ""; const body = await readJsonBody(req, 1024 * 1024); if (!body.ok) { res.statusCode = body.error === "payload too large" ? 413 : 400; res.end(body.error ?? "invalid payload"); return true; } let raw = body.value; if (!raw || typeof raw !== "object" || Array.isArray(raw)) { res.statusCode = 400; res.end("invalid payload"); return true; } // Transform Google Workspace Add-on format to standard Chat API format const rawObj = raw as { commonEventObject?: { hostApp?: string }; chat?: { messagePayload?: { space?: GoogleChatSpace; message?: GoogleChatMessage }; user?: GoogleChatUser; eventTime?: string; }; authorizationEventObject?: { systemIdToken?: string }; }; if (rawObj.commonEventObject?.hostApp === "CHAT" && rawObj.chat?.messagePayload) { const chat = rawObj.chat; const messagePayload = chat.messagePayload; raw = { type: "MESSAGE", space: messagePayload?.space, message: messagePayload?.message, user: chat.user, eventTime: chat.eventTime, }; // For Add-ons, the bearer token may be in authorizationEventObject.systemIdToken const systemIdToken = rawObj.authorizationEventObject?.systemIdToken; if (!bearer && systemIdToken) { Object.assign(req.headers, { authorization: `Bearer ${systemIdToken}` }); } } const event = raw as GoogleChatEvent; const eventType = event.type ?? (raw as { eventType?: string }).eventType; if (typeof eventType !== "string") { res.statusCode = 400; res.end("invalid payload"); return true; } if (!event.space || typeof event.space !== "object" || Array.isArray(event.space)) { res.statusCode = 400; res.end("invalid payload"); return true; } if (eventType === "MESSAGE") { if (!event.message || typeof event.message !== "object" || Array.isArray(event.message)) { res.statusCode = 400; res.end("invalid payload"); return true; } } // Re-extract bearer in case it was updated from Add-on format const authHeaderNow = String(req.headers.authorization ?? ""); const effectiveBearer = authHeaderNow.toLowerCase().startsWith("bearer ") ? authHeaderNow.slice("bearer ".length) : bearer; let selected: WebhookTarget | undefined; for (const target of targets) { const audienceType = target.audienceType; const audience = target.audience; const verification = await verifyGoogleChatRequest({ bearer: effectiveBearer, audienceType, audience, }); if (verification.ok) { selected = target; break; } } if (!selected) { res.statusCode = 401; res.end("unauthorized"); return true; } selected.statusSink?.({ lastInboundAt: Date.now() }); processGoogleChatEvent(event, selected).catch((err) => { selected?.runtime.error?.( `[${selected.account.accountId}] Google Chat webhook failed: ${String(err)}`, ); }); res.statusCode = 200; res.setHeader("Content-Type", "application/json"); res.end("{}"); return true; } async function processGoogleChatEvent(event: GoogleChatEvent, target: WebhookTarget) { const eventType = event.type ?? (event as { eventType?: string }).eventType; if (eventType !== "MESSAGE") return; if (!event.message || !event.space) return; await processMessageWithPipeline({ event, account: target.account, config: target.config, runtime: target.runtime, core: target.core, statusSink: target.statusSink, mediaMaxMb: target.mediaMaxMb, }); } function normalizeUserId(raw?: string | null): string { const trimmed = raw?.trim() ?? ""; if (!trimmed) return ""; return trimmed.replace(/^users\//i, "").toLowerCase(); } export function isSenderAllowed( senderId: string, senderEmail: string | undefined, allowFrom: string[], ) { if (allowFrom.includes("*")) return true; const normalizedSenderId = normalizeUserId(senderId); const normalizedEmail = senderEmail?.trim().toLowerCase() ?? ""; return allowFrom.some((entry) => { const normalized = String(entry).trim().toLowerCase(); if (!normalized) return false; if (normalized === normalizedSenderId) 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(/^(googlechat|google-chat|gchat):/i, "") === normalizedSenderId) { return true; } return false; }); } function resolveGroupConfig(params: { groupId: string; groupName?: string | null; groups?: Record; systemPrompt?: string }>; }) { const { groupId, groupName, groups } = params; const entries = groups ?? {}; const keys = Object.keys(entries); if (keys.length === 0) { return { entry: undefined, allowlistConfigured: false }; } const normalizedName = groupName?.trim().toLowerCase(); const candidates = [groupId, groupName ?? "", normalizedName ?? ""].filter(Boolean); let entry = candidates.map((candidate) => entries[candidate]).find(Boolean); if (!entry && normalizedName) { entry = entries[normalizedName]; } const fallback = entries["*"]; return { entry: entry ?? fallback, allowlistConfigured: true, fallback }; } function extractMentionInfo(annotations: GoogleChatAnnotation[], botUser?: string | null) { const mentionAnnotations = annotations.filter((entry) => entry.type === "USER_MENTION"); const hasAnyMention = mentionAnnotations.length > 0; const botTargets = new Set(["users/app", botUser?.trim()].filter(Boolean) as string[]); const wasMentioned = mentionAnnotations.some((entry) => { const userName = entry.userMention?.user?.name; if (!userName) return false; if (botTargets.has(userName)) return true; return normalizeUserId(userName) === "app"; }); return { hasAnyMention, wasMentioned }; } /** * Resolve bot display name with fallback chain: * 1. Account config name * 2. Agent name from config * 3. "Clawdbot" as generic fallback */ function resolveBotDisplayName(params: { accountName?: string; agentId: string; config: ClawdbotConfig; }): string { const { accountName, agentId, config } = params; if (accountName?.trim()) return accountName.trim(); const agent = config.agents?.list?.find((a) => a.id === agentId); if (agent?.name?.trim()) return agent.name.trim(); return "Clawdbot"; } async function processMessageWithPipeline(params: { event: GoogleChatEvent; account: ResolvedGoogleChatAccount; config: ClawdbotConfig; runtime: GoogleChatRuntimeEnv; core: GoogleChatCoreRuntime; statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; mediaMaxMb: number; }): Promise { const { event, account, config, runtime, core, statusSink, mediaMaxMb } = params; const space = event.space; const message = event.message; if (!space || !message) return; const spaceId = space.name ?? ""; if (!spaceId) return; const spaceType = (space.type ?? "").toUpperCase(); const isGroup = spaceType !== "DM"; const sender = message.sender ?? event.user; const senderId = sender?.name ?? ""; const senderName = sender?.displayName ?? ""; const senderEmail = sender?.email ?? undefined; const allowBots = account.config.allowBots === true; if (!allowBots) { if (sender?.type?.toUpperCase() === "BOT") { logVerbose(core, runtime, `skip bot-authored message (${senderId || "unknown"})`); return; } if (senderId === "users/app") { logVerbose(core, runtime, "skip app-authored message"); return; } } const messageText = (message.argumentText ?? message.text ?? "").trim(); const attachments = message.attachment ?? []; const hasMedia = attachments.length > 0; const rawBody = messageText || (hasMedia ? "" : ""); if (!rawBody) return; const defaultGroupPolicy = config.channels?.defaults?.groupPolicy; const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; const groupConfigResolved = resolveGroupConfig({ groupId: spaceId, groupName: space.displayName ?? null, groups: account.config.groups ?? undefined, }); const groupEntry = groupConfigResolved.entry; const groupUsers = groupEntry?.users ?? account.config.groupAllowFrom ?? []; let effectiveWasMentioned: boolean | undefined; if (isGroup) { if (groupPolicy === "disabled") { logVerbose(core, runtime, `drop group message (groupPolicy=disabled, space=${spaceId})`); return; } const groupAllowlistConfigured = groupConfigResolved.allowlistConfigured; const groupAllowed = Boolean(groupEntry) || Boolean((account.config.groups ?? {})["*"]); if (groupPolicy === "allowlist") { if (!groupAllowlistConfigured) { logVerbose( core, runtime, `drop group message (groupPolicy=allowlist, no allowlist, space=${spaceId})`, ); return; } if (!groupAllowed) { logVerbose(core, runtime, `drop group message (not allowlisted, space=${spaceId})`); return; } } if (groupEntry?.enabled === false || groupEntry?.allow === false) { logVerbose(core, runtime, `drop group message (space disabled, space=${spaceId})`); return; } if (groupUsers.length > 0) { const ok = isSenderAllowed(senderId, senderEmail, groupUsers.map((v) => String(v))); if (!ok) { logVerbose(core, runtime, `drop group message (sender not allowed, ${senderId})`); return; } } } const dmPolicy = account.config.dm?.policy ?? "pairing"; const configAllowFrom = (account.config.dm?.allowFrom ?? []).map((v) => String(v)); const shouldComputeAuth = core.channel.commands.shouldComputeCommandAuthorized(rawBody, config); const storeAllowFrom = !isGroup && (dmPolicy !== "open" || shouldComputeAuth) ? await core.channel.pairing.readAllowFromStore("googlechat").catch(() => []) : []; const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom]; const commandAllowFrom = isGroup ? groupUsers.map((v) => String(v)) : effectiveAllowFrom; const useAccessGroups = config.commands?.useAccessGroups !== false; const senderAllowedForCommands = isSenderAllowed(senderId, senderEmail, commandAllowFrom); const commandAuthorized = shouldComputeAuth ? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({ useAccessGroups, authorizers: [ { configured: commandAllowFrom.length > 0, allowed: senderAllowedForCommands }, ], }) : undefined; if (isGroup) { const requireMention = groupEntry?.requireMention ?? account.config.requireMention ?? true; const annotations = message.annotations ?? []; const mentionInfo = extractMentionInfo(annotations, account.config.botUser); const allowTextCommands = core.channel.commands.shouldHandleTextCommands({ cfg: config, surface: "googlechat", }); const mentionGate = resolveMentionGatingWithBypass({ isGroup: true, requireMention, canDetectMention: true, wasMentioned: mentionInfo.wasMentioned, implicitMention: false, hasAnyMention: mentionInfo.hasAnyMention, allowTextCommands, hasControlCommand: core.channel.text.hasControlCommand(rawBody, config), commandAuthorized: commandAuthorized === true, }); effectiveWasMentioned = mentionGate.effectiveWasMentioned; if (mentionGate.shouldSkip) { logVerbose(core, runtime, `drop group message (mention required, space=${spaceId})`); return; } } if (!isGroup) { if (dmPolicy === "disabled" || account.config.dm?.enabled === false) { logVerbose(core, runtime, `Blocked Google Chat DM from ${senderId} (dmPolicy=disabled)`); return; } if (dmPolicy !== "open") { const allowed = senderAllowedForCommands; if (!allowed) { if (dmPolicy === "pairing") { const { code, created } = await core.channel.pairing.upsertPairingRequest({ channel: "googlechat", id: senderId, meta: { name: senderName || undefined, email: senderEmail }, }); if (created) { logVerbose(core, runtime, `googlechat pairing request sender=${senderId}`); try { await sendGoogleChatMessage({ account, space: spaceId, text: core.channel.pairing.buildPairingReply({ channel: "googlechat", idLine: `Your Google Chat user id: ${senderId}`, code, }), }); statusSink?.({ lastOutboundAt: Date.now() }); } catch (err) { logVerbose(core, runtime, `pairing reply failed for ${senderId}: ${String(err)}`); } } } else { logVerbose( core, runtime, `Blocked unauthorized Google Chat sender ${senderId} (dmPolicy=${dmPolicy})`, ); } return; } } } if ( isGroup && core.channel.commands.isControlCommandMessage(rawBody, config) && commandAuthorized !== true ) { logVerbose(core, runtime, `googlechat: drop control command from ${senderId}`); return; } const route = core.channel.routing.resolveAgentRoute({ cfg: config, channel: "googlechat", accountId: account.accountId, peer: { kind: isGroup ? "group" : "dm", id: spaceId, }, }); let mediaPath: string | undefined; let mediaType: string | undefined; if (attachments.length > 0) { const first = attachments[0]; const attachmentData = await downloadAttachment(first, account, mediaMaxMb, core); if (attachmentData) { mediaPath = attachmentData.path; mediaType = attachmentData.contentType; } } const fromLabel = isGroup ? space.displayName || `space:${spaceId}` : senderName || `user:${senderId}`; const storePath = core.channel.session.resolveStorePath(config.session?.store, { agentId: route.agentId, }); const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config); const previousTimestamp = core.channel.session.readSessionUpdatedAt({ storePath, sessionKey: route.sessionKey, }); const body = core.channel.reply.formatAgentEnvelope({ channel: "Google Chat", from: fromLabel, timestamp: event.eventTime ? Date.parse(event.eventTime) : undefined, previousTimestamp, envelope: envelopeOptions, body: rawBody, }); const groupSystemPrompt = groupConfigResolved.entry?.systemPrompt?.trim() || undefined; const ctxPayload = core.channel.reply.finalizeInboundContext({ Body: body, RawBody: rawBody, CommandBody: rawBody, From: `googlechat:${senderId}`, To: `googlechat:${spaceId}`, SessionKey: route.sessionKey, AccountId: route.accountId, ChatType: isGroup ? "channel" : "direct", ConversationLabel: fromLabel, SenderName: senderName || undefined, SenderId: senderId, SenderUsername: senderEmail, WasMentioned: isGroup ? effectiveWasMentioned : undefined, CommandAuthorized: commandAuthorized, Provider: "googlechat", Surface: "googlechat", MessageSid: message.name, MessageSidFull: message.name, ReplyToId: message.thread?.name, ReplyToIdFull: message.thread?.name, MediaPath: mediaPath, MediaType: mediaType, MediaUrl: mediaPath, GroupSpace: isGroup ? space.displayName ?? undefined : undefined, GroupSystemPrompt: isGroup ? groupSystemPrompt : undefined, OriginatingChannel: "googlechat", OriginatingTo: `googlechat:${spaceId}`, }); void core.channel.session .recordSessionMetaFromInbound({ storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey, ctx: ctxPayload, }) .catch((err) => { runtime.error?.(`googlechat: failed updating session meta: ${String(err)}`); }); // Typing indicator setup // Note: Reaction mode requires user OAuth, not available with service account auth. // If reaction is configured, we fall back to message mode with a warning. let typingIndicator = account.config.typingIndicator ?? "message"; if (typingIndicator === "reaction") { runtime.error?.( `[${account.accountId}] typingIndicator="reaction" requires user OAuth (not supported with service account). Falling back to "message" mode.`, ); typingIndicator = "message"; } let typingMessageName: string | undefined; // Start typing indicator (message mode only, reaction mode not supported with app auth) if (typingIndicator === "message") { try { const botName = resolveBotDisplayName({ accountName: account.config.name, agentId: route.agentId, config, }); const result = await sendGoogleChatMessage({ account, space: spaceId, text: `_${botName} is typing..._`, thread: message.thread?.name, }); typingMessageName = result?.messageName; } catch (err) { runtime.error?.(`Failed sending typing message: ${String(err)}`); } } await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ ctx: ctxPayload, cfg: config, dispatcherOptions: { deliver: async (payload) => { await deliverGoogleChatReply({ payload, account, spaceId, runtime, core, config, statusSink, typingMessageName, }); // Only use typing message for first delivery typingMessageName = undefined; }, onError: (err, info) => { runtime.error?.( `[${account.accountId}] Google Chat ${info.kind} reply failed: ${String(err)}`, ); }, }, }); } async function downloadAttachment( attachment: GoogleChatAttachment, account: ResolvedGoogleChatAccount, mediaMaxMb: number, core: GoogleChatCoreRuntime, ): Promise<{ path: string; contentType?: string } | null> { const resourceName = attachment.attachmentDataRef?.resourceName; if (!resourceName) return null; const maxBytes = Math.max(1, mediaMaxMb) * 1024 * 1024; const downloaded = await downloadGoogleChatMedia({ account, resourceName, maxBytes }); const saved = await core.channel.media.saveMediaBuffer( downloaded.buffer, downloaded.contentType ?? attachment.contentType, "inbound", maxBytes, attachment.contentName, ); return { path: saved.path, contentType: saved.contentType }; } async function deliverGoogleChatReply(params: { payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string; replyToId?: string }; account: ResolvedGoogleChatAccount; spaceId: string; runtime: GoogleChatRuntimeEnv; core: GoogleChatCoreRuntime; config: ClawdbotConfig; statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; typingMessageName?: string; }): Promise { const { payload, account, spaceId, runtime, core, config, statusSink, typingMessageName } = params; const mediaList = payload.mediaUrls?.length ? payload.mediaUrls : payload.mediaUrl ? [payload.mediaUrl] : []; 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; for (const mediaUrl of mediaList) { const caption = first && !suppressCaption ? payload.text : undefined; first = false; try { const loaded = await core.channel.media.fetchRemoteMedia(mediaUrl, { maxBytes: (account.config.mediaMaxMb ?? 20) * 1024 * 1024, }); const upload = await uploadAttachmentForReply({ account, spaceId, buffer: loaded.buffer, contentType: loaded.contentType, filename: loaded.filename ?? "attachment", }); if (!upload.attachmentUploadToken) { throw new Error("missing attachment upload token"); } await sendGoogleChatMessage({ account, space: spaceId, text: caption, thread: payload.replyToId, attachments: [ { attachmentUploadToken: upload.attachmentUploadToken, contentName: loaded.filename }, ], }); statusSink?.({ lastOutboundAt: Date.now() }); } catch (err) { runtime.error?.(`Google Chat attachment send failed: ${String(err)}`); } } return; } if (payload.text) { const chunkLimit = account.config.textChunkLimit ?? 4000; const chunkMode = core.channel.text.resolveChunkMode( config, "googlechat", account.accountId, ); const chunks = core.channel.text.chunkMarkdownTextWithMode( payload.text, chunkLimit, chunkMode, ); for (let i = 0; i < chunks.length; i++) { const chunk = chunks[i]; try { // Edit typing message with first chunk if available if (i === 0 && typingMessageName) { await updateGoogleChatMessage({ account, messageName: typingMessageName, text: chunk, }); } else { await sendGoogleChatMessage({ account, space: spaceId, text: chunk, thread: payload.replyToId, }); } statusSink?.({ lastOutboundAt: Date.now() }); } catch (err) { runtime.error?.(`Google Chat message send failed: ${String(err)}`); } } } } async function uploadAttachmentForReply(params: { account: ResolvedGoogleChatAccount; spaceId: string; buffer: Buffer; contentType?: string; filename: string; }) { const { account, spaceId, buffer, contentType, filename } = params; const { uploadGoogleChatAttachment } = await import("./api.js"); return await uploadGoogleChatAttachment({ account, space: spaceId, filename, buffer, contentType, }); } export function monitorGoogleChatProvider(options: GoogleChatMonitorOptions): () => void { const core = getGoogleChatRuntime(); const webhookPath = resolveWebhookPath(options.webhookPath, options.webhookUrl); if (!webhookPath) { options.runtime.error?.(`[${options.account.accountId}] invalid webhook path`); return () => {}; } const audienceType = normalizeAudienceType(options.account.config.audienceType); const audience = options.account.config.audience?.trim(); const mediaMaxMb = options.account.config.mediaMaxMb ?? 20; const unregister = registerGoogleChatWebhookTarget({ account: options.account, config: options.config, runtime: options.runtime, core, path: webhookPath, audienceType, audience, statusSink: options.statusSink, mediaMaxMb, }); return unregister; } export async function startGoogleChatMonitor(params: GoogleChatMonitorOptions): Promise<() => void> { return monitorGoogleChatProvider(params); } export function resolveGoogleChatWebhookPath(params: { account: ResolvedGoogleChatAccount; }): string { return resolveWebhookPath( params.account.config.webhookPath, params.account.config.webhookUrl, ) ?? "/googlechat"; } export function computeGoogleChatMediaMaxMb(params: { account: ResolvedGoogleChatAccount }) { return params.account.config.mediaMaxMb ?? 20; }