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
package/src/auth/uat.js CHANGED
@@ -9,9 +9,16 @@
9
9
  // - decodeTokenExpiry(token) — JWT exp parsing
10
10
  // - getValidUAT(client) — returns current UAT, refreshes if expiring
11
11
  // - refreshUAT(client) — full refresh dance with file lock + persist
12
- // - withUAT(client, fn) — wrapper that retries fn once on auth-error codes
12
+ // - withUAT(client, fn) — wrapper that retries fn once on auth codes + on
13
+ // transient throws (classifyError action='retry'); v1.3.12 widening
13
14
  // - uatREST(client, method, path, opts) — generic UAT REST helper
14
- // - asUserOrApp(client, opts) — UAT-first, bot-fallback wrapper
15
+ // - asUserOrApp(client, opts) — legacy UAT-first / bot-fallback signature.
16
+ // v1.3.12: the body is now a thin shape adapter around
17
+ // withIdentityFallback (src/auth/identity-state.js). The public contract
18
+ // — return data with _viaUser ∈ {true,false} + optional _fallbackWarning,
19
+ // throw Error with .uatSummary + .appError on dual failure — is
20
+ // preserved so 15+ existing callsites in calendar/docs/bitable/wiki/okr/
21
+ // tasks/drive/im keep compiling.
15
22
  // - persistUAT(client) — writes through auth/credentials
16
23
  // - adoptPersistedUATIfNewer(client) — peer-rotation adoption
17
24
  // - acquireRefreshLock / releaseRefreshLock — cross-process advisory lock
@@ -144,8 +151,24 @@ function persistUAT(client) {
144
151
  }
145
152
 
146
153
  async function withUAT(client, fn) {
154
+ const { classifyError } = require('../error-codes');
147
155
  let uat = await getValidUAT(client);
148
- const data = await fn(uat);
156
+
157
+ // First attempt. If fn() throws an upstream flake (network reset, response
158
+ // body truncated mid-JSON, gateway 5xx), classifyError says action='retry'
159
+ // and we re-run once with the same UAT — the token is still valid, the
160
+ // call just lost. Auth-related codes are the existing refresh path below.
161
+ let data;
162
+ try {
163
+ data = await fn(uat);
164
+ } catch (err) {
165
+ const cls = classifyError(err);
166
+ if (cls.action === 'retry') {
167
+ return fn(uat);
168
+ }
169
+ throw err;
170
+ }
171
+
149
172
  if (data.code === 99991668 || data.code === 99991663 || data.code === 99991677) {
150
173
  if (data.code === 99991668 && typeof data.msg === 'string' && /not support/i.test(data.msg)) {
151
174
  return data;
@@ -182,40 +205,31 @@ async function uatREST(client, method, urlPath, { body, query } = {}) {
182
205
  }
183
206
 
184
207
  async function asUserOrApp(client, { uatPath, method = 'GET', body, query, sdkFn, label }) {
185
- let uatSummary = null;
186
- if (client.hasUAT) {
187
- try {
188
- const data = await uatREST(client, method, uatPath, { body, query });
189
- if (data.code === 0) {
190
- data._viaUser = true;
191
- return data;
192
- }
193
- uatSummary = `as user: code=${data.code} msg=${data.msg}`;
194
- console.error(`[feishu-user-plugin] ${label} ${uatSummary}, retrying as app`);
195
- } catch (err) {
196
- uatSummary = `as user: ${err.message}`;
197
- console.error(`[feishu-user-plugin] ${label} as user threw (${err.message}), retrying as app`);
198
- }
199
- }
208
+ // v1.3.12: internal implementation routes through withIdentityFallback in
209
+ // src/auth/identity-state.js. The public shape — return data with _viaUser
210
+ // and optional _fallbackWarning, throw Error with .uatSummary + .appError
211
+ // when both sides fail is preserved for 15+ existing callsites.
212
+ const { withIdentityFallback } = require('./identity-state');
200
213
  try {
201
- const appData = await client._safeSDKCall(sdkFn, label);
202
- if (appData && typeof appData === 'object') {
203
- appData._viaUser = false;
204
- if (uatSummary) {
205
- appData._fallbackWarning = `⚠️ UAT 不可用 (${uatSummary}),本次操作以 bot 身份执行。资源归属于共享 bot「Claude聊天助手」,不是你。恢复方法:运行 \`npx feishu-user-plugin oauth\` 后重启 Claude Code / Codex。`;
206
- } else if (!client.hasUAT) {
207
- appData._fallbackWarning = `⚠️ 未配置 UAT,本次操作以 bot 身份执行。资源归属于共享 bot「Claude聊天助手」,不是你。想让资源归你所有,先跑 \`npx feishu-user-plugin oauth\` 然后重启 Claude Code / Codex。`;
208
- }
209
- }
210
- return appData;
211
- } catch (appErr) {
212
- if (uatSummary) {
213
- const err = new Error(`${label} failed on both identities. ${uatSummary}. as app: ${appErr.message}`);
214
- err.uatSummary = uatSummary;
215
- err.appError = appErr;
216
- throw err;
214
+ const result = await withIdentityFallback({
215
+ client,
216
+ uatFn: () => uatREST(client, method, uatPath, { body, query }),
217
+ botFn: () => client._safeSDKCall(sdkFn, label),
218
+ label,
219
+ });
220
+ // Surface state machine breadcrumbs in stderr so long-running servers can
221
+ // be diagnosed without grepping the LLM transcript. (Pre-v1.3.12 already
222
+ // logged on fallback; we keep parity but only when fallback actually
223
+ // happened, not on every BOT_ONLY call.)
224
+ if (result.via === 'bot' && result.viaReason) {
225
+ console.error(`[feishu-user-plugin] ${label} fell back to bot (${result.identity}): ${result.viaReason}`);
217
226
  }
218
- throw appErr;
227
+ return result.data;
228
+ } catch (e) {
229
+ // Legacy callers expect err.appError — keep the alias alongside the new
230
+ // err.botError that withIdentityFallback sets.
231
+ if (e && e.botError && !e.appError) e.appError = e.botError;
232
+ throw e;
219
233
  }
220
234
  }
221
235
 
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
+ }
122
146
  }
123
- // Fallback: resolve remaining unknowns via cookie-based user identity client
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
+ }
166
+ }
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,20 +175,101 @@ 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
+ for (const id of unknownUserIds) {
203
+ if (!this._userNameCache.has(id)) this._userNameCache.set(id, null);
204
+ }
205
+ for (const id of unknownAppIds) {
206
+ if (!this._appNameCache.has(id)) this._appNameCache.set(id, null);
207
+ }
208
+
209
+ // Step 4: populate senderName, isExternal, displayLabel
135
210
  for (const item of items) {
136
- if (item.senderId) {
137
- item.senderName = this._userNameCache.get(item.senderId) || null;
211
+ item.senderName = item.senderId ? (this._userNameCache.get(item.senderId) || null) : null;
212
+ if (this._selfTenantKey && item.senderTenantKey && item.senderTenantKey !== this._selfTenantKey) {
213
+ item.isExternal = true;
138
214
  }
215
+ item.displayLabel = this._computeDisplayLabel(item);
139
216
  }
140
217
  }
