@tencent-connect/openclaw-qqbot 1.6.4-alpha.2 → 1.6.4-alpha.20
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 +24 -7
- package/README.zh.md +24 -7
- package/clawdbot.plugin.json +1 -1
- package/dist/index.js +2 -0
- package/dist/src/admin-resolver.d.ts +27 -0
- package/dist/src/admin-resolver.js +122 -0
- package/dist/src/channel.js +37 -2
- package/dist/src/credential-backup.d.ts +31 -0
- package/dist/src/credential-backup.js +66 -0
- package/dist/src/gateway.js +126 -1451
- package/dist/src/inbound-attachments.d.ts +58 -0
- package/dist/src/inbound-attachments.js +234 -0
- package/dist/src/message-queue.d.ts +50 -0
- package/dist/src/message-queue.js +115 -0
- package/dist/src/outbound-deliver.d.ts +48 -0
- package/dist/src/outbound-deliver.js +462 -0
- package/dist/src/outbound.js +3 -3
- package/dist/src/reply-dispatcher.d.ts +35 -0
- package/dist/src/reply-dispatcher.js +311 -0
- package/dist/src/slash-commands.d.ts +2 -0
- package/dist/src/slash-commands.js +778 -121
- 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/remind.d.ts +2 -0
- package/dist/src/tools/remind.js +247 -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 +16 -4
- package/dist/src/update-checker.js +60 -41
- package/dist/src/utils/file-utils.d.ts +9 -0
- package/dist/src/utils/file-utils.js +43 -0
- package/dist/src/utils/platform.d.ts +10 -0
- package/dist/src/utils/platform.js +16 -0
- package/dist/src/utils/text-parsing.d.ts +32 -0
- package/dist/src/utils/text-parsing.js +78 -0
- package/index.ts +2 -0
- package/moltbot.plugin.json +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/scripts/cleanup-legacy-plugins.sh +3 -6
- package/scripts/upgrade-via-alt-pkg.sh +307 -0
- package/scripts/upgrade-via-npm.ps1 +287 -0
- package/scripts/upgrade-via-npm.sh +30 -15
- package/scripts/upgrade-via-source.sh +69 -0
- package/skills/{qqbot-cron → qqbot-remind}/SKILL.md +40 -20
- package/src/admin-resolver.ts +140 -0
- package/src/bot-logs-2026-03-21T11-21-47(2).txt +46 -0
- package/src/channel.ts +36 -2
- package/src/credential-backup.ts +72 -0
- package/src/gateway.ts +147 -1506
- package/src/inbound-attachments.ts +304 -0
- package/src/message-queue.ts +169 -0
- package/src/outbound-deliver.ts +552 -0
- package/src/outbound.ts +3 -3
- package/src/reply-dispatcher.ts +334 -0
- package/src/slash-commands.ts +800 -118
- package/src/startup-greeting.ts +98 -0
- package/src/stt.ts +86 -0
- package/src/tools/remind.ts +296 -0
- package/src/typing-keepalive.ts +59 -0
- package/src/update-checker.ts +66 -42
- package/src/utils/file-utils.ts +45 -0
- package/src/utils/platform.ts +17 -0
- package/src/utils/text-parsing.ts +80 -0
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
**Connect your AI assistant to QQ — private chat, group chat, and rich media, all in one plugin.**
|
|
12
12
|
|
|
13
|
-
### 🚀 Current Version: `v1.6.
|
|
13
|
+
### 🚀 Current Version: `v1.6.4`
|
|
14
14
|
|
|
15
15
|
[](./LICENSE)
|
|
16
16
|
[](https://bot.q.qq.com/wiki/)
|
|
@@ -36,13 +36,13 @@ Scan to join the QQ group chat
|
|
|
36
36
|
|
|
37
37
|
| Feature | Description |
|
|
38
38
|
|---------|-------------|
|
|
39
|
-
| 🔒 **Multi-Scene** | C2C private chat, group @messages
|
|
39
|
+
| 🔒 **Multi-Scene** | C2C private chat, group @messages |
|
|
40
40
|
| 🖼️ **Rich Media** | Send & receive images, voice, video, and files |
|
|
41
41
|
| 🎙️ **Voice (STT/TTS)** | Speech-to-text transcription & text-to-speech replies |
|
|
42
|
+
| 🔥 **One-Click Hot Upgrade** | Send `/bot-upgrade` in private chat to upgrade — no server login needed |
|
|
42
43
|
| ⏰ **Scheduled Push** | Proactive message delivery via scheduled tasks |
|
|
43
44
|
| 🔗 **URL Support** | Direct URL sending in private chat (no restrictions) |
|
|
44
45
|
| ⌨️ **Typing Indicator** | "Bot is typing..." status shown in real-time |
|
|
45
|
-
| 🔄 **Hot Reload** | Install via npm with seamless hot updates |
|
|
46
46
|
| 📝 **Markdown** | Full Markdown formatting support |
|
|
47
47
|
| 🛠️ **Commands** | Native OpenClaw command integration |
|
|
48
48
|
| 💬 **Quoted Context** | Resolve QQ `REFIDX_*` quoted messages and inject quote body into AI context |
|
|
@@ -177,15 +177,24 @@ Shows framework version, plugin version, and a direct link to the official repos
|
|
|
177
177
|
|
|
178
178
|
<img width="360" src="docs/images/slash-help.jpg" alt="Help Demo" />
|
|
179
179
|
|
|
180
|
-
#### `/bot-upgrade` — Upgrade
|
|
180
|
+
#### `/bot-upgrade` — One-Click Hot Upgrade
|
|
181
181
|
|
|
182
182
|
> **You**: `/bot-upgrade`
|
|
183
183
|
>
|
|
184
|
-
> **QQBot**: 📌 Current
|
|
184
|
+
> **QQBot**: 📌 Current: v1.6.4-alpha.12 / ✅ New version v1.6.4 available / Click button below to confirm
|
|
185
185
|
|
|
186
|
-
|
|
186
|
+
Send in private chat to upgrade the plugin without server login. Supported usage:
|
|
187
187
|
|
|
188
|
-
|
|
188
|
+
| Command | Description |
|
|
189
|
+
|---------|-------------|
|
|
190
|
+
| `/bot-upgrade` | Check for updates, show confirmation button |
|
|
191
|
+
| `/bot-upgrade --latest` | Confirm upgrade to the latest version |
|
|
192
|
+
| `/bot-upgrade --version 1.6.4` | Upgrade to a specific version |
|
|
193
|
+
| `/bot-upgrade --force` | Force reinstall current version |
|
|
194
|
+
|
|
195
|
+
Credentials are automatically backed up before upgrade. Version existence is verified against npm before proceeding. Auto-recovery on failure.
|
|
196
|
+
|
|
197
|
+
<!-- TODO: add /bot-upgrade screenshot -->
|
|
189
198
|
|
|
190
199
|
#### `/bot-logs` — Log Export
|
|
191
200
|
|
|
@@ -197,6 +206,14 @@ Exports the last ~2000 lines of gateway logs as a file for quick troubleshooting
|
|
|
197
206
|
|
|
198
207
|
<img width="360" src="docs/images/slash-logs.jpg" alt="Logs Demo" />
|
|
199
208
|
|
|
209
|
+
#### Usage Help
|
|
210
|
+
|
|
211
|
+
All commands support a `?` suffix to show usage:
|
|
212
|
+
|
|
213
|
+
> **You**: `/bot-upgrade ?`
|
|
214
|
+
>
|
|
215
|
+
> **QQBot**: 📖 /bot-upgrade usage: …
|
|
216
|
+
|
|
200
217
|
---
|
|
201
218
|
|
|
202
219
|
## 🚀 Getting Started
|
package/README.zh.md
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
**让你的 AI 助手接入 QQ — 私聊、群聊、富媒体,一个插件全搞定。**
|
|
11
11
|
|
|
12
|
-
### 🚀 当前版本: `v1.6.
|
|
12
|
+
### 🚀 当前版本: `v1.6.4`
|
|
13
13
|
|
|
14
14
|
[](./LICENSE)
|
|
15
15
|
[](https://bot.q.qq.com/wiki/)
|
|
@@ -31,13 +31,13 @@
|
|
|
31
31
|
|
|
32
32
|
| 功能 | 说明 |
|
|
33
33
|
|------|------|
|
|
34
|
-
| 🔒 **多场景支持** | C2C 私聊、群聊
|
|
34
|
+
| 🔒 **多场景支持** | C2C 私聊、群聊 @消息 |
|
|
35
35
|
| 🖼️ **富媒体消息** | 支持图片、语音、视频、文件的收发 |
|
|
36
36
|
| 🎙️ **语音能力 (STT/TTS)** | 语音转文字自动转录 & 文字转语音回复 |
|
|
37
|
+
| 🔥 **一键热更新** | 私聊发送 `/bot-upgrade` 即可完成版本升级,无需登录服务器 |
|
|
37
38
|
| ⏰ **定时推送** | 支持定时任务触发后主动推送消息 |
|
|
38
39
|
| 🔗 **URL 无限制** | 私聊可直接发送 URL |
|
|
39
40
|
| ⌨️ **输入状态** | 实时显示"Bot 正在输入中…"状态 |
|
|
40
|
-
| 🔄 **热更新** | 支持 npm 方式安装和无缝热更新 |
|
|
41
41
|
| 📝 **Markdown** | 完整支持 Markdown 格式消息 |
|
|
42
42
|
| 🛠️ **原生命令** | 支持 OpenClaw 原生命令 |
|
|
43
43
|
| 💬 **引用上下文** | 解析 QQ `REFIDX_*` 引用消息,并将引用内容注入 AI 上下文 |
|
|
@@ -172,15 +172,24 @@ AI 可直接发送视频,支持本地文件和公网 URL。
|
|
|
172
172
|
|
|
173
173
|
<img width="360" src="docs/images/slash-help.jpg" alt="Help 演示" />
|
|
174
174
|
|
|
175
|
-
#### `/bot-upgrade` —
|
|
175
|
+
#### `/bot-upgrade` — 一键热更新
|
|
176
176
|
|
|
177
177
|
> **你**:`/bot-upgrade`
|
|
178
178
|
>
|
|
179
|
-
> **QQBot**:📌当前版本 /
|
|
179
|
+
> **QQBot**:📌当前版本 v1.6.4-alpha.12 / ✅发现新版本 v1.6.4 / 点击下方按钮确认升级
|
|
180
180
|
|
|
181
|
-
|
|
181
|
+
在私聊中发送即可完成版本升级,全程无需登录服务器。支持的用法:
|
|
182
182
|
|
|
183
|
-
|
|
183
|
+
| 命令 | 说明 |
|
|
184
|
+
|------|------|
|
|
185
|
+
| `/bot-upgrade` | 检查是否有新版本,展示确认按钮 |
|
|
186
|
+
| `/bot-upgrade --latest` | 确认升级到最新版本 |
|
|
187
|
+
| `/bot-upgrade --version 1.6.4` | 升级到指定版本 |
|
|
188
|
+
| `/bot-upgrade --force` | 强制重新安装当前版本 |
|
|
189
|
+
|
|
190
|
+
升级流程自动备份凭证,升级前校验版本是否存在于 npm,升级失败自动恢复。
|
|
191
|
+
|
|
192
|
+
<!-- TODO: 补充 /bot-upgrade 截图 -->
|
|
184
193
|
|
|
185
194
|
#### `/bot-logs` — 日志导出
|
|
186
195
|
|
|
@@ -192,6 +201,14 @@ AI 可直接发送视频,支持本地文件和公网 URL。
|
|
|
192
201
|
|
|
193
202
|
<img width="360" src="docs/images/slash-logs.jpg" alt="Logs 演示" />
|
|
194
203
|
|
|
204
|
+
#### 用法查询
|
|
205
|
+
|
|
206
|
+
所有指令都支持 `?` 后缀查看用法说明:
|
|
207
|
+
|
|
208
|
+
> **你**:`/bot-upgrade ?`
|
|
209
|
+
>
|
|
210
|
+
> **QQBot**:📖 /bot-upgrade 用法:…
|
|
211
|
+
|
|
195
212
|
---
|
|
196
213
|
|
|
197
214
|
## 🚀 快速开始
|
package/clawdbot.plugin.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"name": "OpenClaw QQ Bot",
|
|
4
4
|
"description": "QQ Bot channel plugin with message support, cron jobs, and proactive messaging",
|
|
5
5
|
"channels": ["qqbot"],
|
|
6
|
-
"skills": ["skills/qqbot-channel", "skills/qqbot-
|
|
6
|
+
"skills": ["skills/qqbot-channel", "skills/qqbot-remind", "skills/qqbot-media"],
|
|
7
7
|
"capabilities": {
|
|
8
8
|
"proactiveMessaging": true,
|
|
9
9
|
"cronJobs": true
|
package/dist/index.js
CHANGED
|
@@ -2,6 +2,7 @@ import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
|
|
2
2
|
import { qqbotPlugin } from "./src/channel.js";
|
|
3
3
|
import { setQQBotRuntime } from "./src/runtime.js";
|
|
4
4
|
import { registerChannelTool } from "./src/tools/channel.js";
|
|
5
|
+
import { registerRemindTool } from "./src/tools/remind.js";
|
|
5
6
|
const plugin = {
|
|
6
7
|
id: "openclaw-qqbot",
|
|
7
8
|
name: "QQ Bot",
|
|
@@ -11,6 +12,7 @@ const plugin = {
|
|
|
11
12
|
setQQBotRuntime(api.runtime);
|
|
12
13
|
api.registerChannel({ plugin: qqbotPlugin });
|
|
13
14
|
registerChannelTool(api);
|
|
15
|
+
registerRemindTool(api);
|
|
14
16
|
},
|
|
15
17
|
};
|
|
16
18
|
export default plugin;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 管理员解析器模块
|
|
3
|
+
* - 管理员 openid 持久化读写
|
|
4
|
+
* - 升级问候目标读写
|
|
5
|
+
* - 启动问候语发送
|
|
6
|
+
*/
|
|
7
|
+
export interface AdminResolverContext {
|
|
8
|
+
accountId: string;
|
|
9
|
+
appId: string;
|
|
10
|
+
clientSecret: string;
|
|
11
|
+
log?: {
|
|
12
|
+
info: (msg: string) => void;
|
|
13
|
+
error: (msg: string) => void;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export declare function loadAdminOpenId(accountId: string): string | undefined;
|
|
17
|
+
export declare function saveAdminOpenId(accountId: string, openid: string): void;
|
|
18
|
+
export declare function loadUpgradeGreetingTargetOpenId(accountId: string, appId: string): string | undefined;
|
|
19
|
+
export declare function clearUpgradeGreetingTargetOpenId(accountId: string, appId: string): void;
|
|
20
|
+
/**
|
|
21
|
+
* 解析管理员 openid:
|
|
22
|
+
* 1. 优先读持久化文件(稳定)
|
|
23
|
+
* 2. fallback 取第一个私聊用户,并写入文件锁定
|
|
24
|
+
*/
|
|
25
|
+
export declare function resolveAdminOpenId(ctx: Pick<AdminResolverContext, "accountId" | "log">): string | undefined;
|
|
26
|
+
/** 异步发送启动问候语(仅发给管理员) */
|
|
27
|
+
export declare function sendStartupGreetings(ctx: AdminResolverContext, trigger: "READY" | "RESUMED"): void;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 管理员解析器模块
|
|
3
|
+
* - 管理员 openid 持久化读写
|
|
4
|
+
* - 升级问候目标读写
|
|
5
|
+
* - 启动问候语发送
|
|
6
|
+
*/
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
import { getQQBotDataDir } from "./utils/platform.js";
|
|
10
|
+
import { listKnownUsers } from "./known-users.js";
|
|
11
|
+
import { getAccessToken, sendProactiveC2CMessage } from "./api.js";
|
|
12
|
+
import { getStartupGreetingPlan, markStartupGreetingSent, markStartupGreetingFailed } from "./startup-greeting.js";
|
|
13
|
+
// ---- 文件路径 ----
|
|
14
|
+
function getAdminMarkerFile(accountId) {
|
|
15
|
+
return path.join(getQQBotDataDir("data"), `admin-${accountId}.json`);
|
|
16
|
+
}
|
|
17
|
+
function getUpgradeGreetingTargetFile(accountId, appId) {
|
|
18
|
+
const safeAccountId = accountId.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
19
|
+
const safeAppId = appId.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
20
|
+
return path.join(getQQBotDataDir("data"), `upgrade-greeting-target-${safeAccountId}-${safeAppId}.json`);
|
|
21
|
+
}
|
|
22
|
+
// ---- 管理员 openid 持久化 ----
|
|
23
|
+
export function loadAdminOpenId(accountId) {
|
|
24
|
+
try {
|
|
25
|
+
const file = getAdminMarkerFile(accountId);
|
|
26
|
+
if (fs.existsSync(file)) {
|
|
27
|
+
const data = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
28
|
+
if (data.openid)
|
|
29
|
+
return data.openid;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
catch { /* 文件损坏视为无 */ }
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
export function saveAdminOpenId(accountId, openid) {
|
|
36
|
+
try {
|
|
37
|
+
fs.writeFileSync(getAdminMarkerFile(accountId), JSON.stringify({ openid, savedAt: new Date().toISOString() }));
|
|
38
|
+
}
|
|
39
|
+
catch { /* ignore */ }
|
|
40
|
+
}
|
|
41
|
+
// ---- 升级问候目标 ----
|
|
42
|
+
export function loadUpgradeGreetingTargetOpenId(accountId, appId) {
|
|
43
|
+
try {
|
|
44
|
+
const file = getUpgradeGreetingTargetFile(accountId, appId);
|
|
45
|
+
if (fs.existsSync(file)) {
|
|
46
|
+
const data = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
47
|
+
if (!data.openid)
|
|
48
|
+
return undefined;
|
|
49
|
+
if (data.appId && data.appId !== appId)
|
|
50
|
+
return undefined;
|
|
51
|
+
if (data.accountId && data.accountId !== accountId)
|
|
52
|
+
return undefined;
|
|
53
|
+
return data.openid;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
catch { /* 文件损坏视为无 */ }
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
export function clearUpgradeGreetingTargetOpenId(accountId, appId) {
|
|
60
|
+
try {
|
|
61
|
+
const file = getUpgradeGreetingTargetFile(accountId, appId);
|
|
62
|
+
if (fs.existsSync(file)) {
|
|
63
|
+
fs.unlinkSync(file);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
catch { /* ignore */ }
|
|
67
|
+
}
|
|
68
|
+
// ---- 解析管理员 ----
|
|
69
|
+
/**
|
|
70
|
+
* 解析管理员 openid:
|
|
71
|
+
* 1. 优先读持久化文件(稳定)
|
|
72
|
+
* 2. fallback 取第一个私聊用户,并写入文件锁定
|
|
73
|
+
*/
|
|
74
|
+
export function resolveAdminOpenId(ctx) {
|
|
75
|
+
const saved = loadAdminOpenId(ctx.accountId);
|
|
76
|
+
if (saved)
|
|
77
|
+
return saved;
|
|
78
|
+
const first = listKnownUsers({ accountId: ctx.accountId, type: "c2c", sortBy: "firstSeenAt", sortOrder: "asc", limit: 1 })[0]?.openid;
|
|
79
|
+
if (first) {
|
|
80
|
+
saveAdminOpenId(ctx.accountId, first);
|
|
81
|
+
ctx.log?.info(`[qqbot:${ctx.accountId}] Auto-detected admin openid: ${first} (persisted)`);
|
|
82
|
+
}
|
|
83
|
+
return first;
|
|
84
|
+
}
|
|
85
|
+
// ---- 启动问候语 ----
|
|
86
|
+
/** 异步发送启动问候语(仅发给管理员) */
|
|
87
|
+
export function sendStartupGreetings(ctx, trigger) {
|
|
88
|
+
(async () => {
|
|
89
|
+
const plan = getStartupGreetingPlan();
|
|
90
|
+
if (!plan.shouldSend || !plan.greeting) {
|
|
91
|
+
ctx.log?.info(`[qqbot:${ctx.accountId}] Skipping startup greeting (${plan.reason ?? "debounced"}, trigger=${trigger})`);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const upgradeTargetOpenId = loadUpgradeGreetingTargetOpenId(ctx.accountId, ctx.appId);
|
|
95
|
+
const targetOpenId = upgradeTargetOpenId || resolveAdminOpenId(ctx);
|
|
96
|
+
if (!targetOpenId) {
|
|
97
|
+
markStartupGreetingFailed(plan.version, "no-admin");
|
|
98
|
+
ctx.log?.info(`[qqbot:${ctx.accountId}] Skipping startup greeting (no admin or known user)`);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
const receiverType = upgradeTargetOpenId ? "upgrade-requester" : "admin";
|
|
103
|
+
ctx.log?.info(`[qqbot:${ctx.accountId}] Sending startup greeting to ${receiverType} (trigger=${trigger}): "${plan.greeting}"`);
|
|
104
|
+
const token = await getAccessToken(ctx.appId, ctx.clientSecret);
|
|
105
|
+
const GREETING_TIMEOUT_MS = 10_000;
|
|
106
|
+
await Promise.race([
|
|
107
|
+
sendProactiveC2CMessage(token, targetOpenId, plan.greeting),
|
|
108
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("Startup greeting send timeout (10s)")), GREETING_TIMEOUT_MS)),
|
|
109
|
+
]);
|
|
110
|
+
markStartupGreetingSent(plan.version);
|
|
111
|
+
if (upgradeTargetOpenId) {
|
|
112
|
+
clearUpgradeGreetingTargetOpenId(ctx.accountId, ctx.appId);
|
|
113
|
+
}
|
|
114
|
+
ctx.log?.info(`[qqbot:${ctx.accountId}] Sent startup greeting to ${receiverType}: ${targetOpenId}`);
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
118
|
+
markStartupGreetingFailed(plan.version, message);
|
|
119
|
+
ctx.log?.error(`[qqbot:${ctx.accountId}] Failed to send startup greeting: ${message}`);
|
|
120
|
+
}
|
|
121
|
+
})();
|
|
122
|
+
}
|
package/dist/src/channel.js
CHANGED
|
@@ -4,6 +4,8 @@ import { sendText, sendMedia } from "./outbound.js";
|
|
|
4
4
|
import { startGateway } from "./gateway.js";
|
|
5
5
|
import { qqbotOnboardingAdapter } from "./onboarding.js";
|
|
6
6
|
import { getQQBotRuntime } from "./runtime.js";
|
|
7
|
+
import { saveCredentialBackup, loadCredentialBackup } from "./credential-backup.js";
|
|
8
|
+
import { initApiConfig } from "./api.js";
|
|
7
9
|
/** QQ Bot 单条消息文本长度上限 */
|
|
8
10
|
export const TEXT_CHUNK_LIMIT = 5000;
|
|
9
11
|
/**
|
|
@@ -58,7 +60,13 @@ export const qqbotPlugin = {
|
|
|
58
60
|
accountId,
|
|
59
61
|
clearBaseFields: ["appId", "clientSecret", "clientSecretFile", "name"],
|
|
60
62
|
}),
|
|
61
|
-
isConfigured: (account) =>
|
|
63
|
+
isConfigured: (account) => {
|
|
64
|
+
if (account?.appId && account?.clientSecret)
|
|
65
|
+
return true;
|
|
66
|
+
// 配置为空但有凭证备份时仍返回 true,让 startAccount 有机会恢复凭证
|
|
67
|
+
const backup = loadCredentialBackup(account?.accountId);
|
|
68
|
+
return backup !== null;
|
|
69
|
+
},
|
|
62
70
|
describeAccount: (account) => ({
|
|
63
71
|
accountId: account?.accountId ?? DEFAULT_ACCOUNT_ID,
|
|
64
72
|
name: account?.name,
|
|
@@ -194,6 +202,7 @@ export const qqbotPlugin = {
|
|
|
194
202
|
console.log(`[qqbot:channel] sendText called — accountId=${accountId}, to=${to}, replyToId=${replyToId}, text.length=${text?.length ?? 0}`);
|
|
195
203
|
console.log(`[qqbot:channel] sendText text preview: ${text?.slice(0, 100)}${(text?.length ?? 0) > 100 ? "..." : ""}`);
|
|
196
204
|
const account = resolveQQBotAccount(cfg, accountId);
|
|
205
|
+
initApiConfig({ markdownSupport: account.markdownSupport });
|
|
197
206
|
console.log(`[qqbot:channel] sendText resolved account: id=${account.accountId}, appId=${account.appId}, enabled=${account.enabled}`);
|
|
198
207
|
const result = await sendText({ to, text, accountId, replyToId, account });
|
|
199
208
|
console.log(`[qqbot:channel] sendText result: messageId=${result.messageId}, error=${result.error ?? "none"}`);
|
|
@@ -206,6 +215,7 @@ export const qqbotPlugin = {
|
|
|
206
215
|
sendMedia: async ({ to, text, mediaUrl, accountId, replyToId, cfg }) => {
|
|
207
216
|
console.log(`[qqbot:channel] sendMedia called — accountId=${accountId}, to=${to}, replyToId=${replyToId}, mediaUrl=${mediaUrl?.slice(0, 80)}, text.length=${text?.length ?? 0}`);
|
|
208
217
|
const account = resolveQQBotAccount(cfg, accountId);
|
|
218
|
+
initApiConfig({ markdownSupport: account.markdownSupport });
|
|
209
219
|
console.log(`[qqbot:channel] sendMedia resolved account: id=${account.accountId}, appId=${account.appId}, enabled=${account.enabled}`);
|
|
210
220
|
const result = await sendMedia({ to, text: text ?? "", mediaUrl: mediaUrl ?? "", accountId, replyToId, account });
|
|
211
221
|
console.log(`[qqbot:channel] sendMedia result: messageId=${result.messageId}, error=${result.error ?? "none"}`);
|
|
@@ -218,7 +228,30 @@ export const qqbotPlugin = {
|
|
|
218
228
|
},
|
|
219
229
|
gateway: {
|
|
220
230
|
startAccount: async (ctx) => {
|
|
221
|
-
|
|
231
|
+
let { account } = ctx;
|
|
232
|
+
const { abortSignal, log, cfg } = ctx;
|
|
233
|
+
// 凭证恢复:如果 appId/secret 为空(热更新打断可能导致配置丢失),尝试从暂存文件恢复
|
|
234
|
+
if (!account.appId || !account.clientSecret) {
|
|
235
|
+
const backup = loadCredentialBackup(account.accountId);
|
|
236
|
+
if (backup) {
|
|
237
|
+
log?.info(`[qqbot:${account.accountId}] 配置中凭证为空,从暂存文件恢复 (appId=${backup.appId}, savedAt=${backup.savedAt})`);
|
|
238
|
+
try {
|
|
239
|
+
const runtime = getQQBotRuntime();
|
|
240
|
+
const restoredCfg = applyQQBotAccountConfig(cfg, account.accountId, {
|
|
241
|
+
appId: backup.appId,
|
|
242
|
+
clientSecret: backup.clientSecret,
|
|
243
|
+
});
|
|
244
|
+
const configApi = runtime.config;
|
|
245
|
+
await configApi.writeConfigFile(restoredCfg);
|
|
246
|
+
// 重新解析 account 以获取恢复后的值
|
|
247
|
+
account = resolveQQBotAccount(restoredCfg, account.accountId);
|
|
248
|
+
log?.info(`[qqbot:${account.accountId}] 凭证已恢复`);
|
|
249
|
+
}
|
|
250
|
+
catch (e) {
|
|
251
|
+
log?.error(`[qqbot:${account.accountId}] 凭证恢复失败: ${e}`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
222
255
|
log?.info(`[qqbot:${account.accountId}] Starting gateway — appId=${account.appId}, enabled=${account.enabled}, name=${account.name ?? "unnamed"}`);
|
|
223
256
|
console.log(`[qqbot:channel] startAccount: accountId=${account.accountId}, appId=${account.appId}, secretSource=${account.secretSource}`);
|
|
224
257
|
await startGateway({
|
|
@@ -228,6 +261,8 @@ export const qqbotPlugin = {
|
|
|
228
261
|
log,
|
|
229
262
|
onReady: () => {
|
|
230
263
|
log?.info(`[qqbot:${account.accountId}] Gateway ready`);
|
|
264
|
+
// 启动成功,保存凭证快照供后续恢复使用
|
|
265
|
+
saveCredentialBackup(account.accountId, account.appId, account.clientSecret);
|
|
231
266
|
ctx.setStatus({
|
|
232
267
|
...ctx.getStatus(),
|
|
233
268
|
running: true,
|
|
@@ -0,0 +1,31 @@
|
|
|
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
|
+
interface CredentialBackup {
|
|
17
|
+
accountId: string;
|
|
18
|
+
appId: string;
|
|
19
|
+
clientSecret: string;
|
|
20
|
+
savedAt: string;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* 保存凭证快照到暂存文件(gateway 成功启动后调用)
|
|
24
|
+
*/
|
|
25
|
+
export declare function saveCredentialBackup(accountId: string, appId: string, clientSecret: string): void;
|
|
26
|
+
/**
|
|
27
|
+
* 从暂存文件读取凭证(仅在配置为空时调用)
|
|
28
|
+
* 返回 null 表示无可用备份
|
|
29
|
+
*/
|
|
30
|
+
export declare function loadCredentialBackup(accountId?: string): CredentialBackup | null;
|
|
31
|
+
export {};
|
|
@@ -0,0 +1,66 @@
|
|
|
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
|
+
import fs from "node:fs";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import { getQQBotDataDir } from "./utils/platform.js";
|
|
19
|
+
const BACKUP_FILENAME = "credential-backup.json";
|
|
20
|
+
function getBackupPath() {
|
|
21
|
+
return path.join(getQQBotDataDir("data"), BACKUP_FILENAME);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* 保存凭证快照到暂存文件(gateway 成功启动后调用)
|
|
25
|
+
*/
|
|
26
|
+
export function saveCredentialBackup(accountId, appId, clientSecret) {
|
|
27
|
+
if (!appId || !clientSecret)
|
|
28
|
+
return; // 不保存空凭证
|
|
29
|
+
try {
|
|
30
|
+
const backupPath = getBackupPath();
|
|
31
|
+
const data = {
|
|
32
|
+
accountId,
|
|
33
|
+
appId,
|
|
34
|
+
clientSecret,
|
|
35
|
+
savedAt: new Date().toISOString(),
|
|
36
|
+
};
|
|
37
|
+
const tmpPath = backupPath + ".tmp";
|
|
38
|
+
fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2) + "\n", "utf8");
|
|
39
|
+
fs.renameSync(tmpPath, backupPath);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// 非关键操作,静默忽略
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* 从暂存文件读取凭证(仅在配置为空时调用)
|
|
47
|
+
* 返回 null 表示无可用备份
|
|
48
|
+
*/
|
|
49
|
+
export function loadCredentialBackup(accountId) {
|
|
50
|
+
try {
|
|
51
|
+
const backupPath = getBackupPath();
|
|
52
|
+
if (!fs.existsSync(backupPath))
|
|
53
|
+
return null;
|
|
54
|
+
const raw = fs.readFileSync(backupPath, "utf8");
|
|
55
|
+
const data = JSON.parse(raw);
|
|
56
|
+
if (!data.appId || !data.clientSecret)
|
|
57
|
+
return null;
|
|
58
|
+
// 如果指定了 accountId,校验是否匹配
|
|
59
|
+
if (accountId && data.accountId !== accountId)
|
|
60
|
+
return null;
|
|
61
|
+
return data;
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|