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
|
@@ -6,11 +6,12 @@ const { TOOLS } = require(path.join(__dirname, '..', 'src', 'server'));
|
|
|
6
6
|
|
|
7
7
|
const failures = [];
|
|
8
8
|
|
|
9
|
-
// Source 1: README.md "N tools"
|
|
9
|
+
// Source 1: README.md tool count — accepts "N tools" (English) or "N 工具" (Chinese)
|
|
10
|
+
// since README.md is Chinese-primary while README.en.md mirrors in English.
|
|
10
11
|
const readme = fs.readFileSync(path.join(__dirname, '..', 'README.md'), 'utf8');
|
|
11
|
-
const readmeMatch = readme.match(/(\d+)\s
|
|
12
|
+
const readmeMatch = readme.match(/(\d+)\s*(?:tools|工具)/);
|
|
12
13
|
if (!readmeMatch) {
|
|
13
|
-
failures.push('No "N tools"
|
|
14
|
+
failures.push('No "N tools" / "N 工具" marker in README.md');
|
|
14
15
|
} else if (parseInt(readmeMatch[1], 10) !== TOOLS.length) {
|
|
15
16
|
failures.push(`README.md claims ${readmeMatch[1]} tools, src/server.js has ${TOOLS.length}`);
|
|
16
17
|
}
|
|
@@ -4,9 +4,8 @@ ROOT="$(git rev-parse --show-toplevel)"
|
|
|
4
4
|
cd "$ROOT"
|
|
5
5
|
if git diff --cached --name-only | grep -qx "CLAUDE.md"; then
|
|
6
6
|
tail -n +2 CLAUDE.md > /tmp/feishu-claude-body.$$
|
|
7
|
-
{ echo "# feishu-user-plugin — Codex
|
|
7
|
+
{ echo "# feishu-user-plugin — Codex 指令"; cat /tmp/feishu-claude-body.$$; } > AGENTS.md
|
|
8
8
|
rm -f /tmp/feishu-claude-body.$$
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
echo "[hook] CLAUDE.md → AGENTS.md + skill reference synced"
|
|
9
|
+
git add AGENTS.md
|
|
10
|
+
echo "[hook] CLAUDE.md → AGENTS.md synced"
|
|
12
11
|
fi
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/verify-app-name.js — diagnostic: does the current app have the
|
|
3
|
+
// tenant-side `application:application:self_manage` scope?
|
|
4
|
+
//
|
|
5
|
+
// Hits Feishu's app-info endpoint with the current credentials' APP_ID/SECRET
|
|
6
|
+
// and prints either the resolved app name (scope is granted) or the error
|
|
7
|
+
// code (with remediation pointing at docs/AUTH-SETUP.md).
|
|
8
|
+
//
|
|
9
|
+
// Usage:
|
|
10
|
+
// node scripts/verify-app-name.js
|
|
11
|
+
//
|
|
12
|
+
// Exit codes:
|
|
13
|
+
// 0 scope works, displayLabel will say "[Bot] AppName"
|
|
14
|
+
// 1 99991672 — scope missing, displayLabel will fall back to "[Bot] (cli_xxx)"
|
|
15
|
+
// 2 other auth failure (wrong APP_ID/SECRET, network, etc.)
|
|
16
|
+
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
const { readCredentials } = require('../src/auth/credentials');
|
|
20
|
+
|
|
21
|
+
async function main() {
|
|
22
|
+
const creds = readCredentials() || {};
|
|
23
|
+
const appId = creds.LARK_APP_ID;
|
|
24
|
+
const appSecret = creds.LARK_APP_SECRET;
|
|
25
|
+
if (!appId || !appSecret) {
|
|
26
|
+
console.error('No LARK_APP_ID/SECRET in credentials. Run `npx feishu-user-plugin setup` first.');
|
|
27
|
+
process.exit(2);
|
|
28
|
+
}
|
|
29
|
+
console.error(`Probing app info for APP_ID=${appId}…`);
|
|
30
|
+
|
|
31
|
+
const tokenRes = await fetch('https://open.feishu.cn/open-apis/auth/v3/app_access_token/internal', {
|
|
32
|
+
method: 'POST',
|
|
33
|
+
headers: { 'content-type': 'application/json' },
|
|
34
|
+
body: JSON.stringify({ app_id: appId, app_secret: appSecret }),
|
|
35
|
+
});
|
|
36
|
+
const tokenData = await tokenRes.json();
|
|
37
|
+
if (!tokenData.app_access_token) {
|
|
38
|
+
console.error(`app_access_token request failed: ${JSON.stringify(tokenData)}`);
|
|
39
|
+
process.exit(2);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const infoRes = await fetch(`https://open.feishu.cn/open-apis/application/v6/applications/${appId}?lang=zh_cn`, {
|
|
43
|
+
headers: { 'Authorization': `Bearer ${tokenData.app_access_token}` },
|
|
44
|
+
});
|
|
45
|
+
const info = await infoRes.json();
|
|
46
|
+
|
|
47
|
+
if (info.code === 0 && info.data?.app?.app_name) {
|
|
48
|
+
console.error(`OK — app name resolves to "${info.data.app.app_name}". displayLabel will read "[Bot] ${info.data.app.app_name}".`);
|
|
49
|
+
process.exit(0);
|
|
50
|
+
}
|
|
51
|
+
if (info.code === 99991672) {
|
|
52
|
+
console.error('FAIL — code 99991672. The tenant-side scope `application:application:self_manage` is not granted.');
|
|
53
|
+
console.error('Fix:');
|
|
54
|
+
console.error(` 1. Open https://open.feishu.cn/app/${appId}/safe — "应用身份" tab`);
|
|
55
|
+
console.error(' 2. Add scope `application:application:self_manage` (marked 免审权限 — no admin review needed)');
|
|
56
|
+
console.error(' 3. Save; no re-publish required');
|
|
57
|
+
console.error(' 4. Re-run this script to confirm');
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
console.error(`FAIL — unexpected response: code=${info.code} msg=${info.msg || JSON.stringify(info)}`);
|
|
61
|
+
process.exit(2);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
main().catch((e) => { console.error(`Threw: ${e.message}`); process.exit(2); });
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: feishu-user-plugin
|
|
3
|
-
version: "1.3.
|
|
4
|
-
description: "All-in-one Feishu
|
|
5
|
-
allowed-tools: send_to_user, send_to_group, send_as_user, send_image_as_user, send_file_as_user, send_post_as_user, batch_send, send_card_as_user, search_contacts, create_p2p_chat, get_chat_info, get_user_info, get_login_status, list_profiles, switch_profile, manage_profile_hints, read_p2p_messages, list_user_chats, list_chats, read_messages, send_message_as_bot, reply_message, forward_message, delete_message, update_message, add_reaction, delete_reaction, pin_message, create_group, update_group, list_members, manage_members, search_docs, read_doc, get_doc_blocks, create_doc, manage_doc_block, read_doc_markdown, manage_bitable_app, manage_bitable_table, manage_bitable_field, manage_bitable_view, manage_bitable_record, upload_bitable_attachment, list_wiki_spaces, search_wiki, list_wiki_nodes, get_wiki_node, create_wiki_node, update_wiki_node, move_wiki_node, copy_wiki_node, delete_wiki_node, list_files, create_folder, upload_drive_file, manage_drive_file, upload_image, upload_file, download_message_resource, download_doc_image, list_user_okrs, get_okrs, list_okr_periods, create_okr_progress_record, list_okr_progress_records, delete_okr_progress_record, list_calendars, list_calendar_events, get_calendar_event, create_calendar_event, update_calendar_event, delete_calendar_event, respond_calendar_event, get_freebusy, list_tasks, get_task, create_task, update_task, complete_task, delete_task, manage_task_members, get_new_events, manage_ws_status
|
|
3
|
+
version: "1.3.13"
|
|
4
|
+
description: "All-in-one Feishu MCP server + CLI tool — send messages as yourself (incl. batch_send), read group/P2P chats (auto-expands merge_forward), manage docs/tables/wiki (full CRUD)/drive, OKR (with progress writes), calendar (read+write), Tasks v2, multi-profile auto-switch, real-time WS events. v1.3.13: search_messages tool (Protobuf phase 2, UAT-only), CLI tool mode (tool list / help / dispatch), IdentityState state machine + credentials hot-reload (UAT viaUser flag preserved across the 15+ write tools, no-restart UAT/cookie reload, startup-blind-window closed), displayLabel + sender semantics pack, WS owner PID liveness, gitleaks secret scan, oauth.js token-leak hardening, fixture unit tests pulled into CI gate."
|
|
5
|
+
allowed-tools: send_to_user, send_to_group, send_as_user, send_image_as_user, send_file_as_user, send_post_as_user, batch_send, send_card_as_user, search_contacts, create_p2p_chat, get_chat_info, get_user_info, get_login_status, list_profiles, switch_profile, manage_profile_hints, read_p2p_messages, list_user_chats, list_chats, read_messages, search_messages, send_message_as_bot, reply_message, forward_message, delete_message, update_message, add_reaction, delete_reaction, pin_message, create_group, update_group, list_members, manage_members, search_docs, read_doc, get_doc_blocks, create_doc, manage_doc_block, read_doc_markdown, manage_bitable_app, manage_bitable_table, manage_bitable_field, manage_bitable_view, manage_bitable_record, upload_bitable_attachment, list_wiki_spaces, search_wiki, list_wiki_nodes, get_wiki_node, create_wiki_node, update_wiki_node, move_wiki_node, copy_wiki_node, delete_wiki_node, list_files, create_folder, upload_drive_file, manage_drive_file, upload_image, upload_file, download_message_resource, download_doc_image, list_user_okrs, get_okrs, list_okr_periods, create_okr_progress_record, list_okr_progress_records, delete_okr_progress_record, list_calendars, list_calendar_events, get_calendar_event, create_calendar_event, update_calendar_event, delete_calendar_event, respond_calendar_event, get_freebusy, list_tasks, get_task, create_task, update_task, complete_task, delete_task, manage_task_members, get_new_events, manage_ws_status
|
|
6
6
|
user_invocable: true
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
- `/digest 群名` 整理聊天摘要
|
|
16
16
|
|
|
17
17
|
## 通过邮箱或手机号查找
|
|
18
|
-
|
|
18
|
+
邮箱、手机号、姓名都可以作为 `query` 直接传给 `search_contacts`,不需要单独的工具:
|
|
19
19
|
```
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
search_contacts({ query: "xxx@xxx.com" })
|
|
21
|
+
search_contacts({ query: "+86xxx" })
|
|
22
22
|
```
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// src/auth/credentials-monitor.js — single poller for credentials.json changes.
|
|
2
|
+
//
|
|
3
|
+
// The MCP server is long-lived; users routinely re-run `npx feishu-user-plugin
|
|
4
|
+
// oauth` from another shell to refresh UAT, expecting the running server to
|
|
5
|
+
// pick up the new token. Pre-v1.3.12 only the active-profile field was
|
|
6
|
+
// observed (server.js::_syncActiveProfileFromDisk); UAT changes, cookie
|
|
7
|
+
// rotations, and cache invalidation all required a Claude Code restart.
|
|
8
|
+
//
|
|
9
|
+
// CredentialsMonitor unifies the polling: per-tool-call `sync()` stats the
|
|
10
|
+
// file, hashes its contents, and fires per-field hooks on diff. Owners
|
|
11
|
+
// (officialClient, userClient, _userNameCache) register hooks at boot.
|
|
12
|
+
//
|
|
13
|
+
// Hash strategy: we don't trust mtime alone because `touch` would falsely
|
|
14
|
+
// trigger a UAT reload across every dispatcher call. Content hash + mtime
|
|
15
|
+
// means: skip the read entirely when mtime unchanged (cheap path), and
|
|
16
|
+
// when mtime advanced compute SHA-256 of the active profile's fields and
|
|
17
|
+
// compare per-field hashes to decide which hooks fire.
|
|
18
|
+
//
|
|
19
|
+
// Design choices:
|
|
20
|
+
// - factory not singleton: `createCredentialsMonitor({ path? })` so tests
|
|
21
|
+
// can use a tmpdir and server.js wires one against the real path.
|
|
22
|
+
// - synchronous sync(): callers are the request dispatcher; we can't await
|
|
23
|
+
// before handling a tool call. fs.statSync + fs.readFileSync are fine —
|
|
24
|
+
// credentials.json is tiny (a few KiB max).
|
|
25
|
+
// - hooks fire synchronously in registration order; exceptions are logged
|
|
26
|
+
// to stderr and don't block other hooks.
|
|
27
|
+
|
|
28
|
+
'use strict';
|
|
29
|
+
|
|
30
|
+
const fs = require('fs');
|
|
31
|
+
const os = require('os');
|
|
32
|
+
const path = require('path');
|
|
33
|
+
const crypto = require('crypto');
|
|
34
|
+
|
|
35
|
+
const DEFAULT_PATH = path.join(os.homedir(), '.feishu-user-plugin', 'credentials.json');
|
|
36
|
+
|
|
37
|
+
function _hash(s) {
|
|
38
|
+
return crypto.createHash('sha256').update(s, 'utf8').digest('hex').slice(0, 16);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function _readSafely(p) {
|
|
42
|
+
try { return fs.readFileSync(p, 'utf8'); } catch (_) { return null; }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function _statSafely(p) {
|
|
46
|
+
try { return fs.statSync(p); } catch (_) { return null; }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function _parseSafely(s) {
|
|
50
|
+
try {
|
|
51
|
+
const o = JSON.parse(s);
|
|
52
|
+
if (o && typeof o === 'object' && o.profiles && o.active) return o;
|
|
53
|
+
} catch (_) {}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function _activeProfileEnv(canonical) {
|
|
58
|
+
if (!canonical) return null;
|
|
59
|
+
const p = canonical.profiles[canonical.active];
|
|
60
|
+
return p ? { ...p } : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function _fieldHash(env, key) {
|
|
64
|
+
if (!env || env[key] === undefined || env[key] === null) return null;
|
|
65
|
+
return _hash(String(env[key]));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function createCredentialsMonitor({ path: credPath = DEFAULT_PATH } = {}) {
|
|
69
|
+
const hooks = {
|
|
70
|
+
uat: [],
|
|
71
|
+
cookie: [],
|
|
72
|
+
profile: [],
|
|
73
|
+
invalidate: [],
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// Baseline state: null until first successful sync().
|
|
77
|
+
let lastMtimeMs = null;
|
|
78
|
+
let lastActive = null;
|
|
79
|
+
let lastUatHash = null;
|
|
80
|
+
let lastCookieHash = null;
|
|
81
|
+
// refresh field is paired with UAT — refresh-only rotation also fires uat hook.
|
|
82
|
+
let lastRefreshHash = null;
|
|
83
|
+
// `_initialized` flips on first successful read. We DON'T reset on file
|
|
84
|
+
// disappearance — that way a user who briefly removes credentials.json
|
|
85
|
+
// (e.g. by hand-editing in vim with backup file shenanigans) doesn't see
|
|
86
|
+
// the next reappear treated as a silent rebaseline.
|
|
87
|
+
let _initialized = false;
|
|
88
|
+
|
|
89
|
+
function _fire(list, arg, label) {
|
|
90
|
+
for (const cb of list) {
|
|
91
|
+
try { cb(arg); } catch (e) {
|
|
92
|
+
console.error(`[feishu-user-plugin] credentials-monitor ${label} hook threw: ${e.message}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function sync() {
|
|
98
|
+
const stat = _statSafely(credPath);
|
|
99
|
+
if (!stat) {
|
|
100
|
+
// File missing — early return. We don't reset state because the file
|
|
101
|
+
// may reappear and we want to diff against what we last saw.
|
|
102
|
+
lastMtimeMs = null; // force re-read on reappear (mtime will differ)
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
// Cheap exit when mtime hasn't advanced.
|
|
106
|
+
if (lastMtimeMs !== null && stat.mtimeMs === lastMtimeMs) return;
|
|
107
|
+
|
|
108
|
+
const raw = _readSafely(credPath);
|
|
109
|
+
if (raw === null) { lastMtimeMs = stat.mtimeMs; return; }
|
|
110
|
+
const canonical = _parseSafely(raw);
|
|
111
|
+
if (!canonical) { lastMtimeMs = stat.mtimeMs; return; }
|
|
112
|
+
|
|
113
|
+
const env = _activeProfileEnv(canonical);
|
|
114
|
+
const active = canonical.active;
|
|
115
|
+
const uatHash = _fieldHash(env, 'LARK_USER_ACCESS_TOKEN');
|
|
116
|
+
const cookieHash = _fieldHash(env, 'LARK_COOKIE');
|
|
117
|
+
const refreshHash = _fieldHash(env, 'LARK_USER_REFRESH_TOKEN');
|
|
118
|
+
|
|
119
|
+
// First observation establishes baseline silently.
|
|
120
|
+
const baselining = !_initialized;
|
|
121
|
+
lastMtimeMs = stat.mtimeMs;
|
|
122
|
+
_initialized = true;
|
|
123
|
+
|
|
124
|
+
if (baselining) {
|
|
125
|
+
lastActive = active;
|
|
126
|
+
lastUatHash = uatHash;
|
|
127
|
+
lastCookieHash = cookieHash;
|
|
128
|
+
lastRefreshHash = refreshHash;
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let anyChange = false;
|
|
133
|
+
if (active !== lastActive) {
|
|
134
|
+
anyChange = true;
|
|
135
|
+
const prev = lastActive;
|
|
136
|
+
lastActive = active;
|
|
137
|
+
_fire(hooks.profile, { from: prev, to: active, env }, 'profile');
|
|
138
|
+
}
|
|
139
|
+
if (uatHash !== lastUatHash || refreshHash !== lastRefreshHash) {
|
|
140
|
+
anyChange = true;
|
|
141
|
+
lastUatHash = uatHash;
|
|
142
|
+
lastRefreshHash = refreshHash;
|
|
143
|
+
_fire(hooks.uat, env, 'uat');
|
|
144
|
+
}
|
|
145
|
+
if (cookieHash !== lastCookieHash) {
|
|
146
|
+
anyChange = true;
|
|
147
|
+
lastCookieHash = cookieHash;
|
|
148
|
+
_fire(hooks.cookie, env, 'cookie');
|
|
149
|
+
}
|
|
150
|
+
if (anyChange) {
|
|
151
|
+
_fire(hooks.invalidate, env, 'invalidate');
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Force-fire all hooks as if everything changed. Used by switch_profile
|
|
156
|
+
// when it wants to short-circuit the mtime debounce.
|
|
157
|
+
function forceInvalidate() {
|
|
158
|
+
const stat = _statSafely(credPath);
|
|
159
|
+
const raw = stat ? _readSafely(credPath) : null;
|
|
160
|
+
const canonical = raw ? _parseSafely(raw) : null;
|
|
161
|
+
const env = _activeProfileEnv(canonical) || {};
|
|
162
|
+
_fire(hooks.profile, { from: lastActive, to: canonical?.active, env }, 'profile');
|
|
163
|
+
_fire(hooks.uat, env, 'uat');
|
|
164
|
+
_fire(hooks.cookie, env, 'cookie');
|
|
165
|
+
_fire(hooks.invalidate, env, 'invalidate');
|
|
166
|
+
if (canonical) {
|
|
167
|
+
lastActive = canonical.active;
|
|
168
|
+
lastUatHash = _fieldHash(env, 'LARK_USER_ACCESS_TOKEN');
|
|
169
|
+
lastCookieHash = _fieldHash(env, 'LARK_COOKIE');
|
|
170
|
+
lastRefreshHash = _fieldHash(env, 'LARK_USER_REFRESH_TOKEN');
|
|
171
|
+
lastMtimeMs = stat.mtimeMs;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
sync,
|
|
177
|
+
forceInvalidate,
|
|
178
|
+
onUatChange: (cb) => { hooks.uat.push(cb); },
|
|
179
|
+
onCookieChange: (cb) => { hooks.cookie.push(cb); },
|
|
180
|
+
onProfileSwitch: (cb) => { hooks.profile.push(cb); },
|
|
181
|
+
onCacheInvalidate: (cb) => { hooks.invalidate.push(cb); },
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
module.exports = { createCredentialsMonitor };
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// src/auth/identity-state.js — identity state machine for UAT-first/bot-fallback flows.
|
|
2
|
+
//
|
|
3
|
+
// The v1.3.7 pattern was `asUserOrApp` in src/auth/uat.js: try UAT, catch any
|
|
4
|
+
// failure, retry with bot, and attach a string `_fallbackWarning`. That works
|
|
5
|
+
// but is opaque: the caller can't tell whether UAT was revoked (need to
|
|
6
|
+
// re-oauth), expired (will refresh on next call), missing scope (need admin),
|
|
7
|
+
// or just experienced a network blip. The 2026-05 incident showed how this
|
|
8
|
+
// hides "UAT revoked for weeks" because every tool silently retried bot.
|
|
9
|
+
//
|
|
10
|
+
// This module adds a first-class IdentityState enum + a reactive cache. Each
|
|
11
|
+
// call to withIdentityFallback returns `{ data, via, viaReason, identity }`;
|
|
12
|
+
// LLMs and the get_login_status diagnostic can read the identity directly
|
|
13
|
+
// instead of grepping warning strings.
|
|
14
|
+
//
|
|
15
|
+
// Cache: keyed by appId (one entry per LarkOfficialClient instance is enough
|
|
16
|
+
// in practice — clients aren't shared across appIds in this codebase, but
|
|
17
|
+
// keying by appId is the safe contract). 30 second TTL. Refined writes from
|
|
18
|
+
// withIdentityFallback bypass TTL and write through immediately.
|
|
19
|
+
//
|
|
20
|
+
// What it owns:
|
|
21
|
+
// - IdentityState enum
|
|
22
|
+
// - resolveIdentity(client) — reads in-memory state into a state value
|
|
23
|
+
// - withIdentityFallback({client, uatFn, botFn, label}) — composable wrapper
|
|
24
|
+
// - invalidateIdentity(client) — cache eviction (CredentialsMonitor hook)
|
|
25
|
+
|
|
26
|
+
'use strict';
|
|
27
|
+
|
|
28
|
+
const IdentityState = Object.freeze({
|
|
29
|
+
VALID_USER: 'VALID_USER',
|
|
30
|
+
UAT_EXPIRED: 'UAT_EXPIRED',
|
|
31
|
+
UAT_REVOKED: 'UAT_REVOKED',
|
|
32
|
+
UAT_MISSING_SCOPE: 'UAT_MISSING_SCOPE',
|
|
33
|
+
BOT_ONLY: 'BOT_ONLY',
|
|
34
|
+
NO_CREDENTIALS: 'NO_CREDENTIALS',
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const CACHE_TTL_MS = 30_000;
|
|
38
|
+
const _cache = new Map(); // key = client.appId — value = { state, expiresAt }
|
|
39
|
+
|
|
40
|
+
function _key(client) {
|
|
41
|
+
// Fall back to a sentinel for clients without appId so NO_CREDENTIALS is still cacheable.
|
|
42
|
+
return client?.appId || '__no_app__';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function _readInMemoryState(client) {
|
|
46
|
+
if (!client) return IdentityState.NO_CREDENTIALS;
|
|
47
|
+
const hasApp = !!client.appId;
|
|
48
|
+
const hasUAT = !!client.hasUAT;
|
|
49
|
+
if (!hasUAT && !hasApp) return IdentityState.NO_CREDENTIALS;
|
|
50
|
+
if (!hasUAT) return IdentityState.BOT_ONLY;
|
|
51
|
+
// hasUAT true — distinguish VALID_USER vs UAT_EXPIRED by expiry timestamp.
|
|
52
|
+
// expires=0 means "we never decoded — treat as valid and let the refresh
|
|
53
|
+
// path decide". Negative expiry never occurs but is treated as expired.
|
|
54
|
+
const now = Math.floor(Date.now() / 1000);
|
|
55
|
+
if (client._uatExpires && client._uatExpires > 0 && client._uatExpires <= now) {
|
|
56
|
+
return IdentityState.UAT_EXPIRED;
|
|
57
|
+
}
|
|
58
|
+
return IdentityState.VALID_USER;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function resolveIdentity(client) {
|
|
62
|
+
const k = _key(client);
|
|
63
|
+
const cached = _cache.get(k);
|
|
64
|
+
if (cached && cached.expiresAt > Date.now()) return cached.state;
|
|
65
|
+
const state = _readInMemoryState(client);
|
|
66
|
+
_cache.set(k, { state, expiresAt: Date.now() + CACHE_TTL_MS });
|
|
67
|
+
return state;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function invalidateIdentity(client) {
|
|
71
|
+
_cache.delete(_key(client));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Write a refined state through to the cache (bypasses TTL). Called when
|
|
75
|
+
// withIdentityFallback observes a definitive UAT failure (revoked, missing
|
|
76
|
+
// scope) — the cache should reflect what we just learned, not what was
|
|
77
|
+
// inferred from in-memory state alone.
|
|
78
|
+
function _refineIdentity(client, state) {
|
|
79
|
+
_cache.set(_key(client), { state, expiresAt: Date.now() + CACHE_TTL_MS });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Classify a UAT-side failure into a refined IdentityState + a human-readable
|
|
83
|
+
// via_reason string. Returns null if the failure isn't auth-related (caller
|
|
84
|
+
// should keep the original VALID_USER state and just record the via_reason).
|
|
85
|
+
function _classifyUatFailure(uatResp, uatError) {
|
|
86
|
+
if (uatError) {
|
|
87
|
+
const msg = uatError.message || String(uatError);
|
|
88
|
+
// Network/JSON parse errors don't refine identity — UAT is still presumed
|
|
89
|
+
// valid, we just couldn't reach Feishu this call.
|
|
90
|
+
return { state: null, viaReason: `as user: ${msg}` };
|
|
91
|
+
}
|
|
92
|
+
if (!uatResp || typeof uatResp !== 'object') return null;
|
|
93
|
+
const code = uatResp.code;
|
|
94
|
+
const detail = `as user: code=${code} msg=${uatResp.msg}`;
|
|
95
|
+
switch (code) {
|
|
96
|
+
case 20064: return { state: IdentityState.UAT_REVOKED, viaReason: detail };
|
|
97
|
+
case 99991663: return { state: IdentityState.UAT_EXPIRED, viaReason: detail };
|
|
98
|
+
case 99991668: return { state: IdentityState.UAT_MISSING_SCOPE, viaReason: detail };
|
|
99
|
+
case 99991677: return { state: IdentityState.UAT_EXPIRED, viaReason: detail };
|
|
100
|
+
default: return { state: null, viaReason: detail };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function _buildFallbackWarning({ identity, viaReason, hadUAT }) {
|
|
105
|
+
if (!hadUAT) {
|
|
106
|
+
// BOT_ONLY — the caller never configured UAT. Strictly speaking this
|
|
107
|
+
// isn't a "fallback" (no UAT attempt was made), but we keep the
|
|
108
|
+
// informational warning because users frequently *think* they configured
|
|
109
|
+
// UAT and are surprised to find resources owned by the shared bot. Same
|
|
110
|
+
// wording as the legacy asUserOrApp path so existing get_login_status
|
|
111
|
+
// expectations don't shift.
|
|
112
|
+
return `⚠️ 未配置 UAT,本次操作以 bot 身份执行。资源归属于共享 bot「Claude聊天助手」,不是你。想让资源归你所有,先跑 \`npx feishu-user-plugin oauth\` 然后重启 Claude Code / Codex。`;
|
|
113
|
+
}
|
|
114
|
+
let hint;
|
|
115
|
+
switch (identity) {
|
|
116
|
+
case IdentityState.UAT_REVOKED:
|
|
117
|
+
hint = 'UAT 已被撤销 (invalid_grant)';
|
|
118
|
+
break;
|
|
119
|
+
case IdentityState.UAT_MISSING_SCOPE:
|
|
120
|
+
hint = 'UAT 缺少所需 scope';
|
|
121
|
+
break;
|
|
122
|
+
case IdentityState.UAT_EXPIRED:
|
|
123
|
+
hint = 'UAT 已过期';
|
|
124
|
+
break;
|
|
125
|
+
default:
|
|
126
|
+
hint = 'UAT 不可用';
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
return `⚠️ ${hint} (${viaReason}),本次操作以 bot 身份执行。资源归属于共享 bot「Claude聊天助手」,不是你。恢复方法:运行 \`npx feishu-user-plugin oauth\` 后重启 Claude Code / Codex。`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// withIdentityFallback({ client, uatFn, botFn, label })
|
|
133
|
+
//
|
|
134
|
+
// Tries uatFn first (when client has UAT), classifies any failure, then runs
|
|
135
|
+
// botFn. Returns `{ data, via, viaReason?, identity, fallbackWarning? }`.
|
|
136
|
+
// Throws an Error with `.uatSummary` and `.botError` when both sides fail.
|
|
137
|
+
async function withIdentityFallback({ client, uatFn, botFn, label }) {
|
|
138
|
+
if (!label) throw new Error('withIdentityFallback: label is required (for error messages)');
|
|
139
|
+
|
|
140
|
+
let identity = await resolveIdentity(client);
|
|
141
|
+
const hadUAT = !!client?.hasUAT;
|
|
142
|
+
let uatSummary = null;
|
|
143
|
+
|
|
144
|
+
if (hadUAT) {
|
|
145
|
+
let uatResp = null;
|
|
146
|
+
let uatErr = null;
|
|
147
|
+
try {
|
|
148
|
+
uatResp = await uatFn();
|
|
149
|
+
} catch (e) {
|
|
150
|
+
uatErr = e;
|
|
151
|
+
}
|
|
152
|
+
if (uatResp && uatResp.code === 0) {
|
|
153
|
+
// Preserve the legacy _viaUser marker that 15+ _asUserOrApp callers read
|
|
154
|
+
// via `res._viaUser`. Without this flag, calendar/docs/bitable/wiki/okr/
|
|
155
|
+
// tasks/drive write tools labelled UAT-owned resources as viaUser:false,
|
|
156
|
+
// making users believe a bot created them. Caught by Codex review on
|
|
157
|
+
// PR #103 (P1 — set _viaUser on successful UAT results).
|
|
158
|
+
const data = { ...uatResp, _viaUser: true };
|
|
159
|
+
return { data, via: 'uat', identity };
|
|
160
|
+
}
|
|
161
|
+
const cls = _classifyUatFailure(uatResp, uatErr);
|
|
162
|
+
uatSummary = cls?.viaReason || `as user: unknown failure`;
|
|
163
|
+
if (cls?.state) {
|
|
164
|
+
identity = cls.state;
|
|
165
|
+
_refineIdentity(client, cls.state);
|
|
166
|
+
}
|
|
167
|
+
// fall through to bot
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Bot path
|
|
171
|
+
let botData;
|
|
172
|
+
let botError;
|
|
173
|
+
try {
|
|
174
|
+
botData = await botFn();
|
|
175
|
+
} catch (e) {
|
|
176
|
+
botError = e;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (botError) {
|
|
180
|
+
if (uatSummary) {
|
|
181
|
+
const err = new Error(`${label} failed on both identities. ${uatSummary}. as app: ${botError.message}`);
|
|
182
|
+
err.uatSummary = uatSummary;
|
|
183
|
+
err.botError = botError;
|
|
184
|
+
err.identity = identity;
|
|
185
|
+
throw err;
|
|
186
|
+
}
|
|
187
|
+
throw botError;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Decorate the bot response with via metadata + (when UAT was attempted) a
|
|
191
|
+
// fallback warning the caller can surface to the LLM.
|
|
192
|
+
const data = { ...botData };
|
|
193
|
+
data._viaUser = false;
|
|
194
|
+
const fallbackWarning = _buildFallbackWarning({ identity, viaReason: uatSummary, hadUAT });
|
|
195
|
+
if (fallbackWarning) data._fallbackWarning = fallbackWarning;
|
|
196
|
+
const out = { data, via: 'bot', identity };
|
|
197
|
+
if (uatSummary) out.viaReason = uatSummary;
|
|
198
|
+
if (fallbackWarning) out.fallbackWarning = fallbackWarning;
|
|
199
|
+
return out;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
module.exports = {
|
|
203
|
+
IdentityState,
|
|
204
|
+
resolveIdentity,
|
|
205
|
+
withIdentityFallback,
|
|
206
|
+
invalidateIdentity,
|
|
207
|
+
_refineIdentity, // exported for D's CredentialsMonitor hook (private API)
|
|
208
|
+
_readInMemoryState, // exported for testing edge cases (private API)
|
|
209
|
+
};
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
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
|
|