@ryantest/openclaw-qqbot 1.6.6-alpha.4 → 1.6.7-beta.2

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 (53) hide show
  1. package/README.md +24 -15
  2. package/README.zh.md +24 -15
  3. package/dist/src/api.d.ts +32 -5
  4. package/dist/src/api.js +111 -12
  5. package/dist/src/channel.d.ts +18 -0
  6. package/dist/src/channel.js +85 -2
  7. package/dist/src/config.d.ts +33 -2
  8. package/dist/src/config.js +125 -1
  9. package/dist/src/gateway.js +566 -24
  10. package/dist/src/group-history.d.ts +136 -0
  11. package/dist/src/group-history.js +226 -0
  12. package/dist/src/message-gating.d.ts +53 -0
  13. package/dist/src/message-gating.js +107 -0
  14. package/dist/src/message-queue.d.ts +36 -0
  15. package/dist/src/message-queue.js +164 -22
  16. package/dist/src/outbound.d.ts +4 -4
  17. package/dist/src/outbound.js +18 -6
  18. package/dist/src/ref-index-store.js +5 -28
  19. package/dist/src/request-context.d.ts +7 -0
  20. package/dist/src/request-context.js +7 -0
  21. package/dist/src/slash-commands.d.ts +6 -0
  22. package/dist/src/slash-commands.js +3 -3
  23. package/dist/src/tools/remind.js +17 -9
  24. package/dist/src/types.d.ts +90 -2
  25. package/dist/src/utils/audio-convert.d.ts +1 -1
  26. package/dist/src/utils/audio-convert.js +1 -1
  27. package/dist/src/utils/chunked-upload.d.ts +11 -2
  28. package/dist/src/utils/chunked-upload.js +63 -11
  29. package/dist/src/utils/media-send.js +1 -1
  30. package/dist/src/utils/text-parsing.js +7 -18
  31. package/package.json +1 -1
  32. package/scripts/postinstall-link-sdk.js +22 -9
  33. package/scripts/upgrade-via-npm.sh +11 -3
  34. package/scripts/upgrade-via-source.sh +63 -15
  35. package/skills/qqbot-remind/SKILL.md +21 -11
  36. package/src/api.ts +135 -7
  37. package/src/channel.ts +85 -2
  38. package/src/config.ts +170 -3
  39. package/src/gateway.ts +662 -29
  40. package/src/group-history.ts +328 -0
  41. package/src/message-gating.ts +190 -0
  42. package/src/message-queue.ts +201 -21
  43. package/src/openclaw-plugin-sdk.d.ts +65 -0
  44. package/src/outbound.ts +18 -6
  45. package/src/ref-index-store.ts +5 -27
  46. package/src/request-context.ts +10 -0
  47. package/src/slash-commands.ts +3 -3
  48. package/src/tools/remind.ts +17 -9
  49. package/src/types.ts +94 -2
  50. package/src/utils/audio-convert.ts +1 -1
  51. package/src/utils/chunked-upload.ts +76 -12
  52. package/src/utils/media-send.ts +1 -2
  53. package/src/utils/text-parsing.ts +7 -14
package/src/types.ts CHANGED
@@ -26,6 +26,31 @@ export interface ResolvedQQBotAccount {
26
26
  config: QQBotAccountConfig;
27
27
  }
28
28
 
