@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.
- package/README.md +83 -57
- package/docs/access-control-design.md +8 -5
- package/docs/attachment-pipeline-v2.md +33 -41
- package/docs/feishu-onboarding.md +36 -33
- package/docs/persistence.md +6 -4
- package/docs/refactor-plan.md +1 -1
- package/docs/workspace-isolation-design.md +7 -8
- package/lib/client.js +444 -288
- package/lib/client.js.map +1 -1
- package/lib/index.js +9017 -6794
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/access/cards.ts +9 -9
- package/src/access/controller.ts +10 -10
- package/src/access/index.ts +1 -0
- package/src/access/policy.ts +10 -10
- package/src/access/requests.ts +32 -31
- package/src/access/store.ts +43 -13
- package/src/access/targets.ts +56 -0
- package/src/cards/approval.ts +96 -26
- package/src/cards/index.ts +1 -0
- package/src/cards/onboarding.ts +1 -0
- package/src/cards/picker.ts +108 -0
- package/src/cards/streaming.ts +56 -7
- package/src/cards/wiki.ts +124 -0
- package/src/client/api.ts +5 -25
- package/src/client/panel/access-card.tsx +24 -15
- package/src/client/panel/access-tab.tsx +3 -3
- package/src/client/panel/connection-tab.tsx +70 -1
- package/src/client/panel/index.tsx +11 -62
- package/src/client/panel/shared.tsx +96 -24
- package/src/client/panel/workspace-tab.tsx +2 -18
- package/src/client/string-list.ts +139 -0
- package/src/client/styles.ts +5 -0
- package/src/commands/registry.ts +8 -5
- package/src/config.ts +55 -54
- package/src/domain.ts +36 -123
- package/src/feishu/delivery.ts +34 -1
- package/src/feishu/inbound-attachments.ts +1 -1
- package/src/feishu/manifest.ts +8 -31
- package/src/feishu/message-read.ts +97 -24
- package/src/feishu/scopes.ts +194 -0
- package/src/feishu/send-file.ts +543 -0
- package/src/feishu/slash-commands.ts +134 -0
- package/src/feishu/tools.ts +72 -112
- package/src/feishu/wiki/api.ts +346 -0
- package/src/feishu/wiki/consent.ts +183 -0
- package/src/feishu/wiki/links.ts +122 -0
- package/src/feishu/wiki/scope.ts +66 -0
- package/src/feishu/wiki/tools.ts +984 -0
- package/src/feishu-channel.ts +280 -9
- package/src/index.ts +5 -4
- package/src/interactions/approvals.ts +187 -7
- package/src/interactions/pending.ts +3 -3
- package/src/routing/inbound.ts +73 -106
- package/src/service.ts +545 -440
- package/src/session/manager.ts +1 -1
- package/src/session/routes.ts +2 -3
- package/src/settings/contract.ts +110 -89
- package/src/settings/routes.ts +3 -17
- package/src/settings/surface.ts +144 -94
- package/src/streamer.ts +94 -5
- package/src/workspaces.ts +21 -29
- package/src/cleanup.ts +0 -546
- package/src/client/panel/cleanup-card.tsx +0 -106
- 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
|
+
}
|
package/src/client/styles.ts
CHANGED
|
@@ -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}
|
package/src/commands/registry.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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:
|
|
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
|
|
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 {
|
|
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
|
-
*
|
|
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
|
-
*
|
|
339
|
-
*
|
|
340
|
-
*
|
|
341
|
-
*
|
|
342
|
-
*
|
|
343
|
-
*
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
|
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.
|
|
379
|
-
*
|
|
380
|
-
*
|
|
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
|
-
|
|
383
|
-
|
|
384
|
-
'must be a Feishu
|
|
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
|
|
45
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 (`
|
|
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
|
|
220
|
-
|
|
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
|
|
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; `
|
|
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
|
-
|
|
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: '
|
|
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
|
-
|
|
405
|
-
valueSchema: typeof
|
|
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
|
-
|
|
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
|
-
|
|
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 = `
|
|
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
|
-
|
|
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,
|
|
475
|
+
export function chatRouteKey(chatType: 'p2p' | 'group', chatId: string, senderUserId: string): string {
|
|
563
476
|
return chatType === 'p2p'
|
|
564
|
-
? `chat:p2p:${chatId}:${
|
|
565
|
-
: `chat:group:${chatId}:${
|
|
477
|
+
? `chat:p2p:${chatId}:${senderUserId}`
|
|
478
|
+
: `chat:group:${chatId}:${senderUserId}`
|
|
566
479
|
}
|