diff --git a/src/feishu/event-handler.ts b/src/feishu/event-handler.ts index 82da650..e038aa4 100644 --- a/src/feishu/event-handler.ts +++ b/src/feishu/event-handler.ts @@ -38,7 +38,7 @@ import { handleMemoryCommand, handleMemoryCardAction } from '../memory/commands. import { getRepoIdentity } from '../workspace/identity.js'; import { parseRepoNameFromWorkspaceDir } from '../workspace/manager.js'; import { generateQuickAck } from '../utils/quick-ack.js'; -import { checkThreadRelevance } from '../utils/thread-relevance.js'; +import { checkThreadRelevance, type RecentMessage } from '../utils/thread-relevance.js'; import { compressImage, compressImageForHistory } from '../utils/image-compress.js'; // 注册审批通过后的消息重新入队回调(避免 approval.ts → event-handler.ts 循环依赖) @@ -462,6 +462,60 @@ function isThreadCreatorAgent(threadId: string, agentId: string): boolean { return true; } +/** 每条消息文本在上下文中的最大长度 */ +const RELEVANCE_CONTEXT_MAX_LEN = 100; + +/** + * 获取最近消息作为 Qwen 话题相关性判断的上下文。 + * 只取文本摘要,跳过纯附件消息,每条截断以控制 token 用量。 + * 带上发言人真名,帮助 Qwen 判断"你"指的是谁。 + */ +async function fetchRelevanceContext( + threadId: string, + chatId: string, + currentMessageId: string, + botDisplayName: string, +): Promise { + try { + const messages = await feishuClient.fetchRecentMessages(threadId, 'thread', 8, chatId); + const result: Array = []; + for (const m of messages) { + // 跳过当前消息 + if (m.messageId === currentMessageId) continue; + // 跳过无文本的消息(纯图片/文件/卡片) + const text = m.content?.trim(); + if (!text || text === '[图片]') continue; + result.push({ + senderType: m.senderType, + senderName: m.senderType === 'app' ? botDisplayName : undefined, // bot 用 displayName,user 后面批量解析 + content: text.slice(0, RELEVANCE_CONTEXT_MAX_LEN), + senderId: m.senderId, + }); + } + // 只保留最近 5 条有效消息 + const recent = result.slice(-5); + + // 批量解析人类用户的真名(利用 _userNameCache 缓存,避免重复调 API) + const userIds = recent + .filter(m => m.senderType === 'user' && m.senderId) + .map(m => m.senderId!); + if (userIds.length > 0) { + await resolveUserNames(userIds, chatId); + for (const m of recent) { + if (m.senderType === 'user' && m.senderId) { + m.senderName = _userNameCache.get(m.senderId); + } + } + } + + // 清除 senderId(不需要传给 Qwen) + return recent.map(({ senderId: _, ...rest }) => rest); + } catch { + // 获取失败不影响主流程,退化为无上下文 + return []; + } +} + // ============================================================ // 队列驱动:同一 thread 内串行执行,不同 thread 间可并行 // queueKey = threadId 存在时用 `chatId:threadId`,否则用 `chatId` @@ -759,7 +813,8 @@ async function handleMessageEvent(data: MessageEventData, accountId: string = 'd if (ts && (isOwner(userId) || ts.userId === userId)) { // 语义判断:用 Qwen 小模型判断无 @mention 的消息是否在跟 bot 对话 const botDisplayName = agentRegistry.get(agentId)?.displayName ?? 'bot'; - const relevant = await checkThreadRelevance(text, botDisplayName); + const recentCtx = await fetchRelevanceContext(threadId, chatId, messageId, botDisplayName); + const relevant = await checkThreadRelevance(text, botDisplayName, recentCtx); if (relevant) { threadBypass = true; logger.debug({ threadId, agentId, accountId }, 'Thread creator bypass: responding without @mention'); @@ -786,7 +841,8 @@ async function handleMessageEvent(data: MessageEventData, accountId: string = 'd } else { // 语义判断:与多 bot 模式对齐,用 Qwen 小模型判断消息是否在跟 bot 对话 const botDisplayName = agentRegistry.get(agentId)?.displayName ?? 'bot'; - const relevant = await checkThreadRelevance(text, botDisplayName); + const recentCtx = threadId ? await fetchRelevanceContext(threadId, chatId, messageId, botDisplayName) : []; + const relevant = await checkThreadRelevance(text, botDisplayName, recentCtx); if (!relevant) { logger.info({ messageId, threadId, text: text?.slice(0, 100) }, 'Single-bot thread bypass skipped — message not directed at bot'); return; diff --git a/src/utils/__tests__/thread-relevance.test.ts b/src/utils/__tests__/thread-relevance.test.ts index 3c54f9e..7e55ec3 100644 --- a/src/utils/__tests__/thread-relevance.test.ts +++ b/src/utils/__tests__/thread-relevance.test.ts @@ -34,16 +34,20 @@ describe('parseRelevanceResponse', () => { expect(parseRelevanceResponse('respond: false')).toBe(false); }); - it('should default to true for unparseable response', () => { - expect(parseRelevanceResponse('不确定')).toBe(true); + it('should fallback to true when raw contains "true" keyword', () => { + expect(parseRelevanceResponse('respond: true')).toBe(true); }); - it('should default to true for empty string', () => { - expect(parseRelevanceResponse('')).toBe(true); + it('should default to false for unparseable response (宁可不回)', () => { + expect(parseRelevanceResponse('不确定')).toBe(false); }); - it('should handle malformed JSON gracefully', () => { - expect(parseRelevanceResponse('{respond: true')).toBe(true); + it('should default to false for empty string (宁可不回)', () => { + expect(parseRelevanceResponse('')).toBe(false); + }); + + it('should default to false for malformed JSON without keywords', () => { + expect(parseRelevanceResponse('{respond: ???}')).toBe(false); }); }); @@ -106,6 +110,54 @@ describe('checkThreadRelevance', () => { expect(userMsg).toContain('测试消息'); }); + it('should include recent context with sender names when provided', async () => { + mockCreate.mockResolvedValue({ + choices: [{ message: { content: '{"respond": false}' } }], + }); + + const recentMessages = [ + { senderType: 'app' as const, senderName: '大师', content: '好的,我来帮你看看' }, + { senderType: 'user' as const, senderName: '林美辰', content: '不给偷鸡' }, + ]; + + await checkThreadRelevance('你不是应该用SkillHub么?', '大师', recentMessages); + + const userMsg = mockCreate.mock.calls[0][0].messages[1].content; + expect(userMsg).toContain('最近对话'); + expect(userMsg).toContain('[大师(bot)]: 好的,我来帮你看看'); + expect(userMsg).toContain('[林美辰]: 不给偷鸡'); + expect(userMsg).toContain('新消息:你不是应该用SkillHub么?'); + }); + + it('should fallback to [bot]/[user] tag when senderName is missing', async () => { + mockCreate.mockResolvedValue({ + choices: [{ message: { content: '{"respond": true}' } }], + }); + + const recentMessages = [ + { senderType: 'app' as const, content: '收到' }, + { senderType: 'user' as const, content: '帮我看看' }, + ]; + + await checkThreadRelevance('继续', 'bot', recentMessages); + + const userMsg = mockCreate.mock.calls[0][0].messages[1].content; + expect(userMsg).toContain('[bot]: 收到'); + expect(userMsg).toContain('[user]: 帮我看看'); + }); + + it('should work without recent context (backward compatible)', async () => { + mockCreate.mockResolvedValue({ + choices: [{ message: { content: '{"respond": true}' } }], + }); + + await checkThreadRelevance('帮我查一下', 'bot'); + + const userMsg = mockCreate.mock.calls[0][0].messages[1].content; + expect(userMsg).not.toContain('最近对话'); + expect(userMsg).toContain('新消息:帮我查一下'); + }); + it('should default to false on API error', async () => { mockCreate.mockRejectedValue(new Error('API error')); diff --git a/src/utils/thread-relevance.ts b/src/utils/thread-relevance.ts index e7d75db..44aea69 100644 --- a/src/utils/thread-relevance.ts +++ b/src/utils/thread-relevance.ts @@ -10,6 +10,9 @@ import { getClient } from './quick-ack.js'; const RELEVANCE_PROMPT = `你是一个消息路由判断器。在一个群聊话题中,机器人之前参与了对话。 现在收到一条新消息(没有 @机器人),判断这条消息是否**明确需要机器人回复**。 +你会看到最近的对话上下文([bot] 表示机器人发的,[user] 表示人类发的)和当前新消息。 +请结合上下文判断"新消息"是在跟机器人说话,还是在跟其他人说话。 + 严格按 JSON 格式回复,不要输出任何其他内容: {"respond": true} 或 {"respond": false} @@ -22,9 +25,20 @@ respond: false 的条件: - 消息是在跟其他人聊天、讨论、感叹、评论 - 消息是自言自语、告知别人状态(如"等等"、"我看看"、"稍等") - 消息是对其他人说的话(即使话题中有机器人参与) +- 消息中的"你"指的是其他人而非机器人(根据上下文判断) - 短句/语气词/感叹(如"哦"、"好的"、"噗"、"可以"、"稳了") - 无法确定是否在跟机器人说话 → false(宁可不回)`; +/** 最近消息上下文条目(由调用方从 fetchRecentMessages 结果中精简) */ +export interface RecentMessage { + /** 'user' = 人类, 'app' = 机器人 */ + senderType: 'user' | 'app'; + /** 发送者名称(真名),未知时可省略 */ + senderName?: string; + /** 消息文本(已截断) */ + content: string; +} + /** * 判断话题内无 @mention 的消息是否需要 bot 回复。 * @@ -32,24 +46,40 @@ respond: false 的条件: * * @param message 用户消息文本 * @param botName bot 显示名称 + * @param recentMessages 最近 N 条消息上下文(不含当前消息),可选 * @returns true = 应该回复, false = 不应该回复 */ export async function checkThreadRelevance( message: string, botName: string, + recentMessages?: RecentMessage[], ): Promise { if (!config.quickAck.enabled) return false; // 未配置小模型,宁可不回,用户可 @bot 明确触发 const client = await getClient(); if (!client) return false; + // 组装上下文:最近消息 + 当前消息 + let userContent = `机器人名称:${botName}\n`; + if (recentMessages?.length) { + userContent += '最近对话:\n'; + for (const msg of recentMessages) { + const tag = msg.senderName + ? `[${msg.senderName}${msg.senderType === 'app' ? '(bot)' : ''}]` + : (msg.senderType === 'app' ? '[bot]' : '[user]'); + userContent += `${tag}: ${msg.content}\n`; + } + userContent += '\n'; + } + userContent += `新消息:${message.slice(0, 300)}`; + try { const result = await Promise.race([ client.chat.completions.create({ model: config.quickAck.model, messages: [ { role: 'system', content: RELEVANCE_PROMPT }, - { role: 'user', content: `机器人名称:${botName}\n消息内容:${message.slice(0, 300)}` }, + { role: 'user', content: userContent }, ], max_tokens: 20, temperature: 0, @@ -75,7 +105,7 @@ export async function checkThreadRelevance( /** * 解析 Qwen 返回的 JSON 判断结果。 - * 解析失败默认返回 true(宁可多回)。 + * 解析失败默认返回 false(宁可不回,与 checkThreadRelevance 设计原则一致)。 */ export function parseRelevanceResponse(raw: string): boolean { try { @@ -92,11 +122,12 @@ export function parseRelevanceResponse(raw: string): boolean { } // Fallback: check for keywords - if (raw.includes('false')) { - logger.info({ respond: false, raw, fallback: true }, 'Thread relevance check result (fallback)'); - return false; + if (raw.includes('true')) { + logger.info({ respond: true, raw, fallback: true }, 'Thread relevance check result (fallback)'); + return true; } - logger.info({ respond: true, raw, fallback: true }, 'Thread relevance check result (fallback)'); - return true; + // 默认不回复——宁可不回,用户可 @bot 明确触发 + logger.info({ respond: false, raw, fallback: true }, 'Thread relevance check result (fallback)'); + return false; }