Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 59 additions & 3 deletions src/feishu/event-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 循环依赖)
Expand Down Expand Up @@ -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<RecentMessage[]> {
try {
const messages = await feishuClient.fetchRecentMessages(threadId, 'thread', 8, chatId);
const result: Array<RecentMessage & { senderId?: string }> = [];
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`
Expand Down Expand Up @@ -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');
Expand All @@ -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;
Expand Down
64 changes: 58 additions & 6 deletions src/utils/__tests__/thread-relevance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});

Expand Down Expand Up @@ -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'));

Expand Down
45 changes: 38 additions & 7 deletions src/utils/thread-relevance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ import { getClient } from './quick-ack.js';
const RELEVANCE_PROMPT = `你是一个消息路由判断器。在一个群聊话题中,机器人之前参与了对话。
现在收到一条新消息(没有 @机器人),判断这条消息是否**明确需要机器人回复**。

你会看到最近的对话上下文([bot] 表示机器人发的,[user] 表示人类发的)和当前新消息。
请结合上下文判断"新消息"是在跟机器人说话,还是在跟其他人说话。

严格按 JSON 格式回复,不要输出任何其他内容:
{"respond": true} 或 {"respond": false}

Expand All @@ -22,34 +25,61 @@ respond: false 的条件:
- 消息是在跟其他人聊天、讨论、感叹、评论
- 消息是自言自语、告知别人状态(如"等等"、"我看看"、"稍等")
- 消息是对其他人说的话(即使话题中有机器人参与)
- 消息中的"你"指的是其他人而非机器人(根据上下文判断)
- 短句/语气词/感叹(如"哦"、"好的"、"噗"、"可以"、"稳了")
- 无法确定是否在跟机器人说话 → false(宁可不回)`;

/** 最近消息上下文条目(由调用方从 fetchRecentMessages 结果中精简) */
export interface RecentMessage {
/** 'user' = 人类, 'app' = 机器人 */
senderType: 'user' | 'app';
/** 发送者名称(真名),未知时可省略 */
senderName?: string;
/** 消息文本(已截断) */
content: string;
}

/**
* 判断话题内无 @mention 的消息是否需要 bot 回复。
*
* 使用 Qwen 小模型快速语义判断,超时/失败默认返回 false(宁可不回,用户可 @bot 明确触发)。
*
* @param message 用户消息文本
* @param botName bot 显示名称
* @param recentMessages 最近 N 条消息上下文(不含当前消息),可选
* @returns true = 应该回复, false = 不应该回复
*/
export async function checkThreadRelevance(
message: string,
botName: string,
recentMessages?: RecentMessage[],
): Promise<boolean> {
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,
Expand All @@ -75,7 +105,7 @@ export async function checkThreadRelevance(

/**
* 解析 Qwen 返回的 JSON 判断结果。
* 解析失败默认返回 true(宁可多回)。
* 解析失败默认返回 false(宁可不回,与 checkThreadRelevance 设计原则一致)。
*/
export function parseRelevanceResponse(raw: string): boolean {
try {
Expand All @@ -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;
}
Loading