@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.
- package/README.md +39 -11
- package/README.zh.md +25 -10
- 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 +805 -72
- package/chat.py +590 -17
- package/db.py +356 -140
- package/dsh-plugin/cordis.patch.yml +7 -3
- package/dsh-plugin/install.sh +28 -11
- package/dsh-plugin/lib/agent-driver.js +710 -89
- package/dsh-plugin/lib/client.bundle.js +94 -9
- package/dsh-plugin/lib/index.js +529 -39
- package/dsh-plugin/lib/pack-closure.mjs +291 -0
- package/dsh-plugin/scripts/prepack-check.mjs +30 -2
- package/dsh-plugin/src/client.js +94 -9
- package/dshdriver.py +74 -5
- package/dshevents.py +65 -3
- package/feishu.py +1197 -34
- package/feishu_conv.py +920 -0
- package/lifecycle.py +116 -13
- package/package.json +6 -1
- package/requirements.txt +2 -1
- package/server.py +218 -26
- package/sessparse.py +150 -44
- package/touchstone.py +6 -3
- package/waitq.py +23 -0
- package/webui/dist-plugin/assets/index-ChP3sg6y.css +1 -0
- package/webui/dist-plugin/assets/index-UrMyeAXF.js +484 -0
- package/webui/dist-plugin/index.html +2 -2
- package/webui/dist-plugin/assets/index-B0WoP1Qy.css +0 -1
- package/webui/dist-plugin/assets/index-DtwKoi1T.js +0 -473
|
@@ -0,0 +1,1738 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 飞书出站推送族(M7-R2)——`feishu.py` 的**出站半**:`:31-1038`(常量 / 事件闸门接线 /
|
|
3
|
+
* 渲染 / 入队 / 卡片构建族 / plan 方案文档族 / 业务侧语义函数)+ `:2599-2678`(投递线程)。
|
|
4
|
+
*
|
|
5
|
+
* Python 是 oracle:本文件的每一处文案、键序、截断长度、退避数值都对着 `feishu.py` 的对应
|
|
6
|
+
* 行写,**不按直觉改写**。对拍判据见 `tools/parity/cases-mod/feishu_outbound.json`。
|
|
7
|
+
*
|
|
8
|
+
* ## 与相邻车道的接缝(**写清楚,别假装它们不存在**)
|
|
9
|
+
*
|
|
10
|
+
* 1. **配置读助手**(`user_config`/`app_config`/`notify_events`/`hook_of`)在 `feishu_cfg.js`
|
|
11
|
+
* (M7-R1)**已实现**——本文件**import 后原样再导出**,绝不重写第二份(两份实现在对拍里
|
|
12
|
+
* 会漂移成两个事实)。`notifyEvents`/`hookOf` 因此是本模块的公开口,但归属 R1。
|
|
13
|
+
*
|
|
14
|
+
* 2. **REST 车道**(`feishu_rest.js`,M7-R4):`_docx_create` 与 DM 卡片发送要调飞书 REST。
|
|
15
|
+
* 本文件**懒 import**(`loadRestLane()`)而不是静态 import,理由是并行车道可能尚未落地,
|
|
16
|
+
* 静态 import 会让整个模块 import 失败(连纯函数都用不了)。**失败不吞**:导入失败/接口
|
|
17
|
+
* 不符一律抛 `RestLaneUnavailable`(`name='FeishuRestError'`,与 R4 的类同形),由
|
|
18
|
+
* `attachPlan`/`dmInteractionCard` 按 oracle 的口径**留痕到 stderr**(Python 侧这两处
|
|
19
|
+
* 本来就是 best-effort + print,见 `feishu.py:934-936`/`:964-966`)。
|
|
20
|
+
*
|
|
21
|
+
* 3. **入站/对话/接线缝**(M7.5 与 R3):`startNotifier` 里 Python 还调了 `feishu_conv.start()`
|
|
22
|
+
* (注册 chat 消息终态钩子)——那是**对话车道**的接线,本文件**不调**(`feishu_conv.js`
|
|
23
|
+
* 尚不存在,静默 try 会变成「装好了其实没接」)。`setFailureNotifier`(runner.js)与
|
|
24
|
+
* `optionalHook('feishuCardReview')`(board.js)的注入同样不在本文件范围内:这里只把
|
|
25
|
+
* `cardBlocked`/`cardReview`/`taskFailed` 按 oracle 形参表备好,供后来注入者调用。
|
|
26
|
+
*
|
|
27
|
+
* 4. **`session_id` → 方案文本**(`planSessionTexts`):Python 函数内懒 import `sessparse`
|
|
28
|
+
* 是为了避开模块级循环依赖;Node 侧 `sessparse.js` 不 import 本文件(只有 node 内置 +
|
|
29
|
+
* `zstd-frames.js`),**没有环** ⇒ 用静态 import。Node 的 `await import()` 会把 async
|
|
30
|
+
* 传染给整条同步调用链(Python 侧是同步函数),故这里刻意静态化。
|
|
31
|
+
*
|
|
32
|
+
* ## 跨运行时的已知分歧(逐条登记,不藏)
|
|
33
|
+
* - **错误文案**:Python `urlopen` 的 `URLError/OSError` 文案(`<urlopen error [Errno 111] …>`)
|
|
34
|
+
* 与 Node 的 `ECONNREFUSED` 家族天然不同;坏 payload 的 `Expecting value: line 1 column 1`
|
|
35
|
+
* 与 `JSON.parse` 的 `Unexpected token` 同理。**只有 `code=<n> <msg>` 这一支是两侧同形的**
|
|
36
|
+
* (飞书自己的业务码+文案),用例也只钉这一支。
|
|
37
|
+
* - **3xx 重定向**:`urllib` 的 `HTTPRedirectHandler` 会自动跟随(301/302/303 把 POST 降级成
|
|
38
|
+
* GET、307 保 body,最多 10 跳),本实现**不跟随**:非 2xx 直接按失败档返回
|
|
39
|
+
* `HTTP Error <status>: <reason>`(=Python `HTTPError.__str__` 的形态)。真实飞书 webhook
|
|
40
|
+
* 不重定向,故这条只在「被中间件改写」的部署里显形——登记未验,**没有对拍覆盖**。
|
|
41
|
+
* - **浮点**:`str(1.0)`/`repr` 的 int-vs-float 信息在 JS 里不存在(同 `pyjson.js` 文件头),
|
|
42
|
+
* 只影响错误文案与卡片上的数字形态。
|
|
43
|
+
*
|
|
44
|
+
* 零 npm 依赖:只用 `node:crypto` / `node:http` / `node:https` 与同仓库的 db/pyjson/lib/sessparse。
|
|
45
|
+
*/
|
|
46
|
+
import crypto from 'node:crypto';
|
|
47
|
+
import http from 'node:http';
|
|
48
|
+
import https from 'node:https';
|
|
49
|
+
|
|
50
|
+
import {
|
|
51
|
+
feishuOutboxDue, feishuOutboxMark, feishuOutboxPush, getFeishuBindingByUser, getProject,
|
|
52
|
+
} from './db.js';
|
|
53
|
+
import {
|
|
54
|
+
FEISHU_DEFAULT_EVENTS, FEISHU_EVENTS, appConfig, hookOf, notifyEvents,
|
|
55
|
+
} from './feishu_cfg.js';
|
|
56
|
+
import { PY_WS, pyStrip } from './lib.js';
|
|
57
|
+
import { pyJsonDumps } from './pyjson.js';
|
|
58
|
+
import * as sessparse from './sessparse.js';
|
|
59
|
+
|
|
60
|
+
// 配置读助手(R1)原样再导出:本模块的调用方(routes/**)按 feishu.py 的名字取它们。
|
|
61
|
+
export { FEISHU_DEFAULT_EVENTS, FEISHU_EVENTS, appConfig, hookOf, notifyEvents };
|
|
62
|
+
|
|
63
|
+
// --------------------------------------------------------------------------
|
|
64
|
+
// 常量(逐条对齐 feishu.py:31-69 / :605-614)
|
|
65
|
+
// --------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
/** 重试退避(秒):3 次后标 failed 留痕。对齐 `feishu.RETRY_DELAYS`(feishu.py:36) */
|
|
68
|
+
export const RETRY_DELAYS = Object.freeze([60, 300, 1800]);
|
|
69
|
+
|
|
70
|
+
/** webhook POST 超时(秒),对齐 `feishu.SEND_TIMEOUT`(feishu.py:37) */
|
|
71
|
+
export const SEND_TIMEOUT = 5;
|
|
72
|
+
|
|
73
|
+
/** 发送线程轮询间隔(秒),对齐 `feishu._TICK`(feishu.py:38) */
|
|
74
|
+
export const TICK = 1.0;
|
|
75
|
+
|
|
76
|
+
/** kind → [通知标题, 是否附站点链接],对齐 `feishu._EVENT_META`(feishu.py:41) */
|
|
77
|
+
export const EVENT_META = Object.freeze({
|
|
78
|
+
blocked_interaction: Object.freeze(['🤔 卡片等待回复', true]),
|
|
79
|
+
task_failed: Object.freeze(['❌ 任务失败', true]),
|
|
80
|
+
card_review: Object.freeze(['📋 卡片待审核', false]),
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
/** 多子题渲染截断(飞书卡片元素/文本都有长度上限,超长会整条被拒收),feishu.py:49-51 */
|
|
84
|
+
export const Q_TEXT_MAX = 500; // 单题题面
|
|
85
|
+
export const Q_OPT_MAX = 100; // 单选项 label
|
|
86
|
+
export const Q_DESC_MAX = 160; // 选项描述次行 / 题目补充说明
|
|
87
|
+
|
|
88
|
+
/** 多选类「卡片点选」暂存态上限,feishu.py:56-58(暂存本体在 M7.5 入站车道) */
|
|
89
|
+
export const MQ_TTL = 1800;
|
|
90
|
+
export const MQ_MAX_BUCKETS = 64;
|
|
91
|
+
export const MQ_FORM_MAX_BYTES = 950; // 飞书 form 元素硬限 1000
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 不可远程作答(`board._iw_interaction` 的 `answerable=false`)时的**统一引导文案**,
|
|
95
|
+
* 逐字对齐 `feishu._NO_REMOTE_ANSWER_HINT`(feishu.py:68)(换行处是 Python 的隐式拼接)。
|
|
96
|
+
*/
|
|
97
|
+
export const NO_REMOTE_ANSWER_HINT = '该等待不支持远程作答(提问由 dsh 直跑会话发起),'
|
|
98
|
+
+ '请在 dsh 会话窗口作答';
|
|
99
|
+
|
|
100
|
+
/** plan 方案增强族常量,feishu.py:605-614 */
|
|
101
|
+
export const PLAN_ENV = 'TS_FEISHU_PLAN';
|
|
102
|
+
export const PLAN_INLINE_MAX = 1500;
|
|
103
|
+
export const PLAN_DOC_MAX = 20000;
|
|
104
|
+
export const PLAN_THINK_MIN = 200;
|
|
105
|
+
export const PLAN_THINK_MAX = 8000;
|
|
106
|
+
export const PLAN_DOC_TTL = 1800;
|
|
107
|
+
export const MD_BLOCK_MAX = 1800;
|
|
108
|
+
export const ASK_TOOL = 'ask_user_question';
|
|
109
|
+
export const DOC_TIMEOUT = 8;
|
|
110
|
+
|
|
111
|
+
/** 列名常量的可读别名(`feishu_hooks` / `feishu_outbox` 的列名在 db.js 里是 snake_case) */
|
|
112
|
+
const RETRY_MAX = RETRY_DELAYS.length;
|
|
113
|
+
|
|
114
|
+
// --------------------------------------------------------------------------
|
|
115
|
+
// Python 语义小工具(只在本地用;`maskWebhook`/`maskOpenId`/`pyJsonDumps` 一律复用既有实现)
|
|
116
|
+
// --------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Python 真值口径(`""`/`0`/`None`/`False`/空容器为假)。
|
|
120
|
+
* `feishu_cfg.js` 里有一份同名私有函数,但它没导出、且本文件不许改它 ⇒ 这里自带一份,
|
|
121
|
+
* 语义与那份逐字相同。
|
|
122
|
+
*/
|
|
123
|
+
function pyTruthy(value) {
|
|
124
|
+
if (Array.isArray(value)) return value.length > 0;
|
|
125
|
+
if (value !== null && typeof value === 'object') return Object.keys(value).length > 0;
|
|
126
|
+
return Boolean(value);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Python `dict.get(key, default)`:**键存在但值为 None 时返回 None**(不是 default) */
|
|
130
|
+
function dictGet(dict, key, dflt = undefined) {
|
|
131
|
+
return Object.hasOwn(dict, key) ? dict[key] : dflt;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Python `str(x)`(浮点的 int/float 偏差见文件头「跨运行时的已知分歧」) */
|
|
135
|
+
function pyStr(value) {
|
|
136
|
+
if (typeof value === 'string') return value;
|
|
137
|
+
if (value === null || value === undefined) return 'None';
|
|
138
|
+
if (typeof value === 'boolean') return value ? 'True' : 'False';
|
|
139
|
+
if (typeof value === 'number' || typeof value === 'bigint') return String(value);
|
|
140
|
+
if (typeof value === 'object') return pyRepr(value);
|
|
141
|
+
return String(value);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Python `repr(str)`(单引号口径:含 `'` 且不含 `"` 时改用双引号,与 CPython 同规则) */
|
|
145
|
+
function pyQuote(text) {
|
|
146
|
+
const s = String(text);
|
|
147
|
+
const quote = s.includes("'") && !s.includes('"') ? '"' : "'";
|
|
148
|
+
const body = s
|
|
149
|
+
.replace(/\\/g, '\\\\')
|
|
150
|
+
.replace(/\n/g, '\\n')
|
|
151
|
+
.replace(/\r/g, '\\r')
|
|
152
|
+
.replace(/\t/g, '\\t')
|
|
153
|
+
.replace(new RegExp(quote, 'g'), `\\${quote}`);
|
|
154
|
+
return quote + body + quote;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Python `repr(x)`(**浅层**向:容器/集合按同规则递归;类实例给 `{...}`——只服务错误文案) */
|
|
158
|
+
function pyRepr(value) {
|
|
159
|
+
if (typeof value === 'string') return pyQuote(value);
|
|
160
|
+
if (value === null || value === undefined) return 'None';
|
|
161
|
+
if (typeof value === 'boolean') return value ? 'True' : 'False';
|
|
162
|
+
if (typeof value === 'number' || typeof value === 'bigint') return String(value);
|
|
163
|
+
if (Array.isArray(value)) return `[${value.map((v) => pyRepr(v)).join(', ')}]`;
|
|
164
|
+
if (value instanceof Set) return `{${[...value].map((v) => pyRepr(v)).join(', ')}}`;
|
|
165
|
+
if (value instanceof Error) return errRepr(value);
|
|
166
|
+
if (typeof value === 'object') {
|
|
167
|
+
return `{${Object.entries(value).map(([k, v]) => `${pyQuote(k)}: ${pyRepr(v)}`).join(', ')}}`;
|
|
168
|
+
}
|
|
169
|
+
return String(value);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** 异常的 repr(`f"{e!r}"` 的可用子集:`FeishuRestError('…')` 形态) */
|
|
173
|
+
function errRepr(error) {
|
|
174
|
+
if (error instanceof Error) return `${error.name || 'Error'}(${pyQuote(error.message)})`;
|
|
175
|
+
return pyRepr(error);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** 异常文案(`str(e)` 的可用子集;Python `str(e)` 就是 message) */
|
|
179
|
+
function errText(error) {
|
|
180
|
+
if (error instanceof Error) return error.message;
|
|
181
|
+
return pyStr(error);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Python `str(x or "")`:假值先归一成空串,再 `str()` */
|
|
185
|
+
function pyStrOrEmpty(value) {
|
|
186
|
+
return pyStr(pyTruthy(value) ? value : '');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** 按**码点**计数(Python `len(str)`)。lib.js 的 `sliceCodePoints` 没导出 ⇒ 本地一份 */
|
|
190
|
+
function cpLen(text) {
|
|
191
|
+
return Array.from(text).length;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Python `text[:n]`(码点口径) */
|
|
195
|
+
function cpSlice(text, n) {
|
|
196
|
+
return Array.from(text).slice(0, n).join('');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Python `text.rstrip()` */
|
|
200
|
+
const PY_RSTRIP_RE = new RegExp(`${PY_WS}+$`);
|
|
201
|
+
/** Python `text.lstrip()` */
|
|
202
|
+
const PY_LSTRIP_RE = new RegExp(`^${PY_WS}+`);
|
|
203
|
+
function pyRstrip(text) {
|
|
204
|
+
return String(text).replace(PY_RSTRIP_RE, '');
|
|
205
|
+
}
|
|
206
|
+
function pyLstrip(text) {
|
|
207
|
+
return String(text).replace(PY_LSTRIP_RE, '');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Python `str.splitlines()` 的行边界(`\r\n` 算一行;顺序敏感,`\r\n` 必须在前) */
|
|
211
|
+
const SPLIT_LINES_RE = /\r\n|[\n\v\f\r\x1c-\x1e\x85\u2028\u2029]/;
|
|
212
|
+
function pyFirstLine(text) {
|
|
213
|
+
const parts = String(text).split(SPLIT_LINES_RE);
|
|
214
|
+
return parts.length ? parts[0] : '';
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* `(value or "")[:limit]`(Python 切片语义:`str` 按码点、`list` 先切片再 repr、
|
|
219
|
+
* 数字/布尔/dict 抛 TypeError)。
|
|
220
|
+
*/
|
|
221
|
+
function clipField(value, limit) {
|
|
222
|
+
const v = pyTruthy(value) ? value : '';
|
|
223
|
+
if (typeof v === 'string') return cpSlice(v, limit);
|
|
224
|
+
if (Array.isArray(v)) return pyRepr(v.slice(0, limit));
|
|
225
|
+
throw new TypeError(`不支持切片的值类型: ${typeof v}(Python 侧同一处会抛 TypeError)`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** 往 stderr 留痕(Python 侧对应几处 `print(..., file=sys.stderr, flush=True)`) */
|
|
229
|
+
function stderrLine(text) {
|
|
230
|
+
process.stderr.write(`${text}\n`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** 普通对象判定(排除 null / 数组;`isinstance(x, dict)` 的可用子集) */
|
|
234
|
+
function isPlainObject(value) {
|
|
235
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// --------------------------------------------------------------------------
|
|
239
|
+
// 渲染(feishu.py:146-219)
|
|
240
|
+
// --------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* 飞书自定义机器人签名,对齐 `feishu.sign`(feishu.py:146):
|
|
244
|
+
* `base64(hmac_sha256(key=f"{ts}\n{secret}", msg=b""))`。
|
|
245
|
+
*
|
|
246
|
+
* **注意 key/msg 的位置**:Python 原式 `hmac.new(string_to_sign.encode("utf-8"),
|
|
247
|
+
* digestmod=sha256)` **只给了 key**、没给 msg ⇒ 被签名的是空串,`f"{ts}\n{secret}"` 是
|
|
248
|
+
* **密钥**。照直觉写成「key=secret, msg=ts」会与飞书对不上(用例三条固定向量钉住这点)。
|
|
249
|
+
* @param {string} secret
|
|
250
|
+
* @param {string|number} ts
|
|
251
|
+
* @returns {string} base64 文本
|
|
252
|
+
*/
|
|
253
|
+
export function sign(secret, ts) {
|
|
254
|
+
const key = Buffer.from(`${ts}\n${secret}`, 'utf8');
|
|
255
|
+
return crypto.createHmac('sha256', key).digest('base64');
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* text 消息体,对齐 `feishu._payload`(feishu.py:154):`json.dumps({"msg_type": "text",
|
|
260
|
+
* "content": {"text": text}}, ensure_ascii=False)` —— 键序 `msg_type`→`content`、分隔符
|
|
261
|
+
* `", "`/`": "`、中文与换行**不转义**,故必须走 `pyJsonDumps`(`JSON.stringify` 会全错)。
|
|
262
|
+
* timestamp/sign 在发送时刻补(队列重试后旧签会过期)。
|
|
263
|
+
* @param {string} text
|
|
264
|
+
* @returns {string}
|
|
265
|
+
*/
|
|
266
|
+
export function payload(text) {
|
|
267
|
+
return pyJsonDumps({ msg_type: 'text', content: { text } });
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* 单选项归一 → `{id, label, description}`,对齐 `feishu._option_view`(feishu.py:246):
|
|
272
|
+
* 兼容 dsh `AskUserQuestionOption` 的 dict(label/description)与旧形态的纯字符串
|
|
273
|
+
* (测试替身/旧插件仍可能给 str,直接 `.get` 会炸穿推送)。
|
|
274
|
+
* @param {unknown} raw
|
|
275
|
+
* @returns {{id: string, label: string, description: string}}
|
|
276
|
+
*/
|
|
277
|
+
export function optionView(raw) {
|
|
278
|
+
if (isPlainObject(raw)) {
|
|
279
|
+
const label = raw.label || raw.text || raw.value || '';
|
|
280
|
+
return {
|
|
281
|
+
id: pyStr(raw.id || label),
|
|
282
|
+
label: pyStr(label),
|
|
283
|
+
description: pyStr(raw.description || ''),
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
return { id: pyStr(raw || ''), label: pyStr(raw || ''), description: '' };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* 截断长文本(空值归一为空串;超限加省略号),对齐 `feishu._clip_text`(feishu.py:240)。
|
|
291
|
+
* `str(text or "").strip()` 的 strip 走 lib.js 的 `pyStrip`(Python 空白集合,不是 JS `trim`)。
|
|
292
|
+
*/
|
|
293
|
+
export function clipText(text, limit) {
|
|
294
|
+
const s = pyStrip(pyStrOrEmpty(text));
|
|
295
|
+
return cpLen(s) <= limit ? s : `${cpSlice(s, limit)}…`;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* `interaction.questions`(1-4 题)→ 逐题渲染视图,对齐 `feishu._question_views`(feishu.py:257)。
|
|
300
|
+
* 视图:`{no, header, question, body, options:[{no,label,description}], multi_select,
|
|
301
|
+
* allow_other, other_label}`。
|
|
302
|
+
* @param {unknown[]} qs
|
|
303
|
+
* @returns {object[]}
|
|
304
|
+
*/
|
|
305
|
+
export function questionViews(qs) {
|
|
306
|
+
const views = [];
|
|
307
|
+
let index = 0;
|
|
308
|
+
for (const raw of qs) {
|
|
309
|
+
index += 1;
|
|
310
|
+
const q = isPlainObject(raw) ? raw : {};
|
|
311
|
+
const options = [];
|
|
312
|
+
let j = 0;
|
|
313
|
+
for (const o of (q.options || [])) {
|
|
314
|
+
j += 1;
|
|
315
|
+
const ov = optionView(o);
|
|
316
|
+
options.push({
|
|
317
|
+
no: j,
|
|
318
|
+
label: clipText(ov.label, Q_OPT_MAX),
|
|
319
|
+
description: clipText(ov.description, Q_DESC_MAX),
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
views.push({
|
|
323
|
+
no: index,
|
|
324
|
+
header: clipText(q.header, 60),
|
|
325
|
+
question: clipText(q.question, Q_TEXT_MAX),
|
|
326
|
+
body: clipText(q.body || q.detail, Q_DESC_MAX),
|
|
327
|
+
options,
|
|
328
|
+
multi_select: pyTruthy(q.multi_select),
|
|
329
|
+
allow_other: pyTruthy(q.allow_other),
|
|
330
|
+
other_label: clipText(q.other_label || '其他', 40),
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
return views;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* 单题提问 ctx → 单题视图,对齐 `feishu._single_question_view`(feishu.py:286):
|
|
338
|
+
* `ctx.questions` 优先,旧调用面/测试替身只给平面字段时按平面字段兜底。
|
|
339
|
+
*/
|
|
340
|
+
export function singleQuestionView(ctx) {
|
|
341
|
+
const qs = (ctx.questions || []).filter((q) => pyTruthy(q));
|
|
342
|
+
if (qs.length) return questionViews(qs)[0];
|
|
343
|
+
const opts = [];
|
|
344
|
+
let i = 0;
|
|
345
|
+
for (const o of (ctx.options || [])) {
|
|
346
|
+
i += 1;
|
|
347
|
+
const ov = optionView(o);
|
|
348
|
+
opts.push({ no: i, label: clipText(ov.label, Q_OPT_MAX), description: '' });
|
|
349
|
+
}
|
|
350
|
+
return {
|
|
351
|
+
no: 1,
|
|
352
|
+
header: '',
|
|
353
|
+
question: clipText(ctx.question, Q_TEXT_MAX),
|
|
354
|
+
body: '',
|
|
355
|
+
options: opts,
|
|
356
|
+
multi_select: pyTruthy(ctx.multi_select),
|
|
357
|
+
allow_other: pyTruthy(ctx.allow_other),
|
|
358
|
+
other_label: clipText(ctx.other_label || '其他', 40),
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* 多子题 → 群 webhook 文本行,对齐 `feishu._question_text_lines`(feishu.py:340)
|
|
364
|
+
* (msg_type=text 不渲染 markdown,按纯文本排版)。
|
|
365
|
+
* @param {object[]} views
|
|
366
|
+
* @returns {string[]}
|
|
367
|
+
*/
|
|
368
|
+
export function questionTextLines(views) {
|
|
369
|
+
const lines = [];
|
|
370
|
+
for (const v of views) {
|
|
371
|
+
const title = v.header || `第 ${v.no} 题`;
|
|
372
|
+
const head = `【${v.no}/${views.length} ${title}】`;
|
|
373
|
+
lines.push(v.question ? `${head}${v.question}` : head);
|
|
374
|
+
if (v.body) lines.push(` ${v.body}`);
|
|
375
|
+
for (const o of v.options) {
|
|
376
|
+
lines.push(` ${o.no}) ${o.label}`);
|
|
377
|
+
if (o.description) lines.push(` ${o.description}`);
|
|
378
|
+
}
|
|
379
|
+
const caps = [];
|
|
380
|
+
if (v.multi_select) caps.push('多选');
|
|
381
|
+
if (v.allow_other) caps.push(`可自定义:${v.other_label}`);
|
|
382
|
+
if (caps.length) lines.push(` (${caps.join(';')})`);
|
|
383
|
+
}
|
|
384
|
+
return lines;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* 多子题作答语法回执,对齐 `feishu._multi_answer_help`(feishu.py:2299)(在 `:2399`
|
|
389
|
+
* 一族的作答解析里共用;出站侧 `render` 也调它)。
|
|
390
|
+
* @param {string|number} cid
|
|
391
|
+
* @param {unknown[]} qs
|
|
392
|
+
* @returns {string}
|
|
393
|
+
*/
|
|
394
|
+
export function multiAnswerHelp(cid, qs) {
|
|
395
|
+
const marks = Array.from({ length: qs.length }, (_, k) => `${k + 1}:<选项序号>`);
|
|
396
|
+
return `该提问含 ${qs.length} 道子题:可在卡片上逐题点选(点满自动送达),`
|
|
397
|
+
+ `或回复「作答 ${cid} ${marks.join(' ')}」一次答全;`
|
|
398
|
+
+ '多选写「1:2,3」,自定义写「1:文字」';
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* 事件 → 通知文案,对齐 `feishu._render`(feishu.py:160):首行标题 `[项目名]`,按 kind 拼
|
|
403
|
+
* 卡片/任务/提问/选项/错误/pipeline 跳过 行,末行可选站点链接;凭据永不入文案。
|
|
404
|
+
*
|
|
405
|
+
* **逐字节判据**(用例里有六种分支的固定向量):标题行取项目名(项目不存在 ⇒ `#<id>`)、
|
|
406
|
+
* 卡片行/任务行的截断长度(60/50 码点)、审批档的三行 + 同意/拒绝指令、
|
|
407
|
+
* 不可作答档的统一引导、多子题档的逐题完整展示 + 一条指令答全、`base_url` 为空或该 kind
|
|
408
|
+
* 的 `_EVENT_META` 第二项为 False 时**不附**「详情:」行。
|
|
409
|
+
* @param {string} kind
|
|
410
|
+
* @param {object} ctx
|
|
411
|
+
* @param {number} projectId
|
|
412
|
+
* @param {string} [baseUrl]
|
|
413
|
+
* @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
|
|
414
|
+
* @returns {string}
|
|
415
|
+
*/
|
|
416
|
+
export function render(kind, ctx, projectId, baseUrl = '', options = {}) {
|
|
417
|
+
const meta = EVENT_META[kind];
|
|
418
|
+
if (meta === undefined) throw new TypeError(`未知事件类别: ${kind}(Python 侧是 KeyError)`);
|
|
419
|
+
const [head, withLink] = meta;
|
|
420
|
+
const proj = getProject(projectId, options);
|
|
421
|
+
const lines = [`${head} [${proj ? proj.name : `#${projectId}`}]`];
|
|
422
|
+
if ('card_id' in ctx) lines.push(`卡片 #${pyStr(ctx.card_id)} ${clipField(ctx.title, 60)}`);
|
|
423
|
+
if ('task_id' in ctx) {
|
|
424
|
+
lines.push(`任务 #${pyStr(ctx.task_id)} ${clipField(ctx.name, 50)}(${pyStrOrEmpty(ctx.type)})`);
|
|
425
|
+
}
|
|
426
|
+
const cid = ctx.card_id;
|
|
427
|
+
if (ctx.kind === 'approval') {
|
|
428
|
+
// 审批等待:动作/工具/内容摘要 + 同意/拒绝指令(比通用「agent 提问」行更准确)
|
|
429
|
+
lines.push(`审批请求:${pyStr(ctx.action || ctx.tool || '工具调用')}`);
|
|
430
|
+
if (pyTruthy(ctx.tool)) lines.push(`工具:${pyStr(ctx.tool)}`);
|
|
431
|
+
if (pyTruthy(ctx.input)) lines.push(`内容:${cpSlice(pyStr(ctx.input), 200)}`);
|
|
432
|
+
lines.push(`飞书回复「同意 ${pyStr(cid)}」批准,「拒绝 ${pyStr(cid)}」拒绝;`
|
|
433
|
+
+ `「同意 ${pyStr(cid)} 会话」本会话内同类调用自动放行`);
|
|
434
|
+
} else {
|
|
435
|
+
const qs = (ctx.questions || []).filter((q) => pyTruthy(q));
|
|
436
|
+
if (qs.length > 1) {
|
|
437
|
+
// 多子题:逐题完整展示(旧实现只渲染首题 q0,其余子题在飞书上不可见)
|
|
438
|
+
lines.push(`agent 提问(共 ${qs.length} 题):`);
|
|
439
|
+
lines.push(...questionTextLines(questionViews(qs)));
|
|
440
|
+
} else if (pyTruthy(ctx.question)) {
|
|
441
|
+
lines.push(`agent 提问:${pyStr(ctx.question)}`);
|
|
442
|
+
let i = 0;
|
|
443
|
+
for (const o of (ctx.options || [])) {
|
|
444
|
+
i += 1;
|
|
445
|
+
lines.push(` ${i}) ${pyStr(o)}`);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
if (kind === 'blocked_interaction') {
|
|
449
|
+
const nQ = qs.length || (pyTruthy(ctx.questions_count) ? ctx.questions_count : 0);
|
|
450
|
+
if (!pyTruthy(ctx.answerable)) {
|
|
451
|
+
// 不可远程作答的等待(answerable=false):引导回 dsh 会话窗口(见常量注释)
|
|
452
|
+
lines.push(NO_REMOTE_ANSWER_HINT);
|
|
453
|
+
} else if (nQ > 1) {
|
|
454
|
+
// 多子题:一条指令按题号逐题答全(表单卡超 form 体积硬限时同样走这条)
|
|
455
|
+
const filled = qs.length ? qs : Array.from({ length: Number(nQ) }, () => null);
|
|
456
|
+
lines.push(multiAnswerHelp(cid, filled));
|
|
457
|
+
} else {
|
|
458
|
+
const tips = [`飞书回复「作答 ${pyStr(cid)} <选项序号>」按选项作答`];
|
|
459
|
+
if (pyTruthy(ctx.multi_select)) tips.push(`多选逗号分隔(作答 ${pyStr(cid)} 1,3)`);
|
|
460
|
+
if (pyTruthy(ctx.allow_other)) tips.push(`「作答 ${pyStr(cid)} <文字>」自定义回答`);
|
|
461
|
+
lines.push(tips.join(';'));
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
if (pyTruthy(ctx.error)) lines.push(`错误:${cpSlice(pyStr(ctx.error), 200)}`);
|
|
466
|
+
if (pyTruthy(ctx.skipped_n)) lines.push(`pipeline 后段 ${pyStr(ctx.skipped_n)} 条已连带跳过`);
|
|
467
|
+
if (withLink) {
|
|
468
|
+
const base = String(baseUrl || '').replace(/\/+$/, '');
|
|
469
|
+
if (base) lines.push(`详情:${base}`);
|
|
470
|
+
}
|
|
471
|
+
return lines.join('\n');
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* 旁路事件入队,对齐 `feishu.push_event`(feishu.py:222):
|
|
476
|
+
* hook 选择 → events 过滤 → 渲染 → 幂等入 outbox(记项目所有者 user_id)。
|
|
477
|
+
*
|
|
478
|
+
* **永不抛出**(任何异常吞掉,绝不影响业务主流程)——这是本函数唯一的不变量;
|
|
479
|
+
* dedup 键默认 `kind:project_id:card_id|task_id`,`ctx.dedup_key` 显式给了就优先。
|
|
480
|
+
* @param {number} projectId
|
|
481
|
+
* @param {string} kind
|
|
482
|
+
* @param {object} ctx
|
|
483
|
+
* @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
|
|
484
|
+
* @returns {void}
|
|
485
|
+
*/
|
|
486
|
+
export function pushEvent(projectId, kind, ctx, options = {}) {
|
|
487
|
+
try {
|
|
488
|
+
const h = hookOf(projectId, options);
|
|
489
|
+
if (h === null || !h.events.has(kind)) return;
|
|
490
|
+
const text = render(kind, ctx, projectId, h.base_url || '', options);
|
|
491
|
+
const dedup = ctx.dedup_key
|
|
492
|
+
|| `${kind}:${projectId}:${ctx.card_id || ctx.task_id || ''}`;
|
|
493
|
+
feishuOutboxPush(h.target, h.secret, payload(text), dedup, h.user_id || 0, options);
|
|
494
|
+
} catch {
|
|
495
|
+
// 照抄 Python 的 `except Exception: pass`:推送是旁路,失败的可见性由 outbox 行承担
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// --------------------------------------------------------------------------
|
|
500
|
+
// 交互卡片构建族(feishu.py:310-585;DM 单聊推送用,REST 经 `restSendCard`)
|
|
501
|
+
// --------------------------------------------------------------------------
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* 单题**多选**且可远程作答 → `[mini 表单元素 or null, 指引文案]`,对齐
|
|
505
|
+
* `feishu._single_multi_select_pick`(feishu.py:310)。
|
|
506
|
+
*
|
|
507
|
+
* 多选组件 `multi_select_static` 按飞书官方规定**只能内嵌表单容器**,故给「单题 mini 表单 +
|
|
508
|
+
* 提交按钮」;表单 JSON 超 `MQ_FORM_MAX_BYTES`(飞书 form 元素 1000 字节硬限,超限必被
|
|
509
|
+
* `230099/11310` 拒收且整卡静默丢失)或该题没有选项时**不发控件**,只回文本作答指引。
|
|
510
|
+
* @param {string|number} cid
|
|
511
|
+
* @param {object} ctx
|
|
512
|
+
* @returns {[object|null, string]}
|
|
513
|
+
*/
|
|
514
|
+
export function singleMultiSelectPick(cid, ctx) {
|
|
515
|
+
const q = singleQuestionView(ctx);
|
|
516
|
+
const tail = q.allow_other ? `;自定义文字回复「作答 ${cid} <文字>」` : '';
|
|
517
|
+
if (!q.options.length) {
|
|
518
|
+
return [null, q.allow_other
|
|
519
|
+
? `请回复「作答 ${cid} <文字>」作答`
|
|
520
|
+
: '该提问没有可选选项,请到站点会话窗口处理'];
|
|
521
|
+
}
|
|
522
|
+
const form = multiSelectForm(cid, q, '提交作答');
|
|
523
|
+
if (formCharLen(form) > MQ_FORM_MAX_BYTES) {
|
|
524
|
+
return [null, `多选题:请回复「作答 ${cid} 1,3」(逗号分隔序号)${tail}`];
|
|
525
|
+
}
|
|
526
|
+
return [form, '多选题:勾选后点「提交作答」送达;'
|
|
527
|
+
+ `也可回复「作答 ${cid} 1,3」(逗号分隔序号)${tail}`];
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* 多子题 → DM 卡片逐题 markdown 元素(**只读**展示),对齐
|
|
532
|
+
* `feishu._question_card_blocks`(feishu.py:333)。
|
|
533
|
+
*/
|
|
534
|
+
export function questionCardBlocks(views) {
|
|
535
|
+
return views.map((v) => questionMdElement(v, views.length));
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* 单题 → markdown 元素,对齐 `feishu._question_md_element`(feishu.py:363)
|
|
540
|
+
* (「只读展示」与「逐题点选卡」共用同一份排版,避免两处漂移)。
|
|
541
|
+
*/
|
|
542
|
+
export function questionMdElement(v, n) {
|
|
543
|
+
const title = v.header || v.question || `第 ${v.no} 题`;
|
|
544
|
+
const md = [`**${v.no}/${n} ${title}**`];
|
|
545
|
+
if (v.header && v.question) md.push(v.question);
|
|
546
|
+
if (v.body) md.push(v.body);
|
|
547
|
+
for (const o of v.options) {
|
|
548
|
+
md.push(`${o.no}) ${o.label}`);
|
|
549
|
+
if (o.description) md.push(` ${o.description}`); // 全角缩进:描述次行
|
|
550
|
+
}
|
|
551
|
+
const caps = [];
|
|
552
|
+
if (v.multi_select) caps.push('多选');
|
|
553
|
+
if (v.allow_other) caps.push(`可自定义:${v.other_label}`);
|
|
554
|
+
if (caps.length) md.push(`(${caps.join(';')})`);
|
|
555
|
+
return { tag: 'markdown', content: md.join('\n') };
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* 表单 JSON 的**码点数**,对齐 Python 的 `len(json.dumps(form, ensure_ascii=False))`。
|
|
560
|
+
*
|
|
561
|
+
* 常量名里的 "BYTES" 是历史命名(飞书 form 元素硬限 1000 字节),但 Python 量的确实是
|
|
562
|
+
* `len(str)` = **码点个数**,不是 UTF-8 字节数。照抄同一口径:改用 `Buffer.byteLength`
|
|
563
|
+
* 会把中文算成 3 字节,等于把「装得下」误判成「装不下」而**少发控件**。
|
|
564
|
+
* @param {object} form
|
|
565
|
+
* @returns {number}
|
|
566
|
+
*/
|
|
567
|
+
function formCharLen(form) {
|
|
568
|
+
return cpLen(pyJsonDumps(form));
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* 多选题 → **单题 mini 表单**,对齐 `feishu._multi_select_form`(feishu.py:432)。
|
|
573
|
+
* 提交按钮与组件同带 `t=mq/c/n`:飞书表单提交帧走**按钮**的 behaviors.value,
|
|
574
|
+
* 组件值在 `action.form_value[<组件 name>]`(数组)。
|
|
575
|
+
*/
|
|
576
|
+
export function multiSelectForm(cid, v, btnText = '') {
|
|
577
|
+
return {
|
|
578
|
+
tag: 'form',
|
|
579
|
+
name: `mqf${cid}_${v.no}`,
|
|
580
|
+
elements: [
|
|
581
|
+
{
|
|
582
|
+
tag: 'multi_select_static',
|
|
583
|
+
name: `mq${cid}_${v.no}`,
|
|
584
|
+
placeholder: { tag: 'plain_text', content: '请选择(可多选)' },
|
|
585
|
+
options: v.options.map((o) => ({
|
|
586
|
+
text: { tag: 'plain_text', content: o.label },
|
|
587
|
+
value: String(o.no),
|
|
588
|
+
})),
|
|
589
|
+
behaviors: [{ type: 'callback', value: { t: 'mq', c: cid, n: v.no } }],
|
|
590
|
+
},
|
|
591
|
+
{
|
|
592
|
+
tag: 'button',
|
|
593
|
+
name: `mqs${cid}_${v.no}`,
|
|
594
|
+
form_action_type: 'submit',
|
|
595
|
+
type: 'primary',
|
|
596
|
+
text: { tag: 'plain_text', content: btnText || `提交第 ${v.no} 题` },
|
|
597
|
+
behaviors: [{ type: 'callback', value: { t: 'mq', c: cid, n: v.no } }],
|
|
598
|
+
},
|
|
599
|
+
],
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* 多子题 → 「逐题点选卡」元素,对齐 `feishu._multi_click_elements`(feishu.py:386):
|
|
605
|
+
* 返回 `[elements, 只能文本作答的题号列表]`。单选放**根级** `select_static`;多选包一层
|
|
606
|
+
* 单题 mini 表单(超预算或没选项 ⇒ 该题进 text_only,绝不发出必被拒的卡)。
|
|
607
|
+
*/
|
|
608
|
+
export function multiClickElements(cid, views) {
|
|
609
|
+
const els = [];
|
|
610
|
+
const textOnly = [];
|
|
611
|
+
const n = views.length;
|
|
612
|
+
for (const v of views) {
|
|
613
|
+
els.push(questionMdElement(v, n));
|
|
614
|
+
if (!v.options.length) {
|
|
615
|
+
textOnly.push(v.no);
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
if (v.multi_select) {
|
|
619
|
+
const form = multiSelectForm(cid, v);
|
|
620
|
+
if (formCharLen(form) <= MQ_FORM_MAX_BYTES) els.push(form);
|
|
621
|
+
else textOnly.push(v.no); // 组件装不进 form 硬限:退回文本作答
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
624
|
+
els.push({
|
|
625
|
+
tag: 'select_static',
|
|
626
|
+
name: `mq${cid}_${v.no}`,
|
|
627
|
+
placeholder: { tag: 'plain_text', content: '请选择' },
|
|
628
|
+
options: v.options.map((o) => ({
|
|
629
|
+
text: { tag: 'plain_text', content: o.label },
|
|
630
|
+
value: String(o.no),
|
|
631
|
+
})),
|
|
632
|
+
behaviors: [{ type: 'callback', value: { t: 'mq', c: cid, n: v.no } }],
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
return [els, textOnly];
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* 逐题点选卡末行指引,对齐 `feishu._multi_click_hint`(feishu.py:455)。
|
|
640
|
+
* `textOnly` = 卡片上没有可点控件的题号;`multiForms` = 发了多选 mini 表单的题号。
|
|
641
|
+
*/
|
|
642
|
+
export function multiClickHint(cid, qs, textOnly = [], multiForms = []) {
|
|
643
|
+
const extra = [...new Set([
|
|
644
|
+
...textOnly,
|
|
645
|
+
...qs.map((q, i) => ((q || {}).allow_other ? i + 1 : null)).filter((v) => v !== null),
|
|
646
|
+
])].sort((a, b) => a - b);
|
|
647
|
+
const lines = [`逐题选择即可,答满 ${qs.length} 题自动送达 agent。`];
|
|
648
|
+
const forms = [...new Set(multiForms)].sort((a, b) => a - b);
|
|
649
|
+
if (forms.length) {
|
|
650
|
+
lines.push(forms.map((i) => `第 ${i} 题是多选:勾选后点「提交第 ${i} 题」`).join(';'));
|
|
651
|
+
}
|
|
652
|
+
if (extra.length) {
|
|
653
|
+
lines.push(`第 ${extra.join('、')} 题请回复「作答 ${cid} `
|
|
654
|
+
+ extra.map((i) => `${i}:<值>`).join(' ')
|
|
655
|
+
+ '」作答(自定义文字写 <文字>,可选项写序号,多选逗号分隔;已点选的题不用重复给)');
|
|
656
|
+
}
|
|
657
|
+
return lines.join('\n');
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* `blocked_interaction` ctx → schema 2.0 交互卡片 dict,对齐
|
|
662
|
+
* `feishu._build_interaction_card`(feishu.py:477)(DM 单聊推送用)。
|
|
663
|
+
*
|
|
664
|
+
* 形态分支(与 `render` 的文本分支**不是**同一套,别互相套用):可远程作答的多子题 ⇒
|
|
665
|
+
* 逐题点选卡(单选根级下拉 / 多选 mini 表单)+ 指引;不可远程作答的多子题 ⇒ 逐题只读 +
|
|
666
|
+
* 统一引导;审批 ⇒ 批准/会话内批准/拒绝三按钮;单题多选 ⇒ mini 表单(装不下则文字指引);
|
|
667
|
+
* 单题单选 ⇒ 每选项一个按钮。按钮 value 精简键 `t/c/n/i`。
|
|
668
|
+
* @param {object} ctx
|
|
669
|
+
* @param {string} projectName
|
|
670
|
+
* @returns {object}
|
|
671
|
+
*/
|
|
672
|
+
export function buildInteractionCard(ctx, projectName) {
|
|
673
|
+
const kind = ctx.kind;
|
|
674
|
+
const cid = ctx.card_id;
|
|
675
|
+
const elements = [];
|
|
676
|
+
// M6(2026-10-10):方案先上(折叠面板 + 完整方案链接);取不到方案时整块不出现
|
|
677
|
+
if (pyTruthy(ctx.plan_inline) && kind !== 'approval') {
|
|
678
|
+
elements.push({
|
|
679
|
+
tag: 'collapsible_panel',
|
|
680
|
+
expanded: true,
|
|
681
|
+
header: {
|
|
682
|
+
title: { tag: 'plain_text', content: '📄 agent 的方案 / 回答(点标题可折叠)' },
|
|
683
|
+
},
|
|
684
|
+
elements: [{ tag: 'markdown', content: ctx.plan_inline }],
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
if (pyTruthy(ctx.plan_doc_url) && kind !== 'approval') {
|
|
688
|
+
elements.push({
|
|
689
|
+
tag: 'markdown',
|
|
690
|
+
content: `📄 [查看完整方案(飞书文档)](${ctx.plan_doc_url})`,
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
if (kind === 'approval') {
|
|
694
|
+
const lines = [`**审批请求**:${pyStr(ctx.action || ctx.tool || '工具调用')}`];
|
|
695
|
+
if (pyTruthy(ctx.tool)) lines.push(`工具:${pyStr(ctx.tool)}`);
|
|
696
|
+
if (pyTruthy(ctx.input)) lines.push(`内容:${cpSlice(pyStr(ctx.input), 200)}`);
|
|
697
|
+
elements.push({ tag: 'markdown', content: lines.join('\n') });
|
|
698
|
+
for (const [label, t, style] of [
|
|
699
|
+
['✅ 批准', 'p', 'primary'],
|
|
700
|
+
['🔁 会话内批准', 's', 'default'],
|
|
701
|
+
['⛔ 拒绝', 'd', 'danger'],
|
|
702
|
+
]) {
|
|
703
|
+
elements.push({
|
|
704
|
+
tag: 'button',
|
|
705
|
+
type: style,
|
|
706
|
+
text: { tag: 'plain_text', content: label },
|
|
707
|
+
behaviors: [{ type: 'callback', value: { t, c: cid } }],
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
} else {
|
|
711
|
+
const qs = (ctx.questions || []).filter((q) => pyTruthy(q));
|
|
712
|
+
if (qs.length > 1) {
|
|
713
|
+
const views = questionViews(qs);
|
|
714
|
+
elements.push({
|
|
715
|
+
tag: 'markdown',
|
|
716
|
+
content: [
|
|
717
|
+
`**agent 提问(共 ${qs.length} 题)**`,
|
|
718
|
+
`卡片 #${pyStr(cid)} ${clipField(ctx.title, 60)}`,
|
|
719
|
+
].join('\n'),
|
|
720
|
+
});
|
|
721
|
+
if (pyTruthy(ctx.answerable)) {
|
|
722
|
+
const [clickEls, textOnly] = multiClickElements(cid, views);
|
|
723
|
+
elements.push(...clickEls);
|
|
724
|
+
const forms = views
|
|
725
|
+
.filter((v) => v.multi_select && !textOnly.includes(v.no))
|
|
726
|
+
.map((v) => v.no);
|
|
727
|
+
elements.push({
|
|
728
|
+
tag: 'markdown',
|
|
729
|
+
content: multiClickHint(cid, qs, textOnly, forms),
|
|
730
|
+
});
|
|
731
|
+
} else {
|
|
732
|
+
elements.push(...questionCardBlocks(views));
|
|
733
|
+
elements.push({ tag: 'markdown', content: NO_REMOTE_ANSWER_HINT });
|
|
734
|
+
}
|
|
735
|
+
} else {
|
|
736
|
+
const md = [
|
|
737
|
+
`**agent 提问**:${pyStr(ctx.question || 'agent 正在等待你的回答')}`,
|
|
738
|
+
`卡片 #${pyStr(cid)} ${clipField(ctx.title, 60)}`,
|
|
739
|
+
];
|
|
740
|
+
let i = 0;
|
|
741
|
+
for (const o of (ctx.options || [])) {
|
|
742
|
+
i += 1;
|
|
743
|
+
md.push(`${i}) ${pyStr(o)}`);
|
|
744
|
+
}
|
|
745
|
+
elements.push({ tag: 'markdown', content: md.join('\n') });
|
|
746
|
+
let hint = null;
|
|
747
|
+
if (!pyTruthy(ctx.answerable)) {
|
|
748
|
+
hint = NO_REMOTE_ANSWER_HINT;
|
|
749
|
+
} else if ((ctx.questions_count || qs.length || 0) > 1) {
|
|
750
|
+
hint = `该提问含 ${pyStr(ctx.questions_count || qs.length)} 道子题,`
|
|
751
|
+
+ '请到站点会话窗口逐题作答';
|
|
752
|
+
} else if (pyTruthy(ctx.multi_select)) {
|
|
753
|
+
const [form, pickHint] = singleMultiSelectPick(cid, ctx);
|
|
754
|
+
hint = pickHint;
|
|
755
|
+
if (form !== null) elements.push(form);
|
|
756
|
+
}
|
|
757
|
+
if (pyTruthy(ctx.answerable) && !hint && kind === 'question') {
|
|
758
|
+
let k = 0;
|
|
759
|
+
for (const o of (ctx.options || [])) {
|
|
760
|
+
k += 1;
|
|
761
|
+
elements.push({
|
|
762
|
+
tag: 'button',
|
|
763
|
+
type: k === 1 ? 'primary' : 'default',
|
|
764
|
+
text: { tag: 'plain_text', content: `${k}) ${pyStr(o)}` },
|
|
765
|
+
behaviors: [{ type: 'callback', value: { t: 'a', c: cid, i: k } }],
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
if (pyTruthy(ctx.allow_other)) hint = `自定义回答请回复「作答 ${pyStr(cid)} <文字>」`;
|
|
769
|
+
}
|
|
770
|
+
if (hint) elements.push({ tag: 'markdown', content: hint });
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
return {
|
|
774
|
+
schema: '2.0',
|
|
775
|
+
header: {
|
|
776
|
+
title: { tag: 'plain_text', content: `🤔 卡片等待回复 [${projectName}]` },
|
|
777
|
+
template: 'orange',
|
|
778
|
+
},
|
|
779
|
+
body: { elements },
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// --------------------------------------------------------------------------
|
|
784
|
+
// REST 车道缝(M7-R4 的 `feishu_rest.js`)
|
|
785
|
+
// --------------------------------------------------------------------------
|
|
786
|
+
|
|
787
|
+
/** REST 车道不可用的响亮失败(`name`/`code` 与 R4 的 `FeishuRestError` 同形,便于按名判定) */
|
|
788
|
+
export class RestLaneUnavailable extends Error {
|
|
789
|
+
constructor(message) {
|
|
790
|
+
super(message);
|
|
791
|
+
this.name = 'FeishuRestError';
|
|
792
|
+
this.code = null;
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
let REST_LANE = null;
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* 懒加载 REST 车道(**不外抛成静默成功**:导入失败/接口不符一律抛 `RestLaneUnavailable`)。
|
|
800
|
+
* 懒加载的理由见文件头接缝②;接口冻结为 `rest(method, path, {params, jsonBody, cfg, timeout})`
|
|
801
|
+
* 与 `tenantToken(cfg)`(本文件只用到 `rest`/`restSendCard`/`FeishuRestError`)。
|
|
802
|
+
* @returns {Promise<object>} `feishu_rest.js` 的模块命名空间
|
|
803
|
+
*/
|
|
804
|
+
async function loadRestLane() {
|
|
805
|
+
if (REST_LANE !== null) return REST_LANE;
|
|
806
|
+
let lane;
|
|
807
|
+
try {
|
|
808
|
+
lane = await import('./feishu_rest.js');
|
|
809
|
+
} catch (error) {
|
|
810
|
+
throw new RestLaneUnavailable(
|
|
811
|
+
'飞书 REST 车道不可用(backend/lib/feishu_rest.js 缺失或导入失败):'
|
|
812
|
+
+ `${errText(error)}——本调用需要 M7-R4 的 REST 车道`,
|
|
813
|
+
);
|
|
814
|
+
}
|
|
815
|
+
if (typeof lane.rest !== 'function' || typeof lane.FeishuRestError !== 'function') {
|
|
816
|
+
throw new RestLaneUnavailable(
|
|
817
|
+
'飞书 REST 车道接口不符:需要导出 rest(method, path, {params, jsonBody, cfg, timeout})'
|
|
818
|
+
+ ' 与 FeishuRestError',
|
|
819
|
+
);
|
|
820
|
+
}
|
|
821
|
+
REST_LANE = lane;
|
|
822
|
+
return REST_LANE;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// --------------------------------------------------------------------------
|
|
826
|
+
// plan 方案文档族(feishu.py:605-936)
|
|
827
|
+
// --------------------------------------------------------------------------
|
|
828
|
+
|
|
829
|
+
/** 作答方案增强总闸,对齐 `feishu.plan_enabled`(feishu.py:617):`TS_FEISHU_PLAN=0` 关闭 */
|
|
830
|
+
export function planEnabled() {
|
|
831
|
+
return (process.env[PLAN_ENV] || '1') !== '0';
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/** 北京时间字符串,对齐 `feishu._beijing_now`(feishu.py:622)(容器时区=UTC,一律按 UTC+8 落) */
|
|
835
|
+
export function beijingNow() {
|
|
836
|
+
const d = new Date(Date.now() + 8 * 3600 * 1000);
|
|
837
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
838
|
+
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} `
|
|
839
|
+
+ `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* 会话转录 → 提问前的「方案/回答」文本 `{assistant, think}`,对齐
|
|
844
|
+
* `feishu._plan_session_texts`(feishu.py:628):取「最后一次 `ask_user_question` **之前**」的
|
|
845
|
+
* 末条 assistant 与末条 think;读不到/解析异常 ⇒ 两项皆空串(方案取数失败绝不影响卡片发送)。
|
|
846
|
+
* @param {string} sid
|
|
847
|
+
* @returns {{assistant: string, think: string}}
|
|
848
|
+
*/
|
|
849
|
+
export function planSessionTexts(sid) {
|
|
850
|
+
const empty = { assistant: '', think: '' };
|
|
851
|
+
const s = pyStrip(pyStr(sid == null ? '' : sid));
|
|
852
|
+
if (!s) return empty;
|
|
853
|
+
try {
|
|
854
|
+
const res = sessparse.load('dsh', s, 'main') || {};
|
|
855
|
+
const entries = res.entries || [];
|
|
856
|
+
if (!res.found || !entries.length) return empty;
|
|
857
|
+
let cut = entries.length;
|
|
858
|
+
for (let i = entries.length - 1; i >= 0; i -= 1) {
|
|
859
|
+
const e = entries[i] || {};
|
|
860
|
+
if (e.kind === 'tool_call' && e.name === ASK_TOOL) {
|
|
861
|
+
cut = i;
|
|
862
|
+
break;
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
let assistant = '';
|
|
866
|
+
let think = '';
|
|
867
|
+
for (const raw of entries.slice(0, cut).reverse()) {
|
|
868
|
+
const e = raw || {};
|
|
869
|
+
if (e.kind === 'assistant' && !assistant) assistant = pyStrip(pyStr(e.text || ''));
|
|
870
|
+
else if (e.kind === 'think' && !think) think = pyStrip(pyStr(e.text || ''));
|
|
871
|
+
if (assistant && think) break;
|
|
872
|
+
}
|
|
873
|
+
return { assistant, think };
|
|
874
|
+
} catch {
|
|
875
|
+
return empty;
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* 卡内折叠摘要(上限 `PLAN_INLINE_MAX`),对齐 `feishu._plan_inline_markdown`(feishu.py:667):
|
|
881
|
+
* 以末条 assistant 回答为主;assistant 过短(< `PLAN_THINK_MIN`)时补 think 摘录。
|
|
882
|
+
* @param {object} ctx
|
|
883
|
+
* @param {{assistant?: string, think?: string}} texts
|
|
884
|
+
* @returns {string}
|
|
885
|
+
*/
|
|
886
|
+
export function planInlineMarkdown(ctx, texts) {
|
|
887
|
+
const a = pyStrip(pyStrOrEmpty((texts || {}).assistant));
|
|
888
|
+
const t = pyStrip(pyStrOrEmpty((texts || {}).think));
|
|
889
|
+
const parts = [];
|
|
890
|
+
if (a) parts.push(a);
|
|
891
|
+
if (cpLen(a) < PLAN_THINK_MIN && t) parts.push(`**(思考摘录)**\n${t}`);
|
|
892
|
+
if (!parts.length) return '';
|
|
893
|
+
let md = parts.join('\n\n');
|
|
894
|
+
if (cpLen(md) > PLAN_INLINE_MAX) {
|
|
895
|
+
md = `${pyRstrip(cpSlice(md, PLAN_INLINE_MAX))}\n\n…(摘要已截断,完整内容见下方飞书文档)`;
|
|
896
|
+
}
|
|
897
|
+
return md;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/**
|
|
901
|
+
* 提问**全文**(不截断)→ markdown,对齐 `feishu._plan_question_md`(feishu.py:689)。
|
|
902
|
+
* 与卡片渲染(按 `Q_TEXT_MAX`/`Q_OPT_MAX`/`Q_DESC_MAX` 截断)刻意分开:文档承载的正是
|
|
903
|
+
* 卡片放不下的完整信息。
|
|
904
|
+
* @param {object} ctx
|
|
905
|
+
* @returns {string}
|
|
906
|
+
*/
|
|
907
|
+
export function planQuestionMd(ctx) {
|
|
908
|
+
const qs = (ctx.questions || []).filter((q) => pyTruthy(q));
|
|
909
|
+
const lines = [];
|
|
910
|
+
if (!qs.length) {
|
|
911
|
+
const q = pyStrip(pyStrOrEmpty(ctx.question));
|
|
912
|
+
if (q) lines.push(q);
|
|
913
|
+
let i = 0;
|
|
914
|
+
for (const o of (ctx.options || [])) {
|
|
915
|
+
i += 1;
|
|
916
|
+
lines.push(`${i}) ${optionView(o).label}`);
|
|
917
|
+
}
|
|
918
|
+
return lines.join('\n');
|
|
919
|
+
}
|
|
920
|
+
const n = qs.length;
|
|
921
|
+
let i = 0;
|
|
922
|
+
for (const q of qs) {
|
|
923
|
+
i += 1;
|
|
924
|
+
const title = pyStr(q.header || q.question || `第 ${i} 题`);
|
|
925
|
+
lines.push(`### ${i}/${n} ${title}`);
|
|
926
|
+
if (q.header && q.question) lines.push(pyStr(q.question));
|
|
927
|
+
const body = pyStrip(pyStrOrEmpty(q.body));
|
|
928
|
+
if (body) lines.push(body);
|
|
929
|
+
let j = 0;
|
|
930
|
+
for (const o of (q.options || [])) {
|
|
931
|
+
j += 1;
|
|
932
|
+
const ov = optionView(o);
|
|
933
|
+
lines.push(`${j}) ${ov.label}`);
|
|
934
|
+
if (ov.description) lines.push(` ${ov.description}`); // 全角缩进:描述次行
|
|
935
|
+
}
|
|
936
|
+
const caps = [];
|
|
937
|
+
if (q.multi_select) caps.push('多选');
|
|
938
|
+
if (q.allow_other) caps.push(`可自定义:${q.other_label || '其他'}`);
|
|
939
|
+
if (caps.length) lines.push(`(${caps.join(';')})`);
|
|
940
|
+
}
|
|
941
|
+
return lines.join('\n');
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/**
|
|
945
|
+
* 文档标题:卡号 + 题头,对齐 `feishu._plan_doc_title`(feishu.py:729)。
|
|
946
|
+
* @param {object} ctx
|
|
947
|
+
* @returns {string}
|
|
948
|
+
*/
|
|
949
|
+
export function planDocTitle(ctx) {
|
|
950
|
+
const head = pyFirstLine(pyStrip(pyStr(ctx.header || ctx.question || '提问')));
|
|
951
|
+
return clipText(`卡 ${pyStr(ctx.card_id)} · ${head}`, 80);
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/**
|
|
955
|
+
* 完整方案文档正文(markdown,经 `mdBlocks` 转飞书块),对齐
|
|
956
|
+
* `feishu._plan_doc_markdown`(feishu.py:736):提问**全文** + 末条 assistant 回答 +
|
|
957
|
+
* 作答指引 + 必要时的 think 摘录;整体按 `PLAN_DOC_MAX` 截断(码点)。
|
|
958
|
+
* @param {object} ctx
|
|
959
|
+
* @param {{assistant?: string, think?: string}} texts
|
|
960
|
+
* @param {string} [projectName]
|
|
961
|
+
* @returns {string}
|
|
962
|
+
*/
|
|
963
|
+
export function planDocMarkdown(ctx, texts, projectName = '') {
|
|
964
|
+
const cid = ctx.card_id;
|
|
965
|
+
const a = pyStrip(pyStrOrEmpty((texts || {}).assistant));
|
|
966
|
+
const t = pyStrip(pyStrOrEmpty((texts || {}).think));
|
|
967
|
+
const lines = [
|
|
968
|
+
`# 卡 ${pyStr(cid)} · agent 的方案与提问`, '',
|
|
969
|
+
`- 项目:${pyStr(pyTruthy(projectName) ? projectName : '—')}`,
|
|
970
|
+
`- 卡片:${cpSlice(pyStrOrEmpty(ctx.title), 60)}`,
|
|
971
|
+
`- 时间:${beijingNow()}(北京时间)`,
|
|
972
|
+
];
|
|
973
|
+
const sid = pyStrip(pyStrOrEmpty(ctx.session_id));
|
|
974
|
+
if (sid) lines.push(`- 会话:${sid}`);
|
|
975
|
+
if (a) lines.push('', '## agent 的方案 / 回答', '', a);
|
|
976
|
+
lines.push('', '## 提问(全文)', '', planQuestionMd(ctx));
|
|
977
|
+
lines.push('', '## 如何作答', '',
|
|
978
|
+
'- 在飞书聊天里点卡片上的选项按钮 / 下拉即完成作答(多选题勾选后点「提交」);',
|
|
979
|
+
`- 也可以直接回复:「作答 ${pyStr(cid)} <序号>」(多选逗号分隔,如 \`作答 ${pyStr(cid)} 1,3\`),`
|
|
980
|
+
+ `或「作答 ${pyStr(cid)} <自定义文字>」写自由文本;`,
|
|
981
|
+
'- 点选与文本作答可混用,已点选的题不必重复给。');
|
|
982
|
+
if (cpLen(a) < PLAN_THINK_MIN && t) {
|
|
983
|
+
lines.push('', '## agent 的思考摘录', '', cpSlice(t, PLAN_THINK_MAX));
|
|
984
|
+
}
|
|
985
|
+
let md = lines.join('\n');
|
|
986
|
+
if (cpLen(md) > PLAN_DOC_MAX) md = `${cpSlice(md, PLAN_DOC_MAX)}\n\n…(正文已截断)`;
|
|
987
|
+
return md;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
/**
|
|
991
|
+
* 清掉 docx 文本块**不渲染**的行内 markdown 记号,对齐 `feishu._md_plain`(feishu.py:768)。
|
|
992
|
+
*
|
|
993
|
+
* 逐点照抄三条正则:Python 的 `.` **不匹配 `\n`**(故 JS 侧一律写 `[^\n]` 而不是 `.`,
|
|
994
|
+
* 否则 `**a\nb**` 这类跨行会被误吞)。
|
|
995
|
+
* @param {string} s
|
|
996
|
+
* @returns {string}
|
|
997
|
+
*/
|
|
998
|
+
export function mdPlain(s) {
|
|
999
|
+
let out = String(s);
|
|
1000
|
+
out = out.replace(/\*\*([^\n]+?)\*\*/g, '$1');
|
|
1001
|
+
out = out.replace(/(?<!\*)\*([^*\n]+?)\*(?!\*)/g, '$1');
|
|
1002
|
+
out = out.replace(/~~([^\n]+?)~~/g, '$1');
|
|
1003
|
+
return out.replace(/`/g, '');
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/**
|
|
1007
|
+
* 极简 markdown → 飞书文档块,对齐 `feishu._md_blocks`(feishu.py:777):
|
|
1008
|
+
* 标题(3/4/5)/项目符号(12)/有序(13)/引用(15)/代码(14)/分隔线(22)/正文(2);
|
|
1009
|
+
* 表格分隔行(`| --- | --- |`)丢弃(飞书块不支持表格);超长段落按 `MD_BLOCK_MAX`
|
|
1010
|
+
* **码点**切块;产出恒为合法块形状,绝不抛。
|
|
1011
|
+
* @param {string} text
|
|
1012
|
+
* @returns {object[]}
|
|
1013
|
+
*/
|
|
1014
|
+
export function mdBlocks(text) {
|
|
1015
|
+
const blocks = [];
|
|
1016
|
+
let inCode = false;
|
|
1017
|
+
const buf = [];
|
|
1018
|
+
|
|
1019
|
+
const mk = (bt, key, content) => ({
|
|
1020
|
+
block_type: bt,
|
|
1021
|
+
[key]: {
|
|
1022
|
+
elements: [{
|
|
1023
|
+
text_run: {
|
|
1024
|
+
content,
|
|
1025
|
+
text_element_style: { bold: bt === 3 || bt === 4 || bt === 5 },
|
|
1026
|
+
},
|
|
1027
|
+
}],
|
|
1028
|
+
style: {},
|
|
1029
|
+
},
|
|
1030
|
+
});
|
|
1031
|
+
const addText = (bt, key, s) => {
|
|
1032
|
+
if (!s) return;
|
|
1033
|
+
const cps = Array.from(s);
|
|
1034
|
+
for (let i = 0; i < cps.length; i += MD_BLOCK_MAX) {
|
|
1035
|
+
blocks.push(mk(bt, key, cps.slice(i, i + MD_BLOCK_MAX).join('')));
|
|
1036
|
+
}
|
|
1037
|
+
};
|
|
1038
|
+
const flushCode = () => {
|
|
1039
|
+
if (buf.length) {
|
|
1040
|
+
addText(14, 'code', buf.join('\n'));
|
|
1041
|
+
buf.length = 0;
|
|
1042
|
+
}
|
|
1043
|
+
};
|
|
1044
|
+
|
|
1045
|
+
const source = pyTruthy(text) ? pyStr(text) : '';
|
|
1046
|
+
for (const raw of source.split('\n')) {
|
|
1047
|
+
const line = pyRstrip(raw);
|
|
1048
|
+
if (pyStrip(line).startsWith('```')) {
|
|
1049
|
+
if (inCode) {
|
|
1050
|
+
flushCode();
|
|
1051
|
+
inCode = false;
|
|
1052
|
+
} else {
|
|
1053
|
+
inCode = true;
|
|
1054
|
+
}
|
|
1055
|
+
continue;
|
|
1056
|
+
}
|
|
1057
|
+
if (inCode) {
|
|
1058
|
+
buf.push(line);
|
|
1059
|
+
continue;
|
|
1060
|
+
}
|
|
1061
|
+
const s = mdPlain(line);
|
|
1062
|
+
if (!pyStrip(s)) continue;
|
|
1063
|
+
if (/^\|[\s\-:|]+\|$/.test(pyStrip(s))) continue; // 表格分隔行:丢
|
|
1064
|
+
if (s.startsWith('### ')) addText(5, 'heading3', s.slice(4));
|
|
1065
|
+
else if (s.startsWith('## ')) addText(4, 'heading2', s.slice(3));
|
|
1066
|
+
else if (s.startsWith('# ')) addText(3, 'heading1', s.slice(2));
|
|
1067
|
+
else if (pyStrip(s) === '---') blocks.push({ block_type: 22, divider: {} });
|
|
1068
|
+
else if (pyLstrip(s).startsWith('> ')) addText(15, 'quote', pyLstrip(s).slice(2));
|
|
1069
|
+
else if (new RegExp(`^${PY_WS}*[-*] `).test(s)) {
|
|
1070
|
+
addText(12, 'bullet', s.replace(new RegExp(`^${PY_WS}*[-*] `), ''));
|
|
1071
|
+
} else if (new RegExp(`^${PY_WS}*\\d+[.)] `).test(s)) {
|
|
1072
|
+
addText(13, 'ordered', s.replace(new RegExp(`^${PY_WS}*\\d+[.)] `), ''));
|
|
1073
|
+
} else addText(2, 'text', s);
|
|
1074
|
+
}
|
|
1075
|
+
flushCode();
|
|
1076
|
+
return blocks;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
/**
|
|
1080
|
+
* 建飞书云文档并授权给用户 → 返回规范链接,对齐 `feishu._docx_create`(feishu.py:842)。
|
|
1081
|
+
*
|
|
1082
|
+
* 配方(2026-10-10 真机实测通过):① 建文档;② 写正文(**单次 ≤50 块**);
|
|
1083
|
+
* ③ PATCH 链接分享「组织内可阅读」(**必做**:文档默认归应用所有,不做这步用户打不开);
|
|
1084
|
+
* ④ POST 把用户加成协作者(**best-effort**:失败只留痕,第③步已保证可读);
|
|
1085
|
+
* ⑤ metas/batch_query(带 `with_url=true`)取规范链接,漏了会 `code=0` 静默回空串 ⇒
|
|
1086
|
+
* 此时按租户域名兜底拼链接。任一步失败抛 REST 车道的 `FeishuRestError`。
|
|
1087
|
+
*
|
|
1088
|
+
* **Node 侧是 async**(REST 走 fetch):Python 是同步函数,调用链 `attachPlan` 因此也是 async,
|
|
1089
|
+
* 见文件头接缝②。
|
|
1090
|
+
* @param {string} title
|
|
1091
|
+
* @param {string} mdText
|
|
1092
|
+
* @param {object} cfg 用户应用凭据
|
|
1093
|
+
* @param {string} [openId]
|
|
1094
|
+
* @param {number} [timeout]
|
|
1095
|
+
* @returns {Promise<string>} 文档链接
|
|
1096
|
+
*/
|
|
1097
|
+
export async function docxCreate(title, mdText, cfg, openId, timeout = DOC_TIMEOUT) {
|
|
1098
|
+
const lane = await loadRestLane();
|
|
1099
|
+
const body = (await lane.rest('POST', '/open-apis/docx/v1/documents', {
|
|
1100
|
+
jsonBody: { title }, cfg, timeout,
|
|
1101
|
+
})) || {};
|
|
1102
|
+
const doc = (((body.data || {}).document || {}).document_id) || '';
|
|
1103
|
+
if (!doc) throw new lane.FeishuRestError('建文档成功但没拿到 document_id');
|
|
1104
|
+
const blocks = mdBlocks(mdText);
|
|
1105
|
+
for (let i = 0; i < blocks.length; i += 50) {
|
|
1106
|
+
await lane.rest('POST', `/open-apis/docx/v1/documents/${doc}/blocks/${doc}/children`, {
|
|
1107
|
+
jsonBody: { children: blocks.slice(i, i + 50), index: i }, cfg, timeout,
|
|
1108
|
+
});
|
|
1109
|
+
}
|
|
1110
|
+
await lane.rest('PATCH', `/open-apis/drive/v1/permissions/${doc}/public`, {
|
|
1111
|
+
params: { type: 'docx' },
|
|
1112
|
+
jsonBody: { link_share_entity: 'tenant_readable', external_access_entity: 'open' },
|
|
1113
|
+
cfg,
|
|
1114
|
+
timeout,
|
|
1115
|
+
});
|
|
1116
|
+
if (openId) {
|
|
1117
|
+
try {
|
|
1118
|
+
// need_notification=false:文档链接已在卡上,再加一条「分享给你」的通知就是重复打扰
|
|
1119
|
+
await lane.rest('POST', `/open-apis/drive/v1/permissions/${doc}/members`, {
|
|
1120
|
+
params: { type: 'docx', need_notification: 'false' },
|
|
1121
|
+
jsonBody: { member_type: 'openid', member_id: openId, perm: 'full_access' },
|
|
1122
|
+
cfg,
|
|
1123
|
+
timeout,
|
|
1124
|
+
});
|
|
1125
|
+
} catch (error) {
|
|
1126
|
+
if (!(error instanceof lane.FeishuRestError)) throw error;
|
|
1127
|
+
stderrLine('feishu docx: 加协作者失败(第③步链接分享已可读,继续): '
|
|
1128
|
+
+ errRepr(error));
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
const meta = (await lane.rest('POST', '/open-apis/drive/v1/metas/batch_query', {
|
|
1132
|
+
params: { user_id_type: 'open_id' },
|
|
1133
|
+
jsonBody: { request_docs: [{ doc_token: doc, doc_type: 'docx' }], with_url: true },
|
|
1134
|
+
cfg,
|
|
1135
|
+
timeout,
|
|
1136
|
+
})) || {};
|
|
1137
|
+
const metas = (meta.data || {}).metas || [];
|
|
1138
|
+
const url = (metas.length ? (metas[0].url || '') : '') || '';
|
|
1139
|
+
return url || `https://feishu.cn/docx/${doc}`; // 兜底:域名由飞书 301/302 重定向
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
/**
|
|
1143
|
+
* `(卡, 提问)` → 已建文档链接的记账表,对齐 `feishu._PLAN_DOCS`(feishu.py:611)。
|
|
1144
|
+
*
|
|
1145
|
+
* Python 的键是二元 tuple `(int(card_id), str(qid))`;JS 用 `\u0000` 拼接的字符串等价表达
|
|
1146
|
+
* (qid 不可能含 NUL;Python tuple 的 `(1, "2")` 与 `(1, 2)` 不同键,这里 `int()` 后拼同样区分)。
|
|
1147
|
+
* @type {Map<string, {url: string, at: number}>}
|
|
1148
|
+
*/
|
|
1149
|
+
const PLAN_DOCS = new Map();
|
|
1150
|
+
|
|
1151
|
+
function planDocKey(cardId, qid) {
|
|
1152
|
+
return `${cardId}\u0000${qid}`;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
/** 取该 (卡, 提问) 已建文档链接,对齐 `feishu._plan_doc_get`(feishu.py:890)(顺带清过期记录) */
|
|
1156
|
+
export function planDocGet(cardId, qid) {
|
|
1157
|
+
const now = Date.now() / 1000;
|
|
1158
|
+
for (const [k, v] of [...PLAN_DOCS.entries()]) {
|
|
1159
|
+
if (now - (v.at || 0) > PLAN_DOC_TTL) PLAN_DOCS.delete(k);
|
|
1160
|
+
}
|
|
1161
|
+
const v = PLAN_DOCS.get(planDocKey(cardId, qid));
|
|
1162
|
+
return v ? (v.url || '') : '';
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
/** 记账(同一提问只建一次文档;上限截断防无界增长),对齐 `feishu._plan_doc_put`(feishu.py:899) */
|
|
1166
|
+
export function planDocPut(cardId, qid, url) {
|
|
1167
|
+
PLAN_DOCS.set(planDocKey(cardId, qid), { url, at: Date.now() / 1000 });
|
|
1168
|
+
while (PLAN_DOCS.size > 128) {
|
|
1169
|
+
let oldestKey = null;
|
|
1170
|
+
let oldestAt = Infinity;
|
|
1171
|
+
for (const [k, v] of PLAN_DOCS.entries()) {
|
|
1172
|
+
if ((v.at || 0) < oldestAt) {
|
|
1173
|
+
oldestAt = v.at || 0;
|
|
1174
|
+
oldestKey = k;
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
PLAN_DOCS.delete(oldestKey);
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
/**
|
|
1182
|
+
* 把「方案」挂到卡片上下文,对齐 `feishu._attach_plan`(feishu.py:906):
|
|
1183
|
+
* `plan_inline`(卡内折叠摘要)+ `plan_doc_url`(文档链接)。
|
|
1184
|
+
*
|
|
1185
|
+
* best-effort:**绝不外抛**——方案是锦上添花,卡本身必须发出去。失败**留痕到 stderr**
|
|
1186
|
+
* (Python 同款 print),不是静默成功。无会话直接返回,不发无意义文档;同一 (卡, 提问)
|
|
1187
|
+
* 只建一次文档。
|
|
1188
|
+
* @param {object} ctx 原地修改(挂 `plan_inline`/`plan_doc_url`)
|
|
1189
|
+
* @param {string} projectName
|
|
1190
|
+
* @param {object} cfg
|
|
1191
|
+
* @param {string} [openId]
|
|
1192
|
+
* @returns {Promise<void>}
|
|
1193
|
+
*/
|
|
1194
|
+
export async function attachPlan(ctx, projectName, cfg, openId = '') {
|
|
1195
|
+
try {
|
|
1196
|
+
const sid = pyStrip(pyStrOrEmpty(ctx.session_id));
|
|
1197
|
+
if (!sid) return;
|
|
1198
|
+
const texts = planSessionTexts(sid);
|
|
1199
|
+
const inline = planInlineMarkdown(ctx, texts);
|
|
1200
|
+
if (inline) ctx.plan_inline = inline;
|
|
1201
|
+
const cid = Math.trunc(Number(ctx.card_id || 0)) || 0;
|
|
1202
|
+
const qid = pyStrOrEmpty(ctx.qid);
|
|
1203
|
+
const cached = planDocGet(cid, qid);
|
|
1204
|
+
if (cached) {
|
|
1205
|
+
ctx.plan_doc_url = cached;
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1208
|
+
const md = planDocMarkdown(ctx, texts, projectName);
|
|
1209
|
+
if (!pyStrip(md)) return;
|
|
1210
|
+
const url = await docxCreate(planDocTitle(ctx), md, cfg, openId);
|
|
1211
|
+
if (url) {
|
|
1212
|
+
planDocPut(cid, qid, url);
|
|
1213
|
+
ctx.plan_doc_url = url;
|
|
1214
|
+
}
|
|
1215
|
+
} catch (error) {
|
|
1216
|
+
stderrLine('feishu docx: 建方案文档失败(卡照发,仅卡内摘要): ' + errRepr(error));
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
// --------------------------------------------------------------------------
|
|
1221
|
+
// 业务侧语义函数(board / runner 钩子调用;feishu.py:939-1034)
|
|
1222
|
+
// --------------------------------------------------------------------------
|
|
1223
|
+
|
|
1224
|
+
/**
|
|
1225
|
+
* 旁路:向项目所有者的飞书单聊发交互卡片,对齐
|
|
1226
|
+
* `feishu._dm_interaction_card`(feishu.py:939)。
|
|
1227
|
+
*
|
|
1228
|
+
* **受 `notifyEvents` 闸门约束**(2026-10-10 修实障:此前只看绑定与凭据 ⇒ 用户关掉全部
|
|
1229
|
+
* 事件后卡片仍持续送达),再要求账号绑定 + 应用凭据齐全才发。卡片是即时交互载体,
|
|
1230
|
+
* **不走 outbox**(过期提问的分钟级重试无意义);失败仅留痕——群 webhook 文本推送
|
|
1231
|
+
* (`pushEvent`)始终是主通道,不受影响。
|
|
1232
|
+
*
|
|
1233
|
+
* Node 侧是 async(REST 走 fetch);异常**内部吞掉并留痕**(照抄 Python 的 except + print)。
|
|
1234
|
+
* @param {number} projectId
|
|
1235
|
+
* @param {object} ctx
|
|
1236
|
+
* @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
|
|
1237
|
+
* @returns {Promise<void>} 永不 reject
|
|
1238
|
+
*/
|
|
1239
|
+
export async function dmInteractionCard(projectId, ctx, options = {}) {
|
|
1240
|
+
try {
|
|
1241
|
+
if (!notifyEvents(projectId, options).has('blocked_interaction')) return;
|
|
1242
|
+
const proj = getProject(projectId, options);
|
|
1243
|
+
const owner = (proj ? proj.user_id : 0) || 0;
|
|
1244
|
+
const binding = getFeishuBindingByUser(owner, options);
|
|
1245
|
+
const cfg = appConfig(owner, options);
|
|
1246
|
+
if (!binding || !cfg) return;
|
|
1247
|
+
const name = proj ? proj.name : `#${projectId}`;
|
|
1248
|
+
// 审批卡不做方案文档(它的「背景」由审批请求本身给出,建文档没意义);开关可回滚
|
|
1249
|
+
if (planEnabled() && pyStrOrEmpty(ctx.kind) !== 'approval') {
|
|
1250
|
+
await attachPlan(ctx, name, cfg, binding.open_id || '');
|
|
1251
|
+
}
|
|
1252
|
+
const lane = await loadRestLane();
|
|
1253
|
+
await lane.restSendCard(binding.open_id, buildInteractionCard(ctx, name), cfg);
|
|
1254
|
+
} catch (error) {
|
|
1255
|
+
stderrLine('feishu outbound: DM 交互卡片发送失败: ' + errRepr(error));
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
/**
|
|
1260
|
+
* 卡片交互等待(`board._iw_apply` block 分支调用),对齐
|
|
1261
|
+
* `feishu.card_blocked`(feishu.py:969):qid 进 `dedup_key` 防同一提问在重试窗口内重复推送。
|
|
1262
|
+
*
|
|
1263
|
+
* **形参表照 Python**(3 位 + 本仓库的 `options` 末位);内部从 card/interaction 拼 ctx
|
|
1264
|
+
* (提问带 `questions_count`/`multi_select`/`allow_other`,审批带 `tool`/`action`/`input`),
|
|
1265
|
+
* 然后 `pushEvent`(同步)+ 旁路 DM 卡片(不 await,Python 侧同为「发完即走」)。
|
|
1266
|
+
* @param {number} projectId
|
|
1267
|
+
* @param {object} card 看板卡行(db 行即可,内部转普通对象)
|
|
1268
|
+
* @param {object} interaction
|
|
1269
|
+
* @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
|
|
1270
|
+
* @returns {void}
|
|
1271
|
+
*/
|
|
1272
|
+
export function cardBlocked(projectId, card, interaction, options = {}) {
|
|
1273
|
+
const c = isPlainObject(card) ? card : { ...card };
|
|
1274
|
+
if (!('id' in c)) throw new TypeError('card 缺少 id(Python 侧此处是 KeyError)');
|
|
1275
|
+
const kind = pyStrOrEmpty(interaction.kind);
|
|
1276
|
+
const qs = (interaction.questions || []).filter((q) => pyTruthy(q));
|
|
1277
|
+
const q0 = qs.length ? qs[0] : {};
|
|
1278
|
+
const ctx = {
|
|
1279
|
+
card_id: c.id,
|
|
1280
|
+
title: c.title || '',
|
|
1281
|
+
kind,
|
|
1282
|
+
// 方案取数(attachPlan 读会话转录)与建文档去重都要这两个键;缺了它们整块自动跳过
|
|
1283
|
+
session_id: pyStrOrEmpty(c.session_id),
|
|
1284
|
+
qid: pyStrOrEmpty(interaction.qid),
|
|
1285
|
+
question: interaction.question || '',
|
|
1286
|
+
// 选项展示名取 label(交互归一后的字段):旧代码取不存在的 text 字段,
|
|
1287
|
+
// 推送里选项序号后恒空白
|
|
1288
|
+
options: (interaction.options || []).map((o) => o.label || o.text || ''),
|
|
1289
|
+
answerable: pyTruthy(interaction.answerable),
|
|
1290
|
+
dedup_key: `card:${c.id}:interaction:${interaction.qid || ''}`,
|
|
1291
|
+
};
|
|
1292
|
+
if (kind === 'approval') {
|
|
1293
|
+
ctx.tool = interaction.tool || '';
|
|
1294
|
+
ctx.action = interaction.action || '';
|
|
1295
|
+
ctx.input = interaction.input || '';
|
|
1296
|
+
} else {
|
|
1297
|
+
ctx.questions_count = qs.length;
|
|
1298
|
+
ctx.multi_select = pyTruthy(dictGet(q0, 'multi_select', interaction.multi_select));
|
|
1299
|
+
ctx.allow_other = pyTruthy(dictGet(q0, 'allow_other', interaction.allow_other));
|
|
1300
|
+
// 逐题视图透传:多子题按题能力取组件;组不成表单时同一份数据供逐题只读展示
|
|
1301
|
+
ctx.questions = qs.map((q) => ({
|
|
1302
|
+
id: pyStrOrEmpty(q.id),
|
|
1303
|
+
question: q.question || '',
|
|
1304
|
+
header: q.header || '',
|
|
1305
|
+
body: q.body || '',
|
|
1306
|
+
options: (q.options || []).map((o) => optionView(o)),
|
|
1307
|
+
multi_select: pyTruthy(q.multi_select),
|
|
1308
|
+
allow_other: pyTruthy(q.allow_other),
|
|
1309
|
+
other_label: q.other_label || '',
|
|
1310
|
+
}));
|
|
1311
|
+
}
|
|
1312
|
+
pushEvent(projectId, 'blocked_interaction', ctx, options);
|
|
1313
|
+
// 旁路:不 await(Python 同为发完即走)。dmInteractionCard 内部自吞异常,不会 reject;
|
|
1314
|
+
// 末尾的 catch 只防御「留痕函数本身抛」这种不可能路径,且**不改变**可观测结果。
|
|
1315
|
+
dmInteractionCard(projectId, ctx, options).catch(() => {});
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
/**
|
|
1319
|
+
* 卡片落 review 待审核,对齐 `feishu.card_review`(feishu.py:1022)(默认 events 不含此 kind,
|
|
1320
|
+
* 项目级开启才推)。形参表照 Python:`(project_id, card)` 两位。
|
|
1321
|
+
* @param {number} projectId
|
|
1322
|
+
* @param {object} card
|
|
1323
|
+
* @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
|
|
1324
|
+
* @returns {void}
|
|
1325
|
+
*/
|
|
1326
|
+
export function cardReview(projectId, card, options = {}) {
|
|
1327
|
+
const c = isPlainObject(card) ? card : { ...card };
|
|
1328
|
+
if (!('id' in c)) throw new TypeError('card 缺少 id(Python 侧此处是 KeyError)');
|
|
1329
|
+
pushEvent(projectId, 'card_review', { card_id: c.id, title: c.title || '' }, options);
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
/**
|
|
1333
|
+
* 任务 failed/interrupted(runner 各终态写点调用),对齐
|
|
1334
|
+
* `feishu.task_failed`(feishu.py:1029):形参表照 Python
|
|
1335
|
+
* `(project_id, task_id, name, ttype, error, skipped_n=0)`,末位多一个本仓库的 `options`。
|
|
1336
|
+
*
|
|
1337
|
+
* **已知形态差异**:Python 的 `skipped_n` 是关键字实参,而 mod-run 的 `kwargs` 在 Node 侧会
|
|
1338
|
+
* 合并成**末位对象** ⇒ 将来若要用 `kwargs` 驱动 `skipped_n`,它会落到 `options` 位上。
|
|
1339
|
+
* 用 `args` 传第 6 位则两边都对(用例未驱动本函数,登记在 M7-R2 未验项里)。
|
|
1340
|
+
* @param {number} projectId
|
|
1341
|
+
* @param {number} taskId
|
|
1342
|
+
* @param {string} name
|
|
1343
|
+
* @param {string} ttype
|
|
1344
|
+
* @param {string} error
|
|
1345
|
+
* @param {number} [skippedN]
|
|
1346
|
+
* @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
|
|
1347
|
+
* @returns {void}
|
|
1348
|
+
*/
|
|
1349
|
+
export function taskFailed(projectId, taskId, name, ttype, error, skippedN = 0, options = {}) {
|
|
1350
|
+
pushEvent(projectId, 'task_failed', {
|
|
1351
|
+
task_id: taskId, name, type: ttype, error, skipped_n: skippedN,
|
|
1352
|
+
}, options);
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
// --------------------------------------------------------------------------
|
|
1356
|
+
// 投递线程(feishu.py:2599-2678)
|
|
1357
|
+
// --------------------------------------------------------------------------
|
|
1358
|
+
|
|
1359
|
+
/** `code == 0` 的 Python 口径(`False == 0`、`0.0 == 0` 都为真;`!==` 会把 `false` 误判) */
|
|
1360
|
+
function pyEqZero(value) {
|
|
1361
|
+
if (typeof value === 'boolean') return value === false;
|
|
1362
|
+
if (typeof value === 'bigint') return value === 0n;
|
|
1363
|
+
return value === 0;
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
/**
|
|
1367
|
+
* 同步发一枪 POST(`node:http`/`node:https`),逐字节照抄 Python `urllib` 的线上形态:
|
|
1368
|
+
* ```
|
|
1369
|
+
* POST <path?query> HTTP/1.1
|
|
1370
|
+
* Accept-Encoding: identity
|
|
1371
|
+
* Content-Length: <n>
|
|
1372
|
+
* Host: <host[:port]> # 默认端口不写端口
|
|
1373
|
+
* User-Agent: Python-urllib/3.12
|
|
1374
|
+
* Content-Type: application/json
|
|
1375
|
+
* Connection: close
|
|
1376
|
+
* ```
|
|
1377
|
+
* 头序/大小写/值都是**实测**的 oracle 形态(`doc_ai/plan/202610/20261014_1930_M7…` §1.4),
|
|
1378
|
+
* 不是「Node 习惯怎么发就怎么发」。**不跟随 3xx**(见文件头「跨运行时的已知分歧」)。
|
|
1379
|
+
* @param {string} target
|
|
1380
|
+
* @param {Buffer} bodyBytes
|
|
1381
|
+
* @returns {Promise<{status: number, statusMessage: string, body: Buffer}>}
|
|
1382
|
+
*/
|
|
1383
|
+
function postJson(target, bodyBytes) {
|
|
1384
|
+
return new Promise((resolve, reject) => {
|
|
1385
|
+
let url;
|
|
1386
|
+
try {
|
|
1387
|
+
url = new URL(String(target));
|
|
1388
|
+
} catch (error) {
|
|
1389
|
+
reject(new Error(`unknown url type: ${String(target)}`));
|
|
1390
|
+
return;
|
|
1391
|
+
}
|
|
1392
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
1393
|
+
reject(new Error(`unknown url type: ${url.protocol}`));
|
|
1394
|
+
return;
|
|
1395
|
+
}
|
|
1396
|
+
const isHttps = url.protocol === 'https:';
|
|
1397
|
+
const mod = isHttps ? https : http;
|
|
1398
|
+
const defaultPort = isHttps ? 443 : 80;
|
|
1399
|
+
const port = url.port ? Number(url.port) : defaultPort;
|
|
1400
|
+
const hostHeader = port === defaultPort ? url.hostname : `${url.hostname}:${port}`;
|
|
1401
|
+
const req = mod.request({
|
|
1402
|
+
hostname: url.hostname,
|
|
1403
|
+
port,
|
|
1404
|
+
method: 'POST',
|
|
1405
|
+
path: `${url.pathname}${url.search}`,
|
|
1406
|
+
headers: {
|
|
1407
|
+
'Accept-Encoding': 'identity',
|
|
1408
|
+
'Content-Length': String(bodyBytes.length),
|
|
1409
|
+
Host: hostHeader,
|
|
1410
|
+
'User-Agent': 'Python-urllib/3.12',
|
|
1411
|
+
'Content-Type': 'application/json',
|
|
1412
|
+
Connection: 'close',
|
|
1413
|
+
},
|
|
1414
|
+
}, (res) => {
|
|
1415
|
+
const chunks = [];
|
|
1416
|
+
res.on('data', (c) => chunks.push(c));
|
|
1417
|
+
res.on('end', () => resolve({
|
|
1418
|
+
status: res.statusCode,
|
|
1419
|
+
statusMessage: res.statusMessage || '',
|
|
1420
|
+
body: Buffer.concat(chunks),
|
|
1421
|
+
}));
|
|
1422
|
+
res.on('error', reject);
|
|
1423
|
+
});
|
|
1424
|
+
req.on('error', reject);
|
|
1425
|
+
req.setTimeout(SEND_TIMEOUT * 1000, () => {
|
|
1426
|
+
req.destroy(new Error(`timed out after ${SEND_TIMEOUT}s`));
|
|
1427
|
+
});
|
|
1428
|
+
req.end(bodyBytes);
|
|
1429
|
+
});
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
/**
|
|
1433
|
+
* 同步发送一条 outbox 行,对齐 `feishu._send_one`(feishu.py:2607):发送时刻补
|
|
1434
|
+
* timestamp/sign(secret 不落日志,错误摘要只含飞书返回 code/msg),返回 `[ok, err]`。
|
|
1435
|
+
*
|
|
1436
|
+
* 不外抛(坏 URL/网络/非 JSON 响应都转错误串)——外抛会让投递线程该行永不标记、每秒空转重试。
|
|
1437
|
+
* 唯一的例外照抄 Python:payload 解析出的值**不是 dict** 而 secret 非空时,
|
|
1438
|
+
* `body["timestamp"] = ts` 会抛 TypeError(Python 同样在 try 之外)。
|
|
1439
|
+
* @param {{id: number, retries: number, payload: string, secret: string, target: string}} row
|
|
1440
|
+
* @returns {Promise<[boolean, string]>}
|
|
1441
|
+
*/
|
|
1442
|
+
export async function sendOne(row) {
|
|
1443
|
+
const ts = String(Math.trunc(Date.now() / 1000));
|
|
1444
|
+
let body;
|
|
1445
|
+
try {
|
|
1446
|
+
body = JSON.parse(row.payload);
|
|
1447
|
+
} catch (error) {
|
|
1448
|
+
return [false, cpSlice(errText(error), 200)];
|
|
1449
|
+
}
|
|
1450
|
+
if (row.secret) {
|
|
1451
|
+
if (!isPlainObject(body)) {
|
|
1452
|
+
throw new TypeError('payload 不是 JSON 对象,无法补 timestamp/sign(Python 同处抛 TypeError)');
|
|
1453
|
+
}
|
|
1454
|
+
body.timestamp = ts;
|
|
1455
|
+
body.sign = sign(row.secret, ts);
|
|
1456
|
+
}
|
|
1457
|
+
let out;
|
|
1458
|
+
let resp;
|
|
1459
|
+
try {
|
|
1460
|
+
resp = await postJson(row.target, Buffer.from(pyJsonDumps(body), 'utf8'));
|
|
1461
|
+
} catch (error) {
|
|
1462
|
+
return [false, cpSlice(errText(error), 200)];
|
|
1463
|
+
}
|
|
1464
|
+
if (resp.status < 200 || resp.status >= 300) {
|
|
1465
|
+
// Python 侧 urlopen 对 4xx/5xx 抛 HTTPError,文案形态=`HTTP Error <code>: <reason>`
|
|
1466
|
+
return [false, cpSlice(`HTTP Error ${resp.status}: ${resp.statusMessage}`, 200)];
|
|
1467
|
+
}
|
|
1468
|
+
try {
|
|
1469
|
+
out = JSON.parse(resp.body.toString('utf8'));
|
|
1470
|
+
} catch (error) {
|
|
1471
|
+
return [false, cpSlice(errText(error), 200)];
|
|
1472
|
+
}
|
|
1473
|
+
if (isPlainObject(out)) {
|
|
1474
|
+
if (pyEqZero(dictGet(out, 'code'))) return [true, ''];
|
|
1475
|
+
return [false, cpSlice(`code=${pyStr(dictGet(out, 'code'))} `
|
|
1476
|
+
+ `${pyStr(dictGet(out, 'msg', ''))}`, 200)];
|
|
1477
|
+
}
|
|
1478
|
+
return [false, cpSlice(pyStr(out), 200)];
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
/**
|
|
1482
|
+
* 发送 + 落账,对齐 `feishu._send_one_and_mark`(feishu.py:2635):
|
|
1483
|
+
* 成功标 `sent`(`retries` **不变**、`next_at=0`、`last_error=''`);
|
|
1484
|
+
* 失败 `retries+1` 按 `RETRY_DELAYS` 退避续期 `pending`,第 4 次(`n > 3`)标 `failed` 留痕。
|
|
1485
|
+
* @param {{id: number, retries: number, payload: string, secret: string, target: string}} row
|
|
1486
|
+
* @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
|
|
1487
|
+
* @returns {Promise<boolean>}
|
|
1488
|
+
*/
|
|
1489
|
+
export async function sendOneAndMark(row, options = {}) {
|
|
1490
|
+
const [ok, err] = await sendOne(row);
|
|
1491
|
+
if (ok) {
|
|
1492
|
+
feishuOutboxMark(row.id, 'sent', row.retries, 0, '', options);
|
|
1493
|
+
return true;
|
|
1494
|
+
}
|
|
1495
|
+
const n = row.retries + 1;
|
|
1496
|
+
if (n > RETRY_MAX) {
|
|
1497
|
+
feishuOutboxMark(row.id, 'failed', n, 0, err, options);
|
|
1498
|
+
} else {
|
|
1499
|
+
feishuOutboxMark(row.id, 'pending', n, Date.now() / 1000 + RETRY_DELAYS[n - 1], err,
|
|
1500
|
+
options);
|
|
1501
|
+
}
|
|
1502
|
+
return false;
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
/**
|
|
1506
|
+
* 投递线程循环(对齐 `feishu._send_loop`(feishu.py:2651)):每轮取到期 pending 行逐条发送,
|
|
1507
|
+
* **双层 `except: pass`**(单行失败不影响整轮、整轮失败不影响下一轮)。
|
|
1508
|
+
*
|
|
1509
|
+
* Node 没有 daemon 线程:等价物是 `setInterval` + `.unref()`(不阻塞进程退出)。
|
|
1510
|
+
* Python 先跑一轮体再 `sleep(1)` ⇒ 这里同样**先立刻跑一轮**再起定时器。
|
|
1511
|
+
* @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
|
|
1512
|
+
* @returns {NodeJS.Timeout} 定时器句柄(已 unref;测试用 clearInterval 收尾)
|
|
1513
|
+
*/
|
|
1514
|
+
export function sendLoop(options = {}) {
|
|
1515
|
+
const tick = async () => {
|
|
1516
|
+
try {
|
|
1517
|
+
for (const row of feishuOutboxDue(Date.now() / 1000, 5, options)) {
|
|
1518
|
+
try {
|
|
1519
|
+
await sendOneAndMark(row, options);
|
|
1520
|
+
} catch {
|
|
1521
|
+
// 内层 except:单行坏数据(如 payload 非 dict + 非空 secret)不该拖垮整轮
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
} catch {
|
|
1525
|
+
// 外层 except:取行失败(库被锁/表缺)同样只跳过本轮
|
|
1526
|
+
}
|
|
1527
|
+
};
|
|
1528
|
+
tick().catch(() => {});
|
|
1529
|
+
const timer = setInterval(() => { tick().catch(() => {}); }, TICK * 1000);
|
|
1530
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
1531
|
+
return timer;
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
/** `_SENDER_STARTED`(feishu.py:39):幂等守卫(模块级,长驻进程内只起一次) */
|
|
1535
|
+
let SENDER_STARTED = false;
|
|
1536
|
+
|
|
1537
|
+
/**
|
|
1538
|
+
* 幂等启动投递线程,对齐 `feishu.start_notifier`(feishu.py:2665-2678)。
|
|
1539
|
+
*
|
|
1540
|
+
* 副作用有两个(照 oracle 的顺序):① `feishu_conv.start(options)` 注册 chat 的消息终态
|
|
1541
|
+
* 钩子(M7.5-R3 接线:**这是本模块唯一的接线点,漏掉则回流全哑且无任何报错**);
|
|
1542
|
+
* ② 起投递线程 `sendLoop`(未配置时空转)。
|
|
1543
|
+
* @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
|
|
1544
|
+
* @returns {NodeJS.Timeout|null} 首次调用返回定时器句柄;重复调用返回 `null`(no-op)
|
|
1545
|
+
*/
|
|
1546
|
+
export function startNotifier(options = {}) {
|
|
1547
|
+
if (SENDER_STARTED) return null;
|
|
1548
|
+
SENDER_STARTED = true;
|
|
1549
|
+
feishuConv.start(options); // = feishu.py:2676(注册终态钩子;幂等,单一注册位)
|
|
1550
|
+
return sendLoop(options); // = 起 feishu-notifier 投递线程
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
/** 仅供测试/自检:重置 `_SENDER_STARTED` 守卫(Python 侧可直接 poke 模块全局,Node 不行) */
|
|
1554
|
+
export function resetSenderStarted() {
|
|
1555
|
+
SENDER_STARTED = false;
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
// ==========================================================================
|
|
1559
|
+
// M7-R3:个人配置写口 / 绑定码 / 入站闸门
|
|
1560
|
+
// (`feishu.py:1617-1653` 的 `start_inbound_for` + `:2004-2060` 的绑定码/绑定 +
|
|
1561
|
+
// `:3152-3174` 的 `save_user_config`)
|
|
1562
|
+
// ==========================================================================
|
|
1563
|
+
//
|
|
1564
|
+
// 本节**只在文件末尾追加**(R2 车道已冻结本文件上半部分):已有的函数、常量、私有助手
|
|
1565
|
+
// (`pyTruthy` / `dictGet` / `pyStr` / `pyStrOrEmpty` / `pyStrip`)原样复用,不重写、不改口径。
|
|
1566
|
+
// 三处 import 也放在末尾——ESM 的 import 声明会被提升,语义与置顶完全相同(Node 24 实测)。
|
|
1567
|
+
//
|
|
1568
|
+
// ══ 三条「照抄才不失真」的口径 ═════════════════════════════════════════════════════
|
|
1569
|
+
// 1. **`str(v or "").strip()` 落库**(`feishu.py:3166-3169`):五个字符串键走 `str()`,
|
|
1570
|
+
// 容器**走 Python repr**(`["a"]`⇒`"['a']"`、`{"a":1}`⇒`"{'a': 1}"`)——用 `JSON.stringify`
|
|
1571
|
+
// 兜底会差引号与空格。故复用模块私有的 `pyStrOrEmpty` + `lib.js:pyStrip`,不另写一份。
|
|
1572
|
+
// 2. **两个开关走 Python `bool()`**(`:3164-3165`):非空串一律真(`"0"`⇒1)、空容器一律假
|
|
1573
|
+
// (`[]`/`{}`⇒0)。JS 的 `Boolean([])`/`Boolean({})` 都是 true ⇒ 必须过 `pyTruthy`。
|
|
1574
|
+
// 3. **`start_inbound_for` 的 stderr 行逐字相同**(`:1639-1641`):凭据缺失时的文案是
|
|
1575
|
+
// `feishu inbound: 用户 {user_id} 应用凭据缺失,长连接未启动`(用户 id 是数字,不是用户名)。
|
|
1576
|
+
//
|
|
1577
|
+
// ══ M7 的边界(**不许伪造成 true**)═══════════════════════════════════════════════
|
|
1578
|
+
// 入站 WS 长连接是 M7.5 的本体(`feishu_ws.js` + `feishu_inbound.js` + `feishu_conv.js`)。
|
|
1579
|
+
// 本节的 `startInboundFor` 只做「闸门 + 注册表查询 + stderr 一行」,**不建线程**:
|
|
1580
|
+
// - 凭据缺失 ⇒ 打 oracle 逐字相同的行 + 回 `false`(这一档与 oracle 完全一致);
|
|
1581
|
+
// - 凭据齐全 ⇒ **本轮也回 `false`**,另打一行说明「入站长连接未移植(M7.5 接线)」。
|
|
1582
|
+
// 为什么凭据齐全也不能回 true:oracle 里这一位是 `threading.Thread.start()` 的**真实结果**,
|
|
1583
|
+
// M7 没有对应物。回 true 会让 `POST /api/me/feishu-cfg` 的 `inbound_started` 变成
|
|
1584
|
+
// 「保存后立刻为 true」,而实际一条连接都没建立——页面据此显示「已连接」是**假成功**。
|
|
1585
|
+
//
|
|
1586
|
+
// ══ 已知分歧(登记,不擅自「修」)═════════════════════════════════════════════════
|
|
1587
|
+
// `feishu_user_cfgs.json` 是**合法 JSON 但非对象**(`null`/`[]`/`123`/`"x"`,只有手工改库
|
|
1588
|
+
// 才可能)时,oracle 在 `cfg.get(...)`/`cfg[k]=...` 上抛 AttributeError/TypeError ⇒ 连接被
|
|
1589
|
+
// 掐断。本节里:
|
|
1590
|
+
// - `saveUserConfig` 走 `requireCfgDict` **同形抛 TypeError**(由 `dispatchSafely` 收口成
|
|
1591
|
+
// `res.destroy()`)——比 oracle 早一步(写在赋值/落库之前),但**不可观测**:非对象的
|
|
1592
|
+
// `json.dumps` 重写是幂等的(`null`→`null`、`[]`→`[]`),库里字节不变。
|
|
1593
|
+
// - `startInboundFor` 走 R1 冻结的 `feishu_cfg.js:appConfig`(对 `[]`/`123` **宽松**:
|
|
1594
|
+
// `[].app_id` 是 undefined ⇒ 判为「凭据缺失」)。这是 R1 已登记的已知分歧
|
|
1595
|
+
// (`feishu_cfg.js` 文件头末条),本节不另造第二套口径。
|
|
1596
|
+
|
|
1597
|
+
/** 绑定码字符集(去易混字符 `0/1/I/L/O`)。对齐 `feishu._BIND_ALPHABET`(feishu.py:2009) */
|
|
1598
|
+
export const _BIND_ALPHABET = '23456789ABCDEFGHJKMNPQRSTUVWXYZ';
|
|
1599
|
+
|
|
1600
|
+
/** 绑定码有效期(秒)。对齐 `feishu._BIND_TTL`(feishu.py:2010) */
|
|
1601
|
+
export const _BIND_TTL = 600;
|
|
1602
|
+
|
|
1603
|
+
/**
|
|
1604
|
+
* 绑定码注册表(`code → [user_id, expires(秒)]`),**进程内存态、不入库**。
|
|
1605
|
+
* 对齐 `feishu._BIND_CODES`(feishu.py:2011)。语义照抄:
|
|
1606
|
+
* - 换码(`makeBindCode`)**不清理旧条目**——旧码靠 TTL 判死,`bindUser` 的 pop 顺手清;
|
|
1607
|
+
* - 进程重启即全丢;无 TTL 回显(响应里只有 `code`)。
|
|
1608
|
+
* 导出只为「单测能直接钉住注册表形态」;产品代码只用 `makeBindCode`/`bindUser`。
|
|
1609
|
+
*/
|
|
1610
|
+
export const _BIND_CODES = new Map();
|
|
1611
|
+
|
|
1612
|
+
/**
|
|
1613
|
+
* 生成 6 位绑定码并登记。对齐 `feishu.make_bind_code`(feishu.py:2019)。
|
|
1614
|
+
*
|
|
1615
|
+
* `secrets.choice(_BIND_ALPHABET)` ↔ `crypto.randomInt(len)`:两者都是**均匀**取一个字符
|
|
1616
|
+
* (`Math.random()*32` 也行但分布口径不同,故显式用 CSPRNG,与 `secrets` 同族)。
|
|
1617
|
+
* 无幂等、无 TTL 回显;同一次调用只写一个条目。
|
|
1618
|
+
* @param {number} userId
|
|
1619
|
+
* @returns {string} 6 位码(字符集 `_BIND_ALPHABET`)
|
|
1620
|
+
*/
|
|
1621
|
+
export function makeBindCode(userId) {
|
|
1622
|
+
let code = '';
|
|
1623
|
+
for (let i = 0; i < 6; i += 1) {
|
|
1624
|
+
code += _BIND_ALPHABET[crypto.randomInt(_BIND_ALPHABET.length)];
|
|
1625
|
+
}
|
|
1626
|
+
_BIND_CODES.set(code, [userId, Date.now() / 1000 + _BIND_TTL]);
|
|
1627
|
+
return code;
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
/**
|
|
1631
|
+
* 校验绑定码并绑定(open_id 侧发起)。`null`=成功,字符串=错误文案。
|
|
1632
|
+
* 对齐 `feishu.bind_user`(feishu.py:2028-2036)。三处易错点照抄:
|
|
1633
|
+
* ① 码先 `(code or "").strip().upper()` 归一(大小写/首尾空白都容忍,`null`/`""` 归一成空串);
|
|
1634
|
+
* ② **先 pop 再判**——校验失败(未知/过期)也照样出表,重放同一个码只会失败一次;
|
|
1635
|
+
* ③ `db.set_feishu_binding` 的「换绑归零」语义(同 user_id 旧行先删)在 db 层,本函数不管。
|
|
1636
|
+
* @param {string} code 用户报出的绑定码
|
|
1637
|
+
* @param {string} openId 飞书号 open_id
|
|
1638
|
+
* @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
|
|
1639
|
+
* @returns {string|null}
|
|
1640
|
+
*/
|
|
1641
|
+
export function bindUser(code, openId, options = {}) {
|
|
1642
|
+
const key = String(code || '').trim().toUpperCase();
|
|
1643
|
+
const item = _BIND_CODES.get(key);
|
|
1644
|
+
_BIND_CODES.delete(key);
|
|
1645
|
+
if (item === undefined) return '绑定码无效或已被使用';
|
|
1646
|
+
const [userId, expires] = item;
|
|
1647
|
+
if (Date.now() / 1000 > expires) return '绑定码已过期,请到站点重新生成';
|
|
1648
|
+
setFeishuBinding(openId, userId, 0, options);
|
|
1649
|
+
return null;
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
/**
|
|
1653
|
+
* 非普通对象的配置 ⇒ 同形抛(Python 在 `cfg.get`/`cfg[k]=` 上抛 AttributeError/TypeError)。
|
|
1654
|
+
* 由 `dispatchSafely` 收口成 `res.destroy()`(只断这一条连接)。理由见本节「已知分歧」。
|
|
1655
|
+
* @param {unknown} cfg
|
|
1656
|
+
* @returns {object}
|
|
1657
|
+
*/
|
|
1658
|
+
function requireCfgDict(cfg) {
|
|
1659
|
+
if (cfg === null || typeof cfg !== 'object' || Array.isArray(cfg)) {
|
|
1660
|
+
const name = cfg === null ? 'NoneType' : Array.isArray(cfg) ? 'list' : typeof cfg;
|
|
1661
|
+
throw new TypeError(`'${name}' object has no attribute 'get'`);
|
|
1662
|
+
}
|
|
1663
|
+
return cfg;
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
/**
|
|
1667
|
+
* 保存用户飞书配置并即时反馈(服务端设置页唯一写口)。
|
|
1668
|
+
* 对齐 `feishu.save_user_config`(feishu.py:3152-3174)。逐条照抄:
|
|
1669
|
+
* ① `push_enabled` / `enabled` 两个开关:**带键才写**,值过 Python `bool()` 后落 1/0;
|
|
1670
|
+
* ② 五个字符串键 `default_webhook`/`default_secret`/`base_url`/`app_id`/`app_secret`:
|
|
1671
|
+
* **带键才写**(缺省=不改)、显式空串/假值=清除,值走 `str(v or "").strip()`;
|
|
1672
|
+
* ③ 整对象覆盖落库(`db.set_feishu_user_cfg`,JSON 文本走 `pyJsonDumps`);
|
|
1673
|
+
* ④ 落库**之后**才 `startInboundFor`(幂等拉起入站长连接);
|
|
1674
|
+
* ⑤ `verify` 只在 `app_config(user_id)` 非 None(App ID + Secret **两件齐全**)时算,
|
|
1675
|
+
* 否则恒 `None`——即「凭据不全 ⇒ 零网络请求」。
|
|
1676
|
+
*
|
|
1677
|
+
* `verifyCredentials` 是 R5 冻结的凭据快检(`feishu_provision.js`,签名
|
|
1678
|
+
* `(userId, options={}) → Promise<{ok, detail}>`,**绝不抛出**)——本函数**真调**它;
|
|
1679
|
+
* 网络路径的用例不触发(会把 `app_id`/`app_secret` 同时写成真值才会走到)。
|
|
1680
|
+
* @param {number} userId
|
|
1681
|
+
* @param {object} body 请求体(**必是普通对象**:`server.py:_read_json` 已把非 dict 折成 `{}`)
|
|
1682
|
+
* @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
|
|
1683
|
+
* @returns {Promise<{inbound_started: boolean, verify: object|null}>}
|
|
1684
|
+
*/
|
|
1685
|
+
export async function saveUserConfig(userId, body, options = {}) {
|
|
1686
|
+
const cfg = requireCfgDict(userConfig(userId, options));
|
|
1687
|
+
// 推送总控(2026-10-10):与旧 `enabled` 是两个独立开关,缺省=不改(不在 body 里就不动)
|
|
1688
|
+
if (Object.hasOwn(body, 'push_enabled')) cfg.push_enabled = pyTruthy(body.push_enabled) ? 1 : 0;
|
|
1689
|
+
if (Object.hasOwn(body, 'enabled')) cfg.enabled = pyTruthy(body.enabled) ? 1 : 0;
|
|
1690
|
+
for (const key of ['default_webhook', 'default_secret', 'base_url', 'app_id', 'app_secret']) {
|
|
1691
|
+
if (Object.hasOwn(body, key)) cfg[key] = pyStrip(pyStrOrEmpty(body[key]));
|
|
1692
|
+
}
|
|
1693
|
+
setFeishuUserCfg(userId, cfg, options);
|
|
1694
|
+
const started = startInboundFor(userId, options);
|
|
1695
|
+
const verify = appConfig(userId, options) ? await verifyCredentials(userId, options) : null;
|
|
1696
|
+
return { inbound_started: Boolean(started), verify };
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
/**
|
|
1700
|
+
* 入站长连接闸门 + 真拉起(`feishu.start_inbound_for`,feishu.py:1628-1645)。
|
|
1701
|
+
*
|
|
1702
|
+
* **M7.5-R2 接线(2026-10-15)**:M7 的占位实现(凭据齐全也回 `false`、不建线程)已由
|
|
1703
|
+
* `backend/lib/feishu_inbound.js:startInboundFor` 取代——本函数现在是**薄转发**,
|
|
1704
|
+
* 实现在入站车道那个文件里(线程句柄/`wsRun`/stderr 三行都在那儿)。
|
|
1705
|
+
* 语义照 oracle 的三段:① 注册表 `is_alive()` 幂等短路 → ② `app_config` 缺失 ⇒
|
|
1706
|
+
* 打逐字 stderr 行 + `false` → ③ 建「线程」、**先登记后 start**、回 `true`。
|
|
1707
|
+
* 末位 `options` 沿用本文件既有的注入约定(`db`/`dbPath`/`env`),并透传给入站实现
|
|
1708
|
+
* (那里还认 `clientClass`/`wsClient`/`now`/`log`/`rest`/`board`/`conv`/`slash`/`dbmod`)。
|
|
1709
|
+
*
|
|
1710
|
+
* ⚠与 M7 占位的行为差异(**调用方要重读**):凭据齐全时现在回 `true` 并真的发起长连接,
|
|
1711
|
+
* 于是 `POST /api/me/feishu-cfg` 的 `inbound_started` 会随之为 `true`
|
|
1712
|
+
* (`backend/test/feishu-me-routes.test.mjs` 里钉住旧占位语义的两条用例因此失效,
|
|
1713
|
+
* 见 M7.5-R2 交付报告)。
|
|
1714
|
+
* @param {number} userId
|
|
1715
|
+
* @param {object} [options]
|
|
1716
|
+
* @returns {boolean} 是否真的拉起了**新**连接
|
|
1717
|
+
*/
|
|
1718
|
+
export function startInboundFor(userId, options = {}) {
|
|
1719
|
+
return inboundStartFor(userId, options);
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
/**
|
|
1723
|
+
* 服务启动遍历全部用户配置逐个拉起(`feishu.start_inbound_all`,feishu.py:1648-1656)。
|
|
1724
|
+
* 顺序 = `db.list_feishu_user_cfgs()` 的行序;返回**新**启动的连接数。
|
|
1725
|
+
* ⚠无 try/except ⇒ 任一用户抛异常即中断其余用户(半启动),与 oracle 同形。
|
|
1726
|
+
* @param {object} [options]
|
|
1727
|
+
* @returns {number}
|
|
1728
|
+
*/
|
|
1729
|
+
export function startInboundAll(options = {}) {
|
|
1730
|
+
return inboundStartAll(options);
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
// 本节新增的依赖(ESM import 声明提升,等价于置顶;放末尾以严守「只追加」的写作用域)
|
|
1734
|
+
import { setFeishuBinding, setFeishuUserCfg } from './db.js';
|
|
1735
|
+
import { inboundStatus, userConfig } from './feishu_cfg.js';
|
|
1736
|
+
import { startInboundAll as inboundStartAll, startInboundFor as inboundStartFor } from './feishu_inbound.js';
|
|
1737
|
+
import * as feishuConv from './feishu_conv.js';
|
|
1738
|
+
import { verifyCredentials } from './feishu_provision.js';
|