@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.
- package/README.md +302 -2
- package/cordis.patch.yml +3 -0
- package/docs/access-control-design.md +550 -0
- package/docs/assets/web-settings-panel.png +0 -0
- package/docs/assets/web-settings-restart-pending.png +0 -0
- package/docs/feasibility-and-design.md +213 -0
- package/docs/feishu-onboarding.md +268 -0
- package/docs/persistence.md +199 -0
- package/docs/refactor-plan.md +56 -0
- package/docs/review-dsh-0.2.0.md +389 -0
- package/docs/slash-commands-implementation-plan.md +410 -0
- package/docs/web-display-only-surface-roadmap.md +363 -0
- package/lib/client.js +2226 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +10217 -0
- package/lib/index.js.map +1 -0
- package/package.json +98 -4
- package/src/access/cards.ts +188 -0
- package/src/access/controller.ts +201 -0
- package/src/access/index.ts +13 -0
- package/src/access/policy.ts +103 -0
- package/src/access/requests.ts +512 -0
- package/src/access/store.ts +220 -0
- package/src/approvals.ts +8 -0
- package/src/cards/approval.ts +114 -0
- package/src/cards/index.ts +11 -0
- package/src/cards/primitives.ts +82 -0
- package/src/cards/question.ts +141 -0
- package/src/cards/streaming.ts +563 -0
- package/src/cards.ts +8 -0
- package/src/cleanup.ts +398 -0
- package/src/client/api.ts +262 -0
- package/src/client/catalog.ts +92 -0
- package/src/client/index.ts +61 -0
- package/src/client/panel.tsx +1475 -0
- package/src/client/styles.ts +98 -0
- package/src/commands/registry.ts +155 -0
- package/src/commands.ts +335 -0
- package/src/config.ts +206 -0
- package/src/domain.ts +428 -0
- package/src/dsh-types.ts +11 -0
- package/src/feishu/agent-context.ts +62 -0
- package/src/feishu/delivery.ts +188 -0
- package/src/feishu/inbound-attachments.ts +436 -0
- package/src/feishu-channel.ts +339 -0
- package/src/health.ts +233 -0
- package/src/host/agent.ts +181 -0
- package/src/host/commands.ts +63 -0
- package/src/host/core.ts +345 -0
- package/src/host/index.ts +12 -0
- package/src/host/interaction.ts +101 -0
- package/src/host/services.ts +320 -0
- package/src/index.ts +122 -0
- package/src/interactions/approvals.ts +154 -0
- package/src/interactions/pending.ts +162 -0
- package/src/interactions/questions.ts +274 -0
- package/src/questions.ts +8 -0
- package/src/routing/inbound.ts +524 -0
- package/src/service.ts +2138 -0
- package/src/session/manager.ts +352 -0
- package/src/session/repair.ts +157 -0
- package/src/session/request-policy.ts +192 -0
- package/src/session/routes.ts +165 -0
- package/src/session-commands.ts +310 -0
- package/src/session-export.ts +112 -0
- package/src/settings/catalog.ts +309 -0
- package/src/settings/contract.ts +508 -0
- package/src/settings/overlay.ts +164 -0
- package/src/settings/routes.ts +409 -0
- package/src/settings/surface.ts +773 -0
- package/src/stats.ts +485 -0
- package/src/streamer.ts +1108 -0
- package/src/workspaces.ts +199 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace directory resolution: one DSH workspace per (user × chat).
|
|
3
|
+
*
|
|
4
|
+
* Every Feishu topic gets its own workspace, grouped by the (chat, user) pair:
|
|
5
|
+
* - p2p: `<baseDir>/p2p/<open_id>/<topic>/`
|
|
6
|
+
* - group: `<baseDir>/groups/<chat_id>/<open_id>/<topic>/`
|
|
7
|
+
*
|
|
8
|
+
* Attachments received before a topic exists are staged below
|
|
9
|
+
* `<baseDir>/_staging/<chatType>/<chat_id>/<open_id>/` and adopted into the
|
|
10
|
+
* topic workspace once its first Session is created.
|
|
11
|
+
*
|
|
12
|
+
* Feishu ids (`ou_…`, `oc_…`) are safe path segments, but they are validated
|
|
13
|
+
* anyway so a hostile id can never escape `baseDir`.
|
|
14
|
+
*
|
|
15
|
+
* @module dsh-channels/workspaces
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { constants } from 'node:fs'
|
|
19
|
+
import { access, mkdir, stat } from 'node:fs/promises'
|
|
20
|
+
import { homedir } from 'node:os'
|
|
21
|
+
import path from 'node:path'
|
|
22
|
+
import type { HostContext, Workspace } from './dsh-types.ts'
|
|
23
|
+
|
|
24
|
+
const SAFE_SEGMENT = /^[A-Za-z0-9._-]+$/
|
|
25
|
+
|
|
26
|
+
/** Upper bound for a panel-supplied workspace root (filesystem path limit headroom). */
|
|
27
|
+
const MAX_BASE_DIR_LENGTH = 4096
|
|
28
|
+
|
|
29
|
+
/** Lexical validation result for a workspace root. */
|
|
30
|
+
export type BaseDirCheck =
|
|
31
|
+
| { ok: true; value: string }
|
|
32
|
+
| { ok: false; message: string }
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Validate and normalize a workspace root supplied by the settings panel.
|
|
36
|
+
*
|
|
37
|
+
* The profile layer keeps accepting whatever the deployment wrote (including
|
|
38
|
+
* the historical relative default), but a value *typed into the Web panel*
|
|
39
|
+
* must name an unambiguous location: a relative path would resolve against
|
|
40
|
+
* the host process's unpredictable cwd. `~` is expanded for convenience and
|
|
41
|
+
* the result is normalized; the root itself is rejected because workspace
|
|
42
|
+
* directories are appended to it.
|
|
43
|
+
*
|
|
44
|
+
* @param raw - untrusted panel value.
|
|
45
|
+
* @param home - home directory seam (tests pin it; production uses `homedir()`).
|
|
46
|
+
* @returns the normalized absolute path, or a user-facing message.
|
|
47
|
+
*/
|
|
48
|
+
export function normalizeWorkspaceBaseDir(raw: unknown, home: string = homedir()): BaseDirCheck {
|
|
49
|
+
if (typeof raw !== 'string') return { ok: false, message: '必须是字符串' }
|
|
50
|
+
const trimmed = raw.trim()
|
|
51
|
+
if (trimmed === '') return { ok: false, message: '不能为空' }
|
|
52
|
+
if (trimmed.length > MAX_BASE_DIR_LENGTH) {
|
|
53
|
+
return { ok: false, message: `路径长度不得超过 ${MAX_BASE_DIR_LENGTH} 个字符` }
|
|
54
|
+
}
|
|
55
|
+
// eslint-disable-next-line no-control-regex
|
|
56
|
+
if (/[\u0000-\u001f\u007f]/.test(trimmed)) return { ok: false, message: '包含非法控制字符' }
|
|
57
|
+
let expanded = trimmed
|
|
58
|
+
if (expanded === '~') {
|
|
59
|
+
expanded = home
|
|
60
|
+
} else if (expanded.startsWith('~/') || expanded.startsWith('~\\')) {
|
|
61
|
+
expanded = path.join(home, expanded.slice(2))
|
|
62
|
+
}
|
|
63
|
+
// Check absoluteness before resolving: `path.resolve` would silently turn a
|
|
64
|
+
// relative panel value into a cwd-dependent absolute path.
|
|
65
|
+
if (!path.isAbsolute(expanded)) {
|
|
66
|
+
return { ok: false, message: '必须是绝对路径(例如 /Users/you/.dsh/dsh-channels/workspaces)' }
|
|
67
|
+
}
|
|
68
|
+
const normalized = path.resolve(expanded)
|
|
69
|
+
if (path.parse(normalized).root === normalized) {
|
|
70
|
+
return { ok: false, message: '不能使用文件系统根目录' }
|
|
71
|
+
}
|
|
72
|
+
return { ok: true, value: normalized }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Materialize one panel-supplied workspace root and prove it is usable.
|
|
77
|
+
*
|
|
78
|
+
* Called when the panel saves `baseDir` so an unusable location fails at save
|
|
79
|
+
* time instead of surfacing later as "会话创建失败" inside a Feishu chat.
|
|
80
|
+
* The directory is created here (the same `mkdir -p` the first session would
|
|
81
|
+
* perform); an existing non-directory is refused.
|
|
82
|
+
*
|
|
83
|
+
* @returns an error message, or `undefined` when the directory is writable.
|
|
84
|
+
*/
|
|
85
|
+
export async function prepareWorkspaceBaseDir(dir: string): Promise<string | undefined> {
|
|
86
|
+
try {
|
|
87
|
+
await mkdir(dir, { recursive: true })
|
|
88
|
+
} catch (error) {
|
|
89
|
+
return `无法创建工作区目录:${String(error)}`
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
const info = await stat(dir)
|
|
93
|
+
if (!info.isDirectory()) return '该路径已存在但不是目录'
|
|
94
|
+
} catch (error) {
|
|
95
|
+
return `无法读取工作区目录:${String(error)}`
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
await access(dir, constants.W_OK)
|
|
99
|
+
} catch {
|
|
100
|
+
return '工作区目录不可写'
|
|
101
|
+
}
|
|
102
|
+
return undefined
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function safeSegment(raw: string, what: string): string {
|
|
106
|
+
const segment = raw.replace(/^\/+|\/+$/g, '')
|
|
107
|
+
if (!SAFE_SEGMENT.test(segment) || segment === '.' || segment === '..') {
|
|
108
|
+
throw new Error(`dsh-channels: unsafe ${what} for workspace path: ${JSON.stringify(raw)}`)
|
|
109
|
+
}
|
|
110
|
+
return segment
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Stable, filesystem-safe token for one Feishu conversation: a real topic uses
|
|
115
|
+
* its thread id, while a degraded chat-scoped conversation uses the Session id
|
|
116
|
+
* that owns it (there is no topic boundary to key on).
|
|
117
|
+
*/
|
|
118
|
+
export function topicTokenFor(
|
|
119
|
+
routeKey: string,
|
|
120
|
+
kind: 'thread' | 'chat',
|
|
121
|
+
sessionId: string,
|
|
122
|
+
): string {
|
|
123
|
+
return kind === 'thread' ? safeSegment(routeKey, 'route key') : safeSegment(sessionId, 'session id')
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Deterministic workspace directory for one Feishu topic: every route gets its
|
|
128
|
+
* own workspace below its (user × chat) grouping directory.
|
|
129
|
+
*
|
|
130
|
+
* - p2p: `<baseDir>/topics/p2p/<open_id>/<topic>/`
|
|
131
|
+
* - group: `<baseDir>/topics/groups/<chat_id>/<open_id>/<topic>/`
|
|
132
|
+
*
|
|
133
|
+
* A separate `topics/` root keeps new workspaces out of the legacy (user ×
|
|
134
|
+
* chat) workspace directories left by older versions, so cleaning one can
|
|
135
|
+
* never delete the other.
|
|
136
|
+
*/
|
|
137
|
+
export function workspacePathFor(
|
|
138
|
+
baseDir: string,
|
|
139
|
+
chatType: 'p2p' | 'group',
|
|
140
|
+
chatId: string,
|
|
141
|
+
senderOpenId: string,
|
|
142
|
+
topicToken: string,
|
|
143
|
+
): string {
|
|
144
|
+
const chat = safeSegment(chatId, 'chatId')
|
|
145
|
+
const user = safeSegment(senderOpenId, 'senderOpenId')
|
|
146
|
+
const topic = safeSegment(topicToken, 'topic token')
|
|
147
|
+
return chatType === 'p2p'
|
|
148
|
+
? path.join(baseDir, 'topics', 'p2p', user, topic)
|
|
149
|
+
: path.join(baseDir, 'topics', 'groups', chat, user, topic)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Staging directory for attachments that arrive before their topic (and so
|
|
154
|
+
* its workspace) exists. Not a registered workspace: files move into the real
|
|
155
|
+
* workspace as soon as the first Session of that topic is created.
|
|
156
|
+
*/
|
|
157
|
+
export function stagingPathFor(
|
|
158
|
+
baseDir: string,
|
|
159
|
+
chatType: 'p2p' | 'group',
|
|
160
|
+
chatId: string,
|
|
161
|
+
senderOpenId: string,
|
|
162
|
+
): string {
|
|
163
|
+
const chat = safeSegment(chatId, 'chatId')
|
|
164
|
+
const user = safeSegment(senderOpenId, 'senderOpenId')
|
|
165
|
+
return path.join(baseDir, '_staging', chatType, chat, user)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Human-friendly workspace title shown in the DSH workspace list. */
|
|
169
|
+
export function workspaceTitleFor(
|
|
170
|
+
chatType: 'p2p' | 'group',
|
|
171
|
+
chatId: string,
|
|
172
|
+
senderOpenId: string,
|
|
173
|
+
topicToken?: string,
|
|
174
|
+
): string {
|
|
175
|
+
const base = chatType === 'p2p'
|
|
176
|
+
? `飞书私聊 · ${senderOpenId}`
|
|
177
|
+
: `飞书群聊 · ${chatId} · ${senderOpenId}`
|
|
178
|
+
return topicToken === undefined ? base : `${base} · ${topicToken}`
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Ensure one topic's workspace directory exists and is registered with the
|
|
183
|
+
* host's workspace registry. Idempotent: repeated calls return the same record.
|
|
184
|
+
*/
|
|
185
|
+
export async function ensureWorkspace(
|
|
186
|
+
ctx: HostContext,
|
|
187
|
+
baseDir: string,
|
|
188
|
+
chatType: 'p2p' | 'group',
|
|
189
|
+
chatId: string,
|
|
190
|
+
senderOpenId: string,
|
|
191
|
+
topicToken: string,
|
|
192
|
+
): Promise<Workspace> {
|
|
193
|
+
const dir = workspacePathFor(baseDir, chatType, chatId, senderOpenId, topicToken)
|
|
194
|
+
await mkdir(dir, { recursive: true })
|
|
195
|
+
return ctx.workspaceRegistry.create(
|
|
196
|
+
dir,
|
|
197
|
+
workspaceTitleFor(chatType, chatId, senderOpenId, topicToken),
|
|
198
|
+
)
|
|
199
|
+
}
|