Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
544
extensions/googlechat/src/monitor.ts
Normal file
544
extensions/googlechat/src/monitor.ts
Normal file
@@ -0,0 +1,544 @@
|
||||
// Googlechat plugin module implements monitor behavior.
|
||||
import {
|
||||
recordChannelBotPairLoopAndCheckSuppression,
|
||||
type ChannelBotLoopProtectionFacts,
|
||||
} from "openclaw/plugin-sdk/channel-inbound";
|
||||
import { mergePairLoopGuardConfig } from "openclaw/plugin-sdk/pair-loop-guard-runtime";
|
||||
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
import {
|
||||
resolveInboundRouteEnvelopeBuilderWithRuntime,
|
||||
resolveWebhookPath,
|
||||
} from "../runtime-api.js";
|
||||
import type { ResolvedGoogleChatAccount } from "./accounts.js";
|
||||
import { downloadGoogleChatMedia, sendGoogleChatMessage } from "./api.js";
|
||||
import { maybeHandleGoogleChatApprovalCardClick } from "./approval-card-click.js";
|
||||
import type { GoogleChatAudienceType } from "./auth.js";
|
||||
import { applyGoogleChatInboundAccessPolicy } from "./monitor-access.js";
|
||||
import { resolveGoogleChatDurableReplyOptions } from "./monitor-durable.js";
|
||||
import { deliverGoogleChatReply } from "./monitor-reply-delivery.js";
|
||||
import {
|
||||
registerGoogleChatWebhookTarget,
|
||||
setGoogleChatWebhookEventProcessor,
|
||||
} from "./monitor-routing.js";
|
||||
import type {
|
||||
GoogleChatCoreRuntime,
|
||||
GoogleChatMonitorOptions,
|
||||
GoogleChatRuntimeEnv,
|
||||
WebhookTarget,
|
||||
} from "./monitor-types.js";
|
||||
import { warnAppPrincipalMisconfiguration } from "./monitor-webhook.js";
|
||||
import { getGoogleChatRuntime } from "./runtime.js";
|
||||
import type { GoogleChatAttachment, GoogleChatEvent, GoogleChatSpace } from "./types.js";
|
||||
|
||||
setGoogleChatWebhookEventProcessor(processGoogleChatEvent);
|
||||
|
||||
function logVerbose(core: GoogleChatCoreRuntime, runtime: GoogleChatRuntimeEnv, message: string) {
|
||||
if (core.logging.shouldLogVerbose()) {
|
||||
runtime.log?.(`[googlechat] ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAudienceType(value?: string | null): GoogleChatAudienceType | undefined {
|
||||
const normalized = normalizeOptionalLowercaseString(value);
|
||||
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;
|
||||
}
|
||||
|
||||
function resolveGoogleChatTimestampMs(eventTime?: string): number | undefined {
|
||||
if (!eventTime) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = Date.parse(eventTime);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
function isGoogleChatGroupSpace(space: GoogleChatSpace): boolean {
|
||||
const spaceType = (space.spaceType ?? "").toUpperCase();
|
||||
// Google Chat deprecates `type` in favor of `spaceType`; known modern
|
||||
// values must win if the fields disagree. Fall back to the bot-DM flag and
|
||||
// legacy type so incomplete payloads retain their existing direct routing.
|
||||
if (spaceType === "DIRECT_MESSAGE") {
|
||||
return false;
|
||||
}
|
||||
if (spaceType === "SPACE" || spaceType === "GROUP_CHAT") {
|
||||
return true;
|
||||
}
|
||||
return space.singleUserBotDm !== true && (space.type ?? "").toUpperCase() !== "DM";
|
||||
}
|
||||
|
||||
function resolveGoogleChatBotLoopProtection(params: {
|
||||
allowBots: boolean;
|
||||
isBotSender: boolean;
|
||||
senderId: string;
|
||||
appUserId: string;
|
||||
accountId: string;
|
||||
conversationId: string;
|
||||
config?: ChannelBotLoopProtectionFacts["config"];
|
||||
defaultsConfig?: ChannelBotLoopProtectionFacts["defaultsConfig"];
|
||||
eventTime?: string;
|
||||
}): ChannelBotLoopProtectionFacts | undefined {
|
||||
if (
|
||||
!params.allowBots ||
|
||||
!params.isBotSender ||
|
||||
!params.senderId ||
|
||||
params.senderId === params.appUserId
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
scopeId: params.accountId,
|
||||
conversationId: params.conversationId,
|
||||
senderId: params.senderId,
|
||||
receiverId: params.appUserId,
|
||||
config: params.config,
|
||||
defaultsConfig: params.defaultsConfig,
|
||||
defaultEnabled: true,
|
||||
nowMs: resolveGoogleChatTimestampMs(params.eventTime),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveGoogleChatBotLoopProtectionConfig(params: {
|
||||
accountConfig?: ChannelBotLoopProtectionFacts["config"];
|
||||
groupConfig?: ChannelBotLoopProtectionFacts["config"];
|
||||
}): ChannelBotLoopProtectionFacts["config"] {
|
||||
return mergePairLoopGuardConfig(params.accountConfig, params.groupConfig);
|
||||
}
|
||||
|
||||
function shouldSuppressGoogleChatBotLoop(params: {
|
||||
botLoopProtection?: ChannelBotLoopProtectionFacts;
|
||||
core: GoogleChatCoreRuntime;
|
||||
runtime: GoogleChatRuntimeEnv;
|
||||
}): boolean {
|
||||
if (!params.botLoopProtection) {
|
||||
return false;
|
||||
}
|
||||
const botLoopResult = recordChannelBotPairLoopAndCheckSuppression(params.botLoopProtection);
|
||||
if (!botLoopResult.suppressed) {
|
||||
return false;
|
||||
}
|
||||
logVerbose(
|
||||
params.core,
|
||||
params.runtime,
|
||||
`skip bot-to-bot loop in ${params.botLoopProtection.conversationId}`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function processGoogleChatEvent(event: GoogleChatEvent, target: WebhookTarget) {
|
||||
const eventType = event.type ?? (event as { eventType?: string }).eventType;
|
||||
if (eventType === "CARD_CLICKED") {
|
||||
await maybeHandleGoogleChatApprovalCardClick({ event, target });
|
||||
return;
|
||||
}
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve bot display name with fallback chain:
|
||||
* 1. Account config name
|
||||
* 2. Agent name from config
|
||||
* 3. "OpenClaw" as generic fallback
|
||||
*/
|
||||
function resolveBotDisplayName(params: {
|
||||
accountName?: string;
|
||||
agentId: string;
|
||||
config: OpenClawConfig;
|
||||
}): 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 "OpenClaw";
|
||||
}
|
||||
|
||||
async function processMessageWithPipeline(params: {
|
||||
event: GoogleChatEvent;
|
||||
account: ResolvedGoogleChatAccount;
|
||||
config: OpenClawConfig;
|
||||
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 isGroup = isGoogleChatGroupSpace(space);
|
||||
const sender = message.sender ?? event.user;
|
||||
const senderId = sender?.name ?? "";
|
||||
const senderName = sender?.displayName ?? "";
|
||||
const senderEmail = sender?.email ?? undefined;
|
||||
const isBotSender = sender?.type?.toUpperCase() === "BOT";
|
||||
const appUserId = account.config.botUser?.trim() || "users/app";
|
||||
|
||||
const allowBots = account.config.allowBots === true;
|
||||
if (!allowBots) {
|
||||
if (isBotSender) {
|
||||
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 access = await applyGoogleChatInboundAccessPolicy({
|
||||
account,
|
||||
config,
|
||||
core,
|
||||
space,
|
||||
message,
|
||||
isGroup,
|
||||
senderId,
|
||||
senderName,
|
||||
senderEmail,
|
||||
rawBody,
|
||||
statusSink,
|
||||
logVerbose: (messageLocal) => logVerbose(core, runtime, messageLocal),
|
||||
});
|
||||
if (!access.ok) {
|
||||
return;
|
||||
}
|
||||
const { commandAuthorized, effectiveWasMentioned, groupBotLoopProtection, groupSystemPrompt } =
|
||||
access;
|
||||
const botLoopProtection = resolveGoogleChatBotLoopProtection({
|
||||
allowBots,
|
||||
isBotSender,
|
||||
senderId,
|
||||
appUserId,
|
||||
accountId: account.accountId,
|
||||
conversationId: spaceId,
|
||||
config: resolveGoogleChatBotLoopProtectionConfig({
|
||||
accountConfig: account.config.botLoopProtection,
|
||||
groupConfig: groupBotLoopProtection,
|
||||
}),
|
||||
defaultsConfig: config.channels?.defaults?.botLoopProtection,
|
||||
eventTime: event.eventTime,
|
||||
});
|
||||
if (shouldSuppressGoogleChatBotLoop({ botLoopProtection, core, runtime })) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({
|
||||
cfg: config,
|
||||
channel: "googlechat",
|
||||
accountId: account.accountId,
|
||||
peer: {
|
||||
kind: isGroup ? ("group" as const) : ("direct" as const),
|
||||
id: spaceId,
|
||||
},
|
||||
runtime: core.channel,
|
||||
sessionStore: config.session?.store,
|
||||
});
|
||||
|
||||
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 timestampMs = resolveGoogleChatTimestampMs(event.eventTime);
|
||||
const { storePath, body } = buildEnvelope({
|
||||
channel: "Google Chat",
|
||||
from: fromLabel,
|
||||
timestamp: timestampMs,
|
||||
body: rawBody,
|
||||
});
|
||||
|
||||
const replyThreadName = isGroup ? message.thread?.name : undefined;
|
||||
const ctxPayload = core.channel.inbound.buildContext({
|
||||
channel: "googlechat",
|
||||
accountId: route.accountId,
|
||||
messageId: message.name,
|
||||
messageIdFull: message.name,
|
||||
timestamp: timestampMs,
|
||||
from: `googlechat:${senderId}`,
|
||||
sender: {
|
||||
id: senderId,
|
||||
name: senderName || undefined,
|
||||
username: senderEmail,
|
||||
isBot: isBotSender || undefined,
|
||||
},
|
||||
conversation: {
|
||||
kind: isGroup ? "channel" : "direct",
|
||||
id: spaceId,
|
||||
label: fromLabel,
|
||||
},
|
||||
route: {
|
||||
agentId: route.agentId,
|
||||
accountId: route.accountId,
|
||||
routeSessionKey: route.sessionKey,
|
||||
},
|
||||
reply: {
|
||||
to: `googlechat:${spaceId}`,
|
||||
originatingTo: `googlechat:${spaceId}`,
|
||||
replyToId: replyThreadName,
|
||||
replyToIdFull: replyThreadName,
|
||||
},
|
||||
message: {
|
||||
body,
|
||||
bodyForAgent: rawBody,
|
||||
rawBody,
|
||||
commandBody: rawBody,
|
||||
},
|
||||
media:
|
||||
mediaPath || mediaType
|
||||
? [
|
||||
{
|
||||
path: mediaPath,
|
||||
url: mediaPath,
|
||||
contentType: mediaType,
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
supplemental: {
|
||||
groupSystemPrompt: isGroup ? groupSystemPrompt : undefined,
|
||||
},
|
||||
extra: {
|
||||
ChatType: isGroup ? "channel" : "direct",
|
||||
WasMentioned: isGroup ? effectiveWasMentioned : undefined,
|
||||
CommandAuthorized: commandAuthorized,
|
||||
GroupSubject: undefined,
|
||||
GroupSpace: isGroup ? (space.displayName ?? undefined) : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
// 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: replyThreadName,
|
||||
});
|
||||
typingMessageName = result?.messageName;
|
||||
} catch (err) {
|
||||
runtime.error?.(`Failed sending typing message: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
await core.channel.inbound.run({
|
||||
channel: "googlechat",
|
||||
accountId: route.accountId,
|
||||
raw: message,
|
||||
adapter: {
|
||||
ingest: () => ({
|
||||
id: message.name ?? spaceId,
|
||||
timestamp: timestampMs,
|
||||
rawText: rawBody,
|
||||
textForAgent: rawBody,
|
||||
textForCommands: rawBody,
|
||||
raw: message,
|
||||
}),
|
||||
resolveTurn: () => ({
|
||||
cfg: config,
|
||||
channel: "googlechat",
|
||||
accountId: route.accountId,
|
||||
agentId: route.agentId,
|
||||
routeSessionKey: route.sessionKey,
|
||||
storePath,
|
||||
ctxPayload,
|
||||
recordInboundSession: core.channel.session.recordInboundSession,
|
||||
dispatchReplyWithBufferedBlockDispatcher:
|
||||
core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
|
||||
delivery: {
|
||||
durable: (payload, info) =>
|
||||
resolveGoogleChatDurableReplyOptions({
|
||||
payload,
|
||||
infoKind: info.kind,
|
||||
spaceId,
|
||||
typingMessageName,
|
||||
}),
|
||||
deliver: async (payload) => {
|
||||
await deliverGoogleChatReply({
|
||||
payload,
|
||||
account,
|
||||
spaceId,
|
||||
runtime,
|
||||
core,
|
||||
config,
|
||||
statusSink,
|
||||
typingMessageName,
|
||||
});
|
||||
// Only use typing message for first delivery
|
||||
typingMessageName = undefined;
|
||||
},
|
||||
onDelivered: () => {
|
||||
statusSink?.({ lastOutboundAt: Date.now() });
|
||||
},
|
||||
onError: (err, info) => {
|
||||
runtime.error?.(
|
||||
`[${account.accountId}] Google Chat ${info.kind} reply failed: ${String(err)}`,
|
||||
);
|
||||
},
|
||||
},
|
||||
replyPipeline: {},
|
||||
record: {
|
||||
onRecordError: (err) => {
|
||||
runtime.error?.(`googlechat: failed updating session meta: ${String(err)}`);
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const testing = {
|
||||
processMessageWithPipeline,
|
||||
resolveGoogleChatBotLoopProtection,
|
||||
resolveGoogleChatBotLoopProtectionConfig,
|
||||
shouldSuppressGoogleChatBotLoop,
|
||||
};
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
function monitorGoogleChatProvider(options: GoogleChatMonitorOptions): () => void {
|
||||
const core = getGoogleChatRuntime();
|
||||
const webhookPath = resolveWebhookPath({
|
||||
webhookPath: options.webhookPath,
|
||||
webhookUrl: options.webhookUrl,
|
||||
defaultPath: "/googlechat",
|
||||
});
|
||||
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;
|
||||
|
||||
warnAppPrincipalMisconfiguration({
|
||||
accountId: options.account.accountId,
|
||||
audienceType,
|
||||
appPrincipal: options.account.config.appPrincipal,
|
||||
log: options.runtime.log,
|
||||
});
|
||||
|
||||
const unregisterTarget = registerGoogleChatWebhookTarget({
|
||||
account: options.account,
|
||||
config: options.config,
|
||||
runtime: options.runtime,
|
||||
core,
|
||||
path: webhookPath,
|
||||
audienceType,
|
||||
audience,
|
||||
statusSink: options.statusSink,
|
||||
mediaMaxMb,
|
||||
});
|
||||
|
||||
return () => {
|
||||
unregisterTarget();
|
||||
};
|
||||
}
|
||||
|
||||
export async function startGoogleChatMonitor(
|
||||
params: GoogleChatMonitorOptions,
|
||||
): Promise<() => void> {
|
||||
return monitorGoogleChatProvider(params);
|
||||
}
|
||||
|
||||
export function resolveGoogleChatWebhookPath(params: {
|
||||
account: ResolvedGoogleChatAccount;
|
||||
}): string {
|
||||
return (
|
||||
resolveWebhookPath({
|
||||
webhookPath: params.account.config.webhookPath,
|
||||
webhookUrl: params.account.config.webhookUrl,
|
||||
defaultPath: "/googlechat",
|
||||
}) ?? "/googlechat"
|
||||
);
|
||||
}
|
||||
export { testing as __testing };
|
||||
Reference in New Issue
Block a user