@tencent-connect/openclaw-qqbot 1.5.7 → 1.6.0-alpha.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.
Files changed (43) hide show
  1. package/README.md +9 -2
  2. package/README.zh.md +7 -2
  3. package/dist/scripts/upgrade-via-npm.sh +168 -0
  4. package/dist/src/api.d.ts +36 -27
  5. package/dist/src/api.js +83 -25
  6. package/dist/src/channel.js +5 -5
  7. package/dist/src/gateway.js +624 -609
  8. package/dist/src/image-server.d.ts +7 -1
  9. package/dist/src/image-server.js +72 -11
  10. package/dist/src/message-queue.d.ts +267 -0
  11. package/dist/src/message-queue.js +558 -0
  12. package/dist/src/outbound.d.ts +53 -0
  13. package/dist/src/outbound.js +538 -610
  14. package/dist/src/ref-index-store.d.ts +70 -0
  15. package/dist/src/ref-index-store.js +274 -0
  16. package/dist/src/slash-commands.d.ts +49 -36
  17. package/dist/src/slash-commands.js +189 -190
  18. package/dist/src/types.d.ts +23 -0
  19. package/dist/src/user-messages.d.ts +43 -0
  20. package/dist/src/user-messages.js +65 -0
  21. package/dist/src/utils/audio-convert.d.ts +18 -2
  22. package/dist/src/utils/audio-convert.js +63 -4
  23. package/dist/src/utils/media-tags.js +12 -2
  24. package/dist/src/utils/platform.d.ts +2 -1
  25. package/dist/src/utils/platform.js +18 -2
  26. package/package.json +1 -1
  27. package/scripts/upgrade-via-npm.sh +85 -115
  28. package/scripts/upgrade-via-source.sh +152 -31
  29. package/skills/qqbot-cron/SKILL.md +46 -423
  30. package/skills/qqbot-media/SKILL.md +29 -182
  31. package/src/api.ts +16 -5
  32. package/src/channel.ts +6 -7
  33. package/src/gateway.ts +421 -525
  34. package/src/image-server.ts +72 -10
  35. package/src/openclaw-plugin-sdk.d.ts +1 -1
  36. package/src/outbound.ts +571 -611
  37. package/src/ref-index-store.ts +1 -1
  38. package/src/slash-commands.ts +287 -0
  39. package/src/types.ts +18 -1
  40. package/src/user-messages.ts +73 -0
  41. package/src/utils/audio-convert.ts +69 -4
  42. package/src/utils/media-tags.ts +12 -2
  43. package/dist/AI/345/210/233/346/226/260/345/272/224/347/224/250/345/245/226_/347/224/263/346/212/245/344/271/246.md +0 -211
package/README.md CHANGED
@@ -6,14 +6,19 @@
6
6
 
7
7
  # QQ Bot Channel Plugin for OpenClaw
8
8
 
9
+
10
+
9
11
  **Connect your AI assistant to QQ — private chat, group chat, and rich media, all in one plugin.**
10
12
 
