larkit 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/AGENT_SKILL.md +12 -0
  2. package/README.md +765 -2
  3. package/bin/larkit-mcp.js +8 -0
  4. package/bin/larkit.js +532 -0
  5. package/larkit.config.example.json +12 -0
  6. package/package.json +65 -4
  7. package/scripts/.tmp-npm-webauth.mjs +49 -0
  8. package/scripts/build-index.mjs +66 -0
  9. package/scripts/check-syntax.mjs +28 -0
  10. package/scripts/login-worker.mjs +50 -0
  11. package/scripts/publish-pack.mjs +277 -0
  12. package/scripts/verify-bootstrap.mjs +238 -0
  13. package/scripts/verify-console.mjs +60 -0
  14. package/scripts/verify-index.mjs +59 -0
  15. package/scripts/verify-mcp.mjs +215 -0
  16. package/skills/larkit/SKILL.md +120 -0
  17. package/skills/larkit/references/console.md +86 -0
  18. package/skills/larkit/references/playbooks.md +177 -0
  19. package/skills/larkit/references/tools.md +115 -0
  20. package/skills/larkit/references/truth-model.md +117 -0
  21. package/src/auth/login.js +171 -0
  22. package/src/auth/service.js +213 -0
  23. package/src/auth/store.js +213 -0
  24. package/src/bootstrap/channel.js +201 -0
  25. package/src/bootstrap/cli-install.js +184 -0
  26. package/src/bootstrap/download.js +182 -0
  27. package/src/bootstrap/init.js +672 -0
  28. package/src/bootstrap/pack-cli.js +97 -0
  29. package/src/bootstrap/pack.js +541 -0
  30. package/src/bootstrap/report.js +62 -0
  31. package/src/bootstrap/skills.js +223 -0
  32. package/src/bootstrap/state.js +66 -0
  33. package/src/bootstrap/tar.js +417 -0
  34. package/src/console/browser.js +338 -0
  35. package/src/console/client.js +827 -0
  36. package/src/console/dispatch.js +101 -0
  37. package/src/docs/client.js +284 -0
  38. package/src/indexer/anchors.js +366 -0
  39. package/src/indexer/db.js +449 -0
  40. package/src/indexer/extract-assets.js +285 -0
  41. package/src/indexer/extract-deb.js +56 -0
  42. package/src/indexer/format-pool.js +64 -0
  43. package/src/indexer/format-worker.js +132 -0
  44. package/src/indexer/format.js +239 -0
  45. package/src/indexer/protocol.js +123 -0
  46. package/src/mcp/server.js +433 -0
  47. package/src/probe/client.js +225 -0
  48. package/src/probe/openapi.js +194 -0
  49. package/src/protocol/catalog.js +146 -0
  50. package/src/protocol/data/README.md +15 -0
  51. package/src/protocol/data/bundle.json +1 -0
  52. package/src/protocol/data/commands.json +12896 -0
  53. package/src/protocol/data/constants.json +8 -0
  54. package/src/protocol/data/reference/PROTOCOL.md +66 -0
  55. package/src/protocol/data/reference/endpoints.json +46 -0
  56. package/src/protocol/data/schema.json.gz +0 -0
  57. package/src/protocol/descriptor.js +358 -0
  58. package/src/protocol/schema.js +235 -0
  59. package/src/protocol/transport.js +387 -0
  60. package/src/shared/browser.js +70 -0
  61. package/src/shared/config.js +90 -0
  62. package/src/shared/paths.js +151 -0
  63. package/src/shared/quiet.js +12 -0
  64. package/src/shared/util.js +124 -0
  65. package/src/truth/fts.js +24 -0
  66. package/src/truth/search.js +554 -0
  67. package/src/truth/verified.js +107 -0
  68. package/verified_truths/README.md +28 -0
  69. package/verified_truths/card-json-2.0-official.md +55 -0
  70. package/verified_truths/client-ws-gateway-boundaries.md +33 -0
  71. package/verified_truths/feishu-developer-console-apis.md +104 -0
  72. package/workers/truth-api/src/index.js +180 -0
  73. package/workers/truth-api/wrangler.toml +31 -0
