diff --git a/src/claude/executor.ts b/src/claude/executor.ts index 40e7f43f..744776b2 100644 --- a/src/claude/executor.ts +++ b/src/claude/executor.ts @@ -196,7 +196,10 @@ function buildWorkspaceSystemPrompt(workingDir?: string): string { - **feishu_drive**: 浏览云空间文件。 - **feishu_bitable**: 读写多维表格。链接格式: https://xxx.feishu.cn/base/TOKEN - **feishu_chat_members**: 获取当前群聊的成员列表 (open_id + 姓名)。在需要了解群内有谁、@某人、分配任务时使用。 -- **feishu_task**: 创建和管理飞书任务。action: create/get/list/update。due/start 支持 Unix 时间戳或 ISO 日期 (如 "2026-03-15")。members 为 JSON 数组。update 需指定 update_fields。 +- **feishu_task**: 创建和管理飞书任务。action: create/get/list/list_tasklists/update/delete/add_members/remove_members。due/start 支持 Unix 时间戳或 ISO 日期 (如 "2026-03-15")。members 为 JSON 数组。update 需指定 update_fields。 + - add_members/remove_members 用于创建后修改任务成员(执行者/关注者),需要 task_guid + members 参数 + - list_tasklists 列出可用的任务清单,创建任务时可通过 tasklists 参数指定归属清单 + - 创建任务后工具会返回正确的 applink URL,直接使用即可,**不要自行拼接或编造任务链接** URL Token 提取规则: - /docx/ABC123 → doc_token: ABC123 diff --git a/src/feishu/tools/__tests__/task.test.ts b/src/feishu/tools/__tests__/task.test.ts index 35342010..a0661c6c 100644 --- a/src/feishu/tools/__tests__/task.test.ts +++ b/src/feishu/tools/__tests__/task.test.ts @@ -10,6 +10,9 @@ const mockTaskGet = vi.fn(); const mockTaskList = vi.fn(); const mockTaskPatch = vi.fn(); const mockTaskDelete = vi.fn(); +const mockTaskAddMembers = vi.fn(); +const mockTaskRemoveMembers = vi.fn(); +const mockTasklistList = vi.fn(); const mockRequest = vi.fn(); vi.mock('../../client.js', () => ({ @@ -27,6 +30,11 @@ vi.mock('../../client.js', () => ({ get: (...args: unknown[]) => mockTaskGet(...args), patch: (...args: unknown[]) => mockTaskPatch(...args), delete: (...args: unknown[]) => mockTaskDelete(...args), + addMembers: (...args: unknown[]) => mockTaskAddMembers(...args), + removeMembers: (...args: unknown[]) => mockTaskRemoveMembers(...args), + }, + tasklist: { + list: (...args: unknown[]) => mockTasklistList(...args), }, }, }, @@ -123,7 +131,7 @@ describe('parseDueDate', () => { describe('feishu_task tool', () => { describe('create', () => { - it('should create a task with only summary', async () => { + it('should create a task with only summary and include applink', async () => { mockTaskCreate.mockResolvedValue({ code: 0, data: { task: { guid: 'TASK_001', summary: '开会' } }, @@ -131,6 +139,7 @@ describe('feishu_task tool', () => { const result = await capturedHandler({ action: 'create', summary: '开会' }); expect(result.content[0].text).toContain('TASK_001'); expect(result.content[0].text).toContain('开会'); + expect(result.content[0].text).toContain('https://applink.feishu.cn/client/todo/detail?guid=TASK_001'); expect(mockTaskCreate).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ summary: '开会' }), params: { user_id_type: 'open_id' }, @@ -406,6 +415,166 @@ describe('feishu_task tool', () => { }); }); + describe('add_members', () => { + it('should add members to a task', async () => { + mockTaskAddMembers.mockResolvedValue({ code: 0 }); + const result = await capturedHandler({ + action: 'add_members', + task_guid: 'TASK_001', + members: '[{"id": "ou_123", "role": "follower"}]', + }); + expect(result.content[0].text).toBe('成员已添加'); + expect(mockTaskAddMembers).toHaveBeenCalledWith(expect.objectContaining({ + path: { task_guid: 'TASK_001' }, + data: { members: [{ id: 'ou_123', role: 'follower' }] }, + params: { user_id_type: 'open_id' }, + }), undefined); + }); + + it('should require task_guid', async () => { + const result = await capturedHandler({ + action: 'add_members', + members: '[{"id": "ou_123", "role": "assignee"}]', + }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('task_guid'); + }); + + it('should require members', async () => { + const result = await capturedHandler({ + action: 'add_members', + task_guid: 'TASK_001', + }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('members'); + }); + + it('should handle API errors', async () => { + mockTaskAddMembers.mockResolvedValue({ code: 1470404, msg: 'task not found' }); + const result = await capturedHandler({ + action: 'add_members', + task_guid: 'TASK_001', + members: '[{"id": "ou_123", "role": "assignee"}]', + }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('1470404'); + }); + }); + + describe('remove_members', () => { + it('should remove members from a task', async () => { + mockTaskRemoveMembers.mockResolvedValue({ code: 0 }); + const result = await capturedHandler({ + action: 'remove_members', + task_guid: 'TASK_001', + members: '[{"id": "ou_123", "role": "follower"}]', + }); + expect(result.content[0].text).toBe('成员已移除'); + expect(mockTaskRemoveMembers).toHaveBeenCalledWith(expect.objectContaining({ + path: { task_guid: 'TASK_001' }, + data: { members: [{ id: 'ou_123', role: 'follower' }] }, + params: { user_id_type: 'open_id' }, + }), undefined); + }); + + it('should require task_guid', async () => { + const result = await capturedHandler({ + action: 'remove_members', + members: '[{"id": "ou_123", "role": "assignee"}]', + }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('task_guid'); + }); + + it('should require members', async () => { + const result = await capturedHandler({ + action: 'remove_members', + task_guid: 'TASK_001', + }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('members'); + }); + }); + + describe('list_tasklists', () => { + it('should list tasklists', async () => { + mockTasklistList.mockResolvedValue({ + code: 0, + data: { + items: [ + { guid: 'TL_001', name: 'UrhoX', creator: { id: 'ou_111' }, url: 'https://...' }, + { guid: 'TL_002', name: 'Bug', creator: { id: 'ou_222' }, url: 'https://...' }, + ], + has_more: false, + }, + }); + const result = await capturedHandler({ action: 'list_tasklists' }); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.items).toHaveLength(2); + expect(parsed.items[0].guid).toBe('TL_001'); + expect(parsed.items[0].name).toBe('UrhoX'); + expect(parsed.items[1].name).toBe('Bug'); + expect(mockTasklistList).toHaveBeenCalledWith(expect.objectContaining({ + params: expect.objectContaining({ page_size: 20, user_id_type: 'open_id' }), + }), undefined); + }); + + it('should pass page_size and page_token', async () => { + mockTasklistList.mockResolvedValue({ + code: 0, + data: { items: [], has_more: false }, + }); + await capturedHandler({ action: 'list_tasklists', page_size: 5, page_token: 'abc' }); + expect(mockTasklistList).toHaveBeenCalledWith(expect.objectContaining({ + params: expect.objectContaining({ page_size: 5, page_token: 'abc' }), + }), undefined); + }); + + it('should handle API errors', async () => { + mockTasklistList.mockResolvedValue({ code: 99999, msg: 'forbidden' }); + const result = await capturedHandler({ action: 'list_tasklists' }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('99999'); + }); + }); + + describe('create with tasklists', () => { + it('should create a task with tasklists', async () => { + mockTaskCreate.mockResolvedValue({ + code: 0, + data: { task: { guid: 'TASK_010', summary: '新功能' } }, + }); + const result = await capturedHandler({ + action: 'create', + summary: '新功能', + tasklists: '[{"tasklist_guid": "TL_001"}]', + }); + expect(result.isError).toBeUndefined(); + const callData = mockTaskCreate.mock.calls[0][0].data; + expect(callData.tasklists).toEqual([{ tasklist_guid: 'TL_001' }]); + }); + + it('should reject invalid tasklists JSON', async () => { + const result = await capturedHandler({ + action: 'create', + summary: '任务', + tasklists: 'not-json', + }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('不是有效的 JSON'); + }); + + it('should reject tasklists without tasklist_guid', async () => { + const result = await capturedHandler({ + action: 'create', + summary: '任务', + tasklists: '[{"name": "UrhoX"}]', + }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('tasklist_guid'); + }); + }); + describe('update with completed_at', () => { it('should set completed_at to current timestamp', async () => { mockTaskPatch.mockResolvedValue({ code: 0 }); diff --git a/src/feishu/tools/task.ts b/src/feishu/tools/task.ts index 94cabee2..ef896be4 100644 --- a/src/feishu/tools/task.ts +++ b/src/feishu/tools/task.ts @@ -95,6 +95,35 @@ function validateMembers(jsonStr: string): Array<{ id: string; role: string; typ return parsed as Array<{ id: string; role: string; type?: string }>; } +/** + * 校验 tasklists JSON 字符串,解析为飞书任务 API 要求的清单数组 + * + * 格式: [{"tasklist_guid": "xxx", "section_guid": "yyy"}] + * - tasklist_guid: 必填,清单 ID(通过 list_tasklists 获取) + * - section_guid: 可选,清单中的分组 ID + */ +function validateTasklists(jsonStr: string): Array<{ tasklist_guid: string; section_guid?: string }> { + let parsed: unknown; + try { + parsed = JSON.parse(jsonStr); + } catch { + throw new Error('tasklists 不是有效的 JSON 字符串'); + } + if (!Array.isArray(parsed)) { + throw new Error('tasklists 必须是 JSON 数组 (如 [{"tasklist_guid": "xxx"}])'); + } + for (const item of parsed) { + if (typeof item !== 'object' || item === null) { + throw new Error('tasklists 数组元素必须是对象'); + } + const obj = item as Record; + if (typeof obj.tasklist_guid !== 'string' || !obj.tasklist_guid) { + throw new Error('tasklists 元素需要 tasklist_guid 字段'); + } + } + return parsed as Array<{ tasklist_guid: string; section_guid?: string }>; +} + /** * 飞书任务 MCP 工具 * @@ -111,19 +140,23 @@ export function feishuTaskTool(getUserToken?: () => Promise) '创建和管理飞书任务 (Task v2)。', '', 'Actions:', - '- create: 创建任务 (需要 summary)', + '- create: 创建任务 (需要 summary, 可选 tasklists 指定归属清单)', '- get: 获取任务详情 (需要 task_guid)', '- list: 查询任务列表 (支持 completed/page_size 过滤)', + '- list_tasklists: 列出可用的任务清单 (返回 guid + name)', '- update: 编辑任务 (需要 task_guid + update_fields)', '- delete: 删除任务 (需要 task_guid)', + '- add_members: 添加任务成员 (需要 task_guid + members)', + '- remove_members: 移除任务成员 (需要 task_guid + members)', '', '时间格式 (due/start): Unix 秒级时间戳 或 ISO 日期 (如 "2026-03-15" 或 "2026-03-15T10:00:00")', 'members 格式: JSON 数组 \'[{"id": "ou_xxx", "role": "assignee"}]\'', + 'tasklists 格式: JSON 数组 \'[{"tasklist_guid": "xxx"}]\' (通过 list_tasklists 获取可用清单)', 'update_fields: 逗号分隔的字段名 (如 "summary,due,description,completed_at")', ' - completed_at: 设为当前时间表示完成任务,设为空字符串表示取消完成', ].join('\n'), { - action: z.enum(['create', 'get', 'list', 'update', 'delete']).describe('操作类型'), + action: z.enum(['create', 'get', 'list', 'list_tasklists', 'update', 'delete', 'add_members', 'remove_members']).describe('操作类型'), // create / update 共用 summary: z.string().optional().describe('任务标题 (create 时必填)'), description: z.string().optional().describe('任务描述'), @@ -136,9 +169,10 @@ export function feishuTaskTool(getUserToken?: () => Promise) update_fields: z.string().optional().describe('更新的字段名, 逗号分隔 (如 "summary,due")'), // create 专用 members: z.string().optional().describe('成员 JSON 数组 (如 \'[{"id": "ou_xxx", "role": "assignee"}]\')'), + tasklists: z.string().optional().describe('归属清单 JSON 数组 (如 \'[{"tasklist_guid": "xxx"}]\', 通过 list_tasklists 获取)'), // list - page_size: z.number().optional().describe('每页任务数 (list 时可选, 默认 20)'), - page_token: z.string().optional().describe('分页 token (list 时可选)'), + page_size: z.number().optional().describe('每页数量 (list/list_tasklists 时可选, 默认 20)'), + page_token: z.string().optional().describe('分页 token (list/list_tasklists 时可选)'), completed: z.boolean().optional().describe('筛选已完成/未完成 (list 时可选)'), user_id_type: z.string().optional().describe('用户 ID 类型 (默认 open_id)'), }, @@ -166,6 +200,9 @@ export function feishuTaskTool(getUserToken?: () => Promise) if (args.members) { data.members = validateMembers(args.members); } + if (args.tasklists) { + data.tasklists = validateTasklists(args.tasklists); + } const resp = await client.task.v2.task.create({ data: data as { summary: string }, @@ -173,14 +210,16 @@ export function feishuTaskTool(getUserToken?: () => Promise) }, userTokenOpt); if (resp.code !== 0) throw new Error(`创建任务失败 (${resp.code}): ${resp.msg}`); const task = resp.data?.task; + const guid = task?.guid ?? '(未知)'; return { content: [{ type: 'text' as const, text: [ '任务已创建', - `guid: ${task?.guid ?? '(未知)'}`, + `guid: ${guid}`, `summary: ${task?.summary ?? ''}`, task?.due ? `due: ${task.due.timestamp}` : '', + guid !== '(未知)' ? `link: https://applink.feishu.cn/client/todo/detail?guid=${guid}` : '', ].filter(Boolean).join('\n'), }], }; @@ -304,6 +343,35 @@ export function feishuTaskTool(getUserToken?: () => Promise) }; } + case 'list_tasklists': { + const tlResp = await client.task.v2.tasklist.list({ + params: { + page_size: args.page_size ?? 20, + ...(args.page_token ? { page_token: args.page_token } : {}), + user_id_type: args.user_id_type ?? 'open_id', + }, + }, userTokenOpt); + if (tlResp.code !== 0) throw new Error(`查询任务清单失败 (${tlResp.code}): ${tlResp.msg}`); + + const tasklists = (tlResp.data?.items ?? []).map((tl) => ({ + guid: tl.guid, + name: tl.name, + creator_id: tl.creator?.id, + url: tl.url, + })); + + return { + content: [{ + type: 'text' as const, + text: JSON.stringify({ + items: tasklists, + has_more: tlResp.data?.has_more ?? false, + page_token: tlResp.data?.page_token, + }, null, 2), + }], + }; + } + case 'update': { if (!args.task_guid) throw new Error('update 操作需要 task_guid'); if (!args.update_fields) throw new Error('update 操作需要 update_fields (逗号分隔的字段名, 如 "summary,due")'); @@ -356,6 +424,42 @@ export function feishuTaskTool(getUserToken?: () => Promise) }; } + case 'add_members': { + if (!args.task_guid) throw new Error('add_members 操作需要 task_guid'); + if (!args.members) throw new Error('add_members 操作需要 members (JSON 数组)'); + const addMembers = validateMembers(args.members); + const addResp = await client.task.v2.task.addMembers({ + path: { task_guid: args.task_guid }, + data: { members: addMembers }, + params: { user_id_type: args.user_id_type ?? 'open_id' }, + }, userTokenOpt); + if (addResp.code !== 0) throw new Error(`添加成员失败 (${addResp.code}): ${addResp.msg}`); + return { + content: [{ + type: 'text' as const, + text: '成员已添加', + }], + }; + } + + case 'remove_members': { + if (!args.task_guid) throw new Error('remove_members 操作需要 task_guid'); + if (!args.members) throw new Error('remove_members 操作需要 members (JSON 数组)'); + const rmMembers = validateMembers(args.members); + const rmResp = await client.task.v2.task.removeMembers({ + path: { task_guid: args.task_guid }, + data: { members: rmMembers }, + params: { user_id_type: args.user_id_type ?? 'open_id' }, + }, userTokenOpt); + if (rmResp.code !== 0) throw new Error(`移除成员失败 (${rmResp.code}): ${rmResp.msg}`); + return { + content: [{ + type: 'text' as const, + text: '成员已移除', + }], + }; + } + default: return { content: [{ type: 'text' as const, text: `未知 action: ${args.action}` }], isError: true }; }