@gavinc-cn/touchstone-dsh 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +39 -11
  2. package/README.zh.md +25 -10
  3. package/backend/lib/access.js +44 -0
  4. package/backend/lib/agents.js +341 -0
  5. package/backend/lib/args.js +94 -0
  6. package/backend/lib/auth.js +195 -0
  7. package/backend/lib/board.js +6154 -0
  8. package/backend/lib/builtin-assets.js +1177 -0
  9. package/backend/lib/casescan.js +456 -0
  10. package/backend/lib/chat.js +2053 -0
  11. package/backend/lib/cookie.js +76 -0
  12. package/backend/lib/db.js +2260 -0
  13. package/backend/lib/dshdriver.js +1148 -0
  14. package/backend/lib/dshevents.js +851 -0
  15. package/backend/lib/export-cases.js +261 -0
  16. package/backend/lib/feishu.js +1738 -0
  17. package/backend/lib/feishu_cfg.js +228 -0
  18. package/backend/lib/feishu_conv.js +1484 -0
  19. package/backend/lib/feishu_device.js +428 -0
  20. package/backend/lib/feishu_inbound.js +1674 -0
  21. package/backend/lib/feishu_provision.js +855 -0
  22. package/backend/lib/feishu_rest.js +398 -0
  23. package/backend/lib/feishu_slash.js +568 -0
  24. package/backend/lib/feishu_ws.js +950 -0
  25. package/backend/lib/http.js +312 -0
  26. package/backend/lib/lib.js +973 -0
  27. package/backend/lib/loadcase.js +749 -0
  28. package/backend/lib/loadgen.js +1407 -0
  29. package/backend/lib/localbus.js +164 -0
  30. package/backend/lib/lock.js +405 -0
  31. package/backend/lib/proc.js +278 -0
  32. package/backend/lib/prompts.js +1021 -0
  33. package/backend/lib/pybase64.js +114 -0
  34. package/backend/lib/pyjson.js +214 -0
  35. package/backend/lib/query.js +125 -0
  36. package/backend/lib/rag.js +1310 -0
  37. package/backend/lib/runner.js +4520 -0
  38. package/backend/lib/servercaps.js +182 -0
  39. package/backend/lib/serverstate.js +237 -0
  40. package/backend/lib/sessparse.js +1176 -0
  41. package/backend/lib/sse.js +207 -0
  42. package/backend/lib/static.js +84 -0
  43. package/backend/lib/taskjson.js +302 -0
  44. package/backend/lib/waitq.js +2114 -0
  45. package/backend/lib/worktree.js +588 -0
  46. package/backend/lib/zstd-frames.js +218 -0
  47. package/backend/routes/admin.js +553 -0
  48. package/backend/routes/agents.js +149 -0
  49. package/backend/routes/assets.js +145 -0
  50. package/backend/routes/board.js +1220 -0
  51. package/backend/routes/feishu.js +269 -0
  52. package/backend/routes/feishu_app.js +215 -0
  53. package/backend/routes/feishu_hook.js +358 -0
  54. package/backend/routes/index.js +297 -0
  55. package/backend/routes/projects.js +1059 -0
  56. package/backend/routes/sessions.js +946 -0
  57. package/backend/routes/tasks.js +1530 -0
  58. package/backend/schema/fixtures/ancient.sql +189 -0
  59. package/backend/schema/fixtures/pre_alter.sql +177 -0
  60. package/backend/schema/migrations.json +154 -0
  61. package/backend/schema/schema.json +1451 -0
  62. package/backend/schema/schema.sql +216 -0
  63. package/backend/server.js +498 -0
  64. package/board.py +805 -72
  65. package/chat.py +590 -17
  66. package/db.py +356 -140
  67. package/dsh-plugin/cordis.patch.yml +7 -3
  68. package/dsh-plugin/install.sh +28 -11
  69. package/dsh-plugin/lib/agent-driver.js +710 -89
  70. package/dsh-plugin/lib/client.bundle.js +94 -9
  71. package/dsh-plugin/lib/index.js +529 -39
  72. package/dsh-plugin/lib/pack-closure.mjs +291 -0
  73. package/dsh-plugin/scripts/prepack-check.mjs +30 -2
  74. package/dsh-plugin/src/client.js +94 -9
  75. package/dshdriver.py +74 -5
  76. package/dshevents.py +65 -3
  77. package/feishu.py +1197 -34
  78. package/feishu_conv.py +920 -0
  79. package/lifecycle.py +116 -13
  80. package/package.json +6 -1
  81. package/requirements.txt +2 -1
  82. package/server.py +218 -26
  83. package/sessparse.py +150 -44
  84. package/touchstone.py +6 -3
  85. package/waitq.py +23 -0
  86. package/webui/dist-plugin/assets/index-ChP3sg6y.css +1 -0
  87. package/webui/dist-plugin/assets/index-UrMyeAXF.js +484 -0
  88. package/webui/dist-plugin/index.html +2 -2
  89. package/webui/dist-plugin/assets/index-B0WoP1Qy.css +0 -1
  90. package/webui/dist-plugin/assets/index-DtwKoi1T.js +0 -473
