@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/feishu.py
CHANGED
|
@@ -10,6 +10,7 @@ webhook + 站点地址),入站长连接按用户各自拉起,推送回落
|
|
|
10
10
|
- 参考实现:同机另一飞书机器人项目(同机实证的签名/消息形态)
|
|
11
11
|
"""
|
|
12
12
|
import argparse
|
|
13
|
+
import asyncio
|
|
13
14
|
import base64
|
|
14
15
|
import hashlib
|
|
15
16
|
import hmac
|
|
@@ -1062,13 +1063,24 @@ def app_config(user_id):
|
|
|
1062
1063
|
def inbound_status(user_id):
|
|
1063
1064
|
"""用户入站长连接状态摘要(设置页展示用):configured=凭据齐全;thread=连接
|
|
1064
1065
|
线程是否存活。thread 为 True 仅代表线程在 client.start() 内(SDK 内置重连);
|
|
1065
|
-
连接失败细节看 server.log
|
|
1066
|
+
连接失败细节看 server.log。**形状是 `/api/me/feishu-cfg` 响应的子对象 ⇒ 只许这两键**
|
|
1067
|
+
(「线程绑的哪个应用」不放进这里,见 `inbound_thread_app_id`)。"""
|
|
1066
1068
|
cfg = app_config(user_id)
|
|
1067
1069
|
t = _WS_THREADS.get(user_id)
|
|
1068
1070
|
return {"configured": cfg is not None,
|
|
1069
1071
|
"thread": t is not None and t.is_alive()}
|
|
1070
1072
|
|
|
1071
1073
|
|
|
1074
|
+
def inbound_thread_app_id(user_id):
|
|
1075
|
+
"""**拉起入站线程时**用的 app_id(M10,2026-10-17):换凭据后旧连接会一直在跑,
|
|
1076
|
+
doctor 靠它判「线程绑的应用 ≠ 配置里的应用」的假绿;M11 起这个不一致可由
|
|
1077
|
+
`restart_inbound_for` 在进程内收口(免重启站点)。
|
|
1078
|
+
空串 = 当前进程没为该用户拉过线程(老进程/测试替身)⇒ 调用方不许据此告警。
|
|
1079
|
+
刻意**不并进 `inbound_status`**:那是 `GET /api/me/feishu-cfg` 的响应子对象,
|
|
1080
|
+
多一个键就是对外契约变更(node 侧 5 条形状判据当场抓过这个泄漏)。"""
|
|
1081
|
+
return _WS_APP_ID.get(user_id) or ""
|
|
1082
|
+
|
|
1083
|
+
|
|
1072
1084
|
def _tenant_token(cfg):
|
|
1073
1085
|
"""tenant_access_token(按 app_id 分桶缓存,过期前 5 分钟刷新;与参考实现同节律)。"""
|
|
1074
1086
|
app_id = cfg.get("app_id") or ""
|
|
@@ -1580,6 +1592,7 @@ HELP_TEXT = (
|
|
|
1580
1592
|
"Touchstone 指令(单聊可用):\n"
|
|
1581
1593
|
"直接发普通文字 = 与当前会话对话(未绑定会在默认项目自动新建)。\n"
|
|
1582
1594
|
"· 帮助 — 本说明\n"
|
|
1595
|
+
"· 接入 — 飞书接入操作卡片(换应用/重连/绑定码/自检/补齐/发布)\n"
|
|
1583
1596
|
"· 绑定 <码> — 绑定站点账号(绑定码在站点侧栏「设置」→ 飞书设置 生成)\n"
|
|
1584
1597
|
"· 解绑 — 解除绑定\n"
|
|
1585
1598
|
"· 当前 — 查看默认项目与当前会话\n"
|
|
@@ -1606,6 +1619,14 @@ HELP_TEXT = (
|
|
|
1606
1619
|
)
|
|
1607
1620
|
|
|
1608
1621
|
_WS_THREADS = {} # user_id -> 入站线程(已拉起且在 run 的;线程退出后残留引用)
|
|
1622
|
+
# user_id -> **拉起该线程时**用的 app_id(M10,2026-10-17):换凭据后旧连接会一直在跑,
|
|
1623
|
+
# doctor 拿它判「线程绑的应用 ≠ 配置里的应用」(M11 起这种不一致可由 `restart_inbound_for`
|
|
1624
|
+
# 在进程内收口,不再只有「重启站点」一条路)。
|
|
1625
|
+
_WS_APP_ID = {}
|
|
1626
|
+
# user_id -> (client, loop):M11 起每条长连接独立 event loop、可在进程内停止
|
|
1627
|
+
# (`stop_inbound_for`/`restart_inbound_for` 用;见 `_ws_run` 的 loop 说明)。
|
|
1628
|
+
_WS_CLIENTS = {}
|
|
1629
|
+
_WS_STOP_TIMEOUT = 8.0 # 停连接时等线程退出的上限(秒);实测收口在毫秒级
|
|
1609
1630
|
_MSG_SEEN = [] # message_id LRU(list 尾=最新)
|
|
1610
1631
|
_MSG_SEEN_SET = set()
|
|
1611
1632
|
_MSG_SEEN_MAX = 512
|
|
@@ -1628,7 +1649,7 @@ def _seen_event(eid):
|
|
|
1628
1649
|
def start_inbound_for(user_id):
|
|
1629
1650
|
"""幂等启动指定用户的入站长连接线程(首次配置保存时即时拉起/服务启动遍历);
|
|
1630
1651
|
应用凭据缺失或线程已在运行时不启动。返回是否启动了新线程。
|
|
1631
|
-
|
|
1652
|
+
凭据变更后用 `restart_inbound_for` 停旧起新(M11 起免重启站点);
|
|
1632
1653
|
线程随进程 daemon 退出。生命周期日志走 stderr(server.log 可查)。"""
|
|
1633
1654
|
cur = _WS_THREADS.get(user_id)
|
|
1634
1655
|
if cur is not None and cur.is_alive():
|
|
@@ -1641,6 +1662,7 @@ def start_inbound_for(user_id):
|
|
|
1641
1662
|
t = threading.Thread(target=_ws_run, args=(user_id, cfg), daemon=True,
|
|
1642
1663
|
name=f"feishu-inbound-{user_id}")
|
|
1643
1664
|
_WS_THREADS[user_id] = t
|
|
1665
|
+
_WS_APP_ID[user_id] = cfg.get("app_id") or "" # 记「这条线程绑的是哪个应用」
|
|
1644
1666
|
t.start()
|
|
1645
1667
|
return True
|
|
1646
1668
|
|
|
@@ -1722,9 +1744,18 @@ def _make_ws_client(cfg, event_handler, card_handler, log_level=None):
|
|
|
1722
1744
|
def _ws_run(user_id, cfg):
|
|
1723
1745
|
"""ws 客户端线程主体(按用户应用):start() 阻塞,SDK 内置自动重连
|
|
1724
1746
|
(同机实证;断连窗口的入站消息不补推=已知限制)。随进程 daemon 退出,
|
|
1725
|
-
handler 内禁上抛。cfg
|
|
1747
|
+
handler 内禁上抛。cfg 是**拉起时**的凭据快照——换凭据后由
|
|
1748
|
+
`restart_inbound_for` 停旧起新(M11 起免重启站点)。
|
|
1749
|
+
|
|
1750
|
+
M11(2026-10-17)关键结构:每条连接**独立 event loop**(patch
|
|
1751
|
+
`lark_oapi.ws.client.loop`)。SDK 的 `start()` 用模块级 loop 且 `_select()` 永不返回,
|
|
1752
|
+
共享 loop 就无法只停一条连接;独立 loop 让 `stop_inbound_for` 只收口自己这条。
|
|
1753
|
+
必须同时 `asyncio.set_event_loop(loop)`:SDK 的 `ExpiringCache.__init__` 用
|
|
1754
|
+
`asyncio.get_event_loop()` 起清扫任务(`core/cache/expiring_cache.py:12-18`),
|
|
1755
|
+
不设的话该任务会建在另一条永不运行的 loop 上,退出时报「Task was destroyed」。"""
|
|
1726
1756
|
try:
|
|
1727
1757
|
import lark_oapi as lark
|
|
1758
|
+
from lark_oapi.ws import client as ws_sdk_client
|
|
1728
1759
|
except ImportError:
|
|
1729
1760
|
print("feishu inbound: lark-oapi 未安装,入站禁用",
|
|
1730
1761
|
file=sys.stderr, flush=True)
|
|
@@ -1754,16 +1785,111 @@ def _ws_run(user_id, cfg):
|
|
|
1754
1785
|
logging.basicConfig(level=logging.DEBUG,
|
|
1755
1786
|
format="%(asctime)s %(name)s %(levelname)s %(message)s")
|
|
1756
1787
|
logging.getLogger("websockets").setLevel(logging.INFO)
|
|
1788
|
+
loop = asyncio.new_event_loop()
|
|
1789
|
+
asyncio.set_event_loop(loop) # SDK 内部 ExpiringCache 取的是它(见 docstring)
|
|
1790
|
+
loop.set_exception_handler(lambda _loop, _ctx: None) # 私有 loop:asyncio 层噪声不外溢
|
|
1791
|
+
ws_sdk_client.loop = loop # SDK start() 读/写模块级 loop(client.py:164-179)
|
|
1757
1792
|
client = _make_ws_client(cfg, handler, lambda data: _on_card_action(data, cfg),
|
|
1758
1793
|
log_level=lark.LogLevel.DEBUG if ws_debug
|
|
1759
1794
|
else lark.LogLevel.ERROR)
|
|
1795
|
+
_WS_CLIENTS[user_id] = (client, loop)
|
|
1760
1796
|
print(f"feishu inbound: 用户 {user_id} 连接长连接网关(app_id={cfg['app_id']})…",
|
|
1761
1797
|
file=sys.stderr, flush=True)
|
|
1762
1798
|
try:
|
|
1763
1799
|
client.start()
|
|
1800
|
+
except asyncio.CancelledError:
|
|
1801
|
+
pass # 停止请求(M11 `stop_inbound_for`):不是异常,不刷 ERROR 行
|
|
1764
1802
|
except Exception as e: # 连接建立失败/网关拒绝等:留痕后线程退出(不再自动重试)
|
|
1765
1803
|
print(f"feishu inbound: 用户 {user_id} 长连接异常退出: {e!r}(重启站点重试)",
|
|
1766
1804
|
file=sys.stderr, flush=True)
|
|
1805
|
+
finally:
|
|
1806
|
+
_WS_CLIENTS.pop(user_id, None)
|
|
1807
|
+
_drain_ws_loop(loop)
|
|
1808
|
+
|
|
1809
|
+
|
|
1810
|
+
def _drain_ws_loop(loop):
|
|
1811
|
+
"""收口一条入站连接的 event loop(M11):有界等待取消送达 → 关闭。
|
|
1812
|
+
无界等会卡在 SDK 心跳的 120s sleep 上;1s 足够让任务取消收敛(实测毫秒级)。"""
|
|
1813
|
+
try:
|
|
1814
|
+
pending = [t for t in asyncio.all_tasks(loop) if not t.done()]
|
|
1815
|
+
if pending:
|
|
1816
|
+
loop.run_until_complete(asyncio.wait(pending, timeout=1.0))
|
|
1817
|
+
except BaseException: # noqa: BLE001 - 收口阶段吞掉一切(含已关闭/已停止)
|
|
1818
|
+
pass
|
|
1819
|
+
try:
|
|
1820
|
+
loop.close()
|
|
1821
|
+
except BaseException: # noqa: BLE001
|
|
1822
|
+
pass
|
|
1823
|
+
|
|
1824
|
+
|
|
1825
|
+
def stop_inbound_for(user_id):
|
|
1826
|
+
"""停掉该用户的入站长连接(M11:换应用凭据后免重启站点)。返回是否真的停了
|
|
1827
|
+
一条在跑的连接——幂等:没在跑 ⇒ False、零副作用。
|
|
1828
|
+
|
|
1829
|
+
收口算法(实测见 plan 记录):关闭 ws(SDK 的 `_disconnect`)→ 置
|
|
1830
|
+
`_auto_reconnect=False`(否则 receive loop 会立刻重连)→ 取消该 loop 上全部任务 →
|
|
1831
|
+
停 loop;线程退出后 `_ws_run` 的 finally 再关 loop。线程是 daemon:即使超时
|
|
1832
|
+
未退也不会阻塞进程退出。"""
|
|
1833
|
+
th = _WS_THREADS.get(user_id)
|
|
1834
|
+
if th is None or not th.is_alive():
|
|
1835
|
+
return False
|
|
1836
|
+
ent = _WS_CLIENTS.get(user_id)
|
|
1837
|
+
if ent is None: # 线程已起、客户端还在构造/导入 SDK 中:有界等一小段
|
|
1838
|
+
for _ in range(50):
|
|
1839
|
+
if not th.is_alive():
|
|
1840
|
+
return False
|
|
1841
|
+
time.sleep(0.02)
|
|
1842
|
+
ent = _WS_CLIENTS.get(user_id)
|
|
1843
|
+
if ent is not None:
|
|
1844
|
+
break
|
|
1845
|
+
if ent is None:
|
|
1846
|
+
return False
|
|
1847
|
+
client, loop = ent
|
|
1848
|
+
try:
|
|
1849
|
+
from lark_oapi.core.log import logger as lark_logger
|
|
1850
|
+
except Exception: # noqa: BLE001 - 没有日志模块也不影响停止
|
|
1851
|
+
lark_logger = None
|
|
1852
|
+
try:
|
|
1853
|
+
if lark_logger is not None:
|
|
1854
|
+
# 主动停连接时 SDK 的 receive loop 会打一条 ERROR「receive message loop exit」——
|
|
1855
|
+
# 那是预期内收口、不是故障;临时抬到 CRITICAL,停完复原(默认级别就是 ERROR)。
|
|
1856
|
+
lark_logger.setLevel(logging.CRITICAL)
|
|
1857
|
+
client._auto_reconnect = False
|
|
1858
|
+
try:
|
|
1859
|
+
asyncio.run_coroutine_threadsafe(client._disconnect(), loop).result(3.0)
|
|
1860
|
+
except BaseException: # noqa: BLE001 - 连接可能已断/loop 已停
|
|
1861
|
+
pass
|
|
1862
|
+
|
|
1863
|
+
def _shutdown():
|
|
1864
|
+
try:
|
|
1865
|
+
for task in asyncio.all_tasks(loop):
|
|
1866
|
+
task.cancel()
|
|
1867
|
+
except BaseException: # noqa: BLE001
|
|
1868
|
+
pass
|
|
1869
|
+
loop.call_later(0.05, loop.stop)
|
|
1870
|
+
|
|
1871
|
+
try:
|
|
1872
|
+
loop.call_soon_threadsafe(_shutdown)
|
|
1873
|
+
except RuntimeError: # loop 已关闭:线程自己会退
|
|
1874
|
+
pass
|
|
1875
|
+
th.join(_WS_STOP_TIMEOUT)
|
|
1876
|
+
finally:
|
|
1877
|
+
if lark_logger is not None:
|
|
1878
|
+
try:
|
|
1879
|
+
lark_logger.setLevel(logging.ERROR)
|
|
1880
|
+
except Exception: # noqa: BLE001
|
|
1881
|
+
pass
|
|
1882
|
+
return not th.is_alive()
|
|
1883
|
+
|
|
1884
|
+
|
|
1885
|
+
def restart_inbound_for(user_id):
|
|
1886
|
+
"""换掉该用户的入站长连接(M11):停旧 → 用**当前库里**的凭据起新。
|
|
1887
|
+
返回是否起了新连接(False = 旧线程没停下来导致新连接没起,或凭据不全)。
|
|
1888
|
+
停超时也照常尝试起新——但 `start_inbound_for` 见线程还活着会短路,故不会并存两条。
|
|
1889
|
+
M11.1 起由 `POST /api/me/feishu/reconnect`(设置页面板「重连入站」按钮)直接暴露。"""
|
|
1890
|
+
stop_inbound_for(user_id)
|
|
1891
|
+
return start_inbound_for(user_id)
|
|
1892
|
+
|
|
1767
1893
|
|
|
1768
1894
|
|
|
1769
1895
|
def _on_message_event(data, cfg):
|
|
@@ -1845,6 +1971,13 @@ def handle_message_event(evt, cfg=None):
|
|
|
1845
1971
|
rest_reply(mid, "尚未绑定 Touchstone 账号:登录站点 → 侧栏「设置」→ 飞书设置,"
|
|
1846
1972
|
"生成绑定码后在这里回复「绑定 <码>」完成绑定", cfg)
|
|
1847
1973
|
return
|
|
1974
|
+
# M11「接入」:发一张可点的飞书接入操作卡片(换应用/重连/绑定码/自检/补齐/发布),
|
|
1975
|
+
# 用**当前这条连接**的应用凭据发——卡片按钮回调会回到同一条连接
|
|
1976
|
+
if intent is not None and intent["action"] == "setup":
|
|
1977
|
+
ok, err = send_setup_card(binding["user_id"], cfg)
|
|
1978
|
+
rest_reply(mid, "🛠 已发送「飞书接入操作卡片」,请在卡片上操作。"
|
|
1979
|
+
if ok else f"卡片发送失败:{err}", cfg)
|
|
1980
|
+
return
|
|
1848
1981
|
if intent is None:
|
|
1849
1982
|
if evt.get("chat_type") != "group" and feishu_conv.enabled():
|
|
1850
1983
|
# 单聊未识别文本 → 通用对话路由(投递给当前会话;无会话按默认项目自动新建)
|
|
@@ -1908,6 +2041,10 @@ def _on_card_action(data, cfg):
|
|
|
1908
2041
|
# M4 权限引导卡片(t=sc)独立于卡片交互流:不带卡号,回调里重跑斜杠指令同步
|
|
1909
2042
|
if t == "sc":
|
|
1910
2043
|
return _on_slash_perm_action(sender, value, cfg)
|
|
2044
|
+
# M11 接入操作卡片(t=fs):同样不带卡号,独立于看板/通用对话卡片流;
|
|
2045
|
+
# 身份(open_id→binding)+ nonce 双复核在 _on_setup_card_action 里
|
|
2046
|
+
if t == "fs":
|
|
2047
|
+
return _on_setup_card_action(sender, value, cfg)
|
|
1911
2048
|
# 通用对话卡片(t=dp 项目总览 / t=ss 会话列表)同样不带卡号:在既有看板
|
|
1912
2049
|
# 卡片白名单**之前**分流,看板回调(a/mq/p/s/d)路径一个字节都不动
|
|
1913
2050
|
if t in ("dp", "ss"):
|
|
@@ -1978,12 +2115,266 @@ def _card_sdk_to_dict(data):
|
|
|
1978
2115
|
}
|
|
1979
2116
|
|
|
1980
2117
|
|
|
2118
|
+
# ---------- M11:换凭据免重启 + 飞书接入操作卡片(2026-10-17) ----------
|
|
2119
|
+
#
|
|
2120
|
+
# 用户痛点(M10 §五.1):换应用后必须重启站点才换长连接,而绑定码是进程内存态
|
|
2121
|
+
# (10 分钟 TTL)⇒「先生成码再重启」的顺序必然踩「绑定码无效/已过期」。M11 把长连接
|
|
2122
|
+
# 做成可停可换(`stop_inbound_for`/`restart_inbound_for`),并把「换应用 → 取新绑定码
|
|
2123
|
+
# → 重连 → 去新机器人绑定」做成一张 DM 卡片的按钮,**顺序由代码保证**,不依赖用户记忆。
|
|
2124
|
+
#
|
|
2125
|
+
# 卡片安全:按钮 value 只带 {t=fs, a=动作, u=用户id, n=nonce}——回调按 open_id→binding
|
|
2126
|
+
# 复核身份,再按 nonce(进程内存态、10 分钟、绑定到该用户)复核新鲜度/归属;卡片本身
|
|
2127
|
+
# 无状态(动作全部在服务端重读配置与线程态),过期后发「接入」重新获取。
|
|
2128
|
+
#
|
|
2129
|
+
# 两条投递通道(都按「open_id 属于哪个应用」决定用谁的凭据发):
|
|
2130
|
+
# - 用户主动发「接入」→ 用**当前连接**的凭据(cfg 参数)发卡;
|
|
2131
|
+
# - 换应用成功后自动发「确认切换」卡 → 必须用**旧应用**凭据(绑定行的 open_id 是旧
|
|
2132
|
+
# 应用下的标识,新应用还不认识它;见 `maybe_send_switch_card`)。
|
|
2133
|
+
|
|
2134
|
+
_SETUP_NONCES = {} # nonce -> (user_id, expires epoch);内存态不入库
|
|
2135
|
+
_SETUP_NONCE_TTL = 600
|
|
2136
|
+
|
|
2137
|
+
|
|
2138
|
+
def _setup_nonce(user_id):
|
|
2139
|
+
"""发接入卡片时登记一个按钮 nonce(顺带清过期项;每张卡片一个,多按钮共用)。"""
|
|
2140
|
+
now = time.time()
|
|
2141
|
+
for key in [k for k, v in list(_SETUP_NONCES.items()) if now > v[1]]:
|
|
2142
|
+
_SETUP_NONCES.pop(key, None)
|
|
2143
|
+
nonce = secrets.token_hex(8)
|
|
2144
|
+
_SETUP_NONCES[nonce] = (int(user_id), now + _SETUP_NONCE_TTL)
|
|
2145
|
+
return nonce
|
|
2146
|
+
|
|
2147
|
+
|
|
2148
|
+
def setup_card(user_id, app_id, thread_app, nonce):
|
|
2149
|
+
"""「飞书接入操作卡片」(schema 2.0,DM 单聊)。**纯函数**:给定入参 ⇒ 确定 JSON
|
|
2150
|
+
(两侧后端逐字节对拍用;node 侧 `feishu_inbound.js::setupCard`)。
|
|
2151
|
+
|
|
2152
|
+
入参:user_id=站点用户 id;app_id=配置里的应用;thread_app=入站长连接**当前绑的**
|
|
2153
|
+
应用(空串=连接未运行);nonce=按钮防伪令牌。两应用不同 ⇒ 卡片切到「换用新应用」
|
|
2154
|
+
形态(换绑后自动发的那张:按钮会先取新绑定码,再用新应用重连)。"""
|
|
2155
|
+
app_id = str(app_id or "")
|
|
2156
|
+
thread_app = str(thread_app or "")
|
|
2157
|
+
switching = bool(app_id) and bool(thread_app) and app_id != thread_app
|
|
2158
|
+
uid = int(user_id)
|
|
2159
|
+
|
|
2160
|
+
def _button(label, action, style="default"):
|
|
2161
|
+
return {"tag": "button", "type": style,
|
|
2162
|
+
"text": {"tag": "plain_text", "content": label},
|
|
2163
|
+
"behaviors": [{"type": "callback",
|
|
2164
|
+
"value": {"t": "fs", "a": action, "u": uid, "n": nonce}}]}
|
|
2165
|
+
|
|
2166
|
+
elements = [{"tag": "markdown", "content": "\n".join([
|
|
2167
|
+
"**当前应用**:" + (("`" + app_id + "`") if app_id else "(未配置)"),
|
|
2168
|
+
"**入站长连接**:" + (("运行中 · 绑在 `" + thread_app + "`") if thread_app else "未运行"),
|
|
2169
|
+
])}]
|
|
2170
|
+
if switching:
|
|
2171
|
+
elements.append({"tag": "markdown", "content":
|
|
2172
|
+
f"检测到应用凭据已换为 `{app_id}`,但长连接还绑在旧应用 "
|
|
2173
|
+
f"`{thread_app}` 上。点「换用新应用」:我会先把新绑定码发到本会话,"
|
|
2174
|
+
"再用新应用重连——之后在新机器人单聊里回复「绑定 <码>」即可。"})
|
|
2175
|
+
elements.append(_button("🔁 换用新应用", "switch", "primary"))
|
|
2176
|
+
else:
|
|
2177
|
+
elements.append({"tag": "markdown", "content":
|
|
2178
|
+
"需要时点下面的按钮;换应用后我会自动发一张新的确认卡片。"})
|
|
2179
|
+
elements.append(_button("🔄 重连入站", "restart", "primary"))
|
|
2180
|
+
elements.append(_button("🔑 生成绑定码", "bindcode"))
|
|
2181
|
+
elements.append(_button("🩺 配置自检", "doctor"))
|
|
2182
|
+
elements.append(_button("🧩 补齐配置", "apply"))
|
|
2183
|
+
elements.append(_button("🚀 提交发布", "publish"))
|
|
2184
|
+
elements.append({"tag": "markdown",
|
|
2185
|
+
"content": "卡片 10 分钟内有效;过期后发「接入」重新获取。"})
|
|
2186
|
+
return {"schema": "2.0",
|
|
2187
|
+
"header": {"title": {"tag": "plain_text",
|
|
2188
|
+
"content": "🔁 飞书应用已变更" if switching else "🛠 飞书接入操作"},
|
|
2189
|
+
"template": "orange" if switching else "blue"},
|
|
2190
|
+
"body": {"elements": elements}}
|
|
2191
|
+
|
|
2192
|
+
|
|
2193
|
+
def send_setup_card(user_id, cfg=None):
|
|
2194
|
+
"""给该用户 DM 发「飞书接入操作卡片」。返回 (ok, err)。
|
|
2195
|
+
`cfg` 缺省=当前配置;**换应用自动发送时调用方必须传旧应用的 cfg**(见模块段注释)。"""
|
|
2196
|
+
if cfg is None:
|
|
2197
|
+
cfg = app_config(user_id)
|
|
2198
|
+
if cfg is None:
|
|
2199
|
+
return False, "未配置应用凭据(App ID / App Secret),先保存配置再发卡片"
|
|
2200
|
+
binding = db.get_feishu_binding_by_user(user_id)
|
|
2201
|
+
if not binding:
|
|
2202
|
+
return False, "尚未绑定飞书账号:设置页生成绑定码后在机器人单聊回复「绑定 <码>」"
|
|
2203
|
+
cur = app_config(user_id) or {}
|
|
2204
|
+
thread_app = (inbound_thread_app_id(user_id)
|
|
2205
|
+
if inbound_status(user_id).get("thread") else "")
|
|
2206
|
+
card = setup_card(user_id, cur.get("app_id") or "", thread_app, _setup_nonce(user_id))
|
|
2207
|
+
try:
|
|
2208
|
+
rest_send_card(binding["open_id"], card, cfg)
|
|
2209
|
+
return True, ""
|
|
2210
|
+
except FeishuRestError as e:
|
|
2211
|
+
return False, f"卡片发送失败:{e}"
|
|
2212
|
+
|
|
2213
|
+
|
|
2214
|
+
def maybe_send_switch_card(user_id, old_cfg):
|
|
2215
|
+
"""换应用凭据后自动发「确认切换」卡片(M11):绑定行的 open_id 属于旧应用,故必须用
|
|
2216
|
+
**旧凭据**发这条 DM(新凭据发不进这个单聊)。条件:① 旧连接还在跑;② 配置里的应用
|
|
2217
|
+
与旧连接不同;③ 有绑定行。绝不抛出(后台线程/扫码 worker 调用),失败只留一行 stderr。
|
|
2218
|
+
返回是否真的发出。"""
|
|
2219
|
+
try:
|
|
2220
|
+
old_app = str((old_cfg or {}).get("app_id") or "")
|
|
2221
|
+
new_app = str((app_config(user_id) or {}).get("app_id") or "")
|
|
2222
|
+
if not old_app or not new_app or old_app == new_app:
|
|
2223
|
+
return False
|
|
2224
|
+
if not inbound_status(user_id).get("thread"):
|
|
2225
|
+
return False
|
|
2226
|
+
if inbound_thread_app_id(user_id) == new_app:
|
|
2227
|
+
return False
|
|
2228
|
+
binding = db.get_feishu_binding_by_user(user_id)
|
|
2229
|
+
if not binding:
|
|
2230
|
+
return False
|
|
2231
|
+
card = setup_card(user_id, new_app, old_app, _setup_nonce(user_id))
|
|
2232
|
+
rest_send_card(binding["open_id"], card, old_cfg)
|
|
2233
|
+
return True
|
|
2234
|
+
except Exception as e: # noqa: BLE001 - 自动卡片绝不反噬换凭据主流程
|
|
2235
|
+
print(f"feishu provision: 用户 {user_id} 换应用确认卡片发送失败: {e!r}",
|
|
2236
|
+
file=sys.stderr, flush=True)
|
|
2237
|
+
return False
|
|
2238
|
+
|
|
2239
|
+
|
|
2240
|
+
def _setup_switch(user_id, sender, cfg):
|
|
2241
|
+
"""「换用新应用」:生成新绑定码 → 用**旧应用** DM 发出 → 停旧连接、用新凭据重连。
|
|
2242
|
+
顺序是设计要点:码必须先到用户手上(旧应用通道),再断旧连接(断完就发不出了)。
|
|
2243
|
+
返回 (toast, dm_text);dm_text 为空表示已有独立 DM 说明(不再重复发)。"""
|
|
2244
|
+
cur = app_config(user_id)
|
|
2245
|
+
new_app = str((cur or {}).get("app_id") or "")
|
|
2246
|
+
old_app = str((cfg or {}).get("app_id") or "")
|
|
2247
|
+
if cur is None or not new_app:
|
|
2248
|
+
return ({"toast": {"type": "error", "content": "新应用凭据不完整"}},
|
|
2249
|
+
"❌ 应用凭据不完整:先在设置页保存新应用的 App ID / App Secret,再点本按钮。")
|
|
2250
|
+
if old_app == new_app: # 同应用(只换了 secret):直接重连,绑定关系不变
|
|
2251
|
+
if restart_inbound_for(user_id):
|
|
2252
|
+
return ({"toast": {"type": "success", "content": "已重连"}},
|
|
2253
|
+
f"🔄 已用当前应用 `{new_app}` 重新建立长连接,绑定关系不变。")
|
|
2254
|
+
return ({"toast": {"type": "error", "content": "重连失败,看私聊"}},
|
|
2255
|
+
"❌ 重连失败:旧连接未在超时内退出;稍后再点一次,仍不行请重启站点。")
|
|
2256
|
+
code = make_bind_code(user_id)
|
|
2257
|
+
text = (f"🔁 已切换到新应用 `{new_app}`。\n"
|
|
2258
|
+
f"**新的绑定码:{code}**(10 分钟内有效)\n"
|
|
2259
|
+
"本(旧)机器人的连接马上断开;请到新机器人单聊里回复:\n"
|
|
2260
|
+
f"绑定 {code}")
|
|
2261
|
+
try:
|
|
2262
|
+
rest_send_text(sender, text, cfg)
|
|
2263
|
+
except FeishuRestError as e:
|
|
2264
|
+
# 码没送到就不动连接(否则用户既进不去新机器人、旧机器人也断了)
|
|
2265
|
+
return ({"toast": {"type": "error", "content": "绑定码发送失败"}},
|
|
2266
|
+
f"❌ 绑定码没能发进本会话({e});重连未执行,可重试本按钮。")
|
|
2267
|
+
if restart_inbound_for(user_id):
|
|
2268
|
+
return ({"toast": {"type": "success", "content": "已切换,去新机器人绑定"}}, "")
|
|
2269
|
+
return ({"toast": {"type": "error", "content": "已换应用但重连失败,看私聊"}},
|
|
2270
|
+
f"⚠️ 新应用 `{new_app}` 重连失败(旧连接未退出);绑定码 {code} 仍有效,"
|
|
2271
|
+
"可稍后再点本按钮,或重启站点。")
|
|
2272
|
+
|
|
2273
|
+
|
|
2274
|
+
def _setup_restart(user_id):
|
|
2275
|
+
"""「重连入站」:停旧连接、用当前库里的凭据重连(同应用换 secret 的主路径)。"""
|
|
2276
|
+
cur = app_config(user_id)
|
|
2277
|
+
if cur is None:
|
|
2278
|
+
return ({"toast": {"type": "error", "content": "应用凭据未配置"}},
|
|
2279
|
+
"❌ 应用凭据未配置(App ID / App Secret):先在设置页保存,再点本按钮。")
|
|
2280
|
+
app_id = str(cur.get("app_id") or "")
|
|
2281
|
+
if restart_inbound_for(user_id):
|
|
2282
|
+
return ({"toast": {"type": "success", "content": "已重连"}},
|
|
2283
|
+
f"🔄 已用当前应用 `{app_id}` 重新建立长连接。")
|
|
2284
|
+
return ({"toast": {"type": "error", "content": "重连失败,看私聊"}},
|
|
2285
|
+
"❌ 重连失败:旧连接未在超时内退出,稍后再点一次;仍不行请重启站点。")
|
|
2286
|
+
|
|
2287
|
+
|
|
2288
|
+
def _setup_bindcode(user_id):
|
|
2289
|
+
"""「生成绑定码」:发一个新码(10 分钟、内存态;旧码不失效,码均可用一次)。"""
|
|
2290
|
+
code = make_bind_code(user_id)
|
|
2291
|
+
return ({"toast": {"type": "success", "content": "绑定码已私聊发你"}},
|
|
2292
|
+
f"🔑 新的绑定码:{code}(10 分钟内有效)\n"
|
|
2293
|
+
"· 未绑定:在本机器人单聊回复「绑定 <码>」\n"
|
|
2294
|
+
"· 已绑定换号:先发「解绑」,再发「绑定 <码>」")
|
|
2295
|
+
|
|
2296
|
+
|
|
2297
|
+
def _setup_slow_action(user_id, sender, action, cfg):
|
|
2298
|
+
"""慢动作(自检/补齐配置/提交发布)后台执行,结果 DM 回执。绝不外抛。
|
|
2299
|
+
走后台线程的理由:卡片回调要尽快返回 ACK,这几项是多次 REST(2~5s)。"""
|
|
2300
|
+
try:
|
|
2301
|
+
if action == "doctor":
|
|
2302
|
+
out = doctor(user_id)
|
|
2303
|
+
summary = out.get("summary") or {}
|
|
2304
|
+
bad = [it for it in (out.get("items") or [])
|
|
2305
|
+
if it.get("state") in ("fail", "warn")]
|
|
2306
|
+
lines = [f"🩺 配置自检:ok {summary.get('ok', 0)} / fail {summary.get('fail', 0)} / "
|
|
2307
|
+
f"warn {summary.get('warn', 0)} / skip {summary.get('skip', 0)}"]
|
|
2308
|
+
for it in bad:
|
|
2309
|
+
lines.append(f"· {it.get('label')}:{it.get('detail')}"
|
|
2310
|
+
+ (f"({it.get('hint')})" if it.get("hint") else ""))
|
|
2311
|
+
text = "\n".join(lines)
|
|
2312
|
+
elif action == "apply":
|
|
2313
|
+
out = provision_apply(user_id)
|
|
2314
|
+
text = ("🧩 配置补齐成功:机器人能力 / 权限 / 长连接订阅 / 事件 / 回调已写入"
|
|
2315
|
+
"(需发布新版本生效)。" if out.get("ok")
|
|
2316
|
+
else f"❌ 配置补齐失败:{out.get('error') or '未知错误'}")
|
|
2317
|
+
else:
|
|
2318
|
+
out = provision_publish(user_id)
|
|
2319
|
+
text = (f"🚀 已提交发布版本 {out.get('version') or ''};"
|
|
2320
|
+
"仍需租户管理员审批后线上生效。" if out.get("ok")
|
|
2321
|
+
else f"❌ 提交发布失败:{out.get('error') or '未知错误'}")
|
|
2322
|
+
_dm_text(sender, text, cfg)
|
|
2323
|
+
except Exception as e: # noqa: BLE001 - 后台线程兜底,绝不外抛
|
|
2324
|
+
print(f"feishu inbound: 接入卡片后台动作异常: {e!r}", file=sys.stderr, flush=True)
|
|
2325
|
+
|
|
2326
|
+
|
|
2327
|
+
def _on_setup_card_action(sender, value, cfg):
|
|
2328
|
+
"""「飞书接入操作卡片」按钮回调(M11):open_id→binding + nonce 双重复核 → 动作分发。
|
|
2329
|
+
返回 toast dict(None=不更新卡片)。`cfg` = 点击到达那条连接的应用凭据——旧应用发的
|
|
2330
|
+
卡片必须用旧应用凭据回 DM,否则发不进当前单聊。"""
|
|
2331
|
+
binding = db.get_feishu_binding_by_open(sender)
|
|
2332
|
+
if binding is None:
|
|
2333
|
+
_dm_text(sender, "尚未绑定 Touchstone 账号:登录站点 → 侧栏「设置」→ "
|
|
2334
|
+
"飞书设置,生成绑定码后在单聊回复「绑定 <码>」完成绑定", cfg)
|
|
2335
|
+
return {"toast": {"type": "error", "content": "尚未绑定站点账号"}}
|
|
2336
|
+
user_id = int(binding["user_id"])
|
|
2337
|
+
uid = int(value.get("u") or 0)
|
|
2338
|
+
if uid and uid != user_id:
|
|
2339
|
+
return {"toast": {"type": "error", "content": "该卡片不属于当前账号"}}
|
|
2340
|
+
nonce = str(value.get("n") or "")
|
|
2341
|
+
item = _SETUP_NONCES.get(nonce)
|
|
2342
|
+
if item is None or item[0] != user_id:
|
|
2343
|
+
return {"toast": {"type": "error", "content": "卡片已失效,发「接入」重新获取"}}
|
|
2344
|
+
if time.time() > item[1]:
|
|
2345
|
+
_SETUP_NONCES.pop(nonce, None)
|
|
2346
|
+
return {"toast": {"type": "error", "content": "卡片已过期,发「接入」重新获取"}}
|
|
2347
|
+
action = str(value.get("a") or "")
|
|
2348
|
+
try:
|
|
2349
|
+
if action == "switch":
|
|
2350
|
+
toast, text = _setup_switch(user_id, sender, cfg)
|
|
2351
|
+
elif action == "restart":
|
|
2352
|
+
toast, text = _setup_restart(user_id)
|
|
2353
|
+
elif action == "bindcode":
|
|
2354
|
+
toast, text = _setup_bindcode(user_id)
|
|
2355
|
+
elif action in ("doctor", "apply", "publish"):
|
|
2356
|
+
# 慢动作:后台线程执行 + 立即 toast(避免卡片回调 ACK 超时)
|
|
2357
|
+
threading.Thread(target=_setup_slow_action,
|
|
2358
|
+
args=(user_id, sender, action, cfg), daemon=True,
|
|
2359
|
+
name=f"feishu-setup-{action}-{user_id}").start()
|
|
2360
|
+
return {"toast": {"type": "success", "content": "已收到,结果稍后私聊发你"}}
|
|
2361
|
+
else:
|
|
2362
|
+
return None
|
|
2363
|
+
except Exception as e: # noqa: BLE001 - 卡片回调绝不外抛
|
|
2364
|
+
print(f"feishu inbound: 接入卡片动作异常: {e!r}", file=sys.stderr, flush=True)
|
|
2365
|
+
return {"toast": {"type": "error", "content": "操作失败,详情看站点日志"}}
|
|
2366
|
+
if text:
|
|
2367
|
+
_dm_text(sender, text, cfg)
|
|
2368
|
+
return toast
|
|
2369
|
+
|
|
2370
|
+
|
|
1981
2371
|
# 规则意图表(M3:未命中且 nl_parse=agent 时走 agent headless 兜底解析)
|
|
1982
2372
|
# 每条规则的「中文写法 | 斜杠别名」两分支共用同一组捕获组(组数必须一致,执行器按
|
|
1983
2373
|
# groups 取值):斜杠别名对应 SLASH_COMMANDS 注册的指令名,用户从飞书指令面板选中后
|
|
1984
2374
|
# 投递过来的文本就是「/名称 [参数]」,与中文指令落到同一个 action。
|
|
1985
2375
|
_INTENT_RULES = (
|
|
1986
2376
|
("help", r"^(?:帮助|help|/help)$"),
|
|
2377
|
+
("setup", r"^(?:接入)$"),
|
|
1987
2378
|
("bind", r"^(?:绑定|/bind)\s*([A-Za-z0-9]{4,8})$"),
|
|
1988
2379
|
("unbind", r"^(?:解绑|/unbind)$"),
|
|
1989
2380
|
("default_project", r"^(?:默认项目|/project)\s+(\S+)$"),
|
|
@@ -2831,9 +3222,18 @@ def doctor(user_id, send_probe=False):
|
|
|
2831
3222
|
f"探测失败: {e!r}", "稍后重试"))
|
|
2832
3223
|
|
|
2833
3224
|
st = inbound_status(user_id)
|
|
3225
|
+
# M10(2026-10-17):线程活着但**绑的不是配置里的应用** ⇒ 换凭据后没重启站点。
|
|
3226
|
+
# 这是真实踩过的坑:面板显示「已接入应用 X」、本项也绿,但消息仍打到旧机器人。
|
|
3227
|
+
# 「线程绑的 app」走独立访问口 `inbound_thread_app_id`,不动 `inbound_status` 的形状。
|
|
3228
|
+
cfg_app, thread_app = (cfg.get("app_id") or ""), inbound_thread_app_id(user_id)
|
|
2834
3229
|
if not has_creds:
|
|
2835
3230
|
items.append(_doc_item("inbound_ws", "入站长连接(长连接)", "skip",
|
|
2836
3231
|
"凭据未配置,跳过", ""))
|
|
3232
|
+
elif st.get("thread") and cfg_app and thread_app and thread_app != cfg_app:
|
|
3233
|
+
items.append(_doc_item(
|
|
3234
|
+
"inbound_ws", "入站长连接(长连接)", "warn",
|
|
3235
|
+
f"连接线程仍绑在 {thread_app}(配置已改为 {cfg_app})",
|
|
3236
|
+
"在机器人私聊发「接入」点「换用新应用」即可免重启切换;仍不生效再重启站点"))
|
|
2837
3237
|
else:
|
|
2838
3238
|
items.append(_doc_item(
|
|
2839
3239
|
"inbound_ws", "入站长连接(长连接)",
|
|
@@ -3073,11 +3473,17 @@ def _provision_worker(user_id, cancel):
|
|
|
3073
3473
|
return
|
|
3074
3474
|
# 合并写库:保留既有 enabled / webhook / base_url 等字段,只换应用凭据
|
|
3075
3475
|
cfg = dict(user_config(user_id) or {})
|
|
3476
|
+
old_cfg = {"app_id": str(cfg.get("app_id") or ""),
|
|
3477
|
+
"app_secret": str(cfg.get("app_secret") or "")}
|
|
3076
3478
|
cfg["app_id"] = app_id
|
|
3077
3479
|
cfg["app_secret"] = secret
|
|
3078
3480
|
db.set_feishu_user_cfg(user_id, cfg)
|
|
3079
3481
|
_TOKEN_CACHE.pop(app_id, None) # 新凭据:清同 app_id 的旧 token 缓存
|
|
3080
3482
|
st["app_id"] = app_id
|
|
3483
|
+
# M11:换应用后旧连接仍在跑(绑定关系也还是旧应用的 open_id)⇒ 用**旧应用**通道发
|
|
3484
|
+
# 「确认切换」卡片;用户点按钮后由「先取码、再重连」顺序完成切换(无需重启站点)。
|
|
3485
|
+
# 必须在 start_inbound_for 之前发:那时旧线程还活着、旧 open_id 也还认得。
|
|
3486
|
+
maybe_send_switch_card(user_id, old_cfg)
|
|
3081
3487
|
try:
|
|
3082
3488
|
start_inbound_for(user_id) # 起长连接(失败只留痕,状态里如实呈现)
|
|
3083
3489
|
except Exception as e:
|
|
@@ -3157,6 +3563,8 @@ def save_user_config(user_id, body):
|
|
|
3157
3563
|
填错只能翻日志;`verify` 随响应回给页面做即时提示(凭据不全时为 None,零请求)。
|
|
3158
3564
|
返回 {"inbound_started": bool, "verify": {...}|None}。"""
|
|
3159
3565
|
cfg = user_config(user_id)
|
|
3566
|
+
old_cfg = {"app_id": str(cfg.get("app_id") or ""),
|
|
3567
|
+
"app_secret": str(cfg.get("app_secret") or "")}
|
|
3160
3568
|
# 推送总控(2026-10-10):与旧 `enabled` 是两个独立开关——旧键只管「无绑定行回落
|
|
3161
3569
|
# 默认 webhook」与入站,本键一票否决全部自动推送。缺省=不改(不在 body 里就不动)。
|
|
3162
3570
|
if "push_enabled" in body:
|
|
@@ -3170,6 +3578,12 @@ def save_user_config(user_id, body):
|
|
|
3170
3578
|
db.set_feishu_user_cfg(user_id, cfg)
|
|
3171
3579
|
started = start_inbound_for(user_id)
|
|
3172
3580
|
verify = verify_credentials(user_id) if app_config(user_id) else None
|
|
3581
|
+
# M11:手工换应用(非扫码路径)同样发「确认切换」卡片——旧连接还活着时用旧应用通道
|
|
3582
|
+
# 通知用户点确认(后台线程发,REST 不阻塞保存响应;条件在 maybe_send_switch_card 里
|
|
3583
|
+
# 复核,凭据没变/线程没跑时是零请求空转)
|
|
3584
|
+
if ("app_id" in body or "app_secret" in body):
|
|
3585
|
+
threading.Thread(target=maybe_send_switch_card, args=(user_id, old_cfg),
|
|
3586
|
+
daemon=True, name=f"feishu-switch-card-{user_id}").start()
|
|
3173
3587
|
return {"inbound_started": bool(started), "verify": verify}
|
|
3174
3588
|
|
|
3175
3589
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gavinc-cn/touchstone-dsh",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "AI-agent-driven automated testing platform for DeepSeek Harness (dsh): Markdown case library, bug reports, per-project serial queue, development board and built-in load testing.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Touchstone contributors",
|