@gavinc-cn/touchstone-dsh 0.2.0 → 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.
- package/LICENSE +0 -0
- package/README.md +23 -15
- package/README.zh.md +17 -12
- package/auth.py +0 -0
- package/backend/lib/access.js +44 -0
- package/backend/lib/agents.js +341 -0
- package/backend/lib/args.js +94 -0
- package/backend/lib/auth.js +195 -0
- package/backend/lib/board.js +6154 -0
- package/backend/lib/builtin-assets.js +1177 -0
- package/backend/lib/casescan.js +456 -0
- package/backend/lib/chat.js +2053 -0
- package/backend/lib/cookie.js +76 -0
- package/backend/lib/db.js +2260 -0
- package/backend/lib/dshdriver.js +1148 -0
- package/backend/lib/dshevents.js +851 -0
- package/backend/lib/export-cases.js +261 -0
- package/backend/lib/feishu.js +1738 -0
- package/backend/lib/feishu_cfg.js +228 -0
- package/backend/lib/feishu_conv.js +1484 -0
- package/backend/lib/feishu_device.js +428 -0
- package/backend/lib/feishu_inbound.js +1674 -0
- package/backend/lib/feishu_provision.js +855 -0
- package/backend/lib/feishu_rest.js +398 -0
- package/backend/lib/feishu_slash.js +568 -0
- package/backend/lib/feishu_ws.js +950 -0
- package/backend/lib/http.js +312 -0
- package/backend/lib/lib.js +973 -0
- package/backend/lib/loadcase.js +749 -0
- package/backend/lib/loadgen.js +1407 -0
- package/backend/lib/localbus.js +164 -0
- package/backend/lib/lock.js +405 -0
- package/backend/lib/proc.js +278 -0
- package/backend/lib/prompts.js +1021 -0
- package/backend/lib/pybase64.js +114 -0
- package/backend/lib/pyjson.js +214 -0
- package/backend/lib/query.js +125 -0
- package/backend/lib/rag.js +1310 -0
- package/backend/lib/runner.js +4520 -0
- package/backend/lib/servercaps.js +182 -0
- package/backend/lib/serverstate.js +237 -0
- package/backend/lib/sessparse.js +1176 -0
- package/backend/lib/sse.js +207 -0
- package/backend/lib/static.js +84 -0
- package/backend/lib/taskjson.js +302 -0
- package/backend/lib/waitq.js +2114 -0
- package/backend/lib/worktree.js +588 -0
- package/backend/lib/zstd-frames.js +218 -0
- package/backend/routes/admin.js +553 -0
- package/backend/routes/agents.js +149 -0
- package/backend/routes/assets.js +145 -0
- package/backend/routes/board.js +1220 -0
- package/backend/routes/feishu.js +269 -0
- package/backend/routes/feishu_app.js +215 -0
- package/backend/routes/feishu_hook.js +358 -0
- package/backend/routes/index.js +297 -0
- package/backend/routes/projects.js +1059 -0
- package/backend/routes/sessions.js +946 -0
- package/backend/routes/tasks.js +1530 -0
- package/backend/schema/fixtures/ancient.sql +189 -0
- package/backend/schema/fixtures/pre_alter.sql +177 -0
- package/backend/schema/migrations.json +154 -0
- package/backend/schema/schema.json +1451 -0
- package/backend/schema/schema.sql +216 -0
- package/backend/server.js +498 -0
- package/board.py +30 -1
- package/builtin_assets.py +0 -0
- package/builtin_prompts/free_style/flow.md +0 -0
- package/builtin_prompts/free_style/load_test.md +0 -0
- package/builtin_prompts/free_style/templates/bug_report_template.md +0 -0
- package/builtin_prompts/free_style/templates/case_template.md +0 -0
- package/builtin_prompts/free_style/templates/index_template.md +0 -0
- package/builtin_prompts/free_style/templates/load_charts_template.md +0 -0
- package/builtin_prompts/free_style/templates/load_index_template.md +0 -0
- package/builtin_prompts/free_style/templates/load_plan_template.md +0 -0
- package/builtin_prompts/free_style/templates/load_scenario_template.md +0 -0
- package/builtin_prompts/free_style/templates/status_template.md +0 -0
- package/db.py +294 -129
- package/dsh-plugin/cordis.patch.yml +0 -0
- package/dsh-plugin/install.sh +22 -8
- package/dsh-plugin/lib/client.bundle.js +0 -0
- package/dsh-plugin/lib/index.js +299 -29
- package/dsh-plugin/lib/pack-closure.mjs +291 -0
- package/dsh-plugin/scripts/build.mjs +0 -0
- package/dsh-plugin/scripts/prepack-check.mjs +30 -2
- package/dsh-plugin/scripts/select-bundle.mjs +0 -0
- package/dsh-plugin/src/client.js +0 -0
- package/dshevents.py +0 -0
- package/export_cases.py +0 -0
- package/extensions/dsh-autocommit/asset.json +0 -0
- package/extensions/dsh-autocommit/pkg/lib/index.mjs +0 -0
- package/extensions/dsh-autocommit/pkg/package.json +0 -0
- package/feishu_conv.py +0 -0
- package/lib.py +0 -0
- package/lifecycle.py +116 -13
- package/loadcase.py +0 -0
- package/loadgen.py +0 -0
- package/localbus.py +0 -0
- package/migrate_p7b.py +0 -0
- package/package.json +6 -1
- package/platcompat.py +0 -0
- package/prompts.py +0 -0
- package/rag.py +0 -0
- package/requirements.txt +0 -0
- package/touchstone.py +0 -0
- package/waitq.py +0 -0
- package/webui/dist-plugin/assets/index-ChP3sg6y.css +1 -0
- package/webui/dist-plugin/assets/{index-JLwyZZxC.js → index-UrMyeAXF.js} +110 -110
- package/webui/dist-plugin/index.html +2 -2
- package/worktree.py +0 -0
- package/webui/dist-plugin/assets/index-tJ0TlvQ3.css +0 -1
|
@@ -0,0 +1,1674 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 飞书**入站语义**(M7.5-R2):`feishu.py:1577-2604` 的零依赖 Node 移植。
|
|
3
|
+
*
|
|
4
|
+
* Oracle = 仓库根 `feishu.py`(**未改动的 Python 真源**):消息主流程、卡片回调主流程、
|
|
5
|
+
* 规则意图表、绑定码消费、看板/任务动作、多选「逐题点选」暂存态、全部用户可见文案。
|
|
6
|
+
* 每条实现上方的 `feishu.py:<行号>` 指 oracle 对应行;**与契约清单冲突时以源码为准**。
|
|
7
|
+
*
|
|
8
|
+
* ══ 一、调用面(谁调这里 / 这里调谁)══════════════════════════════════════════════════
|
|
9
|
+
* - `backend/lib/feishu.js` 的 `startInboundFor` / `startInboundAll`(M7 占位改写)→ 本文件;
|
|
10
|
+
* - `backend/lib/feishu_ws.js`(R1)的 `eventHandler` / `cardHandler` 收**合包后的 payload
|
|
11
|
+
* 字节**⇒ 本文件自己做 `JSON.parse` 与「SDK 模型 → dict」的取值链(`parseEventPayload`);
|
|
12
|
+
* - 复用 M7 既有件:`feishu.js` 的 `bindUser`/`multiAnswerHelp`/`NO_REMOTE_ANSWER_HINT`/
|
|
13
|
+
* `MQ_TTL`/`MQ_MAX_BUCKETS`(**不写第二份**)、`feishu_cfg.js` 的 `appConfig`/
|
|
14
|
+
* `inboundStatus`/`setInboundThread`、`feishu_rest.js` 的 rest 族、`db.js` 的飞书访问族、
|
|
15
|
+
* `board.js` 的 `interactionOfSid`/`answerInteraction`/`answerApproval`/`moveCard`/`startCard`、
|
|
16
|
+
* `feishu_slash.js` 的 `syncSlashCommands`、`feishu_provision.js` 的 `_LAST_EVENT_TS`。
|
|
17
|
+
*
|
|
18
|
+
* ══ 二、已登记的跨运行时差异(**不是等价物**,逐条写清)════════════════════════════════
|
|
19
|
+
* 1. **依赖注入**(架构约定,M7 起):Python 靠模块级全局互相调用(`rest_reply`/`db.*`/
|
|
20
|
+
* `board.*`),Node ESM 的 import 绑定不可打补丁 ⇒ 每个有外部副作用的函数末位多一个
|
|
21
|
+
* `options = {}`,可注入 `db`/`rest`/`board`/`conv`/`slash`/`feishu`/`cfg`/`now`/`log`/`env`/
|
|
22
|
+
* `wsClient`/`clientClass`/`wsLog`,**默认值是真模块**(产品路径不传 options)。
|
|
23
|
+
* 2. **同步 → 异步**:oracle 整条入站链同步阻塞(跑在 SDK 事件循环线程里);Node 侧
|
|
24
|
+
* `handleMessageEvent`/`onCardAction`/`executeIntent`/`answerByToken`/`decideApproval`/
|
|
25
|
+
* `executeBoardAction`/`answerMultiClick`/`dmText`/`onSlashPermAction`/`wsRun` 整链 async。
|
|
26
|
+
* **await 处一律保持 oracle 的串行顺序**(不许为了「性能」并发发起两个会改同一状态的 await)。
|
|
27
|
+
* `startInboundFor`/`startInboundAll` **保持同步**(对应 `saveUserConfig` 的
|
|
28
|
+
* `inbound_started` 是同步布尔)。
|
|
29
|
+
* 3. **线程模型**:Python 每用户一条 daemon 线程(`threading.Thread(daemon=True)`,不阻塞进程
|
|
30
|
+
* 退出)。Node 没有等价物,这里用一个**分离的 async 循环** + `feishu_cfg.js` 的注册表句柄
|
|
31
|
+
* (契约 `{isAlive(): boolean}`)表达:`alive` 在 `start()` 前置真、循环退出后置假,
|
|
32
|
+
* 条目**残留不清理**(照 1608 注释,判死只靠 `is_alive()`)。差异:R1 的 WS 客户端内部
|
|
33
|
+
* 用未 `unref()` 的 `setTimeout` 做重连退避 ⇒ **断网时重连循环会持有 Node 事件循环**
|
|
34
|
+
* (Python daemon 线程不会)——已登记为差异,未改 R1(写作用域外)。
|
|
35
|
+
* 4. **锁**:`_MQ_LOCK`(2108)在单线程 Node 里是空操作;`mqPut/mqTake/mqPeek/mqDrop` 都是
|
|
36
|
+
* 同步函数 ⇒ 临界区天然原子(比 oracle 更强,不是更弱)。两个 LRU 在 oracle 里也无锁。
|
|
37
|
+
* 5. **`{e!r}` 的近似**:oracle 的 6 处 `{e!r}`(1712/1765/1782/1860/1881/1948)跨运行时
|
|
38
|
+
* 不可能逐字对齐;本文件用 `errorRepr()` 实现 Python `repr(exc)` 的**近似**
|
|
39
|
+
* (`Name('message')` + Python 字符串 repr),异常类名取 JS 的 `e.name`(本文件自造的
|
|
40
|
+
* `AttributeError`/`ValueError`/`KeyError`/`TypeError` 与 `FeishuRestError` 同名可对齐)。
|
|
41
|
+
* **残余分叉**:Python 内建异常的消息文本、异常链、自定义异常类名无法对齐(见报告「未验」)。
|
|
42
|
+
* 6. **`_WS_THREADS` 键类型**(1608 的字典键语义):`7 != "7"` ⇒ 注册表键必须是**数值**;
|
|
43
|
+
* 本文件所有入口都把 `userId` 当数值用(字符串会建出第二条连接,与 oracle 同风险)。
|
|
44
|
+
* 7. **`int()`/`str()`/真值**:oracle 的 `int(str)`(截断、Unicode 数字、下划线)、
|
|
45
|
+
* `str(x)`(`True`/`['a']`/`{'a': 1}` 的 Python 形态)、`bool()`(`[]`/`{}`/`0`/`""` 全假、
|
|
46
|
+
* `nan` 为真)都在本文件用 `pyInt`/`pyStr`/`pyTruthy` 显式实现。**残余**:`json.loads`
|
|
47
|
+
* 把 `2.0` 读成 float 而 `JSON.parse` 得到 number 2 ⇒ `str(2.0)=="2.0"` vs `"2"` 类分叉
|
|
48
|
+
* 无法在 JS 侧恢复(信息在 parse 时就丢了);整数值浮点的 `.0`、`<1e-4`/`≥1e16` 的
|
|
49
|
+
* 定点-指数切换阈值同 `pyjson.js` 文件头已登记的偏差。
|
|
50
|
+
* 8. **`str.replace` / 切片 / `len`**:Python 的 `str.replace` 替换**全部**出现、`s[:n]`/`len(s)`
|
|
51
|
+
* 按**码点**计 —— 本文件用 `replaceAll` 与 `pySlice`/`pyLen`(`Array.from` 迭代码点),
|
|
52
|
+
* 不用 JS 的 `replace`(首次) 与 `.slice`(UTF-16 码元)。
|
|
53
|
+
* 9. **`_on_slash_perm_action` 归属**(契约清单 §3.3 的阻塞项):它的唯一调用点是入站 1910、
|
|
54
|
+
* 被调者 `_slash_summary`(1495-1503)的唯一调用点是它 1567 ⇒ 两者都归入站车道的调用面,
|
|
55
|
+
* 且 `feishu_slash.js` 已冻结 ⇒ **在本文件内实现**(`onSlashPermAction`/`slashSummary`),
|
|
56
|
+
* 不改 `feishu_slash.js`(只新增、不修改任何既有文件)。
|
|
57
|
+
* 10. **R3 依赖**:`feishu_conv.js` 尚未落地。默认 `conv` 是**惰性动态 import** 的转发器
|
|
58
|
+
* (R3 落地后产品路径零改动即生效);在此之前调用它会抛模块解析错误,被入站主流程的
|
|
59
|
+
* 兜底 catch 吞成一行 `处理异常`(等价于 oracle 的同类异常分支)。
|
|
60
|
+
*
|
|
61
|
+
* ══ 三、Node 专有的测试缝(**不是 oracle 的一部分**)══════════════════════════════════
|
|
62
|
+
* `resetInboundState()` / `msgSeenSize()` / `evtSeenSize()` / `mqStageSize()` —— 只为单测
|
|
63
|
+
* 能确定地构造与观察进程内态;产品路径不调用。
|
|
64
|
+
*/
|
|
65
|
+
import {
|
|
66
|
+
MQ_MAX_BUCKETS, MQ_TTL, NO_REMOTE_ANSWER_HINT,
|
|
67
|
+
} from './feishu.js';
|
|
68
|
+
import { appConfig, inboundStatus, setInboundThread } from './feishu_cfg.js';
|
|
69
|
+
import { _LAST_EVENT_TS } from './feishu_provision.js';
|
|
70
|
+
import { FeishuRestError } from './feishu_rest.js';
|
|
71
|
+
import { PY_WS } from './lib.js';
|
|
72
|
+
import { FeishuWsClient } from './feishu_ws.js';
|
|
73
|
+
import * as boardMod from './board.js';
|
|
74
|
+
import * as dbMod from './db.js';
|
|
75
|
+
import * as feishuMod from './feishu.js';
|
|
76
|
+
import * as restMod from './feishu_rest.js';
|
|
77
|
+
import * as slashMod from './feishu_slash.js';
|
|
78
|
+
|
|
79
|
+
/** Python `\s` 的字符类**内部**源码(从 `lib.js:PY_WS` 反解,保证与 `lib.js` 同表) */
|
|
80
|
+
const PY_SPACE_SRC = PY_WS.slice(1, -1);
|
|
81
|
+
const PY_SPLIT_WS = new RegExp(`[${PY_SPACE_SRC}]+`, 'u');
|
|
82
|
+
const PY_SPLIT_WS_COMMA = new RegExp(`[${PY_SPACE_SRC},]+`, 'u');
|
|
83
|
+
const PY_STRIP_RE = new RegExp(`^[${PY_SPACE_SRC}]+|[${PY_SPACE_SRC}]+$`, 'gu');
|
|
84
|
+
/** Python `\d` 的 Unicode 语义(`str` 模式的 `\d` = `\p{Nd}`) */
|
|
85
|
+
const ND_RE = /\p{Nd}/u;
|
|
86
|
+
const ND_PLUS = new RegExp('^\\p{Nd}+$', 'u');
|
|
87
|
+
/** `_multi_answer_parts` 的入口闸:`re.match(r"\s*\d+\s*:", token or "")` */
|
|
88
|
+
const MULTI_PARTS_GATE = new RegExp(`^[${PY_SPACE_SRC}]*\\p{Nd}+[${PY_SPACE_SRC}]*:`, 'u');
|
|
89
|
+
const MULTI_PARTS_TOKEN = new RegExp(
|
|
90
|
+
`^(\\p{Nd}+)[${PY_SPACE_SRC}]*:[${PY_SPACE_SRC}]*([^\\n]*)$`, 'u');
|
|
91
|
+
/** `\d+(?:\s*,\s*\d+)*`(fullmatch):`_multi_answers:2337` / `_answer_by_token:2411` */
|
|
92
|
+
const INDEX_CSV = new RegExp(
|
|
93
|
+
`^\\p{Nd}+(?:[${PY_SPACE_SRC}]*,[${PY_SPACE_SRC}]*\\p{Nd}+)*$`, 'u');
|
|
94
|
+
const INDEX_CSV_SPLIT = new RegExp(`[${PY_SPACE_SRC}]*,[${PY_SPACE_SRC}]*`, 'u');
|
|
95
|
+
|
|
96
|
+
// --------------------------------------------------------------------------
|
|
97
|
+
// Python 值语义小工具(每一条都对着 CPython 行为写)
|
|
98
|
+
// --------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
/** Python `bool()`:`None`/`False`/`0`/`""`/空容器假;**`nan` 为真**(JS `Boolean(NaN)` 是假) */
|
|
101
|
+
function pyTruthy(value) {
|
|
102
|
+
if (value === null || value === undefined) return false;
|
|
103
|
+
if (typeof value === 'boolean') return value;
|
|
104
|
+
if (typeof value === 'number') return Number.isNaN(value) ? true : value !== 0;
|
|
105
|
+
if (typeof value === 'string') return value.length > 0;
|
|
106
|
+
if (Array.isArray(value)) return value.length > 0;
|
|
107
|
+
if (typeof value === 'object') return Object.keys(value).length > 0;
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Python `a or b`(JS 的 `||` 对 `[]`/`{}`/`"0"` 分叉 ⇒ 必须显式) */
|
|
112
|
+
function pyOr(a, b) {
|
|
113
|
+
return pyTruthy(a) ? a : b;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Python 类型名(异常文案用;只覆盖 oracle 会出现的形态) */
|
|
117
|
+
function pyTypeName(value) {
|
|
118
|
+
if (value === null || value === undefined) return 'NoneType';
|
|
119
|
+
if (Array.isArray(value)) return 'list';
|
|
120
|
+
switch (typeof value) {
|
|
121
|
+
case 'string': return 'str';
|
|
122
|
+
case 'number': return Number.isInteger(value) ? 'int' : 'float';
|
|
123
|
+
case 'boolean': return 'bool';
|
|
124
|
+
case 'object': return 'dict';
|
|
125
|
+
default: return typeof value;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function attributeError(typeName, attr) {
|
|
130
|
+
const e = new Error(`'${typeName}' object has no attribute '${attr}'`);
|
|
131
|
+
e.name = 'AttributeError';
|
|
132
|
+
return e;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function valueError(message) {
|
|
136
|
+
const e = new Error(message);
|
|
137
|
+
e.name = 'ValueError';
|
|
138
|
+
return e;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Python `dict.get(key[, default])`:键存在但值为 None ⇒ **返回 None**(不是 default) */
|
|
142
|
+
function dget(dict, key, dflt) {
|
|
143
|
+
if (dict === null || dict === undefined || typeof dict !== 'object' || Array.isArray(dict)) {
|
|
144
|
+
throw attributeError(pyTypeName(dict), 'get');
|
|
145
|
+
}
|
|
146
|
+
if (Object.hasOwn(dict, key)) return dict[key] === undefined ? null : dict[key];
|
|
147
|
+
return dflt === undefined ? null : dflt;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Python 下标取值:缺键 ⇒ KeyError(对应 oracle 的 `intent["action"]` 这类写法) */
|
|
151
|
+
function requireKey(dict, key) {
|
|
152
|
+
if (dict === null || dict === undefined || typeof dict !== 'object') {
|
|
153
|
+
throw attributeError(pyTypeName(dict), key);
|
|
154
|
+
}
|
|
155
|
+
if (!Object.hasOwn(dict, key)) {
|
|
156
|
+
const e = new Error(`'${key}'`);
|
|
157
|
+
e.name = 'KeyError';
|
|
158
|
+
throw e;
|
|
159
|
+
}
|
|
160
|
+
return dict[key] === undefined ? null : dict[key];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Python `str.strip()`——**非 str 抛 AttributeError**(`lib.js:pyStrip` 会静默 `String(v)`) */
|
|
164
|
+
function pyStripStrict(value) {
|
|
165
|
+
if (typeof value !== 'string') throw attributeError(pyTypeName(value), 'strip');
|
|
166
|
+
return value.replace(PY_STRIP_RE, '');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Python `str.split()`(无参):按 Python 空白集合切并丢空段 */
|
|
170
|
+
function pySplitWs(value) {
|
|
171
|
+
if (typeof value !== 'string') throw attributeError(pyTypeName(value), 'split');
|
|
172
|
+
return value.split(PY_SPLIT_WS).filter((x) => x.length > 0);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Python 浮点 repr 的**近似**:整数形按 JS 最短表示;指数记法补到 ≥2 位并带显式符号 */
|
|
176
|
+
function pyNumRepr(value) {
|
|
177
|
+
if (Number.isNaN(value)) return 'nan';
|
|
178
|
+
if (value === Infinity) return 'inf';
|
|
179
|
+
if (value === -Infinity) return '-inf';
|
|
180
|
+
if (Number.isInteger(value)) return Object.is(value, -0) ? '0' : String(value);
|
|
181
|
+
return String(value).replace(/e([+-]?)(\d+)$/, (m, sign, digits) =>
|
|
182
|
+
`e${sign === '-' ? '-' : '+'}${digits.padStart(2, '0')}`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Python 字符串 repr(单引号优先;含 `'` 且不含 `"` 时改用双引号;控制符转义) */
|
|
186
|
+
function pyReprStr(value) {
|
|
187
|
+
const quote = value.includes("'") && !value.includes('"') ? '"' : "'";
|
|
188
|
+
let out = '';
|
|
189
|
+
for (const ch of value) {
|
|
190
|
+
if (ch === '\\') out += '\\\\';
|
|
191
|
+
else if (ch === quote) out += `\\${ch}`;
|
|
192
|
+
else if (ch === '\n') out += '\\n';
|
|
193
|
+
else if (ch === '\r') out += '\\r';
|
|
194
|
+
else if (ch === '\t') out += '\\t';
|
|
195
|
+
else out += ch;
|
|
196
|
+
}
|
|
197
|
+
return `${quote}${out}${quote}`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Python `repr(value)`(容器走自己的 repr:`['a']` / `{'a': 1}`) */
|
|
201
|
+
function pyRepr(value) {
|
|
202
|
+
if (value === null || value === undefined) return 'None';
|
|
203
|
+
if (typeof value === 'string') return pyReprStr(value);
|
|
204
|
+
if (typeof value === 'boolean') return value ? 'True' : 'False';
|
|
205
|
+
if (typeof value === 'number') return pyNumRepr(value);
|
|
206
|
+
if (typeof value === 'bigint') return value.toString();
|
|
207
|
+
if (Array.isArray(value)) return `[${value.map(pyRepr).join(', ')}]`;
|
|
208
|
+
if (typeof value === 'object') {
|
|
209
|
+
return `{${Object.keys(value).map((k) => `${pyReprStr(k)}: ${pyRepr(value[k])}`).join(', ')}}`;
|
|
210
|
+
}
|
|
211
|
+
return String(value);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Python `str(value)`(容器 = repr;`None`/`True`/`False` 是 Python 字面形态) */
|
|
215
|
+
function pyStr(value) {
|
|
216
|
+
if (value === null || value === undefined) return 'None';
|
|
217
|
+
if (typeof value === 'string') return value;
|
|
218
|
+
if (typeof value === 'boolean') return value ? 'True' : 'False';
|
|
219
|
+
if (typeof value === 'number') return pyNumRepr(value);
|
|
220
|
+
return pyRepr(value);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** `\p{Nd}` 字符的十进制数字值(Unicode 十进制数字块恒为连续 10 个码点) */
|
|
224
|
+
function ndValue(ch) {
|
|
225
|
+
const cp = ch.codePointAt(0);
|
|
226
|
+
let zero = cp;
|
|
227
|
+
for (let i = 1; i <= 9 && zero > 0; i += 1) {
|
|
228
|
+
if (!ND_RE.test(String.fromCodePoint(zero - 1))) break;
|
|
229
|
+
zero -= 1;
|
|
230
|
+
}
|
|
231
|
+
return cp - zero;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Python `int(value)`:**失败必须抛**(`Number("")===0` / `Number("abc")===NaN` 会静默改分支)。
|
|
236
|
+
* 支持:bool、number(截断向零;`nan`/`inf` 抛)、字符串(可选符号 + `\p{Nd}` 数字 +
|
|
237
|
+
* 数字间下划线,首尾 Python 空白)。
|
|
238
|
+
*/
|
|
239
|
+
function pyInt(value) {
|
|
240
|
+
if (typeof value === 'boolean') return value ? 1 : 0;
|
|
241
|
+
if (typeof value === 'number') {
|
|
242
|
+
if (Number.isNaN(value)) {
|
|
243
|
+
throw valueError(`invalid literal for int() with base 10: '${pyStr(value)}'`);
|
|
244
|
+
}
|
|
245
|
+
if (!Number.isFinite(value)) throw valueError('cannot convert float infinity to integer');
|
|
246
|
+
return Math.trunc(value);
|
|
247
|
+
}
|
|
248
|
+
if (typeof value === 'bigint') return Number(value);
|
|
249
|
+
if (typeof value !== 'string') {
|
|
250
|
+
throw new TypeError('int() argument must be a string, a bytes-like object or a real number, '
|
|
251
|
+
+ `not '${pyTypeName(value)}'`);
|
|
252
|
+
}
|
|
253
|
+
const raw = pyStripStrict(value);
|
|
254
|
+
let body = raw;
|
|
255
|
+
let sign = 1;
|
|
256
|
+
if (body.startsWith('+') || body.startsWith('-')) {
|
|
257
|
+
if (body.startsWith('-')) sign = -1;
|
|
258
|
+
body = body.slice(1);
|
|
259
|
+
}
|
|
260
|
+
if (body.length === 0) throw valueError(`invalid literal for int() with base 10: '${value}'`);
|
|
261
|
+
let digits = '';
|
|
262
|
+
for (let i = 0; i < body.length; i += 1) {
|
|
263
|
+
const ch = body[i];
|
|
264
|
+
if (ch === '_') {
|
|
265
|
+
const prev = i > 0 ? body[i - 1] : '';
|
|
266
|
+
const next = i + 1 < body.length ? body[i + 1] : '';
|
|
267
|
+
if (prev === '' || next === '' || !ND_RE.test(prev) || !ND_RE.test(next)) {
|
|
268
|
+
throw valueError(`invalid literal for int() with base 10: '${value}'`);
|
|
269
|
+
}
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
if (!ND_RE.test(ch)) throw valueError(`invalid literal for int() with base 10: '${value}'`);
|
|
273
|
+
digits += String(ndValue(ch));
|
|
274
|
+
}
|
|
275
|
+
return sign * Number(digits);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Python `x in (None, "")` 的相等比较口径(`0`/`False` **不在**其中,2186/2190) */
|
|
279
|
+
function isNoneOrEmpty(value) {
|
|
280
|
+
return value === null || value === undefined || value === '';
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** `Numeric_Type=Digit` 的上标/带圈形(`str.isdigit()` 比 `\p{Nd}` 宽的那部分) */
|
|
284
|
+
const DIGIT_EXTRA = /[\u00b2\u00b3\u00b9\u2070\u2074-\u2079\u2080-\u2089\u2460-\u2473\u24ea\u24ff\u2776-\u2793]/u;
|
|
285
|
+
|
|
286
|
+
function pyIsDigit(value) {
|
|
287
|
+
if (typeof value !== 'string') throw attributeError(pyTypeName(value), 'isdigit');
|
|
288
|
+
if (value.length === 0) return false;
|
|
289
|
+
for (const ch of value) if (!ND_RE.test(ch) && !DIGIT_EXTRA.test(ch)) return false;
|
|
290
|
+
return true;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Python `s[:n]`(按**码点**切,不是 UTF-16 码元) */
|
|
294
|
+
function pySlice(value, n) {
|
|
295
|
+
if (typeof value !== 'string') throw attributeError(pyTypeName(value), '__getitem__');
|
|
296
|
+
const points = Array.from(value);
|
|
297
|
+
return points.length <= n ? value : points.slice(0, n).join('');
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Python `len(s)`(按码点计;非 str 抛 TypeError) */
|
|
301
|
+
function pyLen(value) {
|
|
302
|
+
if (typeof value === 'string') return Array.from(value).length;
|
|
303
|
+
if (Array.isArray(value)) return value.length;
|
|
304
|
+
if (value !== null && typeof value === 'object') return Object.keys(value).length;
|
|
305
|
+
throw new TypeError(`object of type '${pyTypeName(value)}' has no len()`);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Python `dict(x)` 的浅拷贝口径(字符串/数字 ⇒ 抛,list-of-pairs ⇒ 接受) */
|
|
309
|
+
function pyDictCopy(value) {
|
|
310
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value)) return { ...value };
|
|
311
|
+
if (Array.isArray(value)) {
|
|
312
|
+
const out = {};
|
|
313
|
+
for (const pair of value) {
|
|
314
|
+
if (!Array.isArray(pair) || pair.length !== 2) {
|
|
315
|
+
throw valueError('dictionary update sequence element has length != 2');
|
|
316
|
+
}
|
|
317
|
+
out[pair[0]] = pair[1];
|
|
318
|
+
}
|
|
319
|
+
return out;
|
|
320
|
+
}
|
|
321
|
+
if (typeof value === 'string') {
|
|
322
|
+
throw valueError(`dictionary update sequence element #0 has length ${value.length}; 2 is required`);
|
|
323
|
+
}
|
|
324
|
+
throw new TypeError(`'${pyTypeName(value)}' object is not iterable`);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Python `json.loads(text)`:非 str ⇒ TypeError(`JSON.parse(123)` 会静默成功) */
|
|
328
|
+
function pyJsonLoads(text) {
|
|
329
|
+
if (typeof text !== 'string') {
|
|
330
|
+
throw new TypeError(`the JSON object must be str, bytes or bytearray, not ${pyTypeName(text)}`);
|
|
331
|
+
}
|
|
332
|
+
return JSON.parse(text);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Python `re.split(r"[\s,]+", s)` 的等价切分 */
|
|
336
|
+
function splitWsComma(value) {
|
|
337
|
+
return value.split(PY_SPLIT_WS_COMMA).filter((x) => x.length > 0);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Python 正则 → JS 正则的**逐字符**翻译(只做会分叉的四处):
|
|
342
|
+
* `\s`→PY_SPACE 类、`\S`→取反类、`\d`→`\p{Nd}`、`.`→`[^\n]`(Python 的 `.` 只排除 `\n`)。
|
|
343
|
+
*/
|
|
344
|
+
function pyPatternToJs(src) {
|
|
345
|
+
let out = '';
|
|
346
|
+
for (let i = 0; i < src.length; i += 1) {
|
|
347
|
+
const ch = src[i];
|
|
348
|
+
if (ch === '\\') {
|
|
349
|
+
const nx = src[i + 1];
|
|
350
|
+
i += 1;
|
|
351
|
+
if (nx === 's') out += `[${PY_SPACE_SRC}]`;
|
|
352
|
+
else if (nx === 'S') out += `[^${PY_SPACE_SRC}]`;
|
|
353
|
+
else if (nx === 'd') out += '\\p{Nd}';
|
|
354
|
+
else if (nx === 'D') out += '\\P{Nd}';
|
|
355
|
+
else out += `\\${nx}`;
|
|
356
|
+
} else if (ch === '.') out += '[^\\n]';
|
|
357
|
+
else out += ch;
|
|
358
|
+
}
|
|
359
|
+
return out;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** 异常 → Python `repr(e)` 的近似(见文件头差异 5) */
|
|
363
|
+
export function errorRepr(e) {
|
|
364
|
+
if (e instanceof Error) {
|
|
365
|
+
const name = typeof e.pyName === 'string' ? e.pyName : (e.name || 'Error');
|
|
366
|
+
return `${name}(${pyReprStr(e.message === undefined ? String(e) : e.message)})`;
|
|
367
|
+
}
|
|
368
|
+
return pyRepr(e);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// --------------------------------------------------------------------------
|
|
372
|
+
// 依赖解析(末位 `options` 注入;默认值 = 真模块)
|
|
373
|
+
// --------------------------------------------------------------------------
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* 默认日志口:`console.error(line)`(= 写 stderr 且补一个 `\n`,与
|
|
377
|
+
* `print(..., file=sys.stderr, flush=True)` 同形)。用 `console.error` 而不是直接
|
|
378
|
+
* `process.stderr.write` 的原因:仓库既有单测(`feishu-me-routes.test.mjs` 的
|
|
379
|
+
* `captureStderr`)就是**打桩 `console.error`** 来抓这几行的。
|
|
380
|
+
*/
|
|
381
|
+
function defaultLog(line) {
|
|
382
|
+
console.error(line);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** `feishu_conv.js`(M7.5-R3)惰性转发器:R3 落地后产品路径零改动即生效(文件头差异 10) */
|
|
386
|
+
let convModulePromise = null;
|
|
387
|
+
function loadConvModule() {
|
|
388
|
+
if (convModulePromise === null) convModulePromise = import('./feishu_conv.js');
|
|
389
|
+
return convModulePromise;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function lazyConv() {
|
|
393
|
+
const call = async (name, args) => {
|
|
394
|
+
const mod = await loadConvModule();
|
|
395
|
+
const fn = mod[name];
|
|
396
|
+
if (typeof fn !== 'function') throw new TypeError(`feishu_conv.js 未导出 ${name}(M7.5-R3)`);
|
|
397
|
+
return fn(...args);
|
|
398
|
+
};
|
|
399
|
+
return {
|
|
400
|
+
enabled: (...a) => call('enabled', a),
|
|
401
|
+
routeText: (...a) => call('routeText', a),
|
|
402
|
+
convIntent: (...a) => call('convIntent', a),
|
|
403
|
+
onCardAction: (...a) => call('onCardAction', a),
|
|
404
|
+
setDefaultProject: (...a) => call('setDefaultProject', a),
|
|
405
|
+
projectById: (...a) => call('projectById', a),
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* 依赖表:Python 的模块级全局(`db.*`/`rest_reply`/`board.*`/`feishu_conv.*`)的注入点。
|
|
411
|
+
*
|
|
412
|
+
* ⚠**`options.db` 不是模块槽**:仓库既有语义(`db.js`/`feishu.js`/`board.js` 全线)里
|
|
413
|
+
* `options.db` = **node:sqlite 句柄**,会被逐层透传给 db.js 的数据访问函数。要替换整个
|
|
414
|
+
* **db 模块**用 `options.dbmod`(Node 侧专属注入点,oracle 无对应物)。
|
|
415
|
+
*/
|
|
416
|
+
function dep(options = {}) {
|
|
417
|
+
return {
|
|
418
|
+
db: options.dbmod || dbMod,
|
|
419
|
+
rest: options.rest || restMod,
|
|
420
|
+
board: options.board || boardMod,
|
|
421
|
+
conv: options.conv || lazyConv(),
|
|
422
|
+
slash: options.slash || slashMod,
|
|
423
|
+
feishu: options.feishu || feishuMod,
|
|
424
|
+
now: options.now || (() => Date.now() / 1000),
|
|
425
|
+
log: options.log || defaultLog,
|
|
426
|
+
env: options.env || process.env,
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// ==========================================================================
|
|
431
|
+
// 用户可见常量(`feishu.py:1579-1606` / `1985-2003` / `2599-2603`)
|
|
432
|
+
// ==========================================================================
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* 未识别文本 / `help` 意图 / 未知看板动作的兜底回复。
|
|
436
|
+
* **源码 28 行、求值后 21 行**(1596+1597+1598 / 1600+1601 / 1604+1605 三处相邻字面量隐式
|
|
437
|
+
* 拼接)——本常量按**求值后**形态书写,单测用 oracle 现算的 utf-8 sha256 锚住。
|
|
438
|
+
*/
|
|
439
|
+
export const HELP_TEXT = 'Touchstone 指令(单聊可用):\n'
|
|
440
|
+
+ '直接发普通文字 = 与当前会话对话(未绑定会在默认项目自动新建)。\n'
|
|
441
|
+
+ '· 帮助 — 本说明\n'
|
|
442
|
+
+ '· 绑定 <码> — 绑定站点账号(绑定码在站点侧栏「设置」→ 飞书设置 生成)\n'
|
|
443
|
+
+ '· 解绑 — 解除绑定\n'
|
|
444
|
+
+ '· 当前 — 查看默认项目与当前会话\n'
|
|
445
|
+
+ '· 项目 — 项目总览卡(点选即设为默认项目)\n'
|
|
446
|
+
+ '· 会话 — 默认项目最近活跃的 10 个会话(点选即绑定)\n'
|
|
447
|
+
+ '· 新建会话 [标题] — 在默认项目下新建并绑定\n'
|
|
448
|
+
+ '· 绑定会话 <序号|标题关键字|会话id前缀> — 绑定已有会话\n'
|
|
449
|
+
+ '· 解绑会话 — 只解绑会话,保留默认项目\n'
|
|
450
|
+
+ '· 默认项目 <项目名> — 设置默认项目\n'
|
|
451
|
+
+ '· 状态 [项目名] — 任务与看板摘要\n'
|
|
452
|
+
+ '· 卡片 <关键字> — 找卡片(id 前缀/标题包含)\n'
|
|
453
|
+
+ '· 通过 <卡片> — 待审核卡片完成审核\n'
|
|
454
|
+
+ '· 驳回 <卡片> <意见> — 打回继续开发\n'
|
|
455
|
+
+ '· 作答 <卡片> <选项序号|文字> — 回复 agent 提问'
|
|
456
|
+
+ '(多选逗号分隔:作答 7 1,3;文字=自定义回答。单题多选卡上勾选后'
|
|
457
|
+
+ '点「提交作答」即可,无需打字)\n'
|
|
458
|
+
+ '· 作答 <卡片> <题号:值 …> — 多子题一次答全'
|
|
459
|
+
+ '(如「作答 7 1:2 2:1 3:1,3」;多选写 1:2,3,自定义写 1:文字。'
|
|
460
|
+
+ '多子题卡可直接在卡片上逐题点选,点满自动送达,已点选的题不必重复给)\n'
|
|
461
|
+
+ '· 同意 <卡片> [会话] — 批准挂起的工具审批(「会话」=本会话内自动放行)\n'
|
|
462
|
+
+ '· 拒绝 <卡片> — 拒绝挂起的工具审批\n'
|
|
463
|
+
+ '· 快捷指令:输入框里打「/」可直接选 /status、/cards、/approve 等'
|
|
464
|
+
+ '(参数用法与上面对应中文指令一致)';
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* 规则意图表(**顺序即优先级**,首个命中即返回)。**存 Python 原文**(与 oracle 的
|
|
468
|
+
* `(action, pattern)` 元组同形,便于对拍);匹配走 `INTENT_RES` 的翻译版。
|
|
469
|
+
*/
|
|
470
|
+
export const INTENT_RULES = Object.freeze([
|
|
471
|
+
Object.freeze(['help', '^(?:帮助|help|/help)$']),
|
|
472
|
+
Object.freeze(['bind', '^(?:绑定|/bind)\\s*([A-Za-z0-9]{4,8})$']),
|
|
473
|
+
Object.freeze(['unbind', '^(?:解绑|/unbind)$']),
|
|
474
|
+
Object.freeze(['default_project', '^(?:默认项目|/project)\\s+(\\S+)$']),
|
|
475
|
+
Object.freeze(['current', '^(?:当前|/current)$']),
|
|
476
|
+
Object.freeze(['projects', '^(?:项目|/projects)$']),
|
|
477
|
+
Object.freeze(['sessions', '^(?:会话|/sessions)$']),
|
|
478
|
+
Object.freeze(['new', '^(?:新建会话|/new)(?:\\s+(.+))?$']),
|
|
479
|
+
Object.freeze(['use', '^(?:绑定会话|/use)\\s+(.+)$']),
|
|
480
|
+
Object.freeze(['leave', '^(?:解绑会话|/leave)$']),
|
|
481
|
+
Object.freeze(['status', '^(?:状态|/status)(?:\\s+(.+))?$']),
|
|
482
|
+
Object.freeze(['card', '^(?:卡片|/cards?)\\s+(.+)$']),
|
|
483
|
+
Object.freeze(['approve', '^(?:通过|/approve)\\s+(\\S+)$']),
|
|
484
|
+
Object.freeze(['reject', '^(?:驳回|/reject)\\s+(\\S+)\\s+(.+)$']),
|
|
485
|
+
Object.freeze(['answer', '^(?:作答|/answer)\\s+(\\S+)\\s+(.+)$']),
|
|
486
|
+
Object.freeze(['agree', '^(?:同意|/agree)\\s+(\\S+)(?:\\s+(会话|session))?$']),
|
|
487
|
+
Object.freeze(['deny', '^(?:拒绝|/deny)\\s+(\\S+)$']),
|
|
488
|
+
]);
|
|
489
|
+
|
|
490
|
+
/** `_INTENT_RULES` 的编译产物(oracle 每轮 `re.match(pat, t)` 都重编译,这里缓存一次) */
|
|
491
|
+
const INTENT_RES = INTENT_RULES.map(([action, pat]) =>
|
|
492
|
+
[action, new RegExp(pyPatternToJs(pat), 'u')]);
|
|
493
|
+
|
|
494
|
+
/** `_COL_ORDER`(2599):看板五列的**展示顺序**(`_board_summary` 的计数顺序即它) */
|
|
495
|
+
export const COL_ORDER = Object.freeze(['todo', 'doing', 'blocked', 'review', 'done']);
|
|
496
|
+
/** `_COL_LABELS`(2600-2601) */
|
|
497
|
+
export const COL_LABELS = Object.freeze({
|
|
498
|
+
todo: '待开发', doing: '正在开发', blocked: '阻塞', review: '待审核', done: '已完成',
|
|
499
|
+
});
|
|
500
|
+
/** `_TASK_STATUS`(2602-2603) */
|
|
501
|
+
export const TASK_STATUS = Object.freeze({
|
|
502
|
+
queued: '排队', running: '运行中', done: '已完成', failed: '失败', stopped: '已停止',
|
|
503
|
+
interrupted: '中断',
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
/** `dict.get(k, k)` 形态的映射取值(键存在但值为 None 时 Python 回 None,这里照抄) */
|
|
507
|
+
function lookupLabel(table, key) {
|
|
508
|
+
return Object.hasOwn(table, key) ? table[key] : key;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// ==========================================================================
|
|
512
|
+
// 进程内态(`feishu.py:1608-1614` / `2108-2109`)
|
|
513
|
+
// ==========================================================================
|
|
514
|
+
|
|
515
|
+
/** `_MSG_SEEN`(1609-1611):message_id 去重 LRU,**最大 512**(`len > 512` 才淘汰) */
|
|
516
|
+
const MSG_SEEN = new Set();
|
|
517
|
+
/** `_EVT_SEEN`(1612-1614):卡片回调 event_id 去重 LRU,同样是 512;两者互不影响 */
|
|
518
|
+
const EVT_SEEN = new Set();
|
|
519
|
+
const MSG_SEEN_MAX = 512;
|
|
520
|
+
const EVT_SEEN_MAX = 512;
|
|
521
|
+
/** `_MQ_STAGE`(2109):`(卡号:int, qid:str) -> {n, vals:Map<no,val>, at}`(桶键见 `mqKey`) */
|
|
522
|
+
const MQ_STAGE = new Map();
|
|
523
|
+
|
|
524
|
+
/** LRU 记一条:命中 ⇒ true(**方向与直觉相反**:oracle 的调用点写作 `if ..._seen(x): return`) */
|
|
525
|
+
function seenPush(table, max, key) {
|
|
526
|
+
if (table.has(key)) return true;
|
|
527
|
+
table.add(key);
|
|
528
|
+
if (table.size > max) {
|
|
529
|
+
// Python: `set.discard(list.pop(0))` —— JS Set 的迭代序即插入序
|
|
530
|
+
table.delete(table.values().next().value);
|
|
531
|
+
}
|
|
532
|
+
return false;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** 卡片回调 event_id 去重(1617-1625):`true`=重复 */
|
|
536
|
+
export function seenEvent(eid) {
|
|
537
|
+
return seenPush(EVT_SEEN, EVT_SEEN_MAX, eid);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** `message_id` 去重(1785-1793):`true`=重复消息(飞书会重推) */
|
|
541
|
+
export function seenMessage(mid) {
|
|
542
|
+
return seenPush(MSG_SEEN, MSG_SEEN_MAX, mid);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** Node 专有测试缝:当前消息 LRU 长度(oracle 判据 `len > 512` ⇒ 稳态恰好 512) */
|
|
546
|
+
export function msgSeenSize() { return MSG_SEEN.size; }
|
|
547
|
+
|
|
548
|
+
/** Node 专有测试缝:事件 LRU 长度 */
|
|
549
|
+
export function evtSeenSize() { return EVT_SEEN.size; }
|
|
550
|
+
|
|
551
|
+
/** Node 专有测试缝:暂存桶数量(oracle 判据 `len > 64` ⇒ 稳态恰好 64) */
|
|
552
|
+
export function mqStageSize() { return MQ_STAGE.size; }
|
|
553
|
+
|
|
554
|
+
/** Node 专有测试缝:清空三个进程内态(产品路径不调用)——对齐 oracle 的「重启即丢」 */
|
|
555
|
+
export function resetInboundState() {
|
|
556
|
+
MSG_SEEN.clear();
|
|
557
|
+
EVT_SEEN.clear();
|
|
558
|
+
MQ_STAGE.clear();
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// ==========================================================================
|
|
562
|
+
// 意图解析(`feishu.py:2009-2016`)
|
|
563
|
+
// ==========================================================================
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* 规则意图解析:未识别返回 `null`。
|
|
567
|
+
* `t = (text or "").strip()`(2011)只剥首尾;`re.match` 逐条试表,**首个命中即返回**;
|
|
568
|
+
* `groups` 是定长列表(未参与的捕获组是 `null`,不是 `""`)。
|
|
569
|
+
*/
|
|
570
|
+
export function parseIntent(text) {
|
|
571
|
+
const t = pyStripStrict(pyOr(text, ''));
|
|
572
|
+
for (const [action, re] of INTENT_RES) {
|
|
573
|
+
const m = re.exec(t);
|
|
574
|
+
if (m !== null) {
|
|
575
|
+
return { action, groups: Array.from(m).slice(1).map((g) => (g === undefined ? null : g)) };
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
return null;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// ==========================================================================
|
|
582
|
+
// toast / DM(`feishu.py:1865-1881` / `2202-2204`)
|
|
583
|
+
// ==========================================================================
|
|
584
|
+
|
|
585
|
+
/** `_toast`(2202-2204):键序 `type → content`,kind 不校验 */
|
|
586
|
+
export function toast(kind, content) {
|
|
587
|
+
return { toast: { type: kind, content } };
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* `_toast_for`(1865-1873):执行器回执 → 卡片 toast。
|
|
592
|
+
* `ok = reply.startswith(("已收下", "✅"))`(元组 = OR);`reply is None` ⇒ `null`;
|
|
593
|
+
* **空串走 error 分支**(1868 只判 `is None`)。
|
|
594
|
+
*/
|
|
595
|
+
export function toastFor(reply) {
|
|
596
|
+
if (reply === null || reply === undefined) return null;
|
|
597
|
+
const ok = reply.startsWith('已收下') || reply.startsWith('✅');
|
|
598
|
+
return {
|
|
599
|
+
toast: {
|
|
600
|
+
type: ok ? 'success' : 'error',
|
|
601
|
+
content: ok ? '已收下,详情看机器人单聊' : '操作失败,详情看机器人单聊',
|
|
602
|
+
},
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/** `_dm_text`(1876-1881):DM 文本旁路发送,失败留痕不外抛 */
|
|
607
|
+
export async function dmText(openId, text, cfg, options = {}) {
|
|
608
|
+
const d = dep(options);
|
|
609
|
+
try {
|
|
610
|
+
await d.rest.restSendText(openId, text, cfg);
|
|
611
|
+
} catch (e) {
|
|
612
|
+
d.log(`feishu inbound: DM 回执发送失败: ${errorRepr(e)}`);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/** 卡片路径的未绑定引导(1916-1917/1924-1925:**「在单聊回复」**,区别于 1845 的「在这里回复」) */
|
|
617
|
+
const UNBOUND_DM_CARD = '尚未绑定 Touchstone 账号:登录站点 → 侧栏「设置」→ '
|
|
618
|
+
+ '飞书设置,生成绑定码后在单聊回复「绑定 <码>」完成绑定';
|
|
619
|
+
/** 消息路径 / 权限卡路径的未绑定引导(1845-1846 与 1558-1559:**「在这里回复」**) */
|
|
620
|
+
const UNBOUND_DM_MSG = '尚未绑定 Touchstone 账号:登录站点 → 侧栏「设置」→ '
|
|
621
|
+
+ '飞书设置,生成绑定码后在这里回复「绑定 <码>」完成绑定';
|
|
622
|
+
|
|
623
|
+
// ==========================================================================
|
|
624
|
+
// 多选暂存桶(`feishu.py:2096-2199`)
|
|
625
|
+
// ==========================================================================
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* 桶键 = `(int(cid), str(qid or ""))`(2125/2141/2152/2159)。Python 的 tuple 键在 JS 里用
|
|
629
|
+
* NUL 分隔的复合串表达(cid 是数字、qid 是字符串 ⇒ 无歧义;NUL 不可能是 qid 的合法组成)。
|
|
630
|
+
*/
|
|
631
|
+
function mqKey(cid, qid) {
|
|
632
|
+
return `${pyStr(pyInt(cid))}\u0000${pyStr(pyOr(qid, ''))}`;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* `_mq_prune_locked`(2112-2118):清过期(`now - at > 1800`,**严格 `>`**)+ 超量
|
|
637
|
+
* (`len > 64` ⇒ 削到恰好 64;`min` 平局取**插入序**第一个)。
|
|
638
|
+
* 调用方必须已持锁 —— Node 侧临界区由同步函数天然保证(文件头差异 4)。
|
|
639
|
+
*/
|
|
640
|
+
export function mqPruneLocked(now, options = {}) {
|
|
641
|
+
dep(options);
|
|
642
|
+
for (const [key, bucket] of [...MQ_STAGE.entries()]) {
|
|
643
|
+
if (now - bucket.at > MQ_TTL) MQ_STAGE.delete(key);
|
|
644
|
+
}
|
|
645
|
+
while (MQ_STAGE.size > MQ_MAX_BUCKETS) {
|
|
646
|
+
let oldest = null;
|
|
647
|
+
let best = Infinity;
|
|
648
|
+
for (const [key, bucket] of MQ_STAGE) { // Python `min`:首个最小值(插入序)
|
|
649
|
+
if (bucket.at < best) { best = bucket.at; oldest = key; }
|
|
650
|
+
}
|
|
651
|
+
MQ_STAGE.delete(oldest);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* `_mq_put`(2121-2135):写一题答案 → `[已答数, 总题数, 是否答满]`。
|
|
657
|
+
* `now = time.time()` 一次取、复用于 prune 与 `bucket.at`(2124/2127/2133);
|
|
658
|
+
* 桶不存在**或题数不符即重建**(2129-2131:卡片可能已指向新提问,宁可重来不可串答)。
|
|
659
|
+
*/
|
|
660
|
+
export function mqPut(cid, qid, n, no, val, options = {}) {
|
|
661
|
+
const d = dep(options);
|
|
662
|
+
const now = d.now();
|
|
663
|
+
const key = mqKey(cid, qid);
|
|
664
|
+
mqPruneLocked(now, options);
|
|
665
|
+
let bucket = MQ_STAGE.get(key);
|
|
666
|
+
const total = pyInt(n);
|
|
667
|
+
if (bucket === undefined || bucket.n !== total) {
|
|
668
|
+
bucket = { n: total, vals: new Map(), at: now };
|
|
669
|
+
MQ_STAGE.set(key, bucket);
|
|
670
|
+
}
|
|
671
|
+
bucket.vals.set(pyInt(no), pyStr(val));
|
|
672
|
+
bucket.at = now;
|
|
673
|
+
const done = bucket.vals.size;
|
|
674
|
+
return [done, total, done >= total];
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* `_mq_take`(2138-2146):取走并清空 → `[[题号, 题值], …]`(题号**数值升序**)。
|
|
679
|
+
* ⚠**先 pop 再判题数**(2143-2144)⇒ 题数不符时桶已被销毁;**不看 TTL**(390 行的陷阱)。
|
|
680
|
+
*/
|
|
681
|
+
export function mqTake(cid, qid, n, options = {}) {
|
|
682
|
+
dep(options);
|
|
683
|
+
const key = mqKey(cid, qid);
|
|
684
|
+
const bucket = MQ_STAGE.get(key);
|
|
685
|
+
MQ_STAGE.delete(key);
|
|
686
|
+
if (bucket === undefined || bucket.n !== pyInt(n)) return [];
|
|
687
|
+
return [...bucket.vals.keys()].sort((a, b) => a - b).map((no) => [no, bucket.vals.get(no)]);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* `_mq_peek`(2149-2153):只读已暂存答案(浅拷贝、不改动暂存、不刷 `at`、不判 TTL)。
|
|
692
|
+
* 形态差异:Python 返回 `dict[int, str]`,JS 无 int 键字典 ⇒ 返回**数值串为键的普通对象**;
|
|
693
|
+
* 范围内的唯一消费者(`_answer_by_token` 的多子题合并)会转回 number 并按数值升序排。
|
|
694
|
+
*/
|
|
695
|
+
export function mqPeek(cid, qid, options = {}) {
|
|
696
|
+
dep(options);
|
|
697
|
+
const bucket = MQ_STAGE.get(mqKey(cid, qid));
|
|
698
|
+
if (bucket === undefined) return {};
|
|
699
|
+
const out = {};
|
|
700
|
+
for (const [no, val] of bucket.vals) out[no] = val;
|
|
701
|
+
return out;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/** `_mq_drop`(2156-2159):丢弃该提问的暂存(不存在时静默) */
|
|
705
|
+
export function mqDrop(cid, qid, options = {}) {
|
|
706
|
+
dep(options);
|
|
707
|
+
MQ_STAGE.delete(mqKey(cid, qid));
|
|
708
|
+
return undefined;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* `_multi_click_index`(2162-2199):飞书回调 → 所选选项**序号**列表(1 起);取不到 `null`。
|
|
713
|
+
* 三条来源:① `multi` 时 `action.form_value[<组件 name>]`(数组 / 字符串按空白逗号切)→ 兜底
|
|
714
|
+
* `action.options`;② 非 `multi` 时 `action.option`(标量或数组);③ 兜底 `action.value.i`。
|
|
715
|
+
* 任一元素不过 `\d+` 即**整体**返回 `null`;`re.fullmatch` 走 `\p{Nd}`(Python `\d` 的 Unicode 语义)。
|
|
716
|
+
*/
|
|
717
|
+
export function multiClickIndex(action, multi, name) {
|
|
718
|
+
let vals = null;
|
|
719
|
+
if (pyTruthy(multi)) {
|
|
720
|
+
const raw = dget(pyOr(dget(action, 'form_value'), {}), name);
|
|
721
|
+
if (Array.isArray(raw) && raw.length > 0) vals = raw.map(pyStr);
|
|
722
|
+
else if (typeof raw === 'string' && pyTruthy(pyStripStrict(raw))) {
|
|
723
|
+
vals = splitWsComma(pyStripStrict(raw));
|
|
724
|
+
}
|
|
725
|
+
if (vals === null) { // 兼容别种回传形态(2178-2181)
|
|
726
|
+
const alt = dget(action, 'options');
|
|
727
|
+
if (Array.isArray(alt) && alt.length > 0) vals = alt.map(pyStr);
|
|
728
|
+
}
|
|
729
|
+
} else {
|
|
730
|
+
const raw = dget(action, 'option');
|
|
731
|
+
if (Array.isArray(raw) && raw.length > 0) vals = raw.map(pyStr);
|
|
732
|
+
else if (!isNoneOrEmpty(raw)) vals = [pyStr(raw)]; // `raw not in (None, "")`:0/False 放过
|
|
733
|
+
}
|
|
734
|
+
if (vals === null) {
|
|
735
|
+
const one = dget(pyOr(dget(action, 'value'), {}), 'i');
|
|
736
|
+
if (!isNoneOrEmpty(one)) vals = [pyStr(one)];
|
|
737
|
+
}
|
|
738
|
+
if (!pyTruthy(vals)) return null;
|
|
739
|
+
const out = [];
|
|
740
|
+
for (const v of vals) {
|
|
741
|
+
if (!ND_PLUS.test(v)) return null;
|
|
742
|
+
out.push(pyInt(v));
|
|
743
|
+
}
|
|
744
|
+
return out;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// ==========================================================================
|
|
748
|
+
// 多子题作答载荷(`feishu.py:2276-2359`)
|
|
749
|
+
// ==========================================================================
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* `_multi_answer_parts`(2276-2296):`1:2 2:1 3:3` → `[[题号, 题值], …]`;非多题形态 `null`。
|
|
753
|
+
* 入口闸只锚开头(`re.match`);切词用无参 `split()`;不以 `<数字>:` 开头的 token
|
|
754
|
+
* **续接到上一题**(含空格);题号可重复(本函数不查重)。
|
|
755
|
+
*/
|
|
756
|
+
export function multiAnswerParts(token) {
|
|
757
|
+
if (!MULTI_PARTS_GATE.test(pyOr(token, ''))) return null;
|
|
758
|
+
const parts = [];
|
|
759
|
+
let cur = null;
|
|
760
|
+
for (const tok of pySplitWs(pyStr(token))) {
|
|
761
|
+
const m = MULTI_PARTS_TOKEN.exec(tok);
|
|
762
|
+
if (m !== null) {
|
|
763
|
+
cur = [pyInt(m[1]), m[2]];
|
|
764
|
+
parts.push(cur);
|
|
765
|
+
} else if (cur !== null) {
|
|
766
|
+
cur[1] = `${cur[1]} ${tok}`.trim();
|
|
767
|
+
} else {
|
|
768
|
+
return null;
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
return parts.length > 0 ? parts.map(([no, val]) => [no, val]) : null;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* `_multi_answers`(2309-2359):逐题值 → `board.answer_interaction` 的 answers 载荷。
|
|
776
|
+
* 返回 `[answers, err]`;**err 非空即 answers 为 `null`**(不提交半份)。阶段一归一/查重/缺题,
|
|
777
|
+
* 阶段二按**题号升序**逐题造载荷(`wire` 走 `str()`,序号形态不去重)。
|
|
778
|
+
*/
|
|
779
|
+
export function multiAnswers(qs, parts) {
|
|
780
|
+
const n = qs.length;
|
|
781
|
+
const seen = new Map();
|
|
782
|
+
for (const [no, val] of parts) {
|
|
783
|
+
if (no < 1 || no > n) return [null, `题号 ${no} 无效(本提问共 ${n} 题)`];
|
|
784
|
+
if (seen.has(no)) return [null, `第 ${no} 题重复作答`];
|
|
785
|
+
seen.set(no, pyStripStrict(pyOr(val, '')));
|
|
786
|
+
}
|
|
787
|
+
const missing = [];
|
|
788
|
+
for (let i = 1; i <= n; i += 1) if (!seen.has(i)) missing.push(i);
|
|
789
|
+
if (missing.length > 0) {
|
|
790
|
+
const heads = missing.map((i) =>
|
|
791
|
+
`第 ${i} 题(${pyOr(dget(pyOr(qs[i - 1], {}), 'header'), '未命名')})`).join('、');
|
|
792
|
+
return [null, `还差 ${heads} 未作答,请一次答全 ${n} 题`];
|
|
793
|
+
}
|
|
794
|
+
const out = [];
|
|
795
|
+
for (let i = 1; i <= n; i += 1) {
|
|
796
|
+
const q = pyOr(qs[i - 1], {});
|
|
797
|
+
const val = seen.get(i);
|
|
798
|
+
const wire = pyStr(pyOr(dget(q, 'id'), `q_${i - 1}`));
|
|
799
|
+
const opts = pyOr(dget(q, 'options'), []);
|
|
800
|
+
const multi = pyTruthy(dget(q, 'multi_select'));
|
|
801
|
+
if (INDEX_CSV.test(val)) { // 序号形态(单个/逗号多选)
|
|
802
|
+
if (!pyTruthy(opts)) return [null, `第 ${i} 题没有选项,请直接写回答文字`];
|
|
803
|
+
const idxs = val.split(INDEX_CSV_SPLIT).map(pyInt);
|
|
804
|
+
if (idxs.some((x) => x < 1 || x > opts.length)) {
|
|
805
|
+
return [null, opts.length > 1
|
|
806
|
+
? `第 ${i} 题选项序号无效(共 ${opts.length} 个选项)`
|
|
807
|
+
: `第 ${i} 题只有一个选项,序号填 1`];
|
|
808
|
+
}
|
|
809
|
+
if (idxs.length > 1 && !multi) return [null, `第 ${i} 题为单选,请只给一个序号`];
|
|
810
|
+
const ids = idxs.map((x) => pyStr(pyOr(dget(pyOr(opts[x - 1], {}), 'id'), '')));
|
|
811
|
+
if (ids.length > 1) out.push({ wire, kind: 'multi', option_ids: ids });
|
|
812
|
+
else out.push({ wire, kind: 'single', option_id: ids[0] });
|
|
813
|
+
continue;
|
|
814
|
+
}
|
|
815
|
+
if (!pyTruthy(val)) return [null, `第 ${i} 题未作答`];
|
|
816
|
+
if (pyLen(val) > 2000) return [null, `第 ${i} 题自定义输入过长(限 2000 字)`];
|
|
817
|
+
if (!pyTruthy(dget(q, 'allow_other', true))) {
|
|
818
|
+
return [null, `第 ${i} 题不支持自定义输入,请给选项序号`];
|
|
819
|
+
}
|
|
820
|
+
out.push({ wire, kind: 'other', text: val });
|
|
821
|
+
}
|
|
822
|
+
return [out, ''];
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// ==========================================================================
|
|
826
|
+
// 找卡 / 项目定位 / 详情 / 摘要(`feishu.py:2082-2093` / `2523-2596`)
|
|
827
|
+
// ==========================================================================
|
|
828
|
+
|
|
829
|
+
/** `_find_cards`(2548-2560):纯数字按 id 前缀,否则标题包含;命中序 = 项目 id + 列 + 列内序 */
|
|
830
|
+
export async function findCards(userId, key, options = {}) {
|
|
831
|
+
const d = dep(options);
|
|
832
|
+
const hits = [];
|
|
833
|
+
for (const p of d.db.listProjects(userId, options)) {
|
|
834
|
+
for (const c of d.db.listBoardCards(dget(p, 'id'), options)) {
|
|
835
|
+
const row = { ...c };
|
|
836
|
+
if (pyIsDigit(key)) {
|
|
837
|
+
if (pyStr(row.id).startsWith(key)) hits.push([p, row]);
|
|
838
|
+
} else if (pyOr(row.title, '').includes(key)) {
|
|
839
|
+
hits.push([p, row]);
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
return hits;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/**
|
|
847
|
+
* `_find_user_card`(2082-2093):找卡并消歧 → `[project, card]` 或 `[null, 错误文案]`。
|
|
848
|
+
* 成功时第二项是**空串**(2093)⇒ 调用方必须用 `is None` 判(本文件两处都判 `null`)。
|
|
849
|
+
*/
|
|
850
|
+
export async function findUserCard(userId, key, options = {}) {
|
|
851
|
+
const hits = await findCards(userId, key, options);
|
|
852
|
+
if (hits.length === 0) {
|
|
853
|
+
return [null, `没有找到匹配「${pyStr(key)}」的卡片(仅搜你自己的项目;支持 id 前缀/标题包含)`];
|
|
854
|
+
}
|
|
855
|
+
if (hits.length > 1) {
|
|
856
|
+
const lines = ['匹配到多张卡,请用更精确的 id 或关键字:'];
|
|
857
|
+
for (const [p, c] of hits.slice(0, 6)) { // 最多 6 条
|
|
858
|
+
lines.push(`· #${c.id} [${p.name}] ${pySlice(pyOr(c.title, ''), 30)}`);
|
|
859
|
+
}
|
|
860
|
+
return [null, lines.join('\n')];
|
|
861
|
+
}
|
|
862
|
+
return [hits[0], ''];
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/** `_card_detail`(2563-2574):列位/阻塞原因/最近错误,末尾一行固定引导(`%s` 五次同值) */
|
|
866
|
+
export function cardDetail(p, c) {
|
|
867
|
+
const lines = [
|
|
868
|
+
`卡片 #${c.id} [${p.name}] ${pySlice(pyOr(c.title, ''), 40)}`,
|
|
869
|
+
`状态:${lookupLabel(COL_LABELS, c.column_key)}`,
|
|
870
|
+
];
|
|
871
|
+
if (c.column_key === 'blocked') {
|
|
872
|
+
lines.push(`阻塞原因:${pyOr(pyOr(dget(c, 'block_text'), dget(c, 'block_kind')), '未知')}`);
|
|
873
|
+
}
|
|
874
|
+
if (pyTruthy(dget(c, 'last_error'))) lines.push(`最近错误:${pySlice(c.last_error, 120)}`);
|
|
875
|
+
lines.push('回复「作答 %s <选项序号|文字>」回复提问;「同意 %s」/「拒绝 %s」'
|
|
876
|
+
.concat('处理审批;「通过 %s」完成审核;「驳回 %s 意见」打回')
|
|
877
|
+
.replaceAll('%s', pyStr(c.id)));
|
|
878
|
+
return lines.join('\n');
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/** `_board_summary`(2577-2596):任务计数 + 逐任务点名(不截条数)+ 五列计数 + 阻塞卡(≤5) */
|
|
882
|
+
export async function boardSummary(p, options = {}) {
|
|
883
|
+
const d = dep(options);
|
|
884
|
+
const tasks = pyOr(d.db.listTasks(dget(p, 'id'), options), []);
|
|
885
|
+
const nRun = tasks.filter((t) => t.status === 'running').length;
|
|
886
|
+
const nQue = tasks.filter((t) => t.status === 'queued').length;
|
|
887
|
+
const lines = [`[${p.name}] 任务:运行中 ${nRun} / 排队 ${nQue}(共 ${tasks.length})`];
|
|
888
|
+
for (const t of tasks) {
|
|
889
|
+
if (t.status === 'running' || t.status === 'queued') {
|
|
890
|
+
lines.push(`· #${t.id} ${pySlice(t.name, 30)}(${lookupLabel(TASK_STATUS, t.status)})`);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
const cards = d.db.listBoardCards(dget(p, 'id'), options).map((c) => ({ ...c }));
|
|
894
|
+
const byCol = {};
|
|
895
|
+
for (const c of cards) {
|
|
896
|
+
byCol[c.column_key] = (Object.hasOwn(byCol, c.column_key) ? byCol[c.column_key] : 0) + 1;
|
|
897
|
+
}
|
|
898
|
+
lines.push('看板:' + COL_ORDER.map((k) =>
|
|
899
|
+
`${lookupLabel(COL_LABELS, k)}${Object.hasOwn(byCol, k) ? byCol[k] : 0}`).join(' '));
|
|
900
|
+
const blocked = cards.filter((c) => c.column_key === 'blocked');
|
|
901
|
+
for (const c of blocked.slice(0, 5)) {
|
|
902
|
+
lines.push(`⛔ #${c.id} ${pySlice(pyOr(c.title, ''), 30)}`
|
|
903
|
+
+ `(${pyOr(pyOr(dget(c, 'block_text'), dget(c, 'block_kind')), '')})`);
|
|
904
|
+
}
|
|
905
|
+
return lines.join('\n');
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/**
|
|
909
|
+
* `_resolve_project`(2523-2545):显式名(精确 → 唯一包含)→ 默认项目 → 用户唯一项目 →
|
|
910
|
+
* 报错列可选(**最多 8 个**、`、` 连接)。返回 `[project, ""]` 或 `[null, 错误文案]`。
|
|
911
|
+
*/
|
|
912
|
+
export async function resolveProject(binding, name, options = {}) {
|
|
913
|
+
const d = dep(options);
|
|
914
|
+
const userId = requireKey(binding, 'user_id');
|
|
915
|
+
const projects = d.db.listProjects(userId, options);
|
|
916
|
+
if (pyTruthy(name)) {
|
|
917
|
+
for (const p of projects) if (p.name === name) return [p, ''];
|
|
918
|
+
const hits = projects.filter((p) => p.name.includes(name));
|
|
919
|
+
if (hits.length === 1) return [hits[0], ''];
|
|
920
|
+
return [null, `项目「${pyStr(name)}」不在你的项目列表`];
|
|
921
|
+
}
|
|
922
|
+
if (pyTruthy(requireKey(binding, 'default_project_id'))) {
|
|
923
|
+
for (const p of projects) {
|
|
924
|
+
if (p.id === binding.default_project_id) return [p, ''];
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
if (projects.length === 1) return [projects[0], ''];
|
|
928
|
+
if (projects.length === 0) return [null, '你还没有项目,请先到站点创建'];
|
|
929
|
+
return [null, '请指明项目(回复「状态 项目名」),可选:'
|
|
930
|
+
+ projects.slice(0, 8).map((p) => p.name).join('、')];
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/** `_set_default_project`(2062-2079):`project_id` 非 0 按 id,否则按名(精确 → 唯一包含) */
|
|
934
|
+
export async function setDefaultProject(binding, name, projectId = 0, options = {}) {
|
|
935
|
+
const d = dep(options);
|
|
936
|
+
if (pyTruthy(projectId)) {
|
|
937
|
+
const project = await d.conv.projectById(binding, projectId, options);
|
|
938
|
+
if (project === null || project === undefined) return '项目不在你的项目列表';
|
|
939
|
+
// 必须走 R3 的同一个 set_default_project(换项目自动清当前会话在该函数内,2064-2065)
|
|
940
|
+
return await d.conv.setDefaultProject(binding, project, options);
|
|
941
|
+
}
|
|
942
|
+
const userId = requireKey(binding, 'user_id');
|
|
943
|
+
const projects = d.db.listProjects(userId, options);
|
|
944
|
+
for (const p of projects) {
|
|
945
|
+
if (p.name === name) return await d.conv.setDefaultProject(binding, p, options);
|
|
946
|
+
}
|
|
947
|
+
const hits = projects.filter((p) => p.name.includes(name));
|
|
948
|
+
if (hits.length === 1) return await d.conv.setDefaultProject(binding, hits[0], options);
|
|
949
|
+
// ⚠歧义(≥2 命中)与零命中**同一文案**(与 `_resolve_project` 的「列可选」不同)
|
|
950
|
+
return `项目「${pyStr(name)}」不在你的项目列表`;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
// ==========================================================================
|
|
954
|
+
// 作答与审批(`feishu.py:2207-2468`)—— 文本指令与卡片按钮**共用**
|
|
955
|
+
// ==========================================================================
|
|
956
|
+
|
|
957
|
+
/** `_answer_by_token`(2362-2448):作答单题/多题提问,返回回执文案(恒非 None) */
|
|
958
|
+
export async function answerByToken(p, c, token, options = {}) {
|
|
959
|
+
const d = dep(options);
|
|
960
|
+
const st = d.board.interactionOfSid(pyOr(c.session_id, ''));
|
|
961
|
+
if (!pyTruthy(st) || !pyTruthy(dget(st, 'pending'))) {
|
|
962
|
+
return `卡片 #${c.id} 当前没有等待中的提问`;
|
|
963
|
+
}
|
|
964
|
+
if (dget(st, 'kind') === 'approval') {
|
|
965
|
+
return `该等待是工具审批,请回复「同意 ${c.id}」或「拒绝 ${c.id}」`;
|
|
966
|
+
}
|
|
967
|
+
if (!pyTruthy(dget(st, 'answerable'))) return NO_REMOTE_ANSWER_HINT;
|
|
968
|
+
const qs = pyOr(dget(st, 'questions'), []); // 单题分支**不过滤假值**(2378/2406)
|
|
969
|
+
if (qs.length > 1) {
|
|
970
|
+
let parts = multiAnswerParts(token);
|
|
971
|
+
const staged = mqPeek(c.id, dget(st, 'qid'), options); // 同一个 st 快照(2381/2397/2398)
|
|
972
|
+
if (parts === null) {
|
|
973
|
+
if (!pyTruthy(staged)) return d.feishu.multiAnswerHelp(c.id, qs); // 复用 M7 实现(2384)
|
|
974
|
+
parts = Object.entries(staged).map(([k, v]) => [Number(k), v])
|
|
975
|
+
.sort((a, b) => a[0] - b[0]);
|
|
976
|
+
} else {
|
|
977
|
+
const nos = parts.map(([no]) => no);
|
|
978
|
+
const dupAt = nos.findIndex((x) => nos.filter((y) => y === x).length > 1);
|
|
979
|
+
if (dupAt >= 0) return `第 ${nos[dupAt]} 题重复作答`;
|
|
980
|
+
const merged = new Map(Object.entries(staged).map(([k, v]) => [Number(k), v]));
|
|
981
|
+
for (const [no, val] of parts) merged.set(no, val); // 文本值覆盖暂存值
|
|
982
|
+
parts = [...merged.entries()].sort((a, b) => a[0] - b[0]);
|
|
983
|
+
}
|
|
984
|
+
const [answers, err] = multiAnswers(qs, parts);
|
|
985
|
+
if (pyTruthy(err)) return err; // ⚠多题分支的 err **无前缀**(2396)
|
|
986
|
+
const err2 = await d.board.answerInteraction(p, c, dget(st, 'qid'), answers, options);
|
|
987
|
+
mqDrop(c.id, dget(st, 'qid'), options); // 提交后被消费(成功与否都清,2398)
|
|
988
|
+
if (pyTruthy(err2)) return `作答失败:${err2}`;
|
|
989
|
+
return `已收下:卡片 #${c.id} ${answers.length} 道作答,排队送达中`;
|
|
990
|
+
}
|
|
991
|
+
if (multiAnswerParts(token) !== null) {
|
|
992
|
+
// 单题收到「1:2」这类逐题语法:明确纠正,别当自由文本提交(2402-2405)
|
|
993
|
+
return `该提问只有一道题,请回复「作答 ${c.id} <选项序号>」`
|
|
994
|
+
+ `或「作答 ${c.id} <文字>」`;
|
|
995
|
+
}
|
|
996
|
+
const q0 = qs.length > 0 ? qs[0] : {};
|
|
997
|
+
const opts = pyOr(pyOr(dget(q0, 'options'), null), pyOr(dget(st, 'options'), [])); // 两级兜底
|
|
998
|
+
const wire = pyOr(dget(q0, 'id'), 'q_0'); // ⚠**无 str()**(2408),与 2334 不一致
|
|
999
|
+
const optList = opts.map((o, i) => `${i + 1})${dget(pyOr(o, {}), 'label', '')}`).join(' / ');
|
|
1000
|
+
if (INDEX_CSV.test(token)) {
|
|
1001
|
+
const idxs = token.split(INDEX_CSV_SPLIT).map(pyInt);
|
|
1002
|
+
if (idxs.some((i) => i < 1 || i > opts.length)) {
|
|
1003
|
+
return pyTruthy(opts) ? `选项序号无效,可回复:${optList}` : '该提问没有可选选项';
|
|
1004
|
+
}
|
|
1005
|
+
if (idxs.length === 1) {
|
|
1006
|
+
const opt = opts[idxs[0] - 1];
|
|
1007
|
+
const err = await d.board.answerInteraction(p, c, dget(st, 'qid'),
|
|
1008
|
+
[{ wire, kind: 'single', option_id: dget(pyOr(opt, {}), 'id') }], options);
|
|
1009
|
+
if (pyTruthy(err)) return `作答失败:${err}`;
|
|
1010
|
+
return `已收下:卡片 #${c.id} 选择「${dget(pyOr(opt, {}), 'label', '')}」,排队送达中`;
|
|
1011
|
+
}
|
|
1012
|
+
if (!pyTruthy(dget(q0, 'multi_select'))) return '该问题不支持多选,请回复单个选项序号';
|
|
1013
|
+
const chosen = idxs.map((i) => opts[i - 1]);
|
|
1014
|
+
const err = await d.board.answerInteraction(p, c, dget(st, 'qid'),
|
|
1015
|
+
[{ wire, kind: 'multi', option_ids: chosen.map((o) => dget(pyOr(o, {}), 'id')) }], options);
|
|
1016
|
+
if (pyTruthy(err)) return `作答失败:${err}`;
|
|
1017
|
+
const labels = chosen.map((o) => dget(pyOr(o, {}), 'label', '')).join('、');
|
|
1018
|
+
return `已收下:卡片 #${c.id} 多选「${labels}」,排队送达中`;
|
|
1019
|
+
}
|
|
1020
|
+
if (!pyTruthy(dget(q0, 'allow_other'))) { // 文本形态:须题目 allow_other(2437)
|
|
1021
|
+
if (pyTruthy(opts)) return `该问题不支持自定义输入,可回复选项序号:${optList}`;
|
|
1022
|
+
return '该提问无选项且不支持自定义输入,请到站点会话窗口处理';
|
|
1023
|
+
}
|
|
1024
|
+
if (pyLen(token) > 2000) return '输入内容过长(限 2000 字)';
|
|
1025
|
+
const err = await d.board.answerInteraction(p, c, dget(st, 'qid'),
|
|
1026
|
+
[{ wire, kind: 'other', text: token }], options);
|
|
1027
|
+
if (pyTruthy(err)) return `作答失败:${err}`;
|
|
1028
|
+
return `已收下:卡片 #${c.id} 自定义回答,排队送达中`;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
/**
|
|
1032
|
+
* `_answer_multi_click`(2207-2273):多选类点选(多子题逐题点选 / 单题多选 mini 表单)。
|
|
1033
|
+
* 返回 `[DM 回执文案 or "", toast]`——**toast 恒有**,未答满时 DM 是空串。
|
|
1034
|
+
* 校验一律读 `interaction_of_sid` 的**当前**服务端值;`st.get("qid")` 在三处复用**同一个快照**。
|
|
1035
|
+
*/
|
|
1036
|
+
export async function answerMultiClick(p, c, value, action, options = {}) {
|
|
1037
|
+
const d = dep(options);
|
|
1038
|
+
const st = d.board.interactionOfSid(pyOr(c.session_id, ''));
|
|
1039
|
+
if (!pyTruthy(st) || !pyTruthy(dget(st, 'pending'))) {
|
|
1040
|
+
return [`卡片 #${c.id} 当前没有等待中的提问`, toast('error', '提问已结束')];
|
|
1041
|
+
}
|
|
1042
|
+
if (dget(st, 'kind') !== 'question') {
|
|
1043
|
+
return [`卡片 #${c.id} 当前等待的不是提问,请回复「同意 ${c.id}」或「拒绝 ${c.id}」`,
|
|
1044
|
+
toast('error', '该等待是审批')];
|
|
1045
|
+
}
|
|
1046
|
+
if (!pyTruthy(dget(st, 'answerable'))) {
|
|
1047
|
+
return [NO_REMOTE_ANSWER_HINT, toast('error', '请到 dsh 会话窗口作答')];
|
|
1048
|
+
}
|
|
1049
|
+
const qs = pyOr(dget(st, 'questions'), []).filter((q) => pyTruthy(q)); // 过滤假值题(2227)
|
|
1050
|
+
const n = qs.length;
|
|
1051
|
+
if (n < 1) return [`卡片 #${c.id} 当前没有等待中的提问`, toast('error', '提问已结束')];
|
|
1052
|
+
if (n === 1 && !pyTruthy(dget(pyOr(qs[0], {}), 'multi_select'))) {
|
|
1053
|
+
return [`该提问是单选题,请用卡片上的选项按钮,或回复「作答 ${c.id} <选项序号>」`,
|
|
1054
|
+
toast('error', '单题单选请用按钮')];
|
|
1055
|
+
}
|
|
1056
|
+
let no = 0;
|
|
1057
|
+
try {
|
|
1058
|
+
no = pyInt(dget(value, 'n'));
|
|
1059
|
+
} catch (e) {
|
|
1060
|
+
// oracle `except (TypeError, ValueError): no = 0`(2235-2238)
|
|
1061
|
+
if (e instanceof TypeError || (e instanceof Error && e.name === 'ValueError')) no = 0;
|
|
1062
|
+
else throw e;
|
|
1063
|
+
}
|
|
1064
|
+
if (no < 1 || no > n) {
|
|
1065
|
+
return ['题号无效(卡片可能已过期),请重新作答', toast('error', '题号无效')];
|
|
1066
|
+
}
|
|
1067
|
+
const q = pyOr(qs[no - 1], {});
|
|
1068
|
+
const opts = pyOr(dget(q, 'options'), []);
|
|
1069
|
+
if (!pyTruthy(opts)) {
|
|
1070
|
+
return [`第 ${no} 题没有选项,请回复「作答 ${c.id} ${no}:<文字>」`,
|
|
1071
|
+
toast('error', '该题请写文字')];
|
|
1072
|
+
}
|
|
1073
|
+
const idxs = multiClickIndex(action, pyTruthy(dget(q, 'multi_select')), `mq${c.id}_${no}`);
|
|
1074
|
+
if (idxs === null) {
|
|
1075
|
+
return [`没收到你选的选项,请改用「作答 ${c.id} ${no}:<选项序号>」`,
|
|
1076
|
+
toast('error', '未收到选项,请看单聊')];
|
|
1077
|
+
}
|
|
1078
|
+
if (idxs.some((i) => i < 1 || i > opts.length)) {
|
|
1079
|
+
return [`第 ${no} 题选项序号无效(共 ${opts.length} 个选项)`, toast('error', '选项无效')];
|
|
1080
|
+
}
|
|
1081
|
+
if (idxs.length > 1 && !pyTruthy(dget(q, 'multi_select'))) {
|
|
1082
|
+
return [`第 ${no} 题为单选,请只选一项`, toast('error', '单选只能选一项')];
|
|
1083
|
+
}
|
|
1084
|
+
// 与文本指令同一形态:去重 + 升序 + 半角逗号无空格(2257)
|
|
1085
|
+
const val = [...new Set(idxs)].sort((a, b) => a - b).map((i) => pyStr(i)).join(',');
|
|
1086
|
+
const [done, total, ready] = mqPut(c.id, dget(st, 'qid'), n, no, val, options);
|
|
1087
|
+
if (!ready) {
|
|
1088
|
+
return ['', toast('success', `已记下第 ${no} 题(${done}/${total})`)]; // 全角括号 + 半角斜杠
|
|
1089
|
+
}
|
|
1090
|
+
const parts = mqTake(c.id, dget(st, 'qid'), n, options);
|
|
1091
|
+
if (parts.length < n) {
|
|
1092
|
+
// 极端竞态(取走前被 TTL 清掉/并发取走):不提交半份,让用户重选(2262-2265)
|
|
1093
|
+
return ['作答未提交(暂存已过期),请重新逐题选择', toast('error', '暂存过期,请重选')];
|
|
1094
|
+
}
|
|
1095
|
+
const [answers, err] = multiAnswers(qs, parts);
|
|
1096
|
+
if (pyTruthy(err)) return [`作答未提交:${err}`, toast('error', '作答未提交')];
|
|
1097
|
+
const err2 = await d.board.answerInteraction(p, c, dget(st, 'qid'), answers, options);
|
|
1098
|
+
if (pyTruthy(err2)) return [`作答失败:${err2}`, toast('error', '作答失败')];
|
|
1099
|
+
return [`已收下:卡片 #${c.id} ${answers.length} 道作答,排队送达中`,
|
|
1100
|
+
toast('success', `${answers.length} 题已答完,已送达`)];
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
/** `_decide_approval`(2451-2468):审批决定(文本与卡片按钮共用);`decision` 不校验 */
|
|
1104
|
+
export async function decideApproval(p, c, decision, scope, options = {}) {
|
|
1105
|
+
const d = dep(options);
|
|
1106
|
+
const st = d.board.interactionOfSid(pyOr(c.session_id, ''));
|
|
1107
|
+
if (!pyTruthy(st) || !pyTruthy(dget(st, 'pending')) || dget(st, 'kind') !== 'approval') {
|
|
1108
|
+
return `卡片 #${c.id} 当前没有等待中的审批(提问类请回复「作答 ${c.id} <选项序号|文字>」)`;
|
|
1109
|
+
}
|
|
1110
|
+
const aid = pyStr(pyOr(dget(st, 'approval_id'), ''));
|
|
1111
|
+
if (!pyTruthy(aid)) return '审批不存在或已过期,请到站点会话窗口处理';
|
|
1112
|
+
const err = await d.board.answerApproval(p, c, aid, decision, scope, options);
|
|
1113
|
+
if (pyTruthy(err)) return `审批失败:${err}`;
|
|
1114
|
+
const verb = decision === 'approved' ? '已批准' : '已拒绝';
|
|
1115
|
+
const tail = scope === 'session' ? '(本会话内同类调用自动放行)' : '';
|
|
1116
|
+
return `✅ 卡片 #${c.id} 审批${verb}${tail},排队送达中`;
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
// ==========================================================================
|
|
1120
|
+
// 看板动作(`feishu.py:2471-2520`)
|
|
1121
|
+
// ==========================================================================
|
|
1122
|
+
|
|
1123
|
+
/**
|
|
1124
|
+
* `_execute_board_action`(2471-2520):状态/找卡/通过/驳回/作答/同意/拒绝。
|
|
1125
|
+
* `board.move_card` 的 err 是 **dict**(`{"error"|"blocked"|"merge_pending"}`);
|
|
1126
|
+
* `board.start_card` **只捕 RuntimeError**(2510)——Node 侧对应 `Error`(board.js 只抛 Error)。
|
|
1127
|
+
*/
|
|
1128
|
+
export async function executeBoardAction(binding, intent, senderOpenId, options = {}) {
|
|
1129
|
+
const d = dep(options);
|
|
1130
|
+
const action = requireKey(intent, 'action'); // 2477 在 try 之外 ⇒ 缺键穿透
|
|
1131
|
+
const g = requireKey(intent, 'groups');
|
|
1132
|
+
const userId = requireKey(binding, 'user_id');
|
|
1133
|
+
if (action === 'status') {
|
|
1134
|
+
const [p, err] = await resolveProject(binding, g[0], options);
|
|
1135
|
+
if (p === null || p === undefined) return err;
|
|
1136
|
+
return await boardSummary(p, options);
|
|
1137
|
+
}
|
|
1138
|
+
const key = pyStripStrict(g.length > 0 ? g[0] : '');
|
|
1139
|
+
const [found, err] = await findUserCard(userId, key, options);
|
|
1140
|
+
if (found === null || found === undefined) return err;
|
|
1141
|
+
const [p, c] = found;
|
|
1142
|
+
if (action === 'card') return cardDetail(p, c);
|
|
1143
|
+
if (action === 'approve') {
|
|
1144
|
+
if (c.column_key !== 'review') {
|
|
1145
|
+
return `卡片 #${c.id} 不在「待审核」列(当前 ${c.column_key}),无法通过`;
|
|
1146
|
+
}
|
|
1147
|
+
const [, moveErr] = await d.board.moveCard(p, c.id, 'done', '', null, false, options);
|
|
1148
|
+
if (pyTruthy(moveErr)) {
|
|
1149
|
+
if (Object.hasOwn(moveErr, 'merge_pending')) {
|
|
1150
|
+
const mp = pyOr(dget(moveErr, 'merge_pending'), {});
|
|
1151
|
+
return `卡片 #${pyStr(c.id)} 还有 ${pyStr(dget(mp, 'ahead', 0))} 个提交没回流主分支`
|
|
1152
|
+
+ `(${pyStr(dget(mp, 'branch', ''))} → ${pyStr(dget(mp, 'target', ''))});`
|
|
1153
|
+
+ '请在网页看板上点「通过」,选择「交给 agent 合并」或「仅通过(不合并)」';
|
|
1154
|
+
}
|
|
1155
|
+
return '审核失败:' + pyStr(pyOr(pyOr(dget(moveErr, 'error'), dget(moveErr, 'blocked')),
|
|
1156
|
+
'门禁拦截'));
|
|
1157
|
+
}
|
|
1158
|
+
return `✅ 卡片 #${c.id} ${pySlice(pyOr(c.title, ''), 30)} 已审核通过`;
|
|
1159
|
+
}
|
|
1160
|
+
if (action === 'reject') {
|
|
1161
|
+
if (c.column_key !== 'review') {
|
|
1162
|
+
return `卡片 #${c.id} 不在「待审核」列(当前 ${c.column_key}),无法驳回`;
|
|
1163
|
+
}
|
|
1164
|
+
try {
|
|
1165
|
+
await d.board.startCard(p, c, pyOr(g[1], ''), options);
|
|
1166
|
+
} catch (e) {
|
|
1167
|
+
if (e instanceof Error) return `打回失败:${e.message}`; // Python `str(RuntimeError)`
|
|
1168
|
+
throw e;
|
|
1169
|
+
}
|
|
1170
|
+
return `↩️ 卡片 #${c.id} 已打回继续开发,打回意见将注入 agent`;
|
|
1171
|
+
}
|
|
1172
|
+
if (action === 'answer') return await answerByToken(p, c, pyStripStrict(pyOr(g[1], '')), options);
|
|
1173
|
+
if (action === 'agree' || action === 'deny') {
|
|
1174
|
+
const decision = action === 'agree' ? 'approved' : 'rejected';
|
|
1175
|
+
const scope = (action === 'agree' && (g.length > 1 ? g[1] : '') === '会话') ? 'session' : '';
|
|
1176
|
+
return await decideApproval(p, c, decision, scope, options);
|
|
1177
|
+
}
|
|
1178
|
+
return HELP_TEXT;
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
// ==========================================================================
|
|
1182
|
+
// 意图执行(`feishu.py:2038-2059`)
|
|
1183
|
+
// ==========================================================================
|
|
1184
|
+
|
|
1185
|
+
/**
|
|
1186
|
+
* `execute_intent`(2038-2059):按绑定身份执行意图;返回回复文案(**空串 = 不回复**)。
|
|
1187
|
+
* `action, g = intent["action"], intent["groups"]` 在 `try` **之外**(2042)⇒ 缺键穿透;
|
|
1188
|
+
* 异常映射**顺序敏感**:先 `FeishuRestError` 后宽泛 Exception。
|
|
1189
|
+
*/
|
|
1190
|
+
export async function executeIntent(binding, intent, senderOpenId, cfg = null, options = {}) {
|
|
1191
|
+
const d = dep(options);
|
|
1192
|
+
const action = requireKey(intent, 'action');
|
|
1193
|
+
const g = requireKey(intent, 'groups');
|
|
1194
|
+
try {
|
|
1195
|
+
if (action === 'help') return HELP_TEXT;
|
|
1196
|
+
if (action === 'bind') return '当前账号已绑定,无需重复绑定'; // 第二道(2047)
|
|
1197
|
+
if (action === 'unbind') {
|
|
1198
|
+
d.db.delFeishuBindingByOpen(senderOpenId, options);
|
|
1199
|
+
return '已解绑。重新绑定请到站点生成新的绑定码';
|
|
1200
|
+
}
|
|
1201
|
+
if (action === 'default_project') {
|
|
1202
|
+
return await setDefaultProject(binding, pyOr(g[0], ''), 0, options);
|
|
1203
|
+
}
|
|
1204
|
+
if (['current', 'projects', 'sessions', 'new', 'use', 'leave'].includes(action)) {
|
|
1205
|
+
return await d.conv.convIntent(binding, action, g, senderOpenId, cfg, options);
|
|
1206
|
+
}
|
|
1207
|
+
return await executeBoardAction(binding, intent, senderOpenId, options);
|
|
1208
|
+
} catch (e) {
|
|
1209
|
+
if (e instanceof FeishuRestError) return '飞书接口调用失败,请稍后重试';
|
|
1210
|
+
return '指令执行失败,请稍后重试';
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
// ==========================================================================
|
|
1215
|
+
// 消息入站主流程(`feishu.py:1769-1860`)
|
|
1216
|
+
// ==========================================================================
|
|
1217
|
+
|
|
1218
|
+
/** SDK 事件载荷 → 普通对象(Buffer/字符串都收;R1 的 handler 给的是**字节**) */
|
|
1219
|
+
function parseEventPayload(payload) {
|
|
1220
|
+
if (Buffer.isBuffer(payload)) return JSON.parse(payload.toString('utf8'));
|
|
1221
|
+
if (typeof payload === 'string') return JSON.parse(payload);
|
|
1222
|
+
if (payload === null || payload === undefined || typeof payload !== 'object') {
|
|
1223
|
+
throw new TypeError('事件载荷既不是字节也不是对象');
|
|
1224
|
+
}
|
|
1225
|
+
return payload;
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
/**
|
|
1229
|
+
* `_on_message_event`(1769-1782):SDK `P2ImMessageReceiveV1` → 统一 6 键 dict → 主流程。
|
|
1230
|
+
* 键序与 1776-1780 一致;`mentions[].open_id` 缺省是 **`None`**(1774,与 `_card_sdk_to_dict`
|
|
1231
|
+
* 的 `""` 相反);异常全吞并打 1782 那行。
|
|
1232
|
+
* 形态差异:oracle 入参是 SDK 模型对象(属性链),本移植收**已解析的普通对象/字节**
|
|
1233
|
+
* (R1 给字节)——属性链断链仍照 oracle 抛 AttributeError。
|
|
1234
|
+
*/
|
|
1235
|
+
export async function onMessageEvent(payload, cfg, options = {}) {
|
|
1236
|
+
const d = dep(options);
|
|
1237
|
+
try {
|
|
1238
|
+
const data = parseEventPayload(payload);
|
|
1239
|
+
const event = dget(data, 'event');
|
|
1240
|
+
if (!pyTruthy(event)) throw attributeError('NoneType', 'message');
|
|
1241
|
+
const msg = dget(event, 'message');
|
|
1242
|
+
if (!pyTruthy(msg)) throw attributeError('NoneType', 'mentions');
|
|
1243
|
+
const mentions = pyOr(dget(msg, 'mentions'), []).map((m) => {
|
|
1244
|
+
const id = pyTruthy(m) ? dget(m, 'id') : null;
|
|
1245
|
+
return { key: dget(m, 'key'), open_id: pyTruthy(id) ? dget(id, 'open_id') : null };
|
|
1246
|
+
});
|
|
1247
|
+
const sender = dget(pyOr(dget(event, 'sender'), {}), 'sender_id');
|
|
1248
|
+
if (!pyTruthy(sender)) throw attributeError('NoneType', 'open_id');
|
|
1249
|
+
await handleMessageEvent({
|
|
1250
|
+
message_id: dget(msg, 'message_id'),
|
|
1251
|
+
message_type: dget(msg, 'message_type'),
|
|
1252
|
+
content: dget(msg, 'content'),
|
|
1253
|
+
chat_type: dget(msg, 'chat_type'),
|
|
1254
|
+
mentions,
|
|
1255
|
+
sender_open_id: dget(sender, 'open_id'),
|
|
1256
|
+
}, cfg, options);
|
|
1257
|
+
} catch (e) {
|
|
1258
|
+
d.log(`feishu inbound: 事件解析异常: ${errorRepr(e)}`);
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
/**
|
|
1263
|
+
* `handle_message_event`(1796-1860):**入站主干**。
|
|
1264
|
+
* 顺序即语义:去重先于一切(1805)→ 群聊 @ 判定(1810-1814,静默)→ 非文本(1815-1818)→
|
|
1265
|
+
* 取文本并剥 @ 占位符(1819-1827)→ **绑定指令先于绑定校验**(1837 在 1844 之前)→
|
|
1266
|
+
* 未识别文本分岔(1848-1855,群聊恒 HELP_TEXT)→ 执行(1856-1858,空串不回复)。
|
|
1267
|
+
* `cfg` 默认 `null`(1796 的 `cfg=None`)——`rest_reply` 会照 oracle 抛「应用未配置」。
|
|
1268
|
+
*/
|
|
1269
|
+
export async function handleMessageEvent(evt, cfg = null, options = {}) {
|
|
1270
|
+
const d = dep(options);
|
|
1271
|
+
try {
|
|
1272
|
+
const mid = dget(evt, 'message_id');
|
|
1273
|
+
if (!pyTruthy(mid) || seenMessage(mid)) return;
|
|
1274
|
+
d.log(`feishu inbound: 收到消息 chat_type=${pyStr(dget(evt, 'chat_type'))} `
|
|
1275
|
+
+ `type=${pyStr(dget(evt, 'message_type'))} `
|
|
1276
|
+
+ `sender=${pySlice(pyStr(pyOr(dget(evt, 'sender_open_id'), '')), 12)}`);
|
|
1277
|
+
if (dget(evt, 'chat_type') === 'group') {
|
|
1278
|
+
const bo = await d.rest.botOpenId(cfg);
|
|
1279
|
+
const mentions = pyOr(dget(evt, 'mentions'), []);
|
|
1280
|
+
if (!pyTruthy(bo) || !mentions.some((m) => dget(pyOr(m, {}), 'open_id') === bo)) return;
|
|
1281
|
+
}
|
|
1282
|
+
if (dget(evt, 'message_type') !== 'text') {
|
|
1283
|
+
if (dget(evt, 'chat_type') !== 'group') {
|
|
1284
|
+
await d.rest.restReply(mid, '暂不支持该消息类型,请发送文字指令', cfg);
|
|
1285
|
+
}
|
|
1286
|
+
return;
|
|
1287
|
+
}
|
|
1288
|
+
let content;
|
|
1289
|
+
try {
|
|
1290
|
+
content = pyJsonLoads(pyOr(dget(evt, 'content'), '{}'));
|
|
1291
|
+
} catch (e) {
|
|
1292
|
+
if (e instanceof SyntaxError) content = {}; // oracle: `except ValueError`(1821-1822)
|
|
1293
|
+
else throw e;
|
|
1294
|
+
}
|
|
1295
|
+
let text = pyStripStrict(pyOr(dget(content, 'text'), ''));
|
|
1296
|
+
for (const m of pyOr(dget(evt, 'mentions'), [])) {
|
|
1297
|
+
const key = dget(pyOr(m, {}), 'key');
|
|
1298
|
+
if (pyTruthy(key)) {
|
|
1299
|
+
if (typeof key !== 'string') throw new TypeError('replace() argument 1 must be str');
|
|
1300
|
+
text = pyStripStrict(text.replaceAll(key, '')); // 全局替换 + 每次 strip(1824-1827)
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
if (!pyTruthy(text)) return;
|
|
1304
|
+
const sender = pyOr(dget(evt, 'sender_open_id'), '');
|
|
1305
|
+
const binding = d.db.getFeishuBindingByOpen(sender, options);
|
|
1306
|
+
const intent = parseIntent(text);
|
|
1307
|
+
d.log(`feishu inbound: 文本=${pyReprStr(pySlice(text, 30))} `
|
|
1308
|
+
+ `intent=${pyOr(dget(pyOr(intent, {}), 'action'), '-')}`);
|
|
1309
|
+
// 绑定指令先于绑定校验(1835-1843):否则未绑定者永远绑不上(死锁)
|
|
1310
|
+
if (pyTruthy(intent) && dget(intent, 'action') === 'bind') {
|
|
1311
|
+
if (binding !== null && binding !== undefined) {
|
|
1312
|
+
await d.rest.restReply(mid, '当前账号已绑定,无需重复绑定(解绑后可重新绑定)', cfg);
|
|
1313
|
+
return;
|
|
1314
|
+
}
|
|
1315
|
+
const err = d.feishu.bindUser(dget(intent, 'groups')[0], sender, options);
|
|
1316
|
+
await d.rest.restReply(mid, (err === null || err === undefined)
|
|
1317
|
+
? '绑定成功,发「帮助」查看可用指令' : err, cfg);
|
|
1318
|
+
return;
|
|
1319
|
+
}
|
|
1320
|
+
if (binding === null || binding === undefined) {
|
|
1321
|
+
await d.rest.restReply(mid, UNBOUND_DM_MSG, cfg);
|
|
1322
|
+
return;
|
|
1323
|
+
}
|
|
1324
|
+
if (intent === null) {
|
|
1325
|
+
let reply;
|
|
1326
|
+
if (dget(evt, 'chat_type') !== 'group' && pyTruthy(await d.conv.enabled(options))) {
|
|
1327
|
+
reply = await d.conv.routeText(binding, text, cfg, options);
|
|
1328
|
+
} else {
|
|
1329
|
+
reply = HELP_TEXT; // 群聊保持原行为:不落会话(1853)
|
|
1330
|
+
}
|
|
1331
|
+
await d.rest.restReply(mid, reply, cfg);
|
|
1332
|
+
return;
|
|
1333
|
+
}
|
|
1334
|
+
const reply = await executeIntent(binding, intent, sender, cfg, options);
|
|
1335
|
+
if (pyTruthy(reply)) await d.rest.restReply(mid, reply, cfg); // 空串不回复(1857)
|
|
1336
|
+
} catch (e) {
|
|
1337
|
+
d.log(`feishu inbound: 处理异常: ${errorRepr(e)}`);
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
// ==========================================================================
|
|
1342
|
+
// 卡片回调主流程(`feishu.py:1884-1978`)
|
|
1343
|
+
// ==========================================================================
|
|
1344
|
+
|
|
1345
|
+
/**
|
|
1346
|
+
* `_card_sdk_to_dict`(1952-1978):SDK `P2CardActionTrigger` 模型 → `_on_card_action` 的 dict。
|
|
1347
|
+
* 白名单只拷 7 键(`tag`/`name`/`form_value`/`input_value`/`option`/`options`/`checked`),
|
|
1348
|
+
* **仅当值 `is not None`**(`checked=False` 会被拷贝);**`timezone` 不拷**;
|
|
1349
|
+
* `header`/`operator` 缺省是 **`""`**(1971/1974)。
|
|
1350
|
+
* 入参在本移植里是**已解析的普通对象**(R1 给字节 ⇒ `wsRun` 先 parse 再喂这里)。
|
|
1351
|
+
*/
|
|
1352
|
+
export function cardSdkToDict(data) {
|
|
1353
|
+
const header = dget(data, 'header');
|
|
1354
|
+
const event = dget(data, 'event');
|
|
1355
|
+
const operator = pyTruthy(event) ? dget(event, 'operator') : null;
|
|
1356
|
+
const action = pyTruthy(event) ? dget(event, 'action') : null;
|
|
1357
|
+
const actionD = { value: pyTruthy(action) ? pyOr(dget(action, 'value'), {}) : {} };
|
|
1358
|
+
if (pyTruthy(action)) {
|
|
1359
|
+
for (const k of ['tag', 'name', 'form_value', 'input_value', 'option', 'options', 'checked']) {
|
|
1360
|
+
const v = dget(action, k);
|
|
1361
|
+
if (v !== null && v !== undefined) actionD[k] = v;
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
return {
|
|
1365
|
+
header: { event_id: pyTruthy(header) ? pyOr(dget(header, 'event_id'), '') : '' },
|
|
1366
|
+
event: {
|
|
1367
|
+
operator: { open_id: pyTruthy(operator) ? pyOr(dget(operator, 'open_id'), '') : '' },
|
|
1368
|
+
action: actionD,
|
|
1369
|
+
},
|
|
1370
|
+
};
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
/**
|
|
1374
|
+
* `_on_card_action`(1884-1949):卡片点击回调主流程。
|
|
1375
|
+
* 顺序即语义:`event_id` 去重(1899-1901,**空 event_id 不去重**)→ `t=="sc"`(1909)→
|
|
1376
|
+
* `t in ("dp","ss")`(1913)→ 卡号白名单 `t in ("a","mq","p","s","d") and c`(1920)→
|
|
1377
|
+
* 绑定校验 → 找卡 → 按 `t` 分派。异常全吞(1947-1949)⇒ 返回 `null`(不更新卡片)。
|
|
1378
|
+
*/
|
|
1379
|
+
export async function onCardAction(data, cfg, options = {}) {
|
|
1380
|
+
const d = dep(options);
|
|
1381
|
+
try {
|
|
1382
|
+
const header = pyOr(dget(data, 'header'), {});
|
|
1383
|
+
const eventId = pyStr(pyOr(pyOr(dget(header, 'event_id'), dget(data, 'event_id')), ''));
|
|
1384
|
+
if (pyTruthy(eventId) && seenEvent(eventId)) return null;
|
|
1385
|
+
const event = pyOr(dget(data, 'event'), {});
|
|
1386
|
+
const sender = pyStr(pyOr(dget(pyOr(dget(event, 'operator'), {}), 'open_id'), ''));
|
|
1387
|
+
const action = pyOr(dget(event, 'action'), {});
|
|
1388
|
+
const value = pyDictCopy(pyOr(dget(action, 'value'), {}));
|
|
1389
|
+
const t = pyStr(pyOr(dget(value, 't'), ''));
|
|
1390
|
+
const key = pyStr(pyOr(dget(value, 'c'), ''));
|
|
1391
|
+
if (t === 'sc') return await onSlashPermAction(sender, value, cfg, options);
|
|
1392
|
+
if (t === 'dp' || t === 'ss') {
|
|
1393
|
+
const binding = d.db.getFeishuBindingByOpen(sender, options);
|
|
1394
|
+
if (binding === null || binding === undefined) {
|
|
1395
|
+
await dmText(sender, UNBOUND_DM_CARD, cfg, options);
|
|
1396
|
+
return toast('error', '尚未绑定站点账号');
|
|
1397
|
+
}
|
|
1398
|
+
return await d.conv.onCardAction(sender, binding, value, action, cfg, options);
|
|
1399
|
+
}
|
|
1400
|
+
if (!['a', 'mq', 'p', 's', 'd'].includes(t) || !pyTruthy(key)) return null;
|
|
1401
|
+
const binding = d.db.getFeishuBindingByOpen(sender, options);
|
|
1402
|
+
if (binding === null || binding === undefined) {
|
|
1403
|
+
await dmText(sender, UNBOUND_DM_CARD, cfg, options);
|
|
1404
|
+
return toast('error', '尚未绑定站点账号');
|
|
1405
|
+
}
|
|
1406
|
+
const [found, err] = await findUserCard(requireKey(binding, 'user_id'), key, options);
|
|
1407
|
+
if (found === null || found === undefined) {
|
|
1408
|
+
await dmText(sender, err, cfg, options);
|
|
1409
|
+
return toast('error', '没找到对应卡片');
|
|
1410
|
+
}
|
|
1411
|
+
const [p, c] = found;
|
|
1412
|
+
let reply;
|
|
1413
|
+
if (t === 'mq') {
|
|
1414
|
+
const [dm, replyToast] = await answerMultiClick(p, c, value, action, options);
|
|
1415
|
+
if (pyTruthy(dm)) await dmText(sender, dm, cfg, options);
|
|
1416
|
+
return replyToast; // 每次点击都回 toast(含进度)(1932-1937)
|
|
1417
|
+
}
|
|
1418
|
+
if (t === 'a') {
|
|
1419
|
+
reply = await answerByToken(p, c, pyStr(pyOr(dget(value, 'i'), '')), options);
|
|
1420
|
+
} else {
|
|
1421
|
+
const decision = (t === 'p' || t === 's') ? 'approved' : 'rejected';
|
|
1422
|
+
reply = await decideApproval(p, c, decision, t === 's' ? 'session' : '', options);
|
|
1423
|
+
}
|
|
1424
|
+
if (pyTruthy(reply)) await dmText(sender, reply, cfg, options);
|
|
1425
|
+
return toastFor(reply);
|
|
1426
|
+
} catch (e) {
|
|
1427
|
+
d.log(`feishu inbound: 卡片回调处理异常: ${errorRepr(e)}`);
|
|
1428
|
+
return null;
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
// ==========================================================================
|
|
1433
|
+
// 斜杠权限卡回调(`feishu.py:1495-1503` / `1553-1574`)—— 见文件头差异 9
|
|
1434
|
+
// ==========================================================================
|
|
1435
|
+
|
|
1436
|
+
/** `_slash_summary`(1495-1503):同步结果一句话摘要(DM 回执与设置页提示共用) */
|
|
1437
|
+
export function slashSummary(out) {
|
|
1438
|
+
let s = `新增 ${pyLen(pyOr(dget(out, 'created'), []))}、`
|
|
1439
|
+
+ `更新 ${pyLen(pyOr(dget(out, 'updated'), []))}、`
|
|
1440
|
+
+ `已是最新 ${pyLen(pyOr(dget(out, 'kept'), []))}`;
|
|
1441
|
+
if (pyTruthy(dget(out, 'failed'))) s += `、失败 ${pyLen(dget(out, 'failed'))}`;
|
|
1442
|
+
return s;
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
/**
|
|
1446
|
+
* `_on_slash_perm_action`(1553-1574):权限引导卡按钮回调 —— 复核身份(必须是该卡片指向的、
|
|
1447
|
+
* 已绑定的用户)→ 重跑 `sync_slash_commands` → DM 文本回执 + toast。
|
|
1448
|
+
*/
|
|
1449
|
+
export async function onSlashPermAction(sender, value, cfg, options = {}) {
|
|
1450
|
+
const d = dep(options);
|
|
1451
|
+
const binding = d.db.getFeishuBindingByOpen(sender, options);
|
|
1452
|
+
if (binding === null || binding === undefined) {
|
|
1453
|
+
await dmText(sender, UNBOUND_DM_MSG, cfg, options);
|
|
1454
|
+
return toast('error', '尚未绑定站点账号');
|
|
1455
|
+
}
|
|
1456
|
+
const userId = requireKey(binding, 'user_id');
|
|
1457
|
+
const uid = pyInt(pyOr(dget(value, 'u'), 0));
|
|
1458
|
+
if (pyTruthy(uid) && uid !== pyInt(userId)) {
|
|
1459
|
+
return toast('error', '该卡片不属于当前账号'); // 防串号(1562-1564)
|
|
1460
|
+
}
|
|
1461
|
+
const out = await d.slash.syncSlashCommands(userId, options);
|
|
1462
|
+
if (pyTruthy(dget(out, 'ok'))) {
|
|
1463
|
+
await dmText(sender, '✅ 快捷指令注册完成:' + slashSummary(out)
|
|
1464
|
+
+ '\n约 5 分钟后在机器人私聊输入「/」即可看到(重启飞书客户端可加速)。', cfg, options);
|
|
1465
|
+
return toast('success', '注册完成');
|
|
1466
|
+
}
|
|
1467
|
+
let text = '❌ 仍未成功:' + pyOr(dget(out, 'error'), '未知错误');
|
|
1468
|
+
if (pyTruthy(dget(out, 'need_scope'))) {
|
|
1469
|
+
text += '\n看起来权限还没生效:确认已在权限管理里添加读+写并**创建版本发布**,'
|
|
1470
|
+
+ '稍等约 1 分钟再点一次卡片上的按钮。';
|
|
1471
|
+
}
|
|
1472
|
+
await dmText(sender, text, cfg, options);
|
|
1473
|
+
return toast('error', '仍未成功,看私聊回复');
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
// ==========================================================================
|
|
1477
|
+
// 入站长连接(`feishu.py:1628-1656` / `1659-1766`)
|
|
1478
|
+
// ==========================================================================
|
|
1479
|
+
|
|
1480
|
+
/** 入站「线程」句柄:契约 `{isAlive(): boolean}`(`feishu_cfg.js:216` 的接缝) */
|
|
1481
|
+
function makeInboundRunner(userId, cfg, options) {
|
|
1482
|
+
let alive = false;
|
|
1483
|
+
return {
|
|
1484
|
+
userId,
|
|
1485
|
+
cfg,
|
|
1486
|
+
isAlive: () => alive,
|
|
1487
|
+
/** 等价 `threading.Thread.start()`:登记在 `start()` **之前**(1643-1644) */
|
|
1488
|
+
start() {
|
|
1489
|
+
alive = true;
|
|
1490
|
+
void wsRun(userId, cfg, options)
|
|
1491
|
+
.catch((e) => { // wsRun 自身已吞异常;这只是「线程入口绝不上抛」的兜底
|
|
1492
|
+
defaultLog(`feishu inbound: 用户 ${userId} 长连接异常退出: `
|
|
1493
|
+
+ `${errorRepr(e)}(重启站点重试)`);
|
|
1494
|
+
})
|
|
1495
|
+
.finally(() => { alive = false; });
|
|
1496
|
+
},
|
|
1497
|
+
};
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
/**
|
|
1501
|
+
* `start_inbound_for`(1628-1645):幂等拉起某用户的入站长连接;返回是否**新**启动。
|
|
1502
|
+
* 顺序:① 注册表判活短路(1634)→ ② `app_config` → ③ 缺失打 stderr 一行并回 false →
|
|
1503
|
+
* ④ 建「线程」→ ⑤ 登记 → ⑥ `start()`。**同步返回**(对应 `save_user_config` 的
|
|
1504
|
+
* `inbound_started` 是同步布尔)。
|
|
1505
|
+
* 已知分叉:oracle 只读一次 `app_config`;这里 `inboundStatus` 为算 `thread` 也读一次
|
|
1506
|
+
* (`feishu_cfg.js` 的既有接缝),故共两次**只读**查询(不可观测,登记于此)。
|
|
1507
|
+
*/
|
|
1508
|
+
export function startInboundFor(userId, options = {}) {
|
|
1509
|
+
const d = dep(options);
|
|
1510
|
+
if (inboundStatus(userId, options).thread) return false; // ① 只信 is_alive()
|
|
1511
|
+
const cfg = appConfig(userId, options); // ②
|
|
1512
|
+
if (cfg === null || cfg === undefined) { // ③
|
|
1513
|
+
d.log(`feishu inbound: 用户 ${userId} 应用凭据缺失,长连接未启动`);
|
|
1514
|
+
return false;
|
|
1515
|
+
}
|
|
1516
|
+
const runner = makeInboundRunner(userId, cfg, options); // ④
|
|
1517
|
+
setInboundThread(userId, runner); // ⑤(在 start 之前)
|
|
1518
|
+
runner.start(); // ⑥
|
|
1519
|
+
return true;
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
/**
|
|
1523
|
+
* `start_inbound_all`(1648-1656):服务启动遍历全部用户配置,为凭据齐全者逐个拉起。
|
|
1524
|
+
* 顺序 = `db.list_feishu_user_cfgs()` 的行序(无 ORDER BY ⇒ 表扫描序);返回新启动数。
|
|
1525
|
+
* 无 try/except ⇒ 任一用户抛异常即中断其余用户(半启动),本移植照抄。
|
|
1526
|
+
*/
|
|
1527
|
+
export function startInboundAll(options = {}) {
|
|
1528
|
+
const d = dep(options);
|
|
1529
|
+
let n = 0;
|
|
1530
|
+
for (const [userId, cfg] of d.db.listFeishuUserCfgs(options)) {
|
|
1531
|
+
if (pyTruthy(dget(cfg, 'app_id')) && pyTruthy(dget(cfg, 'app_secret'))) {
|
|
1532
|
+
if (startInboundFor(userId, options)) n += 1;
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
return n;
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
/**
|
|
1539
|
+
* `_make_ws_client`(1659-1719):构造支持卡片回调分派的 ws 客户端。
|
|
1540
|
+
* Node 侧这个职责由 R1 的 `FeishuWsClient` 承担(帧分发/合包/ACK 都在 R1);本函数只做
|
|
1541
|
+
* **构造参数的对齐**:两个位置参数是 `cfg` 的**下标取值**(缺键 ⇒ KeyError,1717)、
|
|
1542
|
+
* 不传 `domain`(⇒ R1 默认官方域名)、不传 `autoReconnect`(⇒ R1 默认 true);
|
|
1543
|
+
* `log_level` 缺省是 `ERROR`(1715-1716)⇒ 默认**静默**(契约 §4.1-3),
|
|
1544
|
+
* `TS_FEISHU_WS_DEBUG` 非空(**`"0"` 也算**,1752 的 `bool()`)时开 DEBUG 日志。
|
|
1545
|
+
*/
|
|
1546
|
+
export function makeWsClient(cfg, eventHandler, cardHandler, logLevel = null, options = {}) {
|
|
1547
|
+
dep(options);
|
|
1548
|
+
// 注入点:`clientClass`(类/factory)与 `wsClient`(同一形态的别名,供量具/单测直接替换)
|
|
1549
|
+
const Ctor = options.clientClass || options.wsClient || FeishuWsClient;
|
|
1550
|
+
const appId = requireKey(cfg, 'app_id');
|
|
1551
|
+
const appSecret = requireKey(cfg, 'app_secret');
|
|
1552
|
+
const verbose = (logLevel !== null && logLevel !== undefined)
|
|
1553
|
+
&& (typeof logLevel === 'number' ? logLevel <= 10 : String(logLevel).toLowerCase() === 'debug');
|
|
1554
|
+
const log = options.wsLog || (verbose
|
|
1555
|
+
? ((level, msg) => defaultLog(`feishu-ws ${level}: ${msg}`))
|
|
1556
|
+
: (() => {}));
|
|
1557
|
+
return new Ctor({
|
|
1558
|
+
appId: pyStr(appId), appSecret: pyStr(appSecret), eventHandler, cardHandler, log,
|
|
1559
|
+
});
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
/**
|
|
1563
|
+
* `_ws_run`(1722-1766):ws 客户端线程主体,`start()` 阻塞到 `close()`。
|
|
1564
|
+
* 三处对齐:① 连接行(1760-1761,结尾是 **U+2026**);② 两个 handler 入口都先
|
|
1565
|
+
* `_touch_event(userId)` 打点(1734/1740,写 `feishu_provision._LAST_EVENT_TS`);
|
|
1566
|
+
* ③ 退出行(1765-1766,`{e!r}` 走 `errorRepr` 近似)。
|
|
1567
|
+
* **协议兜底路径**(1695-1713):`cardHandler` 收字节 → `JSON.parse`(坏 JSON 静默 return)→
|
|
1568
|
+
* `_on_card_action(data, cfg)`,**返回值丢弃**(没有写回任何响应);异常只留一行 stderr。
|
|
1569
|
+
* 事件帧路由:SDK 的 `register_p2_*` 按 `header.event_type` 分派(R1 只给一个 eventHandler)——
|
|
1570
|
+
* `im.message.receive_v1` → `onMessageEvent`(返回值被丢弃);`card.action.trigger` →
|
|
1571
|
+
* `cardSdkToDict` + `onCardAction`(**返回值进 ACK data**,对应 callback processor 路的序列化);
|
|
1572
|
+
* `im.message.message_read_v1` → 空实现(1745-1746,返回值 None);**其余类型抛** —— 对应
|
|
1573
|
+
* `_processorMap` 查不到时的 `EventException("processor not found")`(`dispatcher_handler.py:173-181`),
|
|
1574
|
+
* 客户端 catch 后回 ACK `{"code": 500}`(不是 200,否则等于静默吞事件)。
|
|
1575
|
+
* 差异:oracle 的 `lark-oapi` 未安装分支(1729-1731)在 Node 侧不存在(R1 已落地)。
|
|
1576
|
+
*/
|
|
1577
|
+
export async function wsRun(userId, cfg, options = {}) {
|
|
1578
|
+
const d = dep(options);
|
|
1579
|
+
const touch = () => { _LAST_EVENT_TS.set(userId, d.now()); };
|
|
1580
|
+
const onEventPayload = async (payload) => {
|
|
1581
|
+
let data;
|
|
1582
|
+
try {
|
|
1583
|
+
data = parseEventPayload(payload);
|
|
1584
|
+
} catch (e) {
|
|
1585
|
+
d.log(`feishu inbound: 事件解析异常: ${errorRepr(e)}`);
|
|
1586
|
+
return null;
|
|
1587
|
+
}
|
|
1588
|
+
const header = pyOr(dget(data, 'header'), {});
|
|
1589
|
+
const type = pyOr(dget(header, 'event_type'), '');
|
|
1590
|
+
if (type === 'card.action.trigger') {
|
|
1591
|
+
touch();
|
|
1592
|
+
return await onCardAction(cardSdkToDict(data), cfg, options);
|
|
1593
|
+
}
|
|
1594
|
+
if (type === 'im.message.receive_v1') {
|
|
1595
|
+
touch();
|
|
1596
|
+
await onMessageEvent(data, cfg, options);
|
|
1597
|
+
return null;
|
|
1598
|
+
}
|
|
1599
|
+
if (type === 'im.message.message_read_v1') {
|
|
1600
|
+
// 已读回执:oracle 注册的是空 lambda(`feishu.py:1745-1746`)⇒ 返回值 None ⇒ ACK 只有
|
|
1601
|
+
// `{"code": 200}`;且 `_touch_event` 只打在消息/卡片两个 processor 里,回执**不打点**
|
|
1602
|
+
// (别"顺手"补上——doctor 的「订阅是否真生效」判据靠这个点数)。
|
|
1603
|
+
return null;
|
|
1604
|
+
}
|
|
1605
|
+
// 未注册类型:oracle 在 `_processorMap` 里查不到 ⇒ 抛 `EventException("processor not found,
|
|
1606
|
+
// type: …")`(`dispatcher_handler.py:173-181`)⇒ R1 客户端 catch 后回 ACK `{"code": 500}`
|
|
1607
|
+
// 且**不加 biz_rt**。这里若 `return null` 就变成 200 —— 飞书据此判定"已送达、不重推",
|
|
1608
|
+
// 等于**静默吞掉一个事件**。(2026-10-15 与真 SDK 逐字节对拍时量出来的,见
|
|
1609
|
+
// `tools/parity/probe/ws_oracle_diff.py` 的 `om_fake_post_ping_*` 那条 ACK。)
|
|
1610
|
+
throw new Error(`processor not found, type: ${type}`);
|
|
1611
|
+
};
|
|
1612
|
+
const onCardPayload = (payload) => {
|
|
1613
|
+
let data;
|
|
1614
|
+
try {
|
|
1615
|
+
data = parseEventPayload(payload); // 坏 JSON/坏字节:静默 return(1707-1708)
|
|
1616
|
+
} catch {
|
|
1617
|
+
return;
|
|
1618
|
+
}
|
|
1619
|
+
// 照 `_run_card_handler`:另起一路、返回值丢弃;异常只打 1712-1713 那行
|
|
1620
|
+
void onCardAction(data, cfg, options).catch((e) => {
|
|
1621
|
+
d.log(`feishu inbound: 卡片回调处理异常: ${errorRepr(e)}`);
|
|
1622
|
+
});
|
|
1623
|
+
};
|
|
1624
|
+
const wsDebug = pyTruthy(dget(d.env, 'TS_FEISHU_WS_DEBUG')); // 非空即真(连 "0" 也算)
|
|
1625
|
+
const client = makeWsClient(cfg, onEventPayload, onCardPayload,
|
|
1626
|
+
wsDebug ? 'debug' : 'error', options);
|
|
1627
|
+
d.log(`feishu inbound: 用户 ${userId} 连接长连接网关(app_id=${requireKey(cfg, 'app_id')})…`);
|
|
1628
|
+
try {
|
|
1629
|
+
await client.start();
|
|
1630
|
+
} catch (e) {
|
|
1631
|
+
d.log(`feishu inbound: 用户 ${userId} 长连接异常退出: ${errorRepr(e)}(重启站点重试)`);
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
// --------------------------------------------------------------------------
|
|
1636
|
+
// snake_case 别名(照 `board.js` 的 Python 名别名先例:零成本、便于对拍量具按 oracle 名发现)
|
|
1637
|
+
// --------------------------------------------------------------------------
|
|
1638
|
+
export const _HELP_TEXT = HELP_TEXT;
|
|
1639
|
+
export const _INTENT_RULES = INTENT_RULES;
|
|
1640
|
+
export const _seen_event = seenEvent;
|
|
1641
|
+
export const _seen_message = seenMessage;
|
|
1642
|
+
export const _toast = toast;
|
|
1643
|
+
export const _toast_for = toastFor;
|
|
1644
|
+
export const _dm_text = dmText;
|
|
1645
|
+
export const _mq_prune_locked = mqPruneLocked;
|
|
1646
|
+
export const _mq_put = mqPut;
|
|
1647
|
+
export const _mq_take = mqTake;
|
|
1648
|
+
export const _mq_peek = mqPeek;
|
|
1649
|
+
export const _mq_drop = mqDrop;
|
|
1650
|
+
export const _multi_click_index = multiClickIndex;
|
|
1651
|
+
export const _multi_answer_parts = multiAnswerParts;
|
|
1652
|
+
export const _multi_answers = multiAnswers;
|
|
1653
|
+
export const _answer_by_token = answerByToken;
|
|
1654
|
+
export const _answer_multi_click = answerMultiClick;
|
|
1655
|
+
export const _decide_approval = decideApproval;
|
|
1656
|
+
export const _execute_board_action = executeBoardAction;
|
|
1657
|
+
export const _resolve_project = resolveProject;
|
|
1658
|
+
export const _find_cards = findCards;
|
|
1659
|
+
export const _find_user_card = findUserCard;
|
|
1660
|
+
export const _card_detail = cardDetail;
|
|
1661
|
+
export const _board_summary = boardSummary;
|
|
1662
|
+
export const _set_default_project = setDefaultProject;
|
|
1663
|
+
export const _parse_intent = parseIntent;
|
|
1664
|
+
export const _handle_message_event = handleMessageEvent;
|
|
1665
|
+
export const _execute_intent = executeIntent;
|
|
1666
|
+
export const _on_message_event = onMessageEvent;
|
|
1667
|
+
export const _on_card_action = onCardAction;
|
|
1668
|
+
export const _card_sdk_to_dict = cardSdkToDict;
|
|
1669
|
+
export const _on_slash_perm_action = onSlashPermAction;
|
|
1670
|
+
export const _slash_summary = slashSummary;
|
|
1671
|
+
export const _make_ws_client = makeWsClient;
|
|
1672
|
+
export const _ws_run = wsRun;
|
|
1673
|
+
export const _start_inbound_for = startInboundFor;
|
|
1674
|
+
export const _start_inbound_all = startInboundAll;
|