@@ -403,6 +403,107 @@ export class FeishuGateway {
403403 private recentContents : Map < string , number > = new Map ( ) ;
404404 private readonly dedupWindowMs = 5000 ;
405405
406+ /**
407+ * 高风险操作内容哈希去重:针对 restart / self-update 等会导致进程退出的命令,
408+ * 飞书服务器可能因进程快速退出而认为消息未送达、延迟重发,导致反复重启。
409+ * 对匹配关键词的消息计算内容哈希,3 小时窗口内同哈希静默丢弃。
410+ * 持久化到磁盘,重启后依然生效。
411+ */
412+ private static readonly HIGH_RISK_KEYWORDS = [
413+ '/feishu restart' , '/飞书 restart' , '/feishu update' ,
414+ 'self_update' , 'self-update' , '自更新' , '重启' , '热重启' ,
415+ ] as const ;
416+ private static readonly HIGH_RISK_DEDUP_WINDOW_MS = 3 * 60 * 60 * 1000 ; // 3 hours
417+ private highRiskHashes : Map < string , number > = new Map ( ) ; // hash → first-seen timestamp
418+
419+ /** 获取高风险哈希去重文件的绝对路径 */
420+ private getHighRiskDedupFilePath ( ) : string {
421+ const homeDir = os . homedir ( ) ;
422+ const geminiDir = path . join ( homeDir , '.easycode-user' ) ;
423+ return path . join ( geminiDir , 'feishu-highrisk-dedup.json' ) ;
424+ }
425+
426+ /** 从磁盘加载高风险哈希缓存,并清除过期条目 */
427+ private loadHighRiskDedup ( ) : void {
428+ try {
429+ const filePath = this . getHighRiskDedupFilePath ( ) ;
430+ if ( fs . existsSync ( filePath ) ) {
431+ const content = fs . readFileSync ( filePath , 'utf8' ) ;
432+ const entries : Array < [ string , number ] > = JSON . parse ( content ) ;
433+ if ( Array . isArray ( entries ) ) {
434+ const now = Date . now ( ) ;
435+ for ( const [ hash , ts ] of entries ) {
436+ if ( typeof hash === 'string' && typeof ts === 'number' && now - ts < FeishuGateway . HIGH_RISK_DEDUP_WINDOW_MS ) {
437+ this . highRiskHashes . set ( hash , ts ) ;
438+ }
439+ }
440+ dlog ( `[Feishu] Loaded ${ this . highRiskHashes . size } active high-risk dedup entries from disk.` ) ;
441+ }
442+ }
443+ } catch ( e : any ) {
444+ dwarn ( `[Feishu] Failed to load high-risk dedup cache: ${ e ?. message || e } ` ) ;
445+ }
446+ }
447+
448+ /** 保存高风险哈希缓存到磁盘 */
449+ private saveHighRiskDedup ( ) : void {
450+ try {
451+ const filePath = this . getHighRiskDedupFilePath ( ) ;
452+ const dirPath = path . dirname ( filePath ) ;
453+ if ( ! fs . existsSync ( dirPath ) ) {
454+ fs . mkdirSync ( dirPath , { recursive : true } ) ;
455+ }
456+ const entries = Array . from ( this . highRiskHashes . entries ( ) ) ;
457+ fs . writeFileSync ( filePath , JSON . stringify ( entries , null , 2 ) , 'utf8' ) ;
458+ } catch ( e : any ) {
459+ dwarn ( `[Feishu] Failed to save high-risk dedup cache: ${ e ?. message || e } ` ) ;
460+ }
461+ }
462+
463+ /** 判断消息内容是否匹配高风险关键词 */
464+ private isHighRiskMessage ( text : string ) : boolean {
465+ const lower = text . toLowerCase ( ) ;
466+ return FeishuGateway . HIGH_RISK_KEYWORDS . some ( kw => lower . includes ( kw ) ) ;
467+ }
468+
469+ /** 对消息内容计算简单哈希(用于去重比对) */
470+ private computeContentHash ( chatId : string , text : string ) : string {
471+ const raw = `${ chatId } :${ text } ` ;
472+ // Simple DJB2 hash — fast, sufficient for dedup purposes
473+ let hash = 5381 ;
474+ for ( let i = 0 ; i < raw . length ; i ++ ) {
475+ hash = ( ( hash << 5 ) + hash + raw . charCodeAt ( i ) ) & 0x7FFFFFFF ;
476+ }
477+ return `hr_${ hash . toString ( 36 ) } ` ;
478+ }
479+
480+ /**
481+ * 检查高风险消息是否已在窗口内处理过。
482+ * 如果是新的高风险消息,记录其哈希并持久化。
483+ * @returns true 表示应静默丢弃,false 表示可以执行
484+ */
485+ private checkHighRiskDedup ( chatId : string , text : string ) : boolean {
486+ if ( ! this . isHighRiskMessage ( text ) ) return false ;
487+
488+ const hash = this . computeContentHash ( chatId , text ) ;
489+ const now = Date . now ( ) ;
490+ const firstSeen = this . highRiskHashes . get ( hash ) ;
491+
492+ if ( firstSeen !== undefined && now - firstSeen < FeishuGateway . HIGH_RISK_DEDUP_WINDOW_MS ) {
493+ dlog ( `[Feishu] High-risk dedup: skipping duplicate "${ text . slice ( 0 , 40 ) } " (hash=${ hash } , age=${ Math . round ( ( now - firstSeen ) / 60000 ) } min)` ) ;
494+ return true ;
495+ }
496+
497+ // 新的高风险消息,记录并持久化
498+ this . highRiskHashes . set ( hash , now ) ;
499+ // 清理过期条目
500+ for ( const [ h , ts ] of this . highRiskHashes ) {
501+ if ( now - ts >= FeishuGateway . HIGH_RISK_DEDUP_WINDOW_MS ) this . highRiskHashes . delete ( h ) ;
502+ }
503+ this . saveHighRiskDedup ( ) ;
504+ return false ;
505+ }
506+
406507 /** 群名缓存:key 为 chatId,value 为解析出的群名(成功才缓存,失败/空名不缓存以便后续重试) */
407508 private chatNameCache : Map < string , string > = new Map ( ) ;
408509
@@ -454,6 +555,7 @@ export class FeishuGateway {
454555 this . appSecret = appSecret ;
455556 this . domain = domain ;
456557 this . loadProcessedMessages ( ) ;
558+ this . loadHighRiskDedup ( ) ;
457559 }
458560
459561 private get apiBaseUrl ( ) : string {
@@ -1260,6 +1362,14 @@ export class FeishuGateway {
12601362 return { code : 0 } ;
12611363 }
12621364
1365+ // 高风险操作内容哈希去重:防止 restart / self-update 等命令因飞书重发而反复执行
1366+ if ( this . checkHighRiskDedup ( feishuMsg . chatId , feishuMsg . text ) ) {
1367+ const preview = feishuMsg . text . length > 30 ? feishuMsg . text . slice ( 0 , 30 ) + '…' : feishuMsg . text ;
1368+ await this . sendMessage ( feishuMsg . chatId ,
1369+ `检测到疑似重复的飞书服务端消息推送:「${ preview } 」,已丢弃。如果是您自己发的消息,请变换措辞重发。` ) ;
1370+ return { code : 0 } ;
1371+ }
1372+
12631373 // 标记为正在处理
12641374 if ( feishuMsg . messageId && feishuMsg . messageId . startsWith ( 'om_' ) ) {
12651375 this . inFlightMessages . add ( feishuMsg . messageId ) ;
0 commit comments