@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.
- package/clawdbot.plugin.json +1 -1
- package/dist/index.js +2 -0
- package/dist/src/channel.js +3 -0
- package/dist/src/gateway.js +25 -3
- package/dist/src/slash-commands.js +37 -8
- package/dist/src/tools/remind.d.ts +2 -0
- package/dist/src/tools/remind.js +247 -0
- package/dist/src/utils/file-utils.d.ts +9 -0
- package/dist/src/utils/file-utils.js +43 -0
- package/dist/src/utils/platform.d.ts +10 -0
- package/dist/src/utils/platform.js +16 -0
- package/index.ts +2 -0
- package/moltbot.plugin.json +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/skills/{qqbot-cron → qqbot-remind}/SKILL.md +40 -20
- package/src/channel.ts +3 -0
- package/src/gateway.ts +28 -4
- package/src/slash-commands.ts +35 -8
- package/src/tools/remind.ts +296 -0
- package/src/utils/file-utils.ts +45 -0
- package/src/utils/platform.ts +17 -0
package/clawdbot.plugin.json
CHANGED
|
@@ -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-
|
|
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;
|
package/dist/src/channel.js
CHANGED
|
@@ -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"}`);
|
package/dist/src/gateway.js
CHANGED
|
@@ -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-
|
|
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
|
-
|
|
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
|
-
//
|
|
348
|
-
const channelsBefore = JSON.stringify(cfg.channels
|
|
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
|
-
//
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
|
|
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,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
|
|
package/moltbot.plugin.json
CHANGED
|
@@ -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-
|
|
6
|
+
"skills": ["skills/qqbot-channel", "skills/qqbot-remind", "skills/qqbot-media"],
|
|
7
7
|
"capabilities": {
|
|
8
8
|
"proactiveMessaging": true,
|
|
9
9
|
"cronJobs": true
|
package/openclaw.plugin.json
CHANGED
|
@@ -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-
|
|
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,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
name: qqbot-
|
|
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
|
|
11
|
+
**当用户提到「提醒」「闹钟」「定时」「X分钟/小时后」「每天X点」「叫我」等任何涉及延时或定时的请求时,你必须调用工具,绝对不能只用自然语言回复说"好的,我会提醒你"!**
|
|
12
12
|
|
|
13
|
-
你没有内存或后台线程,口头承诺"到时候提醒"
|
|
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 |
|
|
112
|
-
|
|
113
|
-
| "5分钟后提醒我喝水" | `add` | `
|
|
114
|
-
| "
|
|
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-
|
|
1015
|
+
// 静态系统提示已移至 skills/qqbot-remind/SKILL.md 和 skills/qqbot-media/SKILL.md
|
|
992
1016
|
// BodyForAgent 只保留必要的动态上下文信息
|
|
993
1017
|
|
|
994
1018
|
// ============ 用户标识信息 ============
|
package/src/slash-commands.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
445
|
-
const channelsBefore = JSON.stringify(cfg.channels
|
|
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
|
-
//
|
|
452
|
-
|
|
453
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/utils/file-utils.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/utils/platform.ts
CHANGED
|
@@ -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
|
/**
|