@tencent-connect/openclaw-qqbot 1.6.4-alpha.13 → 1.6.4-alpha.15

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.
@@ -3,7 +3,7 @@
3
3
  "name": "OpenClaw QQ Bot",
4
4
  "description": "QQ Bot channel plugin with message support, cron jobs, and proactive messaging",
5
5
  "channels": ["qqbot"],
6
- "skills": ["skills/qqbot-channel", "skills/qqbot-cron", "skills/qqbot-media"],
6
+ "skills": ["skills/qqbot-channel", "skills/qqbot-remind", "skills/qqbot-media"],
7
7
  "capabilities": {
8
8
  "proactiveMessaging": true,
9
9
  "cronJobs": true
package/dist/index.js CHANGED
@@ -2,6 +2,7 @@ import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
2
2
  import { qqbotPlugin } from "./src/channel.js";
3
3
  import { setQQBotRuntime } from "./src/runtime.js";
4
4
  import { registerChannelTool } from "./src/tools/channel.js";
5
+ import { registerRemindTool } from "./src/tools/remind.js";
5
6
  const plugin = {
6
7
  id: "openclaw-qqbot",
7
8
  name: "QQ Bot",
@@ -11,6 +12,7 @@ const plugin = {
11
12
  setQQBotRuntime(api.runtime);
12
13
  api.registerChannel({ plugin: qqbotPlugin });
13
14
  registerChannelTool(api);
15
+ registerRemindTool(api);
14
16
  },
15
17
  };
16
18
  export default plugin;
@@ -5,6 +5,7 @@ import { startGateway } from "./gateway.js";
5
5
  import { qqbotOnboardingAdapter } from "./onboarding.js";
6
6
  import { getQQBotRuntime } from "./runtime.js";
7
7
  import { saveCredentialBackup, loadCredentialBackup } from "./credential-backup.js";
8
+ import { initApiConfig } from "./api.js";
8
9
  /** QQ Bot 单条消息文本长度上限 */
9
10
  export const TEXT_CHUNK_LIMIT = 5000;
10
11
  /**
@@ -201,6 +202,7 @@ export const qqbotPlugin = {
201
202
  console.log(`[qqbot:channel] sendText called — accountId=${accountId}, to=${to}, replyToId=${replyToId}, text.length=${text?.length ?? 0}`);
202
203
  console.log(`[qqbot:channel] sendText text preview: ${text?.slice(0, 100)}${(text?.length ?? 0) > 100 ? "..." : ""}`);
203
204
  const account = resolveQQBotAccount(cfg, accountId);
205
+ initApiConfig({ markdownSupport: account.markdownSupport });
204
206
  console.log(`[qqbot:channel] sendText resolved account: id=${account.accountId}, appId=${account.appId}, enabled=${account.enabled}`);
205
207
  const result = await sendText({ to, text, accountId, replyToId, account });
206
208
  console.log(`[qqbot:channel] sendText result: messageId=${result.messageId}, error=${result.error ?? "none"}`);
@@ -213,6 +215,7 @@ export const qqbotPlugin = {
213
215
  sendMedia: async ({ to, text, mediaUrl, accountId, replyToId, cfg }) => {
214
216
  console.log(`[qqbot:channel] sendMedia called — accountId=${accountId}, to=${to}, replyToId=${replyToId}, mediaUrl=${mediaUrl?.slice(0, 80)}, text.length=${text?.length ?? 0}`);
215
217
  const account = resolveQQBotAccount(cfg, accountId);
218
+ initApiConfig({ markdownSupport: account.markdownSupport });
216
219
  console.log(`[qqbot:channel] sendMedia resolved account: id=${account.accountId}, appId=${account.appId}, enabled=${account.enabled}`);
217
220
  const result = await sendMedia({ to, text: text ?? "", mediaUrl: mediaUrl ?? "", accountId, replyToId, account });
218
221
  console.log(`[qqbot:channel] sendMedia result: messageId=${result.messageId}, error=${result.error ?? "none"}`);
@@ -8,12 +8,12 @@ import { getQQBotRuntime } from "./runtime.js";
8
8
  import { setRefIndex, getRefIndex, formatRefEntryForAgent, flushRefIndex } from "./ref-index-store.js";
9
9
  import { matchSlashCommand, getPluginVersion } from "./slash-commands.js";
10
10
  import { triggerUpdateCheck } from "./update-checker.js";
11
- import { startImageServer, isImageServerRunning, downloadFile } from "./image-server.js";
12
11
  import { getImageSize, formatQQBotMarkdownImage, hasQQBotImageSize } from "./utils/image-size.js";
13
12
  import { parseQQBotPayload, encodePayloadForCron, isCronReminderPayload, isMediaPayload } from "./utils/payload.js";
14
13
  import { convertSilkToWav, isVoiceAttachment, formatDuration, resolveTTSConfig, textToSilk } from "./utils/audio-convert.js";
14
+ import { startImageServer, isImageServerRunning } from "./image-server.js";
15
15
  import { normalizeMediaTags } from "./utils/media-tags.js";
16
- import { checkFileSize, readFileAsync, fileExistsAsync, formatFileSize } from "./utils/file-utils.js";
16
+ import { checkFileSize, readFileAsync, fileExistsAsync, formatFileSize, downloadFile } from "./utils/file-utils.js";
17
17
  import { getQQBotDataDir, isLocalPath as isLocalFilePath, normalizePath, sanitizeFileName, runDiagnostics } from "./utils/platform.js";
18
18
  import { sendPhoto, sendVoice, sendVideoMsg, sendDocument, sendMedia as sendMediaAuto } from "./outbound.js";
19
19
  import { chunkText, TEXT_CHUNK_LIMIT } from "./channel.js";
@@ -628,12 +628,34 @@ export async function startGateway(ctx) {
628
628
  };
629
629
  };
630
630
  // 斜杠指令拦截:在入队前匹配插件级指令,命中则直接回复,不入队
631
+ // 紧急命令列表:这些命令会立即执行,不进入斜杠匹配流程
632
+ const URGENT_COMMANDS = ["/stop"];
631
633
  const trySlashCommandOrEnqueue = async (msg) => {
632
634
  const content = (msg.content ?? "").trim();
633
635
  if (!content.startsWith("/")) {
634
636
  enqueueMessage(msg);
635
637
  return;
636
638
  }
639
+ // 检测是否为紧急命令 — 立即执行,清空该用户队列
640
+ const contentLower = content.toLowerCase();
641
+ const isUrgentCommand = URGENT_COMMANDS.some(cmd => contentLower.startsWith(cmd.toLowerCase()));
642
+ if (isUrgentCommand) {
643
+ log?.info(`[qqbot:${account.accountId}] Urgent command detected: ${content.slice(0, 20)}, executing immediately`);
644
+ const peerId = getMessagePeerId(msg);
645
+ const queue = userQueues.get(peerId);
646
+ if (queue) {
647
+ const droppedCount = queue.length;
648
+ queue.length = 0;
649
+ totalEnqueued = Math.max(0, totalEnqueued - droppedCount);
650
+ log?.info(`[qqbot:${account.accountId}] Dropped ${droppedCount} queued messages for ${peerId} due to urgent command`);
651
+ }
652
+ if (handleMessageFnRef) {
653
+ handleMessageFnRef(msg).catch(err => {
654
+ log?.error(`[qqbot:${account.accountId}] Urgent command error: ${err}`);
655
+ });
656
+ }
657
+ return;
658
+ }
637
659
  const receivedAt = Date.now();
638
660
  const peerId = getMessagePeerId(msg);
639
661
  const cmdCtx = {
@@ -834,7 +856,7 @@ export async function startGateway(ctx) {
834
856
  });
835
857
  const envelopeOptions = pluginRuntime.channel.reply.resolveEnvelopeFormatOptions(cfg);
836
858
  // 组装消息体
837
- // 静态系统提示已移至 skills/qqbot-cron/SKILL.md 和 skills/qqbot-media/SKILL.md
859
+ // 静态系统提示已移至 skills/qqbot-remind/SKILL.md 和 skills/qqbot-media/SKILL.md
838
860
  // BodyForAgent 只保留必要的动态上下文信息
839
861
  // ============ 用户标识信息 ============
840
862
  // 收集额外的系统提示(如果配置了账户级别的 systemPrompt)
@@ -337,26 +337,55 @@ function switchPluginSourceToNpm() {
337
337
  const cfgPath = path.join(homeDir, `.${cli}`, `${cli}.json`);
338
338
  if (!fs.existsSync(cfgPath))
339
339
  continue;
340
- // 读取当前配置
340
+ // 读取当前配置(保留原始文本用于回退)
341
341
  const raw = fs.readFileSync(cfgPath, "utf8");
342
- const cfg = JSON.parse(raw);
342
+ let cfg;
343
+ try {
344
+ cfg = JSON.parse(raw);
345
+ }
346
+ catch {
347
+ // 配置文件已经是损坏的 JSON,不要继续操作以免加剧问题
348
+ break;
349
+ }
343
350
  const inst = cfg?.plugins?.installs?.["openclaw-qqbot"];
344
351
  if (!inst || inst.source === "npm") {
345
352
  break; // 无需修改
346
353
  }
347
- // 记录修改前的 channels.qqbot 快照,用于写后校验
348
- const channelsBefore = JSON.stringify(cfg.channels?.qqbot ?? null);
354
+ // 记录修改前的完整快照,用于写后校验
355
+ const channelsBefore = JSON.stringify(cfg.channels ?? null);
349
356
  inst.source = "npm";
350
357
  delete inst.sourcePath;
351
358
  const newRaw = JSON.stringify(cfg, null, 4) + "\n";
352
- // 写后校验:重新解析确认 channels.qqbot 未被破坏
353
- const verify = JSON.parse(newRaw);
354
- const channelsAfter = JSON.stringify(verify.channels?.qqbot ?? null);
359
+ // 写后校验:重新解析确认整个 JSON 合法且 channels 未被破坏
360
+ let verify;
361
+ try {
362
+ verify = JSON.parse(newRaw);
363
+ }
364
+ catch {
365
+ // stringify 后竟然无法 parse(理论上不会),放弃写入
366
+ break;
367
+ }
368
+ const channelsAfter = JSON.stringify(verify.channels ?? null);
355
369
  if (channelsBefore !== channelsAfter) {
356
370
  // channels 数据异常,放弃写入
357
371
  break;
358
372
  }
359
- fs.writeFileSync(cfgPath, newRaw);
373
+ // 原子写入:先写临时文件,再 rename 替换,避免写入中途崩溃导致配置文件损坏
374
+ const tmpPath = cfgPath + ".qqbot-upgrade.tmp";
375
+ fs.writeFileSync(tmpPath, newRaw, { mode: 0o644 });
376
+ // 再次校验临时文件的完整性
377
+ try {
378
+ JSON.parse(fs.readFileSync(tmpPath, "utf8"));
379
+ }
380
+ catch {
381
+ // 写入的临时文件不完整,清理后放弃
382
+ try {
383
+ fs.unlinkSync(tmpPath);
384
+ }
385
+ catch { }
386
+ break;
387
+ }
388
+ fs.renameSync(tmpPath, cfgPath);
360
389
  break;
361
390
  }
362
391
  }
@@ -0,0 +1,2 @@
1
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
+ export declare function registerRemindTool(api: OpenClawPluginApi): void;
@@ -0,0 +1,247 @@
1
+ // ========== JSON Schema ==========
2
+ const RemindSchema = {
3
+ type: "object",
4
+ properties: {
5
+ action: {
6
+ type: "string",
7
+ description: "操作类型。add=创建提醒, list=查看已有提醒, remove=删除提醒",
8
+ enum: ["add", "list", "remove"],
9
+ },
10
+ content: {
11
+ type: "string",
12
+ description: '提醒内容,如"喝水"、"开会"。action=add 时必填。',
13
+ },
14
+ to: {
15
+ type: "string",
16
+ description: "投递目标地址,取自上下文中 [QQBot] to= 的值。" +
17
+ "私聊格式:user_openid,群聊格式:group:group_openid。action=add 时必填。",
18
+ },
19
+ time: {
20
+ type: "string",
21
+ description: "时间描述。支持两种格式:\n" +
22
+ "1. 相对时间:如 \"5m\"(5分钟后)、\"1h\"(1小时后)、\"1h30m\"(1.5小时后)、\"2d\"(2天后)\n" +
23
+ "2. cron 表达式:如 \"0 8 * * *\"(每天8点)、\"0 9 * * 1-5\"(工作日9点)\n" +
24
+ "系统会自动判断:包含空格的视为 cron 表达式(周期提醒),否则视为相对时间(一次性提醒)。\n" +
25
+ "action=add 时必填。",
26
+ },
27
+ timezone: {
28
+ type: "string",
29
+ description: "时区,仅周期提醒(cron)时需要。默认 \"Asia/Shanghai\"。",
30
+ },
31
+ name: {
32
+ type: "string",
33
+ description: "提醒任务名称(可选)。默认自动从 content 截取前 20 字。",
34
+ },
35
+ jobId: {
36
+ type: "string",
37
+ description: "要删除的任务 ID。action=remove 时必填,先用 list 获取。",
38
+ },
39
+ },
40
+ required: ["action"],
41
+ };
42
+ // ========== 工具函数 ==========
43
+ function json(data) {
44
+ return {
45
+ content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
46
+ details: data,
47
+ };
48
+ }
49
+ /**
50
+ * 解析相对时间字符串为毫秒数
51
+ * 支持格式:5m, 1h, 1h30m, 2d, 30s, 1d2h30m 等
52
+ */
53
+ function parseRelativeTime(timeStr) {
54
+ const s = timeStr.trim().toLowerCase();
55
+ // 纯数字 → 视为分钟
56
+ if (/^\d+$/.test(s)) {
57
+ return parseInt(s, 10) * 60_000;
58
+ }
59
+ let totalMs = 0;
60
+ let matched = false;
61
+ const regex = /(\d+(?:\.\d+)?)\s*(d|h|m|s)/g;
62
+ let match;
63
+ while ((match = regex.exec(s)) !== null) {
64
+ matched = true;
65
+ const value = parseFloat(match[1]);
66
+ const unit = match[2];
67
+ switch (unit) {
68
+ case "d":
69
+ totalMs += value * 86_400_000;
70
+ break;
71
+ case "h":
72
+ totalMs += value * 3_600_000;
73
+ break;
74
+ case "m":
75
+ totalMs += value * 60_000;
76
+ break;
77
+ case "s":
78
+ totalMs += value * 1_000;
79
+ break;
80
+ }
81
+ }
82
+ return matched ? Math.round(totalMs) : null;
83
+ }
84
+ /**
85
+ * 判断是否为 cron 表达式(包含空格且有 3~6 段)
86
+ */
87
+ function isCronExpression(timeStr) {
88
+ const parts = timeStr.trim().split(/\s+/);
89
+ return parts.length >= 3 && parts.length <= 6;
90
+ }
91
+ /**
92
+ * 自动生成任务名称
93
+ */
94
+ function generateJobName(content) {
95
+ const trimmed = content.trim();
96
+ const short = trimmed.length > 20 ? trimmed.slice(0, 20) + "…" : trimmed;
97
+ return `提醒: ${short}`;
98
+ }
99
+ /**
100
+ * 构建一次性提醒的 cron 工具参数
101
+ */
102
+ function buildOnceJob(params, delayMs) {
103
+ const atMs = Date.now() + delayMs;
104
+ const to = params.to;
105
+ const content = params.content;
106
+ const name = params.name || generateJobName(content);
107
+ return {
108
+ action: "add",
109
+ job: {
110
+ name,
111
+ schedule: { kind: "at", atMs },
112
+ sessionTarget: "isolated",
113
+ wakeMode: "now",
114
+ deleteAfterRun: true,
115
+ payload: {
116
+ kind: "agentTurn",
117
+ message: buildReminderPrompt(content),
118
+ deliver: true,
119
+ channel: "qqbot",
120
+ to,
121
+ },
122
+ },
123
+ };
124
+ }
125
+ /**
126
+ * 构建周期提醒的 cron 工具参数
127
+ */
128
+ function buildCronJob(params) {
129
+ const to = params.to;
130
+ const content = params.content;
131
+ const name = params.name || generateJobName(content);
132
+ const tz = params.timezone || "Asia/Shanghai";
133
+ return {
134
+ action: "add",
135
+ job: {
136
+ name,
137
+ schedule: { kind: "cron", expr: params.time.trim(), tz },
138
+ sessionTarget: "isolated",
139
+ wakeMode: "now",
140
+ payload: {
141
+ kind: "agentTurn",
142
+ message: buildReminderPrompt(content),
143
+ deliver: true,
144
+ channel: "qqbot",
145
+ to,
146
+ },
147
+ },
148
+ };
149
+ }
150
+ /**
151
+ * 构建提醒 payload 中的 AI prompt
152
+ */
153
+ function buildReminderPrompt(content) {
154
+ return (`你是一个暖心的提醒助手。请用温暖、有趣的方式提醒用户:${content}。` +
155
+ `要求:(1) 不要回复HEARTBEAT_OK (2) 不要解释你是谁 ` +
156
+ `(3) 直接输出一条暖心的提醒消息 (4) 可以加一句简短的鸡汤或关怀的话 ` +
157
+ `(5) 控制在2-3句话以内 (6) 用emoji点缀`);
158
+ }
159
+ /**
160
+ * 格式化延迟时间为人类可读文本
161
+ */
162
+ function formatDelay(ms) {
163
+ const totalSeconds = Math.round(ms / 1000);
164
+ if (totalSeconds < 60)
165
+ return `${totalSeconds}秒`;
166
+ const totalMinutes = Math.round(ms / 60_000);
167
+ if (totalMinutes < 60)
168
+ return `${totalMinutes}分钟`;
169
+ const hours = Math.floor(totalMinutes / 60);
170
+ const minutes = totalMinutes % 60;
171
+ if (minutes === 0)
172
+ return `${hours}小时`;
173
+ return `${hours}小时${minutes}分钟`;
174
+ }
175
+ // ========== 注册入口 ==========
176
+ export function registerRemindTool(api) {
177
+ api.registerTool({
178
+ name: "qqbot_remind",
179
+ label: "QQBot 定时提醒",
180
+ description: "创建、查询、删除 QQ 定时提醒。" +
181
+ "使用简单参数即可,无需手动构造 cron JSON。\n" +
182
+ "创建提醒:action=add, content=提醒内容, to=目标地址, time=时间\n" +
183
+ "查看提醒:action=list\n" +
184
+ "删除提醒:action=remove, jobId=任务ID(先 list 获取)\n" +
185
+ "时间格式示例:\"5m\"(5分钟后) \"1h\"(1小时后) \"0 8 * * *\"(每天8点)",
186
+ parameters: RemindSchema,
187
+ execute(_toolCallId, params) {
188
+ const p = params;
189
+ // ===== list =====
190
+ if (p.action === "list") {
191
+ return json({
192
+ _instruction: "请立即使用 cron 工具执行以下参数",
193
+ cronParams: { action: "list" },
194
+ });
195
+ }
196
+ // ===== remove =====
197
+ if (p.action === "remove") {
198
+ if (!p.jobId) {
199
+ return json({ error: "action=remove 时 jobId 为必填参数。请先用 action=list 获取 jobId。" });
200
+ }
201
+ return json({
202
+ _instruction: "请立即使用 cron 工具执行以下参数",
203
+ cronParams: { action: "remove", jobId: p.jobId },
204
+ });
205
+ }
206
+ // ===== add =====
207
+ if (!p.content) {
208
+ return json({ error: "action=add 时 content(提醒内容)为必填参数" });
209
+ }
210
+ if (!p.to) {
211
+ return json({ error: "action=add 时 to(目标地址)为必填参数,取自上下文 [QQBot] to= 的值" });
212
+ }
213
+ if (!p.time) {
214
+ return json({ error: "action=add 时 time(时间)为必填参数。示例:\"5m\"、\"1h30m\"、\"0 8 * * *\"" });
215
+ }
216
+ // 判断是 cron 表达式还是相对时间
217
+ if (isCronExpression(p.time)) {
218
+ // 周期提醒
219
+ const cronJob = buildCronJob(p);
220
+ return json({
221
+ _instruction: "请立即使用 cron 工具执行以下参数,然后回复用户提醒已设置",
222
+ cronParams: cronJob,
223
+ summary: `⏰ 周期提醒: "${p.content}" (${p.time}, tz=${p.timezone || "Asia/Shanghai"})`,
224
+ });
225
+ }
226
+ // 一次性提醒
227
+ const delayMs = parseRelativeTime(p.time);
228
+ if (!delayMs || delayMs <= 0) {
229
+ return json({
230
+ error: `无法解析时间 "${p.time}"。支持格式:` +
231
+ `相对时间如 "5m"、"1h"、"1h30m"、"2d";` +
232
+ `cron 表达式如 "0 8 * * *"(每天8点)`,
233
+ });
234
+ }
235
+ if (delayMs < 30_000) {
236
+ return json({ error: "提醒时间不能少于 30 秒" });
237
+ }
238
+ const onceJob = buildOnceJob(p, delayMs);
239
+ return json({
240
+ _instruction: "请立即使用 cron 工具执行以下参数,然后回复用户提醒已设置",
241
+ cronParams: onceJob,
242
+ summary: `⏰ ${formatDelay(delayMs)}后提醒: "${p.content}"`,
243
+ });
244
+ },
245
+ }, { name: "qqbot_remind" });
246
+ console.log("[qqbot-remind] Registered QQBot remind tool");
247
+ }
@@ -44,3 +44,12 @@ export declare function formatFileSize(bytes: number): string;
44
44
  * 根据文件扩展名获取 MIME 类型
45
45
  */
46
46
  export declare function getMimeType(filePath: string): string;
47
+ /**
48
+ * 将远端文件下载到本地目录。
49
+ *
50
+ * @param url 远端 URL
51
+ * @param destDir 目标目录(不存在时自动创建)
52
+ * @param originalFilename 可选的原始文件名(覆盖 URL 推断)
53
+ * @returns 本地文件完整路径;下载失败返回 null
54
+ */
55
+ export declare function downloadFile(url: string, destDir: string, originalFilename?: string): Promise<string | null>;
@@ -3,6 +3,7 @@
3
3
  */
4
4
  import * as fs from "node:fs";
5
5
  import * as path from "node:path";
6
+ import crypto from "node:crypto";
6
7
  /** QQ Bot API 最大上传文件大小:20MB */
7
8
  export const MAX_UPLOAD_SIZE = 20 * 1024 * 1024;
8
9
  /** 大文件阈值(超过此值发送进度提示):5MB */
@@ -105,3 +106,45 @@ export function getMimeType(filePath) {
105
106
  };
106
107
  return mimeTypes[ext] ?? "application/octet-stream";
107
108
  }
