Skip to content
Merged
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
172 changes: 172 additions & 0 deletions src/__tests__/thread-reaction.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
51 changes: 51 additions & 0 deletions src/feishu/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | undefined> {
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<boolean> {
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;
Expand Down
11 changes: 11 additions & 0 deletions src/feishu/event-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,其他类型照常走完整查询
Expand Down Expand Up @@ -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) {
Expand Down
Loading