@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.
@@ -62,11 +62,14 @@
62
62
  * `resetInboundState()` / `msgSeenSize()` / `evtSeenSize()` / `mqStageSize()` —— 只为单测
63
63
  * 能确定地构造与观察进程内态;产品路径不调用。
64
64
  */
65
+ import crypto from 'node:crypto';
65
66
  import {
66
67
  MQ_MAX_BUCKETS, MQ_TTL, NO_REMOTE_ANSWER_HINT,
67
68
  } from './feishu.js';
68
- import { appConfig, inboundStatus, setInboundThread } from './feishu_cfg.js';
69
- import { _LAST_EVENT_TS } from './feishu_provision.js';
69
+ import { appConfig, inboundRunner, inboundStatus, inboundThreadAppId,
70
+ setInboundThread } from './feishu_cfg.js';
71
+ import { _LAST_EVENT_TS, doctor, provisionApply,
72
+ provisionPublish } from './feishu_provision.js';
70
73
  import { FeishuRestError } from './feishu_rest.js';
71
74
  import { PY_WS } from './lib.js';
72
75
  import { FeishuWsClient } from './feishu_ws.js';
@@ -76,6 +79,9 @@ import * as feishuMod from './feishu.js';
76
79
  import * as restMod from './feishu_rest.js';
77
80
  import * as slashMod from './feishu_slash.js';
78
81
 
82
+ /** M11 慢动作依赖(自检/补齐配置/提交发布):**可注入**以便单测隔离网络 */
83
+ const PROVISION_MOD = Object.freeze({ doctor, provisionApply, provisionPublish });
84
+
79
85
  /** Python `\s` 的字符类**内部**源码(从 `lib.js:PY_WS` 反解,保证与 `lib.js` 同表) */
80
86
  const PY_SPACE_SRC = PY_WS.slice(1, -1);
81
87
  const PY_SPLIT_WS = new RegExp(`[${PY_SPACE_SRC}]+`, 'u');
