@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 +122 -29
- package/client/index.js +120 -36
- package/lib/index.js +38 -22
- package/lib/qq/gateway.js +348 -0
- package/lib/qq/index.js +228 -0
- package/lib/qq/node.js +85 -0
- package/package.json +6 -6
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
384
|
-
|
|
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:
|
|
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
|
-
|
|
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: "
|
|
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
|
-
//
|
|
11
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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
|
-
:
|
|
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:
|
|
695
|
+
marginBottom: 8,
|
|
597
696
|
},
|
|
598
697
|
}, `发现新版本 v${info.latest}(当前 v${info.current})`),
|
|
599
|
-
|
|
600
|
-
|
|
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: '
|
|
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
|
-
//
|
|
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?.
|
|
506
|
-
const cfg = stored.
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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
|
-
|
|
515
|
-
logger.info('dsh-bridge: restored
|
|
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
|
-
|
|
532
|
+
qq.node._configRestored = true;
|
|
522
533
|
}
|
|
523
534
|
})();
|
|
524
|
-
|
|
525
|
-
await
|
|
526
|
-
|
|
527
|
-
if (cfg.
|
|
528
|
-
|
|
529
|
-
|
|
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
|
|
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 }
|
package/lib/qq/index.js
ADDED
|
@@ -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
|
+
"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
|
+
}
|