@modesto_ma/dsh-channels 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/README.md +302 -2
  2. package/cordis.patch.yml +3 -0
  3. package/docs/access-control-design.md +550 -0
  4. package/docs/assets/web-settings-panel.png +0 -0
  5. package/docs/assets/web-settings-restart-pending.png +0 -0
  6. package/docs/feasibility-and-design.md +213 -0
  7. package/docs/feishu-onboarding.md +268 -0
  8. package/docs/persistence.md +199 -0
  9. package/docs/refactor-plan.md +56 -0
  10. package/docs/review-dsh-0.2.0.md +389 -0
  11. package/docs/slash-commands-implementation-plan.md +410 -0
  12. package/docs/web-display-only-surface-roadmap.md +363 -0
  13. package/lib/client.js +2226 -0
  14. package/lib/client.js.map +1 -0
  15. package/lib/index.js +10217 -0
  16. package/lib/index.js.map +1 -0
  17. package/package.json +98 -4
  18. package/src/access/cards.ts +188 -0
  19. package/src/access/controller.ts +201 -0
  20. package/src/access/index.ts +13 -0
  21. package/src/access/policy.ts +103 -0
  22. package/src/access/requests.ts +512 -0
  23. package/src/access/store.ts +220 -0
  24. package/src/approvals.ts +8 -0
  25. package/src/cards/approval.ts +114 -0
  26. package/src/cards/index.ts +11 -0
  27. package/src/cards/primitives.ts +82 -0
  28. package/src/cards/question.ts +141 -0
  29. package/src/cards/streaming.ts +563 -0
  30. package/src/cards.ts +8 -0
  31. package/src/cleanup.ts +398 -0
  32. package/src/client/api.ts +262 -0
  33. package/src/client/catalog.ts +92 -0
  34. package/src/client/index.ts +61 -0
  35. package/src/client/panel.tsx +1475 -0
  36. package/src/client/styles.ts +98 -0
  37. package/src/commands/registry.ts +155 -0
  38. package/src/commands.ts +335 -0
  39. package/src/config.ts +206 -0
  40. package/src/domain.ts +428 -0
  41. package/src/dsh-types.ts +11 -0
  42. package/src/feishu/agent-context.ts +62 -0
  43. package/src/feishu/delivery.ts +188 -0
  44. package/src/feishu/inbound-attachments.ts +436 -0
  45. package/src/feishu-channel.ts +339 -0
  46. package/src/health.ts +233 -0
  47. package/src/host/agent.ts +181 -0
  48. package/src/host/commands.ts +63 -0
  49. package/src/host/core.ts +345 -0
  50. package/src/host/index.ts +12 -0
  51. package/src/host/interaction.ts +101 -0
  52. package/src/host/services.ts +320 -0
  53. package/src/index.ts +122 -0
  54. package/src/interactions/approvals.ts +154 -0
  55. package/src/interactions/pending.ts +162 -0
  56. package/src/interactions/questions.ts +274 -0
  57. package/src/questions.ts +8 -0
  58. package/src/routing/inbound.ts +524 -0
  59. package/src/service.ts +2138 -0
  60. package/src/session/manager.ts +352 -0
  61. package/src/session/repair.ts +157 -0
  62. package/src/session/request-policy.ts +192 -0
  63. package/src/session/routes.ts +165 -0
  64. package/src/session-commands.ts +310 -0
  65. package/src/session-export.ts +112 -0
  66. package/src/settings/catalog.ts +309 -0
  67. package/src/settings/contract.ts +508 -0
  68. package/src/settings/overlay.ts +164 -0
  69. package/src/settings/routes.ts +409 -0
  70. package/src/settings/surface.ts +773 -0
  71. package/src/stats.ts +485 -0
  72. package/src/streamer.ts +1108 -0
  73. package/src/workspaces.ts +199 -0
