@modesto_ma/dsh-channels 0.4.0 → 0.5.0-beta.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.
Files changed (66) hide show
  1. package/README.md +83 -57
  2. package/docs/access-control-design.md +8 -5
  3. package/docs/attachment-pipeline-v2.md +33 -41
  4. package/docs/feishu-onboarding.md +36 -33
  5. package/docs/persistence.md +6 -4
  6. package/docs/refactor-plan.md +1 -1
  7. package/docs/workspace-isolation-design.md +7 -8
  8. package/lib/client.js +444 -288
  9. package/lib/client.js.map +1 -1
  10. package/lib/index.js +9017 -6794
  11. package/lib/index.js.map +1 -1
  12. package/package.json +1 -1
  13. package/src/access/cards.ts +9 -9
  14. package/src/access/controller.ts +10 -10
  15. package/src/access/index.ts +1 -0
  16. package/src/access/policy.ts +10 -10
  17. package/src/access/requests.ts +32 -31
  18. package/src/access/store.ts +43 -13
  19. package/src/access/targets.ts +56 -0
  20. package/src/cards/approval.ts +96 -26
  21. package/src/cards/index.ts +1 -0
  22. package/src/cards/onboarding.ts +1 -0
  23. package/src/cards/picker.ts +108 -0
  24. package/src/cards/streaming.ts +56 -7
  25. package/src/cards/wiki.ts +124 -0
  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 +96 -24
  32. package/src/client/panel/workspace-tab.tsx +2 -18
  33. package/src/client/string-list.ts +139 -0
  34. package/src/client/styles.ts +5 -0
  35. package/src/commands/registry.ts +8 -5
  36. package/src/config.ts +55 -54
  37. package/src/domain.ts +36 -123
  38. package/src/feishu/delivery.ts +34 -1
  39. package/src/feishu/inbound-attachments.ts +1 -1
  40. package/src/feishu/manifest.ts +8 -31
  41. package/src/feishu/message-read.ts +97 -24
  42. package/src/feishu/scopes.ts +194 -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 +72 -112
  46. package/src/feishu/wiki/api.ts +346 -0
  47. package/src/feishu/wiki/consent.ts +183 -0
  48. package/src/feishu/wiki/links.ts +122 -0
  49. package/src/feishu/wiki/scope.ts +66 -0
  50. package/src/feishu/wiki/tools.ts +984 -0
  51. package/src/feishu-channel.ts +280 -9
  52. package/src/index.ts +5 -4
  53. package/src/interactions/approvals.ts +187 -7
  54. package/src/interactions/pending.ts +3 -3
  55. package/src/routing/inbound.ts +73 -106
  56. package/src/service.ts +545 -440
  57. package/src/session/manager.ts +1 -1
  58. package/src/session/routes.ts +2 -3
  59. package/src/settings/contract.ts +110 -89
  60. package/src/settings/routes.ts +3 -17
  61. package/src/settings/surface.ts +144 -94
  62. package/src/streamer.ts +94 -5
  63. package/src/workspaces.ts +21 -29
  64. package/src/cleanup.ts +0 -546
  65. package/src/client/panel/cleanup-card.tsx +0 -106
  66. package/src/feishu/pending-attachments.ts +0 -562
