feishu-user-plugin 1.3.11 → 1.3.13

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 (51) hide show
  1. package/.claude-plugin/plugin.json +2 -2
  2. package/.cursor-plugin/plugin.json +2 -2
  3. package/.mcpb/manifest.json +3 -3
  4. package/CHANGELOG.md +159 -8
  5. package/README.en.md +130 -413
  6. package/README.md +69 -259
  7. package/package.json +2 -2
  8. package/scripts/check-description-drift.js +73 -0
  9. package/scripts/check-docs-sync.js +7 -16
  10. package/scripts/check-scopes.js +99 -0
  11. package/scripts/check-tool-count.js +4 -3
  12. package/scripts/sync-claude-md.sh +3 -4
  13. package/scripts/verify-app-name.js +64 -0
  14. package/skills/feishu-user-plugin/SKILL.md +3 -3
  15. package/skills/feishu-user-plugin/references/search.md +3 -3
  16. package/src/auth/credentials-monitor.js +185 -0
  17. package/src/auth/identity-state.js +209 -0
  18. package/src/auth/uat.js +49 -35
  19. package/src/cli.js +87 -0
  20. package/src/clients/official/base.js +170 -14
  21. package/src/clients/official/calendar.js +3 -1
  22. package/src/clients/official/im.js +76 -2
  23. package/src/clients/official/okr.js +2 -1
  24. package/src/error-codes.js +40 -0
  25. package/src/events/lockfile.js +40 -4
  26. package/src/events/owner.js +11 -2
  27. package/src/index.js +1 -1
  28. package/src/logger.js +11 -5
  29. package/src/oauth.js +65 -14
  30. package/src/server.js +76 -37
  31. package/src/test-all.js +41 -0
  32. package/src/test-cli-tool.js +87 -0
  33. package/src/test-credentials-monitor.js +124 -0
  34. package/src/test-display-label.js +88 -0
  35. package/src/test-error-codes.js +85 -0
  36. package/src/test-identity-state.js +177 -0
  37. package/src/test-lark-desktop.js +1 -0
  38. package/src/test-lockfile-pid.js +90 -0
  39. package/src/test-lru-cache.js +145 -0
  40. package/src/test-negative-cache.js +85 -0
  41. package/src/test-populate-sender-names.js +98 -0
  42. package/src/test-search-messages.js +101 -0
  43. package/src/test-send-shape.js +115 -0
  44. package/src/test-via-user.js +94 -0
  45. package/src/test-with-uat-retry.js +135 -0
  46. package/src/tools/_registry.js +24 -1
  47. package/src/tools/calendar.js +5 -5
  48. package/src/tools/im-read.js +52 -4
  49. package/src/tools/messaging-user.js +1 -1
  50. package/src/utils.js +83 -0
  51. package/skills/feishu-user-plugin/references/CLAUDE.md +0 -524
package/src/cli.js CHANGED
@@ -33,6 +33,9 @@ switch (cmd) {
33
33
  }
34
34
  break;
35
35
  }
36
+ case 'tool':
37
+ runTool();
38
+ break;
36
39
  case 'migrate':
37
40
  migrate();
38
41
  break;
@@ -60,6 +63,10 @@ Commands:
60
63
  migrate One-time consolidation: copy creds from harness configs into
61
64
  ~/.feishu-user-plugin/credentials.json (single source of truth).
62
65
  Dry-run by default. Add --confirm to actually write.
66
+ tool Invoke any MCP tool from the shell (v1.3.12):
67
+ \`tool list\` — list all tool names
68
+ \`tool help <name>\` — print schema for <name>
69
+ \`tool <name> '<json-args>'\` — invoke <name>, print response
63
70
  help Show this help
64
71
 
65
72
  Setup options:
@@ -106,6 +113,86 @@ Auto-renewal (optional):
106
113
  `);
107
114
  }
108
115
 