109
+ /**
110
+ * 将远端文件下载到本地目录。
111
+ *
112
+ * @param url 远端 URL
113
+ * @param destDir 目标目录(不存在时自动创建)
114
+ * @param originalFilename 可选的原始文件名(覆盖 URL 推断)
115
+ * @returns 本地文件完整路径;下载失败返回 null
116
+ */
117
+ export async function downloadFile(url, destDir, originalFilename) {
118
+ try {
119
+ if (!fs.existsSync(destDir)) {
120
+ fs.mkdirSync(destDir, { recursive: true });
121
+ }
122
+ const resp = await fetch(url, { redirect: "follow" });
123
+ if (!resp.ok || !resp.body)
124
+ return null;
125
+ // 确定文件名:优先使用 originalFilename,否则从 URL 推断
126
+ let filename = originalFilename?.trim() || "";
127
+ if (!filename) {
128
+ try {
129
+ const urlPath = new URL(url).pathname;
130
+ filename = path.basename(urlPath) || "download";
131
+ }
132
+ catch {
133
+ filename = "download";
134
+ }
135
+ }
136
+ // 加上时间戳避免同名冲突
137
+ const ts = Date.now();
138
+ const ext = path.extname(filename);
139
+ const base = path.basename(filename, ext) || "file";
140
+ const rand = crypto.randomBytes(3).toString("hex");
141
+ const safeFilename = `${base}_${ts}_${rand}${ext}`;
142
+ const destPath = path.join(destDir, safeFilename);
143
+ const buffer = Buffer.from(await resp.arrayBuffer());
144
+ await fs.promises.writeFile(destPath, buffer);
145
+ return destPath;
146
+ }
147
+ catch {
148
+ return null;
149
+ }
150
+ }
@@ -29,6 +29,16 @@ export declare function getHomeDir(): string;
29
29
  * 替代各文件中分散的 path.join(HOME, ".openclaw", "qqbot", ...)
