@modesto_ma/dsh-channels 0.3.0 → 0.5.0-beta.1

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 (64) hide show
  1. package/README.md +123 -53
  2. package/docs/access-control-design.md +7 -5
  3. package/docs/attachment-pipeline-v2.md +310 -0
  4. package/docs/feasibility-and-design.md +1 -1
  5. package/docs/feishu-onboarding.md +127 -21
  6. package/docs/persistence.md +6 -4
  7. package/docs/refactor-plan.md +2 -2
  8. package/docs/workspace-isolation-design.md +13 -11
  9. package/lib/client.js +179 -288
  10. package/lib/client.js.map +1 -1
  11. package/lib/index.js +12125 -6382
  12. package/lib/index.js.map +1 -1
  13. package/package.json +1 -1
  14. package/src/access/cards.ts +9 -9
  15. package/src/access/controller.ts +10 -10
  16. package/src/access/index.ts +1 -0
  17. package/src/access/policy.ts +10 -10
  18. package/src/access/requests.ts +32 -31
  19. package/src/access/store.ts +12 -12
  20. package/src/access/targets.ts +56 -0
  21. package/src/cards/approval.ts +96 -26
  22. package/src/cards/index.ts +1 -0
  23. package/src/cards/onboarding.ts +1 -0
  24. package/src/cards/picker.ts +115 -0
  25. package/src/cards/streaming.ts +56 -7
  26. package/src/client/api.ts +5 -25
  27. package/src/client/panel/access-card.tsx +24 -15
  28. package/src/client/panel/access-tab.tsx +3 -3
  29. package/src/client/panel/connection-tab.tsx +70 -1
  30. package/src/client/panel/index.tsx +11 -62
  31. package/src/client/panel/shared.tsx +0 -23
  32. package/src/client/panel/workspace-tab.tsx +2 -18
  33. package/src/client/styles.ts +2 -0
  34. package/src/commands/registry.ts +2 -2
  35. package/src/config.ts +190 -32
  36. package/src/domain.ts +28 -18
  37. package/src/feishu/agent-context.ts +65 -19
  38. package/src/feishu/inbound-attachments.ts +440 -3
  39. package/src/feishu/manifest.ts +247 -0
  40. package/src/feishu/message-read.ts +1330 -0
  41. package/src/feishu/native-attachments.ts +998 -0
  42. package/src/feishu/scopes.ts +171 -0
  43. package/src/feishu/send-file.ts +543 -0
  44. package/src/feishu/slash-commands.ts +134 -0
  45. package/src/feishu/tools.ts +2305 -0
  46. package/src/feishu-channel.ts +322 -6
  47. package/src/host/agent.ts +55 -0
  48. package/src/host/attachments.ts +210 -0
  49. package/src/host/index.ts +1 -0
  50. package/src/index.ts +5 -4
  51. package/src/interactions/approvals.ts +187 -7
  52. package/src/interactions/pending.ts +3 -3
  53. package/src/routing/inbound.ts +620 -49
  54. package/src/service.ts +808 -375
  55. package/src/session/manager.ts +67 -13
  56. package/src/session/routes.ts +2 -3
  57. package/src/settings/contract.ts +90 -89
  58. package/src/settings/overlay.ts +124 -13
  59. package/src/settings/routes.ts +3 -17
  60. package/src/settings/surface.ts +156 -82
  61. package/src/streamer.ts +94 -5
  62. package/src/workspaces.ts +39 -26
  63. package/src/cleanup.ts +0 -535
  64. package/src/client/panel/cleanup-card.tsx +0 -106
@@ -6,12 +6,13 @@
6
6
  * - error:连接信息卡 + 最近一次连接失败 + 「重试」
7
7
  * - connected:连接信息卡 + 「重启飞书连接」+ 凭据卡
8
8
  * - connecting:禁用按钮显示「连接中…」
9
+ * - 已连接且权限自检完成:权限自检卡(缺失权限红字 + 开发者后台深链)
9
10
  *
10
11
  * @module dsh-channels/client/panel/connection-tab
11
12
  */
12
13
 
13
14
  import { PANEL_COPY } from '../../settings/contract.ts'
14
- import type { ChannelStatusWire, PanelWire } from '../../settings/contract.ts'
15
+ import type { ChannelStatusWire, PanelWire, ScopeCheckWire, SlashCommandSyncWire } from '../../settings/contract.ts'
15
16
  import { CredentialsCard } from './credentials-card.tsx'
16
17
  import { connectionStateLabel, fieldLabel, formatUptime, phaseBadge, TabHelp } from './shared.tsx'
17
18
  import type { BusyAction, CredentialsView } from './shared.tsx'
@@ -35,6 +36,8 @@ export function ConnectionTab(props: ConnectionTabProps): JSX.Element {
35
36
  {status.phase === 'unconfigured'
36
37
  ? <OnboardingCard status={status} />
37
38
  : <ConnectionInfoCard status={status} busy={busy} onReconnect={onReconnect} />}
39
+ {status.scopeCheck === undefined ? null : <ScopeCheckCard check={status.scopeCheck} />}
40
+ {status.slashCommandSync === undefined ? null : <SlashCommandSyncCard sync={status.slashCommandSync} />}
38
41
  <CredentialsCard panel={panel} credentials={credentials} />
39
42
  </div>
40
43
  )
