@tencent-connect/openclaw-qqbot 1.6.7-beta.3 → 1.7.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -3
- package/README.zh.md +10 -9
- package/dist/src/api.d.ts +21 -5
- package/dist/src/api.js +67 -41
- package/dist/src/channel.js +16 -12
- package/dist/src/gateway.js +59 -48
- package/dist/src/message-queue.d.ts +5 -0
- package/dist/src/outbound-deliver.js +8 -8
- package/dist/src/outbound.d.ts +15 -0
- package/dist/src/outbound.js +41 -4
- package/dist/src/ref-index-store.d.ts +31 -0
- package/dist/src/ref-index-store.js +49 -1
- package/dist/src/runtime.js +3 -0
- package/dist/src/slash-commands.js +403 -25
- package/dist/src/streaming.d.ts +6 -9
- package/dist/src/streaming.js +25 -40
- package/dist/src/types.d.ts +25 -19
- package/dist/src/types.js +5 -0
- package/dist/src/utils/media-send.d.ts +12 -2
- package/dist/src/utils/media-send.js +84 -38
- package/dist/src/utils/media-tags.js +2 -1
- package/dist/src/utils/pkg-version.js +19 -9
- package/dist/src/utils/text-parsing.d.ts +4 -5
- package/dist/src/utils/text-parsing.js +17 -12
- package/openclaw.plugin.json +6 -1
- package/package.json +4 -1
- package/scripts/upgrade-via-npm.sh +707 -407
- package/scripts/upgrade-via-source.sh +113 -9
- package/skills/qqbot-upgrade/SKILL.md +79 -0
- package/src/api.ts +82 -44
- package/src/channel.ts +17 -11
- package/src/gateway.ts +64 -51
- package/src/message-queue.ts +5 -0
- package/src/openclaw-plugin-sdk.d.ts +2 -0
- package/src/outbound-deliver.ts +21 -8
- package/src/outbound.ts +51 -3
- package/src/ref-index-store.ts +78 -1
- package/src/runtime.ts +3 -0
- package/src/slash-commands.ts +413 -24
- package/src/streaming.ts +29 -54
- package/src/types.ts +29 -19
- package/src/utils/media-send.ts +89 -38
- package/src/utils/media-tags.ts +2 -1
- package/src/utils/pkg-version.ts +18 -8
- package/src/utils/text-parsing.ts +21 -11
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.
|
|
13
|
+
### 🚀 Current Version: `v1.7.0`
|
|
14
14
|
|
|
15
15
|
[](./LICENSE)
|
|
16
16
|
[](https://bot.q.qq.com/wiki/)
|
|
@@ -45,11 +45,50 @@ Scan to join the QQ group chat
|
|
|
45
45
|
| ⌨️ **Typing Indicator** | "Bot is typing..." status shown in real-time |
|
|
46
46
|
| 📝 **Markdown** | Full Markdown formatting support |
|
|
47
47
|
| 🛠️ **Commands** | Native OpenClaw command integration |
|
|
48
|
-
| 💬 **Quoted Context** |
|
|
48
|
+
| 💬 **Quoted Context** | Parses the original message a user is replying to and injects it into AI context, so the model always knows exactly which message is being referenced |
|
|
49
49
|
| 📦 **Large File Support** | Auto chunked upload for large files (parallel upload with retry), up to 100 MB |
|
|
50
50
|
|
|
51
51
|
---
|
|
52
52
|
|
|
53
|
+
## 🆚 Standalone Plugin vs OpenClaw Built-in: Which to Choose?
|
|
54
|
+
|
|
55
|
+
Starting from **OpenClaw 2026.3.31**, a QQBot plugin is bundled with OpenClaw. The two plugins **cannot run at the same time** — choose one based on your needs.
|
|
56
|
+
|
|
57
|
+
| Feature | This Plugin (Standalone) | OpenClaw Built-in |
|
|
58
|
+
|---------|:------------------------:|:-----------------:|
|
|
59
|
+
| 🔒 Multi-scene (C2C / Group) | ✅ | ✅ |
|
|
60
|
+
| 🖼️ Rich media (image / voice / video / file) | ✅ | ✅ |
|
|
61
|
+
| 🎙️ Voice STT / TTS | ✅ | ✅ |
|
|
62
|
+
| 🔥 One-click hot upgrade (`/bot-upgrade`) | ✅ | ❌ |
|
|
63
|
+
| ⏰ Scheduled push (proactive messages) | ✅ | ✅ |
|
|
64
|
+
| 🔗 URL support | ✅ | ✅ |
|
|
65
|
+
| ⌨️ Typing indicator | ✅ | ✅ |
|
|
66
|
+
| 📝 Markdown | ✅ | ✅ |
|
|
67
|
+
| 🛠️ Slash commands / native commands | ✅ | ✅ |
|
|
68
|
+
| 💬 Quoted context (injected into AI) | ✅ | ✅ |
|
|
69
|
+
| 📦 Large file support (up to 100MB) | ✅ | ❌ |
|
|
70
|
+
| Installation | Requires separate install | Bundled, zero setup |
|
|
71
|
+
| Update cadence | Independent releases, faster iteration | Ships with OpenClaw |
|
|
72
|
+
|
|
73
|
+
### Which should I pick?
|
|
74
|
+
|
|
75
|
+
**Choose the OpenClaw built-in plugin if you:**
|
|
76
|
+
|
|
77
|
+
- Want zero-setup out of the box
|
|
78
|
+
- Are just getting started and want to try QQ Bot quickly
|
|
79
|
+
|
|
80
|
+
**Choose this plugin (standalone) if you:**
|
|
81
|
+
|
|
82
|
+
- Want faster feature iteration and more capabilities
|
|
83
|
+
|
|
84
|
+
> ⚠️ Both plugins cannot run simultaneously. If you've upgraded to OpenClaw 2026.3.31+, run the following command to install this plugin — the built-in version will be disabled automatically:
|
|
85
|
+
> ```bash
|
|
86
|
+
> curl -fsSL https://raw.githubusercontent.com/tencent-connect/openclaw-qqbot/main/scripts/upgrade-via-npm.sh | bash
|
|
87
|
+
> ```
|
|
88
|
+
> After upgrading, you'll unlock large file transfers, message reference context, and all other advanced features.
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
53
92
|
## 📸 Feature Showcase
|
|
54
93
|
|
|
55
94
|
> **Note:** This plugin serves as a **message channel** only — it relays messages between QQ and OpenClaw. Capabilities like image understanding, voice transcription, drawing, etc. depend on the **AI model** you configure and the **skills** installed in OpenClaw, not on this plugin itself.
|
|
@@ -192,6 +231,11 @@ Credentials are automatically backed up before upgrade. Version existence is ver
|
|
|
192
231
|
|
|
193
232
|
> ⚠️ Hot upgrade is currently not supported on Windows. Sending `/bot-upgrade` on Windows will return a manual upgrade guide instead.
|
|
194
233
|
|
|
234
|
+
> ⚠️ v1.6.6 and below do not support hot upgrade via `/bot-upgrade`. Please upgrade using the following command:
|
|
235
|
+
> ```bash
|
|
236
|
+
> curl -fsSL https://raw.githubusercontent.com/tencent-connect/openclaw-qqbot/main/scripts/upgrade-via-npm.sh | bash
|
|
237
|
+
> ```
|
|
238
|
+
|
|
195
239
|
<img width="360" src="docs/images/hot-update.jpg" alt="Hot Upgrade Demo" />
|
|
196
240
|
|
|
197
241
|
#### `/bot-logs` — Log Export
|
|
@@ -252,7 +296,7 @@ curl -fsSL https://raw.githubusercontent.com/tencent-connect/openclaw-qqbot/main
|
|
|
252
296
|
|
|
253
297
|
One command does it all: download script → cleanup old plugins → install → configure channel → restart service. Once done, open QQ and start chatting!
|
|
254
298
|
|
|
255
|
-
> `--appid` and `--secret` are **required for first-time install**. For subsequent upgrades:
|
|
299
|
+
> `--appid` and `--secret` are **required for first-time install**. For subsequent upgrades, run the following command to upgrade to the latest version:
|
|
256
300
|
> ```bash
|
|
257
301
|
> curl -fsSL https://raw.githubusercontent.com/tencent-connect/openclaw-qqbot/main/scripts/upgrade-via-npm.sh | bash
|
|
258
302
|
> ```
|
package/README.zh.md
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
**让你的 AI 助手接入 QQ — 私聊、群聊、富媒体,一个插件全搞定。**
|
|
11
11
|
|
|
12
|
-
### 🚀 当前版本: `v1.
|
|
12
|
+
### 🚀 当前版本: `v1.7.0`
|
|
13
13
|
|
|
14
14
|
[](./LICENSE)
|
|
15
15
|
[](https://bot.q.qq.com/wiki/)
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
| ⌨️ **输入状态** | 实时显示"Bot 正在输入中…"状态 |
|
|
41
41
|
| 📝 **Markdown** | 完整支持 Markdown 格式消息 |
|
|
42
42
|
| 🛠️ **原生命令** | 支持 OpenClaw 原生命令 |
|
|
43
|
-
| 💬 **引用上下文** |
|
|
43
|
+
| 💬 **引用上下文** | 解析用户回复的原始消息内容,注入 AI 上下文,让模型准确理解"在回复哪条消息" |
|
|
44
44
|
| 📦 **大文件支持** | 大文件自动分片并行上传,最大支持 100 MB |
|
|
45
45
|
|
|
46
46
|
---
|
|
@@ -49,13 +49,9 @@
|
|
|
49
49
|
|
|
50
50
|
> **说明:** 本插件仅作为**消息通道**,负责在 QQ 和 OpenClaw 之间传递消息。图片理解、语音转录、AI 画图等能力取决于你配置的 **AI 模型**以及在 OpenClaw 中安装的 **skill**,而非插件本身提供。
|
|
51
51
|
|
|
52
|
-
### 💬
|
|
52
|
+
### 💬 引用消息上下文
|
|
53
53
|
|
|
54
|
-
QQ
|
|
55
|
-
|
|
56
|
-
- 入站/出站消息中的 `ref_idx` 会自动建立索引。
|
|
57
|
-
- 存储位置:`~/.openclaw/qqbot/data/ref-index.jsonl`(网关重启后仍可恢复)。
|
|
58
|
-
- 引用内容支持文本 + 媒体摘要(图片/语音/视频/文件)。
|
|
54
|
+
用户在 QQ 中引用某条消息发送时,插件会自动解析被引用的消息内容并注入 AI 上下文,让模型清楚地知道"用户在回复哪条消息",从而给出更准确的回复。支持文本及媒体消息(图片/语音/视频/文件),换设备后同样可用。
|
|
59
55
|
|
|
60
56
|
<img width="360" src="docs/images/ref-msg.png" alt="引用消息上下文演示" />
|
|
61
57
|
|
|
@@ -187,6 +183,11 @@ AI 可直接发送视频,支持本地文件和公网 URL。
|
|
|
187
183
|
|
|
188
184
|
> ⚠️ 热更新指令暂不支持 Windows 系统,在 Windows 上发送 `/bot-upgrade` 会返回手动升级指引。
|
|
189
185
|
|
|
186
|
+
> ⚠️ v1.6.6 及以下版本暂不支持通过 `/bot-upgrade` 执行热更新,请通过以下命令升级:
|
|
187
|
+
> ```bash
|
|
188
|
+
> curl -fsSL https://raw.githubusercontent.com/tencent-connect/openclaw-qqbot/main/scripts/upgrade-via-npm.sh | bash
|
|
189
|
+
> ```
|
|
190
|
+
|
|
190
191
|
<img width="360" src="docs/images/hot-update.jpg" alt="一键热更新演示" />
|
|
191
192
|
|
|
192
193
|
#### `/bot-logs` — 日志导出
|
|
@@ -247,7 +248,7 @@ curl -fsSL https://raw.githubusercontent.com/tencent-connect/openclaw-qqbot/main
|
|
|
247
248
|
|
|
248
249
|
一行命令搞定:下载脚本 → 清理旧插件 → 安装 → 配置通道 → 启动服务。完成后打开 QQ 即可开始聊天!
|
|
249
250
|
|
|
250
|
-
> 首次安装**必须**传 `--appid` 和 `--secret
|
|
251
|
+
> 首次安装**必须**传 `--appid` 和 `--secret`。后续升级执行此指令可以升级为最新版:
|
|
251
252
|
> ```bash
|
|
252
253
|
> curl -fsSL https://raw.githubusercontent.com/tencent-connect/openclaw-qqbot/main/scripts/upgrade-via-npm.sh | bash
|
|
253
254
|
> ```
|
package/dist/src/api.d.ts
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
* QQ Bot API 鉴权和请求封装
|
|
3
3
|
* [修复版] 已重构为支持多实例并发,消除全局变量冲突
|
|
4
4
|
*/
|
|
5
|
+
/** API 模块的日志接口,与 GatewayContext.log 对齐 */
|
|
6
|
+
export interface ApiLogger {
|
|
7
|
+
info: (msg: string) => void;
|
|
8
|
+
error: (msg: string) => void;
|
|
9
|
+
warn?: (msg: string) => void;
|
|
10
|
+
debug?: (msg: string) => void;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* 注入自定义 logger(在 gateway 启动时调用,将 api 模块的日志统一接入框架日志系统)
|
|
14
|
+
*/
|
|
15
|
+
export declare function setApiLogger(logger: ApiLogger): void;
|
|
5
16
|
/** API 请求错误,携带 HTTP status code 和业务错误码 */
|
|
6
17
|
export declare class ApiError extends Error {
|
|
7
18
|
readonly status: number;
|
|
@@ -16,7 +27,9 @@ export declare class ApiError extends Error {
|
|
|
16
27
|
/** 回包中的原始 message 字段(用于向用户展示兜底文案) */
|
|
17
28
|
bizMessage?: string | undefined);
|
|
18
29
|
}
|
|
19
|
-
|
|
30
|
+
/** 由 setQQBotRuntime 调用,将 api.runtime.version 注入到 User-Agent */
|
|
31
|
+
export declare function setOpenClawVersion(version: string): void;
|
|
32
|
+
export declare function getPluginUserAgent(): string;
|
|
20
33
|
/** 出站消息元信息(结构化存储,不做预格式化) */
|
|
21
34
|
export interface OutboundMeta {
|
|
22
35
|
/** 消息文本内容 */
|
|
@@ -91,7 +104,7 @@ export declare const UPLOAD_PREPARE_FALLBACK_CODE = 40093002;
|
|
|
91
104
|
export declare function getGatewayUrl(accessToken: string): Promise<string>;
|
|
92
105
|
/** 回应按钮交互(INTERACTION_CREATE),避免客户端按钮持续 loading */
|
|
93
106
|
export declare function acknowledgeInteraction(accessToken: string, interactionId: string, code?: 0 | 1 | 2 | 3 | 4 | 5, data?: Record<string, unknown>): Promise<void>;
|
|
94
|
-
/** 获取插件版本号(从 package.json 读取,和
|
|
107
|
+
/** 获取插件版本号(从 package.json 读取,和 getPluginUserAgent() 同源) */
|
|
95
108
|
export declare function getApiPluginVersion(): string;
|
|
96
109
|
export interface MessageResponse {
|
|
97
110
|
id: string;
|
|
@@ -269,7 +282,7 @@ export declare function startBackgroundTokenRefresh(appId: string, clientSecret:
|
|
|
269
282
|
*/
|
|
270
283
|
export declare function stopBackgroundTokenRefresh(appId?: string): void;
|
|
271
284
|
export declare function isBackgroundTokenRefreshRunning(appId?: string): boolean;
|
|
272
|
-
import type { StreamMessageRequest
|
|
285
|
+
import type { StreamMessageRequest } from "./types.js";
|
|
273
286
|
/**
|
|
274
287
|
* 发送流式消息(C2C 私聊)
|
|
275
288
|
*
|
|
@@ -278,10 +291,13 @@ import type { StreamMessageRequest, StreamMessageResponse } from "./types.js";
|
|
|
278
291
|
* - 后续分片携带 stream_msg_id 和递增 msg_seq
|
|
279
292
|
* - input_state="1" 表示生成中,"10" 表示生成结束(终结状态)
|
|
280
293
|
*
|
|
294
|
+
* 仅在终结分片(input_state=DONE)时触发 refIdx 回调,
|
|
295
|
+
* 中间分片直接调用 apiRequest,避免存入过多无效的中间态数据。
|
|
296
|
+
*
|
|
281
297
|
* @param accessToken - access_token
|
|
282
298
|
* @param openid - 用户 openid
|
|
283
299
|
* @param req - 流式消息请求体
|
|
284
|
-
* @returns
|
|
300
|
+
* @returns 消息响应(复用 MessageResponse,错误会直接抛出异常)
|
|
285
301
|
*/
|
|
286
|
-
export declare function sendC2CStreamMessage(accessToken: string, openid: string, req: StreamMessageRequest): Promise<
|
|
302
|
+
export declare function sendC2CStreamMessage(accessToken: string, openid: string, req: StreamMessageRequest): Promise<MessageResponse>;
|
|
287
303
|
export {};
|
package/dist/src/api.js
CHANGED
|
@@ -5,6 +5,19 @@
|
|
|
5
5
|
import os from "node:os";
|
|
6
6
|
import { computeFileHash, getCachedFileInfo, setCachedFileInfo } from "./utils/upload-cache.js";
|
|
7
7
|
import { sanitizeFileName } from "./utils/platform.js";
|
|
8
|
+
/** 默认使用 console,外部可通过 setApiLogger 注入框架 log */
|
|
9
|
+
let log = {
|
|
10
|
+
info: (msg) => console.log(msg),
|
|
11
|
+
error: (msg) => console.error(msg),
|
|
12
|
+
warn: (msg) => console.warn(msg),
|
|
13
|
+
debug: (msg) => console.debug(msg),
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* 注入自定义 logger(在 gateway 启动时调用,将 api 模块的日志统一接入框架日志系统)
|
|
17
|
+
*/
|
|
18
|
+
export function setApiLogger(logger) {
|
|
19
|
+
log = logger;
|
|
20
|
+
}
|
|
8
21
|
// ============ 自定义错误 ============
|
|
9
22
|
/** API 请求错误,携带 HTTP status code 和业务错误码 */
|
|
10
23
|
export class ApiError extends Error {
|
|
@@ -28,11 +41,20 @@ export class ApiError extends Error {
|
|
|
28
41
|
const API_BASE = "https://api.sgroup.qq.com";
|
|
29
42
|
const TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken";
|
|
30
43
|
// ============ Plugin User-Agent ============
|
|
31
|
-
// 格式: QQBotPlugin/{version} (Node/{nodeVersion}; {os})
|
|
32
|
-
// 示例: QQBotPlugin/1.6.0 (Node/22.14.0; darwin)
|
|
44
|
+
// 格式: QQBotPlugin/{version} (Node/{nodeVersion}; {os}; OpenClaw/{openclawVersion})
|
|
45
|
+
// 示例: QQBotPlugin/1.6.0 (Node/22.14.0; darwin; OpenClaw/2026.3.31)
|
|
33
46
|
import { getPackageVersion } from "./utils/pkg-version.js";
|
|
34
47
|
const _pluginVersion = getPackageVersion(import.meta.url);
|
|
35
|
-
|
|
48
|
+
// 初始值为 "unknown",由 setQQBotRuntime 注入后更新为真实版本
|
|
49
|
+
let _openclawVersion = "unknown";
|
|
50
|
+
/** 由 setQQBotRuntime 调用,将 api.runtime.version 注入到 User-Agent */
|
|
51
|
+
export function setOpenClawVersion(version) {
|
|
52
|
+
if (version)
|
|
53
|
+
_openclawVersion = version;
|
|
54
|
+
}
|
|
55
|
+
export function getPluginUserAgent() {
|
|
56
|
+
return `QQBotPlugin/${_pluginVersion} (Node/${process.versions.node}; ${os.platform()}; OpenClaw/${_openclawVersion})`;
|
|
57
|
+
}
|
|
36
58
|
// 运行时配置
|
|
37
59
|
let currentMarkdownSupport = false;
|
|
38
60
|
let onMessageSentHook = null;
|
|
@@ -83,7 +105,7 @@ export async function getAccessToken(appId, clientSecret) {
|
|
|
83
105
|
// Singleflight: 如果当前 appId 已有进行中的 Token 获取请求,复用它
|
|
84
106
|
let fetchPromise = tokenFetchPromises.get(normalizedAppId);
|
|
85
107
|
if (fetchPromise) {
|
|
86
|
-
|
|
108
|
+
log.info(`[qqbot-api:${normalizedAppId}] Token fetch in progress, waiting for existing request...`);
|
|
87
109
|
return fetchPromise;
|
|
88
110
|
}
|
|
89
111
|
// 创建新的 Token 获取 Promise(singleflight 入口)
|
|
@@ -104,9 +126,9 @@ export async function getAccessToken(appId, clientSecret) {
|
|
|
104
126
|
*/
|
|
105
127
|
async function doFetchToken(appId, clientSecret) {
|
|
106
128
|
const requestBody = { appId, clientSecret };
|
|
107
|
-
const requestHeaders = { "Content-Type": "application/json", "User-Agent":
|
|
129
|
+
const requestHeaders = { "Content-Type": "application/json", "User-Agent": getPluginUserAgent() };
|
|
108
130
|
// 打印请求信息(隐藏敏感信息)
|
|
109
|
-
|
|
131
|
+
log.info(`[qqbot-api:${appId}] >>> POST ${TOKEN_URL} [secret: ${clientSecret.slice(0, 6)}...len=${clientSecret.length}]`);
|
|
110
132
|
let response;
|
|
111
133
|
try {
|
|
112
134
|
response = await fetch(TOKEN_URL, {
|
|
@@ -116,7 +138,7 @@ async function doFetchToken(appId, clientSecret) {
|
|
|
116
138
|
});
|
|
117
139
|
}
|
|
118
140
|
catch (err) {
|
|
119
|
-
|
|
141
|
+
log.error(`[qqbot-api:${appId}] <<< Network error: ${err}`);
|
|
120
142
|
throw new Error(`Network error getting access_token: ${err instanceof Error ? err.message : String(err)}`);
|
|
121
143
|
}
|
|
122
144
|
// 打印响应头
|
|
@@ -125,18 +147,18 @@ async function doFetchToken(appId, clientSecret) {
|
|
|
125
147
|
responseHeaders[key] = value;
|
|
126
148
|
});
|
|
127
149
|
const tokenTraceId = response.headers.get("x-tps-trace-id") ?? "";
|
|
128
|
-
|
|
150
|
+
log.info(`[qqbot-api:${appId}] <<< Status: ${response.status} ${response.statusText}${tokenTraceId ? ` | TraceId: ${tokenTraceId}` : ""}`);
|
|
129
151
|
let data;
|
|
130
152
|
let rawBody;
|
|
131
153
|
try {
|
|
132
154
|
rawBody = await response.text();
|
|
133
155
|
// 隐藏 token 值
|
|
134
156
|
const logBody = rawBody.replace(/"access_token"\s*:\s*"[^"]+"/g, '"access_token": "***"');
|
|
135
|
-
|
|
157
|
+
log.info(`[qqbot-api:${appId}] <<< Body: ${logBody}`);
|
|
136
158
|
data = JSON.parse(rawBody);
|
|
137
159
|
}
|
|
138
160
|
catch (err) {
|
|
139
|
-
|
|
161
|
+
log.error(`[qqbot-api:${appId}] <<< Parse error: ${err}`);
|
|
140
162
|
throw new Error(`Failed to parse access_token response: ${err instanceof Error ? err.message : String(err)}`);
|
|
141
163
|
}
|
|
142
164
|
if (!data.access_token) {
|
|
@@ -148,7 +170,7 @@ async function doFetchToken(appId, clientSecret) {
|
|
|
148
170
|
expiresAt,
|
|
149
171
|
appId,
|
|
150
172
|
});
|
|
151
|
-
|
|
173
|
+
log.info(`[qqbot-api:${appId}] Token cached, expires at: ${new Date(expiresAt).toISOString()}`);
|
|
152
174
|
return data.access_token;
|
|
153
175
|
}
|
|
154
176
|
/**
|
|
@@ -159,11 +181,11 @@ export function clearTokenCache(appId) {
|
|
|
159
181
|
if (appId) {
|
|
160
182
|
const normalizedAppId = String(appId).trim();
|
|
161
183
|
tokenCacheMap.delete(normalizedAppId);
|
|
162
|
-
|
|
184
|
+
log.info(`[qqbot-api:${normalizedAppId}] Token cache cleared manually.`);
|
|
163
185
|
}
|
|
164
186
|
else {
|
|
165
187
|
tokenCacheMap.clear();
|
|
166
|
-
|
|
188
|
+
log.info(`[qqbot-api] All token caches cleared.`);
|
|
167
189
|
}
|
|
168
190
|
}
|
|
169
191
|
/**
|
|
@@ -198,10 +220,11 @@ const FILE_UPLOAD_TIMEOUT = 120000; // 文件上传 120 秒
|
|
|
198
220
|
*/
|
|
199
221
|
export async function apiRequest(accessToken, method, path, body, timeoutMs) {
|
|
200
222
|
const url = `${API_BASE}${path}`;
|
|
223
|
+
const reqTs = Date.now(); // 毫秒时间戳,用于关联同一次请求的所有日志
|
|
201
224
|
const headers = {
|
|
202
225
|
Authorization: `QQBot ${accessToken}`,
|
|
203
226
|
"Content-Type": "application/json",
|
|
204
|
-
"User-Agent":
|
|
227
|
+
"User-Agent": getPluginUserAgent(),
|
|
205
228
|
};
|
|
206
229
|
const isFileUpload = path.includes("/files");
|
|
207
230
|
const timeout = timeoutMs ?? (isFileUpload ? FILE_UPLOAD_TIMEOUT : DEFAULT_API_TIMEOUT);
|
|
@@ -218,13 +241,13 @@ export async function apiRequest(accessToken, method, path, body, timeoutMs) {
|
|
|
218
241
|
options.body = JSON.stringify(body);
|
|
219
242
|
}
|
|
220
243
|
// 打印请求信息
|
|
221
|
-
|
|
244
|
+
log.info(`[qqbot-api][${reqTs}] >>> ${method} ${url} (timeout: ${timeout}ms)`);
|
|
222
245
|
if (body) {
|
|
223
246
|
const logBody = { ...body };
|
|
224
247
|
if (typeof logBody.file_data === "string") {
|
|
225
248
|
logBody.file_data = `<base64 ${logBody.file_data.length} chars>`;
|
|
226
249
|
}
|
|
227
|
-
|
|
250
|
+
log.info(`[qqbot-api][${reqTs}] >>> Body: ${JSON.stringify(logBody)}`);
|
|
228
251
|
}
|
|
229
252
|
let res;
|
|
230
253
|
try {
|
|
@@ -233,10 +256,10 @@ export async function apiRequest(accessToken, method, path, body, timeoutMs) {
|
|
|
233
256
|
catch (err) {
|
|
234
257
|
clearTimeout(timeoutId);
|
|
235
258
|
if (err instanceof Error && err.name === "AbortError") {
|
|
236
|
-
|
|
259
|
+
log.error(`[qqbot-api][${reqTs}] <<< Request timeout after ${timeout}ms`);
|
|
237
260
|
throw new Error(`Request timeout[${path}]: exceeded ${timeout}ms`);
|
|
238
261
|
}
|
|
239
|
-
|
|
262
|
+
log.error(`[qqbot-api][${reqTs}] <<< Network error: ${err}`);
|
|
240
263
|
throw new Error(`Network error [${path}]: ${err instanceof Error ? err.message : String(err)}`);
|
|
241
264
|
}
|
|
242
265
|
finally {
|
|
@@ -247,7 +270,7 @@ export async function apiRequest(accessToken, method, path, body, timeoutMs) {
|
|
|
247
270
|
responseHeaders[key] = value;
|
|
248
271
|
});
|
|
249
272
|
const traceId = res.headers.get("x-tps-trace-id") ?? "";
|
|
250
|
-
|
|
273
|
+
log.info(`[qqbot-api][${reqTs}] <<< Status: ${res.status} ${res.statusText}${traceId ? ` | TraceId: ${traceId}` : ""}`);
|
|
251
274
|
let rawBody;
|
|
252
275
|
try {
|
|
253
276
|
rawBody = await res.text();
|
|
@@ -255,7 +278,7 @@ export async function apiRequest(accessToken, method, path, body, timeoutMs) {
|
|
|
255
278
|
catch (err) {
|
|
256
279
|
throw new Error(`读取响应失败[${path}]: ${err instanceof Error ? err.message : String(err)}`);
|
|
257
280
|
}
|
|
258
|
-
|
|
281
|
+
log.info(`[qqbot-api][${reqTs}] <<< Body: ${rawBody}`);
|
|
259
282
|
// 检测非 JSON 响应(HTML 网关错误页 / CDN 限流页等)
|
|
260
283
|
const contentType = res.headers.get("content-type") ?? "";
|
|
261
284
|
const isHtmlResponse = contentType.includes("text/html") || rawBody.trimStart().startsWith("<");
|
|
@@ -283,13 +306,13 @@ export async function apiRequest(accessToken, method, path, body, timeoutMs) {
|
|
|
283
306
|
}
|
|
284
307
|
// 成功响应但不是 JSON(极端异常情况)
|
|
285
308
|
if (isHtmlResponse) {
|
|
286
|
-
throw new
|
|
309
|
+
throw new ApiError(`QQ 服务端返回了非 JSON 响应(${path}),可能是临时故障,请稍后重试`, res.status, path);
|
|
287
310
|
}
|
|
288
311
|
try {
|
|
289
312
|
return JSON.parse(rawBody);
|
|
290
313
|
}
|
|
291
314
|
catch {
|
|
292
|
-
throw new
|
|
315
|
+
throw new ApiError(`开放平台响应格式异常(${path}),请稍后重试`, res.status, path);
|
|
293
316
|
}
|
|
294
317
|
}
|
|
295
318
|
// ============ 上传重试(指数退避) ============
|
|
@@ -310,7 +333,7 @@ async function apiRequestWithRetry(accessToken, method, path, body, maxRetries =
|
|
|
310
333
|
}
|
|
311
334
|
if (attempt < maxRetries) {
|
|
312
335
|
const delay = UPLOAD_BASE_DELAY_MS * Math.pow(2, attempt);
|
|
313
|
-
|
|
336
|
+
log.info(`[qqbot-api] Upload attempt ${attempt + 1} failed, retrying in ${delay}ms: ${errMsg.slice(0, 100)}`);
|
|
314
337
|
await new Promise(resolve => setTimeout(resolve, delay));
|
|
315
338
|
}
|
|
316
339
|
}
|
|
@@ -334,7 +357,7 @@ async function completeUploadWithRetry(accessToken, method, path, body) {
|
|
|
334
357
|
lastError = err instanceof Error ? err : new Error(String(err));
|
|
335
358
|
if (attempt < COMPLETE_UPLOAD_MAX_RETRIES) {
|
|
336
359
|
const delay = COMPLETE_UPLOAD_BASE_DELAY_MS * Math.pow(2, attempt);
|
|
337
|
-
|
|
360
|
+
(log.warn ?? log.error)(`[qqbot-api] CompleteUpload attempt ${attempt + 1} failed, retrying in ${delay}ms: ${lastError.message.slice(0, 200)}`);
|
|
338
361
|
await new Promise(resolve => setTimeout(resolve, delay));
|
|
339
362
|
}
|
|
340
363
|
}
|
|
@@ -397,13 +420,13 @@ async function partFinishWithRetry(accessToken, method, path, body, retryTimeout
|
|
|
397
420
|
// 命中特定错误码 → 进入持续重试模式
|
|
398
421
|
if (isRetryableBizCode(err)) {
|
|
399
422
|
const timeoutMs = retryTimeoutMs ?? PART_FINISH_RETRYABLE_DEFAULT_TIMEOUT_MS;
|
|
400
|
-
|
|
423
|
+
(log.warn ?? log.error)(`[qqbot-api] PartFinish hit retryable bizCode=${err.bizCode}, entering persistent retry (timeout=${timeoutMs / 1000}s, interval=1s)...`);
|
|
401
424
|
await partFinishPersistentRetry(accessToken, method, path, body, timeoutMs);
|
|
402
425
|
return;
|
|
403
426
|
}
|
|
404
427
|
if (attempt < PART_FINISH_MAX_RETRIES) {
|
|
405
428
|
const delay = PART_FINISH_BASE_DELAY_MS * Math.pow(2, attempt);
|
|
406
|
-
|
|
429
|
+
(log.warn ?? log.error)(`[qqbot-api] PartFinish attempt ${attempt + 1} failed, retrying in ${delay}ms: ${lastError.message.slice(0, 200)}`);
|
|
407
430
|
await new Promise(resolve => setTimeout(resolve, delay));
|
|
408
431
|
}
|
|
409
432
|
}
|
|
@@ -421,14 +444,14 @@ async function partFinishPersistentRetry(accessToken, method, path, body, timeou
|
|
|
421
444
|
while (Date.now() < deadline) {
|
|
422
445
|
try {
|
|
423
446
|
await apiRequest(accessToken, method, path, body);
|
|
424
|
-
|
|
447
|
+
log.info(`[qqbot-api] PartFinish persistent retry succeeded after ${attempt} retries`);
|
|
425
448
|
return;
|
|
426
449
|
}
|
|
427
450
|
catch (err) {
|
|
428
451
|
lastError = err instanceof Error ? err : new Error(String(err));
|
|
429
452
|
// 如果不再是可重试的错误码,直接抛出(可能是其他类型的错误)
|
|
430
453
|
if (!isRetryableBizCode(err)) {
|
|
431
|
-
|
|
454
|
+
log.error(`[qqbot-api] PartFinish persistent retry: error is no longer retryable (bizCode=${err.bizCode ?? "N/A"}), aborting`);
|
|
432
455
|
throw lastError;
|
|
433
456
|
}
|
|
434
457
|
attempt++;
|
|
@@ -436,12 +459,12 @@ async function partFinishPersistentRetry(accessToken, method, path, body, timeou
|
|
|
436
459
|
if (remaining <= 0)
|
|
437
460
|
break;
|
|
438
461
|
const actualDelay = Math.min(PART_FINISH_RETRYABLE_INTERVAL_MS, remaining);
|
|
439
|
-
|
|
462
|
+
(log.warn ?? log.error)(`[qqbot-api] PartFinish persistent retry #${attempt}: bizCode=${err.bizCode}, retrying in ${actualDelay}ms (remaining=${Math.round(remaining / 1000)}s)`);
|
|
440
463
|
await new Promise(resolve => setTimeout(resolve, actualDelay));
|
|
441
464
|
}
|
|
442
465
|
}
|
|
443
466
|
// 超时
|
|
444
|
-
|
|
467
|
+
log.error(`[qqbot-api] PartFinish persistent retry timed out after ${timeoutMs / 1000}s (${attempt} attempts)`);
|
|
445
468
|
throw new Error(`upload_part_finish 持续重试超时(${timeoutMs / 1000}s, ${attempt} 次重试),中止上传`);
|
|
446
469
|
}
|
|
447
470
|
export async function getGatewayUrl(accessToken) {
|
|
@@ -452,7 +475,7 @@ export async function getGatewayUrl(accessToken) {
|
|
|
452
475
|
export async function acknowledgeInteraction(accessToken, interactionId, code = 0, data) {
|
|
453
476
|
await apiRequest(accessToken, "PUT", `/interactions/${interactionId}`, { code, ...(data ? { data } : {}) });
|
|
454
477
|
}
|
|
455
|
-
/** 获取插件版本号(从 package.json 读取,和
|
|
478
|
+
/** 获取插件版本号(从 package.json 读取,和 getPluginUserAgent() 同源) */
|
|
456
479
|
export function getApiPluginVersion() {
|
|
457
480
|
return _pluginVersion;
|
|
458
481
|
}
|
|
@@ -467,7 +490,7 @@ async function sendAndNotify(accessToken, method, path, body, meta) {
|
|
|
467
490
|
onMessageSentHook(result.ext_info.ref_idx, meta);
|
|
468
491
|
}
|
|
469
492
|
catch (err) {
|
|
470
|
-
|
|
493
|
+
log.error(`[qqbot-api] onMessageSent hook error: ${err}`);
|
|
471
494
|
}
|
|
472
495
|
}
|
|
473
496
|
return result;
|
|
@@ -755,15 +778,15 @@ export async function sendGroupVideoMessage(accessToken, groupOpenid, videoUrl,
|
|
|
755
778
|
const backgroundRefreshControllers = new Map();
|
|
756
779
|
export function startBackgroundTokenRefresh(appId, clientSecret, options) {
|
|
757
780
|
if (backgroundRefreshControllers.has(appId)) {
|
|
758
|
-
|
|
781
|
+
log.info(`[qqbot-api:${appId}] Background token refresh already running`);
|
|
759
782
|
return;
|
|
760
783
|
}
|
|
761
|
-
const { refreshAheadMs = 5 * 60 * 1000, randomOffsetMs = 30 * 1000, minRefreshIntervalMs = 60 * 1000, retryDelayMs = 5 * 1000, log, } = options ?? {};
|
|
784
|
+
const { refreshAheadMs = 5 * 60 * 1000, randomOffsetMs = 30 * 1000, minRefreshIntervalMs = 60 * 1000, retryDelayMs = 5 * 1000, log: refreshLog, } = options ?? {};
|
|
762
785
|
const controller = new AbortController();
|
|
763
786
|
backgroundRefreshControllers.set(appId, controller);
|
|
764
787
|
const signal = controller.signal;
|
|
765
788
|
const refreshLoop = async () => {
|
|
766
|
-
|
|
789
|
+
refreshLog?.info?.(`[qqbot-api:${appId}] Background token refresh started`);
|
|
767
790
|
while (!signal.aborted) {
|
|
768
791
|
try {
|
|
769
792
|
await getAccessToken(appId, clientSecret);
|
|
@@ -772,27 +795,27 @@ export function startBackgroundTokenRefresh(appId, clientSecret, options) {
|
|
|
772
795
|
const expiresIn = cached.expiresAt - Date.now();
|
|
773
796
|
const randomOffset = Math.random() * randomOffsetMs;
|
|
774
797
|
const refreshIn = Math.max(expiresIn - refreshAheadMs - randomOffset, minRefreshIntervalMs);
|
|
775
|
-
|
|
798
|
+
refreshLog?.debug?.(`[qqbot-api:${appId}] Token valid, next refresh in ${Math.round(refreshIn / 1000)}s`);
|
|
776
799
|
await sleep(refreshIn, signal);
|
|
777
800
|
}
|
|
778
801
|
else {
|
|
779
|
-
|
|
802
|
+
refreshLog?.debug?.(`[qqbot-api:${appId}] No cached token, retrying soon`);
|
|
780
803
|
await sleep(minRefreshIntervalMs, signal);
|
|
781
804
|
}
|
|
782
805
|
}
|
|
783
806
|
catch (err) {
|
|
784
807
|
if (signal.aborted)
|
|
785
808
|
break;
|
|
786
|
-
|
|
809
|
+
refreshLog?.error?.(`[qqbot-api:${appId}] Background token refresh failed: ${err}`);
|
|
787
810
|
await sleep(retryDelayMs, signal);
|
|
788
811
|
}
|
|
789
812
|
}
|
|
790
813
|
backgroundRefreshControllers.delete(appId);
|
|
791
|
-
|
|
814
|
+
refreshLog?.info?.(`[qqbot-api:${appId}] Background token refresh stopped`);
|
|
792
815
|
};
|
|
793
816
|
refreshLoop().catch((err) => {
|
|
794
817
|
backgroundRefreshControllers.delete(appId);
|
|
795
|
-
|
|
818
|
+
refreshLog?.error?.(`[qqbot-api:${appId}] Background token refresh crashed: ${err}`);
|
|
796
819
|
});
|
|
797
820
|
}
|
|
798
821
|
/**
|
|
@@ -844,10 +867,13 @@ async function sleep(ms, signal) {
|
|
|
844
867
|
* - 后续分片携带 stream_msg_id 和递增 msg_seq
|
|
845
868
|
* - input_state="1" 表示生成中,"10" 表示生成结束(终结状态)
|
|
846
869
|
*
|
|
870
|
+
* 仅在终结分片(input_state=DONE)时触发 refIdx 回调,
|
|
871
|
+
* 中间分片直接调用 apiRequest,避免存入过多无效的中间态数据。
|
|
872
|
+
*
|
|
847
873
|
* @param accessToken - access_token
|
|
848
874
|
* @param openid - 用户 openid
|
|
849
875
|
* @param req - 流式消息请求体
|
|
850
|
-
* @returns
|
|
876
|
+
* @returns 消息响应(复用 MessageResponse,错误会直接抛出异常)
|
|
851
877
|
*/
|
|
852
878
|
export async function sendC2CStreamMessage(accessToken, openid, req) {
|
|
853
879
|
const path = `/v2/users/${openid}/stream_messages`;
|
package/dist/src/channel.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { applyAccountNameToChannelSection, deleteAccountFromConfigSection, setAccountEnabledInConfigSection, } from "openclaw/plugin-sdk/core";
|
|
2
2
|
import { DEFAULT_ACCOUNT_ID, listQQBotAccountIds, resolveQQBotAccount, applyQQBotAccountConfig, resolveDefaultQQBotAccountId, resolveRequireMention, resolveToolPolicy, resolveGroupConfig } from "./config.js";
|
|
3
|
-
import { sendText, sendMedia } from "./outbound.js";
|
|
3
|
+
import { sendText, sendMedia, resolveUserFacingMediaError } from "./outbound.js";
|
|
4
4
|
import { startGateway } from "./gateway.js";
|
|
5
5
|
import { qqbotOnboardingAdapter } from "./onboarding.js";
|
|
6
6
|
import { getQQBotRuntime } from "./runtime.js";
|
|
@@ -17,6 +17,16 @@ export function chunkText(text, limit) {
|
|
|
17
17
|
const runtime = getQQBotRuntime();
|
|
18
18
|
return runtime.channel.text.chunkMarkdownText(text, limit);
|
|
19
19
|
}
|
|
20
|
+
function buildChannelMediaError(result) {
|
|
21
|
+
const err = new Error(resolveUserFacingMediaError(result));
|
|
22
|
+
if (result.errorCode) {
|
|
23
|
+
err.code = result.errorCode;
|
|
24
|
+
}
|
|
25
|
+
if (result.qqBizCode !== undefined) {
|
|
26
|
+
err.qqBizCode = result.qqBizCode;
|
|
27
|
+
}
|
|
28
|
+
return err;
|
|
29
|
+
}
|
|
20
30
|
export const qqbotPlugin = {
|
|
21
31
|
id: "qqbot",
|
|
22
32
|
meta: {
|
|
@@ -264,18 +274,12 @@ export const qqbotPlugin = {
|
|
|
264
274
|
const result = await sendMedia({ to, text: text ?? "", mediaUrl: mediaUrl ?? "", accountId, replyToId, account });
|
|
265
275
|
console.log(`[qqbot:channel] sendMedia result: messageId=${result.messageId}, error=${result.error ?? "none"}`);
|
|
266
276
|
// 此 sendMedia 是框架 Channel Plugin 的标准出站接口,
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
//
|
|
277
|
+
// 由框架 deliver.js (deliverOutboundPayloads) 或 message-actions 调用。
|
|
278
|
+
// 当 throw Error 后,框架 pi-tool-definition-adapter 会将错误转化为
|
|
279
|
+
// tool 的 { status: "error" } 返回给 AI 模型,模型会自行生成错误回复给用户。
|
|
280
|
+
// 因此此处不应主动发送兜底文本,否则会与模型的回复重复。
|
|
270
281
|
if (result.error) {
|
|
271
|
-
|
|
272
|
-
const fallbackResult = await sendText({ to, text: result.error, accountId, replyToId, account });
|
|
273
|
-
console.log(`[qqbot:channel] sendMedia fallback text sent: messageId=${fallbackResult.messageId}, error=${fallbackResult.error ?? "none"}`);
|
|
274
|
-
}
|
|
275
|
-
catch (fallbackErr) {
|
|
276
|
-
console.error(`[qqbot:channel] sendMedia fallback text failed: ${fallbackErr}`);
|
|
277
|
-
}
|
|
278
|
-
throw new Error(result.error);
|
|
282
|
+
throw buildChannelMediaError(result);
|
|
279
283
|
}
|
|
280
284
|
return {
|
|
281
285
|
channel: "qqbot",
|