@ryantest/openclaw-qqbot 0.0.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/LICENSE +22 -0
- package/README.md +483 -0
- package/README.zh.md +478 -0
- package/bin/qqbot-cli.js +243 -0
- package/clawdbot.plugin.json +16 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +26 -0
- package/dist/src/admin-resolver.d.ts +27 -0
- package/dist/src/admin-resolver.js +122 -0
- package/dist/src/api.d.ts +156 -0
- package/dist/src/api.js +599 -0
- package/dist/src/channel.d.ts +11 -0
- package/dist/src/channel.js +354 -0
- package/dist/src/config.d.ts +25 -0
- package/dist/src/config.js +161 -0
- package/dist/src/credential-backup.d.ts +31 -0
- package/dist/src/credential-backup.js +66 -0
- package/dist/src/gateway.d.ts +18 -0
- package/dist/src/gateway.js +1265 -0
- package/dist/src/image-server.d.ts +68 -0
- package/dist/src/image-server.js +462 -0
- package/dist/src/inbound-attachments.d.ts +58 -0
- package/dist/src/inbound-attachments.js +234 -0
- package/dist/src/known-users.d.ts +100 -0
- package/dist/src/known-users.js +263 -0
- package/dist/src/message-queue.d.ts +50 -0
- package/dist/src/message-queue.js +115 -0
- package/dist/src/onboarding.d.ts +10 -0
- package/dist/src/onboarding.js +203 -0
- package/dist/src/outbound-deliver.d.ts +48 -0
- package/dist/src/outbound-deliver.js +462 -0
- package/dist/src/outbound.d.ts +203 -0
- package/dist/src/outbound.js +1102 -0
- package/dist/src/proactive.d.ts +170 -0
- package/dist/src/proactive.js +399 -0
- package/dist/src/ref-index-store.d.ts +70 -0
- package/dist/src/ref-index-store.js +273 -0
- package/dist/src/reply-dispatcher.d.ts +35 -0
- package/dist/src/reply-dispatcher.js +311 -0
- package/dist/src/runtime.d.ts +3 -0
- package/dist/src/runtime.js +10 -0
- package/dist/src/session-store.d.ts +52 -0
- package/dist/src/session-store.js +254 -0
- package/dist/src/slash-commands.d.ts +71 -0
- package/dist/src/slash-commands.js +1179 -0
- package/dist/src/startup-greeting.d.ts +30 -0
- package/dist/src/startup-greeting.js +78 -0
- package/dist/src/stt.d.ts +21 -0
- package/dist/src/stt.js +70 -0
- package/dist/src/tools/channel.d.ts +16 -0
- package/dist/src/tools/channel.js +234 -0
- package/dist/src/tools/remind.d.ts +2 -0
- package/dist/src/tools/remind.js +247 -0
- package/dist/src/types.d.ts +175 -0
- package/dist/src/types.js +1 -0
- package/dist/src/typing-keepalive.d.ts +27 -0
- package/dist/src/typing-keepalive.js +64 -0
- package/dist/src/update-checker.d.ts +34 -0
- package/dist/src/update-checker.js +166 -0
- package/dist/src/user-messages.d.ts +8 -0
- package/dist/src/user-messages.js +8 -0
- package/dist/src/utils/audio-convert.d.ts +89 -0
- package/dist/src/utils/audio-convert.js +704 -0
- package/dist/src/utils/file-utils.d.ts +55 -0
- package/dist/src/utils/file-utils.js +150 -0
- package/dist/src/utils/image-size.d.ts +51 -0
- package/dist/src/utils/image-size.js +234 -0
- package/dist/src/utils/media-tags.d.ts +14 -0
- package/dist/src/utils/media-tags.js +164 -0
- package/dist/src/utils/payload.d.ts +112 -0
- package/dist/src/utils/payload.js +186 -0
- package/dist/src/utils/platform.d.ts +137 -0
- package/dist/src/utils/platform.js +390 -0
- package/dist/src/utils/text-parsing.d.ts +32 -0
- package/dist/src/utils/text-parsing.js +80 -0
- package/dist/src/utils/upload-cache.d.ts +34 -0
- package/dist/src/utils/upload-cache.js +93 -0
- package/index.ts +31 -0
- package/moltbot.plugin.json +16 -0
- package/node_modules/@eshaz/web-worker/LICENSE +201 -0
- package/node_modules/@eshaz/web-worker/README.md +134 -0
- package/node_modules/@eshaz/web-worker/browser.js +17 -0
- package/node_modules/@eshaz/web-worker/cjs/browser.js +16 -0
- package/node_modules/@eshaz/web-worker/cjs/node.js +219 -0
- package/node_modules/@eshaz/web-worker/index.d.ts +4 -0
- package/node_modules/@eshaz/web-worker/node.js +223 -0
- package/node_modules/@eshaz/web-worker/package.json +54 -0
- package/node_modules/@wasm-audio-decoders/common/index.js +5 -0
- package/node_modules/@wasm-audio-decoders/common/package.json +36 -0
- package/node_modules/@wasm-audio-decoders/common/src/WASMAudioDecoderCommon.js +231 -0
- package/node_modules/@wasm-audio-decoders/common/src/WASMAudioDecoderWorker.js +129 -0
- package/node_modules/@wasm-audio-decoders/common/src/puff/README +67 -0
- package/node_modules/@wasm-audio-decoders/common/src/puff/build_puff.js +31 -0
- package/node_modules/@wasm-audio-decoders/common/src/puff/puff.c +863 -0
- package/node_modules/@wasm-audio-decoders/common/src/puff/puff.h +35 -0
- package/node_modules/@wasm-audio-decoders/common/src/utilities.js +3 -0
- package/node_modules/@wasm-audio-decoders/common/types.d.ts +7 -0
- package/node_modules/mpg123-decoder/README.md +265 -0
- package/node_modules/mpg123-decoder/dist/mpg123-decoder.min.js +185 -0
- package/node_modules/mpg123-decoder/dist/mpg123-decoder.min.js.map +1 -0
- package/node_modules/mpg123-decoder/index.js +8 -0
- package/node_modules/mpg123-decoder/package.json +58 -0
- package/node_modules/mpg123-decoder/src/EmscriptenWasm.js +464 -0
- package/node_modules/mpg123-decoder/src/MPEGDecoder.js +200 -0
- package/node_modules/mpg123-decoder/src/MPEGDecoderWebWorker.js +21 -0
- package/node_modules/mpg123-decoder/types.d.ts +30 -0
- package/node_modules/silk-wasm/LICENSE +21 -0
- package/node_modules/silk-wasm/README.md +85 -0
- package/node_modules/silk-wasm/lib/index.cjs +16 -0
- package/node_modules/silk-wasm/lib/index.d.ts +70 -0
- package/node_modules/silk-wasm/lib/index.mjs +16 -0
- package/node_modules/silk-wasm/lib/silk.wasm +0 -0
- package/node_modules/silk-wasm/lib/utils.d.ts +4 -0
- package/node_modules/silk-wasm/package.json +39 -0
- package/node_modules/simple-yenc/.github/FUNDING.yml +1 -0
- package/node_modules/simple-yenc/.prettierignore +1 -0
- package/node_modules/simple-yenc/LICENSE +7 -0
- package/node_modules/simple-yenc/README.md +163 -0
- package/node_modules/simple-yenc/dist/esm.js +1 -0
- package/node_modules/simple-yenc/dist/index.js +1 -0
- package/node_modules/simple-yenc/package.json +50 -0
- package/node_modules/simple-yenc/rollup.config.js +27 -0
- package/node_modules/simple-yenc/src/simple-yenc.js +302 -0
- package/node_modules/ws/LICENSE +20 -0
- package/node_modules/ws/README.md +548 -0
- package/node_modules/ws/browser.js +8 -0
- package/node_modules/ws/index.js +13 -0
- package/node_modules/ws/lib/buffer-util.js +131 -0
- package/node_modules/ws/lib/constants.js +19 -0
- package/node_modules/ws/lib/event-target.js +292 -0
- package/node_modules/ws/lib/extension.js +203 -0
- package/node_modules/ws/lib/limiter.js +55 -0
- package/node_modules/ws/lib/permessage-deflate.js +528 -0
- package/node_modules/ws/lib/receiver.js +706 -0
- package/node_modules/ws/lib/sender.js +602 -0
- package/node_modules/ws/lib/stream.js +161 -0
- package/node_modules/ws/lib/subprotocol.js +62 -0
- package/node_modules/ws/lib/validation.js +152 -0
- package/node_modules/ws/lib/websocket-server.js +554 -0
- package/node_modules/ws/lib/websocket.js +1393 -0
- package/node_modules/ws/package.json +69 -0
- package/node_modules/ws/wrapper.mjs +8 -0
- package/openclaw.plugin.json +16 -0
- package/package.json +76 -0
- package/scripts/cleanup-legacy-plugins.sh +124 -0
- package/scripts/proactive-api-server.ts +369 -0
- package/scripts/send-proactive.ts +293 -0
- package/scripts/set-markdown.sh +156 -0
- package/scripts/test-sendmedia.ts +116 -0
- package/scripts/upgrade-via-alt-pkg.sh +307 -0
- package/scripts/upgrade-via-npm.ps1 +296 -0
- package/scripts/upgrade-via-npm.sh +301 -0
- package/scripts/upgrade-via-source.sh +774 -0
- package/skills/qqbot-channel/SKILL.md +263 -0
- package/skills/qqbot-channel/references/api_references.md +521 -0
- package/skills/qqbot-media/SKILL.md +56 -0
- package/skills/qqbot-remind/SKILL.md +149 -0
- package/src/admin-resolver.ts +140 -0
- package/src/api.ts +819 -0
- package/src/bot-logs-2026-03-21T11-21-47(2).txt +46 -0
- package/src/channel.ts +381 -0
- package/src/config.ts +187 -0
- package/src/credential-backup.ts +72 -0
- package/src/gateway.log +43 -0
- package/src/gateway.ts +1404 -0
- package/src/image-server.ts +539 -0
- package/src/inbound-attachments.ts +304 -0
- package/src/known-users.ts +353 -0
- package/src/message-queue.ts +169 -0
- package/src/onboarding.ts +274 -0
- package/src/openclaw-2026-03-21.log +3729 -0
- package/src/openclaw-plugin-sdk.d.ts +522 -0
- package/src/outbound-deliver.ts +552 -0
- package/src/outbound.ts +1266 -0
- package/src/proactive.ts +530 -0
- package/src/ref-index-store.ts +357 -0
- package/src/reply-dispatcher.ts +334 -0
- package/src/runtime.ts +14 -0
- package/src/session-store.ts +303 -0
- package/src/slash-commands.ts +1305 -0
- package/src/startup-greeting.ts +98 -0
- package/src/stt.ts +86 -0
- package/src/tools/channel.ts +281 -0
- package/src/tools/remind.ts +296 -0
- package/src/types.ts +183 -0
- package/src/typing-keepalive.ts +59 -0
- package/src/update-checker.ts +179 -0
- package/src/user-messages.ts +7 -0
- package/src/utils/audio-convert.ts +803 -0
- package/src/utils/file-utils.ts +167 -0
- package/src/utils/image-size.ts +266 -0
- package/src/utils/media-tags.ts +182 -0
- package/src/utils/payload.ts +265 -0
- package/src/utils/platform.ts +435 -0
- package/src/utils/text-parsing.ts +82 -0
- package/src/utils/upload-cache.ts +128 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
|
|
2
|
+
========== gateway.log (last 44 of 44 lines) ==========
|
|
3
|
+
from: C:\Users\v_qachchen\.openclaw
|
|
4
|
+
[2026-03-20T15:55:33.816Z] state: stopped → starting
|
|
5
|
+
[2026-03-20T15:55:33.819Z] [extract] 检测到 runtime.tar.gz,开始解压...
|
|
6
|
+
[2026-03-20T15:55:37.282Z] [extract] runtime.tar.gz 解压完成 (3.5s)
|
|
7
|
+
[2026-03-20T15:55:37.286Z] [extract] 检测到 gateway.tar.gz,开始解压...
|
|
8
|
+
[2026-03-20T15:56:05.916Z] [extract] gateway.tar.gz 解压完成 (28.6s)
|
|
9
|
+
[2026-03-20T15:56:05.924Z] --- gateway start ---
|
|
10
|
+
[2026-03-20T15:56:05.926Z] platform=win32 arch=x64 packaged=true
|
|
11
|
+
[2026-03-20T15:56:05.927Z] resourcesPath=C:\Users\v_qachchen\AppData\Local\Programs\HoldClaw\resources\resources
|
|
12
|
+
[2026-03-20T15:56:05.928Z] nodeBin=C:\Users\v_qachchen\AppData\Local\Programs\HoldClaw\resources\resources\runtime\node.exe exists=true
|
|
13
|
+
[2026-03-20T15:56:05.928Z] entry=C:\Users\v_qachchen\AppData\Local\Programs\HoldClaw\resources\resources\gateway\node_modules\openclaw\openclaw.mjs exists=true
|
|
14
|
+
[2026-03-20T15:56:05.929Z] cwd=C:\Users\v_qachchen\.openclaw\workspace exists=true
|
|
15
|
+
[2026-03-20T15:56:05.929Z] token=a98d...06c4 port=19789
|
|
16
|
+
[2026-03-20T15:56:05.978Z] TTS 未配置,使用默认 SiliconFlow URL
|
|
17
|
+
[2026-03-20T15:56:05.979Z] spawn: C:\Users\v_qachchen\AppData\Local\Programs\HoldClaw\resources\resources\runtime\node.exe C:\Users\v_qachchen\AppData\Local\Programs\HoldClaw\resources\resources\gateway\node_modules\openclaw\openclaw.mjs gateway run --port 19789 --bind loopback
|
|
18
|
+
[2026-03-20T15:56:14.259Z] stdout: |
|
|
19
|
+
o Doctor warnings ------------------------------------------------------+
|
|
20
|
+
| |
|
|
21
|
+
| - channels.imessage.groupPolicy is "allowlist" but groupAllowFrom is |
|
|
22
|
+
| empty — this channel does not fall back to allowFrom, so all group |
|
|
23
|
+
| messages will be silently dropped. Add sender IDs to |
|
|
24
|
+
| channels.imessage.groupAllowFrom, or set groupPolicy to "open". |
|
|
25
|
+
| |
|
|
26
|
+
+------------------------------------------------------------------------+
|
|
27
|
+
[2026-03-20T15:56:44.457Z] stdout: 2026-03-20T15:56:44.457Z [canvas] host mounted at http://127.0.0.1:19789/__openclaw__/canvas/ (root C:\Users\v_qachchen\.openclaw\canvas)
|
|
28
|
+
[2026-03-20T15:56:44.786Z] stdout: 2026-03-20T15:56:44.784Z [heartbeat] started
|
|
29
|
+
[2026-03-20T15:56:44.791Z] stdout: 2026-03-20T15:56:44.791Z [health-monitor] started (interval: 300s, startup-grace: 60s, channel-connect-grace: 120s)
|
|
30
|
+
[2026-03-20T15:56:44.804Z] stdout: 2026-03-20T15:56:44.802Z [gateway] agent model: custom/hy-hunyuan-instruct
|
|
31
|
+
[2026-03-20T15:56:44.806Z] stdout: 2026-03-20T15:56:44.806Z [gateway] listening on ws://127.0.0.1:19789, ws://[::1]:19789 (PID 224544)
|
|
32
|
+
[2026-03-20T15:56:44.812Z] stdout: 2026-03-20T15:56:44.812Z [gateway] log file: \tmp\openclaw\openclaw-2026-03-20.log
|
|
33
|
+
[2026-03-20T15:56:44.929Z] stdout: 2026-03-20T15:56:44.929Z [browser/server] Browser control listening on http://127.0.0.1:19791/ (auth=token)
|
|
34
|
+
[2026-03-20T15:56:45.150Z] health check passed, child alive
|
|
35
|
+
[2026-03-20T15:56:45.151Z] state: starting → running
|
|
36
|
+
[2026-03-20T15:56:46.272Z] stdout: 2026-03-20T15:56:46.112Z [hooks:loader] Registered hook: boot-md -> gateway:startup
|
|
37
|
+
2026-03-20T15:56:46.179Z [hooks:loader] Registered hook: bootstrap-extra-files -> agent:bootstrap
|
|
38
|
+
2026-03-20T15:56:46.247Z [hooks:loader] Registered hook: command-logger -> command
|
|
39
|
+
[2026-03-20T15:56:46.273Z] stderr: 2026-03-20T15:56:46.166Z [ws] closed before connect conn=bd4ae208-a072-4475-8c6b-63daad0e7025 remote=127.0.0.1 fwd=n/a origin=file:// host=127.0.0.1:19789 ua=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HoldClaw/1.0.14 Chrome/144.0.7559.225 Electron/40.7.0 Safari/537.36 code=1006 reason=n/a
|
|
40
|
+
2026-03-20T15:56:46.235Z [ws] unauthorized conn=723433fc-d3d5-4065-b919-924e0992dcfe remote=127.0.0.1 client=openclaw-control-ui webchat vdev reason=token_mismatch
|
|
41
|
+
2026-03-20T15:56:46.252Z [ws] closed before connect conn=723433fc-d3d5-4065-b919-924e0992dcfe remote=127.0.0.1 fwd=n/a origin=file:// host=127.0.0.1:19789 ua=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HoldClaw/1.0.14 Chrome/144.0.7559.225 Electron/40.7.0 Safari/537.36 code=1008 reason=unauthorized: gateway token mismatch (open the dashboard URL and paste the token in Control UI settings)
|
|
42
|
+
[2026-03-20T15:56:46.318Z] stdout: 2026-03-20T15:56:46.302Z [hooks:loader] Registered hook: session-memory -> command:new, command:reset
|
|
43
|
+
2026-03-20T15:56:46.308Z [hooks] loaded 4 internal hook handlers
|
|
44
|
+
[2026-03-20T15:56:46.637Z] stdout: 2026-03-20T15:56:46.637Z [gateway] update available (latest): v2026.3.13 (current v2026.3.2). Run: openclaw update
|
|
45
|
+
[2026-03-20T15:56:47.076Z] stdout: 2026-03-20T15:56:47.076Z [gateway] device pairing auto-approved device=bd0fa7fe507891dfe0875c53fd38d0582145228b1478b4e62ad102f07d9254df role=operator
|
|
46
|
+
[2026-03-20T15:56:47.081Z] stdout: 2026-03-20T15:56:47.081Z [ws] webchat connected conn=200f754d-9791-4dcc-bfaa-93c839311f65 remote=127.0.0.1 client=openclaw-control-ui webchat vdev
|
package/src/channel.ts
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ChannelPlugin,
|
|
3
|
+
type OpenClawConfig,
|
|
4
|
+
applyAccountNameToChannelSection,
|
|
5
|
+
deleteAccountFromConfigSection,
|
|
6
|
+
setAccountEnabledInConfigSection,
|
|
7
|
+
} from "openclaw/plugin-sdk";
|
|
8
|
+
|
|
9
|
+
import type { ResolvedQQBotAccount } from "./types.js";
|
|
10
|
+
import { DEFAULT_ACCOUNT_ID, listQQBotAccountIds, resolveQQBotAccount, applyQQBotAccountConfig, resolveDefaultQQBotAccountId } from "./config.js";
|
|
11
|
+
import { sendText, sendMedia } from "./outbound.js";
|
|
12
|
+
import { startGateway } from "./gateway.js";
|
|
13
|
+
import { qqbotOnboardingAdapter } from "./onboarding.js";
|
|
14
|
+
import { getQQBotRuntime } from "./runtime.js";
|
|
15
|
+
import { saveCredentialBackup, loadCredentialBackup } from "./credential-backup.js";
|
|
16
|
+
import { initApiConfig } from "./api.js";
|
|
17
|
+
|
|
18
|
+
/** QQ Bot 单条消息文本长度上限 */
|
|
19
|
+
export const TEXT_CHUNK_LIMIT = 5000;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Markdown 感知的文本分块函数
|
|
23
|
+
* 委托给 SDK 内置的 channel.text.chunkMarkdownText
|
|
24
|
+
* 支持代码块自动关闭/重开、括号感知等
|
|
25
|
+
*/
|
|
26
|
+
export function chunkText(text: string, limit: number): string[] {
|
|
27
|
+
const runtime = getQQBotRuntime();
|
|
28
|
+
return runtime.channel.text.chunkMarkdownText(text, limit);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
|
|
32
|
+
id: "qqbot",
|
|
33
|
+
meta: {
|
|
34
|
+
id: "qqbot",
|
|
35
|
+
label: "QQ Bot",
|
|
36
|
+
selectionLabel: "QQ Bot",
|
|
37
|
+
docsPath: "/docs/channels/qqbot",
|
|
38
|
+
blurb: "Connect to QQ via official QQ Bot API",
|
|
39
|
+
order: 50,
|
|
40
|
+
},
|
|
41
|
+
capabilities: {
|
|
42
|
+
chatTypes: ["direct", "group"],
|
|
43
|
+
media: true,
|
|
44
|
+
reactions: false,
|
|
45
|
+
threads: false,
|
|
46
|
+
/**
|
|
47
|
+
* blockStreaming: true 表示该 Channel 支持块流式
|
|
48
|
+
* 框架会收集流式响应,然后通过 deliver 回调发送
|
|
49
|
+
*/
|
|
50
|
+
blockStreaming: true,
|
|
51
|
+
},
|
|
52
|
+
reload: { configPrefixes: ["channels.qqbot"] },
|
|
53
|
+
// CLI onboarding wizard
|
|
54
|
+
onboarding: qqbotOnboardingAdapter,
|
|
55
|
+
|
|
56
|
+
config: {
|
|
57
|
+
listAccountIds: (cfg) => listQQBotAccountIds(cfg),
|
|
58
|
+
resolveAccount: (cfg, accountId) => resolveQQBotAccount(cfg, accountId),
|
|
59
|
+
defaultAccountId: (cfg) => resolveDefaultQQBotAccountId(cfg),
|
|
60
|
+
// 新增:设置账户启用状态
|
|
61
|
+
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
|
62
|
+
setAccountEnabledInConfigSection({
|
|
63
|
+
cfg,
|
|
64
|
+
sectionKey: "qqbot",
|
|
65
|
+
accountId,
|
|
66
|
+
enabled,
|
|
67
|
+
allowTopLevel: true,
|
|
68
|
+
}),
|
|
69
|
+
// 新增:删除账户
|
|
70
|
+
deleteAccount: ({ cfg, accountId }) =>
|
|
71
|
+
deleteAccountFromConfigSection({
|
|
72
|
+
cfg,
|
|
73
|
+
sectionKey: "qqbot",
|
|
74
|
+
accountId,
|
|
75
|
+
clearBaseFields: ["appId", "clientSecret", "clientSecretFile", "name"],
|
|
76
|
+
}),
|
|
77
|
+
isConfigured: (account) => {
|
|
78
|
+
if (account?.appId && account?.clientSecret) return true;
|
|
79
|
+
// 配置为空但有凭证备份时仍返回 true,让 startAccount 有机会恢复凭证
|
|
80
|
+
const backup = loadCredentialBackup(account?.accountId);
|
|
81
|
+
return backup !== null;
|
|
82
|
+
},
|
|
83
|
+
describeAccount: (account) => ({
|
|
84
|
+
accountId: account?.accountId ?? DEFAULT_ACCOUNT_ID,
|
|
85
|
+
name: account?.name,
|
|
86
|
+
enabled: account?.enabled ?? false,
|
|
87
|
+
configured: Boolean(account?.appId && account?.clientSecret),
|
|
88
|
+
tokenSource: account?.secretSource,
|
|
89
|
+
}),
|
|
90
|
+
// 关键:解析 allowFrom 配置,用于命令授权
|
|
91
|
+
resolveAllowFrom: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId?: string }) => {
|
|
92
|
+
const account = resolveQQBotAccount(cfg, accountId);
|
|
93
|
+
const allowFrom = account.config?.allowFrom ?? [];
|
|
94
|
+
console.log(`[qqbot] resolveAllowFrom: accountId=${accountId}, allowFrom=${JSON.stringify(allowFrom)}`);
|
|
95
|
+
return allowFrom.map((entry: string | number) => String(entry));
|
|
96
|
+
},
|
|
97
|
+
// 格式化 allowFrom 条目(移除 qqbot: 前缀,统一大写)
|
|
98
|
+
formatAllowFrom: ({ allowFrom }: { allowFrom: Array<string | number> }) =>
|
|
99
|
+
allowFrom
|
|
100
|
+
.map((entry: string | number) => String(entry).trim())
|
|
101
|
+
.filter(Boolean)
|
|
102
|
+
.map((entry: string) => entry.replace(/^qqbot:/i, ""))
|
|
103
|
+
.map((entry: string) => entry.toUpperCase()), // QQ openid 是大写的
|
|
104
|
+
},
|
|
105
|
+
setup: {
|
|
106
|
+
// 新增:规范化账户 ID
|
|
107
|
+
resolveAccountId: ({ accountId }) => accountId?.trim().toLowerCase() || DEFAULT_ACCOUNT_ID,
|
|
108
|
+
// 新增:应用账户名称
|
|
109
|
+
applyAccountName: ({ cfg, accountId, name }) =>
|
|
110
|
+
applyAccountNameToChannelSection({
|
|
111
|
+
cfg,
|
|
112
|
+
channelKey: "qqbot",
|
|
113
|
+
accountId,
|
|
114
|
+
name,
|
|
115
|
+
}),
|
|
116
|
+
validateInput: ({ input }) => {
|
|
117
|
+
if (!input.token && !input.tokenFile && !input.useEnv) {
|
|
118
|
+
return "QQBot requires --token (format: appId:clientSecret) or --use-env";
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
},
|
|
122
|
+
applyAccountConfig: ({ cfg, accountId, input }) => {
|
|
123
|
+
let appId = "";
|
|
124
|
+
let clientSecret = "";
|
|
125
|
+
|
|
126
|
+
if (input.token) {
|
|
127
|
+
const parts = input.token.split(":");
|
|
128
|
+
if (parts.length === 2) {
|
|
129
|
+
appId = parts[0];
|
|
130
|
+
clientSecret = parts[1];
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return applyQQBotAccountConfig(cfg, accountId, {
|
|
135
|
+
appId,
|
|
136
|
+
clientSecret,
|
|
137
|
+
clientSecretFile: input.tokenFile,
|
|
138
|
+
name: input.name,
|
|
139
|
+
imageServerBaseUrl: input.imageServerBaseUrl,
|
|
140
|
+
});
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
// Messaging 配置:用于解析目标地址
|
|
144
|
+
messaging: {
|
|
145
|
+
/**
|
|
146
|
+
* 规范化目标地址
|
|
147
|
+
* 支持以下格式:
|
|
148
|
+
* - qqbot:c2c:openid -> 私聊
|
|
149
|
+
* - qqbot:group:groupid -> 群聊
|
|
150
|
+
* - qqbot:channel:channelid -> 频道
|
|
151
|
+
* - c2c:openid -> 私聊
|
|
152
|
+
* - group:groupid -> 群聊
|
|
153
|
+
* - channel:channelid -> 频道
|
|
154
|
+
* - 纯 openid(32位十六进制)-> 私聊
|
|
155
|
+
*/
|
|
156
|
+
normalizeTarget: (target: string): string | undefined => {
|
|
157
|
+
// 去掉 qqbot: 前缀(如果有)
|
|
158
|
+
const id = target.replace(/^qqbot:/i, "");
|
|
159
|
+
|
|
160
|
+
// 检查是否是已知格式
|
|
161
|
+
if (id.startsWith("c2c:") || id.startsWith("group:") || id.startsWith("channel:")) {
|
|
162
|
+
return `qqbot:${id}`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// 检查是否是纯 openid(32位十六进制,不带连字符)
|
|
166
|
+
// QQ Bot OpenID 格式类似: 207A5B8339D01F6582911C014668B77B
|
|
167
|
+
const openIdHexPattern = /^[0-9a-fA-F]{32}$/;
|
|
168
|
+
if (openIdHexPattern.test(id)) {
|
|
169
|
+
return `qqbot:c2c:${id}`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// 检查是否是 UUID 格式的 openid(带连字符)
|
|
173
|
+
const openIdUuidPattern = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
|
174
|
+
if (openIdUuidPattern.test(id)) {
|
|
175
|
+
return `qqbot:c2c:${id}`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// 不认识的格式,返回 undefined 让核心使用原始值
|
|
179
|
+
return undefined;
|
|
180
|
+
},
|
|
181
|
+
/**
|
|
182
|
+
* 目标解析器配置
|
|
183
|
+
* 用于判断一个目标 ID 是否看起来像 QQ Bot 的格式
|
|
184
|
+
*/
|
|
185
|
+
targetResolver: {
|
|
186
|
+
/**
|
|
187
|
+
* 判断目标 ID 是否可能是 QQ Bot 格式
|
|
188
|
+
* 支持以下格式:
|
|
189
|
+
* - qqbot:c2c:xxx
|
|
190
|
+
* - qqbot:group:xxx
|
|
191
|
+
* - qqbot:channel:xxx
|
|
192
|
+
* - c2c:xxx
|
|
193
|
+
* - group:xxx
|
|
194
|
+
* - channel:xxx
|
|
195
|
+
* - UUID 格式的 openid
|
|
196
|
+
*/
|
|
197
|
+
looksLikeId: (id: string): boolean => {
|
|
198
|
+
// 带 qqbot: 前缀的格式
|
|
199
|
+
if (/^qqbot:(c2c|group|channel):/i.test(id)) {
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
// 不带前缀但有类型标识
|
|
203
|
+
if (/^(c2c|group|channel):/i.test(id)) {
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
// 32位十六进制 openid(不带连字符)
|
|
207
|
+
if (/^[0-9a-fA-F]{32}$/.test(id)) {
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
// UUID 格式的 openid(带连字符)
|
|
211
|
+
const openIdPattern = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
|
212
|
+
return openIdPattern.test(id);
|
|
213
|
+
},
|
|
214
|
+
hint: "QQ Bot 目标格式: qqbot:c2c:openid (私聊) 或 qqbot:group:groupid (群聊)",
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
outbound: {
|
|
218
|
+
deliveryMode: "direct",
|
|
219
|
+
chunker: (text, limit) => getQQBotRuntime().channel.text.chunkMarkdownText(text, limit),
|
|
220
|
+
chunkerMode: "markdown",
|
|
221
|
+
textChunkLimit: 5000,
|
|
222
|
+
sendText: async ({ to, text, accountId, replyToId, cfg }) => {
|
|
223
|
+
console.log(`[qqbot:channel] sendText called — accountId=${accountId}, to=${to}, replyToId=${replyToId}, text.length=${text?.length ?? 0}`);
|
|
224
|
+
console.log(`[qqbot:channel] sendText text preview: ${text?.slice(0, 100)}${(text?.length ?? 0) > 100 ? "..." : ""}`);
|
|
225
|
+
const account = resolveQQBotAccount(cfg, accountId);
|
|
226
|
+
initApiConfig({ markdownSupport: account.markdownSupport });
|
|
227
|
+
console.log(`[qqbot:channel] sendText resolved account: id=${account.accountId}, appId=${account.appId}, enabled=${account.enabled}`);
|
|
228
|
+
const result = await sendText({ to, text, accountId, replyToId, account });
|
|
229
|
+
console.log(`[qqbot:channel] sendText result: messageId=${result.messageId}, error=${result.error ?? "none"}`);
|
|
230
|
+
return {
|
|
231
|
+
channel: "qqbot",
|
|
232
|
+
messageId: result.messageId,
|
|
233
|
+
error: result.error ? new Error(result.error) : undefined,
|
|
234
|
+
};
|
|
235
|
+
},
|
|
236
|
+
sendMedia: async ({ to, text, mediaUrl, accountId, replyToId, cfg }) => {
|
|
237
|
+
console.log(`[qqbot:channel] sendMedia called — accountId=${accountId}, to=${to}, replyToId=${replyToId}, mediaUrl=${mediaUrl?.slice(0, 80)}, text.length=${text?.length ?? 0}`);
|
|
238
|
+
const account = resolveQQBotAccount(cfg, accountId);
|
|
239
|
+
initApiConfig({ markdownSupport: account.markdownSupport });
|
|
240
|
+
console.log(`[qqbot:channel] sendMedia resolved account: id=${account.accountId}, appId=${account.appId}, enabled=${account.enabled}`);
|
|
241
|
+
const result = await sendMedia({ to, text: text ?? "", mediaUrl: mediaUrl ?? "", accountId, replyToId, account });
|
|
242
|
+
console.log(`[qqbot:channel] sendMedia result: messageId=${result.messageId}, error=${result.error ?? "none"}`);
|
|
243
|
+
return {
|
|
244
|
+
channel: "qqbot",
|
|
245
|
+
messageId: result.messageId,
|
|
246
|
+
error: result.error ? new Error(result.error) : undefined,
|
|
247
|
+
};
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
gateway: {
|
|
251
|
+
startAccount: async (ctx) => {
|
|
252
|
+
let { account } = ctx;
|
|
253
|
+
const { abortSignal, log, cfg } = ctx;
|
|
254
|
+
|
|
255
|
+
// 凭证恢复:如果 appId/secret 为空(热更新打断可能导致配置丢失),尝试从暂存文件恢复
|
|
256
|
+
if (!account.appId || !account.clientSecret) {
|
|
257
|
+
const backup = loadCredentialBackup(account.accountId);
|
|
258
|
+
if (backup) {
|
|
259
|
+
log?.info(`[qqbot:${account.accountId}] 配置中凭证为空,从暂存文件恢复 (appId=${backup.appId}, savedAt=${backup.savedAt})`);
|
|
260
|
+
try {
|
|
261
|
+
const runtime = getQQBotRuntime();
|
|
262
|
+
const restoredCfg = applyQQBotAccountConfig(cfg, account.accountId, {
|
|
263
|
+
appId: backup.appId,
|
|
264
|
+
clientSecret: backup.clientSecret,
|
|
265
|
+
});
|
|
266
|
+
const configApi = runtime.config as { writeConfigFile: (cfg: unknown) => Promise<void> };
|
|
267
|
+
await configApi.writeConfigFile(restoredCfg);
|
|
268
|
+
// 重新解析 account 以获取恢复后的值
|
|
269
|
+
account = resolveQQBotAccount(restoredCfg, account.accountId);
|
|
270
|
+
log?.info(`[qqbot:${account.accountId}] 凭证已恢复`);
|
|
271
|
+
} catch (e) {
|
|
272
|
+
log?.error(`[qqbot:${account.accountId}] 凭证恢复失败: ${e}`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
log?.info(`[qqbot:${account.accountId}] Starting gateway — appId=${account.appId}, enabled=${account.enabled}, name=${account.name ?? "unnamed"}`);
|
|
278
|
+
console.log(`[qqbot:channel] startAccount: accountId=${account.accountId}, appId=${account.appId}, secretSource=${account.secretSource}`);
|
|
279
|
+
|
|
280
|
+
await startGateway({
|
|
281
|
+
account,
|
|
282
|
+
abortSignal,
|
|
283
|
+
cfg,
|
|
284
|
+
log,
|
|
285
|
+
onReady: () => {
|
|
286
|
+
log?.info(`[qqbot:${account.accountId}] Gateway ready`);
|
|
287
|
+
// 启动成功,保存凭证快照供后续恢复使用
|
|
288
|
+
saveCredentialBackup(account.accountId, account.appId, account.clientSecret);
|
|
289
|
+
ctx.setStatus({
|
|
290
|
+
...ctx.getStatus(),
|
|
291
|
+
running: true,
|
|
292
|
+
connected: true,
|
|
293
|
+
lastConnectedAt: Date.now(),
|
|
294
|
+
});
|
|
295
|
+
},
|
|
296
|
+
onError: (error) => {
|
|
297
|
+
log?.error(`[qqbot:${account.accountId}] Gateway error: ${error.message}`);
|
|
298
|
+
ctx.setStatus({
|
|
299
|
+
...ctx.getStatus(),
|
|
300
|
+
lastError: error.message,
|
|
301
|
+
});
|
|
302
|
+
},
|
|
303
|
+
});
|
|
304
|
+
},
|
|
305
|
+
// 新增:登出账户(清除配置中的凭证)
|
|
306
|
+
logoutAccount: async ({ accountId, cfg }) => {
|
|
307
|
+
const nextCfg = { ...cfg } as OpenClawConfig;
|
|
308
|
+
const nextQQBot = cfg.channels?.qqbot ? { ...cfg.channels.qqbot } : undefined;
|
|
309
|
+
let cleared = false;
|
|
310
|
+
let changed = false;
|
|
311
|
+
|
|
312
|
+
if (nextQQBot) {
|
|
313
|
+
const qqbot = nextQQBot as Record<string, unknown>;
|
|
314
|
+
if (accountId === DEFAULT_ACCOUNT_ID && qqbot.clientSecret) {
|
|
315
|
+
delete qqbot.clientSecret;
|
|
316
|
+
cleared = true;
|
|
317
|
+
changed = true;
|
|
318
|
+
}
|
|
319
|
+
const accounts = qqbot.accounts as Record<string, Record<string, unknown>> | undefined;
|
|
320
|
+
if (accounts && accountId in accounts) {
|
|
321
|
+
const entry = accounts[accountId] as Record<string, unknown> | undefined;
|
|
322
|
+
if (entry && "clientSecret" in entry) {
|
|
323
|
+
delete entry.clientSecret;
|
|
324
|
+
cleared = true;
|
|
325
|
+
changed = true;
|
|
326
|
+
}
|
|
327
|
+
if (entry && Object.keys(entry).length === 0) {
|
|
328
|
+
delete accounts[accountId];
|
|
329
|
+
changed = true;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (changed && nextQQBot) {
|
|
335
|
+
nextCfg.channels = { ...nextCfg.channels, qqbot: nextQQBot };
|
|
336
|
+
const runtime = getQQBotRuntime();
|
|
337
|
+
const configApi = runtime.config as { writeConfigFile: (cfg: OpenClawConfig) => Promise<void> };
|
|
338
|
+
await configApi.writeConfigFile(nextCfg);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const resolved = resolveQQBotAccount(changed ? nextCfg : cfg, accountId);
|
|
342
|
+
const loggedOut = resolved.secretSource === "none";
|
|
343
|
+
const envToken = Boolean(process.env.QQBOT_CLIENT_SECRET);
|
|
344
|
+
|
|
345
|
+
return { ok: true, cleared, envToken, loggedOut };
|
|
346
|
+
},
|
|
347
|
+
},
|
|
348
|
+
status: {
|
|
349
|
+
defaultRuntime: {
|
|
350
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
351
|
+
running: false,
|
|
352
|
+
connected: false,
|
|
353
|
+
lastConnectedAt: null,
|
|
354
|
+
lastError: null,
|
|
355
|
+
lastInboundAt: null,
|
|
356
|
+
lastOutboundAt: null,
|
|
357
|
+
},
|
|
358
|
+
// 新增:构建通道摘要
|
|
359
|
+
buildChannelSummary: ({ snapshot }: { snapshot: Record<string, unknown> }) => ({
|
|
360
|
+
configured: snapshot.configured ?? false,
|
|
361
|
+
tokenSource: snapshot.tokenSource ?? "none",
|
|
362
|
+
running: snapshot.running ?? false,
|
|
363
|
+
connected: snapshot.connected ?? false,
|
|
364
|
+
lastConnectedAt: snapshot.lastConnectedAt ?? null,
|
|
365
|
+
lastError: snapshot.lastError ?? null,
|
|
366
|
+
}),
|
|
367
|
+
buildAccountSnapshot: ({ account, runtime }: { account?: ResolvedQQBotAccount; runtime?: Record<string, unknown> }) => ({
|
|
368
|
+
accountId: account?.accountId ?? DEFAULT_ACCOUNT_ID,
|
|
369
|
+
name: account?.name,
|
|
370
|
+
enabled: account?.enabled ?? false,
|
|
371
|
+
configured: Boolean(account?.appId && account?.clientSecret),
|
|
372
|
+
tokenSource: account?.secretSource,
|
|
373
|
+
running: runtime?.running ?? false,
|
|
374
|
+
connected: runtime?.connected ?? false,
|
|
375
|
+
lastConnectedAt: runtime?.lastConnectedAt ?? null,
|
|
376
|
+
lastError: runtime?.lastError ?? null,
|
|
377
|
+
lastInboundAt: runtime?.lastInboundAt ?? null,
|
|
378
|
+
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
|
379
|
+
}),
|
|
380
|
+
},
|
|
381
|
+
};
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import type { ResolvedQQBotAccount, QQBotAccountConfig } from "./types.js";
|
|
2
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_ACCOUNT_ID = "default";
|
|
5
|
+
|
|
6
|
+
interface QQBotChannelConfig extends QQBotAccountConfig {
|
|
7
|
+
accounts?: Record<string, QQBotAccountConfig>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function normalizeAppId(raw: unknown): string {
|
|
11
|
+
if (raw === null || raw === undefined) return "";
|
|
12
|
+
return String(raw).trim();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 列出所有 QQBot 账户 ID
|
|
17
|
+
*/
|
|
18
|
+
export function listQQBotAccountIds(cfg: OpenClawConfig): string[] {
|
|
19
|
+
const ids = new Set<string>();
|
|
20
|
+
const qqbot = cfg.channels?.qqbot as QQBotChannelConfig | undefined;
|
|
21
|
+
|
|
22
|
+
if (qqbot?.appId) {
|
|
23
|
+
ids.add(DEFAULT_ACCOUNT_ID);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (qqbot?.accounts) {
|
|
27
|
+
for (const accountId of Object.keys(qqbot.accounts)) {
|
|
28
|
+
if (qqbot.accounts[accountId]?.appId) {
|
|
29
|
+
ids.add(accountId);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return Array.from(ids);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 获取默认账户 ID
|
|
39
|
+
*/
|
|
40
|
+
export function resolveDefaultQQBotAccountId(cfg: OpenClawConfig): string {
|
|
41
|
+
const qqbot = cfg.channels?.qqbot as QQBotChannelConfig | undefined;
|
|
42
|
+
// 如果有默认账户配置,返回 default
|
|
43
|
+
if (qqbot?.appId) {
|
|
44
|
+
return DEFAULT_ACCOUNT_ID;
|
|
45
|
+
}
|
|
46
|
+
// 否则返回第一个配置的账户
|
|
47
|
+
if (qqbot?.accounts) {
|
|
48
|
+
const ids = Object.keys(qqbot.accounts);
|
|
49
|
+
if (ids.length > 0) {
|
|
50
|
+
return ids[0];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return DEFAULT_ACCOUNT_ID;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 解析 QQBot 账户配置
|
|
58
|
+
*/
|
|
59
|
+
export function resolveQQBotAccount(
|
|
60
|
+
cfg: OpenClawConfig,
|
|
61
|
+
accountId?: string | null
|
|
62
|
+
): ResolvedQQBotAccount {
|
|
63
|
+
const resolvedAccountId = accountId ?? DEFAULT_ACCOUNT_ID;
|
|
64
|
+
const qqbot = cfg.channels?.qqbot as QQBotChannelConfig | undefined;
|
|
65
|
+
|
|
66
|
+
// 基础配置
|
|
67
|
+
let accountConfig: QQBotAccountConfig = {};
|
|
68
|
+
let appId = "";
|
|
69
|
+
let clientSecret = "";
|
|
70
|
+
let secretSource: "config" | "file" | "env" | "none" = "none";
|
|
71
|
+
|
|
72
|
+
if (resolvedAccountId === DEFAULT_ACCOUNT_ID) {
|
|
73
|
+
// 默认账户从顶层读取
|
|
74
|
+
accountConfig = {
|
|
75
|
+
enabled: qqbot?.enabled,
|
|
76
|
+
name: qqbot?.name,
|
|
77
|
+
appId: qqbot?.appId,
|
|
78
|
+
clientSecret: qqbot?.clientSecret,
|
|
79
|
+
clientSecretFile: qqbot?.clientSecretFile,
|
|
80
|
+
dmPolicy: qqbot?.dmPolicy,
|
|
81
|
+
allowFrom: qqbot?.allowFrom,
|
|
82
|
+
systemPrompt: qqbot?.systemPrompt,
|
|
83
|
+
imageServerBaseUrl: qqbot?.imageServerBaseUrl,
|
|
84
|
+
markdownSupport: qqbot?.markdownSupport ?? true,
|
|
85
|
+
};
|
|
86
|
+
appId = normalizeAppId(qqbot?.appId);
|
|
87
|
+
} else {
|
|
88
|
+
// 命名账户从 accounts 读取
|
|
89
|
+
const account = qqbot?.accounts?.[resolvedAccountId];
|
|
90
|
+
accountConfig = account ?? {};
|
|
91
|
+
appId = normalizeAppId(account?.appId);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 解析 clientSecret
|
|
95
|
+
if (accountConfig.clientSecret) {
|
|
96
|
+
clientSecret = accountConfig.clientSecret;
|
|
97
|
+
secretSource = "config";
|
|
98
|
+
} else if (accountConfig.clientSecretFile) {
|
|
99
|
+
// 从文件读取(运行时处理)
|
|
100
|
+
secretSource = "file";
|
|
101
|
+
} else if (process.env.QQBOT_CLIENT_SECRET && resolvedAccountId === DEFAULT_ACCOUNT_ID) {
|
|
102
|
+
clientSecret = process.env.QQBOT_CLIENT_SECRET;
|
|
103
|
+
secretSource = "env";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// AppId 也可以从环境变量读取
|
|
107
|
+
if (!appId && process.env.QQBOT_APP_ID && resolvedAccountId === DEFAULT_ACCOUNT_ID) {
|
|
108
|
+
appId = normalizeAppId(process.env.QQBOT_APP_ID);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
accountId: resolvedAccountId,
|
|
113
|
+
name: accountConfig.name,
|
|
114
|
+
enabled: accountConfig.enabled !== false,
|
|
115
|
+
appId,
|
|
116
|
+
clientSecret,
|
|
117
|
+
secretSource,
|
|
118
|
+
systemPrompt: accountConfig.systemPrompt,
|
|
119
|
+
imageServerBaseUrl: accountConfig.imageServerBaseUrl || process.env.QQBOT_IMAGE_SERVER_BASE_URL,
|
|
120
|
+
markdownSupport: accountConfig.markdownSupport !== false,
|
|
121
|
+
config: accountConfig,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* 应用账户配置
|
|
127
|
+
*/
|
|
128
|
+
export function applyQQBotAccountConfig(
|
|
129
|
+
cfg: OpenClawConfig,
|
|
130
|
+
accountId: string,
|
|
131
|
+
input: { appId?: string; clientSecret?: string; clientSecretFile?: string; name?: string; imageServerBaseUrl?: string }
|
|
132
|
+
): OpenClawConfig {
|
|
133
|
+
const next = { ...cfg };
|
|
134
|
+
|
|
135
|
+
if (accountId === DEFAULT_ACCOUNT_ID) {
|
|
136
|
+
// 如果没有设置过 allowFrom,默认设置为 ["*"]
|
|
137
|
+
const existingConfig = (next.channels?.qqbot as QQBotChannelConfig) || {};
|
|
138
|
+
const allowFrom = existingConfig.allowFrom ?? ["*"];
|
|
139
|
+
|
|
140
|
+
next.channels = {
|
|
141
|
+
...next.channels,
|
|
142
|
+
qqbot: {
|
|
143
|
+
...(next.channels?.qqbot as Record<string, unknown> || {}),
|
|
144
|
+
enabled: true,
|
|
145
|
+
allowFrom,
|
|
146
|
+
...(input.appId ? { appId: input.appId } : {}),
|
|
147
|
+
...(input.clientSecret
|
|
148
|
+
? { clientSecret: input.clientSecret }
|
|
149
|
+
: input.clientSecretFile
|
|
150
|
+
? { clientSecretFile: input.clientSecretFile }
|
|
151
|
+
: {}),
|
|
152
|
+
...(input.name ? { name: input.name } : {}),
|
|
153
|
+
...(input.imageServerBaseUrl ? { imageServerBaseUrl: input.imageServerBaseUrl } : {}),
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
} else {
|
|
157
|
+
// 如果没有设置过 allowFrom,默认设置为 ["*"]
|
|
158
|
+
const existingAccountConfig = (next.channels?.qqbot as QQBotChannelConfig)?.accounts?.[accountId] || {};
|
|
159
|
+
const allowFrom = existingAccountConfig.allowFrom ?? ["*"];
|
|
160
|
+
|
|
161
|
+
next.channels = {
|
|
162
|
+
...next.channels,
|
|
163
|
+
qqbot: {
|
|
164
|
+
...(next.channels?.qqbot as Record<string, unknown> || {}),
|
|
165
|
+
enabled: true,
|
|
166
|
+
accounts: {
|
|
167
|
+
...((next.channels?.qqbot as QQBotChannelConfig)?.accounts || {}),
|
|
168
|
+
[accountId]: {
|
|
169
|
+
...((next.channels?.qqbot as QQBotChannelConfig)?.accounts?.[accountId] || {}),
|
|
170
|
+
enabled: true,
|
|
171
|
+
allowFrom,
|
|
172
|
+
...(input.appId ? { appId: input.appId } : {}),
|
|
173
|
+
...(input.clientSecret
|
|
174
|
+
? { clientSecret: input.clientSecret }
|
|
175
|
+
: input.clientSecretFile
|
|
176
|
+
? { clientSecretFile: input.clientSecretFile }
|
|
177
|
+
: {}),
|
|
178
|
+
...(input.name ? { name: input.name } : {}),
|
|
179
|
+
...(input.imageServerBaseUrl ? { imageServerBaseUrl: input.imageServerBaseUrl } : {}),
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return next;
|
|
187
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 凭证暂存与恢复
|
|
3
|
+
*
|
|
4
|
+
* 解决热更新被打断时 openclaw.json 中 appId/secret 丢失的问题。
|
|
5
|
+
*
|
|
6
|
+
* 原理:
|
|
7
|
+
* - 每次 gateway 成功启动后,把当前账户的 appId/secret 写入暂存文件
|
|
8
|
+
* - 插件启动时如果检测到配置中 appId/secret 为空,尝试从暂存文件恢复
|
|
9
|
+
* - 暂存文件存储在 ~/.openclaw/qqbot/data/ 下,不受插件目录替换影响
|
|
10
|
+
*
|
|
11
|
+
* 安全保障:
|
|
12
|
+
* - 只在 appId/secret **确实为空** 时才尝试恢复(不干扰正常配置变更)
|
|
13
|
+
* - 恢复后通过 openclaw 的 config API 写回配置文件,确保框架感知到变更
|
|
14
|
+
* - 暂存文件使用原子写入(先写 .tmp 再 rename)防止损坏
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import fs from "node:fs";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { getQQBotDataDir } from "./utils/platform.js";
|
|
20
|
+
|
|
21
|
+
const BACKUP_FILENAME = "credential-backup.json";
|
|
22
|
+
|
|
23
|
+
interface CredentialBackup {
|
|
24
|
+
accountId: string;
|
|
25
|
+
appId: string;
|
|
26
|
+
clientSecret: string;
|
|
27
|
+
savedAt: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function getBackupPath(): string {
|
|
31
|
+
return path.join(getQQBotDataDir("data"), BACKUP_FILENAME);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 保存凭证快照到暂存文件(gateway 成功启动后调用)
|
|
36
|
+
*/
|
|
37
|
+
export function saveCredentialBackup(accountId: string, appId: string, clientSecret: string): void {
|
|
38
|
+
if (!appId || !clientSecret) return; // 不保存空凭证
|
|
39
|
+
try {
|
|
40
|
+
const backupPath = getBackupPath();
|
|
41
|
+
const data: CredentialBackup = {
|
|
42
|
+
accountId,
|
|
43
|
+
appId,
|
|
44
|
+
clientSecret,
|
|
45
|
+
savedAt: new Date().toISOString(),
|
|
46
|
+
};
|
|
47
|
+
const tmpPath = backupPath + ".tmp";
|
|
48
|
+
fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2) + "\n", "utf8");
|
|
49
|
+
fs.renameSync(tmpPath, backupPath);
|
|
50
|
+
} catch {
|
|
51
|
+
// 非关键操作,静默忽略
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 从暂存文件读取凭证(仅在配置为空时调用)
|
|
57
|
+
* 返回 null 表示无可用备份
|
|
58
|
+
*/
|
|
59
|
+
export function loadCredentialBackup(accountId?: string): CredentialBackup | null {
|
|
60
|
+
try {
|
|
61
|
+
const backupPath = getBackupPath();
|
|
62
|
+
if (!fs.existsSync(backupPath)) return null;
|
|
63
|
+
const raw = fs.readFileSync(backupPath, "utf8");
|
|
64
|
+
const data: CredentialBackup = JSON.parse(raw);
|
|
65
|
+
if (!data.appId || !data.clientSecret) return null;
|
|
66
|
+
// 如果指定了 accountId,校验是否匹配
|
|
67
|
+
if (accountId && data.accountId !== accountId) return null;
|
|
68
|
+
return data;
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|