@@ -0,0 +1,568 @@
1
+ /**
2
+ * 飞书斜杠指令族(M7-R4;`feishu.py:1194-1574` 的对应物)。
3
+ *
4
+ * 机制背景(照抄 `feishu.py:1186-1192` 的注释):飞书客户端输入「/」弹出的指令面板由开放平台
5
+ * **按应用维度**下发;应用用 OpenAPI 注册若干条指令(名称 + 说明 + 图标),用户选中即把
6
+ * 「/名称 [参数]」当普通文本消息发给机器人(走既有 im.message.receive_v1 长连接)。
7
+ * 注册/更新约 5 分钟服务端生效 + 客户端约 3 分钟缓存;PC 飞书 7.70+、移动 7.71+。
8
+ *
9
+ * 逐点对齐 oracle(行号即 `feishu.py`):
10
+ * - `SLASH_API`(:1194) / `SLASH_COMMANDS`(:1198,**17 条,逐条照抄含中文说明与 icon_key**);
11
+ * - `_SLASH_NAMES`(:1217) / `_SLASH_PERM_CODES`(:1219) / `SLASH_ICON_KEYS`(:1229,官方全表 72 键);
12
+ * - `slashErrorHint`(:1334) / `_needScope`(:1346);
13
+ * - `listSlashCommands`(:1351) / `createSlashCommand`(:1363) / `updateSlashCommand`(:1373) /
14
+ * `deleteSlashCommand`(:1380) / `_slashUpsert`(:1385);
15
+ * - `slashStatus`(:1412) / `syncSlashCommands`(:1433) / `clearSlashCommands`(:1468);
16
+ * - `slashPermCard`(:1504) / `sendSlashPermCard`(:1536)。
17
+ *
18
+ * 三条**不许「顺手改」**的语义:
19
+ * ① 同步**只增/改自己的**指令,**绝不删**用户的其他指令(`extra` 只报不动);
20
+ * ② `_slashUpsert` 的差异判定 = 说明**或图标**不一致即更新(图标现已证明可回读,严格判定
21
+ * 既幂等、又能自愈存量指令);创建撞名 `40000000` 时重取列表按 `command_id` 转更新,
22
+ * 使重复点击「注册/同步」必然收敛而不是报错;
23
+ * ③ 缺 scope 的三个业务码 `99991640/99991672/99991679` 走 `need_scope` 标记 + 权限引导文案
24
+ * (设置页据此提供「发飞书权限卡片」入口)。
25
+ *
26
+ * 与 Python 的形态差异(有意,逐条登记):
27
+ * - 全部 async;返回值 Promise。Python 是同步阻塞。
28
+ * - tuple → **冻结数组**、frozenset → `Set`(成员测试同构;`Object.freeze` 对 Set 无约束力,
29
+ * 别往里 add)。对拍按元素/集合比,别比容器类型。
30
+ * - `slash_upsert` 的 `str` 键 dict(`by_name`)⇒ 接受 **`Map` 或普通对象**(`nameGet` 兼容两者)。
31
+ * - `send_slash_perm_card` 返回 Python tuple ⇒ 这里返回**数组** `[ok, err]`(同 `db.js` 的
32
+ * `listFeishuUserCfgs` 对 `list[tuple]` 的处理)。
33
+ * - 末位多一个 `options = {db, dbPath, env}`(对齐 `feishu_cfg.js`/`db.js` 既有读口形态),
34
+ * 逐层透传给 `appConfig`/`getFeishuBindingByUser`。**不传就会落进缺省库路径**(生产库!),
35
+ * 调用方必传。
36
+ * - `sorted()` 用**码点比较**(Python 语义;JS 默认 `sort` 是 UTF-16 码元序,emoji 上分叉)。
37
+ *
38
+ * 零 npm 依赖(只用 `./feishu_rest.js`、`./feishu_cfg.js`、`./db.js`)。
39
+ */
40
+ import { getFeishuBindingByUser } from './db.js';
41
+ import { appConfig } from './feishu_cfg.js';
42
+ import {
43
+ FEISHU_DOMAIN, FeishuRestError, rest, restSendCard,
44
+ } from './feishu_rest.js';
45
+
46
+ /** 斜杠指令 OpenAPI 根路径(`feishu.py:1194`;`rest` 会再补一层 `/open-apis` 前缀判定) */
47
+ export const SLASH_API = '/open-apis/application/v7/app_slash_commands';
48
+
49
+ /**
50
+ * 冻结「元组表」的每一行(Python tuple of tuples 的对应物:保序、不可变、按元素比)。
51
+ * @param {any[][]} rows
52
+ * @returns {ReadonlyArray<ReadonlyArray<any>>}
53
+ */
54
+ function freezeRows(rows) {
55
+ return Object.freeze(rows.map((row) => Object.freeze(row)));
56
+ }
57
+
58
+ /**
59
+ * TS 期望指令清单(指令名, 中文说明, icon_key)。对齐 `feishu.SLASH_COMMANDS`(feishu.py:1198-1216):
60
+ * 指令名与 `_INTENT_RULES` 的 `/别名` 一一对应(**顺序即面板顺序**);`icon_key` **必须**取
61
+ * `SLASH_ICON_KEYS` 内的值,表外的键(含按语义自造的)创建即报业务码 40000031
62
+ * (2026-10-09 真机取证:自造的 `add_outlined`/`link_outlined` 让同步停在 15/17 条)。
63
+ */
64
+ export const SLASH_COMMANDS = freezeRows([
65
+ ['help', '查看可用指令', 'skill_outlined'],
66
+ ['bind', '绑定站点账号:/bind 绑定码', 'member_outlined'],
67
+ ['unbind', '解除站点账号绑定', 'clear_outlined'],
68
+ ['project', '设置默认项目:/project 项目名', 'home_outlined'],
69
+ ['current', '查看默认项目与当前会话', 'home_outlined'],
70
+ ['projects', '项目总览卡:点选设为默认项目', 'database_outlined'],
71
+ ['sessions', '默认项目最近会话:点选绑定', 'chat_outlined'],
72
+ ['new', '在默认项目下新建会话并绑定:/new [标题]', 'add-chat-ai_outlined'],
73
+ ['use', '绑定已有会话:/use 序号|关键字|会话id前缀', 'global-link_outlined'],
74
+ ['leave', '解绑当前会话(保留默认项目)', 'clear_outlined'],
75
+ ['status', '查看任务与看板摘要:/status [项目名]', 'database_outlined'],
76
+ ['cards', '查找卡片:/cards 关键字(id 前缀 / 标题包含)', 'codeblock_outlined'],
77
+ ['approve', '通过待审核卡片:/approve 卡片', 'flag_outlined'],
78
+ ['reject', '驳回卡片继续开发:/reject 卡片 意见', 'edit_outlined'],
79
+ ['answer', '回复 agent 提问:/answer 卡片 选项序号|文字', 'chat_outlined'],
80
+ ['agree', '批准挂起的工具审批:/agree 卡片 [session]', 'sent_outlined'],
81
+ ['deny', '拒绝挂起的工具审批:/deny 卡片', 'ai-block_outlined'],
82
+ ]);
83
+
84
+ /** 指令名元组(`feishu._SLASH_NAMES`(:1217);判「是不是 TS 自己的指令」用) */
85
+ export const _SLASH_NAMES = Object.freeze(SLASH_COMMANDS.map((row) => row[0]));
86
+
87
+ /** 缺权限/scope 类业务码(`feishu.py:1219`:99991640 lacks permission;另两码同为 scope 相关) */
88
+ export const _SLASH_PERM_CODES = Object.freeze([99991640, 99991672, 99991679]);
89
+
90
+ /** 缺「应用指令」权限的中文引导(`feishu._SLASH_PERM_HINT`:1220-1221,逐字照抄) */
91
+ const SLASH_PERM_HINT = '应用缺少「应用指令」权限:飞书开发者后台 → 权限管理,添加 '
92
+ + 'application:app_slash_command:read 与 write,创建并发布新版本后重试';
93
+
94
+ /** icon_key 非法的中文引导(`feishu._SLASH_ICON_HINT`:1222,逐字照抄) */
95
+ const SLASH_ICON_HINT = '指令图标 icon_key 不合法(取值见官方 Slash Command Icon Key 说明)';
96
+
97
+ /**
98
+ * 官方「Slash Command Icon Key 说明」全量可选值(`feishu.SLASH_ICON_KEYS`:1229-1254,共 72 个,
99
+ * 2026-10-09 抄自开放平台文档)。Python 是 `frozenset` ⇒ 这里用 `Set`(同构,成员测试走 `.has`)。
100
+ * 表外的键一律非法(创建/更新报 40000031)。缺省 `skill_outlined`。
101
+ */
102
+ export const SLASH_ICON_KEYS = new Set([
103
+ // AI 系(官方表前 31 项)
104
+ 'skill_outlined', 'light-ai_outlined', 'chat-ai_outlined',
105
+ 'ai-edit-continue_outlined', 'education-ai_outlined', 'ai-trans-switch_outlined',
106
+ 'language-ai_outlined', 'toolbar-more-ai_outlined', 'image-ai_outlined',
107
+ 'ai-agent_outlined', 'ai-deepthink_outlined', 'ai-style_outlined',
108
+ 'ai-reminder_outlined', 'ai-block_outlined', 'ai-simplify-expression_outlined',
109
+ 'promptword_outlined', 'meeting-ai_outlined', 'ai-improvewriting_outlined',
110
+ 'slash-ai_outlined', 'pa-cost-ai_outlined', 'update-ai_outlined',
111
+ 'diagnosis-ai_outlined', 'ai-functions_outlined', 'mic-ai_outlined',
112
+ 'search-ai_outlined', 'explanation-ai_outlined', 'add-ai_outlined',
113
+ 'add-chat-ai_outlined', 'ai-notification_outlined', 'dynamic-layout_outlined',
114
+ 'ai-doc_outlined',
115
+ // 通用(官方表其余 41 项)
116
+ 'home_outlined', 'mail_outlined', 'browser-mac_outlined', 'flag_outlined',
117
+ 'calendar-line_outlined', 'sent_outlined', 'gift_outlined', 'tag_outlined',
118
+ 'cloud_outlined', 'bonus-payroll_outlined', 'vote_outlined', 'chat_outlined',
119
+ 'emoji_outlined', 'member_outlined', 'robot_outlined', 'mic_outlined',
120
+ 'raisehand_outlined', 'officephone_outlined', 'effects_outlined', 'labs_outlined',
121
+ 'computer_outlined', 'clear_outlined', 'mouse_outlined', 'cellphone_outlined',
122
+ 'cursor_outlined', 'plugin_outlined', 'edit_outlined', 'style-fillcolor_outlined',
123
+ 'codeblock_outlined', 'code_outlined', 'folder_outlined', 'file-link-word_outlined',
124
+ 'file-link-sheet_outlined', 'file-link-image_outlined', 'global-link_outlined',
125
+ 'champion_outlined', 'local_outlined', 'day_outlined', 'waiting_outlined',
126
+ 'database_outlined', 'marketplace_outlined',
127
+ ]);
128
+
129
+ /** Python 真值口径(`""`/`0`/`None`/`False`/空容器为假) */
130
+ function pyTruthy(value) {
131
+ if (Array.isArray(value)) return value.length > 0;
132
+ if (value !== null && typeof value === 'object') return Object.keys(value).length > 0;
133
+ return Boolean(value);
134
+ }
135
+
136
+ /** `isinstance(x, dict)`(`node:sqlite` 的行是普通对象,故直接判原型链外的普通对象) */
137
+ function isDict(value) {
138
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
139
+ }
140
+
141
+ /** Python `str(x)` 的可用子集(错误文案里逐字出现;浮点偏差见 `pyjson.js` 文件头) */
142
+ function pyStr(value) {
143
+ if (value === null || value === undefined) return 'None';
144
+ if (value === true) return 'True';
145
+ if (value === false) return 'False';
146
+ if (value instanceof Error) return value.message;
147
+ return String(value);
148
+ }
149
+
150
+ /** Python `sorted(list[str])`:**按码点**比较(JS 默认 `sort` 是 UTF-16 码元序,astral 字符上分叉) */
151
+ function pySorted(values) {
152
+ return [...values].sort((a, b) => pyStrCompare(a, b));
153
+ }
154
+
155
+ /**
156
+ * Python 字符串序(逐码点比较)。两侧都是「最短往返」字符串,故与 CPython 的 `<` 等价。
157
+ * @param {string} a
158
+ * @param {string} b
159
+ * @returns {number} -1 / 0 / 1
160
+ */
161
+ function pyStrCompare(a, b) {
162
+ const ai = a[Symbol.iterator]();
163
+ const bi = b[Symbol.iterator]();
164
+ for (;;) {
165
+ const x = ai.next();
166
+ const y = bi.next();
167
+ if (x.done && y.done) return 0;
168
+ if (x.done) return -1;
169
+ if (y.done) return 1;
170
+ const cx = x.value.codePointAt(0);
171
+ const cy = y.value.codePointAt(0);
172
+ if (cx !== cy) return cx < cy ? -1 : 1;
173
+ }
174
+ }
175
+
176
+ /** `x in _SLASH_PERM_CODES`(Python tuple 的 `in` = 逐个 `==`;字符串不隐式转数字) */
177
+ function isPermCode(code) {
178
+ return _SLASH_PERM_CODES.some((candidate) => candidate === code);
179
+ }
180
+
181
+ /**
182
+ * Python `getattr(err, "code", None)`:没有 `code` 属性 ⇒ `null`(`undefined` 同折成 `null`)。
183
+ * @param {any} err
184
+ * @returns {any}
185
+ */
186
+ function errCode(err) {
187
+ const code = err === null || err === undefined ? null : err.code;
188
+ return code === undefined ? null : code;
189
+ }
190
+
191
+ /**
192
+ * 说明载荷:`default_value` 兜底 + 中文 i18n。对齐 `feishu._slash_description`(feishu.py:1302-1307)。
193
+ * **图标不放这里**——2026-10-05 线上取证:放 `description` 内可被接受但**被忽略**(回读恒为
194
+ * 默认 `skill_outlined`);放 item 级 POST/PATCH 均生效。
195
+ * @param {string} desc
196
+ * @returns {{default_value: string, i18n: {zh_cn: string}}}
197
+ */
198
+ function slashDescription(desc) {
199
+ return { default_value: desc, i18n: { zh_cn: desc } };
200
+ }
201
+
202
+ /**
203
+ * 远端 item → 说明文本。对齐 `feishu._slash_remote_desc`(feishu.py:1310-1318):
204
+ * GET 回包是 `{default_value,i18n}` 对象,兼容直接给字符串的形态。
205
+ * @param {any} item
206
+ * @returns {string}
207
+ */
208
+ function slashRemoteDesc(item) {
209
+ const d = (item || {}).description;
210
+ if (typeof d === 'string') return d;
211
+ if (isDict(d)) return d.default_value || (d.i18n || {}).zh_cn || '';
212
+ return '';
213
+ }
214
+
215
+ /**
216
+ * 远端 item → icon_key。对齐 `feishu._slash_remote_icon`(feishu.py:1321-1331):
217
+ * 官方示例把 icon 放 `description` 内、回包示例放 item 级,**两种形态都认**;都取不到按缺省
218
+ * `skill_outlined`(与创建时的默认一致)。
219
+ * @param {any} item
220
+ * @returns {string}
221
+ */
222
+ function slashRemoteIcon(item) {
223
+ const candidates = [(item || {}).icon];
224
+ const d = (item || {}).description;
225
+ if (isDict(d)) candidates.push(d.icon);
226
+ for (const holder of candidates) {
227
+ if (isDict(holder) && pyTruthy(holder.icon_key)) return holder.icon_key;
228
+ }
229
+ return 'skill_outlined';
230
+ }
231
+
232
+ /**
233
+ * 飞书业务码 → 用户可操作的中文引导(设置页展示用,不抛异常)。
234
+ * 对齐 `feishu.slash_error_hint`(feishu.py:1334-1343)。
235
+ * @param {any} err 一般是 `FeishuRestError`(带 `code`),也接受任意异常
236
+ * @returns {string}
237
+ */
238
+ export function slashErrorHint(err) {
239
+ const code = errCode(err);
240
+ if (isPermCode(code)) return SLASH_PERM_HINT;
241
+ if (code === 40000031) return SLASH_ICON_HINT;
242
+ if (code === 99992402) return '指令参数校验失败:指令名需为不含「/」的标识符,说明不可为空';
243
+ return pyStr(err);
244
+ }
245
+
246
+ /**
247
+ * 该失败是否属「应用缺 scope」类——决定要不要向用户发飞书权限引导卡片。
248
+ * 对齐 `feishu._need_scope`(feishu.py:1346-1348)。
249
+ * @param {any} err
250
+ * @returns {boolean}
251
+ */
252
+ export function _needScope(err) {
253
+ return isPermCode(errCode(err));
254
+ }
255
+
256
+ /**
257
+ * 已注册指令列表(GET;需要 `application:app_slash_command:read`)。
258
+ * 对齐 `feishu.list_slash_commands`(feishu.py:1351-1360)。
259
+ * @param {object} cfg 用户应用凭据
260
+ * @returns {Promise<Array<{command_id: string, command: string, description: string,
261
+ * icon: string, update_time: string}>>}
262
+ */
263
+ export async function listSlashCommands(cfg) {
264
+ const out = await rest('GET', SLASH_API, { cfg });
265
+ const items = ((out.data || {}).items) || [];
266
+ return items.map((i) => ({
267
+ command_id: pyStr(i.command_id || ''),
268
+ command: i.command || '',
269
+ description: slashRemoteDesc(i),
270
+ icon: slashRemoteIcon(i),
271
+ update_time: i.update_time || '',
272
+ }));
273
+ }
274
+
275
+ /**
276
+ * 注册一条指令,返回 `command_id`(重名报 40000000 command already exists)。
277
+ * 对齐 `feishu.create_slash_command`(feishu.py:1363-1370);icon 走 item 级(实测生效)。
278
+ * @param {object} cfg
279
+ * @param {string} command
280
+ * @param {string} desc
281
+ * @param {string} [iconKey]
282
+ * @returns {Promise<string>}
283
+ */
284
+ export async function createSlashCommand(cfg, command, desc, iconKey = 'skill_outlined') {
285
+ const out = await rest('POST', SLASH_API, {
286
+ cfg,
287
+ jsonBody: {
288
+ command,
289
+ description: slashDescription(desc),
290
+ icon: { icon_key: iconKey || 'skill_outlined' },
291
+ },
292
+ });
293
+ return pyStr((out.data || {}).command_id || '');
294
+ }
295
+
296
+ /**
297
+ * 按 `command_id` 更新说明与图标(PATCH 幂等;指令名注册后不可改)。
298
+ * 对齐 `feishu.update_slash_command`(feishu.py:1373-1377)。
299
+ * @param {object} cfg
300
+ * @param {string} commandId
301
+ * @param {string} desc
302
+ * @param {string} [iconKey]
303
+ * @returns {Promise<void>}
304
+ */
305
+ export async function updateSlashCommand(cfg, commandId, desc, iconKey = 'skill_outlined') {
306
+ await rest('PATCH', `${SLASH_API}/${commandId}`, {
307
+ cfg,
308
+ jsonBody: {
309
+ description: slashDescription(desc),
310
+ icon: { icon_key: iconKey || 'skill_outlined' },
311
+ },
312
+ });
313
+ }
314
+
315
+ /**
316
+ * 删除一条指令(已删除的再删返回 command_id not found,由调用方按幂等处理)。
317
+ * 对齐 `feishu.delete_slash_command`(feishu.py:1380-1382)。
318
+ * @param {object} cfg
319
+ * @param {string} commandId
320
+ * @returns {Promise<void>}
321
+ */
322
+ export async function deleteSlashCommand(cfg, commandId) {
323
+ await rest('DELETE', `${SLASH_API}/${commandId}`, { cfg });
324
+ }
325
+
326
+ /** `by_name` 查表(`feishu._slash_upsert` 的入参):`Map` 与普通对象都接受;未命中回 `null` */
327
+ function nameGet(index, name) {
328
+ if (index instanceof Map) {
329
+ const hit = index.get(name);
330
+ return hit === undefined ? null : hit;
331
+ }
332
+ if (index !== null && typeof index === 'object' && Object.hasOwn(index, name)) return index[name];
333
+ return null;
334
+ }
335
+
336
+ /**
337
+ * 单条幂等 upsert(同步循环内的最小执行单元):返回 `'created'`/`'updated'`/`'kept'`。
338
+ * 对齐 `feishu._slash_upsert`(feishu.py:1385-1409)。差异判定 = 说明**或图标**不一致即更新;
339
+ * 创建撞名(40000000)时重取列表按 `command_id` 转更新,保证重复点击必然收敛。
340
+ * @param {object} cfg
341
+ * @param {string} name 指令名
342
+ * @param {string} desc 中文说明
343
+ * @param {string} icon icon_key
344
+ * @param {Map<string, object>|object} byName `command -> 远端 item` 查表
345
+ * @returns {Promise<'created'|'updated'|'kept'>}
346
+ */
347
+ export async function _slashUpsert(cfg, name, desc, icon, byName) {
348
+ const cur = nameGet(byName, name);
349
+ if (cur !== null) {
350
+ if (cur.description === desc && cur.icon === icon) return 'kept';
351
+ await updateSlashCommand(cfg, cur.command_id, desc, icon);
352
+ return 'updated';
353
+ }
354
+ try {
355
+ await createSlashCommand(cfg, name, desc, icon);
356
+ return 'created';
357
+ } catch (e) {
358
+ if (!(e instanceof FeishuRestError)) throw e;
359
+ if (errCode(e) !== 40000000) throw e;
360
+ const fresh = new Map((await listSlashCommands(cfg)).map((r) => [r.command, r]));
361
+ const again = nameGet(fresh, name);
362
+ if (again === null) throw e;
363
+ await updateSlashCommand(cfg, again.command_id, desc, icon);
364
+ return 'updated';
365
+ }
366
+ }
367
+
368
+ /**
369
+ * 设置页读取口:TS 期望清单 + 飞书侧现状(含用户自己的其他指令)。
370
+ * 对齐 `feishu.slash_status`(feishu.py:1412-1430):凭据缺失/权限不足等一律走 `error` 字段返回,
371
+ * **不抛异常**(页面照常渲染并给引导);`need_scope` = 缺「应用指令」scope。
372
+ * @param {number} userId
373
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options] 透传给 `appConfig`
374
+ * @returns {Promise<{configured: boolean, desired: Array<object>, remote: Array<object>,
375
+ * extra: string[], need_scope: boolean, error: string}>}
376
+ */
377
+ export async function slashStatus(userId, options = {}) {
378
+ const desired = SLASH_COMMANDS.map(([command, description, icon]) =>
379
+ ({ command, description, icon }));
380
+ const cfg = appConfig(userId, options);
381
+ if (cfg === null) {
382
+ return {
383
+ configured: false, desired, remote: [], extra: [], need_scope: false,
384
+ error: '未配置应用凭据(App ID/App Secret),先在上方「飞书机器人配置」保存后重试',
385
+ };
386
+ }
387
+ let remote;
388
+ try {
389
+ remote = await listSlashCommands(cfg);
390
+ } catch (e) {
391
+ if (!(e instanceof FeishuRestError)) throw e;
392
+ return {
393
+ configured: true, desired, remote: [], extra: [],
394
+ need_scope: _needScope(e), error: slashErrorHint(e),
395
+ };
396
+ }
397
+ const extra = pySorted(remote.filter((r) => !_SLASH_NAMES.includes(r.command))
398
+ .map((r) => r.command));
399
+ return { configured: true, desired, remote, extra, need_scope: false, error: '' };
400
+ }
401
+
402
+ /**
403
+ * 把 TS 指令清单幂等同步到飞书:只增/改**自己的**指令,**绝不删**用户的其他指令。
404
+ * 对齐 `feishu.sync_slash_commands`(feishu.py:1433-1465)。
405
+ * @param {number} userId
406
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
407
+ * @returns {Promise<{ok: boolean, created?: string[], updated?: string[], kept?: string[],
408
+ * extra?: string[], failed?: string[], need_scope: boolean, error: string}>}
409
+ */
410
+ export async function syncSlashCommands(userId, options = {}) {
411
+ const cfg = appConfig(userId, options);
412
+ if (cfg === null) {
413
+ return { ok: false, need_scope: false, error: '未配置应用凭据(App ID/App Secret)' };
414
+ }
415
+ let remote;
416
+ try {
417
+ remote = await listSlashCommands(cfg);
418
+ } catch (e) {
419
+ if (!(e instanceof FeishuRestError)) throw e;
420
+ return { ok: false, need_scope: _needScope(e), error: slashErrorHint(e) };
421
+ }
422
+ const byName = new Map(remote.map((r) => [r.command, r]));
423
+ const buckets = { created: [], updated: [], kept: [] };
424
+ const failed = [];
425
+ let needScope = false;
426
+ for (const [name, desc, icon] of SLASH_COMMANDS) {
427
+ if (!SLASH_ICON_KEYS.has(icon)) {
428
+ // 本地预检:表外的键飞书必回 40000031,先在此拦下——省一次请求,
429
+ // 且不让远端停在「同步到一半」的状态(同步是逐条进行的)
430
+ failed.push(`${name}: ${SLASH_ICON_HINT}`);
431
+ continue;
432
+ }
433
+ try {
434
+ buckets[await _slashUpsert(cfg, name, desc, icon, byName)].push(name);
435
+ } catch (e) {
436
+ if (!(e instanceof FeishuRestError)) throw e;
437
+ needScope = needScope || _needScope(e);
438
+ failed.push(`${name}: ${slashErrorHint(e)}`);
439
+ }
440
+ }
441
+ return {
442
+ ok: failed.length === 0,
443
+ created: buckets.created, updated: buckets.updated, kept: buckets.kept,
444
+ extra: pySorted([...byName.keys()].filter((n) => !_SLASH_NAMES.includes(n))),
445
+ failed, need_scope: needScope,
446
+ error: failed.length > 0 ? failed[0] : '',
447
+ };
448
+ }
449
+
450
+ /**
451
+ * 删除**TS 自己的**指令(名字命中 `SLASH_COMMANDS` 的远端项);用户其他指令不动。
452
+ * 对齐 `feishu.clear_slash_commands`(feishu.py:1468-1492)。
453
+ * @param {number} userId
454
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
455
+ * @returns {Promise<{ok: boolean, deleted?: string[], extra?: string[], failed?: string[],
456
+ * need_scope: boolean, error: string}>}
457
+ */
458
+ export async function clearSlashCommands(userId, options = {}) {
459
+ const cfg = appConfig(userId, options);
460
+ if (cfg === null) {
461
+ return { ok: false, need_scope: false, error: '未配置应用凭据(App ID/App Secret)' };
462
+ }
463
+ let remote;
464
+ try {
465
+ remote = await listSlashCommands(cfg);
466
+ } catch (e) {
467
+ if (!(e instanceof FeishuRestError)) throw e;
468
+ return { ok: false, need_scope: _needScope(e), error: slashErrorHint(e) };
469
+ }
470
+ const deleted = [];
471
+ const failed = [];
472
+ let needScope = false;
473
+ for (const r of remote) {
474
+ if (!_SLASH_NAMES.includes(r.command)) continue;
475
+ try {
476
+ await deleteSlashCommand(cfg, r.command_id);
477
+ deleted.push(r.command);
478
+ } catch (e) {
479
+ if (!(e instanceof FeishuRestError)) throw e;
480
+ needScope = needScope || _needScope(e);
481
+ failed.push(`${r.command}: ${slashErrorHint(e)}`);
482
+ }
483
+ }
484
+ return {
485
+ ok: failed.length === 0,
486
+ deleted: pySorted(deleted),
487
+ extra: pySorted(remote.filter((r) => !_SLASH_NAMES.includes(r.command)).map((r) => r.command)),
488
+ failed, need_scope: needScope,
489
+ error: failed.length > 0 ? failed[0] : '',
490
+ };
491
+ }
492
+
493
+ /**
494
+ * 权限引导卡片(schema 2.0,DM 单聊)。对齐 `feishu.slash_perm_card`(feishu.py:1504-1533):
495
+ * 说明缺哪个 scope、三步怎么开通、开发者后台直达链接,底部「我已开通,重试注册」按钮
496
+ * (卡片无状态,`value` 只带身份 `u` 与动作 `t=sc`,回调时按 open_id→binding 复核)。
497
+ * 链接只给「应用详情页」根 URL(深链路由未在官方文档中出现,不猜)。
498
+ * @param {string} appId
499
+ * @param {number} userId
500
+ * @returns {object} 卡片 dict(逐字节照抄 oracle)
501
+ */
502
+ export function slashPermCard(appId, userId) {
503
+ const url = appId ? `${FEISHU_DOMAIN}/app/${appId}` : `${FEISHU_DOMAIN}/app`;
504
+ const content = '**Touchstone 快捷指令**(机器人私聊输入框打「/」弹出的指令面板)'
505
+ + '需要你的飞书应用先开通权限:\n'
506
+ + '· `application:app_slash_command:read`\n'
507
+ + '· `application:app_slash_command:write`\n\n'
508
+ + '**开通三步**:\n'
509
+ + `1. 打开 [开发者后台 · 本应用](${url}),左侧 **开发配置 → 权限管理**,点「开通权限」\n`
510
+ + '2. 搜索 `app_slash_command`,把读、写两个权限都勾上并确认开通\n'
511
+ + '3. 左侧 **应用发布 → 版本管理与发布** → 创建版本并发布'
512
+ + '(权限改动必须发版、过审后才生效)\n\n'
513
+ + '完成后点下面的按钮,我立刻重试注册;若刚发版,可等约 1 分钟再点一次。';
514
+ const uid = pyInt(userId);
515
+ const payload = { t: 'sc', a: 'retry', u: uid };
516
+ return {
517
+ schema: '2.0',
518
+ header: {
519
+ title: { tag: 'plain_text', content: '🔑 需要开通「应用指令」权限' },
520
+ template: 'orange',
521
+ },
522
+ body: {
523
+ elements: [
524
+ { tag: 'markdown', content },
525
+ {
526
+ tag: 'button',
527
+ type: 'primary',
528
+ text: { tag: 'plain_text', content: '我已开通,重试注册' },
529
+ behaviors: [{ type: 'callback', value: payload }],
530
+ },
531
+ ],
532
+ },
533
+ };
534
+ }
535
+
536
+ /** Python `int(x)` 的可用子集:非数值抛 TypeError(Python 也是 TypeError/ValueError) */
537
+ function pyInt(value) {
538
+ if (typeof value === 'bigint') return Number(value);
539
+ const num = typeof value === 'number' ? value : Number(String(value).trim());
540
+ if (!Number.isFinite(num)) throw new TypeError(`int() argument must be a string or a number: ${pyStr(value)}`);
541
+ return Math.trunc(num);
542
+ }
543
+
544
+ /**
545
+ * 向该用户的飞书单聊发权限引导卡片(需已绑定 + 应用凭据齐全)。
546
+ * 对齐 `feishu.send_slash_perm_card`(feishu.py:1536-1550):未绑定/未配凭据属正常情形,
547
+ * 调用方据此给站点侧提示,**不抛异常**。
548
+ * @param {number} userId
549
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options] 透传给配置/绑定读口
550
+ * @returns {Promise<[boolean, string]>} `[ok, err]`(Python tuple)
551
+ */
552
+ export async function sendSlashPermCard(userId, options = {}) {
553
+ const cfg = appConfig(userId, options);
554
+ if (cfg === null) {
555
+ return [false, '未配置应用凭据(App ID/App Secret),先保存配置再发卡片'];
556
+ }
557
+ const binding = getFeishuBindingByUser(userId, options);
558
+ if (!binding) {
559
+ return [false, '尚未绑定飞书账号:设置页生成绑定码后在机器人单聊回复「绑定 <码>」'];
560
+ }
561
+ try {
562
+ await restSendCard(binding.open_id, slashPermCard(cfg.app_id || '', userId), cfg);
563
+ return [true, ''];
564
+ } catch (e) {
565
+ if (!(e instanceof FeishuRestError)) throw e;
566
+ return [false, `卡片发送失败:${pyStr(e)}`];
567
+ }
568
+ }