141
218
 
219
+ _computeDisplayLabel(item) {
220
+ const prefix = item.isRecalled ? '[已撤回] ' : '';
221
+ if (!item.senderId) return prefix + '[系统]';
222
+ if (item.senderType === 'anonymous') return prefix + '[匿名]';
223
+ if (item.senderType === 'app') {
224
+ const appName = this._appNameCache.get(item.senderId);
225
+ return prefix + `[Bot] ${appName || `(${item.senderId})`}`;
226
+ }
227
+ return prefix + (item.senderName || `(${item.senderId})`);
228
+ }
229
+
230
+ async getAppName(appId) {
231
+ if (this._appNameCache.has(appId)) return this._appNameCache.get(appId);
232
+ try {
233
+ const token = await this._getAppToken();
234
+ const res = await fetchWithTimeout(
235
+ `https://open.feishu.cn/open-apis/application/v6/applications/${encodeURIComponent(appId)}?lang=zh_cn`,
236
+ { headers: { 'Authorization': `Bearer ${token}` }, timeoutMs: 10000 },
237
+ );
238
+ const data = await res.json();
239
+ const name = data?.data?.app?.app_name;
240
+ if (name) {
241
+ this._appNameCache.set(appId, name);
242
+ return name;
243
+ }
244
+ } catch {
245
+ // best-effort; null means "couldn't resolve, fall back to id"
246
+ }
247
+ return null;
248
+ }
249
+
250
+ async _resolveSelfTenantKey() {
251
+ if (this._selfTenantKey) return this._selfTenantKey;
252
+ const res = await fetchWithTimeout('https://open.feishu.cn/open-apis/auth/v3/app_access_token/internal', {
253
+ method: 'POST',
254
+ headers: { 'content-type': 'application/json' },
255
+ body: JSON.stringify({ app_id: this.appId, app_secret: this.appSecret }),
256
+ timeoutMs: 10000,
257
+ });
258
+ const data = await res.json();
259
+ if (data?.tenant_key) this._selfTenantKey = data.tenant_key;
260
+ return this._selfTenantKey;
261
+ }
262
+
142
263
  // --- Helpers ---
143
264
 
144
265
  _formatMessage(m) {
145
266
  if (!m) return null;
146
267
  let body = m.body?.content || '';
147
268
  try { body = JSON.parse(body); } catch {}
269
+ // Feishu signals recall by replacing body content with the literal string
270
+ // "This message was recalled" (not a JSON object). Surface as boolean so
271
+ // the LLM doesn't have to pattern-match the string.
272
+ const isRecalled = (typeof body === 'string' && /recalled/i.test(body));
148
273
  const out = {
149
274
  messageId: m.message_id,
150
275
  chatId: m.chat_id,
@@ -155,6 +280,12 @@ class LarkOfficialClient {
155
280
  createTime: this._normalizeTimestamp(m.create_time),
156
281
  updateTime: this._normalizeTimestamp(m.update_time),
157
282
  };
283
+ // Raw sender metadata — LLM may need id_type (to pick the right
284
+ // user_id_type for contact API) or tenant_key (to know cross-tenant).
285
+ if (m.sender?.id_type) out.senderIdType = m.sender.id_type;
286
+ if (m.sender?.tenant_key) out.senderTenantKey = m.sender.tenant_key;
287
+ if (isRecalled) out.isRecalled = true;
288
+ if (m.parent_id) out.isThreadReply = true;
158
289
  if (Array.isArray(m.mentions) && m.mentions.length > 0) out.mentions = m.mentions;
159
290
  if (m.upper_message_id) out.upperMessageId = m.upper_message_id;
160
291
  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
  }