30
30
  */
31
31
  export declare function getQQBotDataDir(...subPaths: string[]): string;
32
+ /**
33
+ * 获取 .openclaw/media/qqbot 下的子目录路径,并自动创建
34
+ *
35
+ * 与 getQQBotDataDir 不同,此目录位于 OpenClaw 核心的媒体安全白名单
36
+ * (~/.openclaw/media) 之下,下载到这里的文件可以被框架的 image/media
37
+ * 工具直接访问,不会触发 "Local media path is not under an allowed directory" 错误。
38
+ *
39
+ * 用于存放从 QQ 下载的图片、语音等需要被框架处理的媒体文件。
40
+ */
41
+ export declare function getQQBotMediaDir(...subPaths: string[]): string;
32
42
  /**
33
43
  * 获取系统临时目录(跨平台安全)
34
44
  * Mac: /var/folders/... 或 /tmp
@@ -59,6 +59,22 @@ export function getQQBotDataDir(...subPaths) {
59
59
  }
60
60
  return dir;
61
61
  }
62
+ /**
63
+ * 获取 .openclaw/media/qqbot 下的子目录路径,并自动创建
64
+ *
65
+ * 与 getQQBotDataDir 不同,此目录位于 OpenClaw 核心的媒体安全白名单
66
+ * (~/.openclaw/media) 之下,下载到这里的文件可以被框架的 image/media
67
+ * 工具直接访问,不会触发 "Local media path is not under an allowed directory" 错误。
68
+ *
69
+ * 用于存放从 QQ 下载的图片、语音等需要被框架处理的媒体文件。
70
+ */
71
+ export function getQQBotMediaDir(...subPaths) {
72
+ const dir = path.join(getHomeDir(), ".openclaw", "media", "qqbot", ...subPaths);
73
+ if (!fs.existsSync(dir)) {
74
+ fs.mkdirSync(dir, { recursive: true });
75
+ }
76
+ return dir;
77
+ }
62
78
  // ============ 临时目录 ============