@@ -0,0 +1,508 @@
1
+ /**
2
+ * Web 设置面板的线上契约:Host 半侧的路由/面板数据与 Browser 半侧的渲染共用。
3
+ *
4
+ * 本模块**没有运行时依赖**:Host 与 Client bundle 都直接内联它。因此这里只放
5
+ * 常量、字段清单与可序列化的 wire 类型,不 import zod / node / DSH 内部包。
6
+ *
7
+ * @module dsh-channels/settings/contract
8
+ */
9
+
10
+ /** Same-origin routes the settings panel talks to. */
11
+ export const STATUS_ROUTE = '/dsh-channels/status'
12
+ export const PANEL_ROUTE = '/dsh-channels/panel'
13
+ export const CONFIG_ROUTE = '/dsh-channels/config'
14
+ export const CREDENTIALS_ROUTE = '/dsh-channels/credentials'
15
+ export const RECONNECT_ROUTE = '/dsh-channels/reconnect'
16
+ /** 模型/推理档位/预设选择器的宿主目录(provider → model → effort 级联刷新)。 */
17
+ export const CATALOG_ROUTE = '/dsh-channels/catalog'
18
+ /** 手动执行一次过期工作区清理(POST,无请求体)。 */
19
+ export const CLEANUP_ROUTE = '/dsh-channels/cleanup'
20
+ /** 访问与授权只读快照(GET/HEAD):准入模式、管理员、群/用户白名单、待处理申请。 */
21
+ export const ACCESS_ROUTE = '/dsh-channels/access'
22
+ /** 群白名单增删(POST/DELETE,body `{ chatId, name? }` / `{ chatId }`)。 */
23
+ export const ACCESS_GROUPS_ROUTE = '/dsh-channels/access/groups'
24
+ /** 用户白名单增删(POST/DELETE,body `{ openId, name? }` / `{ openId }`)。 */
25
+ export const ACCESS_USERS_ROUTE = '/dsh-channels/access/users'
26
+ /** 结算一条待处理授权申请(POST,body `{ token, decision }`)。 */
27
+ export const ACCESS_REQUESTS_ROUTE = '/dsh-channels/access/requests'
28
+
29
+ /**
30
+ * 可由 Web 面板覆盖的配置项。全部是 `src/config.ts` 里已存在的字段;
31
+ * 覆盖值写进 `$DSH_HOME/dsh-channels/settings.json`,优先级高于 profile
32
+ * 的 `cordis.patch.yml`(见 README「Web 设置面板」)。
33
+ *
34
+ * 刻意不包含 `appId` / `appSecret`:它们是**凭据引用名**,面板改为直接
35
+ * 编辑引用背后的值(`PUT /dsh-channels/credentials`,经 DSH 凭据服务写入),
36
+ * 因此不会落进覆盖层文件。
37
+ *
38
+ * `baseDir`(工作区根目录 = 模型/工具产生文件的落点)可覆盖,但面板只接受
39
+ * **绝对路径**(`~` 会展开);已有 Session / 工作区不迁移,只影响之后新建的
40
+ * 会话。校验与保存时建目录见 `src/workspaces.ts`。
41
+ */
42
+ export const EDITABLE_KEYS = [
43
+ // 工作区
44
+ 'baseDir',
45
+ 'workspaceRetention',
46
+ 'workspaceRetentionAuto',
47
+ // 行为
48
+ 'dmMode',
49
+ 'requireMention',
50
+ 'threadFallback',
51
+ 'welcomeOnboarding',
52
+ 'allowPermissionSwitch',
53
+ // 准入与授权(管理员/准入模式/申请有效期;白名单由「访问与授权」卡片管理)
54
+ 'adminOpenId',
55
+ 'accessMode',
56
+ 'authRequestTtlDays',
57
+ // 内容
58
+ 'acceptAttachments',
59
+ // 模型
60
+ 'provider',
61
+ 'model',
62
+ 'reasoningEffort',
63
+ 'agentPreset',
64
+ // 流式卡片
65
+ 'refreshMs',
66
+ 'statusRefreshMs',
67
+ 'loggerLevel',
68
+ ] as const
69
+
70
+ export type EditableKey = (typeof EDITABLE_KEYS)[number]
71
+
72
+ /**
73
+ * 只有重连飞书长连接后才生效的字段:它们在 `FeishuService.start()` 建连时
74
+ * 被交给官方 SDK 或流式器,运行中修改不会影响已建立的连接。
75
+ */
76
+ export const RESTART_KEYS = [
77
+ 'dmMode',
78
+ 'requireMention',
79
+ 'refreshMs',
80
+ 'statusRefreshMs',
81
+ 'loggerLevel',
82
+ ] as const satisfies readonly EditableKey[]
83
+
84
+ export type RestartKey = (typeof RESTART_KEYS)[number]
85
+
86
+ /** Render kinds the generic panel form understands. */
87
+ export type FieldKind = 'boolean' | 'number' | 'text' | 'select'
88
+
89
+ /** Field groups map one-to-one onto panel cards. */
90
+ export type FieldGroup = 'workspace' | 'behavior' | 'content' | 'model' | 'streaming'
91
+
92
+ /** One selectable value rendered by the generic form. */
93
+ export interface FieldOptionWire {
94
+ value: string
95
+ label: string
96
+ }
97
+
98
+ /** One editable row, fully described by the Host so the client stays schema-free. */
99
+ export interface EditableFieldWire {
100
+ key: EditableKey
101
+ label: string
102
+ kind: FieldKind
103
+ group: FieldGroup
104
+ help?: string
105
+ options?: readonly FieldOptionWire[]
106
+ /** Selector is not editable yet (e.g. pick the provider before the model). */
107
+ disabled?: boolean
108
+ min?: number
109
+ max?: number
110
+ step?: number
111
+ placeholder?: string
112
+ /** Render across the whole form width (long paths). */
113
+ wide?: boolean
114
+ /** 保存后需重连飞书通道才生效。 */
115
+ restartRequired: boolean
116
+ }
117
+
118
+ /**
119
+ * One selector group resolved from live host services. `selectable: false`
120
+ * means the host could not enumerate values (service absent / lookup failed),
121
+ * so the field falls back to the free-text control with the same key.
122
+ */
123
+ export interface SelectCatalogWire {
124
+ selectable: boolean
125
+ /** Rendered in order; the leading `''` entry is the inherit/clear choice. */
126
+ options: FieldOptionWire[]
127
+ disabled?: boolean
128
+ /** Host-side explanation shown instead of the static field help. */
129
+ help?: string
130
+ }
131
+
132
+ /** `GET /dsh-channels/catalog` payload: the model-facing selector catalog. */
133
+ export interface CatalogWire {
134
+ provider: SelectCatalogWire
135
+ model: SelectCatalogWire
136
+ reasoningEffort: SelectCatalogWire
137
+ agentPreset: SelectCatalogWire
138
+ /** Deployment default selection, when the host exposes `agentDefaultModel`. */
139
+ deploymentDefault?: {
140
+ provider?: string
141
+ model?: string
142
+ reasoningEffort?: string
143
+ }
144
+ /** Roster default preset id, when the host exposes the preset roster. */
145
+ defaultPresetId?: string
146
+ }
147
+
148
+ /** Values the panel may submit. Empty strings for optional text fields clear the override. */
149
+ export type ConfigValue = string | number | boolean
150
+ export type EditableValues = Partial<Record<EditableKey, ConfigValue>>
151
+
152
+ /** Read-only facts shown in the advanced card. */
153
+ export interface ReadOnlyFieldWire {
154
+ key: string
155
+ label: string
156
+ value: string
157
+ help?: string
158
+ }
159
+
160
+ /** One credential reference plus its Host-side state (values never cross the wire). */
161
+ export interface CredentialRefWire {
162
+ ref: string
163
+ configured: boolean
164
+ /** false when the mounted credentials provider cannot store values. */
165
+ writable: boolean
166
+ /** Where the configured value comes from (e.g. `file`), when the provider reports it. */
167
+ source?: string
168
+ }
169
+
170
+ /** The two credential slots the panel may write. */
171
+ export type CredentialField = 'appId' | 'appSecret'
172
+
173
+ /** Official-SDK WebSocket lifecycle snapshot (structural copy, no SDK import on the wire). */
174
+ export interface ConnectionWire {
175
+ state: string
176
+ lastConnectTime?: number
177
+ nextConnectTime?: number
178
+ reconnectAttempts: number
179
+ }
180
+
181
+ /** Runtime connection/liveness facts. */
182
+ export interface ChannelStatusWire {
183
+ running: boolean
184
+ level: 'healthy' | 'degraded' | 'unhealthy'
185
+ startedAt?: number
186
+ uptimeMs?: number
187
+ connection?: ConnectionWire
188
+ botOpenIdTail?: string
189
+ routeCount: number
190
+ liveAgentCount: number
191
+ inflightTurnCount: number
192
+ /** 最近一次启动失败的原因(成功后清空)。 */
193
+ lastStartError?: string
194
+ /** 健康判定的原因说明(空数组 = 健康);由 Host 的 health 折叠给出。 */
195
+ reasons: string[]
196
+ /** 面板显示的所有键值对来源版本。 */
197
+ version: string
198
+ /** Web 覆盖文件绝对路径(用户可直接编辑)。 */
199
+ overlayPath: string
200
+ /** 正在重连时为 true,此时面板按钮禁用。 */
201
+ busy: boolean
202
+ }
203
+
204
+ /** The whole editable configuration surface, with provenance. */
205
+ export interface PanelConfigWire {
206
+ /** profile `cordis.patch.yml` 提供的值(覆盖层之下)。 */
207
+ base: Record<string, ConfigValue | undefined>
208
+ /** 合并后的生效值。 */
209
+ effective: Record<string, ConfigValue | undefined>
210
+ /** 已写入 Web 覆盖层的值。 */
211
+ overlay: EditableValues
212
+ overriddenKeys: EditableKey[]
213
+ /** 与当前运行中连接不一致、需重连才生效的字段。 */
214
+ restartPendingKeys: EditableKey[]
215
+ fields: EditableFieldWire[]
216
+ readOnly: ReadOnlyFieldWire[]
217
+ credentials: {
218
+ appId: CredentialRefWire
219
+ appSecret: CredentialRefWire
220
+ }
221
+ }
222
+
223
+ /** One authorized group (the first of the two admission gates). */
224
+ export interface AccessGroupWire {
225
+ chatId: string
226
+ /** Human-readable group name captured at authorization time. */
227
+ name?: string
228
+ /** Which authorization entry produced the grant. */
229
+ source: 'command' | 'card' | 'panel'
230
+ /** Epoch ms when it was authorized. */
231
+ addedAt: number
232
+ }
233
+
234
+ /** One authorized user `open_id` (global across every authorized group). */
235
+ export interface AccessUserWire {
236
+ openId: string
237
+ /** Display name captured at authorization time. */
238
+ name?: string
239
+ /** Which authorization entry produced the grant. */
240
+ source: 'command' | 'card' | 'panel'
241
+ /** Epoch ms when it was authorized. */
242
+ addedAt: number
243
+ }
244
+
245
+ /** One pending authorization request the panel may settle (same path as the DM card). */
246
+ export interface AccessRequestWire {
247
+ /** Opaque token; never derived from the target id. */
248
+ token: string
249
+ kind: 'group' | 'user'
250
+ /** `chat_id` for a group request, `open_id` for a user request. */
251
+ targetId: string
252
+ /** Display name of the requested target (group name / user name), when known. */
253
+ name?: string
254
+ /** Chat the request originated from, when applicable. */
255
+ chatId?: string
256
+ /** Group name captured for the card. */
257
+ groupName?: string
258
+ /** Open_id of the user who triggered a user request. */
259
+ requester?: string
260
+ /** Epoch ms when the request was created. */
261
+ createdAt: number
262
+ /** Epoch ms after which the request can no longer be decided. */
263
+ expiresAt: number
264
+ }
265
+
266
+ /**
267
+ * `GET /dsh-channels/access` payload: admission mode, the administrator
268
+ * reference, both whitelists, and the still-pending authorization requests.
269
+ */
270
+ export interface AccessWire {
271
+ mode: 'open' | 'guarded'
272
+ admin: {
273
+ openId?: string
274
+ /** false while guarded mode would refuse every conversation (fail closed). */
275
+ configured: boolean
276
+ }
277
+ groups: AccessGroupWire[]
278
+ users: AccessUserWire[]
279
+ requests: AccessRequestWire[]
280
+ }
281
+
282
+ /** Write responses for every `/dsh-channels/access*` mutation. */
283
+ export type AccessMutationResponse =
284
+ | { ok: true; access: AccessWire }
285
+ | { ok: false; error: string }
286
+
287
+ /** `GET /dsh-channels/panel` payload. */
288
+ export interface PanelWire {
289
+ status: ChannelStatusWire
290
+ config: PanelConfigWire
291
+ cleanup: CleanupPreviewWire
292
+ /** 访问与授权卡片的数据(准入模式、管理员、白名单、待处理申请)。 */
293
+ access: AccessWire
294
+ }
295
+
296
+ /**
297
+ * What the workspace-retention cleanup would delete right now. Counts are
298
+ * zero and `error` is set when the Host cannot compute the preview (for
299
+ * example the cleanup seam is unavailable); the panel stays usable.
300
+ */
301
+ export interface CleanupPreviewWire {
302
+ /** Effective `workspaceRetention` value (`off` / `7d` / `30d` / `90d` / `180d`). */
303
+ retention: string
304
+ /** Retention threshold in milliseconds; absent when `retention` is `off`. */
305
+ thresholdMs?: number
306
+ /** Periodic cleanup runs at startup and once per day. */
307
+ auto: boolean
308
+ /** Plugin-created workspaces under the current baseDir. */
309
+ workspaces: number
310
+ /** Workspaces older than the threshold (the deletable set). */
311
+ candidates: number
312
+ /** Total bytes currently occupied by the candidates. */
313
+ candidateBytes: number
314
+ /** Why the preview could not be computed (counts are zero then). */
315
+ error?: string
316
+ }
317
+
318
+ /** Outcome of one manual cleanup run (partial failures do not abort the run). */
319
+ export interface CleanupRunWire {
320
+ ok: boolean
321
+ deletedWorkspaces: number
322
+ retiredRoutes: number
323
+ freedBytes: number
324
+ /** One `path: message` line per workspace that could not be deleted. */
325
+ failures: string[]
326
+ error?: string
327
+ }
328
+
329
+ /** `POST /dsh-channels/cleanup` response. */
330
+ export type CleanupResponse =
331
+ | { ok: true; panel: PanelWire; result: CleanupRunWire }
332
+ | { ok: false; error: string }
333
+
334
+ /** `PUT /dsh-channels/config` body. */
335
+ export interface SaveConfigRequest {
336
+ values: EditableValues
337
+ }
338
+
339
+ /** Per-field validation failure. */
340
+ export interface FieldIssueWire {
341
+ key: string
342
+ message: string
343
+ }
344
+
345
+ /** `PUT /dsh-channels/config` response. */
346
+ export type SaveConfigResponse =
347
+ | { ok: true; panel: PanelWire; clearedKeys: EditableKey[] }
348
+ | { ok: false; error: string; issues?: FieldIssueWire[] }
349
+
350
+ /** `POST /dsh-channels/reconnect` response. */
351
+ export type ReconnectResponse =
352
+ | { ok: true; panel: PanelWire }
353
+ | { ok: false; error: string }
354
+
355
+ /** `DELETE /dsh-channels/config` response. */
356
+ export type ClearConfigResponse =
357
+ | { ok: true; panel: PanelWire }
358
+ | { ok: false; error: string }
359
+
360
+ /** `PUT /dsh-channels/credentials` body: only provided, non-empty values are written. */
361
+ export interface SaveCredentialsRequest {
362
+ appId?: string
363
+ appSecret?: string
364
+ }
365
+
366
+ /** `DELETE /dsh-channels/credentials` body. */
367
+ export interface ClearCredentialRequest {
368
+ field: CredentialField
369
+ }
370
+
371
+ /** How a saved credential reached the running channel. */
372
+ export type CredentialReconnectState = 'auto' | 'pending' | 'not-needed'
373
+
374
+ export interface CredentialsSavedPayload {
375
+ panel: PanelWire
376
+ reconnect: CredentialReconnectState
377
+ /** Set when the automatic reconnect after a save failed (the value is still saved). */
378
+ autoReconnectError?: string
379
+ }
380
+
381
+ /** Credential write/remove responses (values never appear in either direction). */
382
+ export type SaveCredentialsResponse =
383
+ | ({ ok: true } & CredentialsSavedPayload)
384
+ | { ok: false; error: string; issues?: FieldIssueWire[] }
385
+
386
+ export type ClearCredentialResponse =
387
+ | ({ ok: true } & CredentialsSavedPayload)
388
+ | { ok: false; error: string; issues?: FieldIssueWire[] }
389
+
390
+ /** Display labels for the panel header/cards (kept on the wire so the client owns no copy). */
391
+ export const PANEL_COPY = {
392
+ title: '飞书渠道',
393
+ description: '飞书长连接状态、应用凭据与常用配置。凭据写入 DSH 凭据库且不回显;标注「需重连」的项需重启飞书连接生效。',
394
+ statusCard: '连接状态',
395
+ credentialsCard: '飞书应用凭据',
396
+ modelCard: '模型与推理',
397
+ modelCardHelp: '模型服务商、模型 id、推理档位与新会话的 Agent 预设。下拉选项来自宿主机实时目录;保存后对新会话生效。',
398
+ configCard: '常用配置',
399
+ advancedCard: '只读与高级信息',
400
+ appIdLabel: '飞书 App ID',
401
+ appSecretLabel: '飞书 App Secret',
402
+ credentialsHelp: '值通过 DSH 凭据服务写入本机凭据库;面板只显示是否已配置,永不回显已存值。留空表示保持当前值。',
403
+ credentialsRef: '凭据引用',
404
+ saveCredentials: '保存凭据',
405
+ savingCredentials: '保存中…',
406
+ credentialSaved: '凭据已保存',
407
+ credentialSavedReconnected: '凭据已保存,已自动重连飞书通道',
408
+ credentialSavedPending: '凭据已保存,重启飞书连接后生效',
409
+ credentialRemoved: '已移除存储的凭据',
410
+ credentialRemovedPending: '已移除存储的凭据,重启飞书连接后生效',
411
+ removeCredential: '移除已存值',
412
+ removingCredential: '移除中…',
413
+ credentialConfigured: '已配置',
414
+ credentialMissing: '未配置',
415
+ credentialReadOnly: '当前凭据存储不可写',
416
+ appIdPlaceholder: '输入 App ID(cli_…)',
417
+ appSecretPlaceholder: '输入 App Secret',
418
+ save: '保存配置',
419
+ saveAll: '保存全部配置',
420
+ saveModel: '保存模型配置',
421
+ modelActionsHint: '只保存模型与推理字段',
422
+ saving: '保存中…',
423
+ clear: '清除 Web 覆盖',
424
+ clearing: '清除中…',
425
+ reconnect: '重启飞书连接',
426
+ reconnecting: '重连中…',
427
+ refresh: '刷新状态',
428
+ retry: '重试',
429
+ loading: '加载中…',
430
+ loadFailed: '设置面板加载失败',
431
+ saved: '已保存',
432
+ savedModel: '模型配置已保存',
433
+ cleared: '已清除 Web 覆盖,恢复为 profile 配置',
434
+ catalogFailed: '模型目录加载失败,已退回手填。',
435
+ catalogLoading: '正在读取宿主机选项…',
436
+ catalogUnavailable: '宿主机未提供可选项,请手填。',
437
+ noOverrides: '当前没有 Web 覆盖',
438
+ overridden: 'Web 覆盖',
439
+ inherited: '继承 profile',
440
+ restartTag: '需重连',
441
+ restartBanner: '以下配置需重启飞书连接后生效:',
442
+ restartWarning: '重启会断开当前飞书长连接并中断进行中的回复,请确认空闲时操作。',
443
+ lastError: '最近一次启动失败',
444
+ notRunning: '未运行',
445
+ version: '插件版本',
446
+ overlayPath: '覆盖文件',
447
+ cleanupCard: '工作区清理',
448
+ cleanupCardHelp: '删除超过保留期且不再活跃的飞书工作区目录;只会删除本插件在「工作区根目录」下创建的目录,DSH 会话日志不删除。删除不可恢复:相关旧话题会失效并提示已过期,工作区列表记录同步移除。',
449
+ cleanupRetention: '保留期',
450
+ cleanupAutoLabel: '自动清理',
451
+ cleanupCandidates: '可清理工作区',
452
+ cleanupCandidateBytes: '预计释放空间',
453
+ cleanupRetentionOff: '关闭(不删除)',
454
+ cleanupAutoOn: '已开启(启动时 + 每天一次)',
455
+ cleanupAutoOff: '已关闭(仅手动执行)',
456
+ cleanupAutoInactive: '未执行(保留期为关闭)',
457
+ cleanupNow: '立即清理',
458
+ cleanupRunning: '清理中…',
459
+ cleanupConfirm: '确定清理过期工作区吗?该操作不可恢复,会删除这些工作区内的全部文件(包括 attachments/ 附件)。',
460
+ cleanupConfirmHint: '删除前会再次确认;保留期为「关闭」或没有过期工作区时按钮不可用。',
461
+ cleanupDisabledOff: '保留策略为「关闭」:请先在「常用配置 → 工作区与文件」中选择保留天数。',
462
+ cleanupDisabledEmpty: '没有超过保留期的过期工作区。',
463
+ cleanupPreviewFailed: '无法读取清理预览',
464
+ cleanupDone: '清理完成',
465
+ cleanupFailed: '清理失败',
466
+ cleanupDeleted: '已删除工作区',
467
+ cleanupRetired: '已清理话题路由',
468
+ cleanupFreed: '释放空间',
469
+ cleanupFailures: '以下工作区未能删除:',
470
+ accessCard: '访问与授权',
471
+ accessCardHelp: '准入采用 fail-closed:管理员不受白名单限制(私聊、任意群聊都可直接对话);非管理员私聊会被拒绝,群聊需要「群已授权 + 用户在白名单」两层同时命中。管理员 open_id 在「常用配置 → 行为」中填写,私聊机器人发送 /userid 获取。',
472
+ accessModeOpenWarning: '当前准入模式为开放(open):应用可用范围内的任何人都能开启对话。该模式不安全,仅建议调试时使用。',
473
+ accessAdminMissingWarning: '当前准入=guarded 但未配置管理员,所有对话都会被拒绝。请先在「常用配置 → 行为 → 管理员 open_id」填写。',
474
+ accessAdminLabel: '管理员',
475
+ accessAdminMissing: '未配置',
476
+ accessAdminHint: '修改管理员请到「常用配置 → 行为 → 管理员 open_id」;私聊机器人发送 /userid 可查看自己的 open_id(ou_ 开头,不是 user_id/邮箱)。',
477
+ accessGroupsTitle: '已授权群',
478
+ accessGroupsEmpty: '尚未授权任何群。机器人被拉进群后,管理员会收到带 chat_id 的群信息卡;也可在群内发送 /groupid 获取。',
479
+ accessUsersTitle: '已授权用户',
480
+ accessUsersEmpty: '尚未授权任何用户。已授权用户可在所有已授权群里与机器人对话(私聊仍只开给管理员)。',
481
+ accessRequestsTitle: '待处理申请',
482
+ accessRequestsEmpty: '没有待处理的授权申请。',
483
+ accessGroupChatIdPlaceholder: '群 chat_id(oc_…)',
484
+ accessGroupNamePlaceholder: '群名(可选)',
485
+ accessUserOpenIdPlaceholder: '用户 open_id(ou_…)',
486
+ accessUserNamePlaceholder: '昵称(可选)',
487
+ accessAddGroup: '添加群',
488
+ accessAddUser: '添加用户',
489
+ accessRemove: '移除',
490
+ accessAllow: '授权',
491
+ accessDeny: '忽略',
492
+ accessWorking: '处理中…',
493
+ accessSourceCommand: '对话命令',
494
+ accessSourceCard: '授权卡',
495
+ accessSourcePanel: '设置面板',
496
+ accessRequestGroup: '群申请',
497
+ accessRequestUser: '用户申请',
498
+ accessRequestRequester: '申请人',
499
+ accessRequestExpires: '有效期至',
500
+ accessGroupAdded: '已添加群授权',
501
+ accessGroupRemoved: '已移除群授权',
502
+ accessUserAdded: '已添加用户授权',
503
+ accessUserRemoved: '已移除用户授权',
504
+ accessRequestAllowed: '已授权',
505
+ accessRequestDenied: '已忽略',
506
+ accessHints: '三种授权入口:群内 /auth 命令、入群与用户授权卡、本面板。撤销立即生效;白名单只约束非管理员(管理员在任何私聊/群聊都直接放行),非管理员需要群和用户两层同时命中。',
507
+ accessChatIdHint: 'chat_id 可从入群群信息卡或群内 /groupid 获取;open_id 可私聊机器人发送 /userid 获取。',
508
+ } as const
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Web 面板覆盖层:`$DSH_HOME/dsh-channels/settings.json` 的读写、schema 校验与合并。
3
+ *
4
+ * 设计取舍:
5
+ * - 覆盖层只存 Web 面板可编辑的字段(`EDITABLE_KEYS`),凭据引用刻意不可
6
+ * 覆盖(变动等于换凭据);`baseDir` 可覆盖,但走 `workspaces.ts` 的严格
7
+ * 规范化(绝对路径 / `~` 展开 / 拒绝根目录),不接受相对路径。
8
+ * - 文件损坏/字段过期时**降级为空覆盖**并告警,绝不阻止飞书通道启动;
9
+ * 面板保存时则由 zod 完整校验后整体落盘。
10
+ * - 写入走同目录临时文件 + rename,避免半截文件。
11
+ *
12
+ * @module dsh-channels/settings/overlay
13
+ */
14
+
15
+ import { promises as fs } from 'node:fs'
16
+ import { homedir } from 'node:os'
17
+ import { dirname, join } from 'node:path'
18
+ import type { z } from 'zod'
19
+ import { configSchema } from '../config.ts'
20
+ import type { FeishuConfig } from '../config.ts'
21
+ import { normalizeWorkspaceBaseDir } from '../workspaces.ts'
22
+ import { EDITABLE_KEYS } from './contract.ts'
23
+ import type { ConfigValue, EditableKey } from './contract.ts'
24
+
25
+ /** The subset of `FeishuConfig` the Web panel may override. */
26
+ export type OverlayPatch = Partial<Pick<FeishuConfig, EditableKey>>
27
+
28
+ /** Fields whose empty string means "clear the override" (optional text config). */
29
+ const OPTIONAL_TEXT_KEYS = new Set<EditableKey>(['provider', 'model', 'reasoningEffort', 'agentPreset'])
30
+
31
+ /**
32
+ * Validate only the keys actually present in the input. Validating through
33
+ * the canonical `configSchema` field-by-field keeps one source of truth for
34
+ * bounds/enums — and, unlike `z.object(shape).partial()`, it does not leak
35
+ * schema defaults into the overlay (which would otherwise turn a one-field
36
+ * edit into "override every editable key").
37
+ */
38
+ export function parseOverlay(raw: unknown): { ok: true; patch: OverlayPatch } | { ok: false; issues: Array<{ path: string; message: string }> } {
39
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
40
+ return { ok: false, issues: [{ path: '', message: '必须是 JSON 对象' }] }
41
+ }
42
+ const source = raw as Record<string, unknown>
43
+ const issues: Array<{ path: string; message: string }> = []
44
+ const patch: Record<string, unknown> = {}
45
+ const shape = configSchema.shape as unknown as Record<string, z.ZodType>
46
+ for (const key of EDITABLE_KEYS) {
47
+ if (!Object.hasOwn(source, key)) continue
48
+ const value = source[key]
49
+ if (value === undefined) continue
50
+ // `baseDir` needs location semantics the shared string schema cannot
51
+ // express (absolute only, `~` expansion, no filesystem root).
52
+ if (key === 'baseDir') {
53
+ const checked = normalizeWorkspaceBaseDir(value)
54
+ if (!checked.ok) issues.push({ path: key, message: checked.message })
55
+ else patch[key] = checked.value
56
+ continue
57
+ }
58
+ const parsed = shape[key]!.safeParse(value)
59
+ if (!parsed.success) {
60
+ issues.push({ path: key, message: parsed.error.issues[0]?.message ?? '非法值' })
61
+ continue
62
+ }
63
+ patch[key] = parsed.data
64
+ }
65
+ if (issues.length > 0) return { ok: false, issues }
66
+ return { ok: true, patch: normalizeOverlay(patch) }
67
+ }
68
+
69
+ /** `$DSH_HOME` resolution shared with the rest of the DSH ecosystem. */
70
+ export function resolveDshHome(env: NodeJS.ProcessEnv = process.env): string {
71
+ const configured = env.DSH_HOME?.trim()
72
+ return configured !== undefined && configured !== '' ? configured : join(homedir(), '.dsh')
73
+ }
74
+
75
+ /** Default overlay path: `$DSH_HOME/dsh-channels/settings.json`. */
76
+ export function defaultOverlayPath(env: NodeJS.ProcessEnv = process.env): string {
77
+ return join(resolveDshHome(env), 'dsh-channels', 'settings.json')
78
+ }
79
+
80
+ /** Warn sink used for degraded reads (never throws). */
81
+ export interface OverlayLogger {
82
+ warn?(message: string, ...args: unknown[]): void
83
+ }
84
+
85
+ /** Strip `undefined`; drop empty-string overrides for optional text fields. */
86
+ export function normalizeOverlay(patch: Record<string, unknown>): OverlayPatch {
87
+ const normalized: Record<string, unknown> = {}
88
+ for (const key of EDITABLE_KEYS) {
89
+ const value = patch[key]
90
+ if (value === undefined) continue
91
+ if (OPTIONAL_TEXT_KEYS.has(key) && value === '') continue
92
+ normalized[key] = value
93
+ }
94
+ return normalized as OverlayPatch
95
+ }
96
+
97
+ /** Merge the Web overlay over the profile-provided base config. */
98
+ export function mergeOverlay(base: FeishuConfig, overlay: OverlayPatch): FeishuConfig {
99
+ return { ...base, ...overlay }
100
+ }
101
+
102
+ /** Project one config onto the editable-key subset (wire shape). */
103
+ export function pickEditable(config: FeishuConfig): Record<EditableKey, ConfigValue | undefined> {
104
+ const picked: Record<string, ConfigValue | undefined> = {}
105
+ for (const key of EDITABLE_KEYS) picked[key] = config[key] as ConfigValue | undefined
106
+ return picked as Record<EditableKey, ConfigValue | undefined>
107
+ }
108
+
109
+ /**
110
+ * One JSON overlay file. Reads degrade to `{}` with a warning; writes are
111
+ * atomic and owner-readable only.
112
+ */
113
+ export class OverlayStore {
114
+ constructor(
115
+ readonly file: string,
116
+ private readonly logger?: OverlayLogger,
117
+ ) {}
118
+
119
+ /** Load the overlay; a missing/corrupt/stale file degrades to an empty overlay. */
120
+ async load(): Promise<OverlayPatch> {
121
+ let text: string
122
+ try {
123
+ text = await fs.readFile(this.file, 'utf8')
124
+ } catch (error) {
125
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
126
+ this.logger?.warn?.('dsh-channels: Web 覆盖配置读取失败(%s),按未覆盖处理:%s', this.file, String(error))
127
+ }
128
+ return {}
129
+ }
130
+ let raw: unknown
131
+ try {
132
+ raw = JSON.parse(text)
133
+ } catch (error) {
134
+ this.logger?.warn?.('dsh-channels: Web 覆盖配置不是合法 JSON(%s),按未覆盖处理:%s', this.file, String(error))
135
+ return {}
136
+ }
137
+ const parsed = parseOverlay(raw)
138
+ if (!parsed.ok) {
139
+ this.logger?.warn?.(
140
+ 'dsh-channels: Web 覆盖配置含非法字段(%s),按未覆盖处理:%s',
141
+ this.file,
142
+ parsed.issues.map(issue => `${issue.path}: ${issue.message}`).join('; '),
143
+ )
144
+ return {}
145
+ }
146
+ return parsed.patch
147
+ }
148
+
149
+ /** Persist the validated overlay atomically. */
150
+ async save(patch: OverlayPatch): Promise<void> {
151
+ await fs.mkdir(dirname(this.file), { recursive: true })
152
+ const temporary = `${this.file}.tmp-${process.pid}-${Date.now()}`
153
+ await fs.writeFile(temporary, `${JSON.stringify(normalizeOverlay(patch as Record<string, unknown>), null, 2)}\n`, {
154
+ encoding: 'utf8',
155
+ mode: 0o600,
156
+ })
157
+ await fs.rename(temporary, this.file)
158
+ }
159
+
160
+ /** Remove the overlay file; the plugin falls back to the profile config. */
161
+ async clear(): Promise<void> {
162
+ await fs.rm(this.file, { force: true })
163
+ }
164
+ }