116
+ // `tool` subcommand — invoke any MCP tool from the shell (v1.3.12).
117
+ //
118
+ // Usage:
119
+ // npx feishu-user-plugin tool list
120
+ // npx feishu-user-plugin tool help <name>
121
+ // npx feishu-user-plugin tool <name> '<json-args>'
122
+ //
123
+ // Reuses src/server.js's HANDLERS + buildCtx, so behaviour is identical to
124
+ // calling the tool from an MCP client. Output: tool's content[0].text
125
+ // (which is JSON for most tools — pipe through `jq` if you like).
126
+ async function runTool() {
127
+ const { TOOLS, HANDLERS, buildCtx } = require('./server');
128
+ const sub = process.argv[3];
129
+
130
+ if (!sub || sub === '--help' || sub === '-h') {
131
+ console.log(`Usage:
132
+ npx feishu-user-plugin tool list
133
+ List all ${TOOLS.length} registered tool names.
134
+ npx feishu-user-plugin tool help <name>
135
+ Print the inputSchema for <name>.
136
+ npx feishu-user-plugin tool <name> '<json-args>'
137
+ Invoke <name> with the given JSON args, print response text to stdout.
138
+
139
+ Examples:
140
+ npx feishu-user-plugin tool list
141
+ npx feishu-user-plugin tool help send_as_user
142
+ npx feishu-user-plugin tool get_login_status '{}'
143
+ npx feishu-user-plugin tool search_messages '{"query":"周报","page_size":5}'
144
+ `);
145
+ process.exit(sub ? 0 : 2);
146
+ }
147
+
148
+ if (sub === 'list') {
149
+ for (const t of TOOLS) {
150
+ console.log(t.name);
151
+ }
152
+ return;
153
+ }
154
+
155
+ if (sub === 'help') {
156
+ const name = process.argv[4];
157
+ if (!name) { console.error('Usage: tool help <name>'); process.exit(2); }
158
+ const t = TOOLS.find((x) => x.name === name);
159
+ if (!t) { console.error(`Unknown tool: ${name}. Try \`tool list\`.`); process.exit(2); }
160
+ console.log(`# ${t.name}\n`);
161
+ console.log(t.description || '(no description)');
162
+ console.log('\n## inputSchema\n');
163
+ console.log(JSON.stringify(t.inputSchema || {}, null, 2));
164
+ return;
165
+ }
166
+
167
+ // Dispatch path: sub is the tool name, argv[4] is the JSON args.
168
+ const name = sub;
169
+ const handler = HANDLERS[name];
170
+ if (!handler) { console.error(`Unknown tool: ${name}. Try \`tool list\`.`); process.exit(2); }
171
+
172
+ const jsonArgs = process.argv[4] || '{}';
173
+ let args;
174
+ try { args = JSON.parse(jsonArgs); }
175
+ catch (e) {
176
+ console.error(`tool ${name}: failed to parse JSON args (${e.message}). Pass a single quoted JSON string, e.g. '{"key":"value"}'.`);
177
+ process.exit(2);
178
+ }
179
+
180
+ try {
181
+ const ctx = buildCtx();
182
+ const result = await handler(args, ctx);
183
+ const text = result?.content?.[0]?.text;
184
+ if (typeof text === 'string') {
185
+ console.log(text);
186
+ } else {
187
+ console.log(JSON.stringify(result, null, 2));
188
+ }
189
+ if (result?.isError) process.exit(1);
190
+ } catch (e) {
191
+ console.error(`tool ${name}: ${e.message}`);
192
+ process.exit(1);
193
+ }
194
+ }
195
+
109
196
  function migrate() {
110
197
  const { migrate: runMigrate } = require('./auth/credentials');
111
198
  const confirm = process.argv.includes('--confirm');
@@ -1,5 +1,5 @@
1
1
  const lark = require('@larksuiteoapi/node-sdk');
2
- const { fetchWithTimeout } = require('../../utils');
2
+ const { fetchWithTimeout, LRUCache } = require('../../utils');
3
3
  const { stderrLogger } = require('../../logger');
4
4
  const uatLifecycle = require('../../auth/uat');
5
5
 
@@ -11,7 +11,13 @@ class LarkOfficialClient {
11
11
  this._uat = null;
12
12
  this._uatRefresh = null;
13
13
  this._uatExpires = 0;
14
- this._userNameCache = new Map(); // open_id display name
14
+ // v1.3.12: bounded caches with TTL. Pre-v1.3.12 these were plain Maps that
15
+ // grew without bound and never expired, so a week-old server would carry
16
+ // stale display names. 500 entries / 10min covers the common chat-volume
17
+ // working set without keeping cold entries forever.
18
+ this._userNameCache = new LRUCache({ max: 500, ttlMs: 600_000 }); // open_id → display name
19
+ this._appNameCache = new LRUCache({ max: 100, ttlMs: 600_000 }); // app_id (cli_xxx) → app name
20
+ this._selfTenantKey = null; // populated lazily on first message read
15
21
  }
16
22
 
17
23
  // --- UAT (User Access Token) Management ---
@@ -109,20 +115,58 @@ class LarkOfficialClient {
109
115
  }
110
116
 
111
117
  async _populateSenderNames(items, userClient) {
112
- // Collect unique sender IDs that aren't cached
113
- const unknownIds = new Set();
118
+ // Step 0: harvest mention name data — Feishu IM API ships mentions[].name
119
+ // alongside each message body, so we get (id, name) pairs for any user
120
+ // who got @-mentioned without spending a contact API call.
114
121
  for (const item of items) {
115
- if (item.senderId && !this._userNameCache.has(item.senderId)) {
116
- unknownIds.add(item.senderId);
122
+ if (Array.isArray(item.mentions)) {
123
+ for (const m of item.mentions) {
124
+ if (m.id && m.name && !this._userNameCache.has(m.id)) {
125
+ this._userNameCache.set(m.id, m.name);
126
+ }
127
+ }
117
128
  }
118
129
  }
119
- // Parallel resolve via official contact API (instead of sequential N calls)
120
- if (unknownIds.size > 0) {
121
- await Promise.allSettled([...unknownIds].map(id => this.getUserById(id)));
130
+
131
+ // Step 1: lazy-resolve our own tenant_key so we can flag isExternal senders.
132
+ if (this._selfTenantKey === null) {
133
+ await this._resolveSelfTenantKey().catch(() => {});
134
+ }
135
+
136
+ // Step 2: collect unique unknown user / app ids
137
+ const unknownUserIds = new Set();
138
+ const unknownAppIds = new Set();
139
+ for (const item of items) {
140
+ if (!item.senderId) continue;
141
+ if (item.senderType === 'app') {
142
+ if (!this._appNameCache.has(item.senderId)) unknownAppIds.add(item.senderId);
143
+ } else if (!this._userNameCache.has(item.senderId)) {
144
+ unknownUserIds.add(item.senderId);
145
+ }
146
+ }
147
+
148
+ // Step 3: parallel resolve users via contact API.
149
+ // v1.3.12: read result.status so failed lookups don't disappear silently.
150
+ // The 2026-05 incident had ~100% lookup failure for weeks (UAT revoked +
151
+ // tenant scope gap) and senderName=null without any breadcrumb in stderr.
152
+ if (unknownUserIds.size > 0) {
153
+ const userIds = [...unknownUserIds];
154
+ const results = await Promise.allSettled(userIds.map(id => this.getUserById(id)));
155
+ const failed = [];
156
+ for (let i = 0; i < results.length; i++) {
157
+ if (results[i].status === 'rejected') {
158
+ failed.push({ id: userIds[i], reason: results[i].reason?.message || String(results[i].reason) });
159
+ }
160
+ }
161
+ if (failed.length) {
162
+ const sample = failed.slice(0, 3).map(f => `${f.id}(${f.reason})`).join(', ');
163
+ const tail = failed.length > 3 ? ` (+${failed.length - 3} more)` : '';
164
+ console.error(`[feishu-user-plugin] sender name lookup failed for ${failed.length}/${userIds.length} ids: ${sample}${tail}`);
165
+ }
122
166
  }
123
- // Fallback: resolve remaining unknowns via cookie-based user identity client
167
+ // Cookie fallback for any user still unknown
124
168
  if (userClient) {
125
- for (const id of unknownIds) {
169
+ for (const id of unknownUserIds) {
126
170
  if (!this._userNameCache.has(id)) {
127
171
  try {
128
172
  const name = await userClient.getUserName(id);
@@ -131,12 +175,114 @@ class LarkOfficialClient {
131
175
  }
132
176
  }
133
177
  }
134
- // Populate senderName field
178
+ // Parallel resolve apps (best-effort; usually only self-app appears).
179
+ // v1.3.12: same Promise.allSettled status-reading fix as the user path.
180
+ if (unknownAppIds.size > 0) {
181
+ const appIds = [...unknownAppIds];
182
+ const results = await Promise.allSettled(appIds.map(id => this.getAppName(id)));
183
+ const failed = [];
184
+ for (let i = 0; i < results.length; i++) {
185
+ if (results[i].status === 'rejected') {
186
+ failed.push({ id: appIds[i], reason: results[i].reason?.message || String(results[i].reason) });
187
+ }
188
+ }
189
+ if (failed.length) {
190
+ const sample = failed.slice(0, 3).map(f => `${f.id}(${f.reason})`).join(', ');
191
+ const tail = failed.length > 3 ? ` (+${failed.length - 3} more)` : '';
192
+ console.error(`[feishu-user-plugin] app name lookup failed for ${failed.length}/${appIds.length} ids: ${sample}${tail}`);
193
+ }
194
+ }
195
+
196
+ // v1.3.12 negative cache: any id we tried to resolve but couldn't gets
197
+ // a null sentinel under the same LRU+TTL. The 10-min TTL on the cache
198
+ // gives renamed / newly-joined users a re-resolution window without
199
+ // dispatching N redundant API calls per read_messages on hot chats.
200
+ // has(id)==true / get(id)==null lets _computeDisplayLabel fall back to
201
+ // "(open_id)" exactly the same way as before.
202
+ //
203
+ // PR #103 Copilot followup: getUserById / getAppName return null on
204
+ // non-zero Feishu codes (e.g. 99991672, scope missing) WITHOUT rejecting,
205
+ // so the per-batch Promise.allSettled rejection log misses these. Log
206
+ // the ids that ended without a name as a separate stderr line so failure
207
+ // shape is observable regardless of whether the underlying lookup
208
+ // returned null or rejected.
209
+ const unresolvedUserIds = [];
210
+ for (const id of unknownUserIds) {
211
+ if (!this._userNameCache.has(id) || this._userNameCache.get(id) === null) {
212
+ this._userNameCache.set(id, null);
213
+ unresolvedUserIds.push(id);
214
+ }
215
+ }
216
+ if (unresolvedUserIds.length) {
217
+ const sample = unresolvedUserIds.slice(0, 5).join(', ');
218
+ const tail = unresolvedUserIds.length > 5 ? ` (+${unresolvedUserIds.length - 5} more)` : '';
219
+ console.error(`[feishu-user-plugin] sender name unresolved (cached null) for ${unresolvedUserIds.length} id(s): ${sample}${tail}`);
220
+ }
221
+ const unresolvedAppIds = [];
222
+ for (const id of unknownAppIds) {
223
+ if (!this._appNameCache.has(id) || this._appNameCache.get(id) === null) {
224
+ this._appNameCache.set(id, null);
225
+ unresolvedAppIds.push(id);
226
+ }
227
+ }
228
+ if (unresolvedAppIds.length) {
229
+ const sample = unresolvedAppIds.slice(0, 5).join(', ');
230
+ const tail = unresolvedAppIds.length > 5 ? ` (+${unresolvedAppIds.length - 5} more)` : '';
231
+ console.error(`[feishu-user-plugin] app name unresolved (cached null) for ${unresolvedAppIds.length} id(s): ${sample}${tail}`);
232
+ }
233
+
234
+ // Step 4: populate senderName, isExternal, displayLabel
135
235
  for (const item of items) {
136
- if (item.senderId) {
137
- item.senderName = this._userNameCache.get(item.senderId) || null;
236
+ item.senderName = item.senderId ? (this._userNameCache.get(item.senderId) || null) : null;
237
+ if (this._selfTenantKey && item.senderTenantKey && item.senderTenantKey !== this._selfTenantKey) {
238
+ item.isExternal = true;
239
+ }
240
+ item.displayLabel = this._computeDisplayLabel(item);
241
+ }
242
+ }
243
+
244
+ _computeDisplayLabel(item) {
245
+ const prefix = item.isRecalled ? '[已撤回] ' : '';
246
+ if (!item.senderId) return prefix + '[系统]';
247
+ if (item.senderType === 'anonymous') return prefix + '[匿名]';
248
+ if (item.senderType === 'app') {
249
+ const appName = this._appNameCache.get(item.senderId);
250
+ return prefix + `[Bot] ${appName || `(${item.senderId})`}`;
251
+ }
252
+ return prefix + (item.senderName || `(${item.senderId})`);
253
+ }
254
+
255
+ async getAppName(appId) {
256
+ if (this._appNameCache.has(appId)) return this._appNameCache.get(appId);
257
+ try {
258
+ const token = await this._getAppToken();
259
+ const res = await fetchWithTimeout(
260
+ `https://open.feishu.cn/open-apis/application/v6/applications/${encodeURIComponent(appId)}?lang=zh_cn`,
261
+ { headers: { 'Authorization': `Bearer ${token}` }, timeoutMs: 10000 },
262
+ );
263
+ const data = await res.json();
264
+ const name = data?.data?.app?.app_name;
265
+ if (name) {
266
+ this._appNameCache.set(appId, name);
267
+ return name;
138
268
  }
269
+ } catch {
270
+ // best-effort; null means "couldn't resolve, fall back to id"
139
271
  }
272
+ return null;
273
+ }
274
+
275
+ async _resolveSelfTenantKey() {
276
+ if (this._selfTenantKey) return this._selfTenantKey;
277
+ const res = await fetchWithTimeout('https://open.feishu.cn/open-apis/auth/v3/app_access_token/internal', {
278
+ method: 'POST',
279
+ headers: { 'content-type': 'application/json' },
280
+ body: JSON.stringify({ app_id: this.appId, app_secret: this.appSecret }),
281
+ timeoutMs: 10000,
282
+ });
283
+ const data = await res.json();
284
+ if (data?.tenant_key) this._selfTenantKey = data.tenant_key;
285
+ return this._selfTenantKey;
140
286
  }
141
287
 
142
288
  // --- Helpers ---
@@ -145,6 +291,10 @@ class LarkOfficialClient {
145
291
  if (!m) return null;
146
292
  let body = m.body?.content || '';
147
293
  try { body = JSON.parse(body); } catch {}
294
+ // Feishu signals recall by replacing body content with the literal string
295
+ // "This message was recalled" (not a JSON object). Surface as boolean so
296
+ // the LLM doesn't have to pattern-match the string.
297
+ const isRecalled = (typeof body === 'string' && /recalled/i.test(body));
148
298
  const out = {
149
299
  messageId: m.message_id,
150
300
  chatId: m.chat_id,
@@ -155,6 +305,12 @@ class LarkOfficialClient {
155
305
  createTime: this._normalizeTimestamp(m.create_time),
156
306
  updateTime: this._normalizeTimestamp(m.update_time),
157
307
  };
308
+ // Raw sender metadata — LLM may need id_type (to pick the right
309
+ // user_id_type for contact API) or tenant_key (to know cross-tenant).
310
+ if (m.sender?.id_type) out.senderIdType = m.sender.id_type;
311
+ if (m.sender?.tenant_key) out.senderTenantKey = m.sender.tenant_key;
312
+ if (isRecalled) out.isRecalled = true;
313
+ if (m.parent_id) out.isThreadReply = true;
158
314
  if (Array.isArray(m.mentions) && m.mentions.length > 0) out.mentions = m.mentions;
159
315
  if (m.upper_message_id) out.upperMessageId = m.upper_message_id;
160
316
  if (m.root_id) out.rootId = m.root_id;
@@ -68,7 +68,9 @@ module.exports = {
68
68
  },
69
69
 
70
70
  // --- Calendar write (v1.3.7) ---
71
- // Requires `calendar:calendar.event:write` scope on app + UAT.
71
+ // Requires the 4 verb-specific scopes on app + UAT:
72
+ // calendar:calendar.event:create / update / delete / reply
73
+ // Feishu has no umbrella `:write` scope — using it 422-rejects OAuth.
72
74
 
73
75
  async createCalendarEvent(calendarId, eventData) {
74
76
  if (!calendarId) throw new Error('createCalendarEvent: calendarId is required');
@@ -329,6 +329,22 @@ module.exports = {
329
329
  return f;
330
330
  });
331
331
  await this._populateSenderNames(children, userClient);
332
+ // Best-effort: surface the origin chat NAME for each child so the LLM doesn't
333
+ // misread the children as native messages of the current chat. A single
334
+ // merge_forward usually has 1 origin chat → 1 API call. Failures are silent
335
+ // (bot may not be in the origin chat) and the field is simply absent.
336
+ const originChatIds = [...new Set(children.map(c => c.originChatId).filter(Boolean))];
337
+ const chatNameMap = new Map();
338
+ await Promise.allSettled(originChatIds.map(async (cid) => {
339
+ try {
340
+ const info = await this.getChatInfo(cid);
341
+ if (info?.name) chatNameMap.set(cid, info.name);
342
+ } catch {}
343
+ }));
344
+ for (const c of children) {
345
+ const name = c.originChatId && chatNameMap.get(c.originChatId);
346
+ if (name) c.forwardedFromChatName = name;
347
+ }
332
348
  return children;
333
349
  },
334
350
 
@@ -366,7 +382,10 @@ module.exports = {
366
382
  //
367
383
  // Throws a single, wrapped error if BOTH paths fail or if UAT is absent and
368
384
  // the bot failed; the message points the user at `npx feishu-user-plugin oauth`.
369
- async readMessagesWithFallback(chatId, options, userClient, { skipBot = false, via = 'bot' } = {}) {
385
+ async readMessagesWithFallback(chatId, options, userClient, { skipBot = false, skipUat = false, via = 'bot' } = {}) {
386
+ if (skipBot && skipUat) {
387
+ throw new Error('readMessagesWithFallback: cannot set both skipBot and skipUat — at least one identity path must be allowed');
388
+ }
370
389
  const tryUAT = async (viaLabel, reason) => {
371
390
  if (!this.hasUAT) {
372
391
  const hint = 'To read external / private groups, configure UAT via: npx feishu-user-plugin oauth';
@@ -381,7 +400,16 @@ module.exports = {
381
400
  };
382
401
 
383
402
  if (skipBot) {
384
- return tryUAT(via || 'contacts', 'contacts_resolved_external');
403
+ // Two reasons we skipBot:
404
+ // Path B (im-read.js): via='contacts' — chat was found only via
405
+ // cookie search_contacts, bot definitely can't see it. Reason
406
+ // field surfaces this to the LLM as "contacts_resolved_external".
407
+ // v1.3.12 via_user=true: caller explicitly opted for UAT. No
408
+ // failure happened; no reason needed.
409
+ if (via === 'contacts') {
410
+ return tryUAT('contacts', 'contacts_resolved_external');
411
+ }
412
+ return tryUAT(via && via !== 'bot' ? via : 'user');
385
413
  }
386
414
 
387
415
  // Attempt 1 — bot identity.
@@ -406,9 +434,55 @@ module.exports = {
406
434
  }
407
435
  }
408
436
 
437
+ // v1.3.12: when caller passes via_user=false (skipUat=true), surface
438
+ // the bot error instead of silently falling through to UAT. The user
439
+ // explicitly opted out of cross-identity fallback.
440
+ if (skipUat) {
441
+ throw new Error(`Bot path failed and via_user=false specified: ${botErr.message}`);
442
+ }
409
443
  // Fall through to UAT — if UAT is missing, tryUAT throws the user-friendly
410
444
  // "run npx feishu-user-plugin oauth" error instead of the raw Feishu payload.
411
445
  return tryUAT('user', klass.reason);
412
446
  }
413
447
  },
448
+
449
+ // --- Message search (v1.3.12, B.5) ---
450
+ //
451
+ // Wraps POST /open-apis/search/v2/message. UAT-only (Feishu doesn't expose
452
+ // bot path; probed 2026-05-15 — bot returns 99991668 "user access token not
453
+ // support"). Requires the `search:message` OAuth scope; without it the API
454
+ // returns 99991679 (permission_violations.subject="search:message"). Caller
455
+ // should re-run `npx feishu-user-plugin oauth` after adding the scope.
456
+ //
457
+ // Returns the raw shape from Feishu: { items: [{ message_id, ... }], page_token, has_more }.
458
+ // Items are message-id pointers — call read_messages / read_p2p_messages
459
+ // with the parent chat_id to fetch full bodies if needed.
460
+ async searchMessages({ query, fromIds, chatIds, messageTypes, atUserIds, fromTypes, pageSize = 20, pageToken } = {}) {
461
+ if (!query || typeof query !== 'string') {
462
+ throw new Error('searchMessages: query (search keyword string) is required');
463
+ }
464
+ if (!this.hasUAT) {
465
+ throw new Error('search_messages requires UAT (Feishu does not expose a bot-path search). Run: npx feishu-user-plugin oauth');
466
+ }
467
+ const body = { query };
468
+ if (pageSize) body.page_size = pageSize;
469
+ if (pageToken) body.page_token = pageToken;
470
+ if (Array.isArray(fromIds) && fromIds.length) body.from_ids = fromIds;
471
+ if (Array.isArray(chatIds) && chatIds.length) body.chat_ids = chatIds;
472
+ if (Array.isArray(messageTypes) && messageTypes.length) body.message_type_list = messageTypes;
473
+ if (Array.isArray(atUserIds) && atUserIds.length) body.at_chatter_ids = atUserIds;
474
+ if (Array.isArray(fromTypes) && fromTypes.length) body.from_types = fromTypes;
475
+ const data = await this._uatREST('POST', '/open-apis/search/v2/message', { body });
476
+ if (data.code === 99991679) {
477
+ throw new Error(`search_messages: UAT lacks the search:message scope. Re-run \`npx feishu-user-plugin oauth\` after the v1.3.12 SCOPES update.`);
478
+ }
479
+ if (data.code !== 0) {
480
+ throw new Error(`search_messages failed (code=${data.code}): ${data.msg}`);
481
+ }
482
+ return {
483
+ items: data.data?.items || [],
484
+ pageToken: data.data?.page_token || null,
485
+ hasMore: !!data.data?.has_more,
486
+ };
487
+ },
414
488
  };
@@ -57,7 +57,8 @@ module.exports = {
57
57
  },
58
58
 
59
59
  // --- OKR progress record write (v1.3.7) ---
60
- // Requires `okr:okr.content:write` (or wider okr:okr) on the OAuth.
60
+ // Requires `okr:okr.content:writeonly` (or wider okr:okr) on the OAuth.
61
+ // Note: Feishu uses `:writeonly` (one word), not `:write`.
61
62
 
62
63
  async createOkrProgressRecord({ targetId, targetType, content, sourceTitle, sourceUrl, sourceUrlPc, sourceUrlMobile, progressRate, userIdType = 'open_id' }) {
63
64
  if (!targetId) throw new Error('createOkrProgressRecord: target_id is required (the key_result_id or objective_id)');
@@ -26,6 +26,28 @@ const FAILURE_MAP = {
26
26
  // Chat does not exist (from the bot's POV — may still be accessible to user).
27
27
  19001: { action: 'uat', reason: 'bot_chat_not_found' },
28
28
 
29
+ // UAT revoked — refresh_token explicitly invalid_grant (user revoked OAuth
30
+ // or 30-day window elapsed). The live trigger for this code lives in
31
+ // identity-state.js::_classifyUatFailure (UAT REST throws / returns 20064);
32
+ // this entry exists for *symmetry* — should a bot-side surface ever return
33
+ // 20064 (it shouldn't, bot uses app_access_token not refresh_token), the
34
+ // fallback caller would route to UAT once and surface revocation.
35
+ 20064: { action: 'uat', reason: 'uat_revoked' },
36
+ // Cross-tenant bot block — bot lives in tenant A, target resource is in
37
+ // tenant B. Will never be granted. Distinct from 240001 (which is the
38
+ // older code form for the same shape); both surface in production.
39
+ 91403: { action: 'uat', reason: 'bot_cross_tenant' },
40
+
41
+ // Upload pipeline transient errors — the Feishu upload gateway is
42
+ // intermittently flaky; one retry after a moment usually clears.
43
+ // 1254000 / 1254001 are generic upload failures, 1254301 is multipart
44
+ // size mismatch (rare race when the body is being computed concurrently),
45
+ // 1254400 is "upload service busy" the gateway returns under load.
46
+ 1254000: { action: 'retry', reason: 'upload_transient' },
47
+ 1254001: { action: 'retry', reason: 'upload_transient' },
48
+ 1254301: { action: 'retry', reason: 'upload_transient' },
49
+ 1254400: { action: 'retry', reason: 'upload_transient' },
50
+
29
51
  // Rate limited — Feishu throttles, try once more after a brief pause.
30
52
  42101: { action: 'retry', reason: 'bot_rate_limited' },
31
53
  // Frequency control variants occasionally observed.
@@ -41,8 +63,21 @@ const TRANSIENT_PATTERNS = [
41
63
  /ETIMEDOUT/i,
42
64
  /fetch timeout after/i, // from utils.fetchWithTimeout
43
65
  /socket hang up/i,
66
+ /Unexpected end of JSON input/i,
67
+ /Unexpected token .* in JSON/i,
44
68
  ];
45
69
 
70
+ // Recognise res.json() parse failures so withUAT widens retry to them too.
71
+ // The Feishu gateway occasionally returns a truncated body that crashes
72
+ // JSON.parse — classifying the SyntaxError as transient lets the wrapper
73
+ // recover with a single retry instead of bubbling a cryptic parse error.
74
+ function _isJsonParseError(err) {
75
+ if (!err) return false;
76
+ if (err.name === 'SyntaxError') return true;
77
+ const msg = err.message || '';
78
+ return /Unexpected end of JSON input/i.test(msg) || /Unexpected token .* in JSON/i.test(msg);
79
+ }
80
+
46
81
  /**
47
82
  * Classify an error thrown by a bot-API path.
48
83
  * Input is either the Feishu code number (preferred) or the Error object —
@@ -65,6 +100,11 @@ function classifyError(errOrCode) {
65
100
  if (code != null && FAILURE_MAP[code]) {
66
101
  return { ...FAILURE_MAP[code], code };
67
102
  }
103
+ // res.json() parse failures get a dedicated reason so logs disambiguate
104
+ // them from generic network flakes (different remediation in monitoring).
105
+ if (errOrCode && typeof errOrCode === 'object' && _isJsonParseError(errOrCode)) {
106
+ return { action: 'retry', reason: 'response_parse_error', code };
107
+ }
68
108
  for (const re of TRANSIENT_PATTERNS) {
69
109
  if (re.test(msg)) return { action: 'retry', reason: 'bot_network_error', code };
70
110
  }
@@ -24,10 +24,36 @@ function _writeLockBody(fd, info) {
24
24
  fs.writeSync(fd, body);
25
25
  }
26
26
 
27
+ // Probe whether `pid` is a live process. Returns true when alive (or when we
28
+ // can't tell for certain; we prefer "alive" on EPERM because falsely
29
+ // declaring an EPERM'd process dead would race-steal a lock from another
30
+ // user's MCP server). Returns false only when ESRCH says "no such process".
31
+ function _isProcessAlive(pid) {
32
+ if (!Number.isFinite(pid) || pid <= 0) return true; // unknown → safe default
33
+ try {
34
+ process.kill(pid, 0);
35
+ return true;
36
+ } catch (e) {
37
+ if (e.code === 'ESRCH') return false;
38
+ // EPERM — process exists but we can't signal it. Treat as alive.
39
+ return true;
40
+ }
41
+ }
42
+
43
+ function _readPidFromLock(lockPath) {
44
+ try {
45
+ const body = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
46
+ if (body && typeof body.pid === 'number' && body.pid > 0) return body.pid;
47
+ } catch (_) { /* malformed or unreadable — caller falls back to mtime */ }
48
+ return null;
49
+ }
50
+
27
51
  // Long-lived (owner) acquisition.
28
52
  //
29
53
  // Returns { release(), heartbeat() } on success.
30
- // Returns null if lock active (mtime within staleMs).
54
+ // Returns null if lock active (mtime within staleMs AND pid still alive).
55
+ // v1.3.12: pid liveness check shortcircuits the 60s stale window when the
56
+ // holder process is definitively gone (SIGKILL'd, crashed, host reboot).
31
57
  function acquireLongLived(lockPath, { info = {}, staleMs = 60_000 } = {}) {
32
58
  _ensureDir(lockPath);
33
59
 
@@ -38,8 +64,18 @@ function acquireLongLived(lockPath, { info = {}, staleMs = 60_000 } = {}) {
38
64
  }
39
65
  if (stat) {
40
66
  const ageMs = Date.now() - stat.mtimeMs;
41
- if (ageMs < staleMs) return null;
42
- // Stale try to steal. Rename out of the way to make room for EXCL create.
67
+ const fresh = ageMs < staleMs;
68
+ let canSteal = !fresh;
69
+ if (fresh) {
70
+ // mtime suggests alive — verify by pid liveness. If the body has a pid
71
+ // and that pid is gone, the holder crashed and we can steal now.
72
+ const pid = _readPidFromLock(lockPath);
73
+ if (pid !== null && !_isProcessAlive(pid)) {
74
+ canSteal = true;
75
+ }
76
+ }
77
+ if (!canSteal) return null;
78
+ // Stealable — rename out of the way to make room for EXCL create.
43
79
  const stolenPath = lockPath + '.stale-' + process.pid + '-' + Date.now();
44
80
  try { fs.renameSync(lockPath, stolenPath); } catch (e) {
45
81
  // Race: someone else got there first; try again from scratch.
@@ -123,4 +159,4 @@ function withMutex(lockPath, fn, { staleMs = 30_000, retries = 30, retryDelayMs
123
159
  }
124
160
  }
125
161
 
126
- module.exports = { acquireLongLived, withMutex };
162
+ module.exports = { acquireLongLived, withMutex, _isProcessAlive };
@@ -7,7 +7,7 @@
7
7
 
8
8
  const path = require('path');
9
9
  const fs = require('fs');
10
- const { acquireLongLived } = require('./lockfile');
10
+ const { acquireLongLived, _isProcessAlive } = require('./lockfile');
11
11
 
12
12
  const OWNER_LOCK_FILENAME = 'ws-owner.lock';
13
13
  const HEARTBEAT_INTERVAL_MS = 15_000;
@@ -43,6 +43,12 @@ function tryClaim(dir, { info = {}, force = false } = {}) {
43
43
  }
44
44
 
45
45
  // Read current owner info without modifying anything.
46
+ //
47
+ // v1.3.12: `alive` is now the conjunction of mtime-fresh AND pid-alive. The
48
+ // non-owner poll in server.js calls readOwnerInfo every 30s; under the old
49
+ // definition a SIGKILL'd owner kept the lock looking alive until the 60s
50
+ // stale window elapsed. With the pid check, takeover happens on the next
51
+ // poll after the crash regardless of mtime.
46
52
  function readOwnerInfo(dir) {
47
53
  const lockPath = _ownerLockPath(dir);
48
54
  let body = null;
@@ -53,13 +59,16 @@ function readOwnerInfo(dir) {
53
59
  } catch (_) {}
54
60
  if (!body) return { exists: false };
55
61
  const ageSec = mtimeMs ? Math.floor((Date.now() - mtimeMs) / 1000) : null;
62
+ const mtimeFresh = ageSec !== null && ageSec * 1000 < STALE_MS;
63
+ const pidAlive = typeof body.pid === 'number' ? _isProcessAlive(body.pid) : true;
56
64
  return {
57
65
  exists: true,
58
66
  pid: body.pid,
59
67
  start_time: body.start_time,
60
68
  mtimeMs,
61
69
  last_heartbeat_age_seconds: ageSec,
62
- alive: ageSec !== null && ageSec * 1000 < STALE_MS,
70
+ pid_alive: pidAlive,
71
+ alive: mtimeFresh && pidAlive,
63
72
  };
64
73
  }
65
74
 
package/src/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- require('./logger'); // installs global stdout guard — MUST be first
2
+ require('./logger').installStdoutGuard(); // redirect any stray console.log → stderr — MUST be first (MCP stdio uses stdout)
3
3
  require('./server').main().catch((err) => {
4
4
  console.error('Fatal:', err);
5
5
  process.exit(1);