2026-01-23 16:45:37 -06:00
import type { IncomingMessage , ServerResponse } from "node:http" ;
2026-01-30 03:15:10 +01:00
import type { OpenClawConfig } from "openclaw/plugin-sdk" ;
2026-02-13 19:14:36 +01:00
import {
2026-02-22 12:44:02 +01:00
GROUP_POLICY_BLOCKED_LABEL ,
2026-02-13 19:14:36 +01:00
createReplyPrefixOptions ,
readJsonBodyWithLimit ,
2026-02-16 14:51:55 +00:00
registerWebhookTarget ,
rejectNonPostWebhookRequest ,
2026-02-24 01:32:23 +00:00
isDangerousNameMatchingEnabled ,
2026-02-22 12:35:02 +01:00
resolveAllowlistProviderRuntimeGroupPolicy ,
2026-02-22 12:44:02 +01:00
resolveDefaultGroupPolicy ,
2026-02-21 11:52:21 +01:00
resolveSingleWebhookTargetAsync ,
2026-02-15 19:35:48 +00:00
resolveWebhookPath ,
2026-02-16 14:51:55 +00:00
resolveWebhookTargets ,
2026-02-22 12:35:02 +01:00
warnMissingProviderGroupPolicyFallbackOnce ,
2026-02-13 19:14:36 +01:00
requestBodyErrorToText ,
resolveMentionGatingWithBypass ,
} from "openclaw/plugin-sdk" ;
2026-01-31 21:13:13 +09:00
import { type ResolvedGoogleChatAccount } from "./accounts.js" ;
2026-01-23 16:45:37 -06:00
import {
downloadGoogleChatMedia ,
2026-01-24 23:23:24 +00:00
deleteGoogleChatMessage ,
2026-01-23 16:45:37 -06:00
sendGoogleChatMessage ,
2026-01-24 20:16:14 +00:00
updateGoogleChatMessage ,
2026-01-23 16:45:37 -06:00
} from "./api.js" ;
import { verifyGoogleChatRequest , type GoogleChatAudienceType } from "./auth.js" ;
import { getGoogleChatRuntime } from "./runtime.js" ;
2026-02-18 01:34:35 +00:00
import type {
GoogleChatAnnotation ,
GoogleChatAttachment ,
GoogleChatEvent ,
GoogleChatSpace ,
GoogleChatMessage ,
GoogleChatUser ,
} from "./types.js" ;
2026-01-23 16:45:37 -06:00
export type GoogleChatRuntimeEnv = {
log ? : ( message : string ) = > void ;
error ? : ( message : string ) = > void ;
} ;
export type GoogleChatMonitorOptions = {
account : ResolvedGoogleChatAccount ;
2026-01-30 03:15:10 +01:00
config : OpenClawConfig ;
2026-01-23 16:45:37 -06:00
runtime : GoogleChatRuntimeEnv ;
abortSignal : AbortSignal ;
webhookPath? : string ;
webhookUrl? : string ;
statusSink ? : ( patch : { lastInboundAt? : number ; lastOutboundAt? : number } ) = > void ;
} ;
type GoogleChatCoreRuntime = ReturnType < typeof getGoogleChatRuntime > ;
type WebhookTarget = {
account : ResolvedGoogleChatAccount ;
2026-01-30 03:15:10 +01:00
config : OpenClawConfig ;
2026-01-23 16:45:37 -06:00
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 } ` ) ;
}
}
2026-02-14 15:31:26 +01:00
const warnedDeprecatedUsersEmailAllowFrom = new Set < string > ( ) ;
function warnDeprecatedUsersEmailEntries (
core : GoogleChatCoreRuntime ,
runtime : GoogleChatRuntimeEnv ,
entries : string [ ] ,
) {
const deprecated = entries . map ( ( v ) = > String ( v ) . trim ( ) ) . filter ( ( v ) = > /^users\/.+@.+/i . test ( v ) ) ;
if ( deprecated . length === 0 ) {
return ;
}
const key = deprecated
. map ( ( v ) = > v . toLowerCase ( ) )
. sort ( )
. join ( "," ) ;
if ( warnedDeprecatedUsersEmailAllowFrom . has ( key ) ) {
return ;
}
warnedDeprecatedUsersEmailAllowFrom . add ( key ) ;
logVerbose (
core ,
runtime ,
` Deprecated allowFrom entry detected: "users/<email>" is no longer treated as an email allowlist. Use raw email (alice@example.com) or immutable user id (users/<id>). entries= ${ deprecated . join ( ", " ) } ` ,
) ;
}
2026-01-23 16:45:37 -06:00
export function registerGoogleChatWebhookTarget ( target : WebhookTarget ) : ( ) = > void {
2026-02-16 14:51:55 +00:00
return registerWebhookTarget ( webhookTargets , target ) . unregister ;
2026-01-23 16:45:37 -06:00
}
function normalizeAudienceType ( value? : string | null ) : GoogleChatAudienceType | undefined {
const normalized = value ? . trim ( ) . toLowerCase ( ) ;
if ( normalized === "app-url" || normalized === "app_url" || normalized === "app" ) {
return "app-url" ;
}
2026-01-31 21:13:13 +09:00
if (
normalized === "project-number" ||
normalized === "project_number" ||
normalized === "project"
) {
2026-01-23 16:45:37 -06:00
return "project-number" ;
}
return undefined ;
}
export async function handleGoogleChatWebhookRequest (
req : IncomingMessage ,
res : ServerResponse ,
) : Promise < boolean > {
2026-02-16 14:51:55 +00:00
const resolved = resolveWebhookTargets ( req , webhookTargets ) ;
if ( ! resolved ) {
2026-01-31 22:13:48 +09:00
return false ;
}
2026-02-16 14:51:55 +00:00
const { targets } = resolved ;
2026-01-23 16:45:37 -06:00
2026-02-16 14:51:55 +00:00
if ( rejectNonPostWebhookRequest ( req , res ) ) {
2026-01-23 16:45:37 -06:00
return true ;
}
const authHeader = String ( req . headers . authorization ? ? "" ) ;
const bearer = authHeader . toLowerCase ( ) . startsWith ( "bearer " )
? authHeader . slice ( "bearer " . length )
: "" ;
2026-02-13 19:14:36 +01:00
const body = await readJsonBodyWithLimit ( req , {
maxBytes : 1024 * 1024 ,
timeoutMs : 30_000 ,
emptyObjectOnEmpty : false ,
} ) ;
2026-01-23 16:45:37 -06:00
if ( ! body . ok ) {
2026-02-13 19:14:36 +01:00
res . statusCode =
body . code === "PAYLOAD_TOO_LARGE" ? 413 : body.code === "REQUEST_BODY_TIMEOUT" ? 408 : 400 ;
res . end (
body . code === "REQUEST_BODY_TIMEOUT"
? requestBodyErrorToText ( "REQUEST_BODY_TIMEOUT" )
: body . error ,
) ;
2026-01-23 16:45:37 -06:00
return true ;
}
2026-01-24 01:12:48 +00:00
let raw = body . value ;
2026-01-23 16:45:37 -06:00
if ( ! raw || typeof raw !== "object" || Array . isArray ( raw ) ) {
res . statusCode = 400 ;
res . end ( "invalid payload" ) ;
return true ;
}
2026-01-24 01:12:48 +00:00
// 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 ,
} ;
2026-01-31 21:13:13 +09:00
2026-01-24 01:12:48 +00:00
// 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 ;
2026-01-23 16:45:37 -06:00
if ( typeof eventType !== "string" ) {
res . statusCode = 400 ;
res . end ( "invalid payload" ) ;
return true ;
}
2026-01-24 01:12:48 +00:00
if ( ! event . space || typeof event . space !== "object" || Array . isArray ( event . space ) ) {
2026-01-23 16:45:37 -06:00
res . statusCode = 400 ;
res . end ( "invalid payload" ) ;
return true ;
}
if ( eventType === "MESSAGE" ) {
2026-01-24 01:12:48 +00:00
if ( ! event . message || typeof event . message !== "object" || Array . isArray ( event . message ) ) {
2026-01-23 16:45:37 -06:00
res . statusCode = 400 ;
res . end ( "invalid payload" ) ;
return true ;
}
}
2026-01-24 01:12:48 +00:00
// 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 ;
2026-01-23 16:45:37 -06:00
2026-02-21 11:52:21 +01:00
const matchedTarget = await resolveSingleWebhookTargetAsync ( targets , async ( target ) = > {
2026-01-23 16:45:37 -06:00
const audienceType = target . audienceType ;
const audience = target . audience ;
const verification = await verifyGoogleChatRequest ( {
2026-01-24 01:12:48 +00:00
bearer : effectiveBearer ,
2026-01-23 16:45:37 -06:00
audienceType ,
audience ,
} ) ;
2026-02-21 11:52:21 +01:00
return verification . ok ;
} ) ;
2026-01-23 16:45:37 -06:00
2026-02-21 11:52:21 +01:00
if ( matchedTarget . kind === "none" ) {
2026-01-23 16:45:37 -06:00
res . statusCode = 401 ;
res . end ( "unauthorized" ) ;
return true ;
}
2026-02-21 11:52:21 +01:00
if ( matchedTarget . kind === "ambiguous" ) {
2026-02-14 17:11:39 +01:00
res . statusCode = 401 ;
res . end ( "ambiguous webhook target" ) ;
return true ;
}
2026-02-21 11:52:21 +01:00
const selected = matchedTarget . target ;
2026-01-23 16:45:37 -06:00
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 ;
2026-01-31 22:13:48 +09:00
if ( eventType !== "MESSAGE" ) {
return ;
}
if ( ! event . message || ! event . space ) {
return ;
}
2026-01-23 16:45:37 -06:00
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 ( ) ? ? "" ;
2026-01-31 22:13:48 +09:00
if ( ! trimmed ) {
return "" ;
}
2026-01-23 16:45:37 -06:00
return trimmed . replace ( /^users\//i , "" ) . toLowerCase ( ) ;
}
2026-02-14 15:31:26 +01:00
function isEmailLike ( value : string ) : boolean {
// Keep this intentionally loose; allowlists are user-provided config.
return value . includes ( "@" ) ;
}
2026-01-24 23:23:24 +00:00
export function isSenderAllowed (
senderId : string ,
senderEmail : string | undefined ,
allowFrom : string [ ] ,
2026-02-24 01:01:51 +00:00
allowNameMatching = false ,
2026-01-24 23:23:24 +00:00
) {
2026-01-31 22:13:48 +09:00
if ( allowFrom . includes ( "*" ) ) {
return true ;
}
2026-01-23 16:45:37 -06:00
const normalizedSenderId = normalizeUserId ( senderId ) ;
const normalizedEmail = senderEmail ? . trim ( ) . toLowerCase ( ) ? ? "" ;
return allowFrom . some ( ( entry ) = > {
const normalized = String ( entry ) . trim ( ) . toLowerCase ( ) ;
2026-01-31 22:13:48 +09:00
if ( ! normalized ) {
return false ;
}
2026-02-14 15:31:26 +01:00
// Accept `googlechat:<id>` but treat `users/...` as an *ID* only (deprecated `users/<email>`).
const withoutPrefix = normalized . replace ( /^(googlechat|google-chat|gchat):/i , "" ) ;
if ( withoutPrefix . startsWith ( "users/" ) ) {
return normalizeUserId ( withoutPrefix ) === normalizedSenderId ;
2026-01-31 22:13:48 +09:00
}
2026-02-14 15:31:26 +01:00
2026-02-24 01:01:51 +00:00
// Raw email allowlist entries are a break-glass override.
if ( allowNameMatching && normalizedEmail && isEmailLike ( withoutPrefix ) ) {
2026-02-14 15:31:26 +01:00
return withoutPrefix === normalizedEmail ;
2026-01-24 23:23:24 +00:00
}
2026-02-14 15:31:26 +01:00
return withoutPrefix . replace ( /^users\//i , "" ) === normalizedSenderId ;
2026-01-23 16:45:37 -06:00
} ) ;
}
function resolveGroupConfig ( params : {
groupId : string ;
groupName? : string | null ;
2026-01-31 21:13:13 +09:00
groups? : Record <
string ,
{
requireMention? : boolean ;
allow? : boolean ;
enabled? : boolean ;
users? : Array < string | number > ;
systemPrompt? : string ;
}
> ;
2026-01-23 16:45:37 -06:00
} ) {
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 ;
2026-01-31 22:13:48 +09:00
if ( ! userName ) {
return false ;
}
if ( botTargets . has ( userName ) ) {
return true ;
}
2026-01-23 16:45:37 -06:00
return normalizeUserId ( userName ) === "app" ;
} ) ;
return { hasAnyMention , wasMentioned } ;
}
2026-01-24 20:16:14 +00:00
/ * *
* Resolve bot display name with fallback chain :
* 1 . Account config name
* 2 . Agent name from config
2026-01-30 03:15:10 +01:00
* 3 . "OpenClaw" as generic fallback
2026-01-24 20:16:14 +00:00
* /
function resolveBotDisplayName ( params : {
accountName? : string ;
agentId : string ;
2026-01-30 03:15:10 +01:00
config : OpenClawConfig ;
2026-01-24 20:16:14 +00:00
} ) : string {
const { accountName , agentId , config } = params ;
2026-01-31 22:13:48 +09:00
if ( accountName ? . trim ( ) ) {
return accountName . trim ( ) ;
}
2026-01-24 20:16:14 +00:00
const agent = config . agents ? . list ? . find ( ( a ) = > a . id === agentId ) ;
2026-01-31 22:13:48 +09:00
if ( agent ? . name ? . trim ( ) ) {
return agent . name . trim ( ) ;
}
2026-01-30 03:15:10 +01:00
return "OpenClaw" ;
2026-01-24 20:16:14 +00:00
}
2026-01-23 16:45:37 -06:00
async function processMessageWithPipeline ( params : {
event : GoogleChatEvent ;
account : ResolvedGoogleChatAccount ;
2026-01-30 03:15:10 +01:00
config : OpenClawConfig ;
2026-01-23 16:45:37 -06:00
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 ;
2026-01-31 22:13:48 +09:00
if ( ! space || ! message ) {
return ;
}
2026-01-23 16:45:37 -06:00
const spaceId = space . name ? ? "" ;
2026-01-31 22:13:48 +09:00
if ( ! spaceId ) {
return ;
}
2026-01-23 16:45:37 -06:00
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 ;
2026-02-24 01:32:23 +00:00
const allowNameMatching = isDangerousNameMatchingEnabled ( account . config ) ;
2026-01-23 16:45:37 -06:00
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>" : "" ) ;
2026-01-31 22:13:48 +09:00
if ( ! rawBody ) {
return ;
}
2026-01-23 16:45:37 -06:00
2026-02-22 12:44:02 +01:00
const defaultGroupPolicy = resolveDefaultGroupPolicy ( config ) ;
2026-02-22 12:35:02 +01:00
const { groupPolicy , providerMissingFallbackApplied } =
resolveAllowlistProviderRuntimeGroupPolicy ( {
providerConfigPresent : config.channels?.googlechat !== undefined ,
groupPolicy : account.config.groupPolicy ,
defaultGroupPolicy ,
} ) ;
warnMissingProviderGroupPolicyFallbackOnce ( {
providerMissingFallbackApplied ,
providerKey : "googlechat" ,
accountId : account.accountId ,
2026-02-22 12:44:02 +01:00
blockedLabel : GROUP_POLICY_BLOCKED_LABEL.space ,
2026-02-22 12:35:02 +01:00
log : ( message ) = > logVerbose ( core , runtime , message ) ,
2026-02-22 12:17:44 +01:00
} ) ;
2026-01-23 16:45:37 -06:00
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 ;
2026-01-31 21:13:13 +09:00
const groupAllowed = Boolean ( groupEntry ) || Boolean ( ( account . config . groups ? ? { } ) [ "*" ] ) ;
2026-01-23 16:45:37 -06:00
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 ) {
2026-02-14 15:31:26 +01:00
warnDeprecatedUsersEmailEntries (
core ,
runtime ,
groupUsers . map ( ( v ) = > String ( v ) ) ,
) ;
2026-01-31 21:13:13 +09:00
const ok = isSenderAllowed (
senderId ,
senderEmail ,
groupUsers . map ( ( v ) = > String ( v ) ) ,
2026-02-24 01:01:51 +00:00
allowNameMatching ,
2026-01-31 21:13:13 +09:00
) ;
2026-01-23 16:45:37 -06:00
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 =
2026-02-22 00:00:23 +01:00
! isGroup && dmPolicy !== "allowlist" && ( dmPolicy !== "open" || shouldComputeAuth )
2026-01-23 16:45:37 -06:00
? await core . channel . pairing . readAllowFromStore ( "googlechat" ) . catch ( ( ) = > [ ] )
: [ ] ;
const effectiveAllowFrom = [ . . . configAllowFrom , . . . storeAllowFrom ] ;
2026-02-14 15:31:26 +01:00
warnDeprecatedUsersEmailEntries ( core , runtime , effectiveAllowFrom ) ;
2026-01-23 16:45:37 -06:00
const commandAllowFrom = isGroup ? groupUsers . map ( ( v ) = > String ( v ) ) : effectiveAllowFrom ;
const useAccessGroups = config . commands ? . useAccessGroups !== false ;
2026-02-24 01:01:51 +00:00
const senderAllowedForCommands = isSenderAllowed (
senderId ,
senderEmail ,
commandAllowFrom ,
allowNameMatching ,
) ;
2026-01-23 16:45:37 -06:00
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 : {
2026-02-08 16:20:52 -08:00
kind : isGroup ? "group" : "direct" ,
2026-01-23 16:45:37 -06:00
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 ,
2026-02-10 00:35:56 -06:00
BodyForAgent : rawBody ,
2026-01-23 16:45:37 -06:00
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 ,
2026-01-31 21:13:13 +09:00
GroupSpace : isGroup ? ( space . displayName ? ? undefined ) : undefined ,
2026-01-23 16:45:37 -06:00
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 ) } ` ) ;
} ) ;
2026-01-24 20:16:14 +00:00
// 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 ) } ` ) ;
}
}
feat: per-channel responsePrefix override (#9001)
* feat: per-channel responsePrefix override
Add responsePrefix field to all channel config types and Zod schemas,
enabling per-channel and per-account outbound response prefix overrides.
Resolution cascade (most specific wins):
L1: channels.<ch>.accounts.<id>.responsePrefix
L2: channels.<ch>.responsePrefix
L3: (reserved for channels.defaults)
L4: messages.responsePrefix (existing global)
Semantics:
- undefined -> inherit from parent level
- empty string -> explicitly no prefix (stops cascade)
- "auto" -> derive [identity.name] from routed agent
Changes:
- Core logic: resolveResponsePrefix() in identity.ts accepts
optional channel/accountId and walks the cascade
- resolveEffectiveMessagesConfig() passes channel context through
- Types: responsePrefix added to WhatsApp, Telegram, Discord, Slack,
Signal, iMessage, Google Chat, MS Teams, Feishu, BlueBubbles configs
- Zod schemas: responsePrefix added for config validation
- All channel handlers wired: telegram, discord, slack, signal,
imessage, line, heartbeat runner, route-reply, native commands
- 23 new tests covering backward compat, channel/account levels,
full cascade, auto keyword, empty string stops, unknown fallthrough
Fully backward compatible - no existing config is affected.
Fixes #8857
* fix: address CI lint + review feedback
- Replace Record<string, any> with proper typed helpers (no-explicit-any)
- Add curly braces to single-line if returns (eslint curly)
- Fix JSDoc: 'Per-channel' → 'channel/account' on shared config types
- Extract getChannelConfig() helper for type-safe dynamic key access
* fix: finish responsePrefix overrides (#9001) (thanks @mudrii)
* fix: normalize prefix wiring and types (#9001) (thanks @mudrii)
---------
Co-authored-by: Gustavo Madeira Santana <gumadeiras@gmail.com>
2026-02-05 05:16:34 +08:00
const { onModelSelected , . . . prefixOptions } = createReplyPrefixOptions ( {
cfg : config ,
agentId : route.agentId ,
channel : "googlechat" ,
accountId : route.accountId ,
} ) ;
2026-01-23 16:45:37 -06:00
await core . channel . reply . dispatchReplyWithBufferedBlockDispatcher ( {
ctx : ctxPayload ,
cfg : config ,
dispatcherOptions : {
feat: per-channel responsePrefix override (#9001)
* feat: per-channel responsePrefix override
Add responsePrefix field to all channel config types and Zod schemas,
enabling per-channel and per-account outbound response prefix overrides.
Resolution cascade (most specific wins):
L1: channels.<ch>.accounts.<id>.responsePrefix
L2: channels.<ch>.responsePrefix
L3: (reserved for channels.defaults)
L4: messages.responsePrefix (existing global)
Semantics:
- undefined -> inherit from parent level
- empty string -> explicitly no prefix (stops cascade)
- "auto" -> derive [identity.name] from routed agent
Changes:
- Core logic: resolveResponsePrefix() in identity.ts accepts
optional channel/accountId and walks the cascade
- resolveEffectiveMessagesConfig() passes channel context through
- Types: responsePrefix added to WhatsApp, Telegram, Discord, Slack,
Signal, iMessage, Google Chat, MS Teams, Feishu, BlueBubbles configs
- Zod schemas: responsePrefix added for config validation
- All channel handlers wired: telegram, discord, slack, signal,
imessage, line, heartbeat runner, route-reply, native commands
- 23 new tests covering backward compat, channel/account levels,
full cascade, auto keyword, empty string stops, unknown fallthrough
Fully backward compatible - no existing config is affected.
Fixes #8857
* fix: address CI lint + review feedback
- Replace Record<string, any> with proper typed helpers (no-explicit-any)
- Add curly braces to single-line if returns (eslint curly)
- Fix JSDoc: 'Per-channel' → 'channel/account' on shared config types
- Extract getChannelConfig() helper for type-safe dynamic key access
* fix: finish responsePrefix overrides (#9001) (thanks @mudrii)
* fix: normalize prefix wiring and types (#9001) (thanks @mudrii)
---------
Co-authored-by: Gustavo Madeira Santana <gumadeiras@gmail.com>
2026-02-05 05:16:34 +08:00
. . . prefixOptions ,
2026-01-23 16:45:37 -06:00
deliver : async ( payload ) = > {
await deliverGoogleChatReply ( {
payload ,
account ,
spaceId ,
runtime ,
core ,
2026-01-25 04:05:14 +00:00
config ,
2026-01-23 16:45:37 -06:00
statusSink ,
2026-01-24 20:16:14 +00:00
typingMessageName ,
2026-01-23 16:45:37 -06:00
} ) ;
2026-01-24 20:16:14 +00:00
// Only use typing message for first delivery
typingMessageName = undefined ;
2026-01-23 16:45:37 -06:00
} ,
onError : ( err , info ) = > {
runtime . error ? . (
` [ ${ account . accountId } ] Google Chat ${ info . kind } reply failed: ${ String ( err ) } ` ,
) ;
} ,
} ,
feat: per-channel responsePrefix override (#9001)
* feat: per-channel responsePrefix override
Add responsePrefix field to all channel config types and Zod schemas,
enabling per-channel and per-account outbound response prefix overrides.
Resolution cascade (most specific wins):
L1: channels.<ch>.accounts.<id>.responsePrefix
L2: channels.<ch>.responsePrefix
L3: (reserved for channels.defaults)
L4: messages.responsePrefix (existing global)
Semantics:
- undefined -> inherit from parent level
- empty string -> explicitly no prefix (stops cascade)
- "auto" -> derive [identity.name] from routed agent
Changes:
- Core logic: resolveResponsePrefix() in identity.ts accepts
optional channel/accountId and walks the cascade
- resolveEffectiveMessagesConfig() passes channel context through
- Types: responsePrefix added to WhatsApp, Telegram, Discord, Slack,
Signal, iMessage, Google Chat, MS Teams, Feishu, BlueBubbles configs
- Zod schemas: responsePrefix added for config validation
- All channel handlers wired: telegram, discord, slack, signal,
imessage, line, heartbeat runner, route-reply, native commands
- 23 new tests covering backward compat, channel/account levels,
full cascade, auto keyword, empty string stops, unknown fallthrough
Fully backward compatible - no existing config is affected.
Fixes #8857
* fix: address CI lint + review feedback
- Replace Record<string, any> with proper typed helpers (no-explicit-any)
- Add curly braces to single-line if returns (eslint curly)
- Fix JSDoc: 'Per-channel' → 'channel/account' on shared config types
- Extract getChannelConfig() helper for type-safe dynamic key access
* fix: finish responsePrefix overrides (#9001) (thanks @mudrii)
* fix: normalize prefix wiring and types (#9001) (thanks @mudrii)
---------
Co-authored-by: Gustavo Madeira Santana <gumadeiras@gmail.com>
2026-02-05 05:16:34 +08:00
replyOptions : {
onModelSelected ,
} ,
2026-01-23 16:45:37 -06:00
} ) ;
}
async function downloadAttachment (
attachment : GoogleChatAttachment ,
account : ResolvedGoogleChatAccount ,
mediaMaxMb : number ,
core : GoogleChatCoreRuntime ,
) : Promise < { path : string ; contentType? : string } | null > {
const resourceName = attachment . attachmentDataRef ? . resourceName ;
2026-01-31 22:13:48 +09:00
if ( ! resourceName ) {
return null ;
}
2026-01-23 16:45:37 -06:00
const maxBytes = Math . max ( 1 , mediaMaxMb ) * 1024 * 1024 ;
2026-01-24 23:23:24 +00:00
const downloaded = await downloadGoogleChatMedia ( { account , resourceName , maxBytes } ) ;
2026-01-23 16:45:37 -06:00
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 ;
2026-01-30 03:15:10 +01:00
config : OpenClawConfig ;
2026-01-23 16:45:37 -06:00
statusSink ? : ( patch : { lastInboundAt? : number ; lastOutboundAt? : number } ) = > void ;
2026-01-24 20:16:14 +00:00
typingMessageName? : string ;
2026-01-23 16:45:37 -06:00
} ) : Promise < void > {
2026-01-31 21:13:13 +09:00
const { payload , account , spaceId , runtime , core , config , statusSink , typingMessageName } =
params ;
2026-01-23 16:45:37 -06:00
const mediaList = payload . mediaUrls ? . length
? payload . mediaUrls
: payload . mediaUrl
? [ payload . mediaUrl ]
: [ ] ;
if ( mediaList . length > 0 ) {
2026-01-24 23:23:24 +00:00
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 ) } ` ) ;
}
}
}
2026-01-23 16:45:37 -06:00
let first = true ;
for ( const mediaUrl of mediaList ) {
2026-01-24 23:23:24 +00:00
const caption = first && ! suppressCaption ? payload.text : undefined ;
2026-01-23 16:45:37 -06:00
first = false ;
try {
2026-02-09 10:05:38 -08:00
const loaded = await core . channel . media . fetchRemoteMedia ( {
url : mediaUrl ,
2026-01-23 16:45:37 -06:00
maxBytes : ( account . config . mediaMaxMb ? ? 20 ) * 1024 * 1024 ,
} ) ;
const upload = await uploadAttachmentForReply ( {
account ,
spaceId ,
buffer : loaded.buffer ,
contentType : loaded.contentType ,
2026-02-09 10:05:38 -08:00
filename : loaded.fileName ? ? "attachment" ,
2026-01-23 16:45:37 -06:00
} ) ;
if ( ! upload . attachmentUploadToken ) {
throw new Error ( "missing attachment upload token" ) ;
}
await sendGoogleChatMessage ( {
account ,
space : spaceId ,
text : caption ,
thread : payload.replyToId ,
attachments : [
2026-02-09 10:05:38 -08:00
{ attachmentUploadToken : upload.attachmentUploadToken , contentName : loaded.fileName } ,
2026-01-23 16:45:37 -06:00
] ,
} ) ;
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 ;
2026-01-31 21:13:13 +09:00
const chunkMode = core . channel . text . resolveChunkMode ( config , "googlechat" , account . accountId ) ;
const chunks = core . channel . text . chunkMarkdownTextWithMode ( payload . text , chunkLimit , chunkMode ) ;
2026-01-24 20:16:14 +00:00
for ( let i = 0 ; i < chunks . length ; i ++ ) {
const chunk = chunks [ i ] ;
2026-01-23 16:45:37 -06:00
try {
2026-01-24 20:16:14 +00:00
// 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 ,
} ) ;
}
2026-01-23 16:45:37 -06:00
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 ( ) ;
2026-02-15 19:35:48 +00:00
const webhookPath = resolveWebhookPath ( {
webhookPath : options.webhookPath ,
webhookUrl : options.webhookUrl ,
defaultPath : "/googlechat" ,
} ) ;
2026-01-23 16:45:37 -06:00
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 ;
}
2026-01-31 21:13:13 +09:00
export async function startGoogleChatMonitor (
params : GoogleChatMonitorOptions ,
) : Promise < ( ) = > void > {
2026-01-23 16:45:37 -06:00
return monitorGoogleChatProvider ( params ) ;
}
export function resolveGoogleChatWebhookPath ( params : {
account : ResolvedGoogleChatAccount ;
} ) : string {
2026-01-31 21:13:13 +09:00
return (
2026-02-15 19:35:48 +00:00
resolveWebhookPath ( {
webhookPath : params.account.config.webhookPath ,
webhookUrl : params.account.config.webhookUrl ,
defaultPath : "/googlechat" ,
} ) ? ? "/googlechat"
2026-01-31 21:13:13 +09:00
) ;
2026-01-23 16:45:37 -06:00
}
export function computeGoogleChatMediaMaxMb ( params : { account : ResolvedGoogleChatAccount } ) {
return params . account . config . mediaMaxMb ? ? 20 ;
}