dsh-im-union 0.0.1 → 0.1.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.
Files changed (101) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.en.md +10 -2
  3. package/README.md +10 -2
  4. package/THIRD_PARTY_NOTICES.md +18 -8
  5. package/client.js +126 -2
  6. package/dsh.plugin.json +1 -1
  7. package/lib/admin/api.d.ts +19 -1
  8. package/lib/admin/api.js +63 -14
  9. package/lib/channels/dingtalk.d.ts +186 -0
  10. package/lib/channels/dingtalk.js +1017 -0
  11. package/lib/channels/discord.d.ts +45 -3
  12. package/lib/channels/discord.js +119 -28
  13. package/lib/channels/feishu.d.ts +160 -8
  14. package/lib/channels/feishu.js +478 -20
  15. package/lib/channels/imessage.d.ts +158 -0
  16. package/lib/channels/imessage.js +547 -0
  17. package/lib/channels/matrix.d.ts +262 -0
  18. package/lib/channels/matrix.js +888 -0
  19. package/lib/channels/meta.js +49 -1
  20. package/lib/channels/qq.d.ts +214 -0
  21. package/lib/channels/qq.js +706 -0
  22. package/lib/channels/registry.js +7 -0
  23. package/lib/channels/slack.d.ts +232 -0
  24. package/lib/channels/slack.js +896 -0
  25. package/lib/channels/telegram.d.ts +23 -1
  26. package/lib/channels/telegram.js +78 -16
  27. package/lib/channels/types.d.ts +6 -0
  28. package/lib/channels/wecom-app.d.ts +138 -0
  29. package/lib/channels/wecom-app.js +600 -0
  30. package/lib/channels/wecom.d.ts +11 -3
  31. package/lib/channels/wecom.js +2 -1
  32. package/lib/channels/whatsapp.d.ts +198 -0
  33. package/lib/channels/whatsapp.js +471 -0
  34. package/lib/client.js +126 -2
  35. package/lib/engine/account-manager.d.ts +9 -2
  36. package/lib/engine/account-manager.js +20 -3
  37. package/lib/engine/commands.d.ts +17 -0
  38. package/lib/engine/commands.js +78 -0
  39. package/lib/engine/connector.d.ts +232 -0
  40. package/lib/engine/connector.js +767 -0
  41. package/lib/engine/delivery-log.d.ts +62 -0
  42. package/lib/engine/delivery-log.js +166 -0
  43. package/lib/engine/files.d.ts +42 -0
  44. package/lib/engine/files.js +50 -0
  45. package/lib/engine/fragments.d.ts +55 -0
  46. package/lib/engine/fragments.js +89 -0
  47. package/lib/engine/gateway.d.ts +38 -12
  48. package/lib/engine/gateway.js +388 -15
  49. package/lib/engine/proactive.d.ts +146 -0
  50. package/lib/engine/proactive.js +335 -0
  51. package/lib/engine/quote.d.ts +51 -0
  52. package/lib/engine/quote.js +82 -0
  53. package/lib/engine/voice.d.ts +162 -0
  54. package/lib/engine/voice.js +553 -0
  55. package/lib/engine/zip.d.ts +14 -0
  56. package/lib/engine/zip.js +131 -0
  57. package/lib/host-compat/deliverables-compat.d.ts +49 -0
  58. package/lib/host-compat/deliverables-compat.js +96 -0
  59. package/lib/host-compat/services.d.ts +2 -0
  60. package/lib/host-compat/services.js +2 -0
  61. package/lib/host-compat/session-api.d.ts +10 -0
  62. package/lib/host-compat/session-api.js +69 -0
  63. package/lib/host-compat/session-source.d.ts +9 -0
  64. package/lib/host-compat/session-source.js +45 -0
  65. package/lib/host-compat/uploads-compat.d.ts +44 -0
  66. package/lib/host-compat/uploads-compat.js +66 -0
  67. package/lib/index.d.ts +2 -0
  68. package/lib/index.js +158 -4
  69. package/package.json +2 -2
  70. package/src/admin/api.ts +79 -13
  71. package/src/channels/dingtalk.ts +1126 -0
  72. package/src/channels/discord.ts +156 -39
  73. package/src/channels/feishu.ts +557 -26
  74. package/src/channels/imessage.ts +646 -0
  75. package/src/channels/matrix.ts +1045 -0
  76. package/src/channels/meta.ts +49 -1
  77. package/src/channels/qq.ts +853 -0
  78. package/src/channels/registry.ts +7 -0
  79. package/src/channels/slack.ts +1024 -0
  80. package/src/channels/telegram.ts +93 -18
  81. package/src/channels/types.ts +6 -0
  82. package/src/channels/wecom-app.ts +665 -0
  83. package/src/channels/wecom.ts +13 -4
  84. package/src/channels/whatsapp.ts +598 -0
  85. package/src/engine/account-manager.ts +20 -3
  86. package/src/engine/commands.ts +83 -0
  87. package/src/engine/connector.ts +901 -0
  88. package/src/engine/delivery-log.ts +201 -0
  89. package/src/engine/files.ts +74 -0
  90. package/src/engine/fragments.ts +113 -0
  91. package/src/engine/gateway.ts +379 -15
  92. package/src/engine/proactive.ts +457 -0
  93. package/src/engine/quote.ts +84 -0
  94. package/src/engine/voice.ts +673 -0
  95. package/src/engine/zip.ts +154 -0
  96. package/src/host-compat/deliverables-compat.ts +141 -0
  97. package/src/host-compat/services.ts +4 -0
  98. package/src/host-compat/session-api.ts +79 -0
  99. package/src/host-compat/session-source.ts +47 -0
  100. package/src/host-compat/uploads-compat.ts +126 -0
  101. package/src/index.ts +158 -5