@@ -186,6 +189,72 @@ function ConnectionInfoCard(props: {
186
189
  )
187
190
  }
188
191
 
192
+ /** Post-connect scope self-check: missing scopes in red, each with a console deep link. */
193
+ function ScopeCheckCard(props: { check: ScopeCheckWire }): JSX.Element {
194
+ const { check } = props
195
+ return (
196
+ <section className="dsh-channels-cfg__card">
197
+ <div className="dsh-channels-cfg__cardHead">
198
+ <span className="dsh-channels-cfg__cardTitle">{PANEL_COPY.scopeCheckTitle}</span>
199
+ </div>
200
+ {check.state === 'ok'
201
+ ? <div className="dsh-channels-cfg__notice dsh-channels-cfg__notice--ok">{PANEL_COPY.scopeCheckOk}</div>
202
+ : null}
203
+ {check.state === 'error'
204
+ ? (
205
+ <div className="dsh-channels-cfg__notice dsh-channels-cfg__notice--error">
206
+ <strong>{PANEL_COPY.scopeCheckError}</strong>
207
+ <span className="dsh-channels-cfg__mono">{check.error}</span>
208
+ </div>
209
+ )
210
+ : null}
211
+ {check.state === 'missing'
212
+ ? (
213
+ <div className="dsh-channels-cfg__notice dsh-channels-cfg__notice--error">
214
+ <strong>{PANEL_COPY.scopeCheckMissing}</strong>
215
+ <ul className="dsh-channels-cfg__scopeList">
216
+ {check.missing.map(item => (
217
+ <li key={item.scopes.join(',')}>
218
+ <span>[{item.level === 'required' ? PANEL_COPY.scopeCheckRequired : PANEL_COPY.scopeCheckOptional}] </span>
219
+ <span className="dsh-channels-cfg__mono">{item.scopes.join(' / ')}</span>
220
+ <span>:{item.purpose} </span>
221
+ <a href={item.consoleUrl} target="_blank" rel="noreferrer noopener">{PANEL_COPY.scopeCheckOpenOne}</a>
222
+ </li>
223
+ ))}
224
+ </ul>
225
+ <a href={check.consoleUrl} target="_blank" rel="noreferrer noopener">{PANEL_COPY.scopeCheckOpenAll}</a>
226
+ </div>
227
+ )
228
+ : null}
229
+ </section>
230
+ )
231
+ }
232
+
233
+ /** Post-connect native slash-command panel sync: counts on success, the Feishu reason on failure. */
234
+ function SlashCommandSyncCard(props: { sync: SlashCommandSyncWire }): JSX.Element {
235
+ const { sync } = props
236
+ return (
237
+ <section className="dsh-channels-cfg__card">
238
+ <div className="dsh-channels-cfg__cardHead">
239
+ <span className="dsh-channels-cfg__cardTitle">{PANEL_COPY.slashCommandSyncTitle}</span>
240
+ </div>
241
+ {sync.state === 'ok'
242
+ ? (
243
+ <div className="dsh-channels-cfg__notice dsh-channels-cfg__notice--ok">
244
+ <span>{PANEL_COPY.slashCommandSyncOk} </span>
245
+ <span>{PANEL_COPY.slashCommandSyncTotal} {sync.total} · {PANEL_COPY.slashCommandSyncCreated} {sync.created} · {PANEL_COPY.slashCommandSyncUpdated} {sync.updated} · {PANEL_COPY.slashCommandSyncRemoved} {sync.removed}</span>
246
+ </div>
247
+ )
248
+ : (
249
+ <div className="dsh-channels-cfg__notice dsh-channels-cfg__notice--error">
250
+ <strong>{PANEL_COPY.slashCommandSyncError}</strong>
251
+ <span className="dsh-channels-cfg__mono">{sync.error}</span>
252
+ </div>
253
+ )}
254
+ </section>
255
+ )
256
+ }
257
+
189
258
  /** Config saved but only applied on the next connect: offer the one-click restart. */