13
+ ### 🚀 Current Version: `v1.5.7`
14
+
11
15
  [![License](https://img.shields.io/badge/license-MIT-green)](./LICENSE)
12
16
  [![QQ Bot](https://img.shields.io/badge/QQ_Bot-API_v2-red)](https://bot.q.qq.com/wiki/)
13
17
  [![Platform](https://img.shields.io/badge/platform-OpenClaw-orange)](https://github.com/tencent-connect/openclaw-qqbot)
14
18
  [![Node.js](https://img.shields.io/badge/Node.js->=18-339933?logo=node.js&logoColor=white)](https://nodejs.org/)
15
19
  [![TypeScript](https://img.shields.io/badge/TypeScript-5.9-3178C6?logo=typescript&logoColor=white)](https://www.typescriptlang.org/)
16
20
 
21
+
17
22
  <br/>
18
23
 
19
24
  **[简体中文](README.zh.md) | English**
@@ -56,6 +61,8 @@ QQ quote events carry index keys (e.g. `REFIDX_xxx`) instead of full original me
56
61
  - Store path: `~/.openclaw/qqbot/data/ref-index.jsonl` (survives gateway restart).
57
62
  - Quote body may include text + media summary (image/voice/video/file).
58
63
 
64
+ <img width="360" src="docs/images/ref_msg.png" alt="Quoted Message Context Demo" />
65
+
59
66
  ### 🎙️ Voice Messages (STT)
60
67
 
61
68
  With STT configured, the plugin automatically transcribes voice messages to text before passing them to AI. The whole process is transparent to the user — sending voice feels as natural as sending text.
@@ -86,7 +93,7 @@ If your main model supports vision (e.g. Tencent Hunyuan `hunyuan-vision`), AI c
86
93
 
87
94
  <img width="360" src="docs/images/59d421891f813b0d3c0cbe12574b6a72_720.jpg" alt="Image Understanding Demo" />
88
95
 
89
- ### 🎨 Image Generation
96
+ ### 🎨 Image Sending
90
97
 
91
98
  > **You**: Draw me a cat
92
99
  >
@@ -96,7 +103,7 @@ AI sends images via `<qqimg>path</qqimg>`. Supports local paths and URLs. Format
96
103
 
97
104
  <img width="360" src="docs/images/4645f2b3a20822b7f8d6664a708529eb_720.jpg" alt="Image Generation Demo" />
98
105
 
99
- ### 🔊 Voice Reply (TTS)
106
+ ### 🔊 Voice Sending
100
107
 
101
108
  > **You**: Tell me a joke in voice
102
109
  >
package/README.zh.md CHANGED
@@ -6,8 +6,11 @@
6
6
 
7
7
  # QQ Bot — OpenClaw 渠道插件
8
8
 
9
+
9
10
  **让你的 AI 助手接入 QQ — 私聊、群聊、富媒体,一个插件全搞定。**
10
11
 
12
+ ### 🚀 当前版本: `v1.5.7`
13
+
11
14
  [![License](https://img.shields.io/badge/license-MIT-green)](./LICENSE)
12
15
  [![QQ Bot](https://img.shields.io/badge/QQ_Bot-API_v2-red)](https://bot.q.qq.com/wiki/)
13
16
  [![Platform](https://img.shields.io/badge/platform-OpenClaw-orange)](https://github.com/tencent-connect/openclaw-qqbot)
@@ -53,6 +56,8 @@ QQ 的引用事件通常只携带索引键(如 `REFIDX_xxx`),不直接返
53
56
  - 存储位置:`~/.openclaw/qqbot/data/ref-index.jsonl`(网关重启后仍可恢复)。
54
57
  - 引用内容支持文本 + 媒体摘要(图片/语音/视频/文件)。
55
58
 
59
+ <img width="360" src="docs/images/ref_msg.png" alt="引用消息上下文演示" />
60
+
56
61
  ### 🎙️ 语音消息(STT)
57
62
 
58
63
  配置 STT 后,插件会自动将语音转录为文字再交给 AI 处理。整个过程对用户完全透明——发语音就像发文字一样自然,AI 听得懂你在说什么。
@@ -83,7 +88,7 @@ QQ 的引用事件通常只携带索引键(如 `REFIDX_xxx`),不直接返
83
88
 
84
89
  <img width="360" src="docs/images/59d421891f813b0d3c0cbe12574b6a72_720.jpg" alt="图片理解演示" />
85
90
 
86
- ### 🎨 AI 画图
91
+ ### 🎨 图片发送
87
92
 
88
93
  > **你**:画一只猫咪
89
94
  >
@@ -93,7 +98,7 @@ AI 通过 `<qqimg>路径</qqimg>` 发送图片,支持本地文件路径和网
93
98
 
94
99
  <img width="360" src="docs/images/4645f2b3a20822b7f8d6664a708529eb_720.jpg" alt="发图片演示" />
95
100
 
96
- ### 🔊 语音回复(TTS)
101
+ ### 🔊 语音发送
97
102
 
98
103
  > **你**:给我讲一个笑话
99
104
  >
@@ -0,0 +1,168 @@
1
+ #!/bin/bash
2
+
3
+ # qqbot 通过 npm 包升级(纯文件操作版本)
4
+ #
5
+ # 重要:此脚本不修改 openclaw.json 配置文件!
6
+ # 配置更新由调用方(TS handler)在脚本完成后统一处理,
7
+ # 避免 gateway config watcher 在安装过程中触发 SIGUSR1 重启导致竞态。
8
+ #
9
+ # 用法:
10
+ # upgrade-via-npm.sh # 升级到 latest(默认)
11
+ # upgrade-via-npm.sh --version <version> # 升级到指定版本
12
+ # upgrade-via-npm.sh --self-version # 升级到当前仓库 package.json 版本
13
+
14
+ set -eo pipefail
15
+
16
+ PKG_NAME="@tencent-connect/openclaw-qqbot"
17
+ INSTALL_SRC=""
18
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
19
+ PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
20
+
21
+ LOCAL_VERSION="$(node -e "
22
+ try {
23
+ const fs = require('fs');
24
+ const path = require('path');
25
+ const p = path.join('$PROJECT_DIR', 'package.json');
26
+ const v = JSON.parse(fs.readFileSync(p, 'utf8')).version;
27
+ if (v) process.stdout.write(String(v));
28
+ } catch {}
29
+ " 2>/dev/null || true)"
30
+
31
+ print_usage() {
32
+ echo "用法:"
33
+ echo " upgrade-via-npm.sh # 升级到 latest(默认)"
34
+ echo " upgrade-via-npm.sh --version <版本号> # 升级到指定版本"
35
+ if [ -n "$LOCAL_VERSION" ]; then
36
+ echo " upgrade-via-npm.sh --self-version # 升级到当前仓库版本($LOCAL_VERSION)"
37
+ else
38
+ echo " upgrade-via-npm.sh --self-version # 升级到当前仓库版本"
39
+ fi
40
+ }
41
+
42
+ while [[ $# -gt 0 ]]; do
43
+ case "$1" in
44
+ --tag)
45
+ [ -z "$2" ] && echo "❌ --tag 需要参数" && exit 1
46
+ INSTALL_SRC="${PKG_NAME}@$2"
47
+ shift 2
48
+ ;;
49
+ --version)
50
+ [ -z "$2" ] && echo "❌ --version 需要参数" && exit 1
51
+ INSTALL_SRC="${PKG_NAME}@$2"
52
+ shift 2
53
+ ;;
54
+ --self-version)
55
+ [ -z "$LOCAL_VERSION" ] && echo "❌ 无法从 package.json 读取版本" && exit 1
56
+ INSTALL_SRC="${PKG_NAME}@${LOCAL_VERSION}"
57
+ shift 1
58
+ ;;
59
+ -h|--help)
60
+ print_usage
61
+ exit 0
62
+ ;;
63
+ *) echo "未知选项: $1"; print_usage; exit 1 ;;
64
+ esac
65
+ done
66
+ INSTALL_SRC="${INSTALL_SRC:-${PKG_NAME}@latest}"
67
+
68
+ # 检测 CLI(仅用于确定 extensions 目录路径)
69
+ CMD=""
70
+ for name in openclaw clawdbot moltbot; do
71
+ command -v "$name" &>/dev/null && CMD="$name" && break
72
+ done
73
+ [ -z "$CMD" ] && echo "❌ 未找到 openclaw / clawdbot / moltbot" && exit 1
74
+
75
+ EXTENSIONS_DIR="$HOME/.$CMD/extensions"
76
+
77
+ echo "==========================================="
78
+ echo " qqbot npm 升级: $INSTALL_SRC"
79
+ echo "==========================================="
80
+ echo ""
81
+
82
+ # [1/3] 下载并安装新版本到临时目录
83
+ echo "[1/3] 下载新版本..."
84
+ TMPDIR_PACK=$(mktemp -d)
85
+ EXTRACT_DIR=$(mktemp -d)
86
+ trap "rm -rf '$TMPDIR_PACK' '$EXTRACT_DIR'" EXIT
87
+
88
+ cd "$TMPDIR_PACK"
89
+ npm pack "$INSTALL_SRC" --quiet 2>&1 || { echo "❌ npm pack 失败"; exit 1; }
90
+ TGZ_FILE=$(ls -1 *.tgz 2>/dev/null | head -1)
91
+ [ -z "$TGZ_FILE" ] && echo "❌ 未找到下载的 tgz 文件" && exit 1
92
+ echo " 已下载: $TGZ_FILE"
93
+
94
+ tar xzf "$TGZ_FILE" -C "$EXTRACT_DIR"
95
+ PACKAGE_DIR="$EXTRACT_DIR/package"
96
+ [ ! -d "$PACKAGE_DIR" ] && echo "❌ 解压失败,未找到 package 目录" && exit 1
97
+
98
+ # 准备 staging 目录:放在 ~/.openclaw/ 下(extensions 的父目录),
99
+ # 同一文件系统保证 mv 原子操作,同时避免 OpenClaw 扫描 extensions/ 时发现它。
100
+ STAGING_DIR="$(dirname "$EXTENSIONS_DIR")/.qqbot-upgrade-staging"
101
+ rm -rf "$STAGING_DIR"
102
+ mkdir -p "$STAGING_DIR"
103
+ cp -R "$PACKAGE_DIR/"* "$STAGING_DIR/"
104
+
105
+ # 安装依赖(使用临时缓存目录,避免全局缓存权限问题)
106
+ if [ -f "$STAGING_DIR/package.json" ]; then
107
+ echo " 安装依赖..."
108
+ NPM_TMP_CACHE=$(mktemp -d)
109
+ (cd "$STAGING_DIR" && npm install --omit=dev --cache="$NPM_TMP_CACHE" --quiet 2>&1) || echo " ⚠️ 依赖安装失败(可能无 dependencies)"
110
+ rm -rf "$NPM_TMP_CACHE"
111
+ fi
112
+
113
+ # 清理下载临时文件
114
+ rm -rf "$TMPDIR_PACK" "$EXTRACT_DIR"
115
+ cd "$HOME"
116
+
117
+ # [2/3] 原子替换:先移走旧目录,再把 staging 目录 rename 过去
118
+ # 这样 extensions/openclaw-qqbot 只有极短的不存在时间窗口
119
+ echo ""
120
+ echo "[2/3] 原子替换插件目录..."
121
+ TARGET_DIR="$EXTENSIONS_DIR/openclaw-qqbot"
122
+ OLD_DIR="$(dirname "$EXTENSIONS_DIR")/.qqbot-upgrade-old"
123
+
124
+ rm -rf "$OLD_DIR"
125
+ if [ -d "$TARGET_DIR" ]; then
126
+ mv "$TARGET_DIR" "$OLD_DIR"
127
+ fi
128
+ mv "$STAGING_DIR" "$TARGET_DIR"
129
+ rm -rf "$OLD_DIR"
130
+
131
+ # 清理可能残留的旧版 staging 目录(extensions 内外都清理)
132
+ rm -rf "$EXTENSIONS_DIR/openclaw-qqbot.staging"
133
+ rm -rf "$EXTENSIONS_DIR/.qqbot-upgrade-staging"
134
+ rm -rf "$EXTENSIONS_DIR/.qqbot-upgrade-old"
135
+
136
+ # 同时清理历史遗留的其他目录名
137
+ for dir_name in qqbot openclaw-qq; do
138
+ [ -d "$EXTENSIONS_DIR/$dir_name" ] && rm -rf "$EXTENSIONS_DIR/$dir_name"
139
+ done
140
+ echo " 已安装到: $TARGET_DIR"
141
+
142
+ # [3/3] 输出新版本号和升级报告(供调用方解析)
143
+ echo ""
144
+ echo "[3/3] 验证安装..."
145
+ NEW_VERSION="$(node -e "
146
+ try {
147
+ const fs = require('fs');
148
+ const path = require('path');
149
+ const p = path.join('$EXTENSIONS_DIR', 'openclaw-qqbot', 'package.json');
150
+ if (fs.existsSync(p)) {
151
+ const v = JSON.parse(fs.readFileSync(p, 'utf8')).version;
152
+ if (v) { process.stdout.write(v); process.exit(0); }
153
+ }
154
+ } catch {}
155
+ " 2>/dev/null || true)"
156
+ echo "QQBOT_NEW_VERSION=${NEW_VERSION:-unknown}"
157
+
158
+ # 输出结构化升级报告(QQBOT_REPORT=...),供 TS handler 解析后直接回复用户
159
+ if [ -n "$NEW_VERSION" ] && [ "$NEW_VERSION" != "unknown" ]; then
160
+ echo "QQBOT_REPORT=✅ QQBot 升级完成: v${NEW_VERSION}"
161
+ else
162
+ echo "QQBOT_REPORT=⚠️ QQBot 升级异常,无法确认新版本"
163
+ fi
164
+
165
+ echo ""
166
+ echo "==========================================="
167
+ echo " ✅ 文件安装完成"
168
+ echo "==========================================="
package/dist/src/api.d.ts CHANGED
@@ -2,6 +2,27 @@
2
2
  * QQ Bot API 鉴权和请求封装
3
3
  * [修复版] 已重构为支持多实例并发,消除全局变量冲突
4
4
  */
5
+ export declare const PLUGIN_USER_AGENT: string;
6
+ /** 出站消息元信息(结构化存储,不做预格式化) */
7
+ export interface OutboundMeta {
8
+ /** 消息文本内容 */
9
+ text?: string;
10
+ /** 媒体类型 */
11
+ mediaType?: "image" | "voice" | "video" | "file";
12
+ /** 媒体来源:在线 URL */
13
+ mediaUrl?: string;
14
+ /** 媒体来源:本地文件路径或文件名 */
15
+ mediaLocalPath?: string;
16
+ /** TTS 原文本(仅 voice 类型有效,用于保存 TTS 前的文本内容) */
17
+ ttsText?: string;
18
+ }
19
+ type OnMessageSentCallback = (refIdx: string, meta: OutboundMeta) => void;
20
+ /**
21
+ * 注册出站消息回调
22
+ * 当消息发送成功且 QQ 返回 ref_idx 时,自动回调此函数
23
+ * 用于在最底层统一缓存 bot 出站消息的 refIdx
24
+ */
25
+ export declare function onMessageSent(callback: OnMessageSentCallback): void;
5
26
  /**
6
27
  * 初始化 API 配置
7
28
  * @param options.markdownSupport - 是否支持 markdown 消息(默认 false,需要机器人具备该权限才能启用)
@@ -47,18 +68,21 @@ export declare function getGatewayUrl(accessToken: string): Promise<string>;
47
68
  export interface MessageResponse {
48
69
  id: string;
49
70
  timestamp: number | string;
71
+ /** 消息的引用索引信息(出站时由 QQ 服务端返回) */
72
+ ext_info?: {
73
+ ref_idx?: string;
74
+ };
50
75
  }
51
- export declare function sendC2CMessage(accessToken: string, openid: string, content: string, msgId?: string): Promise<MessageResponse>;
52
- export declare function sendC2CInputNotify(accessToken: string, openid: string, msgId?: string, inputSecond?: number): Promise<void>;
76
+ export declare function sendC2CMessage(accessToken: string, openid: string, content: string, msgId?: string, messageReference?: string): Promise<MessageResponse>;
77
+ export declare function sendC2CInputNotify(accessToken: string, openid: string, msgId?: string, inputSecond?: number): Promise<{
78
+ refIdx?: string;
79
+ }>;
53
80
  export declare function sendChannelMessage(accessToken: string, channelId: string, content: string, msgId?: string): Promise<{
54
81
  id: string;
55
82
  timestamp: string;
56
83
  }>;
57
84
  export declare function sendGroupMessage(accessToken: string, groupOpenid: string, content: string, msgId?: string): Promise<MessageResponse>;
58
- export declare function sendProactiveC2CMessage(accessToken: string, openid: string, content: string): Promise<{
59
- id: string;
60
- timestamp: number;
61
- }>;
85
+ export declare function sendProactiveC2CMessage(accessToken: string, openid: string, content: string): Promise<MessageResponse>;
62
86
  export declare function sendProactiveGroupMessage(accessToken: string, groupOpenid: string, content: string): Promise<{
63
87
  id: string;
64
88
  timestamp: string;
@@ -77,42 +101,27 @@ export interface UploadMediaResponse {
77
101
  }
78
102
  export declare function uploadC2CMedia(accessToken: string, openid: string, fileType: MediaFileType, url?: string, fileData?: string, srvSendMsg?: boolean, fileName?: string): Promise<UploadMediaResponse>;
79
103
  export declare function uploadGroupMedia(accessToken: string, groupOpenid: string, fileType: MediaFileType, url?: string, fileData?: string, srvSendMsg?: boolean, fileName?: string): Promise<UploadMediaResponse>;
80
- export declare function sendC2CMediaMessage(accessToken: string, openid: string, fileInfo: string, msgId?: string, content?: string): Promise<{
81
- id: string;
82
- timestamp: number;
83
- }>;
104
+ export declare function sendC2CMediaMessage(accessToken: string, openid: string, fileInfo: string, msgId?: string, content?: string, meta?: OutboundMeta): Promise<MessageResponse>;
84
105
  export declare function sendGroupMediaMessage(accessToken: string, groupOpenid: string, fileInfo: string, msgId?: string, content?: string): Promise<{
85
106
  id: string;
86
107
  timestamp: string;
87
108
  }>;
88
- export declare function sendC2CImageMessage(accessToken: string, openid: string, imageUrl: string, msgId?: string, content?: string): Promise<{
89
- id: string;
90
- timestamp: number;
91
- }>;
109
+ export declare function sendC2CImageMessage(accessToken: string, openid: string, imageUrl: string, msgId?: string, content?: string, localPath?: string): Promise<MessageResponse>;
92
110
  export declare function sendGroupImageMessage(accessToken: string, groupOpenid: string, imageUrl: string, msgId?: string, content?: string): Promise<{
93
111
  id: string;
94
112
  timestamp: string;
95
113
  }>;
96
- export declare function sendC2CVoiceMessage(accessToken: string, openid: string, voiceBase64: string, msgId?: string): Promise<{
97
- id: string;
98
- timestamp: number;
99
- }>;
100
- export declare function sendGroupVoiceMessage(accessToken: string, groupOpenid: string, voiceBase64: string, msgId?: string): Promise<{
114
+ export declare function sendC2CVoiceMessage(accessToken: string, openid: string, voiceBase64?: string, voiceUrl?: string, msgId?: string, ttsText?: string, filePath?: string): Promise<MessageResponse>;
115
+ export declare function sendGroupVoiceMessage(accessToken: string, groupOpenid: string, voiceBase64?: string, voiceUrl?: string, msgId?: string): Promise<{
101
116
  id: string;
102
117
  timestamp: string;
103
118
  }>;
104
- export declare function sendC2CFileMessage(accessToken: string, openid: string, fileBase64?: string, fileUrl?: string, msgId?: string, fileName?: string): Promise<{
105
- id: string;
106
- timestamp: number;
107
- }>;
119
+ export declare function sendC2CFileMessage(accessToken: string, openid: string, fileBase64?: string, fileUrl?: string, msgId?: string, fileName?: string, localFilePath?: string): Promise<MessageResponse>;
108
120
  export declare function sendGroupFileMessage(accessToken: string, groupOpenid: string, fileBase64?: string, fileUrl?: string, msgId?: string, fileName?: string): Promise<{
109
121
  id: string;
110
122
  timestamp: string;
111
123
  }>;
112
- export declare function sendC2CVideoMessage(accessToken: string, openid: string, videoUrl?: string, videoBase64?: string, msgId?: string, content?: string): Promise<{
113
- id: string;
114
- timestamp: number;
115
- }>;
124
+ export declare function sendC2CVideoMessage(accessToken: string, openid: string, videoUrl?: string, videoBase64?: string, msgId?: string, content?: string, localPath?: string): Promise<MessageResponse>;
116
125
  export declare function sendGroupVideoMessage(accessToken: string, groupOpenid: string, videoUrl?: string, videoBase64?: string, msgId?: string, content?: string): Promise<{
117
126
  id: string;
118
127
  timestamp: string;
package/dist/src/api.js CHANGED
@@ -2,12 +2,33 @@
2
2
  * QQ Bot API 鉴权和请求封装
3
3
  * [修复版] 已重构为支持多实例并发,消除全局变量冲突
4
4
  */
5
+ import { createRequire } from "node:module";
6
+ import os from "node:os";
5
7
  import { computeFileHash, getCachedFileInfo, setCachedFileInfo } from "./utils/upload-cache.js";
6
8
  import { sanitizeFileName } from "./utils/platform.js";
7
9
  const API_BASE = "https://api.sgroup.qq.com";
8
10
  const TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken";
11
+ // ============ Plugin User-Agent ============
12
+ // 格式: QQBotPlugin/{version} (Node/{nodeVersion}; {os})
13
+ // 示例: QQBotPlugin/1.6.0 (Node/22.14.0; darwin)
14
+ const _require = createRequire(import.meta.url);
15
+ let _pluginVersion = "unknown";
16
+ try {
17
+ _pluginVersion = _require("../package.json").version ?? "unknown";
18
+ }
19
+ catch { /* fallback */ }
20
+ export const PLUGIN_USER_AGENT = `QQBotPlugin/${_pluginVersion} (Node/${process.versions.node}; ${os.platform()})`;
9
21
  // 运行时配置
10
22
  let currentMarkdownSupport = false;
23
+ let onMessageSentHook = null;
24
+ /**
25
+ * 注册出站消息回调
26
+ * 当消息发送成功且 QQ 返回 ref_idx 时,自动回调此函数
27
+ * 用于在最底层统一缓存 bot 出站消息的 refIdx
28
+ */
29
+ export function onMessageSent(callback) {
30
+ onMessageSentHook = callback;
31
+ }
11
32
  /**
12
33
  * 初始化 API 配置
13
34
  * @param options.markdownSupport - 是否支持 markdown 消息(默认 false,需要机器人具备该权限才能启用)
@@ -65,7 +86,7 @@ export async function getAccessToken(appId, clientSecret) {
65
86
  */
66
87
  async function doFetchToken(appId, clientSecret) {
67
88
  const requestBody = { appId, clientSecret };
68
- const requestHeaders = { "Content-Type": "application/json" };
89
+ const requestHeaders = { "Content-Type": "application/json", "User-Agent": PLUGIN_USER_AGENT };
69
90
  // 打印请求信息(隐藏敏感信息)
70
91
  console.log(`[qqbot-api:${appId}] >>> POST ${TOKEN_URL}`);
71
92
  let response;
@@ -85,7 +106,8 @@ async function doFetchToken(appId, clientSecret) {
85
106
  response.headers.forEach((value, key) => {
86
107
  responseHeaders[key] = value;
87
108
  });
88
- console.log(`[qqbot-api:${appId}] <<< Status: ${response.status} ${response.statusText}`);
109
+ const tokenTraceId = response.headers.get("x-tps-trace-id") ?? "";
110
+ console.log(`[qqbot-api:${appId}] <<< Status: ${response.status} ${response.statusText}${tokenTraceId ? ` | TraceId: ${tokenTraceId}` : ""}`);
89
111
  let data;
90
112
  let rawBody;
91
113
  try {
@@ -160,6 +182,7 @@ export async function apiRequest(accessToken, method, path, body, timeoutMs) {
160
182
  const headers = {
161
183
  Authorization: `QQBot ${accessToken}`,
162
184
  "Content-Type": "application/json",
185
+ "User-Agent": PLUGIN_USER_AGENT,
163
186
  };
164
187
  const isFileUpload = path.includes("/files");
165
188
  const timeout = timeoutMs ?? (isFileUpload ? FILE_UPLOAD_TIMEOUT : DEFAULT_API_TIMEOUT);
@@ -182,6 +205,7 @@ export async function apiRequest(accessToken, method, path, body, timeoutMs) {
182
205
  if (typeof logBody.file_data === "string") {
183
206
  logBody.file_data = `<base64 ${logBody.file_data.length} chars>`;
184
207
  }
208
+ console.log(`[qqbot-api] >>> Body:`, JSON.stringify(logBody));
185
209
  }
186
210
  let res;
187
211
  try {
@@ -203,11 +227,13 @@ export async function apiRequest(accessToken, method, path, body, timeoutMs) {
203
227
  res.headers.forEach((value, key) => {
204
228
  responseHeaders[key] = value;
205
229
  });
206
- console.log(`[qqbot-api] <<< Status: ${res.status} ${res.statusText}`);
230
+ const traceId = res.headers.get("x-tps-trace-id") ?? "";
231
+ console.log(`[qqbot-api] <<< Status: ${res.status} ${res.statusText}${traceId ? ` | TraceId: ${traceId}` : ""}`);
207
232
  let data;
208
233
  let rawBody;
209
234
  try {
210
235
  rawBody = await res.text();
236
+ console.log(`[qqbot-api] <<< Body:`, rawBody);
211
237
  data = JSON.parse(rawBody);
212
238
  }
213
239
  catch (err) {
@@ -248,7 +274,23 @@ export async function getGatewayUrl(accessToken) {
248
274
  const data = await apiRequest(accessToken, "GET", "/gateway");
249
275
  return data.url;
250
276
  }
251
- function buildMessageBody(content, msgId, msgSeq) {
277
+ /**
278
+ * 发送消息并自动触发 refIdx 回调
279
+ * 所有消息发送函数统一经过此处,确保每条出站消息的 refIdx 都被捕获
280
+ */
281
+ async function sendAndNotify(accessToken, method, path, body, meta) {
282
+ const result = await apiRequest(accessToken, method, path, body);
283
+ if (result.ext_info?.ref_idx && onMessageSentHook) {
284
+ try {
285
+ onMessageSentHook(result.ext_info.ref_idx, meta);
286
+ }
287
+ catch (err) {
288
+ console.error(`[qqbot-api] onMessageSent hook error: ${err}`);
289
+ }
290
+ }
291
+ return result;
292
+ }
293
+ function buildMessageBody(content, msgId, msgSeq, messageReference) {
252
294
  const body = currentMarkdownSupport
253
295
  ? {
254
296
  markdown: { content },
@@ -263,12 +305,15 @@ function buildMessageBody(content, msgId, msgSeq) {
263
305
  if (msgId) {
264
306
  body.msg_id = msgId;
265
307
  }
308
+ if (messageReference && !currentMarkdownSupport) {
309
+ body.message_reference = { message_id: messageReference };
310
+ }
266
311
  return body;
267
312
  }
268
- export async function sendC2CMessage(accessToken, openid, content, msgId) {
313
+ export async function sendC2CMessage(accessToken, openid, content, msgId, messageReference) {
269
314
  const msgSeq = msgId ? getNextMsgSeq(msgId) : 1;
270
- const body = buildMessageBody(content, msgId, msgSeq);
271
- return apiRequest(accessToken, "POST", `/v2/users/${openid}/messages`, body);
315
+ const body = buildMessageBody(content, msgId, msgSeq, messageReference);
316
+ return sendAndNotify(accessToken, "POST", `/v2/users/${openid}/messages`, body, { text: content });
272
317
  }
273
318
  export async function sendC2CInputNotify(accessToken, openid, msgId, inputSecond = 60) {
274
319
  const msgSeq = msgId ? getNextMsgSeq(msgId) : 1;
@@ -281,7 +326,8 @@ export async function sendC2CInputNotify(accessToken, openid, msgId, inputSecond
281
326
  msg_seq: msgSeq,
282
327
  ...(msgId ? { msg_id: msgId } : {}),
283
328
  };
284
- await apiRequest(accessToken, "POST", `/v2/users/${openid}/messages`, body);
329
+ const response = await apiRequest(accessToken, "POST", `/v2/users/${openid}/messages`, body);
330
+ return { refIdx: response.ext_info?.ref_idx };
285
331
  }
286
332
  export async function sendChannelMessage(accessToken, channelId, content, msgId) {
287
333
  return apiRequest(accessToken, "POST", `/channels/${channelId}/messages`, {
@@ -307,7 +353,7 @@ function buildProactiveMessageBody(content) {
307
353
  }
308
354
  export async function sendProactiveC2CMessage(accessToken, openid, content) {
309
355
  const body = buildProactiveMessageBody(content);
310
- return apiRequest(accessToken, "POST", `/v2/users/${openid}/messages`, body);
356
+ return sendAndNotify(accessToken, "POST", `/v2/users/${openid}/messages`, body, { text: content });
311
357
  }
312
358
  export async function sendProactiveGroupMessage(accessToken, groupOpenid, content) {
313
359
  const body = buildProactiveMessageBody(content);
@@ -369,15 +415,15 @@ export async function uploadGroupMedia(accessToken, groupOpenid, fileType, url,
369
415
  }
370
416
  return result;
371
417
  }
372
- export async function sendC2CMediaMessage(accessToken, openid, fileInfo, msgId, content) {
418
+ export async function sendC2CMediaMessage(accessToken, openid, fileInfo, msgId, content, meta) {
373
419
  const msgSeq = msgId ? getNextMsgSeq(msgId) : 1;
374
- return apiRequest(accessToken, "POST", `/v2/users/${openid}/messages`, {
420
+ return sendAndNotify(accessToken, "POST", `/v2/users/${openid}/messages`, {
375
421
  msg_type: 7,
376
422
  media: { file_info: fileInfo },
377
423
  msg_seq: msgSeq,
378
424
  ...(content ? { content } : {}),
379
425
  ...(msgId ? { msg_id: msgId } : {}),
380
- });
426
+ }, meta ?? { text: content });
381
427
  }
382
428
  export async function sendGroupMediaMessage(accessToken, groupOpenid, fileInfo, msgId, content) {
383
429
  const msgSeq = msgId ? getNextMsgSeq(msgId) : 1;
@@ -389,9 +435,10 @@ export async function sendGroupMediaMessage(accessToken, groupOpenid, fileInfo,
389
435
  ...(msgId ? { msg_id: msgId } : {}),
390
436
  });
391
437
  }
392
- export async function sendC2CImageMessage(accessToken, openid, imageUrl, msgId, content) {
438
+ export async function sendC2CImageMessage(accessToken, openid, imageUrl, msgId, content, localPath) {
393
439
  let uploadResult;
394
- if (imageUrl.startsWith("data:")) {
440
+ const isBase64 = imageUrl.startsWith("data:");
441
+ if (isBase64) {
395
442
  const matches = imageUrl.match(/^data:([^;]+);base64,(.+)$/);
396
443
  if (!matches)
397
444
  throw new Error("Invalid Base64 Data URL format");
@@ -400,11 +447,18 @@ export async function sendC2CImageMessage(accessToken, openid, imageUrl, msgId,
400
447
  else {
401
448
  uploadResult = await uploadC2CMedia(accessToken, openid, MediaFileType.IMAGE, imageUrl, undefined, false);
402
449
  }
403
- return sendC2CMediaMessage(accessToken, openid, uploadResult.file_info, msgId, content);
450
+ const meta = {
451
+ text: content,
452
+ mediaType: "image",
453
+ ...(!isBase64 ? { mediaUrl: imageUrl } : {}),
454
+ ...(localPath ? { mediaLocalPath: localPath } : {}),
455
+ };
456
+ return sendC2CMediaMessage(accessToken, openid, uploadResult.file_info, msgId, content, meta);
404
457
  }
405
458
  export async function sendGroupImageMessage(accessToken, groupOpenid, imageUrl, msgId, content) {
406
459
  let uploadResult;
407
- if (imageUrl.startsWith("data:")) {
460
+ const isBase64 = imageUrl.startsWith("data:");
461
+ if (isBase64) {
408
462
  const matches = imageUrl.match(/^data:([^;]+);base64,(.+)$/);
409
463
  if (!matches)
410
464
  throw new Error("Invalid Base64 Data URL format");
@@ -415,25 +469,29 @@ export async function sendGroupImageMessage(accessToken, groupOpenid, imageUrl,
415
469
  }
416
470
  return sendGroupMediaMessage(accessToken, groupOpenid, uploadResult.file_info, msgId, content);
417
471
  }
418
- export async function sendC2CVoiceMessage(accessToken, openid, voiceBase64, msgId) {
419
- const uploadResult = await uploadC2CMedia(accessToken, openid, MediaFileType.VOICE, undefined, voiceBase64, false);
420
- return sendC2CMediaMessage(accessToken, openid, uploadResult.file_info, msgId);
472
+ export async function sendC2CVoiceMessage(accessToken, openid, voiceBase64, voiceUrl, msgId, ttsText, filePath) {
473
+ const uploadResult = await uploadC2CMedia(accessToken, openid, MediaFileType.VOICE, voiceUrl, voiceBase64, false);
474
+ return sendC2CMediaMessage(accessToken, openid, uploadResult.file_info, msgId, undefined, {
475
+ mediaType: "voice",
476
+ ...(ttsText ? { ttsText } : {}),
477
+ ...(filePath ? { mediaLocalPath: filePath } : {})
478
+ });
421
479
  }
422
- export async function sendGroupVoiceMessage(accessToken, groupOpenid, voiceBase64, msgId) {
423
- const uploadResult = await uploadGroupMedia(accessToken, groupOpenid, MediaFileType.VOICE, undefined, voiceBase64, false);
480
+ export async function sendGroupVoiceMessage(accessToken, groupOpenid, voiceBase64, voiceUrl, msgId) {
481
+ const uploadResult = await uploadGroupMedia(accessToken, groupOpenid, MediaFileType.VOICE, voiceUrl, voiceBase64, false);
424
482
  return sendGroupMediaMessage(accessToken, groupOpenid, uploadResult.file_info, msgId);
425
483
  }
426
- export async function sendC2CFileMessage(accessToken, openid, fileBase64, fileUrl, msgId, fileName) {
484
+ export async function sendC2CFileMessage(accessToken, openid, fileBase64, fileUrl, msgId, fileName, localFilePath) {
427
485
  const uploadResult = await uploadC2CMedia(accessToken, openid, MediaFileType.FILE, fileUrl, fileBase64, false, fileName);
428
- return sendC2CMediaMessage(accessToken, openid, uploadResult.file_info, msgId);
486
+ return sendC2CMediaMessage(accessToken, openid, uploadResult.file_info, msgId, undefined, { mediaType: "file", mediaUrl: fileUrl, mediaLocalPath: localFilePath ?? fileName });
429
487
  }
430
488
  export async function sendGroupFileMessage(accessToken, groupOpenid, fileBase64, fileUrl, msgId, fileName) {
431
489
  const uploadResult = await uploadGroupMedia(accessToken, groupOpenid, MediaFileType.FILE, fileUrl, fileBase64, false, fileName);
432
490
  return sendGroupMediaMessage(accessToken, groupOpenid, uploadResult.file_info, msgId);
433
491
  }
434
- export async function sendC2CVideoMessage(accessToken, openid, videoUrl, videoBase64, msgId, content) {
492
+ export async function sendC2CVideoMessage(accessToken, openid, videoUrl, videoBase64, msgId, content, localPath) {
435
493
  const uploadResult = await uploadC2CMedia(accessToken, openid, MediaFileType.VIDEO, videoUrl, videoBase64, false);
436
- return sendC2CMediaMessage(accessToken, openid, uploadResult.file_info, msgId, content);
494
+ return sendC2CMediaMessage(accessToken, openid, uploadResult.file_info, msgId, content, { text: content, mediaType: "video", ...(videoUrl ? { mediaUrl: videoUrl } : {}), ...(localPath ? { mediaLocalPath: localPath } : {}) });
437
495
  }
438
496
  export async function sendGroupVideoMessage(accessToken, groupOpenid, videoUrl, videoBase64, msgId, content) {
439
497
  const uploadResult = await uploadGroupMedia(accessToken, groupOpenid, MediaFileType.VIDEO, videoUrl, videoBase64, false);
@@ -151,21 +151,21 @@ export const qqbotPlugin = {
151
151
  const id = target.replace(/^qqbot:/i, "");
152
152
  // 检查是否是已知格式
153
153
  if (id.startsWith("c2c:") || id.startsWith("group:") || id.startsWith("channel:")) {
154
- return { ok: true, to: `qqbot:${id}` };
154
+ return `qqbot:${id}`;
155
155
  }
156
156
  // 检查是否是纯 openid(32位十六进制,不带连字符)
157
157
  // QQ Bot OpenID 格式类似: 207A5B8339D01F6582911C014668B77B
158
158
  const openIdHexPattern = /^[0-9a-fA-F]{32}$/;
159
159
  if (openIdHexPattern.test(id)) {
160
- return { ok: true, to: `qqbot:c2c:${id}` };
160
+ return `qqbot:c2c:${id}`;
161
161
  }
162
162
  // 检查是否是 UUID 格式的 openid(带连字符)
163
163
  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}$/;
164
164
  if (openIdUuidPattern.test(id)) {
165
- return { ok: true, to: `qqbot:c2c:${id}` };
165
+ return `qqbot:c2c:${id}`;
166
166
  }
167
- // 不认识的格式
168
- return { ok: false, error: `unrecognized target format: ${target}` };
167
+ // 不认识的格式,返回 undefined 让核心使用原始值
168
+ return undefined;
169
169
  },
170
170
  /**
171
171
  * 目标解析器配置