@@ -0,0 +1,139 @@
1
+ /**
2
+ * 浏览器半区的 `string-list` 字段辅助:把草稿值读成条目数组,并在保存前
3
+ * 逐项校验,供面板即时标红。Host 保存时按同一规则复核(见
4
+ * `settings/surface.ts` 的 `normalizeStringList`)。
5
+ *
6
+ * 不含 JSX/React,便于单测。条目格式校验直接复用纯函数模块
7
+ * `feishu/wiki/links.ts`(无运行时依赖,可内联进 Client bundle)。
8
+ *
9
+ * @module dsh-channels/client/string-list
10
+ */
11
+
12
+ import { parseWikiRootLink, WIKI_ROOT_LINK_ERRORS } from '../feishu/wiki/links.ts'
13
+ import { PANEL_COPY } from '../settings/contract.ts'
14
+ import type {
15
+ ConfigValue,
16
+ EditableFieldWire,
17
+ FieldIssueWire,
18
+ StringListItemFormat,
19
+ } from '../settings/contract.ts'
20
+
21
+ /** Entry checks per item format: the readable reason, or `undefined` when valid. */
22
+ const ITEM_FORMAT_CHECKS: Readonly<Record<StringListItemFormat, (entry: string) => string | undefined>> = {
23
+ 'wiki-root-link': (entry) => {
24
+ const parsed = parseWikiRootLink(entry)
25
+ return parsed.ok ? undefined : WIKI_ROOT_LINK_ERRORS[parsed.error]
26
+ },
27
+ }
28
+
29
+ /**
30
+ * Entries of a `string-list` value; any other value reads as no entries.
31
+ *
32
+ * @param value - Draft or effective value of a `string-list` field.
33
+ * @returns The entries as stored (untrimmed).
34
+ */
35
+ export function listItems(value: ConfigValue | undefined): readonly string[] {
36
+ return typeof value === 'object' ? value : []
37
+ }
38
+
39
+ /**
40
+ * Validate the entries of one `string-list` draft the way the Host will:
41
+ * empty inputs are ignored, every other entry is trimmed and checked against
42
+ * the field's `itemFormat`, and the non-empty count is bounded by `maxItems`.
43
+ *
44
+ * @param field - The Host descriptor (`kind: 'string-list'`).
45
+ * @param items - The draft entries, empty inputs included.
46
+ * @returns Per-entry issues (with `index` into `items`) and a whole-field issue when over `maxItems`.
47
+ */
48
+ export function stringListIssues(field: EditableFieldWire, items: readonly string[]): FieldIssueWire[] {
49
+ const issues: FieldIssueWire[] = []
50
+ const check = field.itemFormat === undefined ? undefined : ITEM_FORMAT_CHECKS[field.itemFormat]
51
+ let count = 0
52
+ items.forEach((item, index) => {
53
+ const entry = item.trim()
54
+ if (entry === '') return
55
+ count += 1
56
+ const reason = check?.(entry)
57
+ if (reason === undefined) return
58
+ const position = PANEL_COPY.listEntry.replace('{n}', String(index + 1))
59
+ issues.push({ key: field.key, index, message: `${position}「${entry}」:${reason}` })
60
+ })
61
+ if (field.maxItems !== undefined && count > field.maxItems) {
62
+ issues.push({ key: field.key, message: PANEL_COPY.listTooMany.replace('{max}', String(field.maxItems)) })
63
+ }
64
+ return issues
65
+ }
66
+
67
+ /**
68
+ * Turn one `string-list` draft into its save payload. Entries are submitted as
69
+ * typed (untrimmed, empty inputs included) so Host issue indexes point at the
70
+ * same rendered inputs; the Host trims and drops empty inputs.
71
+ *
72
+ * @param field - The Host descriptor (`kind: 'string-list'`).
73
+ * @param raw - The draft value.
74
+ * @returns The payload entries, or the issues that block the save.
75
+ */
76
+ export function stringListDraftValue(
77
+ field: EditableFieldWire,
78
+ raw: ConfigValue | undefined,
79
+ ): { ok: true; value: string[] } | { ok: false; issues: FieldIssueWire[] } {
80
+ const items = listItems(raw)
81
+ const issues = stringListIssues(field, items)
82
+ return issues.length > 0 ? { ok: false, issues } : { ok: true, value: [...items] }
83
+ }
84
+
85
+ /**
86
+ * Inputs the list control renders: one per entry, or one blank input for an
87
+ * empty list so the first entry can be typed directly.
88
+ *
89
+ * @param value - Draft or effective value.
90
+ * @returns The input values, in order.
91
+ */
92
+ export function listRows(value: ConfigValue | undefined): readonly string[] {
93
+ const items = listItems(value)
94
+ return items.length === 0 ? [''] : items
95
+ }
96
+
97
+ /**
98
+ * Replace the entry at one input position.
99
+ *
100
+ * @param rows - Rendered inputs ({@link listRows}).
101
+ * @param index - Position of the edited input.
102
+ * @param entry - The typed value.
103
+ * @returns The next draft entries.
104
+ */
105
+ export function withListEntry(rows: readonly string[], index: number, entry: string): string[] {
106
+ return rows.map((item, position) => (position === index ? entry : item))
107
+ }
108
+
109
+ /**
110
+ * Drop the entry at one input position.
111
+ *
112
+ * @param rows - Rendered inputs ({@link listRows}).
113
+ * @param index - Position of the removed input.
114
+ * @returns The next draft entries.
115
+ */
116
+ export function withoutListEntry(rows: readonly string[], index: number): string[] {
117
+ return rows.filter((_, position) => position !== index)
118
+ }
119
+
120
+ /**
121
+ * Append one blank input.
122
+ *
123
+ * @param rows - Rendered inputs ({@link listRows}).
124
+ * @returns The next draft entries.
125
+ */
126
+ export function withBlankListEntry(rows: readonly string[]): string[] {
127
+ return [...rows, '']
128
+ }
129
+
130
+ /**
131
+ * Whether the add button is disabled: the inputs already reach `maxItems`.
132
+ *
133
+ * @param field - The Host descriptor.
134
+ * @param rows - Rendered inputs ({@link listRows}).
135
+ * @returns `true` when no further input may be added.
136
+ */
137
+ export function listIsFull(field: EditableFieldWire, rows: readonly string[]): boolean {
138
+ return field.maxItems !== undefined && rows.length >= field.maxItems
139
+ }
@@ -59,6 +59,9 @@ const CSS = `
59
59
  .dsh-channels-cfg__inputRow{display:flex;align-items:center;gap:8px;min-width:0}
60
60
  .dsh-channels-cfg__inputRow .dsh-channels-cfg__input{flex:1 1 auto;width:auto;min-width:0}
61
61
  .dsh-channels-cfg__btn--browse{flex:none}
62
+ .dsh-channels-cfg__list{display:flex;flex-direction:column;gap:6px;min-width:0}
63
+ .dsh-channels-cfg__listItem{display:flex;flex-direction:column;gap:2px;min-width:0}
64
+ .dsh-channels-cfg__btn--listRemove{flex:none}
62
65
  .dsh-channels-cfg__input:focus{border-color:var(--dsw-alias-state-business-primary,#0a67d3)}
63
66
  .dsh-channels-cfg__input--invalid{border-color:var(--dsw-alias-state-danger-primary,#c62828)}
64
67
  select.dsh-channels-cfg__input{appearance:auto;cursor:pointer}
@@ -68,6 +71,8 @@ select.dsh-channels-cfg__input{appearance:auto;cursor:pointer}
68
71
  .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
72
  .dsh-channels-cfg__notice--ok{color:var(--dsw-alias-state-business-primary,#0a7d33);background:color-mix(in srgb,currentColor 8%,transparent)}
70
73
  .dsh-channels-cfg__notice--error{color:var(--dsw-alias-state-danger-primary,#c62828);background:color-mix(in srgb,currentColor 8%,transparent)}
74
+ .dsh-channels-cfg__notice a{color:inherit;text-decoration:underline}
75
+ .dsh-channels-cfg__scopeList{margin:2px 0;padding-left:18px;display:flex;flex-direction:column;gap:2px}
71
76
  .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
77
  .dsh-channels-cfg__footer{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding-top:2px}
73
78
  .dsh-channels-cfg__spacer{flex:1}
@@ -28,7 +28,10 @@ export interface ChannelCommandSpec {
28
28
  readonly mentionGate?: boolean
29
29
  /** Only usable in this chat type. */
30
30
  readonly chatType?: 'p2p' | 'group'
31
- /** Answer with a direct reply instead of creating a topic. */
31
+ /**
32
+ * Answer with a direct reply instead of creating a topic; inside an existing
33
+ * topic the reply still lands in that topic.
34
+ */
32
35
  readonly directReply?: boolean
33
36
  /**
34
37
  * Answered by the channel itself (`/stop`), never through the agent command
@@ -47,7 +50,7 @@ export interface ChannelCommandSpec {
47
50
  export const CHANNEL_COMMANDS = [
48
51
  {
49
52
  name: 'help', group: '基础', description: '显示当前支持的斜杠命令',
50
- routeFree: true, directReply: false,
53
+ routeFree: true, directReply: true,
51
54
  },
52
55
  {
53
56
  name: 'userid', group: '基础', description: '在私聊中显示当前用户 ID',
@@ -59,7 +62,7 @@ export const CHANNEL_COMMANDS = [
59
62
  },
60
63
  {
61
64
  name: 'health', group: '基础', description: '显示 Harness 与飞书通道健康状态',
62
- sessionOptional: true,
65
+ sessionOptional: true, directReply: true,
63
66
  },
64
67
  {
65
68
  name: 'status', group: '会话', description: '显示当前 Session 状态与性能指标',
@@ -72,8 +75,8 @@ export const CHANNEL_COMMANDS = [
72
75
  usage: '[name]', channelPlane: true, agentScoped: false,
73
76
  },
74
77
  {
75
- name: 'auth', group: '会话', description: '查看/管理访问授权(仅管理员)',
76
- usage: '[status|allow|revoke|list|user add <open_id>|user remove <open_id>]',
78
+ name: 'auth', group: '会话', description: '查看/管理访问授权,可 @ 成员授权(仅管理员)',
79
+ usage: '[status|allow|revoke|list|user add <@用户|user_id>|user remove <@用户|user_id>]',
77
80
  routeFree: true, channelPlane: true, agentScoped: false,
78
81
  },
79
82
  {
package/src/config.ts CHANGED
@@ -13,7 +13,8 @@
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
+ import { parseWikiRootLink, WIKI_ROOT_LINK_ERRORS } from './feishu/wiki/links.ts'
17
18
 
18
19
  export const FEISHU_LOGGER_LEVELS = ['debug', 'info', 'warn', 'error'] as const
19
20
  export type FeishuLoggerLevel = (typeof FEISHU_LOGGER_LEVELS)[number]
@@ -38,12 +39,6 @@ export const DEFAULT_QUESTION_TIMEOUT_MS = 5 * 60 * 1000
38
39
  export const ATTACHMENT_STORE_MODES = ['native', 'archive'] as const
39
40
  export type AttachmentStoreMode = (typeof ATTACHMENT_STORE_MODES)[number]
40
41
 
41
- /** Default retention of silently registered attachment metadata: 7 days. */
42
- export const DEFAULT_PENDING_ATTACHMENT_TTL_MS = 7 * 24 * 60 * 60 * 1000
43
-
44
- /** Default per-`(chat, thread, sender)` cap of pending attachment rows. */
45
- export const DEFAULT_PENDING_ATTACHMENT_LIMIT = 10
46
-
47
42
  /** Default per-call attachment count of `feishu_attachment_fetch`. */
48
43
  export const DEFAULT_ATTACHMENT_FETCH_MAX_RESOURCES = 10
49
44
 
@@ -103,13 +98,6 @@ export const configSchema = z.object({
103
98
  * against the host process cwd.
104
99
  */
105
100
  baseDir: z.string().min(1).default('./channels-data'),
106
- /**
107
- * Optional path of the Web settings overlay (default
108
- * `$DSH_HOME/dsh-channels/settings.json`). The Web panel writes only
109
- * editable fields there; this path is for embedded hosts, tests, or users
110
- * who want multiple instances with different overlays.
111
- */
112
- settingsFile: z.string().min(1).optional(),
113
101
  /** LLM provider route for sessions this plugin creates (optional → deployment default). */
114
102
  provider: z.string().optional(),
115
103
  /** Model id for sessions this plugin creates (optional → deployment default). */
@@ -193,7 +181,7 @@ export const configSchema = z.object({
193
181
  * behind a prompt manifest; with the archive backend the resources are saved
194
182
  * below `<workspace>/attachments/` before the turn, exactly like the legacy
195
183
  * pipeline. Disabled keeps attachment messages inert (no save, no turn, no
196
- * pending metadata registration).
184
+ * manifest rows).
197
185
  */
198
186
  acceptAttachments: z.boolean().default(true),
199
187
  /**
@@ -263,24 +251,6 @@ export const configSchema = z.object({
263
251
  */
264
252
  promptContextTimeoutMs: z.number().int().min(100).max(10_000)
265
253
  .default(DEFAULT_PROMPT_CONTEXT_TIMEOUT_MS),
266
- /**
267
- * Retention of silently registered attachment metadata (the
268
- * `pending_attachments` table). A message that does not trigger a model turn
269
- * (attachment-only p2p message, un-mentioned group message, attachment-only
270
- * message inside a bound topic) is recorded as metadata only; a later
271
- * triggered message in the same `(chat, thread, sender)` scope may list and
272
- * fetch it. Rows older than this window are removed by the periodic record
273
- * GC. `0` disables age pruning (the per-scope cap still bounds each scope).
274
- * Default 7 days.
275
- */
276
- pendingAttachmentTtlMs: z.number().int().min(0).default(DEFAULT_PENDING_ATTACHMENT_TTL_MS),
277
- /**
278
- * Maximum silently registered attachment rows kept per `(chat, thread,
279
- * sender)` scope. Registering beyond the cap evicts the oldest row of that
280
- * scope; the prompt manifest only ever shows the most recent 5 unconsumed
281
- * rows. Default 10.
282
- */
283
- pendingAttachmentLimit: z.number().int().min(1).max(100).default(DEFAULT_PENDING_ATTACHMENT_LIMIT),
284
254
  /**
285
255
  * Model-facing Feishu tools.
286
256
  *
@@ -312,6 +282,26 @@ export const configSchema = z.object({
312
282
  * - `topicMessagesTimeoutMs`: whole-call timeout of one model-initiated
313
283
  * topic read (default 20 s). Passive prompt excerpts use a separate short
314
284
  * (~3 s) budget and never wait this long.
285
+ * - `sendFile` (default on): register `feishu_send_file` in channel sessions
286
+ * so the model can send an image or file from the topic's working
287
+ * directory back to the current conversation. Needs the Feishu
288
+ * `im:resource` (or `im:resource:upload`) scope; without it a call returns
289
+ * a readable failure and the turn continues.
290
+ *
291
+ * - `wikiRead` (default off): register `feishu_wiki_list` and
292
+ * `feishu_wiki_read` so the model can browse the wiki nodes under
293
+ * `wikiRoots` and read docx documents there. Needs `wiki:wiki` (or
294
+ * `wiki:wiki:readonly`) and `docx:document` (or `docx:document:readonly`).
295
+ * - `wikiWrite` (default off): register `feishu_wiki_create` and
296
+ * `feishu_wiki_edit` so the model can create docx documents under
297
+ * `wikiRoots` and edit documents this app owns. Needs `wiki:wiki` and
298
+ * `docx:document`.
299
+ *
300
+ * The wiki tools act with the app identity and are mounted only while
301
+ * `wikiRoots` is non-empty. Each call is limited to topics whose owner is
302
+ * the administrator or on the wiki whitelist (`/auth wiki add`); opening a
303
+ * document, creating one, and editing one each need the topic owner's
304
+ * consent on an authorization card (a read consent lasts for the topic).
315
305
  *
316
306
  * The Web panel/metrics ship with `panel_docs_release`.
317
307
  */
@@ -326,6 +316,9 @@ export const configSchema = z.object({
326
316
  topicMessages: z.boolean().default(true),
327
317
  topicMessagesTimeoutMs: z.number().int().min(1_000).max(120_000)
328
318
  .default(DEFAULT_TOPIC_MESSAGES_TIMEOUT_MS),
319
+ sendFile: z.boolean().default(true),
320
+ wikiRead: z.boolean().default(false),
321
+ wikiWrite: z.boolean().default(false),
329
322
  }).default({
330
323
  attachmentFetch: true,
331
324
  attachmentFetchMaxResources: DEFAULT_ATTACHMENT_FETCH_MAX_RESOURCES,
@@ -333,28 +326,34 @@ export const configSchema = z.object({
333
326
  attachmentFetchTimeoutMs: DEFAULT_ATTACHMENT_FETCH_TIMEOUT_MS,
334
327
  topicMessages: true,
335
328
  topicMessagesTimeoutMs: DEFAULT_TOPIC_MESSAGES_TIMEOUT_MS,
329
+ sendFile: true,
330
+ wikiRead: false,
331
+ wikiWrite: false,
336
332
  }),
337
333
  /**
338
- * Delete the topic directories of stale conversations, plus legacy
339
- * one-workspace-per-topic directories written by older plugin versions.
340
- * `off` (default) never deletes; `7d`/`30d`/`90d`/`180d` remove those
341
- * directories when their last activity is older than the threshold. User
342
- * workspaces themselves are never deleted. The managed Web panel exposes
343
- * this as a select, previews the candidates, and can run one cleanup
344
- * manually. DSH Session logs are never deleted.
334
+ * Wiki (知识库) locations the wiki tools may touch: whole spaces
335
+ * (`https://<tenant>.feishu.cn/wiki/space/<space_id>`) or single nodes with
336
+ * everything below them (`https://<tenant>.feishu.cn/wiki/<node_token>`).
337
+ * Browsing, reading, creating, and editing outside these roots fail. Empty
338
+ * (default) mounts no wiki tool even when `feishuTools.wikiRead`/`wikiWrite`
339
+ * is on.
345
340
  */
