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
5 changes: 4 additions & 1 deletion src/claude/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
171 changes: 170 additions & 1 deletion src/feishu/tools/__tests__/task.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand All @@ -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),
},
},
},
Expand Down Expand Up @@ -123,14 +131,15 @@ 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: '开会' } },
});
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' },
Expand Down Expand Up @@ -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 });
Expand Down
114 changes: 109 additions & 5 deletions src/feishu/tools/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
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 工具
*
Expand All @@ -111,19 +140,23 @@ export function feishuTaskTool(getUserToken?: () => Promise<string | undefined>)
'创建和管理飞书任务 (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('任务描述'),
Expand All @@ -136,9 +169,10 @@ export function feishuTaskTool(getUserToken?: () => Promise<string | undefined>)
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)'),
},
Expand Down Expand Up @@ -166,21 +200,26 @@ export function feishuTaskTool(getUserToken?: () => Promise<string | undefined>)
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 },
params: { user_id_type: args.user_id_type ?? 'open_id' },
}, 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'),
}],
};
Expand Down Expand Up @@ -304,6 +343,35 @@ export function feishuTaskTool(getUserToken?: () => Promise<string | undefined>)
};
}

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")');
Expand Down Expand Up @@ -356,6 +424,42 @@ export function feishuTaskTool(getUserToken?: () => Promise<string | undefined>)
};
}

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 };
}
Expand Down
Loading