package/CHANGELOG.md CHANGED
@@ -2,6 +2,27 @@
2
2
 
3
3
  本项目版本号遵循语义化版本;发布面与宿主兼容矩阵见 `scripts/hosts.mjs`(README 管理块同源)。
4
4
 
5
+ ## 0.1.0(首个功能版,dist-tag=latest)
6
+
7
+ M1–M3 代码侧全量:14 渠道协议层 + 跨版本窄腰 + 会话引擎 + 管理面。
8
+
9
+ ### Added
10
+
11
+ - 渠道(P0):微信 iLink(官方 ClawBot 协议 + 扫码绑定 UI)、飞书(WS 长连 + 流式卡片/逐步消息双档 + 语音档可选)、Telegram(长轮询 + 编辑流式)、Discord(Gateway v10 + 表格转竖排)、企微智能机器人(长连 + 思考中/工具进度原生显示)
12
+ - 渠道(P1):钉钉 stream + AI Card;QQ 官方 API v2(被动窗口);Slack Socket Mode 零 SDK + thread 模型
13
+ - 渠道(P2):WhatsApp(Baileys,QR 配对 + auth 目录持久化);Matrix(CS API,E2EE 两档诚实档位);iMessage(仅 macOS,非 darwin 硬拒载);企微自建应用(回调验签 + 加解密,webhook 白名单路由)
14
+ - 能力:命令面板 24 命令(含 /fork /rename /delivery /export /speak /mute /goal);审批与交互提问 IM 闭环(waterfall 返回即接管,fail-closed D5);图片/文件输入(宿主元数据判视觉 E4);产物文件回传;投递日志与断线补发(≤3 次,uncertain 绝不重发 D7);超时迟到结果收割;主动投递目标库 + 双向同步;碎片续写(.. / !!);引用上下文增强;会话标题渠道徽章
15
+ - AI Office Connector:查实宿主无公开 connector 协议面 → 离线 Job 队列诚实档(https 强制/租约/审批/并发上限)
16
+
17
+ ### Fixed
18
+
19
+ - Cordis ctx 代理未 inject 属性访问即抛(可选链拦不住)→ 窄腰全 try/catch 守卫
20
+ - 静音判定吞掉 /mute 自身导致的永久静默死锁;/model 与投递链 selections 键不一致导致的视觉判定永命中 unknown
21
+
22
+ ### Changed
23
+
24
+ - publishConfig.tag:next → latest;THIRD_PARTY_NOTICES 运行时依赖清单与实现对齐(纠未采纳的 SDK 列表)
25
+
5
26
  ## 0.0.1(首发占位,tag=next)
6
27
 
7
28
  预发布占位版本,非推荐安装线;语义为「协议层与跨版本骨架完成、真机验收进行中」。
package/README.en.md CHANGED
@@ -8,14 +8,22 @@ A union IM bridge for DeepSeek Harness — a clean-room reimplementation merging
8
8
 
9
9
  ## Status
10
10
 
11
- Under development (M0 cross-version skeleton). See `tasks.md` for phasing.
11
+ **0.1.0 feature release**: all 14 P0/P1/P2 channel protocol layers, the cross-version narrow waist, session engine, approval/question IM loop and multi-account management are delivered (574 unit tests; host-farm smoke green on 0.1.2 / 0.1.5 / 0.1.7 / 0.2.1 lines). Outstanding real-device acceptance items are registered in CHANGELOG.
12
12
 
13
- ## Install (npm package available after M1)
13
+ Highlights:
14
+
15
+ - **Channels**: WeChat (official iLink QR bind), Feishu/Lark (long-connection, streaming cards, optional voice tier), Telegram, Discord, WeCom intelligent bot, DingTalk (stream + AI Card), QQ official bot, Slack (Socket Mode, zero SDK), WhatsApp (Baileys QR pairing), Matrix, iMessage (macOS only), WeCom self-built app (signed callback webhook), and more; unconfigured channels load nothing (lazy imports).
16
+ - **In-chat loop**: 24 commands (`/help /status /new /stop /steer /model(s) /reasoning /preset(s) /session(s) /workspace(s) /fork /rename /queue /mute /unbind /goal /delivery /export /speak /menu`); tool approvals and agent questions answered in the originating chat (deny-by-default, 5-minute expiry, initiator only).
17
+ - **Cross-version**: settings surface auto-adapts gen1/gen2/gen3; capability probing only, never version branches.
18
+
19
+ ## Install
14
20
 
15
21
  ```sh
16
22
  dsh plugin --profile web add dsh-im-union@latest --registry=https://registry.npmjs.org/
17
23
  ```
18
24
 
25
+ Then add accounts in the "IM union bridge" panel: most channels use credential binding (secrets are one-way into the vault and never echoed back); WeChat/WhatsApp use QR pairing; the WeCom self-built app channel requires pointing your callback URL (`/api/dsh-im-union/wecom-app/callback`) in its console. See `SECURITY.md` for the non-negotiable privacy red lines and `THIRD_PARTY_NOTICES.md` for the clean-room provenance.
26
+
19
27
  ## Development
20
28
 