190
259
  function RestartBanner(props: {
191
260
  panel: PanelWire
@@ -2,8 +2,8 @@
2
2
  * 设置 →「飞书渠道」页面:数据加载、5 秒状态轮询、全局 draft 与顶部状态头 /
3
3
  * tab 导航的外壳。
4
4
  *
5
- * 所有 Host 交互(panel / status / catalog / config / credentials / cleanup /
6
- * access / pick-directory)都集中在这里;tab 组件只接收渲染所需的数据与
5
+ * 所有 Host 交互(panel / status / catalog / config / credentials / access /
6
+ * pick-directory)都集中在这里;tab 组件只接收渲染所需的数据与
7
7
  * 回调。页面不 import 任何 DSH 客户端包,只依赖 react 与 `settings.section`
8
8
  * 槽位。
9
9
  *
@@ -17,7 +17,6 @@ import type {
17
17
  AccessWire,
18
18
  CatalogWire,
19
19
  ChannelStatusWire,
20
- CleanupRunWire,
21
20
  ConfigValue,
22
21
  CredentialField,
23
22
  EditableKey,
@@ -39,7 +38,6 @@ import {
39
38
  removeAccessGroup,
40
39
  removeAccessUser,
41
40
  resolveAccessRequest,
42
- runCleanup,
43
41
  saveConfig,
44
42
  saveCredentials,
45
43
  } from '../api.ts'
@@ -51,7 +49,6 @@ import { ModelTab } from './model-tab.tsx'
51
49
  import { SessionsTab } from './sessions-tab.tsx'
52
50
  import {
53
51
  fieldLabel,
54
- formatBytes,
55
52
  messageOf,
56
53
  NoticeBar,
57
54
  phaseBadge,
@@ -85,8 +82,6 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
85
82
  const [appSecretInput, setAppSecretInput] = useState('')
86
83
  const [catalog, setCatalog] = useState<CatalogWire | null>(null)
87
84
  const [catalogBusy, setCatalogBusy] = useState(false)
88
- const [cleanupResult, setCleanupResult] = useState<CleanupRunWire | null>(null)
89
- const [cleanupError, setCleanupError] = useState<string | null>(null)
90
85
  const [accessNotice, setAccessNotice] = useState<string | null>(null)
91
86
  const [accessError, setAccessError] = useState<string | null>(null)
92
87
  const catalogTicket = useRef(0)
@@ -102,8 +97,6 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
102
97
  const loadPanel = useCallback(async (): Promise<void> => {
103
98
  setLoad({ phase: 'loading' })
104
99
  setNotice(null)
105
- setCleanupResult(null)
106
- setCleanupError(null)
107
100
  setAccessNotice(null)
108
101
  setAccessError(null)
109
102
  try {
@@ -145,7 +138,7 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
145
138
  setPollError(null)
146
139
  // A phase transition changes what the panel payload can offer: the
147
140
  // storage domain opens/closes with the connection, so the access
148
- // whitelists and the cleanup preview go stale. Re-read the panel but
141
+ // whitelists go stale. Re-read the panel but
149
142
  // never touch the dirty draft.
150
143
  if (previousPhase !== undefined && previousPhase !== next.phase) {
151
144
  try {
@@ -260,8 +253,8 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
260
253
  }, [])
261
254
 
262
255
  /**
263
- * Manual refresh: reload the whole panel payload so access lists, the cleanup
264
- * preview and `restartPendingKeys` catch up with the Host. The dirty draft is
256
+ * Manual refresh: reload the whole panel payload so access lists and
257
+ * `restartPendingKeys` catch up with the Host. The dirty draft is
265
258
  * deliberately preserved — refreshing never discards user input.
266
259
  */
267
260
  const onRefresh = useCallback(async (): Promise<void> => {
@@ -301,8 +294,6 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
301
294
  }
302
295
  setLoad({ phase: 'ready', panel: result.panel })
303
296
  setStatus(result.panel.status)
304
- setCleanupResult(null)
305
- setCleanupError(null)
306
297
  // Clear only the submitted keys: edits typed while the request was in
307
298
  // flight stay in the draft instead of being silently discarded.
308
299
  setDraft(previous => {
@@ -372,8 +363,6 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
372
363
  setLoad({ phase: 'ready', panel: result.panel })
373
364
  setStatus(result.panel.status)
374
365
  setDraft({})
375
- setCleanupResult(null)
376
- setCleanupError(null)
377
366
  setNotice({ kind: 'ok', text: PANEL_COPY.cleared })
378
367
  } catch (error) {
379
368
  setNotice({ kind: 'error', text: messageOf(error) })
@@ -417,40 +406,6 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
417
406
  }
418
407
  }, [refreshStatus, running])
419
408
 
420
- /**
421
- * Delete stale workspaces after an explicit confirmation. The Host refreshes
422
- * the panel in the same response, so the preview counts update immediately;
423
- * partial failures stay visible in the card instead of being swallowed.
424
- */
425
- const onRunCleanup = useCallback(async (): Promise<void> => {
426
- if (panel === null) return
427
- const { cleanup } = panel
428
- const confirmed = typeof window.confirm === 'function'
429
- ? window.confirm(
430
- `${PANEL_COPY.cleanupConfirm}\n${PANEL_COPY.cleanupCandidates}:${cleanup.candidates} · ${PANEL_COPY.cleanupCandidateBytes}:${formatBytes(cleanup.candidateBytes)}`,
431
- )
432
- : true
433
- if (!confirmed) return
434
- setBusy('cleanup')
435
- setNotice(null)
436
- setCleanupResult(null)
437
- setCleanupError(null)
438
- try {
439
- const response = await runCleanup()
440
- if (!response.ok) {
441
- setCleanupError(response.error)
442
- return
443
- }
444
- setLoad({ phase: 'ready', panel: response.panel })
445
- setStatus(response.panel.status)
446
- setCleanupResult(response.result)
447
- } catch (error) {
448
- setCleanupError(messageOf(error))
449
- } finally {
450
- setBusy(null)
451
- }
452
- }, [panel])
453
-
454
409
  /** Replace the embedded access snapshot after a whitelist mutation. */
455
410
  const applyAccess = useCallback((access: AccessWire): void => {
456
411
  setLoad(previous => previous.phase === 'ready'
@@ -498,12 +453,12 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
498
453
  }
499
454
  }, [applyAccess])
500
455
 
501
- const onAddAccessUser = useCallback(async (openId: string, name: string): Promise<boolean> => {
456
+ const onAddAccessUser = useCallback(async (userId: string, name: string): Promise<boolean> => {
502
457
  setBusy('access')
503
458
  setAccessError(null)
504
459
  setAccessNotice(null)
505
460
  try {
506
- const result = await addAccessUser(openId, name)
461
+ const result = await addAccessUser(userId, name)
507
462
  if (!result.ok) {
508
463
  setAccessError(result.error)
509
464
  return false
@@ -519,12 +474,12 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
519
474
  }
520
475
  }, [applyAccess])
521
476
 
522
- const onRemoveAccessUser = useCallback(async (openId: string): Promise<void> => {
477
+ const onRemoveAccessUser = useCallback(async (userId: string): Promise<void> => {
523
478
  setBusy('access')
524
479
  setAccessError(null)
525
480
  setAccessNotice(null)
526
481
  try {
527
- const result = await removeAccessUser(openId)
482
+ const result = await removeAccessUser(userId)
528
483
  if (!result.ok) {
529
484
  setAccessError(result.error)
530
485
  return
@@ -729,13 +684,7 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
729
684
  {activeTab === 'model' ? <ModelTab form={form} catalogBusy={catalogBusy} /> : null}
730
685
  {activeTab === 'workspace'
731
686
  ? (
732
- <WorkspaceTab
733
- form={form}
734
- cleanup={form.panel.cleanup}
735
- cleanupResult={cleanupResult}
736
- cleanupError={cleanupError}
737
- onRunCleanup={() => { void onRunCleanup() }}
738
- />
687
+ <WorkspaceTab form={form} />
739
688
  )
740
689
  : null}
741
690
  {activeTab === 'access'
@@ -747,7 +696,7 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
747
696
  onAddGroup={onAddAccessGroup}
748
697
  onRemoveGroup={chatId => { void onRemoveAccessGroup(chatId) }}
749
698
  onAddUser={onAddAccessUser}
750
- onRemoveUser={openId => { void onRemoveAccessUser(openId) }}
699
+ onRemoveUser={userId => { void onRemoveAccessUser(userId) }}
751
700
  onResolveRequest={(token, decision) => { void onResolveAccessRequest(token, decision) }}
752
701
  />
753
702
  )
@@ -13,7 +13,6 @@ import { FIELD_GROUP_LABELS, PANEL_COPY, TAB_FIELD_GROUPS } from '../../settings
13
13
  import type {
14
14
  AccessWire,
15
15
  ChannelPhaseWire,
16
- CleanupPreviewWire,
17
16
  ConfigValue,
18
17
  CredentialField,
19
18
  EditableFieldWire,
@@ -46,7 +45,6 @@ export type BusyAction =
46
45
  | 'reconnect'
47
46
  | 'credentials'
48
47
  | 'credentials-clear'
49
- | 'cleanup'
50
48
  | 'access'
51
49
 
52
50
  /** Tone of a small badge; maps onto `dsh-channels-cfg__badge--*` classes. */
@@ -56,20 +54,6 @@ export function messageOf(error: unknown): string {
56
54
  return error instanceof Error ? error.message : String(error)
57
55
  }
58
56
 
59
- /** Human-readable byte size for the cleanup preview/result (base-2 units). */
60
- export function formatBytes(bytes: number): string {
61
- if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
62
- const units = ['B', 'KB', 'MB', 'GB', 'TB']
63
- let value = bytes
64
- let unit = 0
65
- while (value >= 1024 && unit < units.length - 1) {
66
- value /= 1024
67
- unit += 1
68
- }
69
- const digits = unit === 0 || value >= 10 ? 0 : 1
70
- return `${value.toFixed(digits)} ${units[unit] ?? 'B'}`
71
- }
72
-
73
57
  /** Compact duration for the connection card. */
74
58
  export function formatUptime(ms: number | undefined): string {
75
59
  if (ms === undefined || !Number.isFinite(ms)) return PANEL_COPY.unknownValue
@@ -479,10 +463,3 @@ export function connectionStateLabel(state: string | undefined): { text: string;
479
463
  default: return { text: state ?? PANEL_COPY.unknownValue, tone: 'muted' }
480
464
  }
481
465
  }
482
-
483
- /** Retention label, reusing the Host field options so the copy stays on the Host. */
484
- export function retentionLabel(panel: PanelWire, cleanup: CleanupPreviewWire): string {
485
- const field = panel.config.fields.find(item => item.key === 'workspaceRetention')
486
- return field?.options?.find(option => option.value === cleanup.retention)?.label
487
- ?? (cleanup.retention === 'off' ? PANEL_COPY.cleanupRetentionOff : cleanup.retention)
488
- }
@@ -1,40 +1,24 @@
1
1
  /**
2
- * 「工作区」tab:工作区根目录 / 过期保留策略 / 自动清理字段 + 目录清理卡。
3
- * 表单保存栏在字段卡正下方,清理卡(独立动作,不走 draft)在其后。
2
+ * 「工作区」tab:工作区根目录字段与保存栏。
4
3
  *
5
4
  * @module dsh-channels/client/panel/workspace-tab
6
5
  */
7
6
 
8
7
  import { PANEL_COPY, TAB_FIELD_GROUPS } from '../../settings/contract.ts'
9
- import type { CleanupPreviewWire, CleanupRunWire } from '../../settings/contract.ts'
10
- import { CleanupCard } from './cleanup-card.tsx'
11
8
  import { FieldsCard, SaveBar, TabHelp } from './shared.tsx'
12
9
  import type { FormView } from './shared.tsx'
13
10
 
14
11
  export interface WorkspaceTabProps {
15
12
  form: FormView
16
- cleanup: CleanupPreviewWire
17
- cleanupResult: CleanupRunWire | null
18
- cleanupError: string | null
19
- onRunCleanup: () => void
20
13
  }
21
14
 
22
15
  export function WorkspaceTab(props: WorkspaceTabProps): JSX.Element {
23
- const { form, cleanup, cleanupResult, cleanupError, onRunCleanup } = props
16
+ const { form } = props
24
17
  return (
25
18
  <div className="dsh-channels-cfg__tabBody">
26
19
  <TabHelp text={PANEL_COPY.workspaceTabHelp} />
27
20
  <FieldsCard form={form} groups={TAB_FIELD_GROUPS.workspace} />
28
21
  <SaveBar form={form} tab="workspace" />
29
- <CleanupCard
30
- panel={form.panel}
31
- cleanup={cleanup}
32
- busy={form.busy}
33
- locked={form.locked}
34
- result={cleanupResult}
35
- error={cleanupError}
36
- onRun={onRunCleanup}
37
- />
38
22
  </div>
39
23
  )
40
24
  }
@@ -68,6 +68,8 @@ select.dsh-channels-cfg__input{appearance:auto;cursor:pointer}
68
68
  .dsh-channels-cfg__notice{display:flex;flex-direction:column;gap:2px;padding:8px 10px;border-radius:8px;font-size:12px;line-height:18px;overflow-wrap:anywhere}
69
69
  .dsh-channels-cfg__notice--ok{color:var(--dsw-alias-state-business-primary,#0a7d33);background:color-mix(in srgb,currentColor 8%,transparent)}
70
70
  .dsh-channels-cfg__notice--error{color:var(--dsw-alias-state-danger-primary,#c62828);background:color-mix(in srgb,currentColor 8%,transparent)}
71
+ .dsh-channels-cfg__notice a{color:inherit;text-decoration:underline}
72
+ .dsh-channels-cfg__scopeList{margin:2px 0;padding-left:18px;display:flex;flex-direction:column;gap:2px}
71
73
  .dsh-channels-cfg__banner{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:8px 10px;border-radius:8px;border:1px dashed var(--dsw-alias-state-warning-primary,#a15c00);color:var(--dsw-alias-state-warning-primary,#a15c00);font-size:12px;line-height:18px}
72
74
  .dsh-channels-cfg__footer{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding-top:2px}
73
75
  .dsh-channels-cfg__spacer{flex:1}
@@ -72,8 +72,8 @@ export const CHANNEL_COMMANDS = [
72
72
  usage: '[name]', channelPlane: true, agentScoped: false,
73
73
  },
74
74
  {
75
- name: 'auth', group: '会话', description: '查看/管理访问授权(仅管理员)',
76
- usage: '[status|allow|revoke|list|user add <open_id>|user remove <open_id>]',
75
+ name: 'auth', group: '会话', description: '查看/管理访问授权,可 @ 成员授权(仅管理员)',
76
+ usage: '[status|allow|revoke|list|user add <@用户|user_id>|user remove <@用户|user_id>]',
77
77
  routeFree: true, channelPlane: true, agentScoped: false,
78
78
  },
79
79
  {
package/src/config.ts CHANGED
@@ -13,7 +13,7 @@
13
13
  */
14
14
 
15
15
  import { z } from 'zod'
16
- import { WORKSPACE_RETENTION_VALUES } from './cleanup.ts'
16
+ import { FEISHU_USER_ID_PATTERN } from './domain.ts'
17
17
 
18
18
  export const FEISHU_LOGGER_LEVELS = ['debug', 'info', 'warn', 'error'] as const
19
19
  export type FeishuLoggerLevel = (typeof FEISHU_LOGGER_LEVELS)[number]
@@ -34,6 +34,48 @@ export const DEFAULT_APPROVAL_TIMEOUT_MS = 5 * 60 * 1000
34
34
  /** Default structured-question window: 5 minutes per `ask_user_question` call. */
35
35
  export const DEFAULT_QUESTION_TIMEOUT_MS = 5 * 60 * 1000
36
36
 
37
+ /** Backends for inbound attachment storage, selected by `attachmentStore`. */
38
+ export const ATTACHMENT_STORE_MODES = ['native', 'archive'] as const
39
+ export type AttachmentStoreMode = (typeof ATTACHMENT_STORE_MODES)[number]
40
+
41
+ /** Default per-call attachment count of `feishu_attachment_fetch`. */
42
+ export const DEFAULT_ATTACHMENT_FETCH_MAX_RESOURCES = 10
43
+
44
+ /** Default per-call total download budget of `feishu_attachment_fetch` (100 MiB). */
45
+ export const DEFAULT_ATTACHMENT_FETCH_MAX_BYTES = 100 * 1024 * 1024
46
+
47
+ /** Default whole-call timeout of `feishu_attachment_fetch` (120 s). */
48
+ export const DEFAULT_ATTACHMENT_FETCH_TIMEOUT_MS = 120_000
49
+
50
+ /** Default per-image byte cap for trigger-message prefetch (20 MiB). */
51
+ export const DEFAULT_PREFETCH_IMAGE_MAX_BYTES = 20 * 1024 * 1024
52
+
53
+ /** Default total byte budget for one trigger-message image prefetch (50 MiB). */
54
+ export const DEFAULT_PREFETCH_TOTAL_MAX_BYTES = 50 * 1024 * 1024
55
+
56
+ /** Default short timeout for one trigger-message image prefetch (10 s). */
57
+ export const DEFAULT_PREFETCH_TIMEOUT_MS = 10_000
58
+
59
+ /**
60
+ * Default number of recent topic messages attached to a new Session's first
61
+ * turn (0 disables the history bootstrap). Mirrors
62
+ * `HISTORY_BOOTSTRAP_DEFAULT` in `feishu/message-read.ts`.
63
+ */
64
+ export const DEFAULT_HISTORY_BOOTSTRAP_LIMIT = 10
65
+
66
+ /** Hard cap of the first-turn history bootstrap. */
67
+ export const MAX_HISTORY_BOOTSTRAP_LIMIT = 20
68
+
69
+ /** Default whole-call timeout of `feishu_topic_messages` (20 s). */
70
+ export const DEFAULT_TOPIC_MESSAGES_TIMEOUT_MS = 20_000
71
+
72
+ /**
73
+ * Default budget of one passive prompt-enrichment message read (quoted excerpt
74
+ * / first-turn topic history). Kept short: the turn must not wait long for an
75
+ * optional context block.
76
+ */
77
+ export const DEFAULT_PROMPT_CONTEXT_TIMEOUT_MS = 3_000
78
+
37
79
  /** A credential reference: `FEISHU_APP_ID`-style name resolved through ctx.credentials. */
38
80
  const credentialRefSchema = z.string().regex(
39
81
  CREDENTIAL_REF_PATTERN,
@@ -55,13 +97,6 @@ export const configSchema = z.object({
55
97
  * against the host process cwd.
56
98
  */
57
99
  baseDir: z.string().min(1).default('./channels-data'),
58
- /**
59
- * Optional path of the Web settings overlay (default
60
- * `$DSH_HOME/dsh-channels/settings.json`). The Web panel writes only
61
- * editable fields there; this path is for embedded hosts, tests, or users
62
- * who want multiple instances with different overlays.
63
- */
64
- settingsFile: z.string().min(1).optional(),
65
100
  /** LLM provider route for sessions this plugin creates (optional → deployment default). */
66
101
  provider: z.string().optional(),
67
102
  /** Model id for sessions this plugin creates (optional → deployment default). */
@@ -138,34 +173,155 @@ export const configSchema = z.object({
138
173
  */
139
174
  approvalTimeoutMs: z.number().int().min(10_000).max(3_600_000).default(DEFAULT_APPROVAL_TIMEOUT_MS),
140
175
  /**
141
- * Accept inbound Feishu images, files, and merged-forward chat records and
142
- * save them below `<workspace>/attachments/`. Nothing is forwarded to the
143
- * model automatically: the Agent reads whichever attachment the
144
- * conversation needs with its ordinary filesystem tools. Disabled keeps
145
- * attachment messages inert (no save, no turn).
176
+ * Accept inbound Feishu images, files, and merged-forward chat records.
177
+ *
178
+ * With the native backend (default) images of the triggering message are
179
+ * prefetched into the DSH native attachment library and files stay lazy
180
+ * behind a prompt manifest; with the archive backend the resources are saved
181
+ * below `<workspace>/attachments/` before the turn, exactly like the legacy
182
+ * pipeline. Disabled keeps attachment messages inert (no save, no turn, no
183
+ * manifest rows).
146
184
  */
147
185
  acceptAttachments: z.boolean().default(true),
148
186
  /**
149
- * Delete the topic directories of stale conversations, plus legacy
150
- * one-workspace-per-topic directories written by older plugin versions.
151
- * `off` (default) never deletes; `7d`/`30d`/`90d`/`180d` remove those
152
- * directories when their last activity is older than the threshold. User
153
- * workspaces themselves are never deleted. The managed Web panel exposes
154
- * this as a select, previews the candidates, and can run one cleanup
155
- * manually. DSH Session logs are never deleted.
187
+ * Backend for inbound Feishu images and files.
188
+ *
189
+ * - `native` (default): store into the DSH native attachment library
190
+ * (`ctx.attachments`) as durable `ImageBlock`/`FileBlock` references; if
191
+ * the host does not mount that service, the storage layer falls back to
192
+ * `archive` for that deployment.
193
+ * - `archive`: legacy behavior — write the resources into the topic
194
+ * workspace `attachments/` directory through the existing intake.
195
+ *
196
+ * Admission consumes this switch through the backend-selecting inbound
197
+ * store: an explicit `archive` always uses the legacy download path, and a
198
+ * `native` deployment without the mounted host service degrades to the same
199
+ * legacy path (see `effectiveAttachmentMode`).
200
+ */
201
+ attachmentStore: z.enum(ATTACHMENT_STORE_MODES).default('native'),
202
+ /**
203
+ * Prefetch images attached to the triggering message into the first model
204
+ * turn (native attachment backend only). The session, topic, and streaming
205
+ * card are already visible before the prefetch starts; the turn only waits
206
+ * for the (short, bounded) image download, never for file bytes. Failed,
207
+ * oversized, or timed-out images degrade to a manifest row that the model
208
+ * can fetch with `feishu_attachment_fetch`. Set `false` to keep every
209
+ * attachment lazy (images then need the tool as well).
210
+ */
211
+ prefetchImages: z.boolean().default(true),
212
+ /**
213
+ * Largest single image the trigger-message prefetch buffers in memory before
214
+ * refusing it (default 20 MiB). A refused image stays in the manifest and
215
+ * is fetched on demand.
216
+ */
217
+ prefetchImageMaxBytes: z.number().int().min(1).max(200 * 1024 * 1024)
218
+ .default(DEFAULT_PREFETCH_IMAGE_MAX_BYTES),
219
+ /**
220
+ * Total byte budget of one trigger-message image prefetch (default 50 MiB).
221
+ * Once exhausted, the remaining images degrade to the manifest.
222
+ */
223
+ prefetchTotalMaxBytes: z.number().int().min(1).max(1024 * 1024 * 1024)
224
+ .default(DEFAULT_PREFETCH_TOTAL_MAX_BYTES),
225
+ /**
226
+ * Short whole-prefetch timeout (default 10 s). On expiry the already-loaded
227
+ * images stay in the prompt and the rest degrade to the manifest, so a slow
228
+ * image download never blocks the turn for long.
229
+ */
230
+ prefetchTimeoutMs: z.number().int().min(100).max(120_000).default(DEFAULT_PREFETCH_TIMEOUT_MS),
231
+ /**
232
+ * Number of recent messages of the current topic automatically attached to
233
+ * the FIRST turn of a new Session (decision D1: the model should see the
234
+ * discussion before @ without asking). The `<feishu_history>` block is
235
+ * bounded and truncated with an explicit marker and each line carries the
236
+ * message_id needed to fetch a named attachment; older messages remain
237
+ * reachable through the `feishu_topic_messages` tool. `0` disables the
238
+ * bootstrap entirely. Reads use a short (~3 s) prompt-enrichment budget and a
239
+ * failure (missing scope, p2p history unavailable, channel down) degrades
240
+ * silently: the turn still runs.
156
241
  */
157
- workspaceRetention: z.enum(WORKSPACE_RETENTION_VALUES).default('off'),
242
+ historyBootstrapLimit: z.number().int().min(0).max(MAX_HISTORY_BOOTSTRAP_LIMIT)
243
+ .default(DEFAULT_HISTORY_BOOTSTRAP_LIMIT),
158
244
  /**
159
- * Run the retention cleanup at startup and once per day. Off by default:
160
- * deletion is irreversible, so it stays an explicit opt-in even when a
161
- * retention threshold is configured.
245
+ * Whole-read budget (milliseconds) of the passive prompt enrichment: the
246
+ * quoted-message excerpt and the first-turn topic history. Deliberately short
247
+ * (default 3 s) and independent from `feishuTools.topicMessagesTimeoutMs`:
248
+ * on expiry the block is omitted and the turn runs. Model-initiated reads
249
+ * through `feishu_topic_messages` use the tool timeout instead.
162
250
  */
163
- workspaceRetentionAuto: z.boolean().default(false),
251
+ promptContextTimeoutMs: z.number().int().min(100).max(10_000)
252
+ .default(DEFAULT_PROMPT_CONTEXT_TIMEOUT_MS),
253
+ /**
254
+ * Model-facing Feishu tools.
255
+ *
256
+ * - `attachmentFetch` (default on): register `feishu_attachment_fetch` in
257
+ * channel sessions so the model can fetch images/files of a known message
258
+ * on demand. Registration is skipped (with one warning) on deployments
259
+ * without the host `tools` / `attachments` services. Turning it off also
260
+ * keeps inbound attachments on the archive backend: lazy native files
261
+ * would be unreachable without the tool, so admission downloads them to
262
+ * the workspace instead (see `effectiveAttachmentMode`).
263
+ * - `attachmentFetchMaxResources`: maximum attachments one tool call may
264
+ * fetch (1–50). A message with more attachments is refused with a readable
265
+ * reason so the model can retry per `file_key`.
266
+ * - `attachmentFetchMaxBytes`: total download budget of one tool call
267
+ * (default 100 MiB). Files stream to the native attachment library, so the
268
+ * limit is enforced while streaming rather than buffered first.
269
+ * - `attachmentFetchTimeoutMs`: whole-call timeout (default 120 s). On expiry
270
+ * the in-flight download is cancelled and reported per attachment.
271
+ * - `topicMessages` (default on): register `feishu_topic_messages` in
272
+ * channel sessions so the model can read the quoted message / earlier
273
+ * topic messages on demand and authorize their attachments for fetch.
274
+ * Reads require the Feishu message-read scopes; a missing scope degrades to
275
+ * a readable tool failure and never fails the turn. This switch only
276
+ * controls the model-facing tool: the passive `<feishu_quote>` /
277
+ * `<feishu_history>` prompt excerpts stay enabled (they are bounded and
278
+ * degrade silently) and are configured by `historyBootstrapLimit`. On the
279
+ * archive backend the tool is still mounted for history text, while its
280
+ * description states that `feishu_attachment_fetch` is unavailable.
281
+ * - `topicMessagesTimeoutMs`: whole-call timeout of one model-initiated
282
+ * topic read (default 20 s). Passive prompt excerpts use a separate short
283
+ * (~3 s) budget and never wait this long.
284
+ * - `sendFile` (default on): register `feishu_send_file` in channel sessions
285
+ * so the model can send an image or file from the topic's working
286
+ * directory back to the current conversation. Needs the Feishu
287
+ * `im:resource` (or `im:resource:upload`) scope; without it a call returns
288
+ * a readable failure and the turn continues.
289
+ *
290
+ * The Web panel/metrics ship with `panel_docs_release`.
291
+ */
292
+ feishuTools: z.object({
293
+ attachmentFetch: z.boolean().default(true),
294
+ attachmentFetchMaxResources: z.number().int().min(1).max(50)
295
+ .default(DEFAULT_ATTACHMENT_FETCH_MAX_RESOURCES),
296
+ attachmentFetchMaxBytes: z.number().int().min(1).max(1024 * 1024 * 1024)
297
+ .default(DEFAULT_ATTACHMENT_FETCH_MAX_BYTES),
298
+ attachmentFetchTimeoutMs: z.number().int().min(1_000).max(600_000)
299
+ .default(DEFAULT_ATTACHMENT_FETCH_TIMEOUT_MS),
300
+ topicMessages: z.boolean().default(true),
301
+ topicMessagesTimeoutMs: z.number().int().min(1_000).max(120_000)
302
+ .default(DEFAULT_TOPIC_MESSAGES_TIMEOUT_MS),
303
+ sendFile: z.boolean().default(true),
304
+ }).default({
305
+ attachmentFetch: true,
306
+ attachmentFetchMaxResources: DEFAULT_ATTACHMENT_FETCH_MAX_RESOURCES,
307
+ attachmentFetchMaxBytes: DEFAULT_ATTACHMENT_FETCH_MAX_BYTES,
308
+ attachmentFetchTimeoutMs: DEFAULT_ATTACHMENT_FETCH_TIMEOUT_MS,
309
+ topicMessages: true,
310
+ topicMessagesTimeoutMs: DEFAULT_TOPIC_MESSAGES_TIMEOUT_MS,
311
+ sendFile: true,
312
+ }),
164
313
  /**
165
314
  * Send a one-time onboarding card on the first private message of a chat
166
315
  * (facts + command cheat sheet). Persisted per chat; never repeats.
167
316
  */
168
317
  welcomeOnboarding: z.boolean().default(true),
318
+ /**
319
+ * Sync the command registry to Feishu's native `/` panel on every connect
320
+ * (needs the `application:app_slash_command:read`/`write` scopes). Off by
321
+ * default: some tenants forbid apps from adding slash commands. When off,
322
+ * the plugin neither reads nor writes the panel.
323
+ */
324
+ slashCommandPanel: z.boolean().default(false),
169
325
  /**
170
326
  * Allow `/permissions <name>` to switch this Session's sandbox + approval
171
327
  * preset from Feishu. Read-only `/permissions` always works; switching is
@@ -184,15 +340,17 @@ export const configSchema = z.object({
184
340
  */
185
341
  accessMode: z.enum(['open', 'guarded']).default('guarded'),
186
342
  /**
187
- * The unique administrator's Feishu open_id (`ou_...`). Set it in the Web
343
+ * The unique administrator's Feishu `user_id`. Set it in the Web
188
344
  * settings panel (or the profile config). Get it by sending `/userid` to the
189
- * bot in a private chat. While `accessMode` is `guarded` and this is unset,
190
- * every conversation is refused (fail closed); `/userid` and a fixed notice
191
- * stay available so the deployment can be bootstrapped.
345
+ * bot in a private chat. When unset, the Feishu app owner (looked up via
346
+ * `application.v6.application.get` at connect; needs an app-info read scope)
347
+ * is the administrator. While `accessMode` is `guarded` and neither is
348
+ * available, every conversation is refused (fail closed); `/userid` and a
349
+ * fixed notice stay available so the deployment can be bootstrapped.
192
350
  */
193
- adminOpenId: z.string().regex(
194
- /^ou_[A-Za-z0-9_-]+$/,
195
- 'must be a Feishu open_id such as ou_xxx (send /userid to the bot in a private chat to see it)',
351
+ adminUserId: z.string().regex(
352
+ FEISHU_USER_ID_PATTERN,
353
+ 'must be a Feishu user_id, not an open_id (send /userid to the bot in a private chat to see it)',
196
354
  ).optional(),
197
355
  /**
198
356
  * Lifetime of one pending authorization request and of the DM card that asks