diff --git a/src/__tests__/agent-router.test.ts b/src/__tests__/agent-router.test.ts index e7d2f8f4..c37c5a17 100644 --- a/src/__tests__/agent-router.test.ts +++ b/src/__tests__/agent-router.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { resolveAgent, shouldRespond, validateBindings } from '../agent/router.js'; +import { resolveAgent, shouldRespond, getRespondReason, validateBindings } from '../agent/router.js'; import type { AgentBinding, InboundContext } from '../agent/types.js'; describe('resolveAgent', () => { @@ -123,3 +123,71 @@ describe('validateBindings', () => { expect(validateBindings([])).toEqual([]); }); }); + +describe('getRespondReason', () => { + const botA = 'bot_open_id_a'; + const botB = 'bot_open_id_b'; + const allBots = new Set([botA, botB]); + const userMention = { id: { open_id: 'user_123' } }; + + it('returns "p2p" for private chats', () => { + expect(getRespondReason('p2p', [], botA, allBots)).toBe('p2p'); + expect(getRespondReason('p2p', [], botA, new Set())).toBe('p2p'); + }); + + it('returns "mentioned" when bot is @mentioned', () => { + const mentions = [{ id: { open_id: botA } }]; + expect(getRespondReason('group', mentions, botA, allBots)).toBe('mentioned'); + }); + + it('returns undefined when other bot is @mentioned but not this one', () => { + const mentions = [{ id: { open_id: botB } }]; + expect(getRespondReason('group', mentions, botA, allBots)).toBeUndefined(); + }); + + it('returns "commander" when no bot @mentioned and this bot is commander', () => { + expect(getRespondReason('group', [], botA, allBots, botA)).toBe('commander'); + }); + + it('returns undefined when no bot @mentioned and this bot is NOT commander', () => { + expect(getRespondReason('group', [], botB, allBots, botA)).toBeUndefined(); + }); + + it('returns undefined when no @mention and no commander', () => { + expect(getRespondReason('group', [], botA, allBots)).toBeUndefined(); + expect(getRespondReason('group', [], botB, allBots)).toBeUndefined(); + }); + + it('ignores human-only mentions (no commander)', () => { + expect(getRespondReason('group', [userMention], botA, allBots)).toBeUndefined(); + }); + + it('commander responds when only human mentions present', () => { + expect(getRespondReason('group', [userMention], botA, allBots, botA)).toBe('commander'); + }); + + it('explicit @mention overrides commander', () => { + const mentions = [{ id: { open_id: botB } }]; + expect(getRespondReason('group', mentions, botA, allBots, botA)).toBeUndefined(); + expect(getRespondReason('group', mentions, botB, allBots, botA)).toBe('mentioned'); + }); + + it('handles empty allBotOpenIds gracefully', () => { + const emptyBots = new Set(); + expect(getRespondReason('group', [], botA, emptyBots)).toBeUndefined(); + const mentions = [{ id: { open_id: botA } }]; + expect(getRespondReason('group', mentions, botA, emptyBots)).toBeUndefined(); + }); + + it('handles mentions with missing open_id', () => { + const mentions = [{ id: {} }, { id: { open_id: undefined } }]; + expect(getRespondReason('group', mentions, botA, allBots)).toBeUndefined(); + }); + + it('shouldRespond wrapper matches getRespondReason', () => { + expect(shouldRespond('p2p', [], botA, allBots)).toBe(true); + expect(shouldRespond('group', [], botA, allBots)).toBe(false); + expect(shouldRespond('group', [{ id: { open_id: botA } }], botA, allBots)).toBe(true); + expect(shouldRespond('group', [], botA, allBots, botA)).toBe(true); + }); +}); diff --git a/src/__tests__/lazy-history-images.test.ts b/src/__tests__/lazy-history-images.test.ts new file mode 100644 index 00000000..c9340731 --- /dev/null +++ b/src/__tests__/lazy-history-images.test.ts @@ -0,0 +1,172 @@ +/** + * Tests for lazy loading of parent chat images in history context. + * + * 与 lazy-history-files.test.ts 对齐:当 buildChatHistoryContext / buildDirectTaskHistory + * 从父群补充消息时,父群中的图片附件不应被自动下载并嵌入 prompt。 + * 应仅注入元数据提示(包含 message_id 和 image_key),由 LLM 在需要时 + * 通过 feishu_download_message_image MCP 工具按需加载。 + * + * 这条防护针对的真实场景:群聊里有人发简历图片 → 别人在话题里 @bot 问另一份简历, + * 此前 bot 会把所有简历图片一起下载分析,导致候选人信息混淆。 + */ +// @ts-nocheck — test file +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const mockDownloadMessageImage = vi.fn(); + +vi.mock('../feishu/client.js', () => ({ + feishuClient: { + downloadMessageImage: (...args: unknown[]) => mockDownloadMessageImage(...args), + }, +})); + +vi.mock('../utils/logger.js', () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +vi.mock('../utils/image-compress.js', () => ({ + compressImage: vi.fn(), + compressImageForHistory: vi.fn(async (buf: Buffer, mediaType: string) => ({ + data: buf, + mediaType, + })), +})); + +import { _testDownloadHistoryImages as downloadHistoryImages } from '../feishu/event-handler.js'; + +function makeMsg(id: string, imageRefs?: Array<{ imageKey: string }>) { + return { messageId: id, ...(imageRefs ? { imageRefs } : {}) }; +} + +// JPEG magic bytes — let detectImageMediaType succeed +const JPEG_PREFIX = Buffer.from([0xff, 0xd8, 0xff, 0xe0]); +function makeImageBuf(payload = 'fake'): Buffer { + return Buffer.concat([JPEG_PREFIX, Buffer.from(payload)]); +} + +describe('downloadHistoryImages with parentMsgCount (lazy loading)', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockDownloadMessageImage.mockResolvedValue(makeImageBuf()); + }); + + it('downloads all images when parentMsgCount is 0 (default)', async () => { + const messages = [ + makeMsg('m1', [{ imageKey: 'ik1' }]), + makeMsg('m2', [{ imageKey: 'ik2' }]), + ]; + + const result = await downloadHistoryImages(messages); + + expect(mockDownloadMessageImage).toHaveBeenCalledTimes(2); + expect(result.images).toHaveLength(2); + expect(result.lazyHints).toHaveLength(0); + }); + + it('skips parent message images and outputs metadata instead', async () => { + const messages = [ + // 父群消息(index 0,在 parentMsgCount=1 范围内)— 模拟群里其他人发的简历 + makeMsg('parent_msg_other_resume', [{ imageKey: 'ik_other_resume' }]), + // 话题消息(index 1,超出 parentMsgCount)— 用户当前关心的简历 + makeMsg('thread_msg_target_resume', [{ imageKey: 'ik_target_resume' }]), + ]; + + const result = await downloadHistoryImages(messages, 1); + + // 仅话题图片被下载 + expect(mockDownloadMessageImage).toHaveBeenCalledTimes(1); + expect(mockDownloadMessageImage).toHaveBeenCalledWith('thread_msg_target_resume', 'ik_target_resume'); + + expect(result.images).toHaveLength(1); + + // 父群图片变成元数据 + expect(result.lazyHints).toHaveLength(1); + expect(result.lazyHints[0]).toContain('parent_msg_other_resume'); + expect(result.lazyHints[0]).toContain('ik_other_resume'); + expect(result.lazyHints[0]).toContain('feishu_download_message_image'); + expect(result.lazyHints[0]).toContain('未自动加载'); + }); + + it('handles parent-only scenario (all images from parent chat)', async () => { + const messages = [ + makeMsg('p1', [{ imageKey: 'ik1' }]), + makeMsg('p2', [{ imageKey: 'ik2' }]), + ]; + + const result = await downloadHistoryImages(messages, 2); + + // 全部父群图片都不下载 + expect(mockDownloadMessageImage).not.toHaveBeenCalled(); + expect(result.images).toHaveLength(0); + expect(result.lazyHints).toHaveLength(2); + }); + + it('handles thread-only scenario (parentMsgCount=0)', async () => { + const messages = [ + makeMsg('t1', [{ imageKey: 'ik1' }]), + ]; + + const result = await downloadHistoryImages(messages, 0); + + expect(mockDownloadMessageImage).toHaveBeenCalledTimes(1); + expect(result.images).toHaveLength(1); + expect(result.lazyHints).toHaveLength(0); + }); + + it('returns empty when no images in messages', async () => { + const messages = [makeMsg('m1'), makeMsg('m2')]; + + const result = await downloadHistoryImages(messages, 1); + + expect(mockDownloadMessageImage).not.toHaveBeenCalled(); + expect(result.images).toHaveLength(0); + expect(result.lazyHints).toHaveLength(0); + }); + + it('metadata format includes message_id and image_key for tool invocation', async () => { + const messages = [ + makeMsg('om_abc123', [{ imageKey: 'img_xyz789' }]), + ]; + + const result = await downloadHistoryImages(messages, 1); + + const meta = result.lazyHints[0]; + expect(meta).toContain('message_id="om_abc123"'); + expect(meta).toContain('image_key="img_xyz789"'); + }); + + it('handles download failure for thread images gracefully', async () => { + mockDownloadMessageImage.mockRejectedValue(new Error('network error')); + + const messages = [ + makeMsg('p1', [{ imageKey: 'ik1' }]), + makeMsg('t1', [{ imageKey: 'ik2' }]), + ]; + + const result = await downloadHistoryImages(messages, 1); + + // 父群图片仍输出元数据 + expect(result.lazyHints).toHaveLength(1); + expect(result.lazyHints[0]).toContain('p1'); + // 话题图片下载失败,images 为空 + expect(result.images).toHaveLength(0); + }); + + it('multiple imageRefs in one message are tracked separately by source', async () => { + const messages = [ + makeMsg('parent_msg', [{ imageKey: 'ik_p1' }, { imageKey: 'ik_p2' }]), + makeMsg('thread_msg', [{ imageKey: 'ik_t1' }]), + ]; + + const result = await downloadHistoryImages(messages, 1); + + // 父群消息的两张图片都成 metadata + expect(result.lazyHints).toHaveLength(2); + expect(result.lazyHints[0]).toContain('ik_p1'); + expect(result.lazyHints[1]).toContain('ik_p2'); + + // 话题图片正常下载 + expect(mockDownloadMessageImage).toHaveBeenCalledTimes(1); + expect(mockDownloadMessageImage).toHaveBeenCalledWith('thread_msg', 'ik_t1'); + }); +}); diff --git a/src/__tests__/mention-gate.test.ts b/src/__tests__/mention-gate.test.ts new file mode 100644 index 00000000..56556c1b --- /dev/null +++ b/src/__tests__/mention-gate.test.ts @@ -0,0 +1,438 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── Mocks ── + +vi.mock('@larksuiteoapi/node-sdk', () => { + class MockClient { + constructor() {} + request = vi.fn().mockResolvedValue({ code: 0, bot: { open_id: 'ou_self_bot', app_name: 'TestBot' } }); + im = { + message: { create: vi.fn(), reply: vi.fn(), patch: vi.fn() }, + chatMembers: {}, + }; + contact = { user: { get: vi.fn() } }; + } + return { + Client: MockClient, + WSClient: class { start = vi.fn() }, + EventDispatcher: class { register = vi.fn() }, + CardActionHandler: class {}, + }; +}); + +vi.mock('../utils/logger.js', () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +const mockIsMultiBotMode = vi.fn(() => false); +vi.mock('../config.js', () => ({ + config: { + feishu: { encryptKey: '', verifyToken: '', tools: { doc: false, wiki: false, drive: false, bitable: false, chat: false } }, + claude: { defaultWorkDir: '/tmp' }, + agent: { bindings: [], botAccounts: [], groupConfigs: {} as Record }, + chat: { historyMaxCount: 10, historyMaxChars: 4000 }, + db: { sessionDbPath: ':memory:' }, + quickAck: { enabled: false }, + security: {}, + }, + isMultiBotMode: () => mockIsMultiBotMode(), +})); + +const mockGetBotOpenId = vi.fn((_id: string) => 'ou_dev_bot'); +const mockGetAllBotOpenIds = vi.fn(() => new Set(['ou_dev_bot', 'ou_pm_bot'])); +vi.mock('../feishu/multi-account.js', () => ({ + accountManager: { + getAllBotOpenIds: () => mockGetAllBotOpenIds(), + getBotOpenId: (id: string) => mockGetBotOpenId(id), + getClient: vi.fn(), + getDefaultClient: vi.fn(), + allAccounts: vi.fn(() => []), + initializeSingleBot: vi.fn(), + }, +})); + +vi.mock('../feishu/client.js', () => ({ + feishuClient: { + botOpenId: 'ou_self_bot', + replyText: vi.fn(), replyTextInThread: vi.fn(), sendCard: vi.fn(), + replyCardInThread: vi.fn(), updateCard: vi.fn(), downloadMessageImage: vi.fn(), + fetchRecentMessages: vi.fn().mockResolvedValue([]), getUserName: vi.fn(), + getChatMembers: vi.fn().mockResolvedValue([]), fetchBotInfo: vi.fn(), + }, + runWithAccountId: vi.fn((_: string, fn: () => any) => fn()), +})); + +const mockGetThreadSession = vi.fn((_threadId: string, _agentId?: string) => undefined as any); +vi.mock('../session/manager.js', () => ({ + sessionManager: { + get: vi.fn(), getOrCreate: vi.fn(() => ({ workingDir: '/tmp', status: 'idle' })), + setWorkingDir: vi.fn(), setStatus: vi.fn(), setConversationId: vi.fn(), + getThreadSession: (...args: any[]) => mockGetThreadSession(args[0], args[1]), + upsertThreadSession: vi.fn(), setThreadConversationId: vi.fn(), + setThreadWorkingDir: vi.fn(), resetThreadConversation: vi.fn(), + reset: vi.fn(), cleanup: vi.fn(), close: vi.fn(), + }, +})); + +vi.mock('../session/queue.js', () => ({ taskQueue: { enqueue: vi.fn(), dequeue: vi.fn(), complete: vi.fn(), cancelAllForChat: vi.fn(), pendingCountForChat: vi.fn(() => 0) } })); +vi.mock('../claude/executor.js', () => ({ claudeExecutor: { execute: vi.fn(), killSessionsForChat: vi.fn(), killAll: vi.fn(), cleanup: vi.fn() } })); +vi.mock('../feishu/approval.js', () => ({ checkAndRequestApproval: vi.fn().mockResolvedValue(true), handleApprovalTextCommand: vi.fn(() => false), handleApprovalCardAction: vi.fn(), setOnApproved: vi.fn(), cleanupExpiredApprovals: vi.fn() })); +vi.mock('../feishu/thread-context.js', () => ({ resolveThreadContext: vi.fn() })); +vi.mock('../pipeline/store.js', () => ({ pipelineStore: { get: vi.fn(), findPendingByChat: vi.fn(), tryStart: vi.fn(), markRunningAsInterrupted: vi.fn(), cleanExpired: vi.fn(), close: vi.fn() } })); +vi.mock('../pipeline/runner.js', () => ({ createPendingPipeline: vi.fn(), startPipeline: vi.fn(), abortPipeline: vi.fn(), cancelPipeline: vi.fn(), retryPipeline: vi.fn(), recoverInterruptedPipelines: vi.fn().mockResolvedValue(undefined) })); +vi.mock('../agent/router.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + resolveAgent: vi.fn(() => 'dev'), + getRespondReason: actual.getRespondReason, + shouldRespond: vi.fn(() => true), + }; +}); + +const mockAgentRegistryGet = vi.fn((_id: string) => undefined as any); +vi.mock('../agent/registry.js', () => ({ + agentRegistry: { + get: (id: string) => mockAgentRegistryGet(id), + getOrThrow: vi.fn(() => ({ replyMode: 'thread' })), + allIds: vi.fn(() => ['dev', 'pm']), + }, +})); +vi.mock('../agent/config-loader.js', () => ({ readPersonaFile: vi.fn(), loadKnowledgeContent: vi.fn(), loadAgentConfig: vi.fn(() => ({})), startConfigWatcher: vi.fn(), stopConfigWatcher: vi.fn(), reloadAgentConfig: vi.fn() })); +vi.mock('../agent/tools/discussion.js', () => ({ createDiscussionMcpServer: vi.fn() })); +vi.mock('../workspace/manager.js', () => ({ setupWorkspace: vi.fn() })); + +const mockCheckThreadRelevance = vi.fn(async (_msg?: unknown, _bot?: unknown) => false); +vi.mock('../utils/thread-relevance.js', () => ({ + checkThreadRelevance: (msg: unknown, bot: unknown) => mockCheckThreadRelevance(msg, bot), +})); + +const mockIsOwner = vi.fn((_userId: string) => false); +vi.mock('../utils/security.js', () => ({ + isUserAllowed: vi.fn(() => true), + containsDangerousCommand: vi.fn(() => false), + isOwner: (userId: string) => mockIsOwner(userId), + autoDetectOwner: vi.fn(() => false), +})); + +// ── Tests ── + +describe('resolveMentionGate', () => { + let resolveMentionGate: (input: any) => Promise; + + const baseInput = { + chatType: 'group', + mentionedBot: false, + mentions: [] as Array<{ id: { open_id?: string } }>, + threadId: undefined as string | undefined, + messageId: 'msg_001', + text: '拉一下 四季物语 这个游戏的源码', + userId: 'ou_user_1', + chatId: 'oc_chat_1', + agentId: 'dev', + accountId: 'dev', + images: undefined as any, + documents: undefined as any, + }; + + beforeEach(async () => { + vi.clearAllMocks(); + mockIsMultiBotMode.mockReturnValue(false); + mockGetBotOpenId.mockReturnValue('ou_dev_bot'); + mockGetAllBotOpenIds.mockReturnValue(new Set(['ou_dev_bot', 'ou_pm_bot'])); + mockGetThreadSession.mockReturnValue(undefined); + mockCheckThreadRelevance.mockResolvedValue(false); + mockIsOwner.mockReturnValue(false); + mockAgentRegistryGet.mockReturnValue(undefined); + + const mod = await import('../feishu/event-handler.js'); + resolveMentionGate = mod._testing.resolveMentionGate; + }); + + // ── 私聊 ── + + it('allows all p2p messages', async () => { + expect(await resolveMentionGate({ ...baseInput, chatType: 'p2p' })).toBe('p2p'); + }); + + it('allows p2p even without @mention', async () => { + expect(await resolveMentionGate({ ...baseInput, chatType: 'p2p', mentionedBot: false })).toBe('p2p'); + }); + + // ── 单 bot 模式 ── + + describe('single-bot mode', () => { + beforeEach(() => { + mockIsMultiBotMode.mockReturnValue(false); + }); + + it('allows group message when bot is @mentioned', async () => { + expect(await resolveMentionGate({ ...baseInput, mentionedBot: true })).toBe('mentioned'); + }); + + it('blocks group message without @mention and no thread session', async () => { + expect(await resolveMentionGate({ ...baseInput })).toBeUndefined(); + }); + + it('blocks group message without @mention even with threadId but no session', async () => { + expect(await resolveMentionGate({ ...baseInput, threadId: 'omt_123' })).toBeUndefined(); + }); + + it('blocks when thread session exists but user is not owner/creator', async () => { + mockGetThreadSession.mockReturnValue({ userId: 'ou_other_user', createdAt: new Date().toISOString() }); + expect(await resolveMentionGate({ ...baseInput, threadId: 'omt_123' })).toBeUndefined(); + }); + + it('allows media from thread session creator', async () => { + mockGetThreadSession.mockReturnValue({ userId: 'ou_user_1', createdAt: new Date().toISOString() }); + expect(await resolveMentionGate({ + ...baseInput, threadId: 'omt_123', images: [{ key: 'img_key' }], + })).toBe('thread_session_media'); + }); + + it('allows message from thread session creator when only two participants', async () => { + mockGetThreadSession.mockReturnValue({ userId: 'ou_user_1', createdAt: new Date().toISOString() }); + // fetchRecentMessages returns [] → humanSenders.size=0 → dual-person bypass + expect(await resolveMentionGate({ ...baseInput, threadId: 'omt_123' })).toBe('thread_session_owner'); + }); + + it('uses Qwen when third person present in thread', async () => { + mockGetThreadSession.mockReturnValue({ userId: 'ou_user_1', createdAt: new Date().toISOString() }); + const { feishuClient: fc } = await import('../feishu/client.js'); + vi.mocked(fc.fetchRecentMessages).mockResolvedValue([ + { messageId: 'm1', senderId: 'ou_user_1', senderType: 'user', content: 'hello', msgType: 'text' }, + { messageId: 'm2', senderId: 'ou_user_2', senderType: 'user', content: 'hi', msgType: 'text' }, + ] as any); + mockCheckThreadRelevance.mockResolvedValue(false); + expect(await resolveMentionGate({ ...baseInput, threadId: 'omt_123' })).toBeUndefined(); + vi.mocked(fc.fetchRecentMessages).mockResolvedValue([]); + }); + + it('allows owner in thread even if not session creator', async () => { + mockGetThreadSession.mockReturnValue({ userId: 'ou_other_user', createdAt: new Date().toISOString() }); + mockIsOwner.mockReturnValue(true); + mockCheckThreadRelevance.mockResolvedValue(true); + expect(await resolveMentionGate({ ...baseInput, threadId: 'omt_123' })).toBe('thread_session_owner'); + }); + + it('allows non-group chat types without @mention', async () => { + expect(await resolveMentionGate({ ...baseInput, chatType: 'supergroup' })).toBe('non_group'); + }); + }); + + // ── 多 bot 模式 ── + + describe('multi-bot mode', () => { + beforeEach(() => { + mockIsMultiBotMode.mockReturnValue(true); + }); + + it('blocks when no bot @mentioned and no commander (the bug scenario)', async () => { + const humanMention = { id: { open_id: 'ou_human_user' } }; + expect(await resolveMentionGate({ + ...baseInput, + mentions: [humanMention], + threadId: 'omt_topic_123', + })).toBeUndefined(); + }); + + it('blocks when no mentions at all in group', async () => { + expect(await resolveMentionGate({ ...baseInput })).toBeUndefined(); + }); + + it('allows when this bot is @mentioned', async () => { + const botMention = { id: { open_id: 'ou_dev_bot' } }; + expect(await resolveMentionGate({ + ...baseInput, mentions: [botMention], + })).toBe('mentioned'); + }); + + it('blocks when other bot is @mentioned but not this one', async () => { + const otherBotMention = { id: { open_id: 'ou_pm_bot' } }; + expect(await resolveMentionGate({ + ...baseInput, mentions: [otherBotMention], + })).toBeUndefined(); + }); + + it('allows commander when no bot @mentioned', async () => { + const { config } = await import('../config.js'); + (config.agent.groupConfigs as any)['oc_chat_1'] = { commander: 'dev' }; + mockGetBotOpenId.mockImplementation((id: string) => id === 'dev' ? 'ou_dev_bot' : ''); + + expect(await resolveMentionGate({ ...baseInput })).toBe('commander'); + + delete (config.agent.groupConfigs as any)['oc_chat_1']; + }); + + it('does NOT allow thread_bypass without existing thread session', async () => { + mockGetThreadSession.mockReturnValue(undefined); + expect(await resolveMentionGate({ + ...baseInput, threadId: 'omt_new_topic', + })).toBeUndefined(); + }); + + it('allows thread_bypass_exclusive when only creator and bot', async () => { + mockGetThreadSession.mockImplementation((_: string, agentId?: string) => { + if (agentId === 'dev') return { userId: 'ou_user_1', createdAt: '2026-01-01T00:00:00Z' }; + return undefined; + }); + // fetchRecentMessages returns [] → only creator + bot → exclusive bypass + expect(await resolveMentionGate({ + ...baseInput, threadId: 'omt_existing_topic', + })).toBe('thread_bypass_exclusive'); + }); + + it('allows thread_bypass when third person present and Qwen says yes', async () => { + mockGetThreadSession.mockImplementation((_: string, agentId?: string) => { + if (agentId === 'dev') return { userId: 'ou_user_1', createdAt: '2026-01-01T00:00:00Z' }; + return undefined; + }); + const { feishuClient: fc } = await import('../feishu/client.js'); + vi.mocked(fc.fetchRecentMessages).mockResolvedValue([ + { messageId: 'm1', senderId: 'ou_user_1', senderType: 'user', content: 'hi', msgType: 'text' }, + { messageId: 'm2', senderId: 'ou_user_other', senderType: 'user', content: 'yo', msgType: 'text' }, + ] as any); + mockCheckThreadRelevance.mockResolvedValue(true); + + expect(await resolveMentionGate({ + ...baseInput, threadId: 'omt_existing_topic', + })).toBe('thread_bypass'); + vi.mocked(fc.fetchRecentMessages).mockResolvedValue([]); + }); + + it('blocks thread_bypass when third person present and Qwen says no', async () => { + mockGetThreadSession.mockImplementation((_: string, agentId?: string) => { + if (agentId === 'dev') return { userId: 'ou_user_1', createdAt: '2026-01-01T00:00:00Z' }; + return undefined; + }); + const { feishuClient: fc } = await import('../feishu/client.js'); + vi.mocked(fc.fetchRecentMessages).mockResolvedValue([ + { messageId: 'm1', senderId: 'ou_user_1', senderType: 'user', content: 'hi', msgType: 'text' }, + { messageId: 'm2', senderId: 'ou_user_other', senderType: 'user', content: 'yo', msgType: 'text' }, + ] as any); + mockCheckThreadRelevance.mockResolvedValue(false); + + expect(await resolveMentionGate({ + ...baseInput, threadId: 'omt_existing_topic', + })).toBeUndefined(); + vi.mocked(fc.fetchRecentMessages).mockResolvedValue([]); + }); + + it('blocks thread_bypass when user is not session creator or owner', async () => { + mockGetThreadSession.mockImplementation((_: string, agentId?: string) => { + if (agentId === 'dev') return { userId: 'ou_different_user', createdAt: '2026-01-01T00:00:00Z' }; + return undefined; + }); + mockCheckThreadRelevance.mockResolvedValue(true); + + expect(await resolveMentionGate({ + ...baseInput, threadId: 'omt_existing_topic', + })).toBeUndefined(); + }); + + it('skips thread_bypass when another bot was @mentioned', async () => { + mockGetThreadSession.mockImplementation((_: string, agentId?: string) => { + if (agentId === 'dev') return { userId: 'ou_user_1', createdAt: '2026-01-01T00:00:00Z' }; + return undefined; + }); + const otherBotMention = { id: { open_id: 'ou_pm_bot' } }; + // When a bot is mentioned, thread bypass is skipped; getRespondReason checks mention + expect(await resolveMentionGate({ + ...baseInput, mentions: [otherBotMention], threadId: 'omt_existing_topic', + })).toBeUndefined(); + }); + }); + + // ── 边界情况 ── + + describe('edge cases', () => { + it('handles empty text gracefully', async () => { + expect(await resolveMentionGate({ ...baseInput, text: '' })).toBeUndefined(); + }); + + it('handles undefined images/documents', async () => { + expect(await resolveMentionGate({ + ...baseInput, images: undefined, documents: undefined, + })).toBeUndefined(); + }); + + it('handles mentions with missing open_id', async () => { + mockIsMultiBotMode.mockReturnValue(true); + const brokenMention = { id: {} }; + expect(await resolveMentionGate({ + ...baseInput, mentions: [brokenMention], + })).toBeUndefined(); + }); + + it('multi-bot mode with empty botOpenId still blocks', async () => { + mockIsMultiBotMode.mockReturnValue(true); + mockGetBotOpenId.mockReturnValue(undefined as any); + expect(await resolveMentionGate({ ...baseInput })).toBeUndefined(); + }); + }); + + // ── Bug 复现场景 ── + + describe('bug reproduction: 话题内 @人类 但 bot 不应响应', () => { + it('multi-bot: @human in topic without bot session → blocked', async () => { + mockIsMultiBotMode.mockReturnValue(true); + const humanMention = { id: { open_id: 'ou_relic_product_xz' } }; + + const result = await resolveMentionGate({ + ...baseInput, + mentions: [humanMention], + threadId: 'omt_1a9f2e23ad959c95', + text: '@Relic-产品小赵 拉一下 四季物语 这个游戏的源码', + }); + + expect(result).toBeUndefined(); + }); + + it('multi-bot: plain message in topic without bot session → blocked', async () => { + mockIsMultiBotMode.mockReturnValue(true); + + const result = await resolveMentionGate({ + ...baseInput, + threadId: 'omt_1a9f2e23ad959c95', + text: '查个边玩边下和预加载的问题', + }); + + expect(result).toBeUndefined(); + }); + + it('multi-bot: @human in topic WITH bot session, third person present, semantic=false → blocked', async () => { + mockIsMultiBotMode.mockReturnValue(true); + mockGetThreadSession.mockImplementation((_: string, agentId?: string) => { + if (agentId === 'dev') return { userId: 'ou_user_1', createdAt: '2026-01-01T00:00:00Z' }; + return undefined; + }); + const { feishuClient: fc } = await import('../feishu/client.js'); + vi.mocked(fc.fetchRecentMessages).mockResolvedValue([ + { messageId: 'm1', senderId: 'ou_user_1', senderType: 'user', content: 'hi', msgType: 'text' }, + { messageId: 'm2', senderId: 'ou_relic_product_xz', senderType: 'user', content: 'ok', msgType: 'text' }, + ] as any); + mockCheckThreadRelevance.mockResolvedValue(false); + + const humanMention = { id: { open_id: 'ou_relic_product_xz' } }; + const result = await resolveMentionGate({ + ...baseInput, + mentions: [humanMention], + threadId: 'omt_topic', + text: '@Relic-产品小赵 看一下这个问题', + }); + + expect(result).toBeUndefined(); + vi.mocked(fc.fetchRecentMessages).mockResolvedValue([]); + }); + + it('single-bot: group message without @mention → blocked', async () => { + mockIsMultiBotMode.mockReturnValue(false); + + const result = await resolveMentionGate({ + ...baseInput, + text: '查个边玩边下和预加载的问题', + }); + + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/src/__tests__/queue-context.test.ts b/src/__tests__/queue-context.test.ts new file mode 100644 index 00000000..3bb6c6d0 --- /dev/null +++ b/src/__tests__/queue-context.test.ts @@ -0,0 +1,163 @@ +/** + * 测试 task queue 的 AsyncLocalStorage 上下文隔离。 + * + * 复现 bug:dev bot 任务先执行,pm bot 任务排队; + * dev 任务 .finally() 触发 pm 任务出队,此时上下文应为 pm 而非 dev。 + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { AsyncLocalStorage } from 'node:async_hooks'; + +vi.mock('../utils/logger.js', () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +import { TaskQueue } from '../session/queue.js'; + +describe('Queue accountId context isolation', () => { + const als = new AsyncLocalStorage(); + let queue: TaskQueue; + + beforeEach(() => { + queue = new TaskQueue(); + }); + + it('QueueTask preserves accountId through enqueue/dequeue', () => { + queue.enqueue('q1', 'chat', 'u1', 'dev-msg', 'mid1', undefined, undefined, undefined, undefined, undefined, undefined, undefined, 'dev'); + queue.enqueue('q1', 'chat', 'u1', 'pm-msg', 'mid2', undefined, undefined, undefined, undefined, undefined, undefined, undefined, 'pm'); + + const t1 = queue.dequeue('q1'); + expect(t1!.message).toBe('dev-msg'); + expect(t1!.accountId).toBe('dev'); + + queue.complete('q1'); + + const t2 = queue.dequeue('q1'); + expect(t2!.message).toBe('pm-msg'); + expect(t2!.accountId).toBe('pm'); + }); + + it('accountId defaults to undefined when not provided', () => { + queue.enqueue('q1', 'chat', 'u1', 'msg', 'mid1'); + const t = queue.dequeue('q1'); + expect(t!.accountId).toBeUndefined(); + }); + + it('BUG: .finally() inherits outer AsyncLocalStorage context', async () => { + const contexts: (string | undefined)[] = []; + + await new Promise((resolve) => { + als.run('dev', () => { + Promise.resolve().finally(() => { + // .finally() 继承了 'dev' 上下文 — 这就是 bug 的根因 + contexts.push(als.getStore()); + resolve(); + }); + }); + }); + + expect(contexts[0]).toBe('dev'); + }); + + it('FIX: als.run() in .finally() overrides inherited context', async () => { + const contexts: (string | undefined)[] = []; + + await new Promise((resolve) => { + als.run('dev', () => { + Promise.resolve().finally(() => { + // 不加 als.run:拿到 'dev'(bug) + contexts.push(als.getStore()); + + // 加 als.run:覆盖为 'pm'(fix) + als.run('pm', () => { + contexts.push(als.getStore()); + }); + + resolve(); + }); + }); + }); + + expect(contexts[0]).toBe('dev'); // 未修复时继承的上下文 + expect(contexts[1]).toBe('pm'); // 修复后正确的上下文 + }); + + it('FIX: simulates processQueue chaining with correct context per task', async () => { + // 模拟真实场景:两个不同 bot 的任务在同一队列中依次执行 + queue.enqueue('q1', 'chat', 'u1', 'dev-task', 'mid1', undefined, undefined, undefined, undefined, undefined, undefined, undefined, 'dev'); + queue.enqueue('q1', 'chat', 'u1', 'pm-task', 'mid2', undefined, undefined, undefined, undefined, undefined, undefined, undefined, 'pm'); + + const executionContexts: { message: string; accountId: string | undefined }[] = []; + + // 模拟 processQueue 的修复逻辑 + function processQueueFixed() { + const task = queue.dequeue('q1'); + if (!task) return Promise.resolve(); + + const taskAccountId = task.accountId ?? 'default'; + + return new Promise((resolve) => { + als.run(taskAccountId, () => { + // 任务执行时记录当前上下文 + executionContexts.push({ + message: task.message, + accountId: als.getStore(), + }); + + // 模拟异步执行完成 + Promise.resolve() + .then(() => task.resolve('done')) + .finally(() => { + queue.complete('q1'); + // 链式处理下一个任务(关键:在 .finally 中) + processQueueFixed().then(resolve); + }); + }); + }); + } + + // 在 dev 上下文中启动第一个任务(模拟 handleMessageEvent) + await als.run('dev', () => processQueueFixed()); + + // 验证:dev 任务用 dev 上下文,pm 任务用 pm 上下文 + expect(executionContexts).toEqual([ + { message: 'dev-task', accountId: 'dev' }, + { message: 'pm-task', accountId: 'pm' }, + ]); + }); + + it('WITHOUT FIX: pm task would inherit dev context', async () => { + // 演示 bug:不用 als.run 包裹时,pm 任务继承 dev 上下文 + queue.enqueue('q1', 'chat', 'u1', 'dev-task', 'mid1', undefined, undefined, undefined, undefined, undefined, undefined, undefined, 'dev'); + queue.enqueue('q1', 'chat', 'u1', 'pm-task', 'mid2', undefined, undefined, undefined, undefined, undefined, undefined, undefined, 'pm'); + + const executionContexts: { message: string; accountId: string | undefined }[] = []; + + function processQueueBuggy() { + const task = queue.dequeue('q1'); + if (!task) return Promise.resolve(); + + // BUG: 不用 als.run 包裹,直接执行 + return new Promise((resolve) => { + executionContexts.push({ + message: task.message, + accountId: als.getStore(), // 继承外层上下文 + }); + + Promise.resolve() + .then(() => task.resolve('done')) + .finally(() => { + queue.complete('q1'); + processQueueBuggy().then(resolve); + }); + }); + } + + await als.run('dev', () => processQueueBuggy()); + + // BUG: pm 任务也看到 'dev' 上下文 + expect(executionContexts).toEqual([ + { message: 'dev-task', accountId: 'dev' }, + { message: 'pm-task', accountId: 'dev' }, // ← 这就是 bug!应该是 'pm' + ]); + }); +}); diff --git a/src/agent/router.ts b/src/agent/router.ts index 5d5494e0..ba52d538 100644 --- a/src/agent/router.ts +++ b/src/agent/router.ts @@ -88,22 +88,23 @@ export function validateBindings(bindings: AgentBinding[]): string[] { return warnings; } +/** 放行原因 — 用于 @mention 过滤器的白名单日志 */ +export type RespondReason = 'p2p' | 'mentioned' | 'commander'; + /** - * 群内 @mention 路由 — 判断当前 bot 是否应该响应此消息 + * 群内 @mention 路由 — 返回放行原因,undefined 表示不响应。 * * 优先级:显式 @mention > commander 模式 > 不响应 */ -export function shouldRespond( +export function getRespondReason( chatType: string, mentions: Array<{ id: { open_id?: string } }>, botOpenId: string, allBotOpenIds: Set, commanderBotOpenId?: string, -): boolean { - // 私聊:始终响应 - if (chatType === 'p2p') return true; +): RespondReason | undefined { + if (chatType === 'p2p') return 'p2p'; - // 群聊:分析 @mention const mentionedBotIds = new Set(); for (const m of mentions) { if (m.id.open_id && allBotOpenIds.has(m.id.open_id)) { @@ -111,16 +112,22 @@ export function shouldRespond( } } - // 规则 1: 消息明确 @了某个 bot → 只有被 @的 bot 响应 if (mentionedBotIds.size > 0) { - return mentionedBotIds.has(botOpenId); + return mentionedBotIds.has(botOpenId) ? 'mentioned' : undefined; } - // 规则 2: 没有 @任何 bot → commander 响应 - if (commanderBotOpenId && botOpenId === commanderBotOpenId) { - return true; - } + if (commanderBotOpenId && botOpenId === commanderBotOpenId) return 'commander'; - // 规则 3: 没有 @,也没有 commander → 不响应 - return false; + return undefined; +} + +/** 向后兼容 wrapper */ +export function shouldRespond( + chatType: string, + mentions: Array<{ id: { open_id?: string } }>, + botOpenId: string, + allBotOpenIds: Set, + commanderBotOpenId?: string, +): boolean { + return getRespondReason(chatType, mentions, botOpenId, allBotOpenIds, commanderBotOpenId) !== undefined; } diff --git a/src/feishu/event-handler.ts b/src/feishu/event-handler.ts index 49579f02..f7d9f877 100644 --- a/src/feishu/event-handler.ts +++ b/src/feishu/event-handler.ts @@ -23,7 +23,7 @@ import { cancelPipeline, retryPipeline, } from '../pipeline/runner.js'; -import { resolveAgent, shouldRespond } from '../agent/router.js'; +import { resolveAgent, getRespondReason } from '../agent/router.js'; import { agentRegistry } from '../agent/registry.js'; import { accountManager } from './multi-account.js'; import { chatBotRegistry } from './bot-registry.js'; @@ -47,7 +47,7 @@ setOnApproved((chatId, userId, text, messageId, accountId, agentId, rootId, thre // accountId 用于恢复正确的 feishuClient 上下文(哪个 bot 收到的消息就由哪个 bot 处理) const queueKey = makeQueueKey(chatId, threadId, agentId as AgentId); runWithAccountId(accountId, () => { - taskQueue.enqueue(queueKey, chatId, userId, text, messageId, rootId, threadId).catch(() => {}); + taskQueue.enqueue(queueKey, chatId, userId, text, messageId, rootId, threadId, undefined, undefined, undefined, undefined, undefined, accountId).catch(() => {}); processQueue(queueKey, agentId as AgentId); }); }); @@ -276,7 +276,7 @@ export function createEventDispatcher(accountId: string = 'default'): lark.Event dispatcher.register({ 'card.action.trigger': async (data: Record) => { try { - const cardBody = await handleCardAction(data); + const cardBody = await runWithAccountId(accountId, () => handleCardAction(data)); // Toast responses: return as-is (no card replacement, just show notification) if (cardBody && 'toast' in cardBody) { return cardBody; @@ -443,7 +443,145 @@ async function handlePipelineRetry(pipelineId: string): Promise; + threadId?: string; + messageId: string; + text: string; + userId: string; + chatId: string; + agentId: string; + accountId: string; + images?: ImageAttachment[]; + documents?: DocumentAttachment[]; +} + +async function resolveMentionGate(input: MentionGateInput): Promise { + const { chatType, mentionedBot, mentions, threadId, messageId, text, userId, chatId, agentId, accountId, images, documents } = input; + + // 私聊始终放行 + if (chatType === 'p2p') return 'p2p'; + + if (isMultiBotMode()) { + const botOpenId = accountManager.getBotOpenId(accountId) ?? ''; + const allBotOpenIds = accountManager.getAllBotOpenIds(); + const groupConfig = config.agent.groupConfigs[chatId]; + const commanderOpenId = groupConfig?.commander + ? accountManager.getBotOpenId(groupConfig.commander) + : undefined; + + // 补充 chatBotRegistry 中跨 app bot open_id + const registryBotIds = chatBotRegistry.getBots(chatId).map(b => b.openId); + const knownBotIds = new Set([...allBotOpenIds, ...registryBotIds]); + const anyBotMentioned = mentions.some(m => knownBotIds.has(m.id.open_id ?? '')); + + // 话题内 thread bypass:话题创建者 bot 无需 @mention + if (threadId && !anyBotMentioned && isThreadCreatorAgent(threadId, agentId)) { + const ts = sessionManager.getThreadSession(threadId, agentId); + if (ts && (isOwner(userId) || ts.userId === userId)) { + const recentMsgs = await feishuClient.fetchRecentMessages(threadId, 'thread', 10); + const humanSenders = new Set(recentMsgs.filter(m => m.senderType === 'user').map(m => m.senderId)); + + if (humanSenders.size <= 1) { + return 'thread_bypass_exclusive'; + } + + const botDisplayName = agentRegistry.get(agentId)?.displayName ?? 'bot'; + const context = await formatThreadContext(recentMsgs, botDisplayName, chatId, accountId); + const relevant = await checkThreadRelevance(text, botDisplayName, context); + if (relevant) { + return 'thread_bypass'; + } + logger.info({ threadId, agentId, text: text.slice(0, 100), humanCount: humanSenders.size }, 'Thread bypass skipped — message not directed at bot'); + } + } + + // @mention / commander 路由 + const reason = getRespondReason(chatType, mentions, botOpenId, knownBotIds, commanderOpenId); + if (reason) return reason; + + return undefined; + } + + // 单 bot 模式 + if (mentionedBot) return 'mentioned'; + if (chatType !== 'group') return 'non_group'; + + // 群聊未 @mention:仅话题内 session 创建者可放行 + const ts = threadId ? sessionManager.getThreadSession(threadId) : undefined; + if (!ts || (!isOwner(userId) && ts.userId !== userId)) { + return undefined; + } + + if (images?.length || documents?.length) { + logger.info({ messageId, threadId }, 'Thread session owner: image/doc bypass'); + return 'thread_session_media'; + } + + const recentMsgs = threadId ? await feishuClient.fetchRecentMessages(threadId, 'thread', 10) : []; + const humanSenders = new Set(recentMsgs.filter(m => m.senderType === 'user').map(m => m.senderId)); + + if (humanSenders.size <= 1) { + return 'thread_session_owner'; + } + + const botDisplayName = agentRegistry.get(agentId)?.displayName ?? 'bot'; + const context = await formatThreadContext(recentMsgs, botDisplayName, chatId, accountId); + const relevant = await checkThreadRelevance(text, botDisplayName, context); + if (!relevant) { + logger.info({ messageId, threadId, text: text?.slice(0, 100), humanCount: humanSenders.size }, 'Thread session owner: semantically not directed at bot'); + return undefined; + } + + logger.info({ messageId, threadId }, 'Thread session owner: semantically relevant'); + return 'thread_session_owner'; +} + +/** + * 将话题最近消息格式化为 Qwen 可读的对话记录。 + * 用于多人话题场景的语义判断,让 Qwen 看清谁在跟谁说话。 + */ +async function formatThreadContext( + messages: Array<{ senderId: string; senderType: string; content: string }>, + botName: string, + chatId: string, + accountId: string, +): Promise { + const userIds = [...new Set(messages.filter(m => m.senderType === 'user').map(m => m.senderId))]; + await resolveUserNames(userIds, chatId); + + const selfBotOpenId = accountManager.getBotOpenId(accountId) ?? ''; + const botNameMap = new Map(); + for (const acc of accountManager.allAccounts()) { + if (acc.botOpenId) botNameMap.set(acc.botOpenId, acc.botName); + } + const lines: string[] = []; + let totalLen = 0; + for (const m of messages) { + let name: string; + if (m.senderType === 'app') { + name = m.senderId === selfBotOpenId + ? `${botName}(bot)` + : `${botNameMap.get(m.senderId) ?? chatBotRegistry.getBots(chatId).find(b => b.openId === m.senderId)?.name ?? '其他bot'}(bot)`; + } else { + name = _userNameCache.get(m.senderId) ?? '用户'; + } + const content = m.senderType === 'app' ? m.content.slice(0, 100) : m.content; + const line = `[${name}]: ${content}`; + if (totalLen + line.length > 1500) break; + lines.push(line); + totalLen += line.length; + } + return lines.join('\n'); +} + +// ============================================================ +// 话题创建者判定 // ============================================================ /** @@ -492,25 +630,30 @@ function processQueue(queueKey: string, agentId: AgentId = 'dev'): void { const task = taskQueue.dequeue(queueKey); if (!task) return; - const agentCfg = agentRegistry.get(agentId); - // direct 模式 → executeDirectTask(话题内也走 direct 路径) - const useDirectMode = agentCfg?.replyMode === 'direct'; + // 恢复 task 入队时的 feishuClient 上下文(accountId), + // 防止 .finally() 回调继承前一个 task 的 AsyncLocalStorage 上下文 + const taskAccountId = task.accountId ?? 'default'; - const executeFn = useDirectMode - ? executeDirectTask(task.message, task.chatId, task.userId, task.messageId, task.images, task.documents, agentId, task.threadId, task.rootId, task.createTime, { forceThread: task.forceThread }, task.messageType) - : executeClaudeTask(task.message, task.chatId, task.userId, task.messageId, task.rootId, task.threadId, task.images, task.documents, agentId, task.createTime, task.messageType); + const execute = () => { + const agentCfg = agentRegistry.get(agentId); + const useDirectMode = agentCfg?.replyMode === 'direct'; - // 注册 task promise:graceful shutdown 时等待结果卡片发送完成 - claudeExecutor.registerTask(executeFn); + const executeFn = useDirectMode + ? executeDirectTask(task.message, task.chatId, task.userId, task.messageId, task.images, task.documents, agentId, task.threadId, task.rootId, task.createTime, { forceThread: task.forceThread }, task.messageType) + : executeClaudeTask(task.message, task.chatId, task.userId, task.messageId, task.rootId, task.threadId, task.images, task.documents, agentId, task.createTime, task.messageType); - executeFn - .then(() => task.resolve('done')) - .catch((err) => task.reject(err instanceof Error ? err : new Error(String(err)))) - .finally(() => { - taskQueue.complete(queueKey); - // 处理队列中的下一个任务 - processQueue(queueKey, agentId); - }); + claudeExecutor.registerTask(executeFn); + + executeFn + .then(() => task.resolve('done')) + .catch((err) => task.reject(err instanceof Error ? err : new Error(String(err)))) + .finally(() => { + taskQueue.complete(queueKey); + processQueue(queueKey, agentId); + }); + }; + + runWithAccountId(taskAccountId, execute); } // ============================================================ @@ -619,7 +762,7 @@ interface ParsedMessage { chatType: string; /** 单 bot 模式下的 @mention 检测结果 */ mentionedBot: boolean; - /** 原始 mentions 数组(多 bot 模式 shouldRespond 使用) */ + /** 原始 mentions 数组(多 bot 模式 @mention 过滤使用) */ mentions: Array<{ id: { open_id?: string } }>; /** message.root_id — 回复链根消息 ID */ rootId?: string; @@ -641,7 +784,7 @@ interface ParsedMessage { * 处理消息事件 (由 EventDispatcher 回调) * * 多 Agent 模式处理流程: - * ① shouldRespond — @mention 过滤 + * ① resolveMentionGate — @mention 白名单过滤 * ② Binding Router — 选 agent 角色 * ③ Slash command — 在 agent 角色确定后执行 * ④ Workspace Router — 选工作目录(在 resolveThreadContext 中) @@ -735,66 +878,16 @@ async function handleMessageEvent(data: MessageEventData, accountId: string = 'd } } - // ── @mention 过滤(必须在所有副作用之前,避免对不该响应的消息发送错误提示) ── - if (isMultiBotMode()) { - const botOpenId = accountManager.getBotOpenId(accountId) ?? ''; - const allBotOpenIds = accountManager.getAllBotOpenIds(); - const groupConfig = config.agent.groupConfigs[chatId]; - const commanderOpenId = groupConfig?.commander - ? accountManager.getBotOpenId(groupConfig.commander) - : undefined; - - // 话题内消息:话题创建者 bot 无需 @mention 即可响应后续消息 - // 前提:消息没有 @任何 bot —— 显式 @bot 是明确的意图信号 - // @人类用户的情况由下游 Qwen 语义判断处理(可能是指代引用,不一定是跟人说话) - // allBotOpenIds 仅包含各 bot 自身 fetchBotInfo 返回的 open_id(同一 app 视角)。 - // 但飞书 open_id 是 app 级别的:pm-bot 收到的 @dev-bot mention 的 open_id ≠ dev-bot 自己的 open_id。 - // 补充 chatBotRegistry 中通过被动收集(sender_type=app)记录的跨 app bot open_id。 - const registryBotIds = chatBotRegistry.getBots(chatId).map(b => b.openId); - const knownBotIds = new Set([...allBotOpenIds, ...registryBotIds]); - const anyBotMentioned = mentions.some(m => knownBotIds.has(m.id.open_id ?? '')); - let threadBypass = false; - if (threadId && !anyBotMentioned && isThreadCreatorAgent(threadId, agentId)) { - const ts = sessionManager.getThreadSession(threadId, agentId); - if (ts && (isOwner(userId) || ts.userId === userId)) { - // 语义判断:用 Qwen 小模型判断无 @mention 的消息是否在跟 bot 对话 - const botDisplayName = agentRegistry.get(agentId)?.displayName ?? 'bot'; - const relevant = await checkThreadRelevance(text, botDisplayName); - if (relevant) { - threadBypass = true; - logger.debug({ threadId, agentId, accountId }, 'Thread creator bypass: responding without @mention'); - } else { - logger.info({ threadId, agentId, text: text.slice(0, 100) }, 'Thread bypass skipped — message not directed at bot'); - } - } - } - - if (!threadBypass && !shouldRespond(chatType, mentions, botOpenId, knownBotIds, commanderOpenId)) { - return; - } - } else { - // 单 bot 模式:群聊中需要 @机器人 才响应 - // 例外:话题内后续消息,需同时满足:① 发送者是 session 创建者 ② 语义判断消息在跟 bot 对话 - if (chatType === 'group' && !mentionedBot) { - const ts = threadId ? sessionManager.getThreadSession(threadId) : undefined; - if (!ts || (!isOwner(userId) && ts.userId !== userId)) { - return; - } - // 纯图片/文档消息没有文本可做语义判断,话题内 session 创建者直接放行 - if (images?.length || documents?.length) { - logger.debug({ messageId, threadId }, 'Message allowed in group thread: image/doc from session creator'); - } else { - // 语义判断:与多 bot 模式对齐,用 Qwen 小模型判断消息是否在跟 bot 对话 - const botDisplayName = agentRegistry.get(agentId)?.displayName ?? 'bot'; - const relevant = await checkThreadRelevance(text, botDisplayName); - if (!relevant) { - logger.info({ messageId, threadId, text: text?.slice(0, 100) }, 'Single-bot thread bypass skipped — message not directed at bot'); - return; - } - logger.debug({ messageId, threadId }, 'Message allowed in group thread: sender is session creator + semantically relevant'); - } - } + // ── @mention 过滤(白名单模式:必须有明确放行理由,否则一律拦截) ── + const passReason = await resolveMentionGate({ + chatType, mentionedBot, mentions, threadId, messageId, text, + userId, chatId, agentId, accountId, images, documents, + }); + if (!passReason) { + logger.info({ messageId, chatId, threadId, agentId, accountId, chatType }, '@mention gate: blocked — no pass reason'); + return; } + logger.info({ messageId, chatId, threadId, agentId, accountId, passReason }, '@mention gate: passed'); // root_id 单独出现(无 thread_id)= 主面板引用回复,不是话题内消息,正常处理即可 @@ -845,7 +938,7 @@ async function handleMessageEvent(data: MessageEventData, accountId: string = 'd // 斜杠命令、管道确认、审批命令仅对文本消息有效 if (text && !forceThread) { // 处理斜杠命令(在 agent 角色确定后执行) - const commandResult = await handleSlashCommand(text, chatId, userId, messageId, rootId, effectiveThreadId, agentId); + const commandResult = await handleSlashCommand(text, chatId, userId, messageId, rootId, effectiveThreadId, agentId, accountId); if (commandResult) return; // 处理管道消息确认(卡片按钮的文本 fallback) @@ -887,7 +980,7 @@ async function handleMessageEvent(data: MessageEventData, accountId: string = 'd const queueKey = perMessageParallel ? makeQueueKey(chatId, undefined, agentId, messageId) : makeQueueKey(chatId, effectiveThreadId, agentId, isDirectMode ? userId : undefined); - taskQueue.enqueue(queueKey, chatId, userId, effectiveText, messageId, rootId, effectiveThreadId, images, documents, createTime, forceThread, messageType).catch(() => {}); + taskQueue.enqueue(queueKey, chatId, userId, effectiveText, messageId, rootId, effectiveThreadId, images, documents, createTime, forceThread, messageType, accountId).catch(() => {}); processQueue(queueKey, agentId); } @@ -902,6 +995,7 @@ async function handleSlashCommand( rootId?: string, effectiveThreadId?: string, agentId: AgentId = 'dev', + accountId: string = 'default', ): Promise { const trimmed = text.trim(); @@ -1077,7 +1171,7 @@ async function handleSlashCommand( const queueKey = editThreadId ? makeQueueKey(chatId, editThreadId, agentId) : makeQueueKey(chatId, undefined, agentId); - taskQueue.enqueue(queueKey, chatId, userId, editPrompt, messageId, editThreadReplyMsgId || rootId, editThreadId).catch(() => {}); + taskQueue.enqueue(queueKey, chatId, userId, editPrompt, messageId, editThreadReplyMsgId || rootId, editThreadId, undefined, undefined, undefined, undefined, undefined, accountId).catch(() => {}); processQueue(queueKey, agentId); } @@ -1263,22 +1357,49 @@ const MAX_HISTORY_IMAGES = 5; /** * 从历史消息中下载图片(最多 MAX_HISTORY_IMAGES 张,使用更激进的压缩)。 * 优先取最新的图片(消息列表已按时间正序排列,从末尾取)。 + * + * 父群消息中的图片不自动下载,仅注入元数据提示供 LLM 按需调用工具加载, + * 防止话题外的图片(如其他人发的简历)干扰话题内的分析。 + * + * @param parentMsgCount 父群补充消息数量(messages 数组前 N 条来自父群) + * @returns { images, lazyHints } images 直接嵌入多模态,lazyHints 拼入 prompt 文本 */ async function downloadHistoryImages( messages: Array<{ messageId: string; imageRefs?: Array<{ imageKey: string }> }>, -): Promise { - // 收集所有图片引用(最新的在后面),取最近 N 张 - const refs: Array<{ messageId: string; imageKey: string }> = []; - for (const msg of messages) { + parentMsgCount = 0, +): Promise<{ images: ImageAttachment[]; lazyHints: string[] }> { + // 收集所有图片引用,标记是否来自父群 + const refs: Array<{ messageId: string; imageKey: string; fromParent: boolean }> = []; + for (let i = 0; i < messages.length; i++) { + const msg = messages[i]; if (msg.imageRefs) { + const fromParent = i < parentMsgCount; for (const ref of msg.imageRefs) { - refs.push({ messageId: msg.messageId, imageKey: ref.imageKey }); + refs.push({ messageId: msg.messageId, imageKey: ref.imageKey, fromParent }); } } } - if (refs.length === 0) return []; + if (refs.length === 0) return { images: [], lazyHints: [] }; + + // 父群图片:lazy loading(注入元数据,不下载) + const lazyHints: string[] = []; + const downloadable = refs.filter(ref => { + if (ref.fromParent) { + lazyHints.push( + `[群聊历史图片] — 未自动加载。如需查看,可调用 feishu_download_message_image 工具(参数: message_id="${ref.messageId}", image_key="${ref.imageKey}")`, + ); + return false; + } + return true; + }); + + if (lazyHints.length > 0) { + logger.info({ parentImageCount: lazyHints.length }, 'Parent chat images skipped (lazy loading), metadata injected'); + } + + if (downloadable.length === 0) return { images: [], lazyHints }; - const toDownload = refs.slice(-MAX_HISTORY_IMAGES); + const toDownload = downloadable.slice(-MAX_HISTORY_IMAGES); const results = await Promise.all(toDownload.map(async (ref) => { try { @@ -1301,10 +1422,10 @@ async function downloadHistoryImages( const images = results.filter((img): img is ImageAttachment => img !== null); if (images.length > 0) { - logger.info({ count: images.length, totalRefs: refs.length }, 'Downloaded history images'); + logger.info({ count: images.length, totalRefs: refs.length, parentSkipped: lazyHints.length }, 'Downloaded history images'); } - return images; + return { images, lazyHints }; } /** 支持下载并嵌入 prompt 的文本类文件扩展名 */ @@ -1440,41 +1561,48 @@ async function downloadHistoryFiles( } /** - * 构建飞书聊天历史上下文(首次 @bot 时注入,帮助 Claude 理解对话背景) + * buildHistoryContext 的可选行为参数。 * - * @param chatId - 群聊 ID - * @param threadId - 话题 ID(话题内消息时传入) - * @param currentMessageId - 当前消息 ID(用于过滤,避免把自己也算进历史) - * @param afterMsgId - 增量去重锚点:只返回比此 ID 更新的消息(resume 时使用) - * @returns { text, newestMsgId },无消息时 text 为 undefined + * 两个公开入口(buildChatHistoryContext / buildDirectTaskHistory)共用同一份 + * fork + 去重 + 附件下载逻辑,只通过 options 区分诊断细节。 */ -async function buildChatHistoryContext( +interface BuildHistoryOptions { + /** direct 任务路径打印额外的 pipeline 日志,便于排查上下文注入问题 */ + verboseLogging?: boolean; + /** 出错时的日志 message,便于在日志里区分入口 */ + errorLabel?: string; +} + +/** + * 统一的飞书聊天历史构建实现。 + * + * 逻辑:fork 语义 + 增量去重 + 父群懒加载附件,详见 buildChatHistoryContext / buildDirectTaskHistory 的 doc。 + */ +async function buildHistoryContext( chatId: string, threadId?: string, currentMessageId?: string, afterMsgId?: string, selfBotOpenIds?: Set, + options: BuildHistoryOptions = {}, ): Promise { + const { verboseLogging = false, errorLabel = 'Failed to build chat history context' } = options; try { type HistoryMsg = { messageId: string; senderId: string; senderType: 'user' | 'app'; content: string; msgType: string; createTime?: string; imageRefs?: Array<{ imageKey: string }> }; let messages: HistoryMsg[]; let parentMsgCount = 0; if (!threadId) { - // 主聊天区:直接取父群最近消息 messages = await feishuClient.fetchRecentMessages(chatId, 'chat', config.chat.historyMaxCount); } else { - // 话题模式:fork 语义(与 buildDirectTaskHistory 一致) const threadMsgs = await feishuClient.fetchRecentMessages(threadId, 'thread', 50, chatId); const filtered = currentMessageId ? threadMsgs.filter(m => m.messageId !== currentMessageId) : threadMsgs; if (filtered.length === 0) { - // 话题为空,从父群 fork messages = await feishuClient.fetchRecentMessages(chatId, 'chat', config.chat.historyMaxCount); } else if (filtered.length <= config.chat.historyMaxCount) { - // 话题消息不足 max,补充父群消息 const remaining = config.chat.historyMaxCount - filtered.length; if (remaining > 0) { const parentMsgs = await feishuClient.fetchRecentMessages(chatId, 'chat', remaining); @@ -1484,50 +1612,91 @@ async function buildChatHistoryContext( messages = filtered; } } else { - // 话题消息 > max:首条 + 最近 (max - 1) 条 const first = filtered[0]; const latest = filtered.slice(-(config.chat.historyMaxCount - 1)); messages = [first, ...latest]; } } - // 过滤当前消息(主聊天区路径,话题路径已在上面过滤) + const beforeCurrentFilter = messages.length; if (!threadId && currentMessageId) { messages = messages.filter(m => m.messageId !== currentMessageId); } - // 记录最新 messageId(去重锚点,在过滤 afterMsgId 之前取) const newestMsgId = messages.length > 0 ? messages[messages.length - 1].messageId : undefined; - // 增量去重:只保留 afterMsgId 之后的新消息 + const beforeDedupFilter = messages.length; if (afterMsgId && messages.length > 0) { const idx = messages.findIndex(m => m.messageId === afterMsgId); if (idx >= 0) { messages = messages.slice(idx + 1); - // afterMsgId 去重后,被移除的消息可能包含 parent 消息,需重新计算 parentMsgCount = Math.max(0, parentMsgCount - (idx + 1)); } - // afterMsgId 不在列表中 → 可能消息已过期滚动,注入全部 + if (verboseLogging) { + logger.info( + { chatId, afterMsgId, foundIdx: messages.length !== beforeDedupFilter ? 'found' : 'not_found', beforeDedup: beforeDedupFilter, afterDedup: messages.length }, + 'History afterMsgId dedup applied', + ); + } + } + + if (verboseLogging) { + logger.info( + { + chatId, + threadId, + currentMessageId, + afterMsgId, + newestMsgId, + fetchedCount: beforeCurrentFilter, + afterCurrentFilter: beforeDedupFilter, + afterDedupFilter: messages.length, + msgIds: messages.map(m => m.messageId), + msgTypes: messages.map(m => m.msgType), + msgContentLens: messages.map(m => m.content.length), + }, + 'buildDirectTaskHistory message pipeline', + ); } - const [text, images, historyFiles] = await Promise.all([ + const [text, imagesResult, historyFiles] = await Promise.all([ formatHistoryMessages(messages, chatId, selfBotOpenIds, parentMsgCount > 0 ? { parentMsgCount } : undefined), - downloadHistoryImages(messages), + downloadHistoryImages(messages, parentMsgCount), downloadHistoryFiles(messages, parentMsgCount), ]); + const fileTexts = [...historyFiles.fileTexts, ...imagesResult.lazyHints]; return { text: text ?? undefined, newestMsgId, - ...(images.length > 0 ? { images } : {}), + ...(imagesResult.images.length > 0 ? { images: imagesResult.images } : {}), ...(historyFiles.documents.length > 0 ? { documents: historyFiles.documents } : {}), - ...(historyFiles.fileTexts.length > 0 ? { fileTexts: historyFiles.fileTexts } : {}), + ...(fileTexts.length > 0 ? { fileTexts } : {}), }; } catch (err) { - logger.error({ err, chatId, threadId }, 'Failed to build chat history context'); + logger.error({ err, chatId, threadId }, errorLabel); return {}; } } +/** + * 构建飞书聊天历史上下文(首次 @bot 时注入,帮助 Claude 理解对话背景) + * + * @param chatId - 群聊 ID + * @param threadId - 话题 ID(话题内消息时传入) + * @param currentMessageId - 当前消息 ID(用于过滤,避免把自己也算进历史) + * @param afterMsgId - 增量去重锚点:只返回比此 ID 更新的消息(resume 时使用) + * @returns { text, newestMsgId },无消息时 text 为 undefined + */ +async function buildChatHistoryContext( + chatId: string, + threadId?: string, + currentMessageId?: string, + afterMsgId?: string, + selfBotOpenIds?: Set, +): Promise { + return buildHistoryContext(chatId, threadId, currentMessageId, afterMsgId, selfBotOpenIds); +} + /** 用户名缓存:open_id → 用户名(TTL 由 Map 生命周期管理,进程重启清空) */ const _userNameCache = new Map(); @@ -1624,6 +1793,7 @@ export const _testFormatHistoryMessages = formatHistoryMessages; /** 仅测试用:导出 downloadHistoryFiles */ export const _testDownloadHistoryFiles = downloadHistoryFiles; +export const _testDownloadHistoryImages = downloadHistoryImages; /** * 格式化历史消息为上下文文本(共享逻辑)。 @@ -2783,6 +2953,8 @@ interface HistoryResult { * - 话题消息 M ≥ max → 首条 + 最近 (max - 1) 条 * - 话题为空 → 从父群 fork * + * 与 buildChatHistoryContext 共用同一份实现,仅多打印一份 pipeline 诊断日志。 + * * @param afterMsgId 上次注入的最新 messageId,有值时只返回比它更新的消息 */ async function buildDirectTaskHistory( @@ -2792,100 +2964,10 @@ async function buildDirectTaskHistory( afterMsgId?: string, selfBotOpenIds?: Set, ): Promise { - try { - type HistoryMsg = { messageId: string; senderId: string; senderType: 'user' | 'app'; content: string; msgType: string; createTime?: string; imageRefs?: Array<{ imageKey: string }> }; - let messages: HistoryMsg[]; - let parentMsgCount = 0; // 父群补充消息数量,用于结构化分区 - - if (!threadId) { - // 主聊天区:直接取父群最近消息 - messages = await feishuClient.fetchRecentMessages(chatId, 'chat', config.chat.historyMaxCount); - } else { - // 话题模式:fork 语义 - const threadMsgs = await feishuClient.fetchRecentMessages(threadId, 'thread', 50, chatId); - const filtered = currentMessageId - ? threadMsgs.filter(m => m.messageId !== currentMessageId) - : threadMsgs; - - if (filtered.length === 0) { - // 话题为空,从父群 fork - messages = await feishuClient.fetchRecentMessages(chatId, 'chat', config.chat.historyMaxCount); - } else if (filtered.length <= config.chat.historyMaxCount) { - // 话题消息不足 max,补充父群消息 - const remaining = config.chat.historyMaxCount - filtered.length; - if (remaining > 0) { - const parentMsgs = await feishuClient.fetchRecentMessages(chatId, 'chat', remaining); - parentMsgCount = parentMsgs.length; - messages = [...parentMsgs, ...filtered]; - } else { - messages = filtered; - } - } else { - // 话题消息 > max:首条 + 最近 (max - 1) 条 - const first = filtered[0]; - const latest = filtered.slice(-(config.chat.historyMaxCount - 1)); - messages = [first, ...latest]; - } - } - - // 过滤当前消息(主聊天区路径,话题路径已在上面过滤) - const beforeCurrentFilter = messages.length; - if (!threadId && currentMessageId) { - messages = messages.filter(m => m.messageId !== currentMessageId); - } - - // 记录最新 messageId(去重锚点,在过滤 afterMsgId 之前取) - const newestMsgId = messages.length > 0 ? messages[messages.length - 1].messageId : undefined; - - // 增量去重:只保留 afterMsgId 之后的新消息 - const beforeDedupFilter = messages.length; - if (afterMsgId && messages.length > 0) { - const idx = messages.findIndex(m => m.messageId === afterMsgId); - if (idx >= 0) { - messages = messages.slice(idx + 1); - // afterMsgId 去重后,被移除的消息可能包含 parent 消息,需重新计算 - parentMsgCount = Math.max(0, parentMsgCount - (idx + 1)); - } - // afterMsgId 不在列表中 → 可能消息已过期滚动,注入全部 - logger.info( - { chatId, afterMsgId, foundIdx: messages.length !== beforeDedupFilter ? 'found' : 'not_found', beforeDedup: beforeDedupFilter, afterDedup: messages.length }, - 'History afterMsgId dedup applied', - ); - } - - logger.info( - { - chatId, - threadId, - currentMessageId, - afterMsgId, - newestMsgId, - fetchedCount: beforeCurrentFilter, - afterCurrentFilter: beforeDedupFilter, - afterDedupFilter: messages.length, - msgIds: messages.map(m => m.messageId), - msgTypes: messages.map(m => m.msgType), - msgContentLens: messages.map(m => m.content.length), - }, - 'buildDirectTaskHistory message pipeline', - ); - - const [text, images, historyFiles] = await Promise.all([ - formatHistoryMessages(messages, chatId, selfBotOpenIds, parentMsgCount > 0 ? { parentMsgCount } : undefined), - downloadHistoryImages(messages), - downloadHistoryFiles(messages, parentMsgCount), - ]); - return { - text: text ?? undefined, - newestMsgId, - ...(images.length > 0 ? { images } : {}), - ...(historyFiles.documents.length > 0 ? { documents: historyFiles.documents } : {}), - ...(historyFiles.fileTexts.length > 0 ? { fileTexts: historyFiles.fileTexts } : {}), - }; - } catch (err) { - logger.error({ err, chatId, threadId }, 'Failed to build direct task history'); - return {}; - } + return buildHistoryContext(chatId, threadId, currentMessageId, afterMsgId, selfBotOpenIds, { + verboseLogging: true, + errorLabel: 'Failed to build direct task history', + }); } /** @@ -3510,7 +3592,7 @@ function handleBotDeletedEvent(data: Record, accountId: string) } /** @internal 测试用导出 */ -export const _testing = { handleBotAddedEvent, handleBotDeletedEvent, makeQueueKey, injectQuotedMessage }; +export const _testing = { handleBotAddedEvent, handleBotDeletedEvent, makeQueueKey, injectQuotedMessage, resolveMentionGate }; function formatDuration(ms: number): string { if (ms < 1000) return `${ms}ms`; diff --git a/src/feishu/message-builder.ts b/src/feishu/message-builder.ts index 45c74d3a..3ef76490 100644 --- a/src/feishu/message-builder.ts +++ b/src/feishu/message-builder.ts @@ -2,6 +2,7 @@ * 飞书消息卡片构建器 * 用于构建执行状态卡片、结果卡片等 */ +import { hostname } from 'os'; import { PHASE_META } from '../pipeline/types.js'; import type { PipelinePhase } from '../pipeline/types.js'; @@ -130,7 +131,7 @@ export function buildResultCard( elements: [ { tag: 'plain_text', - content: `${icon} ${status} | ⏱️ ${durationStr}`, + content: `${icon} ${status} | ⏱️ ${durationStr} | 🖥️ ${hostname()}:${process.pid}`, }, ], }, diff --git a/src/session/__tests__/queue.test.ts b/src/session/__tests__/queue.test.ts index ac3e426f..92d950e8 100644 --- a/src/session/__tests__/queue.test.ts +++ b/src/session/__tests__/queue.test.ts @@ -243,4 +243,32 @@ describe('TaskQueue', () => { expect(queue.pendingCount('chat2:threadB')).toBe(1); }); }); + + describe('accountId', () => { + it('should store accountId on enqueued task', () => { + queue.enqueue('q1', 'chat1', 'user1', 'msg', 'mid1', undefined, undefined, undefined, undefined, undefined, undefined, undefined, 'pm'); + const task = queue.dequeue('q1'); + expect(task).toBeDefined(); + expect(task!.accountId).toBe('pm'); + }); + + it('should default accountId to undefined when not provided', () => { + queue.enqueue('q1', 'chat1', 'user1', 'msg', 'mid1'); + const task = queue.dequeue('q1'); + expect(task).toBeDefined(); + expect(task!.accountId).toBeUndefined(); + }); + + it('should preserve different accountIds for sequential tasks in same queue', () => { + queue.enqueue('q1', 'chat1', 'user1', 'msg1', 'mid1', undefined, undefined, undefined, undefined, undefined, undefined, undefined, 'dev'); + queue.enqueue('q1', 'chat1', 'user1', 'msg2', 'mid2', undefined, undefined, undefined, undefined, undefined, undefined, undefined, 'pm'); + + const t1 = queue.dequeue('q1'); + expect(t1!.accountId).toBe('dev'); + + queue.complete('q1'); + const t2 = queue.dequeue('q1'); + expect(t2!.accountId).toBe('pm'); + }); + }); }); diff --git a/src/session/queue.ts b/src/session/queue.ts index 7d809a6f..bc01991f 100644 --- a/src/session/queue.ts +++ b/src/session/queue.ts @@ -29,6 +29,7 @@ export class TaskQueue { createTime?: string, forceThread?: boolean, messageType?: string, + accountId?: string, ): Promise { return new Promise((resolve, reject) => { const task: QueueTask = { @@ -44,6 +45,7 @@ export class TaskQueue { messageType, createTime, forceThread, + accountId, resolve, reject, createdAt: new Date(), diff --git a/src/session/types.ts b/src/session/types.ts index 4fcd02ae..373c1d32 100644 --- a/src/session/types.ts +++ b/src/session/types.ts @@ -92,6 +92,8 @@ export interface QueueTask { rootId?: string; /** 飞书话题 ID (message.thread_id),用于话题标识 */ threadId?: string; + /** bot 账号标识,用于恢复 feishuClient 上下文 */ + accountId?: string; /** 图片附件列表 (用户发送图片消息时) */ images?: import('../claude/types.js').ImageAttachment[]; /** 文档附件列表 (用户发送 PDF 等文件时) */ diff --git a/src/utils/thread-relevance.ts b/src/utils/thread-relevance.ts index e7d75db6..76d12c51 100644 --- a/src/utils/thread-relevance.ts +++ b/src/utils/thread-relevance.ts @@ -7,7 +7,7 @@ import { config } from '../config.js'; import { logger } from './logger.js'; import { getClient } from './quick-ack.js'; -const RELEVANCE_PROMPT = `你是一个消息路由判断器。在一个群聊话题中,机器人之前参与了对话。 +const RELEVANCE_PROMPT_SIMPLE = `你是一个消息路由判断器。在一个群聊话题中,机器人之前参与了对话。 现在收到一条新消息(没有 @机器人),判断这条消息是否**明确需要机器人回复**。 严格按 JSON 格式回复,不要输出任何其他内容: @@ -25,6 +25,23 @@ respond: false 的条件: - 短句/语气词/感叹(如"哦"、"好的"、"噗"、"可以"、"稳了") - 无法确定是否在跟机器人说话 → false(宁可不回)`; +const RELEVANCE_PROMPT_WITH_CONTEXT = `你是一个消息路由判断器。在一个群聊话题中,有多人参与对话,其中包括机器人。 +现在收到一条新消息(没有 @机器人),根据对话上下文判断这条消息是否在跟机器人说话。 + +严格按 JSON 格式回复,不要输出任何其他内容: +{"respond": true} 或 {"respond": false} + +respond: true 的条件(必须满足至少一条): +- 消息在回应机器人刚才的回复(追问、反馈、确认) +- 消息**明确**在向机器人提问、请求帮助、布置任务 +- 消息提到了机器人的名字并期望它做某事 + +respond: false 的条件: +- 从对话上下文看,消息是在跟其他人说话 +- 消息是回应其他人(非机器人)的发言 +- 消息是自言自语、告知状态 +- 无法确定是否在跟机器人说话 → false(宁可不回)`; + /** * 判断话题内无 @mention 的消息是否需要 bot 回复。 * @@ -32,30 +49,37 @@ respond: false 的条件: * * @param message 用户消息文本 * @param botName bot 显示名称 + * @param conversationContext 话题最近对话记录(多人场景提供,帮助 Qwen 判断消息在跟谁说话) * @returns true = 应该回复, false = 不应该回复 */ export async function checkThreadRelevance( message: string, botName: string, + conversationContext?: string, ): Promise { - if (!config.quickAck.enabled) return false; // 未配置小模型,宁可不回,用户可 @bot 明确触发 + if (!config.quickAck.enabled) return false; const client = await getClient(); if (!client) return false; + const systemPrompt = conversationContext ? RELEVANCE_PROMPT_WITH_CONTEXT : RELEVANCE_PROMPT_SIMPLE; + const userContent = conversationContext + ? `机器人名称:${botName}\n\n最近对话:\n${conversationContext}\n\n当前消息:\n${message.slice(0, 300)}` + : `机器人名称:${botName}\n消息内容:${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: 'system', content: systemPrompt }, + { role: 'user', content: userContent }, ], max_tokens: 20, temperature: 0, enable_thinking: false, } as never), - new Promise((resolve) => setTimeout(() => resolve(null), 2000)), + new Promise((resolve) => setTimeout(() => resolve(null), 3000)), ]); if (!result) {