@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
@@ -24,6 +24,28 @@ export interface ResolvedQQBotAccount {
24
24
  markdownSupport: boolean;
25
25
  config: QQBotAccountConfig;
26
26
  }
27
+ /** 群消息策略:open=全响应 | allowlist=白名单 | disabled=不响应 */
28
+ export type GroupPolicy = "open" | "allowlist" | "disabled";
29
+ /** 工具策略:full=全部 | restricted=限制敏感工具 | none=禁止 */
30
+ export type ToolPolicy = "full" | "restricted" | "none";
31
+ /** 单个群的配置 */
32
+ export interface GroupConfig {
33
+ /** 是否需要 @机器人才响应(默认 true) */
34
+ requireMention?: boolean;
35
+ /**
36
+ * 是否忽略 @了其他用户但没有 @机器人的消息(默认 false)。
37
+ * 开启后,消息中 @了其他人但未 @bot 时直接丢弃(不记录历史、不触发 AI)。
38
+ */
39
+ ignoreOtherMentions?: boolean;
40
+ /** 群聊中 AI 可使用的工具范围(默认 restricted) */
41
+ toolPolicy?: ToolPolicy;
42
+ /** 群名称 */
43
+ name?: string;
44
+ /** 群消息行为 PE(未配置时使用内置默认值) */
45
+ prompt?: string;
46
+ /** 群历史消息缓存条数(0 禁用,默认 20) */
47
+ historyLimit?: number;
48
+ }
27
49
  /**
28
50
  * QQ Bot 账户配置
29
51
  */
