@openclaw/qqbot 2026.5.1-beta.1
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/api.ts +56 -0
- package/channel-plugin-api.ts +1 -0
- package/dist/.boundary-tsc.stamp +1 -0
- package/dist/.boundary-tsc.tsbuildinfo +1 -0
- package/index.ts +29 -0
- package/openclaw.plugin.json +814 -0
- package/package.json +66 -0
- package/runtime-api.ts +9 -0
- package/setup-entry.ts +9 -0
- package/setup-plugin-api.ts +3 -0
- package/skills/qqbot-channel/SKILL.md +262 -0
- package/skills/qqbot-channel/references/api_references.md +521 -0
- package/skills/qqbot-media/SKILL.md +37 -0
- package/skills/qqbot-remind/SKILL.md +153 -0
- package/src/bridge/approval/capability.ts +237 -0
- package/src/bridge/approval/handler-runtime.ts +204 -0
- package/src/bridge/bootstrap.ts +135 -0
- package/src/bridge/channel-entry.ts +18 -0
- package/src/bridge/commands/framework-context-adapter.ts +60 -0
- package/src/bridge/commands/framework-registration.ts +47 -0
- package/src/bridge/commands/from-parser.test.ts +86 -0
- package/src/bridge/commands/from-parser.ts +60 -0
- package/src/bridge/commands/result-dispatcher.ts +76 -0
- package/src/bridge/config-shared.ts +132 -0
- package/src/bridge/config.ts +111 -0
- package/src/bridge/gateway.ts +174 -0
- package/src/bridge/logger.ts +31 -0
- package/src/bridge/narrowing.ts +31 -0
- package/src/bridge/plugin-version.test.ts +146 -0
- package/src/bridge/plugin-version.ts +102 -0
- package/src/bridge/runtime.ts +25 -0
- package/src/bridge/sdk-adapter.ts +131 -0
- package/src/bridge/setup/finalize.ts +144 -0
- package/src/bridge/setup/surface.ts +34 -0
- package/src/bridge/tools/channel.ts +58 -0
- package/src/bridge/tools/index.ts +15 -0
- package/src/bridge/tools/remind.test.ts +124 -0
- package/src/bridge/tools/remind.ts +91 -0
- package/src/channel.setup.ts +33 -0
- package/src/channel.ts +288 -0
- package/src/command-auth.test.ts +62 -0
- package/src/config-schema.ts +84 -0
- package/src/config.test.ts +364 -0
- package/src/engine/access/access-control.test.ts +198 -0
- package/src/engine/access/access-control.ts +226 -0
- package/src/engine/access/index.ts +16 -0
- package/src/engine/access/resolve-policy.test.ts +59 -0
- package/src/engine/access/resolve-policy.ts +57 -0
- package/src/engine/access/sender-match.test.ts +60 -0
- package/src/engine/access/sender-match.ts +55 -0
- package/src/engine/access/types.ts +53 -0
- package/src/engine/adapter/audio.port.ts +27 -0
- package/src/engine/adapter/commands.port.ts +22 -0
- package/src/engine/adapter/history.port.ts +52 -0
- package/src/engine/adapter/index.ts +139 -0
- package/src/engine/adapter/mention-gate.port.ts +50 -0
- package/src/engine/adapter/types.ts +38 -0
- package/src/engine/api/api-client.ts +212 -0
- package/src/engine/api/media-chunked.test.ts +336 -0
- package/src/engine/api/media-chunked.ts +622 -0
- package/src/engine/api/media.ts +218 -0
- package/src/engine/api/messages.ts +293 -0
- package/src/engine/api/retry.ts +217 -0
- package/src/engine/api/routes.ts +95 -0
- package/src/engine/api/token.ts +271 -0
- package/src/engine/approval/index.test.ts +22 -0
- package/src/engine/approval/index.ts +224 -0
- package/src/engine/commands/builtin/log-helpers.ts +319 -0
- package/src/engine/commands/builtin/register-all.ts +17 -0
- package/src/engine/commands/builtin/register-approve.ts +201 -0
- package/src/engine/commands/builtin/register-basic.ts +95 -0
- package/src/engine/commands/builtin/register-clear-storage.ts +187 -0
- package/src/engine/commands/builtin/register-logs.ts +20 -0
- package/src/engine/commands/builtin/register-streaming.ts +137 -0
- package/src/engine/commands/builtin/state.ts +31 -0
- package/src/engine/commands/slash-command-auth.ts +48 -0
- package/src/engine/commands/slash-command-handler.ts +146 -0
- package/src/engine/commands/slash-commands-impl.test.ts +8 -0
- package/src/engine/commands/slash-commands-impl.ts +61 -0
- package/src/engine/commands/slash-commands.ts +199 -0
- package/src/engine/config/credential-backup.test.ts +88 -0
- package/src/engine/config/credential-backup.ts +107 -0
- package/src/engine/config/credentials.ts +76 -0
- package/src/engine/config/group.test.ts +234 -0
- package/src/engine/config/group.ts +299 -0
- package/src/engine/config/resolve.test.ts +152 -0
- package/src/engine/config/resolve.ts +283 -0
- package/src/engine/config/setup-logic.ts +84 -0
- package/src/engine/engine-import-boundary.test.ts +73 -0
- package/src/engine/gateway/codec.ts +47 -0
- package/src/engine/gateway/constants.ts +117 -0
- package/src/engine/gateway/event-dispatcher.ts +177 -0
- package/src/engine/gateway/gateway-connection.ts +371 -0
- package/src/engine/gateway/gateway.ts +291 -0
- package/src/engine/gateway/inbound-attachments.test.ts +126 -0
- package/src/engine/gateway/inbound-attachments.ts +360 -0
- package/src/engine/gateway/inbound-context.ts +195 -0
- package/src/engine/gateway/inbound-pipeline.self-echo.test.ts +218 -0
- package/src/engine/gateway/inbound-pipeline.ts +235 -0
- package/src/engine/gateway/interaction-handler.ts +220 -0
- package/src/engine/gateway/message-queue.test.ts +282 -0
- package/src/engine/gateway/message-queue.ts +499 -0
- package/src/engine/gateway/outbound-dispatch.test.ts +231 -0
- package/src/engine/gateway/outbound-dispatch.ts +575 -0
- package/src/engine/gateway/reconnect.ts +199 -0
- package/src/engine/gateway/stages/access-stage.ts +132 -0
- package/src/engine/gateway/stages/assembly-stage.ts +156 -0
- package/src/engine/gateway/stages/content-stage.test.ts +77 -0
- package/src/engine/gateway/stages/content-stage.ts +77 -0
- package/src/engine/gateway/stages/envelope-stage.test.ts +152 -0
- package/src/engine/gateway/stages/envelope-stage.ts +144 -0
- package/src/engine/gateway/stages/group-gate-stage.ts +292 -0
- package/src/engine/gateway/stages/index.ts +18 -0
- package/src/engine/gateway/stages/quote-stage.ts +113 -0
- package/src/engine/gateway/stages/refidx-stage.ts +62 -0
- package/src/engine/gateway/stages/stub-contexts.ts +116 -0
- package/src/engine/gateway/types.ts +264 -0
- package/src/engine/gateway/typing-keepalive.ts +79 -0
- package/src/engine/group/activation.test.ts +114 -0
- package/src/engine/group/activation.ts +147 -0
- package/src/engine/group/history.test.ts +314 -0
- package/src/engine/group/history.ts +321 -0
- package/src/engine/group/mention.test.ts +141 -0
- package/src/engine/group/mention.ts +197 -0
- package/src/engine/group/message-gating.test.ts +188 -0
- package/src/engine/group/message-gating.ts +216 -0
- package/src/engine/messaging/decode-media-path.ts +82 -0
- package/src/engine/messaging/media-source.ts +215 -0
- package/src/engine/messaging/media-type-detect.ts +37 -0
- package/src/engine/messaging/outbound-audio-port.ts +38 -0
- package/src/engine/messaging/outbound-deliver.ts +810 -0
- package/src/engine/messaging/outbound-media-send.ts +702 -0
- package/src/engine/messaging/outbound-reply.ts +27 -0
- package/src/engine/messaging/outbound-result-helpers.ts +54 -0
- package/src/engine/messaging/outbound-types.ts +45 -0
- package/src/engine/messaging/outbound.ts +485 -0
- package/src/engine/messaging/reply-dispatcher.ts +597 -0
- package/src/engine/messaging/reply-limiter.ts +164 -0
- package/src/engine/messaging/sender.ts +729 -0
- package/src/engine/messaging/streaming-c2c.ts +1192 -0
- package/src/engine/messaging/streaming-media-send.ts +544 -0
- package/src/engine/messaging/target-parser.ts +104 -0
- package/src/engine/ref/format-message-ref.ts +142 -0
- package/src/engine/ref/format-ref-entry.test.ts +60 -0
- package/src/engine/ref/format-ref-entry.ts +27 -0
- package/src/engine/ref/store.ts +224 -0
- package/src/engine/ref/types.ts +27 -0
- package/src/engine/session/known-users.ts +254 -0
- package/src/engine/session/session-store.ts +284 -0
- package/src/engine/tools/channel-api.ts +244 -0
- package/src/engine/tools/remind-logic.test.ts +280 -0
- package/src/engine/tools/remind-logic.ts +377 -0
- package/src/engine/types.ts +313 -0
- package/src/engine/utils/attachment-tags.test.ts +186 -0
- package/src/engine/utils/attachment-tags.ts +174 -0
- package/src/engine/utils/audio.test.ts +250 -0
- package/src/engine/utils/audio.ts +585 -0
- package/src/engine/utils/data-paths.ts +38 -0
- package/src/engine/utils/diagnostics.ts +109 -0
- package/src/engine/utils/file-utils.test.ts +72 -0
- package/src/engine/utils/file-utils.ts +225 -0
- package/src/engine/utils/format.test.ts +68 -0
- package/src/engine/utils/format.ts +70 -0
- package/src/engine/utils/image-size.test.ts +158 -0
- package/src/engine/utils/image-size.ts +249 -0
- package/src/engine/utils/log.test.ts +28 -0
- package/src/engine/utils/log.ts +61 -0
- package/src/engine/utils/media-tags.test.ts +32 -0
- package/src/engine/utils/media-tags.ts +177 -0
- package/src/engine/utils/payload.test.ts +68 -0
- package/src/engine/utils/payload.ts +145 -0
- package/src/engine/utils/platform-storage-laziness.test.ts +65 -0
- package/src/engine/utils/platform.test.ts +148 -0
- package/src/engine/utils/platform.ts +343 -0
- package/src/engine/utils/request-context.ts +60 -0
- package/src/engine/utils/string-normalize.ts +91 -0
- package/src/engine/utils/stt.test.ts +104 -0
- package/src/engine/utils/stt.ts +100 -0
- package/src/engine/utils/text-parsing.test.ts +29 -0
- package/src/engine/utils/text-parsing.ts +155 -0
- package/src/engine/utils/upload-cache.ts +96 -0
- package/src/engine/utils/voice-text.ts +15 -0
- package/src/exec-approvals.ts +218 -0
- package/src/manifest-schema.test.ts +56 -0
- package/src/qqbot-test-support.ts +29 -0
- package/src/types.ts +210 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QQ Channel API proxy tool core logic.
|
|
3
|
+
* QQ 频道 API 代理工具核心逻辑。
|
|
4
|
+
*
|
|
5
|
+
* Provides an authenticated HTTP proxy for the QQ Open Platform channel
|
|
6
|
+
* APIs. The caller (old tools/channel.ts shell) resolves the access
|
|
7
|
+
* token and passes it in; this module handles URL building, path
|
|
8
|
+
* validation, fetch, and structured response formatting.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { formatErrorMessage } from "../utils/format.js";
|
|
12
|
+
import { debugLog, debugError } from "../utils/log.js";
|
|
13
|
+
|
|
14
|
+
const API_BASE = "https://api.sgroup.qq.com";
|
|
15
|
+
const DEFAULT_TIMEOUT_MS = 30000;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Channel API call parameters.
|
|
19
|
+
* 频道 API 调用参数。
|
|
20
|
+
*/
|
|
21
|
+
export interface ChannelApiParams {
|
|
22
|
+
method: string;
|
|
23
|
+
path: string;
|
|
24
|
+
body?: Record<string, unknown>;
|
|
25
|
+
query?: Record<string, string>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* JSON Schema for AI tool parameters (used by framework registration).
|
|
30
|
+
* AI Tool 参数的 JSON Schema 定义(供框架注册使用)。
|
|
31
|
+
*/
|
|
32
|
+
export const ChannelApiSchema = {
|
|
33
|
+
type: "object",
|
|
34
|
+
properties: {
|
|
35
|
+
method: {
|
|
36
|
+
type: "string",
|
|
37
|
+
description: "HTTP method. Allowed values: GET, POST, PUT, PATCH, DELETE.",
|
|
38
|
+
enum: ["GET", "POST", "PUT", "PATCH", "DELETE"],
|
|
39
|
+
},
|
|
40
|
+
path: {
|
|
41
|
+
type: "string",
|
|
42
|
+
description:
|
|
43
|
+
"API path without the host. Replace placeholders with concrete values. " +
|
|
44
|
+
"Examples: /users/@me/guilds, /guilds/{guild_id}/channels, /channels/{channel_id}.",
|
|
45
|
+
},
|
|
46
|
+
body: {
|
|
47
|
+
type: "object",
|
|
48
|
+
description:
|
|
49
|
+
"JSON request body for POST/PUT/PATCH requests. GET/DELETE usually do not need it.",
|
|
50
|
+
},
|
|
51
|
+
query: {
|
|
52
|
+
type: "object",
|
|
53
|
+
description:
|
|
54
|
+
"URL query parameters as key/value pairs appended to the path. " +
|
|
55
|
+
'For example, { "limit": "100", "after": "0" } becomes ?limit=100&after=0.',
|
|
56
|
+
additionalProperties: { type: "string" },
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
required: ["method", "path"],
|
|
60
|
+
} as const;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Build the full API URL from base + path + query params.
|
|
64
|
+
* 拼接 API 基地址 + 路径 + 查询参数。
|
|
65
|
+
*/
|
|
66
|
+
function buildUrl(path: string, query?: Record<string, string>): string {
|
|
67
|
+
let url = `${API_BASE}${path}`;
|
|
68
|
+
if (query && Object.keys(query).length > 0) {
|
|
69
|
+
const params = new URLSearchParams();
|
|
70
|
+
for (const [key, value] of Object.entries(query)) {
|
|
71
|
+
if (value !== undefined && value !== null && value !== "") {
|
|
72
|
+
params.set(key, value);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const qs = params.toString();
|
|
76
|
+
if (qs) {
|
|
77
|
+
url += `?${qs}`;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return url;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Validate API path format; returns an error string or null if valid.
|
|
85
|
+
* 校验 API 路径格式,返回错误描述或 null(合法)。
|
|
86
|
+
*/
|
|
87
|
+
function validatePath(path: string): string | null {
|
|
88
|
+
if (!path.startsWith("/")) {
|
|
89
|
+
return "path must start with /";
|
|
90
|
+
}
|
|
91
|
+
if (path.includes("..") || path.includes("//")) {
|
|
92
|
+
return "path must not contain .. or //";
|
|
93
|
+
}
|
|
94
|
+
if (!/^\/[a-zA-Z0-9\-._~:@!$&'()*+,;=/%]+$/.test(path) && path !== "/") {
|
|
95
|
+
return "path contains unsupported characters";
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function json(data: unknown) {
|
|
101
|
+
return {
|
|
102
|
+
content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }],
|
|
103
|
+
details: data,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Options provided by the caller when executing a channel API request.
|
|
109
|
+
* 执行频道 API 请求时由调用方提供的选项。
|
|
110
|
+
*/
|
|
111
|
+
interface ChannelApiExecuteOptions {
|
|
112
|
+
accessToken: string;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Execute a channel API proxy request.
|
|
117
|
+
* 执行频道 API 代理请求。
|
|
118
|
+
*
|
|
119
|
+
* The caller provides the access token; this function handles
|
|
120
|
+
* URL building, path validation, HTTP fetch, and structured
|
|
121
|
+
* response formatting suitable for AI tool output.
|
|
122
|
+
*/
|
|
123
|
+
export async function executeChannelApi(
|
|
124
|
+
params: ChannelApiParams,
|
|
125
|
+
options: ChannelApiExecuteOptions,
|
|
126
|
+
) {
|
|
127
|
+
if (!params.method) {
|
|
128
|
+
return json({ error: "method is required" });
|
|
129
|
+
}
|
|
130
|
+
if (!params.path) {
|
|
131
|
+
return json({ error: "path is required" });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const method = params.method.toUpperCase();
|
|
135
|
+
if (!["GET", "POST", "PUT", "PATCH", "DELETE"].includes(method)) {
|
|
136
|
+
return json({
|
|
137
|
+
error: `Unsupported HTTP method: ${method}. Allowed values: GET, POST, PUT, PATCH, DELETE`,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const pathError = validatePath(params.path);
|
|
142
|
+
if (pathError) {
|
|
143
|
+
return json({ error: pathError });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (
|
|
147
|
+
(method === "GET" || method === "DELETE") &&
|
|
148
|
+
params.body &&
|
|
149
|
+
Object.keys(params.body).length > 0
|
|
150
|
+
) {
|
|
151
|
+
debugLog(`[qqbot-channel-api] ${method} request with body, body will be ignored`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
const url = buildUrl(params.path, params.query);
|
|
156
|
+
const headers: Record<string, string> = {
|
|
157
|
+
Authorization: `QQBot ${options.accessToken}`,
|
|
158
|
+
"Content-Type": "application/json",
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const controller = new AbortController();
|
|
162
|
+
const timeoutId = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS);
|
|
163
|
+
|
|
164
|
+
const fetchOptions: RequestInit = {
|
|
165
|
+
method,
|
|
166
|
+
headers,
|
|
167
|
+
signal: controller.signal,
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
if (params.body && ["POST", "PUT", "PATCH"].includes(method)) {
|
|
171
|
+
fetchOptions.body = JSON.stringify(params.body);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
debugLog(`[qqbot-channel-api] >>> ${method} ${url} (timeout: ${DEFAULT_TIMEOUT_MS}ms)`);
|
|
175
|
+
|
|
176
|
+
let res: Response;
|
|
177
|
+
try {
|
|
178
|
+
res = await fetch(url, fetchOptions);
|
|
179
|
+
} catch (err) {
|
|
180
|
+
clearTimeout(timeoutId);
|
|
181
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
182
|
+
debugError(`[qqbot-channel-api] <<< Request timeout after ${DEFAULT_TIMEOUT_MS}ms`);
|
|
183
|
+
return json({
|
|
184
|
+
error: `Request timed out after ${DEFAULT_TIMEOUT_MS}ms`,
|
|
185
|
+
path: params.path,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
debugError("[qqbot-channel-api] <<< Network error:", err);
|
|
189
|
+
return json({
|
|
190
|
+
error: `Network error: ${formatErrorMessage(err)}`,
|
|
191
|
+
path: params.path,
|
|
192
|
+
});
|
|
193
|
+
} finally {
|
|
194
|
+
clearTimeout(timeoutId);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
debugLog(`[qqbot-channel-api] <<< Status: ${res.status} ${res.statusText}`);
|
|
198
|
+
|
|
199
|
+
const rawBody = await res.text();
|
|
200
|
+
if (!rawBody || rawBody.trim() === "") {
|
|
201
|
+
if (res.ok) {
|
|
202
|
+
return json({ success: true, status: res.status, path: params.path });
|
|
203
|
+
}
|
|
204
|
+
return json({
|
|
205
|
+
error: `API returned ${res.status} ${res.statusText}`,
|
|
206
|
+
status: res.status,
|
|
207
|
+
path: params.path,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
let parsed: unknown;
|
|
212
|
+
try {
|
|
213
|
+
parsed = JSON.parse(rawBody);
|
|
214
|
+
} catch {
|
|
215
|
+
parsed = rawBody;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (!res.ok) {
|
|
219
|
+
const errMsg =
|
|
220
|
+
typeof parsed === "object" && parsed && "message" in parsed
|
|
221
|
+
? String((parsed as { message?: unknown }).message)
|
|
222
|
+
: `${res.status} ${res.statusText}`;
|
|
223
|
+
debugError(`[qqbot-channel-api] Error [${method} ${params.path}]: ${errMsg}`);
|
|
224
|
+
return json({
|
|
225
|
+
error: errMsg,
|
|
226
|
+
status: res.status,
|
|
227
|
+
path: params.path,
|
|
228
|
+
details: parsed,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return json({
|
|
233
|
+
success: true,
|
|
234
|
+
status: res.status,
|
|
235
|
+
path: params.path,
|
|
236
|
+
data: parsed,
|
|
237
|
+
});
|
|
238
|
+
} catch (err) {
|
|
239
|
+
return json({
|
|
240
|
+
error: formatErrorMessage(err),
|
|
241
|
+
path: params.path,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
parseRelativeTime,
|
|
4
|
+
isCronExpression,
|
|
5
|
+
formatDelay,
|
|
6
|
+
generateJobName,
|
|
7
|
+
buildReminderPrompt,
|
|
8
|
+
executeRemind,
|
|
9
|
+
executeScheduledRemind,
|
|
10
|
+
prepareRemindCronAction,
|
|
11
|
+
} from "./remind-logic.js";
|
|
12
|
+
|
|
13
|
+
describe("engine/tools/remind-logic", () => {
|
|
14
|
+
describe("parseRelativeTime", () => {
|
|
15
|
+
it("parses minutes shorthand", () => {
|
|
16
|
+
expect(parseRelativeTime("5m")).toBe(5 * 60_000);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("parses hours shorthand", () => {
|
|
20
|
+
expect(parseRelativeTime("1h")).toBe(3_600_000);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("parses combined hours and minutes", () => {
|
|
24
|
+
expect(parseRelativeTime("1h30m")).toBe(90 * 60_000);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("parses days", () => {
|
|
28
|
+
expect(parseRelativeTime("2d")).toBe(2 * 86_400_000);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("parses seconds", () => {
|
|
32
|
+
expect(parseRelativeTime("45s")).toBe(45_000);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("treats plain numbers as minutes", () => {
|
|
36
|
+
expect(parseRelativeTime("10")).toBe(10 * 60_000);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("returns null for unparseable input", () => {
|
|
40
|
+
expect(parseRelativeTime("never")).toBeNull();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("is case insensitive", () => {
|
|
44
|
+
expect(parseRelativeTime("5M")).toBe(5 * 60_000);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("isCronExpression", () => {
|
|
49
|
+
it("detects standard 5-field cron", () => {
|
|
50
|
+
expect(isCronExpression("0 8 * * *")).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("detects weekday range cron", () => {
|
|
54
|
+
expect(isCronExpression("0 9 * * 1-5")).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("rejects short input", () => {
|
|
58
|
+
expect(isCronExpression("5m")).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("rejects too many fields", () => {
|
|
62
|
+
expect(isCronExpression("0 0 0 0 0 0 0")).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("formatDelay", () => {
|
|
67
|
+
it("formats seconds", () => {
|
|
68
|
+
expect(formatDelay(45_000)).toBe("45s");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("formats minutes", () => {
|
|
72
|
+
expect(formatDelay(300_000)).toBe("5m");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("formats hours", () => {
|
|
76
|
+
expect(formatDelay(3_600_000)).toBe("1h");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("formats hours and minutes", () => {
|
|
80
|
+
expect(formatDelay(5_400_000)).toBe("1h30m");
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("generateJobName", () => {
|
|
85
|
+
it("returns short content as-is", () => {
|
|
86
|
+
expect(generateJobName("drink water")).toBe("Reminder: drink water");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("truncates long content", () => {
|
|
90
|
+
const long = "a very long reminder content that exceeds twenty characters";
|
|
91
|
+
const name = generateJobName(long);
|
|
92
|
+
expect(name.length).toBeLessThan(40);
|
|
93
|
+
expect(name).toContain("…");
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("buildReminderPrompt", () => {
|
|
98
|
+
it("includes the content in the prompt", () => {
|
|
99
|
+
const prompt = buildReminderPrompt("drink water");
|
|
100
|
+
expect(prompt).toContain("drink water");
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("executeRemind", () => {
|
|
105
|
+
it("renders internal scheduling output without exposing cronParams", () => {
|
|
106
|
+
const result = executeRemind({ action: "list" });
|
|
107
|
+
expect(result.details).toEqual({
|
|
108
|
+
_instruction: "Gateway cron action prepared for internal QQ reminder scheduling.",
|
|
109
|
+
action: "list",
|
|
110
|
+
summary: undefined,
|
|
111
|
+
});
|
|
112
|
+
expect((result.details as { _instruction: string })._instruction).not.toContain(
|
|
113
|
+
"Use the cron tool",
|
|
114
|
+
);
|
|
115
|
+
expect(result.details).not.toHaveProperty("cronParams");
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("prepareRemindCronAction", () => {
|
|
120
|
+
it("returns error when removing without jobId", () => {
|
|
121
|
+
const result = prepareRemindCronAction({ action: "remove" });
|
|
122
|
+
expect(result).toEqual({
|
|
123
|
+
ok: false,
|
|
124
|
+
error: "jobId is required when action=remove. Use action=list first.",
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("returns error when content is missing for add", () => {
|
|
129
|
+
const result = prepareRemindCronAction({ action: "add", to: "qqbot:c2c:123", time: "5m" });
|
|
130
|
+
expect(result).toEqual({ ok: false, error: "content is required when action=add" });
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("returns error when delay is too short", () => {
|
|
134
|
+
const result = prepareRemindCronAction({
|
|
135
|
+
action: "add",
|
|
136
|
+
content: "test",
|
|
137
|
+
to: "qqbot:c2c:123",
|
|
138
|
+
time: "10s",
|
|
139
|
+
});
|
|
140
|
+
expect(result).toEqual({ ok: false, error: "Reminder delay must be at least 30 seconds" });
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("builds once job with delivery envelope for relative time", () => {
|
|
144
|
+
const result = prepareRemindCronAction({
|
|
145
|
+
action: "add",
|
|
146
|
+
content: "test reminder",
|
|
147
|
+
to: "qqbot:c2c:123",
|
|
148
|
+
time: "5m",
|
|
149
|
+
});
|
|
150
|
+
expect(result.ok).toBe(true);
|
|
151
|
+
expect(result.ok ? result.cronAction.action : undefined).toBe("add");
|
|
152
|
+
const job = result.ok && result.cronAction.action === "add" ? result.cronAction.job : null;
|
|
153
|
+
expect(job?.schedule.kind).toBe("at");
|
|
154
|
+
expect(job?.payload.kind).toBe("agentTurn");
|
|
155
|
+
expect(job?.delivery).toEqual({
|
|
156
|
+
mode: "announce",
|
|
157
|
+
channel: "qqbot",
|
|
158
|
+
to: "qqbot:c2c:123",
|
|
159
|
+
accountId: "default",
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("builds cron job with delivery envelope for cron expression", () => {
|
|
164
|
+
const result = prepareRemindCronAction({
|
|
165
|
+
action: "add",
|
|
166
|
+
content: "test reminder",
|
|
167
|
+
to: "qqbot:c2c:123",
|
|
168
|
+
time: "0 8 * * *",
|
|
169
|
+
});
|
|
170
|
+
expect(result.ok).toBe(true);
|
|
171
|
+
const job = result.ok && result.cronAction.action === "add" ? result.cronAction.job : null;
|
|
172
|
+
expect(job?.schedule.kind).toBe("cron");
|
|
173
|
+
expect(job?.delivery.to).toBe("qqbot:c2c:123");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("falls back to ctx.fallbackTo when to is omitted", () => {
|
|
177
|
+
const result = prepareRemindCronAction(
|
|
178
|
+
{ action: "add", content: "test", time: "5m" },
|
|
179
|
+
{ fallbackTo: "qqbot:c2c:ctx-target", fallbackAccountId: "alt" },
|
|
180
|
+
);
|
|
181
|
+
expect(result.ok).toBe(true);
|
|
182
|
+
const job = result.ok && result.cronAction.action === "add" ? result.cronAction.job : null;
|
|
183
|
+
expect(job?.delivery.to).toBe("qqbot:c2c:ctx-target");
|
|
184
|
+
expect(job?.delivery.accountId).toBe("alt");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("prefers AI-supplied to over ctx fallback", () => {
|
|
188
|
+
const result = prepareRemindCronAction(
|
|
189
|
+
{ action: "add", content: "test", time: "5m", to: "qqbot:group:ai-chosen" },
|
|
190
|
+
{ fallbackTo: "qqbot:c2c:ctx-target", fallbackAccountId: "alt" },
|
|
191
|
+
);
|
|
192
|
+
expect(result.ok).toBe(true);
|
|
193
|
+
const job = result.ok && result.cronAction.action === "add" ? result.cronAction.job : null;
|
|
194
|
+
expect(job?.delivery.to).toBe("qqbot:group:ai-chosen");
|
|
195
|
+
expect(job?.delivery.accountId).toBe("alt");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("returns error when neither AI nor ctx provides a target", () => {
|
|
199
|
+
const result = prepareRemindCronAction({ action: "add", content: "test", time: "5m" });
|
|
200
|
+
expect(result).toEqual({
|
|
201
|
+
ok: false,
|
|
202
|
+
error:
|
|
203
|
+
"Unable to determine delivery target for action=add. " +
|
|
204
|
+
"The reminder can only be scheduled from within an active conversation.",
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
describe("executeScheduledRemind", () => {
|
|
210
|
+
it("runs cron.add directly for relative reminders", async () => {
|
|
211
|
+
const calls: unknown[] = [];
|
|
212
|
+
const result = await executeScheduledRemind(
|
|
213
|
+
{ action: "add", content: "test reminder", to: "qqbot:c2c:123", time: "5m" },
|
|
214
|
+
{},
|
|
215
|
+
async (params) => {
|
|
216
|
+
calls.push(params);
|
|
217
|
+
return { id: "job-1" };
|
|
218
|
+
},
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
expect(calls).toHaveLength(1);
|
|
222
|
+
expect(calls[0]).toMatchObject({
|
|
223
|
+
action: "add",
|
|
224
|
+
job: {
|
|
225
|
+
sessionTarget: "isolated",
|
|
226
|
+
payload: { kind: "agentTurn" },
|
|
227
|
+
delivery: {
|
|
228
|
+
mode: "announce",
|
|
229
|
+
channel: "qqbot",
|
|
230
|
+
to: "qqbot:c2c:123",
|
|
231
|
+
accountId: "default",
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
expect(result.details).toEqual({
|
|
236
|
+
ok: true,
|
|
237
|
+
action: "add",
|
|
238
|
+
summary: '⏰ Reminder in 5m: "test reminder"',
|
|
239
|
+
cronResult: { id: "job-1" },
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("runs cron list and remove through the scheduler", async () => {
|
|
244
|
+
const calls: unknown[] = [];
|
|
245
|
+
await executeScheduledRemind({ action: "list" }, {}, async (params) => {
|
|
246
|
+
calls.push(params);
|
|
247
|
+
return { jobs: [] };
|
|
248
|
+
});
|
|
249
|
+
await executeScheduledRemind({ action: "remove", jobId: "job-1" }, {}, async (params) => {
|
|
250
|
+
calls.push(params);
|
|
251
|
+
return { ok: true };
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
expect(calls).toEqual([{ action: "list" }, { action: "remove", jobId: "job-1" }]);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("does not call scheduler when validation fails", async () => {
|
|
258
|
+
const result = await executeScheduledRemind({ action: "add", time: "5m" }, {}, async () => {
|
|
259
|
+
throw new Error("should not run");
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
expect((result.details as { error: string }).error).toContain("content");
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("returns a clear error when Gateway cron fails", async () => {
|
|
266
|
+
const result = await executeScheduledRemind(
|
|
267
|
+
{ action: "remove", jobId: "job-1" },
|
|
268
|
+
{},
|
|
269
|
+
async () => {
|
|
270
|
+
throw new Error("gateway unavailable");
|
|
271
|
+
},
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
expect(result.details).toEqual({
|
|
275
|
+
error: "Failed to run Gateway cron action: gateway unavailable",
|
|
276
|
+
action: "remove",
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
});
|