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.
- package/.claude-plugin/plugin.json +2 -2
- package/.cursor-plugin/plugin.json +2 -2
- package/.mcpb/manifest.json +3 -3
- package/CHANGELOG.md +159 -8
- package/README.en.md +130 -413
- package/README.md +69 -259
- package/package.json +2 -2
- package/scripts/check-description-drift.js +73 -0
- package/scripts/check-docs-sync.js +7 -16
- package/scripts/check-scopes.js +99 -0
- package/scripts/check-tool-count.js +4 -3
- package/scripts/sync-claude-md.sh +3 -4
- package/scripts/verify-app-name.js +64 -0
- package/skills/feishu-user-plugin/SKILL.md +3 -3
- package/skills/feishu-user-plugin/references/search.md +3 -3
- package/src/auth/credentials-monitor.js +185 -0
- package/src/auth/identity-state.js +209 -0
- package/src/auth/uat.js +49 -35
- package/src/cli.js +87 -0
- package/src/clients/official/base.js +170 -14
- package/src/clients/official/calendar.js +3 -1
- package/src/clients/official/im.js +76 -2
- package/src/clients/official/okr.js +2 -1
- package/src/error-codes.js +40 -0
- package/src/events/lockfile.js +40 -4
- package/src/events/owner.js +11 -2
- package/src/index.js +1 -1
- package/src/logger.js +11 -5
- package/src/oauth.js +65 -14
- package/src/server.js +76 -37
- package/src/test-all.js +41 -0
- package/src/test-cli-tool.js +87 -0
- package/src/test-credentials-monitor.js +124 -0
- package/src/test-display-label.js +88 -0
- package/src/test-error-codes.js +85 -0
- package/src/test-identity-state.js +177 -0
- package/src/test-lark-desktop.js +1 -0
- package/src/test-lockfile-pid.js +90 -0
- package/src/test-lru-cache.js +145 -0
- package/src/test-negative-cache.js +85 -0
- package/src/test-populate-sender-names.js +98 -0
- package/src/test-search-messages.js +101 -0
- package/src/test-send-shape.js +115 -0
- package/src/test-via-user.js +94 -0
- package/src/test-with-uat-retry.js +135 -0
- package/src/tools/_registry.js +24 -1
- package/src/tools/calendar.js +5 -5
- package/src/tools/im-read.js +52 -4
- package/src/tools/messaging-user.js +1 -1
- package/src/utils.js +83 -0
- 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
|
-
|
|
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
|
-
//
|
|
113
|
-
|
|
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 (
|
|
116
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
//
|
|
167
|
+
// Cookie fallback for any user still unknown
|
|
124
168
|
if (userClient) {
|
|
125
|
-
for (const id of
|
|
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
|
-
//
|
|
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
|
-
|
|
137
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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)');
|
package/src/error-codes.js
CHANGED
|
@@ -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
|
}
|
package/src/events/lockfile.js
CHANGED
|
@@ -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
|
-
|
|
42
|
-
|
|
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 };
|
package/src/events/owner.js
CHANGED
|
@@ -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
|
-
|
|
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'); //
|
|
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);
|