feishu-user-plugin 1.3.6 → 1.3.8

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.
Files changed (71) hide show
  1. package/.claude-plugin/plugin.json +2 -2
  2. package/CHANGELOG.md +71 -0
  3. package/README.md +72 -41
  4. package/package.json +10 -3
  5. package/scripts/capture-feishu-protobuf.js +86 -0
  6. package/scripts/check-changelog.js +31 -0
  7. package/scripts/check-docs-sync.js +41 -0
  8. package/scripts/check-tool-count.js +40 -0
  9. package/scripts/check-version.js +40 -0
  10. package/scripts/decode-feishu-protobuf.js +115 -0
  11. package/scripts/smoke.js +224 -0
  12. package/scripts/sync-claude-md.sh +12 -0
  13. package/scripts/sync-server-json.js +71 -0
  14. package/scripts/sync-team-skills.sh +22 -0
  15. package/scripts/test-all-tools.js +158 -0
  16. package/scripts/test-wiki-attach-fallback.js +71 -0
  17. package/scripts/test-ws-events.js +84 -0
  18. package/skills/feishu-user-plugin/SKILL.md +5 -5
  19. package/skills/feishu-user-plugin/references/CLAUDE.md +248 -318
  20. package/skills/feishu-user-plugin/references/table.md +18 -9
  21. package/src/auth/cookie.js +30 -0
  22. package/src/auth/credentials.js +399 -0
  23. package/src/auth/profile-router.js +248 -0
  24. package/src/auth/uat.js +231 -0
  25. package/src/cli.js +45 -13
  26. package/src/clients/official/base.js +188 -0
  27. package/src/clients/official/bitable.js +269 -0
  28. package/src/clients/official/calendar.js +176 -0
  29. package/src/clients/official/contacts.js +54 -0
  30. package/src/clients/official/docs.js +301 -0
  31. package/src/clients/official/drive.js +77 -0
  32. package/src/clients/official/groups.js +68 -0
  33. package/src/clients/official/im.js +414 -0
  34. package/src/clients/official/index.js +30 -0
  35. package/src/clients/official/okr.js +127 -0
  36. package/src/clients/official/tasks.js +142 -0
  37. package/src/clients/official/uploads.js +260 -0
  38. package/src/clients/official/wiki.js +207 -0
  39. package/src/{client.js → clients/user.js} +25 -33
  40. package/src/config.js +13 -8
  41. package/src/events/event-buffer.js +100 -0
  42. package/src/events/index.js +5 -0
  43. package/src/events/ws-server.js +86 -0
  44. package/src/index.js +4 -1977
  45. package/src/logger.js +20 -0
  46. package/src/oauth.js +5 -1
  47. package/src/official.js +5 -1944
  48. package/src/prompts/_registry.js +69 -0
  49. package/src/prompts/index.js +54 -0
  50. package/src/server.js +305 -0
  51. package/src/setup.js +16 -1
  52. package/src/test-all.js +2 -2
  53. package/src/test-comprehensive.js +3 -3
  54. package/src/test-send.js +1 -1
  55. package/src/tools/_registry.js +31 -0
  56. package/src/tools/bitable.js +246 -0
  57. package/src/tools/calendar.js +207 -0
  58. package/src/tools/contacts.js +66 -0
  59. package/src/tools/diagnostics.js +172 -0
  60. package/src/tools/docs.js +158 -0
  61. package/src/tools/drive.js +111 -0
  62. package/src/tools/events.js +64 -0
  63. package/src/tools/groups.js +81 -0
  64. package/src/tools/im-read.js +259 -0
  65. package/src/tools/messaging-bot.js +151 -0
  66. package/src/tools/messaging-user.js +292 -0
  67. package/src/tools/okr.js +159 -0
  68. package/src/tools/profile.js +74 -0
  69. package/src/tools/tasks.js +168 -0
  70. package/src/tools/uploads.js +63 -0
  71. package/src/tools/wiki.js +191 -0
