Skip to content

Commit 67c7d85

Browse files
committed
Merge branch 'ls-dev' into 'master'
fix(feishu): high-risk message content-hash dedup See merge request ai_native/DeepVCode/DeepVcodeClient!497
2 parents 226ef8e + e051693 commit 67c7d85

2 files changed

Lines changed: 256 additions & 0 deletions

File tree

packages/cli/src/services/feishu/gateway.test.ts

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1226,6 +1226,9 @@ describe('FeishuGateway - Message Deduplication', () => {
12261226
gateway = new FeishuGateway('mock-app-id', 'mock-app-secret');
12271227
messageCallback = null;
12281228

1229+
// Clear high-risk dedup cache to prevent cross-test pollution from disk
1230+
(gateway as any).highRiskHashes.clear();
1231+
12291232
mockRegister.mockImplementation((handlers: any) => {
12301233
if (handlers['im.message.receive_v1']) {
12311234
messageCallback = handlers['im.message.receive_v1'];
@@ -1366,6 +1369,149 @@ describe('FeishuGateway - Message Deduplication', () => {
13661369
expect(callCount).toBe(2);
13671370
expect((gateway as any).processedMessages.has('om_test_failure_123')).toBe(true);
13681371
});
1372+
1373+
it('deduplicates high-risk messages (restart/update) via content hash within 3-hour window', async () => {
1374+
let callCount = 0;
1375+
gateway.onMessage = async (msg) => {
1376+
callCount++;
1377+
return 'ok';
1378+
};
1379+
1380+
const restartEvent = {
1381+
event: {
1382+
message: {
1383+
message_id: 'om_hr_001',
1384+
message_type: 'text',
1385+
content: JSON.stringify({ text: '/feishu restart' }),
1386+
chat_id: 'oc_restart_chat',
1387+
chat_type: 'group',
1388+
},
1389+
sender: { sender_id: { open_id: 'ou_001' } },
1390+
},
1391+
};
1392+
1393+
// 1. First /feishu restart should be processed
1394+
await messageCallback(restartEvent);
1395+
expect(callCount).toBe(1);
1396+
1397+
// 2. Same content, different messageId (simulating Feishu redelivery) should be dropped
1398+
const redeliverEvent = {
1399+
event: {
1400+
message: {
1401+
message_id: 'om_hr_002', // different ID — bypasses messageId dedup
1402+
message_type: 'text',
1403+
content: JSON.stringify({ text: '/feishu restart' }),
1404+
chat_id: 'oc_restart_chat',
1405+
chat_type: 'group',
1406+
},
1407+
sender: { sender_id: { open_id: 'ou_001' } },
1408+
},
1409+
};
1410+
(gateway as any).recentContents.clear(); // bypass short-window content dedup
1411+
const result = await messageCallback(redeliverEvent);
1412+
expect(result).toEqual({ code: 0 });
1413+
expect(callCount).toBe(1); // still 1, deduplicated
1414+
1415+
// Verify that a warning message was sent back to the chat about the dropped duplicate
1416+
const allFetchCalls = (globalThis as any).fetch?.mock?.calls || [];
1417+
const sentMessages = allFetchCalls
1418+
.filter((call: any[]) => {
1419+
const url = call[0];
1420+
return typeof url === 'string' && url.includes('/im/v1/messages');
1421+
})
1422+
.map((call: any[]) => {
1423+
try {
1424+
const body = JSON.parse(call[1]?.body || '{}');
1425+
return JSON.parse(body?.content || '{}').text || '';
1426+
} catch { return ''; }
1427+
});
1428+
const warningMsg = sentMessages.find((t: string) => t.includes('疑似重复') && t.includes('已丢弃'));
1429+
expect(warningMsg).toBeTruthy();
1430+
});
1431+
1432+
it('allows different high-risk messages from the same chat', async () => {
1433+
let callCount = 0;
1434+
gateway.onMessage = async (msg) => {
1435+
callCount++;
1436+
return 'ok';
1437+
};
1438+
1439+
// First message: /feishu restart
1440+
const event1 = {
1441+
event: {
1442+
message: {
1443+
message_id: 'om_diff_001',
1444+
message_type: 'text',
1445+
content: JSON.stringify({ text: '/feishu restart' }),
1446+
chat_id: 'oc_diff_chat',
1447+
chat_type: 'group',
1448+
},
1449+
sender: { sender_id: { open_id: 'ou_001' } },
1450+
},
1451+
};
1452+
await messageCallback(event1);
1453+
expect(callCount).toBe(1);
1454+
1455+
// Second message: self_update (different content, should NOT be deduplicated)
1456+
const event2 = {
1457+
event: {
1458+
message: {
1459+
message_id: 'om_diff_002',
1460+
message_type: 'text',
1461+
content: JSON.stringify({ text: '请帮我 self_update 更新到最新版' }),
1462+
chat_id: 'oc_diff_chat',
1463+
chat_type: 'group',
1464+
},
1465+
sender: { sender_id: { open_id: 'ou_001' } },
1466+
},
1467+
};
1468+
(gateway as any).recentContents.clear();
1469+
await messageCallback(event2);
1470+
expect(callCount).toBe(2);
1471+
});
1472+
1473+
it('does not deduplicate normal (non-high-risk) messages', async () => {
1474+
let callCount = 0;
1475+
gateway.onMessage = async (msg) => {
1476+
callCount++;
1477+
return 'ok';
1478+
};
1479+
1480+
const normalEvent = {
1481+
event: {
1482+
message: {
1483+
message_id: 'om_normal_001',
1484+
message_type: 'text',
1485+
content: JSON.stringify({ text: '帮我写个 hello world' }),
1486+
chat_id: 'oc_normal_chat',
1487+
chat_type: 'group',
1488+
},
1489+
sender: { sender_id: { open_id: 'ou_001' } },
1490+
},
1491+
};
1492+
1493+
// Even with a different messageId but same content, normal messages are only
1494+
// caught by the 5-second content dedup, not the high-risk hash dedup.
1495+
await messageCallback(normalEvent);
1496+
expect(callCount).toBe(1);
1497+
1498+
const normalEvent2 = {
1499+
event: {
1500+
message: {
1501+
message_id: 'om_normal_002',
1502+
message_type: 'text',
1503+
content: JSON.stringify({ text: '帮我写个 hello world' }),
1504+
chat_id: 'oc_normal_chat',
1505+
chat_type: 'group',
1506+
},
1507+
sender: { sender_id: { open_id: 'ou_001' } },
1508+
},
1509+
};
1510+
(gateway as any).recentContents.clear(); // bypass short-window dedup
1511+
await messageCallback(normalEvent2);
1512+
// high-risk dedup does NOT apply to normal messages, so this goes through
1513+
expect(callCount).toBe(2);
1514+
});
13691515
});
13701516

13711517
// ---------------------------------------------------------------------------

packages/cli/src/services/feishu/gateway.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)