@@ -421,6 +427,9 @@ function dep(options = {}) {
421
427
  conv: options.conv || lazyConv(),
422
428
  slash: options.slash || slashMod,
423
429
  feishu: options.feishu || feishuMod,
430
+ provision: options.provision || PROVISION_MOD,
431
+ // M11:卡片动作里的「停旧起新」可在单测里替换(默认 = 本文件真实现)
432
+ restart: options.restart || restartInboundFor,
424
433
  now: options.now || (() => Date.now() / 1000),
425
434
  log: options.log || defaultLog,
426
435
  env: options.env || process.env,
@@ -433,12 +442,14 @@ function dep(options = {}) {
433
442
 
434
443
  /**
435
444
  * 未识别文本 / `help` 意图 / 未知看板动作的兜底回复。
436
- * **源码 28 行、求值后 21 行**(1596+1597+1598 / 1600+1601 / 1604+1605 三处相邻字面量隐式
437
- * 拼接)——本常量按**求值后**形态书写,单测用 oracle 现算的 utf-8 sha256 锚住。
445
+ * **源码 29 行、求值后 22 行**(1596+1597+1598 / 1600+1601 / 1604+1605 三处相邻字面量隐式
446
+ * 拼接;M11 增「· 接入」一行)——本常量按**求值后**形态书写,单测用 oracle 现算的
447
+ * utf-8 sha256 锚住。
438
448
  */
439
449
  export const HELP_TEXT = 'Touchstone 指令(单聊可用):\n'
440
450
  + '直接发普通文字 = 与当前会话对话(未绑定会在默认项目自动新建)。\n'
441
451
  + '· 帮助 — 本说明\n'
452
+ + '· 接入 — 飞书接入操作卡片(换应用/重连/绑定码/自检/补齐/发布)\n'
442
453
  + '· 绑定 <码> — 绑定站点账号(绑定码在站点侧栏「设置」→ 飞书设置 生成)\n'
443
454
  + '· 解绑 — 解除绑定\n'
444
455
  + '· 当前 — 查看默认项目与当前会话\n'
@@ -469,6 +480,7 @@ export const HELP_TEXT = 'Touchstone 指令(单聊可用):\n'
469
480
  */
470
481
  export const INTENT_RULES = Object.freeze([
471
482
  Object.freeze(['help', '^(?:帮助|help|/help)$']),
483
+ Object.freeze(['setup', '^(?:接入)$']),
472
484
  Object.freeze(['bind', '^(?:绑定|/bind)\\s*([A-Za-z0-9]{4,8})$']),
473
485
  Object.freeze(['unbind', '^(?:解绑|/unbind)$']),
474
486
  Object.freeze(['default_project', '^(?:默认项目|/project)\\s+(\\S+)$']),
@@ -521,6 +533,38 @@ const EVT_SEEN_MAX = 512;
521
533
  /** `_MQ_STAGE`(2109):`(卡号:int, qid:str) -> {n, vals:Map<no,val>, at}`(桶键见 `mqKey`) */
522
534
  const MQ_STAGE = new Map();
523
535
 
536
+ // ---- M11(2026-10-17):可停长连接 + 接入卡片 nonce(对齐 feishu.py 的 `_WS_CLIENTS`/`_SETUP_NONCES`)
537
+
538
+ /** `_WS_CLIENTS`(M11):`user_id -> FeishuWsClient`,停连接要拿到客户端本体(停完即摘) */
539
+ export const _WS_CLIENTS = new Map();
540
+ /** 停连接时等 runner 收口的步长/上限(毫秒);实测 close() 在毫秒级返回 */
541
+ const WS_STOP_POLL_MS = 20;
542
+ export const WS_STOP_TIMEOUT_MS = 8000;
543
+ /** `_SETUP_NONCES`(M11):`nonce -> [user_id, expires(秒)]`;进程内存态、10 分钟、绑定用户 */
544
+ export const _SETUP_NONCES = new Map();
545
+ export const SETUP_NONCE_TTL = 600;
546
+
547
+ /** `feishu._setup_nonce`(M11):登记一个按钮 nonce(顺带清过期项)。 */
548
+ export function setupNonce(userId) {
549
+ const now = Date.now() / 1000;
550
+ for (const [key, item] of [..._SETUP_NONCES]) {
551
+ if (now > item[1]) _SETUP_NONCES.delete(key);
552
+ }
553
+ const nonce = crypto.randomBytes(8).toString('hex'); // Python secrets.token_hex(8)
554
+ _SETUP_NONCES.set(nonce, [pyInt(userId), now + SETUP_NONCE_TTL]);
555
+ return nonce;
556
+ }
557
+
558
+ /** Node 专有测试缝:清空 M11 进程内态(产品路径不调用;对齐 oracle「重启即丢」) */
559
+ export function resetSetupState() {
560
+ _SETUP_NONCES.clear();
561
+ }
562
+
563
+ /** Python `time.sleep(s)` 的异步等价(只用于停连接的有界等待/测试) */
564
+ function sleepMs(ms) {
565
+ return new Promise((resolve) => { setTimeout(resolve, ms); });
566
+ }
567
+
524
568
  /** LRU 记一条:命中 ⇒ true(**方向与直觉相反**:oracle 的调用点写作 `if ..._seen(x): return`) */
525
569
  function seenPush(table, max, key) {
526
570
  if (table.has(key)) return true;
@@ -1321,6 +1365,15 @@ export async function handleMessageEvent(evt, cfg = null, options = {}) {
1321
1365
  await d.rest.restReply(mid, UNBOUND_DM_MSG, cfg);
1322
1366
  return;
1323
1367
  }
1368
+ // M11「接入」:发一张可点的飞书接入操作卡片(换应用/重连/绑定码/自检/补齐/发布),
1369
+ // 用**当前这条连接**的应用凭据发——卡片按钮回调会回到同一条连接
1370
+ if (pyTruthy(intent) && dget(intent, 'action') === 'setup') {
1371
+ const [ok, err] = await sendSetupCard(requireKey(binding, 'user_id'), cfg, options);
1372
+ await d.rest.restReply(mid, pyTruthy(ok)
1373
+ ? '🛠 已发送「飞书接入操作卡片」,请在卡片上操作。'
1374
+ : `卡片发送失败:${pyStr(err)}`, cfg);
1375
+ return;
1376
+ }
1324
1377
  if (intent === null) {
1325
1378
  let reply;
1326
1379
  if (dget(evt, 'chat_type') !== 'group' && pyTruthy(await d.conv.enabled(options))) {
@@ -1389,6 +1442,9 @@ export async function onCardAction(data, cfg, options = {}) {
1389
1442
  const t = pyStr(pyOr(dget(value, 't'), ''));
1390
1443
  const key = pyStr(pyOr(dget(value, 'c'), ''));
1391
1444
  if (t === 'sc') return await onSlashPermAction(sender, value, cfg, options);
1445
+ // M11 接入操作卡片(t=fs):独立于看板/通用对话卡片流;身份 + nonce 双复核在
1446
+ // onSetupCardAction 里(与 oracle 的 `_on_card_action` 1909 附近同构)
1447
+ if (t === 'fs') return await onSetupCardAction(sender, value, cfg, options);
1392
1448
  if (t === 'dp' || t === 'ss') {
1393
1449
  const binding = d.db.getFeishuBindingByOpen(sender, options);
1394
1450
  if (binding === null || binding === undefined) {
@@ -1514,7 +1570,9 @@ export function startInboundFor(userId, options = {}) {
1514
1570
  return false;
1515
1571
  }
1516
1572
  const runner = makeInboundRunner(userId, cfg, options); // ④
1517
- setInboundThread(userId, runner); // ⑤(在 start 之前)
1573
+ // ⑤(在 start 之前)——第三个参数记「这条线程绑的是哪个应用」(对齐 `feishu._WS_APP_ID`,
1574
+ // M10:换凭据后线程仍在跑,doctor 靠它识别「线程 app ≠ 配置 app」的假绿)
1575
+ setInboundThread(userId, runner, cfg.app_id);
1518
1576
  runner.start(); // ⑥
1519
1577
  return true;
1520
1578
  }
@@ -1624,14 +1682,349 @@ export async function wsRun(userId, cfg, options = {}) {
1624
1682
  const wsDebug = pyTruthy(dget(d.env, 'TS_FEISHU_WS_DEBUG')); // 非空即真(连 "0" 也算)
1625
1683
  const client = makeWsClient(cfg, onEventPayload, onCardPayload,
1626
1684
  wsDebug ? 'debug' : 'error', options);
1685
+ _WS_CLIENTS.set(userId, client); // M11:停连接要拿到客户端本体
1627
1686
  d.log(`feishu inbound: 用户 ${userId} 连接长连接网关(app_id=${requireKey(cfg, 'app_id')})…`);
1628
1687
  try {
1629
- await client.start();
1688
+ await client.start(); // M11:close() ⇒ 这里 resolve(不再是永不返回)
1630
1689
  } catch (e) {
1631
1690
  d.log(`feishu inbound: 用户 ${userId} 长连接异常退出: ${errorRepr(e)}(重启站点重试)`);
1691
+ } finally {
1692
+ _WS_CLIENTS.delete(userId);
1693
+ }
1694
+ }
1695
+
1696
+ /**
1697
+ * `feishu.stop_inbound_for`(M11):停掉该用户的入站长连接,返回是否真的停了一条在跑的。
1698
+ * 与 oracle 的差异在**机制**不在语义:Python 侧要收口 SDK 的模块级 event loop(见
1699
+ * `feishu.py:_ws_run` 的 per-run loop 说明),Node 侧 R1 的 `FeishuWsClient` 自带干净
1700
+ * `close()`(`feishu_ws.js` 文件头「追加能力 close()」)⇒ 直接 close 再等 runner 收口。
1701
+ * 幂等:没在跑 ⇒ `false`、零副作用。
1702
+ * @param {number} userId
1703
+ * @param {{dbmod?: object, rest?: object, log?: Function, env?: NodeJS.ProcessEnv}} [options]
1704
+ * @returns {Promise<boolean>}
1705
+ */
1706
+ export async function stopInboundFor(userId, options = {}) {
1707
+ const d = dep(options);
1708
+ const runner = inboundRunner(userId);
1709
+ if (runner === null || runner === undefined || !pyTruthy(runner.isAlive())) return false;
1710
+ const client = _WS_CLIENTS.get(userId);
1711
+ if (client === null || client === undefined) {
1712
+ // runner 已登记但 wsRun 还没走到 makeWsClient:有界等一小段(正常路径是同步建好的)
1713
+ const deadline = Date.now() + 1000;
1714
+ while (Date.now() < deadline && !_WS_CLIENTS.has(userId)) {
1715
+ await sleepMs(WS_STOP_POLL_MS);
1716
+ }
1717
+ }
1718
+ const live = _WS_CLIENTS.get(userId);
1719
+ if (live !== null && live !== undefined && typeof live.close === 'function') {
1720
+ try {
1721
+ await live.close(); // close() 会 resolve start() 的 promise ⇒ runner 收口
1722
+ } catch (e) {
1723
+ d.log(`feishu inbound: 用户 ${userId} 停止长连接异常: ${errorRepr(e)}`);
1724
+ }
1725
+ }
1726
+ const deadline = Date.now() + WS_STOP_TIMEOUT_MS;
1727
+ while (pyTruthy(runner.isAlive()) && Date.now() < deadline) {
1728
+ await sleepMs(WS_STOP_POLL_MS);
1729
+ }
1730
+ return !pyTruthy(runner.isAlive());
1731
+ }
1732
+
1733
+ /**
1734
+ * `feishu.restart_inbound_for`(M11):停旧 → 用**当前库里**的凭据起新。
1735
+ * 返回是否起了新连接(旧线程没停下来时 `startInboundFor` 会短路 ⇒ false,不会并存两条)。
1736
+ * @param {number} userId
1737
+ * @param {object} [options]
1738
+ * @returns {Promise<boolean>}
1739
+ */
1740
+ export async function restartInboundFor(userId, options = {}) {
1741
+ await stopInboundFor(userId, options);
1742
+ return startInboundFor(userId, options);
1743
+ }
1744
+
1745
+ // ==========================================================================
1746
+ // M11 接入操作卡片(`feishu.py` 同名节):换凭据免重启的 DM 按钮面
1747
+ // ==========================================================================
1748
+
1749
+ /** Python `_button` 闭包的等价物:六个按钮共用同一个 value 形状 `{t=fs, a=动作, u, n}` */
1750
+ function setupButton(uid, nonce, label, action, style = 'default') {
1751
+ return {
1752
+ tag: 'button',
1753
+ type: style,
1754
+ text: { tag: 'plain_text', content: label },
1755
+ behaviors: [{ type: 'callback', value: { t: 'fs', a: action, u: uid, n: nonce } }],
1756
+ };
1757
+ }
1758
+
1759
+ /**
1760
+ * `feishu.setup_card`(M11):飞书接入操作卡片(schema 2.0,DM 单聊)。
1761
+ * **纯函数**:给定入参 ⇒ 确定 JSON(两侧后端逐字节对拍,见
1762
+ * `tools/parity/cases-mod/feishu_setup_card.json`)。键序照 Python dict 插入序。
1763
+ * @param {number|string} userId 站点用户 id
1764
+ * @param {string} appId 配置里的应用(空=未配置)
1765
+ * @param {string} threadApp 入站长连接**当前绑的**应用(空=连接未运行)
1766
+ * @param {string} nonce 按钮防伪令牌(回调时校验 uid+nonce)
1767
+ */
1768
+ export function setupCard(userId, appId, threadApp, nonce) {
1769
+ const app = pyStr(pyOr(appId, ''));
1770
+ const thread = pyStr(pyOr(threadApp, ''));
1771
+ const switching = pyTruthy(app) && pyTruthy(thread) && app !== thread;
1772
+ const uid = pyInt(userId);
1773
+ const elements = [{
1774
+ tag: 'markdown',
1775
+ content: '**当前应用**:' + (pyTruthy(app) ? '`' + app + '`' : '(未配置)') + '\n'
1776
+ + '**入站长连接**:' + (pyTruthy(thread) ? '运行中 · 绑在 `' + thread + '`' : '未运行'),
1777
+ }];
1778
+ if (switching) {
1779
+ elements.push({
1780
+ tag: 'markdown',
1781
+ content: `检测到应用凭据已换为 \`${app}\`,但长连接还绑在旧应用 `
1782
+ + `\`${thread}\` 上。点「换用新应用」:我会先把新绑定码发到本会话,`
1783
+ + '再用新应用重连——之后在新机器人单聊里回复「绑定 <码>」即可。',
1784
+ });
1785
+ elements.push(setupButton(uid, nonce, '🔁 换用新应用', 'switch', 'primary'));
1786
+ } else {
1787
+ elements.push({
1788
+ tag: 'markdown',
1789
+ content: '需要时点下面的按钮;换应用后我会自动发一张新的确认卡片。',
1790
+ });
1791
+ elements.push(setupButton(uid, nonce, '🔄 重连入站', 'restart', 'primary'));
1792
+ }
1793
+ elements.push(setupButton(uid, nonce, '🔑 生成绑定码', 'bindcode'));
1794
+ elements.push(setupButton(uid, nonce, '🩺 配置自检', 'doctor'));
1795
+ elements.push(setupButton(uid, nonce, '🧩 补齐配置', 'apply'));
1796
+ elements.push(setupButton(uid, nonce, '🚀 提交发布', 'publish'));
1797
+ elements.push({ tag: 'markdown', content: '卡片 10 分钟内有效;过期后发「接入」重新获取。' });
1798
+ return {
1799
+ schema: '2.0',
1800
+ header: {
1801
+ title: {
1802
+ tag: 'plain_text',
1803
+ content: switching ? '🔁 飞书应用已变更' : '🛠 飞书接入操作',
1804
+ },
1805
+ template: switching ? 'orange' : 'blue',
1806
+ },
1807
+ body: { elements },
1808
+ };
1809
+ }
1810
+
1811
+ /**
1812
+ * `feishu.send_setup_card`(M11):给该用户 DM 发接入卡片。返回 `[ok, err]`。
1813
+ * `cfg` 缺省=当前配置;**换应用自动发送时必须传旧应用的 cfg**(绑定行的 open_id
1814
+ * 属于旧应用,新凭据发不进这个单聊)。
1815
+ */
1816
+ export async function sendSetupCard(userId, cfg = null, options = {}) {
1817
+ const d = dep(options);
1818
+ const sendCfg = (cfg === null || cfg === undefined) ? appConfig(userId, options) : cfg;
1819
+ if (sendCfg === null || sendCfg === undefined) {
1820
+ return [false, '未配置应用凭据(App ID / App Secret),先保存配置再发卡片'];
1821
+ }
1822
+ const binding = d.db.getFeishuBindingByUser(userId, options);
1823
+ if (binding === null || binding === undefined) {
1824
+ return [false, '尚未绑定飞书账号:设置页生成绑定码后在机器人单聊回复「绑定 <码>」'];
1825
+ }
1826
+ const cur = appConfig(userId, options) || {};
1827
+ const threadApp = pyTruthy(inboundStatus(userId, options).thread)
1828
+ ? inboundThreadAppId(userId) : '';
1829
+ const card = setupCard(userId, pyOr(dget(cur, 'app_id'), ''), threadApp, setupNonce(userId));
1830
+ try {
1831
+ await d.rest.restSendCard(binding.open_id, card, sendCfg);
1832
+ return [true, ''];
1833
+ } catch (e) {
1834
+ if (!(e instanceof FeishuRestError)) throw e;
1835
+ return [false, `卡片发送失败:${pyStr(e.message)}`];
1632
1836
  }
1633
1837
  }
1634
1838
 
1839
+ /**
1840
+ * `feishu.maybe_send_switch_card`(M11):换应用凭据后自动发「确认切换」卡片。
1841
+ * 用**旧凭据**发(绑定 open_id 是旧应用下的标识)。条件:旧连接在跑 + 配置应用与旧
1842
+ * 连接不同 + 有绑定行。绝不抛出(worker/保存后后台调用),失败只打一行日志。
1843
+ * @returns {Promise<boolean>} 是否真的发出
1844
+ */
1845
+ export async function maybeSendSwitchCard(userId, oldCfg, options = {}) {
1846
+ const d = dep(options);
1847
+ try {
1848
+ const oldApp = pyStr(pyOr(dget(oldCfg, 'app_id'), ''));
1849
+ const newApp = pyStr(pyOr(dget(appConfig(userId, options) || {}, 'app_id'), ''));
1850
+ if (!pyTruthy(oldApp) || !pyTruthy(newApp) || oldApp === newApp) return false;
1851
+ if (!pyTruthy(inboundStatus(userId, options).thread)) return false;
1852
+ if (inboundThreadAppId(userId) === newApp) return false;
1853
+ const binding = d.db.getFeishuBindingByUser(userId, options);
1854
+ if (binding === null || binding === undefined) return false;
1855
+ const card = setupCard(userId, newApp, oldApp, setupNonce(userId));
1856
+ await d.rest.restSendCard(binding.open_id, card, oldCfg);
1857
+ return true;
1858
+ } catch (e) {
1859
+ d.log(`feishu provision: 用户 ${userId} 换应用确认卡片发送失败: ${errorRepr(e)}`);
1860
+ return false;
1861
+ }
1862
+ }
1863
+
1864
+ /**
1865
+ * `feishu._setup_switch`(M11):「换用新应用」——生成新绑定码 → 用**旧应用** DM 发出
1866
+ * → 停旧连接、用新凭据重连。顺序是设计要点:码必须先到用户手上(旧应用通道),
1867
+ * 再断旧连接(断完就发不出了)。返回 `[toast, dmText]`;dmText 空=不再重复发。
1868
+ */
1869
+ async function setupSwitchAction(userId, sender, cfg, options = {}) {
1870
+ const d = dep(options);
1871
+ const cur = appConfig(userId, options);
1872
+ const newApp = pyStr(pyOr(dget(cur, 'app_id'), ''));
1873
+ const oldApp = pyStr(pyOr(dget(cfg, 'app_id'), ''));
1874
+ if (cur === null || cur === undefined || !pyTruthy(newApp)) {
1875
+ return [toast('error', '新应用凭据不完整'),
1876
+ '❌ 应用凭据不完整:先在设置页保存新应用的 App ID / App Secret,再点本按钮。'];
1877
+ }
1878
+ if (oldApp === newApp) { // 同应用(只换了 secret):直接重连,绑定关系不变
1879
+ if (await d.restart(userId, options)) {
1880
+ return [toast('success', '已重连'),
1881
+ `🔄 已用当前应用 \`${newApp}\` 重新建立长连接,绑定关系不变。`];
1882
+ }
1883
+ return [toast('error', '重连失败,看私聊'),
1884
+ '❌ 重连失败:旧连接未在超时内退出;稍后再点一次,仍不行请重启站点。'];
1885
+ }
1886
+ const code = d.feishu.makeBindCode(userId);
1887
+ const text = `🔁 已切换到新应用 \`${newApp}\`。\n`
1888
+ + `**新的绑定码:${code}**(10 分钟内有效)\n`
1889
+ + '本(旧)机器人的连接马上断开;请到新机器人单聊里回复:\n'
1890
+ + `绑定 ${code}`;
1891
+ try {
1892
+ await d.rest.restSendText(sender, text, cfg);
1893
+ } catch (e) {
1894
+ if (!(e instanceof FeishuRestError)) throw e;
1895
+ // 码没送到就不动连接(否则用户既进不去新机器人、旧机器人也断了)
1896
+ return [toast('error', '绑定码发送失败'),
1897
+ `❌ 绑定码没能发进本会话(${pyStr(e.message)});重连未执行,可重试本按钮。`];
1898
+ }
1899
+ if (await d.restart(userId, options)) {
1900
+ return [toast('success', '已切换,去新机器人绑定'), ''];
1901
+ }
1902
+ return [toast('error', '已换应用但重连失败,看私聊'),
1903
+ `⚠️ 新应用 \`${newApp}\` 重连失败(旧连接未退出);绑定码 ${code} 仍有效,`
1904
+ + '可稍后再点本按钮,或重启站点。'];
1905
+ }
1906
+
1907
+ /** `feishu._setup_restart`(M11):「重连入站」——停旧连接、用当前凭据重连。 */
1908
+ async function setupRestartAction(userId, options = {}) {
1909
+ const cur = appConfig(userId, options);
1910
+ if (cur === null || cur === undefined) {
1911
+ return [toast('error', '应用凭据未配置'),
1912
+ '❌ 应用凭据未配置(App ID / App Secret):先在设置页保存,再点本按钮。'];
1913
+ }
1914
+ const appId = pyStr(pyOr(dget(cur, 'app_id'), ''));
1915
+ if (await dep(options).restart(userId, options)) {
1916
+ return [toast('success', '已重连'),
1917
+ `🔄 已用当前应用 \`${appId}\` 重新建立长连接。`];
1918
+ }
1919
+ return [toast('error', '重连失败,看私聊'),
1920
+ '❌ 重连失败:旧连接未在超时内退出,稍后再点一次;仍不行请重启站点。'];
1921
+ }
1922
+
1923
+ /** `feishu._setup_bindcode`(M11):「生成绑定码」——发新码 + 两条使用指引。 */
1924
+ function setupBindcodeAction(userId, options = {}) {
1925
+ const code = dep(options).feishu.makeBindCode(userId);
1926
+ return [toast('success', '绑定码已私聊发你'),
1927
+ `🔑 新的绑定码:${code}(10 分钟内有效)\n`
1928
+ + '· 未绑定:在本机器人单聊回复「绑定 <码>」\n'
1929
+ + '· 已绑定换号:先发「解绑」,再发「绑定 <码>」'];
1930
+ }
1931
+
1932
+ /**
1933
+ * `feishu._setup_slow_action`(M11):慢动作(自检/补齐配置/提交发布)后台执行,
1934
+ * 结果 DM 回执。卡片回调要尽快 ACK,这几项是多次 REST(2~5s)⇒ 不占回调。
1935
+ */
1936
+ export async function setupSlowAction(userId, sender, action, cfg, options = {}) {
1937
+ const d = dep(options);
1938
+ try {
1939
+ let text;
1940
+ if (action === 'doctor') {
1941
+ const out = await d.provision.doctor(userId, false, options);
1942
+ const summary = pyOr(dget(out, 'summary'), {});
1943
+ const bad = pyOr(dget(out, 'items'), []).filter((it) => {
1944
+ const state = dget(it, 'state');
1945
+ return state === 'fail' || state === 'warn';
1946
+ });
1947
+ const lines = [`🩺 配置自检:ok ${pyOr(dget(summary, 'ok'), 0)} / `
1948
+ + `fail ${pyOr(dget(summary, 'fail'), 0)} / `
1949
+ + `warn ${pyOr(dget(summary, 'warn'), 0)} / `
1950
+ + `skip ${pyOr(dget(summary, 'skip'), 0)}`];
1951
+ for (const it of bad) {
1952
+ const hint = dget(it, 'hint');
1953
+ lines.push(`· ${pyStr(pyOr(dget(it, 'label'), ''))}:`
1954
+ + `${pyStr(pyOr(dget(it, 'detail'), ''))}`
1955
+ + (pyTruthy(hint) ? `(${pyStr(hint)})` : ''));
1956
+ }
1957
+ text = lines.join('\n');
1958
+ } else if (action === 'apply') {
1959
+ const out = await d.provision.provisionApply(userId, options);
1960
+ text = pyTruthy(dget(out, 'ok'))
1961
+ ? '🧩 配置补齐成功:机器人能力 / 权限 / 长连接订阅 / 事件 / 回调已写入(需发布新版本生效)。'
1962
+ : `❌ 配置补齐失败:${pyStr(pyOr(dget(out, 'error'), '未知错误'))}`;
1963
+ } else {
1964
+ const out = await d.provision.provisionPublish(userId, options);
1965
+ text = pyTruthy(dget(out, 'ok'))
1966
+ ? `🚀 已提交发布版本 ${pyStr(pyOr(dget(out, 'version'), ''))};仍需租户管理员审批后线上生效。`
1967
+ : `❌ 提交发布失败:${pyStr(pyOr(dget(out, 'error'), '未知错误'))}`;
1968
+ }
1969
+ await dmText(sender, text, cfg, options);
1970
+ } catch (e) {
1971
+ d.log(`feishu inbound: 接入卡片后台动作异常: ${errorRepr(e)}`);
1972
+ }
1973
+ }
1974
+
1975
+ /**
1976
+ * `feishu._on_setup_card_action`(M11):接入卡片按钮回调——
1977
+ * open_id→binding + nonce 双重复核 → 动作分发。`cfg` = 点击到达那条连接的应用凭据。
1978
+ */
1979
+ export async function onSetupCardAction(sender, value, cfg, options = {}) {
1980
+ const d = dep(options);
1981
+ const binding = d.db.getFeishuBindingByOpen(sender, options);
1982
+ if (binding === null || binding === undefined) {
1983
+ await dmText(sender, UNBOUND_DM_CARD, cfg, options);
1984
+ return toast('error', '尚未绑定站点账号');
1985
+ }
1986
+ const userId = requireKey(binding, 'user_id');
1987
+ const uid = pyInt(pyOr(dget(value, 'u'), 0));
1988
+ if (pyTruthy(uid) && uid !== pyInt(userId)) {
1989
+ return toast('error', '该卡片不属于当前账号'); // 防串号
1990
+ }
1991
+ const nonce = pyStr(pyOr(dget(value, 'n'), ''));
1992
+ const item = _SETUP_NONCES.get(nonce);
1993
+ if (item === undefined || item === null || item[0] !== pyInt(userId)) {
1994
+ return toast('error', '卡片已失效,发「接入」重新获取');
1995
+ }
1996
+ if (Date.now() / 1000 > item[1]) {
1997
+ _SETUP_NONCES.delete(nonce);
1998
+ return toast('error', '卡片已过期,发「接入」重新获取');
1999
+ }
2000
+ const action = pyStr(pyOr(dget(value, 'a'), ''));
2001
+ try {
2002
+ let out;
2003
+ if (action === 'switch') {
2004
+ out = await setupSwitchAction(userId, sender, cfg, options);
2005
+ } else if (action === 'restart') {
2006
+ out = await setupRestartAction(userId, options);
2007
+ } else if (action === 'bindcode') {
2008
+ out = setupBindcodeAction(userId, options);
2009
+ } else if (action === 'doctor' || action === 'apply' || action === 'publish') {
2010
+ // 慢动作:后台执行 + 立即 toast(避免卡片回调 ACK 超时)
2011
+ void setupSlowAction(userId, sender, action, cfg, options).catch((e) => {
2012
+ d.log(`feishu inbound: 接入卡片后台动作异常: ${errorRepr(e)}`);
2013
+ });
2014
+ return toast('success', '已收到,结果稍后私聊发你');
2015
+ } else {
2016
+ return null;
2017
+ }
2018
+ const [toastOut, text] = out;
2019
+ if (pyTruthy(text)) await dmText(sender, text, cfg, options);
2020
+ return toastOut;
2021
+ } catch (e) {
2022
+ d.log(`feishu inbound: 接入卡片动作异常: ${errorRepr(e)}`);
2023
+ return toast('error', '操作失败,详情看站点日志');
2024
+ }
2025
+ }
2026
+
2027
+
1635
2028
  // --------------------------------------------------------------------------
1636
2029
  // snake_case 别名(照 `board.js` 的 Python 名别名先例:零成本、便于对拍量具按 oracle 名发现)
1637
2030
  // --------------------------------------------------------------------------
@@ -1672,3 +2065,13 @@ export const _make_ws_client = makeWsClient;
1672
2065
  export const _ws_run = wsRun;
1673
2066
  export const _start_inbound_for = startInboundFor;
1674
2067
  export const _start_inbound_all = startInboundAll;
2068
+ // ---- M11:换凭据免重启 + 接入操作卡片(snake_case 别名照既有先例,便于按 oracle 名对拍)
2069
+ export const _stop_inbound_for = stopInboundFor;
2070
+ export const _restart_inbound_for = restartInboundFor;
2071
+ export const _setup_nonce = setupNonce;
2072
+ export const _setup_card = setupCard;
2073
+ export const _send_setup_card = sendSetupCard;
2074
+ export const _maybe_send_switch_card = maybeSendSwitchCard;
2075
+ export const _setup_slow_action = setupSlowAction;
2076
+ export const _on_setup_card_action = onSetupCardAction;
2077
+ export const _setup_bindcode = setupBindcodeAction;
@@ -145,7 +145,7 @@ export async function verifyCredentials(userId, options = {}) {
145
145
  // ══ 追加段的 import:**写在末尾**(文件开头那段 import 属「已冻结区」,一字不动)═════
146
146
  // ESM 的 `import` 声明在模块顶层**任意位置**都合法(模块求值前提升),所以这里再写同源 import
147
147
  // 既不碰冻结区、也不会产生第二份模块实例。
148
- import { inboundStatus, userConfig } from './feishu_cfg.js';
148
+ import { inboundStatus, inboundThreadAppId, userConfig } from './feishu_cfg.js';
149
149
  import { restSendText } from './feishu_rest.js';
150
150
  import { _needScope, slashStatus } from './feishu_slash.js';
151
151
  import { getFeishuBindingByUser, setFeishuUserCfg } from './db.js';
@@ -154,7 +154,7 @@ import {
154
154
  _provisionAddons, createCancelEvent, makeDeviceFlow,
155
155
  } from './feishu_device.js';
156
156
  import { _TOKEN_CACHE } from './feishu_rest.js';
157
- import { startInboundFor as inboundStartFor } from './feishu_inbound.js';
157
+ import { maybeSendSwitchCard, startInboundFor as inboundStartFor } from './feishu_inbound.js';
158
158
  // `provisionSupported()` 的能力判据之一(与 `feishu_device.js` 里做 addons 编码的是同一个
159
159
  // 内置模块;「本运行时能否跑 RFC 8628 客户端」= fetch + gzip 两件)。
160
160
  import { gzipSync } from 'node:zlib';
@@ -382,8 +382,10 @@ async function _provisionWorker(userId, cancel, options = {}) {
382
382
  if (!isCurrentFlow()) return; // 旧流程:凭据不再落库(新流程会自己拿一份)
383
383
 
384
384
  // 合并写库:保留既有 enabled / webhook / base_url 等字段,只换应用凭据
385
+ let oldCfg;
385
386
  try {
386
387
  const cfg = { ...(userConfig(userId, options) || {}) };
388
+ oldCfg = { app_id: cfg.app_id || '', app_secret: cfg.app_secret || '' }; // M11 换应用卡片用
387
389
  cfg.app_id = appId;
388
390
  cfg.app_secret = secret;
389
391
  setFeishuUserCfg(userId, cfg, options);
@@ -395,6 +397,10 @@ async function _provisionWorker(userId, cancel, options = {}) {
395
397
  return;
396
398
  }
397
399
 
400
+ // M11:换应用后旧连接仍在跑(绑定关系也还是旧应用的 open_id)⇒ 用**旧应用**通道发
401
+ // 「确认切换」卡片;用户点按钮后由「先取码、再重连」顺序完成切换(无需重启站点)。
402
+ // 必须在 startInboundFor 之前发:那时旧 runner 还活着、旧 open_id 也还认得。
403
+ await maybeSendSwitchCard(userId, oldCfg, options);
398
404
  try {
399
405
  inboundStartFor(userId, options); // 起长连接(失败只留痕,状态里如实呈现)
400
406
  } catch (error) {
@@ -504,6 +510,33 @@ export function _docItem(key, label, state, detail, hint = '') {
504
510
  return { key, label, state, detail, hint };
505
511
  }
506
512
 
513
+ /**
514
+ * doctor 第三项 `inbound_ws`(M10,2026-10-17 新增一档 warn):线程活着但**绑的不是配置里的
515
+ * 应用** ⇒ 换凭据后没重启站点。这是实测踩过的坑:面板显示「已接入应用 X」、本项也绿,
516
+ * 但消息仍打到旧机器人。文案与 oracle `feishu.doctor` 的同名分支**逐字一致**(两侧各有单测钉住)。
517
+ *
518
+ * 抽成纯函数只为可判:doctor 其余项依赖真网络,端到端测要打桩整套 REST;这一项只取决于
519
+ * 两个入参,纯函数就能把「warn / ok / fail 三档 + 文案」钉死。
520
+ * @param {{thread?: boolean, app_id?: string}} st `{thread: inboundStatus().thread,
521
+ * app_id: inboundThreadAppId()}` —— 两者来自不同访问口,见 feishu_cfg.js 的形状说明
522
+ * @param {object} cfg 该用户的应用配置(读 `app_id`)
523
+ */
524
+ export function inboundWsItem(st, cfg) {
525
+ const thread = pyTruthy(st && st.thread);
526
+ const cfgApp = (cfg && cfg.app_id) || '';
527
+ const threadApp = (st && st.app_id) || '';
528
+ if (thread && cfgApp && threadApp && threadApp !== cfgApp) {
529
+ return _docItem('inbound_ws', '入站长连接(长连接)', 'warn',
530
+ `连接线程仍绑在 ${threadApp}(配置已改为 ${cfgApp})`,
531
+ '在机器人私聊发「接入」点「换用新应用」即可免重启切换;仍不生效再重启站点');
532
+ }
533
+ return _docItem('inbound_ws', '入站长连接(长连接)',
534
+ thread ? 'ok' : 'fail',
535
+ thread ? '连接线程运行中' : '未运行',
536
+ thread ? ''
537
+ : '凭据保存后自动连接;仍不启动看站点日志,并确认事件订阅方式为「长连接」');
538
+ }
539
+
507
540
  /**
508
541
  * 逐项配置体检(`feishu.doctor`(feishu.py:2776-2925)):八项 + summary 计数,**绝不抛出**。
509
542
  *
@@ -571,10 +604,8 @@ export async function doctor(userId, sendProbe = false, options = {}) {
571
604
  if (!hasCreds) {
572
605
  items.push(_docItem('inbound_ws', '入站长连接(长连接)', 'skip', '凭据未配置,跳过', ''));
573
606
  } else {
574
- items.push(_docItem('inbound_ws', '入站长连接(长连接)', pyTruthy(st.thread) ? 'ok' : 'fail',
575
- pyTruthy(st.thread) ? '连接线程运行中' : '未运行',
576
- pyTruthy(st.thread) ? ''
577
- : '凭据保存后自动连接;仍不启动看站点日志,并确认事件订阅方式为「长连接」'));
607
+ // 「线程绑的 app」走独立访问口(不进 `inboundStatus` 的形状,见 feishu_cfg.js 的说明)
608
+ items.push(inboundWsItem({ thread: st.thread, app_id: inboundThreadAppId(userId) }, cfg));
578
609
  }
579
610
 
580
611
  const lastEv = _LAST_EVENT_TS.get(userId);
@@ -94,6 +94,30 @@ export function readPidRecord(pidPath) {
94
94
  }
95
95
  }
96
96
 
97
+ /**
98
+ * 归属记录是否指向「可能活着的别家后端」。**唯一判据源**(writePidRecord 与
99
+ * liveForeignHolder 共用,别再各写一份——2026-10-17 的 Windows 事故就是两处各自判活)。
100
+ *
101
+ * 判据:
102
+ * - 不是自己、pid 合法;
103
+ * - **`backend: "node"` 的记录一律不算**(2026-10-17 真机事故新增):Node 版后端自己也持
104
+ * `<db>.lock` 上的 SQLite 排它锁,并一直持有到进程退出(`locking_mode=EXCLUSIVE`)。
105
+ * 所以**只要本进程已经拿到这把锁**(两个调用点的唯一生产前提),一条 node 记录就不可能
106
+ * 还属于活实例 ⇒ 必然是残留:典型成因是 Windows 上 SIGTERM=TerminateProcess,后端优雅
107
+ * 退出/`release()` 跑不到、`.pid` 不删;pid 随后被无关进程复用。
108
+ * (M1 之前的 Python 实例只持 flock,SQLite 锁看不见它 ⇒ 无 `backend` 键的记录仍要判活。)
109
+ * - 其余记录(Python 版):pid 存活且命令行像本平台后端才算活着。
110
+ */
111
+ export function recordIsLiveForeignHolder(record) {
112
+ if (!record || typeof record !== 'object' || Array.isArray(record)) return false;
113
+ const pid = Number(record.pid);
114
+ if (!Number.isInteger(pid) || pid <= 0 || pid === process.pid) return false;
115
+ if (record.backend === 'node') return false; // 见上:已持 SQLite 锁 ⇒ 本版记录必为残留
116
+ if (!pidAlive(pid)) return false;
117
+ if (!looksLikeBackend(pid)) return false;
118
+ return true;
119
+ }
120
+
97
121
  /**
98
122
  * 写 `.pid` 归属记录(临时文件 + rename 原子替换,对齐 Python `_write_record`)。
99
123
  * 写不进去**不影响运行**(只是失去存量孤儿识别能力):记一条日志返回 false。
@@ -102,12 +126,11 @@ export function writePidRecord(pidPath, record, logger) {
102
126
  const log = typeof logger === 'function' ? logger : () => {};
103
127
  // 绝不覆写「活着的别家后端」的归属记录(2026-10-11 评审 P1):那是对方的排障凭据,
104
128
  // 实测被覆盖并删除过一次(Node 起在同一库上,旧版 Python 实例的 `.pid` 就消失了)。
105
- // 自己写自己(同 pid)允许——重启/重入路径要用。判活 + 命令行像后端,扛 PID 复用。
129
+ // 自己写自己(同 pid)允许——重启/重入路径要用。判据统一走 recordIsLiveForeignHolder
130
+ // (node 记录按「已持锁 ⇒ 必为残留」处理,Windows PID 复用才不会把记录永久钉在原处)。
106
131
  const existing = readPidRecord(pidPath);
107
- const existingPid = existing ? Number(existing.pid) : 0;
108
- if (existingPid > 0 && existingPid !== Number(record.pid)
109
- && pidAlive(existingPid) && looksLikeBackend(existingPid)) {
110
- log(`[lock] 已有活着的后端归属记录 pid=${existingPid},拒绝覆写 ${pidPath}`);
132
+ if (recordIsLiveForeignHolder(existing)) {
133
+ log(`[lock] 已有活着的后端归属记录 pid=${existing.pid},拒绝覆写 ${pidPath}`);
111
134
  return false;
112
135
  }
113
136
  const tmp = `${pidPath}.tmp`;
@@ -216,17 +239,15 @@ export function reapStale({ record, staleWaitMs = STALE_WAIT_MS, logger } = {})
216
239
  * 为什么需要这道额外检查(2026-10-11 评审 P1,**已在生产上实际发生过一次**):Node 只有
217
240
  * SQLite 那道锁,而 M1 之前的 Python 实例只持 flock(Node 看不见)⇒ Node 能起在同一库上,
218
241
  * 实测还覆写并删掉了生产实例的 `.pid`。`looksLikeBackend` 用来扛 PID 复用误判。
242
+ *
243
+ * **调用前提:本进程已拿到 SQLite 排它锁**(`backend:"node"` 记录不算活实例的判据靠它,见
244
+ * `recordIsLiveForeignHolder`;2026-10-17 桌面端 Windows 永久 exit 3 事故的修复点)。
219
245
  * @param {string} pidPath
220
246
  * @returns {object|null}
221
247
  */
222
248
  export function liveForeignHolder(pidPath) {
223
249
  const record = readPidRecord(pidPath);
224
- if (!record) return null;
225
- const pid = Number(record.pid);
226
- if (!Number.isFinite(pid) || pid <= 0 || pid === process.pid) return null;
227
- if (!pidAlive(pid)) return null;
228
- if (!looksLikeBackend(pid)) return null;
229
- return record;
250
+ return recordIsLiveForeignHolder(record) ? record : null;
230
251
  }
231
252
 
232
253
  /** 锁文件不可用(非「被占用」)的拒绝文案:说清现象、危害与两条可行修复路径 */