feishu-user-plugin 1.3.1 → 1.3.3

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/src/index.js CHANGED
@@ -1,4 +1,14 @@
1
1
  #!/usr/bin/env node
2
+ // MCP stdio protocol uses stdout for JSON-RPC. ANY accidental stdout write from
3
+ // this process or its dependencies will corrupt the transport and disconnect the
4
+ // client. v1.3.1 patched the Lark SDK's defaultLogger via a custom logger, but
5
+ // that only covers one dependency. This global redirect is defense-in-depth:
6
+ // any present or future module that calls console.log (or console.info) goes to
7
+ // stderr instead, so MCP stdio stays clean no matter what.
8
+ // Do this BEFORE any other require() so even early log calls are captured.
9
+ console.log = (...args) => console.error(...args);
10
+ console.info = (...args) => console.error(...args);
11
+
2
12
  const { Server } = require('@modelcontextprotocol/sdk/server/index.js');
3
13
  const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
4
14
  const {
@@ -144,12 +154,17 @@ const TOOLS = [
144
154
  // ========== User Identity — Send Messages ==========
145
155
  {
146
156
  name: 'send_as_user',
147
- description: '[User Identity] Send a text message as the logged-in Feishu user. Supports reply threading.',
157
+ description: '[User Identity] Send a text message as the logged-in Feishu user. Supports reply threading and real @-mentions (triggers push notifications).',
148
158
  inputSchema: {
149
159
  type: 'object',
150
160
  properties: {
151
161
  chat_id: { type: 'string', description: 'Target chat ID (numeric)' },
152
- text: { type: 'string', description: 'Message text' },
162
+ text: { type: 'string', description: 'Message text. If `ats` is provided, include the display marker for each @ in this text (default marker is `@<name>`).' },
163
+ ats: {
164
+ type: 'array',
165
+ 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.',
166
+ items: { type: 'object', properties: { userId: { type: 'string' }, name: { type: 'string' }, marker: { type: 'string' } } },
167
+ },
153
168
  root_id: { type: 'string', description: 'Thread root message ID (for reply, optional)' },
154
169
  parent_id: { type: 'string', description: 'Parent message ID (for nested reply, optional)' },
155
170
  },
@@ -164,6 +179,11 @@ const TOOLS = [
164
179
  properties: {
165
180
  user_name: { type: 'string', description: 'Recipient name (Chinese or English)' },
166
181
  text: { type: 'string', description: 'Message text' },
182
+ ats: {
183
+ type: 'array',
184
+ description: 'Optional @-mentions. Same format as send_as_user.ats: [{userId, name}]. Text must contain the `@<name>` marker for each entry.',
185
+ items: { type: 'object', properties: { userId: { type: 'string' }, name: { type: 'string' }, marker: { type: 'string' } } },
186
+ },
167
187
  },
168
188
  required: ['user_name', 'text'],
169
189
  },
@@ -176,6 +196,11 @@ const TOOLS = [
176
196
  properties: {
177
197
  group_name: { type: 'string', description: 'Group chat name' },
178
198
  text: { type: 'string', description: 'Message text' },
199
+ ats: {
200
+ type: 'array',
201
+ description: 'Optional @-mentions that trigger real notifications. Each entry: {userId, name}. Text must contain `@<name>` marker for each entry.',
202
+ items: { type: 'object', properties: { userId: { type: 'string' }, name: { type: 'string' }, marker: { type: 'string' } } },
203
+ },
179
204
  },
180
205
  required: ['group_name', 'text'],
181
206
  },
@@ -222,7 +247,7 @@ const TOOLS = [
222
247
  },
223
248
  {
224
249
  name: 'send_post_as_user',
225
- description: '[User Identity] Send a rich text (POST) message with title and formatted paragraphs.',
250
+ description: '[User Identity] Send a rich text (POST) message with title and formatted paragraphs. Supports real @-mentions that trigger notifications.',
226
251
  inputSchema: {
227
252
  type: 'object',
228
253
  properties: {
@@ -230,7 +255,7 @@ const TOOLS = [
230
255
  title: { type: 'string', description: 'Post title (optional)' },
231
256
  paragraphs: {
232
257
  type: 'array',
233
- description: 'Array of paragraphs. Each paragraph is an array of elements: {tag:"text",text:"..."} or {tag:"a",href:"...",text:"..."} or {tag:"at",userId:"..."}',
258
+ 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)',
234
259
  items: { type: 'array', items: { type: 'object' } },
235
260
  },
236
261
  root_id: { type: 'string', description: 'Thread root message ID (optional)' },
@@ -674,13 +699,13 @@ const TOOLS = [
674
699
  // ========== IM — Bot Send / Edit / Delete ==========
675
700
  {
676
701
  name: 'send_message_as_bot',
677
- description: '[Official API] Send a message as the bot to any chat. Supports text, post, interactive, etc.',
702
+ description: '[Official API] Send a message as the bot to any chat. Supports text, post, interactive, etc. This is the reliable path for @-mentions: include `<at user_id="ou_xxx">Name</at>` inline in text content and Feishu resolves it to a real @-notification.',
678
703
  inputSchema: {
679
704
  type: 'object',
680
705
  properties: {
681
706
  chat_id: { type: 'string', description: 'Target chat_id (oc_xxx) or open_id' },
682
707
  msg_type: { type: 'string', description: 'Message type: text, post, image, interactive, etc.', enum: ['text', 'post', 'image', 'interactive', 'share_chat', 'share_user', 'audio', 'media', 'file', 'sticker'] },
683
- content: { description: 'Message content (string or object, auto-serialized). For text: {"text":"hello"}' },
708
+ content: { description: 'Message content (string or object, auto-serialized). Plain text: {"text":"hello"}. Text with @-mention: {"text":"<at user_id=\\"ou_xxx\\">Alice</at> hi"} — the inline tag becomes a real @-notification.' },
684
709
  },
685
710
  required: ['chat_id', 'msg_type', 'content'],
686
711
  },
@@ -977,6 +1002,20 @@ const TOOLS = [
977
1002
  },
978
1003
  },
979
1004
 
1005
+ // ========== Message Resources (Image/File Download) ==========
1006
+ {
1007
+ name: 'download_image',
1008
+ description: '[User Identity / Official API] Download an image embedded in a message so the model can actually see it. Pass the message_id and image_key returned by read_messages / read_p2p_messages. Tries user identity first (works for any chat the user sees), falls back to app identity (requires the bot to be in the same chat).',
1009
+ inputSchema: {
1010
+ type: 'object',
1011
+ properties: {
1012
+ message_id: { type: 'string', description: 'The message_id (om_xxx) that contains the image — from read_messages / read_p2p_messages' },
1013
+ image_key: { type: 'string', description: 'The image_key from the message content (img_xxx)' },
1014
+ },
1015
+ required: ['message_id', 'image_key'],
1016
+ },
1017
+ },
1018
+
980
1019
  ];
981
1020
 
982
1021
  // --- Server ---
@@ -1008,7 +1047,7 @@ async function handleTool(name, args) {
1008
1047
 
1009
1048
  case 'send_as_user': {
1010
1049
  const c = await getUserClient();
1011
- const r = await c.sendMessage(args.chat_id, args.text, { rootId: args.root_id, parentId: args.parent_id });
1050
+ const r = await c.sendMessage(args.chat_id, args.text, { rootId: args.root_id, parentId: args.parent_id, ats: args.ats });
1012
1051
  return sendResult(r, `Text sent as user to ${args.chat_id}`);
1013
1052
  }
1014
1053
  case 'send_to_user': {
@@ -1023,7 +1062,7 @@ async function handleTool(name, args) {
1023
1062
  const user = users[0];
1024
1063
  const chatId = await c.createChat(user.id);
1025
1064
  if (!chatId) return text(`Failed to create chat with ${user.title}`);
1026
- const r = await c.sendMessage(chatId, args.text);
1065
+ const r = await c.sendMessage(chatId, args.text, { ats: args.ats });
1027
1066
  return sendResult(r, `Text sent to ${user.title} (chat: ${chatId})`);
1028
1067
  }
1029
1068
  case 'send_to_group': {
@@ -1036,7 +1075,7 @@ async function handleTool(name, args) {
1036
1075
  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.`);
1037
1076
  }
1038
1077
  const group = groups[0];
1039
- const r = await c.sendMessage(group.id, args.text);
1078
+ const r = await c.sendMessage(group.id, args.text, { ats: args.ats });
1040
1079
  return sendResult(r, `Text sent to group "${group.title}" (${group.id})`);
1041
1080
  }
1042
1081
 
@@ -1124,9 +1163,20 @@ async function handleTool(name, args) {
1124
1163
  parts.push(` ${status.message}`);
1125
1164
  } catch (e) { parts.push(`Cookie: ${e.message}`); }
1126
1165
  const hasApp = !!(process.env.LARK_APP_ID && process.env.LARK_APP_SECRET);
1127
- parts.push(`App credentials: ${hasApp ? 'Configured' : 'Not set'}`);
1128
- const official = hasApp ? getOfficialClient() : null;
1129
- parts.push(`User access token: ${official?.hasUAT ? 'Configured (P2P reading enabled)' : 'Not set (optional — needed for P2P chat reading. Run OAuth flow to obtain, see README for details)'}`);
1166
+ if (!hasApp) {
1167
+ parts.push(`App credentials: Not set`);
1168
+ } else {
1169
+ const official = getOfficialClient();
1170
+ const probe = await official.verifyApp();
1171
+ if (probe.valid) {
1172
+ const nameBit = probe.appName ? ` "${probe.appName}"` : '';
1173
+ parts.push(`App credentials: Valid — app_id=${probe.appId}${nameBit}`);
1174
+ } else {
1175
+ parts.push(`App credentials: INVALID — app_id=${probe.appId} rejected by Feishu (${probe.error})`);
1176
+ 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.`);
1177
+ }
1178
+ parts.push(`User access token: ${official.hasUAT ? 'Configured (P2P reading enabled)' : 'Not set (optional — needed for P2P chat reading. Run OAuth flow to obtain, see README for details)'}`);
1179
+ }
1130
1180
  return text(parts.join('\n'));
1131
1181
  }
1132
1182
 
@@ -1221,32 +1271,17 @@ async function handleTool(name, args) {
1221
1271
  case 'get_doc_blocks':
1222
1272
  return json(await getOfficialClient().getDocBlocks(args.document_id));
1223
1273
  case 'create_doc': {
1224
- const official = getOfficialClient();
1225
- if (official.hasUAT) {
1226
- try {
1227
- const result = await official.createDocAsUser(args.title, args.folder_id);
1228
- return text(`Document created (as user): ${result.documentId}`);
1229
- } catch (e) {
1230
- console.error(`[feishu-user-plugin] UAT createDoc failed, falling back to app: ${e.message}`);
1231
- }
1232
- }
1233
- return text(`Document created: ${(await official.createDoc(args.title, args.folder_id)).documentId}`);
1274
+ const r = await getOfficialClient().createDoc(args.title, args.folder_id);
1275
+ const ownership = r.viaUser ? ' (as user)' : ' (as app — UAT unavailable or failed; document owned by the app, not you)';
1276
+ return text(`Document created${ownership}: ${r.documentId}`);
1234
1277
  }
1235
1278
 
1236
1279
  // --- Official API: Bitable ---
1237
1280
 
1238
1281
  case 'create_bitable': {
1239
- const official = getOfficialClient();
1240
- if (official.hasUAT) {
1241
- try {
1242
- const r = await official.createBitableAsUser(args.name, args.folder_id);
1243
- return text(`Bitable created (as user): ${r.appToken}\nURL: ${r.url || ''}`);
1244
- } catch (e) {
1245
- console.error(`[feishu-user-plugin] UAT createBitable failed, falling back to app: ${e.message}`);
1246
- }
1247
- }
1248
- const r = await official.createBitable(args.name, args.folder_id);
1249
- return text(`Bitable created: ${r.appToken}\nURL: ${r.url || ''}`);
1282
+ const r = await getOfficialClient().createBitable(args.name, args.folder_id);
1283
+ const ownership = r.viaUser ? ' (as user)' : ' (as app — UAT unavailable or failed; bitable owned by the app, not you)';
1284
+ return text(`Bitable created${ownership}: ${r.appToken}\nURL: ${r.url || ''}`);
1250
1285
  }
1251
1286
  case 'list_bitable_tables':
1252
1287
  return json(await getOfficialClient().listBitableTables(args.app_token));
@@ -1297,15 +1332,9 @@ async function handleTool(name, args) {
1297
1332
  case 'list_files':
1298
1333
  return json(await getOfficialClient().listFiles(args.folder_token));
1299
1334
  case 'create_folder': {
1300
- const official = getOfficialClient();
1301
- if (official.hasUAT) {
1302
- try {
1303
- return text(`Folder created (as user): ${(await official.createFolderAsUser(args.name, args.parent_token)).token}`);
1304
- } catch (e) {
1305
- console.error(`[feishu-user-plugin] UAT createFolder failed, falling back to app: ${e.message}`);
1306
- }
1307
- }
1308
- return text(`Folder created: ${(await official.createFolder(args.name, args.parent_token)).token}`);
1335
+ const r = await getOfficialClient().createFolder(args.name, args.parent_token);
1336
+ const ownership = r.viaUser ? ' (as user)' : ' (as app — UAT unavailable or failed; folder owned by the app, not you)';
1337
+ return text(`Folder created${ownership}: ${r.token}`);
1309
1338
  }
1310
1339
 
1311
1340
  // --- Official API: Contact ---
@@ -1398,6 +1427,18 @@ async function handleTool(name, args) {
1398
1427
  case 'delete_file':
1399
1428
  return text(`File deleted: task=${(await getOfficialClient().deleteFile(args.file_token, args.type)).taskId}`);
1400
1429
 
1430
+ case 'download_image': {
1431
+ const r = await getOfficialClient().downloadMessageResource(args.message_id, args.image_key, 'image');
1432
+ // Return as MCP image content so the model sees the pixels directly.
1433
+ // Also include a tiny text preamble so the via-identity + size are visible in transcript.
1434
+ return {
1435
+ content: [
1436
+ { type: 'text', text: `Image downloaded (${r.viaUser ? 'as user' : 'as app'}, ${r.bytes} bytes, ${r.mimeType}):` },
1437
+ { type: 'image', data: r.base64, mimeType: r.mimeType },
1438
+ ],
1439
+ };
1440
+ }
1441
+
1401
1442
  default:
1402
1443
  return text(`Unknown tool: ${name}`);
1403
1444
  }
@@ -1427,6 +1468,27 @@ async function main() {
1427
1468
  if (!hasCookie) console.error('[feishu-user-plugin] WARNING: LARK_COOKIE not set — user identity tools (send_to_user, etc.) will fail');
1428
1469
  if (!hasApp) console.error('[feishu-user-plugin] WARNING: LARK_APP_ID/SECRET not set — official API tools (read_messages, docs, etc.) will fail');
1429
1470
  if (!hasUAT) console.error('[feishu-user-plugin] WARNING: LARK_USER_ACCESS_TOKEN not set — P2P chat reading (read_p2p_messages) will fail');
1471
+
1472
+ // Validate APP_ID/SECRET against Feishu before serving any tool calls.
1473
+ // Catches the "Claude filled in a wrong/stale APP_ID during install" failure mode
1474
+ // that otherwise surfaces as cryptic 401s on every Official API call (looks like
1475
+ // "MCP 掉线" to the user). Non-blocking — we warn but still serve, because the
1476
+ // user may only need user-identity (cookie) tools.
1477
+ if (hasApp) {
1478
+ try {
1479
+ const probe = await getOfficialClient().verifyApp();
1480
+ if (probe.valid) {
1481
+ const nameBit = probe.appName ? ` "${probe.appName}"` : '';
1482
+ console.error(`[feishu-user-plugin] App verified: ${probe.appId}${nameBit}`);
1483
+ } else {
1484
+ console.error(`[feishu-user-plugin] ERROR: LARK_APP_ID=${probe.appId} was REJECTED by Feishu (${probe.error}).`);
1485
+ console.error('[feishu-user-plugin] → Every Official API tool call will fail. Likely wrong/stale APP_ID.');
1486
+ console.error('[feishu-user-plugin] → Re-run the install prompt from team-skills/plugins/feishu-user-plugin/README.md to get the correct credentials.');
1487
+ }
1488
+ } catch (e) {
1489
+ console.error(`[feishu-user-plugin] WARNING: Could not verify APP_ID (${e.message}); network issue or cold start. Proceeding anyway.`);
1490
+ }
1491
+ }
1430
1492
  }
1431
1493
 
1432
1494
  main().catch(console.error);