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