63
79
  /**
64
80
  * 获取系统临时目录(跨平台安全)
package/index.ts CHANGED
@@ -4,6 +4,7 @@ import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
4
4
  import { qqbotPlugin } from "./src/channel.js";
5
5
  import { setQQBotRuntime } from "./src/runtime.js";
6
6
  import { registerChannelTool } from "./src/tools/channel.js";
7
+ import { registerRemindTool } from "./src/tools/remind.js";
7
8
 
8
9
  const plugin = {
9
10
  id: "openclaw-qqbot",
@@ -14,6 +15,7 @@ const plugin = {
14
15
  setQQBotRuntime(api.runtime);
15
16
  api.registerChannel({ plugin: qqbotPlugin });
16
17
  registerChannelTool(api);
18
+ registerRemindTool(api);
17
19
  },
18
20
  };
19
21
 
@@ -3,7 +3,7 @@
3
3
  "name": "OpenClaw QQ Bot",
4
4
  "description": "QQ Bot channel plugin with message support, cron jobs, and proactive messaging",
5
5
  "channels": ["qqbot"],
6
- "skills": ["skills/qqbot-channel", "skills/qqbot-cron", "skills/qqbot-media"],
6
+ "skills": ["skills/qqbot-channel", "skills/qqbot-remind", "skills/qqbot-media"],
7
7
  "capabilities": {
8
8
  "proactiveMessaging": true,
9
9
  "cronJobs": true
@@ -3,7 +3,7 @@
3
3
  "name": "OpenClaw QQ Bot",
4
4
  "description": "QQ Bot channel plugin with message support, cron jobs, and proactive messaging",
5
5
  "channels": ["qqbot"],
6
- "skills": ["skills/qqbot-channel", "skills/qqbot-cron", "skills/qqbot-media"],
6
+ "skills": ["skills/qqbot-channel", "skills/qqbot-remind", "skills/qqbot-media"],
7
7
  "capabilities": {
8
8
  "proactiveMessaging": true,
9
9
  "cronJobs": true
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tencent-connect/openclaw-qqbot",
3
- "version": "1.6.4-alpha.13",
3
+ "version": "1.6.4-alpha.15",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: qqbot-cron
2
+ name: qqbot-remind
3
3
  description: QQBot 定时提醒。支持一次性和周期性提醒的创建、查询、取消。当通过 QQ 通道通信且涉及提醒/定时任务时使用。
4
4
  metadata: {"openclaw":{"emoji":"⏰","requires":{"config":["channels.qqbot"]}}}
5
5
  ---
@@ -8,13 +8,43 @@ metadata: {"openclaw":{"emoji":"⏰","requires":{"config":["channels.qqbot"]}}}
8
8
 
9
9
  ## ⚠️ 强制规则
10
10
 
11
- **当用户提到「提醒」「闹钟」「定时」「X分钟/小时后」「每天X点」「叫我」等任何涉及延时或定时的请求时,你必须调用 `cron` 工具,绝对不能只用自然语言回复说"好的,我会提醒你"!**
11
+ **当用户提到「提醒」「闹钟」「定时」「X分钟/小时后」「每天X点」「叫我」等任何涉及延时或定时的请求时,你必须调用工具,绝对不能只用自然语言回复说"好的,我会提醒你"!**
12
12
 
13
- 你没有内存或后台线程,口头承诺"到时候提醒"是无效的——只有调用 `cron` 工具才能真正注册定时任务。
13
+ 你没有内存或后台线程,口头承诺"到时候提醒"是无效的——只有调用工具才能真正注册定时任务。
14
14
 
15
15
  ---
16
16
 
17
- ## 核心规则
17
+ ## 推荐流程(优先使用 `qqbot_remind` 工具)
18
+
19
+ **第一步**:调用 `qqbot_remind` 工具,传入简单参数:
20
+
21
+ | 参数 | 说明 | 示例 |
22
+ |------|------|------|
23
+ | `action` | 操作类型 | `"add"` / `"list"` / `"remove"` |
24
+ | `content` | 提醒内容 | `"喝水"` |
25
+ | `to` | 目标地址(取自上下文 `[QQBot] to=` 的值) | `"user_openid_xxx"` 或 `"group:group_openid_xxx"` |
26
+ | `time` | 时间(相对时间或 cron 表达式) | `"5m"` / `"1h30m"` / `"0 8 * * *"` |
27
+ | `jobId` | 任务 ID(仅 remove) | `"xxx"` |
28
+
29
+ **第二步**:`qqbot_remind` 会返回 `cronParams`,你必须**立即**将 `cronParams` 的值作为参数调用 `cron` 工具。
30
+
31
+ **第三步**:根据 `cron` 工具的返回结果,回复用户。
32
+
33
+ ### 示例
34
+
35
+ 用户说:"5分钟后提醒我喝水"
36
+
37
+ 1. 调用 `qqbot_remind`:`{ "action": "add", "content": "喝水", "to": "xxx", "time": "5m" }`
38
+ 2. 收到返回的 `cronParams` → 立即调用 `cron` 工具,参数为该 `cronParams`
39
+ 3. 回复用户:`⏰ 好的,5分钟后提醒你喝水~`
40
+
41
+ ---
42
+
43
+ ## 备用方案(直接使用 `cron` 工具)
44
+
45
+ > 仅当 `qqbot_remind` 工具不可用时使用以下方式。
46
+
47
+ ### 核心规则
18
48
 
19
49
  > **payload.kind 必须是 `"agentTurn"`,绝对不能用 `"systemEvent"`!**
20
50
  > `systemEvent` 只在 AI 会话内部注入文本,用户收不到 QQ 消息。
@@ -32,10 +62,6 @@ metadata: {"openclaw":{"emoji":"⏰","requires":{"config":["channels.qqbot"]}}}
32
62
  > `schedule.atMs` 必须是**绝对毫秒时间戳**(如 `1770733800000`),不支持 `"5m"` 等相对字符串。
33
63
  > 计算方式:`当前时间戳ms + 延迟毫秒`。
34
64
 
35
- ---
36
-
37
- ## 参数模板
38
-
39
65
  ### 一次性提醒(schedule.kind = "at")
40
66
 
41
67
  ```json
@@ -98,20 +124,14 @@ metadata: {"openclaw":{"emoji":"⏰","requires":{"config":["channels.qqbot"]}}}
98
124
 
99
125
  ---
100
126
 
101
- ## 查询与删除
102
-
103
- - **查询**:`{ "action": "list" }`
104
- - **删除**:先 `list` 找到 jobId,再 `{ "action": "remove", "jobId": "{id}" }`
105
- - **修改**:删除后重建
106
-
107
- ---
108
-
109
127
  ## AI 决策指南
110
128
 
111
- | 用户说法 | action | schedule.kind |
112
- |----------|--------|---------------|
113
- | "5分钟后提醒我喝水" | `add` | `at` |
114
- | "每天8点提醒我打卡" | `add` | `cron` |
129
+ | 用户说法 | action | time 格式 |
130
+ |----------|--------|-----------|
131
+ | "5分钟后提醒我喝水" | `add` | `"5m"` |
132
+ | "1小时后提醒开会" | `add` | `"1h"` |
133
+ | "每天8点提醒我打卡" | `add` | `"0 8 * * *"` |
134
+ | "工作日早上9点提醒" | `add` | `"0 9 * * 1-5"` |
115
135
  | "我有哪些提醒" | `list` | — |
116
136
  | "取消喝水提醒" | `remove` | — |
117
137
  | "修改提醒时间" | `remove` → `add` | — |
package/src/channel.ts CHANGED
@@ -13,6 +13,7 @@ import { startGateway } from "./gateway.js";
13
13
  import { qqbotOnboardingAdapter } from "./onboarding.js";
14
14
  import { getQQBotRuntime } from "./runtime.js";
15
15
  import { saveCredentialBackup, loadCredentialBackup } from "./credential-backup.js";
16
+ import { initApiConfig } from "./api.js";
16
17
 
17
18
  /** QQ Bot 单条消息文本长度上限 */
