2026-02-05 18:26:05 +08:00
import type { ClawdbotConfig , RuntimeEnv } from "openclaw/plugin-sdk" ;
import {
2026-02-15 19:35:52 +00:00
buildAgentMediaPayload ,
2026-02-05 18:26:05 +08:00
buildPendingHistoryContextFromMap ,
clearHistoryEntriesIfEnabled ,
2026-02-26 21:55:56 +01:00
createScopedPairingAccess ,
2026-02-05 18:26:05 +08:00
DEFAULT_GROUP_HISTORY_LIMIT ,
type HistoryEntry ,
2026-02-22 12:17:44 +01:00
recordPendingHistoryEntryIfEnabled ,
2026-02-22 12:35:02 +01:00
resolveOpenProviderRuntimeGroupPolicy ,
2026-02-22 12:44:02 +01:00
resolveDefaultGroupPolicy ,
2026-02-22 12:35:02 +01:00
warnMissingProviderGroupPolicyFallbackOnce ,
2026-02-05 18:26:05 +08:00
} from "openclaw/plugin-sdk" ;
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-03-02 03:41:07 +00:00
import { tryRecordMessage , tryRecordMessagePersistent } from "./dedup.js" ;
2026-02-10 08:19:44 +08:00
import { maybeCreateDynamicAgent } from "./dynamic-agent.js" ;
2026-02-19 14:06:11 +01:00
import { normalizeFeishuExternalKey } from "./external-keys.js" ;
import { downloadMessageResourceFeishu } from "./media.js" ;
2026-02-19 15:04:40 +01:00
import {
escapeRegExp ,
extractMentionTargets ,
extractMessageBody ,
isMentionForwardRequest ,
} from "./mention.js" ;
2026-02-05 18:26:05 +08:00
import {
resolveFeishuGroupConfig ,
resolveFeishuReplyPolicy ,
resolveFeishuAllowlistMatch ,
isFeishuGroupAllowed ,
} from "./policy.js" ;
2026-02-28 13:33:20 +08:00
import { parsePostContent } from "./post.js" ;
2026-02-05 18:26:05 +08:00
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-19 15:27:45 +01:00
import type { FeishuMessageContext , FeishuMediaInfo , ResolvedFeishuAccount } from "./types.js" ;
import type { DynamicAgentCreationConfig } from "./types.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) ---
2026-02-27 22:59:42 -06:00
// Cache display names by sender id (open_id/user_id) to avoid an API call on every message.
2026-02-05 18:26:05 +08:00
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 ;
} ;
2026-02-27 22:59:42 -06:00
function resolveSenderLookupIdType ( senderId : string ) : "open_id" | "user_id" | "union_id" {
const trimmed = senderId . trim ( ) ;
if ( trimmed . startsWith ( "ou_" ) ) {
return "open_id" ;
}
if ( trimmed . startsWith ( "on_" ) ) {
return "union_id" ;
}
return "user_id" ;
}
2026-02-05 18:26:05 +08:00
async function resolveFeishuSenderName ( params : {
2026-02-05 19:18:25 +08:00
account : ResolvedFeishuAccount ;
2026-02-27 22:59:42 -06:00
senderId : 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-27 22:59:42 -06:00
const { account , senderId , log } = params ;
2026-02-10 08:19:44 +08:00
if ( ! account . configured ) return { } ;
2026-02-05 18:26:05 +08:00
2026-02-27 22:59:42 -06:00
const normalizedSenderId = senderId . trim ( ) ;
if ( ! normalizedSenderId ) return { } ;
const cached = senderNameCache . get ( normalizedSenderId ) ;
2026-02-05 18:26:05 +08:00
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-27 22:59:42 -06:00
const userIdType = resolveSenderLookupIdType ( normalizedSenderId ) ;
2026-02-05 18:26:05 +08:00
2026-02-27 22:59:42 -06:00
// contact/v3/users/:user_id?user_id_type=<open_id|user_id|union_id>
2026-02-05 19:18:25 +08:00
const res : any = await client . contact . user . get ( {
2026-02-27 22:59:42 -06:00
path : { user_id : normalizedSenderId } ,
params : { user_id_type : userIdType } ,
2026-02-05 18:26:05 +08:00
} ) ;
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" ) {
2026-02-27 22:59:42 -06:00
senderNameCache . set ( normalizedSenderId , { name , expireAt : now + SENDER_NAME_TTL_MS } ) ;
2026-02-05 18:26:05 +08:00
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.
2026-02-27 22:59:42 -06:00
log ( ` feishu: failed to resolve sender name for ${ normalizedSenderId } : ${ String ( err ) } ` ) ;
2026-02-05 18:26:05 +08:00
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 ;
2026-03-01 22:17:08 +08:00
create_time? : string ;
2026-02-05 18:26:05 +08:00
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 {
2026-02-28 13:33:20 +08:00
if ( messageType === "post" ) {
// Extract text content from rich text post
const { textContent } = parsePostContent ( content ) ;
return textContent ;
}
2026-02-05 18:26:05 +08:00
try {
const parsed = JSON . parse ( content ) ;
if ( messageType === "text" ) {
return parsed . text || "" ;
}
2026-02-28 10:15:48 +08:00
if ( messageType === "share_chat" ) {
// Preserve available summary text for merged/forwarded chat messages.
if ( parsed && typeof parsed === "object" ) {
const share = parsed as {
body? : unknown ;
summary? : unknown ;
share_chat_id? : unknown ;
} ;
if ( typeof share . body === "string" && share . body . trim ( ) . length > 0 ) {
return share . body . trim ( ) ;
}
if ( typeof share . summary === "string" && share . summary . trim ( ) . length > 0 ) {
return share . summary . trim ( ) ;
}
if ( typeof share . share_chat_id === "string" && share . share_chat_id . trim ( ) . length > 0 ) {
return ` [Forwarded message: ${ share . share_chat_id . trim ( ) } ] ` ;
}
}
return "[Forwarded message]" ;
}
2026-02-28 10:57:18 +08:00
if ( messageType === "merge_forward" ) {
// Return placeholder; actual content fetched asynchronously in handleFeishuMessage
return "[Merged and Forwarded Message - loading...]" ;
}
2026-02-05 18:26:05 +08:00
return content ;
} catch {
return content ;
}
}
2026-02-28 10:57:18 +08:00
/ * *
* Parse merge_forward message content and fetch sub - messages .
* Returns formatted text content of all sub - messages .
* /
function parseMergeForwardContent ( params : {
content : string ;
log ? : ( . . . args : any [ ] ) = > void ;
} ) : string {
const { content , log } = params ;
const maxMessages = 50 ;
// For merge_forward, the API returns all sub-messages in items array
// with upper_message_id pointing to the merge_forward message.
// The 'content' parameter here is actually the full API response items array as JSON.
log ? . ( ` feishu: parsing merge_forward sub-messages from API response ` ) ;
let items : Array < {
message_id? : string ;
msg_type? : string ;
body ? : { content? : string } ;
sender ? : { id? : string } ;
upper_message_id? : string ;
create_time? : string ;
} > ;
try {
items = JSON . parse ( content ) ;
} catch {
log ? . ( ` feishu: merge_forward items parse failed ` ) ;
return "[Merged and Forwarded Message - parse error]" ;
}
if ( ! Array . isArray ( items ) || items . length === 0 ) {
return "[Merged and Forwarded Message - no sub-messages]" ;
}
// Filter to only sub-messages (those with upper_message_id, skip the merge_forward container itself)
const subMessages = items . filter ( ( item ) = > item . upper_message_id ) ;
if ( subMessages . length === 0 ) {
return "[Merged and Forwarded Message - no sub-messages found]" ;
}
log ? . ( ` feishu: merge_forward contains ${ subMessages . length } sub-messages ` ) ;
// Sort by create_time
subMessages . sort ( ( a , b ) = > {
const timeA = parseInt ( a . create_time || "0" , 10 ) ;
const timeB = parseInt ( b . create_time || "0" , 10 ) ;
return timeA - timeB ;
} ) ;
// Format output
const lines : string [ ] = [ "[Merged and Forwarded Messages]" ] ;
const limitedMessages = subMessages . slice ( 0 , maxMessages ) ;
for ( const item of limitedMessages ) {
const msgContent = item . body ? . content || "" ;
const msgType = item . msg_type || "text" ;
const formatted = formatSubMessageContent ( msgContent , msgType ) ;
lines . push ( ` - ${ formatted } ` ) ;
}
if ( subMessages . length > maxMessages ) {
lines . push ( ` ... and ${ subMessages . length - maxMessages } more messages ` ) ;
}
return lines . join ( "\n" ) ;
}
/ * *
* Format sub - message content based on message type .
* /
function formatSubMessageContent ( content : string , contentType : string ) : string {
try {
const parsed = JSON . parse ( content ) ;
switch ( contentType ) {
case "text" :
return parsed . text || content ;
case "post" : {
const { textContent } = parsePostContent ( content ) ;
return textContent ;
}
case "image" :
return "[Image]" ;
case "file" :
return ` [File: ${ parsed . file_name || "unknown" } ] ` ;
case "audio" :
return "[Audio]" ;
case "video" :
return "[Video]" ;
case "sticker" :
return "[Sticker]" ;
case "merge_forward" :
return "[Nested Merged Forward]" ;
default :
return ` [ ${ contentType } ] ` ;
}
} catch {
return content ;
}
}
2026-02-05 18:26:05 +08:00
function checkBotMentioned ( event : FeishuMessageEvent , botOpenId? : string ) : boolean {
2026-02-11 22:33:04 -06:00
if ( ! botOpenId ) return false ;
2026-02-16 21:01:59 +08:00
const mentions = event . message . mentions ? ? [ ] ;
if ( mentions . length > 0 ) {
return mentions . some ( ( m ) = > m . id . open_id === botOpenId ) ;
}
// Post (rich text) messages may have empty message.mentions when they contain docs/paste
if ( event . message . message_type === "post" ) {
2026-02-16 21:41:57 +08:00
const { mentionedOpenIds } = parsePostContent ( event . message . content ) ;
return mentionedOpenIds . some ( ( id ) = > id === botOpenId ) ;
2026-02-16 21:01:59 +08:00
}
return false ;
2026-02-05 18:26:05 +08:00
}
2026-02-19 15:04:40 +01:00
export function stripBotMention (
2026-02-05 18:26:05 +08:00
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 ) {
2026-02-19 15:04:40 +01:00
result = result . replace ( new RegExp ( ` @ ${ escapeRegExp ( mention . name ) } \\ s* ` , "g" ) , "" ) ;
result = result . replace ( new RegExp ( escapeRegExp ( mention . key ) , "g" ) , "" ) ;
2026-02-05 18:26:05 +08:00
}
2026-02-19 15:04:40 +01:00
return result . trim ( ) ;
2026-02-05 18:26:05 +08:00
}
/ * *
* 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 ) ;
2026-02-19 14:06:11 +01:00
const imageKey = normalizeFeishuExternalKey ( parsed . image_key ) ;
const fileKey = normalizeFeishuExternalKey ( parsed . file_key ) ;
2026-02-05 18:26:05 +08:00
switch ( messageType ) {
case "image" :
2026-02-19 14:06:11 +01:00
return { imageKey } ;
2026-02-05 18:26:05 +08:00
case "file" :
2026-02-19 14:06:11 +01:00
return { fileKey , fileName : parsed.file_name } ;
2026-02-05 18:26:05 +08:00
case "audio" :
2026-02-19 14:06:11 +01:00
return { fileKey } ;
2026-02-05 18:26:05 +08:00
case "video" :
2026-02-28 12:28:37 +08:00
case "media" :
// Video/media has both file_key (video) and image_key (thumbnail)
2026-02-19 14:06:11 +01:00
return { fileKey , imageKey } ;
2026-02-05 18:26:05 +08:00
case "sticker" :
2026-02-19 14:06:11 +01:00
return { fileKey } ;
2026-02-05 18:26:05 +08:00
default :
return { } ;
}
} catch {
return { } ;
}
}
2026-02-28 12:49:05 +08:00
/ * *
* Map Feishu message type to messageResource . get resource type .
* Feishu messageResource API supports only : image | file .
* /
export function toMessageResourceType ( messageType : string ) : "image" | "file" {
return messageType === "image" ? "image" : "file" ;
}
2026-02-05 18:26:05 +08:00
/ * *
* 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" :
2026-02-28 12:28:37 +08:00
case "media" :
2026-02-05 18:26:05 +08:00
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)
2026-02-28 12:28:37 +08:00
const mediaTypes = [ "image" , "file" , "audio" , "video" , "media" , "sticker" , "post" ] ;
2026-02-05 18:26:05 +08:00
if ( ! mediaTypes . includes ( messageType ) ) {
return [ ] ;
}
const out : FeishuMediaInfo [ ] = [ ] ;
const core = getFeishuRuntime ( ) ;
2026-02-28 13:39:24 +08:00
// Handle post (rich text) messages with embedded images/media.
2026-02-05 18:26:05 +08:00
if ( messageType === "post" ) {
2026-02-28 13:39:24 +08:00
const { imageKeys , mediaKeys : postMediaKeys } = parsePostContent ( content ) ;
if ( imageKeys . length === 0 && postMediaKeys . length === 0 ) {
2026-02-05 18:26:05 +08:00
return [ ] ;
}
2026-02-28 13:39:24 +08:00
if ( imageKeys . length > 0 ) {
log ? . ( ` feishu: post message contains ${ imageKeys . length } embedded image(s) ` ) ;
}
if ( postMediaKeys . length > 0 ) {
log ? . ( ` feishu: post message contains ${ postMediaKeys . length } embedded media file(s) ` ) ;
}
2026-02-05 18:26:05 +08:00
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 ) } ` ) ;
}
}
2026-02-28 13:39:24 +08:00
for ( const media of postMediaKeys ) {
try {
const result = await downloadMessageResourceFeishu ( {
cfg ,
messageId ,
fileKey : media.fileKey ,
type : "file" ,
accountId ,
} ) ;
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:video>" ,
} ) ;
log ? . ( ` feishu: downloaded embedded media ${ media . fileKey } , saved to ${ saved . path } ` ) ;
} catch ( err ) {
log ? . ( ` feishu: failed to download embedded media ${ media . fileKey } : ${ String ( err ) } ` ) ;
}
}
2026-02-05 18:26:05 +08:00
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
2026-02-22 19:21:01 +01:00
const fileKey = mediaKeys . fileKey || mediaKeys . imageKey ;
2026-02-05 18:26:05 +08:00
if ( ! fileKey ) {
return [ ] ;
}
2026-02-28 12:49:05 +08:00
const resourceType = toMessageResourceType ( messageType ) ;
2026-02-05 18:26:05 +08:00
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 ( ) .
* /
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 ) ;
2026-02-27 22:59:42 -06:00
const senderOpenId = event . sender . sender_id . open_id ? . trim ( ) ;
const senderUserId = event . sender . sender_id . user_id ? . trim ( ) ;
const senderFallbackId = senderOpenId || senderUserId || "" ;
2026-02-05 18:26:05 +08:00
const ctx : FeishuMessageContext = {
chatId : event.message.chat_id ,
messageId : event.message.message_id ,
2026-02-27 22:59:42 -06:00
senderId : senderUserId || senderOpenId || "" ,
// Keep the historical field name, but fall back to user_id when open_id is unavailable
// (common in some mobile app deliveries).
senderOpenId : senderFallbackId ,
2026-02-05 18:26:05 +08:00
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 ;
}
2026-02-26 13:19:17 +01:00
export function buildFeishuAgentBody ( params : {
ctx : Pick <
FeishuMessageContext ,
"content" | "senderName" | "senderOpenId" | "mentionTargets" | "messageId"
> ;
quotedContent? : string ;
permissionErrorForAgent? : PermissionError ;
} ) : string {
const { ctx , quotedContent , permissionErrorForAgent } = params ;
let messageBody = ctx . content ;
if ( quotedContent ) {
messageBody = ` [Replying to: " ${ quotedContent } "] \ n \ n ${ ctx . content } ` ;
}
// DMs already have per-sender sessions, but this label still improves attribution.
const speaker = ctx . senderName ? ? ctx . senderOpenId ;
messageBody = ` ${ speaker } : ${ messageBody } ` ;
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.] ` ;
}
// Keep message_id on its own line so shared message-id hint stripping can parse it reliably.
messageBody = ` [message_id: ${ ctx . messageId } ] \ n ${ messageBody } ` ;
if ( permissionErrorForAgent ) {
const grantUrl = permissionErrorForAgent . grantUrl ? ? "" ;
messageBody += ` \ n \ n[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 } ] ` ;
}
return messageBody ;
}
2026-02-05 18:26:05 +08:00
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-03-02 03:41:07 +00:00
// Dedup: synchronous memory guard prevents concurrent duplicate dispatch
// before the async persistent check completes.
2026-02-10 08:19:44 +08:00
const messageId = event . message . message_id ;
2026-03-02 03:41:07 +00:00
const memoryDedupeKey = ` ${ account . accountId } : ${ messageId } ` ;
if ( ! tryRecordMessage ( memoryDedupeKey ) ) {
log ( ` feishu: skipping duplicate message ${ messageId } (memory dedup) ` ) ;
return ;
}
// Persistent dedup survives restarts and reconnects.
2026-02-22 10:46:00 +01:00
if ( ! ( await tryRecordMessagePersistent ( messageId , account . accountId , log ) ) ) {
2026-02-10 08:19:44 +08:00
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" ;
2026-02-22 18:54:24 +01:00
const senderUserId = event . sender . sender_id . user_id ? . trim ( ) || undefined ;
2026-02-05 18:26:05 +08:00
2026-02-28 10:57:18 +08:00
// Handle merge_forward messages: fetch full message via API then expand sub-messages
if ( event . message . message_type === "merge_forward" ) {
log (
` feishu[ ${ account . accountId } ]: processing merge_forward message, fetching full content via API ` ,
) ;
try {
// Websocket event doesn't include sub-messages, need to fetch via API
// The API returns all sub-messages in the items array
const client = createFeishuClient ( account ) ;
const response = ( await client . im . message . get ( {
path : { message_id : event.message.message_id } ,
} ) ) as { code? : number ; data ? : { items? : unknown [ ] } } ;
if ( response . code === 0 && response . data ? . items && response . data . items . length > 0 ) {
log (
` feishu[ ${ account . accountId } ]: merge_forward API returned ${ response . data . items . length } items ` ,
) ;
const expandedContent = parseMergeForwardContent ( {
content : JSON.stringify ( response . data . items ) ,
log ,
} ) ;
ctx = { . . . ctx , content : expandedContent } ;
} else {
log ( ` feishu[ ${ account . accountId } ]: merge_forward API returned no items ` ) ;
ctx = { . . . ctx , content : "[Merged and Forwarded Message - could not fetch]" } ;
}
} catch ( err ) {
log ( ` feishu[ ${ account . accountId } ]: merge_forward fetch failed: ${ String ( err ) } ` ) ;
ctx = { . . . ctx , content : "[Merged and Forwarded Message - fetch error]" } ;
}
}
2026-02-05 18:26:05 +08:00
// Resolve sender display name (best-effort) so the agent can attribute messages correctly.
2026-02-28 13:05:54 +08:00
// Optimization: skip if disabled to save API quota (Feishu free tier limit).
2026-02-05 18:26:05 +08:00
let permissionErrorForAgent : PermissionError | undefined ;
2026-02-28 13:05:54 +08:00
if ( feishuCfg ? . resolveSenderNames ? ? true ) {
const senderResult = await resolveFeishuSenderName ( {
account ,
senderId : ctx.senderOpenId ,
log ,
} ) ;
if ( senderResult . name ) ctx = { . . . ctx , senderName : senderResult.name } ;
// Track permission error to inform agent later (with cooldown to avoid repetition)
if ( senderResult . permissionError ) {
const appKey = account . appId ? ? "default" ;
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 18:26:05 +08:00
}
}
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 ) {
2026-02-28 13:39:21 +08:00
if ( groupConfig ? . enabled === false ) {
log ( ` feishu[ ${ account . accountId } ]: group ${ ctx . chatId } is disabled ` ) ;
return ;
}
2026-02-22 12:44:02 +01:00
const defaultGroupPolicy = resolveDefaultGroupPolicy ( cfg ) ;
2026-02-22 12:35:02 +01:00
const { groupPolicy , providerMissingFallbackApplied } = resolveOpenProviderRuntimeGroupPolicy ( {
2026-02-22 12:17:44 +01:00
providerConfigPresent : cfg.channels?.feishu !== undefined ,
groupPolicy : feishuCfg?.groupPolicy ,
defaultGroupPolicy ,
} ) ;
2026-02-22 12:35:02 +01:00
warnMissingProviderGroupPolicyFallbackOnce ( {
providerMissingFallbackApplied ,
providerKey : "feishu" ,
accountId : account.accountId ,
log ,
} ) ;
2026-02-05 18:26:05 +08:00
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-28 13:39:21 +08:00
log (
` feishu[ ${ account . accountId } ]: group ${ ctx . chatId } not in groupAllowFrom (groupPolicy= ${ groupPolicy } ) ` ,
) ;
2026-02-05 18:26:05 +08:00
return ;
}
2026-02-27 19:49:47 -08:00
// Sender-level allowlist: per-group allowFrom takes precedence, then global groupSenderAllowFrom
const perGroupSenderAllowFrom = groupConfig ? . allowFrom ? ? [ ] ;
const globalSenderAllowFrom = feishuCfg ? . groupSenderAllowFrom ? ? [ ] ;
const effectiveSenderAllowFrom =
perGroupSenderAllowFrom . length > 0 ? perGroupSenderAllowFrom : globalSenderAllowFrom ;
if ( effectiveSenderAllowFrom . length > 0 ) {
2026-02-05 18:26:05 +08:00
const senderAllowed = isFeishuGroupAllowed ( {
groupPolicy : "allowlist" ,
2026-02-27 19:49:47 -08:00
allowFrom : effectiveSenderAllowFrom ,
2026-02-05 18:26:05 +08:00
senderId : ctx.senderOpenId ,
2026-02-22 18:54:24 +01:00
senderIds : [ senderUserId ] ,
2026-02-05 18:26:05 +08:00
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-26 21:55:56 +01:00
const pairing = createScopedPairingAccess ( {
core ,
channel : "feishu" ,
accountId : account.accountId ,
} ) ;
2026-02-10 15:37:51 -08:00
const shouldComputeCommandAuthorized = core . channel . commands . shouldComputeCommandAuthorized (
ctx . content ,
cfg ,
) ;
const storeAllowFrom =
2026-02-22 00:00:23 +01:00
! isGroup &&
dmPolicy !== "allowlist" &&
( dmPolicy !== "open" || shouldComputeCommandAuthorized )
2026-02-26 21:55:56 +01:00
? await pairing . readAllowFromStore ( ) . catch ( ( ) = > [ ] )
2026-02-10 15:37:51 -08:00
: [ ] ;
2026-02-13 05:43:30 +01:00
const effectiveDmAllowFrom = [ . . . configAllowFrom , . . . storeAllowFrom ] ;
const dmAllowed = resolveFeishuAllowlistMatch ( {
allowFrom : effectiveDmAllowFrom ,
senderId : ctx.senderOpenId ,
2026-02-22 18:54:24 +01:00
senderIds : [ senderUserId ] ,
2026-02-13 05:43:30 +01:00
senderName : ctx.senderName ,
} ) . allowed ;
if ( ! isGroup && dmPolicy !== "open" && ! dmAllowed ) {
if ( dmPolicy === "pairing" ) {
2026-02-26 21:55:56 +01:00
const { code , created } = await pairing . upsertPairingRequest ( {
2026-02-13 05:43:30 +01:00
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 ;
}
2026-02-22 19:13:04 +01:00
const commandAllowFrom = isGroup
? ( groupConfig ? . allowFrom ? ? configAllowFrom )
: effectiveDmAllowFrom ;
2026-02-10 15:37:51 -08:00
const senderAllowedForCommands = resolveFeishuAllowlistMatch ( {
allowFrom : commandAllowFrom ,
senderId : ctx.senderOpenId ,
2026-02-22 18:54:24 +01:00
senderIds : [ senderUserId ] ,
2026-02-10 15:37:51 -08:00
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-28 09:26:36 +08:00
// Resolve peer ID for session routing.
// Default is one session per group chat; this can be customized with groupSessionScope.
2026-02-10 08:19:44 +08:00
let peerId = isGroup ? ctx.chatId : ctx.senderOpenId ;
2026-02-28 09:26:36 +08:00
let groupSessionScope : "group" | "group_sender" | "group_topic" | "group_topic_sender" =
"group" ;
2026-02-28 09:53:02 +08:00
let topicRootForSession : string | null = null ;
const replyInThread =
isGroup &&
( groupConfig ? . replyInThread ? ? feishuCfg ? . replyInThread ? ? "disabled" ) === "enabled" ;
2026-02-28 09:26:36 +08:00
if ( isGroup ) {
const legacyTopicSessionMode =
groupConfig ? . topicSessionMode ? ? feishuCfg ? . topicSessionMode ? ? "disabled" ;
groupSessionScope =
groupConfig ? . groupSessionScope ? ?
feishuCfg ? . groupSessionScope ? ?
( legacyTopicSessionMode === "enabled" ? "group_topic" : "group" ) ;
2026-02-28 09:53:02 +08:00
// When topic-scoped sessions are enabled and replyInThread is on, the first
// bot reply creates the thread rooted at the current message ID.
if ( groupSessionScope === "group_topic" || groupSessionScope === "group_topic_sender" ) {
topicRootForSession = ctx . rootId ? ? ( replyInThread ? ctx.messageId : null ) ;
}
2026-02-28 09:26:36 +08:00
switch ( groupSessionScope ) {
case "group_sender" :
peerId = ` ${ ctx . chatId } :sender: ${ ctx . senderOpenId } ` ;
break ;
case "group_topic" :
2026-02-28 09:53:02 +08:00
peerId = topicRootForSession ? ` ${ ctx . chatId } :topic: ${ topicRootForSession } ` : ctx . chatId ;
2026-02-28 09:26:36 +08:00
break ;
case "group_topic_sender" :
2026-02-28 09:53:02 +08:00
peerId = topicRootForSession
? ` ${ ctx . chatId } :topic: ${ topicRootForSession } :sender: ${ ctx . senderOpenId } `
2026-02-28 09:26:36 +08:00
: ` ${ ctx . chatId } :sender: ${ ctx . senderOpenId } ` ;
break ;
case "group" :
default :
peerId = ctx . chatId ;
break ;
2026-02-10 08:19:44 +08:00
}
2026-02-28 09:26:36 +08:00
log ( ` feishu[ ${ account . accountId } ]: group session scope= ${ groupSessionScope } , peer= ${ peerId } ` ) ;
2026-02-10 08:19:44 +08:00
}
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-28 09:26:36 +08:00
// Add parentPeer for binding inheritance in topic-scoped modes.
2026-02-24 01:48:51 +08:00
parentPeer :
2026-02-28 09:26:36 +08:00
isGroup &&
2026-02-28 09:53:02 +08:00
topicRootForSession &&
2026-02-28 09:26:36 +08:00
( groupSessionScope === "group_topic" || groupSessionScope === "group_topic_sender" )
2026-02-24 01:48:51 +08:00
? {
kind : "group" ,
id : ctx.chatId ,
}
: null ,
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
2026-03-02 03:21:44 +00:00
// Do not enqueue inbound user previews as system events.
// System events are prepended to future prompts and can be misread as
// authoritative transcript turns.
log ( ` feishu[ ${ account . accountId } ]: ${ inboundLabel } : ${ preview } ` ) ;
2026-02-05 18:26:05 +08:00
// 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
} ) ;
2026-02-15 19:35:52 +00:00
const mediaPayload = buildAgentMediaPayload ( mediaList ) ;
2026-02-05 18:26:05 +08:00
// 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 ) ;
2026-02-26 13:19:17 +01:00
const messageBody = buildFeishuAgentBody ( {
ctx ,
quotedContent ,
permissionErrorForAgent ,
} ) ;
2026-02-05 18:26:05 +08:00
const envelopeFrom = isGroup ? ` ${ ctx . chatId } : ${ ctx . senderOpenId } ` : ctx . senderOpenId ;
if ( permissionErrorForAgent ) {
2026-02-26 13:19:17 +01:00
// Keep the notice in a single dispatch to avoid duplicate replies (#27372).
2026-02-26 18:26:03 +09:00
log ( ` feishu[ ${ account . accountId } ]: appending permission error notice to message body ` ) ;
2026-02-05 18:26:05 +08:00
}
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-26 14:44:39 +08:00
BodyForAgent : messageBody ,
2026-02-10 00:35:56 -06:00
InboundHistory : inboundHistory ,
2026-02-28 23:55:50 +08:00
// Quote/reply message support: use standard ReplyToId for parent,
// and pass root_id for thread reconstruction.
ReplyToId : ctx.parentId ,
RootMessageId : ctx.rootId ,
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 ,
} ) ;
2026-03-01 22:17:08 +08:00
// Parse message create_time (Feishu uses millisecond epoch string).
const messageCreateTimeMs = event . message . create_time
? parseInt ( event . message . create_time , 10 )
: undefined ;
2026-02-05 18:26:05 +08:00
const { dispatcher , replyOptions , markDispatchIdle } = createFeishuReplyDispatcher ( {
cfg ,
agentId : route.agentId ,
runtime : runtime as RuntimeEnv ,
chatId : ctx.chatId ,
replyToMessageId : ctx.messageId ,
2026-02-28 12:24:42 +08:00
skipReplyToInMessages : ! isGroup ,
2026-02-28 09:53:02 +08:00
replyInThread ,
2026-02-28 10:20:53 +08:00
rootId : ctx.rootId ,
2026-02-05 18:26:05 +08:00
mentionTargets : ctx.mentionTargets ,
2026-02-05 19:18:25 +08:00
accountId : account.accountId ,
2026-03-01 22:17:08 +08:00
messageCreateTimeMs ,
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-26 17:36:09 +01:00
const { queuedFinal , counts } = await core . channel . reply . withReplyDispatcher ( {
dispatcher ,
onSettled : ( ) = > {
2026-02-26 17:01:03 +01:00
markDispatchIdle ( ) ;
2026-02-26 17:36:09 +01:00
} ,
run : ( ) = >
core . channel . reply . dispatchReplyFromConfig ( {
ctx : ctxPayload ,
cfg ,
dispatcher ,
replyOptions ,
} ) ,
} ) ;
if ( isGroup && historyKey && chatHistories ) {
clearHistoryEntriesIfEnabled ( {
historyMap : chatHistories ,
historyKey ,
limit : historyLimit ,
} ) ;
2026-02-05 18:26:05 +08:00
}
2026-02-26 17:36:09 +01: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
}
}