@wenbin_wb/dsh-bridge 2.0.4 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,6 +18,7 @@ Seamlessly extend your local DeepSeek Harness to mobile phones, tablets, public
18
18
  - **Cloudflare Tunnel**: One-click public internet exposure, connect from anywhere without a public server of your own — keep working even when you're away from home
19
19
  - **Custom Tunnel**: Connect to your own tunnel server with a fixed domain ([Setup Guide](docs/custom-tunnel.md))
20
20
  - **WeChat Bot (ClawBot / iLink)**: Scan a QR code to log in a WeChat personal account, then chat with, control, and approve your DeepSeek Harness agents right inside WeChat. **Multi-workspace selection, restart-persistent sessions, grouped session listing with titles, media (image/file/voice) transfer, and permission approvals** — over Tencent's official iLink Bot API, no public server or tunnel required ([Usage Guide](docs/wechat-usage.md))
21
+ - **QQ Bot (OpenAPI v2)**: Connect your QQ Bot to receive private/group messages, send Markdown, button keyboards, and rich media. **Full event coverage (C2C / GROUP_AT_MESSAGE_CREATE), auto token refresh, reconnection with backoff, message deduplication** — over Tencent's official QQ Bot OpenAPI v2 ([Usage Guide](docs/qq-usage.md))
21
22
  - **IM Integration (More Planned)**: WeChat / QQ / Feishu / OpenClaw direct chat integration
22
23
  - **Security Alerts**: URLs and QR codes with access warnings to prevent accidental sharing
23
24
  - **Auto Version Check**: Automatic update detection when entering the panel
@@ -35,7 +36,7 @@ Seamlessly extend your local DeepSeek Harness to mobile phones, tablets, public
35
36
  |--------|-------------|--------|
36
37
  | **Platform Abstraction** | Platform-agnostic core (sessions / approvals / commands / digest) shared across IM channels | ✅ **Completed** (v2.0.0) |
37
38
  | **WeChat** | Chat with your Agent directly in WeChat | ✅ Supported (workspaces / persisted sessions / media / approvals) |
38
- | **QQ Bot** | QQ bot integration for group/private chat | 🚧 Next (platform adapter implementation) |
39
+ | **QQ Bot** | QQ bot integration for group/private chat | **Completed** (v2.1.0) Markdown / buttons / rich media |
39
40
  | **Feishu** | Feishu message/bot integration for workplace scenarios | Planned |
40
41
  | **OpenClaw** | Integration with OpenClaw ecosystem | Planned |
41
42
  | **Telegram** | Self-hosted IM channel | Planned |
package/README.zh-CN.md CHANGED
@@ -18,7 +18,8 @@
18
18
  - **Cloudflare 隧道**:一键暴露公网地址,随时随地连接,出差在外、不在家也能接着干,无需自建公网服务器
19
19
  - **自建隧道**:连接自己的隧道服务器,获得固定域名([搭建教程](docs/custom-tunnel.md))
20
20
  - **微信 Bot(ClawBot / iLink)**:扫码登录微信个人号后,直接在微信里对话、控制 DeepSeek Harness 的 agent。**支持多工作区选择、会话跨重启持久化、按工作区分组查看、媒体(图片/文件/语音)收发、权限审批**——走腾讯官方 iLink Bot API,无需公网([使用说明](docs/wechat-usage.md))
21
- - **IM 集成(更多平台规划中)**:QQ / 飞书 / OpenClaw,直接在聊天软件里呼唤你的 Agent
21
+ - **QQ Bot(OpenAPI v2)**:接入 QQ 机器人,私聊/群聊接收消息,发送 Markdown、按钮键盘和富媒体。**完整事件覆盖(C2C / GROUP_AT_MESSAGE_CREATE)、Token 自动刷新、断线重连、消息去重**——走腾讯官方 QQ Bot OpenAPI v2([使用说明](docs/qq-usage.md))
22
+ - **IM 集成(更多平台规划中)**:飞书 / OpenClaw,直接在聊天软件里呼唤你的 Agent
22
23
  - **安全提示**:URL 和二维码带访问警告,防止误分享
23
24
  - **版本检查**:进入面板自动检测是否有新版本
24
25
 
@@ -35,7 +36,7 @@
35
36
  |------|------|------|
36
37
  | **平台抽象层** | 平台无关的核心(会话/审批/命令/digest)跨 IM 渠道复用 | ✅ **已完成**(v2.0.0) |
37
38
  | **微信** | 在微信里直接与你的 Agent 对话 | ✅ 已支持(多工作区 / 会话持久化 / 媒体 / 审批) |
38
- | **QQ Bot** | 接入 QQ 机器人,群聊/私聊唤起 Agent | 🚧 下一步(平台适配器实现) |
39
+ | **QQ Bot** | 接入 QQ 机器人,群聊/私聊唤起 Agent | **已完成**(v2.1.0)— Markdown / 按钮 / 富媒体 |
39
40
  | **飞书** | 飞书消息/机器人集成,办公场景直接调用 | 规划中 |
40
41
  | **OpenClaw** | 与 OpenClaw 生态打通 | 规划中 |
41
42
  | **Telegram** | 适合自托管的 IM 渠道 | 规划中 |
package/client/client.js CHANGED
@@ -323,7 +323,10 @@ function PlatformCard({ platformId, platformName, platformDesc, rpcCall, onStatu
323
323
  digestIntervalSec: String(platform.config.digestIntervalSec ?? 300),
324
324
  approvalTimeoutSec: String(platform.config.approvalTimeoutSec ?? 600),
325
325
  maxMessageChars: String(platform.config.maxMessageChars ?? 2e3),
326
- sendChunkDelayMs: String(platform.config.sendChunkDelayMs ?? 1500)
326
+ sendChunkDelayMs: String(platform.config.sendChunkDelayMs ?? 1500),
327
+ appId: platform.config.appId ?? "",
328
+ // Secret 不由后端回传;空值表示沿用已保存密钥
329
+ clientSecret: ""
327
330
  });
328
331
  }
329
332
  }, [platform?.config]);
@@ -380,14 +383,19 @@ function PlatformCard({ platformId, platformName, platformDesc, rpcCall, onStatu
380
383
  const handleNewId = React.useCallback((e) => setNewId(e.target.value), []);
381
384
  const saveConfig = React.useCallback(async () => {
382
385
  if (!cfgDraft) return;
383
- await act(BRIDGE_ENDPOINTS.platformSetConfig, {
386
+ const payload = {
384
387
  digestIntervalSec: Number(cfgDraft.digestIntervalSec),
385
388
  approvalTimeoutSec: Number(cfgDraft.approvalTimeoutSec),
386
389
  maxMessageChars: Number(cfgDraft.maxMessageChars),
387
390
  sendChunkDelayMs: Number(cfgDraft.sendChunkDelayMs)
388
- });
389
- }, [act, cfgDraft]);
390
- const cfgDirty = cfgDraft && platform?.config && (Number(cfgDraft.digestIntervalSec) !== platform.config.digestIntervalSec || Number(cfgDraft.approvalTimeoutSec) !== platform.config.approvalTimeoutSec || Number(cfgDraft.maxMessageChars) !== platform.config.maxMessageChars || Number(cfgDraft.sendChunkDelayMs) !== platform.config.sendChunkDelayMs);
391
+ };
392
+ if (platformId === "qq") {
393
+ payload.appId = cfgDraft.appId.trim();
394
+ payload.clientSecret = cfgDraft.clientSecret.trim();
395
+ }
396
+ await act(BRIDGE_ENDPOINTS.platformSetConfig, payload);
397
+ }, [act, cfgDraft, platformId]);
398
+ const cfgDirty = cfgDraft && platform?.config && (Number(cfgDraft.digestIntervalSec) !== platform.config.digestIntervalSec || Number(cfgDraft.approvalTimeoutSec) !== platform.config.approvalTimeoutSec || Number(cfgDraft.maxMessageChars) !== platform.config.maxMessageChars || Number(cfgDraft.sendChunkDelayMs) !== platform.config.sendChunkDelayMs || platformId === "qq" && (cfgDraft.appId !== (platform.config.appId ?? "") || cfgDraft.clientSecret !== (platform.config.clientSecret ?? "")));
391
399
  if (!platform && !err) {
392
400
  return React.createElement(
393
401
  "div",
@@ -510,7 +518,7 @@ function PlatformCard({ platformId, platformName, platformDesc, rpcCall, onStatu
510
518
  }, "\u89E3\u7ED1\u8D26\u53F7")
511
519
  )
512
520
  ),
