feishu-user-plugin 1.3.6 → 1.3.7
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/CHANGELOG.md +22 -0
- package/README.md +55 -40
- package/package.json +10 -3
- package/scripts/check-tool-count.js +15 -0
- package/scripts/check-version.js +40 -0
- package/scripts/smoke.js +224 -0
- package/scripts/sync-claude-md.sh +12 -0
- package/scripts/sync-team-skills.sh +22 -0
- package/scripts/test-all-tools.js +158 -0
- package/skills/feishu-user-plugin/SKILL.md +5 -5
- package/skills/feishu-user-plugin/references/CLAUDE.md +138 -99
- package/skills/feishu-user-plugin/references/table.md +18 -9
- package/src/auth/credentials.js +350 -0
- package/src/cli.js +42 -13
- package/src/clients/official/base.js +424 -0
- package/src/clients/official/bitable.js +269 -0
- package/src/clients/official/calendar.js +176 -0
- package/src/clients/official/contacts.js +54 -0
- package/src/clients/official/docs.js +301 -0
- package/src/clients/official/drive.js +77 -0
- package/src/clients/official/groups.js +68 -0
- package/src/clients/official/im.js +414 -0
- package/src/clients/official/index.js +30 -0
- package/src/clients/official/okr.js +127 -0
- package/src/clients/official/tasks.js +142 -0
- package/src/clients/official/uploads.js +260 -0
- package/src/clients/official/wiki.js +207 -0
- package/src/{client.js → clients/user.js} +23 -17
- package/src/index.js +4 -1977
- package/src/logger.js +20 -0
- package/src/oauth.js +5 -1
- package/src/official.js +5 -1944
- package/src/prompts/_registry.js +69 -0
- package/src/prompts/index.js +54 -0
- package/src/server.js +242 -0
- package/src/test-all.js +2 -2
- package/src/test-comprehensive.js +3 -3
- package/src/test-send.js +1 -1
- package/src/tools/_registry.js +30 -0
- package/src/tools/bitable.js +246 -0
- package/src/tools/calendar.js +207 -0
- package/src/tools/contacts.js +66 -0
- package/src/tools/diagnostics.js +172 -0
- package/src/tools/docs.js +158 -0
- package/src/tools/drive.js +111 -0
- package/src/tools/groups.js +81 -0
- package/src/tools/im-read.js +259 -0
- package/src/tools/messaging-bot.js +151 -0
- package/src/tools/messaging-user.js +292 -0
- package/src/tools/okr.js +159 -0
- package/src/tools/profile.js +43 -0
- package/src/tools/tasks.js +168 -0
- package/src/tools/uploads.js +63 -0
- package/src/tools/wiki.js +191 -0
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
const lark = require('@larksuiteoapi/node-sdk');
|
|
2
|
+
const { fetchWithTimeout } = require('../../utils');
|
|
3
|
+
const { stderrLogger } = require('../../logger');
|
|
4
|
+
|
|
5
|
+
class LarkOfficialClient {
|
|
6
|
+
constructor(appId, appSecret) {
|
|
7
|
+
this.appId = appId;
|
|
8
|
+
this.appSecret = appSecret;
|
|
9
|
+
this.client = new lark.Client({ appId, appSecret, disableTokenCache: false, logger: stderrLogger, loggerLevel: lark.LoggerLevel.warn });
|
|
10
|
+
this._uat = null;
|
|
11
|
+
this._uatRefresh = null;
|
|
12
|
+
this._uatExpires = 0;
|
|
13
|
+
this._userNameCache = new Map(); // open_id → display name
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// --- UAT (User Access Token) Management ---
|
|
17
|
+
|
|
18
|
+
loadUAT() {
|
|
19
|
+
const token = process.env.LARK_USER_ACCESS_TOKEN;
|
|
20
|
+
const refresh = process.env.LARK_USER_REFRESH_TOKEN;
|
|
21
|
+
const expires = parseInt(process.env.LARK_UAT_EXPIRES || '0');
|
|
22
|
+
if (token) {
|
|
23
|
+
this._uat = token;
|
|
24
|
+
this._uatRefresh = refresh || null;
|
|
25
|
+
this._uatExpires = expires || this._decodeTokenExpiry(token);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
get hasUAT() {
|
|
30
|
+
return !!this._uat;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Fetches (and caches) an app_access_token directly via the internal endpoint.
|
|
34
|
+
// Avoids relying on SDK-internal token-manager APIs that may change across versions.
|
|
35
|
+
async _getAppToken() {
|
|
36
|
+
const now = Math.floor(Date.now() / 1000);
|
|
37
|
+
if (this._appToken && this._appTokenExpires > now + 60) return this._appToken;
|
|
38
|
+
const res = await fetchWithTimeout('https://open.feishu.cn/open-apis/auth/v3/app_access_token/internal', {
|
|
39
|
+
method: 'POST',
|
|
40
|
+
headers: { 'content-type': 'application/json' },
|
|
41
|
+
body: JSON.stringify({ app_id: this.appId, app_secret: this.appSecret }),
|
|
42
|
+
timeoutMs: 10000,
|
|
43
|
+
});
|
|
44
|
+
const data = await res.json();
|
|
45
|
+
if (data.code !== 0 || !data.app_access_token) {
|
|
46
|
+
throw new Error(`app_access_token failed: ${data.code}: ${data.msg || 'unknown'}`);
|
|
47
|
+
}
|
|
48
|
+
this._appToken = data.app_access_token;
|
|
49
|
+
this._appTokenExpires = now + (typeof data.expire === 'number' ? data.expire : 7200);
|
|
50
|
+
return this._appToken;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Probe APP_ID/SECRET validity by requesting a tenant access token.
|
|
54
|
+
// Catches the common "user's Claude filled in a wrong/stale APP_ID" failure mode
|
|
55
|
+
// (observed in production: 周宇's machine ran with an APP_ID nobody recognized,
|
|
56
|
+
// causing all Official API calls to 401 with cryptic messages that looked like
|
|
57
|
+
// MCP "掉线" to the user). Returns { valid, appId, appName?, error? }.
|
|
58
|
+
async verifyApp() {
|
|
59
|
+
try {
|
|
60
|
+
const token = await this._getAppToken();
|
|
61
|
+
// Try to fetch app display name (best-effort; requires application scope)
|
|
62
|
+
let appName = null;
|
|
63
|
+
try {
|
|
64
|
+
const infoRes = await fetchWithTimeout(`https://open.feishu.cn/open-apis/application/v6/applications/${this.appId}?lang=zh_cn`, {
|
|
65
|
+
headers: { 'Authorization': `Bearer ${token}` },
|
|
66
|
+
timeoutMs: 10000,
|
|
67
|
+
});
|
|
68
|
+
const info = await infoRes.json();
|
|
69
|
+
if (info.code === 0) appName = info.data?.app?.app_name || null;
|
|
70
|
+
} catch (_) { /* name is best-effort; valid creds still matter most */ }
|
|
71
|
+
return { valid: true, appId: this.appId, appName };
|
|
72
|
+
} catch (e) {
|
|
73
|
+
return { valid: false, appId: this.appId, error: e.message };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async _getValidUAT() {
|
|
78
|
+
if (!this._uat) throw new Error('No user_access_token. Run: npx feishu-user-plugin oauth');
|
|
79
|
+
|
|
80
|
+
const now = Math.floor(Date.now() / 1000);
|
|
81
|
+
if (!this._uatExpires) this._uatExpires = this._decodeTokenExpiry(this._uat);
|
|
82
|
+
// Proactively refresh if we know it's expiring within 5 min
|
|
83
|
+
if (this._uatExpires > 0 && this._uatExpires <= now + 300) {
|
|
84
|
+
return this._refreshUAT();
|
|
85
|
+
}
|
|
86
|
+
return this._uat;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
_decodeTokenExpiry(token) {
|
|
90
|
+
try {
|
|
91
|
+
const payload = token?.split('.')?.[1];
|
|
92
|
+
if (!payload) return 0;
|
|
93
|
+
const data = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
|
94
|
+
return typeof data.exp === 'number' ? data.exp : 0;
|
|
95
|
+
} catch (_) {
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
_adoptPersistedUATIfNewer() {
|
|
101
|
+
try {
|
|
102
|
+
// auth/credentials reads credentials.json first; falls back to legacy
|
|
103
|
+
// mcpServers. The peer-rotated UAT will land wherever persistUAT wrote,
|
|
104
|
+
// and we'll see it consistently.
|
|
105
|
+
const { readCredentials } = require('../../auth/credentials');
|
|
106
|
+
const creds = readCredentials();
|
|
107
|
+
const token = creds.LARK_USER_ACCESS_TOKEN;
|
|
108
|
+
const refresh = creds.LARK_USER_REFRESH_TOKEN;
|
|
109
|
+
if (!token && !refresh) return false;
|
|
110
|
+
|
|
111
|
+
const expires = parseInt(creds.LARK_UAT_EXPIRES || '0') || this._decodeTokenExpiry(token);
|
|
112
|
+
const changed = (token && token !== this._uat)
|
|
113
|
+
|| (refresh && refresh !== this._uatRefresh)
|
|
114
|
+
|| (expires && expires !== this._uatExpires);
|
|
115
|
+
if (!changed) return false;
|
|
116
|
+
|
|
117
|
+
if (token) this._uat = token;
|
|
118
|
+
if (refresh) this._uatRefresh = refresh;
|
|
119
|
+
this._uatExpires = expires || 0;
|
|
120
|
+
console.error('[feishu-user-plugin] UAT adopted latest persisted token before refresh');
|
|
121
|
+
return true;
|
|
122
|
+
} catch (e) {
|
|
123
|
+
console.error(`[feishu-user-plugin] UAT persisted-token check failed: ${e.message}`);
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Cross-process advisory lock for UAT refresh. Feishu rotates the refresh_token
|
|
129
|
+
// on every refresh (old one invalidated instantly). When multiple MCP server
|
|
130
|
+
// processes share the same persisted refresh_token and all wake up near expiry,
|
|
131
|
+
// they race: the first wins, the rest see `invalid_grant` and can't recover.
|
|
132
|
+
// This lock serialises refreshes across processes; inside the critical section
|
|
133
|
+
// we also re-read the persisted config so late arrivals adopt the winner's
|
|
134
|
+
// token instead of attempting a doomed refresh with the already-rotated one.
|
|
135
|
+
_uatLockPath() {
|
|
136
|
+
const path = require('path');
|
|
137
|
+
const os = require('os');
|
|
138
|
+
return path.join(os.homedir(), '.claude', 'feishu-uat-refresh.lock');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async _acquireRefreshLock(lockPath, { staleMs = 30000, pollMs = 200, timeoutMs = 20000 } = {}) {
|
|
142
|
+
const fs = require('fs');
|
|
143
|
+
const path = require('path');
|
|
144
|
+
try { fs.mkdirSync(path.dirname(lockPath), { recursive: true }); } catch (_) {}
|
|
145
|
+
const start = Date.now();
|
|
146
|
+
while (Date.now() - start < timeoutMs) {
|
|
147
|
+
try {
|
|
148
|
+
const fd = fs.openSync(lockPath, 'wx'); // O_CREAT | O_EXCL
|
|
149
|
+
fs.writeSync(fd, `${process.pid}\n${Date.now()}\n`);
|
|
150
|
+
fs.closeSync(fd);
|
|
151
|
+
return true;
|
|
152
|
+
} catch (e) {
|
|
153
|
+
if (e.code !== 'EEXIST') throw e;
|
|
154
|
+
try {
|
|
155
|
+
const stat = fs.statSync(lockPath);
|
|
156
|
+
if (Date.now() - stat.mtimeMs > staleMs) {
|
|
157
|
+
try { fs.unlinkSync(lockPath); } catch (_) {}
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
} catch (_) { /* lock vanished under us — retry */ }
|
|
161
|
+
await new Promise(r => setTimeout(r, pollMs));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
_releaseRefreshLock(lockPath) {
|
|
168
|
+
try { require('fs').unlinkSync(lockPath); } catch (_) {}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async _refreshUAT() {
|
|
172
|
+
const lockPath = this._uatLockPath();
|
|
173
|
+
const acquired = await this._acquireRefreshLock(lockPath);
|
|
174
|
+
if (!acquired) {
|
|
175
|
+
console.error('[feishu-user-plugin] UAT refresh lock timed out; proceeding without mutual exclusion');
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
// Re-check under lock: another process may have already refreshed and
|
|
179
|
+
// persisted a new token while we waited. If so, adopt and skip the refresh.
|
|
180
|
+
const now = Math.floor(Date.now() / 1000);
|
|
181
|
+
if (this._adoptPersistedUATIfNewer() && this._uatExpires > now + 300) {
|
|
182
|
+
return this._uat;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (!this._uatRefresh) throw new Error('UAT expired and no refresh token. Run: npx feishu-user-plugin oauth');
|
|
186
|
+
|
|
187
|
+
const res = await fetchWithTimeout('https://open.feishu.cn/open-apis/authen/v2/oauth/token', {
|
|
188
|
+
method: 'POST',
|
|
189
|
+
headers: { 'content-type': 'application/json' },
|
|
190
|
+
body: JSON.stringify({
|
|
191
|
+
grant_type: 'refresh_token',
|
|
192
|
+
client_id: this.appId,
|
|
193
|
+
client_secret: this.appSecret,
|
|
194
|
+
refresh_token: this._uatRefresh,
|
|
195
|
+
}),
|
|
196
|
+
});
|
|
197
|
+
const data = await res.json();
|
|
198
|
+
const tokenData = data.access_token ? data : data.data;
|
|
199
|
+
if (!tokenData?.access_token) throw new Error(`UAT refresh failed: ${JSON.stringify(data)}. Run: npx feishu-user-plugin oauth`);
|
|
200
|
+
|
|
201
|
+
this._uat = tokenData.access_token;
|
|
202
|
+
this._uatRefresh = tokenData.refresh_token || this._uatRefresh;
|
|
203
|
+
const expiresIn = typeof tokenData.expires_in === 'number' && tokenData.expires_in > 0 ? tokenData.expires_in : 7200;
|
|
204
|
+
this._uatExpires = Math.floor(Date.now() / 1000) + expiresIn;
|
|
205
|
+
this._persistUAT();
|
|
206
|
+
console.error('[feishu-user-plugin] UAT refreshed successfully');
|
|
207
|
+
return this._uat;
|
|
208
|
+
} finally {
|
|
209
|
+
if (acquired) this._releaseRefreshLock(lockPath);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
_persistUAT() {
|
|
214
|
+
// Lazy require to avoid circular dependency at module load time.
|
|
215
|
+
// auth/credentials writes to credentials.json when it exists, otherwise
|
|
216
|
+
// falls back to legacy mcpServers persistence — same call site, two
|
|
217
|
+
// outcomes, same end result for callers.
|
|
218
|
+
const { persistToConfig } = require('../../auth/credentials');
|
|
219
|
+
persistToConfig({
|
|
220
|
+
LARK_USER_ACCESS_TOKEN: this._uat,
|
|
221
|
+
LARK_USER_REFRESH_TOKEN: this._uatRefresh,
|
|
222
|
+
LARK_UAT_EXPIRES: String(this._uatExpires),
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// --- UAT-based IM operations (for P2P chats) ---
|
|
227
|
+
|
|
228
|
+
// Wrapper: call fn with UAT, retry once after refresh if auth fails
|
|
229
|
+
async _withUAT(fn) {
|
|
230
|
+
let uat = await this._getValidUAT();
|
|
231
|
+
const data = await fn(uat);
|
|
232
|
+
// Known auth error codes: 99991668 (invalid), 99991663 (expired), 99991677 (auth_expired)
|
|
233
|
+
if (data.code === 99991668 || data.code === 99991663 || data.code === 99991677) {
|
|
234
|
+
// 99991668 is overloaded: "invalid token" (→ refresh helps) vs
|
|
235
|
+
// "endpoint doesn't support UAT at all" (→ refresh is pointless, and
|
|
236
|
+
// worse, it consumes a one-shot refresh_token rotation). The second
|
|
237
|
+
// case is identifiable by the msg "user access token not support" or
|
|
238
|
+
// "not support". If so, surface the code to the caller without refresh.
|
|
239
|
+
if (data.code === 99991668 && typeof data.msg === 'string' && /not support/i.test(data.msg)) {
|
|
240
|
+
return data;
|
|
241
|
+
}
|
|
242
|
+
// Token invalid/expired — try refresh once
|
|
243
|
+
uat = await this._refreshUAT();
|
|
244
|
+
return fn(uat);
|
|
245
|
+
}
|
|
246
|
+
return data;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Generic UAT REST helper. Returns parsed JSON ({code, msg, data}).
|
|
250
|
+
// Array query values are expanded to repeated keys (period_ids=a&period_ids=b)
|
|
251
|
+
// because several Feishu endpoints (OKR, calendar) rely on that convention.
|
|
252
|
+
async _uatREST(method, path, { body, query } = {}) {
|
|
253
|
+
let qs = '';
|
|
254
|
+
if (query) {
|
|
255
|
+
const sp = new URLSearchParams();
|
|
256
|
+
for (const [k, v] of Object.entries(query)) {
|
|
257
|
+
if (v === undefined || v === null) continue;
|
|
258
|
+
if (Array.isArray(v)) { for (const item of v) sp.append(k, String(item)); }
|
|
259
|
+
else sp.append(k, String(v));
|
|
260
|
+
}
|
|
261
|
+
const str = sp.toString();
|
|
262
|
+
if (str) qs = '?' + str;
|
|
263
|
+
}
|
|
264
|
+
const url = 'https://open.feishu.cn' + path + qs;
|
|
265
|
+
return this._withUAT(async (uat) => {
|
|
266
|
+
const headers = { 'Authorization': `Bearer ${uat}` };
|
|
267
|
+
const init = { method, headers };
|
|
268
|
+
if (body !== undefined) {
|
|
269
|
+
headers['content-type'] = 'application/json';
|
|
270
|
+
init.body = JSON.stringify(body);
|
|
271
|
+
}
|
|
272
|
+
const res = await fetchWithTimeout(url, init);
|
|
273
|
+
return res.json();
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Try UAT first (for resources likely owned by the user), fall back to app SDK on failure.
|
|
278
|
+
// Returns SDK-shaped {code, msg, data, _viaUser}. _viaUser is true iff the UAT call succeeded;
|
|
279
|
+
// callers can surface this to distinguish "created by user" vs "created by app" for resources
|
|
280
|
+
// whose ownership matters (docs, bitables, folders).
|
|
281
|
+
//
|
|
282
|
+
// When BOTH paths fail (common for OKR/Calendar if neither UAT nor app has the scope),
|
|
283
|
+
// the final error includes the UAT-side reason too, so the user can tell whether they
|
|
284
|
+
// need a new OAuth (UAT missing scope) or a different app (app missing scope).
|
|
285
|
+
async _asUserOrApp({ uatPath, method = 'GET', body, query, sdkFn, label }) {
|
|
286
|
+
let uatSummary = null;
|
|
287
|
+
if (this.hasUAT) {
|
|
288
|
+
try {
|
|
289
|
+
const data = await this._uatREST(method, uatPath, { body, query });
|
|
290
|
+
if (data.code === 0) {
|
|
291
|
+
data._viaUser = true;
|
|
292
|
+
return data;
|
|
293
|
+
}
|
|
294
|
+
uatSummary = `as user: code=${data.code} msg=${data.msg}`;
|
|
295
|
+
console.error(`[feishu-user-plugin] ${label} ${uatSummary}, retrying as app`);
|
|
296
|
+
} catch (err) {
|
|
297
|
+
uatSummary = `as user: ${err.message}`;
|
|
298
|
+
console.error(`[feishu-user-plugin] ${label} as user threw (${err.message}), retrying as app`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
try {
|
|
302
|
+
const appData = await this._safeSDKCall(sdkFn, label);
|
|
303
|
+
if (appData && typeof appData === 'object') {
|
|
304
|
+
appData._viaUser = false;
|
|
305
|
+
// Attach a warning when we silently fell back to bot identity. This lets
|
|
306
|
+
// write handlers surface "⚠️ created as BOT, not you" so the user doesn't
|
|
307
|
+
// discover it days later when a teammate can read the "private" resource.
|
|
308
|
+
if (uatSummary) {
|
|
309
|
+
appData._fallbackWarning = `⚠️ UAT 不可用 (${uatSummary}),本次操作以 bot 身份执行。资源归属于共享 bot「Claude聊天助手」,不是你。恢复方法:运行 \`npx feishu-user-plugin oauth\` 后重启 Claude Code / Codex。`;
|
|
310
|
+
} else if (!this.hasUAT) {
|
|
311
|
+
appData._fallbackWarning = `⚠️ 未配置 UAT,本次操作以 bot 身份执行。资源归属于共享 bot「Claude聊天助手」,不是你。想让资源归你所有,先跑 \`npx feishu-user-plugin oauth\` 然后重启 Claude Code / Codex。`;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return appData;
|
|
315
|
+
} catch (appErr) {
|
|
316
|
+
if (uatSummary) {
|
|
317
|
+
const err = new Error(`${label} failed on both identities. ${uatSummary}. as app: ${appErr.message}`);
|
|
318
|
+
err.uatSummary = uatSummary;
|
|
319
|
+
err.appError = appErr;
|
|
320
|
+
throw err;
|
|
321
|
+
}
|
|
322
|
+
throw appErr;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// --- Safe SDK Call (extracts real Feishu error from AxiosError) ---
|
|
327
|
+
|
|
328
|
+
async _safeSDKCall(fn, label = 'API') {
|
|
329
|
+
try {
|
|
330
|
+
const res = await fn();
|
|
331
|
+
// SDK returns abbreviated responses for multipart uploads (code/msg undefined)
|
|
332
|
+
// Only treat as error if code is explicitly non-zero
|
|
333
|
+
if (res.code !== undefined && res.code !== 0) throw new Error(`${label} failed (${res.code}): ${res.msg}`);
|
|
334
|
+
return res;
|
|
335
|
+
} catch (err) {
|
|
336
|
+
// Lark SDK uses axios; extract actual Feishu error from response body
|
|
337
|
+
if (err.response?.data) {
|
|
338
|
+
const d = err.response.data;
|
|
339
|
+
const code = d.code ?? d.error ?? 'unknown';
|
|
340
|
+
const msg = d.msg ?? d.error_description ?? d.message ?? JSON.stringify(d);
|
|
341
|
+
throw new Error(`${label} failed (HTTP ${err.response.status}, code=${code}): ${msg}`);
|
|
342
|
+
}
|
|
343
|
+
throw err;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async _populateSenderNames(items, userClient) {
|
|
348
|
+
// Collect unique sender IDs that aren't cached
|
|
349
|
+
const unknownIds = new Set();
|
|
350
|
+
for (const item of items) {
|
|
351
|
+
if (item.senderId && !this._userNameCache.has(item.senderId)) {
|
|
352
|
+
unknownIds.add(item.senderId);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
// Parallel resolve via official contact API (instead of sequential N calls)
|
|
356
|
+
if (unknownIds.size > 0) {
|
|
357
|
+
await Promise.allSettled([...unknownIds].map(id => this.getUserById(id)));
|
|
358
|
+
}
|
|
359
|
+
// Fallback: resolve remaining unknowns via cookie-based user identity client
|
|
360
|
+
if (userClient) {
|
|
361
|
+
for (const id of unknownIds) {
|
|
362
|
+
if (!this._userNameCache.has(id)) {
|
|
363
|
+
try {
|
|
364
|
+
const name = await userClient.getUserName(id);
|
|
365
|
+
if (name) this._userNameCache.set(id, name);
|
|
366
|
+
} catch {}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
// Populate senderName field
|
|
371
|
+
for (const item of items) {
|
|
372
|
+
if (item.senderId) {
|
|
373
|
+
item.senderName = this._userNameCache.get(item.senderId) || null;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// --- Helpers ---
|
|
379
|
+
|
|
380
|
+
_formatMessage(m) {
|
|
381
|
+
if (!m) return null;
|
|
382
|
+
let body = m.body?.content || '';
|
|
383
|
+
try { body = JSON.parse(body); } catch {}
|
|
384
|
+
const out = {
|
|
385
|
+
messageId: m.message_id,
|
|
386
|
+
chatId: m.chat_id,
|
|
387
|
+
senderId: m.sender?.id,
|
|
388
|
+
senderType: m.sender?.sender_type,
|
|
389
|
+
msgType: m.msg_type,
|
|
390
|
+
content: body,
|
|
391
|
+
createTime: this._normalizeTimestamp(m.create_time),
|
|
392
|
+
updateTime: this._normalizeTimestamp(m.update_time),
|
|
393
|
+
};
|
|
394
|
+
if (Array.isArray(m.mentions) && m.mentions.length > 0) out.mentions = m.mentions;
|
|
395
|
+
if (m.upper_message_id) out.upperMessageId = m.upper_message_id;
|
|
396
|
+
if (m.root_id) out.rootId = m.root_id;
|
|
397
|
+
if (m.parent_id) out.parentId = m.parent_id;
|
|
398
|
+
// Extract URL-like strings from text bodies so agents can call WebFetch /
|
|
399
|
+
// read_doc / get_doc_blocks without having to regex the body themselves.
|
|
400
|
+
if (out.msgType === 'text' && typeof body?.text === 'string') {
|
|
401
|
+
const urls = body.text.match(/https?:\/\/[^\s一-鿿]+/g);
|
|
402
|
+
if (urls && urls.length > 0) {
|
|
403
|
+
out.urls = Array.from(new Set(urls));
|
|
404
|
+
const feishuDocs = out.urls.filter(u =>
|
|
405
|
+
/feishu\.cn\/(?:docx|wiki|base|sheets|docs|mindnotes)\//i.test(u));
|
|
406
|
+
if (feishuDocs.length > 0) out.feishuDocs = feishuDocs;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
return out;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
_normalizeTimestamp(ts) {
|
|
413
|
+
if (!ts) return null;
|
|
414
|
+
const n = parseInt(ts);
|
|
415
|
+
// Feishu returns millisecond strings; normalize to seconds
|
|
416
|
+
return String(n > 1e12 ? Math.floor(n / 1000) : n);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// base.js exports only the bare class. clients/official/index.js composes the
|
|
422
|
+
// domain mixins onto its prototype — callers should always import from there,
|
|
423
|
+
// never directly from base.js.
|
|
424
|
+
module.exports = { LarkOfficialClient };
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// src/clients/official/bitable.js
|
|
2
|
+
// Mixed into LarkOfficialClient.prototype by ./index.js (or temporarily by
|
|
3
|
+
// ./base.js during phase A.4–A.11). Methods receive `this` bound to the
|
|
4
|
+
// LarkOfficialClient instance, so they can use this.client, this._safeSDKCall,
|
|
5
|
+
// this._asUserOrApp, this.attachToWiki (mixed in via wiki.js), etc. — all
|
|
6
|
+
// defined in base.js or mixed in via other domain modules.
|
|
7
|
+
|
|
8
|
+
module.exports = {
|
|
9
|
+
// --- Bitable ---
|
|
10
|
+
|
|
11
|
+
async createBitable(name, folderId, { wikiSpaceId, wikiParentNodeToken } = {}) {
|
|
12
|
+
const data = {};
|
|
13
|
+
if (name) data.name = name;
|
|
14
|
+
if (folderId) data.folder_token = folderId;
|
|
15
|
+
const res = await this._asUserOrApp({
|
|
16
|
+
uatPath: `/open-apis/bitable/v1/apps`,
|
|
17
|
+
method: 'POST',
|
|
18
|
+
body: data,
|
|
19
|
+
sdkFn: () => this.client.bitable.app.create({ data }),
|
|
20
|
+
label: 'createBitable',
|
|
21
|
+
});
|
|
22
|
+
const appToken = res.data.app?.app_token;
|
|
23
|
+
const out = { appToken, name: res.data.app?.name, url: res.data.app?.url, viaUser: !!res._viaUser, fallbackWarning: res._fallbackWarning || null };
|
|
24
|
+
if (appToken && wikiSpaceId) {
|
|
25
|
+
try {
|
|
26
|
+
const node = await this.attachToWiki(wikiSpaceId, 'bitable', appToken, wikiParentNodeToken);
|
|
27
|
+
if (node?.node_token) out.wikiNodeToken = node.node_token;
|
|
28
|
+
else if (node?.task_id) out.wikiAttachTaskId = node.task_id;
|
|
29
|
+
} catch (e) {
|
|
30
|
+
out.wikiAttachError = e.message;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
async listBitableTables(appToken) {
|
|
37
|
+
const res = await this._asUserOrApp({
|
|
38
|
+
uatPath: `/open-apis/bitable/v1/apps/${appToken}/tables`,
|
|
39
|
+
sdkFn: () => this.client.bitable.appTable.list({ path: { app_token: appToken } }),
|
|
40
|
+
label: 'listTables',
|
|
41
|
+
});
|
|
42
|
+
return { items: res.data.items || [] };
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
async createBitableTable(appToken, name, fields) {
|
|
46
|
+
const data = { table: { name } };
|
|
47
|
+
if (fields && fields.length > 0) data.table.default_view_name = name;
|
|
48
|
+
if (fields && fields.length > 0) data.table.fields = fields;
|
|
49
|
+
const res = await this._asUserOrApp({
|
|
50
|
+
uatPath: `/open-apis/bitable/v1/apps/${appToken}/tables`,
|
|
51
|
+
method: 'POST',
|
|
52
|
+
body: data,
|
|
53
|
+
sdkFn: () => this.client.bitable.appTable.create({ path: { app_token: appToken }, data }),
|
|
54
|
+
label: 'createTable',
|
|
55
|
+
});
|
|
56
|
+
return { tableId: res.data.table_id, fallbackWarning: res._fallbackWarning || null };
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
async listBitableFields(appToken, tableId) {
|
|
60
|
+
const res = await this._asUserOrApp({
|
|
61
|
+
uatPath: `/open-apis/bitable/v1/apps/${appToken}/tables/${tableId}/fields`,
|
|
62
|
+
sdkFn: () => this.client.bitable.appTableField.list({ path: { app_token: appToken, table_id: tableId } }),
|
|
63
|
+
label: 'listFields',
|
|
64
|
+
});
|
|
65
|
+
return { items: res.data.items || [] };
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
async createBitableField(appToken, tableId, fieldConfig) {
|
|
69
|
+
const res = await this._asUserOrApp({
|
|
70
|
+
uatPath: `/open-apis/bitable/v1/apps/${appToken}/tables/${tableId}/fields`,
|
|
71
|
+
method: 'POST',
|
|
72
|
+
body: fieldConfig,
|
|
73
|
+
sdkFn: () => this.client.bitable.appTableField.create({ path: { app_token: appToken, table_id: tableId }, data: fieldConfig }),
|
|
74
|
+
label: 'createField',
|
|
75
|
+
});
|
|
76
|
+
return { field: res.data.field, fallbackWarning: res._fallbackWarning || null };
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
async updateBitableField(appToken, tableId, fieldId, fieldConfig) {
|
|
80
|
+
const res = await this._asUserOrApp({
|
|
81
|
+
uatPath: `/open-apis/bitable/v1/apps/${appToken}/tables/${tableId}/fields/${fieldId}`,
|
|
82
|
+
method: 'PUT',
|
|
83
|
+
body: fieldConfig,
|
|
84
|
+
sdkFn: () => this.client.bitable.appTableField.update({ path: { app_token: appToken, table_id: tableId, field_id: fieldId }, data: fieldConfig }),
|
|
85
|
+
label: 'updateField',
|
|
86
|
+
});
|
|
87
|
+
return { field: res.data.field };
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
async deleteBitableField(appToken, tableId, fieldId) {
|
|
91
|
+
const res = await this._asUserOrApp({
|
|
92
|
+
uatPath: `/open-apis/bitable/v1/apps/${appToken}/tables/${tableId}/fields/${fieldId}`,
|
|
93
|
+
method: 'DELETE',
|
|
94
|
+
sdkFn: () => this.client.bitable.appTableField.delete({ path: { app_token: appToken, table_id: tableId, field_id: fieldId } }),
|
|
95
|
+
label: 'deleteField',
|
|
96
|
+
});
|
|
97
|
+
return { fieldId: res.data.field_id, deleted: res.data.deleted };
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
async searchBitableRecords(appToken, tableId, { filter, sort, pageSize = 20, pageToken } = {}) {
|
|
101
|
+
const data = {};
|
|
102
|
+
if (filter) data.filter = filter;
|
|
103
|
+
if (sort) data.sort = sort;
|
|
104
|
+
const query = {};
|
|
105
|
+
if (pageSize) query.page_size = String(pageSize);
|
|
106
|
+
if (pageToken) query.page_token = pageToken;
|
|
107
|
+
const res = await this._asUserOrApp({
|
|
108
|
+
uatPath: `/open-apis/bitable/v1/apps/${appToken}/tables/${tableId}/records/search`,
|
|
109
|
+
method: 'POST',
|
|
110
|
+
body: data,
|
|
111
|
+
query,
|
|
112
|
+
sdkFn: () => this.client.bitable.appTableRecord.search({
|
|
113
|
+
path: { app_token: appToken, table_id: tableId },
|
|
114
|
+
params: { page_size: pageSize, ...(pageToken ? { page_token: pageToken } : {}) },
|
|
115
|
+
data,
|
|
116
|
+
}),
|
|
117
|
+
label: 'searchRecords',
|
|
118
|
+
});
|
|
119
|
+
return { items: res.data.items || [], total: res.data.total, hasMore: res.data.has_more };
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
async createBitableRecord(appToken, tableId, fields) {
|
|
123
|
+
const res = await this._asUserOrApp({
|
|
124
|
+
uatPath: `/open-apis/bitable/v1/apps/${appToken}/tables/${tableId}/records`,
|
|
125
|
+
method: 'POST',
|
|
126
|
+
body: { fields },
|
|
127
|
+
sdkFn: () => this.client.bitable.appTableRecord.create({ path: { app_token: appToken, table_id: tableId }, data: { fields } }),
|
|
128
|
+
label: 'createRecord',
|
|
129
|
+
});
|
|
130
|
+
return { recordId: res.data.record?.record_id, fallbackWarning: res._fallbackWarning || null };
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
async updateBitableRecord(appToken, tableId, recordId, fields) {
|
|
134
|
+
const res = await this._asUserOrApp({
|
|
135
|
+
uatPath: `/open-apis/bitable/v1/apps/${appToken}/tables/${tableId}/records/${recordId}`,
|
|
136
|
+
method: 'PUT',
|
|
137
|
+
body: { fields },
|
|
138
|
+
sdkFn: () => this.client.bitable.appTableRecord.update({ path: { app_token: appToken, table_id: tableId, record_id: recordId }, data: { fields } }),
|
|
139
|
+
label: 'updateRecord',
|
|
140
|
+
});
|
|
141
|
+
return { recordId: res.data.record?.record_id };
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
async deleteBitableRecord(appToken, tableId, recordId) {
|
|
145
|
+
const res = await this._asUserOrApp({
|
|
146
|
+
uatPath: `/open-apis/bitable/v1/apps/${appToken}/tables/${tableId}/records/${recordId}`,
|
|
147
|
+
method: 'DELETE',
|
|
148
|
+
sdkFn: () => this.client.bitable.appTableRecord.delete({ path: { app_token: appToken, table_id: tableId, record_id: recordId } }),
|
|
149
|
+
label: 'deleteRecord',
|
|
150
|
+
});
|
|
151
|
+
return { deleted: res.data.deleted };
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
async batchCreateBitableRecords(appToken, tableId, records) {
|
|
155
|
+
const res = await this._asUserOrApp({
|
|
156
|
+
uatPath: `/open-apis/bitable/v1/apps/${appToken}/tables/${tableId}/records/batch_create`,
|
|
157
|
+
method: 'POST',
|
|
158
|
+
body: { records },
|
|
159
|
+
sdkFn: () => this.client.bitable.appTableRecord.batchCreate({ path: { app_token: appToken, table_id: tableId }, data: { records } }),
|
|
160
|
+
label: 'batchCreateRecords',
|
|
161
|
+
});
|
|
162
|
+
return { records: res.data.records || [], fallbackWarning: res._fallbackWarning || null };
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
async batchUpdateBitableRecords(appToken, tableId, records) {
|
|
166
|
+
const res = await this._asUserOrApp({
|
|
167
|
+
uatPath: `/open-apis/bitable/v1/apps/${appToken}/tables/${tableId}/records/batch_update`,
|
|
168
|
+
method: 'POST',
|
|
169
|
+
body: { records },
|
|
170
|
+
sdkFn: () => this.client.bitable.appTableRecord.batchUpdate({ path: { app_token: appToken, table_id: tableId }, data: { records } }),
|
|
171
|
+
label: 'batchUpdateRecords',
|
|
172
|
+
});
|
|
173
|
+
return { records: res.data.records || [] };
|
|
174
|
+
},
|
|
175
|
+
|
|
176
|
+
async batchDeleteBitableRecords(appToken, tableId, recordIds) {
|
|
177
|
+
const res = await this._asUserOrApp({
|
|
178
|
+
uatPath: `/open-apis/bitable/v1/apps/${appToken}/tables/${tableId}/records/batch_delete`,
|
|
179
|
+
method: 'POST',
|
|
180
|
+
body: { records: recordIds },
|
|
181
|
+
sdkFn: () => this.client.bitable.appTableRecord.batchDelete({ path: { app_token: appToken, table_id: tableId }, data: { records: recordIds } }),
|
|
182
|
+
label: 'batchDeleteRecords',
|
|
183
|
+
});
|
|
184
|
+
return { records: res.data.records || [] };
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
async listBitableViews(appToken, tableId) {
|
|
188
|
+
const res = await this._asUserOrApp({
|
|
189
|
+
uatPath: `/open-apis/bitable/v1/apps/${appToken}/tables/${tableId}/views`,
|
|
190
|
+
query: { page_size: '50' },
|
|
191
|
+
sdkFn: () => this.client.bitable.appTableView.list({ path: { app_token: appToken, table_id: tableId }, params: { page_size: 50 } }),
|
|
192
|
+
label: 'listViews',
|
|
193
|
+
});
|
|
194
|
+
return { items: res.data.items || [] };
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
async getBitableRecord(appToken, tableId, recordId) {
|
|
198
|
+
const res = await this._asUserOrApp({
|
|
199
|
+
uatPath: `/open-apis/bitable/v1/apps/${appToken}/tables/${tableId}/records/${recordId}`,
|
|
200
|
+
sdkFn: () => this.client.bitable.appTableRecord.get({ path: { app_token: appToken, table_id: tableId, record_id: recordId } }),
|
|
201
|
+
label: 'getRecord',
|
|
202
|
+
});
|
|
203
|
+
return { record: res.data.record };
|
|
204
|
+
},
|
|
205
|
+
|
|
206
|
+
async deleteBitableTable(appToken, tableId) {
|
|
207
|
+
await this._asUserOrApp({
|
|
208
|
+
uatPath: `/open-apis/bitable/v1/apps/${appToken}/tables/${tableId}`,
|
|
209
|
+
method: 'DELETE',
|
|
210
|
+
sdkFn: () => this.client.bitable.appTable.delete({ path: { app_token: appToken, table_id: tableId } }),
|
|
211
|
+
label: 'deleteTable',
|
|
212
|
+
});
|
|
213
|
+
return { deleted: true };
|
|
214
|
+
},
|
|
215
|
+
|
|
216
|
+
async getBitableMeta(appToken) {
|
|
217
|
+
const res = await this._asUserOrApp({
|
|
218
|
+
uatPath: `/open-apis/bitable/v1/apps/${appToken}`,
|
|
219
|
+
sdkFn: () => this.client.bitable.app.get({ path: { app_token: appToken } }),
|
|
220
|
+
label: 'getBitableMeta',
|
|
221
|
+
});
|
|
222
|
+
return { app: res.data.app };
|
|
223
|
+
},
|
|
224
|
+
|
|
225
|
+
async updateBitableTable(appToken, tableId, name) {
|
|
226
|
+
const res = await this._asUserOrApp({
|
|
227
|
+
uatPath: `/open-apis/bitable/v1/apps/${appToken}/tables/${tableId}`,
|
|
228
|
+
method: 'PATCH',
|
|
229
|
+
body: { name },
|
|
230
|
+
sdkFn: () => this.client.bitable.appTable.patch({ path: { app_token: appToken, table_id: tableId }, data: { name } }),
|
|
231
|
+
label: 'updateTable',
|
|
232
|
+
});
|
|
233
|
+
return { name: res.data.name };
|
|
234
|
+
},
|
|
235
|
+
|
|
236
|
+
async createBitableView(appToken, tableId, viewName, viewType = 'grid') {
|
|
237
|
+
const res = await this._asUserOrApp({
|
|
238
|
+
uatPath: `/open-apis/bitable/v1/apps/${appToken}/tables/${tableId}/views`,
|
|
239
|
+
method: 'POST',
|
|
240
|
+
body: { view_name: viewName, view_type: viewType },
|
|
241
|
+
sdkFn: () => this.client.bitable.appTableView.create({ path: { app_token: appToken, table_id: tableId }, data: { view_name: viewName, view_type: viewType } }),
|
|
242
|
+
label: 'createView',
|
|
243
|
+
});
|
|
244
|
+
return { view: res.data.view, fallbackWarning: res._fallbackWarning || null };
|
|
245
|
+
},
|
|
246
|
+
|
|
247
|
+
async deleteBitableView(appToken, tableId, viewId) {
|
|
248
|
+
await this._asUserOrApp({
|
|
249
|
+
uatPath: `/open-apis/bitable/v1/apps/${appToken}/tables/${tableId}/views/${viewId}`,
|
|
250
|
+
method: 'DELETE',
|
|
251
|
+
sdkFn: () => this.client.bitable.appTableView.delete({ path: { app_token: appToken, table_id: tableId, view_id: viewId } }),
|
|
252
|
+
label: 'deleteView',
|
|
253
|
+
});
|
|
254
|
+
return { deleted: true };
|
|
255
|
+
},
|
|
256
|
+
|
|
257
|
+
async copyBitable(appToken, name, folderId) {
|
|
258
|
+
const data = { name };
|
|
259
|
+
if (folderId) data.folder_token = folderId;
|
|
260
|
+
const res = await this._asUserOrApp({
|
|
261
|
+
uatPath: `/open-apis/bitable/v1/apps/${appToken}/copy`,
|
|
262
|
+
method: 'POST',
|
|
263
|
+
body: data,
|
|
264
|
+
sdkFn: () => this.client.bitable.app.copy({ path: { app_token: appToken }, data }),
|
|
265
|
+
label: 'copyBitable',
|
|
266
|
+
});
|
|
267
|
+
return { app: res.data.app, fallbackWarning: res._fallbackWarning || null };
|
|
268
|
+
},
|
|
269
|
+
};
|