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
package/src/official.js
CHANGED
|
@@ -1,1944 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
// Redirect all Lark SDK logs to stderr.
|
|
7
|
-
// The SDK's defaultLogger.error uses console.log (stdout), which corrupts
|
|
8
|
-
// MCP's JSON-RPC stdio transport and causes session disconnects.
|
|
9
|
-
const stderrLogger = {
|
|
10
|
-
error: (...msg) => console.error('[lark-sdk][error]:', ...msg),
|
|
11
|
-
warn: (...msg) => console.error('[lark-sdk][warn]:', ...msg),
|
|
12
|
-
info: () => {},
|
|
13
|
-
debug: () => {},
|
|
14
|
-
trace: () => {},
|
|
15
|
-
};
|
|
16
|
-
|
|
17
|
-
class LarkOfficialClient {
|
|
18
|
-
constructor(appId, appSecret) {
|
|
19
|
-
this.appId = appId;
|
|
20
|
-
this.appSecret = appSecret;
|
|
21
|
-
this.client = new lark.Client({ appId, appSecret, disableTokenCache: false, logger: stderrLogger, loggerLevel: lark.LoggerLevel.warn });
|
|
22
|
-
this._uat = null;
|
|
23
|
-
this._uatRefresh = null;
|
|
24
|
-
this._uatExpires = 0;
|
|
25
|
-
this._userNameCache = new Map(); // open_id → display name
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
// --- UAT (User Access Token) Management ---
|
|
29
|
-
|
|
30
|
-
loadUAT() {
|
|
31
|
-
const token = process.env.LARK_USER_ACCESS_TOKEN;
|
|
32
|
-
const refresh = process.env.LARK_USER_REFRESH_TOKEN;
|
|
33
|
-
const expires = parseInt(process.env.LARK_UAT_EXPIRES || '0');
|
|
34
|
-
if (token) {
|
|
35
|
-
this._uat = token;
|
|
36
|
-
this._uatRefresh = refresh || null;
|
|
37
|
-
this._uatExpires = expires || this._decodeTokenExpiry(token);
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
get hasUAT() {
|
|
42
|
-
return !!this._uat;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// Fetches (and caches) an app_access_token directly via the internal endpoint.
|
|
46
|
-
// Avoids relying on SDK-internal token-manager APIs that may change across versions.
|
|
47
|
-
async _getAppToken() {
|
|
48
|
-
const now = Math.floor(Date.now() / 1000);
|
|
49
|
-
if (this._appToken && this._appTokenExpires > now + 60) return this._appToken;
|
|
50
|
-
const res = await fetchWithTimeout('https://open.feishu.cn/open-apis/auth/v3/app_access_token/internal', {
|
|
51
|
-
method: 'POST',
|
|
52
|
-
headers: { 'content-type': 'application/json' },
|
|
53
|
-
body: JSON.stringify({ app_id: this.appId, app_secret: this.appSecret }),
|
|
54
|
-
timeoutMs: 10000,
|
|
55
|
-
});
|
|
56
|
-
const data = await res.json();
|
|
57
|
-
if (data.code !== 0 || !data.app_access_token) {
|
|
58
|
-
throw new Error(`app_access_token failed: ${data.code}: ${data.msg || 'unknown'}`);
|
|
59
|
-
}
|
|
60
|
-
this._appToken = data.app_access_token;
|
|
61
|
-
this._appTokenExpires = now + (typeof data.expire === 'number' ? data.expire : 7200);
|
|
62
|
-
return this._appToken;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// Probe APP_ID/SECRET validity by requesting a tenant access token.
|
|
66
|
-
// Catches the common "user's Claude filled in a wrong/stale APP_ID" failure mode
|
|
67
|
-
// (observed in production: 周宇's machine ran with an APP_ID nobody recognized,
|
|
68
|
-
// causing all Official API calls to 401 with cryptic messages that looked like
|
|
69
|
-
// MCP "掉线" to the user). Returns { valid, appId, appName?, error? }.
|
|
70
|
-
async verifyApp() {
|
|
71
|
-
try {
|
|
72
|
-
const token = await this._getAppToken();
|
|
73
|
-
// Try to fetch app display name (best-effort; requires application scope)
|
|
74
|
-
let appName = null;
|
|
75
|
-
try {
|
|
76
|
-
const infoRes = await fetchWithTimeout(`https://open.feishu.cn/open-apis/application/v6/applications/${this.appId}?lang=zh_cn`, {
|
|
77
|
-
headers: { 'Authorization': `Bearer ${token}` },
|
|
78
|
-
timeoutMs: 10000,
|
|
79
|
-
});
|
|
80
|
-
const info = await infoRes.json();
|
|
81
|
-
if (info.code === 0) appName = info.data?.app?.app_name || null;
|
|
82
|
-
} catch (_) { /* name is best-effort; valid creds still matter most */ }
|
|
83
|
-
return { valid: true, appId: this.appId, appName };
|
|
84
|
-
} catch (e) {
|
|
85
|
-
return { valid: false, appId: this.appId, error: e.message };
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
async _getValidUAT() {
|
|
90
|
-
if (!this._uat) throw new Error('No user_access_token. Run: npx feishu-user-plugin oauth');
|
|
91
|
-
|
|
92
|
-
const now = Math.floor(Date.now() / 1000);
|
|
93
|
-
if (!this._uatExpires) this._uatExpires = this._decodeTokenExpiry(this._uat);
|
|
94
|
-
// Proactively refresh if we know it's expiring within 5 min
|
|
95
|
-
if (this._uatExpires > 0 && this._uatExpires <= now + 300) {
|
|
96
|
-
return this._refreshUAT();
|
|
97
|
-
}
|
|
98
|
-
return this._uat;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
_decodeTokenExpiry(token) {
|
|
102
|
-
try {
|
|
103
|
-
const payload = token?.split('.')?.[1];
|
|
104
|
-
if (!payload) return 0;
|
|
105
|
-
const data = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
|
106
|
-
return typeof data.exp === 'number' ? data.exp : 0;
|
|
107
|
-
} catch (_) {
|
|
108
|
-
return 0;
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
_adoptPersistedUATIfNewer() {
|
|
113
|
-
try {
|
|
114
|
-
const { readCredentials } = require('./config');
|
|
115
|
-
const creds = readCredentials();
|
|
116
|
-
const token = creds.LARK_USER_ACCESS_TOKEN;
|
|
117
|
-
const refresh = creds.LARK_USER_REFRESH_TOKEN;
|
|
118
|
-
if (!token && !refresh) return false;
|
|
119
|
-
|
|
120
|
-
const expires = parseInt(creds.LARK_UAT_EXPIRES || '0') || this._decodeTokenExpiry(token);
|
|
121
|
-
const changed = (token && token !== this._uat)
|
|
122
|
-
|| (refresh && refresh !== this._uatRefresh)
|
|
123
|
-
|| (expires && expires !== this._uatExpires);
|
|
124
|
-
if (!changed) return false;
|
|
125
|
-
|
|
126
|
-
if (token) this._uat = token;
|
|
127
|
-
if (refresh) this._uatRefresh = refresh;
|
|
128
|
-
this._uatExpires = expires || 0;
|
|
129
|
-
console.error('[feishu-user-plugin] UAT adopted latest persisted token before refresh');
|
|
130
|
-
return true;
|
|
131
|
-
} catch (e) {
|
|
132
|
-
console.error(`[feishu-user-plugin] UAT persisted-token check failed: ${e.message}`);
|
|
133
|
-
return false;
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
// Cross-process advisory lock for UAT refresh. Feishu rotates the refresh_token
|
|
138
|
-
// on every refresh (old one invalidated instantly). When multiple MCP server
|
|
139
|
-
// processes share the same persisted refresh_token and all wake up near expiry,
|
|
140
|
-
// they race: the first wins, the rest see `invalid_grant` and can't recover.
|
|
141
|
-
// This lock serialises refreshes across processes; inside the critical section
|
|
142
|
-
// we also re-read the persisted config so late arrivals adopt the winner's
|
|
143
|
-
// token instead of attempting a doomed refresh with the already-rotated one.
|
|
144
|
-
_uatLockPath() {
|
|
145
|
-
const path = require('path');
|
|
146
|
-
const os = require('os');
|
|
147
|
-
return path.join(os.homedir(), '.claude', 'feishu-uat-refresh.lock');
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
async _acquireRefreshLock(lockPath, { staleMs = 30000, pollMs = 200, timeoutMs = 20000 } = {}) {
|
|
151
|
-
const fs = require('fs');
|
|
152
|
-
const path = require('path');
|
|
153
|
-
try { fs.mkdirSync(path.dirname(lockPath), { recursive: true }); } catch (_) {}
|
|
154
|
-
const start = Date.now();
|
|
155
|
-
while (Date.now() - start < timeoutMs) {
|
|
156
|
-
try {
|
|
157
|
-
const fd = fs.openSync(lockPath, 'wx'); // O_CREAT | O_EXCL
|
|
158
|
-
fs.writeSync(fd, `${process.pid}\n${Date.now()}\n`);
|
|
159
|
-
fs.closeSync(fd);
|
|
160
|
-
return true;
|
|
161
|
-
} catch (e) {
|
|
162
|
-
if (e.code !== 'EEXIST') throw e;
|
|
163
|
-
try {
|
|
164
|
-
const stat = fs.statSync(lockPath);
|
|
165
|
-
if (Date.now() - stat.mtimeMs > staleMs) {
|
|
166
|
-
try { fs.unlinkSync(lockPath); } catch (_) {}
|
|
167
|
-
continue;
|
|
168
|
-
}
|
|
169
|
-
} catch (_) { /* lock vanished under us — retry */ }
|
|
170
|
-
await new Promise(r => setTimeout(r, pollMs));
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
return false;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
_releaseRefreshLock(lockPath) {
|
|
177
|
-
try { require('fs').unlinkSync(lockPath); } catch (_) {}
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
async _refreshUAT() {
|
|
181
|
-
const lockPath = this._uatLockPath();
|
|
182
|
-
const acquired = await this._acquireRefreshLock(lockPath);
|
|
183
|
-
if (!acquired) {
|
|
184
|
-
console.error('[feishu-user-plugin] UAT refresh lock timed out; proceeding without mutual exclusion');
|
|
185
|
-
}
|
|
186
|
-
try {
|
|
187
|
-
// Re-check under lock: another process may have already refreshed and
|
|
188
|
-
// persisted a new token while we waited. If so, adopt and skip the refresh.
|
|
189
|
-
const now = Math.floor(Date.now() / 1000);
|
|
190
|
-
if (this._adoptPersistedUATIfNewer() && this._uatExpires > now + 300) {
|
|
191
|
-
return this._uat;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
if (!this._uatRefresh) throw new Error('UAT expired and no refresh token. Run: npx feishu-user-plugin oauth');
|
|
195
|
-
|
|
196
|
-
const res = await fetchWithTimeout('https://open.feishu.cn/open-apis/authen/v2/oauth/token', {
|
|
197
|
-
method: 'POST',
|
|
198
|
-
headers: { 'content-type': 'application/json' },
|
|
199
|
-
body: JSON.stringify({
|
|
200
|
-
grant_type: 'refresh_token',
|
|
201
|
-
client_id: this.appId,
|
|
202
|
-
client_secret: this.appSecret,
|
|
203
|
-
refresh_token: this._uatRefresh,
|
|
204
|
-
}),
|
|
205
|
-
});
|
|
206
|
-
const data = await res.json();
|
|
207
|
-
const tokenData = data.access_token ? data : data.data;
|
|
208
|
-
if (!tokenData?.access_token) throw new Error(`UAT refresh failed: ${JSON.stringify(data)}. Run: npx feishu-user-plugin oauth`);
|
|
209
|
-
|
|
210
|
-
this._uat = tokenData.access_token;
|
|
211
|
-
this._uatRefresh = tokenData.refresh_token || this._uatRefresh;
|
|
212
|
-
const expiresIn = typeof tokenData.expires_in === 'number' && tokenData.expires_in > 0 ? tokenData.expires_in : 7200;
|
|
213
|
-
this._uatExpires = Math.floor(Date.now() / 1000) + expiresIn;
|
|
214
|
-
this._persistUAT();
|
|
215
|
-
console.error('[feishu-user-plugin] UAT refreshed successfully');
|
|
216
|
-
return this._uat;
|
|
217
|
-
} finally {
|
|
218
|
-
if (acquired) this._releaseRefreshLock(lockPath);
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
_persistUAT() {
|
|
223
|
-
// Lazy require to avoid circular dependency at module load time
|
|
224
|
-
const { persistToConfig } = require('./config');
|
|
225
|
-
persistToConfig({
|
|
226
|
-
LARK_USER_ACCESS_TOKEN: this._uat,
|
|
227
|
-
LARK_USER_REFRESH_TOKEN: this._uatRefresh,
|
|
228
|
-
LARK_UAT_EXPIRES: String(this._uatExpires),
|
|
229
|
-
});
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
// --- UAT-based IM operations (for P2P chats) ---
|
|
233
|
-
|
|
234
|
-
// Wrapper: call fn with UAT, retry once after refresh if auth fails
|
|
235
|
-
async _withUAT(fn) {
|
|
236
|
-
let uat = await this._getValidUAT();
|
|
237
|
-
const data = await fn(uat);
|
|
238
|
-
// Known auth error codes: 99991668 (invalid), 99991663 (expired), 99991677 (auth_expired)
|
|
239
|
-
if (data.code === 99991668 || data.code === 99991663 || data.code === 99991677) {
|
|
240
|
-
// 99991668 is overloaded: "invalid token" (→ refresh helps) vs
|
|
241
|
-
// "endpoint doesn't support UAT at all" (→ refresh is pointless, and
|
|
242
|
-
// worse, it consumes a one-shot refresh_token rotation). The second
|
|
243
|
-
// case is identifiable by the msg "user access token not support" or
|
|
244
|
-
// "not support". If so, surface the code to the caller without refresh.
|
|
245
|
-
if (data.code === 99991668 && typeof data.msg === 'string' && /not support/i.test(data.msg)) {
|
|
246
|
-
return data;
|
|
247
|
-
}
|
|
248
|
-
// Token invalid/expired — try refresh once
|
|
249
|
-
uat = await this._refreshUAT();
|
|
250
|
-
return fn(uat);
|
|
251
|
-
}
|
|
252
|
-
return data;
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
// Generic UAT REST helper. Returns parsed JSON ({code, msg, data}).
|
|
256
|
-
// Array query values are expanded to repeated keys (period_ids=a&period_ids=b)
|
|
257
|
-
// because several Feishu endpoints (OKR, calendar) rely on that convention.
|
|
258
|
-
async _uatREST(method, path, { body, query } = {}) {
|
|
259
|
-
let qs = '';
|
|
260
|
-
if (query) {
|
|
261
|
-
const sp = new URLSearchParams();
|
|
262
|
-
for (const [k, v] of Object.entries(query)) {
|
|
263
|
-
if (v === undefined || v === null) continue;
|
|
264
|
-
if (Array.isArray(v)) { for (const item of v) sp.append(k, String(item)); }
|
|
265
|
-
else sp.append(k, String(v));
|
|
266
|
-
}
|
|
267
|
-
const str = sp.toString();
|
|
268
|
-
if (str) qs = '?' + str;
|
|
269
|
-
}
|
|
270
|
-
const url = 'https://open.feishu.cn' + path + qs;
|
|
271
|
-
return this._withUAT(async (uat) => {
|
|
272
|
-
const headers = { 'Authorization': `Bearer ${uat}` };
|
|
273
|
-
const init = { method, headers };
|
|
274
|
-
if (body !== undefined) {
|
|
275
|
-
headers['content-type'] = 'application/json';
|
|
276
|
-
init.body = JSON.stringify(body);
|
|
277
|
-
}
|
|
278
|
-
const res = await fetchWithTimeout(url, init);
|
|
279
|
-
return res.json();
|
|
280
|
-
});
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
// Try UAT first (for resources likely owned by the user), fall back to app SDK on failure.
|
|
284
|
-
// Returns SDK-shaped {code, msg, data, _viaUser}. _viaUser is true iff the UAT call succeeded;
|
|
285
|
-
// callers can surface this to distinguish "created by user" vs "created by app" for resources
|
|
286
|
-
// whose ownership matters (docs, bitables, folders).
|
|
287
|
-
//
|
|
288
|
-
// When BOTH paths fail (common for OKR/Calendar if neither UAT nor app has the scope),
|
|
289
|
-
// the final error includes the UAT-side reason too, so the user can tell whether they
|
|
290
|
-
// need a new OAuth (UAT missing scope) or a different app (app missing scope).
|
|
291
|
-
async _asUserOrApp({ uatPath, method = 'GET', body, query, sdkFn, label }) {
|
|
292
|
-
let uatSummary = null;
|
|
293
|
-
if (this.hasUAT) {
|
|
294
|
-
try {
|
|
295
|
-
const data = await this._uatREST(method, uatPath, { body, query });
|
|
296
|
-
if (data.code === 0) {
|
|
297
|
-
data._viaUser = true;
|
|
298
|
-
return data;
|
|
299
|
-
}
|
|
300
|
-
uatSummary = `as user: code=${data.code} msg=${data.msg}`;
|
|
301
|
-
console.error(`[feishu-user-plugin] ${label} ${uatSummary}, retrying as app`);
|
|
302
|
-
} catch (err) {
|
|
303
|
-
uatSummary = `as user: ${err.message}`;
|
|
304
|
-
console.error(`[feishu-user-plugin] ${label} as user threw (${err.message}), retrying as app`);
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
try {
|
|
308
|
-
const appData = await this._safeSDKCall(sdkFn, label);
|
|
309
|
-
if (appData && typeof appData === 'object') {
|
|
310
|
-
appData._viaUser = false;
|
|
311
|
-
// Attach a warning when we silently fell back to bot identity. This lets
|
|
312
|
-
// write handlers surface "⚠️ created as BOT, not you" so the user doesn't
|
|
313
|
-
// discover it days later when a teammate can read the "private" resource.
|
|
314
|
-
if (uatSummary) {
|
|
315
|
-
appData._fallbackWarning = `⚠️ UAT 不可用 (${uatSummary}),本次操作以 bot 身份执行。资源归属于共享 bot「Claude聊天助手」,不是你。恢复方法:运行 \`npx feishu-user-plugin oauth\` 后重启 Claude Code / Codex。`;
|
|
316
|
-
} else if (!this.hasUAT) {
|
|
317
|
-
appData._fallbackWarning = `⚠️ 未配置 UAT,本次操作以 bot 身份执行。资源归属于共享 bot「Claude聊天助手」,不是你。想让资源归你所有,先跑 \`npx feishu-user-plugin oauth\` 然后重启 Claude Code / Codex。`;
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
return appData;
|
|
321
|
-
} catch (appErr) {
|
|
322
|
-
if (uatSummary) {
|
|
323
|
-
const err = new Error(`${label} failed on both identities. ${uatSummary}. as app: ${appErr.message}`);
|
|
324
|
-
err.uatSummary = uatSummary;
|
|
325
|
-
err.appError = appErr;
|
|
326
|
-
throw err;
|
|
327
|
-
}
|
|
328
|
-
throw appErr;
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
async listChatsAsUser({ pageSize = 20, pageToken } = {}) {
|
|
333
|
-
const params = new URLSearchParams({ page_size: String(pageSize) });
|
|
334
|
-
if (pageToken) params.set('page_token', pageToken);
|
|
335
|
-
const data = await this._withUAT(async (uat) => {
|
|
336
|
-
const res = await fetchWithTimeout(`https://open.feishu.cn/open-apis/im/v1/chats?${params}`, {
|
|
337
|
-
headers: { 'Authorization': `Bearer ${uat}` },
|
|
338
|
-
});
|
|
339
|
-
return res.json();
|
|
340
|
-
});
|
|
341
|
-
if (data.code !== 0) throw new Error(`listChatsAsUser failed (${data.code}): ${data.msg}`);
|
|
342
|
-
return { items: data.data.items || [], pageToken: data.data.page_token, hasMore: data.data.has_more };
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
async readMessagesAsUser(chatId, { pageSize = 20, startTime, endTime, pageToken, sortType = 'ByCreateTimeDesc', expandMergeForward = true } = {}, userClient) {
|
|
346
|
-
// Feishu API requires end_time >= start_time; auto-set end_time to now if missing
|
|
347
|
-
if (startTime && !endTime) {
|
|
348
|
-
endTime = String(Math.floor(Date.now() / 1000));
|
|
349
|
-
}
|
|
350
|
-
const params = new URLSearchParams({
|
|
351
|
-
container_id_type: 'chat', container_id: chatId, page_size: String(pageSize),
|
|
352
|
-
sort_type: sortType,
|
|
353
|
-
});
|
|
354
|
-
if (startTime) params.set('start_time', startTime);
|
|
355
|
-
if (endTime) params.set('end_time', endTime);
|
|
356
|
-
if (pageToken) params.set('page_token', pageToken);
|
|
357
|
-
const data = await this._withUAT(async (uat) => {
|
|
358
|
-
const res = await fetchWithTimeout(`https://open.feishu.cn/open-apis/im/v1/messages?${params}`, {
|
|
359
|
-
headers: { 'Authorization': `Bearer ${uat}` },
|
|
360
|
-
});
|
|
361
|
-
return res.json();
|
|
362
|
-
});
|
|
363
|
-
if (data.code !== 0) throw new Error(`readMessagesAsUser failed (${data.code}): ${data.msg}`);
|
|
364
|
-
const items = (data.data.items || []).map(m => this._formatMessage(m));
|
|
365
|
-
await this._populateSenderNames(items, userClient);
|
|
366
|
-
if (expandMergeForward) await this._expandMergeForwardItems(items, userClient, { preferUAT: true });
|
|
367
|
-
return { items, hasMore: data.data.has_more, pageToken: data.data.page_token };
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
// --- IM ---
|
|
371
|
-
|
|
372
|
-
async listChats({ pageSize = 20, pageToken } = {}) {
|
|
373
|
-
const res = await this._safeSDKCall(
|
|
374
|
-
() => this.client.im.chat.list({ params: { page_size: pageSize, page_token: pageToken } }),
|
|
375
|
-
'listChats'
|
|
376
|
-
);
|
|
377
|
-
return { items: res.data.items || [], pageToken: res.data.page_token, hasMore: res.data.has_more };
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
async readMessages(chatId, { pageSize = 20, startTime, endTime, pageToken, sortType = 'ByCreateTimeDesc', expandMergeForward = true } = {}, userClient) {
|
|
381
|
-
const params = { container_id_type: 'chat', container_id: chatId, page_size: pageSize, sort_type: sortType };
|
|
382
|
-
if (startTime) params.start_time = startTime;
|
|
383
|
-
if (endTime) params.end_time = endTime;
|
|
384
|
-
if (pageToken) params.page_token = pageToken;
|
|
385
|
-
const res = await this._safeSDKCall(() => this.client.im.message.list({ params }), 'readMessages');
|
|
386
|
-
const items = (res.data.items || []).map(m => this._formatMessage(m));
|
|
387
|
-
await this._populateSenderNames(items, userClient);
|
|
388
|
-
if (expandMergeForward) await this._expandMergeForwardItems(items, userClient, { preferUAT: false });
|
|
389
|
-
return { items, hasMore: res.data.has_more, pageToken: res.data.page_token };
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
async getMessage(messageId) {
|
|
393
|
-
const res = await this._safeSDKCall(
|
|
394
|
-
() => this.client.im.message.get({ path: { message_id: messageId } }),
|
|
395
|
-
'getMessage'
|
|
396
|
-
);
|
|
397
|
-
return this._formatMessage(res.data);
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
// Download a resource (image/file) attached to a message.
|
|
401
|
-
// Tries UAT first (works for any chat the user is in), falls back to app token
|
|
402
|
-
// (requires the bot to be in the same chat — Feishu restriction).
|
|
403
|
-
// resourceType: 'image' | 'file'. Returns { base64, mimeType, viaUser }.
|
|
404
|
-
async downloadMessageResource(messageId, fileKey, resourceType = 'image') {
|
|
405
|
-
const path = `/open-apis/im/v1/messages/${encodeURIComponent(messageId)}/resources/${encodeURIComponent(fileKey)}?type=${encodeURIComponent(resourceType)}`;
|
|
406
|
-
const url = 'https://open.feishu.cn' + path;
|
|
407
|
-
|
|
408
|
-
// Attempt 1: user identity
|
|
409
|
-
if (this.hasUAT) {
|
|
410
|
-
try {
|
|
411
|
-
const uat = await this._getValidUAT();
|
|
412
|
-
const res = await fetchWithTimeout(url, {
|
|
413
|
-
headers: { 'Authorization': `Bearer ${uat}` },
|
|
414
|
-
timeoutMs: 60000,
|
|
415
|
-
});
|
|
416
|
-
if (res.ok && !res.headers.get('content-type')?.includes('application/json')) {
|
|
417
|
-
const buf = Buffer.from(await res.arrayBuffer());
|
|
418
|
-
return {
|
|
419
|
-
base64: buf.toString('base64'),
|
|
420
|
-
mimeType: res.headers.get('content-type') || 'application/octet-stream',
|
|
421
|
-
bytes: buf.length,
|
|
422
|
-
viaUser: true,
|
|
423
|
-
};
|
|
424
|
-
}
|
|
425
|
-
const errJson = await res.json().catch(() => null);
|
|
426
|
-
console.error(`[feishu-user-plugin] downloadMessageResource as user failed: ${errJson?.code}: ${errJson?.msg || res.statusText}, retrying as app`);
|
|
427
|
-
} catch (e) {
|
|
428
|
-
console.error(`[feishu-user-plugin] downloadMessageResource as user threw (${e.message}), retrying as app`);
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
// Attempt 2: app identity
|
|
433
|
-
const token = await this._getAppToken();
|
|
434
|
-
const res = await fetchWithTimeout(url, {
|
|
435
|
-
headers: { 'Authorization': `Bearer ${token}` },
|
|
436
|
-
timeoutMs: 60000,
|
|
437
|
-
});
|
|
438
|
-
if (!res.ok || res.headers.get('content-type')?.includes('application/json')) {
|
|
439
|
-
const errJson = await res.json().catch(() => null);
|
|
440
|
-
throw new Error(`downloadMessageResource failed: ${errJson?.code}: ${errJson?.msg || res.statusText}. Note: app identity requires the bot to be in the same chat.`);
|
|
441
|
-
}
|
|
442
|
-
const buf = Buffer.from(await res.arrayBuffer());
|
|
443
|
-
return {
|
|
444
|
-
base64: buf.toString('base64'),
|
|
445
|
-
mimeType: res.headers.get('content-type') || 'application/octet-stream',
|
|
446
|
-
bytes: buf.length,
|
|
447
|
-
viaUser: false,
|
|
448
|
-
};
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
async replyMessage(messageId, text, msgType = 'text') {
|
|
452
|
-
const content = msgType === 'text' ? JSON.stringify({ text }) : text;
|
|
453
|
-
const res = await this._safeSDKCall(
|
|
454
|
-
() => this.client.im.message.reply({ path: { message_id: messageId }, data: { content, msg_type: msgType } }),
|
|
455
|
-
'replyMessage'
|
|
456
|
-
);
|
|
457
|
-
return { messageId: res.data.message_id };
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
async forwardMessage(messageId, receiverId, receiveIdType = 'chat_id') {
|
|
461
|
-
const res = await this._safeSDKCall(
|
|
462
|
-
() => this.client.im.message.forward({
|
|
463
|
-
path: { message_id: messageId },
|
|
464
|
-
data: { receive_id: receiverId },
|
|
465
|
-
params: { receive_id_type: receiveIdType },
|
|
466
|
-
}),
|
|
467
|
-
'forwardMessage'
|
|
468
|
-
);
|
|
469
|
-
return { messageId: res.data.message_id };
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
// --- IM: Send (Bot Identity) ---
|
|
473
|
-
|
|
474
|
-
async sendMessageAsBot(chatId, msgType, content, receiveIdType = 'chat_id') {
|
|
475
|
-
const res = await this._safeSDKCall(
|
|
476
|
-
() => this.client.im.message.create({
|
|
477
|
-
params: { receive_id_type: receiveIdType },
|
|
478
|
-
data: { receive_id: chatId, msg_type: msgType, content: typeof content === 'string' ? content : JSON.stringify(content) },
|
|
479
|
-
}),
|
|
480
|
-
'sendMessage'
|
|
481
|
-
);
|
|
482
|
-
return { messageId: res.data.message_id };
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
async deleteMessage(messageId) {
|
|
486
|
-
await this._safeSDKCall(
|
|
487
|
-
() => this.client.im.message.delete({ path: { message_id: messageId } }),
|
|
488
|
-
'deleteMessage'
|
|
489
|
-
);
|
|
490
|
-
return { deleted: true };
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
async updateMessage(messageId, msgType, content) {
|
|
494
|
-
const res = await this._safeSDKCall(
|
|
495
|
-
() => this.client.im.message.patch({
|
|
496
|
-
path: { message_id: messageId },
|
|
497
|
-
data: { msg_type: msgType, content: typeof content === 'string' ? content : JSON.stringify(content) },
|
|
498
|
-
}),
|
|
499
|
-
'updateMessage'
|
|
500
|
-
);
|
|
501
|
-
return { messageId: res.data?.message_id || messageId };
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
// --- IM: Reactions ---
|
|
505
|
-
|
|
506
|
-
async addReaction(messageId, emojiType) {
|
|
507
|
-
const res = await this._safeSDKCall(
|
|
508
|
-
() => this.client.im.messageReaction.create({
|
|
509
|
-
path: { message_id: messageId },
|
|
510
|
-
data: { reaction_type: { emoji_type: emojiType } },
|
|
511
|
-
}),
|
|
512
|
-
'addReaction'
|
|
513
|
-
);
|
|
514
|
-
return { reactionId: res.data.reaction_id };
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
async deleteReaction(messageId, reactionId) {
|
|
518
|
-
await this._safeSDKCall(
|
|
519
|
-
() => this.client.im.messageReaction.delete({
|
|
520
|
-
path: { message_id: messageId, reaction_id: reactionId },
|
|
521
|
-
}),
|
|
522
|
-
'deleteReaction'
|
|
523
|
-
);
|
|
524
|
-
return { deleted: true };
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
// --- IM: Pins ---
|
|
528
|
-
|
|
529
|
-
async pinMessage(messageId, pinned = true) {
|
|
530
|
-
if (pinned) {
|
|
531
|
-
const res = await this._safeSDKCall(
|
|
532
|
-
() => this.client.im.pin.create({ data: { message_id: messageId } }),
|
|
533
|
-
'pinMessage'
|
|
534
|
-
);
|
|
535
|
-
return { pin: res.data.pin };
|
|
536
|
-
}
|
|
537
|
-
await this._safeSDKCall(
|
|
538
|
-
() => this.client.im.pin.delete({ data: { message_id: messageId } }),
|
|
539
|
-
'unpinMessage'
|
|
540
|
-
);
|
|
541
|
-
return { unpinned: true };
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
// --- IM: Chat Management ---
|
|
545
|
-
|
|
546
|
-
async createChat({ name, description, userIds, botIds } = {}) {
|
|
547
|
-
const data = {};
|
|
548
|
-
if (name) data.name = name;
|
|
549
|
-
if (description) data.description = description;
|
|
550
|
-
if (userIds) data.user_id_list = userIds;
|
|
551
|
-
if (botIds) data.bot_id_list = botIds;
|
|
552
|
-
const res = await this._safeSDKCall(
|
|
553
|
-
() => this.client.im.chat.create({ params: { user_id_type: 'open_id' }, data }),
|
|
554
|
-
'createChat'
|
|
555
|
-
);
|
|
556
|
-
return { chatId: res.data.chat_id };
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
async updateChat(chatId, { name, description } = {}) {
|
|
560
|
-
const data = {};
|
|
561
|
-
if (name) data.name = name;
|
|
562
|
-
if (description) data.description = description;
|
|
563
|
-
const res = await this._safeSDKCall(
|
|
564
|
-
() => this.client.im.chat.update({ path: { chat_id: chatId }, data }),
|
|
565
|
-
'updateChat'
|
|
566
|
-
);
|
|
567
|
-
return { updated: true };
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
async listChatMembers(chatId, { pageSize = 50, pageToken } = {}) {
|
|
571
|
-
const res = await this._safeSDKCall(
|
|
572
|
-
() => this.client.im.chatMembers.get({
|
|
573
|
-
path: { chat_id: chatId },
|
|
574
|
-
params: { member_id_type: 'open_id', page_size: pageSize, page_token: pageToken },
|
|
575
|
-
}),
|
|
576
|
-
'listChatMembers'
|
|
577
|
-
);
|
|
578
|
-
return { items: res.data.items || [], hasMore: res.data.has_more, pageToken: res.data.page_token };
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
async addChatMembers(chatId, userIds) {
|
|
582
|
-
const res = await this._safeSDKCall(
|
|
583
|
-
() => this.client.im.chatMembers.create({
|
|
584
|
-
path: { chat_id: chatId },
|
|
585
|
-
params: { member_id_type: 'open_id' },
|
|
586
|
-
data: { id_list: userIds },
|
|
587
|
-
}),
|
|
588
|
-
'addChatMembers'
|
|
589
|
-
);
|
|
590
|
-
return { invalidIds: res.data.invalid_id_list || [] };
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
async removeChatMembers(chatId, userIds) {
|
|
594
|
-
const res = await this._safeSDKCall(
|
|
595
|
-
() => this.client.im.chatMembers.delete({
|
|
596
|
-
path: { chat_id: chatId },
|
|
597
|
-
params: { member_id_type: 'open_id' },
|
|
598
|
-
data: { id_list: userIds },
|
|
599
|
-
}),
|
|
600
|
-
'removeChatMembers'
|
|
601
|
-
);
|
|
602
|
-
return { invalidIds: res.data.invalid_id_list || [] };
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
// --- Upload ---
|
|
606
|
-
|
|
607
|
-
async uploadImage(imagePath, imageType = 'message') {
|
|
608
|
-
const fs = require('fs');
|
|
609
|
-
const res = await this._safeSDKCall(
|
|
610
|
-
() => this.client.im.image.create({
|
|
611
|
-
data: { image_type: imageType, image: fs.createReadStream(imagePath) },
|
|
612
|
-
}),
|
|
613
|
-
'uploadImage'
|
|
614
|
-
);
|
|
615
|
-
// SDK multipart responses may have data at top level or nested under .data
|
|
616
|
-
const imageKey = res.data?.image_key || res.image_key;
|
|
617
|
-
if (!imageKey) throw new Error(`uploadImage: unexpected response structure: ${JSON.stringify(res).slice(0, 500)}`);
|
|
618
|
-
return { imageKey };
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
async uploadFile(filePath, fileType = 'stream', fileName) {
|
|
622
|
-
const fs = require('fs');
|
|
623
|
-
const path = require('path');
|
|
624
|
-
if (!fileName) fileName = path.basename(filePath);
|
|
625
|
-
const res = await this._safeSDKCall(
|
|
626
|
-
() => this.client.im.file.create({
|
|
627
|
-
data: {
|
|
628
|
-
file_type: fileType,
|
|
629
|
-
file_name: fileName,
|
|
630
|
-
file: fs.createReadStream(filePath),
|
|
631
|
-
},
|
|
632
|
-
}),
|
|
633
|
-
'uploadFile'
|
|
634
|
-
);
|
|
635
|
-
// SDK multipart responses may have data at top level or nested under .data
|
|
636
|
-
const fileKey = res.data?.file_key || res.file_key;
|
|
637
|
-
if (!fileKey) throw new Error(`uploadFile: unexpected response structure: ${JSON.stringify(res).slice(0, 500)}`);
|
|
638
|
-
return { fileKey };
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
// --- Docs ---
|
|
642
|
-
|
|
643
|
-
async searchDocs(query, { pageSize = 10, pageToken } = {}) {
|
|
644
|
-
const res = await this._safeSDKCall(
|
|
645
|
-
() => this.client.request({
|
|
646
|
-
method: 'POST', url: '/open-apis/suite/docs-api/search/object',
|
|
647
|
-
data: { search_key: query, count: pageSize, offset: pageToken ? parseInt(pageToken) : 0, owner_ids: [], chat_ids: [], docs_types: [] },
|
|
648
|
-
}),
|
|
649
|
-
'searchDocs'
|
|
650
|
-
);
|
|
651
|
-
return { items: res.data.docs_entities || [], hasMore: res.data.has_more };
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
async readDoc(documentId) {
|
|
655
|
-
const res = await this._asUserOrApp({
|
|
656
|
-
uatPath: `/open-apis/docx/v1/documents/${documentId}/raw_content`,
|
|
657
|
-
query: { lang: '0' },
|
|
658
|
-
sdkFn: () => this.client.docx.document.rawContent({ path: { document_id: documentId }, params: { lang: 0 } }),
|
|
659
|
-
label: 'readDoc',
|
|
660
|
-
});
|
|
661
|
-
return { content: res.data.content };
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
async createDoc(title, folderId, { wikiSpaceId, wikiParentNodeToken } = {}) {
|
|
665
|
-
const res = await this._asUserOrApp({
|
|
666
|
-
uatPath: `/open-apis/docx/v1/documents`,
|
|
667
|
-
method: 'POST',
|
|
668
|
-
body: { title, folder_token: folderId || '' },
|
|
669
|
-
sdkFn: () => this.client.docx.document.create({ data: { title, folder_token: folderId || '' } }),
|
|
670
|
-
label: 'createDoc',
|
|
671
|
-
});
|
|
672
|
-
const documentId = res.data.document?.document_id;
|
|
673
|
-
const out = { documentId, viaUser: !!res._viaUser, fallbackWarning: res._fallbackWarning || null };
|
|
674
|
-
if (documentId && wikiSpaceId) {
|
|
675
|
-
try {
|
|
676
|
-
const node = await this.attachToWiki(wikiSpaceId, 'docx', documentId, wikiParentNodeToken);
|
|
677
|
-
if (node?.node_token) out.wikiNodeToken = node.node_token;
|
|
678
|
-
else if (node?.task_id) out.wikiAttachTaskId = node.task_id;
|
|
679
|
-
} catch (e) {
|
|
680
|
-
out.wikiAttachError = e.message;
|
|
681
|
-
}
|
|
682
|
-
}
|
|
683
|
-
return out;
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
async getDocBlocks(documentId) {
|
|
687
|
-
const res = await this._asUserOrApp({
|
|
688
|
-
uatPath: `/open-apis/docx/v1/documents/${documentId}/blocks`,
|
|
689
|
-
query: { page_size: '500' },
|
|
690
|
-
sdkFn: () => this.client.docx.documentBlock.list({ path: { document_id: documentId }, params: { page_size: 500 } }),
|
|
691
|
-
label: 'getDocBlocks',
|
|
692
|
-
});
|
|
693
|
-
return { items: res.data.items || [] };
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
async createDocBlock(documentId, parentBlockId, children, index) {
|
|
697
|
-
const data = { children };
|
|
698
|
-
if (index !== undefined) data.index = index;
|
|
699
|
-
const res = await this._asUserOrApp({
|
|
700
|
-
uatPath: `/open-apis/docx/v1/documents/${documentId}/blocks/${parentBlockId}/children`,
|
|
701
|
-
method: 'POST',
|
|
702
|
-
body: data,
|
|
703
|
-
sdkFn: () => this.client.docx.documentBlockChildren.create({
|
|
704
|
-
path: { document_id: documentId, block_id: parentBlockId },
|
|
705
|
-
data,
|
|
706
|
-
}),
|
|
707
|
-
label: 'createDocBlock',
|
|
708
|
-
});
|
|
709
|
-
return { blocks: res.data.children || [], fallbackWarning: res._fallbackWarning || null };
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
async updateDocBlock(documentId, blockId, updateBody) {
|
|
713
|
-
const res = await this._asUserOrApp({
|
|
714
|
-
uatPath: `/open-apis/docx/v1/documents/${documentId}/blocks/${blockId}`,
|
|
715
|
-
method: 'PATCH',
|
|
716
|
-
body: updateBody,
|
|
717
|
-
sdkFn: () => this.client.docx.documentBlock.patch({
|
|
718
|
-
path: { document_id: documentId, block_id: blockId },
|
|
719
|
-
data: updateBody,
|
|
720
|
-
}),
|
|
721
|
-
label: 'updateDocBlock',
|
|
722
|
-
});
|
|
723
|
-
return { block: res.data.block };
|
|
724
|
-
}
|
|
725
|
-
|
|
726
|
-
async deleteDocBlocks(documentId, parentBlockId, startIndex, endIndex) {
|
|
727
|
-
await this._asUserOrApp({
|
|
728
|
-
uatPath: `/open-apis/docx/v1/documents/${documentId}/blocks/${parentBlockId}/children/batch_delete`,
|
|
729
|
-
method: 'DELETE',
|
|
730
|
-
body: { start_index: startIndex, end_index: endIndex },
|
|
731
|
-
sdkFn: () => this.client.docx.documentBlockChildren.batchDelete({
|
|
732
|
-
path: { document_id: documentId, block_id: parentBlockId },
|
|
733
|
-
data: { start_index: startIndex, end_index: endIndex },
|
|
734
|
-
}),
|
|
735
|
-
label: 'deleteDocBlocks',
|
|
736
|
-
});
|
|
737
|
-
return { deleted: true };
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
// --- Chat Info (Official API) ---
|
|
741
|
-
|
|
742
|
-
async getChatInfo(chatId) {
|
|
743
|
-
const res = await this._safeSDKCall(
|
|
744
|
-
() => this.client.im.chat.get({ path: { chat_id: chatId } }),
|
|
745
|
-
'getChatInfo'
|
|
746
|
-
);
|
|
747
|
-
return res.data;
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
// --- Bitable ---
|
|
751
|
-
|
|
752
|
-
async createBitable(name, folderId, { wikiSpaceId, wikiParentNodeToken } = {}) {
|
|
753
|
-
const data = {};
|
|
754
|
-
if (name) data.name = name;
|
|
755
|
-
if (folderId) data.folder_token = folderId;
|
|
756
|
-
const res = await this._asUserOrApp({
|
|
757
|
-
uatPath: `/open-apis/bitable/v1/apps`,
|
|
758
|
-
method: 'POST',
|
|
759
|
-
body: data,
|
|
760
|
-
sdkFn: () => this.client.bitable.app.create({ data }),
|
|
761
|
-
label: 'createBitable',
|
|
762
|
-
});
|
|
763
|
-
const appToken = res.data.app?.app_token;
|
|
764
|
-
const out = { appToken, name: res.data.app?.name, url: res.data.app?.url, viaUser: !!res._viaUser, fallbackWarning: res._fallbackWarning || null };
|
|
765
|
-
if (appToken && wikiSpaceId) {
|
|
766
|
-
try {
|
|
767
|
-
const node = await this.attachToWiki(wikiSpaceId, 'bitable', appToken, wikiParentNodeToken);
|
|
768
|
-
if (node?.node_token) out.wikiNodeToken = node.node_token;
|
|
769
|
-
else if (node?.task_id) out.wikiAttachTaskId = node.task_id;
|
|
770
|
-
} catch (e) {
|
|
771
|
-
out.wikiAttachError = e.message;
|
|
772
|
-
}
|
|
773
|
-
}
|
|
774
|
-
return out;
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
async listBitableTables(appToken) {
|
|
778
|
-
const res = await this._asUserOrApp({
|
|
779
|
-
uatPath: `/open-apis/bitable/v1/apps/${appToken}/tables`,
|
|
780
|
-
sdkFn: () => this.client.bitable.appTable.list({ path: { app_token: appToken } }),
|
|
781
|
-
label: 'listTables',
|
|
782
|
-
});
|
|
783
|
-
return { items: res.data.items || [] };
|
|
784
|
-
}
|
|
785
|
-
|
|
786
|
-
async createBitableTable(appToken, name, fields) {
|
|
787
|
-
const data = { table: { name } };
|
|
788
|
-
if (fields && fields.length > 0) data.table.default_view_name = name;
|
|
789
|
-
if (fields && fields.length > 0) data.table.fields = fields;
|
|
790
|
-
const res = await this._asUserOrApp({
|
|
791
|
-
uatPath: `/open-apis/bitable/v1/apps/${appToken}/tables`,
|
|
792
|
-
method: 'POST',
|
|
793
|
-
body: data,
|
|
794
|
-
sdkFn: () => this.client.bitable.appTable.create({ path: { app_token: appToken }, data }),
|
|
795
|
-
label: 'createTable',
|
|
796
|
-
});
|
|
797
|
-
return { tableId: res.data.table_id, fallbackWarning: res._fallbackWarning || null };
|
|
798
|
-
}
|
|
799
|
-
|
|
800
|
-
async listBitableFields(appToken, tableId) {
|
|
801
|
-
const res = await this._asUserOrApp({
|
|
802
|
-
uatPath: `/open-apis/bitable/v1/apps/${appToken}/tables/${tableId}/fields`,
|
|
803
|
-
sdkFn: () => this.client.bitable.appTableField.list({ path: { app_token: appToken, table_id: tableId } }),
|
|
804
|
-
label: 'listFields',
|
|
805
|
-
});
|
|
806
|
-
return { items: res.data.items || [] };
|
|
807
|
-
}
|
|
808
|
-
|
|
809
|
-
async createBitableField(appToken, tableId, fieldConfig) {
|
|
810
|
-
const res = await this._asUserOrApp({
|
|
811
|
-
uatPath: `/open-apis/bitable/v1/apps/${appToken}/tables/${tableId}/fields`,
|
|
812
|
-
method: 'POST',
|
|
813
|
-
body: fieldConfig,
|
|
814
|
-
sdkFn: () => this.client.bitable.appTableField.create({ path: { app_token: appToken, table_id: tableId }, data: fieldConfig }),
|
|
815
|
-
label: 'createField',
|
|
816
|
-
});
|
|
817
|
-
return { field: res.data.field, fallbackWarning: res._fallbackWarning || null };
|
|
818
|
-
}
|
|
819
|
-
|
|
820
|
-
async updateBitableField(appToken, tableId, fieldId, fieldConfig) {
|
|
821
|
-
const res = await this._asUserOrApp({
|
|
822
|
-
uatPath: `/open-apis/bitable/v1/apps/${appToken}/tables/${tableId}/fields/${fieldId}`,
|
|
823
|
-
method: 'PUT',
|
|
824
|
-
body: fieldConfig,
|
|
825
|
-
sdkFn: () => this.client.bitable.appTableField.update({ path: { app_token: appToken, table_id: tableId, field_id: fieldId }, data: fieldConfig }),
|
|
826
|
-
label: 'updateField',
|
|
827
|
-
});
|
|
828
|
-
return { field: res.data.field };
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
async deleteBitableField(appToken, tableId, fieldId) {
|
|
832
|
-
const res = await this._asUserOrApp({
|
|
833
|
-
uatPath: `/open-apis/bitable/v1/apps/${appToken}/tables/${tableId}/fields/${fieldId}`,
|
|
834
|
-
method: 'DELETE',
|
|
835
|
-
sdkFn: () => this.client.bitable.appTableField.delete({ path: { app_token: appToken, table_id: tableId, field_id: fieldId } }),
|
|
836
|
-
label: 'deleteField',
|
|
837
|
-
});
|
|
838
|
-
return { fieldId: res.data.field_id, deleted: res.data.deleted };
|
|
839
|
-
}
|
|
840
|
-
|
|
841
|
-
async searchBitableRecords(appToken, tableId, { filter, sort, pageSize = 20, pageToken } = {}) {
|
|
842
|
-
const data = {};
|
|
843
|
-
if (filter) data.filter = filter;
|
|
844
|
-
if (sort) data.sort = sort;
|
|
845
|
-
const query = {};
|
|
846
|
-
if (pageSize) query.page_size = String(pageSize);
|
|
847
|
-
if (pageToken) query.page_token = pageToken;
|
|
848
|
-
const res = await this._asUserOrApp({
|
|
849
|
-
uatPath: `/open-apis/bitable/v1/apps/${appToken}/tables/${tableId}/records/search`,
|
|
850
|
-
method: 'POST',
|
|
851
|
-
body: data,
|
|
852
|
-
query,
|
|
853
|
-
sdkFn: () => this.client.bitable.appTableRecord.search({
|
|
854
|
-
path: { app_token: appToken, table_id: tableId },
|
|
855
|
-
params: { page_size: pageSize, ...(pageToken ? { page_token: pageToken } : {}) },
|
|
856
|
-
data,
|
|
857
|
-
}),
|
|
858
|
-
label: 'searchRecords',
|
|
859
|
-
});
|
|
860
|
-
return { items: res.data.items || [], total: res.data.total, hasMore: res.data.has_more };
|
|
861
|
-
}
|
|
862
|
-
|
|
863
|
-
async createBitableRecord(appToken, tableId, fields) {
|
|
864
|
-
const res = await this._asUserOrApp({
|
|
865
|
-
uatPath: `/open-apis/bitable/v1/apps/${appToken}/tables/${tableId}/records`,
|
|
866
|
-
method: 'POST',
|
|
867
|
-
body: { fields },
|
|
868
|
-
sdkFn: () => this.client.bitable.appTableRecord.create({ path: { app_token: appToken, table_id: tableId }, data: { fields } }),
|
|
869
|
-
label: 'createRecord',
|
|
870
|
-
});
|
|
871
|
-
return { recordId: res.data.record?.record_id, fallbackWarning: res._fallbackWarning || null };
|
|
872
|
-
}
|
|
873
|
-
|
|
874
|
-
async updateBitableRecord(appToken, tableId, recordId, fields) {
|
|
875
|
-
const res = await this._asUserOrApp({
|
|
876
|
-
uatPath: `/open-apis/bitable/v1/apps/${appToken}/tables/${tableId}/records/${recordId}`,
|
|
877
|
-
method: 'PUT',
|
|
878
|
-
body: { fields },
|
|
879
|
-
sdkFn: () => this.client.bitable.appTableRecord.update({ path: { app_token: appToken, table_id: tableId, record_id: recordId }, data: { fields } }),
|
|
880
|
-
label: 'updateRecord',
|
|
881
|
-
});
|
|
882
|
-
return { recordId: res.data.record?.record_id };
|
|
883
|
-
}
|
|
884
|
-
|
|
885
|
-
async deleteBitableRecord(appToken, tableId, recordId) {
|
|
886
|
-
const res = await this._asUserOrApp({
|
|
887
|
-
uatPath: `/open-apis/bitable/v1/apps/${appToken}/tables/${tableId}/records/${recordId}`,
|
|
888
|
-
method: 'DELETE',
|
|
889
|
-
sdkFn: () => this.client.bitable.appTableRecord.delete({ path: { app_token: appToken, table_id: tableId, record_id: recordId } }),
|
|
890
|
-
label: 'deleteRecord',
|
|
891
|
-
});
|
|
892
|
-
return { deleted: res.data.deleted };
|
|
893
|
-
}
|
|
894
|
-
|
|
895
|
-
async batchCreateBitableRecords(appToken, tableId, records) {
|
|
896
|
-
const res = await this._asUserOrApp({
|
|
897
|
-
uatPath: `/open-apis/bitable/v1/apps/${appToken}/tables/${tableId}/records/batch_create`,
|
|
898
|
-
method: 'POST',
|
|
899
|
-
body: { records },
|
|
900
|
-
sdkFn: () => this.client.bitable.appTableRecord.batchCreate({ path: { app_token: appToken, table_id: tableId }, data: { records } }),
|
|
901
|
-
label: 'batchCreateRecords',
|
|
902
|
-
});
|
|
903
|
-
return { records: res.data.records || [], fallbackWarning: res._fallbackWarning || null };
|
|
904
|
-
}
|
|
905
|
-
|
|
906
|
-
async batchUpdateBitableRecords(appToken, tableId, records) {
|
|
907
|
-
const res = await this._asUserOrApp({
|
|
908
|
-
uatPath: `/open-apis/bitable/v1/apps/${appToken}/tables/${tableId}/records/batch_update`,
|
|
909
|
-
method: 'POST',
|
|
910
|
-
body: { records },
|
|
911
|
-
sdkFn: () => this.client.bitable.appTableRecord.batchUpdate({ path: { app_token: appToken, table_id: tableId }, data: { records } }),
|
|
912
|
-
label: 'batchUpdateRecords',
|
|
913
|
-
});
|
|
914
|
-
return { records: res.data.records || [] };
|
|
915
|
-
}
|
|
916
|
-
|
|
917
|
-
async batchDeleteBitableRecords(appToken, tableId, recordIds) {
|
|
918
|
-
const res = await this._asUserOrApp({
|
|
919
|
-
uatPath: `/open-apis/bitable/v1/apps/${appToken}/tables/${tableId}/records/batch_delete`,
|
|
920
|
-
method: 'POST',
|
|
921
|
-
body: { records: recordIds },
|
|
922
|
-
sdkFn: () => this.client.bitable.appTableRecord.batchDelete({ path: { app_token: appToken, table_id: tableId }, data: { records: recordIds } }),
|
|
923
|
-
label: 'batchDeleteRecords',
|
|
924
|
-
});
|
|
925
|
-
return { records: res.data.records || [] };
|
|
926
|
-
}
|
|
927
|
-
|
|
928
|
-
async listBitableViews(appToken, tableId) {
|
|
929
|
-
const res = await this._asUserOrApp({
|
|
930
|
-
uatPath: `/open-apis/bitable/v1/apps/${appToken}/tables/${tableId}/views`,
|
|
931
|
-
query: { page_size: '50' },
|
|
932
|
-
sdkFn: () => this.client.bitable.appTableView.list({ path: { app_token: appToken, table_id: tableId }, params: { page_size: 50 } }),
|
|
933
|
-
label: 'listViews',
|
|
934
|
-
});
|
|
935
|
-
return { items: res.data.items || [] };
|
|
936
|
-
}
|
|
937
|
-
|
|
938
|
-
async getBitableRecord(appToken, tableId, recordId) {
|
|
939
|
-
const res = await this._asUserOrApp({
|
|
940
|
-
uatPath: `/open-apis/bitable/v1/apps/${appToken}/tables/${tableId}/records/${recordId}`,
|
|
941
|
-
sdkFn: () => this.client.bitable.appTableRecord.get({ path: { app_token: appToken, table_id: tableId, record_id: recordId } }),
|
|
942
|
-
label: 'getRecord',
|
|
943
|
-
});
|
|
944
|
-
return { record: res.data.record };
|
|
945
|
-
}
|
|
946
|
-
|
|
947
|
-
async deleteBitableTable(appToken, tableId) {
|
|
948
|
-
await this._asUserOrApp({
|
|
949
|
-
uatPath: `/open-apis/bitable/v1/apps/${appToken}/tables/${tableId}`,
|
|
950
|
-
method: 'DELETE',
|
|
951
|
-
sdkFn: () => this.client.bitable.appTable.delete({ path: { app_token: appToken, table_id: tableId } }),
|
|
952
|
-
label: 'deleteTable',
|
|
953
|
-
});
|
|
954
|
-
return { deleted: true };
|
|
955
|
-
}
|
|
956
|
-
|
|
957
|
-
async getBitableMeta(appToken) {
|
|
958
|
-
const res = await this._asUserOrApp({
|
|
959
|
-
uatPath: `/open-apis/bitable/v1/apps/${appToken}`,
|
|
960
|
-
sdkFn: () => this.client.bitable.app.get({ path: { app_token: appToken } }),
|
|
961
|
-
label: 'getBitableMeta',
|
|
962
|
-
});
|
|
963
|
-
return { app: res.data.app };
|
|
964
|
-
}
|
|
965
|
-
|
|
966
|
-
async updateBitableTable(appToken, tableId, name) {
|
|
967
|
-
const res = await this._asUserOrApp({
|
|
968
|
-
uatPath: `/open-apis/bitable/v1/apps/${appToken}/tables/${tableId}`,
|
|
969
|
-
method: 'PATCH',
|
|
970
|
-
body: { name },
|
|
971
|
-
sdkFn: () => this.client.bitable.appTable.patch({ path: { app_token: appToken, table_id: tableId }, data: { name } }),
|
|
972
|
-
label: 'updateTable',
|
|
973
|
-
});
|
|
974
|
-
return { name: res.data.name };
|
|
975
|
-
}
|
|
976
|
-
|
|
977
|
-
async createBitableView(appToken, tableId, viewName, viewType = 'grid') {
|
|
978
|
-
const res = await this._asUserOrApp({
|
|
979
|
-
uatPath: `/open-apis/bitable/v1/apps/${appToken}/tables/${tableId}/views`,
|
|
980
|
-
method: 'POST',
|
|
981
|
-
body: { view_name: viewName, view_type: viewType },
|
|
982
|
-
sdkFn: () => this.client.bitable.appTableView.create({ path: { app_token: appToken, table_id: tableId }, data: { view_name: viewName, view_type: viewType } }),
|
|
983
|
-
label: 'createView',
|
|
984
|
-
});
|
|
985
|
-
return { view: res.data.view, fallbackWarning: res._fallbackWarning || null };
|
|
986
|
-
}
|
|
987
|
-
|
|
988
|
-
async deleteBitableView(appToken, tableId, viewId) {
|
|
989
|
-
await this._asUserOrApp({
|
|
990
|
-
uatPath: `/open-apis/bitable/v1/apps/${appToken}/tables/${tableId}/views/${viewId}`,
|
|
991
|
-
method: 'DELETE',
|
|
992
|
-
sdkFn: () => this.client.bitable.appTableView.delete({ path: { app_token: appToken, table_id: tableId, view_id: viewId } }),
|
|
993
|
-
label: 'deleteView',
|
|
994
|
-
});
|
|
995
|
-
return { deleted: true };
|
|
996
|
-
}
|
|
997
|
-
|
|
998
|
-
async copyBitable(appToken, name, folderId) {
|
|
999
|
-
const data = { name };
|
|
1000
|
-
if (folderId) data.folder_token = folderId;
|
|
1001
|
-
const res = await this._asUserOrApp({
|
|
1002
|
-
uatPath: `/open-apis/bitable/v1/apps/${appToken}/copy`,
|
|
1003
|
-
method: 'POST',
|
|
1004
|
-
body: data,
|
|
1005
|
-
sdkFn: () => this.client.bitable.app.copy({ path: { app_token: appToken }, data }),
|
|
1006
|
-
label: 'copyBitable',
|
|
1007
|
-
});
|
|
1008
|
-
return { app: res.data.app, fallbackWarning: res._fallbackWarning || null };
|
|
1009
|
-
}
|
|
1010
|
-
|
|
1011
|
-
// --- Wiki ---
|
|
1012
|
-
|
|
1013
|
-
async listWikiSpaces() {
|
|
1014
|
-
const res = await this._safeSDKCall(() => this.client.wiki.space.list({ params: { page_size: 50 } }), 'listSpaces');
|
|
1015
|
-
return { items: res.data.items || [] };
|
|
1016
|
-
}
|
|
1017
|
-
|
|
1018
|
-
async searchWiki(query) {
|
|
1019
|
-
const res = await this._safeSDKCall(
|
|
1020
|
-
() => this.client.request({ method: 'POST', url: '/open-apis/suite/docs-api/search/object', data: { search_key: query, count: 20, offset: 0, owner_ids: [], chat_ids: [], docs_types: ['wiki'] } }),
|
|
1021
|
-
'searchWiki'
|
|
1022
|
-
);
|
|
1023
|
-
return { items: res.data.docs_entities || [] };
|
|
1024
|
-
}
|
|
1025
|
-
|
|
1026
|
-
// Resolves a wiki node token to its underlying object (docx / sheet / bitable / ...).
|
|
1027
|
-
// `spaceId` argument is kept for backward compatibility but isn't used — the Feishu
|
|
1028
|
-
// endpoint `wiki.v2.getNode` takes only the token.
|
|
1029
|
-
async getWikiNode(nodeToken, _spaceId) {
|
|
1030
|
-
const res = await this._safeSDKCall(() => this.client.wiki.space.getNode({ params: { token: nodeToken } }), 'getNode');
|
|
1031
|
-
return res.data.node;
|
|
1032
|
-
}
|
|
1033
|
-
|
|
1034
|
-
async listWikiNodes(spaceId, { parentNodeToken, pageToken } = {}) {
|
|
1035
|
-
const params = { page_size: 50 };
|
|
1036
|
-
if (parentNodeToken) params.parent_node_token = parentNodeToken;
|
|
1037
|
-
if (pageToken) params.page_token = pageToken;
|
|
1038
|
-
const res = await this._safeSDKCall(
|
|
1039
|
-
() => this.client.wiki.spaceNode.list({ path: { space_id: spaceId }, params }),
|
|
1040
|
-
'listNodes'
|
|
1041
|
-
);
|
|
1042
|
-
return { items: res.data.items || [], hasMore: res.data.has_more };
|
|
1043
|
-
}
|
|
1044
|
-
|
|
1045
|
-
// --- Drive ---
|
|
1046
|
-
|
|
1047
|
-
async listFiles(folderToken, { pageSize = 50, pageToken } = {}) {
|
|
1048
|
-
const params = { page_size: pageSize, folder_token: folderToken || '' };
|
|
1049
|
-
if (pageToken) params.page_token = pageToken;
|
|
1050
|
-
const res = await this._safeSDKCall(() => this.client.drive.file.list({ params }), 'listFiles');
|
|
1051
|
-
return { items: res.data.files || [], hasMore: res.data.has_more };
|
|
1052
|
-
}
|
|
1053
|
-
|
|
1054
|
-
async createFolder(name, parentToken) {
|
|
1055
|
-
const body = { name, folder_token: parentToken || '' };
|
|
1056
|
-
const res = await this._asUserOrApp({
|
|
1057
|
-
uatPath: `/open-apis/drive/v1/files/create_folder`,
|
|
1058
|
-
method: 'POST',
|
|
1059
|
-
body,
|
|
1060
|
-
sdkFn: () => this.client.drive.file.createFolder({ data: body }),
|
|
1061
|
-
label: 'createFolder',
|
|
1062
|
-
});
|
|
1063
|
-
return { token: res.data.token, viaUser: !!res._viaUser, fallbackWarning: res._fallbackWarning || null };
|
|
1064
|
-
}
|
|
1065
|
-
|
|
1066
|
-
// --- Drive: File Operations ---
|
|
1067
|
-
|
|
1068
|
-
async copyFile(fileToken, name, folderToken, type) {
|
|
1069
|
-
const data = { name, folder_token: folderToken || '' };
|
|
1070
|
-
if (type) data.type = type;
|
|
1071
|
-
const res = await this._safeSDKCall(
|
|
1072
|
-
() => this.client.drive.file.copy({ path: { file_token: fileToken }, data }),
|
|
1073
|
-
'copyFile'
|
|
1074
|
-
);
|
|
1075
|
-
return { file: res.data.file };
|
|
1076
|
-
}
|
|
1077
|
-
|
|
1078
|
-
async moveFile(fileToken, folderToken) {
|
|
1079
|
-
const res = await this._safeSDKCall(
|
|
1080
|
-
() => this.client.drive.file.move({ path: { file_token: fileToken }, data: { folder_token: folderToken || '' } }),
|
|
1081
|
-
'moveFile'
|
|
1082
|
-
);
|
|
1083
|
-
return { taskId: res.data.task_id };
|
|
1084
|
-
}
|
|
1085
|
-
|
|
1086
|
-
async deleteFile(fileToken, type) {
|
|
1087
|
-
const res = await this._safeSDKCall(
|
|
1088
|
-
() => this.client.drive.file.delete({ path: { file_token: fileToken }, params: { type: type || 'file' } }),
|
|
1089
|
-
'deleteFile'
|
|
1090
|
-
);
|
|
1091
|
-
return { taskId: res.data.task_id };
|
|
1092
|
-
}
|
|
1093
|
-
|
|
1094
|
-
// --- Contact ---
|
|
1095
|
-
|
|
1096
|
-
async findUserByIdentity({ emails, mobiles } = {}) {
|
|
1097
|
-
const data = {};
|
|
1098
|
-
if (emails) data.emails = Array.isArray(emails) ? emails : [emails];
|
|
1099
|
-
if (mobiles) data.mobiles = Array.isArray(mobiles) ? mobiles : [mobiles];
|
|
1100
|
-
const res = await this._safeSDKCall(
|
|
1101
|
-
() => this.client.contact.user.batchGetId({ data, params: { user_id_type: 'open_id' } }),
|
|
1102
|
-
'findUser'
|
|
1103
|
-
);
|
|
1104
|
-
return { userList: res.data.user_list || [] };
|
|
1105
|
-
}
|
|
1106
|
-
|
|
1107
|
-
// --- Chat ID Resolution ---
|
|
1108
|
-
|
|
1109
|
-
async listAllChats() {
|
|
1110
|
-
const allChats = [];
|
|
1111
|
-
let pageToken;
|
|
1112
|
-
let hasMore = true;
|
|
1113
|
-
while (hasMore) {
|
|
1114
|
-
const res = await this._safeSDKCall(
|
|
1115
|
-
() => this.client.im.chat.list({ params: { page_size: 100, page_token: pageToken } }),
|
|
1116
|
-
'listAllChats'
|
|
1117
|
-
);
|
|
1118
|
-
allChats.push(...(res.data.items || []));
|
|
1119
|
-
pageToken = res.data.page_token;
|
|
1120
|
-
hasMore = res.data.has_more && !!pageToken;
|
|
1121
|
-
}
|
|
1122
|
-
return allChats;
|
|
1123
|
-
}
|
|
1124
|
-
|
|
1125
|
-
// --- Safe SDK Call (extracts real Feishu error from AxiosError) ---
|
|
1126
|
-
|
|
1127
|
-
async _safeSDKCall(fn, label = 'API') {
|
|
1128
|
-
try {
|
|
1129
|
-
const res = await fn();
|
|
1130
|
-
// SDK returns abbreviated responses for multipart uploads (code/msg undefined)
|
|
1131
|
-
// Only treat as error if code is explicitly non-zero
|
|
1132
|
-
if (res.code !== undefined && res.code !== 0) throw new Error(`${label} failed (${res.code}): ${res.msg}`);
|
|
1133
|
-
return res;
|
|
1134
|
-
} catch (err) {
|
|
1135
|
-
// Lark SDK uses axios; extract actual Feishu error from response body
|
|
1136
|
-
if (err.response?.data) {
|
|
1137
|
-
const d = err.response.data;
|
|
1138
|
-
const code = d.code ?? d.error ?? 'unknown';
|
|
1139
|
-
const msg = d.msg ?? d.error_description ?? d.message ?? JSON.stringify(d);
|
|
1140
|
-
throw new Error(`${label} failed (HTTP ${err.response.status}, code=${code}): ${msg}`);
|
|
1141
|
-
}
|
|
1142
|
-
throw err;
|
|
1143
|
-
}
|
|
1144
|
-
}
|
|
1145
|
-
|
|
1146
|
-
// --- Chat Search (keyword-based, works even if bot isn't in the group's list) ---
|
|
1147
|
-
|
|
1148
|
-
async chatSearch(query) {
|
|
1149
|
-
const res = await this._safeSDKCall(
|
|
1150
|
-
() => this.client.im.chat.search({ params: { query, page_size: 20 } }),
|
|
1151
|
-
'chatSearch'
|
|
1152
|
-
);
|
|
1153
|
-
return res.data.items || [];
|
|
1154
|
-
}
|
|
1155
|
-
|
|
1156
|
-
// --- User Name Resolution ---
|
|
1157
|
-
|
|
1158
|
-
async getUserById(userId, userIdType = 'open_id') {
|
|
1159
|
-
if (this._userNameCache.has(userId)) return this._userNameCache.get(userId);
|
|
1160
|
-
try {
|
|
1161
|
-
const res = await this.client.contact.user.get({
|
|
1162
|
-
path: { user_id: userId },
|
|
1163
|
-
params: { user_id_type: userIdType },
|
|
1164
|
-
});
|
|
1165
|
-
if (res.code === 0 && res.data?.user?.name) {
|
|
1166
|
-
this._userNameCache.set(userId, res.data.user.name);
|
|
1167
|
-
return res.data.user.name;
|
|
1168
|
-
}
|
|
1169
|
-
} catch {}
|
|
1170
|
-
return null;
|
|
1171
|
-
}
|
|
1172
|
-
|
|
1173
|
-
async _populateSenderNames(items, userClient) {
|
|
1174
|
-
// Collect unique sender IDs that aren't cached
|
|
1175
|
-
const unknownIds = new Set();
|
|
1176
|
-
for (const item of items) {
|
|
1177
|
-
if (item.senderId && !this._userNameCache.has(item.senderId)) {
|
|
1178
|
-
unknownIds.add(item.senderId);
|
|
1179
|
-
}
|
|
1180
|
-
}
|
|
1181
|
-
// Parallel resolve via official contact API (instead of sequential N calls)
|
|
1182
|
-
if (unknownIds.size > 0) {
|
|
1183
|
-
await Promise.allSettled([...unknownIds].map(id => this.getUserById(id)));
|
|
1184
|
-
}
|
|
1185
|
-
// Fallback: resolve remaining unknowns via cookie-based user identity client
|
|
1186
|
-
if (userClient) {
|
|
1187
|
-
for (const id of unknownIds) {
|
|
1188
|
-
if (!this._userNameCache.has(id)) {
|
|
1189
|
-
try {
|
|
1190
|
-
const name = await userClient.getUserName(id);
|
|
1191
|
-
if (name) this._userNameCache.set(id, name);
|
|
1192
|
-
} catch {}
|
|
1193
|
-
}
|
|
1194
|
-
}
|
|
1195
|
-
}
|
|
1196
|
-
// Populate senderName field
|
|
1197
|
-
for (const item of items) {
|
|
1198
|
-
if (item.senderId) {
|
|
1199
|
-
item.senderName = this._userNameCache.get(item.senderId) || null;
|
|
1200
|
-
}
|
|
1201
|
-
}
|
|
1202
|
-
}
|
|
1203
|
-
|
|
1204
|
-
// --- Helpers ---
|
|
1205
|
-
|
|
1206
|
-
_formatMessage(m) {
|
|
1207
|
-
if (!m) return null;
|
|
1208
|
-
let body = m.body?.content || '';
|
|
1209
|
-
try { body = JSON.parse(body); } catch {}
|
|
1210
|
-
const out = {
|
|
1211
|
-
messageId: m.message_id,
|
|
1212
|
-
chatId: m.chat_id,
|
|
1213
|
-
senderId: m.sender?.id,
|
|
1214
|
-
senderType: m.sender?.sender_type,
|
|
1215
|
-
msgType: m.msg_type,
|
|
1216
|
-
content: body,
|
|
1217
|
-
createTime: this._normalizeTimestamp(m.create_time),
|
|
1218
|
-
updateTime: this._normalizeTimestamp(m.update_time),
|
|
1219
|
-
};
|
|
1220
|
-
if (Array.isArray(m.mentions) && m.mentions.length > 0) out.mentions = m.mentions;
|
|
1221
|
-
if (m.upper_message_id) out.upperMessageId = m.upper_message_id;
|
|
1222
|
-
if (m.root_id) out.rootId = m.root_id;
|
|
1223
|
-
if (m.parent_id) out.parentId = m.parent_id;
|
|
1224
|
-
// Extract URL-like strings from text bodies so agents can call WebFetch /
|
|
1225
|
-
// read_doc / get_doc_blocks without having to regex the body themselves.
|
|
1226
|
-
if (out.msgType === 'text' && typeof body?.text === 'string') {
|
|
1227
|
-
const urls = body.text.match(/https?:\/\/[^\s一-鿿]+/g);
|
|
1228
|
-
if (urls && urls.length > 0) {
|
|
1229
|
-
out.urls = Array.from(new Set(urls));
|
|
1230
|
-
const feishuDocs = out.urls.filter(u =>
|
|
1231
|
-
/feishu\.cn\/(?:docx|wiki|base|sheets|docs|mindnotes)\//i.test(u));
|
|
1232
|
-
if (feishuDocs.length > 0) out.feishuDocs = feishuDocs;
|
|
1233
|
-
}
|
|
1234
|
-
}
|
|
1235
|
-
return out;
|
|
1236
|
-
}
|
|
1237
|
-
|
|
1238
|
-
// Fetch the child messages inside a merge_forward parent. Feishu exposes them
|
|
1239
|
-
// via `/im/v1/messages/{parent_id}` (single-message GET). The response is
|
|
1240
|
-
// actually a list: items[0] is the parent merge_forward placeholder,
|
|
1241
|
-
// items[1..N] are the children carrying `upper_message_id` pointing back to
|
|
1242
|
-
// the parent and `chat_id` pointing to their ORIGIN chat (the one being
|
|
1243
|
-
// forwarded from, not where the merge_forward was posted).
|
|
1244
|
-
//
|
|
1245
|
-
// Media resources (image_key / file_key) on children must be downloaded
|
|
1246
|
-
// using the PARENT message id — a Feishu quirk: downloading with the child
|
|
1247
|
-
// id returns "File not in msg".
|
|
1248
|
-
async readMergeForwardChildren(parentMessageId, userClient, { preferUAT = true } = {}) {
|
|
1249
|
-
const url = `https://open.feishu.cn/open-apis/im/v1/messages/${encodeURIComponent(parentMessageId)}`;
|
|
1250
|
-
|
|
1251
|
-
const tryPath = async (bearer) => {
|
|
1252
|
-
const res = await fetchWithTimeout(url, {
|
|
1253
|
-
headers: { 'Authorization': `Bearer ${bearer}` },
|
|
1254
|
-
timeoutMs: 30000,
|
|
1255
|
-
});
|
|
1256
|
-
return res.json();
|
|
1257
|
-
};
|
|
1258
|
-
|
|
1259
|
-
let data = null;
|
|
1260
|
-
const order = preferUAT ? ['uat', 'bot'] : ['bot', 'uat'];
|
|
1261
|
-
const errors = [];
|
|
1262
|
-
for (const identity of order) {
|
|
1263
|
-
try {
|
|
1264
|
-
if (identity === 'uat') {
|
|
1265
|
-
if (!this.hasUAT) { errors.push('uat: not configured'); continue; }
|
|
1266
|
-
const uat = await this._getValidUAT();
|
|
1267
|
-
const resp = await tryPath(uat);
|
|
1268
|
-
if (resp.code === 0) { data = resp; break; }
|
|
1269
|
-
errors.push(`uat: code=${resp.code} msg=${resp.msg}`);
|
|
1270
|
-
} else {
|
|
1271
|
-
const tat = await this._getAppToken();
|
|
1272
|
-
const resp = await tryPath(tat);
|
|
1273
|
-
if (resp.code === 0) { data = resp; break; }
|
|
1274
|
-
errors.push(`bot: code=${resp.code} msg=${resp.msg}`);
|
|
1275
|
-
}
|
|
1276
|
-
} catch (e) {
|
|
1277
|
-
errors.push(`${identity}: ${e.message}`);
|
|
1278
|
-
}
|
|
1279
|
-
}
|
|
1280
|
-
if (!data) {
|
|
1281
|
-
throw new Error(`readMergeForwardChildren failed: ${errors.join(' | ')}`);
|
|
1282
|
-
}
|
|
1283
|
-
|
|
1284
|
-
// items[0] is the parent itself — filter it out. The rest are children.
|
|
1285
|
-
const rawChildren = (data.data?.items || []).filter(m =>
|
|
1286
|
-
m.message_id !== parentMessageId && m.upper_message_id);
|
|
1287
|
-
|
|
1288
|
-
const children = rawChildren.map(raw => {
|
|
1289
|
-
const f = this._formatMessage(raw);
|
|
1290
|
-
// Surface the parent id on the child so downstream tools (download_image /
|
|
1291
|
-
// download_file) know which id to pass to Feishu's resource endpoint.
|
|
1292
|
-
f.parentMessageId = parentMessageId;
|
|
1293
|
-
// Mark the origin chat explicitly — child.chatId is the ORIGINAL chat the
|
|
1294
|
-
// message came from, not the chat where the merge_forward was posted.
|
|
1295
|
-
f.originChatId = raw.chat_id;
|
|
1296
|
-
return f;
|
|
1297
|
-
});
|
|
1298
|
-
await this._populateSenderNames(children, userClient);
|
|
1299
|
-
return children;
|
|
1300
|
-
}
|
|
1301
|
-
|
|
1302
|
-
// Expand merge_forward placeholders in-place. Adds `children: [...]` or
|
|
1303
|
-
// `expandError` on each merge_forward item. `depth` guards against nesting
|
|
1304
|
-
// (Feishu does allow nested merge_forward, but we cap at 1 level to avoid
|
|
1305
|
-
// exponential fan-out in agent contexts).
|
|
1306
|
-
async _expandMergeForwardItems(items, userClient, { preferUAT = true, depth = 0, maxDepth = 1 } = {}) {
|
|
1307
|
-
if (!items || depth >= maxDepth) return;
|
|
1308
|
-
for (const m of items) {
|
|
1309
|
-
if (m.msgType !== 'merge_forward') continue;
|
|
1310
|
-
try {
|
|
1311
|
-
const children = await this.readMergeForwardChildren(m.messageId, userClient, { preferUAT });
|
|
1312
|
-
m.children = children;
|
|
1313
|
-
// One extra level deep if user really wants, via recursive call.
|
|
1314
|
-
if (depth + 1 < maxDepth) {
|
|
1315
|
-
await this._expandMergeForwardItems(children, userClient, { preferUAT, depth: depth + 1, maxDepth });
|
|
1316
|
-
}
|
|
1317
|
-
} catch (e) {
|
|
1318
|
-
m.expandError = e.message;
|
|
1319
|
-
}
|
|
1320
|
-
}
|
|
1321
|
-
}
|
|
1322
|
-
|
|
1323
|
-
_normalizeTimestamp(ts) {
|
|
1324
|
-
if (!ts) return null;
|
|
1325
|
-
const n = parseInt(ts);
|
|
1326
|
-
// Feishu returns millisecond strings; normalize to seconds
|
|
1327
|
-
return String(n > 1e12 ? Math.floor(n / 1000) : n);
|
|
1328
|
-
}
|
|
1329
|
-
|
|
1330
|
-
// --- Hardened Message Read (v1.3.4) ---
|
|
1331
|
-
|
|
1332
|
-
// Reads messages with explicit fallback routing: tries the bot path first,
|
|
1333
|
-
// classifies any failure via error-codes.js, and escalates to UAT when
|
|
1334
|
-
// appropriate. Returns the same shape as readMessages/readMessagesAsUser
|
|
1335
|
-
// plus `via` ('bot' | 'user' | 'contacts') and, if fallback fired,
|
|
1336
|
-
// `via_reason` (a short enum from classifyError).
|
|
1337
|
-
//
|
|
1338
|
-
// If `skipBot` is true, the bot path is never attempted (callers use this
|
|
1339
|
-
// when the chat_id came from search_contacts — i.e. definitely external).
|
|
1340
|
-
//
|
|
1341
|
-
// Throws a single, wrapped error if BOTH paths fail or if UAT is absent and
|
|
1342
|
-
// the bot failed; the message points the user at `npx feishu-user-plugin oauth`.
|
|
1343
|
-
async readMessagesWithFallback(chatId, options, userClient, { skipBot = false, via = 'bot' } = {}) {
|
|
1344
|
-
const tryUAT = async (viaLabel, reason) => {
|
|
1345
|
-
if (!this.hasUAT) {
|
|
1346
|
-
const hint = 'To read external / private groups, configure UAT via: npx feishu-user-plugin oauth';
|
|
1347
|
-
const err = new Error(`Cannot read chat ${chatId} as bot (${reason || 'bot failed and no UAT configured'}). ${hint}`);
|
|
1348
|
-
err.viaReason = reason;
|
|
1349
|
-
throw err;
|
|
1350
|
-
}
|
|
1351
|
-
const data = await this.readMessagesAsUser(chatId, options, userClient);
|
|
1352
|
-
data.via = viaLabel;
|
|
1353
|
-
if (reason) data.via_reason = reason;
|
|
1354
|
-
return data;
|
|
1355
|
-
};
|
|
1356
|
-
|
|
1357
|
-
if (skipBot) {
|
|
1358
|
-
return tryUAT(via || 'contacts', 'contacts_resolved_external');
|
|
1359
|
-
}
|
|
1360
|
-
|
|
1361
|
-
// Attempt 1 — bot identity.
|
|
1362
|
-
try {
|
|
1363
|
-
const data = await this.readMessages(chatId, options, userClient);
|
|
1364
|
-
data.via = 'bot';
|
|
1365
|
-
return data;
|
|
1366
|
-
} catch (botErr) {
|
|
1367
|
-
const klass = classifyError(botErr);
|
|
1368
|
-
console.error(`[feishu-user-plugin] read_messages bot failed for ${chatId}: ${botErr.message} [class=${klass.action}, reason=${klass.reason}, code=${klass.code}]`);
|
|
1369
|
-
|
|
1370
|
-
if (klass.action === 'retry') {
|
|
1371
|
-
// One retry after short backoff before hopping to UAT.
|
|
1372
|
-
await new Promise(r => setTimeout(r, 2000));
|
|
1373
|
-
try {
|
|
1374
|
-
const data = await this.readMessages(chatId, options, userClient);
|
|
1375
|
-
data.via = 'bot';
|
|
1376
|
-
data.via_reason = klass.reason + '_recovered';
|
|
1377
|
-
return data;
|
|
1378
|
-
} catch (retryErr) {
|
|
1379
|
-
console.error(`[feishu-user-plugin] read_messages bot retry failed for ${chatId}: ${retryErr.message}`);
|
|
1380
|
-
}
|
|
1381
|
-
}
|
|
1382
|
-
|
|
1383
|
-
// Fall through to UAT — if UAT is missing, tryUAT throws the user-friendly
|
|
1384
|
-
// "run npx feishu-user-plugin oauth" error instead of the raw Feishu payload.
|
|
1385
|
-
return tryUAT('user', klass.reason);
|
|
1386
|
-
}
|
|
1387
|
-
}
|
|
1388
|
-
|
|
1389
|
-
// --- Docx Image Read (v1.3.4) ---
|
|
1390
|
-
|
|
1391
|
-
// Download a media asset (image, file, etc.) referenced from inside a Feishu
|
|
1392
|
-
// docx block. The model actually gets the pixels via MCP image content in the
|
|
1393
|
-
// handler layer; here we just return base64 + metadata.
|
|
1394
|
-
//
|
|
1395
|
-
// Feishu's drive/v1/medias/{token}/download requires a query `extra` with
|
|
1396
|
-
// a JSON-encoded doc_token when the media lives inside a doc (to pass
|
|
1397
|
-
// tenant-scoped auth). Passing extra is harmless for generic drive files.
|
|
1398
|
-
async downloadDocImage(imageToken, docToken, docType = 'docx') {
|
|
1399
|
-
if (!imageToken) throw new Error('downloadDocImage: imageToken is required');
|
|
1400
|
-
// Feishu's drive media download uses `extra` as a JSON-string query param to
|
|
1401
|
-
// identify the enclosing doc context. Most observed forms carry both
|
|
1402
|
-
// `doc_type` and `doc_token`; omitting docType falls back to 'docx' which
|
|
1403
|
-
// is the by-far most common case. Omitting extra entirely is safe for
|
|
1404
|
-
// standalone drive-media tokens that don't live inside a doc.
|
|
1405
|
-
const extra = docToken
|
|
1406
|
-
? `?extra=${encodeURIComponent(JSON.stringify({ doc_type: docType, doc_token: docToken }))}`
|
|
1407
|
-
: '';
|
|
1408
|
-
const path = `/open-apis/drive/v1/medias/${encodeURIComponent(imageToken)}/download${extra}`;
|
|
1409
|
-
const url = 'https://open.feishu.cn' + path;
|
|
1410
|
-
|
|
1411
|
-
// Attempt 1 — user identity (most reliable for user-owned docs).
|
|
1412
|
-
if (this.hasUAT) {
|
|
1413
|
-
try {
|
|
1414
|
-
const uat = await this._getValidUAT();
|
|
1415
|
-
const res = await fetchWithTimeout(url, { headers: { 'Authorization': `Bearer ${uat}` }, timeoutMs: 60000 });
|
|
1416
|
-
if (res.ok && !res.headers.get('content-type')?.includes('application/json')) {
|
|
1417
|
-
const buf = Buffer.from(await res.arrayBuffer());
|
|
1418
|
-
return {
|
|
1419
|
-
base64: buf.toString('base64'),
|
|
1420
|
-
mimeType: res.headers.get('content-type') || 'application/octet-stream',
|
|
1421
|
-
bytes: buf.length,
|
|
1422
|
-
viaUser: true,
|
|
1423
|
-
};
|
|
1424
|
-
}
|
|
1425
|
-
const errJson = await res.json().catch(() => null);
|
|
1426
|
-
console.error(`[feishu-user-plugin] downloadDocImage as user failed: ${errJson?.code}: ${errJson?.msg || res.statusText}, retrying as app`);
|
|
1427
|
-
} catch (e) {
|
|
1428
|
-
console.error(`[feishu-user-plugin] downloadDocImage as user threw (${e.message}), retrying as app`);
|
|
1429
|
-
}
|
|
1430
|
-
}
|
|
1431
|
-
|
|
1432
|
-
// Attempt 2 — app identity. Requires the app to have drive access to the doc.
|
|
1433
|
-
const token = await this._getAppToken();
|
|
1434
|
-
const res = await fetchWithTimeout(url, { headers: { 'Authorization': `Bearer ${token}` }, timeoutMs: 60000 });
|
|
1435
|
-
if (!res.ok || res.headers.get('content-type')?.includes('application/json')) {
|
|
1436
|
-
const errJson = await res.json().catch(() => null);
|
|
1437
|
-
throw new Error(`downloadDocImage failed: ${errJson?.code}: ${errJson?.msg || res.statusText}. Note: app identity requires drive access to the document; configure UAT for user-owned docs.`);
|
|
1438
|
-
}
|
|
1439
|
-
const buf = Buffer.from(await res.arrayBuffer());
|
|
1440
|
-
return {
|
|
1441
|
-
base64: buf.toString('base64'),
|
|
1442
|
-
mimeType: res.headers.get('content-type') || 'application/octet-stream',
|
|
1443
|
-
bytes: buf.length,
|
|
1444
|
-
viaUser: false,
|
|
1445
|
-
};
|
|
1446
|
-
}
|
|
1447
|
-
|
|
1448
|
-
// --- Docx Image Write (v1.3.4) ---
|
|
1449
|
-
|
|
1450
|
-
// Upload binary media to Feishu's drive layer so it can be attached to a
|
|
1451
|
-
// docx block, sheet cell, bitable attachment field, etc. Returns the
|
|
1452
|
-
// media's file_token, which is what the host block's replace_*.token
|
|
1453
|
-
// (or bitable attachment field value) expects.
|
|
1454
|
-
//
|
|
1455
|
-
// parentType ∈ {
|
|
1456
|
-
// docx_image, docx_file,
|
|
1457
|
-
// sheet_image, sheet_file,
|
|
1458
|
-
// bitable_image, bitable_file,
|
|
1459
|
-
// doc_image, doc_file, // legacy doc (pre-docx)
|
|
1460
|
-
// ccm_import_open, // import-task host
|
|
1461
|
-
// vc_virtual_background // VC bg, grayscale-only
|
|
1462
|
-
// }
|
|
1463
|
-
// parentNode = the block_id (docx) / spreadsheet_token (sheet) / app_token
|
|
1464
|
-
// (bitable) / doc_token (legacy) — depends on parentType.
|
|
1465
|
-
async uploadMedia(filePath, parentNode, parentType = 'docx_image') {
|
|
1466
|
-
const fs = require('fs');
|
|
1467
|
-
const path = require('path');
|
|
1468
|
-
if (!filePath) throw new Error('uploadMedia: filePath is required');
|
|
1469
|
-
if (!parentNode) throw new Error('uploadMedia: parentNode is required');
|
|
1470
|
-
const ALLOWED = new Set([
|
|
1471
|
-
'docx_image', 'docx_file',
|
|
1472
|
-
'sheet_image', 'sheet_file',
|
|
1473
|
-
'bitable_image', 'bitable_file',
|
|
1474
|
-
'doc_image', 'doc_file',
|
|
1475
|
-
'ccm_import_open', 'vc_virtual_background',
|
|
1476
|
-
]);
|
|
1477
|
-
if (!ALLOWED.has(parentType)) {
|
|
1478
|
-
throw new Error(`uploadMedia: unsupported parent_type "${parentType}". Allowed: ${[...ALLOWED].join(', ')}`);
|
|
1479
|
-
}
|
|
1480
|
-
|
|
1481
|
-
const stat = fs.statSync(filePath);
|
|
1482
|
-
const fileName = path.basename(filePath);
|
|
1483
|
-
const buf = fs.readFileSync(filePath);
|
|
1484
|
-
|
|
1485
|
-
// Best-effort content-type from extension. Feishu doesn't require it but
|
|
1486
|
-
// some CDNs behind the API key off it; the Blob default is text/plain
|
|
1487
|
-
// which would look wrong for binary attachments.
|
|
1488
|
-
const ext = path.extname(fileName).toLowerCase();
|
|
1489
|
-
const mimeMap = {
|
|
1490
|
-
// image
|
|
1491
|
-
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
1492
|
-
'.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml',
|
|
1493
|
-
'.bmp': 'image/bmp', '.tiff': 'image/tiff', '.ico': 'image/x-icon',
|
|
1494
|
-
// doc / archive
|
|
1495
|
-
'.pdf': 'application/pdf', '.zip': 'application/zip',
|
|
1496
|
-
'.doc': 'application/msword',
|
|
1497
|
-
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
1498
|
-
'.xls': 'application/vnd.ms-excel',
|
|
1499
|
-
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
1500
|
-
'.ppt': 'application/vnd.ms-powerpoint',
|
|
1501
|
-
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
1502
|
-
'.txt': 'text/plain', '.md': 'text/markdown', '.csv': 'text/csv', '.json': 'application/json',
|
|
1503
|
-
};
|
|
1504
|
-
const contentType = mimeMap[ext] || 'application/octet-stream';
|
|
1505
|
-
|
|
1506
|
-
const doUpload = async (bearer) => {
|
|
1507
|
-
const form = new FormData();
|
|
1508
|
-
form.append('file_name', fileName);
|
|
1509
|
-
form.append('parent_type', parentType);
|
|
1510
|
-
form.append('parent_node', parentNode);
|
|
1511
|
-
form.append('size', String(stat.size));
|
|
1512
|
-
form.append('file', new Blob([buf], { type: contentType }), fileName);
|
|
1513
|
-
const res = await fetchWithTimeout('https://open.feishu.cn/open-apis/drive/v1/medias/upload_all', {
|
|
1514
|
-
method: 'POST',
|
|
1515
|
-
headers: { 'Authorization': `Bearer ${bearer}` },
|
|
1516
|
-
body: form,
|
|
1517
|
-
timeoutMs: 120000,
|
|
1518
|
-
});
|
|
1519
|
-
return res.json();
|
|
1520
|
-
};
|
|
1521
|
-
|
|
1522
|
-
// User identity first — host resources are usually user-owned.
|
|
1523
|
-
if (this.hasUAT) {
|
|
1524
|
-
try {
|
|
1525
|
-
const data = await this._withUAT(doUpload);
|
|
1526
|
-
if (data.code === 0 && data.data?.file_token) {
|
|
1527
|
-
return { fileToken: data.data.file_token, viaUser: true };
|
|
1528
|
-
}
|
|
1529
|
-
console.error(`[feishu-user-plugin] uploadMedia (${parentType}) as user failed (${data.code}: ${data.msg}), retrying as app`);
|
|
1530
|
-
} catch (e) {
|
|
1531
|
-
console.error(`[feishu-user-plugin] uploadMedia (${parentType}) as user threw (${e.message}), retrying as app`);
|
|
1532
|
-
}
|
|
1533
|
-
}
|
|
1534
|
-
const appToken = await this._getAppToken();
|
|
1535
|
-
const data = await doUpload(appToken);
|
|
1536
|
-
if (data.code !== 0 || !data.data?.file_token) {
|
|
1537
|
-
throw new Error(`uploadMedia (${parentType}) failed: ${data.code}: ${data.msg || 'no file_token returned'}`);
|
|
1538
|
-
}
|
|
1539
|
-
return { fileToken: data.data.file_token, viaUser: false };
|
|
1540
|
-
}
|
|
1541
|
-
|
|
1542
|
-
// Backwards-compat alias — old name from v1.3.4.
|
|
1543
|
-
async uploadDocMedia(filePath, parentNode, parentType = 'docx_image') {
|
|
1544
|
-
return this.uploadMedia(filePath, parentNode, parentType);
|
|
1545
|
-
}
|
|
1546
|
-
|
|
1547
|
-
// Upload a file to a drive folder (NOT for embedding in a doc — that's
|
|
1548
|
-
// uploadMedia). Uses drive/v1/files/upload_all with parent_type=explorer.
|
|
1549
|
-
// Returns { fileToken, viaUser } where fileToken is the cloud-doc file id.
|
|
1550
|
-
async uploadDriveFile(filePath, folderToken) {
|
|
1551
|
-
const fs = require('fs');
|
|
1552
|
-
const path = require('path');
|
|
1553
|
-
if (!filePath) throw new Error('uploadDriveFile: filePath is required');
|
|
1554
|
-
if (!folderToken) throw new Error('uploadDriveFile: folderToken is required (use the destination folder token; for "my space" root call list_files first to get it)');
|
|
1555
|
-
|
|
1556
|
-
const stat = fs.statSync(filePath);
|
|
1557
|
-
const fileName = path.basename(filePath);
|
|
1558
|
-
const buf = fs.readFileSync(filePath);
|
|
1559
|
-
const ext = path.extname(fileName).toLowerCase();
|
|
1560
|
-
const mimeMap = {
|
|
1561
|
-
'.pdf': 'application/pdf', '.zip': 'application/zip',
|
|
1562
|
-
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
1563
|
-
'.txt': 'text/plain', '.md': 'text/markdown', '.csv': 'text/csv', '.json': 'application/json',
|
|
1564
|
-
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
1565
|
-
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
1566
|
-
};
|
|
1567
|
-
const contentType = mimeMap[ext] || 'application/octet-stream';
|
|
1568
|
-
|
|
1569
|
-
const doUpload = async (bearer) => {
|
|
1570
|
-
const form = new FormData();
|
|
1571
|
-
form.append('file_name', fileName);
|
|
1572
|
-
form.append('parent_type', 'explorer');
|
|
1573
|
-
form.append('parent_node', folderToken);
|
|
1574
|
-
form.append('size', String(stat.size));
|
|
1575
|
-
form.append('file', new Blob([buf], { type: contentType }), fileName);
|
|
1576
|
-
const res = await fetchWithTimeout('https://open.feishu.cn/open-apis/drive/v1/files/upload_all', {
|
|
1577
|
-
method: 'POST',
|
|
1578
|
-
headers: { 'Authorization': `Bearer ${bearer}` },
|
|
1579
|
-
body: form,
|
|
1580
|
-
timeoutMs: 120000,
|
|
1581
|
-
});
|
|
1582
|
-
return res.json();
|
|
1583
|
-
};
|
|
1584
|
-
|
|
1585
|
-
if (this.hasUAT) {
|
|
1586
|
-
try {
|
|
1587
|
-
const data = await this._withUAT(doUpload);
|
|
1588
|
-
if (data.code === 0 && data.data?.file_token) {
|
|
1589
|
-
return { fileToken: data.data.file_token, viaUser: true };
|
|
1590
|
-
}
|
|
1591
|
-
console.error(`[feishu-user-plugin] uploadDriveFile as user failed (${data.code}: ${data.msg}), retrying as app`);
|
|
1592
|
-
} catch (e) {
|
|
1593
|
-
console.error(`[feishu-user-plugin] uploadDriveFile as user threw (${e.message}), retrying as app`);
|
|
1594
|
-
}
|
|
1595
|
-
}
|
|
1596
|
-
const appToken = await this._getAppToken();
|
|
1597
|
-
const data = await doUpload(appToken);
|
|
1598
|
-
if (data.code !== 0 || !data.data?.file_token) {
|
|
1599
|
-
throw new Error(`uploadDriveFile failed: ${data.code}: ${data.msg || 'no file_token returned'}`);
|
|
1600
|
-
}
|
|
1601
|
-
return { fileToken: data.data.file_token, viaUser: false };
|
|
1602
|
-
}
|
|
1603
|
-
|
|
1604
|
-
// Create a new image block and populate it from either a local file path or
|
|
1605
|
-
// an already-uploaded media token. Orchestrates the three-step Feishu flow:
|
|
1606
|
-
// 1) create empty image placeholder block
|
|
1607
|
-
// 2) upload pixels (skipped if caller passes a ready-made imageToken)
|
|
1608
|
-
// 3) patch the placeholder with the uploaded token
|
|
1609
|
-
// Returns { blockId, imageToken, viaUser }.
|
|
1610
|
-
async createDocBlockWithImage(documentId, parentBlockId, { imagePath, imageToken, index } = {}) {
|
|
1611
|
-
if (!imagePath && !imageToken) {
|
|
1612
|
-
throw new Error('createDocBlockWithImage: either imagePath or imageToken is required');
|
|
1613
|
-
}
|
|
1614
|
-
|
|
1615
|
-
// Step 1 — empty placeholder.
|
|
1616
|
-
const placeholder = buildEmptyImageBlock();
|
|
1617
|
-
const createBody = { children: [placeholder] };
|
|
1618
|
-
if (index !== undefined) createBody.index = index;
|
|
1619
|
-
const created = await this._asUserOrApp({
|
|
1620
|
-
uatPath: `/open-apis/docx/v1/documents/${documentId}/blocks/${parentBlockId}/children`,
|
|
1621
|
-
method: 'POST',
|
|
1622
|
-
body: createBody,
|
|
1623
|
-
sdkFn: () => this.client.docx.documentBlockChildren.create({
|
|
1624
|
-
path: { document_id: documentId, block_id: parentBlockId },
|
|
1625
|
-
data: createBody,
|
|
1626
|
-
}),
|
|
1627
|
-
label: 'createDocBlockWithImage.placeholder',
|
|
1628
|
-
});
|
|
1629
|
-
const newBlock = (created.data.children || [])[0];
|
|
1630
|
-
const blockId = newBlock?.block_id;
|
|
1631
|
-
if (!blockId) throw new Error(`createDocBlockWithImage: placeholder creation returned no block_id: ${JSON.stringify(created.data).slice(0, 400)}`);
|
|
1632
|
-
|
|
1633
|
-
// Step 2 — upload (if needed).
|
|
1634
|
-
let finalToken = imageToken;
|
|
1635
|
-
let viaUser = !!created._viaUser;
|
|
1636
|
-
let fallbackWarning = created._fallbackWarning || null;
|
|
1637
|
-
if (!finalToken) {
|
|
1638
|
-
const uploaded = await this.uploadMedia(imagePath, blockId, 'docx_image');
|
|
1639
|
-
finalToken = uploaded.fileToken;
|
|
1640
|
-
viaUser = viaUser && uploaded.viaUser; // true iff both steps went via user
|
|
1641
|
-
}
|
|
1642
|
-
|
|
1643
|
-
// Step 3 — attach token to the placeholder via PATCH replace_image.
|
|
1644
|
-
const patch = buildReplaceImagePayload(finalToken);
|
|
1645
|
-
await this._asUserOrApp({
|
|
1646
|
-
uatPath: `/open-apis/docx/v1/documents/${documentId}/blocks/${blockId}`,
|
|
1647
|
-
method: 'PATCH',
|
|
1648
|
-
body: patch,
|
|
1649
|
-
sdkFn: () => this.client.docx.documentBlock.patch({
|
|
1650
|
-
path: { document_id: documentId, block_id: blockId },
|
|
1651
|
-
data: patch,
|
|
1652
|
-
}),
|
|
1653
|
-
label: 'createDocBlockWithImage.replaceImage',
|
|
1654
|
-
});
|
|
1655
|
-
|
|
1656
|
-
return { blockId, imageToken: finalToken, viaUser, fallbackWarning };
|
|
1657
|
-
}
|
|
1658
|
-
|
|
1659
|
-
// Replace an existing image block's media token (e.g. swap the picture in an
|
|
1660
|
-
// already-created image block). Expects an uploaded media token — use
|
|
1661
|
-
// uploadMedia or create_doc_block's image_path shortcut to obtain one.
|
|
1662
|
-
async updateDocBlockImage(documentId, blockId, imageToken) {
|
|
1663
|
-
const patch = buildReplaceImagePayload(imageToken);
|
|
1664
|
-
await this._asUserOrApp({
|
|
1665
|
-
uatPath: `/open-apis/docx/v1/documents/${documentId}/blocks/${blockId}`,
|
|
1666
|
-
method: 'PATCH',
|
|
1667
|
-
body: patch,
|
|
1668
|
-
sdkFn: () => this.client.docx.documentBlock.patch({
|
|
1669
|
-
path: { document_id: documentId, block_id: blockId },
|
|
1670
|
-
data: patch,
|
|
1671
|
-
}),
|
|
1672
|
-
label: 'updateDocBlockImage',
|
|
1673
|
-
});
|
|
1674
|
-
return { blockId, imageToken };
|
|
1675
|
-
}
|
|
1676
|
-
|
|
1677
|
-
// Create a file-attachment block in a docx, mirroring createDocBlockWithImage:
|
|
1678
|
-
// 1) create empty file placeholder block
|
|
1679
|
-
// 2) upload the binary via uploadMedia(parent_type=docx_file)
|
|
1680
|
-
// 3) PATCH with replace_file.token to attach
|
|
1681
|
-
// Returns { blockId, fileToken, viaUser, fallbackWarning }.
|
|
1682
|
-
async createDocBlockWithFile(documentId, parentBlockId, { filePath, fileToken, index } = {}) {
|
|
1683
|
-
if (!filePath && !fileToken) {
|
|
1684
|
-
throw new Error('createDocBlockWithFile: either filePath or fileToken is required');
|
|
1685
|
-
}
|
|
1686
|
-
const placeholder = buildEmptyFileBlock();
|
|
1687
|
-
const createBody = { children: [placeholder] };
|
|
1688
|
-
if (index !== undefined) createBody.index = index;
|
|
1689
|
-
const created = await this._asUserOrApp({
|
|
1690
|
-
uatPath: `/open-apis/docx/v1/documents/${documentId}/blocks/${parentBlockId}/children`,
|
|
1691
|
-
method: 'POST',
|
|
1692
|
-
body: createBody,
|
|
1693
|
-
sdkFn: () => this.client.docx.documentBlockChildren.create({
|
|
1694
|
-
path: { document_id: documentId, block_id: parentBlockId },
|
|
1695
|
-
data: createBody,
|
|
1696
|
-
}),
|
|
1697
|
-
label: 'createDocBlockWithFile.placeholder',
|
|
1698
|
-
});
|
|
1699
|
-
// Feishu auto-wraps a FILE block (block_type=23) in a VIEW block
|
|
1700
|
-
// (block_type=33) — the create response returns the OUTER view block.
|
|
1701
|
-
// We need the inner file block's id for both the media upload (parent_node)
|
|
1702
|
-
// and the replace_file PATCH. Walk children to find it; fall back to a
|
|
1703
|
-
// get_doc_blocks lookup if the response didn't materialize the descendant.
|
|
1704
|
-
const newBlock = (created.data.children || [])[0];
|
|
1705
|
-
const outerBlockId = newBlock?.block_id;
|
|
1706
|
-
if (!outerBlockId) throw new Error(`createDocBlockWithFile: placeholder creation returned no block_id: ${JSON.stringify(created.data).slice(0, 400)}`);
|
|
1707
|
-
// Feishu auto-wraps a FILE block (23) in a VIEW block (33). The create
|
|
1708
|
-
// response's outer block is the view; we need to find the inner file
|
|
1709
|
-
// block for both the media upload (parent_node) and the replace_file PATCH.
|
|
1710
|
-
let blockId = outerBlockId;
|
|
1711
|
-
if (newBlock.block_type !== 23) {
|
|
1712
|
-
const inner = await this._findFileChildOf(documentId, outerBlockId, newBlock.children);
|
|
1713
|
-
if (!inner) throw new Error(`createDocBlockWithFile: could not locate inner FILE block under view ${outerBlockId}`);
|
|
1714
|
-
blockId = inner;
|
|
1715
|
-
}
|
|
1716
|
-
|
|
1717
|
-
let finalToken = fileToken;
|
|
1718
|
-
let viaUser = !!created._viaUser;
|
|
1719
|
-
let fallbackWarning = created._fallbackWarning || null;
|
|
1720
|
-
if (!finalToken) {
|
|
1721
|
-
const uploaded = await this.uploadMedia(filePath, blockId, 'docx_file');
|
|
1722
|
-
finalToken = uploaded.fileToken;
|
|
1723
|
-
viaUser = viaUser && uploaded.viaUser;
|
|
1724
|
-
}
|
|
1725
|
-
|
|
1726
|
-
const patch = buildReplaceFilePayload(finalToken);
|
|
1727
|
-
await this._asUserOrApp({
|
|
1728
|
-
uatPath: `/open-apis/docx/v1/documents/${documentId}/blocks/${blockId}`,
|
|
1729
|
-
method: 'PATCH',
|
|
1730
|
-
body: patch,
|
|
1731
|
-
sdkFn: () => this.client.docx.documentBlock.patch({
|
|
1732
|
-
path: { document_id: documentId, block_id: blockId },
|
|
1733
|
-
data: patch,
|
|
1734
|
-
}),
|
|
1735
|
-
label: 'createDocBlockWithFile.replaceFile',
|
|
1736
|
-
});
|
|
1737
|
-
|
|
1738
|
-
return { blockId, viewBlockId: outerBlockId !== blockId ? outerBlockId : undefined, fileToken: finalToken, viaUser, fallbackWarning };
|
|
1739
|
-
}
|
|
1740
|
-
|
|
1741
|
-
// Helper for createDocBlockWithFile — given a view block id and the children
|
|
1742
|
-
// array surfaced by the create response (just IDs in docx v1), find the
|
|
1743
|
-
// FILE child (block_type=23). If no children list was returned, fall back
|
|
1744
|
-
// to listing the doc and walking by parent_id.
|
|
1745
|
-
async _findFileChildOf(documentId, viewBlockId, childIds) {
|
|
1746
|
-
if (Array.isArray(childIds) && childIds.length > 0) {
|
|
1747
|
-
// childIds[0] is most likely the file block — verify with a get
|
|
1748
|
-
for (const childId of childIds) {
|
|
1749
|
-
try {
|
|
1750
|
-
const res = await this._asUserOrApp({
|
|
1751
|
-
uatPath: `/open-apis/docx/v1/documents/${documentId}/blocks/${childId}`,
|
|
1752
|
-
method: 'GET',
|
|
1753
|
-
sdkFn: () => this.client.docx.documentBlock.get({ path: { document_id: documentId, block_id: childId } }),
|
|
1754
|
-
label: '_findFileChildOf.get',
|
|
1755
|
-
});
|
|
1756
|
-
if (res?.data?.block?.block_type === 23) return childId;
|
|
1757
|
-
} catch (_) { /* fall through */ }
|
|
1758
|
-
}
|
|
1759
|
-
// None matched directly; return the first as best-effort
|
|
1760
|
-
return childIds[0];
|
|
1761
|
-
}
|
|
1762
|
-
// Fallback: list all blocks and find a 23 whose parent_id is the view block
|
|
1763
|
-
try {
|
|
1764
|
-
const res = await this._asUserOrApp({
|
|
1765
|
-
uatPath: `/open-apis/docx/v1/documents/${documentId}/blocks`,
|
|
1766
|
-
method: 'GET',
|
|
1767
|
-
sdkFn: () => this.client.docx.documentBlock.list({ path: { document_id: documentId } }),
|
|
1768
|
-
label: '_findFileChildOf.list',
|
|
1769
|
-
});
|
|
1770
|
-
const items = res?.data?.items || [];
|
|
1771
|
-
const match = items.find(b => b.block_type === 23 && b.parent_id === viewBlockId);
|
|
1772
|
-
return match?.block_id || null;
|
|
1773
|
-
} catch (_) {
|
|
1774
|
-
return null;
|
|
1775
|
-
}
|
|
1776
|
-
}
|
|
1777
|
-
|
|
1778
|
-
// Replace an existing file block's media token. Expects an already-uploaded
|
|
1779
|
-
// file token (use uploadMedia with parent_type=docx_file, or
|
|
1780
|
-
// create_doc_block's file_path shortcut).
|
|
1781
|
-
async updateDocBlockFile(documentId, blockId, fileToken) {
|
|
1782
|
-
const patch = buildReplaceFilePayload(fileToken);
|
|
1783
|
-
await this._asUserOrApp({
|
|
1784
|
-
uatPath: `/open-apis/docx/v1/documents/${documentId}/blocks/${blockId}`,
|
|
1785
|
-
method: 'PATCH',
|
|
1786
|
-
body: patch,
|
|
1787
|
-
sdkFn: () => this.client.docx.documentBlock.patch({
|
|
1788
|
-
path: { document_id: documentId, block_id: blockId },
|
|
1789
|
-
data: patch,
|
|
1790
|
-
}),
|
|
1791
|
-
label: 'updateDocBlockFile',
|
|
1792
|
-
});
|
|
1793
|
-
return { blockId, fileToken };
|
|
1794
|
-
}
|
|
1795
|
-
|
|
1796
|
-
// --- Wiki attach (v1.3.4) ---
|
|
1797
|
-
|
|
1798
|
-
// Move an existing drive resource (docx / bitable / sheet / ...) into a Wiki
|
|
1799
|
-
// space as an 'origin' node. Used by createDoc / createBitable when their
|
|
1800
|
-
// wikiSpaceId option is set.
|
|
1801
|
-
//
|
|
1802
|
-
// Uses wiki/v2/spaces/{space_id}/nodes/move_docs_to_wiki — the documented path
|
|
1803
|
-
// for migrating an existing drive doc into wiki. Note: this endpoint is async;
|
|
1804
|
-
// if the move completes immediately (typical for newly-created docs) we get
|
|
1805
|
-
// back a wiki_token and surface it as node_token. If it's queued we return
|
|
1806
|
-
// { task_id } so the caller can see the async state — we don't currently poll.
|
|
1807
|
-
async attachToWiki(spaceId, objType, objToken, parentNodeToken) {
|
|
1808
|
-
if (!spaceId) throw new Error('attachToWiki: spaceId is required');
|
|
1809
|
-
if (!objType) throw new Error('attachToWiki: objType is required');
|
|
1810
|
-
if (!objToken) throw new Error('attachToWiki: objToken is required');
|
|
1811
|
-
const body = { obj_type: objType, obj_token: objToken, apply: true };
|
|
1812
|
-
if (parentNodeToken) body.parent_wiki_token = parentNodeToken;
|
|
1813
|
-
const res = await this._asUserOrApp({
|
|
1814
|
-
uatPath: `/open-apis/wiki/v2/spaces/${encodeURIComponent(spaceId)}/nodes/move_docs_to_wiki`,
|
|
1815
|
-
method: 'POST',
|
|
1816
|
-
body,
|
|
1817
|
-
sdkFn: () => this.client.wiki.spaceNode.moveDocsToWiki({ path: { space_id: spaceId }, data: body }),
|
|
1818
|
-
label: 'attachToWiki',
|
|
1819
|
-
});
|
|
1820
|
-
const data = res.data || {};
|
|
1821
|
-
if (data.wiki_token) return { node_token: data.wiki_token, applied: !!data.applied };
|
|
1822
|
-
if (data.task_id) return { task_id: data.task_id, applied: false };
|
|
1823
|
-
return data;
|
|
1824
|
-
}
|
|
1825
|
-
|
|
1826
|
-
// --- OKR (v1.3.4) ---
|
|
1827
|
-
|
|
1828
|
-
async listUserOkrs(userId, { periodIds, offset = 0, limit = 10, lang, userIdType = 'open_id' } = {}) {
|
|
1829
|
-
if (!userId) throw new Error('listUserOkrs: userId is required (the user whose OKRs to read). For your own, get your open_id from get_login_status or search_contacts.');
|
|
1830
|
-
const params = { user_id_type: userIdType, offset: String(offset), limit: String(limit) };
|
|
1831
|
-
if (lang) params.lang = lang;
|
|
1832
|
-
if (periodIds && periodIds.length) params.period_ids = periodIds;
|
|
1833
|
-
const res = await this._asUserOrApp({
|
|
1834
|
-
uatPath: `/open-apis/okr/v1/users/${encodeURIComponent(userId)}/okrs`,
|
|
1835
|
-
query: params,
|
|
1836
|
-
sdkFn: () => this.client.okr.userOkr.list({
|
|
1837
|
-
path: { user_id: userId },
|
|
1838
|
-
params: {
|
|
1839
|
-
user_id_type: userIdType,
|
|
1840
|
-
offset: String(offset),
|
|
1841
|
-
limit: String(limit),
|
|
1842
|
-
...(lang ? { lang } : {}),
|
|
1843
|
-
...(periodIds && periodIds.length ? { period_ids: periodIds } : {}),
|
|
1844
|
-
},
|
|
1845
|
-
}),
|
|
1846
|
-
label: 'listUserOkrs',
|
|
1847
|
-
});
|
|
1848
|
-
return { total: res.data.total, items: res.data.okr_list || [] };
|
|
1849
|
-
}
|
|
1850
|
-
|
|
1851
|
-
async getOkrs(okrIds, { lang, userIdType = 'open_id' } = {}) {
|
|
1852
|
-
if (!Array.isArray(okrIds) || okrIds.length === 0) {
|
|
1853
|
-
throw new Error('getOkrs: okrIds must be a non-empty array');
|
|
1854
|
-
}
|
|
1855
|
-
const params = { user_id_type: userIdType, okr_ids: okrIds };
|
|
1856
|
-
if (lang) params.lang = lang;
|
|
1857
|
-
// UAT REST path takes repeated okr_ids= params; URLSearchParams will serialize an array properly
|
|
1858
|
-
const res = await this._asUserOrApp({
|
|
1859
|
-
uatPath: `/open-apis/okr/v1/okrs/batch_get`,
|
|
1860
|
-
query: params,
|
|
1861
|
-
sdkFn: () => this.client.okr.okr.batchGet({ params }),
|
|
1862
|
-
label: 'getOkrs',
|
|
1863
|
-
});
|
|
1864
|
-
return { items: res.data.okr_list || [] };
|
|
1865
|
-
}
|
|
1866
|
-
|
|
1867
|
-
async listOkrPeriods({ pageSize = 10, pageToken } = {}) {
|
|
1868
|
-
const params = { page_size: String(pageSize) };
|
|
1869
|
-
if (pageToken) params.page_token = pageToken;
|
|
1870
|
-
const res = await this._asUserOrApp({
|
|
1871
|
-
uatPath: `/open-apis/okr/v1/periods`,
|
|
1872
|
-
query: params,
|
|
1873
|
-
sdkFn: () => this.client.okr.period.list({ params: { page_size: pageSize, ...(pageToken ? { page_token: pageToken } : {}) } }),
|
|
1874
|
-
label: 'listOkrPeriods',
|
|
1875
|
-
});
|
|
1876
|
-
return { items: res.data.items || [], pageToken: res.data.page_token, hasMore: res.data.has_more };
|
|
1877
|
-
}
|
|
1878
|
-
|
|
1879
|
-
// --- Calendar (v1.3.4) ---
|
|
1880
|
-
|
|
1881
|
-
async listCalendars({ pageSize = 50, pageToken, syncToken } = {}) {
|
|
1882
|
-
// Feishu's calendar/v4/calendars endpoint rejects page_size < 50 with
|
|
1883
|
-
// `99992402 field validation failed` ("the min value is 50"). The docs don't
|
|
1884
|
-
// flag this — smoke-tested against the real API. Clamp to be safe.
|
|
1885
|
-
const ps = Math.max(50, Number(pageSize) || 50);
|
|
1886
|
-
const params = { page_size: String(ps) };
|
|
1887
|
-
if (pageToken) params.page_token = pageToken;
|
|
1888
|
-
if (syncToken) params.sync_token = syncToken;
|
|
1889
|
-
const res = await this._asUserOrApp({
|
|
1890
|
-
uatPath: `/open-apis/calendar/v4/calendars`,
|
|
1891
|
-
query: params,
|
|
1892
|
-
sdkFn: () => this.client.calendar.calendar.list({ params: { page_size: ps, ...(pageToken ? { page_token: pageToken } : {}), ...(syncToken ? { sync_token: syncToken } : {}) } }),
|
|
1893
|
-
label: 'listCalendars',
|
|
1894
|
-
});
|
|
1895
|
-
return {
|
|
1896
|
-
items: res.data.calendar_list || [],
|
|
1897
|
-
pageToken: res.data.page_token,
|
|
1898
|
-
syncToken: res.data.sync_token,
|
|
1899
|
-
hasMore: res.data.has_more,
|
|
1900
|
-
};
|
|
1901
|
-
}
|
|
1902
|
-
|
|
1903
|
-
async listCalendarEvents(calendarId, { startTime, endTime, pageSize = 50, pageToken, syncToken } = {}) {
|
|
1904
|
-
if (!calendarId) throw new Error('listCalendarEvents: calendarId is required');
|
|
1905
|
-
const params = { page_size: String(pageSize) };
|
|
1906
|
-
if (startTime) params.start_time = String(startTime);
|
|
1907
|
-
if (endTime) params.end_time = String(endTime);
|
|
1908
|
-
if (pageToken) params.page_token = pageToken;
|
|
1909
|
-
if (syncToken) params.sync_token = syncToken;
|
|
1910
|
-
const res = await this._asUserOrApp({
|
|
1911
|
-
uatPath: `/open-apis/calendar/v4/calendars/${encodeURIComponent(calendarId)}/events`,
|
|
1912
|
-
query: params,
|
|
1913
|
-
sdkFn: () => this.client.calendar.calendarEvent.list({
|
|
1914
|
-
path: { calendar_id: calendarId },
|
|
1915
|
-
params: {
|
|
1916
|
-
page_size: pageSize,
|
|
1917
|
-
...(startTime ? { start_time: String(startTime) } : {}),
|
|
1918
|
-
...(endTime ? { end_time: String(endTime) } : {}),
|
|
1919
|
-
...(pageToken ? { page_token: pageToken } : {}),
|
|
1920
|
-
...(syncToken ? { sync_token: syncToken } : {}),
|
|
1921
|
-
},
|
|
1922
|
-
}),
|
|
1923
|
-
label: 'listCalendarEvents',
|
|
1924
|
-
});
|
|
1925
|
-
return {
|
|
1926
|
-
items: res.data.items || [],
|
|
1927
|
-
pageToken: res.data.page_token,
|
|
1928
|
-
syncToken: res.data.sync_token,
|
|
1929
|
-
hasMore: res.data.has_more,
|
|
1930
|
-
};
|
|
1931
|
-
}
|
|
1932
|
-
|
|
1933
|
-
async getCalendarEvent(calendarId, eventId) {
|
|
1934
|
-
if (!calendarId || !eventId) throw new Error('getCalendarEvent: calendarId and eventId are required');
|
|
1935
|
-
const res = await this._asUserOrApp({
|
|
1936
|
-
uatPath: `/open-apis/calendar/v4/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(eventId)}`,
|
|
1937
|
-
sdkFn: () => this.client.calendar.calendarEvent.get({ path: { calendar_id: calendarId, event_id: eventId } }),
|
|
1938
|
-
label: 'getCalendarEvent',
|
|
1939
|
-
});
|
|
1940
|
-
return { event: res.data.event };
|
|
1941
|
-
}
|
|
1942
|
-
}
|
|
1943
|
-
|
|
1944
|
-
module.exports = { LarkOfficialClient };
|
|
1
|
+
// Back-compat barrel. The implementation now lives in clients/official/
|
|
2
|
+
// (split by domain in v1.3.7 phase A). This file exists so external callers
|
|
3
|
+
// importing './official' keep working until they migrate to './clients/official'.
|
|
4
|
+
// Will be deleted once all callers are migrated.
|
|
5
|
+
module.exports = require('./clients/official');
|