@wenbin_wb/dsh-bridge 2.0.3 → 2.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.
package/client/client.js CHANGED
@@ -65,7 +65,13 @@ var GITHUB_URL = "https://github.com/wenbin-wb/dsh-bridge";
65
65
  var RELEASES_URL = "https://github.com/wenbin-wb/dsh-bridge/releases";
66
66
  var ISSUES_URL = "https://github.com/wenbin-wb/dsh-bridge/issues/new";
67
67
  var TUNNEL_DOCS_URL = "https://github.com/wenbin-wb/dsh-bridge/blob/main/docs/custom-tunnel.md";
68
- var UPGRADE_COMMAND = "dsh plugin --profile web add @wenbin_wb/dsh-bridge@latest";
68
+ function upgradeCommands(latest) {
69
+ const spec = `@wenbin_wb/dsh-bridge@${latest}`;
70
+ return [
71
+ { id: "dsh", cmd: `dsh plugin --profile web add ${spec}` },
72
+ { id: "npx", cmd: `npx --yes @deepseek-ai/dsh plugin --profile web add ${spec}` }
73
+ ];
74
+ }
69
75
  var name = "dsh-bridge";
70
76
  var inject = ["slots", "connection"];
71
77
  function semverGt(a, b) {
@@ -317,7 +323,10 @@ function PlatformCard({ platformId, platformName, platformDesc, rpcCall, onStatu
317
323
  digestIntervalSec: String(platform.config.digestIntervalSec ?? 300),
318
324
  approvalTimeoutSec: String(platform.config.approvalTimeoutSec ?? 600),
319
325
  maxMessageChars: String(platform.config.maxMessageChars ?? 2e3),
320
- sendChunkDelayMs: String(platform.config.sendChunkDelayMs ?? 1500)
326
+ sendChunkDelayMs: String(platform.config.sendChunkDelayMs ?? 1500),
327
+ appId: platform.config.appId ?? "",
328
+ // Secret 不由后端回传;空值表示沿用已保存密钥
329
+ clientSecret: ""
321
330
  });
322
331
  }
323
332
  }, [platform?.config]);
@@ -374,14 +383,19 @@ function PlatformCard({ platformId, platformName, platformDesc, rpcCall, onStatu
374
383
  const handleNewId = React.useCallback((e) => setNewId(e.target.value), []);
375
384
  const saveConfig = React.useCallback(async () => {
376
385
  if (!cfgDraft) return;
377
- await act(BRIDGE_ENDPOINTS.platformSetConfig, {
386
+ const payload = {
378
387
  digestIntervalSec: Number(cfgDraft.digestIntervalSec),
379
388
  approvalTimeoutSec: Number(cfgDraft.approvalTimeoutSec),
380
389
  maxMessageChars: Number(cfgDraft.maxMessageChars),
381
390
  sendChunkDelayMs: Number(cfgDraft.sendChunkDelayMs)
382
- });
383
- }, [act, cfgDraft]);
384
- 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 ?? "")));
385
399
  if (!platform && !err) {
386
400
  return React.createElement(
387
401
  "div",
@@ -504,7 +518,7 @@ function PlatformCard({ platformId, platformName, platformDesc, rpcCall, onStatu
504
518
  }, "\u89E3\u7ED1\u8D26\u53F7")
505
519
  )
506
520
  ),