29
+ /** 群消息策略:open=全响应 | allowlist=白名单 | disabled=不响应 */
30
+ export type GroupPolicy = "open" | "allowlist" | "disabled";
31
+
32
+ /** 工具策略:full=全部 | restricted=限制敏感工具 | none=禁止 */
33
+ export type ToolPolicy = "full" | "restricted" | "none";
34
+
35
+ /** 单个群的配置 */
36
+ export interface GroupConfig {
37
+ /** 是否需要 @机器人才响应(默认 true) */
38
+ requireMention?: boolean;
39
+ /**
40
+ * 是否忽略 @了其他用户但没有 @机器人的消息(默认 false)。
41
+ * 开启后,消息中 @了其他人但未 @bot 时直接丢弃(不记录历史、不触发 AI)。
42
+ */
43
+ ignoreOtherMentions?: boolean;
44
+ /** 群聊中 AI 可使用的工具范围(默认 restricted) */
45
+ toolPolicy?: ToolPolicy;
46
+ /** 群名称 */
47
+ name?: string;
48
+ /** 群消息行为 PE(未配置时使用内置默认值) */
49
+ prompt?: string;
50
+ /** 群历史消息缓存条数(0 禁用,默认 20) */
51
+ historyLimit?: number;
52
+ }
53
+
29
54
  /**
30
55
  * QQ Bot 账户配置
31
56
  */
