2026-02-05 18:26:05 +08:00
import type { ClawdbotConfig , RuntimeEnv } from "openclaw/plugin-sdk" ;
import {
buildPendingHistoryContextFromMap ,
recordPendingHistoryEntryIfEnabled ,
clearHistoryEntriesIfEnabled ,
DEFAULT_GROUP_HISTORY_LIMIT ,
type HistoryEntry ,
} from "openclaw/plugin-sdk" ;
2026-02-10 09:20:39 +09:00
import type { FeishuMessageContext , FeishuMediaInfo , ResolvedFeishuAccount } from "./types.js" ;
2026-02-10 08:19:44 +08:00
import type { DynamicAgentCreationConfig } from "./types.js" ;
2026-02-05 19:18:25 +08:00
import { resolveFeishuAccount } from "./accounts.js" ;
2026-02-05 18:26:05 +08:00
import { createFeishuClient } from "./client.js" ;
2026-02-13 18:15:19 +00:00
import { tryRecordMessage } from "./dedup.js" ;
2026-02-10 08:19:44 +08:00
import { maybeCreateDynamicAgent } from "./dynamic-agent.js" ;
import { downloadImageFeishu , downloadMessageResourceFeishu } from "./media.js" ;
2026-02-05 18:26:05 +08:00
import { extractMentionTargets , extractMessageBody , isMentionForwardRequest } from "./mention.js" ;
import {
resolveFeishuGroupConfig ,
resolveFeishuReplyPolicy ,
resolveFeishuAllowlistMatch ,
isFeishuGroupAllowed ,
} from "./policy.js" ;
import { createFeishuReplyDispatcher } from "./reply-dispatcher.js" ;
import { getFeishuRuntime } from "./runtime.js" ;
2026-02-13 05:43:30 +01:00
import { getMessageFeishu , sendMessageFeishu } from "./send.js" ;
2026-02-05 18:26:05 +08:00
// --- Permission error extraction ---
// Extract permission grant URL from Feishu API error response.
type PermissionError = {
code : number ;
message : string ;
grantUrl? : string ;
} ;
function extractPermissionError ( err : unknown ) : PermissionError | null {
2026-02-10 08:19:44 +08:00
if ( ! err || typeof err !== "object" ) return null ;
2026-02-05 18:26:05 +08:00
// Axios error structure: err.response.data contains the Feishu error
const axiosErr = err as { response ? : { data? : unknown } } ;
const data = axiosErr . response ? . data ;
2026-02-10 08:19:44 +08:00
if ( ! data || typeof data !== "object" ) return null ;
2026-02-05 18:26:05 +08:00
const feishuErr = data as {
code? : number ;
msg? : string ;
error ? : { permission_violations? : Array < { uri? : string } > } ;
} ;
// Feishu permission error code: 99991672
2026-02-10 08:19:44 +08:00
if ( feishuErr . code !== 99991672 ) return null ;
2026-02-05 18:26:05 +08:00
// Extract the grant URL from the error message (contains the direct link)
const msg = feishuErr . msg ? ? "" ;
const urlMatch = msg . match ( /https:\/\/[^\s,]+\/app\/[^\s,]+/ ) ;
const grantUrl = urlMatch ? . [ 0 ] ;
return {
code : feishuErr.code ,
message : msg ,
grantUrl ,
} ;
}
// --- Sender name resolution (so the agent can distinguish who is speaking in group chats) ---
// Cache display names by open_id to avoid an API call on every message.
const SENDER_NAME_TTL_MS = 10 * 60 * 1000 ;
const senderNameCache = new Map < string , { name : string ; expireAt : number } > ( ) ;
// Cache permission errors to avoid spamming the user with repeated notifications.
// Key: appId or "default", Value: timestamp of last notification
const permissionErrorNotifiedAt = new Map < string , number > ( ) ;
const PERMISSION_ERROR_COOLDOWN_MS = 5 * 60 * 1000 ; // 5 minutes
type SenderNameResult = {
name? : string ;
permissionError? : PermissionError ;
} ;
async function resolveFeishuSenderName ( params : {
2026-02-05 19:18:25 +08:00
account : ResolvedFeishuAccount ;
2026-02-05 18:26:05 +08:00
senderOpenId : string ;
2026-02-05 19:18:25 +08:00
log : ( . . . args : any [ ] ) = > void ;
2026-02-05 18:26:05 +08:00
} ) : Promise < SenderNameResult > {
2026-02-05 19:18:25 +08:00
const { account , senderOpenId , log } = params ;
2026-02-10 08:19:44 +08:00
if ( ! account . configured ) return { } ;
if ( ! senderOpenId ) return { } ;
2026-02-05 18:26:05 +08:00
const cached = senderNameCache . get ( senderOpenId ) ;
const now = Date . now ( ) ;
2026-02-10 08:19:44 +08:00
if ( cached && cached . expireAt > now ) return { name : cached.name } ;
2026-02-05 18:26:05 +08:00
try {
2026-02-05 19:18:25 +08:00
const client = createFeishuClient ( account ) ;
2026-02-05 18:26:05 +08:00
// contact/v3/users/:user_id?user_id_type=open_id
2026-02-05 19:18:25 +08:00
const res : any = await client . contact . user . get ( {
2026-02-05 18:26:05 +08:00
path : { user_id : senderOpenId } ,
params : { user_id_type : "open_id" } ,
} ) ;
const name : string | undefined =
res ? . data ? . user ? . name ||
res ? . data ? . user ? . display_name ||
res ? . data ? . user ? . nickname ||
res ? . data ? . user ? . en_name ;
if ( name && typeof name === "string" ) {
senderNameCache . set ( senderOpenId , { name , expireAt : now + SENDER_NAME_TTL_MS } ) ;
return { name } ;
}
return { } ;
} catch ( err ) {
// Check if this is a permission error
const permErr = extractPermissionError ( err ) ;
if ( permErr ) {
log ( ` feishu: permission error resolving sender name: code= ${ permErr . code } ` ) ;
return { permissionError : permErr } ;
}
// Best-effort. Don't fail message handling if name lookup fails.
log ( ` feishu: failed to resolve sender name for ${ senderOpenId } : ${ String ( err ) } ` ) ;
return { } ;
}
}
export type FeishuMessageEvent = {
sender : {
sender_id : {
open_id? : string ;
user_id? : string ;
union_id? : string ;
} ;
sender_type? : string ;
tenant_key? : string ;
} ;
message : {
message_id : string ;
root_id? : string ;
parent_id? : string ;
chat_id : string ;
chat_type : "p2p" | "group" ;
message_type : string ;
content : string ;
mentions? : Array < {
key : string ;
id : {
open_id? : string ;
user_id? : string ;
union_id? : string ;
} ;
name : string ;
tenant_key? : string ;
} > ;
} ;
} ;
export type FeishuBotAddedEvent = {
chat_id : string ;
operator_id : {
open_id? : string ;
user_id? : string ;
union_id? : string ;
} ;
external : boolean ;
operator_tenant_key? : string ;
} ;
function parseMessageContent ( content : string , messageType : string ) : string {
try {
const parsed = JSON . parse ( content ) ;
if ( messageType === "text" ) {
return parsed . text || "" ;
}
if ( messageType === "post" ) {
// Extract text content from rich text post
const { textContent } = parsePostContent ( content ) ;
return textContent ;
}
return content ;
} catch {
return content ;
}
}
function checkBotMentioned ( event : FeishuMessageEvent , botOpenId? : string ) : boolean {
const mentions = event . message . mentions ? ? [ ] ;
2026-02-10 08:19:44 +08:00
if ( mentions . length === 0 ) return false ;
2026-02-11 22:33:04 -06:00
if ( ! botOpenId ) return false ;
2026-02-05 18:26:05 +08:00
return mentions . some ( ( m ) = > m . id . open_id === botOpenId ) ;
}
function stripBotMention (
text : string ,
mentions? : FeishuMessageEvent [ "message" ] [ "mentions" ] ,
) : string {
2026-02-10 08:19:44 +08:00
if ( ! mentions || mentions . length === 0 ) return text ;
2026-02-05 18:26:05 +08:00
let result = text ;
for ( const mention of mentions ) {
result = result . replace ( new RegExp ( ` @ ${ mention . name } \\ s* ` , "g" ) , "" ) . trim ( ) ;
result = result . replace ( new RegExp ( mention . key , "g" ) , "" ) . trim ( ) ;
}
return result ;
}
/ * *
* Parse media keys from message content based on message type .
* /
function parseMediaKeys (
content : string ,
messageType : string ,
) : {
imageKey? : string ;
fileKey? : string ;
fileName? : string ;
} {
try {
const parsed = JSON . parse ( content ) ;
switch ( messageType ) {
case "image" :
return { imageKey : parsed.image_key } ;
case "file" :
return { fileKey : parsed.file_key , fileName : parsed.file_name } ;
case "audio" :
return { fileKey : parsed.file_key } ;
case "video" :
// Video has both file_key (video) and image_key (thumbnail)
return { fileKey : parsed.file_key , imageKey : parsed.image_key } ;
case "sticker" :
return { fileKey : parsed.file_key } ;
default :
return { } ;
}
} catch {
return { } ;
}
}
/ * *
* Parse post ( rich text ) content and extract embedded image keys .
* Post structure : { title? : string , content : [ [ { tag , text ? , image_key ? , . . . } ] ] }
* /
function parsePostContent ( content : string ) : {
textContent : string ;
imageKeys : string [ ] ;
} {
try {
const parsed = JSON . parse ( content ) ;
const title = parsed . title || "" ;
const contentBlocks = parsed . content || [ ] ;
let textContent = title ? ` ${ title } \ n \ n ` : "" ;
const imageKeys : string [ ] = [ ] ;
for ( const paragraph of contentBlocks ) {
if ( Array . isArray ( paragraph ) ) {
for ( const element of paragraph ) {
if ( element . tag === "text" ) {
textContent += element . text || "" ;
} else if ( element . tag === "a" ) {
// Link: show text or href
textContent += element . text || element . href || "" ;
} else if ( element . tag === "at" ) {
// Mention: @username
textContent += ` @ ${ element . user_name || element . user_id || "" } ` ;
} else if ( element . tag === "img" && element . image_key ) {
// Embedded image
imageKeys . push ( element . image_key ) ;
}
}
textContent += "\n" ;
}
}
return {
textContent : textContent.trim ( ) || "[富文本消息]" ,
imageKeys ,
} ;
} catch {
return { textContent : "[富文本消息]" , imageKeys : [ ] } ;
}
}
/ * *
* Infer placeholder text based on message type .
* /
function inferPlaceholder ( messageType : string ) : string {
switch ( messageType ) {
case "image" :
return "<media:image>" ;
case "file" :
return "<media:document>" ;
case "audio" :
return "<media:audio>" ;
case "video" :
return "<media:video>" ;
case "sticker" :
return "<media:sticker>" ;
default :
return "<media:document>" ;
}
}
/ * *
* Resolve media from a Feishu message , downloading and saving to disk .
* Similar to Discord ' s resolveMediaList ( ) .
* /
async function resolveFeishuMediaList ( params : {
cfg : ClawdbotConfig ;
messageId : string ;
messageType : string ;
content : string ;
maxBytes : number ;
log ? : ( msg : string ) = > void ;
2026-02-05 19:18:25 +08:00
accountId? : string ;
2026-02-05 18:26:05 +08:00
} ) : Promise < FeishuMediaInfo [ ] > {
2026-02-05 19:18:25 +08:00
const { cfg , messageId , messageType , content , maxBytes , log , accountId } = params ;
2026-02-05 18:26:05 +08:00
// Only process media message types (including post for embedded images)
const mediaTypes = [ "image" , "file" , "audio" , "video" , "sticker" , "post" ] ;
if ( ! mediaTypes . includes ( messageType ) ) {
return [ ] ;
}
const out : FeishuMediaInfo [ ] = [ ] ;
const core = getFeishuRuntime ( ) ;
// Handle post (rich text) messages with embedded images
if ( messageType === "post" ) {
const { imageKeys } = parsePostContent ( content ) ;
if ( imageKeys . length === 0 ) {
return [ ] ;
}
log ? . ( ` feishu: post message contains ${ imageKeys . length } embedded image(s) ` ) ;
for ( const imageKey of imageKeys ) {
try {
// Embedded images in post use messageResource API with image_key as file_key
const result = await downloadMessageResourceFeishu ( {
cfg ,
messageId ,
fileKey : imageKey ,
type : "image" ,
2026-02-05 19:18:25 +08:00
accountId ,
2026-02-05 18:26:05 +08:00
} ) ;
let contentType = result . contentType ;
if ( ! contentType ) {
contentType = await core . media . detectMime ( { buffer : result.buffer } ) ;
}
const saved = await core . channel . media . saveMediaBuffer (
result . buffer ,
contentType ,
"inbound" ,
maxBytes ,
) ;
out . push ( {
path : saved.path ,
contentType : saved.contentType ,
placeholder : "<media:image>" ,
} ) ;
log ? . ( ` feishu: downloaded embedded image ${ imageKey } , saved to ${ saved . path } ` ) ;
} catch ( err ) {
log ? . ( ` feishu: failed to download embedded image ${ imageKey } : ${ String ( err ) } ` ) ;
}
}
return out ;
}
// Handle other media types
const mediaKeys = parseMediaKeys ( content , messageType ) ;
if ( ! mediaKeys . imageKey && ! mediaKeys . fileKey ) {
return [ ] ;
}
try {
let buffer : Buffer ;
let contentType : string | undefined ;
let fileName : string | undefined ;
// For message media, always use messageResource API
// The image.get API is only for images uploaded via im/v1/images, not for message attachments
const fileKey = mediaKeys . imageKey || mediaKeys . fileKey ;
if ( ! fileKey ) {
return [ ] ;
}
const resourceType = messageType === "image" ? "image" : "file" ;
const result = await downloadMessageResourceFeishu ( {
cfg ,
messageId ,
fileKey ,
type : resourceType ,
2026-02-05 19:18:25 +08:00
accountId ,
2026-02-05 18:26:05 +08:00
} ) ;
buffer = result . buffer ;
contentType = result . contentType ;
fileName = result . fileName || mediaKeys . fileName ;
// Detect mime type if not provided
if ( ! contentType ) {
contentType = await core . media . detectMime ( { buffer } ) ;
}
// Save to disk using core's saveMediaBuffer
const saved = await core . channel . media . saveMediaBuffer (
buffer ,
contentType ,
"inbound" ,
maxBytes ,
fileName ,
) ;
out . push ( {
path : saved.path ,
contentType : saved.contentType ,
placeholder : inferPlaceholder ( messageType ) ,
} ) ;
log ? . ( ` feishu: downloaded ${ messageType } media, saved to ${ saved . path } ` ) ;
} catch ( err ) {
log ? . ( ` feishu: failed to download ${ messageType } media: ${ String ( err ) } ` ) ;
}
return out ;
}
/ * *
* Build media payload for inbound context .
* Similar to Discord ' s buildDiscordMediaPayload ( ) .
* /
function buildFeishuMediaPayload ( mediaList : FeishuMediaInfo [ ] ) : {
MediaPath? : string ;
MediaType? : string ;
MediaUrl? : string ;
MediaPaths? : string [ ] ;
MediaUrls? : string [ ] ;
MediaTypes? : string [ ] ;
} {
const first = mediaList [ 0 ] ;
const mediaPaths = mediaList . map ( ( media ) = > media . path ) ;
const mediaTypes = mediaList . map ( ( media ) = > media . contentType ) . filter ( Boolean ) as string [ ] ;
return {
MediaPath : first?.path ,
MediaType : first?.contentType ,
MediaUrl : first?.path ,
MediaPaths : mediaPaths.length > 0 ? mediaPaths : undefined ,
MediaUrls : mediaPaths.length > 0 ? mediaPaths : undefined ,
MediaTypes : mediaTypes.length > 0 ? mediaTypes : undefined ,
} ;
}
export function parseFeishuMessageEvent (
event : FeishuMessageEvent ,
botOpenId? : string ,
) : FeishuMessageContext {
const rawContent = parseMessageContent ( event . message . content , event . message . message_type ) ;
const mentionedBot = checkBotMentioned ( event , botOpenId ) ;
const content = stripBotMention ( rawContent , event . message . mentions ) ;
const ctx : FeishuMessageContext = {
chatId : event.message.chat_id ,
messageId : event.message.message_id ,
senderId : event.sender.sender_id.user_id || event . sender . sender_id . open_id || "" ,
senderOpenId : event.sender.sender_id.open_id || "" ,
chatType : event.message.chat_type ,
mentionedBot ,
rootId : event.message.root_id || undefined ,
parentId : event.message.parent_id || undefined ,
content ,
contentType : event.message.message_type ,
} ;
// Detect mention forward request: message mentions bot + at least one other user
if ( isMentionForwardRequest ( event , botOpenId ) ) {
const mentionTargets = extractMentionTargets ( event , botOpenId ) ;
if ( mentionTargets . length > 0 ) {
ctx . mentionTargets = mentionTargets ;
// Extract message body (remove all @ placeholders)
const allMentionKeys = ( event . message . mentions ? ? [ ] ) . map ( ( m ) = > m . key ) ;
ctx . mentionMessageBody = extractMessageBody ( content , allMentionKeys ) ;
}
}
return ctx ;
}
export async function handleFeishuMessage ( params : {
cfg : ClawdbotConfig ;
event : FeishuMessageEvent ;
botOpenId? : string ;
runtime? : RuntimeEnv ;
chatHistories? : Map < string , HistoryEntry [ ] > ;
2026-02-05 19:18:25 +08:00
accountId? : string ;
2026-02-05 18:26:05 +08:00
} ) : Promise < void > {
2026-02-05 19:18:25 +08:00
const { cfg , event , botOpenId , runtime , chatHistories , accountId } = params ;
// Resolve account with merged config
const account = resolveFeishuAccount ( { cfg , accountId } ) ;
const feishuCfg = account . config ;
2026-02-05 18:26:05 +08:00
const log = runtime ? . log ? ? console . log ;
const error = runtime ? . error ? ? console . error ;
2026-02-10 08:19:44 +08:00
// Dedup check: skip if this message was already processed
const messageId = event . message . message_id ;
if ( ! tryRecordMessage ( messageId ) ) {
log ( ` feishu: skipping duplicate message ${ messageId } ` ) ;
return ;
}
2026-02-05 18:26:05 +08:00
let ctx = parseFeishuMessageEvent ( event , botOpenId ) ;
const isGroup = ctx . chatType === "group" ;
// Resolve sender display name (best-effort) so the agent can attribute messages correctly.
const senderResult = await resolveFeishuSenderName ( {
2026-02-05 19:18:25 +08:00
account ,
2026-02-05 18:26:05 +08:00
senderOpenId : ctx.senderOpenId ,
log ,
} ) ;
2026-02-10 08:19:44 +08:00
if ( senderResult . name ) ctx = { . . . ctx , senderName : senderResult.name } ;
2026-02-05 18:26:05 +08:00
// Track permission error to inform agent later (with cooldown to avoid repetition)
let permissionErrorForAgent : PermissionError | undefined ;
if ( senderResult . permissionError ) {
2026-02-05 19:18:25 +08:00
const appKey = account . appId ? ? "default" ;
2026-02-05 18:26:05 +08:00
const now = Date . now ( ) ;
const lastNotified = permissionErrorNotifiedAt . get ( appKey ) ? ? 0 ;
if ( now - lastNotified > PERMISSION_ERROR_COOLDOWN_MS ) {
permissionErrorNotifiedAt . set ( appKey , now ) ;
permissionErrorForAgent = senderResult . permissionError ;
}
}
2026-02-05 19:18:25 +08:00
log (
` feishu[ ${ account . accountId } ]: received message from ${ ctx . senderOpenId } in ${ ctx . chatId } ( ${ ctx . chatType } ) ` ,
) ;
2026-02-05 18:26:05 +08:00
// Log mention targets if detected
if ( ctx . mentionTargets && ctx . mentionTargets . length > 0 ) {
const names = ctx . mentionTargets . map ( ( t ) = > t . name ) . join ( ", " ) ;
2026-02-05 19:18:25 +08:00
log ( ` feishu[ ${ account . accountId } ]: detected @ forward request, targets: [ ${ names } ] ` ) ;
2026-02-05 18:26:05 +08:00
}
const historyLimit = Math . max (
0 ,
feishuCfg ? . historyLimit ? ? cfg . messages ? . groupChat ? . historyLimit ? ? DEFAULT_GROUP_HISTORY_LIMIT ,
) ;
2026-02-10 15:37:51 -08:00
const groupConfig = isGroup
? resolveFeishuGroupConfig ( { cfg : feishuCfg , groupId : ctx.chatId } )
: undefined ;
const dmPolicy = feishuCfg ? . dmPolicy ? ? "pairing" ;
const configAllowFrom = feishuCfg ? . allowFrom ? ? [ ] ;
const useAccessGroups = cfg . commands ? . useAccessGroups !== false ;
2026-02-05 18:26:05 +08:00
if ( isGroup ) {
const groupPolicy = feishuCfg ? . groupPolicy ? ? "open" ;
const groupAllowFrom = feishuCfg ? . groupAllowFrom ? ? [ ] ;
2026-02-05 19:18:25 +08:00
// DEBUG: log(`feishu[${account.accountId}]: groupPolicy=${groupPolicy}`);
2026-02-05 18:26:05 +08:00
// Check if this GROUP is allowed (groupAllowFrom contains group IDs like oc_xxx, not user IDs)
const groupAllowed = isFeishuGroupAllowed ( {
groupPolicy ,
allowFrom : groupAllowFrom ,
senderId : ctx.chatId , // Check group ID, not sender ID
senderName : undefined ,
} ) ;
if ( ! groupAllowed ) {
2026-02-05 19:18:25 +08:00
log ( ` feishu[ ${ account . accountId } ]: sender ${ ctx . senderOpenId } not in group allowlist ` ) ;
2026-02-05 18:26:05 +08:00
return ;
}
// Additional sender-level allowlist check if group has specific allowFrom config
const senderAllowFrom = groupConfig ? . allowFrom ? ? [ ] ;
if ( senderAllowFrom . length > 0 ) {
const senderAllowed = isFeishuGroupAllowed ( {
groupPolicy : "allowlist" ,
allowFrom : senderAllowFrom ,
senderId : ctx.senderOpenId ,
senderName : ctx.senderName ,
} ) ;
if ( ! senderAllowed ) {
log ( ` feishu: sender ${ ctx . senderOpenId } not in group ${ ctx . chatId } sender allowlist ` ) ;
return ;
}
}
const { requireMention } = resolveFeishuReplyPolicy ( {
isDirectMessage : false ,
globalConfig : feishuCfg ,
groupConfig ,
} ) ;
if ( requireMention && ! ctx . mentionedBot ) {
2026-02-05 19:18:25 +08:00
log (
` feishu[ ${ account . accountId } ]: message in group ${ ctx . chatId } did not mention bot, recording to history ` ,
) ;
2026-02-05 18:26:05 +08:00
if ( chatHistories ) {
recordPendingHistoryEntryIfEnabled ( {
historyMap : chatHistories ,
historyKey : ctx.chatId ,
limit : historyLimit ,
entry : {
sender : ctx.senderOpenId ,
body : ` ${ ctx . senderName ? ? ctx . senderOpenId } : ${ ctx . content } ` ,
timestamp : Date.now ( ) ,
messageId : ctx.messageId ,
} ,
} ) ;
}
return ;
}
} else {
}
try {
const core = getFeishuRuntime ( ) ;
2026-02-10 15:37:51 -08:00
const shouldComputeCommandAuthorized = core . channel . commands . shouldComputeCommandAuthorized (
ctx . content ,
cfg ,
) ;
const storeAllowFrom =
2026-02-13 05:43:30 +01:00
! isGroup && ( dmPolicy !== "open" || shouldComputeCommandAuthorized )
2026-02-10 15:37:51 -08:00
? await core . channel . pairing . readAllowFromStore ( "feishu" ) . catch ( ( ) = > [ ] )
: [ ] ;
2026-02-13 05:43:30 +01:00
const effectiveDmAllowFrom = [ . . . configAllowFrom , . . . storeAllowFrom ] ;
const dmAllowed = resolveFeishuAllowlistMatch ( {
allowFrom : effectiveDmAllowFrom ,
senderId : ctx.senderOpenId ,
senderName : ctx.senderName ,
} ) . allowed ;
if ( ! isGroup && dmPolicy !== "open" && ! dmAllowed ) {
if ( dmPolicy === "pairing" ) {
const { code , created } = await core . channel . pairing . upsertPairingRequest ( {
channel : "feishu" ,
id : ctx.senderOpenId ,
meta : { name : ctx.senderName } ,
} ) ;
if ( created ) {
log ( ` feishu[ ${ account . accountId } ]: pairing request sender= ${ ctx . senderOpenId } ` ) ;
try {
await sendMessageFeishu ( {
cfg ,
to : ` user: ${ ctx . senderOpenId } ` ,
text : core.channel.pairing.buildPairingReply ( {
channel : "feishu" ,
idLine : ` Your Feishu user id: ${ ctx . senderOpenId } ` ,
code ,
} ) ,
accountId : account.accountId ,
} ) ;
} catch ( err ) {
log (
` feishu[ ${ account . accountId } ]: pairing reply failed for ${ ctx . senderOpenId } : ${ String ( err ) } ` ,
) ;
}
}
} else {
log (
` feishu[ ${ account . accountId } ]: blocked unauthorized sender ${ ctx . senderOpenId } (dmPolicy= ${ dmPolicy } ) ` ,
) ;
}
return ;
}
const commandAllowFrom = isGroup ? ( groupConfig ? . allowFrom ? ? [ ] ) : effectiveDmAllowFrom ;
2026-02-10 15:37:51 -08:00
const senderAllowedForCommands = resolveFeishuAllowlistMatch ( {
allowFrom : commandAllowFrom ,
senderId : ctx.senderOpenId ,
senderName : ctx.senderName ,
} ) . allowed ;
const commandAuthorized = shouldComputeCommandAuthorized
? core . channel . commands . resolveCommandAuthorizedFromAuthorizers ( {
useAccessGroups ,
authorizers : [
{ configured : commandAllowFrom.length > 0 , allowed : senderAllowedForCommands } ,
] ,
} )
: undefined ;
2026-02-05 18:26:05 +08:00
// In group chats, the session is scoped to the group, but the *speaker* is the sender.
// Using a group-scoped From causes the agent to treat different users as the same person.
const feishuFrom = ` feishu: ${ ctx . senderOpenId } ` ;
const feishuTo = isGroup ? ` chat: ${ ctx . chatId } ` : ` user: ${ ctx . senderOpenId } ` ;
2026-02-10 08:19:44 +08:00
// Resolve peer ID for session routing
// When topicSessionMode is enabled, messages within a topic (identified by root_id)
// get a separate session from the main group chat.
let peerId = isGroup ? ctx.chatId : ctx.senderOpenId ;
if ( isGroup && ctx . rootId ) {
const groupConfig = resolveFeishuGroupConfig ( { cfg : feishuCfg , groupId : ctx.chatId } ) ;
const topicSessionMode =
groupConfig ? . topicSessionMode ? ? feishuCfg ? . topicSessionMode ? ? "disabled" ;
if ( topicSessionMode === "enabled" ) {
// Use chatId:topic:rootId as peer ID for topic-scoped sessions
peerId = ` ${ ctx . chatId } :topic: ${ ctx . rootId } ` ;
log ( ` feishu[ ${ account . accountId } ]: topic session isolation enabled, peer= ${ peerId } ` ) ;
}
}
let route = core . channel . routing . resolveAgentRoute ( {
2026-02-05 18:26:05 +08:00
cfg ,
channel : "feishu" ,
2026-02-05 19:18:25 +08:00
accountId : account.accountId ,
2026-02-05 18:26:05 +08:00
peer : {
2026-02-08 16:20:52 -08:00
kind : isGroup ? "group" : "direct" ,
2026-02-10 08:19:44 +08:00
id : peerId ,
2026-02-05 18:26:05 +08:00
} ,
} ) ;
2026-02-10 08:19:44 +08:00
// Dynamic agent creation for DM users
// When enabled, creates a unique agent instance with its own workspace for each DM user.
let effectiveCfg = cfg ;
if ( ! isGroup && route . matchedBy === "default" ) {
const dynamicCfg = feishuCfg ? . dynamicAgentCreation as DynamicAgentCreationConfig | undefined ;
if ( dynamicCfg ? . enabled ) {
const runtime = getFeishuRuntime ( ) ;
const result = await maybeCreateDynamicAgent ( {
cfg ,
runtime ,
senderOpenId : ctx.senderOpenId ,
dynamicCfg ,
log : ( msg ) = > log ( msg ) ,
} ) ;
if ( result . created ) {
effectiveCfg = result . updatedCfg ;
// Re-resolve route with updated config
route = core . channel . routing . resolveAgentRoute ( {
cfg : result.updatedCfg ,
channel : "feishu" ,
accountId : account.accountId ,
2026-02-10 09:20:39 +09:00
peer : { kind : "direct" , id : ctx.senderOpenId } ,
2026-02-10 08:19:44 +08:00
} ) ;
log (
` feishu[ ${ account . accountId } ]: dynamic agent created, new route: ${ route . sessionKey } ` ,
) ;
}
}
}
2026-02-05 18:26:05 +08:00
const preview = ctx . content . replace ( /\s+/g , " " ) . slice ( 0 , 160 ) ;
const inboundLabel = isGroup
2026-02-05 19:18:25 +08:00
? ` Feishu[ ${ account . accountId } ] message in group ${ ctx . chatId } `
: ` Feishu[ ${ account . accountId } ] DM from ${ ctx . senderOpenId } ` ;
2026-02-05 18:26:05 +08:00
core . system . enqueueSystemEvent ( ` ${ inboundLabel } : ${ preview } ` , {
sessionKey : route.sessionKey ,
contextKey : ` feishu:message: ${ ctx . chatId } : ${ ctx . messageId } ` ,
} ) ;
// Resolve media from message
const mediaMaxBytes = ( feishuCfg ? . mediaMaxMb ? ? 30 ) * 1024 * 1024 ; // 30MB default
const mediaList = await resolveFeishuMediaList ( {
cfg ,
messageId : ctx.messageId ,
messageType : event.message.message_type ,
content : event.message.content ,
maxBytes : mediaMaxBytes ,
log ,
2026-02-05 19:18:25 +08:00
accountId : account.accountId ,
2026-02-05 18:26:05 +08:00
} ) ;
const mediaPayload = buildFeishuMediaPayload ( mediaList ) ;
// Fetch quoted/replied message content if parentId exists
let quotedContent : string | undefined ;
if ( ctx . parentId ) {
try {
2026-02-05 19:18:25 +08:00
const quotedMsg = await getMessageFeishu ( {
cfg ,
messageId : ctx.parentId ,
accountId : account.accountId ,
} ) ;
2026-02-05 18:26:05 +08:00
if ( quotedMsg ) {
quotedContent = quotedMsg . content ;
2026-02-05 19:18:25 +08:00
log (
` feishu[ ${ account . accountId } ]: fetched quoted message: ${ quotedContent ? . slice ( 0 , 100 ) } ` ,
) ;
2026-02-05 18:26:05 +08:00
}
} catch ( err ) {
2026-02-05 19:18:25 +08:00
log ( ` feishu[ ${ account . accountId } ]: failed to fetch quoted message: ${ String ( err ) } ` ) ;
2026-02-05 18:26:05 +08:00
}
}
const envelopeOptions = core . channel . reply . resolveEnvelopeFormatOptions ( cfg ) ;
// Build message body with quoted content if available
let messageBody = ctx . content ;
if ( quotedContent ) {
messageBody = ` [Replying to: " ${ quotedContent } "] \ n \ n ${ ctx . content } ` ;
}
// Include a readable speaker label so the model can attribute instructions.
// (DMs already have per-sender sessions, but the prefix is still useful for clarity.)
const speaker = ctx . senderName ? ? ctx . senderOpenId ;
messageBody = ` ${ speaker } : ${ messageBody } ` ;
// If there are mention targets, inform the agent that replies will auto-mention them
if ( ctx . mentionTargets && ctx . mentionTargets . length > 0 ) {
const targetNames = ctx . mentionTargets . map ( ( t ) = > t . name ) . join ( ", " ) ;
messageBody += ` \ n \ n[System: Your reply will automatically @mention: ${ targetNames } . Do not write @xxx yourself.] ` ;
}
const envelopeFrom = isGroup ? ` ${ ctx . chatId } : ${ ctx . senderOpenId } ` : ctx . senderOpenId ;
// If there's a permission error, dispatch a separate notification first
if ( permissionErrorForAgent ) {
const grantUrl = permissionErrorForAgent . grantUrl ? ? "" ;
const permissionNotifyBody = ` [System: The bot encountered a Feishu API permission error. Please inform the user about this issue and provide the permission grant URL for the admin to authorize. Permission grant URL: ${ grantUrl } ] ` ;
const permissionBody = core . channel . reply . formatAgentEnvelope ( {
channel : "Feishu" ,
from : envelopeFrom ,
timestamp : new Date ( ) ,
envelope : envelopeOptions ,
body : permissionNotifyBody ,
} ) ;
const permissionCtx = core . channel . reply . finalizeInboundContext ( {
Body : permissionBody ,
2026-02-10 00:35:56 -06:00
BodyForAgent : permissionNotifyBody ,
2026-02-05 18:26:05 +08:00
RawBody : permissionNotifyBody ,
CommandBody : permissionNotifyBody ,
From : feishuFrom ,
To : feishuTo ,
SessionKey : route.sessionKey ,
AccountId : route.accountId ,
ChatType : isGroup ? "group" : "direct" ,
GroupSubject : isGroup ? ctx.chatId : undefined ,
SenderName : "system" ,
SenderId : "system" ,
Provider : "feishu" as const ,
Surface : "feishu" as const ,
MessageSid : ` ${ ctx . messageId } :permission-error ` ,
Timestamp : Date.now ( ) ,
WasMentioned : false ,
2026-02-10 15:37:51 -08:00
CommandAuthorized : commandAuthorized ,
2026-02-05 18:26:05 +08:00
OriginatingChannel : "feishu" as const ,
OriginatingTo : feishuTo ,
} ) ;
const {
dispatcher : permDispatcher ,
replyOptions : permReplyOptions ,
markDispatchIdle : markPermIdle ,
} = createFeishuReplyDispatcher ( {
cfg ,
agentId : route.agentId ,
runtime : runtime as RuntimeEnv ,
chatId : ctx.chatId ,
replyToMessageId : ctx.messageId ,
2026-02-05 19:18:25 +08:00
accountId : account.accountId ,
2026-02-05 18:26:05 +08:00
} ) ;
2026-02-05 19:18:25 +08:00
log ( ` feishu[ ${ account . accountId } ]: dispatching permission error notification to agent ` ) ;
2026-02-05 18:26:05 +08:00
await core . channel . reply . dispatchReplyFromConfig ( {
ctx : permissionCtx ,
cfg ,
dispatcher : permDispatcher ,
replyOptions : permReplyOptions ,
} ) ;
markPermIdle ( ) ;
}
const body = core . channel . reply . formatAgentEnvelope ( {
channel : "Feishu" ,
from : envelopeFrom ,
timestamp : new Date ( ) ,
envelope : envelopeOptions ,
body : messageBody ,
} ) ;
let combinedBody = body ;
const historyKey = isGroup ? ctx.chatId : undefined ;
if ( isGroup && historyKey && chatHistories ) {
combinedBody = buildPendingHistoryContextFromMap ( {
historyMap : chatHistories ,
historyKey ,
limit : historyLimit ,
currentMessage : combinedBody ,
formatEntry : ( entry ) = >
core . channel . reply . formatAgentEnvelope ( {
channel : "Feishu" ,
// Preserve speaker identity in group history as well.
from : ` ${ ctx . chatId } : ${ entry . sender } ` ,
timestamp : entry.timestamp ,
body : entry.body ,
envelope : envelopeOptions ,
} ) ,
} ) ;
}
2026-02-10 00:35:56 -06:00
const inboundHistory =
isGroup && historyKey && historyLimit > 0 && chatHistories
? ( chatHistories . get ( historyKey ) ? ? [ ] ) . map ( ( entry ) = > ( {
sender : entry.sender ,
body : entry.body ,
timestamp : entry.timestamp ,
} ) )
: undefined ;
2026-02-05 18:26:05 +08:00
const ctxPayload = core . channel . reply . finalizeInboundContext ( {
Body : combinedBody ,
2026-02-10 00:35:56 -06:00
BodyForAgent : ctx.content ,
InboundHistory : inboundHistory ,
2026-02-05 18:26:05 +08:00
RawBody : ctx.content ,
CommandBody : ctx.content ,
From : feishuFrom ,
To : feishuTo ,
SessionKey : route.sessionKey ,
AccountId : route.accountId ,
ChatType : isGroup ? "group" : "direct" ,
GroupSubject : isGroup ? ctx.chatId : undefined ,
SenderName : ctx.senderName ? ? ctx . senderOpenId ,
SenderId : ctx.senderOpenId ,
Provider : "feishu" as const ,
Surface : "feishu" as const ,
MessageSid : ctx.messageId ,
2026-02-10 00:35:56 -06:00
ReplyToBody : quotedContent ? ? undefined ,
2026-02-05 18:26:05 +08:00
Timestamp : Date.now ( ) ,
WasMentioned : ctx.mentionedBot ,
2026-02-10 15:37:51 -08:00
CommandAuthorized : commandAuthorized ,
2026-02-05 18:26:05 +08:00
OriginatingChannel : "feishu" as const ,
OriginatingTo : feishuTo ,
. . . mediaPayload ,
} ) ;
const { dispatcher , replyOptions , markDispatchIdle } = createFeishuReplyDispatcher ( {
cfg ,
agentId : route.agentId ,
runtime : runtime as RuntimeEnv ,
chatId : ctx.chatId ,
replyToMessageId : ctx.messageId ,
mentionTargets : ctx.mentionTargets ,
2026-02-05 19:18:25 +08:00
accountId : account.accountId ,
2026-02-05 18:26:05 +08:00
} ) ;
2026-02-05 19:18:25 +08:00
log ( ` feishu[ ${ account . accountId } ]: dispatching to agent (session= ${ route . sessionKey } ) ` ) ;
2026-02-05 18:26:05 +08:00
const { queuedFinal , counts } = await core . channel . reply . dispatchReplyFromConfig ( {
ctx : ctxPayload ,
cfg ,
dispatcher ,
replyOptions ,
} ) ;
markDispatchIdle ( ) ;
if ( isGroup && historyKey && chatHistories ) {
clearHistoryEntriesIfEnabled ( {
historyMap : chatHistories ,
historyKey ,
limit : historyLimit ,
} ) ;
}
2026-02-05 19:18:25 +08:00
log (
` feishu[ ${ account . accountId } ]: dispatch complete (queuedFinal= ${ queuedFinal } , replies= ${ counts . final } ) ` ,
) ;
2026-02-05 18:26:05 +08:00
} catch ( err ) {
2026-02-05 19:18:25 +08:00
error ( ` feishu[ ${ account . accountId } ]: failed to dispatch message: ${ String ( err ) } ` ) ;
2026-02-05 18:26:05 +08:00
}
}