dsh-agentone 0.5.44 → 1.0.2

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/lib/client.js CHANGED
@@ -281,6 +281,311 @@ window.__ModuleLoader__.load({
281
281
  );
282
282
  }
283
283
 
284
+ // ==drive-tab-begin==
285
+ // 飞书云盘侧边栏 tab:iframe 直嵌飞书网页版(飞书不设 frame-ancestors,
286
+ // 2026-10-13 spike 实测)。寻址走 /agentone/api/drive/home——host 侧用
287
+ // lark-cli 找到/创建用户的「我的空间/AgentOne」文件夹(folder token 每
288
+ // 用户独立,不能平台级写死,见 lib/drive.js)。页面交互(上传/下载/
289
+ // 整理)全部是飞书官方 Web UI,本组件只管「落到正确层级」+ 引导三态
290
+ // + 内容自动刷新(轮询 /api/drive/rev 清单指纹,变了重挂载 iframe)
291
+ // + 右下角手动刷新按钮(跨域 iframe 收不到事件,也调不了 reload)。
292
+ // better-sidebar 未装(独立 dsh 用户)时 ctx.get 取不到服务,静默不注册。
293
+ const DRIVE_TAB_ID = 'agentone:feishu-drive';
294
+ const DRIVE_HOME_API = '/agentone/api/drive/home';
295
+
296
+ // 寻址响应 → 组件相位(纯函数,测试直接覆盖)。
297
+ const DRIVE_GATE_STATES = [
298
+ 'cli_missing', 'cli_unconfigured', 'cli_unauthorized',
299
+ 'drive_scope_missing', 'drive_app_forbidden',
300
+ ];
301
+
302
+ function driveHomePhase(ok, body) {
303
+ const data = body && typeof body === 'object' ? body : {};
304
+ if (ok && data.state === 'ready' && data.url) {
305
+ return { kind: 'ready', url: data.url, name: data.name || 'AgentOne' };
306
+ }
307
+ if (ok && DRIVE_GATE_STATES.includes(data.state)) {
308
+ return {
309
+ kind: 'gate',
310
+ state: data.state,
311
+ missingScopes: Array.isArray(data.missing_scopes) ? data.missing_scopes : [],
312
+ appMissingScopes: Array.isArray(data.app_missing_scopes) ? data.app_missing_scopes : [],
313
+ };
314
+ }
315
+ return { kind: 'error', message: data.error || '云盘文件夹加载失败,请重试' };
316
+ }
317
+
318
+ const DRIVE_GATE_TEXT = {
319
+ // 首次使用要走一次飞书设备流授权(弹窗里扫码/确认),这层提示必须写明
320
+ cli_unauthorized: '云盘页签需要 lark-cli 的飞书授权才能定位你的「AgentOne」文件夹。首次使用请到 AgentOne 设置页点「去授权」,用手机飞书扫码确认后回来点「重试」。',
321
+ cli_missing: '本机还没有安装 lark-cli,无法定位云盘文件夹。请先在 AgentOne 设置页的「CLI」页签安装,再回来点「重试」。',
322
+ cli_unconfigured: 'lark-cli 还没有完成飞书应用初始化。请先在 AgentOne 设置页的「CLI」页签完成初始化(需要扫码/确认一次),再回来点「重试」。',
323
+ };
324
+
325
+ const scopeList = (scopes) => (scopes.length ? `(缺少:${scopes.join('、')})` : '');
326
+
327
+ /** 三态之外的权限缺口文案:分清「用户重新授权能修」与「只能找管理员」。 */
328
+ function driveGateText(phase) {
329
+ if (phase.state === 'drive_scope_missing') {
330
+ return '你的飞书授权里还没有云盘权限' + scopeList(phase.missingScopes)
331
+ + '。请到 AgentOne 设置页 →「云盘」点「重新授权」,扫码确认后回来点「重试」。';
332
+ }
333
+ if (phase.state === 'drive_app_forbidden') {
334
+ return '飞书应用本身还没有开通云空间权限' + scopeList(phase.appMissingScopes.length ? phase.appMissingScopes : phase.missingScopes)
335
+ + ',重新授权解决不了。请联系管理员在飞书开放平台为应用开通云空间权限并发布版本,然后回来点「重试」。';
336
+ }
337
+ return DRIVE_GATE_TEXT[phase.state] || '云盘暂时不可用,请稍后点「重试」。';
338
+ }
339
+
340
+ const DRIVE_REV_API = '/agentone/api/drive/rev';
341
+ const DRIVE_REV_POLL_MS = 8000;
342
+ const DRIVE_REFRESH_TITLE = '刷新云盘内容';
343
+ const DRIVE_STALE_TITLE = '云盘内容有更新,点击刷新';
344
+
345
+ function FeishuDriveTab() {
346
+ const [phase, setPhase] = React.useState({ kind: 'loading' });
347
+ const [retryTick, setRetryTick] = React.useState(0);
348
+ const [reloadTick, setReloadTick] = React.useState(0);
349
+ const [stale, setStale] = React.useState(false);
350
+ React.useEffect(() => {
351
+ let cancelled = false;
352
+ setPhase({ kind: 'loading' });
353
+ fetch(DRIVE_HOME_API)
354
+ .then(async (resp) => driveHomePhase(resp.ok, await resp.json().catch(() => null)))
355
+ .then((next) => { if (!cancelled) setPhase(next); })
356
+ .catch(() => { if (!cancelled) setPhase({ kind: 'error', message: '网络异常,请重试' }); });
357
+ return () => { cancelled = true; };
358
+ }, [retryTick]);
359
+
360
+ // 自动刷新:iframe 跨域收不到飞书内部事件,只能宿主侧轮询根清单指纹
361
+ // (/drive/rev,服务端 10s 节流)——子文件夹里增删文档会 bump 它在根
362
+ // 清单中的 modified_time(真机实测),指纹一变就重挂载 iframe。首拍只
363
+ // 做基线不重载;rev 为 null(CLI 不可用/资料夹未初始化)按「未知」不动;
364
+ // 页面隐藏时跳过这一拍,省无谓的请求。
365
+ // 重挂载会回到根视图(跨域读不到也恢复不了用户在飞书里点开的层级),
366
+ // 所以检测到用户正浏览云盘(焦点在 iframe 内,activeElement 跨域可读)
367
+ // 时不硬拽——只点亮刷新按钮提示「有更新」,基线不前移;焦点离开后
368
+ // 下一拍自动应用。
369
+ React.useEffect(() => {
370
+ if (phase.kind !== 'ready') return undefined;
371
+ let baseline = null;
372
+ let stopped = false;
373
+ const timer = setInterval(() => {
374
+ if (typeof document !== 'undefined' && document.hidden) return;
375
+ fetch(DRIVE_REV_API)
376
+ .then(async (resp) => (resp.ok ? await resp.json().catch(() => null) : null))
377
+ .then((body) => {
378
+ if (stopped || !body || typeof body.rev !== 'string' || !body.rev) return;
379
+ if (baseline === null) {
380
+ baseline = body.rev;
381
+ return;
382
+ }
383
+ if (body.rev === baseline) return;
384
+ const browsing = typeof document !== 'undefined' && document.activeElement
385
+ && document.activeElement.tagName === 'IFRAME';
386
+ if (browsing) {
387
+ setStale(true);
388
+ return;
389
+ }
390
+ baseline = body.rev;
391
+ setStale(false);
392
+ setReloadTick((t) => t + 1);
393
+ })
394
+ .catch(() => {});
395
+ }, DRIVE_REV_POLL_MS);
396
+ return () => { stopped = true; clearInterval(timer); };
397
+ }, [phase.kind === 'ready' ? phase.url : '']);
398
+
399
+ // 手动刷新:立即重载 iframe 并重新寻址(资料夹被重新定位/收养过也能跟上);
400
+ // 寻址失败保留当前画面不打断
401
+ const refreshDrive = () => {
402
+ setStale(false);
403
+ setReloadTick((t) => t + 1);
404
+ fetch(DRIVE_HOME_API)
405
+ .then(async (resp) => driveHomePhase(resp.ok, await resp.json().catch(() => null)))
406
+ .then((next) => { if (next.kind === 'ready') setPhase(next); })
407
+ .catch(() => {});
408
+ };
409
+
410
+ if (phase.kind === 'loading') {
411
+ return React.createElement('div', {
412
+ style: { padding: '24px', textAlign: 'center', color: 'var(--dsw-alias-label-secondary, #57606a)', fontSize: '13px' },
413
+ }, '正在定位你的飞书云盘文件夹…');
414
+ }
415
+ if (phase.kind === 'ready') {
416
+ // 不设 sandbox:飞书登录态依赖真实第三方 cookie 上下文(spike 实测
417
+ // 无 sandbox 时登录页可渲染、登录后云盘可用)。key 随 reloadTick 变化
418
+ // 即重挂载 = 重载(跨域 iframe 不能调 contentWindow.location.reload)。
419
+ // 用户浏览中时刷新被推迟:顶部横幅 + 胶囊按钮变色双提示(见上面轮询注释)。
420
+ const refreshTitle = stale ? DRIVE_STALE_TITLE : DRIVE_REFRESH_TITLE;
421
+ const banner = React.createElement('div', {
422
+ key: 'stale-banner',
423
+ style: {
424
+ position: 'absolute', top: '0', left: '0', right: '0', zIndex: '1',
425
+ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '8px',
426
+ padding: '6px 10px', fontSize: '12px',
427
+ background: 'rgba(47, 111, 235, 0.92)', color: '#fff',
428
+ boxShadow: '0 1px 4px rgba(0,0,0,0.12)',
429
+ },
430
+ }, '云盘内容有更新',
431
+ React.createElement('button', {
432
+ type: 'button',
433
+ onClick: refreshDrive,
434
+ style: {
435
+ border: '1px solid rgba(255,255,255,.7)', borderRadius: '6px', padding: '1px 10px',
436
+ fontSize: '12px', cursor: 'pointer',
437
+ background: 'rgba(255,255,255,.16)', color: '#fff',
438
+ },
439
+ }, '立即刷新'));
440
+ const frame = React.createElement('iframe', {
441
+ key: `drive-frame-${reloadTick}`,
442
+ src: phase.url,
443
+ title: '飞书云盘',
444
+ style: {
445
+ border: 'none', width: '100%', height: '100%', minHeight: '320px',
446
+ background: '#fff', display: 'block',
447
+ },
448
+ });
449
+ const pill = React.createElement('button', {
450
+ type: 'button',
451
+ title: refreshTitle,
452
+ 'aria-label': refreshTitle,
453
+ onClick: refreshDrive,
454
+ style: {
455
+ position: 'absolute', right: '12px', bottom: '12px', zIndex: '1',
456
+ display: 'inline-flex', alignItems: 'center', gap: '5px',
457
+ height: '28px', padding: '0 12px',
458
+ border: stale ? 'none' : '1px solid rgba(47, 111, 235, 0.28)',
459
+ borderRadius: '14px', cursor: 'pointer', fontSize: '12px',
460
+ background: stale ? '#2f6feb' : '#eaf1fe',
461
+ color: stale ? '#fff' : '#2f6feb',
462
+ boxShadow: '0 1px 5px rgba(0,0,0,.18)',
463
+ },
464
+ }, React.createElement('svg', {
465
+ viewBox: '0 0 24 24', width: '14', height: '14', 'aria-hidden': 'true', focusable: 'false',
466
+ }, React.createElement('path', {
467
+ d: 'M17.65 6.35A7.95 7.95 0 0 0 12 4a8 8 0 1 0 7.73 10h-2.08A6 6 0 1 1 12 6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z',
468
+ fill: 'currentColor',
469
+ })),
470
+ React.createElement('span', null, stale ? '有更新,刷新' : '刷新'));
471
+ return React.createElement('div', {
472
+ style: { position: 'relative', width: '100%', height: '100%', minHeight: '320px' },
473
+ }, ...(stale ? [banner] : []), frame, pill);
474
+ }
475
+ const text = phase.kind === 'gate' ? driveGateText(phase) : phase.message;
476
+ const center = {
477
+ padding: '28px 20px', textAlign: 'center', color: 'var(--dsw-alias-label-secondary, #57606a)',
478
+ fontSize: '13px', lineHeight: '1.7',
479
+ };
480
+ const btnPrimary = {
481
+ padding: '7px 18px', fontSize: '13px', borderRadius: '9px', border: 'none', cursor: 'pointer',
482
+ background: '#2f6feb', color: '#fff', marginRight: '8px',
483
+ };
484
+ const btnGhost = { ...btnPrimary, background: 'var(--dsw-alias-fill-secondary, #f2f3f5)', color: 'var(--dsw-alias-label-primary, #3c434d)' };
485
+ return React.createElement('div', { style: center },
486
+ React.createElement('p', { style: { margin: '0 0 16px' } }, text),
487
+ React.createElement('button', { style: btnPrimary, onClick: () => setRetryTick((t) => t + 1) }, '重试'),
488
+ phase.kind === 'gate'
489
+ ? React.createElement('button', {
490
+ style: btnGhost,
491
+ onClick: () => window.open('/agentone/', '_blank', 'noopener'),
492
+ }, '打开设置页')
493
+ : null);
494
+ }
495
+
496
+ function feishuDriveTabDescriptor() {
497
+ return {
498
+ id: DRIVE_TAB_ID,
499
+ title: () => '飞书云盘',
500
+ description: () => '进入你的飞书云盘「AgentOne」文件夹,资料的上传与管理都在飞书界面内完成',
501
+ order: 80,
502
+ single: true,
503
+ // 飞书风格云盘实心图标(用户提供的 云盘.svg,viewBox 1024)
504
+ icon: (size) => React.createElement('svg', {
505
+ viewBox: '0 0 1024 1024', width: size, height: size,
506
+ 'aria-hidden': 'true', focusable: 'false',
507
+ }, React.createElement('path', {
508
+ d: 'M778.581333 374.101333C766.976 237.226667 651.946667 129.706667 512 129.706667s-254.976 107.52-266.581333 244.394666c-93.184 27.989333-160.768 114.005333-160.768 216.064 0 124.586667 101.034667 225.621333 225.621333 225.621334h404.138667c124.586667 0 225.621333-101.034667 225.621333-225.621334-0.341333-102.058667-68.266667-188.074667-161.450667-216.064z m-128.341333 209.237334c-22.869333 21.845333-62.122667 49.834667-117.418667 58.709333-11.264 1.706667-22.186667 2.730667-32.768 2.730667-72.704 0-126.293333-37.888-142.336-50.858667-18.090667-14.677333-21.162667-41.301333-6.485333-59.392 14.677333-18.090667 41.301333-21.162667 59.392-6.485333 11.605333 9.557333 53.930667 39.253333 108.885333 30.72 35.498667-5.802667 60.416-25.258667 72.021334-36.181334 17.066667-16.042667 43.690667-15.36 59.733333 1.365334 16.725333 16.384 16.042667 43.349333-1.024 59.392z',
509
+ fill: '#635DF7',
510
+ opacity: '.7',
511
+ })),
512
+ component: () => React.createElement(FeishuDriveTab),
513
+ };
514
+ }
515
+
516
+ // 设置页(/agentone/ 子框架,web 走 iframe、桌面壳走 srcdoc)里点「打开云盘」
517
+ // 时,经 postMessage 让宿主页这边的我们把页签开出来;开不了就回 ok:false,
518
+ // 页面自己退回「新窗口打开飞书链接」。srcdoc 子框架 origin 为 'null'。
519
+ const DRIVE_OPEN_MESSAGE = 'agentone:open-feishu-drive';
520
+ const DRIVE_OPEN_RESULT = 'agentone:open-feishu-drive:result';
521
+
522
+ function isDriveOpenRequest(data) {
523
+ return Boolean(data && typeof data === 'object' && data.type === DRIVE_OPEN_MESSAGE);
524
+ }
525
+
526
+ function driveMessageTrusted(origin) {
527
+ return !origin || origin === 'null' || origin === window.location.origin;
528
+ }
529
+
530
+ /** 当前 dsh 会话 id(openTab 的 scope 用;取不到就让服务自己决定)。 */
531
+ function currentDshSessionId() {
532
+ try {
533
+ const raw = window.localStorage.getItem('dsh.sessions.current');
534
+ const current = raw ? JSON.parse(raw) : null;
535
+ return (current && current.sessionId) || null;
536
+ } catch {
537
+ return null;
538
+ }
539
+ }
540
+
541
+ // 注册并返回 disposer。走 cordis 软依赖 inject(与同宿主 dsh-ccpg-canvasui
542
+ // 注册侧边栏 tab 同口径):better-sidebar 未装时回调不执行——独立 dsh
543
+ // 用户静默无页签;装了则等 betterSidebar 真正 provide 后再注册。
544
+ // 不能用同步 ctx.get:client fiber 的激活顺序不保证 better-sidebar 先就绪,
545
+ // 那个窗口里取到 null 会静默跳过且永不重试(真机事故:侧边栏找不到云盘入口)。
546
+ function registerFeishuDriveTab(ctx) {
547
+ if (typeof ctx.inject !== 'function') return () => {};
548
+ return ctx.inject(['betterSidebar'], (scope) => {
549
+ const sidebar = scope.betterSidebar;
550
+ if (!sidebar || typeof sidebar.registerTab !== 'function') return;
551
+ const openDriveTab = () => {
552
+ const sessionId = currentDshSessionId();
553
+ // better-sidebar 0.25 openTab 契约:{type,…} + ({sessionId}) scope
554
+ sidebar.openTab({ type: DRIVE_TAB_ID, title: '飞书云盘' }, sessionId ? { sessionId } : undefined);
555
+ return true;
556
+ };
557
+ const onMessage = (event) => {
558
+ if (!isDriveOpenRequest(event.data) || !driveMessageTrusted(event.origin)) return;
559
+ let ok = false;
560
+ try {
561
+ ok = openDriveTab();
562
+ } catch {
563
+ ok = false;
564
+ }
565
+ const target = event.origin && event.origin !== 'null' ? event.origin : '*';
566
+ try {
567
+ event.source?.postMessage({ type: DRIVE_OPEN_RESULT, ok }, target);
568
+ } catch {
569
+ /* 回执失败不影响已打开/未打开的事实 */
570
+ }
571
+ };
572
+ // 老宿主/测试沙箱可能没有 addEventListener:消息桥是增强,缺了就只注册页签
573
+ const canListen = typeof window.addEventListener === 'function';
574
+ if (canListen) window.addEventListener('message', onMessage);
575
+ const register = () => sidebar.registerTab(feishuDriveTabDescriptor());
576
+ if (typeof scope.effect !== 'function') {
577
+ register();
578
+ return;
579
+ }
580
+ scope.effect(register, 'dsh-agentone: feishu-drive tab');
581
+ if (canListen) {
582
+ scope.effect(() => () => window.removeEventListener('message', onMessage),
583
+ 'dsh-agentone: feishu-drive open bridge');
584
+ }
585
+ });
586
+ }
587
+ // ==drive-tab-end==
588
+
284
589
  // ── 设置导航图标(workbench_fill)─────────────────────────────────────