18
19
  export const TEXT_CHUNK_LIMIT = 5000;
@@ -222,6 +223,7 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
222
223
  console.log(`[qqbot:channel] sendText called — accountId=${accountId}, to=${to}, replyToId=${replyToId}, text.length=${text?.length ?? 0}`);
223
224
  console.log(`[qqbot:channel] sendText text preview: ${text?.slice(0, 100)}${(text?.length ?? 0) > 100 ? "..." : ""}`);
224
225
  const account = resolveQQBotAccount(cfg, accountId);
226
+ initApiConfig({ markdownSupport: account.markdownSupport });
225
227
  console.log(`[qqbot:channel] sendText resolved account: id=${account.accountId}, appId=${account.appId}, enabled=${account.enabled}`);
226
228
  const result = await sendText({ to, text, accountId, replyToId, account });
227
229
  console.log(`[qqbot:channel] sendText result: messageId=${result.messageId}, error=${result.error ?? "none"}`);
@@ -234,6 +236,7 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
234
236
  sendMedia: async ({ to, text, mediaUrl, accountId, replyToId, cfg }) => {
235
237
  console.log(`[qqbot:channel] sendMedia called — accountId=${accountId}, to=${to}, replyToId=${replyToId}, mediaUrl=${mediaUrl?.slice(0, 80)}, text.length=${text?.length ?? 0}`);
236
238
  const account = resolveQQBotAccount(cfg, accountId);
239
+ initApiConfig({ markdownSupport: account.markdownSupport });
237
240
  console.log(`[qqbot:channel] sendMedia resolved account: id=${account.accountId}, appId=${account.appId}, enabled=${account.enabled}`);
238
241
  const result = await sendMedia({ to, text: text ?? "", mediaUrl: mediaUrl ?? "", accountId, replyToId, account });
239
242
  console.log(`[qqbot:channel] sendMedia result: messageId=${result.messageId}, error=${result.error ?? "none"}`);
package/src/gateway.ts CHANGED
@@ -9,13 +9,13 @@ import { getQQBotRuntime } from "./runtime.js";
9
9
  import { setRefIndex, getRefIndex, formatRefEntryForAgent, flushRefIndex, type RefAttachmentSummary } from "./ref-index-store.js";
10
10
  import { matchSlashCommand, getPluginVersion, type SlashCommandContext, type SlashCommandFileResult, type QueueSnapshot } from "./slash-commands.js";
11
11
  import { triggerUpdateCheck } from "./update-checker.js";
12
- import { startImageServer, isImageServerRunning, downloadFile, type ImageServerConfig } from "./image-server.js";
13
12
  import { getImageSize, formatQQBotMarkdownImage, hasQQBotImageSize, DEFAULT_IMAGE_SIZE } from "./utils/image-size.js";
14
13
  import { parseQQBotPayload, encodePayloadForCron, isCronReminderPayload, isMediaPayload, type CronReminderPayload, type MediaPayload } from "./utils/payload.js";
15
14
  import { convertSilkToWav, isVoiceAttachment, formatDuration, resolveTTSConfig, textToSilk } from "./utils/audio-convert.js";
15
+ import { startImageServer, isImageServerRunning, type ImageServerConfig } from "./image-server.js";
16
16
  import { normalizeMediaTags } from "./utils/media-tags.js";
17
- import { checkFileSize, readFileAsync, fileExistsAsync, isLargeFile, formatFileSize } from "./utils/file-utils.js";
18
- import { getQQBotDataDir, isLocalPath as isLocalFilePath, normalizePath, sanitizeFileName, runDiagnostics } from "./utils/platform.js";
17
+ import { checkFileSize, readFileAsync, fileExistsAsync, isLargeFile, formatFileSize, downloadFile } from "./utils/file-utils.js";
18
+ import { getQQBotDataDir, getQQBotMediaDir, isLocalPath as isLocalFilePath, looksLikeLocalPath, normalizePath, sanitizeFileName, runDiagnostics } from "./utils/platform.js";
19
19
 
20
20
  import { sendPhoto, sendVoice, sendVideoMsg, sendDocument, sendMedia as sendMediaAuto, type MediaTargetContext } from "./outbound.js";
21
21
  import { chunkText, TEXT_CHUNK_LIMIT } from "./channel.js";
@@ -748,6 +748,9 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
748
748
  };
