diff --git a/src/__tests__/thread-reaction.test.ts b/src/__tests__/thread-reaction.test.ts new file mode 100644 index 00000000..2a4c3737 --- /dev/null +++ b/src/__tests__/thread-reaction.test.ts @@ -0,0 +1,172 @@ +/** + * Thread Reaction Tests + * + * Tests for the emoji reaction feature in thread messages: + * - When user @bot in a thread (no quick-ack), bot adds a reaction as immediate feedback + * - After formal reply is sent, bot removes the reaction + * - Reaction cleanup happens in finally block (even on error) + */ +// @ts-nocheck — test file +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ============================================================ +// Mock feishuClient +// ============================================================ + +const mockAddReaction = vi.fn(); +const mockRemoveReaction = vi.fn().mockResolvedValue(true); +const mockReplyText = vi.fn(); +const mockReplyTextInThread = vi.fn(); + +vi.mock('../feishu/client.js', () => ({ + feishuClient: { + addReaction: (...args: unknown[]) => mockAddReaction(...args), + removeReaction: (...args: unknown[]) => mockRemoveReaction(...args), + replyText: (...args: unknown[]) => mockReplyText(...args), + replyTextInThread: (...args: unknown[]) => mockReplyTextInThread(...args), + fetchRecentMessages: vi.fn().mockResolvedValue([]), + sendCard: vi.fn(), + updateCard: vi.fn(), + replyCardInThread: vi.fn(), + }, +})); + +vi.mock('../utils/logger.js', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +// ============================================================ +// Tests: Thread reaction logic +// +// Simulates the core reaction logic from executeDirectTask +// without needing to invoke the full function. +// ============================================================ + +/** + * Simulates the thread reaction add/remove flow from executeDirectTask. + */ +async function simulateThreadReactionFlow(params: { + messageId: string; + eventThreadId?: string; + shouldError?: boolean; +}) { + const { feishuClient } = await import('../feishu/client.js'); + const { messageId, eventThreadId, shouldError } = params; + + // Same logic as executeDirectTask + let pendingReactionId: string | undefined; + if (eventThreadId) { + pendingReactionId = await feishuClient.addReaction(messageId, 'OnIt').catch(() => undefined); + } + + try { + if (shouldError) { + throw new Error('Simulated execution error'); + } + + // Simulate successful reply + await feishuClient.replyTextInThread(messageId, 'response'); + } catch { + // Error handling (reply error message) + } finally { + // Cleanup reaction (same as executeDirectTask finally block) + if (pendingReactionId) { + feishuClient.removeReaction(messageId, pendingReactionId).catch(() => {}); + } + } + + return { pendingReactionId }; +} + +describe('thread reaction: immediate feedback for @bot in threads', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('adds reaction when eventThreadId is present', async () => { + mockAddReaction.mockResolvedValue('reaction-123'); + + await simulateThreadReactionFlow({ + messageId: 'msg-1', + eventThreadId: 'thread-1', + }); + + expect(mockAddReaction).toHaveBeenCalledWith('msg-1', 'OnIt'); + expect(mockAddReaction).toHaveBeenCalledTimes(1); + }); + + it('does NOT add reaction when not in thread (main chat)', async () => { + await simulateThreadReactionFlow({ + messageId: 'msg-1', + eventThreadId: undefined, + }); + + expect(mockAddReaction).not.toHaveBeenCalled(); + }); + + it('removes reaction after successful reply', async () => { + mockAddReaction.mockResolvedValue('reaction-456'); + + await simulateThreadReactionFlow({ + messageId: 'msg-1', + eventThreadId: 'thread-1', + }); + + expect(mockRemoveReaction).toHaveBeenCalledWith('msg-1', 'reaction-456'); + }); + + it('removes reaction even on execution error (finally block)', async () => { + mockAddReaction.mockResolvedValue('reaction-789'); + + await simulateThreadReactionFlow({ + messageId: 'msg-1', + eventThreadId: 'thread-1', + shouldError: true, + }); + + // Reaction should still be cleaned up + expect(mockRemoveReaction).toHaveBeenCalledWith('msg-1', 'reaction-789'); + }); + + it('does not attempt removal when addReaction fails', async () => { + mockAddReaction.mockResolvedValue(undefined); + + await simulateThreadReactionFlow({ + messageId: 'msg-1', + eventThreadId: 'thread-1', + }); + + // pendingReactionId is undefined → no removal attempt + expect(mockRemoveReaction).not.toHaveBeenCalled(); + }); + + it('does not attempt removal when addReaction throws', async () => { + mockAddReaction.mockRejectedValue(new Error('API error')); + + await simulateThreadReactionFlow({ + messageId: 'msg-1', + eventThreadId: 'thread-1', + }); + + // .catch(() => undefined) swallows the error, pendingReactionId is undefined + expect(mockRemoveReaction).not.toHaveBeenCalled(); + }); + + it('removal failure does not throw (fire-and-forget)', async () => { + mockAddReaction.mockResolvedValue('reaction-abc'); + mockRemoveReaction.mockRejectedValue(new Error('delete failed')); + + // Should not throw + await expect( + simulateThreadReactionFlow({ + messageId: 'msg-1', + eventThreadId: 'thread-1', + }), + ).resolves.not.toThrow(); + }); +}); diff --git a/src/feishu/client.ts b/src/feishu/client.ts index 69e0e759..11dfe408 100644 --- a/src/feishu/client.ts +++ b/src/feishu/client.ts @@ -641,6 +641,57 @@ export class FeishuClient { } } + /** + * 给消息添加表情回复(reaction) + * 用于在话题内 @bot 时立即反馈(替代 quick-ack) + * + * @param messageId - 要添加表情的消息 ID + * @param emojiType - 表情类型(如 "OnIt", "THUMBSUP" 等飞书 emoji_type) + * @returns reaction_id(用于后续删除),失败返回 undefined + */ + async addReaction(messageId: string, emojiType: string): Promise { + try { + const resp = await this.client.im.messageReaction.create({ + path: { message_id: messageId }, + data: { reaction_type: { emoji_type: emojiType } }, + }); + + if (resp.code !== 0) { + logger.warn({ code: resp.code, msg: resp.msg, messageId, emojiType }, 'Failed to add reaction'); + return undefined; + } + + return resp.data?.reaction_id; + } catch (err) { + logger.warn({ err, messageId, emojiType }, 'Error adding reaction'); + return undefined; + } + } + + /** + * 删除消息的表情回复(reaction) + * + * @param messageId - 消息 ID + * @param reactionId - 要删除的 reaction_id(由 addReaction 返回) + */ + async removeReaction(messageId: string, reactionId: string): Promise { + try { + const resp = await this.client.im.messageReaction.delete({ + path: { message_id: messageId, reaction_id: reactionId }, + }); + + if (resp.code !== 0) { + logger.warn({ code: resp.code, msg: resp.msg, messageId, reactionId }, 'Failed to remove reaction'); + return false; + } + + return true; + } catch (err) { + logger.warn({ err, messageId, reactionId }, 'Error removing reaction'); + return false; + } + } + /** 获取原始 client 以便直接使用 */ get raw(): lark.Client { return this.client; diff --git a/src/feishu/event-handler.ts b/src/feishu/event-handler.ts index 9df455b4..88cd2af8 100644 --- a/src/feishu/event-handler.ts +++ b/src/feishu/event-handler.ts @@ -1614,6 +1614,13 @@ async function executeDirectTask( let threadReplyMsgId: string | undefined = eventThreadId ? rootId : undefined; let threadId: string | undefined = eventThreadId; + // 话题内消息:跳过 quick-ack,改为先添加表情回复作为即时反馈 + // 正式回复发出后再移除表情(在 finally 中清理) + let pendingReactionId: string | undefined; + if (eventThreadId) { + pendingReactionId = await feishuClient.addReaction(messageId, 'OnIt').catch(() => undefined); + } + try { // 快速确认:用小模型判断消息类型并生成短回复 // 纯问候类消息直接回复后跳过 Claude,其他类型照常走完整查询 @@ -1823,6 +1830,10 @@ async function executeDirectTask( await feishuClient.replyText(messageId, errorReply); } } finally { + // 移除话题内的待处理表情回复(无论成功/失败都要清理) + if (pendingReactionId) { + feishuClient.removeReaction(messageId, pendingReactionId).catch(() => {}); + } try { sessionManager.setStatus(chatId, userId, 'idle', agentId); } catch (err) {