feat: add beta googlechat channel
This commit is contained in:
committed by
Peter Steinberger
parent
60661441b1
commit
b76cd6695d
751
extensions/googlechat/src/monitor.ts
Normal file
751
extensions/googlechat/src/monitor.ts
Normal file
@@ -0,0 +1,751 @@
|
||||
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,
|
||||
sendGoogleChatMessage,
|
||||
} from "./api.js";
|
||||
import { verifyGoogleChatRequest, type GoogleChatAudienceType } from "./auth.js";
|
||||
import { getGoogleChatRuntime } from "./runtime.js";
|
||||
import type { GoogleChatAnnotation, GoogleChatAttachment, GoogleChatEvent } 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<typeof getGoogleChatRuntime>;
|
||||
|
||||
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<string, WebhookTarget[]>();
|
||||
|
||||
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<boolean> {
|
||||
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;
|
||||
}
|
||||
|
||||
const raw = body.value;
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
res.statusCode = 400;
|
||||
res.end("invalid payload");
|
||||
return true;
|
||||
}
|
||||
|
||||
const eventType = (raw as { type?: string; eventType?: string }).type ??
|
||||
(raw as { type?: string; eventType?: string }).eventType;
|
||||
if (typeof eventType !== "string") {
|
||||
res.statusCode = 400;
|
||||
res.end("invalid payload");
|
||||
return true;
|
||||
}
|
||||
|
||||
const rawObj = raw as Record<string, unknown>;
|
||||
if (!rawObj.space || typeof rawObj.space !== "object" || Array.isArray(rawObj.space)) {
|
||||
res.statusCode = 400;
|
||||
res.end("invalid payload");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (eventType === "MESSAGE") {
|
||||
if (!rawObj.message || typeof rawObj.message !== "object" || Array.isArray(rawObj.message)) {
|
||||
res.statusCode = 400;
|
||||
res.end("invalid payload");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const event = raw as GoogleChatEvent;
|
||||
|
||||
let selected: WebhookTarget | undefined;
|
||||
for (const target of targets) {
|
||||
const audienceType = target.audienceType;
|
||||
const audience = target.audience;
|
||||
const verification = await verifyGoogleChatRequest({
|
||||
bearer,
|
||||
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();
|
||||
}
|
||||
|
||||
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 (normalized.replace(/^users\//i, "") === normalizedSenderId) return true;
|
||||
if (normalized.replace(/^(googlechat|gchat):/i, "") === normalizedSenderId) return true;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
function resolveGroupConfig(params: {
|
||||
groupId: string;
|
||||
groupName?: string | null;
|
||||
groups?: Record<string, { requireMention?: boolean; allow?: boolean; enabled?: boolean; users?: Array<string | number>; 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 };
|
||||
}
|
||||
|
||||
async function processMessageWithPipeline(params: {
|
||||
event: GoogleChatEvent;
|
||||
account: ResolvedGoogleChatAccount;
|
||||
config: ClawdbotConfig;
|
||||
runtime: GoogleChatRuntimeEnv;
|
||||
core: GoogleChatCoreRuntime;
|
||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||
mediaMaxMb: number;
|
||||
}): Promise<void> {
|
||||
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 ? "<media:attachment>" : "");
|
||||
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)}`);
|
||||
});
|
||||
|
||||
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
||||
ctx: ctxPayload,
|
||||
cfg: config,
|
||||
dispatcherOptions: {
|
||||
deliver: async (payload) => {
|
||||
await deliverGoogleChatReply({
|
||||
payload,
|
||||
account,
|
||||
spaceId,
|
||||
runtime,
|
||||
core,
|
||||
statusSink,
|
||||
});
|
||||
},
|
||||
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 });
|
||||
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;
|
||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||
}): Promise<void> {
|
||||
const { payload, account, spaceId, runtime, core, statusSink } = params;
|
||||
const mediaList = payload.mediaUrls?.length
|
||||
? payload.mediaUrls
|
||||
: payload.mediaUrl
|
||||
? [payload.mediaUrl]
|
||||
: [];
|
||||
|
||||
if (mediaList.length > 0) {
|
||||
let first = true;
|
||||
for (const mediaUrl of mediaList) {
|
||||
const caption = first ? 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 chunks = core.channel.text.chunkMarkdownText(payload.text, chunkLimit);
|
||||
for (const chunk of chunks) {
|
||||
try {
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user