749
749
 
750
750
  // 斜杠指令拦截:在入队前匹配插件级指令,命中则直接回复,不入队
751
+ // 紧急命令列表:这些命令会立即执行,不进入斜杠匹配流程
752
+ const URGENT_COMMANDS = ["/stop"];
753
+
751
754
  const trySlashCommandOrEnqueue = async (msg: QueuedMessage): Promise<void> => {
752
755
  const content = (msg.content ?? "").trim();
753
756
  if (!content.startsWith("/")) {
@@ -755,6 +758,27 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
755
758
  return;
756
759
  }
757
760
 
761
+ // 检测是否为紧急命令 — 立即执行,清空该用户队列
762
+ const contentLower = content.toLowerCase();
763
+ const isUrgentCommand = URGENT_COMMANDS.some(cmd => contentLower.startsWith(cmd.toLowerCase()));
764
+ if (isUrgentCommand) {
765
+ log?.info(`[qqbot:${account.accountId}] Urgent command detected: ${content.slice(0, 20)}, executing immediately`);
766
+ const peerId = getMessagePeerId(msg);
767
+ const queue = userQueues.get(peerId);
768
+ if (queue) {
769
+ const droppedCount = queue.length;
770
+ queue.length = 0;
771
+ totalEnqueued = Math.max(0, totalEnqueued - droppedCount);
772
+ log?.info(`[qqbot:${account.accountId}] Dropped ${droppedCount} queued messages for ${peerId} due to urgent command`);
773
+ }
774
+ if (handleMessageFnRef) {
775
+ handleMessageFnRef(msg).catch(err => {
776
+ log?.error(`[qqbot:${account.accountId}] Urgent command error: ${err}`);
777
+ });
778
+ }
779
+ return;
780
+ }
781
+
758
782
  const receivedAt = Date.now();
759
783
  const peerId = getMessagePeerId(msg);
760
784
 
@@ -988,7 +1012,7 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
988
1012
  const envelopeOptions = pluginRuntime.channel.reply.resolveEnvelopeFormatOptions(cfg);
989
1013
 
990
1014
  // 组装消息体
991
- // 静态系统提示已移至 skills/qqbot-cron/SKILL.md 和 skills/qqbot-media/SKILL.md
1015
+ // 静态系统提示已移至 skills/qqbot-remind/SKILL.md 和 skills/qqbot-media/SKILL.md
992
1016
  // BodyForAgent 只保留必要的动态上下文信息
993
1017
 
994
1018
  // ============ 用户标识信息 ============
@@ -433,30 +433,57 @@ function switchPluginSourceToNpm(): void {
433
433
  const cfgPath = path.join(homeDir, `.${cli}`, `${cli}.json`);
434
434
  if (!fs.existsSync(cfgPath)) continue;
435
435
 
436
- // 读取当前配置
436
+ // 读取当前配置(保留原始文本用于回退)
437
437
  const raw = fs.readFileSync(cfgPath, "utf8");
438
- const cfg = JSON.parse(raw);
438
+
439
+ let cfg: any;
440
+ try {
441
+ cfg = JSON.parse(raw);
442
+ } catch {
443
+ // 配置文件已经是损坏的 JSON,不要继续操作以免加剧问题
444
+ break;
445
+ }
446
+
439
447
  const inst = cfg?.plugins?.installs?.["openclaw-qqbot"];
440
448
  if (!inst || inst.source === "npm") {
441
449
  break; // 无需修改
442
450
  }
443
451
 
444
- // 记录修改前的 channels.qqbot 快照,用于写后校验
445
- const channelsBefore = JSON.stringify(cfg.channels?.qqbot ?? null);
452
+ // 记录修改前的完整快照,用于写后校验
453
+ const channelsBefore = JSON.stringify(cfg.channels ?? null);
446
454
 
447
455
  inst.source = "npm";
448
456
  delete inst.sourcePath;
449
457
  const newRaw = JSON.stringify(cfg, null, 4) + "\n";
450
458
 
451
- // 写后校验:重新解析确认 channels.qqbot 未被破坏
452
- const verify = JSON.parse(newRaw);
453
- const channelsAfter = JSON.stringify(verify.channels?.qqbot ?? null);
459
+ // 写后校验:重新解析确认整个 JSON 合法且 channels 未被破坏
460
+ let verify: any;
461
+ try {
462
+ verify = JSON.parse(newRaw);
463
+ } catch {
464
+ // stringify 后竟然无法 parse(理论上不会),放弃写入
465
+ break;
466
+ }
467
+ const channelsAfter = JSON.stringify(verify.channels ?? null);
454
468
  if (channelsBefore !== channelsAfter) {
455
469
  // channels 数据异常,放弃写入
456
470
  break;
457
471
  }
458
472
 
459
- fs.writeFileSync(cfgPath, newRaw);
473
+ // 原子写入:先写临时文件,再 rename 替换,避免写入中途崩溃导致配置文件损坏
474
+ const tmpPath = cfgPath + ".qqbot-upgrade.tmp";
475
+ fs.writeFileSync(tmpPath, newRaw, { mode: 0o644 });
476
+
477
+ // 再次校验临时文件的完整性
478
+ try {
479
+ JSON.parse(fs.readFileSync(tmpPath, "utf8"));
480
+ } catch {
481
+ // 写入的临时文件不完整,清理后放弃
482
+ try { fs.unlinkSync(tmpPath); } catch {}
483
+ break;
484
+ }
485
+
486
+ fs.renameSync(tmpPath, cfgPath);
460
487
  break;
461
488
  }
462
489
  } catch {
@@ -0,0 +1,296 @@
1
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
+
3
+ // ========== 类型定义 ==========
4
+
5
+ interface RemindParams {
6
+ action: "add" | "list" | "remove";
7
+ /** 提醒内容(action=add 时必填) */
8
+ content?: string;
9
+ /** 目标地址,格式为上下文中的 to= 值(action=add 时必填) */
10
+ to?: string;
11
+ /**
12
+ * 时间描述(action=add 时必填)
13
+ * - 一次性:相对时间如 "5m"、"1h30m"、"2h",或绝对毫秒时间戳
14
+ * - 周期性:cron 表达式如 "0 8 * * *"
15
+ */
16
+ time?: string;
17
+ /** 时区(周期提醒时使用,默认 Asia/Shanghai) */
18
+ timezone?: string;
19
+ /** 提醒名称(可选,默认自动生成) */
20
+ name?: string;
21
+ /** jobId(action=remove 时必填) */
22
+ jobId?: string;
23
+ }
24
+
25
+ // ========== JSON Schema ==========
26
+
27
+ const RemindSchema = {
28
+ type: "object",
29
+ properties: {
30
+ action: {
31
+ type: "string",
32
+ description:
33
+ "操作类型。add=创建提醒, list=查看已有提醒, remove=删除提醒",
34
+ enum: ["add", "list", "remove"],
35
+ },
36
+ content: {
37
+ type: "string",
38
+ description: '提醒内容,如"喝水"、"开会"。action=add 时必填。',
39
+ },
40
+ to: {
41
+ type: "string",
42
+ description:
43
+ "投递目标地址,取自上下文中 [QQBot] to= 的值。" +
44
+ "私聊格式:user_openid,群聊格式:group:group_openid。action=add 时必填。",
45
+ },
46
+ time: {
47
+ type: "string",
48
+ description:
49
+ "时间描述。支持两种格式:\n" +
50
+ "1. 相对时间:如 \"5m\"(5分钟后)、\"1h\"(1小时后)、\"1h30m\"(1.5小时后)、\"2d\"(2天后)\n" +
51
+ "2. cron 表达式:如 \"0 8 * * *\"(每天8点)、\"0 9 * * 1-5\"(工作日9点)\n" +
52
+ "系统会自动判断:包含空格的视为 cron 表达式(周期提醒),否则视为相对时间(一次性提醒)。\n" +
53
+ "action=add 时必填。",
54
+ },
55
+ timezone: {
56
+ type: "string",
57
+ description:
58
+ "时区,仅周期提醒(cron)时需要。默认 \"Asia/Shanghai\"。",
59
+ },
60
+ name: {
61
+ type: "string",
62
+ description: "提醒任务名称(可选)。默认自动从 content 截取前 20 字。",
63
+ },
64
+ jobId: {
65
+ type: "string",
66
+ description: "要删除的任务 ID。action=remove 时必填,先用 list 获取。",
67
+ },
68
+ },
69
+ required: ["action"],
70
+ } as const;
71
+
72
+ // ========== 工具函数 ==========
73
+
74
+ function json(data: unknown) {
75
+ return {
76
+ content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }],
77
+ details: data,
78
+ };
79
+ }
80
+
81
+ /**
82
+ * 解析相对时间字符串为毫秒数
83
+ * 支持格式:5m, 1h, 1h30m, 2d, 30s, 1d2h30m 等
84
+ */
85
+ function parseRelativeTime(timeStr: string): number | null {
86
+ const s = timeStr.trim().toLowerCase();
87
+
88
+ // 纯数字 → 视为分钟
89
+ if (/^\d+$/.test(s)) {
90
+ return parseInt(s, 10) * 60_000;
91
+ }
92
+
93
+ let totalMs = 0;
94
+ let matched = false;
95
+ const regex = /(\d+(?:\.\d+)?)\s*(d|h|m|s)/g;
96
+ let match: RegExpExecArray | null;
97
+
98
+ while ((match = regex.exec(s)) !== null) {
99
+ matched = true;
100
+ const value = parseFloat(match[1]);
101
+ const unit = match[2];
102
+ switch (unit) {
103
+ case "d": totalMs += value * 86_400_000; break;
104
+ case "h": totalMs += value * 3_600_000; break;
105
+ case "m": totalMs += value * 60_000; break;
106
+ case "s": totalMs += value * 1_000; break;
107
+ }
108
+ }
109
+
110
+ return matched ? Math.round(totalMs) : null;
111
+ }
112
+
113
+ /**
114
+ * 判断是否为 cron 表达式(包含空格且有 3~6 段)
115
+ */
116
+ function isCronExpression(timeStr: string): boolean {
117
+ const parts = timeStr.trim().split(/\s+/);
118
+ return parts.length >= 3 && parts.length <= 6;
119
+ }
120
+
121
+ /**
122
+ * 自动生成任务名称
123
+ */
124
+ function generateJobName(content: string): string {
125
+ const trimmed = content.trim();
126
+ const short = trimmed.length > 20 ? trimmed.slice(0, 20) + "…" : trimmed;
127
+ return `提醒: ${short}`;
128
+ }
129
+
130
+ /**
131
+ * 构建一次性提醒的 cron 工具参数
132
+ */
133
+ function buildOnceJob(params: RemindParams, delayMs: number) {
134
+ const atMs = Date.now() + delayMs;
135
+ const to = params.to!;
136
+ const content = params.content!;
137
+ const name = params.name || generateJobName(content);
138
+
139
+ return {
140
+ action: "add",
141
+ job: {
142
+ name,
143
+ schedule: { kind: "at", atMs },
144
+ sessionTarget: "isolated",
145
+ wakeMode: "now",
146
+ deleteAfterRun: true,
147
+ payload: {
148
+ kind: "agentTurn",
149
+ message: buildReminderPrompt(content),
150
+ deliver: true,
151
+ channel: "qqbot",
152
+ to,
153
+ },
154
+ },
155
+ };
156
+ }
157
+
158
+ /**
159
+ * 构建周期提醒的 cron 工具参数
160
+ */
161
+ function buildCronJob(params: RemindParams) {
162
+ const to = params.to!;
163
+ const content = params.content!;
164
+ const name = params.name || generateJobName(content);
165
+ const tz = params.timezone || "Asia/Shanghai";
166
+
167
+ return {
168
+ action: "add",
169
+ job: {
170
+ name,
171
+ schedule: { kind: "cron", expr: params.time!.trim(), tz },
172
+ sessionTarget: "isolated",
173
+ wakeMode: "now",
174
+ payload: {
175
+ kind: "agentTurn",
176
+ message: buildReminderPrompt(content),
177
+ deliver: true,
178
+ channel: "qqbot",
179
+ to,
180
+ },
181
+ },
182
+ };
183
+ }
184
+
185
+ /**
186
+ * 构建提醒 payload 中的 AI prompt
187
+ */
188
+ function buildReminderPrompt(content: string): string {
189
+ return (
190
+ `你是一个暖心的提醒助手。请用温暖、有趣的方式提醒用户:${content}。` +
191
+ `要求:(1) 不要回复HEARTBEAT_OK (2) 不要解释你是谁 ` +
192
+ `(3) 直接输出一条暖心的提醒消息 (4) 可以加一句简短的鸡汤或关怀的话 ` +
193
+ `(5) 控制在2-3句话以内 (6) 用emoji点缀`
194
+ );
195
+ }
196
+
197
+ /**
198
+ * 格式化延迟时间为人类可读文本
199
+ */
200
+ function formatDelay(ms: number): string {
201
+ const totalSeconds = Math.round(ms / 1000);
202
+ if (totalSeconds < 60) return `${totalSeconds}秒`;
203
+ const totalMinutes = Math.round(ms / 60_000);
204
+ if (totalMinutes < 60) return `${totalMinutes}分钟`;
205
+ const hours = Math.floor(totalMinutes / 60);
206
+ const minutes = totalMinutes % 60;
207
+ if (minutes === 0) return `${hours}小时`;
208
+ return `${hours}小时${minutes}分钟`;
209
+ }
210
+
211
+ // ========== 注册入口 ==========
212
+
213
+ export function registerRemindTool(api: OpenClawPluginApi): void {
214
+ api.registerTool(
215
+ {
216
+ name: "qqbot_remind",
217
+ label: "QQBot 定时提醒",
218
+ description:
219
+ "创建、查询、删除 QQ 定时提醒。" +
220
+ "使用简单参数即可,无需手动构造 cron JSON。\n" +
221
+ "创建提醒:action=add, content=提醒内容, to=目标地址, time=时间\n" +
222
+ "查看提醒:action=list\n" +
223
+ "删除提醒:action=remove, jobId=任务ID(先 list 获取)\n" +
224
+ "时间格式示例:\"5m\"(5分钟后) \"1h\"(1小时后) \"0 8 * * *\"(每天8点)",
225
+ parameters: RemindSchema,
226
+ execute(_toolCallId, params) {
227
+ const p = params as RemindParams;
228
+
229
+ // ===== list =====
230
+ if (p.action === "list") {
231
+ return json({
232
+ _instruction: "请立即使用 cron 工具执行以下参数",
233
+ cronParams: { action: "list" },
234
+ });
235
+ }
236
+
237
+ // ===== remove =====
238
+ if (p.action === "remove") {
239
+ if (!p.jobId) {
240
+ return json({ error: "action=remove 时 jobId 为必填参数。请先用 action=list 获取 jobId。" });
241
+ }
242
+ return json({
243
+ _instruction: "请立即使用 cron 工具执行以下参数",
244
+ cronParams: { action: "remove", jobId: p.jobId },
245
+ });
246
+ }
247
+
248
+ // ===== add =====
249
+ if (!p.content) {
250
+ return json({ error: "action=add 时 content(提醒内容)为必填参数" });
251
+ }
252
+ if (!p.to) {
253
+ return json({ error: "action=add 时 to(目标地址)为必填参数,取自上下文 [QQBot] to= 的值" });
254
+ }
255
+ if (!p.time) {
256
+ return json({ error: "action=add 时 time(时间)为必填参数。示例:\"5m\"、\"1h30m\"、\"0 8 * * *\"" });
257
+ }
258
+
259
+ // 判断是 cron 表达式还是相对时间
260
+ if (isCronExpression(p.time)) {
261
+ // 周期提醒
262
+ const cronJob = buildCronJob(p);
263
+ return json({
264
+ _instruction: "请立即使用 cron 工具执行以下参数,然后回复用户提醒已设置",
265
+ cronParams: cronJob,
266
+ summary: `⏰ 周期提醒: "${p.content}" (${p.time}, tz=${p.timezone || "Asia/Shanghai"})`,
267
+ });
268
+ }
269
+
270
+ // 一次性提醒
271
+ const delayMs = parseRelativeTime(p.time);
272
+ if (!delayMs || delayMs <= 0) {
273
+ return json({
274
+ error: `无法解析时间 "${p.time}"。支持格式:` +
275
+ `相对时间如 "5m"、"1h"、"1h30m"、"2d";` +
276
+ `cron 表达式如 "0 8 * * *"(每天8点)`,
277
+ });
278
+ }
279
+
280
+ if (delayMs < 30_000) {
281
+ return json({ error: "提醒时间不能少于 30 秒" });
282
+ }
283
+
284
+ const onceJob = buildOnceJob(p, delayMs);
285
+ return json({
286
+ _instruction: "请立即使用 cron 工具执行以下参数,然后回复用户提醒已设置",
287
+ cronParams: onceJob,
288
+ summary: `⏰ ${formatDelay(delayMs)}后提醒: "${p.content}"`,
289
+ });
290
+ },
291
+ },
292
+ { name: "qqbot_remind" },
293
+ );
294
+
295
+ console.log("[qqbot-remind] Registered QQBot remind tool");
296
+ }
@@ -4,6 +4,7 @@
4
4
 