@@ -0,0 +1,827 @@
1
+ import fs from "node:fs";
2
+ import { randomUUID } from "node:crypto";
3
+ import {
4
+ CONSOLE_RECIPES_FILE,
5
+ } from "../shared/paths.js";
6
+ import { getProfileCookies } from "../auth/store.js";
7
+ import { ensureDir, readJson, writeJson } from "../shared/util.js";
8
+
9
+ export const CONSOLE_BASE = "https://open.feishu.cn";
10
+ const USER_AGENT =
11
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36";
12
+
13
+ const DEFAULT_RECIPES = {
14
+ list_apps: { method: "POST", path: "/developers/v1/app/list" },
15
+ get_app: { method: "POST", path: "/developers/v1/app/:app_id" },
16
+ create_app: { method: "POST", path: "/developers/v1/app/create" },
17
+ get_credentials: { method: "POST", path: "/developers/v1/secret/:app_id" },
18
+ update_app_info: { method: "POST", path: "/developers/v1/base_info/:app_id" },
19
+ list_scopes: { method: "POST", path: "/developers/v1/scope/all/:app_id" },
20
+ applied_scopes: { method: "POST", path: "/developers/v1/scope/applied/:app_id" },
21
+ grant_scopes: { method: "POST", path: "/developers/v1/scope/update/:app_id" },
22
+ list_versions: { method: "POST", path: "/developers/v1/app_version/list/:app_id" },
23
+ create_version: { method: "POST", path: "/developers/v1/app_version/create/:app_id" },
24
+ publish: { method: "POST", path: "/developers/v1/publish/commit/:app_id/:version_id" },
25
+ release: { method: "POST", path: "/developers/v1/publish/release/:app_id/:version_id" },
26
+ get_robot: { method: "POST", path: "/developers/v1/robot/:app_id" },
27
+ switch_robot: { method: "POST", path: "/developers/v1/robot/switch/:app_id" },
28
+ update_robot: { method: "POST", path: "/developers/v1/robot/update/:app_id" },
29
+ switch_event: { method: "POST", path: "/developers/v1/event/switch/:app_id" },
30
+ get_event: { method: "POST", path: "/developers/v1/event/:app_id" },
31
+ update_event: { method: "POST", path: "/developers/v1/event/update/:app_id" },
32
+ switch_callback: { method: "POST", path: "/developers/v1/callback/switch/:app_id" },
33
+ get_callback: { method: "POST", path: "/developers/v1/callback/:app_id" },
34
+ switch_card_callback: {
35
+ method: "POST",
36
+ path: "/developers/v2/robot/push_mode/switch/:app_id",
37
+ },
38
+ };
39
+
40
+ const sessionCache = new Map();
41
+
42
+ function parseCookieString(cookie) {
43
+ const out = {};
44
+ for (const part of String(cookie || "").split(";")) {
45
+ const index = part.indexOf("=");
46
+ if (index <= 0) continue;
47
+ out[part.slice(0, index).trim()] = part.slice(index + 1).trim();
48
+ }
49
+ return out;
50
+ }
51
+
52
+ function cookieString(cookies) {
53
+ return Object.entries(cookies)
54
+ .map(([key, value]) => `${key}=${value}`)
55
+ .join("; ");
56
+ }
57
+
58
+ function mergeSetCookie(response, cookies) {
59
+ const setCookies =
60
+ typeof response.headers.getSetCookie === "function"
61
+ ? response.headers.getSetCookie()
62
+ : [];
63
+ for (const raw of setCookies) {
64
+ const first = raw.split(";")[0];
65
+ const index = first.indexOf("=");
66
+ if (index > 0) {
67
+ cookies[first.slice(0, index).trim()] = first.slice(index + 1).trim();
68
+ }
69
+ }
70
+ }
71
+
72
+ function extractAssignment(html, name) {
73
+ const marker = `${name}=`;
74
+ const start = html.indexOf(marker);
75
+ if (start < 0) return null;
76
+ let i = start + marker.length;
77
+ while (i < html.length && /\s/.test(html[i])) i++;
78
+ if (html[i] !== "{") return null;
79
+ let depth = 0;
80
+ let quote = "";
81
+ let escape = false;
82
+ for (let j = i; j < html.length; j++) {
83
+ const ch = html[j];
84
+ if (quote) {
85
+ if (escape) escape = false;
86
+ else if (ch === "\\") escape = true;
87
+ else if (ch === quote) quote = "";
88
+ continue;
89
+ }
90
+ if (ch === '"' || ch === "'" || ch === "`") {
91
+ quote = ch;
92
+ continue;
93
+ }
94
+ if (ch === "{") depth++;
95
+ else if (ch === "}") {
96
+ depth--;
97
+ if (depth === 0) {
98
+ try {
99
+ return JSON.parse(html.slice(i, j + 1));
100
+ } catch {
101
+ return null;
102
+ }
103
+ }
104
+ }
105
+ }
106
+ return null;
107
+ }
108
+
109
+ function summarizeUser(user) {
110
+ if (!user || typeof user !== "object" || Object.keys(user).length === 0) {
111
+ return null;
112
+ }
113
+ const tenant = user.tenant || user.tenantInfo || {};
114
+ return {
115
+ name: user.name || user.userName || user.displayName || user.enName || "",
116
+ email: user.email || "",
117
+ user_id: user.userId || user.user_id || user.id || "",
118
+ tenant_name: tenant.name || user.tenantName || user.tenant_name || "",
119
+ tenant_id: tenant.id || tenant.tenantId || user.tenantId || user.tenant_id || "",
120
+ is_admin: Boolean(user.isAdmin || user.is_admin),
121
+ };
122
+ }
123
+
124
+ export async function resolveConsoleSession(profile, { force = false } = {}) {
125
+ const { name, cookies: rawCookie } = getProfileCookies(profile);
126
+ const cached = sessionCache.get(name);
127
+ if (!force && cached && Date.now() - cached.at < 5 * 60 * 1000) {
128
+ return cached;
129
+ }
130
+ const cookies = parseCookieString(rawCookie);
131
+ const response = await fetch(`${CONSOLE_BASE}/app`, {
132
+ headers: {
133
+ Cookie: cookieString(cookies),
134
+ "User-Agent": USER_AGENT,
135
+ Accept: "text/html,application/xhtml+xml",
136
+ },
137
+ signal: AbortSignal.timeout(30_000),
138
+ });
139
+ mergeSetCookie(response, cookies);
140
+ const html = await response.text();
141
+ const csrfMatch = html.match(/window\.csrfToken="([^"]+)"/);
142
+ const csrf = csrfMatch?.[1] || cookies.lark_oapi_csrf_token || "";
143
+ const user = summarizeUser(extractAssignment(html, "window.user"));
144
+ const session = {
145
+ profile: name,
146
+ cookie: cookieString(cookies),
147
+ csrf,
148
+ user,
149
+ valid: Boolean(user) && Boolean(csrf),
150
+ page_status: response.status,
151
+ at: Date.now(),
152
+ };
153
+ sessionCache.set(name, session);
154
+ return session;
155
+ }
156
+
157
+ export function invalidateSession(profile) {
158
+ if (profile) sessionCache.delete(profile);
159
+ else sessionCache.clear();
160
+ }
161
+
162
+ function recipeFile() {
163
+ return readJson(CONSOLE_RECIPES_FILE, { version: 1, recipes: {} });
164
+ }
165
+
166
+ export function getRecipe(action) {
167
+ const file = recipeFile();
168
+ return file.recipes?.[action] || DEFAULT_RECIPES[action] || null;
169
+ }
170
+
171
+ export function recordRecipe(action, recipe) {
172
+ if (!action) return;
173
+ ensureDir(CONSOLE_RECIPES_FILE.replace(/\/[^/]+$/, ""));
174
+ const file = recipeFile();
175
+ file.version = 1;
176
+ file.recipes = file.recipes || {};
177
+ file.recipes[action] = {
178
+ ...(file.recipes[action] || {}),
179
+ ...recipe,
180
+ recorded_at: new Date().toISOString(),
181
+ };
182
+ writeJson(CONSOLE_RECIPES_FILE, file);
183
+ }
184
+
185
+ export function listRecipes() {
186
+ return recipeFile().recipes || {};
187
+ }
188
+
189
+ function pathFor(action, params = {}) {
190
+ const recipe = getRecipe(action);
191
+ if (!recipe) return null;
192
+ let path = recipe.path;
193
+ path = path.replace(":app_id", params.app_id || params.appId || "");
194
+ path = path.replace(":version_id", params.version_id || params.versionId || "");
195
+ return path;
196
+ }
197
+
198
+ export async function consoleRequest({
199
+ action,
200
+ path,
201
+ method = "POST",
202
+ body = {},
203
+ profile,
204
+ headers = {},
205
+ timeout = 30_000,
206
+ retry = true,
207
+ } = {}) {
208
+ const session = await resolveConsoleSession(profile);
209
+ if (!session.valid) {
210
+ return {
211
+ ok: false,
212
+ status: 401,
213
+ code: null,
214
+ msg: `Feishu console session is not logged in (profile=${session.profile})`,
215
+ needs_login: true,
216
+ data: null,
217
+ };
218
+ }
219
+ const finalPath = path || (action ? pathFor(action, body) : null);
220
+ if (!finalPath) throw new Error(`unknown console action: ${action}`);
221
+ const url = finalPath.startsWith("http")
222
+ ? finalPath
223
+ : `${CONSOLE_BASE}${finalPath.startsWith("/") ? "" : "/"}${finalPath}`;
224
+ const requestHeaders = {
225
+ Cookie: session.cookie,
226
+ "x-csrf-token": session.csrf,
227
+ "Content-Type": "application/json",
228
+ Accept: "application/json, text/plain, */*",
229
+ "User-Agent": USER_AGENT,
230
+ Origin: CONSOLE_BASE,
231
+ Referer: `${CONSOLE_BASE}/app`,
232
+ "X-Timezone-Offset": String(new Date().getTimezoneOffset()),
233
+ "X-Request-Id": randomUUID(),
234
+ ...headers,
235
+ };
236
+ let response;
237
+ try {
238
+ response = await fetch(url, {
239
+ method,
240
+ headers: requestHeaders,
241
+ body:
242
+ method === "GET" || method === "HEAD"
243
+ ? undefined
244
+ : typeof body === "string"
245
+ ? body
246
+ : JSON.stringify(body ?? {}),
247
+ signal: AbortSignal.timeout(timeout),
248
+ });
249
+ } catch (error) {
250
+ return {
251
+ ok: false,
252
+ status: 0,
253
+ code: null,
254
+ msg: error instanceof Error ? error.message : String(error),
255
+ data: null,
256
+ url,
257
+ };
258
+ }
259
+ const text = await response.text();
260
+ let parsed;
261
+ try {
262
+ parsed = JSON.parse(text);
263
+ } catch {
264
+ parsed = null;
265
+ }
266
+ const code = parsed?.code ?? null;
267
+ const msg = parsed?.msg ?? parsed?.message ?? "";
268
+ const ok = response.ok && (code === null || code === 0);
269
+ const result = {
270
+ ok,
271
+ status: response.status,
272
+ code,
273
+ msg,
274
+ data: parsed?.data ?? parsed ?? null,
275
+ url,
276
+ raw: parsed ? undefined : text.slice(0, 2000),
277
+ needs_login:
278
+ response.status === 401 ||
279
+ response.status === 403 ||
280
+ code === 9499 ||
281
+ /csrf|login|session/i.test(msg),
282
+ };
283
+ if (!ok && retry && (result.needs_login || response.status === 401)) {
284
+ invalidateSession(session.profile);
285
+ return consoleRequest({
286
+ action,
287
+ path: finalPath,
288
+ method,
289
+ body,
290
+ profile: session.profile,
291
+ headers,
292
+ timeout,
293
+ retry: false,
294
+ });
295
+ }
296
+ return result;
297
+ }
298
+
299
+ export function summarizeApp(app) {
300
+ const owner = app.owner || {};
301
+ return {
302
+ app_id: app.appID || app.clientID || app.appId || "",
303
+ name: app.name || app.i18n?.zh_cn?.name || "",
304
+ desc: app.desc || app.i18n?.zh_cn?.description || "",
305
+ status: app.appStatus,
306
+ dev_status: app.appListDevStatus,
307
+ list_status: app.appListStatus,
308
+ app_type: app.appType,
309
+ abilities: app.ability || [],
310
+ version: app.version,
311
+ owner: owner.displayName?.value || owner.displayName || "",
312
+ updated_at: app.updateTime || null,
313
+ };
314
+ }
315
+
316
+ export async function listApps({ profile, limit = 50, query = "" } = {}) {
317
+ const body = { Count: Math.max(1, Math.min(Number(limit) || 50, 200)), Cursor: 0 };
318
+ if (query) {
319
+ body.QueryFilter = { Query: query };
320
+ }
321
+ const response = await consoleRequest({
322
+ action: "list_apps",
323
+ method: "POST",
324
+ body,
325
+ profile,
326
+ });
327
+ if (!response.ok) return { ...response, apps: [] };
328
+ const data = response.data || {};
329
+ const apps = (data.apps || []).map(summarizeApp);
330
+ return {
331
+ ok: true,
332
+ status: response.status,
333
+ total: Number(data.totalCount ?? data.total ?? apps.length),
334
+ apps,
335
+ };
336
+ }
337
+
338
+ export async function getApp({ appId, profile } = {}) {
339
+ if (!appId) throw new Error("app_id is required");
340
+ const response = await consoleRequest({
341
+ action: "get_app",
342
+ method: "POST",
343
+ body: {},
344
+ path: pathFor("get_app", { app_id: appId }),
345
+ profile,
346
+ });
347
+ return { ...response, app: response.ok ? response.data : null };
348
+ }
349
+
350
+ export async function getCredentials({ appId, profile } = {}) {
351
+ if (!appId) throw new Error("app_id is required");
352
+ const [app, secret] = await Promise.all([
353
+ getApp({ appId, profile }),
354
+ consoleRequest({
355
+ action: "get_credentials",
356
+ method: "POST",
357
+ body: {},
358
+ path: pathFor("get_credentials", { app_id: appId }),
359
+ profile,
360
+ }),
361
+ ]);
362
+ if (!secret.ok) return { ...secret, app_id: appId };
363
+ const appSecret = secret.data?.secret || secret.data?.appSecret || secret.data?.app_secret || "";
364
+ return {
365
+ ok: true,
366
+ app_id: appId,
367
+ app_name: app.app?.name || "",
368
+ app_secret: appSecret,
369
+ secret_restricted: Boolean(app.app?.secretRestricted),
370
+ source: "console",
371
+ };
372
+ }
373
+
374
+ export async function updateAppInfo({
375
+ appId,
376
+ name,
377
+ desc,
378
+ avatar,
379
+ i18n,
380
+ languages,
381
+ profile,
382
+ dryRun = false,
383
+ } = {}) {
384
+ if (!appId) throw new Error("app_id is required");
385
+ const app = await getApp({ appId, profile });
386
+ const primary = app.app?.primaryLang || app.app?.langs?.[0] || "zh_cn";
387
+ const nextI18n = i18n
388
+ ? i18n
389
+ : name || desc
390
+ ? {
391
+ [primary]: {
392
+ ...(name ? { name } : {}),
393
+ ...(desc ? { description: desc } : {}),
394
+ },
395
+ }
396
+ : undefined;
397
+ const body = {
398
+ clientId: appId,
399
+ ...(name ? { name } : {}),
400
+ ...(desc ? { desc } : {}),
401
+ ...(avatar ? { avatar } : {}),
402
+ ...(languages ? { languages } : {}),
403
+ ...(nextI18n ? { i18n: nextI18n } : {}),
404
+ };
405
+ const plan = {
406
+ action: "update_app_info",
407
+ method: "POST",
408
+ path: pathFor("update_app_info", { app_id: appId }),
409
+ body,
410
+ };
411
+ if (dryRun) return { ok: true, dry_run: true, plan };
412
+ const response = await consoleRequest({
413
+ action: "update_app_info",
414
+ method: "POST",
415
+ path: plan.path,
416
+ body,
417
+ profile,
418
+ });
419
+ return { ...response, plan };
420
+ }
421
+
422
+ export async function createApp({
423
+ name,
424
+ desc = "",
425
+ avatar = "",
426
+ appSceneType = 0,
427
+ primaryLang = "zh_cn",
428
+ profile,
429
+ dryRun = false,
430
+ } = {}) {
431
+ if (!name) throw new Error("name is required");
432
+ const body = {
433
+ fromProduct: 0,
434
+ appSceneType,
435
+ name,
436
+ desc,
437
+ avatar,
438
+ primaryLang,
439
+ i18n: {
440
+ [primaryLang]: {
441
+ name,
442
+ description: desc,
443
+ },
444
+ },
445
+ };
446
+ const plan = {
447
+ action: "create_app",
448
+ method: "POST",
449
+ path: pathFor("create_app"),
450
+ body,
451
+ };
452
+ if (dryRun) return { ok: true, dry_run: true, plan };
453
+ const response = await consoleRequest({
454
+ action: "create_app",
455
+ method: "POST",
456
+ path: plan.path,
457
+ body,
458
+ profile,
459
+ });
460
+ return {
461
+ ...response,
462
+ app_id: response.data?.ClientID || response.data?.clientID || response.data?.appID,
463
+ plan,
464
+ };
465
+ }
466
+
467
+ export async function listScopes({ appId, profile } = {}) {
468
+ if (!appId) throw new Error("app_id is required");
469
+ const response = await consoleRequest({
470
+ action: "list_scopes",
471
+ method: "POST",
472
+ body: { clientId: appId },
473
+ path: pathFor("list_scopes", { app_id: appId }),
474
+ profile,
475
+ });
476
+ if (!response.ok) return { ...response, scopes: [], scope_bizs: [] };
477
+ const data = response.data || {};
478
+ return {
479
+ ok: true,
480
+ total: (data.scopes || []).length,
481
+ scope_bizs: (data.scopeBizs || []).map((biz) => ({
482
+ id: biz.bizId,
483
+ name: biz.bizName,
484
+ })),
485
+ scopes: (data.scopes || []).map((scope) => ({
486
+ id: scope.id,
487
+ name: scope.name,
488
+ biz: scope.bizId,
489
+ status: scope.status,
490
+ levels: scope.levels,
491
+ desc: scope.desc,
492
+ identity_types: scope.supportScopeIdentityTypes || scope.scopeIdentityType,
493
+ })),
494
+ };
495
+ }
496
+
497
+ export async function appliedScopes({ appId, profile } = {}) {
498
+ if (!appId) throw new Error("app_id is required");
499
+ const response = await consoleRequest({
500
+ action: "applied_scopes",
501
+ method: "POST",
502
+ body: { clientId: appId },
503
+ path: pathFor("applied_scopes", { app_id: appId }),
504
+ profile,
505
+ });
506
+ if (!response.ok) return { ...response, scopes: [] };
507
+ const data = response.data || {};
508
+ return {
509
+ ok: true,
510
+ total: (data.scopes || []).length,
511
+ scopes: (data.scopes || []).map((scope) => ({
512
+ id: scope.id,
513
+ name: scope.name,
514
+ biz: scope.bizId,
515
+ status: scope.status,
516
+ })),
517
+ };
518
+ }
519
+
520
+ async function searchScope({ appId, scopeName, profile }) {
521
+ const response = await consoleRequest({
522
+ action: "search_scope",
523
+ method: "POST",
524
+ body: { clientId: appId, query: scopeName },
525
+ path: `/developers/v1/scope/search/${appId}`,
526
+ profile,
527
+ });
528
+ if (!response.ok) return null;
529
+ const data = response.data || {};
530
+ const list = data.scopes || data.scopeNames || data.items || [];
531
+ if (!list.length) return null;
532
+ const hit = list.find((item) => item.name === scopeName || item === scopeName);
533
+ return hit || list[0] || null;
534
+ }
535
+
536
+ export async function grantScopes({
537
+ appId,
538
+ scopes = [],
539
+ scopeType = "app",
540
+ operation = "add",
541
+ profile,
542
+ dryRun = false,
543
+ } = {}) {
544
+ if (!appId) throw new Error("app_id is required");
545
+ if (!Array.isArray(scopes) || scopes.length === 0) {
546
+ throw new Error("scopes must be a non-empty array of scope names");
547
+ }
548
+ const all = await listScopes({ appId, profile });
549
+ if (!all.ok) return all;
550
+ const byName = new Map(all.scopes.map((scope) => [scope.name, scope]));
551
+ const resolved = [];
552
+ const missing = [];
553
+ for (const name of scopes) {
554
+ const hit = byName.get(name);
555
+ if (hit) resolved.push(hit);
556
+ else {
557
+ const remote = await searchScope({ appId, scopeName: name, profile });
558
+ if (remote?.id) resolved.push(remote);
559
+ else missing.push(name);
560
+ }
561
+ }
562
+ const ids = resolved.map((scope) => String(scope.id));
563
+ const body = {
564
+ clientId: appId,
565
+ operation,
566
+ scopeIds: [],
567
+ ...(scopeType === "user" || scopeType === "both"
568
+ ? { userScopeIDs: ids }
569
+ : { appScopeIDs: ids }),
570
+ ...(scopeType === "both" ? { appScopeIDs: ids } : {}),
571
+ };
572
+ const plan = {
573
+ action: "grant_scopes",
574
+ method: "POST",
575
+ path: pathFor("grant_scopes", { app_id: appId }),
576
+ body,
577
+ resolved: resolved.map((s) => ({ id: s.id, name: s.name })),
578
+ missing,
579
+ };
580
+ if (missing.length && resolved.length === 0) {
581
+ return { ok: false, error: "no scope names resolved", plan };
582
+ }
583
+ if (dryRun) return { ok: true, dry_run: true, plan };
584
+ const response = await consoleRequest({
585
+ action: "grant_scopes",
586
+ method: "POST",
587
+ path: plan.path,
588
+ body,
589
+ profile,
590
+ });
591
+ return { ...response, plan };
592
+ }
593
+
594
+ export async function listVersions({ appId, profile } = {}) {
595
+ if (!appId) throw new Error("app_id is required");
596
+ const response = await consoleRequest({
597
+ action: "list_versions",
598
+ method: "POST",
599
+ body: { clientId: appId },
600
+ path: pathFor("list_versions", { app_id: appId }),
601
+ profile,
602
+ });
603
+ if (!response.ok) return { ...response, versions: [] };
604
+ const data = response.data || {};
605
+ return {
606
+ ok: true,
607
+ versions: (data.versions || []).map((version) => ({
608
+ version_id: version.versionId,
609
+ app_version: version.appVersion,
610
+ status: version.versionStatus,
611
+ remark: version.updateRemark,
612
+ create_time: version.createTime,
613
+ publish_time: version.publishTime,
614
+ })),
615
+ };
616
+ }
617
+
618
+ export async function enableBotWs({
619
+ appId,
620
+ enableBot = true,
621
+ enableEvents = true,
622
+ enableCallbacks = true,
623
+ enableCardRequestMode = false,
624
+ profile,
625
+ dryRun = false,
626
+ } = {}) {
627
+ if (!appId) throw new Error("app_id is required");
628
+ const calls = [];
629
+ if (enableBot) {
630
+ calls.push({
631
+ action: "switch_robot",
632
+ path: pathFor("switch_robot", { app_id: appId }),
633
+ body: { clientId: appId, enable: true },
634
+ });
635
+ }
636
+ if (enableEvents) {
637
+ calls.push({
638
+ action: "switch_event",
639
+ path: pathFor("switch_event", { app_id: appId }),
640
+ body: { clientId: appId, eventMode: 4 },
641
+ });
642
+ }
643
+ if (enableCallbacks) {
644
+ calls.push({
645
+ action: "switch_callback",
646
+ path: pathFor("switch_callback", { app_id: appId }),
647
+ body: { clientId: appId, callbackMode: 4 },
648
+ });
649
+ }
650
+ if (enableCardRequestMode) {
651
+ calls.push({
652
+ action: "switch_card_callback",
653
+ path: pathFor("switch_card_callback", { app_id: appId }),
654
+ body: { clientId: appId, callbackMode: 1 },
655
+ });
656
+ }
657
+ if (dryRun) return { ok: true, dry_run: true, calls };
658
+
659
+ const results = [];
660
+ for (const call of calls) {
661
+ results.push({
662
+ action: call.action,
663
+ ...(await consoleRequest({
664
+ action: call.action,
665
+ method: "POST",
666
+ path: call.path,
667
+ body: call.body,
668
+ profile,
669
+ })),
670
+ });
671
+ }
672
+ const [robot, event, callback] = await Promise.all([
673
+ consoleRequest({
674
+ action: "get_robot",
675
+ method: "POST",
676
+ body: { clientId: appId },
677
+ path: pathFor("get_robot", { app_id: appId }),
678
+ profile,
679
+ }),
680
+ consoleRequest({
681
+ action: "get_event",
682
+ method: "POST",
683
+ body: { clientId: appId, needEventDetail: false },
684
+ path: pathFor("get_event", { app_id: appId }),
685
+ profile,
686
+ }),
687
+ consoleRequest({
688
+ action: "get_callback",
689
+ method: "POST",
690
+ body: { clientId: appId },
691
+ path: pathFor("get_callback", { app_id: appId }),
692
+ profile,
693
+ }),
694
+ ]);
695
+ return {
696
+ ok: results.every((result) => result.ok),
697
+ calls: results,
698
+ verification: {
699
+ bot_enabled: Boolean(robot.data?.enable),
700
+ event_mode: event.data?.eventMode,
701
+ callback_mode: callback.data?.callbackMode,
702
+ card_request_mode: robot.data?.cardCallbackMode,
703
+ events: event.data?.events || [],
704
+ callbacks: callback.data?.callbacks || [],
705
+ },
706
+ raw: {
707
+ robot: robot.data,
708
+ event: event.data,
709
+ callback: callback.data,
710
+ },
711
+ };
712
+ }
713
+
714
+ export async function publishVersion({
715
+ appId,
716
+ version,
717
+ remark = "",
718
+ fullRelease = false,
719
+ createIfMissing = true,
720
+ release = false,
721
+ profile,
722
+ dryRun = false,
723
+ } = {}) {
724
+ if (!appId) throw new Error("app_id is required");
725
+ const versions = await listVersions({ appId, profile });
726
+ if (!versions.ok) return versions;
727
+ let target = versions.versions.find((v) => v.version_id && v.status !== 2);
728
+ const plan = { action: "publish", app_id: appId, steps: [] };
729
+ let created = false;
730
+ if (!target && createIfMissing) {
731
+ const createBody = {
732
+ clientId: appId,
733
+ appVersion: version || nextVersion(versions.versions),
734
+ updateRemark: remark || "larkit automated publish",
735
+ visibleSuggest: {},
736
+ changeLog: remark || "larkit automated publish",
737
+ };
738
+ plan.steps.push({
739
+ action: "create_version",
740
+ path: pathFor("create_version", { app_id: appId }),
741
+ body: createBody,
742
+ });
743
+ if (!dryRun) {
744
+ const createdResponse = await consoleRequest({
745
+ action: "create_version",
746
+ method: "POST",
747
+ path: pathFor("create_version", { app_id: appId }),
748
+ body: createBody,
749
+ profile,
750
+ });
751
+ if (!createdResponse.ok) return { ...createdResponse, plan };
752
+ created = true;
753
+ target = {
754
+ version_id:
755
+ createdResponse.data?.versionId ||
756
+ createdResponse.data?.version_id ||
757
+ createdResponse.data?.VersionId,
758
+ app_version: createBody.appVersion,
759
+ };
760
+ if (!target.version_id) {
761
+ return {
762
+ ok: false,
763
+ error: "version created but versionId missing from response",
764
+ response: createdResponse,
765
+ plan,
766
+ };
767
+ }
768
+ } else {
769
+ target = { version_id: "<new>", app_version: createBody.appVersion };
770
+ }
771
+ }
772
+ if (!target?.version_id) {
773
+ return {
774
+ ok: false,
775
+ error: "no draft version found; pass create_if_missing=true and a version",
776
+ versions: versions.versions,
777
+ plan,
778
+ };
779
+ }
780
+ const commitBody = { clientId: appId, versionId: target.version_id, is_full_release: fullRelease };
781
+ plan.steps.push({
782
+ action: "publish",
783
+ path: pathFor("publish", { app_id: appId, version_id: target.version_id }),
784
+ body: commitBody,
785
+ });
786
+ if (release) {
787
+ plan.steps.push({
788
+ action: "release",
789
+ path: pathFor("release", { app_id: appId, version_id: target.version_id }),
790
+ body: commitBody,
791
+ });
792
+ }
793
+ if (dryRun) return { ok: true, dry_run: true, plan, versions: versions.versions };
794
+ const commitResponse = await consoleRequest({
795
+ action: "publish",
796
+ method: "POST",
797
+ path: pathFor("publish", { app_id: appId, version_id: target.version_id }),
798
+ body: commitBody,
799
+ profile,
800
+ });
801
+ if (!commitResponse.ok) return { ...commitResponse, plan };
802
+ let releaseResponse = null;
803
+ if (release) {
804
+ releaseResponse = await consoleRequest({
805
+ action: "release",
806
+ method: "POST",
807
+ path: pathFor("release", { app_id: appId, version_id: target.version_id }),
808
+ body: commitBody,
809
+ profile,
810
+ });
811
+ }
812
+ return {
813
+ ok: releaseResponse ? releaseResponse.ok : true,
814
+ created_version: created,
815
+ version: target,
816
+ commit: commitResponse,
817
+ release: releaseResponse,
818
+ plan,
819
+ };
820
+ }
821
+
822
+ function nextVersion(versions) {
823
+ const latest = versions.find((v) => /^\d+\.\d+\.\d+$/.test(v.app_version || ""));
824
+ if (!latest) return "1.0.0";
825
+ const [major, minor, patch] = latest.app_version.split(".").map(Number);
826
+ return `${major}.${minor}.${patch + 1}`;
827
+ }