346
- workspaceRetention: z.enum(WORKSPACE_RETENTION_VALUES).default('off'),
347
- /**
348
- * Run the retention cleanup at startup and once per day. Off by default:
349
- * deletion is irreversible, so it stays an explicit opt-in even when a
350
- * retention threshold is configured.
351
- */
352
- workspaceRetentionAuto: z.boolean().default(false),
341
+ wikiRoots: z.array(z.string().trim().superRefine((link, issue) => {
342
+ const parsed = parseWikiRootLink(link)
343
+ if (!parsed.ok) issue.addIssue({ code: 'custom', message: WIKI_ROOT_LINK_ERRORS[parsed.error] })
344
+ })).max(20).default([]),
353
345
  /**
354
346
  * Send a one-time onboarding card on the first private message of a chat
355
347
  * (facts + command cheat sheet). Persisted per chat; never repeats.
356
348
  */
357
349
  welcomeOnboarding: z.boolean().default(true),
350
+ /**
351
+ * Sync the command registry to Feishu's native `/` panel on every connect
352
+ * (needs the `application:app_slash_command:read`/`write` scopes). Off by
353
+ * default: some tenants forbid apps from adding slash commands. When off,
354
+ * the plugin neither reads nor writes the panel.
355
+ */
356
+ slashCommandPanel: z.boolean().default(false),
358
357
  /**
359
358
  * Allow `/permissions <name>` to switch this Session's sandbox + approval
360
359
  * preset from Feishu. Read-only `/permissions` always works; switching is
@@ -373,15 +372,17 @@ export const configSchema = z.object({
373
372
  */