513
- // 未配置 / 登录中:二维码
521
+ // 未配置 / 登录中:二维码(QQ 无二维码,显示凭证表单)
514
522
  (!platform?.configured || showQr) && React.createElement(
515
523
  "div",
516
524
  { style: s.block },
@@ -524,6 +532,51 @@ function PlatformCard({ platformId, platformName, platformDesc, rpcCall, onStatu
524
532
  login.phase === "scaned" ? "\u5DF2\u626B\u7801\uFF0C\u8BF7\u5728\u624B\u673A\u4E0A\u786E\u8BA4\u2026" : platformId === "wechat" ? "\u8BF7\u4F7F\u7528\u5FAE\u4FE1\u626B\u7801\u767B\u5F55\uFF08ClawBot\uFF09" : "\u8BF7\u626B\u7801\u767B\u5F55"
525
533
  ),
526
534
  login.error && React.createElement("div", { style: { ...s.muted, marginTop: 4, color: "var(--dsw-alias-state-warn-primary,#92400e)" } }, login.error)
535
+ ) : platformId === "qq" ? React.createElement(
536
+ "div",
537
+ { style: { display: "flex", flexDirection: "column", gap: 10, marginTop: 4 } },
538
+ React.createElement(
539
+ "div",
540
+ null,
541
+ React.createElement("div", { style: { ...s.muted, marginBottom: 4 } }, "AppID \u2014 QQ \u5F00\u653E\u5E73\u53F0\u673A\u5668\u4EBA\u5E94\u7528 ID"),
542
+ React.createElement("input", {
543
+ style: { ...s.input, width: "100%" },
544
+ placeholder: "\u8BF7\u8F93\u5165 AppID",
545
+ value: cfgDraft?.appId ?? "",
546
+ onChange: (e) => setCfgDraft((d) => ({ ...d, appId: e.target.value }))
547
+ })
548
+ ),
549
+ React.createElement(
550
+ "div",
551
+ null,
552
+ React.createElement("div", { style: { ...s.muted, marginBottom: 4 } }, "ClientSecret \u2014 QQ \u5F00\u653E\u5E73\u53F0\u673A\u5668\u4EBA\u5BC6\u94A5"),
553
+ React.createElement("input", {
554
+ style: { ...s.input, width: "100%" },
555
+ placeholder: "\u8BF7\u8F93\u5165 ClientSecret",
556
+ value: cfgDraft?.clientSecret ?? "",
557
+ onChange: (e) => setCfgDraft((d) => ({ ...d, clientSecret: e.target.value }))
558
+ })
559
+ ),
560
+ React.createElement(
561
+ "div",
562
+ null,
563
+ React.createElement("a", {
564
+ href: "https://bot.q.qq.com/wiki/develop/api-v2/",
565
+ target: "_blank",
566
+ rel: "noopener noreferrer",
567
+ style: s.btnLink
568
+ }, "\u{1F4D6} \u524D\u5F80 QQ \u5F00\u653E\u5E73\u53F0\u7533\u8BF7\u673A\u5668\u4EBA")
569
+ ),
570
+ React.createElement(
571
+ "div",
572
+ { style: { display: "flex", gap: 8, flexWrap: "wrap", alignItems: "center" } },
573
+ React.createElement("button", {
574
+ style: { ...s.btnPri, opacity: busy ? 0.5 : 1 },
575
+ onClick: saveConfig,
576
+ disabled: busy || !cfgDraft?.appId?.trim() || !cfgDraft?.clientSecret?.trim()
577
+ }, busy ? "\u4FDD\u5B58\u4E2D\u2026" : "\u4FDD\u5B58\u5E76\u8FDE\u63A5"),
578
+ login.phase === "error" && React.createElement("div", { style: { ...s.muted, fontSize: 12 } }, login.error ?? "\u8FDE\u63A5\u5931\u8D25")
579
+ )
527
580
  ) : React.createElement(
528
581
  "div",
529
582
  { style: { display: "flex", gap: 8, marginTop: 4, flexWrap: "wrap", alignItems: "center" } },
@@ -602,6 +655,33 @@ function PlatformCard({ platformId, platformName, platformDesc, rpcCall, onStatu
602
655
  onChange: (e) => setCfgDraft((d) => ({ ...d, sendChunkDelayMs: e.target.value }))
603
656
  })
604
657
  ),
658
+ // QQ 平台凭证
659
+ platformId === "qq" && React.createElement(
660
+ "div",
661
+ { style: { display: "flex", flexDirection: "column", gap: 10 } },
662
+ React.createElement(
663
+ "div",
664
+ null,
665
+ React.createElement("div", { style: { ...s.muted, marginBottom: 4 } }, "AppID \u2014 QQ \u5F00\u653E\u5E73\u53F0\u673A\u5668\u4EBA\u5E94\u7528 ID"),
666
+ React.createElement("input", {
667
+ style: { ...s.input, width: 220 },
668
+ placeholder: "\u8BF7\u8F93\u5165 AppID",
669
+ value: cfgDraft.appId,
670
+ onChange: (e) => setCfgDraft((d) => ({ ...d, appId: e.target.value }))
671
+ })
672
+ ),
673
+ React.createElement(
674
+ "div",
675
+ null,
676
+ React.createElement("div", { style: { ...s.muted, marginBottom: 4 } }, "ClientSecret \u2014 QQ \u5F00\u653E\u5E73\u53F0\u673A\u5668\u4EBA\u5BC6\u94A5"),
677
+ React.createElement("input", {
678
+ style: { ...s.input, width: 220 },
679
+ placeholder: "\u8BF7\u8F93\u5165 ClientSecret",
680
+ value: cfgDraft.clientSecret,
681
+ onChange: (e) => setCfgDraft((d) => ({ ...d, clientSecret: e.target.value }))
682
+ })
683
+ )
684
+ ),
605
685
  React.createElement("button", {
606
686
  style: { ...s.btnPri, alignSelf: "flex-start", opacity: cfgDirty && !busy ? 1 : 0.5 },
607
687
  disabled: !cfgDirty || busy,
@@ -615,7 +695,7 @@ function PlatformCard({ platformId, platformName, platformDesc, rpcCall, onStatu
615
695
  React.createElement(
616
696
  "div",
617
697
  { style: { ...s.tip, fontSize: 12 } },
618
- platformId === "wechat" ? "\u8BF4\u660E: \u626B\u7801\u6210\u529F\u540E\uFF0C\u5411\u8BE5\u5FAE\u4FE1 Bot \u53D1\u9001\u7B2C\u4E00\u6761\u6D88\u606F\u5373\u81EA\u52A8\u5B8C\u6210\u767D\u540D\u5355\u6388\u6743\u3002\u4EC5\u767D\u540D\u5355\u5185\u7684\u5FAE\u4FE1\u7528\u6237\u80FD\u9A71\u52A8 agent\uFF0C\u5176\u4ED6\u4EBA\u6D88\u606F\u4F1A\u88AB\u5FFD\u7565\u3002\u4F7F\u7528\u4E13\u7528\u5FAE\u4FE1\u53F7\uFF0C\u907F\u514D\u5F71\u54CD\u4E3B\u53F7\u3002" : "\u8BF4\u660E: \u767B\u5F55\u6210\u529F\u540E\uFF0C\u53D1\u9001\u7B2C\u4E00\u6761\u6D88\u606F\u5373\u81EA\u52A8\u5B8C\u6210\u767D\u540D\u5355\u6388\u6743\u3002\u4EC5\u767D\u540D\u5355\u5185\u7684\u7528\u6237\u80FD\u9A71\u52A8 agent\uFF0C\u5176\u4ED6\u4EBA\u6D88\u606F\u4F1A\u88AB\u5FFD\u7565\u3002"
698
+ platformId === "wechat" ? "\u8BF4\u660E: \u626B\u7801\u6210\u529F\u540E\uFF0C\u5411\u8BE5\u5FAE\u4FE1 Bot \u53D1\u9001\u7B2C\u4E00\u6761\u6D88\u606F\u5373\u81EA\u52A8\u5B8C\u6210\u767D\u540D\u5355\u6388\u6743\u3002\u4EC5\u767D\u540D\u5355\u5185\u7684\u5FAE\u4FE1\u7528\u6237\u80FD\u9A71\u52A8 agent\uFF0C\u5176\u4ED6\u4EBA\u6D88\u606F\u4F1A\u88AB\u5FFD\u7565\u3002\u4F7F\u7528\u4E13\u7528\u5FAE\u4FE1\u53F7\uFF0C\u907F\u514D\u5F71\u54CD\u4E3B\u53F7\u3002" : platformId === "qq" ? "\u8BF4\u660E: \u586B\u5165 QQ \u5F00\u653E\u5E73\u53F0\u673A\u5668\u4EBA\u7684 AppID \u4E0E ClientSecret \u540E\u4FDD\u5B58\u5373\u81EA\u52A8\u8FDE\u63A5\u3002\u7528\u6237\u5411 Bot \u53D1\u9001\u7B2C\u4E00\u6761\u6D88\u606F\u5373\u81EA\u52A8\u5B8C\u6210\u767D\u540D\u5355\u6388\u6743\u3002\u4EC5\u767D\u540D\u5355\u5185\u7684 QQ \u7528\u6237\u80FD\u9A71\u52A8 agent\uFF0C\u5176\u4ED6\u4EBA\u6D88\u606F\u4F1A\u88AB\u5FFD\u7565\u3002" : "\u8BF4\u660E: \u767B\u5F55\u6210\u529F\u540E\uFF0C\u53D1\u9001\u7B2C\u4E00\u6761\u6D88\u606F\u5373\u81EA\u52A8\u5B8C\u6210\u767D\u540D\u5355\u6388\u6743\u3002\u4EC5\u767D\u540D\u5355\u5185\u7684\u7528\u6237\u80FD\u9A71\u52A8 agent\uFF0C\u5176\u4ED6\u4EBA\u6D88\u606F\u4F1A\u88AB\u5FFD\u7565\u3002"
619
699
  )
620
700
  )
621
701
  );
@@ -932,7 +1012,7 @@ function BridgePanel({ rpcCall }) {
932
1012
  } else if (activeTab === "im") {
933
1013
  const IM_PLATFORMS = [
934
1014
  { id: "wechat", label: "\u5FAE\u4FE1", desc: "iLink Bot API\uFF08ClawBot\uFF09" },
935
- { id: "qq", label: "QQ", desc: "NapCat / Mirai" },
1015
+ { id: "qq", label: "QQ", desc: "QQ Bot OpenAPI v2\uFF08\u79C1\u804A/\u7FA4\u804A/\u6309\u94AE\uFF09" },
936
1016
  { id: "feishu", label: "\u98DE\u4E66", desc: "\u5B98\u65B9\u4E8B\u4EF6\u56DE\u8C03 API" }
937
1017
  ];
938
1018
  tabContent = React.createElement(
package/client/index.js CHANGED
@@ -270,6 +270,9 @@ function PlatformCard({ platformId, platformName, platformDesc, rpcCall, onStatu
270
270
  approvalTimeoutSec: String(platform.config.approvalTimeoutSec ?? 600),
271
271
  maxMessageChars: String(platform.config.maxMessageChars ?? 2000),
272
272
  sendChunkDelayMs: String(platform.config.sendChunkDelayMs ?? 1500),
273
+ appId: platform.config.appId ?? '',
274
+ // Secret 不由后端回传;空值表示沿用已保存密钥
275
+ clientSecret: '',
273
276
  });
274
277
  }
275
278
  }, [platform?.config]);
@@ -338,18 +341,28 @@ function PlatformCard({ platformId, platformName, platformDesc, rpcCall, onStatu
338
341
  // 高级设置保存
339
342
  const saveConfig = React.useCallback(async () => {
340
343
  if (!cfgDraft) return;
341
- await act(BRIDGE_ENDPOINTS.platformSetConfig, {
344
+ const payload = {
342
345
  digestIntervalSec: Number(cfgDraft.digestIntervalSec),
343
346
  approvalTimeoutSec: Number(cfgDraft.approvalTimeoutSec),
344
347
  maxMessageChars: Number(cfgDraft.maxMessageChars),
345
348
  sendChunkDelayMs: Number(cfgDraft.sendChunkDelayMs),
346
- });
347
- }, [act, cfgDraft]);
349
+ };
350
+ // QQ 平台额外携带凭证
351
+ if (platformId === 'qq') {
352
+ payload.appId = cfgDraft.appId.trim();
353
+ payload.clientSecret = cfgDraft.clientSecret.trim();
354
+ }
355
+ await act(BRIDGE_ENDPOINTS.platformSetConfig, payload);
356
+ }, [act, cfgDraft, platformId]);
348
357
  const cfgDirty = cfgDraft && platform?.config && (
349
358
  Number(cfgDraft.digestIntervalSec) !== platform.config.digestIntervalSec ||
350
359
  Number(cfgDraft.approvalTimeoutSec) !== platform.config.approvalTimeoutSec ||
351
360
  Number(cfgDraft.maxMessageChars) !== platform.config.maxMessageChars ||
352
- Number(cfgDraft.sendChunkDelayMs) !== platform.config.sendChunkDelayMs
361
+ Number(cfgDraft.sendChunkDelayMs) !== platform.config.sendChunkDelayMs ||
362
+ (platformId === 'qq' && (
363
+ cfgDraft.appId !== (platform.config.appId ?? '') ||
364
+ cfgDraft.clientSecret !== (platform.config.clientSecret ?? '')
365
+ ))
353
366
  );
354
367
 
355
368
  if (!platform && !err) {
@@ -459,7 +472,7 @@ function PlatformCard({ platformId, platformName, platformDesc, rpcCall, onStatu
459
472
  ),
460
473
  ),
461
474
 
462
- // 未配置 / 登录中:二维码
475
+ // 未配置 / 登录中:二维码(QQ 无二维码,显示凭证表单)
463
476
  (!platform?.configured || showQr) && React.createElement('div', { style: s.block },
464
477
  showQr && login.qr
465
478
  ? React.createElement('div', null,
@@ -471,13 +484,48 @@ function PlatformCard({ platformId, platformName, platformDesc, rpcCall, onStatu
471
484
  ),
472
485
  login.error && React.createElement('div', { style: { ...s.muted, marginTop: 4, color: 'var(--dsw-alias-state-warn-primary,#92400e)' } }, login.error),
473
486
  )
474
- : React.createElement('div', { style: { display: 'flex', gap: 8, marginTop: 4, flexWrap: 'wrap', alignItems: 'center' } },
475
- React.createElement('button', {
476
- style: { ...s.btnPri, opacity: busy ? 0.5 : 1 },
477
- onClick: onLogin, disabled: busy,
478
- }, busy ? '处理中…' : '扫码登录'),
479
- login.phase === 'error' && React.createElement('div', { style: { ...s.muted, fontSize: 12 } }, login.error ?? '登录失败'),
480
- ),
487
+ : platformId === 'qq'
488
+ ? React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 10, marginTop: 4 } },
489
+ React.createElement('div', null,
490
+ React.createElement('div', { style: { ...s.muted, marginBottom: 4 } }, 'AppID — QQ 开放平台机器人应用 ID'),
491
+ React.createElement('input', {
492
+ style: { ...s.input, width: '100%' },
493
+ placeholder: '请输入 AppID',
494
+ value: cfgDraft?.appId ?? '',
495
+ onChange: (e) => setCfgDraft(d => ({ ...d, appId: e.target.value })),
496
+ }),
497
+ ),
498
+ React.createElement('div', null,
499
+ React.createElement('div', { style: { ...s.muted, marginBottom: 4 } }, 'ClientSecret — QQ 开放平台机器人密钥'),
500
+ React.createElement('input', {
501
+ style: { ...s.input, width: '100%' },
502
+ placeholder: '请输入 ClientSecret',
503
+ value: cfgDraft?.clientSecret ?? '',
504
+ onChange: (e) => setCfgDraft(d => ({ ...d, clientSecret: e.target.value })),
505
+ }),
506
+ ),
507
+ React.createElement('div', null,
508
+ React.createElement('a', {
509
+ href: 'https://bot.q.qq.com/wiki/develop/api-v2/',
510
+ target: '_blank', rel: 'noopener noreferrer',
511
+ style: s.btnLink,
512
+ }, '📖 前往 QQ 开放平台申请机器人'),
513
+ ),
514
+ React.createElement('div', { style: { display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' } },
515
+ React.createElement('button', {
516
+ style: { ...s.btnPri, opacity: busy ? 0.5 : 1 },
517
+ onClick: saveConfig, disabled: busy || !cfgDraft?.appId?.trim() || !cfgDraft?.clientSecret?.trim(),
518
+ }, busy ? '保存中…' : '保存并连接'),
519
+ login.phase === 'error' && React.createElement('div', { style: { ...s.muted, fontSize: 12 } }, login.error ?? '连接失败'),
520
+ ),
521
+ )
522
+ : React.createElement('div', { style: { display: 'flex', gap: 8, marginTop: 4, flexWrap: 'wrap', alignItems: 'center' } },
523
+ React.createElement('button', {
524
+ style: { ...s.btnPri, opacity: busy ? 0.5 : 1 },
525
+ onClick: onLogin, disabled: busy,
526
+ }, busy ? '处理中…' : '扫码登录'),
527
+ login.phase === 'error' && React.createElement('div', { style: { ...s.muted, fontSize: 12 } }, login.error ?? '登录失败'),
528
+ ),
481
529
  ),
482
530
 
483
531
  // 高级设置(可折叠)
@@ -527,6 +575,27 @@ function PlatformCard({ platformId, platformName, platformDesc, rpcCall, onStatu
527
575
  onChange: (e) => setCfgDraft(d => ({ ...d, sendChunkDelayMs: e.target.value })),
528
576
  }),
529
577
  ),
578
+ // QQ 平台凭证
579
+ platformId === 'qq' && React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 10 } },
580
+ React.createElement('div', null,
581
+ React.createElement('div', { style: { ...s.muted, marginBottom: 4 } }, 'AppID — QQ 开放平台机器人应用 ID'),
582
+ React.createElement('input', {
583
+ style: { ...s.input, width: 220 },
584
+ placeholder: '请输入 AppID',
585
+ value: cfgDraft.appId,
586
+ onChange: (e) => setCfgDraft(d => ({ ...d, appId: e.target.value })),
587
+ }),
588
+ ),
589
+ React.createElement('div', null,
590
+ React.createElement('div', { style: { ...s.muted, marginBottom: 4 } }, 'ClientSecret — QQ 开放平台机器人密钥'),
591
+ React.createElement('input', {
592
+ style: { ...s.input, width: 220 },
593
+ placeholder: '请输入 ClientSecret',
594
+ value: cfgDraft.clientSecret,
595
+ onChange: (e) => setCfgDraft(d => ({ ...d, clientSecret: e.target.value })),
596
+ }),
597
+ ),
598
+ ),
530
599
  React.createElement('button', {
531
600
  style: { ...s.btnPri, alignSelf: 'flex-start', opacity: (cfgDirty && !busy) ? 1 : 0.5 },
532
601
  disabled: !cfgDirty || busy,
@@ -539,7 +608,9 @@ function PlatformCard({ platformId, platformName, platformDesc, rpcCall, onStatu
539
608
  React.createElement('div', { style: { ...s.tip, fontSize: 12 } },
540
609
  platformId === 'wechat'
541
610
  ? '说明: 扫码成功后,向该微信 Bot 发送第一条消息即自动完成白名单授权。仅白名单内的微信用户能驱动 agent,其他人消息会被忽略。使用专用微信号,避免影响主号。'
542
- : '说明: 登录成功后,发送第一条消息即自动完成白名单授权。仅白名单内的用户能驱动 agent,其他人消息会被忽略。'
611
+ : platformId === 'qq'
612
+ ? '说明: 填入 QQ 开放平台机器人的 AppID 与 ClientSecret 后保存即自动连接。用户向 Bot 发送第一条消息即自动完成白名单授权。仅白名单内的 QQ 用户能驱动 agent,其他人消息会被忽略。'
613
+ : '说明: 登录成功后,发送第一条消息即自动完成白名单授权。仅白名单内的用户能驱动 agent,其他人消息会被忽略。'
543
614
  ),
544
615
  ),
545
616
  );
@@ -844,7 +915,7 @@ function BridgePanel({ rpcCall }) {
844
915
  // 从 listPlatforms 动态生成平台列表
845
916
  const IM_PLATFORMS = [
846
917
  { id: 'wechat', label: '微信', desc: 'iLink Bot API(ClawBot)' },
847
- { id: 'qq', label: 'QQ', desc: 'NapCat / Mirai' },
918
+ { id: 'qq', label: 'QQ', desc: 'QQ Bot OpenAPI v2(私聊/群聊/按钮)' },
848
919
  { id: 'feishu', label: '飞书', desc: '官方事件回调 API' },
849
920
  ];
850
921
 
package/lib/index.js CHANGED
@@ -18,6 +18,7 @@ import { CustomTunnelClient } from './tunnel-client.mjs';
18
18
  import { CloudflaredManager } from './cloudflared-manager.mjs';
19
19
  import { PlatformManager } from './platform/manager.js';
20
20
  import { WechatService } from './wechat/index.js';
21
+ import { QqService } from './qq/index.js';
21
22
 
22
23
  const name = 'dsh-bridge';
23
24
  // 微信 Bot 会话桥依赖 DSH 提供的会话/agent/审批/工作区/持久化服务,需显式 inject
@@ -500,37 +501,50 @@ function apply(ctx, config = {}) {
500
501
  });
501
502
  platformManager.register(wechat);
502
503
 
503
- // 启动时读取已保存的微信 Bot 配置(凭证 + 白名单 + 活动会话)
504
+ // QQ Bot(OpenAPI v2)—— 作为 Platform 子类注册进平台管理器
505
+ const qq = new QqService({
506
+ ctx,
507
+ logger,
508
+ config: config.qq ?? {},
509
+ onPersist: async (patch) => {
510
+ const stored = await loadConfig();
511
+ stored.qq = { ...(stored.qq ?? {}), ...patch };
512
+ await saveConfig(stored);
513
+ },
514
+ });
515
+ platformManager.register(qq);
516
+
517
+ // 启动时读取已保存的 QQ Bot 配置(凭证 + 白名单 + 活动会话)
504
518
  loadConfig().then(async (stored) => {
505
- if (stored?.wechat) {
506
- const cfg = stored.wechat;
507
- wechat.node.config.allowFrom = Array.isArray(cfg.allowFrom) ? cfg.allowFrom : [];
508
-
509
- // Promise 包装配置恢复过程,防止 handleInbound 竞态
510
- wechat.node._restoringConfig = (async () => {
519
+ if (stored?.qq) {
520
+ const cfg = stored.qq;
521
+ qq.node.config.allowFrom = Array.isArray(cfg.allowFrom) ? cfg.allowFrom : [];
522
+
523
+ qq.node._restoringConfig = (async () => {
511
524
  try {
512
- // 恢复活动会话 ID(直接覆盖,不判断当前值)
513
525
  if (cfg.activeSessionId) {
514
- wechat.node.activeSessionId = cfg.activeSessionId;
515
- logger.info('dsh-bridge: restored wechat active session: %s', cfg.activeSessionId);
526
+ qq.node.activeSessionId = cfg.activeSessionId;
527
+ logger.info('dsh-bridge: restored qq active session: %s', cfg.activeSessionId);
516
528
  } else {
517
- // 没有持久化的会话时,回退到选第一个
518
- await wechat.node._pickDefaultSession().catch(() => {});
529
+ await qq.node._pickDefaultSession().catch(() => {});
519
530
  }
520
531
  } finally {
521
- wechat.node._configRestored = true;
532
+ qq.node._configRestored = true;
522
533
  }
523
534
  })();
524
-
525
- await wechat.node._restoringConfig;
526
-
527
- if (cfg.token && cfg.accountId) {
528
- wechat.gateway.setCredentials({
529
- token: cfg.token,
535
+
536
+ await qq.node._restoringConfig;
537
+
538
+ if (cfg.appId && cfg.clientSecret) {
539
+ qq.gateway.setCredentials({
540
+ appId: cfg.appId,
541
+ clientSecret: cfg.clientSecret,
542
+ accessToken: cfg.accessToken,
543
+ accessTokenExpiresAt: cfg.accessTokenExpiresAt,
544
+ gatewayUrl: cfg.gatewayUrl,
530
545
  accountId: cfg.accountId,
531
- baseUrl: cfg.baseUrl,
532
546
  });
533
- logger.info('dsh-bridge: loaded saved wechat bot config, starting polling');
547
+ logger.info('dsh-bridge: loaded saved qq bot config, starting gateway');
534
548
  }
535
549
  }
536
550
  }).catch(() => {});
@@ -538,6 +552,7 @@ function apply(ctx, config = {}) {
538
552
  const disposeRpc = installBridgeRpc(ctx, {
539
553
  service,
540
554
  wechat,
555
+ qq,
541
556
  platformManager,
542
557
  logger,
543
558
  saveCustomTunnelConfig: async (serverUrl, accessToken) => {
@@ -556,9 +571,10 @@ function apply(ctx, config = {}) {
556
571
  ctx.effect(() => async () => {
557
572
  try { disposeRpc(); } catch {}
558
573
  await wechat.destroy();
574
+ await qq.destroy();
559
575
  platformManager.dispose();
560
576
  await service.dispose();
561
- }, 'dsh-bridge: stop wechat, proxy and tunnels');
577
+ }, 'dsh-bridge: stop wechat, qq, proxy and tunnels');
562
578
  }
563
579
 
564
580
  export { name, inject, apply };
@@ -0,0 +1,387 @@
1
+ // QQ Bot OpenAPI v2 gateway
2
+ // Official API: https://bot.q.qq.com/wiki/develop/api-v2/
3
+
4
+ import { Service } from '@deepseek-ai/cordis'
5
+ import WebSocket from 'ws'
6
+
7
+ const TOKEN_URL = 'https://bots.qq.com/app/getAppAccessToken'
8
+ const API_BASE = 'https://api.sgroup.qq.com'
9
+ const DEFAULT_GATEWAY = 'wss://api.sgroup.qq.com/websocket/'
10
+ const MAX_MESSAGE_CHARS = 2000
11
+ const TOKEN_MARGIN_MS = 5 * 60_000
12
+ const REQUEST_TIMEOUT_MS = 15_000
13
+ const RECONNECT_DELAY_MS = 3000
14
+
15
+ export const QQ_INTENTS = {
16
+ C2C_MESSAGE_CREATE: 1 << 25,
17
+ INTERACTION_CREATE: 1 << 26,
18
+ GROUP_AT_MESSAGE_CREATE: 1 << 30,
19
+ PUBLIC_GUILD_MESSAGES: 1 << 9,
20
+ DIRECT_MESSAGE: 1 << 12,
21
+ }
22
+
23
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
24
+ const stringValue = (value) => value == null ? '' : String(value)
25
+
26
+ async function requestJson(url, { method = 'GET', token, body, timeoutMs = REQUEST_TIMEOUT_MS } = {}) {
27
+ const controller = new AbortController()
28
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
29
+ try {
30
+ const response = await fetch(url, {
31
+ method,
32
+ headers: {
33
+ accept: 'application/json',
34
+ 'content-type': 'application/json',
35
+ ...(token ? { authorization: `QQBot ${token}` } : {}),
36
+ },
37
+ body: body === undefined ? undefined : JSON.stringify(body),
38
+ signal: controller.signal,
39
+ })
40
+ const raw = await response.text()
41
+ let value = {}
42
+ try { value = raw ? JSON.parse(raw) : {} } catch { value = { message: raw } }
43
+ if (!response.ok) {
44
+ const error = new Error(`QQ API ${response.status}: ${value?.message || value?.msg || value?.code || response.statusText}`)
45
+ error.status = response.status
46
+ error.payload = value
47
+ throw error
48
+ }
49
+ return value
50
+ } finally { clearTimeout(timer) }
51
+ }
52
+
53
+ function normalizeEvent(payload) {
54
+ const data = payload?.d || {}
55
+ const event = payload?.t || ''
56
+ if (event === 'C2C_MESSAGE_CREATE') {
57
+ return {
58
+ type: 'message', scope: 'c2c', event,
59
+ id: data.id || data.msg_id,
60
+ senderId: data.author?.user_openid || data.user_openid,
61
+ peerId: data.author?.user_openid || data.user_openid,
62
+ text: stringValue(data.content).trim(),
63
+ message: data,
64
+ messageReference: data.message_reference,
65
+ }
66
+ }
67
+ if (event === 'GROUP_AT_MESSAGE_CREATE') {
68
+ return {
69
+ type: 'message', scope: 'group', event,
70
+ id: data.id || data.msg_id,
71
+ senderId: data.author?.member_openid || data.author?.id || data.member_openid,
72
+ peerId: data.group_openid || data.group_id,
73
+ groupId: data.group_openid || data.group_id,
74
+ text: stringValue(data.content).trim(),
75
+ message: data,
76
+ messageReference: data.message_reference,
77
+ }
78
+ }
79
+ if (event === 'AT_MESSAGE_CREATE') {
80
+ return {
81
+ type: 'message', scope: 'guild', event,
82
+ id: data.id || data.msg_id,
83
+ senderId: data.author?.id,
84
+ peerId: data.channel_id,
85
+ guildId: data.guild_id,
86
+ text: stringValue(data.content).trim(),
87
+ message: data,
88
+ messageReference: data.message_reference,
89
+ }
90
+ }
91
+ if (event === 'INTERACTION_CREATE') {
92
+ return {
93
+ type: 'interaction', scope: data.group_openid ? 'group' : 'c2c', event,
94
+ id: data.id,
95
+ interactionId: data.id,
96
+ senderId: data.group_member_openid || data.user_openid,
97
+ peerId: data.group_openid || data.user_openid,
98
+ groupId: data.group_openid,
99
+ data: data.data,
100
+ message: data,
101
+ }
102
+ }
103
+ return { type: 'event', event, message: data }
104
+ }
105
+
106
+ export class QqGateway extends Service {
107
+ constructor({ ctx, logger, config = {}, onPersist } = {}) {
108
+ super(ctx, 'qq')
109
+ this.logger = logger
110
+ this.onPersist = onPersist || (() => {})
111
+ this.config = {
112
+ appId: config.appId || '',
113
+ clientSecret: config.clientSecret || '',
114
+ accessToken: config.accessToken || '',
115
+ accessTokenExpiresAt: Number(config.accessTokenExpiresAt || 0),
116
+ gatewayUrl: config.gatewayUrl || '',
117
+ intents: Number(config.intents ?? (QQ_INTENTS.C2C_MESSAGE_CREATE | QQ_INTENTS.GROUP_AT_MESSAGE_CREATE | QQ_INTENTS.INTERACTION_CREATE)),
118
+ reconnectDelayMs: Number(config.reconnectDelayMs ?? RECONNECT_DELAY_MS),
119
+ apiTimeoutMs: Number(config.apiTimeoutMs ?? REQUEST_TIMEOUT_MS),
120
+ }
121
+ this.statusValue = 'idle'
122
+ this.accountId = config.accountId || ''
123
+ this.ws = null
124
+ this.loopTask = null
125
+ this.stopRequested = false
126
+ this.heartbeatTimer = null
127
+ this.heartbeatInterval = 30_000
128
+ this.sequence = null
129
+ this.tokenPromise = null
130
+ this.dedup = new Map()
131
+ }
132
+
133
+ get status() { return this.statusValue }
134
+ get configured() { return Boolean(this.config.appId && this.config.clientSecret) }
135
+ get capabilities() {
136
+ return { supportsGroup: true, supportsMedia: true, supportsVoice: true, supportsTyping: false, maxMessageChars: MAX_MESSAGE_CHARS }
137
+ }
138
+
139
+ setStatus(status) {
140
+ this.statusValue = status
141
+ try { this.ctx.emit?.('qq/status', status) } catch {}
142
+ }
143
+
144
+ async dispose() { await this.stop(); super.dispose?.() }
145
+
146
+ async stop() {
147
+ this.stopRequested = true
148
+ this.clearHeartbeat()
149
+ const ws = this.ws
150
+ this.ws = null
151
+ if (ws) { try { ws.removeAllListeners(); ws.close(); ws.terminate() } catch {} }
152
+ const task = this.loopTask
153
+ this.loopTask = null
154
+ if (task) await task.catch(() => {})
155
+ this.setStatus('idle')
156
+ }
157
+
158
+ async start() {
159
+ if (!this.configured) { this.setStatus('idle'); return }
160
+ if (!this.loopTask) {
161
+ this.stopRequested = false
162
+ this.loopTask = this.runLoop().finally(() => { this.loopTask = null })
163
+ }
164
+ }
165
+
166
+ async persist(patch) {
167
+ try { await this.onPersist(patch) } catch (error) {
168
+ this.logger?.warn?.('[dsh-bridge qq] persist failed: %s', error?.message ?? error)
169
+ }
170
+ }
171
+
172
+ async refreshAccessToken() {
173
+ if (this.config.accessToken && this.config.accessTokenExpiresAt > Date.now() + TOKEN_MARGIN_MS) {
174
+ return this.config.accessToken
175
+ }
176
+ if (this.tokenPromise) return this.tokenPromise
177
+ if (!this.configured) throw new Error('QQ Bot 缺少 AppID 或 ClientSecret')
178
+ this.tokenPromise = (async () => {
179
+ const result = await requestJson(TOKEN_URL, {
180
+ method: 'POST',
181
+ body: { appId: this.config.appId, clientSecret: this.config.clientSecret },
182
+ timeoutMs: this.config.apiTimeoutMs,
183
+ })
184
+ const token = result?.access_token || result?.accessToken
185
+ if (!token) throw new Error('QQ Bot 未返回 access_token')
186
+ const expires = Math.max(60, Number(result?.expires_in || 7200))
187
+ this.config.accessToken = token
188
+ this.config.accessTokenExpiresAt = Date.now() + expires * 1000
189
+ await this.persist({ accessToken: token, accessTokenExpiresAt: this.config.accessTokenExpiresAt })
190
+ return token
191
+ })().finally(() => { this.tokenPromise = null })
192
+ return this.tokenPromise
193
+ }
194
+
195
+ async api(path, options = {}) {
196
+ return requestJson(`${API_BASE}${path}`, {
197
+ ...options,
198
+ token: await this.refreshAccessToken(),
199
+ timeoutMs: this.config.apiTimeoutMs,
200
+ })
201
+ }
202
+
203
+ async runLoop() {
204
+ while (!this.stopRequested) {
205
+ try {
206
+ this.setStatus('starting')
207
+ const token = await this.refreshAccessToken()
208
+ const gateway = this.config.gatewayUrl
209
+ || ((await requestJson(`${API_BASE}/gateway`, { token, timeoutMs: this.config.apiTimeoutMs })).url)
210
+ || DEFAULT_GATEWAY
211
+ await this.connect(gateway, token)
212
+ } catch (error) {
213
+ if (this.stopRequested) break
214
+ this.setStatus('reconnecting')
215
+ this.logger?.warn?.('[dsh-bridge qq] gateway disconnected: %s', error?.message ?? error)
216
+ await sleep(this.config.reconnectDelayMs)
217
+ }
218
+ }
219
+ this.setStatus('idle')
220
+ }
221
+
222
+ connect(url, token) {
223
+ return new Promise((resolve, reject) => {
224
+ const ws = new WebSocket(url)
225
+ this.ws = ws
226
+ let settled = false
227
+ const finish = (error) => {
228
+ if (settled) return
229
+ settled = true
230
+ this.clearHeartbeat()
231
+ if (this.ws === ws) this.ws = null
232
+ error ? reject(error) : resolve()
233
+ }
234
+ ws.on('open', () => this.logger?.info?.('[dsh-bridge qq] gateway connected'))
235
+ ws.on('message', (raw) => {
236
+ let payload
237
+ try { payload = JSON.parse(String(raw)) } catch { return }
238
+ void this.handlePayload(payload, token, ws).catch(finish)
239
+ })
240
+ ws.on('error', finish)
241
+ ws.on('close', (code, reason) => finish(new Error(`QQ gateway closed: ${code} ${reason || ''}`)))
242
+ })
243
+ }
244
+
245
+ async handlePayload(payload, token, ws) {
246
+ const op = Number(payload?.op)
247
+ if (payload?.s != null) this.sequence = payload.s
248
+ if (op === 10) {
249
+ this.heartbeatInterval = Number(payload?.d?.heartbeat_interval || 30_000)
250
+ this.startHeartbeat(ws)
251
+ ws.send(JSON.stringify({
252
+ op: 2,
253
+ d: {
254
+ token: `QQBot ${token}`,
255
+ intents: this.config.intents,
256
+ shard: [0, 1],
257
+ properties: { $os: process.platform, $browser: 'dsh-bridge', $device: 'dsh-bridge' },
258
+ },
259
+ }))
260
+ return
261
+ }
262
+ if (op === 0) {
263
+ if (payload.t === 'READY') {
264
+ this.accountId = payload.d?.user?.id || payload.d?.user?.username || this.accountId
265
+ await this.persist({ accountId: this.accountId })
266
+ this.setStatus('connected')
267
+ }
268
+ const event = normalizeEvent(payload)
269
+ if (event.type === 'message' && event.id && !this.seen(event.id)) {
270
+ this.ctx.emit?.('qq/message', event)
271
+ }
272
+ if (event.type === 'interaction' && event.interactionId) {
273
+ this.ctx.emit?.('qq/interaction', event)
274
+ }
275
+ return
276
+ }
277
+ if (op === 7) { try { ws.close() } catch {}; return }
278
+ if (op === 9) throw new Error('QQ gateway invalid session')
279
+ }
280
+
281
+ startHeartbeat(ws) {
282
+ this.clearHeartbeat()
283
+ const beat = () => {
284
+ if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ op: 1, d: this.sequence }))
285
+ }
286
+ beat()
287
+ this.heartbeatTimer = setInterval(beat, this.heartbeatInterval)
288
+ }
289
+
290
+ clearHeartbeat() {
291
+ if (this.heartbeatTimer) clearInterval(this.heartbeatTimer)
292
+ this.heartbeatTimer = null
293
+ }
294
+
295
+ seen(id) {
296
+ const now = Date.now()
297
+ for (const [key, at] of this.dedup) if (now - at > 300_000) this.dedup.delete(key)
298
+ if (this.dedup.has(id)) return true
299
+ this.dedup.set(id, now)
300
+ return false
301
+ }
302
+
303
+ endpoint(peerId, scope, kind = 'messages') {
304
+ return scope === 'group'
305
+ ? `/v2/groups/${encodeURIComponent(peerId)}/${kind}`
306
+ : `/v2/users/${encodeURIComponent(peerId)}/${kind}`
307
+ }
308
+
309
+ async sendText(peerId, content, opts = {}) {
310
+ return this.api(this.endpoint(peerId, opts.scope), {
311
+ method: 'POST',
312
+ body: {
313
+ content: stringValue(content).slice(0, MAX_MESSAGE_CHARS),
314
+ msg_type: 0,
315
+ msg_id: opts.msgId,
316
+ event_id: opts.eventId,
317
+ },
318
+ })
319
+ }
320
+
321
+ async sendMarkdown(peerId, markdown, opts = {}) {
322
+ return this.api(this.endpoint(peerId, opts.scope), {
323
+ method: 'POST',
324
+ body: {
325
+ markdown: typeof markdown === 'string' ? { content: markdown } : markdown,
326
+ msg_type: 2,
327
+ msg_id: opts.msgId,
328
+ event_id: opts.eventId,
329
+ },
330
+ })
331
+ }
332
+
333
+ async sendKeyboard(peerId, content, keyboard, opts = {}) {
334
+ return this.api(this.endpoint(peerId, opts.scope), {
335
+ method: 'POST',
336
+ body: {
337
+ content: stringValue(content),
338
+ keyboard,
339
+ msg_type: 2,
340
+ msg_id: opts.msgId,
341
+ event_id: opts.eventId,
342
+ },
343
+ })
344
+ }
345
+
346
+ async sendMedia(peerId, media, opts = {}) {
347
+ return this.api(this.endpoint(peerId, opts.scope, 'files'), {
348
+ method: 'POST',
349
+ body: {
350
+ file_type: Number(media.fileType || 1),
351
+ url: media.url,
352
+ srv_send_msg: media.sendMessage !== false,
353
+ },
354
+ })
355
+ }
356
+
357
+ async sendStream(peerId, content, opts = {}) {
358
+ const endpoint = this.endpoint(peerId, opts.scope, 'stream-messages')
359
+ return this.api(endpoint, {
360
+ method: 'POST',
361
+ body: {
362
+ content: stringValue(content).slice(0, MAX_MESSAGE_CHARS),
363
+ msg_type: 0,
364
+ msg_id: opts.msgId,
365
+ event_id: opts.eventId,
366
+ },
367
+ })
368
+ }
369
+
370
+ async respondInteraction(interactionId, response) {
371
+ return this.api(`/interactions/${encodeURIComponent(interactionId)}`, {
372
+ method: 'PUT',
373
+ body: response,
374
+ })
375
+ }
376
+
377
+ async sendTyping() { return { ok: false, unsupported: true } }
378
+
379
+ setCredentials(values = {}) {
380
+ for (const key of ['appId', 'clientSecret', 'accessToken', 'accessTokenExpiresAt', 'gatewayUrl', 'intents']) {
381
+ if (values[key] !== undefined) this.config[key] = values[key]
382
+ }
383
+ if (values.accountId !== undefined) this.accountId = values.accountId
384
+ }
385
+ }
386
+
387
+ export const gatewayConstants = { API_BASE, TOKEN_URL, DEFAULT_GATEWAY, MAX_MESSAGE_CHARS, QQ_INTENTS }
@@ -0,0 +1,228 @@
1
+ // dsh-bridge QQ platform adapter
2
+ // 编排 QqGateway(OpenAPI v2 网关)+ QqConversationNode(QQ⇄DSH 会话桥)。
3
+ // 作为 Platform 子类,注册进 PlatformManager 统一管理。
4
+ //
5
+ // 对外暴露给 bridge-rpc 的接口(与 wechat 一致):getStatus / login / stop /
6
+ // setAllowFrom / setConfig / unbind / destroy。
7
+ //
8
+ // 持久化策略:凭证(appId/clientSecret/accessToken/accountId)与配置由主插件
9
+ // 通过回调 `onPersist` 保存在 $DSH_HOME/dsh-bridge/config.json。
10
+
11
+ import { Platform } from '../platform/base.js'
12
+ import { QqGateway } from './gateway.js'
13
+ import { QqConversationNode } from './node.js'
14
+
15
+ export class QqService extends Platform {
16
+ /**
17
+ * @param {object} opts
18
+ * @param {object} opts.ctx Cordis 上下文
19
+ * @param {object} opts.logger 日志器
20
+ * @param {object} [opts.config] 已持久化的 qq 配置(凭证 + allowFrom + 间隔)
21
+ * @param {(patch: object) => (void|Promise<void>)} opts.onPersist 主插件保存回调
22
+ */
23
+ constructor({ ctx, logger, config = {}, onPersist }) {
24
+ super({ ctx, logger, config, onPersist })
25
+ this.id = 'qq'
26
+ this.name = 'QQ'
27
+
28
+ this.gateway = new QqGateway({
29
+ ctx,
30
+ logger,
31
+ config: {
32
+ appId: config.appId ?? '',
33
+ clientSecret: config.clientSecret ?? '',
34
+ accessToken: config.accessToken ?? '',
35
+ accessTokenExpiresAt: config.accessTokenExpiresAt ?? 0,
36
+ gatewayUrl: config.gatewayUrl ?? '',
37
+ accountId: config.accountId ?? '',
38
+ },
39
+ onPersist: (patch) => this.persist(patch),
40
+ })
41
+
42
+ // 挂到 ctx 供会话节点读取
43
+ try { ctx.qq = this.gateway } catch { /* 挂载失败不致命 */ }
44
+
45
+ this.node = new QqConversationNode(ctx, {
46
+ allowFrom: Array.isArray(config.allowFrom) ? config.allowFrom : [],
47
+ digestIntervalSec: config.digestIntervalSec,
48
+ approvalTimeoutSec: config.approvalTimeoutSec,
49
+ maxMessageChars: config.maxMessageChars,
50
+ sendChunkDelayMs: config.sendChunkDelayMs,
51
+ activeSessionId: config.activeSessionId,
52
+ }, logger, {
53
+ onFirstSender: (senderId) => this.persist({ allowFrom: [senderId] }),
54
+ onActiveSessionChange: (sessionId) => this.persist({ activeSessionId: sessionId }),
55
+ })
56
+ this.bridge = this.node
57
+
58
+ if (this.gateway.configured) {
59
+ void this.start().catch((err) => {
60
+ this.logger.error('[dsh-bridge qq] start failed: %s', err?.message ?? err)
61
+ })
62
+ }
63
+ }
64
+
65
+ // ---- Platform 接口 ----
66
+
67
+ get configured() { return this.gateway.configured }
68
+ get accountId() { return this.gateway.accountId }
69
+
70
+ get capabilities() {
71
+ return {
72
+ ...this.gateway.capabilities,
73
+ maxMessageChars: this.node.config.maxMessageChars ?? gatewayMaxChars(),
74
+ }
75
+ }
76
+
77
+ /** 消息抽象:委托 gateway(供 PlatformManager/未来多平台统一调用)。 */
78
+ async sendText(peerId, text, opts = {}) {
79
+ return this.gateway.sendText(peerId, text, opts)
80
+ }
81
+
82
+ async sendTyping(peerId, state) {
83
+ return this.gateway.sendTyping(peerId, state)
84
+ }
85
+
86
+ async sendMedia(peerId, media, opts = {}) {
87
+ return this.gateway.sendMedia(peerId, media, opts)
88
+ }
89
+
90
+ async sendMarkdown(peerId, markdown, opts = {}) {
91
+ return this.gateway.sendMarkdown(peerId, markdown, opts)
92
+ }
93
+
94
+ async sendKeyboard(peerId, keyboard, opts = {}) {
95
+ return this.gateway.sendKeyboard(peerId, keyboard, opts)
96
+ }
97
+
98
+ /** 合并展示状态给浏览器 UI。 */
99
+ getStatus() {
100
+ const allowFrom = [...(this.node.config.allowFrom ?? [])]
101
+ return {
102
+ id: this.id,
103
+ name: this.name,
104
+ status: this.gateway.status,
105
+ configured: this.gateway.configured,
106
+ accountId: this.gateway.accountId,
107
+ allowFrom,
108
+ peerId: this.node.peerId,
109
+ sessionId: this.node.activeSessionId,
110
+ login: { ...this.loginState },
111
+ capabilities: { ...this.capabilities },
112
+ config: {
113
+ digestIntervalSec: this.node.config.digestIntervalSec,
114
+ approvalTimeoutSec: this.node.config.approvalTimeoutSec,
115
+ maxMessageChars: this.node.config.maxMessageChars,
116
+ sendChunkDelayMs: this.node.config.sendChunkDelayMs,
117
+ appId: this.gateway.config.appId,
118
+ // 密钥不回传到浏览器;设置页留空时保持已保存密钥
119
+ clientSecret: '',
120
+ accountId: this.gateway.accountId,
121
+ },
122
+ }
123
+ }
124
+
125
+ /** 可编辑配置(供通用 PlatformPanel 读取)。 */
126
+ getEditableConfig() {
127
+ return {
128
+ digestIntervalSec: this.node.config.digestIntervalSec,
129
+ approvalTimeoutSec: this.node.config.approvalTimeoutSec,
130
+ maxMessageChars: this.node.config.maxMessageChars,
131
+ sendChunkDelayMs: this.node.config.sendChunkDelayMs,
132
+ }
133
+ }
134
+
135
+ /** 启动网关(凭证已配置时生效)。 */
136
+ async start() {
137
+ if (!this.gateway.configured) {
138
+ this.setStatus('idle')
139
+ return
140
+ }
141
+ await this.gateway.start()
142
+ }
143
+
144
+ /** 停止网关(保留凭证与配置)。 */
145
+ async stop() {
146
+ await this.gateway.stop()
147
+ }
148
+
149
+ /** 完整关停(dispose 时调用)。 */
150
+ async dispose() {
151
+ await this.gateway.stop()
152
+ this.gateway.dispose()
153
+ super.dispose()
154
+ }
155
+
156
+ /** 兼容 v1.x 的别名。 */
157
+ async destroy() {
158
+ await this.dispose()
159
+ }
160
+
161
+ /**
162
+ * 发起登录(配置 AppID/Secret 后即视为已连接;后台执行,状态通过 getStatus 轮询读取)。
163
+ * @returns {Promise<{ok: boolean, error?: string}>} 立即返回(已启动)
164
+ */
165
+ async login(opts = {}) {
166
+ // 无二维码流程:QQ 官方 Bot 用 AppID/Secret 鉴权,登录即配置凭证 + 启动网关
167
+ if (!this.gateway.configured) {
168
+ return { ok: false, error: '请先在配置面板填写 QQ 开放平台的 AppID 与 ClientSecret' }
169
+ }
170
+ this.loginState = { phase: 'done', qrPayload: null, qrKind: null, error: null }
171
+ void this.gateway.start().catch((err) => {
172
+ this.logger.error('[dsh-bridge qq] login start failed: %s', err?.message ?? err)
173
+ })
174
+ return { ok: true }
175
+ }
176
+
177
+ /** 解绑:停止网关并清除凭证,下次重启不再自动重连。 */
178
+ async unbind() {
179
+ await this.gateway.stop()
180
+ this.gateway.setCredentials({ appId: '', clientSecret: '', accessToken: '', accessTokenExpiresAt: 0 })
181
+ await this.persist({ appId: '', clientSecret: '', accessToken: '', accessTokenExpiresAt: 0, accountId: '' })
182
+ this.logger.info('[dsh-bridge qq] unbound: credentials cleared')
183
+ }
184
+
185
+ /** 更新白名单并发起持久化。 */
186
+ async setAllowFrom(list) {
187
+ const clean = Array.isArray(list) ? [...new Set(list.map((x) => String(x).trim()).filter(Boolean))] : []
188
+ this.node.config.allowFrom = clean
189
+ await this.persist({ allowFrom: clean })
190
+ }
191
+
192
+ /** 更新运行时配置并持久化。 */
193
+ async setConfig({ digestIntervalSec, approvalTimeoutSec, maxMessageChars, sendChunkDelayMs, appId, clientSecret } = {}) {
194
+ if (digestIntervalSec != null) this.node.config.digestIntervalSec = Number(digestIntervalSec)
195
+ if (approvalTimeoutSec != null) this.node.config.approvalTimeoutSec = Number(approvalTimeoutSec)
196
+ if (maxMessageChars != null) this.node.config.maxMessageChars = Number(maxMessageChars)
197
+ if (sendChunkDelayMs != null) this.node.config.sendChunkDelayMs = Number(sendChunkDelayMs)
198
+ if (appId !== undefined || clientSecret !== undefined) {
199
+ this.gateway.setCredentials({
200
+ appId: appId !== undefined ? appId : this.gateway.config.appId,
201
+ // 空字符串表示 UI 未修改密钥,避免误覆盖已保存的 secret
202
+ clientSecret: clientSecret?.trim() ? clientSecret.trim() : this.gateway.config.clientSecret,
203
+ })
204
+ }
205
+ const patch = {
206
+ digestIntervalSec: this.node.config.digestIntervalSec,
207
+ approvalTimeoutSec: this.node.config.approvalTimeoutSec,
208
+ maxMessageChars: this.node.config.maxMessageChars,
209
+ sendChunkDelayMs: this.node.config.sendChunkDelayMs,
210
+ }
211
+ if (appId !== undefined || clientSecret !== undefined) {
212
+ patch.appId = this.gateway.config.appId
213
+ patch.clientSecret = this.gateway.config.clientSecret
214
+ }
215
+ await this.persist(patch)
216
+
217
+ // 凭证配置完成后自动启动网关(前端「保存并连接」无需二次点击)
218
+ if (this.gateway.configured && (appId !== undefined || clientSecret !== undefined)) {
219
+ await this.gateway.start().catch((err) => {
220
+ this.logger?.warn?.('[dsh-bridge qq] auto-start failed: %s', err?.message ?? err)
221
+ })
222
+ }
223
+ }
224
+ }
225
+
226
+ function gatewayMaxChars() {
227
+ return 2000
228
+ }
package/lib/qq/node.js ADDED
@@ -0,0 +1,210 @@
1
+ // dsh-bridge QQ conversation node
2
+ // 把 QQ OpenAPI v2 的入站事件(C2C 私聊 / 群聊 @提及)解析后交给平台无关的
3
+ // ConversationBridge 处理,出站通过 QqGateway 发送文本 / Markdown / 按钮。
4
+ // 平台特定部分:
5
+ // - 入站解析:event.scope 决定 c2c / group / guild,text 直接来自 content
6
+ // - 出站:sendText 走 gateway.sendText;长文本用 Markdown 分块
7
+ // - 群聊:@提及消息仅在命中机器人才处理(GROUP_AT_MESSAGE_CREATE 已保证)
8
+
9
+ import { ConversationBridge, conversationBridgeHelpers } from '../platform/conversation-bridge.js'
10
+ import { gatewayConstants } from './gateway.js'
11
+
12
+ const MAX_MESSAGE_CHARS = gatewayConstants.MAX_MESSAGE_CHARS
13
+
14
+ // 把 QqGateway 适配为 ConversationBridge 需要的 Platform 消息接口
15
+ function makePlatform(gateway) {
16
+ return {
17
+ id: 'qq',
18
+ name: 'QQ',
19
+ get accountId() { return gateway.accountId ?? '' },
20
+ get capabilities() { return gateway.capabilities },
21
+ sendText: (peer, text) => gateway.sendText(peer, text, {}),
22
+ sendTyping: (peer, state) => gateway.sendTyping(peer, state),
23
+ sendKeyboard: (peer, content, keyboard) => gateway.sendKeyboard(peer, content, keyboard, {}),
24
+ }
25
+ }
26
+
27
+ export class QqConversationNode extends ConversationBridge {
28
+ /**
29
+ * @param {object} ctx Cordis 上下文(含 ctx.qq 网关服务)
30
+ * @param {object} config 已持久化配置(allowFrom/间隔/活动会话等)
31
+ * @param {object} logger 日志器
32
+ * @param {object} [opts]
33
+ * @param {(senderId: string) => void} [opts.onFirstSender]
34
+ * @param {(sessionId: string) => void} [opts.onActiveSessionChange]
35
+ */
36
+ constructor(ctx, config, logger, { onFirstSender, onActiveSessionChange } = {}) {
37
+ super({
38
+ ctx,
39
+ logger,
40
+ config,
41
+ platform: makePlatform(ctx.qq),
42
+ onFirstSender,
43
+ onActiveSessionChange,
44
+ })
45
+ this.gateway = ctx.qq
46
+ this.lastMessageId = null // 存储最后发送的消息 ID,用于消息引用
47
+
48
+ // 订阅网关入站事件
49
+ this.ctx.on('qq/message', (event) => {
50
+ void this._handleInbound(event)
51
+ })
52
+ this.ctx.on('qq/interaction', (event) => {
53
+ void this._handleInteraction(event)
54
+ })
55
+ }
56
+
57
+ // ---- 出站:覆盖 sendText,在提示消息中添加按钮 ----
58
+
59
+ async sendText(text) {
60
+ const peer = this.peerId
61
+ if (!peer) return
62
+
63
+ // 检测是否是提示用户开始新会话的消息
64
+ const isPromptMessage = text.includes('没有活动会话') || text.includes('恢复会话失败')
65
+
66
+ if (isPromptMessage) {
67
+ // 发送带按钮的消息,方便用户快速操作
68
+ const keyboard = {
69
+ rows: [
70
+ {
71
+ buttons: [
72
+ { id: 'new_conversation', render_data: { label: '🆕 新建会话', visited_label: '新建会话' }, action: { type: 2, permission: { type: 2 } } },
73
+ { id: 'list_sessions', render_data: { label: '📋 会话列表', visited_label: '会话列表' }, action: { type: 2, permission: { type: 2 } } },
74
+ ],
75
+ },
76
+ {
77
+ buttons: [
78
+ { id: 'help', render_data: { label: '❓ 帮助', visited_label: '帮助' }, action: { type: 2, permission: { type: 2 } } },
79
+ ],
80
+ },
81
+ ],
82
+ }
83
+ const result = await this.platform.sendKeyboard(peer, text, keyboard)
84
+ // 保存消息 ID
85
+ if (result?.data?.message_id) {
86
+ this.lastMessageId = result.data.message_id
87
+ }
88
+ return result
89
+ }
90
+
91
+ // 其他消息使用流式发送
92
+ const content = String(text || '').trim()
93
+ if (content.length === 0) return { success: true }
94
+
95
+ const STREAM_CHUNK_SIZE = 200
96
+
97
+ // 如果消息较短,直接发送普通消息
98
+ if (content.length <= STREAM_CHUNK_SIZE) {
99
+ const result = await this.gateway.sendText(peer, content, {})
100
+ // 保存消息 ID
101
+ if (result?.data?.message_id) {
102
+ this.lastMessageId = result.data.message_id
103
+ }
104
+ return result
105
+ }
106
+
107
+ // 流式发送:把内容切分成多段
108
+ const chunks = []
109
+ for (let i = 0; i < content.length; i += STREAM_CHUNK_SIZE) {
110
+ chunks.push(content.slice(i, i + STREAM_CHUNK_SIZE))
111
+ }
112
+
113
+ // 逐段发送流式消息
114
+ let firstMsgId = null
115
+ for (const chunk of chunks) {
116
+ const result = await this.gateway.sendStream(peer, chunk, {
117
+ msgId: firstMsgId, // 后续段关联到第一段
118
+ })
119
+ if (!firstMsgId && result?.data?.message_id) {
120
+ firstMsgId = result.data.message_id
121
+ this.lastMessageId = firstMsgId // 保存第一段消息 ID
122
+ }
123
+ if (result?.error) {
124
+ return { success: false, error: result.error }
125
+ }
126
+ // 流式发送间隔稍短,避免刷屏
127
+ await new Promise(resolve => setTimeout(resolve, 100))
128
+ }
129
+
130
+ return { success: true }
131
+ }
132
+
133
+ // ---- 入站 ----
134
+
135
+ async _handleInbound(event) {
136
+ const sender = String(event.senderId ?? '').trim()
137
+ if (!sender) return
138
+
139
+ // 快速预检查:白名单非空时,未授权发件人直接忽略
140
+ if (!this.isAllowed(sender) && this.config.allowFrom.length > 0) {
141
+ this.logger?.info?.(`[dsh-bridge qq] ignore message from non-allowlisted sender ${sender}`)
142
+ return
143
+ }
144
+
145
+ const text = String(event.text ?? '').trim()
146
+ if (!text) {
147
+ this.logger?.info?.(`[dsh-bridge qq] ignore empty message from ${sender}`)
148
+ return
149
+ }
150
+
151
+ // 群聊:仅在群聊 @ 机器人事件中处理(GROUP_AT_MESSAGE_CREATE 已由网关归一化)
152
+ const isGroup = event.scope === 'group' || event.scope === 'guild'
153
+
154
+ // 检测消息引用(文本交互):如果用户回复了机器人的消息,且当前没有活动会话或消息以 /new 开头,
155
+ // 则自动创建新会话并发送消息
156
+ const messageReference = event.messageReference
157
+ if (messageReference && !this.activeSessionId && !text.startsWith('/')) {
158
+ this.logger?.info?.(`[dsh-bridge qq] detected message reference from ${sender}, auto-starting conversation`)
159
+ // 先创建新会话,再处理消息
160
+ await this.handleInbound({ senderId: sender, text: '/new', isGroup })
161
+ // 等待一小段时间确保会话创建完成
162
+ await new Promise(resolve => setTimeout(resolve, 100))
163
+ }
164
+
165
+ // 交给平台无关核心:白名单/群消息/命令路由/agent 分发
166
+ await this.handleInbound({ senderId: sender, text, isGroup })
167
+ }
168
+
169
+ // ---- 互动事件 ----
170
+
171
+ async _handleInteraction(event) {
172
+ const sender = String(event.senderId ?? '').trim()
173
+ if (!sender) return
174
+
175
+ // 快速预检查:白名单非空时,未授权发件人直接忽略
176
+ if (!this.isAllowed(sender) && this.config.allowFrom.length > 0) {
177
+ this.logger?.info?.(`[dsh-bridge qq] ignore interaction from non-allowlisted sender ${sender}`)
178
+ return
179
+ }
180
+
181
+ const data = event.data || {}
182
+ const resolved = data.resolved || {}
183
+ const buttonId = resolved.button_id || ''
184
+
185
+ // 响应互动
186
+ await this.gateway.respondInteraction(event.interactionId, {
187
+ code: 0,
188
+ })
189
+
190
+ // 根据按钮 ID 执行对应操作
191
+ if (buttonId === 'new_conversation') {
192
+ await this.handleInbound({ senderId: sender, text: '/new', isGroup: event.scope === 'group' })
193
+ } else if (buttonId === 'list_sessions') {
194
+ await this.handleInbound({ senderId: sender, text: '/list', isGroup: event.scope === 'group' })
195
+ } else if (buttonId === 'help') {
196
+ await this.handleInbound({ senderId: sender, text: '/help', isGroup: event.scope === 'group' })
197
+ } else {
198
+ this.logger?.info?.(`[dsh-bridge qq] unknown button interaction: ${buttonId}`)
199
+ }
200
+ }
201
+ }
202
+
203
+ // 导出,便于测试与复用
204
+ export const qqNodeHelpers = {
205
+ splitForQQ: conversationBridgeHelpers.splitForIM,
206
+ digestLine: conversationBridgeHelpers.digestLine,
207
+ textOfAssistantMessage: conversationBridgeHelpers.textOfAssistantMessage,
208
+ listSessions: conversationBridgeHelpers.listSessions,
209
+ sessionsInDisplayOrder: conversationBridgeHelpers.sessionsInDisplayOrder,
210
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wenbin_wb/dsh-bridge",
3
- "version": "2.0.4",
3
+ "version": "2.1.1",
4
4
  "description": "手机扫码即可在移动端/公网继续用 DeepSeek Harness,人不在电脑前也能接着干。一键局域网二维码、Cloudflare 公网隧道、自建隧道与微信 Bot(多工作区/会话持久化/媒体/审批),无需自己搭公网服务器。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",