5
5
  import * as fs from "node:fs";
6
6
  import * as path from "node:path";
7
+ import crypto from "node:crypto";
7
8
 
8
9
  /** QQ Bot API 最大上传文件大小:20MB */
9
10
  export const MAX_UPLOAD_SIZE = 20 * 1024 * 1024;
@@ -120,3 +121,47 @@ export function getMimeType(filePath: string): string {
120
121
  };
121
122
  return mimeTypes[ext] ?? "application/octet-stream";
122
123
  }
124
+
125
+ /**
126
+ * 将远端文件下载到本地目录。
127
+ *
128
+ * @param url 远端 URL
129
+ * @param destDir 目标目录(不存在时自动创建)
130
+ * @param originalFilename 可选的原始文件名(覆盖 URL 推断)
131
+ * @returns 本地文件完整路径;下载失败返回 null
132
+ */
133
+ export async function downloadFile(url: string, destDir: string, originalFilename?: string): Promise<string | null> {
134
+ try {
135
+ if (!fs.existsSync(destDir)) {
136
+ fs.mkdirSync(destDir, { recursive: true });
137
+ }
138
+
139
+ const resp = await fetch(url, { redirect: "follow" });
140
+ if (!resp.ok || !resp.body) return null;
141
+
142
+ // 确定文件名:优先使用 originalFilename,否则从 URL 推断
143
+ let filename = originalFilename?.trim() || "";
144
+ if (!filename) {
145
+ try {
146
+ const urlPath = new URL(url).pathname;
147
+ filename = path.basename(urlPath) || "download";
148
+ } catch {
149
+ filename = "download";
150
+ }
151
+ }
152
+
153
+ // 加上时间戳避免同名冲突
154
+ const ts = Date.now();
155
+ const ext = path.extname(filename);
156
+ const base = path.basename(filename, ext) || "file";
157
+ const rand = crypto.randomBytes(3).toString("hex");
158
+ const safeFilename = `${base}_${ts}_${rand}${ext}`;
159
+
160
+ const destPath = path.join(destDir, safeFilename);
161
+ const buffer = Buffer.from(await resp.arrayBuffer());
162
+ await fs.promises.writeFile(destPath, buffer);
163
+ return destPath;
164
+ } catch {
165
+ return null;
166
+ }
167
+ }
@@ -68,6 +68,23 @@ export function getQQBotDataDir(...subPaths: string[]): string {
68
68
  return dir;
69
69
  }
70
70
 
71
+ /**
72
+ * 获取 .openclaw/media/qqbot 下的子目录路径,并自动创建
73
+ *
74
+ * 与 getQQBotDataDir 不同,此目录位于 OpenClaw 核心的媒体安全白名单
75
+ * (~/.openclaw/media) 之下,下载到这里的文件可以被框架的 image/media
76
+ * 工具直接访问,不会触发 "Local media path is not under an allowed directory" 错误。
77
+ *
78
+ * 用于存放从 QQ 下载的图片、语音等需要被框架处理的媒体文件。
79
+ */
80
+ export function getQQBotMediaDir(...subPaths: string[]): string {
81
+ const dir = path.join(getHomeDir(), ".openclaw", "media", "qqbot", ...subPaths);
82
+ if (!fs.existsSync(dir)) {
83
+ fs.mkdirSync(dir, { recursive: true });
84
+ }
85
+ return dir;
86
+ }
87
+
71
88
  // ============ 临时目录 ============
72
89
 
73
90
  /**