21
29
  ```sh
package/README.md CHANGED
@@ -8,14 +8,22 @@ DeepSeek Harness 的 IM 联合桥——净室重写,能力合集来自社区
8
8
 
9
9
  ## 状态
10
10
 
11
- 开发中(M0 跨版本骨架)。能力分期见根目录 `tasks.md`。
11
+ **0.1.0 功能版**:P0/P1/P2 十四渠道协议层、跨版本窄腰、会话引擎、审批/提问 IM 闭环、多账号管理面均已交付(单测 574 项;宿主农场 0.1.2/0.1.5/0.1.7/0.2.1 四线冒烟全绿)。真机验收项逐项登记在 CHANGELOG;能力分期见开发仓计划台账。
12
12
 
13
- ## 安装(M1 后提供 npm 包)
13
+ 能力总览:
14
+
15
+ - **渠道**:微信(官方 iLink 扫码)、飞书(长连/流式卡片/语音档可选)、Telegram、Discord、企微智能机器人(长连)、钉钉(stream+AI Card)、QQ 官方机器人、Slack(Socket Mode)、WhatsApp(Baileys 扫码)、Matrix、iMessage(仅 macOS)、企微自建应用(回调)等;未配置渠道零依赖零加载(懒 import)。
16
+ - **IM 内闭环**:24 命令面板(/help /status /new /stop /steer /model(s) /reasoning /preset(s) /session(s) /workspace(s) /fork /rename /queue /mute /unbind /goal /delivery /export /speak /menu);工具审批与交互提问原聊天回答(默认拒绝、5 分钟超时、发起者限定)。
17
+ - **跨版本**:设置面 gen1/gen2/gen3 三态自适配;能力探测优先,禁版本号分支。
18
+
19
+ ## 安装
14
20
 
15
21
  ```sh
16
22
  dsh plugin --profile web add dsh-im-union@latest --registry=https://registry.npmjs.org/
17
23
  ```
18
24
 
25
+ 装好后在宿主侧栏/设置页的「IM 联合桥」面板添加账号:大部分渠道走凭据绑定(Bot Token/App 凭据,secret 字段只单向入库不回显);微信/WhatsApp 走扫码绑定;企微自建应用需将本插件管理面回调 URL(`/api/dsh-im-union/wecom-app/callback`)配入后台接收服务器。隐私与凭据红线见 `SECURITY.md`;第三方依赖声明见 `THIRD_PARTY_NOTICES.md`(净室重实现,不复制任何上游源码)。
26
+
19
27
  ## 开发
20
28
 
21
29
  ```sh
@@ -16,12 +16,22 @@ dependencies only.
16
16
 
17
17
  ## Runtime npm dependencies (consumed as-is, licenses per their packages)
18
18
 
19
- - @larksuiteoapi/node-sdk (MIT) — Feishu/Lark bot long-connection SDK
20
- - dingtalk-stream (MIT) — DingTalk Stream mode SDK
21
- - @wecom/aibot-node-sdk — WeCom intelligent-bot WebSocket SDK
22
- - @tencent-connect/qqbot-connector — QQ bot QR pairing helper (declared
23
- UNLICENSED upstream; used as an external dependency only, never vendored)
24
- - qrcode (MIT) — QR rendering for scan-to-bind flows
19
+ - @larksuiteoapi/node-sdk (MIT) — Feishu/Lark bot long-connection SDK (its WS
20
+ handshake/signature surface is a private protocol, so the official SDK is used
21
+ instead of a clean-room reimplementation; lazily imported)
22
+ - @whiskeysockets/baileys (MPL-2.0) — WhatsApp Web multi-device protocol library
23
+ (lazily imported; only the WhatsApp channel touches it)
24
+ - qrcode (MIT) — QR PNG data-URL rendering for the WeChat iLink / WhatsApp
25
+ scan-to-bind flows (lazily imported)
25
26
 
