@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,1484 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 飞书通用对话(会话对话层)—— `feishu_conv.py:1-920` 的零依赖 Node 移植
4
+ * (48 个模块级函数 + `_Card` 类 + 31 个模块级常量)。
5
+ *
6
+ * 职责:把「单聊里的自由文本」变成「某个 dsh 会话的一条消息」,并把会话跑完的答复推回
7
+ * 飞书 DM;另外提供 `项目`/`会话` 两张交互卡片与 `t=dp`/`t=ss` 两个卡片回调。
8
+ *
9
+ * ══ 依赖方向(硬约束,照 `feishu_conv.py:5-6`)═══════════════════════════════════
10
+ * 模块级可 import `db`/`sessparse`/`dshdriver`/`dshevents`/`agents`/`waitq`/`chat`
11
+ * (Node 侧另加 `feishu_rest`/`feishu_cfg`/`pyjson`/`lib`,与 R1/R2 的既有分层一致);
12
+ * Python 里 `chat`/`feishu` 走函数内懒 import 是为了躲 `feishu → runner → chat` 的环——
13
+ * Node 侧实测 `chat.js` 的静态依赖图里**没有任何模块**回头 import `feishu.js`(只有
14
+ * `feishu_inbound.js` 会),故 `chat` 可以静态 import;唯一保留懒 import 的是
15
+ * `feishu_inbound.js`(只为 `conv_intent` 的未知动作兜底取 `HELP_TEXT`,那条分支在生产
16
+ * 路径上不可达:`execute_intent` 的 action 白名单是硬编码的六条)。
17
+ *
18
+ * ══ 与 oracle 的三处结构性差异(都是**必须**的,不是选择)════════════════════════
19
+ * A. **整链 async**:`chat.submit` / `restSendCard` / `restSendText` / `dshdriver.*` 在
20
+ * Node 侧全是 async(Python 是同步阻塞)⇒ `routeText`/`convIntent`/`onCardAction`/
21
+ * `newSession`/`createInProject`/`sendCard`/`dm` 整链 async;`await` 的位置照抄 oracle
22
+ * 的**串行**次序(baseline 先于 submit、建会话先于重申绑定、DM 先于 toast)。
23
+ * 纯读/纯写 DB 的(`currentText`/`projectsCard`/`sessionsCard`/`bindSession`/
24
+ * `setDefaultProject`/`projectById`/`useByToken`/`start`)保持**同步**,形状与 oracle 同。
25
+ * B. **`_Card` 的别名不可枚举**:Python 是 `dict` 子类 + `__getitem__` 特判,Node 没有
26
+ * dict 子类 ⇒ 真实三键对象(`schema`/`header`/`body`)+ `Object.defineProperty` 的
27
+ * **不可枚举**读口 `elements`。不可枚举是关键:`Object.keys`/`pyJsonDumps` 都不带它,
28
+ * 发出去的卡片 JSON 里没有顶层 `elements`(否则体积翻倍 + 未知字段被飞书拒收)。
29
+ * C. **`options` 末位注入**(R2/M7 的既有约定):`options.dbmod` 是 **db 模块**替换口
30
+ * (`options.db` 是 sqlite 句柄,会被透传给 db.js,**不是**模块槽);另有
31
+ * `rest`/`chat`/`feishu`(`appConfig`)/`sessparse`/`dshdriver`/`dshevents`/`agents`/
32
+ * `waitq`/`cfg`/`now`/`log`/`env`。默认值一律真模块 ⇒ 产品路径零改动。
33
+ * `now()`(秒)是本移植新增的**行为中性**注入点(oracle 直接 `time.time()`),
34
+ * 供用例钉 `_LAST_LIST` 的 TTL 与缺省标题;不传时等价于 `Date.now()/1000`。
35
+ *
36
+ * ══ 逐字复现的三条优先级(照契约清单 §0.3)═══════════════════════════════════════
37
+ * ① 发回飞书的字节:文本走 `restSendText`,卡片走 `restSendCard` 的
38
+ * `content: pyJsonDumps(card)` ⇒ **卡片构建的键序就是字节序**;
39
+ * ② 日志分流:`_trace` 走 **stderr**(`options.log`,默认 `console.error`),
40
+ * `notifyMsgDone` 的 yielded 分支走 **stdout**(`console.log`)——两条流不一样;
41
+ * ③ 状态迁移与顺序:`feishu_bindings` 四列指针、`wait_items.meta` 的 `feishu` 载荷、
42
+ * `_LAST_LIST` 内存态,以及**哪些分支先 return**(长度闸先于一切;卡片未被受理才补文本)。
43
+ *
44
+ * 未验清单(真飞书行为、真线程并发、`{e!r}` 的完整 repr 面、多用户 loop 等)见
45
+ * M7.5-R3 实施报告,不藏在注释里。
46
+ */
47
+ import { agentFamily } from './agents.js';
48
+ import * as chatMod from './chat.js';
49
+ import * as dbMod from './db.js';
50
+ import * as dshdriverMod from './dshdriver.js';
51
+ import * as dsheventsMod from './dshevents.js';
52
+ import * as cfgMod from './feishu_cfg.js';
53
+ import * as restMod from './feishu_rest.js';
54
+ import { PY_WS } from './lib.js';
55
+ import { pyJsonDumps } from './pyjson.js';
56
+ import * as sessparseMod from './sessparse.js';
57
+ import * as waitqMod from './waitq.js';
58
+
59
+ // ==========================================================================
60
+ // 模块级常量(`feishu_conv.py:20-50`,逐字)
61
+ // ==========================================================================
62
+
63
+ /** `CONV_ENV`(`:20`):`"0"` 关闭(回到「未识别文本回帮助」) */
64
+ export const CONV_ENV = 'TS_FEISHU_CONV';
65
+ /** `CARD_MAX_BYTES`(`:21`):卡片 JSON 体积预算 */
66
+ export const CARD_MAX_BYTES = 8000;
67
+ /** `PROJECTS_MAX`(`:22`):总览卡最多列的项目数 */
68
+ export const PROJECTS_MAX = 20;
69
+ /** `PROJECTS_FALLBACK`(`:23`):超预算时第二轮降级的项目数 */
70
+ export const PROJECTS_FALLBACK = 10;
71
+ /** `PROJECT_NAME_MAX`(`:24`):卡片里项目名的展示上限 */
72
+ export const PROJECT_NAME_MAX = 200;
73
+ /** `SESSIONS_MAX`(`:25`):列表卡最多列的会话数 */
74
+ export const SESSIONS_MAX = 10;
75
+ /** `LIST_TTL`(`:26`):「绑定会话 <序号>」列表记忆有效期(秒) */
76
+ export const LIST_TTL = 1800;
77
+ /** `REPLY_MAX_CHARS`(`:27`):答复回流截断长度 */
78
+ export const REPLY_MAX_CHARS = 3000;
79
+ /**
80
+ * `_LAST_LIST`(`:28`):`open_id -> [ts, [sid, ...]]`(列表卡的序号记忆;不进库、重启即失)。
81
+ * Python 是模块级 dict,Node 用 Map(键是 open_id 字符串);导出仅供用例与诊断。
82
+ */
83
+ export const _LAST_LIST = new Map();
84
+
85
+ /** `DRIVER_NEEDED`(`:31`) */
86
+ export const DRIVER_NEEDED = '飞书通用对话需要 dsh 插件形态(独立形态的站点没有进程内 agent 驱动)';
87
+ /** `NO_DEFAULT_GUIDE`(`:32`) */
88
+ export const NO_DEFAULT_GUIDE = '请先设置默认项目(回复「默认项目 <项目名>」,或发「项目」在卡片上点选)';
89
+ /** `NO_PROJECTS_GUIDE`(`:33`) */
90
+ export const NO_PROJECTS_GUIDE = '你还没有项目,请先到站点创建';
91
+ /** `STALE_NOTE`(`:34`) */
92
+ export const STALE_NOTE = '(此前绑定的会话已不存在,已解除绑定)';
93
+ /** `UNBIND_NOTE`(`:35`) */
94
+ export const UNBIND_NOTE = '已解除此前绑定的会话(下次发消息将在新项目自动新建)';
95
+ /** `PROJECTS_MORE_FMT`(`:36`):`%d` 走 Python `%` 格式化口径 */
96
+ export const PROJECTS_MORE_FMT = '另有 %d 个项目,回复「默认项目 <项目名>」精确设置';
97
+ /** `PROJECTS_TEXT_HINT`(`:37`):卡片发不出时的文本兜底 */
98
+ export const PROJECTS_TEXT_HINT = '在卡片可用前,可用『默认项目 <项目名>』设置';
99
+ /** `SESSIONS_TEXT_HINT`(`:38`) */
100
+ export const SESSIONS_TEXT_HINT = '可用『绑定会话 <序号|标题关键字|会话id前缀>』绑定';
101
+ /** `TITLE_FMT`(`:39`):新建会话的缺省标题(本地时间,零填充) */
102
+ export const TITLE_FMT = '飞书对话 %m-%d %H:%M';
103
+ /** `SESSION_GONE`(`:40`) */
104
+ export const SESSION_GONE = '该会话已不存在(可能被删除或归档清理),发「会话」刷新列表';
105
+ /** `SESSION_EMPTY`(`:41-42`,源码两段隐式拼接,无空格无换行) */
106
+ export const SESSION_EMPTY = '该默认项目下还没有会话——直接发一句话就会自动新建,'
107
+ + '或回复「新建会话 <标题>」';
108
+ /** `SESSION_STALE`(`:43`) */
109
+ export const SESSION_STALE = '列表记忆已过期或序号越界,先发「会话」刷新最近 10 条,再回复「绑定会话 <序号>」';
110
+ /** `SESSION_MISS_FMT`(`:44`) */
111
+ export const SESSION_MISS_FMT = '没找到匹配「%s」的会话,发「会话」看最近 10 条';
112
+ /** `SESSION_MULTI_FMT`(`:45`,含真实换行) */
113
+ export const SESSION_MULTI_FMT = '「%s」匹配到多个会话,回复更精确的关键字:\n%s';
114
+ /**
115
+ * `SESSION_FAIL_TEXTS`(`:47`):`bind_session` 的失败回执(`t=ss` 回调据此选 toast 类型)。
116
+ * Python 是元组且成员是同一对象引用;判定是**值相等**(`reply in SESSION_FAIL_TEXTS`)
117
+ * ⇒ Node 用数组 + `includes` 值语义等价。
118
+ */
119
+ export const SESSION_FAIL_TEXTS = [NO_DEFAULT_GUIDE, SESSION_GONE];
120
+ /** `SESSION_NAME_MAX`(`:48`):会话名在卡片里的展示上限 */
121
+ export const SESSION_NAME_MAX = 60;
122
+ /** `MESSAGE_TOO_LONG`(`:49-50`,两段隐式拼接) */
123
+ export const MESSAGE_TOO_LONG = '消息过长(单条上限 %d 字):请把长内容作为附件/文件发送,'
124
+ + '或拆成几条短消息再发';
125
+
126
+ // ==========================================================================
127
+ // Python 值语义小工具(每条对着 CPython 行为写;`feishu_inbound.js` 里的同型助手
128
+ // 是**模块私有**的,R3 不许改那个文件 ⇒ 本文件自带一份,见契约清单 §8-D8)
129
+ // ==========================================================================
130
+
131
+ const PY_STRIP_RE = new RegExp(`^${PY_WS}+|${PY_WS}+$`, 'g');
132
+ /** Python 行边界(`str.splitlines()` 的切分点;`\r\n` 是一个边界) */
133
+ const PY_LINE_BREAK_RE = /\r\n|[\n\r\v\f\x1c\x1d\x1e\x85\u2028\u2029]/;
134
+ /** `Numeric_Type=Digit` 的上标/带圈形(`str.isdigit()` 比 `\p{Nd}` 宽的那部分) */
135
+ const ND = /\p{Nd}/u;
136
+ const DIGIT_EXTRA = /[\u00b2\u00b3\u00b9\u2070\u2074-\u2079\u2080-\u2089\u2460-\u2473\u24ea\u24ff\u2776-\u2793]/u;
137
+
138
+ /** Python `bool()`:`None`/`False`/`0`/`""`/空容器假;`nan` 为真 */
139
+ function pyTruthy(value) {
140
+ if (value === null || value === undefined) return false;
141
+ if (typeof value === 'boolean') return value;
142
+ if (typeof value === 'number') return Number.isNaN(value) ? true : value !== 0;
143
+ if (typeof value === 'string') return value.length > 0;
144
+ if (Array.isArray(value)) return value.length > 0;
145
+ if (typeof value === 'object') return Object.keys(value).length > 0;
146
+ return true;
147
+ }
148
+
149
+ /** Python `a or b`(JS `||` 对 `[]`/`{}`/`"0"` 分叉 ⇒ 必须显式) */
150
+ function pyOr(a, b) {
151
+ return pyTruthy(a) ? a : b;
152
+ }
153
+
154
+ /** Python `str(value)`(容器 = repr;`None`/`True`/`False` 是 Python 字面形态) */
155
+ function pyStr(value) {
156
+ if (value === null || value === undefined) return 'None';
157
+ if (typeof value === 'string') return value;
158
+ if (typeof value === 'boolean') return value ? 'True' : 'False';
159
+ if (typeof value === 'number') return pyNumRepr(value);
160
+ if (typeof value === 'bigint') return value.toString();
161
+ return pyRepr(value);
162
+ }
163
+
164
+ /** Python 浮点 repr 的近似(整数形按 JS 最短表示) */
165
+ function pyNumRepr(value) {
166
+ if (Number.isNaN(value)) return 'nan';
167
+ if (value === Infinity) return 'inf';
168
+ if (value === -Infinity) return '-inf';
169
+ if (Number.isInteger(value)) return Object.is(value, -0) ? '0' : String(value);
170
+ return String(value);
171
+ }
172
+
173
+ /** Python 字符串 repr(单引号优先;含 `'` 且不含 `"` 时改用双引号) */
174
+ function pyReprStr(value) {
175
+ const quote = value.includes("'") && !value.includes('"') ? '"' : "'";
176
+ let out = '';
177
+ for (const ch of value) {
178
+ if (ch === '\\') out += '\\\\';
179
+ else if (ch === quote) out += `\\${ch}`;
180
+ else if (ch === '\n') out += '\\n';
181
+ else if (ch === '\r') out += '\\r';
182
+ else if (ch === '\t') out += '\\t';
183
+ else out += ch;
184
+ }
185
+ return `${quote}${out}${quote}`;
186
+ }
187
+
188
+ /** Python `repr(value)`(容器走自己的 repr) */
189
+ function pyRepr(value) {
190
+ if (value === null || value === undefined) return 'None';
191
+ if (typeof value === 'string') return pyReprStr(value);
192
+ if (typeof value === 'boolean') return value ? 'True' : 'False';
193
+ if (typeof value === 'number') return pyNumRepr(value);
194
+ if (typeof value === 'bigint') return value.toString();
195
+ if (Array.isArray(value)) return `[${value.map(pyRepr).join(', ')}]`;
196
+ if (typeof value === 'object') {
197
+ return `{${Object.keys(value).map((k) => `${pyReprStr(k)}: ${pyRepr(value[k])}`).join(', ')}}`;
198
+ }
199
+ return String(value);
200
+ }
201
+
202
+ /** Python 类型名(异常文案用) */
203
+ function pyTypeName(value) {
204
+ if (value === null || value === undefined) return 'NoneType';
205
+ if (Array.isArray(value)) return 'list';
206
+ switch (typeof value) {
207
+ case 'string': return 'str';
208
+ case 'number': return Number.isInteger(value) ? 'int' : 'float';
209
+ case 'boolean': return 'bool';
210
+ case 'bigint': return 'int';
211
+ case 'object': return 'dict';
212
+ default: return typeof value;
213
+ }
214
+ }
215
+
216
+ function makePyError(name, message) {
217
+ const e = new Error(message);
218
+ e.pyName = name;
219
+ return e;
220
+ }
221
+
222
+ function attributeError(typeName, attr) {
223
+ return makePyError('AttributeError', `'${typeName}' object has no attribute '${attr}'`);
224
+ }
225
+
226
+ function valueError(message) {
227
+ return makePyError('ValueError', message);
228
+ }
229
+
230
+ /** Python `dict.get(key[, default])`:键存在但值为 None ⇒ **返回 None**(不是 default) */
231
+ function dget(dict, key, dflt) {
232
+ if (dict === null || dict === undefined || typeof dict !== 'object' || Array.isArray(dict)) {
233
+ throw attributeError(pyTypeName(dict), 'get');
234
+ }
235
+ if (Object.hasOwn(dict, key)) return dict[key] === undefined ? null : dict[key];
236
+ return dflt === undefined ? null : dflt;
237
+ }
238
+
239
+ /** Python 下标取值:缺键 ⇒ KeyError(对应 oracle 的 `binding["open_id"]` 这类写法) */
240
+ function requireKey(dict, key) {
241
+ if (dict === null || dict === undefined || typeof dict !== 'object') {
242
+ throw attributeError(pyTypeName(dict), key);
243
+ }
244
+ if (!Object.hasOwn(dict, key)) throw makePyError('KeyError', `'${key}'`);
245
+ return dict[key] === undefined ? null : dict[key];
246
+ }
247
+
248
+ /** Python `str.strip()`——**非 str 抛 AttributeError**(`lib.js:pyStrip` 会静默 `String(v)`) */
249
+ function pyStrStrip(value) {
250
+ if (typeof value !== 'string') throw attributeError(pyTypeName(value), 'strip');
251
+ return value.replace(PY_STRIP_RE, '');
252
+ }
253
+
254
+ /** `s.splitlines()[0]`(首行;调用点已先 strip,故不会拿到空串) */
255
+ function pyFirstLine(value) {
256
+ return value.split(PY_LINE_BREAK_RE)[0];
257
+ }
258
+
259
+ /** Python `s[:n]`(按**码点**切,不是 UTF-16 码元) */
260
+ function pySlice(value, n) {
261
+ if (typeof value !== 'string') throw attributeError(pyTypeName(value), '__getitem__');
262
+ const points = Array.from(value);
263
+ return points.length <= n ? value : points.slice(0, n).join('');
264
+ }
265
+
266
+ /** Python `len(s)`(按**码点**计,不是 UTF-16 码元) */
267
+ function pyLen(value) {
268
+ if (typeof value === 'string') return Array.from(value).length;
269
+ if (Array.isArray(value)) return value.length;
270
+ if (value !== null && typeof value === 'object') return Object.keys(value).length;
271
+ throw new TypeError(`object of type '${pyTypeName(value)}' has no len()`);
272
+ }
273
+
274
+ /** Python `float(value)`(失败抛 ValueError/TypeError,与 oracle 的 except 面一致) */
275
+ function pyFloat(value) {
276
+ if (typeof value === 'boolean') return value ? 1 : 0;
277
+ if (typeof value === 'number') return value;
278
+ if (typeof value === 'bigint') return Number(value);
279
+ if (typeof value !== 'string') {
280
+ throw new TypeError(`float() argument must be a string or a real number, not '${pyTypeName(value)}'`);
281
+ }
282
+ const raw = pyStrStrip(value);
283
+ if (raw === '') throw valueError("could not convert string to float: ''");
284
+ const lowered = raw.toLowerCase().replace(/^[+-]/, '');
285
+ if (lowered === 'nan') return raw.startsWith('-') ? -NaN : NaN; // eslint-disable-line no-self-compare
286
+ if (lowered === 'inf' || lowered === 'infinity') {
287
+ return raw.startsWith('-') ? -Infinity : Infinity;
288
+ }
289
+ const n = Number(raw);
290
+ if (Number.isNaN(n)) throw valueError(`could not convert string to float: ${pyReprStr(value)}`);
291
+ return n;
292
+ }
293
+
294
+ /** `\p{Nd}` 字符的十进制数字值(Unicode 十进制数字块恒为连续 10 个码点) */
295
+ function ndValue(ch) {
296
+ const cp = ch.codePointAt(0);
297
+ let zero = cp;
298
+ for (let i = 1; i <= 9 && zero > 0; i += 1) {
299
+ if (!ND.test(String.fromCodePoint(zero - 1))) break;
300
+ zero -= 1;
301
+ }
302
+ return cp - zero;
303
+ }
304
+
305
+ /**
306
+ * Python `int(value)`:**失败必须抛**(`Number("")===0` / `Number("abc")===NaN` 会静默改分支)。
307
+ * 支持 bool、number(截断向零;`nan`/`inf` 抛)、字符串(可选符号 + `\p{Nd}` 数字 +
308
+ * 数字间单个下划线,首尾 Python 空白)。
309
+ *
310
+ * 为什么不用 `lib.js:pyIntOr`:它只解析字符串形态(数字 `3.7` 会给 fallback),而 oracle 的
311
+ * `int()` 三种入参都要;`_selected_project_id`/`set_default_project` 还要「失败即抛」的那一支。
312
+ */
313
+ function pyIntStrict(value) {
314
+ if (typeof value === 'boolean') return value ? 1 : 0;
315
+ if (typeof value === 'number') {
316
+ if (Number.isNaN(value)) {
317
+ throw valueError(`invalid literal for int() with base 10: '${pyNumRepr(value)}'`);
318
+ }
319
+ if (!Number.isFinite(value)) throw valueError('cannot convert float infinity to integer');
320
+ return Math.trunc(value);
321
+ }
322
+ if (typeof value === 'bigint') return Number(value);
323
+ if (typeof value !== 'string') {
324
+ throw new TypeError('int() argument must be a string, a bytes-like object or a real number, '
325
+ + `not '${pyTypeName(value)}'`);
326
+ }
327
+ const raw = pyStrStrip(value);
328
+ let body = raw;
329
+ let sign = 1;
330
+ if (body.startsWith('+') || body.startsWith('-')) {
331
+ if (body.startsWith('-')) sign = -1;
332
+ body = body.slice(1);
333
+ }
334
+ if (body.length === 0) throw valueError(`invalid literal for int() with base 10: '${value}'`);
335
+ let digits = '';
336
+ for (let i = 0; i < body.length; i += 1) {
337
+ const ch = body[i];
338
+ if (ch === '_') {
339
+ const prev = i > 0 ? body[i - 1] : '';
340
+ const next = i + 1 < body.length ? body[i + 1] : '';
341
+ if (prev === '' || next === '' || !ND.test(prev) || !ND.test(next)) {
342
+ throw valueError(`invalid literal for int() with base 10: '${value}'`);
343
+ }
344
+ continue;
345
+ }
346
+ if (!ND.test(ch)) throw valueError(`invalid literal for int() with base 10: '${value}'`);
347
+ digits += String(ndValue(ch));
348
+ }
349
+ return sign * Number(digits);
350
+ }
351
+
352
+ /** Python `str.isdigit()`(Nd + Digit 类;`"²"` 为真而 `int("²")` 会抛 —— oracle 的原样边角) */
353
+ function pyIsDigit(value) {
354
+ if (typeof value !== 'string') throw attributeError(pyTypeName(value), 'isdigit');
355
+ if (value.length === 0) return false;
356
+ for (const ch of value) if (!ND.test(ch) && !DIGIT_EXTRA.test(ch)) return false;
357
+ return true;
358
+ }
359
+
360
+ /**
361
+ * Python `%` 单占位符格式化的等价物(本模块只用 `%d`/`%s`)。
362
+ * **不许用 `String.replace`**:替换串里的 `$&`/`$'`/`$1` 会被当成替换模式(`%` 没有这回事)。
363
+ */
364
+ function fmtSlot(template, slot, value) {
365
+ const i = template.indexOf(slot);
366
+ if (i < 0) return template;
367
+ return template.slice(0, i) + value + template.slice(i + slot.length);
368
+ }
369
+
370
+ /** Python `"%d" % n`(入参已保证是 int) */
371
+ function fmtD(template, n) {
372
+ return fmtSlot(template, '%d', pyStr(n));
373
+ }
374
+
375
+ /** Python `"%s" % value`(单参数) */
376
+ function fmtS(template, value) {
377
+ return fmtSlot(template, '%s', value);
378
+ }
379
+
380
+ /** Python `"%s … %s" % (a, b)`(两参数,按出现次序;`SESSION_MULTI_FMT` 用) */
381
+ function fmtS2(template, a, b) {
382
+ const first = template.indexOf('%s');
383
+ if (first < 0) return template;
384
+ const second = template.indexOf('%s', first + 2);
385
+ if (second < 0) return template;
386
+ return template.slice(0, first) + a + template.slice(first + 2, second) + b
387
+ + template.slice(second + 2);
388
+ }
389
+
390
+ /** 异常 → 文本(`str(e)` 口径:`Error` 取 message,不带类名前缀;与 `board.js:1845` 同) */
391
+ function errText(err) {
392
+ if (err === null || err === undefined) return '';
393
+ if (err instanceof Error) return String(err.message ?? err);
394
+ return pyStr(err);
395
+ }
396
+
397
+ /** 异常 → Python `repr(e)` 的近似(`{e!r}` 留痕用;`pyName` 优先,照 `feishu_inbound.js:363`) */
398
+ function errorRepr(e) {
399
+ if (e instanceof Error) {
400
+ const name = typeof e.pyName === 'string' ? e.pyName : (e.name || 'Error');
401
+ return `${name}(${pyReprStr(e.message === undefined ? String(e) : e.message)})`;
402
+ }
403
+ return pyRepr(e);
404
+ }
405
+
406
+ // ==========================================================================
407
+ // 依赖解析(末位 `options` 注入;默认值 = 真模块)
408
+ // ==========================================================================
409
+
410
+ /** 默认日志口:`console.error(line)`(= stderr + `\n`,与 `print(..., file=sys.stderr)` 同形) */
411
+ function defaultLog(line) {
412
+ console.error(line);
413
+ }
414
+
415
+ /** 默认时钟(秒;`time.time()` 口径)。`options.now` 可注入 ⇒ 测 TTL / 缺省标题 */
416
+ function defaultNow() {
417
+ return Date.now() / 1000;
418
+ }
419
+
420
+ /**
421
+ * 依赖表。⚠`options.db` 是 **sqlite 句柄**(透传给 db.js),**不是**模块槽——
422
+ * 要替换整个 db 模块用 `options.dbmod`(与 `feishu_inbound.js:416` 同一约定)。
423
+ */
424
+ function deps(options = {}) {
425
+ return {
426
+ db: options.dbmod || dbMod,
427
+ sessparse: options.sessparse || sessparseMod,
428
+ dshdriver: options.dshdriver || dshdriverMod,
429
+ dshevents: options.dshevents || dsheventsMod,
430
+ agents: options.agents || { agentFamily },
431
+ waitq: options.waitq || waitqMod,
432
+ rest: options.rest || restMod,
433
+ cfg: options.cfg || cfgMod,
434
+ chat: options.chat || chatMod,
435
+ now: options.now || defaultNow,
436
+ log: options.log || defaultLog,
437
+ env: options.env || process.env,
438
+ };
439
+ }
440
+
441
+ /** `app_config(user_id)`:`options.feishu`(feishu 模块替身)优先,否则 `feishu_cfg.appConfig` */
442
+ function appConfigOf(options, userId) {
443
+ if (options.feishu && typeof options.feishu.appConfig === 'function') {
444
+ return options.feishu.appConfig(userId, options);
445
+ }
446
+ return cfgMod.appConfig(userId, options);
447
+ }
448
+
449
+ // ==========================================================================
450
+ // 开关与小工具(`feishu_conv.py:53-55` / `:716-737` / `:918-920`)
451
+ // ==========================================================================
452
+
453
+ /**
454
+ * 通用对话总开关(默认开;`TS_FEISHU_CONV=0` 关闭)。
455
+ *
456
+ * **精确语义**:只有 `strip()` 后**恰好等于 `"0"`** 才关——`"0 "`/`" 0"` 关,
457
+ * `"00"`/`"0.0"`/`"false"`/`"FALSE"`/`"no"`/`""`/未设置**全开**。
458
+ * @param {{env?: NodeJS.ProcessEnv}} [options]
459
+ * @returns {boolean}
460
+ */
461
+ export function enabled(options = {}) {
462
+ const env = deps(options).env || {};
463
+ return pyStrStrip(pyOr(env[CONV_ENV], '')) !== '0';
464
+ }
465
+
466
+ /**
467
+ * binding 字段容错读取(`feishu_conv.py:726-732`):生产是 sqlite3.Row、用例里可能是普通
468
+ * dict,少键一律按缺省值处理。
469
+ *
470
+ * ⚠**不许写成 `binding[key] ?? default`**:SQLite NULL 列在 Python 侧是 `None`(**不是**缺键),
471
+ * 本函数会原样返回 `None`;`??` 会把 `null` 换成 default。判据是「键存在与否」。
472
+ */
473
+ function bindingValue(binding, key, dflt = '') {
474
+ if (binding === null || binding === undefined) return dflt;
475
+ if (typeof binding !== 'object') return dflt;
476
+ if (Array.isArray(binding)) return binding[key] === undefined ? dflt : binding[key];
477
+ if (!Object.hasOwn(binding, key)) return dflt;
478
+ return binding[key] === undefined ? null : binding[key];
479
+ }
480
+
481
+ /** 项目名展示截断(`feishu_conv.py:721-723`):`str(name or "")[:PROJECT_NAME_MAX]`(码点) */
482
+ function clipName(name) {
483
+ return pySlice(pyStr(pyOr(name, '')), PROJECT_NAME_MAX);
484
+ }
485
+
486
+ /** 应用凭据是否齐备(`feishu_conv.py:716-718`):`bool(cfg) and bool(app_id) and bool(app_secret)` */
487
+ function hasCreds(cfg) {
488
+ if (!pyTruthy(cfg)) return false;
489
+ return pyTruthy(dget(cfg, 'app_id')) && pyTruthy(dget(cfg, 'app_secret'));
490
+ }
491
+
492
+ /** stderr 留痕(`feishu_conv.py:918-920`):前缀逐字 `feishu_conv: ` */
493
+ function trace(text, options = {}) {
494
+ deps(options).log(`feishu_conv: ${text}`);
495
+ }
496
+
497
+ // ==========================================================================
498
+ // 卡片对象族(`feishu_conv.py:194-216`)
499
+ // ==========================================================================
500
+
501
+ /**
502
+ * 卡片对象(`_Card`,`feishu_conv.py:194-205`):元素按飞书 schema 2.0 放在 `body.elements`;
503
+ * `card.elements` 是**只读别名**,指向同一列表(便于调用方/用例按元素遍历)。
504
+ *
505
+ * 别名只在读口生效:`pyJsonDumps` 走真实键(`Object.keys` 不带不可枚举属性),发出去的 JSON
506
+ * 里没有顶层 `elements`——既不多占体积预算,也不会因未知字段被飞书拒收。
507
+ */
508
+ function makeCard(header, elements) {
509
+ const card = { schema: '2.0', header, body: { elements } };
510
+ Object.defineProperty(card, 'elements', {
511
+ get() { return this.body.elements; },
512
+ enumerable: false,
513
+ configurable: true,
514
+ });
515
+ return card;
516
+ }
517
+
518
+ /**
519
+ * 卡片实际发出的体积(字节):与 `rest_send_card` **同一序列化口径**
520
+ * (`feishu_rest.js:350` 用 `pyJsonDumps(card)`)——用 `JSON.stringify` 会少算 `", "`/`": "`
521
+ * 与转义差异,体积闸会在临界卡上分叉。
522
+ */
523
+ function cardBytes(card) {
524
+ return Buffer.byteLength(pyJsonDumps(card), 'utf8');
525
+ }
526
+
527
+ // ==========================================================================
528
+ // 项目总览族(`feishu_conv.py:187-340`)
529
+ // ==========================================================================
530
+
531
+ /** 该绑定用户的全部项目(多用户隔离:只查本人;`binding["user_id"]` 直接下标) */
532
+ function projectsOf(binding, options = {}) {
533
+ const d = deps(options);
534
+ return d.db.listProjects(requireKey(binding, 'user_id'), options);
535
+ }
536
+
537
+ /**
538
+ * 按 id 在**本人**的项目里定位(`feishu_conv.py:740-749`):不存在/非本人项目一律 None。
539
+ * `int(pid or 0)` 失败**吞成 None**(与本文件多数 `int()` 路径不同)。
540
+ */
541
+ export function projectById(binding, pid, options = {}) {
542
+ let want;
543
+ try {
544
+ want = pyIntStrict(pyOr(pid, 0));
545
+ } catch (e) {
546
+ if (e instanceof TypeError || (e && e.pyName === 'ValueError')) return null;
547
+ throw e;
548
+ }
549
+ for (const p of projectsOf(binding, options)) {
550
+ if (p.id === want) return p;
551
+ }
552
+ return null;
553
+ }
554
+
555
+ /** 单聊默认项目行(`feishu_conv.py:752-754`);`default_project_id` 走**直接下标** */
556
+ function defaultProject(binding, options = {}) {
557
+ return projectById(binding, requireKey(binding, 'default_project_id'), options);
558
+ }
559
+
560
+ /**
561
+ * 项目定位(`feishu_conv.py:757-769`,口径同 `feishu._resolve_project`):
562
+ * 默认项目 → 用户唯一项目 → `[None, 文案]`;报错分支列出可选项目名(顿号、最多 8 个)。
563
+ * @returns {[object|null, string]}
564
+ */
565
+ function locateProject(binding, options = {}) {
566
+ const project = defaultProject(binding, options);
567
+ if (project !== null) return [project, ''];
568
+ const owned = projectsOf(binding, options);
569
+ if (owned.length === 1) return [owned[0], ''];
570
+ if (owned.length === 0) return [null, NO_PROJECTS_GUIDE];
571
+ return [null, '请指明项目(回复「默认项目 <项目名>」),可选:'
572
+ + owned.slice(0, 8).map((p) => p.name).join('、')];
573
+ }
574
+
575
+ /**
576
+ * 看板五列卡片数(`feishu_conv.py:328-340`):doing/blocked/review 走批量口,
577
+ * todo/done 该口**不返回**(实测只有三列)⇒ 由 `list_board_cards` 现算补齐,
578
+ * 补齐值**无条件覆盖**(日后该口扩到五列也不会重复计)。
579
+ */
580
+ function boardCounts(pid, options = {}) {
581
+ const d = deps(options);
582
+ const counts = { todo: 0, doing: 0, blocked: 0, review: 0, done: 0 };
583
+ const many = pyOr(d.db.boardCountsMany([pid], options)[pid], {});
584
+ for (const key of Object.keys(many)) counts[key] = many[key];
585
+ const tail = { todo: 0, done: 0 };
586
+ for (const card of d.db.listBoardCards(pid, options)) {
587
+ const key = pyStr(pyOr(card.column_key, ''));
588
+ if (Object.hasOwn(tail, key)) tail[key] += 1;
589
+ }
590
+ counts.todo = tail.todo;
591
+ counts.done = tail.done;
592
+ return counts;
593
+ }
594
+
595
+ /** 单项目计数行(`feishu_conv.py:300-325`,逐字) */
596
+ function countsLine(project, options = {}) {
597
+ const d = deps(options);
598
+ const pid = project.id;
599
+ let running = 0;
600
+ let queued = 0;
601
+ for (const t of d.db.listTasks(pid, options)) {
602
+ const status = pyStr(pyOr(t.status, ''));
603
+ if (status === 'running') running += 1;
604
+ else if (status === 'queued') queued += 1;
605
+ }
606
+ const board = boardCounts(pid, options);
607
+ const kinds = {
608
+ [d.waitq.KIND_CARD]: 0, [d.waitq.KIND_MSG]: 0, [d.waitq.KIND_ANSWER]: 0, [d.waitq.KIND_EXT]: 0,
609
+ };
610
+ for (const it of d.waitq.activeItems(pid, options)) {
611
+ const kind = pyStr(pyOr(it.kind, ''));
612
+ if (Object.hasOwn(kinds, kind)) kinds[kind] += 1;
613
+ }
614
+ return `任务 运行 ${running}/排队 ${queued} · 看板 待开发 ${board.todo}/开发中 ${board.doing}`
615
+ + `/阻塞 ${board.blocked}/待审核 ${board.review}/已完成 ${board.done} · `
616
+ + `队列 卡 ${kinds[d.waitq.KIND_CARD]}/消息 ${kinds[d.waitq.KIND_MSG]}`
617
+ + `/作答 ${kinds[d.waitq.KIND_ANSWER]}/外部 ${kinds[d.waitq.KIND_EXT]}`;
618
+ }
619
+
620
+ /** 默认项目置顶、其余按 id 升序(`feishu_conv.py:236-237` 的 `sorted(key=...)`) */
621
+ function orderedProjects(projects, defaultId) {
622
+ return projects.slice().sort((a, b) => {
623
+ const ka = pyIntStrict(a.id) === defaultId ? 0 : 1;
624
+ const kb = pyIntStrict(b.id) === defaultId ? 0 : 1;
625
+ if (ka !== kb) return ka - kb;
626
+ return pyIntStrict(a.id) - pyIntStrict(b.id);
627
+ });
628
+ }
629
+
630
+ /**
631
+ * 总览卡根级元素(`feishu_conv.py:259-276`):每项目一块 markdown(`**名称** ✅/💬` [+ 计数行]),
632
+ * 有项目没列全时补一行文本引导,末尾是**唯一**的根级下拉控件(option.value = 项目 id)。
633
+ */
634
+ function projectsElements(binding, rows, counts, shown, withCounts) {
635
+ const els = [];
636
+ rows.slice(0, shown).forEach((row, i) => {
637
+ const [, name, marks] = row;
638
+ const head = `**${name}**` + (marks.length ? ` ${marks.join(' ')}` : '');
639
+ els.push({ tag: 'markdown', content: head + (withCounts ? `\n${counts[i]}` : '') });
640
+ });
641
+ const extra = rows.length - shown;
642
+ if (extra > 0) els.push({ tag: 'markdown', content: fmtD(PROJECTS_MORE_FMT, extra) });
643
+ els.push({
644
+ tag: 'select_static',
645
+ name: 'ts_projects',
646
+ placeholder: { tag: 'plain_text', content: '选择项目设为默认' },
647
+ options: rows.slice(0, shown).map(([pid, name]) => ({
648
+ text: { tag: 'plain_text', content: pySlice(name, 24) }, value: pyStr(pid),
649
+ })),
650
+ behaviors: [{ type: 'callback', value: { t: 'dp', u: requireKey(binding, 'user_id') } }],
651
+ });
652
+ return els;
653
+ }
654
+
655
+ /**
656
+ * 项目总览卡(`feishu_conv.py:219-256`):无项目返回 `null`(调用方回文本引导)。
657
+ *
658
+ * 体积三道闸(飞书对超限卡片整条拒收):① 最多列 `PROJECTS_MAX`;② 仍超 ⇒ 删计数行;
659
+ * ③ 仍超 ⇒ 项目数降到 `PROJECTS_FALLBACK` 再逐次折半(至少留 1 个)。降级 ②③ 都**不恢复**
660
+ * 计数行;`counts` 按全量算好,降级只裁展示条数。
661
+ */
662
+ function projectsCard(binding, options = {}) {
663
+ const projects = projectsOf(binding, options);
664
+ if (!projects.length) return null;
665
+ const def = defaultProject(binding, options);
666
+ const defaultId = def ? pyIntStrict(def.id) : 0;
667
+ let curPid = pyIntStrict(pyOr(bindingValue(binding, 'cur_project_id', 0), 0));
668
+ if (!pyTruthy(pyStrStrip(pyStr(pyOr(bindingValue(binding, 'cur_sid', ''), ''))))) {
669
+ curPid = 0; // 会话指针为空时 cur_project_id 无意义(不标 💬)
670
+ }
671
+ const ordered = orderedProjects(projects, defaultId);
672
+ const rows = ordered.map((p) => {
673
+ const pid = pyIntStrict(p.id);
674
+ const marks = [];
675
+ if (pid === defaultId) marks.push('✅');
676
+ if (pyTruthy(curPid) && pid === curPid) marks.push('💬');
677
+ return [pid, clipName(p.name), marks];
678
+ });
679
+ const counts = ordered.slice(0, PROJECTS_MAX).map((p) => countsLine(p, options));
680
+ const title = def ? clipName(def.name) : '未设置';
681
+ const header = {
682
+ title: { tag: 'plain_text', content: `项目总览(默认:${title})` },
683
+ template: 'orange',
684
+ };
685
+ const total = rows.length;
686
+ let shown = Math.min(total, PROJECTS_MAX);
687
+ let card = makeCard(header, projectsElements(binding, rows, counts, shown, true));
688
+ if (cardBytes(card) > CARD_MAX_BYTES) { // 降级 ②:删计数行
689
+ card = makeCard(header, projectsElements(binding, rows, counts, shown, false));
690
+ }
691
+ let limit = PROJECTS_FALLBACK; // 降级 ③:收缩项目数
692
+ while (cardBytes(card) > CARD_MAX_BYTES && shown > 1) {
693
+ shown = Math.min(shown, limit);
694
+ card = makeCard(header, projectsElements(binding, rows, counts, shown, false));
695
+ limit = Math.max(1, Math.floor(limit / 2));
696
+ }
697
+ return card;
698
+ }
699
+
700
+ /**
701
+ * 总览卡的**纯文本兜底**(`feishu_conv.py:279-297`):卡片未被受理时用。
702
+ * 排序与卡片一致;项目名同样过 `clipName`;文本没有 8000 字节预算问题 ⇒ 不降级、不裁条数。
703
+ */
704
+ function projectsText(binding, options = {}) {
705
+ const projects = projectsOf(binding, options);
706
+ if (!projects.length) return NO_PROJECTS_GUIDE;
707
+ const def = defaultProject(binding, options);
708
+ const defaultId = def ? pyIntStrict(def.id) : 0;
709
+ const lines = ['项目列表:'];
710
+ for (const p of orderedProjects(projects, defaultId)) {
711
+ const mark = pyIntStrict(p.id) === defaultId ? ' ✅默认' : '';
712
+ lines.push(`· ${clipName(p.name)}${mark}`);
713
+ }
714
+ lines.push(PROJECTS_TEXT_HINT);
715
+ return lines.join('\n');
716
+ }
717
+
718
+ // ==========================================================================
719
+ // 默认项目与卡片回调(`feishu_conv.py:343-423`)
720
+ // ==========================================================================
721
+
722
+ /**
723
+ * 设默认项目(卡片 `t=dp` 与文本指令共用同一段写路径),返回回执文案。
724
+ *
725
+ * 跨项目**自动解绑**(会话是项目目录下的实体,留着跨项目投递会打到错误的工作目录);
726
+ * 换项目同时作废「会话列表卡」的序号记忆(`_LAST_LIST` 是**某个项目下**的顺序)。
727
+ * 同项目重复设置:库照写、会话与记忆都不动。
728
+ *
729
+ * `int()` 失败**不 try**(照 oracle):由调用方 `execute_intent` 的 `except Exception` 兜住。
730
+ */
731
+ export function setDefaultProject(binding, project, options = {}) {
732
+ const d = deps(options);
733
+ const openId = requireKey(binding, 'open_id');
734
+ const changed = pyIntStrict(pyOr(bindingValue(binding, 'default_project_id', 0), 0))
735
+ !== pyIntStrict(project.id);
736
+ d.db.setFeishuDefaultProject(openId, project.id, options);
737
+ if (changed) _LAST_LIST.delete(openId);
738
+ let reply = `默认项目已设为「${project.name}」`;
739
+ const sid = pyStrStrip(pyStr(pyOr(bindingValue(binding, 'cur_sid', ''), '')));
740
+ if (pyTruthy(sid)
741
+ && pyIntStrict(pyOr(bindingValue(binding, 'cur_project_id', 0), 0)) !== pyIntStrict(project.id)) {
742
+ d.db.clearFeishuCurSession(openId, options);
743
+ reply += `\n${UNBIND_NOTE}`;
744
+ }
745
+ return reply;
746
+ }
747
+
748
+ /**
749
+ * 通用对话卡片回调(`feishu_conv.py:366-398`):`t=dp` 项目总览 / `t=ss` 会话列表 →
750
+ * 返回 toast;未知 `t` 返回 `null`(交给飞书兜底,不更新卡片)。
751
+ *
752
+ * 写操作前两道归属复核(多用户隔离):卡片 `value.u` == 点击者 `user_id`(防串号),且所选
753
+ * 项目 ∈ `list_projects(user_id)`;`t=ss` 的会话归属由 `bindSession` 复验**本项目会话桶**兜住。
754
+ * `sender` 未用(open_id 从 binding 取),保留以固定回调签名。
755
+ */
756
+ export async function onCardAction(sender, binding, value, action, cfg = null, options = {}) {
757
+ const v = pyOr(value, {});
758
+ const a = pyOr(action, {});
759
+ if (pyStr(pyOr(dget(v, 'u'), '')) !== pyStr(requireKey(binding, 'user_id'))) {
760
+ return { toast: { type: 'error', content: '这张卡片不属于当前账号' } };
761
+ }
762
+ if (pyStr(pyOr(dget(v, 't'), '')) === 'dp') {
763
+ const project = projectById(binding, selectedProjectId(a), options);
764
+ if (project === null) return { toast: { type: 'error', content: '项目不在你的列表里' } };
765
+ const reply = setDefaultProject(binding, project, options);
766
+ await dm(requireKey(binding, 'open_id'), reply, cfg, 0, options);
767
+ return { toast: { type: 'success', content: '已设为默认项目' } };
768
+ }
769
+ if (pyStr(pyOr(dget(v, 't'), '')) === 'ss') {
770
+ const sid = selectedSession(a);
771
+ if (!pyTruthy(sid)) return { toast: { type: 'error', content: '没有取到要绑定的会话' } };
772
+ const reply = bindSession(binding, sid, '', options);
773
+ await dm(requireKey(binding, 'open_id'), reply, cfg, 0, options); // 完整回执走私聊
774
+ if (reply === SESSION_GONE) {
775
+ return { toast: { type: 'error', content: '会话已不存在,发「会话」刷新列表' } };
776
+ }
777
+ if (SESSION_FAIL_TEXTS.includes(reply)) {
778
+ return { toast: { type: 'error', content: '绑定失败' } };
779
+ }
780
+ return { toast: { type: 'success', content: '已绑定该会话' } };
781
+ }
782
+ void sender; // oracle 留形参以固定回调签名;open_id 一律从 binding 取
783
+ return null;
784
+ }
785
+
786
+ /**
787
+ * 下拉回调里被选中的项目 id(`feishu_conv.py:401-412`):`action.option`(str 或 list,
788
+ * 取首个)→ 兜底 `action.value.i`;非数字返回 `0`(0 不是合法项目 id ⇒ 定位必然失败)。
789
+ */
790
+ function selectedProjectId(action) {
791
+ let raw = dget(pyOr(action, {}), 'option');
792
+ if (Array.isArray(raw)) raw = raw.length ? raw[0] : '';
793
+ if (raw === null || raw === undefined || raw === '') {
794
+ raw = dget(pyOr(dget(pyOr(action, {}), 'value'), {}), 'i');
795
+ }
796
+ try {
797
+ return pyIntStrict(pyStrStrip(pyStr(raw)));
798
+ } catch (e) {
799
+ if (e instanceof TypeError || (e && e.pyName === 'ValueError')) return 0;
800
+ throw e;
801
+ }
802
+ }
803
+
804
+ /** 下拉回调里被选中的会话 id(`feishu_conv.py:415-423`):取不到返回 `""`(绝不无依据绑定) */
805
+ function selectedSession(action) {
806
+ let raw = dget(pyOr(action, {}), 'option');
807
+ if (Array.isArray(raw)) raw = raw.length ? raw[0] : '';
808
+ if (raw === null || raw === undefined || raw === '') {
809
+ raw = dget(pyOr(dget(pyOr(action, {}), 'value'), {}), 'i');
810
+ }
811
+ return pyStrStrip(pyStr(pyOr(raw, '')));
812
+ }
813
+
814
+ // ==========================================================================
815
+ // 发送闸与凭据(`feishu_conv.py:676-718`)
816
+ // ==========================================================================
817
+
818
+ /**
819
+ * 给绑定用户发交互卡片(`项目`/`会话` 指令用):返回 `true` = 飞书已受理。
820
+ *
821
+ * 凭据不全只留痕返回 `false`(与 `_dm` 同闸——绝不用空凭据去打真实接口);异常同样只留痕
822
+ * 返回 `false`(卡片发不出不该让指令执行本身失败)。调用方据此决定是否补发**文本兜底**。
823
+ */
824
+ async function sendCard(openId, card, cfg, options = {}) {
825
+ const d = deps(options);
826
+ if (!hasCreds(cfg)) {
827
+ trace(`卡片跳过(应用凭据缺失)open_id=${pySlice(pyStr(openId), 12)}`, options);
828
+ return false;
829
+ }
830
+ try {
831
+ await d.rest.restSendCard(openId, card, cfg);
832
+ } catch (e) {
833
+ trace(`卡片发送失败: ${errorRepr(e)}`, options);
834
+ return false;
835
+ }
836
+ return true;
837
+ }
838
+
839
+ /**
840
+ * 给绑定用户发 DM 文本(卡片回执 / 答复回流共用)。
841
+ *
842
+ * **凭据前置闸**:`cfg` 缺 `app_id`/`app_secret` 时只留痕返回,绝不发网络请求;
843
+ * `cfg` 为假值(`None`/`{}`/`0`)时现取 `app_config(user_id or 0)`。不返回任何值。
844
+ */
845
+ async function dm(openId, text, cfg = null, userId = 0, options = {}) {
846
+ const d = deps(options);
847
+ const useCfg = pyTruthy(cfg) ? cfg : appConfigOf(options, pyOr(userId, 0));
848
+ if (!hasCreds(useCfg)) {
849
+ trace(`DM 跳过(应用凭据缺失)open_id=${pySlice(pyStr(openId), 12)}`, options);
850
+ return;
851
+ }
852
+ try {
853
+ await d.rest.restSendText(openId, text, useCfg);
854
+ } catch (e) {
855
+ trace(`DM 发送失败: ${errorRepr(e)}`, options);
856
+ }
857
+ }
858
+
859
+ // ==========================================================================
860
+ // 会话列表族、绑定 / 解绑(`feishu_conv.py:426-673`)
861
+ // ==========================================================================
862
+
863
+ /** 会话展示名(`feishu_conv.py:503-511`):标题 → 首问**首行** → sid 短码,截 SESSION_NAME_MAX */
864
+ function sessionName(item) {
865
+ let name = pyStrStrip(pyStr(pyOr(dget(item, 'title'), '')));
866
+ if (!name) {
867
+ const prompt = pyStrStrip(pyStr(pyOr(dget(item, 'first_prompt'), '')));
868
+ name = prompt ? pyStrStrip(pyFirstLine(prompt)) : '';
869
+ }
870
+ if (!name) name = pySlice(pyStr(pyOr(dget(item, 'sid'), '')), 12);
871
+ return pySlice(name, SESSION_NAME_MAX);
872
+ }
873
+
874
+ /**
875
+ * 相对活跃时间(`feishu_conv.py:565-580`):刚刚 / N 分钟前 / N 小时前 / N 天前
876
+ * (脏值/零/负值/未来一律「刚刚」;`%d` 是 `int(delta // N)`)。
877
+ */
878
+ function relTime(mtime, options = {}) {
879
+ const d = deps(options);
880
+ let ts;
881
+ try {
882
+ ts = pyFloat(mtime);
883
+ } catch (e) {
884
+ if (e instanceof TypeError || (e && e.pyName === 'ValueError')) return '刚刚';
885
+ throw e;
886
+ }
887
+ if (ts <= 0) return '刚刚';
888
+ const delta = d.now() - ts;
889
+ if (delta < 60) return '刚刚';
890
+ if (delta < 3600) return fmtD('%d 分钟前', Math.floor(delta / 60));
891
+ if (delta < 86400) return fmtD('%d 小时前', Math.floor(delta / 3600));
892
+ return fmtD('%d 天前', Math.floor(delta / 86400));
893
+ }
894
+
895
+ /**
896
+ * 会话是否正在跑 turn(仅展示用,`feishu_conv.py:548-562`)。
897
+ *
898
+ * 读口=`dshevents` 的进程内注册表(**零请求**):列表卡在入站线程里同步构建,逐会话打驱动
899
+ * `/status`(15s 超时 × 最多 SESSIONS_MAX 条)会把入站线程整条卡住。未连接中枢/未见过该会话
900
+ * ⇒ `get` 返回 None ⇒ **不标**(未知 ≠ 空闲);任何异常同样按「不标」处理。
901
+ */
902
+ function sessionBusy(sid, options = {}) {
903
+ const d = deps(options);
904
+ try {
905
+ const st = d.dshevents.get(sid);
906
+ return pyTruthy(st) && dget(st, 'status') === 'running';
907
+ } catch (e) {
908
+ trace(`会话忙闲探测失败 sid=${pySlice(pyStr(sid), 12)}: ${errorRepr(e)}`, options);
909
+ return false;
910
+ }
911
+ }
912
+
913
+ /**
914
+ * 单行 markdown(`feishu_conv.py:484-500`):`**序号. 名称** · 相对时间 · 状态标`。
915
+ * 状态标顺序固定:🏃 运行中 → 📦 已归档 → ✅ 当前(`" · "` 连接;无标记时整段为空)。
916
+ */
917
+ function sessionRow(index, item, curSid, options = {}) {
918
+ const marks = [];
919
+ if (sessionBusy(item.sid, options)) marks.push('🏃 运行中');
920
+ if (pyTruthy(dget(item, 'archived'))) marks.push('📦 已归档');
921
+ if (pyStr(item.sid) === pyStr(pyOr(curSid, ''))) marks.push('✅ 当前');
922
+ const content = `**${fmtD('%d', index)}. ${sessionName(item)}** · ${relTime(dget(item, 'mtime'), options)}`
923
+ + (marks.length ? ` · ${marks.join(' ')}` : '');
924
+ return { tag: 'markdown', content };
925
+ }
926
+
927
+ /** 列表卡根级元素(`feishu_conv.py:470-481`):`rows[:shown]` + **唯一**根级下拉 */
928
+ function sessionsElements(rows, items, shown, userId) {
929
+ const els = rows.slice(0, shown);
930
+ els.push({
931
+ tag: 'select_static',
932
+ name: 'ts_sessions',
933
+ placeholder: { tag: 'plain_text', content: '选择要绑定的会话' },
934
+ options: items.slice(0, shown).map((it) => ({
935
+ text: { tag: 'plain_text', content: pySlice(sessionName(it), 24) },
936
+ value: pyStr(it.sid),
937
+ })),
938
+ behaviors: [{ type: 'callback', value: { t: 'ss', u: userId } }],
939
+ });
940
+ return els;
941
+ }
942
+
943
+ /** 登记本次列表卡的会话顺序(`feishu_conv.py:540-545`);写前清理过期桶(严格 `>`) */
944
+ function rememberList(openId, sids, options = {}) {
945
+ const d = deps(options);
946
+ const now = d.now();
947
+ for (const key of [..._LAST_LIST.keys()]) {
948
+ const entry = _LAST_LIST.get(key);
949
+ if (now - entry[0] > LIST_TTL) _LAST_LIST.delete(key);
950
+ }
951
+ _LAST_LIST.set(openId, [now, sids.map((s) => pyStr(s))]);
952
+ }
953
+
954
+ /**
955
+ * 会话列表卡(`feishu_conv.py:434-467`):无默认项目返回 `null`;零会话返回**有卡片无控件**
956
+ * (一块引导 markdown,同时登记空表——序号必然越界)。
957
+ *
958
+ * 数据源已按 mtime 倒序(`sessparse.list_sessions`),取前 `SESSIONS_MAX`;登记条数与卡片实际
959
+ * 列出的条数**始终一致**(体积降级后同步收缩,防序号错位)。行内容只算一次:降级只裁条数。
960
+ */
961
+ function sessionsCard(binding, options = {}) {
962
+ const d = deps(options);
963
+ const project = defaultProject(binding, options);
964
+ if (project === null) return null;
965
+ const items = d.sessparse.list_sessions('dsh', project.project_dir).slice(0, SESSIONS_MAX);
966
+ const openId = requireKey(binding, 'open_id');
967
+ const header = {
968
+ title: { tag: 'plain_text', content: `会话列表(默认:${clipName(project.name)})` },
969
+ template: 'blue',
970
+ };
971
+ if (!items.length) {
972
+ rememberList(openId, [], options);
973
+ return makeCard(header, [{ tag: 'markdown', content: SESSION_EMPTY }]);
974
+ }
975
+ const curSid = pyStrStrip(pyStr(pyOr(bindingValue(binding, 'cur_sid', ''), '')));
976
+ const rows = items.map((it, i) => sessionRow(i + 1, it, curSid, options));
977
+ let shown = items.length;
978
+ let card = makeCard(header, sessionsElements(rows, items, shown, requireKey(binding, 'user_id')));
979
+ let limit = Math.floor(shown / 2);
980
+ while (cardBytes(card) > CARD_MAX_BYTES && shown > 1) {
981
+ shown = Math.min(shown, limit);
982
+ card = makeCard(header, sessionsElements(rows, items, shown, requireKey(binding, 'user_id')));
983
+ limit = Math.max(1, Math.floor(limit / 2));
984
+ }
985
+ rememberList(openId, items.slice(0, shown).map((it) => it.sid), options);
986
+ return card;
987
+ }
988
+
989
+ /**
990
+ * 列表卡的**纯文本兜底**(`feishu_conv.py:514-537`):序号取自 `_LAST_LIST[open_id]`
991
+ * (`sessions_card` 刚登记的那份:与卡片下拉严格同序,且已随体积降级同步收缩);
992
+ * 记忆缺失(理论不可达)才现取一份。标题按 sid 从当前会话集**回查**(第二次读盘)。
993
+ */
994
+ function sessionsText(binding, options = {}) {
995
+ const entry = _LAST_LIST.get(requireKey(binding, 'open_id'));
996
+ const sids = entry
997
+ ? entry[1].map((s) => pyStr(s))
998
+ : projectSessions(binding, options).slice(0, SESSIONS_MAX).map((it) => pyStr(it.sid));
999
+ if (!sids.length) return SESSION_EMPTY;
1000
+ const bySid = new Map(projectSessions(binding, options).map((it) => [pyStr(it.sid), it]));
1001
+ const project = defaultProject(binding, options);
1002
+ const lines = [project ? `「${clipName(project.name)}」最近会话:` : '最近会话:'];
1003
+ sids.forEach((sid, i) => {
1004
+ const item = bySid.get(sid) || { sid };
1005
+ lines.push(`${i + 1}. ${sessionName(item)}(${pySlice(sid, 12)})`);
1006
+ });
1007
+ lines.push(SESSIONS_TEXT_HINT);
1008
+ return lines.join('\n');
1009
+ }
1010
+
1011
+ /**
1012
+ * 会话绑定用到的项目(`feishu_conv.py:583-593`):默认项目 → 当前会话所属项目(按
1013
+ * `cur_project_id` 还原)→ None。两处都过 `projectById`,非本人项目一律 None(多用户隔离)。
1014
+ */
1015
+ function sessionProject(binding, options = {}) {
1016
+ let project = defaultProject(binding, options);
1017
+ if (project === null) {
1018
+ project = projectById(binding, bindingValue(binding, 'cur_project_id', 0), options);
1019
+ }
1020
+ return project;
1021
+ }
1022
+
1023
+ /**
1024
+ * `sessionProject` 下的会话列表(`feishu_conv.py:596-603`);项目缺失/读不到返回 `[]`。
1025
+ * 同时是 `bindSession` 的**归属校验数据源**。
1026
+ */
1027
+ function projectSessions(binding, options = {}) {
1028
+ const d = deps(options);
1029
+ const project = sessionProject(binding, options);
1030
+ if (project === null) return [];
1031
+ return d.sessparse.list_sessions('dsh', project.project_dir);
1032
+ }
1033
+
1034
+ /**
1035
+ * 绑定会话(`t=ss` 回调与「绑定会话 …」文本兜底共用),返回回执文案。
1036
+ *
1037
+ * **所有「选中一个 sid 写绑定」的路径都必须走这里**,写前做一次项目归属复验:sid 必须落在
1038
+ * **该项目 `project_dir` 的会话桶**内,判据是 `list_sessions` 的结果集(**不拿 sid 直接开文件**
1039
+ * ——`session_exists` 是跨 bucket 的纯路径存在性,验不出「这个 sid 属不属于当前项目」)。
1040
+ * 复验不过**一个字都不写**。返回文案命中 `SESSION_FAIL_TEXTS` 即表示没写绑定。
1041
+ *
1042
+ * 唯一不经此处的写路径是**新建**会话(`createInProject`)。
1043
+ */
1044
+ function bindSession(binding, sid, title = '', options = {}) {
1045
+ const d = deps(options);
1046
+ const want = pyStrStrip(pyStr(pyOr(sid, '')));
1047
+ const project = sessionProject(binding, options);
1048
+ if (project === null) return NO_DEFAULT_GUIDE;
1049
+ const known = new Set(projectSessions(binding, options).map((it) => pyStr(it.sid)));
1050
+ if (!known.has(want)) return SESSION_GONE;
1051
+ const finalTitle = pyStrStrip(pyStr(pyOr(title, ''))) || d.sessparse.session_title('dsh', want);
1052
+ d.db.setFeishuCurSession(requireKey(binding, 'open_id'), project.id, want, finalTitle, options);
1053
+ return `已绑定「${finalTitle || pySlice(want, 12)}」(项目:${project.name}),`
1054
+ + '接下来发消息都进这个会话';
1055
+ }
1056
+
1057
+ /**
1058
+ * 文本兜底绑定(`feishu_conv.py:636-662`,「绑定会话 <序号|标题关键字|会话id前缀>」)。
1059
+ *
1060
+ * 三分支:① 纯数字 ⇒ 上一张列表卡的序号(`_LAST_LIST`,TTL `LIST_TTL`;过期/越界只回引导,
1061
+ * **绝不瞎猜「第 1 条」**);② 标题包含唯一命中 ⇒ 绑定,多命中回候选(最多 8 条,不猜);
1062
+ * ③ sid **前缀**唯一命中 ⇒ 绑定(`startswith` 不是 `in`)。都不中 ⇒ 引导发「会话」。
1063
+ */
1064
+ function useByToken(binding, token, options = {}) {
1065
+ const d = deps(options);
1066
+ const want = pyStrStrip(pyStr(pyOr(token, '')));
1067
+ if (!want) return SESSION_STALE;
1068
+ if (pyIsDigit(want)) {
1069
+ const entry = _LAST_LIST.get(requireKey(binding, 'open_id'));
1070
+ const idx = pyIntStrict(want) - 1;
1071
+ if (!pyTruthy(entry) || (d.now() - entry[0]) > LIST_TTL || !(idx >= 0 && idx < entry[1].length)) {
1072
+ return SESSION_STALE;
1073
+ }
1074
+ return bindSession(binding, entry[1][idx], '', options);
1075
+ }
1076
+ const items = projectSessions(binding, options);
1077
+ let hits = items.filter((it) => pyStr(pyOr(dget(it, 'title'), '')).includes(want));
1078
+ if (!hits.length) {
1079
+ hits = items.filter((it) => pyStr(pyOr(dget(it, 'sid'), '')).startsWith(want));
1080
+ }
1081
+ if (hits.length === 1) {
1082
+ return bindSession(binding, hits[0].sid, pyOr(dget(hits[0], 'title'), ''), options);
1083
+ }
1084
+ if (hits.length > 1) {
1085
+ const body = hits.slice(0, 8)
1086
+ .map((it) => `· ${sessionName(it)}(${pySlice(pyStr(it.sid), 12)})`).join('\n');
1087
+ return fmtS2(SESSION_MULTI_FMT, want, body);
1088
+ }
1089
+ return fmtS(SESSION_MISS_FMT, want);
1090
+ }
1091
+
1092
+ /** 「解绑会话」(`feishu_conv.py:665-673`):只清当前会话绑定(默认项目原样保留) */
1093
+ function leaveSession(binding, options = {}) {
1094
+ const d = deps(options);
1095
+ const sid = pyStrStrip(pyStr(pyOr(bindingValue(binding, 'cur_sid', ''), '')));
1096
+ if (!pyTruthy(sid)) return '当前未绑定会话';
1097
+ const project = defaultProject(binding, options);
1098
+ const name = project !== null ? project.name : '未设置';
1099
+ d.db.clearFeishuCurSession(requireKey(binding, 'open_id'), options);
1100
+ return `已解绑当前会话(默认项目保持为「${name}」),下次发消息将自动新建`;
1101
+ }
1102
+
1103
+ // ==========================================================================
1104
+ // 建会话 / 当前回执 / 新建会话(`feishu_conv.py:58-88` / `:772-814`)
1105
+ // ==========================================================================
1106
+
1107
+ /**
1108
+ * 「当前」回执(`feishu_conv.py:58-74`):默认项目 + 当前会话四要素(标题 / sid 前 12 位 /
1109
+ * 绑定时间 / 所属项目);未绑定会话时明说「未绑定会话,发消息将自动新建」。
1110
+ */
1111
+ function currentText(binding, options = {}) {
1112
+ const project = defaultProject(binding, options);
1113
+ const lines = [`默认项目:${project ? project.name : '未设置'}`];
1114
+ const sid = pyStrStrip(pyOr(requireKey(binding, 'cur_sid'), ''));
1115
+ if (!pyTruthy(sid)) {
1116
+ lines.push('当前会话:未绑定会话,发消息将自动新建');
1117
+ return lines.join('\n');
1118
+ }
1119
+ const title = pyStrStrip(pyOr(requireKey(binding, 'cur_title'), '')) || pySlice(sid, 12);
1120
+ lines.push(`当前会话:${title}(${pySlice(sid, 12)})`);
1121
+ const boundAt = pyStrStrip(pyOr(requireKey(binding, 'cur_bound_at'), ''));
1122
+ if (pyTruthy(boundAt)) lines.push(`绑定时间:${boundAt}`);
1123
+ const owner = projectById(binding, requireKey(binding, 'cur_project_id'), options);
1124
+ lines.push(`所属项目:${owner ? owner.name : '(项目已不在你的列表)'}`);
1125
+ return lines.join('\n');
1126
+ }
1127
+
1128
+ /** 投递前条目基线(`feishu_conv.py:809-814`):回流按 `after=baseline` 只取本轮新增(异常回落 0) */
1129
+ function baselineOf(sid, options = {}) {
1130
+ const d = deps(options);
1131
+ try {
1132
+ const data = d.sessparse.load('dsh', sid, 'main', 0);
1133
+ return pyIntStrict(pyOr(dget(data, 'total', 0), 0));
1134
+ } catch (e) {
1135
+ return 0;
1136
+ }
1137
+ }
1138
+
1139
+ /**
1140
+ * 在项目目录下新建 dsh 会话并写「当前会话」绑定(`feishu_conv.py:772-806`)。
1141
+ * @returns {Promise<[string, string]>} `[sid, err]`,err 非空 = 失败(失败路径一律不写绑定)
1142
+ */
1143
+ async function createInProject(binding, project, title, options = {}) {
1144
+ const d = deps(options);
1145
+ if (!pyTruthy(d.dshdriver.configured())) return ['', DRIVER_NEEDED];
1146
+ const finalTitle = pyStrStrip(pyOr(title, '')) || newTitle(options);
1147
+ // 项目级模型必须在**建会话时**下传:宿主 `/session` 不拆 `provider/id` 前缀,故先
1148
+ // `splitModel` 拆成 provider + 裸 id;只喂 applySessionDefaults 会漏(它仅在
1149
+ // reasoning_effort 非空时才调 set_model)⇒ 只配模型的项目会静默跑在宿主默认模型上。
1150
+ const [modelProvider, modelId] = d.dshdriver.splitModel(pyStr(pyOr(project.model, '')));
1151
+ let sid;
1152
+ try {
1153
+ sid = await d.dshdriver.createSession(project.project_dir, finalTitle, modelId, modelProvider);
1154
+ } catch (e) {
1155
+ trace(`新建会话失败 project=${project.id}: ${errorRepr(e)}`, options);
1156
+ return ['', `新建会话失败:${errText(e)}`];
1157
+ }
1158
+ try {
1159
+ await d.dshdriver.applySessionDefaults(
1160
+ sid, modelId, modelProvider,
1161
+ pyStrStrip(pyStr(pyOr(project.reasoning_effort, ''))),
1162
+ pyStrStrip(pyStr(pyOr(project.permission_mode, ''))),
1163
+ (text) => trace(text, options),
1164
+ );
1165
+ } catch (e) {
1166
+ trace(`会话默认值下发失败 sid=${sid}: ${errorRepr(e)}`, options); // 只留痕,不阻断
1167
+ }
1168
+ try {
1169
+ await d.dshdriver.rename(sid, finalTitle);
1170
+ } catch (e) {
1171
+ trace(`会话改名失败 sid=${sid}: ${errorRepr(e)}`, options); // 只留痕,不阻断
1172
+ }
1173
+ d.db.setFeishuCurSession(requireKey(binding, 'open_id'), project.id, sid, finalTitle, options);
1174
+ return [sid, ''];
1175
+ }
1176
+
1177
+ /** 新建会话的缺省标题(`feishu_conv.py:913-915`):本地时间 `飞书对话 %m-%d %H:%M`(零填充) */
1178
+ function newTitle(options = {}) {
1179
+ const dt = new Date(deps(options).now() * 1000);
1180
+ const pad = (n) => String(n).padStart(2, '0');
1181
+ return `飞书对话 ${pad(dt.getMonth() + 1)}-${pad(dt.getDate())} `
1182
+ + `${pad(dt.getHours())}:${pad(dt.getMinutes())}`;
1183
+ }
1184
+
1185
+ /**
1186
+ * 「新建会话 [标题]」(`feishu_conv.py:77-88`):默认项目下新建 dsh 会话并绑定,回执写明项目
1187
+ * 与看板副作用(新会话落在 `project_dir`,看板 `sync_sessions` 会自动建一张 sync 卡,不占运行位)。
1188
+ */
1189
+ async function newSession(binding, title = '', options = {}) {
1190
+ const [project, err] = locateProject(binding, options);
1191
+ if (project === null) return err;
1192
+ const finalTitle = pyStrStrip(pyOr(title, '')) || newTitle(options);
1193
+ const [sid, createErr] = await createInProject(binding, project, finalTitle, options);
1194
+ if (pyTruthy(createErr)) return createErr;
1195
+ void sid;
1196
+ return `已新建并绑定「${finalTitle}」(项目:${project.name}),接下来发消息都进这个会话;`
1197
+ + '它也会出现在看板(sync 卡,不占运行位)';
1198
+ }
1199
+
1200
+ // ==========================================================================
1201
+ // R3 主入口:单聊自由文本路由(`feishu_conv.py:91-148`)
1202
+ // ==========================================================================
1203
+
1204
+ /**
1205
+ * 单聊自由文本路由(四分支):有会话直投 → 无会话按默认项目自动新建 → 投递 → 引导。
1206
+ *
1207
+ * 0. **长度闸**:超 `chat.MESSAGE_MAX`(与站点 `POST …/messages` 同一常量)⇒ 直接回中文回执
1208
+ * 并给两条出路(改发文件/附件、或拆成几条),**什么都不提交**(最先 return,不读库、不建会话);
1209
+ * 1. 有 `cur_sid` 且项目仍归本人、会话仍存在 ⇒ 直接投递;失效(会话被删/归档清理/项目易主)
1210
+ * ⇒ 自动解绑 + 留痕提示,再按「无会话」处理;
1211
+ * 2. 无会话 ⇒ 默认项目(回落到用户唯一项目)下自动新建并绑定,再投递;
1212
+ * 3. 投递走 `chat.submit`(统一队列 `m:` 行),`extraMeta` 带 `baseline` 供答复回流只取本轮新增;
1213
+ * 4. 无默认项目(多项目未指定 / 无项目)⇒ 回引导文案 + **项目总览卡**(点选即设默认)。
1214
+ *
1215
+ * ⚠**`note` 两处拼接形态不同**:`:125` 用 `"\n" + note`、`:148` 用 `+ note` 直贴——照抄,别「统一」。
1216
+ *
1217
+ * @param {object} binding `feishu_bindings` 行(四列指针)
1218
+ * @param {string} text 已剥 @ 且非空的文本(主流程保证)
1219
+ * @param {object|null} cfg 应用凭据(可为假值 ⇒ 发送闸只留痕)
1220
+ * @param {object} [options] 依赖注入(见文件头差异 C)
1221
+ * @returns {Promise<string>} 回飞书的文本(**唯一直接调用点**:`feishu_inbound.js` 的
1222
+ * `handleMessageEvent`,条件「单聊 + 意图未识别 + 总开关开」)
1223
+ */
1224
+ export async function routeText(binding, text, cfg = null, options = {}) {
1225
+ const d = deps(options);
1226
+ if (pyLen(pyOr(text, '')) > d.chat.MESSAGE_MAX) {
1227
+ return fmtD(MESSAGE_TOO_LONG, d.chat.MESSAGE_MAX);
1228
+ }
1229
+ const openId = requireKey(binding, 'open_id');
1230
+ let note = '';
1231
+ let sid = pyStrStrip(pyOr(requireKey(binding, 'cur_sid'), ''));
1232
+ let title = pyStrStrip(pyOr(requireKey(binding, 'cur_title'), ''));
1233
+ let project = null;
1234
+ if (pyTruthy(sid)) {
1235
+ project = projectById(binding, requireKey(binding, 'cur_project_id'), options);
1236
+ if (project === null || !pyTruthy(d.sessparse.session_exists('dsh', sid))) {
1237
+ d.db.clearFeishuCurSession(openId, options);
1238
+ sid = '';
1239
+ title = '';
1240
+ project = null;
1241
+ note = STALE_NOTE;
1242
+ }
1243
+ }
1244
+ if (!pyTruthy(sid)) {
1245
+ const located = locateProject(binding, options);
1246
+ project = located[0];
1247
+ // 设计 §3.4 分支 4:无项目时 projects_card 为 None,只回文案,绝不给一张空卡;
1248
+ // 卡片发送走凭据闸,缺凭据只留痕(返回值**故意丢弃**:这一档没有文本兜底)
1249
+ if (project === null) {
1250
+ const card = projectsCard(binding, options);
1251
+ if (card !== null) await sendCard(openId, card, cfg, options);
1252
+ return NO_DEFAULT_GUIDE + (pyTruthy(note) ? `\n${note}` : '');
1253
+ }
1254
+ title = newTitle(options);
1255
+ const created = await createInProject(binding, project, title, options);
1256
+ sid = created[0];
1257
+ if (pyTruthy(created[1])) return created[1] + (pyTruthy(note) ? `\n${note}` : '');
1258
+ // 创建路径内已写绑定;这里**重申**一次(幂等):路由侧不依赖创建函数的副作用
1259
+ // ——「这条消息投到哪个会话」必须与库里的绑定指针一致(第二次写会刷新 cur_bound_at)
1260
+ d.db.setFeishuCurSession(openId, project.id, sid, title, options);
1261
+ }
1262
+ if (!pyTruthy(title)) title = pySlice(sid, 12);
1263
+ const baseline = baselineOf(sid, options);
1264
+ let res;
1265
+ try {
1266
+ res = await d.chat.submit(project.id, sid, text, {
1267
+ ...options,
1268
+ family: d.agents.agentFamily(project.agent_path),
1269
+ extraMeta: {
1270
+ feishu: {
1271
+ open_id: openId,
1272
+ user_id: requireKey(binding, 'user_id'),
1273
+ project_id: project.id,
1274
+ baseline,
1275
+ title,
1276
+ },
1277
+ },
1278
+ });
1279
+ } catch (e) {
1280
+ trace(`投递失败 sid=${sid}: ${errorRepr(e)}`, options);
1281
+ return `投递失败:${errText(e)}` + (pyTruthy(note) ? `\n${note}` : '');
1282
+ }
1283
+ const state = pyTruthy(res.queued) ? '排队中' : '执行中';
1284
+ return `已投递到「${title}」(${state}),跑完把答复发回这里` + note;
1285
+ }
1286
+
1287
+ // ==========================================================================
1288
+ // 指令执行器(`feishu_conv.py:151-184`)
1289
+ // ==========================================================================
1290
+
1291
+ /**
1292
+ * 六条新指令的执行器(`feishu.execute_intent` 分派进来)。
1293
+ *
1294
+ * `current`/`new`/`use`/`leave` 回文本;`projects`/`sessions` 发对应卡片(**卡片即回执**,
1295
+ * 返回空串 ⇒ 调用方不另发文本;卡片没被受理才走文本兜底);未知动作兜底回帮助(不静默、不留白)。
1296
+ * `senderOpenId` 未用(形参只为对齐 `feishu.execute_intent` 的调用形状)。
1297
+ *
1298
+ * @returns {Promise<string>} 回复文案(**空串 = 不回复**)
1299
+ */
1300
+ export async function convIntent(binding, action, groups, senderOpenId, cfg = null, options = {}) {
1301
+ const g = pyOr(groups, []);
1302
+ if (action === 'current') return currentText(binding, options);
1303
+ if (action === 'projects') {
1304
+ const card = projectsCard(binding, options);
1305
+ if (card === null) return NO_PROJECTS_GUIDE;
1306
+ if (!await sendCard(requireKey(binding, 'open_id'), card, cfg, options)) {
1307
+ return projectsText(binding, options); // 卡片没被受理 ⇒ 文本兜底,绝不静默
1308
+ }
1309
+ return ''; // 卡片即回执,不另发文本(execute_intent 见空串不发)
1310
+ }
1311
+ if (action === 'sessions') {
1312
+ const card = sessionsCard(binding, options);
1313
+ if (card === null) return NO_DEFAULT_GUIDE;
1314
+ if (!await sendCard(requireKey(binding, 'open_id'), card, cfg, options)) {
1315
+ return sessionsText(binding, options); // 同上:卡片没被受理 ⇒ 文本兜底
1316
+ }
1317
+ return '';
1318
+ }
1319
+ if (action === 'new') {
1320
+ const first = g.length ? g[0] : null;
1321
+ return newSession(binding, pyStrStrip(pyOr(first, '')), options);
1322
+ }
1323
+ if (action === 'use') return useByToken(binding, g.length ? g[0] : '', options);
1324
+ if (action === 'leave') return leaveSession(binding, options);
1325
+ // 未知动作兜底回帮助(生产路径不可达:`execute_intent` 的 action 白名单硬编码六条)。
1326
+ // `HELP_TEXT` 在 `feishu_inbound.js`(R2);懒 import 躲开
1327
+ // `feishu_conv → feishu_inbound → feishu → feishu_conv` 的静态环。
1328
+ const inbound = await import('./feishu_inbound.js');
1329
+ void senderOpenId;
1330
+ return inbound.HELP_TEXT;
1331
+ }
1332
+
1333
+ // ==========================================================================
1334
+ // 答复回流(`feishu_conv.py:817-915`)
1335
+ // ==========================================================================
1336
+
1337
+ /**
1338
+ * 注册消息终态钩子(`feishu.start_notifier` 调用;幂等)。
1339
+ *
1340
+ * 钩子是**单一注册位**(`chat.setMsgDoneHook` 后注册覆盖先注册),重复调用等价于再注册一次
1341
+ * ——无副作用、不叠加。**这是本模块唯一的接线点,漏掉则回流全哑且无任何报错**
1342
+ * (`feishu.py:2669-2670` 原话):必须在端口绑定之前、且早于投递线程。
1343
+ *
1344
+ * Node 差异:注册的是**绑定了本次 `options` 的闭包**(oracle 注册的是模块函数、靠模块全局取
1345
+ * 依赖)——`chat.fireMsgDone` 只传 `(msgId, state)`,没有 options 位,故依赖必须在注册时定格。
1346
+ * @returns {void}
1347
+ */
1348
+ export function start(options = {}) {
1349
+ const chat = deps(options).chat;
1350
+ chat.setMsgDoneHook((msgId, state) => notifyMsgDone(msgId, state, options));
1351
+ }
1352
+
1353
+ /**
1354
+ * 消息单元终态 → 把本轮答复推回飞书(`feishu_conv.py:835-866`;非飞书来源直接返回)。
1355
+ *
1356
+ * state 三态:`yielded`=turn 挂起等作答,**不推**(作答链路自己会回卡/回执);`error`=推失败
1357
+ * 回执(带 `chat_msgs.error` 摘要);`done`=抽 baseline 之后的新增 assistant 文本推出。
1358
+ * 任何异常都不外抛(钩子是旁路)——Python 侧靠 `chat._fire_msg_done` 的 except 兜,
1359
+ * Node 侧 `chat.fireMsgDone` 同样兜同步抛与 thenable 拒绝(本函数是 async)。
1360
+ */
1361
+ export async function notifyMsgDone(msgId, state, options = {}) {
1362
+ const d = deps(options);
1363
+ const meta = pyOr(dget(pyOr(d.waitq.msgMeta(msgId, options), {}), 'feishu'), {});
1364
+ if (!pyTruthy(meta)) return;
1365
+ const openId = pyOr(dget(meta, 'open_id'), '');
1366
+ if (!pyTruthy(openId)) return;
1367
+ if (!bindingCurrent(openId, dget(meta, 'user_id'), options)) {
1368
+ trace(`答复跳过(绑定已变更)open_id=${pySlice(pyStr(openId), 12)}`, options);
1369
+ return;
1370
+ }
1371
+ if (state === 'yielded') {
1372
+ // **stdout**(不是 _trace 的 stderr!),前缀带方括号
1373
+ console.log(`[feishu_conv] 会话挂起等作答,不推答复:${msgId}`);
1374
+ return;
1375
+ }
1376
+ if (state === 'error') {
1377
+ const row = d.waitq.msgGet(msgId, options);
1378
+ const err = pySlice(pyOr(row !== null && row !== undefined ? row.error : '', ''), 200);
1379
+ await dm(openId, `本轮执行失败:${pyOr(err, '未知原因')}(可在站点会话窗查看详情)`,
1380
+ null, dget(meta, 'user_id'), options);
1381
+ return;
1382
+ }
1383
+ const row = d.waitq.msgGet(msgId, options);
1384
+ const sid = pyOr(row !== null && row !== undefined ? row.sid : '', '');
1385
+ const reply = clipReply(replyOf(sid, pyIntStrict(pyOr(dget(meta, 'baseline'), 0)), options));
1386
+ if (pyTruthy(reply)) await dm(openId, reply, null, dget(meta, 'user_id'), options);
1387
+ }
1388
+
1389
+ /**
1390
+ * 推送前的绑定归属复核(`feishu_conv.py:869-884`):open_id 当前仍有绑定行、且其 `user_id`
1391
+ * 与载荷一致。载荷里的 `open_id` 是**提交那一刻**定格的;用户提交后可能解绑或改绑他人——
1392
+ * 答复含会话正文,只能送给提交者本人。库读写异常一律按「复核不过」处理:宁可不推,绝不误推。
1393
+ */
1394
+ function bindingCurrent(openId, userId, options = {}) {
1395
+ const d = deps(options);
1396
+ let row;
1397
+ try {
1398
+ row = d.db.getFeishuBindingByOpen(openId, options);
1399
+ } catch (e) {
1400
+ trace(`绑定复核失败 open_id=${pySlice(pyStr(openId), 12)}: ${errorRepr(e)}`, options);
1401
+ return false;
1402
+ }
1403
+ if (row === null || row === undefined) return false;
1404
+ return pyStr(row.user_id) === pyStr(userId);
1405
+ }
1406
+
1407
+ /**
1408
+ * 取该会话 `baseline` 之后的新增 assistant 文本(`feishu_conv.py:887-902`)。
1409
+ * 只拼 assistant(think 是草稿、tool_* 是过程,都不是给用户的答复);双换行连接、纯空白片段
1410
+ * 丢弃;异常返回空串。
1411
+ */
1412
+ function replyOf(sid, baseline, options = {}) {
1413
+ const d = deps(options);
1414
+ let data;
1415
+ try {
1416
+ data = d.sessparse.load('dsh', sid, 'main', baseline);
1417
+ } catch (e) {
1418
+ return '';
1419
+ }
1420
+ if (!pyTruthy(dget(data, 'found'))) return '';
1421
+ const parts = pyOr(dget(data, 'entries'), [])
1422
+ .filter((e) => dget(e, 'kind') === 'assistant')
1423
+ .map((e) => pyOr(dget(e, 'text'), ''));
1424
+ return parts.filter((p) => pyTruthy(pyStrStrip(pyStr(p)))).join('\n\n');
1425
+ }
1426
+
1427
+ /** 答复截断(`feishu_conv.py:905-910`):超 `REPLY_MAX_CHARS` 保留首段并附站点指引(码点口径) */
1428
+ function clipReply(text) {
1429
+ const value = pyStrStrip(pyOr(text, ''));
1430
+ if (pyLen(value) <= REPLY_MAX_CHARS) return value;
1431
+ return pySlice(value, REPLY_MAX_CHARS - 200)
1432
+ + '\n……(内容过长已截断,完整内容见站点会话窗 / 看板)';
1433
+ }
1434
+
1435
+ // ==========================================================================
1436
+ // 导出面:冻结接口 7 个(R2 的 `d.conv.*` 调用点)+ 常量 + 用例读口
1437
+ // ==========================================================================
1438
+
1439
+ // `feishu_inbound.js` 的惰性转发器按这 7 个名字取(名字/位置参数签错一位就断链):
1440
+ // enabled(options) / routeText(binding, text, cfg, options) /
1441
+ // convIntent(binding, action, groups, senderOpenId, cfg, options) /
1442
+ // onCardAction(sender, binding, value, action, cfg, options) /
1443
+ // setDefaultProject(binding, project, options) / projectById(binding, projectId, options) /
1444
+ // start(options) —— 全部可 async,调用方都 await。
1445
+
1446
+ /** 用例/诊断读口(Python 私有名 → Node `_` 前缀别名,与 R2 的 `_parse_intent` 同风格) */
1447
+ export const _card = makeCard;
1448
+ export const _card_bytes = cardBytes;
1449
+ export const _projects_card = projectsCard;
1450
+ export const _projects_elements = projectsElements;
1451
+ export const _projects_text = projectsText;
1452
+ export const _sessions_card = sessionsCard;
1453
+ export const _sessions_text = sessionsText;
1454
+ export const _session_row = sessionRow;
1455
+ export const _session_name = sessionName;
1456
+ export const _rel_time = relTime;
1457
+ export const _session_busy = sessionBusy;
1458
+ export const _counts_line = countsLine;
1459
+ export const _board_counts = boardCounts;
1460
+ export const _remember_list = rememberList;
1461
+ export const _project_sessions = projectSessions;
1462
+ export const _session_project = sessionProject;
1463
+ export const _bind_session = bindSession;
1464
+ export const _use_by_token = useByToken;
1465
+ export const _leave_session = leaveSession;
1466
+ export const _locate_project = locateProject;
1467
+ export const _default_project = defaultProject;
1468
+ export const _projects_of = projectsOf;
1469
+ export const _create_in_project = createInProject;
1470
+ export const _baseline_of = baselineOf;
1471
+ export const _clip_name = clipName;
1472
+ export const _clip_reply = clipReply;
1473
+ export const _reply_of = replyOf;
1474
+ export const _binding_value = bindingValue;
1475
+ export const _has_creds = hasCreds;
1476
+ export const _selected_project_id = selectedProjectId;
1477
+ export const _selected_session = selectedSession;
1478
+ export const _send_card = sendCard;
1479
+ export const _dm = dm;
1480
+ export const _new_title = newTitle;
1481
+ export const _trace = trace;
1482
+ export const _notify_msg_done = notifyMsgDone;
1483
+ export const _new_session = newSession;
1484
+ export const _current_text = currentText;