@@ -0,0 +1,172 @@
1
+ // src/tools/diagnostics.js — health checks + media downloads.
2
+ //
3
+ // v1.3.7 (C2.4) consolidates download_image / download_file into:
4
+ // download_message_resource(message_id, key, kind=image|file, save_path?)
5
+ // download_doc_image(image_token, doc_token?, save_path?)
6
+ // Inline-base64 responses are capped at MAX_INLINE_BYTES (2 MiB) to leave
7
+ // headroom under Anthropic's 5 MB API limit; over the cap, save_path is
8
+ // required and the response only includes a short summary.
9
+
10
+ const fs = require('fs');
11
+ const { text } = require('./_registry');
12
+
13
+ const MAX_INLINE_BYTES = 2 * 1024 * 1024; // 2 MiB; Anthropic API cap is 5 MB
14
+
15
+ function inlineTooBig(bytes) {
16
+ return bytes > MAX_INLINE_BYTES;
17
+ }
18
+
19
+ function fmtMB(bytes) {
20
+ return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
21
+ }
22
+
23
+ const schemas = [
24
+ {
25
+ name: 'get_login_status',
26
+ description: 'Check cookie session validity and app credentials status. Also refreshes session.',
27
+ inputSchema: { type: 'object', properties: {} },
28
+ },
29
+ {
30
+ name: 'download_message_resource',
31
+ description: '[User Identity / Official API] Download an image or file attached to a message so the model can see / store it. v1.3.7 (C2.4) consolidates the v1.3.6 download_image (mode 1) + download_file. UAT-first, falls back to app.\n\nFor images, the response includes an inline `image` content block so the model sees pixels. For files, the response includes the bytes as base64 (truncated for display) plus an optional save_path write.\n\n**Size cap:** payloads > 2 MiB MUST pass `save_path`. The Anthropic API rejects responses > 5 MB; we cap at 2 MiB so multipart wrapping has headroom.\n\n**merge_forward children:** Feishu keys media by the parent merge_forward id, not the child id. Use the child\'s `parentMessageId` field (returned by read_messages with expand_merge_forward) — not the child id.',
32
+ inputSchema: {
33
+ type: 'object',
34
+ properties: {
35
+ message_id: { type: 'string', description: 'Message ID (om_xxx). For merge_forward children, use the child\'s `parentMessageId`.' },
36
+ key: { type: 'string', description: 'image_key (img_xxx) for kind=image, file_key for kind=file. From read_messages content.' },
37
+ kind: { type: 'string', enum: ['image', 'file'], description: 'image or file' },
38
+ save_path: { type: 'string', description: 'Absolute local path. Required when downloaded bytes > 2 MiB (else the response would exceed the Anthropic API 5 MB inline limit).' },
39
+ },
40
+ required: ['message_id', 'key', 'kind'],
41
+ },
42
+ },
43
+ {
44
+ name: 'download_doc_image',
45
+ description: '[User Identity / Official API] Download an image embedded in a docx document so the model can see it. Pass the `image_token` from `get_doc_blocks` (block.image.token), and optionally the doc/wiki/URL token to scope the lookup. UAT-first.\n\n**Size cap:** payloads > 2 MiB MUST pass `save_path`.',
46
+ inputSchema: {
47
+ type: 'object',
48
+ properties: {
49
+ image_token: { type: 'string', description: 'Image token (from get_doc_blocks image block)' },
50
+ doc_token: { type: 'string', description: 'Document ID, wiki node token, or Feishu URL (optional but recommended for permission scoping).' },
51
+ save_path: { type: 'string', description: 'Absolute local path. Required when image bytes > 2 MiB.' },
52
+ },
53
+ required: ['image_token'],
54
+ },
55
+ },
56
+ ];
57
+
58
+ function maybeSave(savePath, base64) {
59
+ if (!savePath) return null;
60
+ try {
61
+ fs.writeFileSync(savePath, Buffer.from(base64, 'base64'));
62
+ return { ok: true, path: savePath };
63
+ } catch (e) {
64
+ return { ok: false, path: savePath, error: e.message };
65
+ }
66
+ }
67
+
68
+ const handlers = {
69
+ async get_login_status(_args, ctx) {
70
+ const parts = [];
71
+ try {
72
+ const c = await ctx.getUserClient();
73
+ const status = await c.checkSession();
74
+ parts.push(`Cookie: ${status.valid ? 'Active' : 'Expired'} (${status.userName || status.userId || 'unknown'})`);
75
+ parts.push(` ${status.message}`);
76
+ } catch (e) { parts.push(`Cookie: ${e.message}`); }
77
+ const hasApp = !!(process.env.LARK_APP_ID && process.env.LARK_APP_SECRET);
78
+ if (!hasApp) {
79
+ parts.push(`App credentials: Not set`);
80
+ } else {
81
+ const official = ctx.getOfficialClient();
82
+ const probe = await official.verifyApp();
83
+ if (probe.valid) {
84
+ const nameBit = probe.appName ? ` "${probe.appName}"` : '';
85
+ parts.push(`App credentials: Valid — app_id=${probe.appId}${nameBit}`);
86
+ } else {
87
+ parts.push(`App credentials: INVALID — app_id=${probe.appId} rejected by Feishu (${probe.error})`);
88
+ parts.push(` → Likely wrong/stale APP_ID. Re-run the install prompt from team-skills/plugins/feishu-user-plugin/README.md to get the correct credentials.`);
89
+ }
90
+ if (official.hasUAT) {
91
+ try {
92
+ await official.listChatsAsUser({ pageSize: 1 });
93
+ parts.push('User access token: Valid (P2P/group UAT reading enabled)');
94
+ } catch (e) {
95
+ parts.push(`User access token: INVALID — ${e.message}`);
96
+ parts.push(' → Re-run OAuth: npx feishu-user-plugin oauth, then restart Claude Code / Codex so running MCP servers load the new token.');
97
+ }
98
+ } else {
99
+ parts.push('User access token: Not set (optional — needed for P2P chat reading. Run OAuth flow to obtain, see README for details)');
100
+ }
101
+ }
102
+ return text(parts.join('\n'));
103
+ },
104
+
105
+ async download_message_resource(args, ctx) {
106
+ if (!args.message_id || !args.key) {
107
+ return text('download_message_resource requires message_id and key. For merge_forward children, use the child\'s parentMessageId (not the child id).');
108
+ }
109
+ const kind = args.kind;
110
+ if (kind !== 'image' && kind !== 'file') {
111
+ return text('download_message_resource: kind must be "image" or "file".');
112
+ }
113
+ const r = await ctx.getOfficialClient().downloadMessageResource(args.message_id, args.key, kind);
114
+ const sizeNote = `${r.bytes} bytes (${fmtMB(r.bytes)}, ${r.mimeType})`;
115
+ const tooBig = inlineTooBig(r.bytes);
116
+ if (tooBig && !args.save_path) {
117
+ return text(`Resource is ${sizeNote} — exceeds the 2 MiB inline cap. Re-run download_message_resource with save_path=<absolute path> so the bytes are written to disk and only a small summary is returned.`);
118
+ }
119
+ const saved = maybeSave(args.save_path, r.base64);
120
+ const saveNote = saved
121
+ ? (saved.ok ? `\nSaved to: ${saved.path}` : `\nSave to ${saved.path} failed: ${saved.error}`)
122
+ : '';
123
+ const ident = r.viaUser ? 'as user' : 'as app';
124
+ if (kind === 'image' && !tooBig) {
125
+ return {
126
+ content: [
127
+ { type: 'text', text: `Image from message ${args.message_id} (${ident}, ${sizeNote})${saveNote}` },
128
+ { type: 'image', data: r.base64, mimeType: r.mimeType },
129
+ ],
130
+ };
131
+ }
132
+ if (tooBig) {
133
+ return text(`Resource from message ${args.message_id} downloaded (${ident}, ${sizeNote})${saveNote}\nInline content omitted because the payload exceeds the 2 MiB cap.`);
134
+ }
135
+ return {
136
+ content: [
137
+ { type: 'text', text: `File from message ${args.message_id} (${ident}, ${sizeNote})${saveNote}` },
138
+ { type: 'text', text: `base64 (${r.bytes} bytes, truncated display):\n${r.base64.slice(0, 400)}${r.base64.length > 400 ? '…' : ''}` },
139
+ ],
140
+ };
141
+ },
142
+
143
+ async download_doc_image(args, ctx) {
144
+ if (!args.image_token) {
145
+ return text('download_doc_image requires image_token (from get_doc_blocks image block). Optionally pass doc_token (native id / wiki node / Feishu URL).');
146
+ }
147
+ const docToken = args.doc_token ? await ctx.resolveDocId(args.doc_token) : undefined;
148
+ const r = await ctx.getOfficialClient().downloadDocImage(args.image_token, docToken);
149
+ const sizeNote = `${r.bytes} bytes (${fmtMB(r.bytes)}, ${r.mimeType})`;
150
+ const tooBig = inlineTooBig(r.bytes);
151
+ if (tooBig && !args.save_path) {
152
+ return text(`Image is ${sizeNote} — exceeds the 2 MiB inline cap. Re-run download_doc_image with save_path=<absolute path>.`);
153
+ }
154
+ const saved = maybeSave(args.save_path, r.base64);
155
+ const saveNote = saved
156
+ ? (saved.ok ? `\nSaved to: ${saved.path}` : `\nSave to ${saved.path} failed: ${saved.error}`)
157
+ : '';
158
+ const source = docToken ? `docx ${docToken}` : 'drive media';
159
+ const ident = r.viaUser ? 'as user' : 'as app';
160
+ if (tooBig) {
161
+ return text(`Image from ${source} downloaded (${ident}, ${sizeNote})${saveNote}\nInline content omitted because the payload exceeds the 2 MiB cap.`);
162
+ }
163
+ return {
164
+ content: [
165
+ { type: 'text', text: `Image from ${source} (${ident}, ${sizeNote})${saveNote}` },
166
+ { type: 'image', data: r.base64, mimeType: r.mimeType },
167
+ ],
168
+ };
169
+ },
170
+ };
171
+
172
+ module.exports = { schemas, handlers };
@@ -0,0 +1,158 @@
1
+ // src/tools/docs.js — Feishu document operations.
2
+ //
3
+ // 5 tools (was 7 in v1.3.6): search_docs, read_doc, get_doc_blocks, create_doc,
4
+ // and the consolidated manage_doc_block (action=create|update|delete) which
5
+ // replaces the v1.3.6 trio create_doc_block / update_doc_block / delete_doc_blocks.
6
+
7
+ const { text, json } = require('./_registry');
8
+
9
+ const schemas = [
10
+ {
11
+ name: 'search_docs',
12
+ description: '[Official API] Search Feishu documents by keyword.',
13
+ inputSchema: {
14
+ type: 'object',
15
+ properties: { query: { type: 'string', description: 'Search keyword' } },
16
+ required: ['query'],
17
+ },
18
+ },
19
+ {
20
+ name: 'read_doc',
21
+ description: '[Official API] Read the raw text content of a Feishu document.',
22
+ inputSchema: {
23
+ type: 'object',
24
+ properties: { document_id: { type: 'string', description: 'Document ID or token' } },
25
+ required: ['document_id'],
26
+ },
27
+ },
28
+ {
29
+ name: 'get_doc_blocks',
30
+ description: '[Official API] Get structured block tree of a document. Returns block types, content, and hierarchy for precise document analysis.',
31
+ inputSchema: {
32
+ type: 'object',
33
+ properties: {
34
+ document_id: { type: 'string', description: 'Document ID (from search_docs or create_doc)' },
35
+ },
36
+ required: ['document_id'],
37
+ },
38
+ },
39
+ {
40
+ name: 'create_doc',
41
+ description: '[Official API] Create a new Feishu document. Can place directly under a Wiki space by passing wiki_space_id (optionally wiki_parent_node_token for nested placement) — the plugin creates the doc in drive then attaches it as a Wiki node.',
42
+ inputSchema: {
43
+ type: 'object',
44
+ properties: {
45
+ title: { type: 'string', description: 'Document title' },
46
+ folder_id: { type: 'string', description: 'Parent folder token (optional; ignored when wiki_space_id is set)' },
47
+ wiki_space_id: { type: 'string', description: 'Wiki space ID to place the doc under (optional)' },
48
+ wiki_parent_node_token: { type: 'string', description: 'Parent wiki node token within the space (optional; defaults to space root)' },
49
+ },
50
+ required: ['title'],
51
+ },
52
+ },
53
+ {
54
+ name: 'manage_doc_block',
55
+ description: '[Official API] Manage content blocks in a document. Single tool replaces v1.3.6 create_doc_block / update_doc_block / delete_doc_blocks.\n action=create — five modes:\n (A) Generic — pass `children` array (e.g. [{block_type:2, text:{...}}]).\n (B) Image from local file — pass `image_path`; plugin uploads and patches.\n (C) Image from token — pass `image_token` (already uploaded).\n (D) File attachment from local file — pass `file_path`; plugin handles VIEW-wrap + replace_file.\n (E) File from token — pass `file_token`.\n action=update — generic (pass `update_body`), image-replace (pass `image_token`), or file-replace (pass `file_token`).\n action=delete — pass `parent_block_id` + `start_index` + `end_index` (range delete).\n`document_id` accepts native ID, wiki node token, or Feishu URL.',
56
+ inputSchema: {
57
+ type: 'object',
58
+ properties: {
59
+ action: { type: 'string', enum: ['create', 'update', 'delete'], description: 'Operation to perform' },
60
+ document_id: { type: 'string', description: 'Document ID, wiki node token, or Feishu URL (required for all actions)' },
61
+ block_id: { type: 'string', description: 'Block ID — required for action=update.' },
62
+ parent_block_id: { type: 'string', description: 'Parent block ID — required for create/delete (use document_id for the doc root).' },
63
+ index: { type: 'number', description: 'Insert position for create (optional, appends to end if omitted).' },
64
+ start_index: { type: 'number', description: 'Range start (inclusive) — required for delete.' },
65
+ end_index: { type: 'number', description: 'Range end (exclusive) — required for delete.' },
66
+ children: { type: 'array', description: 'Generic blocks for create mode A. E.g. [{block_type:2, text:{elements:[{text_run:{content:"Hello"}}]}}]', items: { type: 'object' } },
67
+ image_path: { type: 'string', description: 'Local image path — create mode B (mutually exclusive with other create modes).' },
68
+ image_token: { type: 'string', description: 'Pre-uploaded docx image token — create mode C, or update image-replace.' },
69
+ file_path: { type: 'string', description: 'Local file path — create mode D (mutually exclusive with other create modes).' },
70
+ file_token: { type: 'string', description: 'Pre-uploaded docx file token — create mode E, or update file-replace.' },
71
+ update_body: { type: 'object', description: 'Generic update payload for action=update. E.g. {update_text_elements:{elements:[{text_run:{content:"new text"}}]}}.' },
72
+ },
73
+ required: ['action', 'document_id'],
74
+ },
75
+ },
76
+ ];
77
+
78
+ function need(arg, name, action) {
79
+ if (arg === undefined || arg === null || arg === '') {
80
+ throw new Error(`manage_doc_block: ${name} required for action=${action}`);
81
+ }
82
+ }
83
+
84
+ const handlers = {
85
+ async search_docs(args, ctx) {
86
+ return json(await ctx.getOfficialClient().searchDocs(args.query));
87
+ },
88
+ async read_doc(args, ctx) {
89
+ return json(await ctx.getOfficialClient().readDoc(await ctx.resolveDocId(args.document_id)));
90
+ },
91
+ async get_doc_blocks(args, ctx) {
92
+ return json(await ctx.getOfficialClient().getDocBlocks(await ctx.resolveDocId(args.document_id)));
93
+ },
94
+ async create_doc(args, ctx) {
95
+ const r = await ctx.getOfficialClient().createDoc(args.title, args.folder_id, {
96
+ wikiSpaceId: args.wiki_space_id,
97
+ wikiParentNodeToken: args.wiki_parent_node_token,
98
+ });
99
+ const ownership = r.viaUser ? ' (as user)' : ' (as app — UAT unavailable or failed; document owned by the app, not you)';
100
+ const wikiNote = r.wikiNodeToken ? ` [wiki node: ${r.wikiNodeToken}]`
101
+ : r.wikiAttachTaskId ? ` [wiki attach queued — task_id: ${r.wikiAttachTaskId}]`
102
+ : r.wikiAttachError ? ` [WARNING: wiki attach failed — ${r.wikiAttachError}. Doc exists in drive root/folder.]`
103
+ : '';
104
+ const warn = r.fallbackWarning ? `\n\n${r.fallbackWarning}` : '';
105
+ return text(`Document created${ownership}: ${r.documentId}${wikiNote}${warn}`);
106
+ },
107
+ async manage_doc_block(args, ctx) {
108
+ const official = ctx.getOfficialClient();
109
+ const docId = await ctx.resolveDocId(args.document_id);
110
+ switch (args.action) {
111
+ case 'create': {
112
+ need(args.parent_block_id, 'parent_block_id', 'create');
113
+ const modes = [args.children, args.image_path, args.image_token, args.file_path, args.file_token].filter(Boolean);
114
+ if (modes.length > 1) return text('manage_doc_block(create): pass exactly ONE of children / image_path / image_token / file_path / file_token.');
115
+ if (args.image_path || args.image_token) {
116
+ const r = await official.createDocBlockWithImage(docId, args.parent_block_id, {
117
+ imagePath: args.image_path,
118
+ imageToken: args.image_token,
119
+ index: args.index,
120
+ });
121
+ return json(r);
122
+ }
123
+ if (args.file_path || args.file_token) {
124
+ const r = await official.createDocBlockWithFile(docId, args.parent_block_id, {
125
+ filePath: args.file_path,
126
+ fileToken: args.file_token,
127
+ index: args.index,
128
+ });
129
+ return json(r);
130
+ }
131
+ if (!args.children) return text('manage_doc_block(create): children, image_path, image_token, file_path, or file_token is required.');
132
+ return json(await official.createDocBlock(docId, args.parent_block_id, args.children, args.index));
133
+ }
134
+ case 'update': {
135
+ need(args.block_id, 'block_id', 'update');
136
+ const modes = [args.update_body, args.image_token, args.file_token].filter(Boolean);
137
+ if (modes.length > 1) return text('manage_doc_block(update): pass exactly ONE of update_body / image_token / file_token.');
138
+ if (args.image_token) {
139
+ return json(await official.updateDocBlockImage(docId, args.block_id, args.image_token));
140
+ }
141
+ if (args.file_token) {
142
+ return json(await official.updateDocBlockFile(docId, args.block_id, args.file_token));
143
+ }
144
+ if (!args.update_body) return text('manage_doc_block(update): update_body, image_token, or file_token is required.');
145
+ return json(await official.updateDocBlock(docId, args.block_id, args.update_body));
146
+ }
147
+ case 'delete': {
148
+ need(args.parent_block_id, 'parent_block_id', 'delete');
149
+ if (typeof args.start_index !== 'number' || typeof args.end_index !== 'number') {
150
+ throw new Error('manage_doc_block(delete): start_index and end_index (numbers) required.');
151
+ }
152
+ return text(`Blocks deleted: ${(await official.deleteDocBlocks(docId, args.parent_block_id, args.start_index, args.end_index)).deleted}`);
153
+ }
154
+ }
155
+ },
156
+ };
157
+
158
+ module.exports = { schemas, handlers };
@@ -0,0 +1,111 @@
1
+ // src/tools/drive.js — Drive file operations + drive-targeted upload.
2
+ //
3
+ // 4 tools (was 6 in v1.3.6): list_files, create_folder, upload_drive_file,
4
+ // and the consolidated manage_drive_file (action=copy|move|delete) which
5
+ // replaces v1.3.6 copy_file / move_file / delete_file.
6
+
7
+ const { text, json } = require('./_registry');
8
+
9
+ const schemas = [
10
+ {
11
+ name: 'list_files',
12
+ description: '[Official API] List files in a Drive folder.',
13
+ inputSchema: {
14
+ type: 'object',
15
+ properties: { folder_token: { type: 'string', description: 'Folder token (empty for root)' } },
16
+ },
17
+ },
18
+ {
19
+ name: 'create_folder',
20
+ description: '[Official API] Create a new folder in Drive.',
21
+ inputSchema: {
22
+ type: 'object',
23
+ properties: {
24
+ name: { type: 'string', description: 'Folder name' },
25
+ parent_token: { type: 'string', description: 'Parent folder token (optional)' },
26
+ },
27
+ required: ['name'],
28
+ },
29
+ },
30
+ {
31
+ name: 'upload_drive_file',
32
+ description: '[Official API] Upload a file from disk to a Feishu Drive folder (drive/v1/files/upload_all, parent_type=explorer). Returns file_token + url. If wiki_space_id is provided, the uploaded file is then attached to that Wiki space via move_docs_to_wiki (obj_type=file). UAT-first with app fallback.',
33
+ inputSchema: {
34
+ type: 'object',
35
+ properties: {
36
+ file_path: { type: 'string', description: 'Absolute path to the file on disk' },
37
+ folder_token: { type: 'string', description: 'Destination folder token. Use list_files to find one, or pass the user "我的空间" root token.' },
38
+ wiki_space_id: { type: 'string', description: 'Optional. If set, also attach the uploaded file to this Wiki space.' },
39
+ wiki_parent_node_token: { type: 'string', description: 'Optional. Parent node under which to attach in the Wiki space.' },
40
+ },
41
+ required: ['file_path', 'folder_token'],
42
+ },
43
+ },
44
+ {
45
+ name: 'manage_drive_file',
46
+ description: '[Official API] Manage a Drive file/doc/folder. action=copy (duplicate to a new name + folder), move (relocate, returns task_id), delete (remove, returns task_id). `type` is always required (Feishu rejects with 1061002 / 1062501 otherwise).',
47
+ inputSchema: {
48
+ type: 'object',
49
+ properties: {
50
+ action: { type: 'string', enum: ['copy', 'move', 'delete'], description: 'Operation to perform' },
51
+ file_token: { type: 'string', description: 'File/folder token to operate on (required for all actions).' },
52
+ type: { type: 'string', enum: ['file', 'folder', 'doc', 'sheet', 'bitable', 'docx', 'mindnote', 'slides'], description: 'Resource type — Feishu requires this to know which API table to look up.' },
53
+ name: { type: 'string', description: 'New name — required for action=copy.' },
54
+ folder_token: { type: 'string', description: 'Destination folder token — required for action=move; optional for action=copy (defaults to root).' },
55
+ },
56
+ required: ['action', 'file_token', 'type'],
57
+ },
58
+ },
59
+ ];
60
+
61
+ function need(arg, name, action) {
62
+ if (arg === undefined || arg === null || arg === '') {
63
+ throw new Error(`manage_drive_file: ${name} required for action=${action}`);
64
+ }
65
+ }
66
+
67
+ const handlers = {
68
+ async list_files(args, ctx) {
69
+ return json(await ctx.getOfficialClient().listFiles(args.folder_token));
70
+ },
71
+ async create_folder(args, ctx) {
72
+ const r = await ctx.getOfficialClient().createFolder(args.name, args.parent_token);
73
+ const ownership = r.viaUser ? ' (as user)' : ' (as app — UAT unavailable or failed; folder owned by the app, not you)';
74
+ const warn = r.fallbackWarning ? `\n\n${r.fallbackWarning}` : '';
75
+ return text(`Folder created${ownership}: ${r.token}${warn}`);
76
+ },
77
+ async upload_drive_file(args, ctx) {
78
+ const official = ctx.getOfficialClient();
79
+ const up = await official.uploadDriveFile(args.file_path, args.folder_token);
80
+ const out = { fileToken: up.fileToken, viaUser: up.viaUser, url: `https://feishu.cn/file/${up.fileToken}` };
81
+ if (args.wiki_space_id) {
82
+ try {
83
+ const node = await official.attachToWiki(args.wiki_space_id, 'file', up.fileToken, args.wiki_parent_node_token);
84
+ out.wikiNodeToken = node.node_token || null;
85
+ out.wikiAttachTaskId = node.task_id || null;
86
+ } catch (e) {
87
+ out.wikiAttachError = e.message;
88
+ }
89
+ }
90
+ return json(out);
91
+ },
92
+ async manage_drive_file(args, ctx) {
93
+ const c = ctx.getOfficialClient();
94
+ switch (args.action) {
95
+ case 'copy': {
96
+ need(args.name, 'name', 'copy');
97
+ return json(await c.copyFile(args.file_token, args.name, args.folder_token, args.type));
98
+ }
99
+ case 'move': {
100
+ need(args.folder_token, 'folder_token', 'move');
101
+ return text(`File moved: task=${(await c.moveFile(args.file_token, args.folder_token, args.type)).taskId}`);
102
+ }
103
+ case 'delete': {
104
+ const r = await c.deleteFile(args.file_token, args.type);
105
+ return text(r.taskId ? `File deletion queued: task=${r.taskId}` : `File deleted (${args.type})`);
106
+ }
107
+ }
108
+ },
109
+ };
110
+
111
+ module.exports = { schemas, handlers };
@@ -0,0 +1,64 @@
1
+ // src/tools/events.js — real-time event consumption (v1.3.8).
2
+ //
3
+ // Single tool: get_new_events. Drains the EventBuffer that ws-server.js fills
4
+ // from Feishu's realtime WS push. Default: pulls all events accumulated since
5
+ // the last call (drain semantics — consumers must accept that events vanish
6
+ // after read).
7
+
8
+ const { text, json } = require('./_registry');
9
+
10
+ const schemas = [
11
+ {
12
+ name: 'get_new_events',
13
+ description: '[Plugin v1.3.8] Drain real-time events received since the last call. Currently surfaces "im.message.receive_v1" events (replies, group messages). Returns empty when WS isn\'t connected or no events have arrived. Use filter to scope by event_type or chat_id; max_events caps response size.',
14
+ inputSchema: {
15
+ type: 'object',
16
+ properties: {
17
+ event_type: { type: 'string', description: 'Optional: only events of this type (e.g. "im.message.receive_v1").' },
18
+ event_types: { type: 'array', items: { type: 'string' }, description: 'Optional: any-of list of event types.' },
19
+ chat_id: { type: 'string', description: 'Optional: only events from this chat (oc_xxx for groups, message events expose chat_id).' },
20
+ since_seconds: { type: 'integer', description: 'Optional: only events received in the last N seconds.' },
21
+ max_events: { type: 'integer', description: 'Cap on returned events (default 50). Drained events beyond the cap are returned in subsequent calls.' },
22
+ peek: { type: 'boolean', description: 'When true, leave events in the buffer (default false = drain).' },
23
+ },
24
+ },
25
+ },
26
+ ];
27
+
28
+ const handlers = {
29
+ async get_new_events(args, ctx) {
30
+ const buffer = ctx.getEventBuffer && ctx.getEventBuffer();
31
+ if (!buffer) {
32
+ return text('Realtime events are not available. Reasons: APP_ID/SECRET not configured, OR Lark international tenant (Feishu WS only supports feishu.cn), OR the WS handshake failed at startup. Check server stderr for "WS connected" / "WS start failed".');
33
+ }
34
+
35
+ const filter = {};
36
+ if (args.event_type) filter.event_type = args.event_type;
37
+ if (args.event_types) filter.event_types = args.event_types;
38
+ if (args.chat_id) filter.chat_id = args.chat_id;
39
+ if (args.since_seconds) filter.since_seconds = args.since_seconds;
40
+
41
+ const cap = Math.max(1, parseInt(args.max_events, 10) || 50);
42
+
43
+ let events = args.peek ? buffer.peek(filter) : buffer.drain(filter);
44
+ let truncated = false;
45
+ if (events.length > cap) {
46
+ const kept = events.slice(0, cap);
47
+ const overflow = events.slice(cap);
48
+ if (!args.peek) {
49
+ for (const e of overflow) buffer.push(e);
50
+ }
51
+ events = kept;
52
+ truncated = true;
53
+ }
54
+
55
+ return json({
56
+ events,
57
+ stats: buffer.stats(),
58
+ truncated,
59
+ hint: events.length === 0 ? 'No new events. Call again later, or check stats.totalSeen / .totalDropped to confirm WS is alive.' : undefined,
60
+ });
61
+ },
62
+ };
63
+
64
+ module.exports = { schemas, handlers };
@@ -0,0 +1,81 @@
1
+ // src/tools/groups.js — bot-side group chat management.
2
+
3
+ const { text, json } = require('./_registry');
4
+
5
+ const schemas = [
6
+ {
7
+ name: 'create_group',
8
+ description: '[Official API] Create a new group chat (as bot). Can add initial members.',
9
+ inputSchema: {
10
+ type: 'object',
11
+ properties: {
12
+ name: { type: 'string', description: 'Group name' },
13
+ description: { type: 'string', description: 'Group description (optional)' },
14
+ user_ids: { type: 'array', items: { type: 'string' }, description: 'Initial member open_ids (optional)' },
15
+ },
16
+ required: ['name'],
17
+ },
18
+ },
19
+ {
20
+ name: 'update_group',
21
+ description: '[Official API] Update group chat name or description.',
22
+ inputSchema: {
23
+ type: 'object',
24
+ properties: {
25
+ chat_id: { type: 'string', description: 'Chat ID (oc_xxx)' },
26
+ name: { type: 'string', description: 'New group name (optional)' },
27
+ description: { type: 'string', description: 'New description (optional)' },
28
+ },
29
+ required: ['chat_id'],
30
+ },
31
+ },
32
+ {
33
+ name: 'list_members',
34
+ description: '[Official API] List all members in a group chat.',
35
+ inputSchema: {
36
+ type: 'object',
37
+ properties: {
38
+ chat_id: { type: 'string', description: 'Chat ID (oc_xxx)' },
39
+ page_size: { type: 'number', description: 'Items per page (default 50)' },
40
+ page_token: { type: 'string', description: 'Pagination token' },
41
+ },
42
+ required: ['chat_id'],
43
+ },
44
+ },
45
+ {
46
+ name: 'manage_members',
47
+ description: '[Official API] Add or remove members from a group chat. The Feishu API rejects with code 9499 when the IDs in `member_ids` do not match `member_id_type` — pass `member_id_type` explicitly when using union_id or user_id (default: open_id).',
48
+ inputSchema: {
49
+ type: 'object',
50
+ properties: {
51
+ chat_id: { type: 'string', description: 'Group chat ID (oc_xxx)' },
52
+ member_ids: { type: 'array', items: { type: 'string' }, description: 'Array of member identifiers — IDs must match member_id_type.' },
53
+ action: { type: 'string', enum: ['add', 'remove'], description: 'Action to perform' },
54
+ member_id_type: { type: 'string', enum: ['open_id', 'union_id', 'user_id'], description: 'Format of member_ids (default: open_id).', default: 'open_id' },
55
+ },
56
+ required: ['chat_id', 'member_ids', 'action'],
57
+ },
58
+ },
59
+ ];
60
+
61
+ const handlers = {
62
+ async create_group(args, ctx) {
63
+ return text(`Group created: ${(await ctx.getOfficialClient().createChat({ name: args.name, description: args.description, userIds: args.user_ids })).chatId}`);
64
+ },
65
+ async update_group(args, ctx) {
66
+ return text(`Group updated: ${(await ctx.getOfficialClient().updateChat(args.chat_id, { name: args.name, description: args.description })).updated}`);
67
+ },
68
+ async list_members(args, ctx) {
69
+ return json(await ctx.getOfficialClient().listChatMembers(args.chat_id, { pageSize: args.page_size, pageToken: args.page_token }));
70
+ },
71
+ async manage_members(args, ctx) {
72
+ const official = ctx.getOfficialClient();
73
+ const memberIdType = args.member_id_type || 'open_id';
74
+ if (args.action === 'remove') {
75
+ return json(await official.removeChatMembers(args.chat_id, args.member_ids, memberIdType));
76
+ }
77
+ return json(await official.addChatMembers(args.chat_id, args.member_ids, memberIdType));
78
+ },
79
+ };
80
+
81
+ module.exports = { schemas, handlers };