@sidleo3/dsh-chat 0.0.4

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 (47) hide show
  1. package/client/bot-list.js +243 -0
  2. package/client/bot-settings.js +175 -0
  3. package/client/bot-shared-settings.js +561 -0
  4. package/client/chat-ui.js +134 -0
  5. package/client/context-enhancement.js +435 -0
  6. package/client/delivery-targets.js +334 -0
  7. package/client/diagnostics.js +160 -0
  8. package/client/i18n.js +371 -0
  9. package/client/index.js +77 -0
  10. package/client/list-order.js +144 -0
  11. package/client/rpc.js +52 -0
  12. package/client/scoped-mode-editor.js +111 -0
  13. package/client/section.js +250 -0
  14. package/client/session-badges.js +263 -0
  15. package/client/styles.js +960 -0
  16. package/client/version-panel.js +97 -0
  17. package/cordis.patch.yml +5 -0
  18. package/host/bot-model.mjs +53 -0
  19. package/host/bot-settings.mjs +247 -0
  20. package/host/channel-registry.mjs +237 -0
  21. package/host/commands.mjs +857 -0
  22. package/host/deferred.mjs +291 -0
  23. package/host/delivery.mjs +377 -0
  24. package/host/file-log.mjs +169 -0
  25. package/host/guidance.mjs +73 -0
  26. package/host/index.mjs +7 -0
  27. package/host/interactions.mjs +330 -0
  28. package/host/json-store.mjs +144 -0
  29. package/host/log-tail.mjs +63 -0
  30. package/host/panel.mjs +1012 -0
  31. package/host/paths.mjs +50 -0
  32. package/host/plugin.mjs +873 -0
  33. package/host/prompt-context.mjs +70 -0
  34. package/host/rpc.mjs +147 -0
  35. package/host/session-keys.mjs +25 -0
  36. package/host/session-store.mjs +187 -0
  37. package/host/sessions.mjs +1348 -0
  38. package/host/tools.mjs +283 -0
  39. package/lib/client.js +4431 -0
  40. package/lib/index.js +5676 -0
  41. package/package.json +63 -0
  42. package/shared/access-policy.mjs +263 -0
  43. package/shared/channel-rail.mjs +156 -0
  44. package/shared/context-enhancement.mjs +415 -0
  45. package/shared/contract.mjs +120 -0
  46. package/shared/panel-sections.mjs +76 -0
  47. package/shared/reply-reference.mjs +115 -0
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "@sidleo3/dsh-chat",
3
+ "version": "0.0.4",
4
+ "type": "module",
5
+ "description": "DSH 聊天机器人管理入口:渠道注册表、共享内核与设置页入口(飞书/微信等渠道各自独立成插件)",
6
+ "keywords": [
7
+ "deepseek-harness",
8
+ "dsh",
9
+ "dsh-plugin",
10
+ "chat",
11
+ "chatbot"
12
+ ],
13
+ "license": "MIT",
14
+ "main": "./lib/index.js",
15
+ "exports": {
16
+ ".": "./lib/index.js",
17
+ "./client": "./lib/client.js",
18
+ "./package.json": "./package.json"
19
+ },
20
+ "files": [
21
+ "lib",
22
+ "shared",
23
+ "host",
24
+ "client",
25
+ "cordis.patch.yml",
26
+ "package.json"
27
+ ],
28
+ "dsh": {
29
+ "bundle": {
30
+ "patch": "./cordis.patch.yml"
31
+ },
32
+ "client": {
33
+ "inject": [
34
+ "@deepseek-ai/dsh-client-connection",
35
+ "@deepseek-ai/dsh-client-ui-settings",
36
+ "@deepseek-ai/dsh-client-ui-slots",
37
+ "@deepseek-ai/dsh-client-locale"
38
+ ],
39
+ "platform": "web"
40
+ },
41
+ "compatibility": {
42
+ "dsh": "0.1.5-rc.2",
43
+ "profiles": [
44
+ "web"
45
+ ]
46
+ }
47
+ },
48
+ "scripts": {
49
+ "build": "node ../../scripts/build-all.mjs"
50
+ },
51
+ "publishConfig": {
52
+ "access": "public"
53
+ },
54
+ "repository": {
55
+ "type": "git",
56
+ "url": "git+https://github.com/sidleo/dsh-chat.git",
57
+ "directory": "packages/dsh-chat"
58
+ },
59
+ "homepage": "https://github.com/sidleo/dsh-chat#readme",
60
+ "bugs": {
61
+ "url": "https://github.com/sidleo/dsh-chat/issues"
62
+ }
63
+ }
@@ -0,0 +1,263 @@
1
+ /**
2
+ * 访问策略:谁能跟机器人说话、谁能执行命令。
3
+ *
4
+ * 数据结构与 dsh-im 的 `workspaces.json → accessPolicies[botId]` 完全一致,
5
+ * 因此从旧实现导入的策略可以直接用(`mode: open | allowlist`)。
6
+ *
7
+ * 判定语义(调用方负责"属主绕过"):
8
+ * - `open`:任何人可对话;命令权限取"命中的 per-user 覆盖",没有覆盖则取
9
+ * `open.defaultCanExecuteCommands`;
10
+ * - `allowlist`:只有名单内用户可对话;命令权限要求命中的用户全部允许。
11
+ *
12
+ * 浏览器安全:host 与设置页共用这份实现。
13
+ *
14
+ * @module dsh-chat/shared/access-policy
15
+ */
16
+
17
+ export const ACCESS_POLICY_MODES = Object.freeze(['open', 'allowlist']);
18
+ export const ACCESS_CONVERSATION_TYPES = Object.freeze(['direct', 'group']);
19
+
20
+ const USER_ID_MAX_LENGTH = 256;
21
+ const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g;
22
+ const CONTROL_CHARACTER_TEST = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/;
23
+
24
+ /** 判定结果码。 */
25
+ export const ACCESS_RESULTS = Object.freeze({
26
+ OWNER: 'owner',
27
+ OPEN: 'open',
28
+ ALLOWLIST: 'allowlist',
29
+ NOT_LISTED: 'sender-not-allowed',
30
+ COMMAND_DENIED: 'command-not-allowed',
31
+ NO_POLICY: 'no-policy',
32
+ INVALID: 'invalid-context',
33
+ });
34
+
35
+ function invalid(message) {
36
+ const error = new TypeError(message);
37
+ error.code = 'access-policy-invalid';
38
+ return error;
39
+ }
40
+
41
+ function isPlainObject(value) {
42
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
43
+ const prototype = Object.getPrototypeOf(value);
44
+ return prototype === Object.prototype || prototype === null;
45
+ }
46
+
47
+ function hasExactKeys(input, keys) {
48
+ return isPlainObject(input)
49
+ && Reflect.ownKeys(input).length === keys.length
50
+ && keys.every((key) => Object.hasOwn(input, key));
51
+ }
52
+
53
+ function normalizeUserId(value) {
54
+ if (typeof value === 'number' && Number.isFinite(value)) value = String(value);
55
+ if (typeof value !== 'string') throw invalid('用户标识必须是字符串。');
56
+ const normalized = value.replace(CONTROL_CHARACTERS, '').trim();
57
+ if (!normalized || normalized.length > USER_ID_MAX_LENGTH) throw invalid('用户标识无效。');
58
+ return normalized;
59
+ }
60
+
61
+ function validateUser(input) {
62
+ if (!hasExactKeys(input, ['id', 'canExecuteCommands'])) throw invalid('白名单条目格式不正确。');
63
+ if (typeof input.canExecuteCommands !== 'boolean') throw invalid('命令权限必须是布尔值。');
64
+ return Object.freeze({
65
+ id: normalizeUserId(input.id),
66
+ canExecuteCommands: input.canExecuteCommands,
67
+ });
68
+ }
69
+
70
+ function validateScope(input) {
71
+ if (!hasExactKeys(input, ['mode', 'open', 'allowlist'])) throw invalid('访问策略缺少字段。');
72
+ if (!ACCESS_POLICY_MODES.includes(input.mode)) throw invalid('访问模式只能是 open 或 allowlist。');
73
+ if (!hasExactKeys(input.open, ['defaultCanExecuteCommands', 'commandPermissionOverrides'])) {
74
+ throw invalid('open 段格式不正确。');
75
+ }
76
+ if (typeof input.open.defaultCanExecuteCommands !== 'boolean') {
77
+ throw invalid('默认命令权限必须是布尔值。');
78
+ }
79
+ if (!Array.isArray(input.open.commandPermissionOverrides)
80
+ || !Array.isArray(input.allowlist?.users)) {
81
+ throw invalid('访问策略的名单必须是数组。');
82
+ }
83
+ return Object.freeze({
84
+ mode: input.mode,
85
+ open: Object.freeze({
86
+ defaultCanExecuteCommands: input.open.defaultCanExecuteCommands,
87
+ commandPermissionOverrides: Object.freeze(input.open.commandPermissionOverrides.map(validateUser)),
88
+ }),
89
+ allowlist: Object.freeze({ users: Object.freeze(input.allowlist.users.map(validateUser)) }),
90
+ });
91
+ }
92
+
93
+ /**
94
+ * 属主名单里的通配符。
95
+ *
96
+ * 历史配置(上游 dsh-im 绑定时没记录属主)会写成 `['*']`。它表示**这台机器人没有属主**
97
+ * (公开机器人),**不是**"人人都是属主"——后者会让策略引擎第一步的属主绕过生效,
98
+ * 于是这台机器人的访问策略完全失效:名单里没有的人也能照常使用(真机上出现过)。
99
+ */
100
+ export const OWNER_WILDCARD = '*';
101
+
102
+ /** @returns 该机器人是否"公开"(属主名单里只有通配符)。 */
103
+ export function hasWildcardOwner(ownerIds) {
104
+ return Array.isArray(ownerIds) && ownerIds.includes(OWNER_WILDCARD);
105
+ }
106
+
107
+ /**
108
+ * 判定发送者是否是属主(属主由调用方绕过访问策略)。
109
+ *
110
+ * 通配符**不算属主**:它只说明"没记录属主",不授权任何人绕过策略。
111
+ *
112
+ * @param ownerIds - 该应用的属主 id 名单。
113
+ * @param senderId - 发送者在**该应用**里的平台 id。
114
+ * @returns true 表示属主。
115
+ */
116
+ export function isOwnerId(ownerIds, senderId) {
117
+ if (!Array.isArray(ownerIds) || typeof senderId !== 'string' || !senderId) return false;
118
+ return ownerIds.some((id) => id !== OWNER_WILDCARD && id === senderId);
119
+ }
120
+
121
+ /**
122
+ * 严格校验一份完整策略(保存路径用)。
123
+ *
124
+ * @param input - `{ direct, group }`。
125
+ * @returns 冻结后的策略。
126
+ */
127
+ export function validateAccessPolicy(input) {
128
+ if (!hasExactKeys(input, ['direct', 'group'])) throw invalid('请提交完整的访问策略。');
129
+ return Object.freeze({
130
+ direct: validateScope(input.direct),
131
+ group: validateScope(input.group),
132
+ });
133
+ }
134
+
135
+ /**
136
+ * 容错归一化(运行路径用)。
137
+ *
138
+ * 关键取舍:**不完整的历史数据不能被判成"谁都进不来"**——那等于把机器人锁死,
139
+ * 而且现象是"发了没反应",极难排查。因此这里对缺字段做保守填充:
140
+ * - 缺失/非法的 mode → `allowlist`(保守方向);
141
+ * - 缺失的名单 → 空名单(配合属主绕过,等价于"仅属主可用");
142
+ * - 无法识别的条目直接丢弃。
143
+ * 只有"压根没有策略对象"才返回 null(调用方回落"仅属主")。
144
+ *
145
+ * @param input - 任意历史数据。
146
+ * @returns 合法策略或 null。
147
+ */
148
+ export function normalizeAccessPolicy(input) {
149
+ if (!isPlainObject(input)) return null;
150
+ const scopeOf = (value) => {
151
+ const source = isPlainObject(value) ? value : {};
152
+ const open = isPlainObject(source.open) ? source.open : {};
153
+ const allowlist = isPlainObject(source.allowlist) ? source.allowlist : {};
154
+ const usersOf = (value2) => (Array.isArray(value2)
155
+ ? value2.map((user) => {
156
+ try {
157
+ return validateUser(user);
158
+ } catch {
159
+ return null;
160
+ }
161
+ }).filter(Boolean)
162
+ : []);
163
+ return {
164
+ mode: ACCESS_POLICY_MODES.includes(source.mode) ? source.mode : 'allowlist',
165
+ open: {
166
+ defaultCanExecuteCommands: open.defaultCanExecuteCommands === true,
167
+ commandPermissionOverrides: usersOf(open.commandPermissionOverrides),
168
+ },
169
+ allowlist: { users: usersOf(allowlist.users) },
170
+ };
171
+ };
172
+ return Object.freeze({
173
+ direct: Object.freeze(scopeOf(input.direct)),
174
+ group: Object.freeze(scopeOf(input.group)),
175
+ });
176
+ }
177
+
178
+ /**
179
+ * 默认策略:私聊/群聊都只允许名单内用户,且默认不允许命令。
180
+ *
181
+ * @returns 冻结的默认策略。
182
+ */
183
+ export function defaultAccessPolicy() {
184
+ const scope = () => ({
185
+ mode: 'allowlist',
186
+ open: { defaultCanExecuteCommands: false, commandPermissionOverrides: [] },
187
+ allowlist: { users: [] },
188
+ });
189
+ return validateAccessPolicy({ direct: scope(), group: scope() });
190
+ }
191
+
192
+ /**
193
+ * 判定一条消息(或一条命令)是否放行。
194
+ *
195
+ * @param options - {
196
+ * policy, conversationType, senderIds, isCommand?, isOwner?,
197
+ * }。
198
+ * @returns `{ allowed, reason }`。
199
+ */
200
+ export function evaluateAccess({
201
+ policy,
202
+ conversationType,
203
+ senderIds,
204
+ isCommand = false,
205
+ isOwner = false,
206
+ } = {}) {
207
+ if (isOwner) return { allowed: true, reason: ACCESS_RESULTS.OWNER };
208
+ if (!ACCESS_CONVERSATION_TYPES.includes(conversationType)) {
209
+ return { allowed: false, reason: ACCESS_RESULTS.INVALID };
210
+ }
211
+ const normalized = normalizeAccessPolicy(policy);
212
+ if (!normalized) return { allowed: false, reason: ACCESS_RESULTS.NO_POLICY };
213
+
214
+ const candidates = (Array.isArray(senderIds) ? senderIds : [senderIds])
215
+ .map((candidate) => {
216
+ try {
217
+ return normalizeUserId(candidate);
218
+ } catch {
219
+ return null;
220
+ }
221
+ })
222
+ .filter(Boolean);
223
+ if (candidates.length === 0) return { allowed: false, reason: ACCESS_RESULTS.NOT_LISTED };
224
+
225
+ const scope = normalized[conversationType];
226
+ const users = scope.mode === 'open' ? scope.open.commandPermissionOverrides : scope.allowlist.users;
227
+ const matched = users.filter((user) => candidates.includes(user.id));
228
+
229
+ if (scope.mode === 'allowlist' && matched.length === 0) {
230
+ return { allowed: false, reason: ACCESS_RESULTS.NOT_LISTED };
231
+ }
232
+ if (isCommand) {
233
+ const canExecute = scope.mode === 'open'
234
+ ? (matched.length > 0
235
+ ? matched.every((user) => user.canExecuteCommands)
236
+ : scope.open.defaultCanExecuteCommands)
237
+ : matched.every((user) => user.canExecuteCommands);
238
+ if (!canExecute) return { allowed: false, reason: ACCESS_RESULTS.COMMAND_DENIED };
239
+ }
240
+ return {
241
+ allowed: true,
242
+ reason: scope.mode === 'open' ? ACCESS_RESULTS.OPEN : ACCESS_RESULTS.ALLOWLIST,
243
+ };
244
+ }
245
+
246
+ /**
247
+ * 一句话描述某个作用域当前放行谁(设置页与命令输出用)。
248
+ *
249
+ * @param policy - 策略(任意历史数据)。
250
+ * @param conversationType - 'direct' | 'group'。
251
+ * @returns 中文描述。
252
+ */
253
+ export function describeAccessScope(policy, conversationType) {
254
+ const normalized = normalizeAccessPolicy(policy);
255
+ if (!normalized) return '未设置(仅属主可用)';
256
+ const scope = normalized[conversationType];
257
+ if (scope.mode === 'open') {
258
+ return `任何人可用(命令默认${scope.open.defaultCanExecuteCommands ? '允许' : '不允许'})`;
259
+ }
260
+ const count = scope.allowlist.users.length;
261
+ // 名单为空 + allowlist 模式下,只有属主能对话(属主由调用方绕过)。
262
+ return count === 0 ? '仅属主可用' : `名单内 ${count} 人可用`;
263
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * 渠道 rail(client 侧):已安装渠道插件的元数据注册表。
3
+ *
4
+ * 框架无关的纯实现,便于 node 测试直接驱动。UI 用 `useSyncExternalStore` 订阅,
5
+ * 因此渠道包晚于 hub 加载、或运行中被卸载时左栏都能实时反映。
6
+ *
7
+ * @module dsh-chat/shared/channel-rail
8
+ */
9
+
10
+ import { CHANNEL_ID_PATTERN } from './contract.mjs';
11
+
12
+ function isPlainObject(value) {
13
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
14
+ }
15
+
16
+ /**
17
+ * 创建一个渠道元数据注册表。
18
+ *
19
+ * @returns 注册表:register / entries / get / subscribe / getSnapshot / size。
20
+ */
21
+ /**
22
+ * 取渠道图标的可用 URI(`uri` 优先,其次把 `svg` 编码成 data URI)。
23
+ *
24
+ * @param icon - 渠道定义里的 `icon`。
25
+ * @returns data URI 或 null。
26
+ */
27
+ export function channelIconUri(icon) {
28
+ if (!isPlainObject(icon)) return null;
29
+ if (typeof icon.uri === 'string' && icon.uri.startsWith('data:image/')) return icon.uri;
30
+ if (typeof icon.svg === 'string' && icon.svg.trim().startsWith('<svg')) {
31
+ return `data:image/svg+xml,${encodeURIComponent(icon.svg)}`;
32
+ }
33
+ return null;
34
+ }
35
+
36
+ export function createChannelRail() {
37
+ /** @type {Map<string, object>} */
38
+ const byId = new Map();
39
+ const listeners = new Set();
40
+ let snapshot = Object.freeze([]);
41
+
42
+ function refresh() {
43
+ snapshot = Object.freeze(
44
+ [...byId.values()].sort((left, right) => (
45
+ left.order === right.order ? left.id.localeCompare(right.id) : left.order - right.order
46
+ )),
47
+ );
48
+ for (const listener of [...listeners]) {
49
+ try {
50
+ listener();
51
+ } catch {
52
+ // 一个订阅者出错不能影响其他订阅者。
53
+ }
54
+ }
55
+ }
56
+
57
+ return {
58
+ /**
59
+ * 注册一个渠道的显示元数据。
60
+ *
61
+ * @param definition - { id, order, label, logo, icon, sessionBadge, capabilities }。
62
+ * `icon` = `{ svg }` 或 `{ uri }`:渠道图标(设置页卡片与侧边栏会话行共用同一份)。
63
+ * `sessionBadge` = `{ text, color }`:侧边栏会话行里显示的渠道徽标
64
+ * (没有会话行插槽,只能靠 hub 的 DOM 增强渲染,见 client/session-badges.js)。
65
+ * @returns 注销函数。
66
+ */
67
+ register(definition) {
68
+ if (!isPlainObject(definition)) throw new TypeError('chatChannels.register 需要一份渠道元数据对象。');
69
+ const { id, order, label, logo, icon, sessionBadge, capabilities } = definition;
70
+ if (typeof id !== 'string' || !CHANNEL_ID_PATTERN.test(id)) {
71
+ throw new TypeError('渠道 id 必须是 2–32 位小写字母/数字/连字符,且以字母开头。');
72
+ }
73
+ if (typeof label !== 'string' && typeof label !== 'function') {
74
+ throw new TypeError('渠道 label 必须是字符串或返回字符串的函数。');
75
+ }
76
+ if (!Number.isFinite(order)) throw new TypeError('渠道 order 必须是有限数字。');
77
+ if (capabilities !== undefined && !isPlainObject(capabilities)) {
78
+ throw new TypeError('渠道 capabilities 必须是对象。');
79
+ }
80
+ if (icon !== undefined) {
81
+ const hasSvg = isPlainObject(icon) && typeof icon.svg === 'string' && icon.svg.trim().startsWith('<svg');
82
+ const hasUri = isPlainObject(icon) && typeof icon.uri === 'string' && icon.uri.startsWith('data:image/');
83
+ if (!hasSvg && !hasUri) {
84
+ throw new TypeError('渠道 icon 需要 { svg: "<svg …>" } 或 { uri: "data:image/…" }。');
85
+ }
86
+ }
87
+ if (sessionBadge !== undefined) {
88
+ if (!isPlainObject(sessionBadge) || typeof sessionBadge.text !== 'string' || !sessionBadge.text) {
89
+ throw new TypeError('渠道 sessionBadge 需要 { text, color? }。');
90
+ }
91
+ }
92
+ if (byId.has(id)) throw new Error(`渠道 ${id} 已注册,不能重复注册。`);
93
+ const entry = Object.freeze({
94
+ id,
95
+ order,
96
+ label: typeof label === 'function' ? label : () => label,
97
+ logo: logo ?? null,
98
+ // 渠道图标:设置页左栏卡片与侧边栏会话行徽标共用。
99
+ // 官方标志常常是位图(favicon 提取),所以既支持 svg 字符串也支持 data URI。
100
+ icon: icon === undefined
101
+ ? null
102
+ : Object.freeze({
103
+ ...(typeof icon.svg === 'string' ? { svg: icon.svg } : {}),
104
+ ...(typeof icon.uri === 'string' ? { uri: icon.uri } : {}),
105
+ }),
106
+ sessionBadge: sessionBadge === undefined
107
+ ? null
108
+ : Object.freeze({ text: sessionBadge.text, color: sessionBadge.color ?? null }),
109
+ capabilities: Object.freeze({ ...(capabilities ?? {}) }),
110
+ });
111
+ byId.set(id, entry);
112
+ refresh();
113
+ return () => {
114
+ // 只注销本次注册写入的那一条:卸载后又被重新注册的同名渠道不能被误删。
115
+ if (byId.get(id) !== entry) return;
116
+ byId.delete(id);
117
+ refresh();
118
+ };
119
+ },
120
+
121
+ /** @returns 当前渠道列表(按 order 排序的冻结数组)。 */
122
+ entries() {
123
+ return snapshot;
124
+ },
125
+
126
+ /** @returns 快照,供 useSyncExternalStore 使用。 */
127
+ getSnapshot() {
128
+ return snapshot;
129
+ },
130
+
131
+ /**
132
+ * 订阅渠道列表变化。
133
+ *
134
+ * @param listener - 无参回调。
135
+ * @returns 取消订阅函数。
136
+ */
137
+ subscribe(listener) {
138
+ if (typeof listener !== 'function') throw new TypeError('chatChannels.subscribe 需要函数。');
139
+ listeners.add(listener);
140
+ return () => listeners.delete(listener);
141
+ },
142
+
143
+ /**
144
+ * @param id - 渠道 id。
145
+ * @returns 该渠道的元数据,未注册时为 undefined。
146
+ */
147
+ get(id) {
148
+ return byId.get(id);
149
+ },
150
+
151
+ /** @returns 已注册渠道数量。 */
152
+ get size() {
153
+ return byId.size;
154
+ },
155
+ };
156
+ }