feishu-user-plugin 1.3.10 → 1.3.12

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 (61) hide show
  1. package/.claude-plugin/plugin.json +2 -2
  2. package/.cursor-plugin/plugin.json +27 -0
  3. package/.mcpb/manifest.json +91 -0
  4. package/CHANGELOG.md +118 -0
  5. package/PRIVACY.md +105 -0
  6. package/README.en.md +130 -413
  7. package/README.md +88 -258
  8. package/package.json +5 -3
  9. package/scripts/build-mcpb.js +119 -0
  10. package/scripts/check-description-drift.js +73 -0
  11. package/scripts/check-docs-sync.js +7 -16
  12. package/scripts/check-mcp-registry-version.js +43 -0
  13. package/scripts/check-mcpb-version.js +33 -0
  14. package/scripts/check-scopes.js +99 -0
  15. package/scripts/check-tool-count.js +4 -3
  16. package/scripts/check-version.js +5 -0
  17. package/scripts/sync-claude-md.sh +3 -4
  18. package/scripts/sync-team-skills.sh +72 -57
  19. package/scripts/verify-app-name.js +64 -0
  20. package/skills/feishu-user-plugin/SKILL.md +3 -3
  21. package/skills/feishu-user-plugin/references/search.md +3 -3
  22. package/src/auth/credentials-monitor.js +185 -0
  23. package/src/auth/credentials.js +49 -0
  24. package/src/auth/identity-state.js +204 -0
  25. package/src/auth/lark-desktop.js +135 -0
  26. package/src/auth/uat.js +49 -35
  27. package/src/cli.js +87 -0
  28. package/src/clients/official/base.js +145 -14
  29. package/src/clients/official/calendar.js +3 -1
  30. package/src/clients/official/im.js +76 -2
  31. package/src/clients/official/okr.js +2 -1
  32. package/src/error-codes.js +40 -0
  33. package/src/events/lockfile.js +40 -4
  34. package/src/events/owner.js +11 -2
  35. package/src/index.js +1 -1
  36. package/src/logger.js +11 -5
  37. package/src/oauth.js +46 -10
  38. package/src/server.js +102 -37
  39. package/src/setup.js +44 -0
  40. package/src/test-all.js +40 -0
  41. package/src/test-cli-tool.js +87 -0
  42. package/src/test-credentials-monitor.js +124 -0
  43. package/src/test-display-label.js +88 -0
  44. package/src/test-error-codes.js +85 -0
  45. package/src/test-identity-state.js +172 -0
  46. package/src/test-lark-desktop.js +300 -0
  47. package/src/test-lockfile-pid.js +90 -0
  48. package/src/test-lru-cache.js +145 -0
  49. package/src/test-negative-cache.js +85 -0
  50. package/src/test-populate-sender-names.js +98 -0
  51. package/src/test-search-messages.js +101 -0
  52. package/src/test-send-shape.js +115 -0
  53. package/src/test-via-user.js +94 -0
  54. package/src/test-with-uat-retry.js +135 -0
  55. package/src/tools/_registry.js +24 -1
  56. package/src/tools/calendar.js +5 -5
  57. package/src/tools/im-read.js +52 -4
  58. package/src/tools/messaging-user.js +1 -1
  59. package/src/utils.js +83 -0
  60. package/scripts/generate-og-image.js +0 -39
  61. package/skills/feishu-user-plugin/references/CLAUDE.md +0 -523
@@ -107,7 +107,7 @@ const schemas = [
107
107
  },