@@ -37,6 +62,12 @@ export interface QQBotAccountConfig {
37
62
  clientSecretFile?: string;
38
63
  dmPolicy?: "open" | "pairing" | "allowlist";
39
64
  allowFrom?: string[];
65
+ /** 群消息策略(默认 allowlist) */
66
+ groupPolicy?: GroupPolicy;
67
+ /** 群白名单(groupPolicy 为 allowlist 时生效) */
68
+ groupAllowFrom?: string[];
69
+ /** 群配置映射(按 groupOpenid 索引,"*" 为默认) */
70
+ groups?: Record<string, GroupConfig>;
40
71
  /** 系统提示词,会添加在用户消息前面 */
41
72
  systemPrompt?: string;
42
73
  /** 图床服务器公网地址,用于发送图片,例如 http://your-ip:18765 */
@@ -66,8 +97,8 @@ export interface QQBotAccountConfig {
66
97
  upgradeUrl?: string;
67
98
  /**
68
99
  * /bot-upgrade 指令的行为模式
69
- * - "doc":展示升级文档链接(默认,安全模式)
70
- * - "hot-reload":检测到新版本时直接执行 npm 升级脚本进行热更新
100
+ * - "doc":展示升级文档链接(安全模式)
101
+ * - "hot-reload":检测到新版本时直接执行 npm 升级脚本进行热更新(默认)
71
102
  */
72
103
  upgradeMode?: "doc" | "hot-reload";
73
104
  /**
@@ -199,6 +230,8 @@ export interface GroupMessageEvent {
199
230
  author: {
200
231
  id: string;
201
232
  member_openid: string;
233
+ username?: string;
234
+ bot?: boolean;
202
235
  };
203
236
  content: string;
204
237
  id: string;
@@ -210,6 +243,65 @@ export interface GroupMessageEvent {
210
243
  ext?: string[];
211
244
  };
212
245
  attachments?: MessageAttachment[];
246
+ /** @提及列表 */
247
+ mentions?: Array<{
248
+ scope?: "all" | "single";
249
+ id?: string;
250
+ user_openid?: string;
251
+ member_openid?: string;
252
+ nickname?: string;
253
+ bot?: boolean;
254
+ /** 是否 @机器人自身 */
255
+ is_you?: boolean;
256
+ }>;
257
+ }
258
+
259
+ /**
260
+ * 按钮交互事件(INTERACTION_CREATE)
261
+ */
262
+ export interface InteractionEvent {
263
+ /** 事件 ID,用于回应交互(PUT /interactions/{id}) */
264
+ id: string;
265
+ /** 事件类型:11=消息按钮 12=单聊快捷菜单 */
266
+ type: number;
267
+ /** 场景:c2c / group / guild */
268
+ scene?: string;
269
+ /** 场景类型:0=频道 1=群聊 2=单聊 */
270
+ chat_type?: number;
271
+ /** 触发时间 RFC3339 */
272
+ timestamp?: string;
273
+ /** 频道 openid(仅频道场景) */
274
+ guild_id?: string;
275
+ /** 子频道 openid(仅频道场景) */
276
+ channel_id?: string;
277
+ /** 单聊用户 openid(仅 c2c 场景) */
278
+ user_openid?: string;
279
+ /** 群 openid(仅群聊场景) */
280
+ group_openid?: string;
281
+ /** 群内触发用户 openid(仅群聊场景) */
282
+ group_member_openid?: string;
283
+ version: number;
284
+ data: {
285
+ type: number;
286
+ resolved: {
287
+ /** 按钮 action.data 值 */
288
+ button_data?: string;
289
+ /** 按钮 id */
290
+ button_id?: string;
291
+ /** 操作用户 userid(仅频道场景) */
292
+ user_id?: string;
293
+ /** 自定义菜单 id(仅菜单场景) */
294
+ feature_id?: string;
295
+ /** 操作的消息 id(仅频道场景) */
296
+ message_id?: string;
297
+ /** 配置更新:群消息模式 "mention"=@机器人时激活 "always"=总是激活 */
298
+ require_mention?: string;
299
+ /** 配置更新:群消息策略 */
300
+ group_policy?: GroupPolicy;
301
+ /** 配置更新:@文本的名称提及BOT名,多个使用,分隔 */
302
+ mention_patterns?: string;
303
+ };
304
+ };
213
305
  }
214
306
 
215
307
  /**
@@ -412,7 +412,7 @@ const QQ_NATIVE_UPLOAD_FORMATS = [".wav", ".mp3", ".silk"];
412
412
  * 将本地音频文件转换为 QQ Bot 可上传的 Base64
413
413
  *
414
414
  * QQ Bot API 支持直传 WAV、MP3、SILK 三种格式,其他格式仍需转换。
415
- * 转换策略(参考 NapCat/go-cqhttp/Discord/Telegram 的做法):
415
+ * 转换策略:
416
416
  *
417
417
  * 1. WAV / MP3 / SILK → 直传(跳过转换)
418
418
  * 2. 有 ffmpeg → ffmpeg 万能解码为 PCM → silk-wasm 编码
@@ -20,6 +20,8 @@ import {
20
20
  type UploadPrepareResponse,
21
21
  type UploadPrepareHashes,
22
22
  type MediaUploadResponse,
23
+ ApiError,
24
+ UPLOAD_PREPARE_FALLBACK_CODE,
23
25
  c2cUploadPrepare,
24
26
  c2cUploadPartFinish,
25
27
  c2cCompleteUpload,
@@ -30,8 +32,32 @@ import {
30
32
  } from "../api.js";
31
33
  import { formatFileSize } from "./file-utils.js";
32
34
 
33
- /** 分片上传并发控制:最多同时上传 N 个分片 */
34
- const MAX_CONCURRENT_PARTS = 1;
35
+ /**
36
+ * upload_prepare 返回特定错误码(40093002)时抛出:文件超过每日累积上传限制
37
+ * 调用方根据携带的文件信息构造兜底文案发送给用户
38
+ */
39
+ export class UploadDailyLimitExceededError extends Error {
40
+ /** 触发错误的本地文件路径 */
41
+ public readonly filePath: string;
42
+ /** 文件大小(字节) */
43
+ public readonly fileSize: number;
44
+
45
+ constructor(filePath: string, fileSize: number, originalMessage: string) {
46
+ super(originalMessage);
47
+ this.name = "UploadDailyLimitExceededError";
48
+ this.filePath = filePath;
49
+ this.fileSize = fileSize;
50
+ }
51
+ }
52
+
53
+ /** 分片上传默认并发数(服务端未返回 concurrency 时的兜底) */
54
+ const DEFAULT_CONCURRENT_PARTS = 1;
55
+
56
+ /** 分片上传并发上限(即使服务端返回更大的值也不超过此限制) */
57
+ const MAX_CONCURRENT_PARTS = 10;
58
+
59
+ /** partFinish 特定错误码重试超时上限(10 分钟),即使服务端 retry_timeout 更大也不超过此值 */
60
+ const MAX_PART_FINISH_RETRY_TIMEOUT_MS = 10 * 60 * 1000;
35
61
 
36
62
  /** 单个分片上传超时(毫秒)— 5 分钟,兼容低带宽场景 */
37
63
  const PART_UPLOAD_TIMEOUT = 300_000;
@@ -55,8 +81,6 @@ export interface ChunkedUploadProgress {
55
81
  export interface ChunkedUploadOptions {
56
82
  /** 进度回调 */
57
83
  onProgress?: (progress: ChunkedUploadProgress) => void;
58
- /** 最大并发数(默认 2) */
59
- maxConcurrent?: number;
60
84
  /** 日志前缀 */
61
85
  logPrefix?: string;
62
86
  }
@@ -81,7 +105,6 @@ export async function chunkedUploadC2C(
81
105
  options?: ChunkedUploadOptions,
82
106
  ): Promise<MediaUploadResponse> {
83
107
  const prefix = options?.logPrefix ?? "[chunked-upload]";
84
- const maxConcurrent = options?.maxConcurrent ?? MAX_CONCURRENT_PARTS;
85
108
 
86
109
  // 1. 读取文件信息
87
110
  const stat = await fs.promises.stat(filePath);
@@ -98,13 +121,34 @@ export async function chunkedUploadC2C(
98
121
  // 3. 申请上传 → 获取 upload_id + block_size + 预签名链接
99
122
  const accessToken = await getAccessToken(appId, clientSecret);
100
123
  console.log(`${prefix} >>> Calling c2cUploadPrepare(fileType=${fileType}, fileName=${fileName}, fileSize=${fileSize}, md5=${hashes.md5}, sha1=${hashes.sha1}, md5_10m=${hashes.md5_10m})`);
101
- const prepareResp = await c2cUploadPrepare(accessToken, userId, fileType, fileName, fileSize, hashes);
124
+ let prepareResp: UploadPrepareResponse;
125
+ try {
126
+ prepareResp = await c2cUploadPrepare(accessToken, userId, fileType, fileName, fileSize, hashes);
127
+ } catch (err) {
128
+ // 命中特定错误码 → 携带文件信息抛出,由上层构造兜底文案
129
+ if (err instanceof ApiError && err.bizCode === UPLOAD_PREPARE_FALLBACK_CODE) {
130
+ console.warn(`${prefix} c2cUploadPrepare hit fallback code ${UPLOAD_PREPARE_FALLBACK_CODE}`);
131
+ throw new UploadDailyLimitExceededError(filePath, fileSize, err.message);
132
+ }
133
+ throw err;
134
+ }
102
135
  console.log(`${prefix} <<< c2cUploadPrepare response:`, JSON.stringify(prepareResp));
103
136
  const { upload_id, parts } = prepareResp;
104
137
  // QQ 开放平台返回的 block_size 可能是字符串,需要转为数字
105
138
  const block_size = Number(prepareResp.block_size);
106
139
 
107
- console.log(`${prefix} Upload prepared: upload_id=${upload_id}, block_size=${formatFileSize(block_size)}, parts=${parts.length}`);
140
+ // 并发数:使用 API 返回的 concurrency,未返回则用默认值,且不超过上限
141
+ const maxConcurrent = Math.min(
142
+ prepareResp.concurrency ? Number(prepareResp.concurrency) : DEFAULT_CONCURRENT_PARTS,
143
+ MAX_CONCURRENT_PARTS,
144
+ );
145
+
146
+ // partFinish 特定错误码的重试超时:使用 API 返回的 retry_timeout(秒),上限 10 分钟
147
+ const retryTimeoutMs = prepareResp.retry_timeout
148
+ ? Math.min(Number(prepareResp.retry_timeout) * 1000, MAX_PART_FINISH_RETRY_TIMEOUT_MS)
149
+ : undefined;
150
+
151
+ console.log(`${prefix} Upload prepared: upload_id=${upload_id}, block_size=${formatFileSize(block_size)}, parts=${parts.length}, concurrency=${maxConcurrent}, retryTimeout=${retryTimeoutMs ? retryTimeoutMs / 1000 + 's' : 'default'}`);
108
152
 
109
153
  // 4. 并行上传所有分片(带并发控制)
110
154
  let completedParts = 0;
@@ -131,7 +175,7 @@ export async function chunkedUploadC2C(
131
175
  // b. 通知开放平台分片上传完成(需要重新获取 token,避免长时间上传后 token 过期)
132
176
  const token = await getAccessToken(appId, clientSecret);
133
177
  console.log(`${prefix} >>> Calling c2cUploadPartFinish(upload_id=${upload_id}, partIndex=${partIndex}, blockSize=${length}, md5=${md5Hex})`);
134
- await c2cUploadPartFinish(token, userId, upload_id, partIndex, length, md5Hex);
178
+ await c2cUploadPartFinish(token, userId, upload_id, partIndex, length, md5Hex, retryTimeoutMs);
135
179
  console.log(`${prefix} <<< c2cUploadPartFinish(partIndex=${partIndex}) done`);
136
180
 
137
181
  // 更新进度
@@ -188,7 +232,6 @@ export async function chunkedUploadGroup(
188
232
  options?: ChunkedUploadOptions,
189
233
  ): Promise<MediaUploadResponse> {
190
234
  const prefix = options?.logPrefix ?? "[chunked-upload]";
191
- const maxConcurrent = options?.maxConcurrent ?? MAX_CONCURRENT_PARTS;
192
235
 
193
236
  // 1. 读取文件信息
194
237
  const stat = await fs.promises.stat(filePath);
@@ -205,13 +248,34 @@ export async function chunkedUploadGroup(
205
248
  // 3. 申请上传
206
249
  const accessToken = await getAccessToken(appId, clientSecret);
207
250
  console.log(`${prefix} >>> Calling groupUploadPrepare(fileType=${fileType}, fileName=${fileName}, fileSize=${fileSize}, md5=${hashes.md5}, sha1=${hashes.sha1}, md5_10m=${hashes.md5_10m})`);
208
- const prepareResp = await groupUploadPrepare(accessToken, groupId, fileType, fileName, fileSize, hashes);
251
+ let prepareResp: UploadPrepareResponse;
252
+ try {
253
+ prepareResp = await groupUploadPrepare(accessToken, groupId, fileType, fileName, fileSize, hashes);
254
+ } catch (err) {
255
+ // 命中特定错误码 → 携带文件信息抛出,由上层构造兜底文案
256
+ if (err instanceof ApiError && err.bizCode === UPLOAD_PREPARE_FALLBACK_CODE) {
257
+ console.warn(`${prefix} groupUploadPrepare hit fallback code ${UPLOAD_PREPARE_FALLBACK_CODE}`);
258
+ throw new UploadDailyLimitExceededError(filePath, fileSize, err.message);
259
+ }
260
+ throw err;
261
+ }
209
262
  console.log(`${prefix} <<< groupUploadPrepare response:`, JSON.stringify(prepareResp));
210
263
  const { upload_id, parts } = prepareResp;
211
264
  // QQ 开放平台返回的 block_size 可能是字符串,需要转为数字
212
265
  const block_size = Number(prepareResp.block_size);
213
266
 
214
- console.log(`${prefix} Upload prepared: upload_id=${upload_id}, block_size=${formatFileSize(block_size)}, parts=${parts.length}`);
267
+ // 并发数:使用 API 返回的 concurrency,未返回则用默认值,且不超过上限
268
+ const maxConcurrent = Math.min(
269
+ prepareResp.concurrency ? Number(prepareResp.concurrency) : DEFAULT_CONCURRENT_PARTS,
270
+ MAX_CONCURRENT_PARTS,
271
+ );
272
+
273
+ // partFinish 特定错误码的重试超时:使用 API 返回的 retry_timeout(秒),上限 10 分钟
274
+ const retryTimeoutMs = prepareResp.retry_timeout
275
+ ? Math.min(Number(prepareResp.retry_timeout) * 1000, MAX_PART_FINISH_RETRY_TIMEOUT_MS)
276
+ : undefined;
277
+
278
+ console.log(`${prefix} Upload prepared: upload_id=${upload_id}, block_size=${formatFileSize(block_size)}, parts=${parts.length}, concurrency=${maxConcurrent}, retryTimeout=${retryTimeoutMs ? retryTimeoutMs / 1000 + 's' : 'default'}`);
215
279
 
216
280
  // 4. 并行上传所有分片(带并发控制)
217
281
  let completedParts = 0;
@@ -232,7 +296,7 @@ export async function chunkedUploadGroup(
232
296
 
233
297
  const token = await getAccessToken(appId, clientSecret);
234
298
  console.log(`${prefix} >>> Calling groupUploadPartFinish(upload_id=${upload_id}, partIndex=${partIndex}, blockSize=${length}, md5=${md5Hex})`);
235
- await groupUploadPartFinish(token, groupId, upload_id, partIndex, length, md5Hex);
299
+ await groupUploadPartFinish(token, groupId, upload_id, partIndex, length, md5Hex, retryTimeoutMs);
236
300
  console.log(`${prefix} <<< groupUploadPartFinish(partIndex=${partIndex}) done`);
237
301
 
238
302
  completedParts++;
@@ -364,6 +364,7 @@ export async function executeSendQueue(
364
364
  };
365
365
 
366
366
  for (const item of queue) {
367
+ const FALLBACK_MSG = "发送失败,请稍后重试。";
367
368
  try {
368
369
  if (item.type === "text") {
369
370
  if (options.skipInterTagText) {
@@ -380,8 +381,6 @@ export async function executeSendQueue(
380
381
 
381
382
  log?.info(`${prefix} executeSendQueue: sending ${item.type}: ${item.content.slice(0, 80)}...`);
382
383
 
383
- const FALLBACK_MSG = "发送失败,请稍后重试。";
384
-
385
384
  if (item.type === "image") {
386
385
  const result = await sendPhoto(mediaTarget, item.content);
387
386
  if (result.error) {
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import type { RefAttachmentSummary } from "../ref-index-store.js";
6
+ import { inferAttachmentType } from "../group-history.js";
6
7
 
7
8
  /**
8
9
  * 解析 QQ 表情标签,将 <faceType=1,faceId="13",ext="base64..."> 格式
@@ -65,18 +66,10 @@ export function buildAttachmentSummaries(
65
66
  localPaths?: Array<string | null>,
66
67
  ): RefAttachmentSummary[] | undefined {
67
68
  if (!attachments || attachments.length === 0) return undefined;
68
- return attachments.map((att, idx) => {
69
- const ct = att.content_type?.toLowerCase() ?? "";
70
- let type: RefAttachmentSummary["type"] = "unknown";
71
- if (ct.startsWith("image/")) type = "image";
72
- else if (ct === "voice" || ct.startsWith("audio/") || ct.includes("silk") || ct.includes("amr")) type = "voice";
73
- else if (ct.startsWith("video/")) type = "video";
74
- else if (ct.startsWith("application/") || ct.startsWith("text/")) type = "file";
75
- return {
76
- type,
77
- filename: att.filename,
78
- contentType: att.content_type,
79
- localPath: localPaths?.[idx] ?? undefined,
80
- };
81
- });
69
+ return attachments.map((att, idx) => ({
70
+ type: inferAttachmentType(att.content_type),
71
+ filename: att.filename,
72
+ contentType: att.content_type,
73
+ localPath: localPaths?.[idx] ?? undefined,
74
+ }));
82
75
  }