@@ -6,10 +6,17 @@ import {
66 DEFAULT_GROUP_HISTORY_LIMIT ,
77 type HistoryEntry ,
88} from "openclaw/plugin-sdk" ;
9- import type { FeishuMessageContext , FeishuMediaInfo , ResolvedFeishuAccount } from "./types.js" ;
9+ import type {
10+ FeishuConfig ,
11+ FeishuMessageContext ,
12+ FeishuMediaInfo ,
13+ ResolvedFeishuAccount ,
14+ } from "./types.js" ;
15+ import type { DynamicAgentCreationConfig } from "./types.js" ;
1016import { resolveFeishuAccount } from "./accounts.js" ;
1117import { createFeishuClient } from "./client.js" ;
12- import { downloadMessageResourceFeishu } from "./media.js" ;
18+ import { maybeCreateDynamicAgent } from "./dynamic-agent.js" ;
19+ import { downloadImageFeishu , downloadMessageResourceFeishu } from "./media.js" ;
1320import { extractMentionTargets , extractMessageBody , isMentionForwardRequest } from "./mention.js" ;
1421import {
1522 resolveFeishuGroupConfig ,
@@ -21,6 +28,37 @@ import { createFeishuReplyDispatcher } from "./reply-dispatcher.js";
2128import { getFeishuRuntime } from "./runtime.js" ;
2229import { getMessageFeishu } from "./send.js" ;
2330
31+ // --- Message deduplication ---
32+ // Prevent duplicate processing when WebSocket reconnects or Feishu redelivers messages.
33+ const DEDUP_TTL_MS = 30 * 60 * 1000 ; // 30 minutes
34+ const DEDUP_MAX_SIZE = 1_000 ;
35+ const DEDUP_CLEANUP_INTERVAL_MS = 5 * 60 * 1000 ; // cleanup every 5 minutes
36+ const processedMessageIds = new Map < string , number > ( ) ; // messageId -> timestamp
37+ let lastCleanupTime = Date . now ( ) ;
38+
39+ function tryRecordMessage ( messageId : string ) : boolean {
40+ const now = Date . now ( ) ;
41+
42+ // Throttled cleanup: evict expired entries at most once per interval
43+ if ( now - lastCleanupTime > DEDUP_CLEANUP_INTERVAL_MS ) {
44+ for ( const [ id , ts ] of processedMessageIds ) {
45+ if ( now - ts > DEDUP_TTL_MS ) processedMessageIds . delete ( id ) ;
46+ }
47+ lastCleanupTime = now ;
48+ }
49+
50+ if ( processedMessageIds . has ( messageId ) ) return false ;
51+
52+ // Evict oldest entries if cache is full
53+ if ( processedMessageIds . size >= DEDUP_MAX_SIZE ) {
54+ const first = processedMessageIds . keys ( ) . next ( ) . value ! ;
55+ processedMessageIds . delete ( first ) ;
56+ }
57+
58+ processedMessageIds . set ( messageId , now ) ;
59+ return true ;
60+ }
61+
2462// --- Permission error extraction ---
2563// Extract permission grant URL from Feishu API error response.
2664type PermissionError = {
@@ -30,16 +68,12 @@ type PermissionError = {
3068} ;
3169
3270function extractPermissionError ( err : unknown ) : PermissionError | null {
33- if ( ! err || typeof err !== "object" ) {
34- return null ;
35- }
71+ if ( ! err || typeof err !== "object" ) return null ;
3672
3773 // Axios error structure: err.response.data contains the Feishu error
3874 const axiosErr = err as { response ?: { data ?: unknown } } ;
3975 const data = axiosErr . response ?. data ;
40- if ( ! data || typeof data !== "object" ) {
41- return null ;
42- }
76+ if ( ! data || typeof data !== "object" ) return null ;
4377
4478 const feishuErr = data as {
4579 code ?: number ;
@@ -48,9 +82,7 @@ function extractPermissionError(err: unknown): PermissionError | null {
4882 } ;
4983
5084 // Feishu permission error code: 99991672
51- if ( feishuErr . code !== 99991672 ) {
52- return null ;
53- }
85+ if ( feishuErr . code !== 99991672 ) return null ;
5486
5587 // Extract the grant URL from the error message (contains the direct link)
5688 const msg = feishuErr . msg ?? "" ;
@@ -82,28 +114,20 @@ type SenderNameResult = {
82114async function resolveFeishuSenderName ( params : {
83115 account : ResolvedFeishuAccount ;
84116 senderOpenId : string ;
85- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- generic log function
86117 log : ( ...args : any [ ] ) => void ;
87118} ) : Promise < SenderNameResult > {
88119 const { account, senderOpenId, log } = params ;
89- if ( ! account . configured ) {
90- return { } ;
91- }
92- if ( ! senderOpenId ) {
93- return { } ;
94- }
120+ if ( ! account . configured ) return { } ;
121+ if ( ! senderOpenId ) return { } ;
95122
96123 const cached = senderNameCache . get ( senderOpenId ) ;
97124 const now = Date . now ( ) ;
98- if ( cached && cached . expireAt > now ) {
99- return { name : cached . name } ;
100- }
125+ if ( cached && cached . expireAt > now ) return { name : cached . name } ;
101126
102127 try {
103128 const client = createFeishuClient ( account ) ;
104129
105130 // contact/v3/users/:user_id?user_id_type=open_id
106- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK response type
107131 const res : any = await client . contact . user . get ( {
108132 path : { user_id : senderOpenId } ,
109133 params : { user_id_type : "open_id" } ,
@@ -196,22 +220,16 @@ function parseMessageContent(content: string, messageType: string): string {
196220
197221function checkBotMentioned ( event : FeishuMessageEvent , botOpenId ?: string ) : boolean {
198222 const mentions = event . message . mentions ?? [ ] ;
199- if ( mentions . length === 0 ) {
200- return false ;
201- }
202- if ( ! botOpenId ) {
203- return mentions . length > 0 ;
204- }
223+ if ( mentions . length === 0 ) return false ;
224+ if ( ! botOpenId ) return mentions . length > 0 ;
205225 return mentions . some ( ( m ) => m . id . open_id === botOpenId ) ;
206226}
207227
208228function stripBotMention (
209229 text : string ,
210230 mentions ?: FeishuMessageEvent [ "message" ] [ "mentions" ] ,
211231) : string {
212- if ( ! mentions || mentions . length === 0 ) {
213- return text ;
214- }
232+ if ( ! mentions || mentions . length === 0 ) return text ;
215233 let result = text ;
216234 for ( const mention of mentions ) {
217235 result = result . replace ( new RegExp ( `@${ mention . name } \\s*` , "g" ) , "" ) . trim ( ) ;
@@ -523,6 +541,13 @@ export async function handleFeishuMessage(params: {
523541 const log = runtime ?. log ?? console . log ;
524542 const error = runtime ?. error ?? console . error ;
525543
544+ // Dedup check: skip if this message was already processed
545+ const messageId = event . message . message_id ;
546+ if ( ! tryRecordMessage ( messageId ) ) {
547+ log ( `feishu: skipping duplicate message ${ messageId } ` ) ;
548+ return ;
549+ }
550+
526551 let ctx = parseFeishuMessageEvent ( event , botOpenId ) ;
527552 const isGroup = ctx . chatType === "group" ;
528553
@@ -532,9 +557,7 @@ export async function handleFeishuMessage(params: {
532557 senderOpenId : ctx . senderOpenId ,
533558 log,
534559 } ) ;
535- if ( senderResult . name ) {
536- ctx = { ...ctx , senderName : senderResult . name } ;
537- }
560+ if ( senderResult . name ) ctx = { ...ctx , senderName : senderResult . name } ;
538561
539562 // Track permission error to inform agent later (with cooldown to avoid repetition)
540563 let permissionErrorForAgent : PermissionError | undefined ;
@@ -647,16 +670,61 @@ export async function handleFeishuMessage(params: {
647670 const feishuFrom = `feishu:${ ctx . senderOpenId } ` ;
648671 const feishuTo = isGroup ? `chat:${ ctx . chatId } ` : `user:${ ctx . senderOpenId } ` ;
649672
650- const route = core . channel . routing . resolveAgentRoute ( {
673+ // Resolve peer ID for session routing
674+ // When topicSessionMode is enabled, messages within a topic (identified by root_id)
675+ // get a separate session from the main group chat.
676+ let peerId = isGroup ? ctx . chatId : ctx . senderOpenId ;
677+ if ( isGroup && ctx . rootId ) {
678+ const groupConfig = resolveFeishuGroupConfig ( { cfg : feishuCfg , groupId : ctx . chatId } ) ;
679+ const topicSessionMode =
680+ groupConfig ?. topicSessionMode ?? feishuCfg ?. topicSessionMode ?? "disabled" ;
681+ if ( topicSessionMode === "enabled" ) {
682+ // Use chatId:topic:rootId as peer ID for topic-scoped sessions
683+ peerId = `${ ctx . chatId } :topic:${ ctx . rootId } ` ;
684+ log ( `feishu[${ account . accountId } ]: topic session isolation enabled, peer=${ peerId } ` ) ;
685+ }
686+ }
687+
688+ let route = core . channel . routing . resolveAgentRoute ( {
651689 cfg,
652690 channel : "feishu" ,
653691 accountId : account . accountId ,
654692 peer : {
655693 kind : isGroup ? "group" : "direct" ,
656- id : isGroup ? ctx . chatId : ctx . senderOpenId ,
694+ id : peerId ,
657695 } ,
658696 } ) ;
659697
698+ // Dynamic agent creation for DM users
699+ // When enabled, creates a unique agent instance with its own workspace for each DM user.
700+ let effectiveCfg = cfg ;
701+ if ( ! isGroup && route . matchedBy === "default" ) {
702+ const dynamicCfg = feishuCfg ?. dynamicAgentCreation as DynamicAgentCreationConfig | undefined ;
703+ if ( dynamicCfg ?. enabled ) {
704+ const runtime = getFeishuRuntime ( ) ;
705+ const result = await maybeCreateDynamicAgent ( {
706+ cfg,
707+ runtime,
708+ senderOpenId : ctx . senderOpenId ,
709+ dynamicCfg,
710+ log : ( msg ) => log ( msg ) ,
711+ } ) ;
712+ if ( result . created ) {
713+ effectiveCfg = result . updatedCfg ;
714+ // Re-resolve route with updated config
715+ route = core . channel . routing . resolveAgentRoute ( {
716+ cfg : result . updatedCfg ,
717+ channel : "feishu" ,
718+ accountId : account . accountId ,
719+ peer : { kind : "dm" , id : ctx . senderOpenId } ,
720+ } ) ;
721+ log (
722+ `feishu[${ account . accountId } ]: dynamic agent created, new route: ${ route . sessionKey } ` ,
723+ ) ;
724+ }
725+ }
726+ }
727+
660728 const preview = ctx . content . replace ( / \s + / g, " " ) . slice ( 0 , 160 ) ;
661729 const inboundLabel = isGroup
662730 ? `Feishu[${ account . accountId } ] message in group ${ ctx . chatId } `
0 commit comments