@@ -35,6 +57,12 @@ export interface QQBotAccountConfig {
35
57
  clientSecretFile?: string;
36
58
  dmPolicy?: "open" | "pairing" | "allowlist";
37
59
  allowFrom?: string[];
60
+ /** 群消息策略(默认 allowlist) */
61
+ groupPolicy?: GroupPolicy;
62
+ /** 群白名单(groupPolicy 为 allowlist 时生效) */
63
+ groupAllowFrom?: string[];
64
+ /** 群配置映射(按 groupOpenid 索引,"*" 为默认) */
65
+ groups?: Record<string, GroupConfig>;
38
66
  /** 系统提示词,会添加在用户消息前面 */
39
67
  systemPrompt?: string;
40
68
  /** 图床服务器公网地址,用于发送图片,例如 http://your-ip:18765 */
@@ -64,8 +92,8 @@ export interface QQBotAccountConfig {
64
92
  upgradeUrl?: string;
65
93
  /**
66
94
  * /bot-upgrade 指令的行为模式
67
- * - "doc":展示升级文档链接(默认,安全模式)
68
- * - "hot-reload":检测到新版本时直接执行 npm 升级脚本进行热更新
95
+ * - "doc":展示升级文档链接(安全模式)
96
+ * - "hot-reload":检测到新版本时直接执行 npm 升级脚本进行热更新(默认)
69
97
  */
70
98
  upgradeMode?: "doc" | "hot-reload";
71
99
  /**
@@ -191,6 +219,8 @@ export interface GroupMessageEvent {
191
219
  author: {
192
220
  id: string;
193
221
  member_openid: string;
222
+ username?: string;
223
+ bot?: boolean;
194
224
  };
195
225
  content: string;
196
226
  id: string;
@@ -202,6 +232,64 @@ export interface GroupMessageEvent {
202
232
  ext?: string[];
203
233
  };
204
234
  attachments?: MessageAttachment[];
235
+ /** @提及列表 */
236
+ mentions?: Array<{
237
+ scope?: "all" | "single";
238
+ id?: string;
239
+ user_openid?: string;
240
+ member_openid?: string;
241
+ nickname?: string;
242
+ bot?: boolean;
243
+ /** 是否 @机器人自身 */
244
+ is_you?: boolean;
245
+ }>;
246
+ }
247
+ /**
248
+ * 按钮交互事件(INTERACTION_CREATE)
249
+ */
250
+ export interface InteractionEvent {
251
+ /** 事件 ID,用于回应交互(PUT /interactions/{id}) */
252
+ id: string;
253
+ /** 事件类型:11=消息按钮 12=单聊快捷菜单 */
254
+ type: number;
255
+ /** 场景:c2c / group / guild */
256
+ scene?: string;
257
+ /** 场景类型:0=频道 1=群聊 2=单聊 */
258
+ chat_type?: number;
259
+ /** 触发时间 RFC3339 */
260
+ timestamp?: string;
261
+ /** 频道 openid(仅频道场景) */
262
+ guild_id?: string;
263
+ /** 子频道 openid(仅频道场景) */
264
+ channel_id?: string;
265
+ /** 单聊用户 openid(仅 c2c 场景) */
266
+ user_openid?: string;
267
+ /** 群 openid(仅群聊场景) */
268
+ group_openid?: string;
269
+ /** 群内触发用户 openid(仅群聊场景) */
270
+ group_member_openid?: string;
271
+ version: number;
272
+ data: {
273
+ type: number;
274
+ resolved: {
275
+ /** 按钮 action.data 值 */
276
+ button_data?: string;
277
+ /** 按钮 id */
278
+ button_id?: string;
279
+ /** 操作用户 userid(仅频道场景) */
280
+ user_id?: string;
281
+ /** 自定义菜单 id(仅菜单场景) */
282
+ feature_id?: string;
283
+ /** 操作的消息 id(仅频道场景) */
284
+ message_id?: string;
285
+ /** 配置更新:群消息模式 "mention"=@机器人时激活 "always"=总是激活 */
286
+ require_mention?: string;
287
+ /** 配置更新:群消息策略 */
288
+ group_policy?: GroupPolicy;
289
+ /** 配置更新:@文本的名称提及BOT名,多个使用,分隔 */
290
+ mention_patterns?: string;
291
+ };
292
+ };
205
293
  }
206
294
  /**
207
295
  * WebSocket 事件负载
@@ -62,7 +62,7 @@ export declare function textToSilk(text: string, ttsCfg: TTSConfig, outputDir: s
62
62
  * 将本地音频文件转换为 QQ Bot 可上传的 Base64
63
63
  *
64
64
  * QQ Bot API 支持直传 WAV、MP3、SILK 三种格式,其他格式仍需转换。
65
- * 转换策略(参考 NapCat/go-cqhttp/Discord/Telegram 的做法):
65
+ * 转换策略:
66
66
  *
67
67
  * 1. WAV / MP3 / SILK → 直传(跳过转换)
68
68
  * 2. 有 ffmpeg → ffmpeg 万能解码为 PCM → silk-wasm 编码
@@ -340,7 +340,7 @@ const QQ_NATIVE_UPLOAD_FORMATS = [".wav", ".mp3", ".silk"];
340
340
  * 将本地音频文件转换为 QQ Bot 可上传的 Base64
341
341
  *
342
342
  * QQ Bot API 支持直传 WAV、MP3、SILK 三种格式,其他格式仍需转换。
343
- * 转换策略(参考 NapCat/go-cqhttp/Discord/Telegram 的做法):
343
+ * 转换策略:
344
344
  *
345
345
  * 1. WAV / MP3 / SILK → 直传(跳过转换)
346
346
  * 2. 有 ffmpeg → ffmpeg 万能解码为 PCM → silk-wasm 编码
@@ -13,6 +13,17 @@
13
13
  * 注意:N 个分片之间是并行的,但每个分片的"上传 + 完成"是串行的。
14
14
  */
15
15
  import { type MediaFileType, type MediaUploadResponse } from "../api.js";
16
+ /**
17
+ * upload_prepare 返回特定错误码(40093002)时抛出:文件超过每日累积上传限制
18
+ * 调用方根据携带的文件信息构造兜底文案发送给用户
19
+ */
20
+ export declare class UploadDailyLimitExceededError extends Error {
21
+ /** 触发错误的本地文件路径 */
22
+ readonly filePath: string;
23
+ /** 文件大小(字节) */
24
+ readonly fileSize: number;
25
+ constructor(filePath: string, fileSize: number, originalMessage: string);
26
+ }
16
27
  /** 分片上传进度回调 */
17
28
  export interface ChunkedUploadProgress {
18
29
  /** 当前已完成分片数 */
@@ -28,8 +39,6 @@ export interface ChunkedUploadProgress {
28
39
  export interface ChunkedUploadOptions {
29
40
  /** 进度回调 */
30
41
  onProgress?: (progress: ChunkedUploadProgress) => void;
31
- /** 最大并发数(默认 2) */
32
- maxConcurrent?: number;
33
42
  /** 日志前缀 */
34
43
  logPrefix?: string;
35
44
  }
@@ -14,10 +14,30 @@
14
14
  */
15
15
  import * as crypto from "node:crypto";
16
16
  import * as fs from "node:fs";
17
- import { c2cUploadPrepare, c2cUploadPartFinish, c2cCompleteUpload, groupUploadPrepare, groupUploadPartFinish, groupCompleteUpload, getAccessToken, } from "../api.js";
17
+ import { ApiError, UPLOAD_PREPARE_FALLBACK_CODE, c2cUploadPrepare, c2cUploadPartFinish, c2cCompleteUpload, groupUploadPrepare, groupUploadPartFinish, groupCompleteUpload, getAccessToken, } from "../api.js";
18
18
  import { formatFileSize } from "./file-utils.js";
19
- /** 分片上传并发控制:最多同时上传 N 个分片 */
20
- const MAX_CONCURRENT_PARTS = 1;
19
+ /**
20
+ * upload_prepare 返回特定错误码(40093002)时抛出:文件超过每日累积上传限制
21
+ * 调用方根据携带的文件信息构造兜底文案发送给用户
22
+ */
23
+ export class UploadDailyLimitExceededError extends Error {
24
+ /** 触发错误的本地文件路径 */
25
+ filePath;
26
+ /** 文件大小(字节) */
27
+ fileSize;
28
+ constructor(filePath, fileSize, originalMessage) {
29
+ super(originalMessage);
30
+ this.name = "UploadDailyLimitExceededError";
31
+ this.filePath = filePath;
32
+ this.fileSize = fileSize;
33
+ }
34
+ }
35
+ /** 分片上传默认并发数(服务端未返回 concurrency 时的兜底) */
36
+ const DEFAULT_CONCURRENT_PARTS = 1;
37
+ /** 分片上传并发上限(即使服务端返回更大的值也不超过此限制) */
38
+ const MAX_CONCURRENT_PARTS = 10;
39
+ /** partFinish 特定错误码重试超时上限(10 分钟),即使服务端 retry_timeout 更大也不超过此值 */
40
+ const MAX_PART_FINISH_RETRY_TIMEOUT_MS = 10 * 60 * 1000;
21
41
  /** 单个分片上传超时(毫秒)— 5 分钟,兼容低带宽场景 */
22
42
  const PART_UPLOAD_TIMEOUT = 300_000;
23
43
  /** 单个分片上传最大重试次数 */
@@ -35,7 +55,6 @@ const PART_UPLOAD_MAX_RETRIES = 2;
35
55
  */
36
56
  export async function chunkedUploadC2C(appId, clientSecret, userId, filePath, fileType, options) {
37
57
  const prefix = options?.logPrefix ?? "[chunked-upload]";
38
- const maxConcurrent = options?.maxConcurrent ?? MAX_CONCURRENT_PARTS;
39
58
  // 1. 读取文件信息
40
59
  const stat = await fs.promises.stat(filePath);
41
60
  const fileSize = stat.size;
@@ -48,12 +67,29 @@ export async function chunkedUploadC2C(appId, clientSecret, userId, filePath, fi
48
67
  // 3. 申请上传 → 获取 upload_id + block_size + 预签名链接
49
68
  const accessToken = await getAccessToken(appId, clientSecret);
50
69
  console.log(`${prefix} >>> Calling c2cUploadPrepare(fileType=${fileType}, fileName=${fileName}, fileSize=${fileSize}, md5=${hashes.md5}, sha1=${hashes.sha1}, md5_10m=${hashes.md5_10m})`);
51
- const prepareResp = await c2cUploadPrepare(accessToken, userId, fileType, fileName, fileSize, hashes);
70
+ let prepareResp;
71
+ try {
72
+ prepareResp = await c2cUploadPrepare(accessToken, userId, fileType, fileName, fileSize, hashes);
73
+ }
74
+ catch (err) {
75
+ // 命中特定错误码 → 携带文件信息抛出,由上层构造兜底文案
76
+ if (err instanceof ApiError && err.bizCode === UPLOAD_PREPARE_FALLBACK_CODE) {
77
+ console.warn(`${prefix} c2cUploadPrepare hit fallback code ${UPLOAD_PREPARE_FALLBACK_CODE}`);
78
+ throw new UploadDailyLimitExceededError(filePath, fileSize, err.message);
79
+ }
80
+ throw err;
81
+ }
52
82
  console.log(`${prefix} <<< c2cUploadPrepare response:`, JSON.stringify(prepareResp));
53
83
  const { upload_id, parts } = prepareResp;
54
84
  // QQ 开放平台返回的 block_size 可能是字符串,需要转为数字
55
85
  const block_size = Number(prepareResp.block_size);
56
- console.log(`${prefix} Upload prepared: upload_id=${upload_id}, block_size=${formatFileSize(block_size)}, parts=${parts.length}`);
86
+ // 并发数:使用 API 返回的 concurrency,未返回则用默认值,且不超过上限
87
+ const maxConcurrent = Math.min(prepareResp.concurrency ? Number(prepareResp.concurrency) : DEFAULT_CONCURRENT_PARTS, MAX_CONCURRENT_PARTS);
88
+ // partFinish 特定错误码的重试超时:使用 API 返回的 retry_timeout(秒),上限 10 分钟
89
+ const retryTimeoutMs = prepareResp.retry_timeout
90
+ ? Math.min(Number(prepareResp.retry_timeout) * 1000, MAX_PART_FINISH_RETRY_TIMEOUT_MS)
91
+ : undefined;
92
+ 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'}`);
57
93
  // 4. 并行上传所有分片(带并发控制)
58
94
  let completedParts = 0;
59
95
  let uploadedBytes = 0;
@@ -73,7 +109,7 @@ export async function chunkedUploadC2C(appId, clientSecret, userId, filePath, fi
73
109
  // b. 通知开放平台分片上传完成(需要重新获取 token,避免长时间上传后 token 过期)
74
110
  const token = await getAccessToken(appId, clientSecret);
75
111
  console.log(`${prefix} >>> Calling c2cUploadPartFinish(upload_id=${upload_id}, partIndex=${partIndex}, blockSize=${length}, md5=${md5Hex})`);
76
- await c2cUploadPartFinish(token, userId, upload_id, partIndex, length, md5Hex);
112
+ await c2cUploadPartFinish(token, userId, upload_id, partIndex, length, md5Hex, retryTimeoutMs);
77
113
  console.log(`${prefix} <<< c2cUploadPartFinish(partIndex=${partIndex}) done`);
78
114
  // 更新进度
79
115
  completedParts++;
@@ -112,7 +148,6 @@ export async function chunkedUploadC2C(appId, clientSecret, userId, filePath, fi
112
148
  */
113
149
  export async function chunkedUploadGroup(appId, clientSecret, groupId, filePath, fileType, options) {
114
150
  const prefix = options?.logPrefix ?? "[chunked-upload]";
115
- const maxConcurrent = options?.maxConcurrent ?? MAX_CONCURRENT_PARTS;
116
151
  // 1. 读取文件信息
117
152
  const stat = await fs.promises.stat(filePath);
118
153
  const fileSize = stat.size;
@@ -125,12 +160,29 @@ export async function chunkedUploadGroup(appId, clientSecret, groupId, filePath,
125
160
  // 3. 申请上传
126
161
  const accessToken = await getAccessToken(appId, clientSecret);
127
162
  console.log(`${prefix} >>> Calling groupUploadPrepare(fileType=${fileType}, fileName=${fileName}, fileSize=${fileSize}, md5=${hashes.md5}, sha1=${hashes.sha1}, md5_10m=${hashes.md5_10m})`);
128
- const prepareResp = await groupUploadPrepare(accessToken, groupId, fileType, fileName, fileSize, hashes);
163
+ let prepareResp;
164
+ try {
165
+ prepareResp = await groupUploadPrepare(accessToken, groupId, fileType, fileName, fileSize, hashes);
166
+ }
167
+ catch (err) {
168
+ // 命中特定错误码 → 携带文件信息抛出,由上层构造兜底文案
169
+ if (err instanceof ApiError && err.bizCode === UPLOAD_PREPARE_FALLBACK_CODE) {
170
+ console.warn(`${prefix} groupUploadPrepare hit fallback code ${UPLOAD_PREPARE_FALLBACK_CODE}`);
171
+ throw new UploadDailyLimitExceededError(filePath, fileSize, err.message);
172
+ }
173
+ throw err;
174
+ }
129
175
  console.log(`${prefix} <<< groupUploadPrepare response:`, JSON.stringify(prepareResp));
130
176
  const { upload_id, parts } = prepareResp;
131
177
  // QQ 开放平台返回的 block_size 可能是字符串,需要转为数字
132
178
  const block_size = Number(prepareResp.block_size);
133
- console.log(`${prefix} Upload prepared: upload_id=${upload_id}, block_size=${formatFileSize(block_size)}, parts=${parts.length}`);
179
+ // 并发数:使用 API 返回的 concurrency,未返回则用默认值,且不超过上限
180
+ const maxConcurrent = Math.min(prepareResp.concurrency ? Number(prepareResp.concurrency) : DEFAULT_CONCURRENT_PARTS, MAX_CONCURRENT_PARTS);
181
+ // partFinish 特定错误码的重试超时:使用 API 返回的 retry_timeout(秒),上限 10 分钟
182
+ const retryTimeoutMs = prepareResp.retry_timeout
183
+ ? Math.min(Number(prepareResp.retry_timeout) * 1000, MAX_PART_FINISH_RETRY_TIMEOUT_MS)
184
+ : undefined;
185
+ 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'}`);
134
186
  // 4. 并行上传所有分片(带并发控制)
135
187
  let completedParts = 0;
136
188
  let uploadedBytes = 0;
@@ -145,7 +197,7 @@ export async function chunkedUploadGroup(appId, clientSecret, groupId, filePath,
145
197
  await putToPresignedUrl(part.presigned_url, partBuffer, prefix, partNum, parts.length);
146
198
  const token = await getAccessToken(appId, clientSecret);
147
199
  console.log(`${prefix} >>> Calling groupUploadPartFinish(upload_id=${upload_id}, partIndex=${partIndex}, blockSize=${length}, md5=${md5Hex})`);
148
- await groupUploadPartFinish(token, groupId, upload_id, partIndex, length, md5Hex);
200
+ await groupUploadPartFinish(token, groupId, upload_id, partIndex, length, md5Hex, retryTimeoutMs);
149
201
  console.log(`${prefix} <<< groupUploadPartFinish(partIndex=${partIndex}) done`);
150
202
  completedParts++;
151
203
  uploadedBytes += length;
@@ -240,6 +240,7 @@ export async function executeSendQueue(queue, ctx, options = {}) {
240
240
  }
241
241
  };
242
242
  for (const item of queue) {
243
+ const FALLBACK_MSG = "发送失败,请稍后重试。";
243
244
  try {
244
245
  if (item.type === "text") {
245
246
  if (options.skipInterTagText) {
@@ -255,7 +256,6 @@ export async function executeSendQueue(queue, ctx, options = {}) {
255
256
  continue;
256
257
  }
257
258
  log?.info(`${prefix} executeSendQueue: sending ${item.type}: ${item.content.slice(0, 80)}...`);
258
- const FALLBACK_MSG = "发送失败,请稍后重试。";
259
259
  if (item.type === "image") {
260
260
  const result = await sendPhoto(mediaTarget, item.content);
261
261
  if (result.error) {
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * QQ Bot 文本解析工具函数
3
3
  */
4
+ import { inferAttachmentType } from "../group-history.js";
4
5
  /**
5
6
  * 解析 QQ 表情标签,将 <faceType=1,faceId="13",ext="base64..."> 格式
6
7
  * 替换为 【表情: 中文名】 格式
@@ -59,22 +60,10 @@ export function parseRefIndices(ext) {
59
60
  export function buildAttachmentSummaries(attachments, localPaths) {
60
61
  if (!attachments || attachments.length === 0)
61
62
  return undefined;
62
- return attachments.map((att, idx) => {
63
- const ct = att.content_type?.toLowerCase() ?? "";
64
- let type = "unknown";
65
- if (ct.startsWith("image/"))
66
- type = "image";
67
- else if (ct === "voice" || ct.startsWith("audio/") || ct.includes("silk") || ct.includes("amr"))
68
- type = "voice";
69
- else if (ct.startsWith("video/"))
70
- type = "video";
71
- else if (ct.startsWith("application/") || ct.startsWith("text/"))
72
- type = "file";
73
- return {
74
- type,
75
- filename: att.filename,
76
- contentType: att.content_type,
77
- localPath: localPaths?.[idx] ?? undefined,
78
- };
79
- });
63
+ return attachments.map((att, idx) => ({
64
+ type: inferAttachmentType(att.content_type),
65
+ filename: att.filename,
66
+ contentType: att.content_type,
67
+ localPath: localPaths?.[idx] ?? undefined,
68
+ }));
80
69
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ryantest/openclaw-qqbot",
3
- "version": "1.6.6-alpha.4",
3
+ "version": "1.6.7-beta.2",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -10,7 +10,7 @@
10
10
  // globally installed openclaw package, allowing Node's native ESM resolver
11
11
  // (used by jiti with tryNative:true for .js files) to find `openclaw/plugin-sdk`.
12
12
 
13
- import { existsSync, symlinkSync, mkdirSync, realpathSync } from "node:fs";
13
+ import { existsSync, lstatSync, symlinkSync, unlinkSync, rmSync, mkdirSync, realpathSync } from "node:fs";
14
14
  import { dirname, join, resolve } from "node:path";
15
15
  import { fileURLToPath } from "node:url";
16
16
  import { execSync } from "node:child_process";
@@ -18,17 +18,30 @@ import { execSync } from "node:child_process";
18
18
  const __dirname = dirname(fileURLToPath(import.meta.url));
19
19
  const pluginRoot = resolve(__dirname, "..");
20
20
 
21
- // Only run when installed under an openclaw-like extensions directory
22
- // (supports openclaw, clawdbot, moltbot, etc.)
23
- if (!pluginRoot.includes("extensions")) {
24
- process.exit(0);
25
- }
26
-
27
21
  const linkTarget = join(pluginRoot, "node_modules", "openclaw");
28
22
 
29
- // Already linked or exists
23
+ // Check if already a valid symlink pointing to a directory with plugin-sdk/core
30
24
  if (existsSync(linkTarget)) {
31
- process.exit(0);
25
+ try {
26
+ const stat = lstatSync(linkTarget);
27
+ if (stat.isSymbolicLink()) {
28
+ // Symlink exists — verify it has plugin-sdk/core
29
+ if (existsSync(join(linkTarget, "plugin-sdk", "core.js"))) {
30
+ process.exit(0);
31
+ }
32
+ // Symlink is stale or points to wrong target, remove and re-create
33
+ unlinkSync(linkTarget);
34
+ } else if (existsSync(join(linkTarget, "plugin-sdk", "core.js"))) {
35
+ // Real directory with correct structure (e.g. npm installed a good version)
36
+ process.exit(0);
37
+ } else {
38
+ // Real directory from npm install but missing plugin-sdk/core — replace with symlink
39
+ rmSync(linkTarget, { recursive: true, force: true });
40
+ }
41
+ } catch {
42
+ // If stat fails, try to remove and re-create
43
+ try { rmSync(linkTarget, { recursive: true, force: true }); } catch {}
44
+ }
32
45
  }
33
46
 
34
47
  // CLI names to try (openclaw and its aliases)
@@ -18,14 +18,21 @@
18
18
 
19
19
  set -eo pipefail
20
20
 
21
- # 异常退出时清理临时配置文件(防止泄露或残留)
21
+ # 异常退出时清理临时文件(防止泄露或残留)
22
22
  cleanup_on_exit() {
23
23
  if [ -n "$TEMP_CONFIG_FILE" ] && [ -f "$TEMP_CONFIG_FILE" ]; then
24
24
  rm -f "$TEMP_CONFIG_FILE" 2>/dev/null || true
25
25
  fi
26
+ # 异常退出时清理本次备份目录(正常流程中备份已被删除或回滚,这里是兜底)
27
+ if [ -n "$BACKUP_DIR" ] && [ -d "$BACKUP_DIR" ]; then
28
+ rm -rf "$BACKUP_DIR" 2>/dev/null || true
29
+ fi
26
30
  }
27
31
  trap cleanup_on_exit EXIT
28
32
 
33
+ # 清理上次升级可能遗留的备份目录(如上次脚本被 kill 等极端情况)
34
+ find "${TMPDIR:-/tmp}" -maxdepth 1 -name ".qqbot-upgrade-backup-*" -exec rm -rf {} + 2>/dev/null || true
35
+
29
36
  PKG_NAME="@tencent-connect/openclaw-qqbot"
30
37
  PLUGIN_ID="openclaw-qqbot"
31
38
  INSTALL_SRC=""
@@ -291,7 +298,7 @@ if [ "$UPGRADE_OK" != "true" ]; then
291
298
  # 备份旧目录(而非直接删除),install 失败时可回滚
292
299
  BACKUP_DIR=""
293
300
  if [ -d "$EXTENSIONS_DIR/$PLUGIN_ID" ]; then
294
- BACKUP_DIR="$EXTENSIONS_DIR/.openclaw-qqbot-backup-$$"
301
+ BACKUP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/.qqbot-upgrade-backup-XXXXXX")"
295
302
  mv "$EXTENSIONS_DIR/$PLUGIN_ID" "$BACKUP_DIR"
296
303
  echo " 已备份旧目录: $BACKUP_DIR"
297
304
  fi
@@ -311,8 +318,9 @@ if [ "$UPGRADE_OK" != "true" ]; then
311
318
  rm -rf "$BACKUP_DIR"
312
319
  echo " 已清理旧版备份"
313
320
  fi
314
- # 清理 openclaw CLI install 可能留下的额外 backup 目录
321
+ # 清理 openclaw CLI install 可能留下的额外 backup 目录(extensions 内遗留 + 新路径)
315
322
  find "$EXTENSIONS_DIR" -maxdepth 1 -name ".openclaw-qqbot-backup-*" -exec rm -rf {} + 2>/dev/null || true
323
+ find "${TMPDIR:-/tmp}" -maxdepth 1 -name ".qqbot-upgrade-backup-*" -exec rm -rf {} + 2>/dev/null || true
316
324
  else
317
325
  echo " ❌ install 失败"
318
326
  # 回滚:恢复旧目录
@@ -92,30 +92,44 @@ echo "========================================="
92
92
  echo ""
93
93
  echo "[1/6] 备份已有配置..."
94
94
  SAVED_QQBOT_TOKEN=""
95
+ SAVED_QQBOT_CONFIG_FILE="" # 有 qqbot 配置的文件路径
96
+ SAVED_QQBOT_CHANNEL_JSON="" # 完整 channels.qqbot JSON
97
+
98
+ # 完整备份 channels.qqbot 对象(含 token / groups / env / allowFrom 等)
95
99
  for APP_NAME in openclaw clawdbot moltbot; do
96
100
  CONFIG_FILE="$HOME/.$APP_NAME/$APP_NAME.json"
97
101
  if [ -f "$CONFIG_FILE" ]; then
98
- SAVED_QQBOT_TOKEN=$(node -e "
102
+ SAVED_QQBOT_CHANNEL_JSON=$(node -e "
99
103
  const cfg = JSON.parse(require('fs').readFileSync('$CONFIG_FILE', 'utf8'));
100
- // 尝试所有可能的 channel key(原仓库 + 本仓库)
101
104
  const keys = ['qqbot', 'openclaw-qqbot', 'openclaw-qq'];
102
105
  for (const key of keys) {
103
106
  const ch = cfg.channels && cfg.channels[key];
104
- if (!ch) continue;
105
- if (ch.token) { process.stdout.write(ch.token); process.exit(0); }
106
- if (ch.appId && ch.clientSecret) { process.stdout.write(ch.appId + ':' + ch.clientSecret); process.exit(0); }
107
+ if (ch && Object.keys(ch).length > 0) {
108
+ process.stdout.write(JSON.stringify(ch));
109
+ process.exit(0);
110
+ }
107
111
  }
108
112
  " 2>/dev/null || true)
109
- if [ -n "$SAVED_QQBOT_TOKEN" ]; then
110
- echo "已备份 qqbot 通道 token: ${SAVED_QQBOT_TOKEN:0:10}..."
113
+ if [ -n "$SAVED_QQBOT_CHANNEL_JSON" ]; then
114
+ SAVED_QQBOT_CONFIG_FILE="$CONFIG_FILE"
115
+ # 提取 token 供 Step 4 fallback
116
+ SAVED_QQBOT_TOKEN=$(node -e "
117
+ const ch = JSON.parse(process.argv[1]);
118
+ if (ch.token) { process.stdout.write(ch.token); }
119
+ else if (ch.appId && ch.clientSecret) { process.stdout.write(ch.appId + ':' + ch.clientSecret); }
120
+ " "$SAVED_QQBOT_CHANNEL_JSON" 2>/dev/null || true)
121
+ echo "已备份完整 qqbot 通道配置"
122
+ if [ -n "$SAVED_QQBOT_TOKEN" ]; then
123
+ echo " token: ${SAVED_QQBOT_TOKEN:0:10}..."
124
+ fi
111
125
  break
112
126
  fi
113
127
  fi
114
128
  done
115
129
 
116
- # 若当前配置中没有,再尝试从 openclaw 备份文件恢复
117
- if [ -z "$SAVED_QQBOT_TOKEN" ] && [ -d "$HOME/.openclaw" ]; then
118
- SAVED_QQBOT_TOKEN=$(node -e "
130
+ # 若当前配置中没有,从 openclaw 备份文件恢复
131
+ if [ -z "$SAVED_QQBOT_CHANNEL_JSON" ] && [ -d "$HOME/.openclaw" ]; then
132
+ SAVED_QQBOT_CHANNEL_JSON=$(node -e "
119
133
  const fs = require('fs');
120
134
  const path = require('path');
121
135
  const dir = path.join(process.env.HOME, '.openclaw');
@@ -129,16 +143,25 @@ if [ -z "$SAVED_QQBOT_TOKEN" ] && [ -d "$HOME/.openclaw" ]; then
129
143
  const keys = ['qqbot', 'openclaw-qqbot', 'openclaw-qq'];
130
144
  for (const key of keys) {
131
145
  const ch = cfg.channels && cfg.channels[key];
132
- if (!ch) continue;
133
- if (ch.token) { process.stdout.write(ch.token); process.exit(0); }
134
- if (ch.appId && ch.clientSecret) { process.stdout.write(ch.appId + ':' + ch.clientSecret); process.exit(0); }
146
+ if (ch && Object.keys(ch).length > 0) {
147
+ process.stdout.write(JSON.stringify(ch));
148
+ process.exit(0);
149
+ }
135
150
  }
136
151
  } catch {}
137
152
  }
138
153
  " 2>/dev/null || true)
139
154
 
140
- if [ -n "$SAVED_QQBOT_TOKEN" ]; then
141
- echo "已从 ~/.openclaw/openclaw.json.bak* 找到 qqbot 备份 token: ${SAVED_QQBOT_TOKEN:0:10}..."
155
+ if [ -n "$SAVED_QQBOT_CHANNEL_JSON" ]; then
156
+ SAVED_QQBOT_TOKEN=$(node -e "
157
+ const ch = JSON.parse(process.argv[1]);
158
+ if (ch.token) { process.stdout.write(ch.token); }
159
+ else if (ch.appId && ch.clientSecret) { process.stdout.write(ch.appId + ':' + ch.clientSecret); }
160
+ " "$SAVED_QQBOT_CHANNEL_JSON" 2>/dev/null || true)
161
+ echo "已从 ~/.openclaw/openclaw.json.bak* 恢复 qqbot 通道配置"
162
+ if [ -n "$SAVED_QQBOT_TOKEN" ]; then
163
+ echo " token: ${SAVED_QQBOT_TOKEN:0:10}..."
164
+ fi
142
165
  fi
143
166
  fi
144
167
 
@@ -504,6 +527,31 @@ else
504
527
  # 清理 openclaw CLI install 留下的 backup 目录,
505
528
  # 避免 gateway 发现两个同 id 插件不断刷 duplicate plugin id 警告
506
529
  find "$HOME/.openclaw/extensions/" -maxdepth 1 -name ".openclaw-qqbot-backup-*" -exec rm -rf {} + 2>/dev/null || true
530
+ find "${TMPDIR:-/tmp}" -maxdepth 1 -name ".qqbot-upgrade-backup-*" -exec rm -rf {} + 2>/dev/null || true
531
+
532
+ # 恢复 channels.qqbot 完整配置(防止 plugins install 意外覆盖)
533
+ # 策略:直接用备份完整覆盖回去,确保 groups / env / prompts / allowFrom 等所有用户配置不丢失。
534
+ if [ -n "$SAVED_QQBOT_CHANNEL_JSON" ]; then
535
+ node -e "
536
+ const fs = require('fs');
537
+ const path = require('path');
538
+ const saved = JSON.parse(process.argv[1]);
539
+ for (const app of ['openclaw', 'clawdbot', 'moltbot']) {
540
+ const f = path.join(process.env.HOME, '.' + app, app + '.json');
541
+ if (!fs.existsSync(f)) continue;
542
+ const cfg = JSON.parse(fs.readFileSync(f, 'utf8'));
543
+ if (!cfg.channels) { cfg.channels = {}; }
544
+ if (JSON.stringify(cfg.channels.qqbot) === JSON.stringify(saved)) {
545
+ process.stderr.write(' channels.qqbot 配置无变化,无需恢复\\n');
546
+ } else {
547
+ cfg.channels.qqbot = saved;
548
+ fs.writeFileSync(f, JSON.stringify(cfg, null, 4) + '\\n');
549
+ process.stderr.write(' 已完整恢复 channels.qqbot 配置(' + Object.keys(saved).join(', ') + ')\\n');
550
+ }
551
+ break;
552
+ }
553
+ " "$SAVED_QQBOT_CHANNEL_JSON" 2>&1 || true
554
+ fi
507
555
 
508
556
  # 记录更新后的 qqbot 插件版本
509
557
  NEW_QQBOT_VERSION=$(node -e '
@@ -26,7 +26,7 @@ metadata: {"openclaw":{"emoji":"⏰","requires":{"config":["channels.qqbot"]}}}
26
26
  | `time` | 时间(相对时间或 cron 表达式) | `"5m"` / `"1h30m"` / `"0 8 * * *"` |
27
27
  | `jobId` | 任务 ID(仅 remove) | `"xxx"` |
28
28
 
29
- **第二步**:`qqbot_remind` 会返回 `cronParams`,你必须**立即**将 `cronParams` 的值作为参数调用 `cron` 工具。
29
+ **第二步**:`qqbot_remind` 会返回 `cronParams`,你必须**立即**将 `cronParams` 的值**原样**作为参数调用 `cron` 工具。**禁止修改、省略或重组 `cronParams` 中的任何字段**(尤其是 `delivery` 对象中的 `accountId`)。
30
30
 
31
31
  **第三步**:根据 `cron` 工具的返回结果,回复用户。
32
32
 
@@ -54,11 +54,13 @@ metadata: {"openclaw":{"emoji":"⏰","requires":{"config":["channels.qqbot"]}}}
54
54
  | 字段 | 固定值 | 原因 |
55
55
  |------|--------|------|
56
56
  | `payload.kind` | `"agentTurn"` | `systemEvent` 不会发 QQ 消息 |
57
- | `payload.deliver` | `true` | 否则不投递 |
58
- | `payload.channel` | `"qqbot"` | QQ 通道标识 |
59
- | `payload.to` | 用户 openid | 从 `To` 字段获取 |
57
+ | `delivery.mode` | `"announce"` | 投递模式 |
58
+ | `delivery.channel` | `"qqbot"` | QQ 通道标识 |
59
+ | `delivery.to` | 用户 openid | 从 `To` 字段获取 |
60
60
  | `sessionTarget` | `"isolated"` | 隔离会话避免污染 |
61
61
 
62
+ > `delivery.accountId` 必须填写当前会话的账户 ID(如果已知),以确保多账户场景下消息通过正确的机器人账户发送。
63
+
62
64
  > `schedule.atMs` 必须是**绝对毫秒时间戳**(如 `1770733800000`),不支持 `"5m"` 等相对字符串。
63
65
  > 计算方式:`当前时间戳ms + 延迟毫秒`。
64
66
 
@@ -75,10 +77,13 @@ metadata: {"openclaw":{"emoji":"⏰","requires":{"config":["channels.qqbot"]}}}
75
77
  "deleteAfterRun": true,
76
78
  "payload": {
77
79
  "kind": "agentTurn",
78
- "message": "你是一个暖心的提醒助手。请用温暖、有趣的方式提醒用户:{提醒内容}。要求:(1) 不要回复HEARTBEAT_OK (2) 不要解释你是谁 (3) 直接输出一条暖心的提醒消息 (4) 可以加一句简短的鸡汤或关怀的话 (5) 控制在2-3句话以内 (6) 用emoji点缀",
79
- "deliver": true,
80
+ "message": "你是一个暖心的提醒助手。请用温暖、有趣的方式提醒用户:{提醒内容}。要求:(1) 不要回复HEARTBEAT_OK (2) 不要解释你是谁 (3) 直接输出一条暖心的提醒消息 (4) 可以加一句简短的鸡汤或关怀的话 (5) 控制在2-3句话以内 (6) 用emoji点缀"
81
+ },
82
+ "delivery": {
83
+ "mode": "announce",
80
84
  "channel": "qqbot",
81
- "to": "{openid}"
85
+ "to": "{openid}",
86
+ "accountId": "{accountId}"
82
87
  }
83
88
  }
84
89
  }
@@ -96,16 +101,19 @@ metadata: {"openclaw":{"emoji":"⏰","requires":{"config":["channels.qqbot"]}}}
96
101
  "wakeMode": "now",
97
102
  "payload": {
98
103
  "kind": "agentTurn",
99
- "message": "你是一个暖心的提醒助手。请用温暖、有趣的方式提醒用户:{提醒内容}。要求:(1) 不要回复HEARTBEAT_OK (2) 不要解释你是谁 (3) 直接输出一条暖心的提醒消息 (4) 可以加一句简短的鸡汤或关怀的话 (5) 控制在2-3句话以内 (6) 用emoji点缀",
100
- "deliver": true,
104
+ "message": "你是一个暖心的提醒助手。请用温暖、有趣的方式提醒用户:{提醒内容}。要求:(1) 不要回复HEARTBEAT_OK (2) 不要解释你是谁 (3) 直接输出一条暖心的提醒消息 (4) 可以加一句简短的鸡汤或关怀的话 (5) 控制在2-3句话以内 (6) 用emoji点缀"
105
+ },
106
+ "delivery": {
107
+ "mode": "announce",
101
108
  "channel": "qqbot",
102
- "to": "{openid}"
109
+ "to": "{openid}",
110
+ "accountId": "{accountId}"
103
111
  }
104
112
  }
105
113
  }
106
114
  ```
107
115
 
108
- > 周期任务**不加** `deleteAfterRun`。群聊 `to` 格式为 `"group:{group_openid}"`。
116
+ > 周期任务**不加** `deleteAfterRun`。群聊 `to` 格式为 `"qqbot:group:{group_openid}"`。
109
117
 
110
118
  ---
111
119
 
@@ -147,3 +155,5 @@ metadata: {"openclaw":{"emoji":"⏰","requires":{"config":["channels.qqbot"]}}}
147
155
  - 周期:`⏰ 收到,{周期}提醒你{内容}~`
148
156
  - 查询无结果:`📋 目前没有提醒哦~ 说"5分钟后提醒我xxx"试试?`
149
157
  - 删除成功:`✅ 已取消"{名称}"`
158
+
159
+ openclaw cron add \ --name "下班提醒" \ --at "2026-03-26T21:42:31+08:00" \ --message "test" \ --to …``