26
- P1/P2 additions will be listed here as they land (Baileys, @matrix-org/olm,
27
- imapflow/nodemailer, sharp — none are dependencies at M0).
27
+ Everything else (Telegram, Discord, Slack, QQ, DingTalk, WeCom bot long-connection,
28
+ WeCom self-built app callback crypto, WeChat iLink transport, Matrix CS API) is
29
+ implemented directly against public protocol documents with ZERO runtime
30
+ dependencies (Node >= 22 global fetch/WebSocket + node:crypto/node:zlib only).
31
+ Earlier drafts of this file listed dingtalk-stream, @wecom/aibot-node-sdk and
32
+ @tencent-connect/qqbot-connector; those SDKs were never adopted — the respective
33
+ channels are zero-dependency clean-room implementations (corrected at 0.1.0).
34
+
35
+ Optional external tools: ffmpeg (system binary, spawned only when the Feishu
36
+ voice tier is explicitly enabled; never bundled, never assumed present — the
37
+ feature degrades honestly when missing).
package/client.js CHANGED
@@ -37,6 +37,20 @@ window.__ModuleLoader__.load({
37
37
  'accounts.title': '账号管理',
38
38
  'accounts.empty': '暂无账号,在下方添加。',
39
39
  'accounts.add': '添加账号',
40
+ 'accounts.qr.get': '获取登录二维码',
41
+ 'accounts.qr.refresh': '重新获取二维码',
42
+ 'accounts.qr.cancel': '取消扫码',
43
+ 'accounts.qr.needid': '请先填写账号 ID 再获取二维码',
44
+ 'accounts.qr.wait': '等待扫码…(用微信扫上方二维码)',
45
+ 'accounts.qr.scaned': '已扫码,请在手机上确认授权',
46
+ 'accounts.qr.expired': '二维码已过期,请重新获取',
47
+ 'accounts.qr.need_verifycode': '请输入手机上显示的验证码',
48
+ 'accounts.qr.verify_code_blocked': '验证码错误次数过多,请重新获取二维码',
49
+ 'accounts.qr.binded_redirect': '该 Bot 已绑定其它实例',
50
+ 'accounts.qr.scaned_but_redirect': '已扫码,正在重定向等待确认',
51
+ 'accounts.qr.code.submit': '提交验证码',
52
+ 'accounts.qr.confirmed': '扫码绑定完成,凭据已保存并连接',
53
+ 'accounts.saved.bindhint': '已保存(未连接):请用「获取登录二维码」完成扫码授权后自动连接',
40
54
  'accounts.save': '保存',
41
55
  'accounts.cancel': '取消',
42
56
  'accounts.check': '检查连接',
@@ -73,6 +87,20 @@ window.__ModuleLoader__.load({
73
87
  'accounts.title': 'Accounts',
74
88
  'accounts.empty': 'No accounts yet. Add one below.',
75
89
  'accounts.add': 'Add account',
90
+ 'accounts.qr.get': 'Get login QR code',
91
+ 'accounts.qr.refresh': 'Refresh QR code',
92
+ 'accounts.qr.cancel': 'Cancel',
93
+ 'accounts.qr.needid': 'Fill the account ID first, then get the QR code',
94
+ 'accounts.qr.wait': 'Waiting for scan… (scan with WeChat)',
95
+ 'accounts.qr.scaned': 'Scanned — confirm on your phone',
96
+ 'accounts.qr.expired': 'QR expired, fetch a new one',
97
+ 'accounts.qr.need_verifycode': 'Enter the code shown on your phone',
98
+ 'accounts.qr.verify_code_blocked': 'Too many wrong codes, fetch a new QR',
99
+ 'accounts.qr.binded_redirect': 'This bot is already bound to another instance',
100
+ 'accounts.qr.scaned_but_redirect': 'Scanned — redirecting, waiting for confirm',
101
+ 'accounts.qr.code.submit': 'Submit code',
102
+ 'accounts.qr.confirmed': 'QR binding done — credentials saved and connected',
103
+ 'accounts.saved.bindhint': 'Saved (disconnected): use "Get login QR code" to bind, it connects automatically',
76
104
  'accounts.save': 'Save',
77
105
  'accounts.cancel': 'Cancel',
78
106
  'accounts.check': 'Check connection',
@@ -176,6 +204,10 @@ window.__ModuleLoader__.load({
176
204
  var allowHook = useState({});
177
205
  var allowDrafts = allowHook[0];
178
206
  var setAllowDrafts = allowHook[1];
207
+ // 微信扫码绑定态(T1.2):null | {qrcode, qrDataUrl, accountId, status, verifyCode?}
208
+ var qrHook = useState(null);
209
+ var qr = qrHook[0];
210
+ var setQr = qrHook[1];
179
211
 
180
212
  var load = function () {
181
213
  getJSON('/accounts').then(function (result) {
@@ -202,10 +234,14 @@ window.__ModuleLoader__.load({
202
234
  }
203
235
 
204
236
  var submitNew = function () {
237
+ // 微信扫码账号:表单存而连——凭据由扫码流程写入,保存时必须跳过
238
+ // 适配器构建(否则 botToken 缺失工厂即抛 → 被当校验错 400,UI 假报)。
239
+ var payload = { record: form, secrets: form.secrets };
240
+ if (form.channel === 'weixin' && (form.secrets.botToken || '') === '') payload.skipConnect = true;
205
241
  withBusy(function () {
206
- return postJSON('/accounts/save', { record: form, secrets: form.secrets }).then(function (result) {
242
+ return postJSON('/accounts/save', payload).then(function (result) {
207
243
  if (result.status === 200) {
208
- setNotice(t('accounts.saved'));
244
+ setNotice(form.channel === 'weixin' && payload.skipConnect ? t('accounts.saved.bindhint') : t('accounts.saved'));
209
245
  setAddOpen(false);
210
246
  setForm({ channel: form.channel, accountId: '', alias: '', secrets: {} });
211
247
  load();
@@ -243,6 +279,51 @@ window.__ModuleLoader__.load({
243
279
  });
244
280
  };
245
281
 
282
+ var startQr = function () {
283
+ if (form.accountId === '') {
284
+ setNotice(t('accounts.qr.needid'));
285
+ return;
286
+ }
287
+ withBusy(function () {
288
+ return postJSON('/weixin/qr/start', {}).then(function (result) {
289
+ if (result.status === 200 && result.json && result.json.ok) {
290
+ setQr({ qrcode: result.json.qrcode, qrDataUrl: result.json.qrDataUrl, accountId: form.accountId, status: 'wait' });
291
+ } else {
292
+ setNotice((result.json && result.json.error) || 'HTTP ' + result.status);
293
+ }
294
+ });
295
+ });
296
+ };
297
+
298
+ // 状态驱动串行轮询:每次 setQr 产生新对象触发 effect 重跑下一拍;
299
+ // 服务端每请求 hold 最长 ~35s,前端不并发叠轮(cleanup 置活旗防串台)。
300
+ useEffect(function () {
301
+ if (qr === null) return undefined;
302
+ if (qr.status === 'confirmed' || qr.status === 'expired' || qr.status === 'need_verifycode' || qr.status === 'verify_code_blocked') return undefined;
303
+ var alive = true;
304
+ var payload = { qrcode: qr.qrcode, accountId: qr.accountId };
305
+ if (typeof qr.verifyCode === 'string' && qr.verifyCode !== '') payload.verifyCode = qr.verifyCode;
306
+ postJSON('/weixin/qr/status', payload).then(function (result) {
307
+ if (!alive) return;
308
+ if (result.status === 200 && result.json && result.json.ok) {
309
+ var status = result.json.status;
310
+ if (status === 'confirmed') {
311
+ setQr(Object.assign({}, qr, { status: 'confirmed' }));
312
+ setNotice(t('accounts.qr.confirmed'));
313
+ setAddOpen(false);
314
+ load();
315
+ } else {
316
+ setQr(Object.assign({}, qr, { status: status }));
317
+ }
318
+ } else if (alive) {
319
+ setQr(Object.assign({}, qr, { status: 'expired' })); // 请求异常保守收轮:引导重新取码而非假进度
320
+ }
321
+ }).catch(function () {
322
+ if (alive) setQr(Object.assign({}, qr, { status: 'expired' }));
323
+ });
324
+ return function () { alive = false; };
325
+ }, [qr]);
326
+
246
327
  var body = [];
247
328
  body.push(createElement('h4', null, t('accounts.title')));
248
329
  if (notice !== '') body.push(createElement('p', { style: { color: '#a33' } }, notice));
@@ -350,6 +431,48 @@ window.__ModuleLoader__.load({
350
431
  )
351
432
  );
352
433
  });
434
+ var qrBlock = null;
435
+ if (form.channel === 'weixin') {
436
+ if (qr === null || qr.status === 'expired') {
437
+ qrBlock = createElement(
438
+ 'div',
439
+ { key: 'qr-idle', style: { marginTop: 8 } },
440
+ createElement('button', { onClick: startQr, disabled: busy }, t(qr === null ? 'accounts.qr.get' : 'accounts.qr.refresh'))
441
+ );
442
+ } else if (qr.status === 'confirmed') {
443
+ qrBlock = createElement('p', { key: 'qr-done', style: { color: '#2a6' } }, t('accounts.qr.confirmed'));
444
+ } else {
445
+ var qrChildren = [
446
+ createElement('img', { key: 'img', src: qr.qrDataUrl, width: 200, height: 200, alt: 'login qr', style: { display: 'block' } }),
447
+ createElement('p', { key: 'st', style: { marginTop: 4 } }, t('accounts.qr.' + qr.status)),
448
+ ];
449
+ if (qr.status === 'need_verifycode') {
450
+ qrChildren.push(
451
+ createElement(
452
+ 'div',
453
+ { key: 'vc' },
454
+ createElement('input', {
455
+ value: qr.verifyCode || '',
456
+ onChange: function (event) { setQr(Object.assign({}, qr, { verifyCode: event.target.value })); },
457
+ }),
458
+ createElement(
459
+ 'button',
460
+ { style: { marginLeft: 6 }, onClick: function () { setQr(Object.assign({}, qr, { status: 'wait' })); } },
461
+ t('accounts.qr.code.submit')
462
+ )
463
+ )
464
+ );
465
+ }
466
+ qrChildren.push(
467
+ createElement(
468
+ 'button',
469
+ { key: 'cancel', style: { marginTop: 4 }, onClick: function () { setQr(null); } },
470
+ t('accounts.qr.cancel')
471
+ )
472
+ );
473
+ qrBlock = createElement('div', { key: 'qr-live', style: { marginTop: 8, padding: 8, border: '1px solid #ddd', borderRadius: 8 } }, qrChildren);
474
+ }
475
+ }
353
476
  body.push(
354
477
  createElement(
355
478
  'div',
@@ -393,6 +516,7 @@ window.__ModuleLoader__.load({
393
516
  })
394
517
  ),
395
518
  fieldInputs,
519
+ qrBlock,
396
520
  createElement(
397
521
  'div',
398
522
  { style: { marginTop: 8 } },
package/dsh.plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "dsh-im-union",
3
3
  "name": "dsh-im-union",
4
- "version": "0.0.1",
4
+ "version": "0.1.0",
5
5
  "description": {
6
6
  "en": "Union IM bridge for DeepSeek Harness: multi-account bots across messaging platforms, each chat bound to its own session, with cross-host-version compatibility (0.1.2-rc.1 .. 0.2.1-alpha.3).",
7
7
  "zh": "DeepSeek Harness 的 IM 联合桥:多平台多账号机器人、每个聊天绑定独立会话,跨宿主版本兼容(0.1.2-rc.1 至 0.2.1-alpha.3)。"
@@ -22,6 +22,10 @@ export interface AdminRequest {
22
22
  url: URL;
23
23
  body: unknown;
24
24
  respond: AdminResponder;
25
+ /** raw 路由(第三方回调)专用:原文 body 文本 + 查询参数表 + 非 JSON 应答面。 */
26
+ rawBody?: string;
27
+ query?: Record<string, string>;
28
+ respondText?: (status: number, text: string) => void;
25
29
  }
26
30
  export type AdminHandler = (request: AdminRequest) => void | Promise<void>;
27
31
  /** 写请求前置检查;返回错误状态码或 undefined。 */
@@ -29,9 +33,23 @@ export declare function validateMutation(req: IncomingMessage): number | undefin
29
33
  export interface AdminApiHandle {
30
34
  dispose(): void;
31
35
  }
36
+ /**
37
+ * 第三方回调路由白名单(T3.4 企微自建应用等 inbound webhook)。
38
+ * 口径登记:D2/D3 的 requestRejection/JSON 闸适用于**管理面**(浏览器/本机调用);
39
+ * 平台回调来自厂商服务器、无登录 Cookie,其认证就是签名验证(SHA1+AES,
40
+ * fail-closed)——因此命中的路径跳过 requestRejection 与 JSON 前置闸,
41
+ * 改由 handler 自身验签;仍走同一前缀注册、no-store、体积上限与错误零回显纪律。
42
+ */
43
+ export interface RawRoute {
44
+ path: string;
45
+ /** 允许 GET 回明文(echostr 验证面原样输出,不包 JSON)。 */
46
+ plainText?: boolean;
47
+ }
32
48
  /**
33
49
  * 注册管理面。routes 用精确路径匹配(M0 只挂 /ping 与 /state 两个只读探针,
34
50
  * 账号 CRUD 随 M1 渠道层落地)。webServer 缺失(非 web profile)→ 不注册,
35
51
  * 记日志不抛错——CLI/TUI 宿主上插件其余部分照常。
36
52
  */
37
- export declare function registerAdminApi(ctx: CordisLike, routes: Record<string, AdminHandler>): AdminApiHandle;
53
+ export declare function registerAdminApi(ctx: CordisLike, routes: Record<string, AdminHandler>, opts?: {
54
+ rawRoutes?: RawRoute[];
55
+ }): AdminApiHandle;
package/lib/admin/api.js CHANGED
@@ -43,7 +43,8 @@ export function validateMutation(req) {
43
43
  * 账号 CRUD 随 M1 渠道层落地)。webServer 缺失(非 web profile)→ 不注册,
44
44
  * 记日志不抛错——CLI/TUI 宿主上插件其余部分照常。
45
45
  */
46
- export function registerAdminApi(ctx, routes) {
46
+ export function registerAdminApi(ctx, routes, opts) {
47
+ const rawPaths = new Map((opts?.rawRoutes ?? []).map((r) => [r.path, r]));
47
48
  const log = ctx.logger('dsh-im-union');
48
49
  const webServer = optionalService(ctx, 'webServer');
49
50
  if (webServer === undefined || typeof webServer.register !== 'function') {
@@ -55,7 +56,67 @@ export function registerAdminApi(ctx, routes) {
55
56
  path: BASE_PATH,
56
57
  handler: async (req, res) => {
57
58
  try {
58
- // 动态读取,不缓存认证实例(HMR/重启后实例会变)。
59
+ const url = new URL(req.url ?? '/', 'http://localhost');
60
+ const method = (req.method ?? 'GET').toUpperCase();
61
+ if (url.pathname !== BASE_PATH && !url.pathname.startsWith(BASE_PATH + '/')) {
62
+ send(res, 404, { ok: false, error: 'not found' });
63
+ return;
64
+ }
65
+ const path = url.pathname.slice(BASE_PATH.length) || '/';
66
+ const handler = routes[path];
67
+ if (handler === undefined) {
68
+ send(res, 404, { ok: false, error: 'not found' });
69
+ return;
70
+ }
71
+ const rawRoute = rawPaths.get(path);
72
+ if (rawRoute !== undefined) {
73
+ // 第三方回调面:验签即认证,读原文 body(≤1 MiB)后交 handler 处置。
74
+ let rawBody = '';
75
+ if (method !== 'GET' && method !== 'HEAD') {
76
+ const chunks = [];
77
+ let total = 0;
78
+ for await (const chunk of req) {
79
+ const buf = chunk;
80
+ total += buf.length;
81
+ if (total > MAX_BODY_BYTES) {
82
+ send(res, 413, { ok: false, error: '请求体超限' });
83
+ return;
84
+ }
85
+ chunks.push(buf);
86
+ }
87
+ rawBody = Buffer.concat(chunks).toString('utf8');
88
+ }
89
+ const query = {};
90
+ url.searchParams.forEach((value, key) => {
91
+ query[key] = value;
92
+ });
93
+ let settled = false;
94
+ await handler({
95
+ method,
96
+ path,
97
+ url,
98
+ body: query,
99
+ rawBody,
100
+ query,
101
+ respond: (status, payload) => {
102
+ settled = true;
103
+ send(res, status, payload);
104
+ },
105
+ respondText: rawRoute.plainText
106
+ ? (status, text) => {
107
+ settled = true;
108
+ res.statusCode = status;
109
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
110
+ res.setHeader('Cache-Control', 'no-store');
111
+ res.end(text);
112
+ }
113
+ : undefined,
114
+ });
115
+ if (!settled)
116
+ send(res, 200, { ok: true });
117
+ return;
118
+ }
119
+ // 以下 requestRejection/JSON 闸只约束管理面(动态读取,不缓存认证实例)。
59
120
  const connection = optionalService(ctx, 'connection');
60
121
  if (typeof connection?.requestRejection !== 'function') {
61
122
  send(res, 503, { ok: false, error: '宿主认证服务未就绪或版本不支持;请重启 DSH 或升级宿主。' });
@@ -74,19 +135,7 @@ export function registerAdminApi(ctx, routes) {
74
135
  send(res, mutationError, { ok: false, error: '写请求需 application/json 与客户端标识头' });
75
136
  return;
76
137
  }
77
- const url = new URL(req.url ?? '/', 'http://localhost');
78
- if (url.pathname !== BASE_PATH && !url.pathname.startsWith(BASE_PATH + '/')) {
79
- send(res, 404, { ok: false, error: 'not found' });
80
- return;
81
- }
82
- const path = url.pathname.slice(BASE_PATH.length) || '/';
83
- const handler = routes[path];
84
- if (handler === undefined) {
85
- send(res, 404, { ok: false, error: 'not found' });
86
- return;
87
- }
88
138
  let body = {};
89
- const method = (req.method ?? 'GET').toUpperCase();
90
139
  if (method !== 'GET' && method !== 'HEAD') {
91
140
  try {
92
141
  body = await readJson(req);
@@ -0,0 +1,186 @@
1
+ import type { ChatAddress } from '../engine/router.js';
2
+ import type { ChannelAdapter, ChannelCaps, ChannelDiagnosisCheck, MessageListener, OutboundFile, Receipt, ReplyStream, SemanticMessage } from './types.js';
3
+ interface WSLike {
4
+ send(data: string): void;
5
+ close(): void;
6
+ addEventListener(type: string, cb: (event: any) => void): void;
7
+ readyState: number;
8
+ }
9
+ type WSFactory = (url: string) => WSLike;
10
+ type FetchResponseLike = {
11
+ ok: boolean;
12
+ status: number;
13
+ json(): Promise<unknown>;
14
+ arrayBuffer?(): Promise<ArrayBuffer>;
15
+ };
16
+ type FetchLike = (url: string, init?: {
17
+ method?: string;
18
+ headers?: Record<string, string>;
19
+ body?: string | Uint8Array;
20
+ signal?: AbortSignal;
21
+ }) => Promise<FetchResponseLike>;
22
+ export interface DingtalkAdapterOptions {
23
+ clientId: string;
24
+ clientSecret: string;
25
+ /** 覆盖 REST 基址(测试注入/专有云代理)。 */
26
+ apiBase?: string;
27
+ /** 覆盖 oapi 基址(素材上传面,测试注入用)。 */
28
+ oapiBase?: string;
29
+ /** AI 卡片模板 ID;缺位=不启卡片档(文本档)。 */
30
+ cardTemplateId?: string;
31
+ /** 卡片流式变量名(以模板定义为准,默认 content)。 */
32
+ cardTemplateKey?: string;
33
+ /** 流式档位:card=AI Card 优先(默认,需 cardTemplateId)/text=纯文本。 */
34
+ cardMode?: 'card' | 'text';
35
+ /** 机器人编码;文档口径企业内部应用即 AppKey(=clientId)。 */
36
+ robotCode?: string;
37
+ WebSocketImpl?: WSFactory;
38
+ fetchImpl?: FetchLike;
39
+ requestTimeoutMs?: number;
40
+ idleReconnectMs?: number;
41
+ /** 卡片流式帧间隔(默认 1100ms,避开群 webhook ~20 条/分钟量级;测试可置 0)。 */
42
+ streamThrottleMs?: number;
43
+ logger?: (msg: string) => void;
44
+ }
45
+ /** HTTP 4xx / errcode·code 明确拒绝 = 确定性失败(服务端未投递,可安全告知)。 */
46
+ export declare class DingTalkApiError extends Error {
47
+ /** 平台错误码或 HTTP 状态字符串(判型/日志用,不进用户可见文案)。 */
48
+ readonly code: string;
49
+ constructor(message: string,
50
+ /** 平台错误码或 HTTP 状态字符串(判型/日志用,不进用户可见文案)。 */
51
+ code: string);
52
+ }
53
+ /** 结果不明(网络错/超时/5xx/断连)——D7:禁自动重发。 */
54
+ export declare class DingTalkUncertainError extends Error {
55
+ }
56
+ export declare class DingtalkAdapter implements ChannelAdapter {
57
+ readonly id = "dingtalk";
58
+ readonly label = "\u9489\u9489";
59
+ readonly caps: ChannelCaps;
60
+ connected: boolean;
61
+ /** 卡片面确定性失败后置位:本实例不再尝试卡片(内部退化文本档)。 */
62
+ cardDisabled: boolean;
63
+ private readonly clientId;
64
+ private readonly clientSecret;
65
+ private readonly apiBase;
66
+ private readonly oapiBase;
67
+ private readonly cardTemplateId;
68
+ private readonly cardTemplateKey;
69
+ private readonly cardMode;
70
+ private readonly robotCodeConfigured;
71
+ private readonly wsFactory;
72
+ private readonly fetchImpl;
73
+ private readonly requestTimeoutMs;
74
+ private readonly idleReconnectMs;
75
+ private readonly streamThrottleMs;
76
+ private readonly log;
77
+ private ws;
78
+ private listeners;
79
+ private tokenCache;
80
+ private observedRobotCode;
81
+ /** msgId 排重(事件面可能重放)。 */
82
+ private readonly seenMsgIds;
83
+ /** kind:chatId → 回调自带 sessionWebhook(回复首选面,带过期时间)。 */
84
+ private readonly webhookByChat;
85
+ /** kind:chatId → 发送者 userId(batchSend 主动推送用)。 */
86
+ private readonly userIdByChat;
87
+ /** kind:chatId → conversationId(群侧推送 openConversationId 候选)。 */
88
+ private readonly conversationIdByChat;
89
+ private idleTimer;
90
+ private reconnectTimer;
91
+ private lastInboundAt;
92
+ private backoffMs;
93
+ private stopping;
94
+ constructor(options: DingtalkAdapterOptions);
95
+ /** 卡片档是否可用(未配模板/显式 text 档/已确定性失败 → 否)。 */
96
+ private get cardEnabled();
97
+ private get robotCode();
98
+ connect(signal?: AbortSignal): Promise<void>;
99
+ /** 注册 ticket→建连。ticket 90s 单次有效,故每次建连都重新注册。 */
100
+ private openOnce;
101
+ /** POST /v1.0/gateway/connections/open → {endpoint, ticket}。 */
102
+ private openGatewayTicket;
103
+ disconnect(): Promise<void>;
104
+ /** close 后自动重连(指数退避);主动 disconnect/拆除期不重连。 */
105
+ private scheduleReconnect;
106
+ private startIdleWatchdog;
107
+ private stopIdleWatchdog;
108
+ onMessage(listener: MessageListener): () => void;
109
+ private handleFrame;
110
+ /** 回一帧 ACK(messageId 必须与推送一致;发送失败只记日志,不改判业务)。 */
111
+ private ack;
112
+ private parseData;
113
+ /** 回调 payload → 上下文登记 + 排重 + 归一(导出 normalize 供单测直验)。 */
114
+ private handleCallback;
115
+ /**
116
+ * 机器人回调 payload → SemanticMessage。
117
+ * conversationType '2'→group(chatId=conversationId)/'1'→dm(chatId=senderStaffId);
118
+ * 机器人自发回声丢弃;非文本类型给占位文案不丢事件(与飞书同纪律)。
119
+ */
120
+ normalize(payload: Record<string, any>): SemanticMessage | undefined;
121
+ /** 富文本片段 → 线性文本(图片位以占位标注,不丢事件)。 */
122
+ private richTextToText;
123
+ /**
124
+ * 回复:回调自带 sessionWebhook 优先(无需 token);无上下文/已过期 →
125
+ * 机器人主动推送面(batchSend / groupMessages.send)。
126
+ * markdown 优先,格式类拒绝回退纯文本(回退链同 telegram 思路)。
127
+ */
128
+ reply(address: ChatAddress, text: string, signal?: AbortSignal): Promise<Receipt>;
129
+ private replyViaWebhook;
130
+ /** webhook POST:响应 {errcode,errmsg};errcode=0 即成功。 */
131
+ private postWebhook;
132
+ /** 主动推送面(v1.0 机器人接口,需 accessToken)。 */
133
+ private replyViaRobotSend;
134
+ private robotSend;
135
+ /**
136
+ * 出站文件(公开面两步,均已核实):
137
+ * 1) POST {oapiBase}/media/upload?access_token=…&type=file,multipart/form-data
138
+ * 字段名固定 media → { media_id }(普通文件 ≤20MB、3 天有效)
139
+ * 2) 机器人推送 msgKey='sampleFile',msgParam=JSON {mediaId, fileName, fileType}
140
+ * ——文档:机器人发送消息类型 types-of-messages-sent-by-robots
141
+ * 注:协议只支持企业内部应用/第三方企业应用机器人(本适配器即此档);
142
+ * webhook 自定义机器人无文件外发公开面。
143
+ * 判型沿用文本面:errcode≠0/4xx = 确定性 DingTalkApiError;超时/传输失败 = DingTalkUncertainError(D7)。
144
+ */
145
+ sendFile(address: ChatAddress, file: OutboundFile, signal?: AbortSignal): Promise<Receipt>;
146
+ /** 素材上传 multipart:oapi 口径 errcode≠0 / 非 2xx = 确定性;传输失败 = uncertain(不重传)。 */
147
+ private uploadMedia;
148
+ /**
149
+ * 下载码 → 临时链接 → 取字节。
150
+ * 文档:下载机器人接收消息的文件内容
151
+ * https://open.dingtalk.com/document/development/download-the-file-content-of-the-robot-receiving-message
152
+ * 任何失败均降级:保留占位文案加说明,不抛不丢消息(入站面无 Receipt 语义)。
153
+ */
154
+ private fillFile;
155
+ /**
156
+ * AI Card 流式:createAndDeliver 起卡 → PUT /v1.0/card/streaming 逐帧全量
157
+ * (isFull=true,markdown 变量硬性要求;guid 每帧唯一做幂等标志)→
158
+ * finish 帧 isFinalize=true(卡片状态机自动切「完成」档)。
159
+ */
160
+ beginStream(address: ChatAddress, signal?: AbortSignal): Promise<ReplyStream>;
161
+ /** 文本档流式:中间快照无可编辑承载面,只缓存;收轮一次性 reply。 */
162
+ private textStream;
163
+ private createAndDeliverCard;
164
+ private streamUpdate;
165
+ /** accessToken 自管(expireIn 7200s,提前 60s 刷新;文档要求按应用维度缓存)。 */
166
+ private accessToken;
167
+ /** v1.0 接口调用:token 头 + 错误分级(4xx/code 确定性,5xx/网络 uncertain)。 */
168
+ private restV1;
169
+ /**
170
+ * POST JSON 底座:返回 {status, body}。
171
+ * 网络异常/超时/HTTP 5xx → DingTalkUncertainError(D7 禁重发);
172
+ * HTTP 4xx 与业务码由调用方判确定性拒绝。
173
+ */
174
+ private send;
175
+ /** 渠道内不裁决准入:白名单在网关(红线 D6),恒交全局。 */
176
+ authorize(): boolean | undefined;
177
+ /** 诊断三态:credentials 实探(换 token);roundtrip 恒 unverified;零 secret 回显。 */
178
+ diagnose(signal?: AbortSignal): Promise<ChannelDiagnosisCheck[]>;
179
+ }
180
+ /** 工厂挂 registry(secrets 取 meta 里的钉钉字段名 clientId/clientSecret;config 可含 apiBase 覆盖)。 */
181
+ export declare function createDingtalkAdapter(options: {
182
+ accountId: string;
183
+ config: Record<string, string>;
184
+ secrets: Record<string, string | undefined>;
185
+ }): DingtalkAdapter;
186
+ export {};