@wenbin_wb/dsh-bridge 2.0.4 → 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/README.zh-CN.md +3 -2
- package/client/client.js +88 -8
- package/client/index.js +85 -14
- package/lib/index.js +38 -22
- package/lib/qq/gateway.js +387 -0
- package/lib/qq/index.js +228 -0
- package/lib/qq/node.js +210 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -18,6 +18,7 @@ Seamlessly extend your local DeepSeek Harness to mobile phones, tablets, public
|
|
|
18
18
|
- **Cloudflare Tunnel**: One-click public internet exposure, connect from anywhere without a public server of your own — keep working even when you're away from home
|
|
19
19
|
- **Custom Tunnel**: Connect to your own tunnel server with a fixed domain ([Setup Guide](docs/custom-tunnel.md))
|
|
20
20
|
- **WeChat Bot (ClawBot / iLink)**: Scan a QR code to log in a WeChat personal account, then chat with, control, and approve your DeepSeek Harness agents right inside WeChat. **Multi-workspace selection, restart-persistent sessions, grouped session listing with titles, media (image/file/voice) transfer, and permission approvals** — over Tencent's official iLink Bot API, no public server or tunnel required ([Usage Guide](docs/wechat-usage.md))
|
|
21
|
+
- **QQ Bot (OpenAPI v2)**: Connect your QQ Bot to receive private/group messages, send Markdown, button keyboards, and rich media. **Full event coverage (C2C / GROUP_AT_MESSAGE_CREATE), auto token refresh, reconnection with backoff, message deduplication** — over Tencent's official QQ Bot OpenAPI v2 ([Usage Guide](docs/qq-usage.md))
|
|
21
22
|
- **IM Integration (More Planned)**: WeChat / QQ / Feishu / OpenClaw direct chat integration
|
|
22
23
|
- **Security Alerts**: URLs and QR codes with access warnings to prevent accidental sharing
|
|
23
24
|
- **Auto Version Check**: Automatic update detection when entering the panel
|
|
@@ -35,7 +36,7 @@ Seamlessly extend your local DeepSeek Harness to mobile phones, tablets, public
|
|
|
35
36
|
|--------|-------------|--------|
|
|
36
37
|
| **Platform Abstraction** | Platform-agnostic core (sessions / approvals / commands / digest) shared across IM channels | ✅ **Completed** (v2.0.0) |
|
|
37
38
|
| **WeChat** | Chat with your Agent directly in WeChat | ✅ Supported (workspaces / persisted sessions / media / approvals) |
|
|
38
|
-
| **QQ Bot** | QQ bot integration for group/private chat |
|
|
39
|
+
| **QQ Bot** | QQ bot integration for group/private chat | ✅ **Completed** (v2.1.0) — Markdown / buttons / rich media |
|
|
39
40
|
| **Feishu** | Feishu message/bot integration for workplace scenarios | Planned |
|
|
40
41
|
| **OpenClaw** | Integration with OpenClaw ecosystem | Planned |
|
|
41
42
|
| **Telegram** | Self-hosted IM channel | Planned |
|
package/README.zh-CN.md
CHANGED
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
- **Cloudflare 隧道**:一键暴露公网地址,随时随地连接,出差在外、不在家也能接着干,无需自建公网服务器
|
|
19
19
|
- **自建隧道**:连接自己的隧道服务器,获得固定域名([搭建教程](docs/custom-tunnel.md))
|
|
20
20
|
- **微信 Bot(ClawBot / iLink)**:扫码登录微信个人号后,直接在微信里对话、控制 DeepSeek Harness 的 agent。**支持多工作区选择、会话跨重启持久化、按工作区分组查看、媒体(图片/文件/语音)收发、权限审批**——走腾讯官方 iLink Bot API,无需公网([使用说明](docs/wechat-usage.md))
|
|
21
|
-
- **
|
|
21
|
+
- **QQ Bot(OpenAPI v2)**:接入 QQ 机器人,私聊/群聊接收消息,发送 Markdown、按钮键盘和富媒体。**完整事件覆盖(C2C / GROUP_AT_MESSAGE_CREATE)、Token 自动刷新、断线重连、消息去重**——走腾讯官方 QQ Bot OpenAPI v2([使用说明](docs/qq-usage.md))
|
|
22
|
+
- **IM 集成(更多平台规划中)**:飞书 / OpenClaw,直接在聊天软件里呼唤你的 Agent
|
|
22
23
|
- **安全提示**:URL 和二维码带访问警告,防止误分享
|
|
23
24
|
- **版本检查**:进入面板自动检测是否有新版本
|
|
24
25
|
|
|
@@ -35,7 +36,7 @@
|
|
|
35
36
|
|------|------|------|
|
|
36
37
|
| **平台抽象层** | 平台无关的核心(会话/审批/命令/digest)跨 IM 渠道复用 | ✅ **已完成**(v2.0.0) |
|
|
37
38
|
| **微信** | 在微信里直接与你的 Agent 对话 | ✅ 已支持(多工作区 / 会话持久化 / 媒体 / 审批) |
|
|
38
|
-
| **QQ Bot** | 接入 QQ 机器人,群聊/私聊唤起 Agent |
|
|
39
|
+
| **QQ Bot** | 接入 QQ 机器人,群聊/私聊唤起 Agent | ✅ **已完成**(v2.1.0)— Markdown / 按钮 / 富媒体 |
|
|
39
40
|
| **飞书** | 飞书消息/机器人集成,办公场景直接调用 | 规划中 |
|
|
40
41
|
| **OpenClaw** | 与 OpenClaw 生态打通 | 规划中 |
|
|
41
42
|
| **Telegram** | 适合自托管的 IM 渠道 | 规划中 |
|
package/client/client.js
CHANGED
|
@@ -323,7 +323,10 @@ function PlatformCard({ platformId, platformName, platformDesc, rpcCall, onStatu
|
|
|
323
323
|
digestIntervalSec: String(platform.config.digestIntervalSec ?? 300),
|
|
324
324
|
approvalTimeoutSec: String(platform.config.approvalTimeoutSec ?? 600),
|
|
325
325
|
maxMessageChars: String(platform.config.maxMessageChars ?? 2e3),
|
|
326
|
-
sendChunkDelayMs: String(platform.config.sendChunkDelayMs ?? 1500)
|
|
326
|
+
sendChunkDelayMs: String(platform.config.sendChunkDelayMs ?? 1500),
|
|
327
|
+
appId: platform.config.appId ?? "",
|
|
328
|
+
// Secret 不由后端回传;空值表示沿用已保存密钥
|
|
329
|
+
clientSecret: ""
|
|
327
330
|
});
|
|
328
331
|
}
|
|
329
332
|
}, [platform?.config]);
|
|
@@ -380,14 +383,19 @@ function PlatformCard({ platformId, platformName, platformDesc, rpcCall, onStatu
|
|
|
380
383
|
const handleNewId = React.useCallback((e) => setNewId(e.target.value), []);
|
|
381
384
|
const saveConfig = React.useCallback(async () => {
|
|
382
385
|
if (!cfgDraft) return;
|
|
383
|
-
|
|
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
|
-
|
|
390
|
-
|
|
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: "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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
|
-
:
|
|
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: '
|
|
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
|
-
//
|
|
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,387 @@
|
|
|
1
|
+
// QQ Bot OpenAPI v2 gateway
|
|
2
|
+
// Official API: https://bot.q.qq.com/wiki/develop/api-v2/
|
|
3
|
+
|
|
4
|
+
import { Service } from '@deepseek-ai/cordis'
|
|
5
|
+
import WebSocket from 'ws'
|
|
6
|
+
|
|
7
|
+
const TOKEN_URL = 'https://bots.qq.com/app/getAppAccessToken'
|
|
8
|
+
const API_BASE = 'https://api.sgroup.qq.com'
|
|
9
|
+
const DEFAULT_GATEWAY = 'wss://api.sgroup.qq.com/websocket/'
|
|
10
|
+
const MAX_MESSAGE_CHARS = 2000
|
|
11
|
+
const TOKEN_MARGIN_MS = 5 * 60_000
|
|
12
|
+
const REQUEST_TIMEOUT_MS = 15_000
|
|
13
|
+
const RECONNECT_DELAY_MS = 3000
|
|
14
|
+
|
|
15
|
+
export const QQ_INTENTS = {
|
|
16
|
+
C2C_MESSAGE_CREATE: 1 << 25,
|
|
17
|
+
INTERACTION_CREATE: 1 << 26,
|
|
18
|
+
GROUP_AT_MESSAGE_CREATE: 1 << 30,
|
|
19
|
+
PUBLIC_GUILD_MESSAGES: 1 << 9,
|
|
20
|
+
DIRECT_MESSAGE: 1 << 12,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
24
|
+
const stringValue = (value) => value == null ? '' : String(value)
|
|
25
|
+
|
|
26
|
+
async function requestJson(url, { method = 'GET', token, body, timeoutMs = REQUEST_TIMEOUT_MS } = {}) {
|
|
27
|
+
const controller = new AbortController()
|
|
28
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
29
|
+
try {
|
|
30
|
+
const response = await fetch(url, {
|
|
31
|
+
method,
|
|
32
|
+
headers: {
|
|
33
|
+
accept: 'application/json',
|
|
34
|
+
'content-type': 'application/json',
|
|
35
|
+
...(token ? { authorization: `QQBot ${token}` } : {}),
|
|
36
|
+
},
|
|
37
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
38
|
+
signal: controller.signal,
|
|
39
|
+
})
|
|
40
|
+
const raw = await response.text()
|
|
41
|
+
let value = {}
|
|
42
|
+
try { value = raw ? JSON.parse(raw) : {} } catch { value = { message: raw } }
|
|
43
|
+
if (!response.ok) {
|
|
44
|
+
const error = new Error(`QQ API ${response.status}: ${value?.message || value?.msg || value?.code || response.statusText}`)
|
|
45
|
+
error.status = response.status
|
|
46
|
+
error.payload = value
|
|
47
|
+
throw error
|
|
48
|
+
}
|
|
49
|
+
return value
|
|
50
|
+
} finally { clearTimeout(timer) }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function normalizeEvent(payload) {
|
|
54
|
+
const data = payload?.d || {}
|
|
55
|
+
const event = payload?.t || ''
|
|
56
|
+
if (event === 'C2C_MESSAGE_CREATE') {
|
|
57
|
+
return {
|
|
58
|
+
type: 'message', scope: 'c2c', event,
|
|
59
|
+
id: data.id || data.msg_id,
|
|
60
|
+
senderId: data.author?.user_openid || data.user_openid,
|
|
61
|
+
peerId: data.author?.user_openid || data.user_openid,
|
|
62
|
+
text: stringValue(data.content).trim(),
|
|
63
|
+
message: data,
|
|
64
|
+
messageReference: data.message_reference,
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (event === 'GROUP_AT_MESSAGE_CREATE') {
|
|
68
|
+
return {
|
|
69
|
+
type: 'message', scope: 'group', event,
|
|
70
|
+
id: data.id || data.msg_id,
|
|
71
|
+
senderId: data.author?.member_openid || data.author?.id || data.member_openid,
|
|
72
|
+
peerId: data.group_openid || data.group_id,
|
|
73
|
+
groupId: data.group_openid || data.group_id,
|
|
74
|
+
text: stringValue(data.content).trim(),
|
|
75
|
+
message: data,
|
|
76
|
+
messageReference: data.message_reference,
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (event === 'AT_MESSAGE_CREATE') {
|
|
80
|
+
return {
|
|
81
|
+
type: 'message', scope: 'guild', event,
|
|
82
|
+
id: data.id || data.msg_id,
|
|
83
|
+
senderId: data.author?.id,
|
|
84
|
+
peerId: data.channel_id,
|
|
85
|
+
guildId: data.guild_id,
|
|
86
|
+
text: stringValue(data.content).trim(),
|
|
87
|
+
message: data,
|
|
88
|
+
messageReference: data.message_reference,
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (event === 'INTERACTION_CREATE') {
|
|
92
|
+
return {
|
|
93
|
+
type: 'interaction', scope: data.group_openid ? 'group' : 'c2c', event,
|
|
94
|
+
id: data.id,
|
|
95
|
+
interactionId: data.id,
|
|
96
|
+
senderId: data.group_member_openid || data.user_openid,
|
|
97
|
+
peerId: data.group_openid || data.user_openid,
|
|
98
|
+
groupId: data.group_openid,
|
|
99
|
+
data: data.data,
|
|
100
|
+
message: data,
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return { type: 'event', event, message: data }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export class QqGateway extends Service {
|
|
107
|
+
constructor({ ctx, logger, config = {}, onPersist } = {}) {
|
|
108
|
+
super(ctx, 'qq')
|
|
109
|
+
this.logger = logger
|
|
110
|
+
this.onPersist = onPersist || (() => {})
|
|
111
|
+
this.config = {
|
|
112
|
+
appId: config.appId || '',
|
|
113
|
+
clientSecret: config.clientSecret || '',
|
|
114
|
+
accessToken: config.accessToken || '',
|
|
115
|
+
accessTokenExpiresAt: Number(config.accessTokenExpiresAt || 0),
|
|
116
|
+
gatewayUrl: config.gatewayUrl || '',
|
|
117
|
+
intents: Number(config.intents ?? (QQ_INTENTS.C2C_MESSAGE_CREATE | QQ_INTENTS.GROUP_AT_MESSAGE_CREATE | QQ_INTENTS.INTERACTION_CREATE)),
|
|
118
|
+
reconnectDelayMs: Number(config.reconnectDelayMs ?? RECONNECT_DELAY_MS),
|
|
119
|
+
apiTimeoutMs: Number(config.apiTimeoutMs ?? REQUEST_TIMEOUT_MS),
|
|
120
|
+
}
|
|
121
|
+
this.statusValue = 'idle'
|
|
122
|
+
this.accountId = config.accountId || ''
|
|
123
|
+
this.ws = null
|
|
124
|
+
this.loopTask = null
|
|
125
|
+
this.stopRequested = false
|
|
126
|
+
this.heartbeatTimer = null
|
|
127
|
+
this.heartbeatInterval = 30_000
|
|
128
|
+
this.sequence = null
|
|
129
|
+
this.tokenPromise = null
|
|
130
|
+
this.dedup = new Map()
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
get status() { return this.statusValue }
|
|
134
|
+
get configured() { return Boolean(this.config.appId && this.config.clientSecret) }
|
|
135
|
+
get capabilities() {
|
|
136
|
+
return { supportsGroup: true, supportsMedia: true, supportsVoice: true, supportsTyping: false, maxMessageChars: MAX_MESSAGE_CHARS }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
setStatus(status) {
|
|
140
|
+
this.statusValue = status
|
|
141
|
+
try { this.ctx.emit?.('qq/status', status) } catch {}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async dispose() { await this.stop(); super.dispose?.() }
|
|
145
|
+
|
|
146
|
+
async stop() {
|
|
147
|
+
this.stopRequested = true
|
|
148
|
+
this.clearHeartbeat()
|
|
149
|
+
const ws = this.ws
|
|
150
|
+
this.ws = null
|
|
151
|
+
if (ws) { try { ws.removeAllListeners(); ws.close(); ws.terminate() } catch {} }
|
|
152
|
+
const task = this.loopTask
|
|
153
|
+
this.loopTask = null
|
|
154
|
+
if (task) await task.catch(() => {})
|
|
155
|
+
this.setStatus('idle')
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async start() {
|
|
159
|
+
if (!this.configured) { this.setStatus('idle'); return }
|
|
160
|
+
if (!this.loopTask) {
|
|
161
|
+
this.stopRequested = false
|
|
162
|
+
this.loopTask = this.runLoop().finally(() => { this.loopTask = null })
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async persist(patch) {
|
|
167
|
+
try { await this.onPersist(patch) } catch (error) {
|
|
168
|
+
this.logger?.warn?.('[dsh-bridge qq] persist failed: %s', error?.message ?? error)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async refreshAccessToken() {
|
|
173
|
+
if (this.config.accessToken && this.config.accessTokenExpiresAt > Date.now() + TOKEN_MARGIN_MS) {
|
|
174
|
+
return this.config.accessToken
|
|
175
|
+
}
|
|
176
|
+
if (this.tokenPromise) return this.tokenPromise
|
|
177
|
+
if (!this.configured) throw new Error('QQ Bot 缺少 AppID 或 ClientSecret')
|
|
178
|
+
this.tokenPromise = (async () => {
|
|
179
|
+
const result = await requestJson(TOKEN_URL, {
|
|
180
|
+
method: 'POST',
|
|
181
|
+
body: { appId: this.config.appId, clientSecret: this.config.clientSecret },
|
|
182
|
+
timeoutMs: this.config.apiTimeoutMs,
|
|
183
|
+
})
|
|
184
|
+
const token = result?.access_token || result?.accessToken
|
|
185
|
+
if (!token) throw new Error('QQ Bot 未返回 access_token')
|
|
186
|
+
const expires = Math.max(60, Number(result?.expires_in || 7200))
|
|
187
|
+
this.config.accessToken = token
|
|
188
|
+
this.config.accessTokenExpiresAt = Date.now() + expires * 1000
|
|
189
|
+
await this.persist({ accessToken: token, accessTokenExpiresAt: this.config.accessTokenExpiresAt })
|
|
190
|
+
return token
|
|
191
|
+
})().finally(() => { this.tokenPromise = null })
|
|
192
|
+
return this.tokenPromise
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async api(path, options = {}) {
|
|
196
|
+
return requestJson(`${API_BASE}${path}`, {
|
|
197
|
+
...options,
|
|
198
|
+
token: await this.refreshAccessToken(),
|
|
199
|
+
timeoutMs: this.config.apiTimeoutMs,
|
|
200
|
+
})
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async runLoop() {
|
|
204
|
+
while (!this.stopRequested) {
|
|
205
|
+
try {
|
|
206
|
+
this.setStatus('starting')
|
|
207
|
+
const token = await this.refreshAccessToken()
|
|
208
|
+
const gateway = this.config.gatewayUrl
|
|
209
|
+
|| ((await requestJson(`${API_BASE}/gateway`, { token, timeoutMs: this.config.apiTimeoutMs })).url)
|
|
210
|
+
|| DEFAULT_GATEWAY
|
|
211
|
+
await this.connect(gateway, token)
|
|
212
|
+
} catch (error) {
|
|
213
|
+
if (this.stopRequested) break
|
|
214
|
+
this.setStatus('reconnecting')
|
|
215
|
+
this.logger?.warn?.('[dsh-bridge qq] gateway disconnected: %s', error?.message ?? error)
|
|
216
|
+
await sleep(this.config.reconnectDelayMs)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
this.setStatus('idle')
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
connect(url, token) {
|
|
223
|
+
return new Promise((resolve, reject) => {
|
|
224
|
+
const ws = new WebSocket(url)
|
|
225
|
+
this.ws = ws
|
|
226
|
+
let settled = false
|
|
227
|
+
const finish = (error) => {
|
|
228
|
+
if (settled) return
|
|
229
|
+
settled = true
|
|
230
|
+
this.clearHeartbeat()
|
|
231
|
+
if (this.ws === ws) this.ws = null
|
|
232
|
+
error ? reject(error) : resolve()
|
|
233
|
+
}
|
|
234
|
+
ws.on('open', () => this.logger?.info?.('[dsh-bridge qq] gateway connected'))
|
|
235
|
+
ws.on('message', (raw) => {
|
|
236
|
+
let payload
|
|
237
|
+
try { payload = JSON.parse(String(raw)) } catch { return }
|
|
238
|
+
void this.handlePayload(payload, token, ws).catch(finish)
|
|
239
|
+
})
|
|
240
|
+
ws.on('error', finish)
|
|
241
|
+
ws.on('close', (code, reason) => finish(new Error(`QQ gateway closed: ${code} ${reason || ''}`)))
|
|
242
|
+
})
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async handlePayload(payload, token, ws) {
|
|
246
|
+
const op = Number(payload?.op)
|
|
247
|
+
if (payload?.s != null) this.sequence = payload.s
|
|
248
|
+
if (op === 10) {
|
|
249
|
+
this.heartbeatInterval = Number(payload?.d?.heartbeat_interval || 30_000)
|
|
250
|
+
this.startHeartbeat(ws)
|
|
251
|
+
ws.send(JSON.stringify({
|
|
252
|
+
op: 2,
|
|
253
|
+
d: {
|
|
254
|
+
token: `QQBot ${token}`,
|
|
255
|
+
intents: this.config.intents,
|
|
256
|
+
shard: [0, 1],
|
|
257
|
+
properties: { $os: process.platform, $browser: 'dsh-bridge', $device: 'dsh-bridge' },
|
|
258
|
+
},
|
|
259
|
+
}))
|
|
260
|
+
return
|
|
261
|
+
}
|
|
262
|
+
if (op === 0) {
|
|
263
|
+
if (payload.t === 'READY') {
|
|
264
|
+
this.accountId = payload.d?.user?.id || payload.d?.user?.username || this.accountId
|
|
265
|
+
await this.persist({ accountId: this.accountId })
|
|
266
|
+
this.setStatus('connected')
|
|
267
|
+
}
|
|
268
|
+
const event = normalizeEvent(payload)
|
|
269
|
+
if (event.type === 'message' && event.id && !this.seen(event.id)) {
|
|
270
|
+
this.ctx.emit?.('qq/message', event)
|
|
271
|
+
}
|
|
272
|
+
if (event.type === 'interaction' && event.interactionId) {
|
|
273
|
+
this.ctx.emit?.('qq/interaction', event)
|
|
274
|
+
}
|
|
275
|
+
return
|
|
276
|
+
}
|
|
277
|
+
if (op === 7) { try { ws.close() } catch {}; return }
|
|
278
|
+
if (op === 9) throw new Error('QQ gateway invalid session')
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
startHeartbeat(ws) {
|
|
282
|
+
this.clearHeartbeat()
|
|
283
|
+
const beat = () => {
|
|
284
|
+
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ op: 1, d: this.sequence }))
|
|
285
|
+
}
|
|
286
|
+
beat()
|
|
287
|
+
this.heartbeatTimer = setInterval(beat, this.heartbeatInterval)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
clearHeartbeat() {
|
|
291
|
+
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer)
|
|
292
|
+
this.heartbeatTimer = null
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
seen(id) {
|
|
296
|
+
const now = Date.now()
|
|
297
|
+
for (const [key, at] of this.dedup) if (now - at > 300_000) this.dedup.delete(key)
|
|
298
|
+
if (this.dedup.has(id)) return true
|
|
299
|
+
this.dedup.set(id, now)
|
|
300
|
+
return false
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
endpoint(peerId, scope, kind = 'messages') {
|
|
304
|
+
return scope === 'group'
|
|
305
|
+
? `/v2/groups/${encodeURIComponent(peerId)}/${kind}`
|
|
306
|
+
: `/v2/users/${encodeURIComponent(peerId)}/${kind}`
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async sendText(peerId, content, opts = {}) {
|
|
310
|
+
return this.api(this.endpoint(peerId, opts.scope), {
|
|
311
|
+
method: 'POST',
|
|
312
|
+
body: {
|
|
313
|
+
content: stringValue(content).slice(0, MAX_MESSAGE_CHARS),
|
|
314
|
+
msg_type: 0,
|
|
315
|
+
msg_id: opts.msgId,
|
|
316
|
+
event_id: opts.eventId,
|
|
317
|
+
},
|
|
318
|
+
})
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async sendMarkdown(peerId, markdown, opts = {}) {
|
|
322
|
+
return this.api(this.endpoint(peerId, opts.scope), {
|
|
323
|
+
method: 'POST',
|
|
324
|
+
body: {
|
|
325
|
+
markdown: typeof markdown === 'string' ? { content: markdown } : markdown,
|
|
326
|
+
msg_type: 2,
|
|
327
|
+
msg_id: opts.msgId,
|
|
328
|
+
event_id: opts.eventId,
|
|
329
|
+
},
|
|
330
|
+
})
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async sendKeyboard(peerId, content, keyboard, opts = {}) {
|
|
334
|
+
return this.api(this.endpoint(peerId, opts.scope), {
|
|
335
|
+
method: 'POST',
|
|
336
|
+
body: {
|
|
337
|
+
content: stringValue(content),
|
|
338
|
+
keyboard,
|
|
339
|
+
msg_type: 2,
|
|
340
|
+
msg_id: opts.msgId,
|
|
341
|
+
event_id: opts.eventId,
|
|
342
|
+
},
|
|
343
|
+
})
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async sendMedia(peerId, media, opts = {}) {
|
|
347
|
+
return this.api(this.endpoint(peerId, opts.scope, 'files'), {
|
|
348
|
+
method: 'POST',
|
|
349
|
+
body: {
|
|
350
|
+
file_type: Number(media.fileType || 1),
|
|
351
|
+
url: media.url,
|
|
352
|
+
srv_send_msg: media.sendMessage !== false,
|
|
353
|
+
},
|
|
354
|
+
})
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async sendStream(peerId, content, opts = {}) {
|
|
358
|
+
const endpoint = this.endpoint(peerId, opts.scope, 'stream-messages')
|
|
359
|
+
return this.api(endpoint, {
|
|
360
|
+
method: 'POST',
|
|
361
|
+
body: {
|
|
362
|
+
content: stringValue(content).slice(0, MAX_MESSAGE_CHARS),
|
|
363
|
+
msg_type: 0,
|
|
364
|
+
msg_id: opts.msgId,
|
|
365
|
+
event_id: opts.eventId,
|
|
366
|
+
},
|
|
367
|
+
})
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async respondInteraction(interactionId, response) {
|
|
371
|
+
return this.api(`/interactions/${encodeURIComponent(interactionId)}`, {
|
|
372
|
+
method: 'PUT',
|
|
373
|
+
body: response,
|
|
374
|
+
})
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async sendTyping() { return { ok: false, unsupported: true } }
|
|
378
|
+
|
|
379
|
+
setCredentials(values = {}) {
|
|
380
|
+
for (const key of ['appId', 'clientSecret', 'accessToken', 'accessTokenExpiresAt', 'gatewayUrl', 'intents']) {
|
|
381
|
+
if (values[key] !== undefined) this.config[key] = values[key]
|
|
382
|
+
}
|
|
383
|
+
if (values.accountId !== undefined) this.accountId = values.accountId
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export const gatewayConstants = { API_BASE, TOKEN_URL, DEFAULT_GATEWAY, MAX_MESSAGE_CHARS, QQ_INTENTS }
|
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,210 @@
|
|
|
1
|
+
// dsh-bridge QQ conversation node
|
|
2
|
+
// 把 QQ OpenAPI v2 的入站事件(C2C 私聊 / 群聊 @提及)解析后交给平台无关的
|
|
3
|
+
// ConversationBridge 处理,出站通过 QqGateway 发送文本 / Markdown / 按钮。
|
|
4
|
+
// 平台特定部分:
|
|
5
|
+
// - 入站解析:event.scope 决定 c2c / group / guild,text 直接来自 content
|
|
6
|
+
// - 出站:sendText 走 gateway.sendText;长文本用 Markdown 分块
|
|
7
|
+
// - 群聊:@提及消息仅在命中机器人才处理(GROUP_AT_MESSAGE_CREATE 已保证)
|
|
8
|
+
|
|
9
|
+
import { ConversationBridge, conversationBridgeHelpers } from '../platform/conversation-bridge.js'
|
|
10
|
+
import { gatewayConstants } from './gateway.js'
|
|
11
|
+
|
|
12
|
+
const MAX_MESSAGE_CHARS = gatewayConstants.MAX_MESSAGE_CHARS
|
|
13
|
+
|
|
14
|
+
// 把 QqGateway 适配为 ConversationBridge 需要的 Platform 消息接口
|
|
15
|
+
function makePlatform(gateway) {
|
|
16
|
+
return {
|
|
17
|
+
id: 'qq',
|
|
18
|
+
name: 'QQ',
|
|
19
|
+
get accountId() { return gateway.accountId ?? '' },
|
|
20
|
+
get capabilities() { return gateway.capabilities },
|
|
21
|
+
sendText: (peer, text) => gateway.sendText(peer, text, {}),
|
|
22
|
+
sendTyping: (peer, state) => gateway.sendTyping(peer, state),
|
|
23
|
+
sendKeyboard: (peer, content, keyboard) => gateway.sendKeyboard(peer, content, keyboard, {}),
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class QqConversationNode extends ConversationBridge {
|
|
28
|
+
/**
|
|
29
|
+
* @param {object} ctx Cordis 上下文(含 ctx.qq 网关服务)
|
|
30
|
+
* @param {object} config 已持久化配置(allowFrom/间隔/活动会话等)
|
|
31
|
+
* @param {object} logger 日志器
|
|
32
|
+
* @param {object} [opts]
|
|
33
|
+
* @param {(senderId: string) => void} [opts.onFirstSender]
|
|
34
|
+
* @param {(sessionId: string) => void} [opts.onActiveSessionChange]
|
|
35
|
+
*/
|
|
36
|
+
constructor(ctx, config, logger, { onFirstSender, onActiveSessionChange } = {}) {
|
|
37
|
+
super({
|
|
38
|
+
ctx,
|
|
39
|
+
logger,
|
|
40
|
+
config,
|
|
41
|
+
platform: makePlatform(ctx.qq),
|
|
42
|
+
onFirstSender,
|
|
43
|
+
onActiveSessionChange,
|
|
44
|
+
})
|
|
45
|
+
this.gateway = ctx.qq
|
|
46
|
+
this.lastMessageId = null // 存储最后发送的消息 ID,用于消息引用
|
|
47
|
+
|
|
48
|
+
// 订阅网关入站事件
|
|
49
|
+
this.ctx.on('qq/message', (event) => {
|
|
50
|
+
void this._handleInbound(event)
|
|
51
|
+
})
|
|
52
|
+
this.ctx.on('qq/interaction', (event) => {
|
|
53
|
+
void this._handleInteraction(event)
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---- 出站:覆盖 sendText,在提示消息中添加按钮 ----
|
|
58
|
+
|
|
59
|
+
async sendText(text) {
|
|
60
|
+
const peer = this.peerId
|
|
61
|
+
if (!peer) return
|
|
62
|
+
|
|
63
|
+
// 检测是否是提示用户开始新会话的消息
|
|
64
|
+
const isPromptMessage = text.includes('没有活动会话') || text.includes('恢复会话失败')
|
|
65
|
+
|
|
66
|
+
if (isPromptMessage) {
|
|
67
|
+
// 发送带按钮的消息,方便用户快速操作
|
|
68
|
+
const keyboard = {
|
|
69
|
+
rows: [
|
|
70
|
+
{
|
|
71
|
+
buttons: [
|
|
72
|
+
{ id: 'new_conversation', render_data: { label: '🆕 新建会话', visited_label: '新建会话' }, action: { type: 2, permission: { type: 2 } } },
|
|
73
|
+
{ id: 'list_sessions', render_data: { label: '📋 会话列表', visited_label: '会话列表' }, action: { type: 2, permission: { type: 2 } } },
|
|
74
|
+
],
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
buttons: [
|
|
78
|
+
{ id: 'help', render_data: { label: '❓ 帮助', visited_label: '帮助' }, action: { type: 2, permission: { type: 2 } } },
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
}
|
|
83
|
+
const result = await this.platform.sendKeyboard(peer, text, keyboard)
|
|
84
|
+
// 保存消息 ID
|
|
85
|
+
if (result?.data?.message_id) {
|
|
86
|
+
this.lastMessageId = result.data.message_id
|
|
87
|
+
}
|
|
88
|
+
return result
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 其他消息使用流式发送
|
|
92
|
+
const content = String(text || '').trim()
|
|
93
|
+
if (content.length === 0) return { success: true }
|
|
94
|
+
|
|
95
|
+
const STREAM_CHUNK_SIZE = 200
|
|
96
|
+
|
|
97
|
+
// 如果消息较短,直接发送普通消息
|
|
98
|
+
if (content.length <= STREAM_CHUNK_SIZE) {
|
|
99
|
+
const result = await this.gateway.sendText(peer, content, {})
|
|
100
|
+
// 保存消息 ID
|
|
101
|
+
if (result?.data?.message_id) {
|
|
102
|
+
this.lastMessageId = result.data.message_id
|
|
103
|
+
}
|
|
104
|
+
return result
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// 流式发送:把内容切分成多段
|
|
108
|
+
const chunks = []
|
|
109
|
+
for (let i = 0; i < content.length; i += STREAM_CHUNK_SIZE) {
|
|
110
|
+
chunks.push(content.slice(i, i + STREAM_CHUNK_SIZE))
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 逐段发送流式消息
|
|
114
|
+
let firstMsgId = null
|
|
115
|
+
for (const chunk of chunks) {
|
|
116
|
+
const result = await this.gateway.sendStream(peer, chunk, {
|
|
117
|
+
msgId: firstMsgId, // 后续段关联到第一段
|
|
118
|
+
})
|
|
119
|
+
if (!firstMsgId && result?.data?.message_id) {
|
|
120
|
+
firstMsgId = result.data.message_id
|
|
121
|
+
this.lastMessageId = firstMsgId // 保存第一段消息 ID
|
|
122
|
+
}
|
|
123
|
+
if (result?.error) {
|
|
124
|
+
return { success: false, error: result.error }
|
|
125
|
+
}
|
|
126
|
+
// 流式发送间隔稍短,避免刷屏
|
|
127
|
+
await new Promise(resolve => setTimeout(resolve, 100))
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return { success: true }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ---- 入站 ----
|
|
134
|
+
|
|
135
|
+
async _handleInbound(event) {
|
|
136
|
+
const sender = String(event.senderId ?? '').trim()
|
|
137
|
+
if (!sender) return
|
|
138
|
+
|
|
139
|
+
// 快速预检查:白名单非空时,未授权发件人直接忽略
|
|
140
|
+
if (!this.isAllowed(sender) && this.config.allowFrom.length > 0) {
|
|
141
|
+
this.logger?.info?.(`[dsh-bridge qq] ignore message from non-allowlisted sender ${sender}`)
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const text = String(event.text ?? '').trim()
|
|
146
|
+
if (!text) {
|
|
147
|
+
this.logger?.info?.(`[dsh-bridge qq] ignore empty message from ${sender}`)
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 群聊:仅在群聊 @ 机器人事件中处理(GROUP_AT_MESSAGE_CREATE 已由网关归一化)
|
|
152
|
+
const isGroup = event.scope === 'group' || event.scope === 'guild'
|
|
153
|
+
|
|
154
|
+
// 检测消息引用(文本交互):如果用户回复了机器人的消息,且当前没有活动会话或消息以 /new 开头,
|
|
155
|
+
// 则自动创建新会话并发送消息
|
|
156
|
+
const messageReference = event.messageReference
|
|
157
|
+
if (messageReference && !this.activeSessionId && !text.startsWith('/')) {
|
|
158
|
+
this.logger?.info?.(`[dsh-bridge qq] detected message reference from ${sender}, auto-starting conversation`)
|
|
159
|
+
// 先创建新会话,再处理消息
|
|
160
|
+
await this.handleInbound({ senderId: sender, text: '/new', isGroup })
|
|
161
|
+
// 等待一小段时间确保会话创建完成
|
|
162
|
+
await new Promise(resolve => setTimeout(resolve, 100))
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// 交给平台无关核心:白名单/群消息/命令路由/agent 分发
|
|
166
|
+
await this.handleInbound({ senderId: sender, text, isGroup })
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ---- 互动事件 ----
|
|
170
|
+
|
|
171
|
+
async _handleInteraction(event) {
|
|
172
|
+
const sender = String(event.senderId ?? '').trim()
|
|
173
|
+
if (!sender) return
|
|
174
|
+
|
|
175
|
+
// 快速预检查:白名单非空时,未授权发件人直接忽略
|
|
176
|
+
if (!this.isAllowed(sender) && this.config.allowFrom.length > 0) {
|
|
177
|
+
this.logger?.info?.(`[dsh-bridge qq] ignore interaction from non-allowlisted sender ${sender}`)
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const data = event.data || {}
|
|
182
|
+
const resolved = data.resolved || {}
|
|
183
|
+
const buttonId = resolved.button_id || ''
|
|
184
|
+
|
|
185
|
+
// 响应互动
|
|
186
|
+
await this.gateway.respondInteraction(event.interactionId, {
|
|
187
|
+
code: 0,
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
// 根据按钮 ID 执行对应操作
|
|
191
|
+
if (buttonId === 'new_conversation') {
|
|
192
|
+
await this.handleInbound({ senderId: sender, text: '/new', isGroup: event.scope === 'group' })
|
|
193
|
+
} else if (buttonId === 'list_sessions') {
|
|
194
|
+
await this.handleInbound({ senderId: sender, text: '/list', isGroup: event.scope === 'group' })
|
|
195
|
+
} else if (buttonId === 'help') {
|
|
196
|
+
await this.handleInbound({ senderId: sender, text: '/help', isGroup: event.scope === 'group' })
|
|
197
|
+
} else {
|
|
198
|
+
this.logger?.info?.(`[dsh-bridge qq] unknown button interaction: ${buttonId}`)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// 导出,便于测试与复用
|
|
204
|
+
export const qqNodeHelpers = {
|
|
205
|
+
splitForQQ: conversationBridgeHelpers.splitForIM,
|
|
206
|
+
digestLine: conversationBridgeHelpers.digestLine,
|
|
207
|
+
textOfAssistantMessage: conversationBridgeHelpers.textOfAssistantMessage,
|
|
208
|
+
listSessions: conversationBridgeHelpers.listSessions,
|
|
209
|
+
sessionsInDisplayOrder: conversationBridgeHelpers.sessionsInDisplayOrder,
|
|
210
|
+
}
|
package/package.json
CHANGED