285
590
  // dsh 设置弹窗的导航图标由 shell(ui-settings-general)按 section id 硬编码,
286
591
  // 注册项没有 icon 选项(仅 models/agent-presets/plugins 有专用图标,其余一律
@@ -333,6 +638,8 @@ window.__ModuleLoader__.load({
333
638
 
334
639
  function apply(ctx) {
335
640
  setupNavIcon();
641
+ // 飞书云盘侧边栏 tab(better-sidebar 缺失时内部静默跳过)
642
+ registerFeishuDriveTab(ctx);
336
643
  ctx.slots.inject('settings.section', () =>
337
644
  ctx.slots.register(
338
645
  {
@@ -259,7 +259,17 @@ export function createDefaultsSync({ config, profiles, credentials, log, interva
259
259
  promise.finally(() => manualOperations.delete(promise)).catch(() => {});
260
260
  return promise;
261
261
  }
262
- return { sync, manualOperation, snapshot: () => structuredClone(state),
262
+ // 快照是 /api/status(5s 轮询)与 defaults/status 的下发边界:description
263
+ // (技能包全文,几百字/条)只在展示层被引用,而新版页面已彻底不渲染——
264
+ // 在出口剥掉,轮询不再背着几十 KB 死重;内部 state 保持完整
265
+ const snapshot = () => {
266
+ const clone = structuredClone(state);
267
+ for (const list of [clone.skills, clone.plugins]) {
268
+ for (const entry of list || []) delete entry.description;
269
+ }
270
+ return clone;
271
+ };
272
+ return { sync, manualOperation, snapshot,
263
273
  reset() { stop(); profiles.setPolicy([]); state = { source: null, revision: null,
264
274
  checked_at: null, user_id: null, error: null, running: false, skills: [], plugins: [] }; },
265
275
  dispose() { disposed = true; stop(); return Promise.allSettled([inFlight, ...manualOperations.keys()]); },
package/lib/drive.js ADDED
@@ -0,0 +1,289 @@
1
+ // 飞书云盘「AgentOne」文件夹寻址(host 半区)。
2
+ //
3
+ // 侧边栏「飞书云盘」tab 的页面本体是飞书官方 Web UI(iframe 直嵌,飞书
4
+ // 不设 frame-ancestors——2026-10-13 spike 实测),插件只负责把用户落到
5
+ // 正确的层级:他的「我的空间 / AgentOne」根目录。iframe 加载的文件夹 URL
6
+ // 带每用户独立的 folder token(fldcn…),无法平台级写死,所以打开 tab 时
7
+ // 经本模块用 lark-cli 寻址:
8
+ // 1. 本地缓存的 folder token 校验仍可用 → 直接用(用户改文件夹名不
9
+ // 影响,token 不变);
10
+ // 2. 失效则按名字在「我的空间」根目录翻页找;
11
+ // 3. 找不到才创建(发生在用户点开云盘 tab / 设置页点初始化的明确意图
12
+ // 之下,不是后台自动写)。
13
+ // 设置页还支持「收养」:用户粘贴任意云盘文件夹链接,inspect 解出 token
14
+ // 校验可访问后作为文件夹(resolve 的 adoptUrl 路径)。
15
+ import { createHash } from 'node:crypto';
16
+ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
17
+ import { join } from 'node:path';
18
+
19
+ import { storeDir } from './store.js';
20
+
21
+ export const AGENTONE_FOLDER_NAME = 'AgentOne';
22
+ const DRIVE_CACHE_FILE = 'drive.json';
23
+ // 根目录翻页上限(每页 200):正常根目录远不会到 2000 项;超出宁可报错
24
+ // 也不新建——「找不到就建」在翻页盲区会造成两个同名文件夹分裂。
25
+ const MAX_LIST_PAGES = 10;
26
+ const LIST_PAGE_SIZE = 200;
27
+
28
+ /**
29
+ * lark-cli JSON 输出解析。typed 命令与 `api` 子命令统一包 {ok, identity,
30
+ * data} 壳(ok=false 时 error 带结构化原因);解析失败与 ok=false 一律抛
31
+ * LarkError —— 上层的用户可见文案由 classifyMissingScopes 决定,不把
32
+ * lark-cli 的英文原文丢给用户。
33
+ */
34
+ export class LarkError extends Error {
35
+ constructor(payload = {}) {
36
+ const raw = payload.message || payload.hint || '';
37
+ // message 只进日志/排查;用户可见文案由路由层按 kind 生成
38
+ super(`飞书接口调用失败${raw ? `:${raw}` : ''}`);
39
+ this.name = 'LarkError';
40
+ this.type = payload.type || '';
41
+ this.subtype = payload.subtype || '';
42
+ this.code = payload.code ?? null;
43
+ this.missingScopes = Array.isArray(payload.missing_scopes) ? payload.missing_scopes : [];
44
+ }
45
+
46
+ /** 令牌缺 scope(可自助重新授权);99991679 是 lark-cli 的 missing_scope 码。 */
47
+ get isScopeMissing() {
48
+ return this.subtype === 'missing_scope' || this.code === 99991679 || this.missingScopes.length > 0;
49
+ }
50
+ }
51
+
52
+ export function parseLarkJson(text) {
53
+ let data;
54
+ try {
55
+ data = JSON.parse(String(text || '').trim());
56
+ } catch {
57
+ throw new Error('飞书命令返回解析失败,请稍后重试');
58
+ }
59
+ if (data && typeof data === 'object' && !Array.isArray(data) && 'ok' in data) {
60
+ if (data.ok === false) {
61
+ throw new LarkError(data.error || {});
62
+ }
63
+ return data.data ?? {};
64
+ }
65
+ return data ?? {};
66
+ }
67
+
68
+ /** 在一页文件列表里精确匹配指定名字的文件夹(同名文件不算)。 */
69
+ export function pickFolder(files, name) {
70
+ return (Array.isArray(files) ? files : []).find(
71
+ (item) => item?.type === 'folder' && item?.name === name,
72
+ ) || null;
73
+ }
74
+
75
+ /**
76
+ * 缺权限时分责任方——这是「云盘没权限」闭环的关键分支:
77
+ * - 应用已开通该 scope、只是当前令牌没授 → 用户点「重新授权」即可自助解决;
78
+ * - 应用本身没开通 → 重新授权也拿不到,只能管理员去开放平台开通并发布版本。
79
+ * appScopes 传 null(查不到)时按「用户可自助」处理,文案里保留管理员兜底路径。
80
+ */
81
+ export function classifyMissingScopes({ missingScopes = [], appScopes = null } = {}) {
82
+ const missing = (Array.isArray(missingScopes) ? missingScopes : []).filter(Boolean);
83
+ const enabled = Array.isArray(appScopes) ? appScopes : null;
84
+ const appMissing = enabled ? missing.filter((scope) => !enabled.includes(scope)) : [];
85
+ return {
86
+ state: appMissing.length > 0 ? 'drive_app_forbidden' : 'drive_scope_missing',
87
+ missingScopes: missing,
88
+ appMissingScopes: appMissing,
89
+ };
90
+ }
91
+
92
+ /**
93
+ * 把 LarkError 映射成用户可见的权限缺口状态;不是权限问题返回 null。
94
+ * runAppScopes 注入(生产侧跑 `lark-cli auth scopes --json` 取应用已开通清单):
95
+ * 拿不到清单时按「用户可自助」处理,文案里保留管理员兜底路径。
96
+ */
97
+ export async function describeDrivePermission(error, { runAppScopes } = {}) {
98
+ if (!(error instanceof LarkError)) return null;
99
+ if (!error.isScopeMissing && error.type !== 'authorization') return null;
100
+ let appScopes = null;
101
+ if (typeof runAppScopes === 'function') {
102
+ try {
103
+ appScopes = await runAppScopes();
104
+ } catch {
105
+ appScopes = null;
106
+ }
107
+ }
108
+ const verdict = classifyMissingScopes({ missingScopes: error.missingScopes, appScopes });
109
+ return {
110
+ state: verdict.state,
111
+ missing_scopes: verdict.missingScopes,
112
+ app_missing_scopes: verdict.appMissingScopes,
113
+ };
114
+ }
115
+
116
+ /** 寻址缓存路径。归档技能/指令块要把它交给 AI,故对外导出。 */
117
+ export function driveCachePath(config) {
118
+ return join(storeDir(config), DRIVE_CACHE_FILE);
119
+ }
120
+
121
+ /** 读寻址缓存。损坏(截断/手改)不抛出:移开并返回 null,走重新寻址。 */
122
+ export async function loadDriveCache(config) {
123
+ const path = driveCachePath(config);
124
+ let raw;
125
+ try {
126
+ raw = await readFile(path, 'utf8');
127
+ } catch (error) {
128
+ if (error?.code === 'ENOENT') return null;
129
+ throw error;
130
+ }
131
+ try {
132
+ const data = JSON.parse(raw);
133
+ if (!data || typeof data !== 'object') throw new Error('not a drive cache');
134
+ return data;
135
+ } catch {
136
+ await rm(path, { force: true }).catch(() => {});
137
+ return null;
138
+ }
139
+ }
140
+
141
+ /** 写寻址缓存(tmp + rename 原子替换,0600——token 即文件夹地址,按凭据对待)。 */
142
+ export async function saveDriveCache(config, cache) {
143
+ const dir = storeDir(config);
144
+ await mkdir(dir, { recursive: true, mode: 0o700 });
145
+ const path = driveCachePath(config);
146
+ const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
147
+ await writeFile(tmp, JSON.stringify(cache, null, 2) + '\n', { mode: 0o600, flag: 'w' });
148
+ await rename(tmp, path);
149
+ }
150
+
151
+ /**
152
+ * 给飞书链接带上中文界面参数。飞书 Web 套件按 `locale` cookie 决定界面语言,
153
+ * 而 `?lang=…` 会被网关消费并改写该 cookie(实测 zh-CN/en-US 双向生效);
154
+ * 桌面壳内嵌 webview 里这个 cookie 常是 en-US,界面就成了英文。iframe 跨域
155
+ * 改不了 cookie,只能在 URL 上带参数——链接经登录跳转也保留该参数。
156
+ */
157
+ export function withChineseLocale(url) {
158
+ const raw = String(url || '').trim();
159
+ if (!raw) return raw;
160
+ try {
161
+ const parsed = new URL(raw);
162
+ if (!parsed.searchParams.has('lang')) parsed.searchParams.set('lang', 'zh-CN');
163
+ return parsed.toString();
164
+ } catch {
165
+ return raw;
166
+ }
167
+ }
168
+
169
+ /**
170
+ * 寻址服务。run(args, options) → { stdout } 为 lark-cli 执行器(注入以便
171
+ * 测试;生产侧绑定 proc.runCliTool('lark-cli', …)——PATH shim、桌面壳自带
172
+ * Node、超时与中文错误包装都在那一层)。
173
+ */
174
+ export function createDriveService({ run }) {
175
+ const listFolder = async (folderToken, pageSize, pageToken = '') => {
176
+ const params = { page_size: pageSize };
177
+ if (folderToken) params.folder_token = folderToken;
178
+ if (pageToken) params.page_token = pageToken;
179
+ const { stdout } = await run([
180
+ 'api', 'GET', '/open-apis/drive/v1/files',
181
+ '--params', JSON.stringify(params),
182
+ '--format', 'json',
183
+ ]);
184
+ return parseLarkJson(stdout);
185
+ };
186
+
187
+ const service = {
188
+ /** 根目录翻页找「AgentOne」文件夹;遍历完没找到返回 null。 */
189
+ async findAgentOneFolder(name = AGENTONE_FOLDER_NAME) {
190
+ let pageToken = '';
191
+ for (let page = 0; page < MAX_LIST_PAGES; page += 1) {
192
+ const data = await listFolder('', LIST_PAGE_SIZE, pageToken);
193
+ const hit = pickFolder(data.files, name);
194
+ if (hit) {
195
+ return { token: hit.token, url: hit.url || '', name: hit.name };
196
+ }
197
+ if (!data.has_more || !data.page_token) return null;
198
+ pageToken = data.page_token;
199
+ }
200
+ throw new Error(
201
+ `云盘根目录条目过多(超过 ${MAX_LIST_PAGES * LIST_PAGE_SIZE} 项),无法确认「${name}」文件夹是否存在,请先在飞书中整理根目录后再试`,
202
+ );
203
+ },
204
+
205
+ async createAgentOneFolder(name = AGENTONE_FOLDER_NAME) {
206
+ const { stdout } = await run([
207
+ 'drive', '+create-folder', '--name', name, '--format', 'json',
208
+ ]);
209
+ const data = parseLarkJson(stdout);
210
+ const token = data.folder_token || data.token;
211
+ if (!token) throw new Error('创建文件夹失败:飞书未返回文件夹标识,请稍后重试');
212
+ return { token, url: data.url || '', name: data.name || name };
213
+ },
214
+
215
+ /** 校验 token 仍指向可列出的文件夹(列一页子项试探)。任何失败→false。 */
216
+ async folderUsable(token) {
217
+ try {
218
+ await listFolder(String(token || ''), 1);
219
+ return true;
220
+ } catch {
221
+ return false;
222
+ }
223
+ },
224
+
225
+ /**
226
+ * 文件夹首页清单指纹(token+modified_time+name 排序哈希,排序保证与
227
+ * 返回顺序无关)。给云盘页签做自动刷新:往子文件夹里增删文档会 bump 该
228
+ * 子文件夹在父清单中的 modified_time(2026-10-13 真机实测
229
+ * 1791876629 → 1791876675),因此**根清单指纹**即可覆盖一层深度的
230
+ * 变化——项目文件夹正是这一层。失败向上抛,由路由层兜底成 rev:null。
231
+ */
232
+ async fingerprintFolder(token) {
233
+ const data = await listFolder(String(token || ''), LIST_PAGE_SIZE);
234
+ const files = Array.isArray(data.files) ? data.files : [];
235
+ const canonical = files
236
+ .map((item) => `${item?.token || ''}:${item?.modified_time || ''}:${item?.name || ''}`)
237
+ .sort()
238
+ .join('|');
239
+ return createHash('sha256').update(canonical).digest('hex').slice(0, 16);
240
+ },
241
+
242
+ /**
243
+ * 收养用户指定的文件夹链接(…/drive/folder/…):inspect 解出底层
244
+ * token(wiki 链也会解包),非文件夹/解不出即拒,再校验可访问。
245
+ */
246
+ async adoptFolder(url) {
247
+ const raw = String(url || '').trim();
248
+ if (!/^https?:\/\//.test(raw)) {
249
+ throw new Error('请粘贴完整的飞书文件夹链接(以 https:// 开头)');
250
+ }
251
+ const { stdout } = await run([
252
+ 'drive', '+inspect', '--url', raw, '--format', 'json',
253
+ ]);
254
+ const data = parseLarkJson(stdout);
255
+ const token = data.obj_token || data.token;
256
+ const type = String(data.obj_type || data.type || '').toLowerCase();
257
+ if (!token || (type && type !== 'folder')) {
258
+ throw new Error('该链接不是有效的云盘文件夹链接,请使用「…/drive/folder/…」链接');
259
+ }
260
+ if (!(await service.folderUsable(token))) {
261
+ throw new Error('无法访问该文件夹:它可能已被删除,或你没有访问权限');
262
+ }
263
+ return { token, url: data.url || raw, name: data.title || data.name || '' };
264
+ },
265
+
266
+ /**
267
+ * 完整寻址。adoptUrl 优先(设置页「使用此文件夹」);否则缓存有效→
268
+ * 缓存,force(设置页「重新定位」)跳过缓存;失效→按名找→找不到建。
269
+ * 结果落盘缓存。注意:创建只发生在本函数,且必然先有「按名找不到」。
270
+ */
271
+ async resolveAgentOneFolder({ config, force = false, adoptUrl = '' } = {}) {
272
+ if (adoptUrl) {
273
+ const adopted = await service.adoptFolder(adoptUrl);
274
+ await saveDriveCache(config, { agentone_folder: adopted });
275
+ return adopted;
276
+ }
277
+ const cache = await loadDriveCache(config);
278
+ const cached = cache?.agentone_folder;
279
+ if (!force && cached?.token && (await service.folderUsable(cached.token))) {
280
+ return cached;
281
+ }
282
+ const found = await service.findAgentOneFolder();
283
+ const folder = found || (await service.createAgentOneFolder());
284
+ await saveDriveCache(config, { agentone_folder: folder });
285
+ return folder;
286
+ },
287
+ };
288
+ return service;
289
+ }