374
373
  accessMode: z.enum(['open', 'guarded']).default('guarded'),
375
374
  /**
376
- * The unique administrator's Feishu open_id (`ou_...`). Set it in the Web
375
+ * The unique administrator's Feishu `user_id`. Set it in the Web
377
376
  * settings panel (or the profile config). Get it by sending `/userid` to the
378
- * bot in a private chat. While `accessMode` is `guarded` and this is unset,
379
- * every conversation is refused (fail closed); `/userid` and a fixed notice
380
- * stay available so the deployment can be bootstrapped.
377
+ * bot in a private chat. When unset, the Feishu app owner (looked up via
378
+ * `application.v6.application.get` at connect; needs an app-info read scope)
379
+ * is the administrator. While `accessMode` is `guarded` and neither is
380
+ * available, every conversation is refused (fail closed); `/userid` and a
381
+ * fixed notice stay available so the deployment can be bootstrapped.
381
382
  */
382
- adminOpenId: z.string().regex(
383
- /^ou_[A-Za-z0-9_-]+$/,
384
- 'must be a Feishu open_id such as ou_xxx (send /userid to the bot in a private chat to see it)',
383
+ adminUserId: z.string().regex(
384
+ FEISHU_USER_ID_PATTERN,
385
+ 'must be a Feishu user_id, not an open_id (send /userid to the bot in a private chat to see it)',
385
386
  ).optional(),
386
387
  /**
387
388
  * Lifetime of one pending authorization request and of the DM card that asks
package/src/domain.ts CHANGED
@@ -41,8 +41,8 @@ export const routeRecordSchema = z.object({
41
41
  chatId: z.string().min(1),
42
42
  /** `p2p` or `group`. */
43
43
  chatType: z.enum(['p2p', 'group']),
44
- /** Workspace owner (sender open_id). */
45
- senderOpenId: z.string().min(1),
44
+ /** Workspace owner (sender user_id). */
45
+ senderUserId: z.string().min(1),
46
46
  /** Workspace root owning this route: the Session header cwd / sandbox root. */
47
47
  workspacePath: z.string().min(1),
48
48
  /**
@@ -81,15 +81,14 @@ export const expiredRouteRecordSchema = z.object({
81
81
  /** Original chat type, retained for diagnostics and future policy. */
82
82
  chatType: z.enum(['p2p', 'group']),
83
83
  /** Original route owner; never used to grant access after expiry. */
84
- senderOpenId: z.string().min(1),
84
+ senderUserId: z.string().min(1),
85
85
  /** Epoch ms when the route was retired. */
86
86
  expiredAt: z.number().int().nonnegative(),
87
87
  /** Workspace the retired route belonged to; absent in tombstones written by
88
88
  * older plugin versions, so the field must stay optional. */
89
89
  workspacePath: z.string().min(1).optional(),
90
90
  /** Effective working directory the retired route's Session used; absent in
91
- * legacy tombstones (retention cleanup then treats `workspacePath` as the
92
- * directory, matching pre-v2 routes). */
91
+ * legacy tombstones, where `workspacePath` was the directory (pre-v2 routes). */
93
92
  sessionDir: z.string().min(1).optional(),
94
93
  /** Git branch of that directory when it was a worktree checkout. */
95
94
  worktreeBranch: z.string().min(1).optional(),
@@ -202,7 +201,7 @@ export const accessGroupRecordSchema = z.object({
202
201
  name: z.string().optional(),
203
202
  /** Which authorization entry produced this grant. */
204
203
  source: z.enum(['command', 'card', 'panel']),
205
- /** Administrator open_id that authorized it. */
204
+ /** Administrator user_id that authorized it. */
206
205
  addedBy: z.string().min(1),
207
206
  /** Epoch ms when it was authorized. */
208
207
  addedAt: z.number().int().nonnegative(),
@@ -211,18 +210,18 @@ export const accessGroupRecordSchema = z.object({
211
210
  export type AccessGroupRecord = z.infer<typeof accessGroupRecordSchema>
212
211
 
213
212
  /**
214
- * One authorized user (`open_id`), global across every authorized group.
213
+ * One authorized user (`user_id`), global across every authorized group.
215
214
  * Private chats are administrator-only, so this grant only affects group
216
215
  * conversations.
217
216
  */
218
217
  export const accessUserRecordSchema = z.object({
219
- /** Feishu open id (`ou_...`) — also the table key. */
220
- openId: z.string().min(1),
218
+ /** Feishu `user_id` — also the table key. */
219
+ userId: z.string().min(1),
221
220
  /** Display name captured when the user was authorized (optional). */
222
221
  name: z.string().optional(),
223
222
  /** Which authorization entry produced this grant. */
224
223
  source: z.enum(['command', 'card', 'panel']),
225
- /** Administrator open_id that authorized it. */
224
+ /** Administrator user_id that authorized it. */
226
225
  addedBy: z.string().min(1),
227
226
  /** Epoch ms when it was authorized. */
228
227
  addedAt: z.number().int().nonnegative(),
@@ -241,14 +240,14 @@ export const accessRequestRecordSchema = z.object({
241
240
  token: z.string().min(1),
242
241
  /** What is being requested. */
243
242
  kind: z.enum(['group', 'user']),
244
- /** `chat_id` for a group request; `open_id` for a user request. */
243
+ /** `chat_id` for a group request; `user_id` for a user request. */
245
244
  targetId: z.string().min(1),
246
245
  /** Chat the request originated from, when applicable. */
247
246
  chatId: z.string().optional(),
248
247
  /** Group name captured for the card (group requests, or the group the user was seen in). */
249
248
  groupName: z.string().optional(),
250
249
  /** User who triggered a user request. */
251
- requesterOpenId: z.string().optional(),
250
+ requesterUserId: z.string().optional(),
252
251
  /** Display name of that user, when the event carried one. */
253
252
  requesterName: z.string().optional(),
254
253
  /** Lifecycle state; only `pending` requests accept a card action. */
@@ -282,91 +281,8 @@ export const processedRecordSchema = z.object({
282
281
 
283
282
  export type ProcessedRecord = z.infer<typeof processedRecordSchema>
284
283
 
285
- /**
286
- * Silently registered inbound attachment metadata ("pending attachments").
287
- *
288
- * A message that does not trigger a model turn — a p2p attachment-only
289
- * message, an un-mentioned group message, an attachment-only message inside an
290
- * already-bound topic — is recorded here without downloading bytes, replying,
291
- * or reacting. A later triggered message in the same `(chat, thread?, sender)`
292
- * scope can list these entries in its prompt manifest and fetch them on demand
293
- * through the attachment tool. The table stores metadata only (Feishu resource
294
- * keys), never bytes.
295
- *
296
- * Added without a storage-domain version bump: the host's JSON backend loads a
297
- * declared table that is missing from the stored document as empty and keeps
298
- * the stored `unit.version` (1), so existing deployments gain the table with no
299
- * migration. Downgrading to a plugin build without this table would drop it on
300
- * the next write — acceptable because plugin builds are replaced in place.
301
- */
302
- export const PENDING_ATTACHMENT_KINDS = ['image', 'file', 'post', 'merge_forward'] as const
303
-
304
- /** Kind of message that carried the attachment payload. */
305
- export type PendingAttachmentKind = (typeof PENDING_ATTACHMENT_KINDS)[number]
306
-
307
- /**
308
- * One attachment resource inside a pending record. Feishu downloads resources
309
- * by `(message id, file key)`; `containerMessageId` is set when only a
310
- * top-level container id works (merged-forward children).
311
- */
312
- export const pendingAttachmentResourceSchema = z.object({
313
- /** Resource kind delivered by Feishu. */
314
- type: z.enum(['image', 'file']),
315
- /** Feishu resource key (`img_v3_...` / `file_v3_...`). */
316
- fileKey: z.string().min(1),
317
- /** Original file name, when Feishu reports one. */
318
- fileName: z.string().min(1).optional(),
319
- /** Feishu folder entries cannot be downloaded; recorded so the fetch tool can
320
- * answer with a structured "folder unsupported" instead of a raw API error. */
321
- folder: z.boolean().optional(),
322
- /** Top-level container message id to download through, when different from
323
- * this record's own `messageId` (merged-forward child resources). */
324
- containerMessageId: z.string().min(1).optional(),
325
- })
326
-
327
- export type PendingAttachmentResource = z.infer<typeof pendingAttachmentResourceSchema>
328
-
329
- /**
330
- * One silently registered attachment message. Keyed by its Feishu message id so
331
- * a redelivered event can never register twice. `scopeKey` is the
332
- * `(chat, thread?, sender)` isolation unit; `consumed` marks metadata the
333
- * agent already fetched, which then drops out of the prompt manifest (the
334
- * record stays as an authorization fact until its TTL).
335
- */
336
- export const pendingAttachmentRecordSchema = z.object({
337
- /** Key the record is stored under (= `messageId`). */
338
- key: z.string().min(1),
339
- /** `(chat, thread?, sender)` isolation key; see `pendingAttachmentScopeKey`. */
340
- scopeKey: z.string().min(1),
341
- /** Feishu message id of the attachment message (the container id for
342
- * merged-forward records). */
343
- messageId: z.string().min(1),
344
- /** Feishu chat the message arrived in. */
345
- chatId: z.string().min(1),
346
- /** `p2p` or `group`. */
347
- chatType: z.enum(['p2p', 'group']),
348
- /** Feishu topic the message belongs to, when it has one. */
349
- threadId: z.string().min(1).optional(),
350
- /** Normalized sender id (open_id) that the scope is bound to. */
351
- senderOpenId: z.string().min(1),
352
- /** Epoch ms when the metadata was registered (clock seam for tests). */
353
- receivedAt: z.number().int().nonnegative(),
354
- /** Kind of message that carried the payload. */
355
- kind: z.enum(PENDING_ATTACHMENT_KINDS),
356
- /** Attachment payloads; empty for merged-forward containers (children are
357
- * listed through the container's own message id on fetch). */
358
- resources: z.array(pendingAttachmentResourceSchema),
359
- /** Remaining caption text after attachment placeholders, truncated. */
360
- textExcerpt: z.string().min(1).optional(),
361
- /** Set once the agent fetched the attachment; consumed rows leave the
362
- * manifest but stay queryable by message id until the TTL sweep. */
363
- consumed: z.boolean().optional(),
364
- })
365
-
366
- export type PendingAttachmentRecord = z.infer<typeof pendingAttachmentRecordSchema>
367
-
368
284
  export interface FeishuDomainSpecShape {
369
- name: 'channels_routes'
285
+ name: 'channels_routes_v2'
370
286
  version: 1
371
287
  global: {
372
288
  schema: z.ZodType<FeishuDomainGlobal>
@@ -401,8 +317,8 @@ export interface FeishuDomainSpecShape {
401
317
  valueSchema: typeof accessRequestRecordSchema
402
318
  __key?: string
403
319
  }
404
- pending_attachments: {
405
- valueSchema: typeof pendingAttachmentRecordSchema
320
+ access_wiki_users: {
321
+ valueSchema: typeof accessUserRecordSchema
406
322
  __key?: string
407
323
  }
408
324
  }
@@ -425,7 +341,9 @@ export type FeishuDomainGlobal = z.infer<typeof feishuDomainGlobalSchema>
425
341
  * runtime import of `@deepseek-ai/dsh-storage-domain`.
426
342
  */
427
343
  export const feishuDomain: FeishuDomainSpecShape = {
428
- name: 'channels_routes',
344
+ // v2 keys every user by `user_id`. The open_id-keyed `channels_routes` unit
345
+ // is neither read nor migrated; it stays on disk untouched.
346
+ name: 'channels_routes_v2',
429
347
  version: 1,
430
348
  global: {
431
349
  schema: feishuDomainGlobalSchema,
@@ -439,7 +357,10 @@ export const feishuDomain: FeishuDomainSpecShape = {
439
357
  access_groups: { valueSchema: accessGroupRecordSchema },
440
358
  access_users: { valueSchema: accessUserRecordSchema },
441
359
  access_requests: { valueSchema: accessRequestRecordSchema },
442
- pending_attachments: { valueSchema: pendingAttachmentRecordSchema },
360
+ // Wiki whitelist (`/auth wiki add`): same record as `access_users`, an
361
+ // independent grant. Added without a version bump like the other access
362
+ // tables: a missing table loads empty.
363
+ access_wiki_users: { valueSchema: accessUserRecordSchema },
443
364
  },
444
365
  }
445
366
 
@@ -490,7 +411,7 @@ export interface AccessGroupsTable {
490
411
  entries(): IterableIterator<[string, AccessGroupRecord]>
491
412
  }
492
413
 
493
- /** Normalized table handle for authorized users (key = `open_id`). */
414
+ /** Normalized table handle for authorized users (key = `user_id`). */
494
415
  export interface AccessUsersTable {
495
416
  get(key: string): AccessUserRecord | undefined
496
417
  put(key: string, value: AccessUserRecord): Promise<unknown> | void
@@ -506,19 +427,6 @@ export interface AccessRequestsTable {
506
427
  entries(): IterableIterator<[string, AccessRequestRecord]>
507
428
  }
508
429
 
509
- /**
510
- * Normalized table handle for silently registered attachment metadata
511
- * (key = Feishu message id).
512
- */
513
- export interface PendingAttachmentsTable {
514
- get(key: string): PendingAttachmentRecord | undefined
515
- put(key: string, value: PendingAttachmentRecord): Promise<unknown> | void
516
- /** Atomic read-modify-write on the host's write chain (`missing-key` when gone). */
517
- update(key: string, fn: (current: PendingAttachmentRecord) => PendingAttachmentRecord): Promise<unknown>
518
- delete(key: string): Promise<unknown> | void
519
- entries(): IterableIterator<[string, PendingAttachmentRecord]>
520
- }
521
-
522
430
  /** Normalized domain handle. */
523
431
  export interface FeishuDomainHandle {
524
432
  routes: RoutesTable
@@ -528,12 +436,8 @@ export interface FeishuDomainHandle {
528
436
  accessGroups: AccessGroupsTable
529
437
  accessUsers: AccessUsersTable
530
438
  accessRequests: AccessRequestsTable
531
- /**
532
- * Pending attachment metadata. Optional on purpose: an embedded host whose
533
- * storage domain refuses the added table leaves this undefined and the
534
- * registry degrades to a process-memory TTL store (records lost on restart).
535
- */
536
- pendingAttachments?: PendingAttachmentsTable
439
+ /** Wiki whitelist (key = `user_id`); same records as {@link AccessUsersTable}. */
440
+ accessWikiUsers: AccessUsersTable
537
441
  getGlobal(): FeishuDomainGlobal
538
442
  setGlobal(value: FeishuDomainGlobal): Promise<unknown> | void
539
443
  close(): Promise<void>
@@ -558,9 +462,18 @@ export interface RawDomainHandle {
558
462
  close(): Promise<void>
559
463
  }
560
464
 
465
+ /**
466
+ * Feishu tenant `user_id` (letters, digits, `_`, `-`). It doubles as a
467
+ * workspace path segment and as a DM target, where the SDK infers the
468
+ * receive id type from the prefix: `ou_`/`on_`/`oc_` would be sent as an
469
+ * open_id/union_id/chat_id, so those prefixes are rejected (a pasted open_id
470
+ * fails validation instead of matching nobody).
471
+ */
472
+ export const FEISHU_USER_ID_PATTERN = /^(?!ou_|on_|oc_)[A-Za-z0-9_-]+$/u
473
+
561
474
  /** Build a chat-scoped fallback key for degraded (topic-less) routing. */
562
- export function chatRouteKey(chatType: 'p2p' | 'group', chatId: string, senderOpenId: string): string {
475
+ export function chatRouteKey(chatType: 'p2p' | 'group', chatId: string, senderUserId: string): string {
563
476
  return chatType === 'p2p'
564
- ? `chat:p2p:${chatId}:${senderOpenId}`
565
- : `chat:group:${chatId}:${senderOpenId}`
477
+ ? `chat:p2p:${chatId}:${senderUserId}`
478
+ : `chat:group:${chatId}:${senderUserId}`
566
479
  }