@gavinc-cn/touchstone-dsh 0.3.0 → 0.4.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/backend/lib/board.js +84 -11
- package/backend/lib/chat.js +37 -0
- package/backend/lib/feishu.js +10 -1
- package/backend/lib/feishu_cfg.js +36 -2
- package/backend/lib/feishu_inbound.js +409 -6
- package/backend/lib/feishu_provision.js +37 -6
- package/backend/lib/lock.js +32 -11
- package/backend/lib/proc.js +91 -8
- package/backend/lib/runner.js +32 -6
- package/backend/lib/seed_hint.js +40 -0
- package/backend/lib/serverstate.js +5 -15
- package/backend/routes/board.js +9 -1
- package/backend/routes/feishu_app.js +41 -7
- package/backend/routes/index.js +3 -2
- package/backend/routes/projects.js +81 -2
- package/backend/routes/sessions.js +61 -4
- package/backend/server.js +18 -11
- package/board.py +66 -12
- package/chat.py +33 -0
- package/dsh-plugin/lib/client.bundle.js +111 -2
- package/dsh-plugin/lib/index.js +46 -5
- package/dsh-plugin/src/client.js +111 -2
- package/feishu.py +417 -3
- package/package.json +1 -1
- package/server.py +155 -11
- package/webui/dist-plugin/assets/index-3QsYQi7y.css +1 -0
- package/webui/dist-plugin/assets/index-B8AMtW7y.js +486 -0
- package/webui/dist-plugin/index.html +2 -2
- package/webui/dist-plugin/assets/index-ChP3sg6y.css +0 -1
- package/webui/dist-plugin/assets/index-UrMyeAXF.js +0 -484
package/backend/lib/board.js
CHANGED
|
@@ -2630,7 +2630,7 @@ export function buildTaskPrompt(project, card, extra = '') {
|
|
|
2630
2630
|
const desc = String(card.description || '').trim();
|
|
2631
2631
|
const body = [title, desc].filter((seg) => seg).join('\n\n') || '(无)';
|
|
2632
2632
|
const workspace = cardWorkspace(project, card);
|
|
2633
|
-
let p =
|
|
2633
|
+
let p = `${TASK_PROMPT_TAG}${body}\n【工作目录】${workspace}`;
|
|
2634
2634
|
const wt = String(cardOpt(card, 'worktree') || '').trim();
|
|
2635
2635
|
if (wt) {
|
|
2636
2636
|
p += `\n(本任务运行在独立 git worktree 中:分支 ${branchFor(card.id)},`
|
|
@@ -3125,6 +3125,15 @@ function requireCardSession(card, sid) {
|
|
|
3125
3125
|
* 的卡会话在驱动里只剩观察 ⇒ 首跳 404 时经 `ensureWatch` 钩子(`force=true`)补看管声明后
|
|
3126
3126
|
* 重试一次。未接线(M5-3)⇒ 不补声明、直接按失败抛(与 Python「声明失败」同一分支)。
|
|
3127
3127
|
*
|
|
3128
|
+
* **会话存活前置(2026-10-17 修卡 907「TS 侧 /compact 对已结束会话必失败」)**:上面那条
|
|
3129
|
+
* 「池外回落」只对宿主里**仍活着**的会话成立;对宿主里根本没有活 agent 的会话(dsh web
|
|
3130
|
+
* 重启、插件重载、久未接续的旧卡),驱动 `/compact` 必 404「会话已结束(宿主无活动 agent)」
|
|
3131
|
+
* ——补看管声明也救不回来。而 dsh GUI 自己的 `/compact` 对冷会话是按需 resume 再执行命令
|
|
3132
|
+
* (`dsh-api-session-controller.resolve` → `agents.resume`),故这里与投递腿 `deliverNow`
|
|
3133
|
+
* 同款:先 `ensureCardSession` 接回(判据/边界同它——中枢未对齐按「未知」不动、sync 卡不
|
|
3134
|
+
* 收养、回滚阀 `TS_HOST_SESSION_REVIVE=0` 时不动,均为零请求),再走下面的两跳。接不回
|
|
3135
|
+
* **不在此收口**:照旧走两跳,由驱动 404 文案兜底(与修复前完全一致)。
|
|
3136
|
+
*
|
|
3128
3137
|
* `sid` 必须属于该卡片;不支持的族抛 `Error`(server 转 400)。**async**。
|
|
3129
3138
|
* @returns {Promise<void>}
|
|
3130
3139
|
*/
|
|
@@ -3132,6 +3141,8 @@ export async function compactSession(project, card, sid, options = {}) {
|
|
|
3132
3141
|
requireCardSession(card, sid);
|
|
3133
3142
|
const family = agentFamily(project.agent_path || '');
|
|
3134
3143
|
if (family !== 'dsh_plugin') throw new Error(RETIRED_MSG);
|
|
3144
|
+
// 会话存活前置:宿主已失去该会话 ⇒ 先按 resume 语义接回,再压缩(与投递同款)
|
|
3145
|
+
await ensureCardSession(project, card, sid, options);
|
|
3135
3146
|
let last = '';
|
|
3136
3147
|
for (const attempt of [1, 2]) {
|
|
3137
3148
|
try {
|
|
@@ -3910,6 +3921,12 @@ export const SYNC_BUSY_MTIME_S = 120;
|
|
|
3910
3921
|
export const SYNC_TITLE_MAX = 200;
|
|
3911
3922
|
/** sync 卡描述上限(Python `SYNC_DESC_MAX`) */
|
|
3912
3923
|
export const SYNC_DESC_MAX = 2000;
|
|
3924
|
+
/**
|
|
3925
|
+
* 平台任务提示词的段标(Python `TASK_PROMPT_TAG`):`buildTaskPrompt` 的首段。
|
|
3926
|
+
* 除拼提示词外还用作**判据**——会话首问以它开头 ⇒ 这个会话是平台按某张卡片的内容拼
|
|
3927
|
+
* 提示词启动的(2026-10-16 起这类卡的标题/描述也跟随 DSH,见 `isPlatformTaskSession`)。
|
|
3928
|
+
*/
|
|
3929
|
+
export const TASK_PROMPT_TAG = '【任务描述】';
|
|
3913
3930
|
|
|
3914
3931
|
/** 已跑过「存量子代理卡软删收口」的项目 id(Python `_SYNC_SUBAGENT_SWEPT`,每项目一轮) */
|
|
3915
3932
|
const SYNC_SUBAGENT_SWEPT = new Set();
|
|
@@ -4092,24 +4109,67 @@ export function autoTitleForms(item, sid) {
|
|
|
4092
4109
|
export const _auto_title_forms = autoTitleForms;
|
|
4093
4110
|
|
|
4094
4111
|
/**
|
|
4095
|
-
*
|
|
4112
|
+
* 卡面字段是否仍是「平台按卡片内容拼进首问」的**原文形态**(Python `_in_first_prompt`;
|
|
4113
|
+
* 判自动写入用)。
|
|
4114
|
+
*
|
|
4115
|
+
* 平台起会话时把卡片标题/描述原样拼进首问(`buildTaskPrompt`:标题并入【任务描述】段、
|
|
4116
|
+
* 与描述空行分隔)⇒ 卡面字段只要没被用户改过,就必然能在首问原文里原样找到;用户后来
|
|
4117
|
+
* 改写过的文本通常不在(⇒ 不覆盖,尊重用户编辑)。空/纯空白一律 false(空标题由
|
|
4118
|
+
* `autoTitleForms` 那一档负责)。
|
|
4119
|
+
* @param {string} text
|
|
4120
|
+
* @param {object} item
|
|
4121
|
+
* @returns {boolean}
|
|
4122
|
+
*/
|
|
4123
|
+
export function inFirstPrompt(text, item) {
|
|
4124
|
+
const t = String(text === null || text === undefined ? '' : text).trim();
|
|
4125
|
+
return Boolean(t) && String(item.first_prompt || '').includes(t);
|
|
4126
|
+
}
|
|
4127
|
+
|
|
4128
|
+
/** `_in_first_prompt` 的 Python 名别名。 */
|
|
4129
|
+
export const _in_first_prompt = inFirstPrompt;
|
|
4130
|
+
|
|
4131
|
+
/**
|
|
4132
|
+
* 该会话是否「平台按某张卡片的内容拼提示词启动的」(Python `_is_platform_task_session`;
|
|
4133
|
+
* 判据 = 首问以段标开头)。
|
|
4134
|
+
*
|
|
4135
|
+
* 卡片启动腿(`enterDoing` → `buildTaskPrompt`)拼的首问以 `TASK_PROMPT_TAG` 开头;
|
|
4136
|
+
* 用户在 dsh GUI 里直跑的会话首问是用户原文、续跑腿是「继续」,都不会命中。用于把正向
|
|
4137
|
+
* 同步(DSH 标题/首问 → 卡面)从 sync 卡扩到**平台从看板启动过会话的卡**(2026-10-16
|
|
4138
|
+
* 用户诉求:卡面标题/正文与 DSH 保持一致;见 spec/board §48)。
|
|
4139
|
+
* @param {object} item
|
|
4140
|
+
* @returns {boolean}
|
|
4141
|
+
*/
|
|
4142
|
+
export function isPlatformTaskSession(item) {
|
|
4143
|
+
return String(item.first_prompt || '').replace(/^\s+/, '').startsWith(TASK_PROMPT_TAG);
|
|
4144
|
+
}
|
|
4145
|
+
|
|
4146
|
+
/** `_is_platform_task_session` 的 Python 名别名。 */
|
|
4147
|
+
export const _is_platform_task_session = isPlatformTaskSession;
|
|
4148
|
+
|
|
4149
|
+
/**
|
|
4150
|
+
* 卡面按「DSH 当前标题 + 首问全文」补齐的字段 dict(Python `_sync_card_follow`;
|
|
4096
4151
|
* 无变化返回 `{}`)。
|
|
4097
4152
|
*
|
|
4098
4153
|
* 只认「主会话」:遍历到的会话不是卡的主会话(多会话并集里的子会话)时不动。
|
|
4099
|
-
*
|
|
4100
|
-
*
|
|
4154
|
+
* 只在卡仍由平台自动写入时动——卡标题 ∈ `autoTitleForms`(空 / sid 短码 / 任一枚会话
|
|
4155
|
+
* 标题事件 / 旧口径的首问首行)**或**仍是平台拼进首问的原文(`inFirstPrompt`,
|
|
4156
|
+
* 2026-10-16 增:服务「平台从看板启动过会话的卡」——它的标题是用户在卡里敲的原文,
|
|
4157
|
+
* 不在任何标题事件里);用户在卡面行内改过标题就整张卡不再自动覆盖(描述也不动)。
|
|
4158
|
+
* 描述仅在为空、仍是**旧口径自动写入**(首问除首行)、或仍是平台拼进首问的原文时改成
|
|
4159
|
+
* 首问全文;用户写过的描述不覆盖。
|
|
4101
4160
|
* @returns {object}
|
|
4102
4161
|
*/
|
|
4103
4162
|
export function syncCardFollow(card, item, sid) {
|
|
4104
4163
|
if (String(card.session_id || sid) !== String(sid)) return {};
|
|
4105
4164
|
const curTitle = String(card.title || '').trim();
|
|
4106
|
-
if (!autoTitleForms(item, sid).has(curTitle)) return {};
|
|
4165
|
+
if (!autoTitleForms(item, sid).has(curTitle) && !inFirstPrompt(curTitle, item)) return {};
|
|
4107
4166
|
const [title, desc] = syncCardFields(item, sid);
|
|
4108
4167
|
const out = {};
|
|
4109
4168
|
if (curTitle !== title) out.title = title;
|
|
4110
4169
|
const curDesc = String(card.description || '').trim();
|
|
4111
4170
|
const legacyDesc = splitFirstPrompt(item.first_prompt || '')[1];
|
|
4112
|
-
if (desc && curDesc !== desc
|
|
4171
|
+
if (desc && curDesc !== desc
|
|
4172
|
+
&& (curDesc === '' || curDesc === legacyDesc || inFirstPrompt(curDesc, item))) {
|
|
4113
4173
|
out.description = desc;
|
|
4114
4174
|
}
|
|
4115
4175
|
return out;
|
|
@@ -4126,6 +4186,10 @@ export const _sync_card_follow = syncCardFollow;
|
|
|
4126
4186
|
* busy→doing / 空闲→review;存量 sync 卡按同口径补齐 + 归档→done;会话存储被删的 sync 卡
|
|
4127
4187
|
* 自动进 done;绑子代理会话的存量 sync 卡收口进回收站(每项目一轮);两类误建卡的存量也按
|
|
4128
4188
|
* 同款口径收口(每项目一轮)。sync 卡不占 runner 项目占用(外部会话平台控制不了)。
|
|
4189
|
+
* **平台从看板启动过会话的卡**(用户建卡 → 开始/回车启动,首问 = 平台拼的任务提示词,
|
|
4190
|
+
* 见 `isPlatformTaskSession`)同样按上面同一口径补齐卡面——但**列语义不动**:归档/取消
|
|
4191
|
+
* 归档的列映射与各类存量收口仍只服务 `origin='sync'` 卡(用户自己的卡,平台只同步
|
|
4192
|
+
* 标题/描述,不替用户搬列、不软删)。用户改过卡名 ⇒ 整卡不动(含描述)。
|
|
4129
4193
|
*
|
|
4130
4194
|
* **async**:本函数自身不打驱动,但 `setCardPlaceholder`→无;`webBusy` 走 `dshevents` 内存读。
|
|
4131
4195
|
* 除 `subagentSession`(注入缝)外全同步——保持 `async` 只为「调用方统一 await」的形态一致。
|
|
@@ -4164,11 +4228,14 @@ export async function syncSessions(project, options = {}) {
|
|
|
4164
4228
|
{ session_id: sid, sessions: pyJsonDumps([sid]), column_key: col, origin: 'sync' },
|
|
4165
4229
|
options);
|
|
4166
4230
|
created.push(cid);
|
|
4167
|
-
} else if (c.origin === 'sync') {
|
|
4168
|
-
//
|
|
4231
|
+
} else if (c.origin === 'sync' || isPlatformTaskSession(it)) {
|
|
4232
|
+
// 卡面补齐(2026-10-07 起服务 sync 卡;2026-10-16 起扩到「平台从看板启动过会话的卡」,
|
|
4233
|
+
// 见 `syncCardFollow`):标题/描述仍是平台自动写入形态时按 DSH 当前标题 + 首问全文
|
|
4234
|
+
// 覆盖(用户改过名整卡不动)
|
|
4169
4235
|
const fields = syncCardFollow(c, it, sid);
|
|
4170
4236
|
if (Object.keys(fields).length) dbmod.updateBoardCard(c.id, true, fields, options);
|
|
4171
|
-
if (
|
|
4237
|
+
if (c.origin === 'sync'
|
|
4238
|
+
&& (c.column_key === 'doing' || c.column_key === 'review')
|
|
4172
4239
|
&& !hasActiveRun(c.id)) {
|
|
4173
4240
|
// dsh 的 busy/空闲 列映射已移交调和器;此处保留归档→done(archived 来源=
|
|
4174
4241
|
// list_sessions 的宿主归档集文件读,与调和器读驱动帧是两条独立通道)
|
|
@@ -5070,8 +5137,14 @@ export function startInteractionWatcher() {
|
|
|
5070
5137
|
}, delay).unref?.();
|
|
5071
5138
|
};
|
|
5072
5139
|
try {
|
|
5073
|
-
|
|
5074
|
-
} catch {
|
|
5140
|
+
eventSubscribe(() => kick());
|
|
5141
|
+
} catch (e) {
|
|
5142
|
+
// 留痕(2026-10-16 实障):这里原本写的是 `dshMod.subscribe(...)`——`dshMod` 是
|
|
5143
|
+
// `./dshdriver.js`(无该导出),TypeError 被空 catch 吞掉 ⇒ 事件面从没接上,调和器
|
|
5144
|
+
// 退化成只有 `IW_SAFETY_SECONDS` 兜底拍(生产实测:待审核卡追问后 ~60s 才进开发列)。
|
|
5145
|
+
// 事件面若真的不可用,必须能定位(不再是静默降级)。
|
|
5146
|
+
console.log(`[board] 调和器事件订阅失败(退化为 ${IW_SAFETY_SECONDS}s 兜底轮询): ${errText(e)}`);
|
|
5147
|
+
}
|
|
5075
5148
|
IW_TIMER = setInterval(() => {
|
|
5076
5149
|
IW_LAST = nowSec();
|
|
5077
5150
|
void iwTick(); // 兜底对账(60s);事件面未接时的唯一驱动
|
package/backend/lib/chat.js
CHANGED
|
@@ -1171,6 +1171,43 @@ export function cancelQueued({ sid = null, cardId = null } = {}, options = {}) {
|
|
|
1171
1171
|
return count;
|
|
1172
1172
|
}
|
|
1173
1173
|
|
|
1174
|
+
/**
|
|
1175
|
+
* 取消排队中的消息**并回带原文**(`mode=cancel_queue` 端点的实现;用户点「取消排队」)。
|
|
1176
|
+
*
|
|
1177
|
+
* 与 `cancelQueued` 的差别只有「回带」:后者是停止入口共用的计数壳。用户显式取消排队时,
|
|
1178
|
+
* 前端要把消息原文放回输入框,而 `msgsOfSid` 下发给前端的展示文本被截到 120 字——
|
|
1179
|
+
* 回填必须由服务端给**全量原文**,否则长消息被静默截断。
|
|
1180
|
+
*
|
|
1181
|
+
* 逐行只在 `cancel()` 真命中(`chat_msgs` 的 queued→cancelled 原子守卫是仲裁点,R12)
|
|
1182
|
+
* 时才收进结果:与 worker claim 竞态下已被抢走执行的消息既不会被取消、也不会回带,
|
|
1183
|
+
* 前端不会把仍在执行的消息错放回输入框。返回按入队顺序(`msgRows` 的 created_at,id)。
|
|
1184
|
+
*
|
|
1185
|
+
* `filter.msgId` 非空 ⇒ 只取消该条(归属校验由端点层做,本函数只管状态)。
|
|
1186
|
+
*
|
|
1187
|
+
* @param {{sid?: string|null, cardId?: number|string|null, msgId?: string|null}} [filter]
|
|
1188
|
+
* @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
|
|
1189
|
+
* @returns {{cancelled: number, msgs: Array<{id: string, text: string}>}}
|
|
1190
|
+
*/
|
|
1191
|
+
export function cancelQueuedDetail({ sid = null, cardId = null, msgId = null } = {}, options = {}) {
|
|
1192
|
+
if (!sid && !cardId && !msgId) return { cancelled: 0, msgs: [] };
|
|
1193
|
+
let rows;
|
|
1194
|
+
if (msgId) {
|
|
1195
|
+
const one = waitq.msgGet(msgId, options);
|
|
1196
|
+
if (one === null || one.state !== STATE_QUEUED) return { cancelled: 0, msgs: [] };
|
|
1197
|
+
rows = [one];
|
|
1198
|
+
} else {
|
|
1199
|
+
rows = waitq.msgRows({ sid, cardId, states: [STATE_QUEUED] }, options);
|
|
1200
|
+
}
|
|
1201
|
+
const msgs = [];
|
|
1202
|
+
for (const row of rows) {
|
|
1203
|
+
// 先取原文再取消:取消是终态迁移,但仍按「命中才回带」的次序写,读一次即用一次
|
|
1204
|
+
const text = row.message === null || row.message === undefined ? '' : String(row.message);
|
|
1205
|
+
if (!cancel(row.id, options)) continue;
|
|
1206
|
+
msgs.push({ id: row.id, text });
|
|
1207
|
+
}
|
|
1208
|
+
return { cancelled: msgs.length, msgs };
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1174
1211
|
// --------------------------------------------------------------------------
|
|
1175
1212
|
// 立即注入(chat.py:452-493)
|
|
1176
1213
|
// --------------------------------------------------------------------------
|
package/backend/lib/feishu.js
CHANGED
|
@@ -1684,6 +1684,8 @@ function requireCfgDict(cfg) {
|
|
|
1684
1684
|
*/
|
|
1685
1685
|
export async function saveUserConfig(userId, body, options = {}) {
|
|
1686
1686
|
const cfg = requireCfgDict(userConfig(userId, options));
|
|
1687
|
+
// M11:换应用前后的凭据快照(自动「确认切换」卡片用旧应用凭据发,见函数尾)
|
|
1688
|
+
const oldCfg = { app_id: cfg.app_id || '', app_secret: cfg.app_secret || '' };
|
|
1687
1689
|
// 推送总控(2026-10-10):与旧 `enabled` 是两个独立开关,缺省=不改(不在 body 里就不动)
|
|
1688
1690
|
if (Object.hasOwn(body, 'push_enabled')) cfg.push_enabled = pyTruthy(body.push_enabled) ? 1 : 0;
|
|
1689
1691
|
if (Object.hasOwn(body, 'enabled')) cfg.enabled = pyTruthy(body.enabled) ? 1 : 0;
|
|
@@ -1693,6 +1695,12 @@ export async function saveUserConfig(userId, body, options = {}) {
|
|
|
1693
1695
|
setFeishuUserCfg(userId, cfg, options);
|
|
1694
1696
|
const started = startInboundFor(userId, options);
|
|
1695
1697
|
const verify = appConfig(userId, options) ? await verifyCredentials(userId, options) : null;
|
|
1698
|
+
// M11:手工换应用(非扫码路径)同样发「确认切换」卡片——旧连接还活着时用旧应用通道
|
|
1699
|
+
// 通知用户点确认。后台发(不阻塞保存响应);条件在 maybeSendSwitchCard 里复核,
|
|
1700
|
+
// 凭据没变/线程没跑时是零请求空转;自动卡片故障绝不反噬保存主流程。
|
|
1701
|
+
if (Object.hasOwn(body, 'app_id') || Object.hasOwn(body, 'app_secret')) {
|
|
1702
|
+
void maybeSendSwitchCard(userId, oldCfg, options).catch(() => {});
|
|
1703
|
+
}
|
|
1696
1704
|
return { inbound_started: Boolean(started), verify };
|
|
1697
1705
|
}
|
|
1698
1706
|
|
|
@@ -1733,6 +1741,7 @@ export function startInboundAll(options = {}) {
|
|
|
1733
1741
|
// 本节新增的依赖(ESM import 声明提升,等价于置顶;放末尾以严守「只追加」的写作用域)
|
|
1734
1742
|
import { setFeishuBinding, setFeishuUserCfg } from './db.js';
|
|
1735
1743
|
import { inboundStatus, userConfig } from './feishu_cfg.js';
|
|
1736
|
-
import { startInboundAll as inboundStartAll,
|
|
1744
|
+
import { startInboundAll as inboundStartAll, maybeSendSwitchCard,
|
|
1745
|
+
startInboundFor as inboundStartFor } from './feishu_inbound.js';
|
|
1737
1746
|
import * as feishuConv from './feishu_conv.js';
|
|
1738
1747
|
import { verifyCredentials } from './feishu_provision.js';
|
|
@@ -50,6 +50,12 @@ export const FEISHU_DEFAULT_EVENTS = Object.freeze(['blocked_interaction', 'task
|
|
|
50
50
|
|
|
51
51
|
/** 入站线程注册表(`user_id` → 线程形对象;对齐 `feishu._WS_THREADS`,见文件头 M7.5 接缝) */
|
|
52
52
|
const INBOUND_THREADS = new Map();
|
|
53
|
+
/**
|
|
54
|
+
* `user_id` → **拉起该线程时**用的 app_id(对齐 `feishu._WS_APP_ID`,M10 2026-10-17)。
|
|
55
|
+
* 为什么需要:lark SDK 无干净停止 ⇒ 换凭据后旧连接仍在跑、只有重启站点才换;
|
|
56
|
+
* doctor 靠它判「线程绑的应用 ≠ 配置里的应用」,否则那一项会一直假绿。
|
|
57
|
+
*/
|
|
58
|
+
const INBOUND_APP_IDS = new Map();
|
|
53
59
|
|
|
54
60
|
/**
|
|
55
61
|
* Python 真值口径(`""`/`0`/`None`/`False`/空容器为假)。
|
|
@@ -193,7 +199,8 @@ export function hookOf(projectId, options = {}) {
|
|
|
193
199
|
* `configured` = 应用凭据齐全(`appConfig` 非 null);`thread` = 注册表里的线程还活着。
|
|
194
200
|
*
|
|
195
201
|
* `thread: true` 只代表线程还在 `client.start()` 内(SDK 内置重连),连接失败细节看 server.log。
|
|
196
|
-
*
|
|
202
|
+
* **形状是 `GET /api/me/feishu-cfg` 响应的子对象 ⇒ 只许这两键**(「线程绑的哪个应用」
|
|
203
|
+
* 不放进这里,见 `inboundThreadAppId`;加键会改对外契约,node 的形状判据当场会红)。
|
|
197
204
|
* @param {number} userId
|
|
198
205
|
* @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
|
|
199
206
|
* @returns {{configured: boolean, thread: boolean}}
|
|
@@ -207,14 +214,28 @@ export function inboundStatus(userId, options = {}) {
|
|
|
207
214
|
};
|
|
208
215
|
}
|
|
209
216
|
|
|
217
|
+
/**
|
|
218
|
+
* **拉起入站线程时**用的 app_id(对齐 `feishu.inbound_thread_app_id`,M10 2026-10-17):
|
|
219
|
+
* 换凭据后旧连接仍在跑、只有重启站点才换 ⇒ doctor 靠它判「线程绑的应用 ≠ 配置里的应用」
|
|
220
|
+
* 的假绿。空串 = 本进程没为该用户拉过线程 ⇒ 调用方不许据此告警。
|
|
221
|
+
* 刻意**不并进 `inboundStatus`**:那是 `GET /api/me/feishu-cfg` 的响应子对象。
|
|
222
|
+
* @param {number} userId
|
|
223
|
+
* @returns {string}
|
|
224
|
+
*/
|
|
225
|
+
export function inboundThreadAppId(userId) {
|
|
226
|
+
return INBOUND_APP_IDS.get(userId) || '';
|
|
227
|
+
}
|
|
228
|
+
|
|
210
229
|
/**
|
|
211
230
|
* 【M7.5 接缝】登记某用户的入站线程(对齐 `feishu._WS_THREADS[user_id] = t`,feishu.py:1643)。
|
|
212
231
|
* 值的契约:`{ isAlive(): boolean }`(与 `threading.Thread` 同形)。幂等覆盖,不校验重复。
|
|
213
232
|
* @param {number} userId
|
|
214
233
|
* @param {{isAlive: () => boolean}} thread
|
|
234
|
+
* @param {string} [appId] 拉起该线程时用的 app_id(对齐 `feishu._WS_APP_ID[user_id]`)
|
|
215
235
|
*/
|
|
216
|
-
export function setInboundThread(userId, thread) {
|
|
236
|
+
export function setInboundThread(userId, thread, appId = '') {
|
|
217
237
|
INBOUND_THREADS.set(userId, thread);
|
|
238
|
+
INBOUND_APP_IDS.set(userId, appId || '');
|
|
218
239
|
}
|
|
219
240
|
|
|
220
241
|
/**
|
|
@@ -225,4 +246,17 @@ export function setInboundThread(userId, thread) {
|
|
|
225
246
|
*/
|
|
226
247
|
export function clearInboundThread(userId) {
|
|
227
248
|
INBOUND_THREADS.delete(userId);
|
|
249
|
+
INBOUND_APP_IDS.delete(userId);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* 【M11 接缝】取某用户当前登记的入站 runner(**内部用**:M11 起要在进程内停连接,
|
|
254
|
+
* `isAlive()` 不够——得拿到 runner 背后的 ws 客户端,见 `feishu_inbound.js:stopInboundFor`)。
|
|
255
|
+
* Python 侧没有对应函数:那边直接读模块级 `_WS_THREADS`/`_WS_CLIENTS` 两个字典。
|
|
256
|
+
* **不并进 `inboundStatus`**(那是 `GET /api/me/feishu-cfg` 的响应子对象,形状只许两键)。
|
|
257
|
+
* @param {number} userId
|
|
258
|
+
* @returns {{isAlive: () => boolean}|null}
|
|
259
|
+
*/
|
|
260
|
+
export function inboundRunner(userId) {
|
|
261
|
+
return INBOUND_THREADS.get(userId) || null;
|
|
228
262
|
}
|