@wenbin_wb/dsh-bridge 2.0.4 → 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
@@ -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,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.4",
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
+ }