108
108
  {
109
109
  name: 'read_p2p_messages',
110
- description: '[User UAT] Read P2P (direct message) chat history using user_access_token. Works for chats the bot cannot access. Returns newest messages first by default. Auto-expands merge_forward messages into their child messages by default — disable with expand_merge_forward=false. Requires OAuth setup.',
110
+ description: '[User UAT] Read P2P (direct message) chat history using user_access_token. Works for chats the bot cannot access. Returns newest messages first by default. Auto-expands merge_forward messages into their child messages by default — disable with expand_merge_forward=false. Requires OAuth setup.\n\n**Sender semantics (v1.3.12)**: each message has a `displayLabel` (e.g. `周宇`, `[Bot] Claude聊天助手`, `[匿名]`, `[系统]`, `[已撤回] 怪兽`) — prefer it over raw `senderId` when narrating who-said-what. Also surfaced: `senderType` (user|app|anonymous), `senderIdType` (open_id|union_id|user_id), `senderTenantKey`, `isExternal` (cross-tenant), `isRecalled`, `isThreadReply` (parent_id present).',
111
111
  inputSchema: {
112
112
  type: 'object',
113
113
  properties: {
@@ -145,7 +145,7 @@ const schemas = [
145
145
  },
146
146
  {
147
147
  name: 'read_messages',
148
- description: '[Official API + UAT fallback] Read message history from any group. Accepts oc_xxx ID, numeric ID, or chat name (auto-searched). Auto-falls back to UAT for external groups the bot cannot access. Returns newest messages first by default, with sender names resolved. Auto-expands merge_forward messages into their child messages (with original sender / time / content preserved) by default — disable with expand_merge_forward=false. Text messages have URLs extracted into `urls`; Feishu doc links are additionally surfaced as `feishuDocs` so agents can feed them straight into read_doc / get_doc_blocks.',
148
+ description: '[Official API + UAT fallback] Read message history from any group. Accepts oc_xxx ID, numeric ID, or chat name (auto-searched). Auto-falls back to UAT for external groups the bot cannot access. Returns newest messages first by default, with sender names resolved. Auto-expands merge_forward messages into their child messages (with original sender / time / content preserved) by default — disable with expand_merge_forward=false. Text messages have URLs extracted into `urls`; Feishu doc links are additionally surfaced as `feishuDocs` so agents can feed them straight into read_doc / get_doc_blocks.\n\n**Sender semantics (v1.3.12)**: each message has a `displayLabel` (e.g. `周宇`, `[Bot] Claude聊天助手`, `[匿名]`, `[系统]`, `[已撤回] 怪兽`) — prefer it over raw `senderId` when narrating who-said-what. Also surfaced: `senderType` (user|app|anonymous), `senderIdType` (open_id|union_id|user_id), `senderTenantKey`, `isExternal` (cross-tenant), `isRecalled`, `isThreadReply` (parent_id present). **merge_forward children** carry `originChatId` (the chat the conversation came from, NOT the chat you queried) and best-effort `forwardedFromChatName` — do NOT treat children as native messages of the current group.',
149
149
  inputSchema: {
150
150
  type: 'object',
151
151
  properties: {
@@ -155,10 +155,29 @@ const schemas = [
155
155
  end_time: { type: 'string', description: 'End timestamp in seconds (optional)' },
156
156
  sort_type: { type: 'string', enum: ['ByCreateTimeDesc', 'ByCreateTimeAsc'], description: 'Sort order (default: ByCreateTimeDesc = newest first)' },
157
157
  expand_merge_forward: { type: 'boolean', description: 'Auto-expand merge_forward placeholders into their child messages (default true). Children carry parentMessageId; use that id (not the child id) with download_message_resource (kind=image or file).' },
158
+ via_user: { type: 'boolean', description: 'v1.3.12 — explicit identity override. `true` skips the bot path and reads directly via UAT (use when the chat is yours / external and you know bot has no access). `false` skips UAT fallback and surfaces the bot error instead of cross-identity hop (use when you specifically want the bot view). Omit for default auto-fallback (bot first, UAT on failure).' },
158
159
  },
159
160
  required: ['chat_id'],
160
161
  },
161
162
  },
163
+ {
164
+ name: 'search_messages',
165
+ description: '[User UAT, v1.3.12] Search the user\'s IM history by keyword. Wraps Feishu `POST /open-apis/search/v2/message`. Requires UAT with the `search:message` scope (re-run `npx feishu-user-plugin oauth` after v1.3.12 SCOPES update). Feishu does NOT expose a bot-path search; if you only have app credentials this tool will error.\n\nReturns `{items, pageToken, hasMore}` where each item is a `{message_id, chat_id, ...}` pointer — call `read_messages(chat_id)` or `read_p2p_messages(chat_id)` to fetch the full message bodies if needed. The pointer-only return keeps the response token-light when searching across many chats.\n\nFilter knobs (all optional):\n- `chat_ids`: only search inside these chats (oc_xxx)\n- `from_ids`: messages sent by these users (ou_xxx / union_id)\n- `at_user_ids`: messages that @-mention these users\n- `message_types`: e.g. `["text", "post"]`\n- `from_types`: e.g. `["user", "anonymous"]`',
166
+ inputSchema: {
167
+ type: 'object',
168
+ properties: {
169
+ query: { type: 'string', description: 'Search keyword. Plain text; Feishu handles tokenization.' },
170
+ page_size: { type: 'number', description: 'Items per page (default 20, max 100)' },
171
+ page_token: { type: 'string', description: 'Pagination cursor from a previous page' },
172
+ chat_ids: { type: 'array', items: { type: 'string' }, description: 'Restrict to these oc_xxx chats' },
173
+ from_ids: { type: 'array', items: { type: 'string' }, description: 'Restrict to messages from these user ids (ou_xxx / union_id)' },
174
+ at_user_ids: { type: 'array', items: { type: 'string' }, description: 'Restrict to messages that @-mention these user ids' },
175
+ message_types: { type: 'array', items: { type: 'string' }, description: 'Filter by message types (e.g. ["text","post","image","file","interactive"])' },
176
+ from_types: { type: 'array', items: { type: 'string' }, description: 'Filter by sender types (e.g. ["user","anonymous"])' },
177
+ },
178
+ required: ['query'],
179
+ },
180
+ },
162
181
  ];
163
182
 
164
183
  const handlers = {
@@ -231,6 +250,15 @@ const handlers = {
231
250
  sortType: args.sort_type,
232
251
  expandMergeForward: args.expand_merge_forward !== false,
233
252
  };
253
+ // v1.3.12: via_user opt-in routing override. true=skip bot (UAT only),
254
+ // false=skip UAT (bot only / no fallback), undefined=default auto-fallback.
255
+ // Set `via: 'user'` explicitly so readMessagesWithFallback labels the
256
+ // response data.via = 'user' (distinguishing intentional UAT route from
257
+ // the auto-fallback case where 'bot' is the default label).
258
+ const routingOpts = {};
259
+ if (args.via_user === true) { routingOpts.skipBot = true; routingOpts.via = 'user'; }
260
+ else if (args.via_user === false) routingOpts.skipUat = true;
261
+
234
262
  // Get userClient for name resolution fallback (best-effort)
235
263
  let uc = null;
236
264
  try { uc = await ctx.getUserClient(); } catch (_) {}
@@ -238,12 +266,17 @@ const handlers = {
238
266
  // Path A — chat_id that resolves inside bot's / official search scope.
239
267
  const resolvedChatId = await chatIdMapper.resolveToOcId(args.chat_id, official);
240
268
  if (resolvedChatId) {
241
- return json(await official.readMessagesWithFallback(resolvedChatId, msgOpts, uc));
269
+ return json(await official.readMessagesWithFallback(resolvedChatId, msgOpts, uc, routingOpts));
242
270
  }
243
271
 
244
272
  // Path B — external group discovered only via cookie search_contacts.
245
273
  // When we got here the bot definitely can't see it, so skip bot entirely
246
- // and go straight to UAT with a `contacts` via label.
274
+ // and go straight to UAT with a `contacts` via label. If user explicitly
275
+ // set via_user=false (bot-only), short-circuit with a clear error rather
276
+ // than silently routing through UAT anyway.
277
+ if (args.via_user === false) {
278
+ return text(`Cannot find "${args.chat_id}" via bot, and via_user=false explicitly opts out of UAT fallback. Either omit via_user or set via_user=true.`);
279
+ }
247
280
  if (official.hasUAT) {
248
281
  if (!uc) try { uc = await ctx.getUserClient(); } catch (_) {}
249
282
  const contactChatId = await chatIdMapper.resolveViaContacts(args.chat_id, uc);
@@ -254,6 +287,21 @@ const handlers = {
254
287
 
255
288
  return text(`Cannot resolve "${args.chat_id}" to a chat ID.\nSearched: bot's group list, im.chat.search API, and user contacts (search_contacts).\nTry: provide the oc_xxx or numeric chat ID directly.`);
256
289
  },
290
+
291
+ async search_messages(args, ctx) {
292
+ const official = ctx.getOfficialClient();
293
+ const result = await official.searchMessages({
294
+ query: args.query,
295
+ pageSize: args.page_size,
296
+ pageToken: args.page_token,
297
+ chatIds: args.chat_ids,
298
+ fromIds: args.from_ids,
299
+ atUserIds: args.at_user_ids,
300
+ messageTypes: args.message_types,
301
+ fromTypes: args.from_types,
302
+ });
303
+ return json(result);
304
+ },
257
305
  };
258
306
 
259
307
  module.exports = { schemas, handlers };
@@ -294,7 +294,7 @@ const handlers = {
294
294
  },
295
295
  async send_card_as_user(args, ctx) {
296
296
  const r = await ctx.getOfficialClient().sendMessageAsBot(args.chat_id, 'interactive', args.card);
297
- return text(`Card sent (bot): ${r.messageId}`);
297
+ return sendResult(r, { desc: 'Card sent via bot (cookie channel rejects interactive)', viaUser: false });
298
298
  },
299
299
  };
300
300
 
package/src/utils.js CHANGED
@@ -43,10 +43,93 @@ function fetchWithTimeout(url, init = {}) {
43
43
  return fetch(url, { ...rest, signal: rest.signal || controller.signal }).finally(() => clearTimeout(timer));
44
44
  }
45
45
 
46
+ // LRU cache with TTL. Replaces unbounded `new Map()` in base.js for
47
+ // _userNameCache / _appNameCache (v1.3.12). Insertion order in a JS Map gives
48
+ // us LRU for free — re-insertion (delete + set) moves a key to "newest".
49
+ class LRUCache {
50
+ constructor({ max = 500, ttlMs = 600_000 } = {}) {
51
+ if (!Number.isFinite(max) || max <= 0) throw new Error('LRUCache: max must be positive');
52
+ if (!Number.isFinite(ttlMs) || ttlMs <= 0) throw new Error('LRUCache: ttlMs must be positive');
53
+ this._max = max;
54
+ this._ttlMs = ttlMs;
55
+ this._map = new Map(); // key → { value, expiresAt }
56
+ }
57
+
58
+ _isExpired(entry) {
59
+ return entry.expiresAt <= Date.now();
60
+ }
61
+
62
+ get(key) {
63
+ const entry = this._map.get(key);
64
+ if (!entry) return undefined;
65
+ if (this._isExpired(entry)) {
66
+ this._map.delete(key);
67
+ return undefined;
68
+ }
69
+ // Bump recency: re-insert to move to tail.
70
+ this._map.delete(key);
71
+ this._map.set(key, entry);
72
+ return entry.value;
73
+ }
74
+
75
+ has(key) {
76
+ const entry = this._map.get(key);
77
+ if (!entry) return false;
78
+ if (this._isExpired(entry)) {
79
+ this._map.delete(key);
80
+ return false;
81
+ }
82
+ return true;
83
+ }
84
+
85
+ set(key, value) {
86
+ // If the key exists, delete first so re-insert puts it at the tail.
87
+ if (this._map.has(key)) this._map.delete(key);
88
+ this._map.set(key, { value, expiresAt: Date.now() + this._ttlMs });
89
+ while (this._map.size > this._max) {
90
+ const oldest = this._map.keys().next().value;
91
+ this._map.delete(oldest);
92
+ }
93
+ }
94
+
95
+ delete(key) { return this._map.delete(key); }
96
+
97
+ clear() { this._map.clear(); }
98
+
99
+ get size() { return this._map.size; }
100
+
101
+ // Map-compatible iteration. Skips expired entries so callers don't observe
102
+ // stale data via spread / for-of. Order follows the underlying Map's
103
+ // insertion order (= LRU recency order, oldest first).
104
+ *entries() {
105
+ for (const [key, entry] of this._map) {
106
+ if (this._isExpired(entry)) continue;
107
+ yield [key, entry.value];
108
+ }
109
+ }
110
+
111
+ *keys() {
112
+ for (const [key, entry] of this._map) {
113
+ if (this._isExpired(entry)) continue;
114
+ yield key;
115
+ }
116
+ }
117
+
118
+ *values() {
119
+ for (const [, entry] of this._map) {
120
+ if (this._isExpired(entry)) continue;
121
+ yield entry.value;
122
+ }
123
+ }
124
+
125
+ [Symbol.iterator]() { return this.entries(); }
126
+ }
127
+
46
128
  module.exports = {
47
129
  generateRequestId,
48
130
  generateCid,
49
131
  parseCookie,
50
132
  formatCookie,
51
133
  fetchWithTimeout,
134
+ LRUCache,
52
135
  };
@@ -1,39 +0,0 @@
1
- #!/usr/bin/env node
2
- // Render docs/og.svg → docs/og.png at 1200x630.
3
- //
4
- // Idempotent. Run `node scripts/generate-og-image.js` after editing
5
- // docs/og.svg. Commit both the SVG (source) and the PNG (asset used
6
- // by social-media unfurls and `<meta property="og:image">`).
7
- //
8
- // Why PNG: Twitter / WeChat / 飞书 unfurls don't render SVG `og:image`.
9
-
10
- 'use strict';
11
-
12
- const fs = require('fs');
13
- const path = require('path');
14
- const { Resvg } = require('@resvg/resvg-js');
15
-
16
- const svgPath = path.join(__dirname, '..', 'docs', 'og.svg');
17
- const pngPath = path.join(__dirname, '..', 'docs', 'og.png');
18
-
19
- const svg = fs.readFileSync(svgPath, 'utf8');
20
-
21
- const resvg = new Resvg(svg, {
22
- fitTo: { mode: 'width', value: 1200 },
23
- // Try to use system fonts so Chinese characters render. resvg-js loads
24
- // fonts from `font.fontDirs` and `font.defaultFontFamily`. On macOS
25
- // /System/Library/Fonts has PingFang SC; on Linux CI, jekyll-seo-tag
26
- // doesn't run this script anyway — only humans do, locally.
27
- font: {
28
- fontDirs: ['/System/Library/Fonts', '/Library/Fonts', '/usr/share/fonts'],
29
- loadSystemFonts: true,
30
- defaultFontFamily: 'PingFang SC',
31
- },
32
- background: '#0d1117',
33
- });
34
-
35
- const pngBuffer = resvg.render().asPng();
36
- fs.writeFileSync(pngPath, pngBuffer);
37
-
38
- const sizeKb = (pngBuffer.length / 1024).toFixed(1);
39
- console.log(`OK: wrote ${pngPath} (${pngBuffer.length} bytes / ${sizeKb} KiB)`);