feishu-user-plugin 1.3.6 → 1.3.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +2 -2
- package/CHANGELOG.md +22 -0
- package/README.md +55 -40
- package/package.json +10 -3
- package/scripts/check-tool-count.js +15 -0
- package/scripts/check-version.js +40 -0
- package/scripts/smoke.js +224 -0
- package/scripts/sync-claude-md.sh +12 -0
- package/scripts/sync-team-skills.sh +22 -0
- package/scripts/test-all-tools.js +158 -0
- package/skills/feishu-user-plugin/SKILL.md +5 -5
- package/skills/feishu-user-plugin/references/CLAUDE.md +138 -99
- package/skills/feishu-user-plugin/references/table.md +18 -9
- package/src/auth/credentials.js +350 -0
- package/src/cli.js +42 -13
- package/src/clients/official/base.js +424 -0
- package/src/clients/official/bitable.js +269 -0
- package/src/clients/official/calendar.js +176 -0
- package/src/clients/official/contacts.js +54 -0
- package/src/clients/official/docs.js +301 -0
- package/src/clients/official/drive.js +77 -0
- package/src/clients/official/groups.js +68 -0
- package/src/clients/official/im.js +414 -0
- package/src/clients/official/index.js +30 -0
- package/src/clients/official/okr.js +127 -0
- package/src/clients/official/tasks.js +142 -0
- package/src/clients/official/uploads.js +260 -0
- package/src/clients/official/wiki.js +207 -0
- package/src/{client.js → clients/user.js} +23 -17
- package/src/index.js +4 -1977
- package/src/logger.js +20 -0
- package/src/oauth.js +5 -1
- package/src/official.js +5 -1944
- package/src/prompts/_registry.js +69 -0
- package/src/prompts/index.js +54 -0
- package/src/server.js +242 -0
- package/src/test-all.js +2 -2
- package/src/test-comprehensive.js +3 -3
- package/src/test-send.js +1 -1
- package/src/tools/_registry.js +30 -0
- package/src/tools/bitable.js +246 -0
- package/src/tools/calendar.js +207 -0
- package/src/tools/contacts.js +66 -0
- package/src/tools/diagnostics.js +172 -0
- package/src/tools/docs.js +158 -0
- package/src/tools/drive.js +111 -0
- package/src/tools/groups.js +81 -0
- package/src/tools/im-read.js +259 -0
- package/src/tools/messaging-bot.js +151 -0
- package/src/tools/messaging-user.js +292 -0
- package/src/tools/okr.js +159 -0
- package/src/tools/profile.js +43 -0
- package/src/tools/tasks.js +168 -0
- package/src/tools/uploads.js +63 -0
- package/src/tools/wiki.js +191 -0
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
// src/tools/messaging-user.js — User-identity (cookie-based) messaging plus
|
|
2
|
+
// batch_send fan-out and the v1.3.6 bot-default send_card_as_user.
|
|
3
|
+
//
|
|
4
|
+
// All send_*_as_user handlers route through ctx.getUserClient() (cookie identity).
|
|
5
|
+
// batch_send mixes user + bot identities per target. send_card_as_user currently
|
|
6
|
+
// delegates to bot via ctx.getOfficialClient() — the "as_user" suffix is reserved
|
|
7
|
+
// for v1.3.7's reverse-engineered cookie path; default flips when that lands.
|
|
8
|
+
|
|
9
|
+
const { text, sendResult, json } = require('./_registry');
|
|
10
|
+
|
|
11
|
+
// v1.3.7 C1.4: send_*_as_user (cookie protobuf) requires NUMERIC chat_id.
|
|
12
|
+
// When callers pass `oc_xxx` (Open API format), resolve it via
|
|
13
|
+
// getChatInfo(oc_xxx) → name → cookie search(name) → numeric id
|
|
14
|
+
// and cache the mapping for the session. Without resolution, the cookie
|
|
15
|
+
// gateway accepts the call but the message goes nowhere (server treats
|
|
16
|
+
// the chatId field as unknown and returns an empty packet).
|
|
17
|
+
const _ocCache = new Map();
|
|
18
|
+
|
|
19
|
+
async function _resolveCookieChatId(chatId, ctx) {
|
|
20
|
+
if (!chatId || typeof chatId !== 'string') return chatId;
|
|
21
|
+
if (!chatId.startsWith('oc_')) return chatId;
|
|
22
|
+
if (_ocCache.has(chatId)) return _ocCache.get(chatId);
|
|
23
|
+
let name;
|
|
24
|
+
try {
|
|
25
|
+
const info = await ctx.getOfficialClient().getChatInfo(chatId);
|
|
26
|
+
name = info?.name;
|
|
27
|
+
} catch (e) {
|
|
28
|
+
throw new Error(`Cannot resolve ${chatId} to a numeric chat_id (cookie protobuf needs numeric): getChatInfo failed (${e.message}). Pass a numeric chat_id directly — get one via search_contacts + create_p2p_chat (P2P) or list_user_chats (group).`);
|
|
29
|
+
}
|
|
30
|
+
if (!name) {
|
|
31
|
+
throw new Error(`Cannot resolve ${chatId}: getChatInfo returned no name. Pass a numeric chat_id directly.`);
|
|
32
|
+
}
|
|
33
|
+
const c = await ctx.getUserClient();
|
|
34
|
+
const results = await c.search(name);
|
|
35
|
+
// Prefer exact name match on a group; fall back to first group / user with this name.
|
|
36
|
+
const exact = results.find((r) => r.title === name && r.type === 'group');
|
|
37
|
+
const looser = exact || results.find((r) => r.type === 'group') || results.find((r) => r.title === name);
|
|
38
|
+
if (!looser) {
|
|
39
|
+
throw new Error(`Cannot resolve ${chatId} (chat "${name}"): no matching chat in cookie search. The chat may be a P2P with someone outside your search index, or you may not be a member. Use create_p2p_chat for P2P, or pass numeric chat_id directly.`);
|
|
40
|
+
}
|
|
41
|
+
const numeric = String(looser.id);
|
|
42
|
+
_ocCache.set(chatId, numeric);
|
|
43
|
+
return numeric;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const schemas = [
|
|
47
|
+
{
|
|
48
|
+
name: 'send_as_user',
|
|
49
|
+
description: '[User Identity] Send a text message as the logged-in Feishu user. Supports reply threading and real @-mentions (triggers push notifications).',
|
|
50
|
+
inputSchema: {
|
|
51
|
+
type: 'object',
|
|
52
|
+
properties: {
|
|
53
|
+
chat_id: { type: 'string', description: 'Target chat ID. Numeric (from create_p2p_chat / search) preferred; oc_xxx is auto-resolved via getChatInfo + cookie search since v1.3.7 (C1.4).' },
|
|
54
|
+
text: { type: 'string', description: 'Message text. If `ats` is provided, include the display marker for each @ in this text (default marker is `@<name>`).' },
|
|
55
|
+
ats: {
|
|
56
|
+
type: 'array',
|
|
57
|
+
description: 'Optional @-mentions. Each entry: {userId: "ou_xxx", name: "DisplayName"}. The text must contain each @<name> marker in order — it gets spliced into a real AT element so the mentioned user receives a notification.',
|
|
58
|
+
items: { type: 'object', properties: { userId: { type: 'string' }, name: { type: 'string' }, marker: { type: 'string' } } },
|
|
59
|
+
},
|
|
60
|
+
root_id: { type: 'string', description: 'Thread root message ID (for reply, optional)' },
|
|
61
|
+
parent_id: { type: 'string', description: 'Parent message ID (for nested reply, optional)' },
|
|
62
|
+
},
|
|
63
|
+
required: ['chat_id', 'text'],
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
name: 'send_to_user',
|
|
68
|
+
description: '[User Identity] Search user by name → create P2P chat → send text message. All in one step.',
|
|
69
|
+
inputSchema: {
|
|
70
|
+
type: 'object',
|
|
71
|
+
properties: {
|
|
72
|
+
user_name: { type: 'string', description: 'Recipient name (Chinese or English)' },
|
|
73
|
+
text: { type: 'string', description: 'Message text' },
|
|
74
|
+
ats: {
|
|
75
|
+
type: 'array',
|
|
76
|
+
description: 'Optional @-mentions. Same format as send_as_user.ats: [{userId, name}]. Text must contain the `@<name>` marker for each entry.',
|
|
77
|
+
items: { type: 'object', properties: { userId: { type: 'string' }, name: { type: 'string' }, marker: { type: 'string' } } },
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
required: ['user_name', 'text'],
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
name: 'send_to_group',
|
|
85
|
+
description: '[User Identity] Search group by name → send text message. All in one step.',
|
|
86
|
+
inputSchema: {
|
|
87
|
+
type: 'object',
|
|
88
|
+
properties: {
|
|
89
|
+
group_name: { type: 'string', description: 'Group chat name' },
|
|
90
|
+
text: { type: 'string', description: 'Message text' },
|
|
91
|
+
ats: {
|
|
92
|
+
type: 'array',
|
|
93
|
+
description: 'Optional @-mentions that trigger real notifications. Each entry: {userId, name}. Text must contain `@<name>` marker for each entry.',
|
|
94
|
+
items: { type: 'object', properties: { userId: { type: 'string' }, name: { type: 'string' }, marker: { type: 'string' } } },
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
required: ['group_name', 'text'],
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
name: 'batch_send',
|
|
102
|
+
description: '[User Identity / Official API] Send the same or different content to multiple targets in one call. Each target dispatches sequentially with a small delay (anti-rate-limit) and reports per-target success/error. Identity is the cookie user (user-identity sends) unless target.via=bot. Use for broadcast / fan-out scenarios.',
|
|
103
|
+
inputSchema: {
|
|
104
|
+
type: 'object',
|
|
105
|
+
properties: {
|
|
106
|
+
targets: {
|
|
107
|
+
type: 'array',
|
|
108
|
+
description: 'Array of targets. Each entry: { type: "user"|"group"|"chat", id: <user_name | group_name | chat_id>, content: { kind: "text"|"image"|"file"|"post", ... } }. For kind="text": { text }. For "image": { image_key }. For "file": { file_key, file_name }. For "post": { title, paragraphs }. Optional per-target: via="bot" routes through send_message_as_bot (chat_id required).',
|
|
109
|
+
items: { type: 'object' },
|
|
110
|
+
},
|
|
111
|
+
delay_ms: { type: 'number', description: 'Delay between sends in milliseconds (default 200, increase for risky volumes).' },
|
|
112
|
+
},
|
|
113
|
+
required: ['targets'],
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
name: 'send_image_as_user',
|
|
118
|
+
description: '[User Identity] Send an image as the logged-in user. Requires image_key (upload via Official API first).',
|
|
119
|
+
inputSchema: {
|
|
120
|
+
type: 'object',
|
|
121
|
+
properties: {
|
|
122
|
+
chat_id: { type: 'string', description: 'Target chat ID. Numeric preferred; oc_xxx is auto-resolved (v1.3.7 C1.4).' },
|
|
123
|
+
image_key: { type: 'string', description: 'Image key from upload (img_v2_xxx or img_v3_xxx)' },
|
|
124
|
+
root_id: { type: 'string', description: 'Thread root message ID (optional)' },
|
|
125
|
+
},
|
|
126
|
+
required: ['chat_id', 'image_key'],
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
name: 'send_file_as_user',
|
|
131
|
+
description: '[User Identity] Send a file as the logged-in user. Requires file_key (upload via Official API first).',
|
|
132
|
+
inputSchema: {
|
|
133
|
+
type: 'object',
|
|
134
|
+
properties: {
|
|
135
|
+
chat_id: { type: 'string', description: 'Target chat ID. Numeric preferred; oc_xxx is auto-resolved (v1.3.7 C1.4).' },
|
|
136
|
+
file_key: { type: 'string', description: 'File key from upload' },
|
|
137
|
+
file_name: { type: 'string', description: 'Display file name' },
|
|
138
|
+
root_id: { type: 'string', description: 'Thread root message ID (optional)' },
|
|
139
|
+
},
|
|
140
|
+
required: ['chat_id', 'file_key', 'file_name'],
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
name: 'send_post_as_user',
|
|
145
|
+
description: '[User Identity] Send a rich text (POST) message with title and formatted paragraphs. Supports real @-mentions that trigger notifications.',
|
|
146
|
+
inputSchema: {
|
|
147
|
+
type: 'object',
|
|
148
|
+
properties: {
|
|
149
|
+
chat_id: { type: 'string', description: 'Target chat ID. Numeric preferred; oc_xxx is auto-resolved (v1.3.7 C1.4).' },
|
|
150
|
+
title: { type: 'string', description: 'Post title (optional)' },
|
|
151
|
+
paragraphs: {
|
|
152
|
+
type: 'array',
|
|
153
|
+
description: 'Array of paragraphs. Each paragraph is an array of elements:\n• {tag:"text",text:"..."} — plain text\n• {tag:"a",href:"https://...",text:"display"} — hyperlink\n• {tag:"at",userId:"ou_xxx",name:"Display Name"} — real @-mention (triggers notification)',
|
|
154
|
+
items: { type: 'array', items: { type: 'object' } },
|
|
155
|
+
},
|
|
156
|
+
root_id: { type: 'string', description: 'Thread root message ID (optional)' },
|
|
157
|
+
},
|
|
158
|
+
required: ['chat_id', 'paragraphs'],
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
name: 'send_card_as_user',
|
|
163
|
+
description: '[v1.3.6: bot-routed default] Send an interactive card to a chat. **As of v1.3.6, identity defaults to BOT** because user-identity card sending requires reverse-engineering the Feishu web protobuf and is deferred to v1.3.7. The tool name keeps the "as_user" suffix so callers don\'t have to migrate when v1.3.7 lands; once user-identity is implemented the default flips. Pass `card` as a JSON object (Feishu card schema). To force bot explicitly set via="bot".',
|
|
164
|
+
inputSchema: {
|
|
165
|
+
type: 'object',
|
|
166
|
+
properties: {
|
|
167
|
+
chat_id: { type: 'string', description: 'Target chat_id (oc_xxx) or open_id' },
|
|
168
|
+
card: { description: 'Feishu card JSON. See https://open.feishu.cn/cardkit for the schema; build cards visually then paste the resulting JSON here.' },
|
|
169
|
+
via: { type: 'string', enum: ['bot', 'user'], description: 'Identity to send as. Default "bot". "user" returns an explicit not-yet-implemented error in v1.3.6.' },
|
|
170
|
+
},
|
|
171
|
+
required: ['chat_id', 'card'],
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
];
|
|
175
|
+
|
|
176
|
+
const handlers = {
|
|
177
|
+
async send_as_user(args, ctx) {
|
|
178
|
+
const c = await ctx.getUserClient();
|
|
179
|
+
const chatId = await _resolveCookieChatId(args.chat_id, ctx);
|
|
180
|
+
const r = await c.sendMessage(chatId, args.text, { rootId: args.root_id, parentId: args.parent_id, ats: args.ats });
|
|
181
|
+
return sendResult(r, `Text sent as user to ${args.chat_id}`);
|
|
182
|
+
},
|
|
183
|
+
async send_to_user(args, ctx) {
|
|
184
|
+
const c = await ctx.getUserClient();
|
|
185
|
+
const results = await c.search(args.user_name);
|
|
186
|
+
const users = results.filter(r => r.type === 'user');
|
|
187
|
+
if (users.length === 0) return text(`User "${args.user_name}" not found. Results: ${JSON.stringify(results)}`);
|
|
188
|
+
if (users.length > 1) {
|
|
189
|
+
const candidates = users.slice(0, 5).map(u => ` - ${u.title} (ID: ${u.id})`).join('\n');
|
|
190
|
+
return text(`Multiple users match "${args.user_name}":\n${candidates}\nUse search_contacts to find the exact user, then create_p2p_chat + send_as_user.`);
|
|
191
|
+
}
|
|
192
|
+
const user = users[0];
|
|
193
|
+
const chatId = await c.createChat(user.id);
|
|
194
|
+
if (!chatId) return text(`Failed to create chat with ${user.title}`);
|
|
195
|
+
const r = await c.sendMessage(chatId, args.text, { ats: args.ats });
|
|
196
|
+
return sendResult(r, `Text sent to ${user.title} (chat: ${chatId})`);
|
|
197
|
+
},
|
|
198
|
+
async send_to_group(args, ctx) {
|
|
199
|
+
const c = await ctx.getUserClient();
|
|
200
|
+
const results = await c.search(args.group_name);
|
|
201
|
+
const groups = results.filter(r => r.type === 'group');
|
|
202
|
+
if (groups.length === 0) return text(`Group "${args.group_name}" not found. Results: ${JSON.stringify(results)}`);
|
|
203
|
+
if (groups.length > 1) {
|
|
204
|
+
const candidates = groups.slice(0, 5).map(g => ` - ${g.title} (ID: ${g.id})`).join('\n');
|
|
205
|
+
return text(`Multiple groups match "${args.group_name}":\n${candidates}\nUse search_contacts to find the exact group, then send_as_user with the ID.`);
|
|
206
|
+
}
|
|
207
|
+
const group = groups[0];
|
|
208
|
+
const r = await c.sendMessage(group.id, args.text, { ats: args.ats });
|
|
209
|
+
return sendResult(r, `Text sent to group "${group.title}" (${group.id})`);
|
|
210
|
+
},
|
|
211
|
+
async batch_send(args, ctx) {
|
|
212
|
+
if (!Array.isArray(args.targets) || args.targets.length === 0) return text('batch_send: targets must be a non-empty array');
|
|
213
|
+
const delay = typeof args.delay_ms === 'number' ? args.delay_ms : 200;
|
|
214
|
+
const userClient = await ctx.getUserClient();
|
|
215
|
+
const officialClient = ctx.getOfficialClient();
|
|
216
|
+
const results = [];
|
|
217
|
+
for (let i = 0; i < args.targets.length; i++) {
|
|
218
|
+
const t = args.targets[i];
|
|
219
|
+
try {
|
|
220
|
+
if (!t.content || !t.content.kind) throw new Error('content.kind is required');
|
|
221
|
+
// Resolve chat id from name when applicable
|
|
222
|
+
let chatId = t.id;
|
|
223
|
+
if (t.type === 'user' || t.type === 'group') {
|
|
224
|
+
const matches = await userClient.search(t.id);
|
|
225
|
+
const want = matches.filter(m => m.type === t.type);
|
|
226
|
+
if (want.length === 0) throw new Error(`No ${t.type} matches "${t.id}"`);
|
|
227
|
+
if (want.length > 1) throw new Error(`Ambiguous ${t.type} "${t.id}" (${want.length} matches). Use type="chat" with explicit chat_id.`);
|
|
228
|
+
const picked = want[0];
|
|
229
|
+
chatId = t.type === 'user' ? await userClient.createChat(picked.id) : picked.id;
|
|
230
|
+
if (!chatId) throw new Error(`Could not resolve chat for ${t.type} ${picked.title}`);
|
|
231
|
+
} else if (t.via !== 'bot') {
|
|
232
|
+
// type=chat with cookie identity — resolve oc_xxx → numeric (v1.3.7 C1.4).
|
|
233
|
+
chatId = await _resolveCookieChatId(chatId, ctx);
|
|
234
|
+
}
|
|
235
|
+
let r;
|
|
236
|
+
if (t.via === 'bot') {
|
|
237
|
+
const c = t.content;
|
|
238
|
+
const payload = c.kind === 'text' ? { text: c.text }
|
|
239
|
+
: c.kind === 'post' ? { post: { zh_cn: { title: c.title || '', content: c.paragraphs || [] } } }
|
|
240
|
+
: c.kind === 'image' ? { image_key: c.image_key }
|
|
241
|
+
: c.kind === 'interactive' ? c.card
|
|
242
|
+
: null;
|
|
243
|
+
if (!payload) throw new Error(`bot path does not support content.kind=${c.kind}`);
|
|
244
|
+
const msgType = c.kind === 'interactive' ? 'interactive' : c.kind;
|
|
245
|
+
r = await officialClient.sendMessageAsBot(chatId, msgType, payload);
|
|
246
|
+
results.push({ ok: true, target: t, messageId: r.messageId, via: 'bot' });
|
|
247
|
+
} else {
|
|
248
|
+
const c = t.content;
|
|
249
|
+
if (c.kind === 'text') r = await userClient.sendMessage(chatId, c.text, { ats: c.ats });
|
|
250
|
+
else if (c.kind === 'image') r = await userClient.sendImage(chatId, c.image_key);
|
|
251
|
+
else if (c.kind === 'file') r = await userClient.sendFile(chatId, c.file_key, c.file_name);
|
|
252
|
+
else if (c.kind === 'post') r = await userClient.sendPost(chatId, c.title, c.paragraphs);
|
|
253
|
+
else throw new Error(`unknown content.kind=${c.kind}`);
|
|
254
|
+
results.push({ ok: true, target: t, messageId: r.messageId, via: 'user' });
|
|
255
|
+
}
|
|
256
|
+
} catch (e) {
|
|
257
|
+
results.push({ ok: false, target: t, error: e.message });
|
|
258
|
+
}
|
|
259
|
+
if (i < args.targets.length - 1 && delay > 0) await new Promise(r => setTimeout(r, delay));
|
|
260
|
+
}
|
|
261
|
+
const okCount = results.filter(r => r.ok).length;
|
|
262
|
+
return json({ summary: `${okCount}/${results.length} sent`, results });
|
|
263
|
+
},
|
|
264
|
+
async send_image_as_user(args, ctx) {
|
|
265
|
+
const c = await ctx.getUserClient();
|
|
266
|
+
const chatId = await _resolveCookieChatId(args.chat_id, ctx);
|
|
267
|
+
const r = await c.sendImage(chatId, args.image_key, { rootId: args.root_id });
|
|
268
|
+
return sendResult(r, `Image sent to ${args.chat_id}`);
|
|
269
|
+
},
|
|
270
|
+
async send_file_as_user(args, ctx) {
|
|
271
|
+
const c = await ctx.getUserClient();
|
|
272
|
+
const chatId = await _resolveCookieChatId(args.chat_id, ctx);
|
|
273
|
+
const r = await c.sendFile(chatId, args.file_key, args.file_name, { rootId: args.root_id });
|
|
274
|
+
return sendResult(r, `File "${args.file_name}" sent to ${args.chat_id}`);
|
|
275
|
+
},
|
|
276
|
+
async send_post_as_user(args, ctx) {
|
|
277
|
+
const c = await ctx.getUserClient();
|
|
278
|
+
const chatId = await _resolveCookieChatId(args.chat_id, ctx);
|
|
279
|
+
const r = await c.sendPost(chatId, args.title || '', args.paragraphs, { rootId: args.root_id });
|
|
280
|
+
return sendResult(r, `Post sent to ${args.chat_id}`);
|
|
281
|
+
},
|
|
282
|
+
async send_card_as_user(args, ctx) {
|
|
283
|
+
const via = args.via || 'bot';
|
|
284
|
+
if (via === 'user') {
|
|
285
|
+
return text('send_card_as_user via="user" is not implemented in v1.3.6 — user-identity card sending requires reverse-engineering the Feishu web protobuf and is scheduled for v1.3.7. Use via="bot" (default) for now.');
|
|
286
|
+
}
|
|
287
|
+
const r = await ctx.getOfficialClient().sendMessageAsBot(args.chat_id, 'interactive', args.card);
|
|
288
|
+
return text(`Card sent (${via}): ${r.messageId}`);
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
module.exports = { schemas, handlers };
|
package/src/tools/okr.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// src/tools/okr.js — OKR read tools (v1.3.4) + progress record write (v1.3.7).
|
|
2
|
+
|
|
3
|
+
const { json, text } = require('./_registry');
|
|
4
|
+
|
|
5
|
+
// Helper: wrap plain text into the Feishu OKR progressRecord content block schema.
|
|
6
|
+
// Feishu's progressRecord.create expects a `content: { blocks: [...] }` payload
|
|
7
|
+
// where each block is a paragraph or gallery. Most callers just want a plain
|
|
8
|
+
// note, so this helper builds the trivial single-paragraph form.
|
|
9
|
+
function buildOkrContent(text) {
|
|
10
|
+
return {
|
|
11
|
+
blocks: [
|
|
12
|
+
{
|
|
13
|
+
type: 'paragraph',
|
|
14
|
+
paragraph: {
|
|
15
|
+
elements: [
|
|
16
|
+
{ type: 'textRun', textRun: { text: String(text || '') } },
|
|
17
|
+
],
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
],
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const schemas = [
|
|
25
|
+
{
|
|
26
|
+
name: 'list_user_okrs',
|
|
27
|
+
description: '[Official API + UAT] List a user\'s OKRs. Requires the user\'s open_id (get yours via get_login_status or search_contacts). Filter by period_ids to narrow to a specific quarter.',
|
|
28
|
+
inputSchema: {
|
|
29
|
+
type: 'object',
|
|
30
|
+
properties: {
|
|
31
|
+
user_id: { type: 'string', description: 'Target user\'s open_id (or the matching user_id_type)' },
|
|
32
|
+
user_id_type: { type: 'string', enum: ['user_id', 'union_id', 'open_id', 'people_admin_id'], description: 'Type of user_id (default: open_id)' },
|
|
33
|
+
period_ids: { type: 'array', items: { type: 'string' }, description: 'Filter by OKR period IDs (optional). Get period IDs via list_okr_periods.' },
|
|
34
|
+
offset: { type: 'number', description: 'Pagination offset (default 0)' },
|
|
35
|
+
limit: { type: 'number', description: 'Items per page (default 10, max 10)' },
|
|
36
|
+
lang: { type: 'string', description: 'Response language (optional, e.g. "zh_cn", "en_us")' },
|
|
37
|
+
},
|
|
38
|
+
required: ['user_id'],
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: 'get_okrs',
|
|
43
|
+
description: '[Official API + UAT] Batch-fetch full OKR details (objectives, key results, progress, alignments) by OKR IDs.',
|
|
44
|
+
inputSchema: {
|
|
45
|
+
type: 'object',
|
|
46
|
+
properties: {
|
|
47
|
+
okr_ids: { type: 'array', items: { type: 'string' }, description: 'OKR IDs (max 10 per call). From list_user_okrs.' },
|
|
48
|
+
user_id_type: { type: 'string', enum: ['user_id', 'union_id', 'open_id', 'people_admin_id'], description: 'Type of user_ids in response (default: open_id)' },
|
|
49
|
+
lang: { type: 'string', description: 'Response language (optional)' },
|
|
50
|
+
},
|
|
51
|
+
required: ['okr_ids'],
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: 'list_okr_periods',
|
|
56
|
+
description: '[Official API + UAT] List OKR periods (quarters / years) defined in the tenant. Use period_ids from this to filter list_user_okrs.',
|
|
57
|
+
inputSchema: {
|
|
58
|
+
type: 'object',
|
|
59
|
+
properties: {
|
|
60
|
+
page_size: { type: 'number', description: 'Items per page (default 10)' },
|
|
61
|
+
page_token: { type: 'string', description: 'Pagination token' },
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: 'create_okr_progress_record',
|
|
67
|
+
description: '[Official API + UAT, v1.3.7] Add a progress note to an OKR objective or key result. Feishu requires `source_title`, `source_url`, and a block-structured `content`; this tool exposes a simple `content_text` and auto-wraps it into the single-paragraph block format. Pass richer `content` directly if you need lists / mentions / docs links / images.',
|
|
68
|
+
inputSchema: {
|
|
69
|
+
type: 'object',
|
|
70
|
+
properties: {
|
|
71
|
+
target_id: { type: 'string', description: 'ID of the OKR objective or key result. Get from get_okrs response (`objective_list[].id` or `objective_list[].kr_list[].id`).' },
|
|
72
|
+
target_type: { type: 'number', description: '1 = objective, 2 = key result. Pick based on which level target_id refers to.' },
|
|
73
|
+
content_text: { type: 'string', description: 'Plain-text progress note. Auto-wrapped into the Feishu block format. Use `content` instead for rich text.' },
|
|
74
|
+
content: { type: 'object', description: 'Optional: full Feishu block structure ({blocks:[...]}). If provided, overrides content_text.' },
|
|
75
|
+
source_title: { type: 'string', description: 'Source label (default "Progress update"). Shown next to the note in the OKR UI.' },
|
|
76
|
+
source_url: { type: 'string', description: 'Source URL (default https://feishu.cn/). Feishu requires a URL even for plain notes.' },
|
|
77
|
+
source_url_pc: { type: 'string', description: 'Optional PC-specific source URL.' },
|
|
78
|
+
source_url_mobile: { type: 'string', description: 'Optional mobile-specific source URL.' },
|
|
79
|
+
progress_percent: { type: 'number', description: 'Optional progress percent (0-100) to bump alongside the note.' },
|
|
80
|
+
progress_status: { type: 'number', description: 'Optional status code (Feishu enum: 1=on track, 2=at risk, 3=blocked, etc).' },
|
|
81
|
+
user_id_type: { type: 'string', enum: ['user_id', 'union_id', 'open_id'], description: 'Type of user IDs in mentioned_user_list etc. (default open_id)' },
|
|
82
|
+
},
|
|
83
|
+
required: ['target_id', 'target_type'],
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: 'list_okr_progress_records',
|
|
88
|
+
description: '[Official API + UAT, v1.3.7] List progress records for an OKR. Feishu has no native list endpoint — this tool calls get_okrs internally and walks the objective_list / kr_list to extract progress_record IDs (with their target_id and target_type). To read a record\'s full content, you currently need progressRecord.get (not yet wrapped).',
|
|
89
|
+
inputSchema: {
|
|
90
|
+
type: 'object',
|
|
91
|
+
properties: {
|
|
92
|
+
okr_id: { type: 'string', description: 'OKR ID (from list_user_okrs).' },
|
|
93
|
+
user_id_type: { type: 'string', enum: ['user_id', 'union_id', 'open_id'], description: 'Pass-through to get_okrs (default open_id)' },
|
|
94
|
+
},
|
|
95
|
+
required: ['okr_id'],
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
name: 'delete_okr_progress_record',
|
|
100
|
+
description: '[Official API + UAT, v1.3.7] Delete an OKR progress record by its progress_id (from list_okr_progress_records).',
|
|
101
|
+
inputSchema: {
|
|
102
|
+
type: 'object',
|
|
103
|
+
properties: {
|
|
104
|
+
progress_id: { type: 'string', description: 'Progress record ID' },
|
|
105
|
+
},
|
|
106
|
+
required: ['progress_id'],
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
];
|
|
110
|
+
|
|
111
|
+
const handlers = {
|
|
112
|
+
async list_user_okrs(args, ctx) {
|
|
113
|
+
return json(await ctx.getOfficialClient().listUserOkrs(args.user_id, {
|
|
114
|
+
periodIds: args.period_ids, offset: args.offset, limit: args.limit, lang: args.lang,
|
|
115
|
+
userIdType: args.user_id_type,
|
|
116
|
+
}));
|
|
117
|
+
},
|
|
118
|
+
async get_okrs(args, ctx) {
|
|
119
|
+
return json(await ctx.getOfficialClient().getOkrs(args.okr_ids, { lang: args.lang, userIdType: args.user_id_type }));
|
|
120
|
+
},
|
|
121
|
+
async list_okr_periods(args, ctx) {
|
|
122
|
+
return json(await ctx.getOfficialClient().listOkrPeriods({ pageSize: args.page_size, pageToken: args.page_token }));
|
|
123
|
+
},
|
|
124
|
+
async create_okr_progress_record(args, ctx) {
|
|
125
|
+
if (!args.content_text && !args.content) {
|
|
126
|
+
return text('create_okr_progress_record: pass content_text (a plain string, auto-wrapped) or content (a full {blocks:[...]} structure).');
|
|
127
|
+
}
|
|
128
|
+
const content = args.content || buildOkrContent(args.content_text);
|
|
129
|
+
let progressRate;
|
|
130
|
+
if (args.progress_percent !== undefined || args.progress_status !== undefined) {
|
|
131
|
+
progressRate = {};
|
|
132
|
+
if (args.progress_percent !== undefined) progressRate.percent = args.progress_percent;
|
|
133
|
+
if (args.progress_status !== undefined) progressRate.status = args.progress_status;
|
|
134
|
+
}
|
|
135
|
+
const r = await ctx.getOfficialClient().createOkrProgressRecord({
|
|
136
|
+
targetId: args.target_id,
|
|
137
|
+
targetType: args.target_type,
|
|
138
|
+
content,
|
|
139
|
+
sourceTitle: args.source_title,
|
|
140
|
+
sourceUrl: args.source_url,
|
|
141
|
+
sourceUrlPc: args.source_url_pc,
|
|
142
|
+
sourceUrlMobile: args.source_url_mobile,
|
|
143
|
+
progressRate,
|
|
144
|
+
userIdType: args.user_id_type,
|
|
145
|
+
});
|
|
146
|
+
const ownership = r.viaUser ? ' (as user)' : ' (as app — UAT unavailable or failed; record posted as bot)';
|
|
147
|
+
const warn = r.fallbackWarning ? `\n\n${r.fallbackWarning}` : '';
|
|
148
|
+
return text(`Progress record created${ownership}: ${r.progressId}\n${JSON.stringify(r, null, 2)}${warn}`);
|
|
149
|
+
},
|
|
150
|
+
async list_okr_progress_records(args, ctx) {
|
|
151
|
+
return json(await ctx.getOfficialClient().listOkrProgressRecords(args.okr_id, { userIdType: args.user_id_type }));
|
|
152
|
+
},
|
|
153
|
+
async delete_okr_progress_record(args, ctx) {
|
|
154
|
+
const r = await ctx.getOfficialClient().deleteOkrProgressRecord(args.progress_id);
|
|
155
|
+
return text(`Progress record ${args.progress_id} deleted${r.viaUser ? '' : ' (as app)'}`);
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
module.exports = { schemas, handlers };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// src/tools/profile.js — multi-account profile management (v1.3.6).
|
|
2
|
+
//
|
|
3
|
+
// LARK_PROFILES_JSON env var registers extra credential sets; this module
|
|
4
|
+
// exposes them via list_profiles + switch_profile so callers can hot-swap
|
|
5
|
+
// between accounts/tenants without restarting the MCP server.
|
|
6
|
+
|
|
7
|
+
const { text, json } = require('./_registry');
|
|
8
|
+
|
|
9
|
+
const schemas = [
|
|
10
|
+
{
|
|
11
|
+
name: 'list_profiles',
|
|
12
|
+
description: '[Plugin] List all available identity profiles (sets of LARK_COOKIE/APP_ID/APP_SECRET/UAT). The "default" profile uses the top-level env vars; additional profiles come from LARK_PROFILES_JSON. Marks the currently active profile.',
|
|
13
|
+
inputSchema: { type: 'object', properties: {} },
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
name: 'switch_profile',
|
|
17
|
+
description: '[Plugin] Switch the active identity profile. Subsequent tool calls use the new profile\'s credentials. Cached client instances are reset so the next call rebuilds against the new creds.',
|
|
18
|
+
inputSchema: {
|
|
19
|
+
type: 'object',
|
|
20
|
+
properties: {
|
|
21
|
+
name: { type: 'string', description: 'Profile name. "default" for top-level env vars; any key from LARK_PROFILES_JSON otherwise.' },
|
|
22
|
+
},
|
|
23
|
+
required: ['name'],
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
const handlers = {
|
|
29
|
+
async list_profiles(_args, ctx) {
|
|
30
|
+
return json({ active: ctx.getActiveProfile(), profiles: ctx.listProfiles() });
|
|
31
|
+
},
|
|
32
|
+
async switch_profile(args, ctx) {
|
|
33
|
+
const target = args.name;
|
|
34
|
+
const all = ctx.listProfiles();
|
|
35
|
+
if (!all.includes(target)) {
|
|
36
|
+
return text(`Profile "${target}" not found. Available: ${all.join(', ')}. To add more, set LARK_PROFILES_JSON in your MCP env.`);
|
|
37
|
+
}
|
|
38
|
+
ctx.setActiveProfile(target);
|
|
39
|
+
return text(`Switched to profile: ${target}`);
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
module.exports = { schemas, handlers };
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// src/tools/tasks.js — Feishu Tasks v2 tools (v1.3.7 new domain).
|
|
2
|
+
//
|
|
3
|
+
// 7 tools backed by clients/official/tasks.js. All UAT-first.
|
|
4
|
+
// Requires `task:task` scope on the OAuth — re-run `npx feishu-user-plugin oauth`
|
|
5
|
+
// after enabling the scope on the Feishu app console.
|
|
6
|
+
|
|
7
|
+
const { json, text } = require('./_registry');
|
|
8
|
+
|
|
9
|
+
const schemas = [
|
|
10
|
+
{
|
|
11
|
+
name: 'list_tasks',
|
|
12
|
+
description: '[Official API + UAT, v1.3.7] List the current user\'s tasks. Filter by completion or type.',
|
|
13
|
+
inputSchema: {
|
|
14
|
+
type: 'object',
|
|
15
|
+
properties: {
|
|
16
|
+
completed: { type: 'boolean', description: 'true → only completed; false → only pending; omit → all' },
|
|
17
|
+
type: { type: 'string', description: 'Filter by task type (optional). E.g. "all" / "personal".' },
|
|
18
|
+
page_size: { type: 'number', description: 'Items per page (default Feishu default)' },
|
|
19
|
+
page_token: { type: 'string', description: 'Pagination token' },
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
name: 'get_task',
|
|
25
|
+
description: '[Official API + UAT, v1.3.7] Get full details of a single task by GUID.',
|
|
26
|
+
inputSchema: {
|
|
27
|
+
type: 'object',
|
|
28
|
+
properties: {
|
|
29
|
+
task_guid: { type: 'string', description: 'Task GUID (from list_tasks / create_task / Feishu URL)' },
|
|
30
|
+
},
|
|
31
|
+
required: ['task_guid'],
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: 'create_task',
|
|
36
|
+
description: '[Official API + UAT, v1.3.7] Create a new task. summary is required; due / members / etc. are optional.',
|
|
37
|
+
inputSchema: {
|
|
38
|
+
type: 'object',
|
|
39
|
+
properties: {
|
|
40
|
+
summary: { type: 'string', description: 'Task title' },
|
|
41
|
+
description: { type: 'string', description: 'Task description (optional)' },
|
|
42
|
+
due: { type: 'object', description: 'Due time (optional). {timestamp:"<unix-millis>", is_all_day?:true|false}' },
|
|
43
|
+
members: {
|
|
44
|
+
type: 'array',
|
|
45
|
+
description: 'Initial members (optional). Each: {id:"<open_id>", role:"assignee"|"follower", type?:"user", name?:"..."}',
|
|
46
|
+
items: { type: 'object' },
|
|
47
|
+
},
|
|
48
|
+
repeat_rule: { type: 'string', description: 'Recurrence (optional, RFC5545 RRULE)' },
|
|
49
|
+
extra: { type: 'string', description: 'Free-form extra metadata (optional)' },
|
|
50
|
+
},
|
|
51
|
+
required: ['summary'],
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: 'update_task',
|
|
56
|
+
description: '[Official API + UAT, v1.3.7] Patch a task. **update_fields** is required by Feishu — list which fields to update (e.g. ["summary","due","completed_at"]).',
|
|
57
|
+
inputSchema: {
|
|
58
|
+
type: 'object',
|
|
59
|
+
properties: {
|
|
60
|
+
task_guid: { type: 'string', description: 'Task GUID' },
|
|
61
|
+
update_fields: {
|
|
62
|
+
type: 'array',
|
|
63
|
+
description: 'Required. Names of fields to update. E.g. ["summary","description","due","completed_at","start","extra","repeat_rule"]. Feishu only patches fields listed here, ignoring other keys in `task`.',
|
|
64
|
+
items: { type: 'string' },
|
|
65
|
+
},
|
|
66
|
+
task: {
|
|
67
|
+
type: 'object',
|
|
68
|
+
description: 'Field values. E.g. {summary:"new title", due:{timestamp:"1717939200000"}}.',
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
required: ['task_guid', 'update_fields', 'task'],
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
name: 'complete_task',
|
|
76
|
+
description: '[Official API + UAT, v1.3.7] Mark a task complete (or uncomplete it). Convenience wrapper around update_task with completed_at.',
|
|
77
|
+
inputSchema: {
|
|
78
|
+
type: 'object',
|
|
79
|
+
properties: {
|
|
80
|
+
task_guid: { type: 'string', description: 'Task GUID' },
|
|
81
|
+
completed: { type: 'boolean', description: 'true → mark complete (uses Date.now()); false → uncomplete (sets completed_at to "0"). Default true.' },
|
|
82
|
+
},
|
|
83
|
+
required: ['task_guid'],
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: 'delete_task',
|
|
88
|
+
description: '[Official API + UAT, v1.3.7] Permanently delete a task.',
|
|
89
|
+
inputSchema: {
|
|
90
|
+
type: 'object',
|
|
91
|
+
properties: {
|
|
92
|
+
task_guid: { type: 'string', description: 'Task GUID' },
|
|
93
|
+
},
|
|
94
|
+
required: ['task_guid'],
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
name: 'manage_task_members',
|
|
99
|
+
description: '[Official API + UAT, v1.3.7] Add or remove members on a task. Members are objects {id:"<open_id>", role:"assignee"|"follower", type?:"user", name?:""}.',
|
|
100
|
+
inputSchema: {
|
|
101
|
+
type: 'object',
|
|
102
|
+
properties: {
|
|
103
|
+
action: { type: 'string', enum: ['add', 'remove'], description: 'add or remove' },
|
|
104
|
+
task_guid: { type: 'string', description: 'Task GUID' },
|
|
105
|
+
members: {
|
|
106
|
+
type: 'array',
|
|
107
|
+
description: 'Members to add/remove. Each: {id, role, type?, name?}.',
|
|
108
|
+
items: { type: 'object' },
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
required: ['action', 'task_guid', 'members'],
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
const handlers = {
|
|
117
|
+
async list_tasks(args, ctx) {
|
|
118
|
+
return json(await ctx.getOfficialClient().listTasks({
|
|
119
|
+
completed: args.completed,
|
|
120
|
+
type: args.type,
|
|
121
|
+
pageSize: args.page_size,
|
|
122
|
+
pageToken: args.page_token,
|
|
123
|
+
}));
|
|
124
|
+
},
|
|
125
|
+
async get_task(args, ctx) {
|
|
126
|
+
return json(await ctx.getOfficialClient().getTask(args.task_guid));
|
|
127
|
+
},
|
|
128
|
+
async create_task(args, ctx) {
|
|
129
|
+
const data = { summary: args.summary };
|
|
130
|
+
if (args.description !== undefined) data.description = args.description;
|
|
131
|
+
if (args.due !== undefined) data.due = args.due;
|
|
132
|
+
if (args.members !== undefined) data.members = args.members;
|
|
133
|
+
if (args.repeat_rule !== undefined) data.repeat_rule = args.repeat_rule;
|
|
134
|
+
if (args.extra !== undefined) data.extra = args.extra;
|
|
135
|
+
const r = await ctx.getOfficialClient().createTask(data);
|
|
136
|
+
const ownership = r.viaUser ? ' (as user)' : ' (as app — UAT unavailable or failed; task created by the app, not you)';
|
|
137
|
+
const warn = r.fallbackWarning ? `\n\n${r.fallbackWarning}` : '';
|
|
138
|
+
return text(`Task created${ownership}: ${r.task?.guid || '(no guid returned)'}\n${JSON.stringify(r.task, null, 2)}${warn}`);
|
|
139
|
+
},
|
|
140
|
+
async update_task(args, ctx) {
|
|
141
|
+
const r = await ctx.getOfficialClient().updateTask(args.task_guid, args.task, args.update_fields);
|
|
142
|
+
const warn = r.fallbackWarning ? `\n\n${r.fallbackWarning}` : '';
|
|
143
|
+
return text(`Task updated: ${args.task_guid}\n${JSON.stringify(r.task, null, 2)}${warn}`);
|
|
144
|
+
},
|
|
145
|
+
async complete_task(args, ctx) {
|
|
146
|
+
const completed = args.completed === undefined ? true : !!args.completed;
|
|
147
|
+
const r = await ctx.getOfficialClient().completeTask(args.task_guid, completed);
|
|
148
|
+
return text(`Task ${completed ? 'completed' : 'uncompleted'}: ${args.task_guid}`);
|
|
149
|
+
},
|
|
150
|
+
async delete_task(args, ctx) {
|
|
151
|
+
await ctx.getOfficialClient().deleteTask(args.task_guid);
|
|
152
|
+
return text(`Task deleted: ${args.task_guid}`);
|
|
153
|
+
},
|
|
154
|
+
async manage_task_members(args, ctx) {
|
|
155
|
+
const c = ctx.getOfficialClient();
|
|
156
|
+
if (args.action === 'add') {
|
|
157
|
+
const r = await c.addTaskMembers(args.task_guid, args.members);
|
|
158
|
+
return text(`Members added to ${args.task_guid}: ${args.members.length}\n${JSON.stringify(r.task?.members, null, 2)}`);
|
|
159
|
+
}
|
|
160
|
+
if (args.action === 'remove') {
|
|
161
|
+
const r = await c.removeTaskMembers(args.task_guid, args.members);
|
|
162
|
+
return text(`Members removed from ${args.task_guid}: ${args.members.length}\n${JSON.stringify(r.task?.members, null, 2)}`);
|
|
163
|
+
}
|
|
164
|
+
throw new Error('manage_task_members: action must be add or remove');
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
module.exports = { schemas, handlers };
|