2026-03-02 18:02:10 +00:00
import {
GROUP_POLICY_BLOCKED_LABEL ,
createScopedPairingAccess ,
2026-03-07 22:57:39 +00:00
evaluateGroupRouteAccessForPolicy ,
2026-03-07 19:36:02 +00:00
issuePairingChallenge ,
2026-03-02 18:02:10 +00:00
isDangerousNameMatchingEnabled ,
resolveAllowlistProviderRuntimeGroupPolicy ,
resolveDefaultGroupPolicy ,
resolveDmGroupAccessWithLists ,
resolveMentionGatingWithBypass ,
2026-03-07 22:49:30 +00:00
resolveSenderScopedGroupPolicy ,
2026-03-02 18:02:10 +00:00
warnMissingProviderGroupPolicyFallbackOnce ,
2026-03-04 02:33:00 -05:00
} from "openclaw/plugin-sdk/googlechat" ;
import type { OpenClawConfig } from "openclaw/plugin-sdk/googlechat" ;
2026-03-02 18:02:10 +00:00
import type { ResolvedGoogleChatAccount } from "./accounts.js" ;
import { sendGoogleChatMessage } from "./api.js" ;
import type { GoogleChatCoreRuntime } from "./monitor-types.js" ;
import type { GoogleChatAnnotation , GoogleChatMessage , GoogleChatSpace } from "./types.js" ;
function normalizeUserId ( raw? : string | null ) : string {
const trimmed = raw ? . trim ( ) ? ? "" ;
if ( ! trimmed ) {
return "" ;
}
return trimmed . replace ( /^users\//i , "" ) . toLowerCase ( ) ;
}
function isEmailLike ( value : string ) : boolean {
// Keep this intentionally loose; allowlists are user-provided config.
return value . includes ( "@" ) ;
}
export function isSenderAllowed (
senderId : string ,
senderEmail : string | undefined ,
allowFrom : string [ ] ,
allowNameMatching = false ,
) {
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 ;
}
// 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 ;
}
// Raw email allowlist entries are a break-glass override.
if ( allowNameMatching && normalizedEmail && isEmailLike ( withoutPrefix ) ) {
return withoutPrefix === normalizedEmail ;
}
return withoutPrefix . replace ( /^users\//i , "" ) === normalizedSenderId ;
} ) ;
}
type GoogleChatGroupEntry = {
requireMention? : boolean ;
allow? : boolean ;
enabled? : boolean ;
users? : Array < string | number > ;
systemPrompt? : string ;
} ;
function resolveGroupConfig ( params : {
groupId : string ;
groupName? : string | null ;
groups? : Record < string , GoogleChatGroupEntry > ;
} ) {
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 } ;
}
const warnedDeprecatedUsersEmailAllowFrom = new Set < string > ( ) ;
function warnDeprecatedUsersEmailEntries ( logVerbose : ( message : string ) = > void , 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 (
` 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 ( ", " ) } ` ,
) ;
}
export async function applyGoogleChatInboundAccessPolicy ( params : {
account : ResolvedGoogleChatAccount ;
config : OpenClawConfig ;
core : GoogleChatCoreRuntime ;
space : GoogleChatSpace ;
message : GoogleChatMessage ;
isGroup : boolean ;
senderId : string ;
senderName : string ;
senderEmail? : string ;
rawBody : string ;
statusSink ? : ( patch : { lastInboundAt? : number ; lastOutboundAt? : number } ) = > void ;
logVerbose : ( message : string ) = > void ;
} ) : Promise <
| {
ok : true ;
commandAuthorized : boolean | undefined ;
effectiveWasMentioned : boolean | undefined ;
groupSystemPrompt : string | undefined ;
}
| { ok : false }
> {
const {
account ,
config ,
core ,
space ,
message ,
isGroup ,
senderId ,
senderName ,
senderEmail ,
rawBody ,
statusSink ,
logVerbose ,
} = params ;
const allowNameMatching = isDangerousNameMatchingEnabled ( account . config ) ;
const spaceId = space . name ? ? "" ;
const pairing = createScopedPairingAccess ( {
core ,
channel : "googlechat" ,
accountId : account.accountId ,
} ) ;
const defaultGroupPolicy = resolveDefaultGroupPolicy ( config ) ;
const { groupPolicy , providerMissingFallbackApplied } =
resolveAllowlistProviderRuntimeGroupPolicy ( {
providerConfigPresent : config.channels?.googlechat !== undefined ,
groupPolicy : account.config.groupPolicy ,
defaultGroupPolicy ,
} ) ;
warnMissingProviderGroupPolicyFallbackOnce ( {
providerMissingFallbackApplied ,
providerKey : "googlechat" ,
accountId : account.accountId ,
blockedLabel : GROUP_POLICY_BLOCKED_LABEL.space ,
log : logVerbose ,
} ) ;
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 ) {
const groupAllowlistConfigured = groupConfigResolved . allowlistConfigured ;
2026-03-07 22:57:39 +00:00
const routeAccess = evaluateGroupRouteAccessForPolicy ( {
groupPolicy ,
routeAllowlistConfigured : groupAllowlistConfigured ,
routeMatched : Boolean ( groupEntry ) ,
routeEnabled : groupEntry?.enabled !== false && groupEntry ? . allow !== false ,
} ) ;
if ( ! routeAccess . allowed ) {
if ( routeAccess . reason === "disabled" ) {
logVerbose ( ` drop group message (groupPolicy=disabled, space= ${ spaceId } ) ` ) ;
} else if ( routeAccess . reason === "empty_allowlist" ) {
2026-03-02 18:02:10 +00:00
logVerbose ( ` drop group message (groupPolicy=allowlist, no allowlist, space= ${ spaceId } ) ` ) ;
2026-03-07 22:57:39 +00:00
} else if ( routeAccess . reason === "route_not_allowlisted" ) {
2026-03-02 18:02:10 +00:00
logVerbose ( ` drop group message (not allowlisted, space= ${ spaceId } ) ` ) ;
2026-03-07 22:57:39 +00:00
} else if ( routeAccess . reason === "route_disabled" ) {
logVerbose ( ` drop group message (space disabled, space= ${ spaceId } ) ` ) ;
2026-03-02 18:02:10 +00:00
}
return { ok : false } ;
}
if ( groupUsers . length > 0 ) {
const normalizedGroupUsers = groupUsers . map ( ( v ) = > String ( v ) ) ;
warnDeprecatedUsersEmailEntries ( logVerbose , normalizedGroupUsers ) ;
const ok = isSenderAllowed ( senderId , senderEmail , normalizedGroupUsers , allowNameMatching ) ;
if ( ! ok ) {
logVerbose ( ` drop group message (sender not allowed, ${ senderId } ) ` ) ;
return { ok : false } ;
}
}
}
const dmPolicy = account . config . dm ? . policy ? ? "pairing" ;
const configAllowFrom = ( account . config . dm ? . allowFrom ? ? [ ] ) . map ( ( v ) = > String ( v ) ) ;
const normalizedGroupUsers = groupUsers . map ( ( v ) = > String ( v ) ) ;
2026-03-07 22:49:30 +00:00
const senderGroupPolicy = resolveSenderScopedGroupPolicy ( {
groupPolicy ,
groupAllowFrom : normalizedGroupUsers ,
} ) ;
2026-03-02 18:02:10 +00:00
const shouldComputeAuth = core . channel . commands . shouldComputeCommandAuthorized ( rawBody , config ) ;
const storeAllowFrom =
! isGroup && dmPolicy !== "allowlist" && ( dmPolicy !== "open" || shouldComputeAuth )
? await pairing . readAllowFromStore ( ) . catch ( ( ) = > [ ] )
: [ ] ;
const access = resolveDmGroupAccessWithLists ( {
isGroup ,
dmPolicy ,
groupPolicy : senderGroupPolicy ,
allowFrom : configAllowFrom ,
groupAllowFrom : normalizedGroupUsers ,
storeAllowFrom ,
groupAllowFromFallbackToAllowFrom : false ,
isSenderAllowed : ( allowFrom ) = >
isSenderAllowed ( senderId , senderEmail , allowFrom , allowNameMatching ) ,
} ) ;
const effectiveAllowFrom = access . effectiveAllowFrom ;
const effectiveGroupAllowFrom = access . effectiveGroupAllowFrom ;
warnDeprecatedUsersEmailEntries ( logVerbose , effectiveAllowFrom ) ;
const commandAllowFrom = isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom ;
const useAccessGroups = config . commands ? . useAccessGroups !== false ;
const senderAllowedForCommands = isSenderAllowed (
senderId ,
senderEmail ,
commandAllowFrom ,
allowNameMatching ,
) ;
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 ( ` drop group message (mention required, space= ${ spaceId } ) ` ) ;
return { ok : false } ;
}
}
if ( isGroup && access . decision !== "allow" ) {
logVerbose (
` drop group message (sender policy blocked, reason= ${ access . reason } , space= ${ spaceId } ) ` ,
) ;
return { ok : false } ;
}
if ( ! isGroup ) {
if ( account . config . dm ? . enabled === false ) {
logVerbose ( ` Blocked Google Chat DM from ${ senderId } (dmPolicy=disabled) ` ) ;
return { ok : false } ;
}
if ( access . decision !== "allow" ) {
if ( access . decision === "pairing" ) {
2026-03-07 19:36:02 +00:00
await issuePairingChallenge ( {
channel : "googlechat" ,
senderId ,
senderIdLine : ` Your Google Chat user id: ${ senderId } ` ,
2026-03-02 18:02:10 +00:00
meta : { name : senderName || undefined , email : senderEmail } ,
2026-03-07 19:36:02 +00:00
upsertPairingRequest : pairing.upsertPairingRequest ,
onCreated : ( ) = > {
logVerbose ( ` googlechat pairing request sender= ${ senderId } ` ) ;
} ,
sendPairingReply : async ( text ) = > {
2026-03-02 18:02:10 +00:00
await sendGoogleChatMessage ( {
account ,
space : spaceId ,
2026-03-07 19:36:02 +00:00
text ,
2026-03-02 18:02:10 +00:00
} ) ;
statusSink ? . ( { lastOutboundAt : Date.now ( ) } ) ;
2026-03-07 19:36:02 +00:00
} ,
onReplyError : ( err ) = > {
2026-03-02 18:02:10 +00:00
logVerbose ( ` pairing reply failed for ${ senderId } : ${ String ( err ) } ` ) ;
2026-03-07 19:36:02 +00:00
} ,
} ) ;
2026-03-02 18:02:10 +00:00
} else {
logVerbose ( ` Blocked unauthorized Google Chat sender ${ senderId } (dmPolicy= ${ dmPolicy } ) ` ) ;
}
return { ok : false } ;
}
}
if (
isGroup &&
core . channel . commands . isControlCommandMessage ( rawBody , config ) &&
commandAuthorized !== true
) {
logVerbose ( ` googlechat: drop control command from ${ senderId } ` ) ;
return { ok : false } ;
}
return {
ok : true ,
commandAuthorized ,
effectiveWasMentioned ,
groupSystemPrompt : groupEntry?.systemPrompt?.trim ( ) || undefined ,
} ;
}