507
- // 未配置 / 登录中:二维码
521
+ // 未配置 / 登录中:二维码(QQ 无二维码,显示凭证表单)
508
522
  (!platform?.configured || showQr) && React.createElement(
509
523
  "div",
510
524
  { style: s.block },
@@ -518,6 +532,51 @@ function PlatformCard({ platformId, platformName, platformDesc, rpcCall, onStatu
518
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"
519
533
  ),
520
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
+ )
521
580
  ) : React.createElement(
522
581
  "div",
523
582
  { style: { display: "flex", gap: 8, marginTop: 4, flexWrap: "wrap", alignItems: "center" } },
@@ -596,6 +655,33 @@ function PlatformCard({ platformId, platformName, platformDesc, rpcCall, onStatu
596
655
  onChange: (e) => setCfgDraft((d) => ({ ...d, sendChunkDelayMs: e.target.value }))
597
656
  })
598
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
+ ),
599
685
  React.createElement("button", {
600
686
  style: { ...s.btnPri, alignSelf: "flex-start", opacity: cfgDirty && !busy ? 1 : 0.5 },
601
687
  disabled: !cfgDirty || busy,
@@ -609,15 +695,36 @@ function PlatformCard({ platformId, platformName, platformDesc, rpcCall, onStatu
609
695
  React.createElement(
610
696
  "div",
611
697
  { style: { ...s.tip, fontSize: 12 } },
612
- 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"
613
699
  )
614
700
  )
615
701
  );
616
702
  }
703
+ function UpgradeCommandRow({ cmd }) {
704
+ const [copied, copy] = useCopy();
705
+ return React.createElement(
706
+ "div",
707
+ { style: { display: "flex", alignItems: "center", gap: 8 } },
708
+ React.createElement("code", {
709
+ style: {
710
+ ...s.code,
711
+ fontSize: 11,
712
+ color: "var(--dsw-alias-label-secondary,#6b7280)",
713
+ flex: 1,
714
+ minWidth: 0,
715
+ wordBreak: "break-all"
716
+ }
717
+ }, cmd),
718
+ React.createElement("button", {
719
+ style: { ...s.btnGhost, height: 26, padding: "0 10px", fontSize: 12, flexShrink: 0 },
720
+ onClick: () => copy(cmd),
721
+ title: "\u590D\u5236\u5347\u7EA7\u547D\u4EE4"
722
+ }, copied ? "\u2713 \u5DF2\u590D\u5236" : "\u590D\u5236")
723
+ );
724
+ }
617
725
  function VersionBanner({ rpcCall }) {
618
726
  const [info, setInfo] = React.useState(null);
619
727
  const [loading, setLoading] = React.useState(false);
620
- const [copied, copy] = useCopy();
621
728
  const check = React.useCallback(async () => {
622
729
  setLoading(true);
623
730
  try {
@@ -654,6 +761,7 @@ function VersionBanner({ rpcCall }) {
654
761
  }, "\u{1F41B} \u53CD\u9988 Bug")
655
762
  );
656
763
  if (hasUpdate) {
764
+ const cmds = upgradeCommands(info.latest);
657
765
  return React.createElement(
658
766
  "div",
659
767
  { style: { marginBottom: 16 } },
@@ -680,29 +788,14 @@ function VersionBanner({ rpcCall }) {
680
788
  fontSize: 13,
681
789
  fontWeight: 600,
682
790
  color: "var(--dsw-alias-state-info-primary,#1e40af)",
683
- marginBottom: 6
791
+ marginBottom: 8
684
792
  }
685
793
  }, `\u53D1\u73B0\u65B0\u7248\u672C v${info.latest}\uFF08\u5F53\u524D v${info.current}\uFF09`),
794
+ // 升级命令:dsh plugin 与 npx 两种,均带复制按钮
686
795
  React.createElement(
687
796
  "div",
688
- {
689
- style: { display: "flex", alignItems: "center", gap: 8 }
690
- },
691
- React.createElement("code", {
692
- style: {
693
- ...s.code,
694
- fontSize: 11,
695
- color: "var(--dsw-alias-label-secondary,#6b7280)",
696
- flex: 1,
697
- minWidth: 0,
698
- wordBreak: "break-all"
699
- }
700
- }, UPGRADE_COMMAND),
701
- React.createElement("button", {
702
- style: { ...s.btnGhost, height: 26, padding: "0 10px", fontSize: 12, flexShrink: 0 },
703
- onClick: () => copy(UPGRADE_COMMAND),
704
- title: "\u590D\u5236\u5347\u7EA7\u547D\u4EE4"
705
- }, copied ? "\u2713 \u5DF2\u590D\u5236" : "\u590D\u5236")
797
+ { style: { display: "flex", flexDirection: "column", gap: 6 } },
798
+ cmds.map(({ id, cmd }) => React.createElement(UpgradeCommandRow, { key: id, cmd }))
706
799
  )
707
800
  ),
708
801
  React.createElement("button", {
@@ -919,7 +1012,7 @@ function BridgePanel({ rpcCall }) {
919
1012
  } else if (activeTab === "im") {
920
1013
  const IM_PLATFORMS = [
921
1014
  { id: "wechat", label: "\u5FAE\u4FE1", desc: "iLink Bot API\uFF08ClawBot\uFF09" },
922
- { 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" },
923
1016
  { id: "feishu", label: "\u98DE\u4E66", desc: "\u5B98\u65B9\u4E8B\u4EF6\u56DE\u8C03 API" }
924
1017
  ];
925
1018
  tabContent = React.createElement(
package/client/index.js CHANGED
@@ -7,8 +7,14 @@ const RELEASES_URL = 'https://github.com/wenbin-wb/dsh-bridge/releases';
7
7
  const ISSUES_URL = 'https://github.com/wenbin-wb/dsh-bridge/issues/new';
8
8
  const TUNNEL_DOCS_URL = 'https://github.com/wenbin-wb/dsh-bridge/blob/main/docs/custom-tunnel.md';
9
9
 
10
- // 升级命令:用 add @latest 强制安装最新版(update --latest 受已安装依赖版本约束可能无法升级到最新版)
11
- const UPGRADE_COMMAND = 'dsh plugin --profile web add @wenbin_wb/dsh-bridge@latest';
10
+ // 生成升级命令(拼接具体版本号;用 add 而非 update,update --latest 受已安装依赖版本约束可能无法升级到最新版)
11
+ function upgradeCommands(latest) {
12
+ const spec = `@wenbin_wb/dsh-bridge@${latest}`;
13
+ return [
14
+ { id: 'dsh', cmd: `dsh plugin --profile web add ${spec}` },
15
+ { id: 'npx', cmd: `npx --yes @deepseek-ai/dsh plugin --profile web add ${spec}` },
16
+ ];
17
+ }
12
18
 
13
19
  const name = 'dsh-bridge';
14
20
  const inject = ['slots', 'connection'];
@@ -264,6 +270,9 @@ function PlatformCard({ platformId, platformName, platformDesc, rpcCall, onStatu
264
270
  approvalTimeoutSec: String(platform.config.approvalTimeoutSec ?? 600),
265
271
  maxMessageChars: String(platform.config.maxMessageChars ?? 2000),
266
272
  sendChunkDelayMs: String(platform.config.sendChunkDelayMs ?? 1500),
273
+ appId: platform.config.appId ?? '',
274
+ // Secret 不由后端回传;空值表示沿用已保存密钥
275
+ clientSecret: '',
267
276
  });
268
277
  }
269
278
  }, [platform?.config]);
@@ -332,18 +341,28 @@ function PlatformCard({ platformId, platformName, platformDesc, rpcCall, onStatu
332
341
  // 高级设置保存
333
342
  const saveConfig = React.useCallback(async () => {
334
343
  if (!cfgDraft) return;
335
- await act(BRIDGE_ENDPOINTS.platformSetConfig, {
344
+ const payload = {
336
345
  digestIntervalSec: Number(cfgDraft.digestIntervalSec),
337
346
  approvalTimeoutSec: Number(cfgDraft.approvalTimeoutSec),
338
347
  maxMessageChars: Number(cfgDraft.maxMessageChars),
339
348
  sendChunkDelayMs: Number(cfgDraft.sendChunkDelayMs),
340
- });
341
- }, [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]);
342
357
  const cfgDirty = cfgDraft && platform?.config && (
343
358
  Number(cfgDraft.digestIntervalSec) !== platform.config.digestIntervalSec ||
344
359
  Number(cfgDraft.approvalTimeoutSec) !== platform.config.approvalTimeoutSec ||
345
360
  Number(cfgDraft.maxMessageChars) !== platform.config.maxMessageChars ||
346
- 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
+ ))
347
366
  );
348
367
 
349
368
  if (!platform && !err) {
@@ -453,7 +472,7 @@ function PlatformCard({ platformId, platformName, platformDesc, rpcCall, onStatu
453
472
  ),
454
473
  ),
455
474
 
456
- // 未配置 / 登录中:二维码
475
+ // 未配置 / 登录中:二维码(QQ 无二维码,显示凭证表单)
457
476
  (!platform?.configured || showQr) && React.createElement('div', { style: s.block },
458
477
  showQr && login.qr
459
478
  ? React.createElement('div', null,
@@ -465,13 +484,48 @@ function PlatformCard({ platformId, platformName, platformDesc, rpcCall, onStatu
465
484
  ),
466
485
  login.error && React.createElement('div', { style: { ...s.muted, marginTop: 4, color: 'var(--dsw-alias-state-warn-primary,#92400e)' } }, login.error),
467
486
  )
468
- : React.createElement('div', { style: { display: 'flex', gap: 8, marginTop: 4, flexWrap: 'wrap', alignItems: 'center' } },
469
- React.createElement('button', {
470
- style: { ...s.btnPri, opacity: busy ? 0.5 : 1 },
471
- onClick: onLogin, disabled: busy,
472
- }, busy ? '处理中…' : '扫码登录'),
473
- login.phase === 'error' && React.createElement('div', { style: { ...s.muted, fontSize: 12 } }, login.error ?? '登录失败'),
474
- ),
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
+ ),
475
529
  ),
476
530
 
477
531
  // 高级设置(可折叠)
@@ -521,6 +575,27 @@ function PlatformCard({ platformId, platformName, platformDesc, rpcCall, onStatu
521
575
  onChange: (e) => setCfgDraft(d => ({ ...d, sendChunkDelayMs: e.target.value })),
522
576
  }),
523
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
+ ),
524
599
  React.createElement('button', {
525
600
  style: { ...s.btnPri, alignSelf: 'flex-start', opacity: (cfgDirty && !busy) ? 1 : 0.5 },
526
601
  disabled: !cfgDirty || busy,
@@ -533,17 +608,40 @@ function PlatformCard({ platformId, platformName, platformDesc, rpcCall, onStatu
533
608
  React.createElement('div', { style: { ...s.tip, fontSize: 12 } },
534
609
  platformId === 'wechat'
535
610
  ? '说明: 扫码成功后,向该微信 Bot 发送第一条消息即自动完成白名单授权。仅白名单内的微信用户能驱动 agent,其他人消息会被忽略。使用专用微信号,避免影响主号。'
536
- : '说明: 登录成功后,发送第一条消息即自动完成白名单授权。仅白名单内的用户能驱动 agent,其他人消息会被忽略。'
611
+ : platformId === 'qq'
612
+ ? '说明: 填入 QQ 开放平台机器人的 AppID 与 ClientSecret 后保存即自动连接。用户向 Bot 发送第一条消息即自动完成白名单授权。仅白名单内的 QQ 用户能驱动 agent,其他人消息会被忽略。'
613
+ : '说明: 登录成功后,发送第一条消息即自动完成白名单授权。仅白名单内的用户能驱动 agent,其他人消息会被忽略。'
537
614
  ),
538
615
  ),
539
616
  );
540
617
  }
541
618
 
619
+ // 单条升级命令行:命令文本 + 复制按钮
620
+ function UpgradeCommandRow({ cmd }) {
621
+ const [copied, copy] = useCopy();
622
+ return React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8 } },
623
+ React.createElement('code', {
624
+ style: {
625
+ ...s.code,
626
+ fontSize: 11,
627
+ color: 'var(--dsw-alias-label-secondary,#6b7280)',
628
+ flex: 1,
629
+ minWidth: 0,
630
+ wordBreak: 'break-all',
631
+ },
632
+ }, cmd),
633
+ React.createElement('button', {
634
+ style: { ...s.btnGhost, height: 26, padding: '0 10px', fontSize: 12, flexShrink: 0 },
635
+ onClick: () => copy(cmd),
636
+ title: '复制升级命令',
637
+ }, copied ? '✓ 已复制' : '复制'),
638
+ );
639
+ }
640
+
542
641
  // 版本检查 + GitHub/反馈入口
543
642
  function VersionBanner({ rpcCall }) {
544
643
  const [info, setInfo] = React.useState(null);
545
644
  const [loading, setLoading] = React.useState(false);
546
- const [copied, copy] = useCopy();
547
645
 
548
646
  const check = React.useCallback(async () => {
549
647
  setLoading(true);
@@ -575,6 +673,7 @@ function VersionBanner({ rpcCall }) {
575
673
 
576
674
  // 有新版本:信息卡片
577
675
  if (hasUpdate) {
676
+ const cmds = upgradeCommands(info.latest);
578
677
  return React.createElement('div', { style: { marginBottom: 16 } },
579
678
  React.createElement('div', {
580
679
  style: {
@@ -593,27 +692,12 @@ function VersionBanner({ rpcCall }) {
593
692
  fontSize: 13,
594
693
  fontWeight: 600,
595
694
  color: 'var(--dsw-alias-state-info-primary,#1e40af)',
596
- marginBottom: 6,
695
+ marginBottom: 8,
597
696
  },
598
697
  }, `发现新版本 v${info.latest}(当前 v${info.current})`),
599
- React.createElement('div', {
600
- style: { display: 'flex', alignItems: 'center', gap: 8 },
601
- },
602
- React.createElement('code', {
603
- style: {
604
- ...s.code,
605
- fontSize: 11,
606
- color: 'var(--dsw-alias-label-secondary,#6b7280)',
607
- flex: 1,
608
- minWidth: 0,
609
- wordBreak: 'break-all',
610
- },
611
- }, UPGRADE_COMMAND),
612
- React.createElement('button', {
613
- style: { ...s.btnGhost, height: 26, padding: '0 10px', fontSize: 12, flexShrink: 0 },
614
- onClick: () => copy(UPGRADE_COMMAND),
615
- title: '复制升级命令',
616
- }, copied ? '✓ 已复制' : '复制'),
698
+ // 升级命令:dsh plugin 与 npx 两种,均带复制按钮
699
+ React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 6 } },
700
+ cmds.map(({ id, cmd }) => React.createElement(UpgradeCommandRow, { key: id, cmd })),
617
701
  ),
618
702
  ),
619
703
  React.createElement('button', {
@@ -831,7 +915,7 @@ function BridgePanel({ rpcCall }) {
831
915
  // 从 listPlatforms 动态生成平台列表
832
916
  const IM_PLATFORMS = [
833
917
  { id: 'wechat', label: '微信', desc: 'iLink Bot API(ClawBot)' },
834
- { id: 'qq', label: 'QQ', desc: 'NapCat / Mirai' },
918
+ { id: 'qq', label: 'QQ', desc: 'QQ Bot OpenAPI v2(私聊/群聊/按钮)' },
835
919
  { id: 'feishu', label: '飞书', desc: '官方事件回调 API' },
836
920
  ];
837
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,348 @@
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
+ GROUP_AT_MESSAGE_CREATE: 1 << 30,
18
+ PUBLIC_GUILD_MESSAGES: 1 << 9,
19
+ DIRECT_MESSAGE: 1 << 12,
20
+ }
21
+
22
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
23
+ const stringValue = (value) => value == null ? '' : String(value)
24
+
25
+ async function requestJson(url, { method = 'GET', token, body, timeoutMs = REQUEST_TIMEOUT_MS } = {}) {
26
+ const controller = new AbortController()
27
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
28
+ try {
29
+ const response = await fetch(url, {
30
+ method,
31
+ headers: {
32
+ accept: 'application/json',
33
+ 'content-type': 'application/json',
34
+ ...(token ? { authorization: `QQBot ${token}` } : {}),
35
+ },
36
+ body: body === undefined ? undefined : JSON.stringify(body),
37
+ signal: controller.signal,
38
+ })
39
+ const raw = await response.text()
40
+ let value = {}
41
+ try { value = raw ? JSON.parse(raw) : {} } catch { value = { message: raw } }
42
+ if (!response.ok) {
43
+ const error = new Error(`QQ API ${response.status}: ${value?.message || value?.msg || value?.code || response.statusText}`)
44
+ error.status = response.status
45
+ error.payload = value
46
+ throw error
47
+ }
48
+ return value
49
+ } finally { clearTimeout(timer) }
50
+ }
51
+
52
+ function normalizeEvent(payload) {
53
+ const data = payload?.d || {}
54
+ const event = payload?.t || ''
55
+ if (event === 'C2C_MESSAGE_CREATE') {
56
+ return {
57
+ type: 'message', scope: 'c2c', event,
58
+ id: data.id || data.msg_id,
59
+ senderId: data.author?.user_openid || data.user_openid,
60
+ peerId: data.author?.user_openid || data.user_openid,
61
+ text: stringValue(data.content).trim(),
62
+ message: data,
63
+ }
64
+ }
65
+ if (event === 'GROUP_AT_MESSAGE_CREATE') {
66
+ return {
67
+ type: 'message', scope: 'group', event,
68
+ id: data.id || data.msg_id,
69
+ senderId: data.author?.member_openid || data.author?.id || data.member_openid,
70
+ peerId: data.group_openid || data.group_id,
71
+ groupId: data.group_openid || data.group_id,
72
+ text: stringValue(data.content).trim(),
73
+ message: data,
74
+ }
75
+ }
76
+ if (event === 'AT_MESSAGE_CREATE') {
77
+ return {
78
+ type: 'message', scope: 'guild', event,
79
+ id: data.id || data.msg_id,
80
+ senderId: data.author?.id,
81
+ peerId: data.channel_id,
82
+ guildId: data.guild_id,
83
+ text: stringValue(data.content).trim(),
84
+ message: data,
85
+ }
86
+ }
87
+ return { type: 'event', event, message: data }
88
+ }
89
+
90
+ export class QqGateway extends Service {
91
+ constructor({ ctx, logger, config = {}, onPersist } = {}) {
92
+ super(ctx, 'qq')
93
+ this.logger = logger
94
+ this.onPersist = onPersist || (() => {})
95
+ this.config = {
96
+ appId: config.appId || '',
97
+ clientSecret: config.clientSecret || '',
98
+ accessToken: config.accessToken || '',
99
+ accessTokenExpiresAt: Number(config.accessTokenExpiresAt || 0),
100
+ gatewayUrl: config.gatewayUrl || '',
101
+ intents: Number(config.intents ?? (QQ_INTENTS.C2C_MESSAGE_CREATE | QQ_INTENTS.GROUP_AT_MESSAGE_CREATE)),
102
+ reconnectDelayMs: Number(config.reconnectDelayMs ?? RECONNECT_DELAY_MS),
103
+ apiTimeoutMs: Number(config.apiTimeoutMs ?? REQUEST_TIMEOUT_MS),
104
+ }
105
+ this.statusValue = 'idle'
106
+ this.accountId = config.accountId || ''
107
+ this.ws = null
108
+ this.loopTask = null
109
+ this.stopRequested = false
110
+ this.heartbeatTimer = null
111
+ this.heartbeatInterval = 30_000
112
+ this.sequence = null
113
+ this.tokenPromise = null
114
+ this.dedup = new Map()
115
+ }
116
+
117
+ get status() { return this.statusValue }
118
+ get configured() { return Boolean(this.config.appId && this.config.clientSecret) }
119
+ get capabilities() {
120
+ return { supportsGroup: true, supportsMedia: true, supportsVoice: true, supportsTyping: false, maxMessageChars: MAX_MESSAGE_CHARS }
121
+ }
122
+
123
+ setStatus(status) {
124
+ this.statusValue = status
125
+ try { this.ctx.emit?.('qq/status', status) } catch {}
126
+ }
127
+
128
+ async dispose() { await this.stop(); super.dispose?.() }
129
+
130
+ async stop() {
131
+ this.stopRequested = true
132
+ this.clearHeartbeat()
133
+ const ws = this.ws
134
+ this.ws = null
135
+ if (ws) { try { ws.removeAllListeners(); ws.close(); ws.terminate() } catch {} }
136
+ const task = this.loopTask
137
+ this.loopTask = null
138
+ if (task) await task.catch(() => {})
139
+ this.setStatus('idle')
140
+ }
141
+
142
+ async start() {
143
+ if (!this.configured) { this.setStatus('idle'); return }
144
+ if (!this.loopTask) {
145
+ this.stopRequested = false
146
+ this.loopTask = this.runLoop().finally(() => { this.loopTask = null })
147
+ }
148
+ }
149
+
150
+ async persist(patch) {
151
+ try { await this.onPersist(patch) } catch (error) {
152
+ this.logger?.warn?.('[dsh-bridge qq] persist failed: %s', error?.message ?? error)
153
+ }
154
+ }
155
+
156
+ async refreshAccessToken() {
157
+ if (this.config.accessToken && this.config.accessTokenExpiresAt > Date.now() + TOKEN_MARGIN_MS) {
158
+ return this.config.accessToken
159
+ }
160
+ if (this.tokenPromise) return this.tokenPromise
161
+ if (!this.configured) throw new Error('QQ Bot 缺少 AppID 或 ClientSecret')
162
+ this.tokenPromise = (async () => {
163
+ const result = await requestJson(TOKEN_URL, {
164
+ method: 'POST',
165
+ body: { appId: this.config.appId, clientSecret: this.config.clientSecret },
166
+ timeoutMs: this.config.apiTimeoutMs,
167
+ })
168
+ const token = result?.access_token || result?.accessToken
169
+ if (!token) throw new Error('QQ Bot 未返回 access_token')
170
+ const expires = Math.max(60, Number(result?.expires_in || 7200))
171
+ this.config.accessToken = token
172
+ this.config.accessTokenExpiresAt = Date.now() + expires * 1000
173
+ await this.persist({ accessToken: token, accessTokenExpiresAt: this.config.accessTokenExpiresAt })
174
+ return token
175
+ })().finally(() => { this.tokenPromise = null })
176
+ return this.tokenPromise
177
+ }
178
+
179
+ async api(path, options = {}) {
180
+ return requestJson(`${API_BASE}${path}`, {
181
+ ...options,
182
+ token: await this.refreshAccessToken(),
183
+ timeoutMs: this.config.apiTimeoutMs,
184
+ })
185
+ }
186
+
187
+ async runLoop() {
188
+ while (!this.stopRequested) {
189
+ try {
190
+ this.setStatus('starting')
191
+ const token = await this.refreshAccessToken()
192
+ const gateway = this.config.gatewayUrl
193
+ || ((await requestJson(`${API_BASE}/gateway`, { token, timeoutMs: this.config.apiTimeoutMs })).url)
194
+ || DEFAULT_GATEWAY
195
+ await this.connect(gateway, token)
196
+ } catch (error) {
197
+ if (this.stopRequested) break
198
+ this.setStatus('reconnecting')
199
+ this.logger?.warn?.('[dsh-bridge qq] gateway disconnected: %s', error?.message ?? error)
200
+ await sleep(this.config.reconnectDelayMs)
201
+ }
202
+ }
203
+ this.setStatus('idle')
204
+ }
205
+
206
+ connect(url, token) {
207
+ return new Promise((resolve, reject) => {
208
+ const ws = new WebSocket(url)
209
+ this.ws = ws
210
+ let settled = false
211
+ const finish = (error) => {
212
+ if (settled) return
213
+ settled = true
214
+ this.clearHeartbeat()
215
+ if (this.ws === ws) this.ws = null
216
+ error ? reject(error) : resolve()
217
+ }
218
+ ws.on('open', () => this.logger?.info?.('[dsh-bridge qq] gateway connected'))
219
+ ws.on('message', (raw) => {
220
+ let payload
221
+ try { payload = JSON.parse(String(raw)) } catch { return }
222
+ void this.handlePayload(payload, token, ws).catch(finish)
223
+ })
224
+ ws.on('error', finish)
225
+ ws.on('close', (code, reason) => finish(new Error(`QQ gateway closed: ${code} ${reason || ''}`)))
226
+ })
227
+ }
228
+
229
+ async handlePayload(payload, token, ws) {
230
+ const op = Number(payload?.op)
231
+ if (payload?.s != null) this.sequence = payload.s
232
+ if (op === 10) {
233
+ this.heartbeatInterval = Number(payload?.d?.heartbeat_interval || 30_000)
234
+ this.startHeartbeat(ws)
235
+ ws.send(JSON.stringify({
236
+ op: 2,
237
+ d: {
238
+ token: `QQBot ${token}`,
239
+ intents: this.config.intents,
240
+ shard: [0, 1],
241
+ properties: { $os: process.platform, $browser: 'dsh-bridge', $device: 'dsh-bridge' },
242
+ },
243
+ }))
244
+ return
245
+ }
246
+ if (op === 0) {
247
+ if (payload.t === 'READY') {
248
+ this.accountId = payload.d?.user?.id || payload.d?.user?.username || this.accountId
249
+ await this.persist({ accountId: this.accountId })
250
+ this.setStatus('connected')
251
+ }
252
+ const event = normalizeEvent(payload)
253
+ if (event.type === 'message' && event.id && !this.seen(event.id)) {
254
+ this.ctx.emit?.('qq/message', event)
255
+ }
256
+ return
257
+ }
258
+ if (op === 7) { try { ws.close() } catch {}; return }
259
+ if (op === 9) throw new Error('QQ gateway invalid session')
260
+ }
261
+
262
+ startHeartbeat(ws) {
263
+ this.clearHeartbeat()
264
+ const beat = () => {
265
+ if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ op: 1, d: this.sequence }))
266
+ }
267
+ beat()
268
+ this.heartbeatTimer = setInterval(beat, this.heartbeatInterval)
269
+ }
270
+
271
+ clearHeartbeat() {
272
+ if (this.heartbeatTimer) clearInterval(this.heartbeatTimer)
273
+ this.heartbeatTimer = null
274
+ }
275
+
276
+ seen(id) {
277
+ const now = Date.now()
278
+ for (const [key, at] of this.dedup) if (now - at > 300_000) this.dedup.delete(key)
279
+ if (this.dedup.has(id)) return true
280
+ this.dedup.set(id, now)
281
+ return false
282
+ }
283
+
284
+ endpoint(peerId, scope, kind = 'messages') {
285
+ return scope === 'group'
286
+ ? `/v2/groups/${encodeURIComponent(peerId)}/${kind}`
287
+ : `/v2/users/${encodeURIComponent(peerId)}/${kind}`
288
+ }
289
+
290
+ async sendText(peerId, content, opts = {}) {
291
+ return this.api(this.endpoint(peerId, opts.scope), {
292
+ method: 'POST',
293
+ body: {
294
+ content: stringValue(content).slice(0, MAX_MESSAGE_CHARS),
295
+ msg_type: 0,
296
+ msg_id: opts.msgId,
297
+ event_id: opts.eventId,
298
+ },
299
+ })
300
+ }
301
+
302
+ async sendMarkdown(peerId, markdown, opts = {}) {
303
+ return this.api(this.endpoint(peerId, opts.scope), {
304
+ method: 'POST',
305
+ body: {
306
+ markdown: typeof markdown === 'string' ? { content: markdown } : markdown,
307
+ msg_type: 2,
308
+ msg_id: opts.msgId,
309
+ event_id: opts.eventId,
310
+ },
311
+ })
312
+ }
313
+
314
+ async sendKeyboard(peerId, keyboard, opts = {}) {
315
+ return this.api(this.endpoint(peerId, opts.scope), {
316
+ method: 'POST',
317
+ body: {
318
+ content: opts.content || '',
319
+ keyboard,
320
+ msg_type: 2,
321
+ msg_id: opts.msgId,
322
+ event_id: opts.eventId,
323
+ },
324
+ })
325
+ }
326
+
327
+ async sendMedia(peerId, media, opts = {}) {
328
+ return this.api(this.endpoint(peerId, opts.scope, 'files'), {
329
+ method: 'POST',
330
+ body: {
331
+ file_type: Number(media.fileType || 1),
332
+ url: media.url,
333
+ srv_send_msg: media.sendMessage !== false,
334
+ },
335
+ })
336
+ }
337
+
338
+ async sendTyping() { return { ok: false, unsupported: true } }
339
+
340
+ setCredentials(values = {}) {
341
+ for (const key of ['appId', 'clientSecret', 'accessToken', 'accessTokenExpiresAt', 'gatewayUrl', 'intents']) {
342
+ if (values[key] !== undefined) this.config[key] = values[key]
343
+ }
344
+ if (values.accountId !== undefined) this.accountId = values.accountId
345
+ }
346
+ }
347
+
348
+ 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,85 @@
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
+ }
24
+ }
25
+
26
+ export class QqConversationNode extends ConversationBridge {
27
+ /**
28
+ * @param {object} ctx Cordis 上下文(含 ctx.qq 网关服务)
29
+ * @param {object} config 已持久化配置(allowFrom/间隔/活动会话等)
30
+ * @param {object} logger 日志器
31
+ * @param {object} [opts]
32
+ * @param {(senderId: string) => void} [opts.onFirstSender]
33
+ * @param {(sessionId: string) => void} [opts.onActiveSessionChange]
34
+ */
35
+ constructor(ctx, config, logger, { onFirstSender, onActiveSessionChange } = {}) {
36
+ super({
37
+ ctx,
38
+ logger,
39
+ config,
40
+ platform: makePlatform(ctx.qq),
41
+ onFirstSender,
42
+ onActiveSessionChange,
43
+ })
44
+ this.gateway = ctx.qq
45
+
46
+ // 订阅网关入站事件
47
+ this.ctx.on('qq/message', (event) => {
48
+ void this._handleInbound(event)
49
+ })
50
+ }
51
+
52
+ // ---- 入站 ----
53
+
54
+ async _handleInbound(event) {
55
+ const sender = String(event.senderId ?? '').trim()
56
+ if (!sender) return
57
+
58
+ // 快速预检查:白名单非空时,未授权发件人直接忽略
59
+ if (!this.isAllowed(sender) && this.config.allowFrom.length > 0) {
60
+ this.logger?.info?.(`[dsh-bridge qq] ignore message from non-allowlisted sender ${sender}`)
61
+ return
62
+ }
63
+
64
+ const text = String(event.text ?? '').trim()
65
+ if (!text) {
66
+ this.logger?.info?.(`[dsh-bridge qq] ignore empty message from ${sender}`)
67
+ return
68
+ }
69
+
70
+ // 群聊:仅在群聊 @ 机器人事件中处理(GROUP_AT_MESSAGE_CREATE 已由网关归一化)
71
+ const isGroup = event.scope === 'group' || event.scope === 'guild'
72
+
73
+ // 交给平台无关核心:白名单/群消息/命令路由/agent 分发
74
+ await this.handleInbound({ senderId: sender, text, isGroup })
75
+ }
76
+ }
77
+
78
+ // 导出,便于测试与复用
79
+ export const qqNodeHelpers = {
80
+ splitForQQ: conversationBridgeHelpers.splitForIM,
81
+ digestLine: conversationBridgeHelpers.digestLine,
82
+ textOfAssistantMessage: conversationBridgeHelpers.textOfAssistantMessage,
83
+ listSessions: conversationBridgeHelpers.listSessions,
84
+ sessionsInDisplayOrder: conversationBridgeHelpers.sessionsInDisplayOrder,
85
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wenbin_wb/dsh-bridge",
3
- "version": "2.0.3",
3
+ "version": "2.1.0",
4
4
  "description": "手机扫码即可在移动端/公网继续用 DeepSeek Harness,人不在电脑前也能接着干。一键局域网二维码、Cloudflare 公网隧道、自建隧道与微信 Bot(多工作区/会话持久化/媒体/审批),无需自己搭公网服务器。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -22,10 +22,6 @@
22
22
  "docs/custom-tunnel.md",
23
23
  "docs/wechat-usage.md"
24
24
  ],
25
- "scripts": {
26
- "build:client": "node client/build.mjs",
27
- "test": "node --test test/*.test.mjs"
28
- },
29
25
  "dependencies": {
30
26
  "qrcode": "^1.5.3",
31
27
  "ws": "^8.18.0",
@@ -85,5 +81,9 @@
85
81
  "publishConfig": {
86
82
  "access": "public",
87
83
  "registry": "https://registry.npmjs.org/"
84
+ },
85
+ "scripts": {
86
+ "build:client": "node client/build.mjs",
87
+ "test": "node --test test/*.test.mjs"
88
88
  }
89
- }
89
+ }