@openclaw/qqbot 2026.5.2 → 2026.5.3-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/dist/api.js +646 -0
- package/dist/approval-cg0SVahb.js +94 -0
- package/dist/channel-N6Y_Rcjp.js +551 -0
- package/dist/channel-plugin-api.js +2 -0
- package/dist/channel.setup-ByCRAe5H.js +25 -0
- package/dist/config-D6545NkC.js +94 -0
- package/dist/config-schema-DFcjQw73.js +309 -0
- package/dist/exec-approvals-COUsM6wZ.js +138 -0
- package/dist/gateway-CmSUJKSt.js +6726 -0
- package/dist/handler-runtime-Bqm6N0WG.js +120 -0
- package/dist/index.js +29 -0
- package/dist/narrowing-BoieBTIU.js +25 -0
- package/dist/outbound-BJfhwrPg.js +1357 -0
- package/dist/request-context-DXtpwWui.js +921 -0
- package/dist/resolve-D_06fV6-.js +284 -0
- package/dist/runtime-BJAS3eXW.js +18 -0
- package/dist/runtime-api.js +2 -0
- package/dist/secret-contract-api.js +56 -0
- package/dist/sender-p-B14eLG.js +1987 -0
- package/dist/setup-entry.js +15 -0
- package/dist/setup-plugin-api.js +2 -0
- package/dist/string-normalize-Ci6NM5DE.js +98 -0
- package/dist/target-parser-Y0prnrXD.js +245 -0
- package/package.json +15 -6
- package/api.ts +0 -56
- package/channel-plugin-api.ts +0 -1
- package/index.ts +0 -29
- package/runtime-api.ts +0 -9
- package/setup-entry.ts +0 -9
- package/setup-plugin-api.ts +0 -3
- package/src/bridge/approval/capability.ts +0 -237
- package/src/bridge/approval/handler-runtime.ts +0 -204
- package/src/bridge/bootstrap.ts +0 -135
- package/src/bridge/channel-entry.ts +0 -18
- package/src/bridge/commands/framework-context-adapter.ts +0 -60
- package/src/bridge/commands/framework-registration.ts +0 -47
- package/src/bridge/commands/from-parser.test.ts +0 -86
- package/src/bridge/commands/from-parser.ts +0 -60
- package/src/bridge/commands/result-dispatcher.ts +0 -76
- package/src/bridge/config-shared.ts +0 -132
- package/src/bridge/config.ts +0 -111
- package/src/bridge/gateway.ts +0 -174
- package/src/bridge/logger.ts +0 -31
- package/src/bridge/narrowing.ts +0 -31
- package/src/bridge/plugin-version.test.ts +0 -146
- package/src/bridge/plugin-version.ts +0 -102
- package/src/bridge/runtime.ts +0 -25
- package/src/bridge/sdk-adapter.ts +0 -131
- package/src/bridge/setup/finalize.ts +0 -144
- package/src/bridge/setup/surface.ts +0 -34
- package/src/bridge/tools/channel.ts +0 -58
- package/src/bridge/tools/index.ts +0 -15
- package/src/bridge/tools/remind.test.ts +0 -124
- package/src/bridge/tools/remind.ts +0 -91
- package/src/channel.setup.ts +0 -33
- package/src/channel.ts +0 -288
- package/src/command-auth.test.ts +0 -62
- package/src/config-schema.ts +0 -84
- package/src/config.test.ts +0 -364
- package/src/engine/access/access-control.test.ts +0 -198
- package/src/engine/access/access-control.ts +0 -226
- package/src/engine/access/index.ts +0 -16
- package/src/engine/access/resolve-policy.test.ts +0 -59
- package/src/engine/access/resolve-policy.ts +0 -57
- package/src/engine/access/sender-match.test.ts +0 -60
- package/src/engine/access/sender-match.ts +0 -55
- package/src/engine/access/types.ts +0 -53
- package/src/engine/adapter/audio.port.ts +0 -27
- package/src/engine/adapter/commands.port.ts +0 -22
- package/src/engine/adapter/history.port.ts +0 -52
- package/src/engine/adapter/index.ts +0 -139
- package/src/engine/adapter/mention-gate.port.ts +0 -50
- package/src/engine/adapter/types.ts +0 -38
- package/src/engine/api/api-client.ts +0 -212
- package/src/engine/api/media-chunked.test.ts +0 -336
- package/src/engine/api/media-chunked.ts +0 -622
- package/src/engine/api/media.ts +0 -218
- package/src/engine/api/messages.ts +0 -293
- package/src/engine/api/retry.ts +0 -217
- package/src/engine/api/routes.ts +0 -95
- package/src/engine/api/token.ts +0 -271
- package/src/engine/approval/index.test.ts +0 -22
- package/src/engine/approval/index.ts +0 -224
- package/src/engine/commands/builtin/log-helpers.ts +0 -319
- package/src/engine/commands/builtin/register-all.ts +0 -17
- package/src/engine/commands/builtin/register-approve.ts +0 -201
- package/src/engine/commands/builtin/register-basic.ts +0 -95
- package/src/engine/commands/builtin/register-clear-storage.ts +0 -187
- package/src/engine/commands/builtin/register-logs.ts +0 -20
- package/src/engine/commands/builtin/register-streaming.ts +0 -137
- package/src/engine/commands/builtin/state.ts +0 -31
- package/src/engine/commands/slash-command-auth.ts +0 -48
- package/src/engine/commands/slash-command-handler.ts +0 -146
- package/src/engine/commands/slash-commands-impl.test.ts +0 -8
- package/src/engine/commands/slash-commands-impl.ts +0 -61
- package/src/engine/commands/slash-commands.ts +0 -199
- package/src/engine/config/credential-backup.test.ts +0 -88
- package/src/engine/config/credential-backup.ts +0 -107
- package/src/engine/config/credentials.ts +0 -76
- package/src/engine/config/group.test.ts +0 -234
- package/src/engine/config/group.ts +0 -299
- package/src/engine/config/resolve.test.ts +0 -152
- package/src/engine/config/resolve.ts +0 -283
- package/src/engine/config/setup-logic.ts +0 -84
- package/src/engine/engine-import-boundary.test.ts +0 -73
- package/src/engine/gateway/codec.ts +0 -47
- package/src/engine/gateway/constants.ts +0 -117
- package/src/engine/gateway/event-dispatcher.ts +0 -177
- package/src/engine/gateway/gateway-connection.ts +0 -371
- package/src/engine/gateway/gateway.ts +0 -291
- package/src/engine/gateway/inbound-attachments.test.ts +0 -126
- package/src/engine/gateway/inbound-attachments.ts +0 -360
- package/src/engine/gateway/inbound-context.ts +0 -195
- package/src/engine/gateway/inbound-pipeline.self-echo.test.ts +0 -218
- package/src/engine/gateway/inbound-pipeline.ts +0 -235
- package/src/engine/gateway/interaction-handler.ts +0 -220
- package/src/engine/gateway/message-queue.test.ts +0 -282
- package/src/engine/gateway/message-queue.ts +0 -499
- package/src/engine/gateway/outbound-dispatch.test.ts +0 -231
- package/src/engine/gateway/outbound-dispatch.ts +0 -575
- package/src/engine/gateway/reconnect.ts +0 -199
- package/src/engine/gateway/stages/access-stage.ts +0 -132
- package/src/engine/gateway/stages/assembly-stage.ts +0 -156
- package/src/engine/gateway/stages/content-stage.test.ts +0 -77
- package/src/engine/gateway/stages/content-stage.ts +0 -77
- package/src/engine/gateway/stages/envelope-stage.test.ts +0 -152
- package/src/engine/gateway/stages/envelope-stage.ts +0 -144
- package/src/engine/gateway/stages/group-gate-stage.ts +0 -292
- package/src/engine/gateway/stages/index.ts +0 -18
- package/src/engine/gateway/stages/quote-stage.ts +0 -113
- package/src/engine/gateway/stages/refidx-stage.ts +0 -62
- package/src/engine/gateway/stages/stub-contexts.ts +0 -116
- package/src/engine/gateway/types.ts +0 -264
- package/src/engine/gateway/typing-keepalive.ts +0 -79
- package/src/engine/group/activation.test.ts +0 -114
- package/src/engine/group/activation.ts +0 -147
- package/src/engine/group/history.test.ts +0 -314
- package/src/engine/group/history.ts +0 -321
- package/src/engine/group/mention.test.ts +0 -141
- package/src/engine/group/mention.ts +0 -197
- package/src/engine/group/message-gating.test.ts +0 -188
- package/src/engine/group/message-gating.ts +0 -216
- package/src/engine/messaging/decode-media-path.ts +0 -82
- package/src/engine/messaging/media-source.ts +0 -215
- package/src/engine/messaging/media-type-detect.ts +0 -37
- package/src/engine/messaging/outbound-audio-port.ts +0 -38
- package/src/engine/messaging/outbound-deliver.ts +0 -810
- package/src/engine/messaging/outbound-media-send.ts +0 -702
- package/src/engine/messaging/outbound-reply.ts +0 -27
- package/src/engine/messaging/outbound-result-helpers.ts +0 -54
- package/src/engine/messaging/outbound-types.ts +0 -45
- package/src/engine/messaging/outbound.ts +0 -485
- package/src/engine/messaging/reply-dispatcher.ts +0 -597
- package/src/engine/messaging/reply-limiter.ts +0 -164
- package/src/engine/messaging/sender.ts +0 -729
- package/src/engine/messaging/streaming-c2c.ts +0 -1192
- package/src/engine/messaging/streaming-media-send.ts +0 -544
- package/src/engine/messaging/target-parser.ts +0 -104
- package/src/engine/ref/format-message-ref.ts +0 -142
- package/src/engine/ref/format-ref-entry.test.ts +0 -60
- package/src/engine/ref/format-ref-entry.ts +0 -27
- package/src/engine/ref/store.ts +0 -208
- package/src/engine/ref/types.ts +0 -27
- package/src/engine/session/known-users.ts +0 -137
- package/src/engine/session/session-store.ts +0 -204
- package/src/engine/tools/channel-api.ts +0 -244
- package/src/engine/tools/remind-logic.test.ts +0 -280
- package/src/engine/tools/remind-logic.ts +0 -377
- package/src/engine/types.ts +0 -313
- package/src/engine/utils/attachment-tags.test.ts +0 -186
- package/src/engine/utils/attachment-tags.ts +0 -174
- package/src/engine/utils/audio.test.ts +0 -250
- package/src/engine/utils/audio.ts +0 -585
- package/src/engine/utils/data-paths.ts +0 -38
- package/src/engine/utils/diagnostics.ts +0 -109
- package/src/engine/utils/file-utils.test.ts +0 -72
- package/src/engine/utils/file-utils.ts +0 -225
- package/src/engine/utils/format.test.ts +0 -68
- package/src/engine/utils/format.ts +0 -70
- package/src/engine/utils/image-size.test.ts +0 -158
- package/src/engine/utils/image-size.ts +0 -249
- package/src/engine/utils/log.test.ts +0 -28
- package/src/engine/utils/log.ts +0 -61
- package/src/engine/utils/media-tags.test.ts +0 -32
- package/src/engine/utils/media-tags.ts +0 -177
- package/src/engine/utils/payload.test.ts +0 -68
- package/src/engine/utils/payload.ts +0 -145
- package/src/engine/utils/platform-storage-laziness.test.ts +0 -63
- package/src/engine/utils/platform.test.ts +0 -148
- package/src/engine/utils/platform.ts +0 -343
- package/src/engine/utils/request-context.ts +0 -60
- package/src/engine/utils/string-normalize.ts +0 -91
- package/src/engine/utils/stt.test.ts +0 -104
- package/src/engine/utils/stt.ts +0 -100
- package/src/engine/utils/text-parsing.test.ts +0 -29
- package/src/engine/utils/text-parsing.ts +0 -155
- package/src/engine/utils/upload-cache.ts +0 -96
- package/src/engine/utils/voice-text.ts +0 -15
- package/src/exec-approvals.ts +0 -218
- package/src/manifest-schema.test.ts +0 -56
- package/src/qqbot-test-support.ts +0 -29
- package/src/types.ts +0 -210
- package/tsconfig.json +0 -16
|
@@ -0,0 +1,1987 @@
|
|
|
1
|
+
import { c as getPlatformAdapter, i as normalizeOptionalString, n as normalizeLowercaseStringOrEmpty, s as sanitizeFileName } from "./string-normalize-Ci6NM5DE.js";
|
|
2
|
+
import * as fs$1 from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import * as crypto$1 from "node:crypto";
|
|
5
|
+
import crypto from "node:crypto";
|
|
6
|
+
import * as path$1 from "node:path";
|
|
7
|
+
//#region extensions/qqbot/src/engine/types.ts
|
|
8
|
+
/**
|
|
9
|
+
* Core API layer public types.
|
|
10
|
+
*
|
|
11
|
+
* These types are independent of the root `src/types.ts` and only define
|
|
12
|
+
* what the `core/api/` modules need. The old `src/types.ts` remains
|
|
13
|
+
* untouched for backward compatibility.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Structured API error with HTTP status, path, and optional business error code.
|
|
17
|
+
*
|
|
18
|
+
* Compared to the old `api.ts` which throws plain `Error`, this carries
|
|
19
|
+
* machine-readable fields for downstream retry/fallback decisions.
|
|
20
|
+
*/
|
|
21
|
+
var ApiError = class extends Error {
|
|
22
|
+
constructor(message, httpStatus, path, bizCode, bizMessage) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.httpStatus = httpStatus;
|
|
25
|
+
this.path = path;
|
|
26
|
+
this.bizCode = bizCode;
|
|
27
|
+
this.bizMessage = bizMessage;
|
|
28
|
+
this.name = "ApiError";
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
/** Stream message input mode (C2C stream_messages API). */
|
|
32
|
+
const StreamInputMode = {
|
|
33
|
+
/** Each chunk replaces full message content. */
|
|
34
|
+
REPLACE: "replace" };
|
|
35
|
+
/** Stream message input state (numeric per QQ Open Platform). */
|
|
36
|
+
const StreamInputState = {
|
|
37
|
+
GENERATING: 1,
|
|
38
|
+
DONE: 10
|
|
39
|
+
};
|
|
40
|
+
/** Stream message content type. */
|
|
41
|
+
const StreamContentType = { MARKDOWN: "markdown" };
|
|
42
|
+
//#endregion
|
|
43
|
+
//#region extensions/qqbot/src/engine/utils/format.ts
|
|
44
|
+
/**
|
|
45
|
+
* General formatting and string utilities.
|
|
46
|
+
* 通用格式化与字符串工具。
|
|
47
|
+
*
|
|
48
|
+
* Pure utility functions with zero external dependencies.
|
|
49
|
+
* Replaces `openclaw/plugin-sdk/error-runtime` and `text-runtime`
|
|
50
|
+
* helpers for use inside engine/.
|
|
51
|
+
*
|
|
52
|
+
* NOTE: The framework `formatErrorMessage` also applies `redactSensitiveText()`
|
|
53
|
+
* for token masking. We intentionally omit that here — the framework's log
|
|
54
|
+
* pipeline handles redaction at a higher level.
|
|
55
|
+
*/
|
|
56
|
+
/**
|
|
57
|
+
* Format any error object into a readable string.
|
|
58
|
+
* 将任意错误对象格式化为可读字符串。
|
|
59
|
+
*
|
|
60
|
+
* Traverses the `.cause` chain for nested Error objects to include
|
|
61
|
+
* the full error context (e.g. network errors wrapped inside HTTP errors).
|
|
62
|
+
*/
|
|
63
|
+
function formatErrorMessage(err) {
|
|
64
|
+
if (err instanceof Error) {
|
|
65
|
+
let formatted = err.message || err.name || "Error";
|
|
66
|
+
let cause = err.cause;
|
|
67
|
+
const seen = new Set([err]);
|
|
68
|
+
while (cause && !seen.has(cause)) {
|
|
69
|
+
seen.add(cause);
|
|
70
|
+
if (cause instanceof Error) {
|
|
71
|
+
if (cause.message) formatted += ` | ${cause.message}`;
|
|
72
|
+
cause = cause.cause;
|
|
73
|
+
} else if (typeof cause === "string") {
|
|
74
|
+
formatted += ` | ${cause}`;
|
|
75
|
+
break;
|
|
76
|
+
} else break;
|
|
77
|
+
}
|
|
78
|
+
return formatted;
|
|
79
|
+
}
|
|
80
|
+
if (typeof err === "string") return err;
|
|
81
|
+
if (err === null || err === void 0 || typeof err === "number" || typeof err === "boolean" || typeof err === "bigint") return String(err);
|
|
82
|
+
try {
|
|
83
|
+
return JSON.stringify(err);
|
|
84
|
+
} catch {
|
|
85
|
+
return Object.prototype.toString.call(err);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/** Format a millisecond duration into a human-readable string (e.g. "5m 30s"). */
|
|
89
|
+
function formatDuration(durationMs) {
|
|
90
|
+
const seconds = Math.round(durationMs / 1e3);
|
|
91
|
+
if (seconds < 60) return `${seconds}s`;
|
|
92
|
+
const minutes = Math.floor(seconds / 60);
|
|
93
|
+
const remainSeconds = seconds % 60;
|
|
94
|
+
return remainSeconds > 0 ? `${minutes}m ${remainSeconds}s` : `${minutes}m`;
|
|
95
|
+
}
|
|
96
|
+
//#endregion
|
|
97
|
+
//#region extensions/qqbot/src/engine/api/api-client.ts
|
|
98
|
+
/**
|
|
99
|
+
* Core HTTP client for the QQ Open Platform REST API.
|
|
100
|
+
*
|
|
101
|
+
* Key improvements over the old `src/api.ts#apiRequest`:
|
|
102
|
+
* - `ApiClient` is an **instance** — config (baseUrl, timeout, logger, UA)
|
|
103
|
+
* is injected via the constructor, eliminating module-level globals.
|
|
104
|
+
* - Throws structured `ApiError` with httpStatus, bizCode, and path fields.
|
|
105
|
+
* - Detects HTML error pages from CDN/gateway and returns user-friendly messages.
|
|
106
|
+
* - `redactBodyKeys` replaces the hardcoded `file_data` redaction.
|
|
107
|
+
*/
|
|
108
|
+
const DEFAULT_BASE_URL = "https://api.sgroup.qq.com";
|
|
109
|
+
const DEFAULT_TIMEOUT_MS = 3e4;
|
|
110
|
+
const FILE_UPLOAD_TIMEOUT_MS = 12e4;
|
|
111
|
+
/**
|
|
112
|
+
* Stateful HTTP client for the QQ Open Platform.
|
|
113
|
+
*
|
|
114
|
+
* Usage:
|
|
115
|
+
* ```ts
|
|
116
|
+
* const client = new ApiClient({ logger, userAgent: 'QQBotPlugin/1.0' });
|
|
117
|
+
* const data = await client.request<{ url: string }>(token, 'GET', '/gateway');
|
|
118
|
+
* ```
|
|
119
|
+
*/
|
|
120
|
+
var ApiClient = class {
|
|
121
|
+
constructor(config = {}) {
|
|
122
|
+
this.baseUrl = config.baseUrl ?? DEFAULT_BASE_URL;
|
|
123
|
+
this.defaultTimeoutMs = config.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
124
|
+
this.fileUploadTimeoutMs = config.fileUploadTimeoutMs ?? FILE_UPLOAD_TIMEOUT_MS;
|
|
125
|
+
this.logger = config.logger;
|
|
126
|
+
const ua = config.userAgent ?? "QQBotPlugin/unknown";
|
|
127
|
+
this.resolveUserAgent = typeof ua === "function" ? ua : () => ua;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Send an authenticated JSON request to the QQ Open Platform.
|
|
131
|
+
*
|
|
132
|
+
* @param accessToken - Bearer token (`QQBot {token}`).
|
|
133
|
+
* @param method - HTTP method.
|
|
134
|
+
* @param path - API path (appended to baseUrl).
|
|
135
|
+
* @param body - Optional JSON body.
|
|
136
|
+
* @param options - Optional request overrides.
|
|
137
|
+
* @returns Parsed JSON response.
|
|
138
|
+
* @throws {ApiError} On HTTP or parse errors.
|
|
139
|
+
*/
|
|
140
|
+
async request(accessToken, method, path, body, options) {
|
|
141
|
+
const url = `${this.baseUrl}${path}`;
|
|
142
|
+
const headers = {
|
|
143
|
+
Authorization: `QQBot ${accessToken}`,
|
|
144
|
+
"Content-Type": "application/json",
|
|
145
|
+
"User-Agent": this.resolveUserAgent()
|
|
146
|
+
};
|
|
147
|
+
const isFileUpload = options?.uploadRequest === true || path.includes("/files") || path.includes("/upload_prepare") || path.includes("/upload_part_finish");
|
|
148
|
+
const timeout = options?.timeoutMs ?? (isFileUpload ? this.fileUploadTimeoutMs : this.defaultTimeoutMs);
|
|
149
|
+
const controller = new AbortController();
|
|
150
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
151
|
+
const fetchInit = {
|
|
152
|
+
method,
|
|
153
|
+
headers,
|
|
154
|
+
signal: controller.signal
|
|
155
|
+
};
|
|
156
|
+
if (body) fetchInit.body = JSON.stringify(body);
|
|
157
|
+
this.logger?.debug?.(`[qqbot:api] >>> ${method} ${url} (timeout: ${timeout}ms)`);
|
|
158
|
+
if (body && this.logger?.debug) {
|
|
159
|
+
const logBody = { ...body };
|
|
160
|
+
for (const key of options?.redactBodyKeys ?? ["file_data"]) if (typeof logBody[key] === "string") logBody[key] = `<redacted ${logBody[key].length} chars>`;
|
|
161
|
+
this.logger.debug(`[qqbot:api] >>> Body: ${JSON.stringify(logBody)}`);
|
|
162
|
+
}
|
|
163
|
+
let res;
|
|
164
|
+
try {
|
|
165
|
+
res = await fetch(url, fetchInit);
|
|
166
|
+
} catch (err) {
|
|
167
|
+
clearTimeout(timeoutId);
|
|
168
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
169
|
+
this.logger?.error?.(`[qqbot:api] <<< Timeout after ${timeout}ms`);
|
|
170
|
+
throw new ApiError(`Request timeout [${path}]: exceeded ${timeout}ms`, 0, path);
|
|
171
|
+
}
|
|
172
|
+
this.logger?.error?.(`[qqbot:api] <<< Network error: ${formatErrorMessage(err)}`);
|
|
173
|
+
throw new ApiError(`Network error [${path}]: ${formatErrorMessage(err)}`, 0, path);
|
|
174
|
+
} finally {
|
|
175
|
+
clearTimeout(timeoutId);
|
|
176
|
+
}
|
|
177
|
+
const traceId = res.headers.get("x-tps-trace-id") ?? "";
|
|
178
|
+
this.logger?.info?.(`[qqbot:api] <<< Status: ${res.status} ${res.statusText}${traceId ? ` | TraceId: ${traceId}` : ""}`);
|
|
179
|
+
let rawBody;
|
|
180
|
+
try {
|
|
181
|
+
rawBody = await res.text();
|
|
182
|
+
} catch (err) {
|
|
183
|
+
throw new ApiError(`Failed to read response [${path}]: ${formatErrorMessage(err)}`, res.status, path);
|
|
184
|
+
}
|
|
185
|
+
this.logger?.debug?.(`[qqbot:api] <<< Body: ${rawBody}`);
|
|
186
|
+
const isHtmlResponse = (res.headers.get("content-type") ?? "").includes("text/html") || rawBody.trimStart().startsWith("<");
|
|
187
|
+
if (!res.ok) {
|
|
188
|
+
if (isHtmlResponse) throw new ApiError(`${res.status === 502 || res.status === 503 || res.status === 504 ? "调用发生异常,请稍候重试" : res.status === 429 ? "请求过于频繁,已被限流" : `开放平台返回 HTTP ${res.status}`}(${path}),请稍后重试`, res.status, path);
|
|
189
|
+
try {
|
|
190
|
+
const error = JSON.parse(rawBody);
|
|
191
|
+
const bizCode = error.code ?? error.err_code;
|
|
192
|
+
throw new ApiError(`API Error [${path}]: ${error.message ?? rawBody}`, res.status, path, bizCode, error.message);
|
|
193
|
+
} catch (parseErr) {
|
|
194
|
+
if (parseErr instanceof ApiError) throw parseErr;
|
|
195
|
+
throw new ApiError(`API Error [${path}] HTTP ${res.status}: ${rawBody.slice(0, 200)}`, res.status, path);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (isHtmlResponse) throw new ApiError(`QQ 服务端返回了非 JSON 响应(${path}),可能是临时故障,请稍后重试`, res.status, path);
|
|
199
|
+
try {
|
|
200
|
+
return JSON.parse(rawBody);
|
|
201
|
+
} catch {
|
|
202
|
+
throw new ApiError(`开放平台响应格式异常(${path}),请稍后重试`, res.status, path);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
//#endregion
|
|
207
|
+
//#region extensions/qqbot/src/engine/utils/file-utils.ts
|
|
208
|
+
/** Maximum file size accepted by the QQ Bot one-shot upload API (base64 direct). */
|
|
209
|
+
const MAX_UPLOAD_SIZE = 20 * 1024 * 1024;
|
|
210
|
+
/** Absolute upper bound enforced on the chunked upload path (matches server policy). */
|
|
211
|
+
const CHUNKED_UPLOAD_MAX_SIZE = 100 * 1024 * 1024;
|
|
212
|
+
/**
|
|
213
|
+
* Per-{@link MediaFileType} upload metadata: the QQ Open Platform size
|
|
214
|
+
* ceiling and the Chinese display name used in user-facing error messages.
|
|
215
|
+
*
|
|
216
|
+
* Keyed by the enum value so call sites read as
|
|
217
|
+
* `MEDIA_FILE_TYPE_INFO[MediaFileType.IMAGE].maxSize`, and adding a new
|
|
218
|
+
* type forces both fields to be supplied in a single place.
|
|
219
|
+
*/
|
|
220
|
+
const MEDIA_FILE_TYPE_INFO = {
|
|
221
|
+
[1]: {
|
|
222
|
+
maxSize: 30 * 1024 * 1024,
|
|
223
|
+
name: "图片"
|
|
224
|
+
},
|
|
225
|
+
[2]: {
|
|
226
|
+
maxSize: 100 * 1024 * 1024,
|
|
227
|
+
name: "视频"
|
|
228
|
+
},
|
|
229
|
+
[3]: {
|
|
230
|
+
maxSize: 20 * 1024 * 1024,
|
|
231
|
+
name: "语音"
|
|
232
|
+
},
|
|
233
|
+
[4]: {
|
|
234
|
+
maxSize: 100 * 1024 * 1024,
|
|
235
|
+
name: "文件"
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
/** Return the Chinese display name for a media file type code. Defaults to "文件". */
|
|
239
|
+
function getFileTypeName(fileType) {
|
|
240
|
+
return MEDIA_FILE_TYPE_INFO[fileType]?.name ?? "文件";
|
|
241
|
+
}
|
|
242
|
+
/** Return the upload ceiling for a given media file type. Defaults to 100MB. */
|
|
243
|
+
function getMaxUploadSize(fileType) {
|
|
244
|
+
return MEDIA_FILE_TYPE_INFO[fileType]?.maxSize ?? CHUNKED_UPLOAD_MAX_SIZE;
|
|
245
|
+
}
|
|
246
|
+
const QQBOT_MEDIA_SSRF_POLICY = {
|
|
247
|
+
hostnameAllowlist: [
|
|
248
|
+
"*.qpic.cn",
|
|
249
|
+
"*.qq.com",
|
|
250
|
+
"*.weiyun.com",
|
|
251
|
+
"*.qq.com.cn",
|
|
252
|
+
"*.ugcimg.cn",
|
|
253
|
+
"*.myqcloud.com",
|
|
254
|
+
"*.tencentcos.cn",
|
|
255
|
+
"*.tencentcos.com"
|
|
256
|
+
],
|
|
257
|
+
allowRfc2544BenchmarkRange: true
|
|
258
|
+
};
|
|
259
|
+
/** Validate that a file is within the allowed upload size. */
|
|
260
|
+
function checkFileSize(filePath, maxSize = MAX_UPLOAD_SIZE) {
|
|
261
|
+
try {
|
|
262
|
+
const stat = fs$1.statSync(filePath);
|
|
263
|
+
if (stat.size > maxSize) {
|
|
264
|
+
const sizeMB = (stat.size / (1024 * 1024)).toFixed(1);
|
|
265
|
+
const limitMB = (maxSize / (1024 * 1024)).toFixed(0);
|
|
266
|
+
return {
|
|
267
|
+
ok: false,
|
|
268
|
+
size: stat.size,
|
|
269
|
+
error: `File is too large (${sizeMB}MB); QQ Bot API limit is ${limitMB}MB`
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
return {
|
|
273
|
+
ok: true,
|
|
274
|
+
size: stat.size
|
|
275
|
+
};
|
|
276
|
+
} catch (err) {
|
|
277
|
+
return {
|
|
278
|
+
ok: false,
|
|
279
|
+
size: 0,
|
|
280
|
+
error: `Failed to read file metadata: ${formatErrorMessage(err)}`
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
/** Read file contents asynchronously. */
|
|
285
|
+
async function readFileAsync(filePath) {
|
|
286
|
+
return fs$1.promises.readFile(filePath);
|
|
287
|
+
}
|
|
288
|
+
/** Check file readability asynchronously. */
|
|
289
|
+
async function fileExistsAsync(filePath) {
|
|
290
|
+
try {
|
|
291
|
+
await fs$1.promises.access(filePath, fs$1.constants.R_OK);
|
|
292
|
+
return true;
|
|
293
|
+
} catch {
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
/** Format a byte count into a human-readable size string. */
|
|
298
|
+
function formatFileSize(bytes) {
|
|
299
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
300
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
301
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
302
|
+
}
|
|
303
|
+
/** Infer a MIME type from the file extension. */
|
|
304
|
+
function getMimeType(filePath) {
|
|
305
|
+
return MIME_TYPES[normalizeLowercaseStringOrEmpty(path$1.extname(filePath))] ?? "application/octet-stream";
|
|
306
|
+
}
|
|
307
|
+
/** Canonical ext → MIME table. Single source of truth. */
|
|
308
|
+
const MIME_TYPES = {
|
|
309
|
+
".jpg": "image/jpeg",
|
|
310
|
+
".jpeg": "image/jpeg",
|
|
311
|
+
".png": "image/png",
|
|
312
|
+
".gif": "image/gif",
|
|
313
|
+
".webp": "image/webp",
|
|
314
|
+
".bmp": "image/bmp",
|
|
315
|
+
".mp4": "video/mp4",
|
|
316
|
+
".mov": "video/quicktime",
|
|
317
|
+
".avi": "video/x-msvideo",
|
|
318
|
+
".mkv": "video/x-matroska",
|
|
319
|
+
".webm": "video/webm",
|
|
320
|
+
".pdf": "application/pdf",
|
|
321
|
+
".doc": "application/msword",
|
|
322
|
+
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
323
|
+
".xls": "application/vnd.ms-excel",
|
|
324
|
+
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
325
|
+
".zip": "application/zip",
|
|
326
|
+
".tar": "application/x-tar",
|
|
327
|
+
".gz": "application/gzip",
|
|
328
|
+
".txt": "text/plain"
|
|
329
|
+
};
|
|
330
|
+
/** Extensions accepted as image uploads by the QQ Bot media pipeline. */
|
|
331
|
+
const IMAGE_EXTENSIONS = new Set([
|
|
332
|
+
".jpg",
|
|
333
|
+
".jpeg",
|
|
334
|
+
".png",
|
|
335
|
+
".gif",
|
|
336
|
+
".webp",
|
|
337
|
+
".bmp"
|
|
338
|
+
]);
|
|
339
|
+
/**
|
|
340
|
+
* Return the image MIME type for a local file path, or `null` if the
|
|
341
|
+
* extension is not in the supported image whitelist.
|
|
342
|
+
*
|
|
343
|
+
* Use this instead of `getMimeType` when the caller must enforce
|
|
344
|
+
* "image formats only" as a business rule (e.g. constructing a
|
|
345
|
+
* `data:image/...;base64,` URL).
|
|
346
|
+
*/
|
|
347
|
+
function getImageMimeType(filePath) {
|
|
348
|
+
const ext = normalizeLowercaseStringOrEmpty(path$1.extname(filePath));
|
|
349
|
+
if (!IMAGE_EXTENSIONS.has(ext)) return null;
|
|
350
|
+
return MIME_TYPES[ext] ?? null;
|
|
351
|
+
}
|
|
352
|
+
/** Download a remote file into a local directory. */
|
|
353
|
+
async function downloadFile(url, destDir, originalFilename) {
|
|
354
|
+
try {
|
|
355
|
+
let parsedUrl;
|
|
356
|
+
try {
|
|
357
|
+
parsedUrl = new URL(url);
|
|
358
|
+
} catch {
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
361
|
+
if (parsedUrl.protocol !== "https:") return null;
|
|
362
|
+
if (!fs$1.existsSync(destDir)) fs$1.mkdirSync(destDir, { recursive: true });
|
|
363
|
+
const fetched = await getPlatformAdapter().fetchMedia({
|
|
364
|
+
url: parsedUrl.toString(),
|
|
365
|
+
filePathHint: originalFilename,
|
|
366
|
+
ssrfPolicy: QQBOT_MEDIA_SSRF_POLICY
|
|
367
|
+
});
|
|
368
|
+
let filename = normalizeOptionalString(originalFilename) ?? "";
|
|
369
|
+
if (!filename) filename = (normalizeOptionalString(fetched.fileName) ?? path$1.basename(parsedUrl.pathname)) || "download";
|
|
370
|
+
const ts = Date.now();
|
|
371
|
+
const ext = path$1.extname(filename);
|
|
372
|
+
const safeFilename = `${path$1.basename(filename, ext) || "file"}_${ts}_${crypto.randomBytes(3).toString("hex")}${ext}`;
|
|
373
|
+
const destPath = path$1.join(destDir, safeFilename);
|
|
374
|
+
await fs$1.promises.writeFile(destPath, fetched.buffer);
|
|
375
|
+
return destPath;
|
|
376
|
+
} catch (err) {
|
|
377
|
+
console.error(`[qqbot:downloadFile] FAILED url=${url.slice(0, 120)} error=${err instanceof Error ? err.message : String(err)}`);
|
|
378
|
+
if (err instanceof Error && err.stack) console.error(`[qqbot:downloadFile] stack=${err.stack.split("\n").slice(0, 3).join(" | ")}`);
|
|
379
|
+
if (err instanceof Error && err.cause) console.error(`[qqbot:downloadFile] cause=${formatErrorMessage(err.cause)}`);
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
//#endregion
|
|
384
|
+
//#region extensions/qqbot/src/engine/api/retry.ts
|
|
385
|
+
/**
|
|
386
|
+
* Execute an async operation with configurable retry semantics.
|
|
387
|
+
*
|
|
388
|
+
* @param fn - The async operation to retry.
|
|
389
|
+
* @param policy - Standard retry configuration.
|
|
390
|
+
* @param persistentPolicy - Optional persistent retry for specific error codes.
|
|
391
|
+
* @param logger - Optional logger for retry diagnostics.
|
|
392
|
+
* @returns The result of the first successful invocation.
|
|
393
|
+
*/
|
|
394
|
+
async function withRetry(fn, policy, persistentPolicy, logger) {
|
|
395
|
+
let lastError = null;
|
|
396
|
+
for (let attempt = 0; attempt <= policy.maxRetries; attempt++) try {
|
|
397
|
+
return await fn();
|
|
398
|
+
} catch (err) {
|
|
399
|
+
lastError = err instanceof Error ? err : new Error(formatErrorMessage(err));
|
|
400
|
+
if (persistentPolicy?.shouldPersistRetry(lastError)) {
|
|
401
|
+
(logger?.warn ?? logger?.error)?.(`[qqbot:retry] Hit persistent-retry trigger, entering persistent loop (timeout=${persistentPolicy.timeoutMs / 1e3}s)`);
|
|
402
|
+
return await persistentRetryLoop(fn, persistentPolicy, logger);
|
|
403
|
+
}
|
|
404
|
+
if (policy.shouldRetry?.(lastError, attempt) === false) throw lastError;
|
|
405
|
+
if (attempt < policy.maxRetries) {
|
|
406
|
+
const delay = policy.backoff === "exponential" ? policy.baseDelayMs * 2 ** attempt : policy.baseDelayMs;
|
|
407
|
+
logger?.debug?.(`[qqbot:retry] Attempt ${attempt + 1} failed, retrying in ${delay}ms: ${lastError.message.slice(0, 100)}`);
|
|
408
|
+
await sleep$1(delay);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
throw lastError;
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Persistent retry loop: fixed-interval retries bounded by a total timeout.
|
|
415
|
+
*
|
|
416
|
+
* Used for `upload_part_finish` when the server returns specific business
|
|
417
|
+
* error codes indicating the backend is still processing.
|
|
418
|
+
*/
|
|
419
|
+
async function persistentRetryLoop(fn, policy, logger) {
|
|
420
|
+
const deadline = Date.now() + policy.timeoutMs;
|
|
421
|
+
let attempt = 0;
|
|
422
|
+
let lastError = null;
|
|
423
|
+
while (Date.now() < deadline) try {
|
|
424
|
+
const result = await fn();
|
|
425
|
+
logger?.debug?.(`[qqbot:retry] Persistent retry succeeded after ${attempt} retries`);
|
|
426
|
+
return result;
|
|
427
|
+
} catch (err) {
|
|
428
|
+
lastError = err instanceof Error ? err : new Error(formatErrorMessage(err));
|
|
429
|
+
if (!policy.shouldPersistRetry(lastError)) {
|
|
430
|
+
logger?.error?.(`[qqbot:retry] Persistent retry: error is no longer retryable, aborting`);
|
|
431
|
+
throw lastError;
|
|
432
|
+
}
|
|
433
|
+
attempt++;
|
|
434
|
+
const remaining = deadline - Date.now();
|
|
435
|
+
if (remaining <= 0) break;
|
|
436
|
+
const actualDelay = Math.min(policy.intervalMs, remaining);
|
|
437
|
+
(logger?.warn ?? logger?.error)?.(`[qqbot:retry] Persistent retry #${attempt}: retrying in ${actualDelay}ms (remaining=${Math.round(remaining / 1e3)}s)`);
|
|
438
|
+
await sleep$1(actualDelay);
|
|
439
|
+
}
|
|
440
|
+
logger?.error?.(`[qqbot:retry] Persistent retry timed out after ${policy.timeoutMs / 1e3}s (${attempt} attempts)`);
|
|
441
|
+
throw lastError ?? /* @__PURE__ */ new Error(`Persistent retry timed out (${policy.timeoutMs / 1e3}s)`);
|
|
442
|
+
}
|
|
443
|
+
function sleep$1(ms) {
|
|
444
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
445
|
+
}
|
|
446
|
+
/** Standard upload retry: exponential backoff, skip 400/401/timeout errors. */
|
|
447
|
+
const UPLOAD_RETRY_POLICY = {
|
|
448
|
+
maxRetries: 2,
|
|
449
|
+
baseDelayMs: 1e3,
|
|
450
|
+
backoff: "exponential",
|
|
451
|
+
shouldRetry: (error) => {
|
|
452
|
+
const msg = error.message;
|
|
453
|
+
return !(msg.includes("400") || msg.includes("401") || msg.includes("Invalid") || msg.includes("timeout") || msg.includes("Timeout"));
|
|
454
|
+
}
|
|
455
|
+
};
|
|
456
|
+
/** Complete-upload retry: unconditional retry with exponential backoff. */
|
|
457
|
+
const COMPLETE_UPLOAD_RETRY_POLICY = {
|
|
458
|
+
maxRetries: 2,
|
|
459
|
+
baseDelayMs: 2e3,
|
|
460
|
+
backoff: "exponential"
|
|
461
|
+
};
|
|
462
|
+
/** Part-finish standard retry policy. */
|
|
463
|
+
const PART_FINISH_RETRY_POLICY = {
|
|
464
|
+
maxRetries: 2,
|
|
465
|
+
baseDelayMs: 1e3,
|
|
466
|
+
backoff: "exponential"
|
|
467
|
+
};
|
|
468
|
+
/**
|
|
469
|
+
* Build a persistent retry policy for part-finish with a specific timeout.
|
|
470
|
+
*
|
|
471
|
+
* @param retryTimeoutMs - Total timeout (defaults to 2 minutes).
|
|
472
|
+
* @param retryableCodes - Business error codes that trigger persistent retry.
|
|
473
|
+
*/
|
|
474
|
+
function buildPartFinishPersistentPolicy(retryTimeoutMs, retryableCodes = PART_FINISH_RETRYABLE_CODES) {
|
|
475
|
+
return {
|
|
476
|
+
timeoutMs: retryTimeoutMs ?? 120 * 1e3,
|
|
477
|
+
intervalMs: 1e3,
|
|
478
|
+
shouldPersistRetry: (error) => {
|
|
479
|
+
if (retryableCodes.size === 0) return false;
|
|
480
|
+
if ("bizCode" in error && typeof error.bizCode === "number") return retryableCodes.has(error.bizCode);
|
|
481
|
+
return false;
|
|
482
|
+
}
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
/** Business error codes that trigger persistent part-finish retry. */
|
|
486
|
+
const PART_FINISH_RETRYABLE_CODES = new Set([40093001]);
|
|
487
|
+
/** upload_prepare error code indicating daily limit exceeded. */
|
|
488
|
+
const UPLOAD_PREPARE_FALLBACK_CODE = 40093002;
|
|
489
|
+
//#endregion
|
|
490
|
+
//#region extensions/qqbot/src/engine/api/routes.ts
|
|
491
|
+
/**
|
|
492
|
+
* Build the message-send path for C2C or Group.
|
|
493
|
+
*
|
|
494
|
+
* - C2C: `/v2/users/{id}/messages`
|
|
495
|
+
* - Group: `/v2/groups/{id}/messages`
|
|
496
|
+
*/
|
|
497
|
+
function messagePath(scope, targetId) {
|
|
498
|
+
return scope === "c2c" ? `/v2/users/${targetId}/messages` : `/v2/groups/${targetId}/messages`;
|
|
499
|
+
}
|
|
500
|
+
/** Channel message path. */
|
|
501
|
+
function channelMessagePath(channelId) {
|
|
502
|
+
return `/channels/${channelId}/messages`;
|
|
503
|
+
}
|
|
504
|
+
/** DM (direct message inside a guild) path. */
|
|
505
|
+
function dmMessagePath(guildId) {
|
|
506
|
+
return `/dms/${guildId}/messages`;
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* Build the media upload (small-file) path for C2C or Group.
|
|
510
|
+
*
|
|
511
|
+
* - C2C: `/v2/users/{id}/files`
|
|
512
|
+
* - Group: `/v2/groups/{id}/files`
|
|
513
|
+
*/
|
|
514
|
+
function mediaUploadPath(scope, targetId) {
|
|
515
|
+
return scope === "c2c" ? `/v2/users/${targetId}/files` : `/v2/groups/${targetId}/files`;
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* Build the upload_prepare path for C2C or Group.
|
|
519
|
+
*
|
|
520
|
+
* - C2C: `/v2/users/{id}/upload_prepare`
|
|
521
|
+
* - Group: `/v2/groups/{id}/upload_prepare`
|
|
522
|
+
*/
|
|
523
|
+
function uploadPreparePath(scope, targetId) {
|
|
524
|
+
return scope === "c2c" ? `/v2/users/${targetId}/upload_prepare` : `/v2/groups/${targetId}/upload_prepare`;
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* Build the upload_part_finish path for C2C or Group.
|
|
528
|
+
*/
|
|
529
|
+
function uploadPartFinishPath(scope, targetId) {
|
|
530
|
+
return scope === "c2c" ? `/v2/users/${targetId}/upload_part_finish` : `/v2/groups/${targetId}/upload_part_finish`;
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* Build the complete-upload (files) path for C2C or Group.
|
|
534
|
+
* (Same as mediaUploadPath — the complete endpoint reuses the files path.)
|
|
535
|
+
*/
|
|
536
|
+
function uploadCompletePath(scope, targetId) {
|
|
537
|
+
return mediaUploadPath(scope, targetId);
|
|
538
|
+
}
|
|
539
|
+
/** Stream message path (C2C only). */
|
|
540
|
+
function streamMessagePath(openid) {
|
|
541
|
+
return `/v2/users/${openid}/stream_messages`;
|
|
542
|
+
}
|
|
543
|
+
/** Gateway URL path. */
|
|
544
|
+
function gatewayPath() {
|
|
545
|
+
return "/gateway";
|
|
546
|
+
}
|
|
547
|
+
/** Interaction acknowledgement path. */
|
|
548
|
+
function interactionPath(interactionId) {
|
|
549
|
+
return `/interactions/${interactionId}`;
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Generate a message sequence number in the 0..65535 range.
|
|
553
|
+
*
|
|
554
|
+
* Used by both `messages.ts` and `media.ts` to avoid duplicate definitions.
|
|
555
|
+
*/
|
|
556
|
+
function getNextMsgSeq(_msgId) {
|
|
557
|
+
return (Date.now() % 1e8 ^ Math.floor(Math.random() * 65536)) % 65536;
|
|
558
|
+
}
|
|
559
|
+
//#endregion
|
|
560
|
+
//#region extensions/qqbot/src/engine/api/media-chunked.ts
|
|
561
|
+
/**
|
|
562
|
+
* Chunked media upload for the QQ Open Platform.
|
|
563
|
+
*
|
|
564
|
+
* ## Flow (mirrors the upload sequence diagram)
|
|
565
|
+
*
|
|
566
|
+
* 1. `upload_prepare` — submit file metadata + (md5 / sha1 / md5_10m) hashes,
|
|
567
|
+
* receive `{ upload_id, block_size, parts[], concurrency?, retry_timeout? }`.
|
|
568
|
+
* 2. For every part (parallelized under a bounded concurrency):
|
|
569
|
+
* a. Read the part bytes (stream from disk or slice in-memory buffer).
|
|
570
|
+
* b. PUT the bytes to the pre-signed COS URL.
|
|
571
|
+
* c. POST `upload_part_finish { upload_id, part_index, block_size, md5 }`,
|
|
572
|
+
* retrying under {@link PART_FINISH_RETRY_POLICY} + the persistent
|
|
573
|
+
* retry loop for {@link PART_FINISH_RETRYABLE_CODES}.
|
|
574
|
+
* 3. POST `complete_upload { upload_id }` — returns `{ file_uuid, file_info,
|
|
575
|
+
* ttl }` identical to the one-shot path.
|
|
576
|
+
* 4. If `upload_prepare` returns {@link UPLOAD_PREPARE_FALLBACK_CODE}
|
|
577
|
+
* (`40093002` — daily upload quota exceeded), throw
|
|
578
|
+
* {@link UploadDailyLimitExceededError} so the upper layer can surface a
|
|
579
|
+
* user-facing message. The dispatcher is responsible for the fallback
|
|
580
|
+
* (there is no server path that will accept the file at this point).
|
|
581
|
+
*
|
|
582
|
+
* ## Why a class
|
|
583
|
+
*
|
|
584
|
+
* Mirrors {@link MediaApi}: injects {@link ApiClient}, {@link TokenManager},
|
|
585
|
+
* the upload cache adapter, an optional filename sanitizer, and a logger.
|
|
586
|
+
* Keeping the client singleton plumbing consistent means only one place
|
|
587
|
+
* manages UA / baseUrl / file-upload timeouts.
|
|
588
|
+
*
|
|
589
|
+
* ## Upload cache integration
|
|
590
|
+
*
|
|
591
|
+
* Chunked uploads participate in the same `file_info` cache as
|
|
592
|
+
* {@link MediaApi.uploadMedia}. The cache key is derived from the full-file
|
|
593
|
+
* md5 (already computed for `upload_prepare`) so repeat sends of the same
|
|
594
|
+
* large file hit the cache before we even talk to `upload_prepare`.
|
|
595
|
+
*/
|
|
596
|
+
/**
|
|
597
|
+
* Raised when `upload_prepare` returns {@link UPLOAD_PREPARE_FALLBACK_CODE}
|
|
598
|
+
* (40093002). Carries enough context for the outbound layer to render a
|
|
599
|
+
* user-facing fallback message (file name, size, and the originating
|
|
600
|
+
* local path when available).
|
|
601
|
+
*/
|
|
602
|
+
var UploadDailyLimitExceededError = class extends Error {
|
|
603
|
+
constructor(filePath, fileSize, originalMessage) {
|
|
604
|
+
super(originalMessage);
|
|
605
|
+
this.filePath = filePath;
|
|
606
|
+
this.fileSize = fileSize;
|
|
607
|
+
this.name = "UploadDailyLimitExceededError";
|
|
608
|
+
}
|
|
609
|
+
};
|
|
610
|
+
/** Default concurrency when the server does not specify one. */
|
|
611
|
+
const DEFAULT_CONCURRENT_PARTS = 1;
|
|
612
|
+
/** Hard cap on per-upload concurrency regardless of what the server returns. */
|
|
613
|
+
const MAX_CONCURRENT_PARTS = 10;
|
|
614
|
+
/**
|
|
615
|
+
* Upper bound on the persistent-retry window for `upload_part_finish`.
|
|
616
|
+
*
|
|
617
|
+
* The server may suggest `retry_timeout` via `upload_prepare` — we honor
|
|
618
|
+
* it but clamp to 10 minutes so a runaway server can't hold the caller
|
|
619
|
+
* hostage.
|
|
620
|
+
*/
|
|
621
|
+
const MAX_PART_FINISH_RETRY_TIMEOUT_MS = 600 * 1e3;
|
|
622
|
+
/** Per-part PUT timeout (5 minutes). Matches the low-bandwidth tolerance. */
|
|
623
|
+
const PART_UPLOAD_TIMEOUT_MS = 3e5;
|
|
624
|
+
/**
|
|
625
|
+
* Boundary used by `md5_10m` — first 10,002,432 bytes.
|
|
626
|
+
*
|
|
627
|
+
* Files smaller than this return the whole-file md5 for `md5_10m` (per the
|
|
628
|
+
* server contract).
|
|
629
|
+
*/
|
|
630
|
+
const MD5_10M_SIZE = 10002432;
|
|
631
|
+
/**
|
|
632
|
+
* Chunked upload module. Stateless across calls — see
|
|
633
|
+
* {@link ChunkedMediaApi.uploadChunked} for the main entry.
|
|
634
|
+
*/
|
|
635
|
+
var ChunkedMediaApi = class {
|
|
636
|
+
constructor(client, tokenManager, config = {}) {
|
|
637
|
+
this.client = client;
|
|
638
|
+
this.tokenManager = tokenManager;
|
|
639
|
+
this.logger = config.logger;
|
|
640
|
+
this.cache = config.uploadCache;
|
|
641
|
+
this.sanitize = config.sanitizeFileName ?? ((n) => n);
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* Upload a {@link MediaSource} via the chunked endpoint. Only `localPath`
|
|
645
|
+
* and `buffer` sources are accepted — `url` / `base64` must fall through
|
|
646
|
+
* to {@link MediaApi.uploadMedia}.
|
|
647
|
+
*
|
|
648
|
+
* @throws {UploadDailyLimitExceededError} when `upload_prepare` returns
|
|
649
|
+
* {@link UPLOAD_PREPARE_FALLBACK_CODE}.
|
|
650
|
+
*/
|
|
651
|
+
async uploadChunked(opts) {
|
|
652
|
+
const prefix = opts.logPrefix ?? "[qqbot:chunked-upload]";
|
|
653
|
+
const input = resolveSource(opts.source, opts.fileName);
|
|
654
|
+
const displayName = input.fileName;
|
|
655
|
+
const fileSize = input.size;
|
|
656
|
+
const pathLabel = input.kind === "localPath" ? input.path : "<buffer>";
|
|
657
|
+
this.logger?.info?.(`${prefix} Start: file=${displayName} size=${formatFileSize(fileSize)} type=${opts.fileType}`);
|
|
658
|
+
const hashes = await computeHashes(input);
|
|
659
|
+
this.logger?.debug?.(`${prefix} hashes: md5=${hashes.md5} sha1=${hashes.sha1} md5_10m=${hashes.md5_10m}`);
|
|
660
|
+
if (this.cache) {
|
|
661
|
+
const cached = this.cache.get(hashes.md5, opts.scope, opts.targetId, opts.fileType);
|
|
662
|
+
if (cached) {
|
|
663
|
+
this.logger?.info?.(`${prefix} cache HIT (md5=${hashes.md5.slice(0, 8)}) — skipping chunked upload`);
|
|
664
|
+
return {
|
|
665
|
+
file_uuid: "",
|
|
666
|
+
file_info: cached,
|
|
667
|
+
ttl: 0
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
const fileNameForPrepare = opts.fileType === 4 ? this.sanitize(displayName) : displayName;
|
|
672
|
+
const prepareResp = await this.callUploadPrepare(opts, fileNameForPrepare, fileSize, hashes, pathLabel);
|
|
673
|
+
const { upload_id, parts } = prepareResp;
|
|
674
|
+
const block_size = prepareResp.block_size;
|
|
675
|
+
const maxConcurrent = Math.min(prepareResp.concurrency ? prepareResp.concurrency : DEFAULT_CONCURRENT_PARTS, MAX_CONCURRENT_PARTS);
|
|
676
|
+
const retryTimeoutMs = prepareResp.retry_timeout ? Math.min(prepareResp.retry_timeout * 1e3, MAX_PART_FINISH_RETRY_TIMEOUT_MS) : void 0;
|
|
677
|
+
this.logger?.info?.(`${prefix} prepared: upload_id=${upload_id} block=${formatFileSize(block_size)} parts=${parts.length} concurrency=${maxConcurrent}`);
|
|
678
|
+
let completedParts = 0;
|
|
679
|
+
let uploadedBytes = 0;
|
|
680
|
+
const uploadPart = async (part) => {
|
|
681
|
+
const partIndex = part.index;
|
|
682
|
+
const offset = (partIndex - 1) * block_size;
|
|
683
|
+
const length = Math.min(block_size, fileSize - offset);
|
|
684
|
+
const partBuffer = await readPart(input, offset, length);
|
|
685
|
+
const md5Hex = crypto$1.createHash("md5").update(partBuffer).digest("hex");
|
|
686
|
+
this.logger?.debug?.(`${prefix} part ${partIndex}/${parts.length}: ${formatFileSize(length)} offset=${offset} md5=${md5Hex}`);
|
|
687
|
+
await putToPresignedUrl(part.presigned_url, partBuffer, partIndex, parts.length, this.logger, prefix);
|
|
688
|
+
await this.callUploadPartFinish(opts, upload_id, partIndex, length, md5Hex, retryTimeoutMs);
|
|
689
|
+
completedParts++;
|
|
690
|
+
uploadedBytes += length;
|
|
691
|
+
this.logger?.info?.(`${prefix} part ${partIndex}/${parts.length} done (${completedParts}/${parts.length})`);
|
|
692
|
+
opts.onProgress?.({
|
|
693
|
+
completedParts,
|
|
694
|
+
totalParts: parts.length,
|
|
695
|
+
uploadedBytes,
|
|
696
|
+
totalBytes: fileSize
|
|
697
|
+
});
|
|
698
|
+
};
|
|
699
|
+
try {
|
|
700
|
+
await runWithConcurrency(parts.map((part) => () => uploadPart(part)), maxConcurrent);
|
|
701
|
+
} finally {}
|
|
702
|
+
this.logger?.info?.(`${prefix} all parts uploaded, completing...`);
|
|
703
|
+
const result = await this.callCompleteUpload(opts, upload_id);
|
|
704
|
+
this.logger?.info?.(`${prefix} completed: file_uuid=${result.file_uuid} ttl=${result.ttl}s`);
|
|
705
|
+
if (this.cache && result.file_info && result.ttl > 0) this.cache.set(hashes.md5, opts.scope, opts.targetId, opts.fileType, result.file_info, result.file_uuid, result.ttl);
|
|
706
|
+
return result;
|
|
707
|
+
}
|
|
708
|
+
async callUploadPrepare(opts, fileName, fileSize, hashes, pathLabel) {
|
|
709
|
+
const token = await this.tokenManager.getAccessToken(opts.creds.appId, opts.creds.clientSecret);
|
|
710
|
+
const path = uploadPreparePath(opts.scope, opts.targetId);
|
|
711
|
+
try {
|
|
712
|
+
return await this.client.request(token, "POST", path, {
|
|
713
|
+
file_type: opts.fileType,
|
|
714
|
+
file_name: fileName,
|
|
715
|
+
file_size: fileSize,
|
|
716
|
+
md5: hashes.md5,
|
|
717
|
+
sha1: hashes.sha1,
|
|
718
|
+
md5_10m: hashes.md5_10m
|
|
719
|
+
}, { uploadRequest: true });
|
|
720
|
+
} catch (err) {
|
|
721
|
+
if (err instanceof ApiError && err.bizCode === 40093002) throw new UploadDailyLimitExceededError(pathLabel, fileSize, err.message);
|
|
722
|
+
throw err;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
async callUploadPartFinish(opts, uploadId, partIndex, blockSize, md5, retryTimeoutMs) {
|
|
726
|
+
const persistentPolicy = buildPartFinishPersistentPolicy(retryTimeoutMs);
|
|
727
|
+
const path = uploadPartFinishPath(opts.scope, opts.targetId);
|
|
728
|
+
await withRetry(async () => {
|
|
729
|
+
const token = await this.tokenManager.getAccessToken(opts.creds.appId, opts.creds.clientSecret);
|
|
730
|
+
return this.client.request(token, "POST", path, {
|
|
731
|
+
upload_id: uploadId,
|
|
732
|
+
part_index: partIndex,
|
|
733
|
+
block_size: blockSize,
|
|
734
|
+
md5
|
|
735
|
+
}, { uploadRequest: true });
|
|
736
|
+
}, PART_FINISH_RETRY_POLICY, persistentPolicy, this.logger);
|
|
737
|
+
}
|
|
738
|
+
async callCompleteUpload(opts, uploadId) {
|
|
739
|
+
const path = uploadCompletePath(opts.scope, opts.targetId);
|
|
740
|
+
return withRetry(async () => {
|
|
741
|
+
const token = await this.tokenManager.getAccessToken(opts.creds.appId, opts.creds.clientSecret);
|
|
742
|
+
return this.client.request(token, "POST", path, { upload_id: uploadId }, { uploadRequest: true });
|
|
743
|
+
}, COMPLETE_UPLOAD_RETRY_POLICY, void 0, this.logger);
|
|
744
|
+
}
|
|
745
|
+
};
|
|
746
|
+
function resolveSource(source, fileNameOverride) {
|
|
747
|
+
if (source.kind === "localPath") {
|
|
748
|
+
const inferredName = source.path.split(/[/\\]/).pop() || "file";
|
|
749
|
+
return {
|
|
750
|
+
kind: "localPath",
|
|
751
|
+
path: source.path,
|
|
752
|
+
size: source.size,
|
|
753
|
+
fileName: fileNameOverride ?? inferredName
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
if (source.kind === "buffer") return {
|
|
757
|
+
kind: "buffer",
|
|
758
|
+
buffer: source.buffer,
|
|
759
|
+
size: source.buffer.length,
|
|
760
|
+
fileName: fileNameOverride ?? source.fileName ?? "file"
|
|
761
|
+
};
|
|
762
|
+
throw new Error(`ChunkedMediaApi: unsupported source kind '${source.kind}'. Chunked upload only supports 'localPath' and 'buffer'; route 'url'/'base64' through the one-shot uploader.`);
|
|
763
|
+
}
|
|
764
|
+
async function readPart(input, offset, length) {
|
|
765
|
+
if (input.kind === "buffer") return input.buffer.subarray(offset, offset + length);
|
|
766
|
+
const handle = await fs$1.promises.open(input.path, "r");
|
|
767
|
+
try {
|
|
768
|
+
const buf = Buffer.alloc(length);
|
|
769
|
+
const { bytesRead } = await handle.read(buf, 0, length, offset);
|
|
770
|
+
return bytesRead < length ? buf.subarray(0, bytesRead) : buf;
|
|
771
|
+
} finally {
|
|
772
|
+
await handle.close();
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* Stream the source once to compute md5 + sha1 + md5_10m.
|
|
777
|
+
*
|
|
778
|
+
* For buffer inputs the three hashes are computed in a single pass over
|
|
779
|
+
* the existing memory. For localPath inputs a ReadStream drives the
|
|
780
|
+
* hashers so memory use stays constant.
|
|
781
|
+
*/
|
|
782
|
+
async function computeHashes(input) {
|
|
783
|
+
if (input.kind === "buffer") {
|
|
784
|
+
const md5 = crypto$1.createHash("md5").update(input.buffer).digest("hex");
|
|
785
|
+
return {
|
|
786
|
+
md5,
|
|
787
|
+
sha1: crypto$1.createHash("sha1").update(input.buffer).digest("hex"),
|
|
788
|
+
md5_10m: input.size > MD5_10M_SIZE ? crypto$1.createHash("md5").update(input.buffer.subarray(0, MD5_10M_SIZE)).digest("hex") : md5
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
return new Promise((resolve, reject) => {
|
|
792
|
+
const md5 = crypto$1.createHash("md5");
|
|
793
|
+
const sha1 = crypto$1.createHash("sha1");
|
|
794
|
+
const md5_10m = crypto$1.createHash("md5");
|
|
795
|
+
let consumed = 0;
|
|
796
|
+
const needsMd5_10m = input.size > MD5_10M_SIZE;
|
|
797
|
+
const stream = fs$1.createReadStream(input.path);
|
|
798
|
+
stream.on("data", (chunk) => {
|
|
799
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
800
|
+
md5.update(buf);
|
|
801
|
+
sha1.update(buf);
|
|
802
|
+
if (needsMd5_10m) {
|
|
803
|
+
const remaining = MD5_10M_SIZE - consumed;
|
|
804
|
+
if (remaining > 0) md5_10m.update(remaining >= buf.length ? buf : buf.subarray(0, remaining));
|
|
805
|
+
}
|
|
806
|
+
consumed += buf.length;
|
|
807
|
+
});
|
|
808
|
+
stream.on("end", () => {
|
|
809
|
+
const md5Hex = md5.digest("hex");
|
|
810
|
+
resolve({
|
|
811
|
+
md5: md5Hex,
|
|
812
|
+
sha1: sha1.digest("hex"),
|
|
813
|
+
md5_10m: needsMd5_10m ? md5_10m.digest("hex") : md5Hex
|
|
814
|
+
});
|
|
815
|
+
});
|
|
816
|
+
stream.on("error", reject);
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
/** Per-part retry budget for the COS PUT call (exponential backoff). */
|
|
820
|
+
const PART_UPLOAD_MAX_RETRIES = 2;
|
|
821
|
+
async function putToPresignedUrl(presignedUrl, data, partIndex, totalParts, logger, prefix) {
|
|
822
|
+
let lastError = null;
|
|
823
|
+
for (let attempt = 0; attempt <= PART_UPLOAD_MAX_RETRIES; attempt++) {
|
|
824
|
+
const controller = new AbortController();
|
|
825
|
+
const timeoutId = setTimeout(() => controller.abort(), PART_UPLOAD_TIMEOUT_MS);
|
|
826
|
+
try {
|
|
827
|
+
const ab = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
|
|
828
|
+
const startTime = Date.now();
|
|
829
|
+
const response = await fetch(presignedUrl, {
|
|
830
|
+
method: "PUT",
|
|
831
|
+
body: new Blob([ab]),
|
|
832
|
+
headers: { "Content-Length": String(data.length) },
|
|
833
|
+
signal: controller.signal
|
|
834
|
+
});
|
|
835
|
+
const elapsed = Date.now() - startTime;
|
|
836
|
+
const requestId = response.headers.get("x-cos-request-id") ?? "-";
|
|
837
|
+
const etag = response.headers.get("ETag") ?? "-";
|
|
838
|
+
if (!response.ok) {
|
|
839
|
+
const body = await response.text().catch(() => "");
|
|
840
|
+
logger?.error?.(`${prefix} PUT part ${partIndex}/${totalParts}: HTTP ${response.status} ${response.statusText} (${elapsed}ms, requestId=${requestId}) body=${body.slice(0, 160)}`);
|
|
841
|
+
throw new Error(`COS PUT failed: ${response.status} ${response.statusText} - ${body.slice(0, 120)}`);
|
|
842
|
+
}
|
|
843
|
+
logger?.debug?.(`${prefix} PUT part ${partIndex}/${totalParts} OK (${elapsed}ms ETag=${etag} requestId=${requestId})`);
|
|
844
|
+
return;
|
|
845
|
+
} catch (err) {
|
|
846
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
847
|
+
if (lastError.name === "AbortError") lastError = /* @__PURE__ */ new Error(`Part ${partIndex}/${totalParts} upload timeout after ${PART_UPLOAD_TIMEOUT_MS}ms`);
|
|
848
|
+
if (attempt < PART_UPLOAD_MAX_RETRIES) {
|
|
849
|
+
const delay = 1e3 * 2 ** attempt;
|
|
850
|
+
(logger?.warn ?? logger?.error)?.(`${prefix} PUT part ${partIndex}/${totalParts} attempt ${attempt + 1} failed (${lastError.message.slice(0, 120)}), retrying in ${delay}ms`);
|
|
851
|
+
await sleep(delay);
|
|
852
|
+
}
|
|
853
|
+
} finally {
|
|
854
|
+
clearTimeout(timeoutId);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
throw lastError ?? /* @__PURE__ */ new Error(`Part ${partIndex}/${totalParts} upload failed`);
|
|
858
|
+
}
|
|
859
|
+
/**
|
|
860
|
+
* Batch-mode concurrency limiter. Deliberately simple: dispatch N tasks at
|
|
861
|
+
* a time and wait for the whole batch to settle before the next batch.
|
|
862
|
+
*
|
|
863
|
+
* A pool / queue implementation would recover some throughput when tasks
|
|
864
|
+
* have heavy variance, but part uploads are size-uniform (last part can be
|
|
865
|
+
* short) so the extra complexity is not worth it.
|
|
866
|
+
*/
|
|
867
|
+
async function runWithConcurrency(tasks, maxConcurrent) {
|
|
868
|
+
for (let i = 0; i < tasks.length; i += maxConcurrent) {
|
|
869
|
+
const batch = tasks.slice(i, i + maxConcurrent);
|
|
870
|
+
await Promise.all(batch.map((task) => task()));
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
function sleep(ms) {
|
|
874
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
875
|
+
}
|
|
876
|
+
//#endregion
|
|
877
|
+
//#region extensions/qqbot/src/engine/api/media.ts
|
|
878
|
+
/**
|
|
879
|
+
* Media upload API for the QQ Open Platform (small-file direct upload).
|
|
880
|
+
*
|
|
881
|
+
* Key improvements:
|
|
882
|
+
* - Unified `uploadMedia(scope, ...)` replaces `uploadC2CMedia` + `uploadGroupMedia`.
|
|
883
|
+
* - Upload cache integration via composition (passed in constructor).
|
|
884
|
+
* - Uses `withRetry` from the shared retry engine.
|
|
885
|
+
*
|
|
886
|
+
* Chunked upload for files above `LARGE_FILE_THRESHOLD` is tracked by
|
|
887
|
+
* {@link ./media-chunked.ts}; this module currently handles only the
|
|
888
|
+
* one-shot path.
|
|
889
|
+
*/
|
|
890
|
+
/**
|
|
891
|
+
* Small-file media upload module.
|
|
892
|
+
*
|
|
893
|
+
* Handles base64 and URL-based uploads with optional caching and retry.
|
|
894
|
+
*/
|
|
895
|
+
var MediaApi = class {
|
|
896
|
+
constructor(client, tokenManager, config = {}) {
|
|
897
|
+
this.client = client;
|
|
898
|
+
this.tokenManager = tokenManager;
|
|
899
|
+
this.logger = config.logger;
|
|
900
|
+
this.cache = config.uploadCache;
|
|
901
|
+
this.sanitize = config.sanitizeFileName ?? ((n) => n);
|
|
902
|
+
}
|
|
903
|
+
/**
|
|
904
|
+
* Upload media via base64, URL, buffer, or local file path to a C2C or Group target.
|
|
905
|
+
*
|
|
906
|
+
* The `localPath` and `buffer` branches are equivalent to `fileData` for the
|
|
907
|
+
* current one-shot implementation — the file is read and base64-encoded
|
|
908
|
+
* synchronously. They exist as first-class inputs so that a future chunked
|
|
909
|
+
* upload implementation can consume them without interface churn.
|
|
910
|
+
*
|
|
911
|
+
* @param scope - `'c2c'` or `'group'`.
|
|
912
|
+
* @param targetId - User openid or group openid.
|
|
913
|
+
* @param fileType - Media file type code.
|
|
914
|
+
* @param creds - Authentication credentials.
|
|
915
|
+
* @param opts - Upload options. Exactly one of `url`/`fileData`/`buffer`/`localPath`
|
|
916
|
+
* must be supplied.
|
|
917
|
+
* @returns Upload result containing `file_info` for subsequent message sends.
|
|
918
|
+
*/
|
|
919
|
+
async uploadMedia(scope, targetId, fileType, creds, opts) {
|
|
920
|
+
const sources = [
|
|
921
|
+
opts.url,
|
|
922
|
+
opts.fileData,
|
|
923
|
+
opts.buffer,
|
|
924
|
+
opts.localPath
|
|
925
|
+
].filter((v) => v !== void 0);
|
|
926
|
+
if (sources.length === 0) throw new Error(`uploadMedia: one of url/fileData/buffer/localPath is required`);
|
|
927
|
+
if (sources.length > 1) throw new Error(`uploadMedia: url/fileData/buffer/localPath are mutually exclusive (got ${sources.length})`);
|
|
928
|
+
let fileData = opts.fileData;
|
|
929
|
+
if (opts.buffer) fileData = opts.buffer.toString("base64");
|
|
930
|
+
else if (opts.localPath) fileData = (await fs$1.promises.readFile(opts.localPath)).toString("base64");
|
|
931
|
+
if (fileData && this.cache) {
|
|
932
|
+
const hash = this.cache.computeHash(fileData);
|
|
933
|
+
const cached = this.cache.get(hash, scope, targetId, fileType);
|
|
934
|
+
if (cached) return {
|
|
935
|
+
file_uuid: "",
|
|
936
|
+
file_info: cached,
|
|
937
|
+
ttl: 0
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
const body = {
|
|
941
|
+
file_type: fileType,
|
|
942
|
+
srv_send_msg: opts.srvSendMsg ?? false
|
|
943
|
+
};
|
|
944
|
+
if (opts.url) body.url = opts.url;
|
|
945
|
+
else if (fileData) body.file_data = fileData;
|
|
946
|
+
if (fileType === 4 && opts.fileName) body.file_name = this.sanitize(opts.fileName);
|
|
947
|
+
const token = await this.tokenManager.getAccessToken(creds.appId, creds.clientSecret);
|
|
948
|
+
const path = mediaUploadPath(scope, targetId);
|
|
949
|
+
const result = await withRetry(() => this.client.request(token, "POST", path, body, {
|
|
950
|
+
redactBodyKeys: ["file_data"],
|
|
951
|
+
uploadRequest: true
|
|
952
|
+
}), UPLOAD_RETRY_POLICY, void 0, this.logger);
|
|
953
|
+
if (fileData && result.file_info && result.ttl > 0 && this.cache) {
|
|
954
|
+
const hash = this.cache.computeHash(fileData);
|
|
955
|
+
this.cache.set(hash, scope, targetId, fileType, result.file_info, result.file_uuid, result.ttl);
|
|
956
|
+
}
|
|
957
|
+
return result;
|
|
958
|
+
}
|
|
959
|
+
/**
|
|
960
|
+
* Send a media message (upload result → message) to a C2C or Group target.
|
|
961
|
+
*
|
|
962
|
+
* @param scope - `'c2c'` or `'group'`.
|
|
963
|
+
* @param targetId - User openid or group openid.
|
|
964
|
+
* @param fileInfo - `file_info` from a prior upload.
|
|
965
|
+
* @param creds - Authentication credentials.
|
|
966
|
+
* @param opts - Message options.
|
|
967
|
+
*/
|
|
968
|
+
async sendMediaMessage(scope, targetId, fileInfo, creds, opts) {
|
|
969
|
+
const token = await this.tokenManager.getAccessToken(creds.appId, creds.clientSecret);
|
|
970
|
+
const msgSeq = opts?.msgId ? getNextMsgSeq(opts.msgId) : 1;
|
|
971
|
+
const path = messagePath(scope, targetId);
|
|
972
|
+
return this.client.request(token, "POST", path, {
|
|
973
|
+
msg_type: 7,
|
|
974
|
+
media: { file_info: fileInfo },
|
|
975
|
+
msg_seq: msgSeq,
|
|
976
|
+
...opts?.content ? { content: opts.content } : {},
|
|
977
|
+
...opts?.msgId ? { msg_id: opts.msgId } : {}
|
|
978
|
+
});
|
|
979
|
+
}
|
|
980
|
+
};
|
|
981
|
+
//#endregion
|
|
982
|
+
//#region extensions/qqbot/src/engine/api/messages.ts
|
|
983
|
+
/**
|
|
984
|
+
* Message sending module.
|
|
985
|
+
*
|
|
986
|
+
* Usage:
|
|
987
|
+
* ```ts
|
|
988
|
+
* const api = new MessageApi(client, tokenMgr, { markdownSupport: true });
|
|
989
|
+
* await api.sendMessage('c2c', openid, 'Hello!', { appId, clientSecret, msgId });
|
|
990
|
+
* ```
|
|
991
|
+
*/
|
|
992
|
+
var MessageApi = class {
|
|
993
|
+
constructor(client, tokenManager, config) {
|
|
994
|
+
this.messageSentHook = null;
|
|
995
|
+
this.client = client;
|
|
996
|
+
this.tokenManager = tokenManager;
|
|
997
|
+
this.markdownSupport = config.markdownSupport;
|
|
998
|
+
this.logger = config.logger;
|
|
999
|
+
}
|
|
1000
|
+
/** Register a callback invoked when a sent message returns a ref_idx. */
|
|
1001
|
+
onMessageSent(callback) {
|
|
1002
|
+
this.messageSentHook = callback;
|
|
1003
|
+
}
|
|
1004
|
+
/**
|
|
1005
|
+
* Notify the registered hook about a sent message.
|
|
1006
|
+
* Use this for media sends that bypass `sendAndNotify`.
|
|
1007
|
+
*/
|
|
1008
|
+
notifyMessageSent(refIdx, meta) {
|
|
1009
|
+
if (this.messageSentHook) try {
|
|
1010
|
+
this.messageSentHook(refIdx, meta);
|
|
1011
|
+
} catch (err) {
|
|
1012
|
+
this.logger?.error?.(`[qqbot:messages] onMessageSent hook error: ${formatErrorMessage(err)}`);
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
/**
|
|
1016
|
+
* Send a text message to a C2C or Group target.
|
|
1017
|
+
*
|
|
1018
|
+
* Automatically constructs the correct path, body format (markdown vs plain),
|
|
1019
|
+
* and message sequence number.
|
|
1020
|
+
*/
|
|
1021
|
+
async sendMessage(scope, targetId, content, creds, opts) {
|
|
1022
|
+
const token = await this.tokenManager.getAccessToken(creds.appId, creds.clientSecret);
|
|
1023
|
+
const msgSeq = opts?.msgId ? getNextMsgSeq(opts.msgId) : 1;
|
|
1024
|
+
const body = this.buildMessageBody(content, opts?.msgId, msgSeq, opts?.messageReference, opts?.inlineKeyboard);
|
|
1025
|
+
const path = messagePath(scope, targetId);
|
|
1026
|
+
return this.sendAndNotify(creds.appId, token, "POST", path, body, { text: content });
|
|
1027
|
+
}
|
|
1028
|
+
/** Send a proactive (no msgId) message to a C2C or Group target. */
|
|
1029
|
+
async sendProactiveMessage(scope, targetId, content, creds) {
|
|
1030
|
+
if (!content?.trim()) throw new Error("Proactive message content must not be empty");
|
|
1031
|
+
const token = await this.tokenManager.getAccessToken(creds.appId, creds.clientSecret);
|
|
1032
|
+
const body = this.buildProactiveBody(content);
|
|
1033
|
+
const path = messagePath(scope, targetId);
|
|
1034
|
+
return this.sendAndNotify(creds.appId, token, "POST", path, body, { text: content });
|
|
1035
|
+
}
|
|
1036
|
+
/** Send a channel message. */
|
|
1037
|
+
async sendChannelMessage(opts) {
|
|
1038
|
+
const token = await this.tokenManager.getAccessToken(opts.creds.appId, opts.creds.clientSecret);
|
|
1039
|
+
return this.client.request(token, "POST", channelMessagePath(opts.channelId), {
|
|
1040
|
+
content: opts.content,
|
|
1041
|
+
...opts.msgId ? { msg_id: opts.msgId } : {}
|
|
1042
|
+
});
|
|
1043
|
+
}
|
|
1044
|
+
/** Send a DM (guild direct message). */
|
|
1045
|
+
async sendDmMessage(opts) {
|
|
1046
|
+
const token = await this.tokenManager.getAccessToken(opts.creds.appId, opts.creds.clientSecret);
|
|
1047
|
+
return this.client.request(token, "POST", dmMessagePath(opts.guildId), {
|
|
1048
|
+
content: opts.content,
|
|
1049
|
+
...opts.msgId ? { msg_id: opts.msgId } : {}
|
|
1050
|
+
});
|
|
1051
|
+
}
|
|
1052
|
+
/** Send a typing indicator to a C2C user. */
|
|
1053
|
+
async sendInputNotify(opts) {
|
|
1054
|
+
const inputSecond = opts.inputSecond ?? 60;
|
|
1055
|
+
const token = await this.tokenManager.getAccessToken(opts.creds.appId, opts.creds.clientSecret);
|
|
1056
|
+
const msgSeq = opts.msgId ? getNextMsgSeq(opts.msgId) : 1;
|
|
1057
|
+
return { refIdx: (await this.client.request(token, "POST", messagePath("c2c", opts.openid), {
|
|
1058
|
+
msg_type: 6,
|
|
1059
|
+
input_notify: {
|
|
1060
|
+
input_type: 1,
|
|
1061
|
+
input_second: inputSecond
|
|
1062
|
+
},
|
|
1063
|
+
msg_seq: msgSeq,
|
|
1064
|
+
...opts.msgId ? { msg_id: opts.msgId } : {}
|
|
1065
|
+
})).ext_info?.ref_idx };
|
|
1066
|
+
}
|
|
1067
|
+
/** Acknowledge an INTERACTION_CREATE event. */
|
|
1068
|
+
async acknowledgeInteraction(interactionId, creds, code = 0) {
|
|
1069
|
+
const token = await this.tokenManager.getAccessToken(creds.appId, creds.clientSecret);
|
|
1070
|
+
await this.client.request(token, "PUT", interactionPath(interactionId), { code });
|
|
1071
|
+
}
|
|
1072
|
+
/** Get the WebSocket gateway URL. */
|
|
1073
|
+
async getGatewayUrl(creds) {
|
|
1074
|
+
const token = await this.tokenManager.getAccessToken(creds.appId, creds.clientSecret);
|
|
1075
|
+
return (await this.client.request(token, "GET", gatewayPath())).url;
|
|
1076
|
+
}
|
|
1077
|
+
/**
|
|
1078
|
+
* Send a C2C stream message chunk (`/v2/users/{openid}/stream_messages`).
|
|
1079
|
+
* Only supported for one-to-one chats.
|
|
1080
|
+
*/
|
|
1081
|
+
async sendC2CStreamMessage(creds, openid, req) {
|
|
1082
|
+
const token = await this.tokenManager.getAccessToken(creds.appId, creds.clientSecret);
|
|
1083
|
+
const path = streamMessagePath(openid);
|
|
1084
|
+
const body = {
|
|
1085
|
+
input_mode: req.input_mode,
|
|
1086
|
+
input_state: req.input_state,
|
|
1087
|
+
content_type: req.content_type,
|
|
1088
|
+
content_raw: req.content_raw,
|
|
1089
|
+
event_id: req.event_id,
|
|
1090
|
+
msg_id: req.msg_id,
|
|
1091
|
+
msg_seq: req.msg_seq,
|
|
1092
|
+
index: req.index
|
|
1093
|
+
};
|
|
1094
|
+
if (req.stream_msg_id) body.stream_msg_id = req.stream_msg_id;
|
|
1095
|
+
return this.client.request(token, "POST", path, body);
|
|
1096
|
+
}
|
|
1097
|
+
async sendAndNotify(appId, accessToken, method, path, body, meta) {
|
|
1098
|
+
const result = await this.client.request(accessToken, method, path, body);
|
|
1099
|
+
if (result.ext_info?.ref_idx && this.messageSentHook) try {
|
|
1100
|
+
this.messageSentHook(result.ext_info.ref_idx, meta);
|
|
1101
|
+
} catch (err) {
|
|
1102
|
+
this.logger?.error?.(`[qqbot:messages] onMessageSent hook error: ${formatErrorMessage(err)}`);
|
|
1103
|
+
}
|
|
1104
|
+
return result;
|
|
1105
|
+
}
|
|
1106
|
+
buildMessageBody(content, msgId, msgSeq, messageReference, inlineKeyboard) {
|
|
1107
|
+
const body = this.markdownSupport ? {
|
|
1108
|
+
markdown: { content },
|
|
1109
|
+
msg_type: 2,
|
|
1110
|
+
msg_seq: msgSeq
|
|
1111
|
+
} : {
|
|
1112
|
+
content,
|
|
1113
|
+
msg_type: 0,
|
|
1114
|
+
msg_seq: msgSeq
|
|
1115
|
+
};
|
|
1116
|
+
if (msgId) body.msg_id = msgId;
|
|
1117
|
+
if (messageReference && !this.markdownSupport) body.message_reference = { message_id: messageReference };
|
|
1118
|
+
if (inlineKeyboard) body.keyboard = inlineKeyboard;
|
|
1119
|
+
return body;
|
|
1120
|
+
}
|
|
1121
|
+
buildProactiveBody(content) {
|
|
1122
|
+
return this.markdownSupport ? {
|
|
1123
|
+
markdown: { content },
|
|
1124
|
+
msg_type: 2
|
|
1125
|
+
} : {
|
|
1126
|
+
content,
|
|
1127
|
+
msg_type: 0
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
};
|
|
1131
|
+
//#endregion
|
|
1132
|
+
//#region extensions/qqbot/src/engine/api/token.ts
|
|
1133
|
+
const TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken";
|
|
1134
|
+
/**
|
|
1135
|
+
* Per-appId token manager with caching, singleflight, and background refresh.
|
|
1136
|
+
*
|
|
1137
|
+
* Usage:
|
|
1138
|
+
* ```ts
|
|
1139
|
+
* const tm = new TokenManager({ logger, userAgent: 'QQBotPlugin/1.0' });
|
|
1140
|
+
* const token = await tm.getAccessToken('appId', 'secret');
|
|
1141
|
+
* ```
|
|
1142
|
+
*/
|
|
1143
|
+
var TokenManager = class {
|
|
1144
|
+
constructor(config) {
|
|
1145
|
+
this.cache = /* @__PURE__ */ new Map();
|
|
1146
|
+
this.fetchPromises = /* @__PURE__ */ new Map();
|
|
1147
|
+
this.refreshControllers = /* @__PURE__ */ new Map();
|
|
1148
|
+
this.logger = config?.logger;
|
|
1149
|
+
const ua = config?.userAgent ?? "QQBotPlugin/unknown";
|
|
1150
|
+
this.resolveUserAgent = typeof ua === "function" ? ua : () => ua;
|
|
1151
|
+
}
|
|
1152
|
+
/**
|
|
1153
|
+
* Obtain an access token with caching and singleflight semantics.
|
|
1154
|
+
*
|
|
1155
|
+
* When multiple callers request a token for the same appId concurrently,
|
|
1156
|
+
* only one actual HTTP request is made — the others await the same promise.
|
|
1157
|
+
*/
|
|
1158
|
+
async getAccessToken(appId, clientSecret) {
|
|
1159
|
+
const normalizedId = appId.trim();
|
|
1160
|
+
const cached = this.cache.get(normalizedId);
|
|
1161
|
+
const refreshAheadMs = cached ? Math.min(300 * 1e3, (cached.expiresAt - Date.now()) / 3) : 0;
|
|
1162
|
+
if (cached && Date.now() < cached.expiresAt - refreshAheadMs) return cached.token;
|
|
1163
|
+
let pending = this.fetchPromises.get(normalizedId);
|
|
1164
|
+
if (pending) {
|
|
1165
|
+
this.logger?.debug?.(`[qqbot:token:${normalizedId}] Fetch in progress, reusing promise`);
|
|
1166
|
+
return pending;
|
|
1167
|
+
}
|
|
1168
|
+
pending = (async () => {
|
|
1169
|
+
try {
|
|
1170
|
+
return await this.doFetchToken(normalizedId, clientSecret);
|
|
1171
|
+
} finally {
|
|
1172
|
+
this.fetchPromises.delete(normalizedId);
|
|
1173
|
+
}
|
|
1174
|
+
})();
|
|
1175
|
+
this.fetchPromises.set(normalizedId, pending);
|
|
1176
|
+
return pending;
|
|
1177
|
+
}
|
|
1178
|
+
/** Clear the cached token for one appId, or all. */
|
|
1179
|
+
clearCache(appId) {
|
|
1180
|
+
if (appId) {
|
|
1181
|
+
this.cache.delete(appId.trim());
|
|
1182
|
+
this.logger?.debug?.(`[qqbot:token:${appId}] Cache cleared`);
|
|
1183
|
+
} else {
|
|
1184
|
+
this.cache.clear();
|
|
1185
|
+
this.logger?.debug?.(`[token] All caches cleared`);
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
/** Return token status for diagnostics. */
|
|
1189
|
+
getStatus(appId) {
|
|
1190
|
+
if (this.fetchPromises.has(appId)) return {
|
|
1191
|
+
status: "refreshing",
|
|
1192
|
+
expiresAt: this.cache.get(appId)?.expiresAt ?? null
|
|
1193
|
+
};
|
|
1194
|
+
const cached = this.cache.get(appId);
|
|
1195
|
+
if (!cached) return {
|
|
1196
|
+
status: "none",
|
|
1197
|
+
expiresAt: null
|
|
1198
|
+
};
|
|
1199
|
+
const remaining = cached.expiresAt - Date.now();
|
|
1200
|
+
return {
|
|
1201
|
+
status: remaining > Math.min(300 * 1e3, remaining / 3) ? "valid" : "expired",
|
|
1202
|
+
expiresAt: cached.expiresAt
|
|
1203
|
+
};
|
|
1204
|
+
}
|
|
1205
|
+
/** Start a background token refresh loop for one appId. */
|
|
1206
|
+
startBackgroundRefresh(appId, clientSecret, options) {
|
|
1207
|
+
if (this.refreshControllers.has(appId)) {
|
|
1208
|
+
this.logger?.info?.(`[qqbot:token:${appId}] Background refresh already running`);
|
|
1209
|
+
return;
|
|
1210
|
+
}
|
|
1211
|
+
const { refreshAheadMs = 300 * 1e3, randomOffsetMs = 30 * 1e3, minRefreshIntervalMs = 60 * 1e3, retryDelayMs = 5 * 1e3 } = options ?? {};
|
|
1212
|
+
const controller = new AbortController();
|
|
1213
|
+
this.refreshControllers.set(appId, controller);
|
|
1214
|
+
const { signal } = controller;
|
|
1215
|
+
const loop = async () => {
|
|
1216
|
+
this.logger?.info?.(`[qqbot:token:${appId}] Background refresh started`);
|
|
1217
|
+
while (!signal.aborted) try {
|
|
1218
|
+
await this.getAccessToken(appId, clientSecret);
|
|
1219
|
+
const cached = this.cache.get(appId);
|
|
1220
|
+
if (cached) {
|
|
1221
|
+
const expiresIn = cached.expiresAt - Date.now();
|
|
1222
|
+
const randomOffset = Math.random() * randomOffsetMs;
|
|
1223
|
+
const refreshIn = Math.max(expiresIn - refreshAheadMs - randomOffset, minRefreshIntervalMs);
|
|
1224
|
+
this.logger?.debug?.(`[qqbot:token:${appId}] Next refresh in ${Math.round(refreshIn / 1e3)}s`);
|
|
1225
|
+
await this.abortableSleep(refreshIn, signal);
|
|
1226
|
+
} else await this.abortableSleep(minRefreshIntervalMs, signal);
|
|
1227
|
+
} catch (err) {
|
|
1228
|
+
if (signal.aborted) break;
|
|
1229
|
+
this.logger?.error?.(`[qqbot:token:${appId}] Background refresh failed: ${formatErrorMessage(err)}`);
|
|
1230
|
+
await this.abortableSleep(retryDelayMs, signal);
|
|
1231
|
+
}
|
|
1232
|
+
this.refreshControllers.delete(appId);
|
|
1233
|
+
this.logger?.info?.(`[qqbot:token:${appId}] Background refresh stopped`);
|
|
1234
|
+
};
|
|
1235
|
+
loop().catch((err) => {
|
|
1236
|
+
this.refreshControllers.delete(appId);
|
|
1237
|
+
this.logger?.error?.(`[qqbot:token:${appId}] Background refresh crashed: ${err}`);
|
|
1238
|
+
});
|
|
1239
|
+
}
|
|
1240
|
+
/** Stop background refresh for one appId, or all. */
|
|
1241
|
+
stopBackgroundRefresh(appId) {
|
|
1242
|
+
if (appId) {
|
|
1243
|
+
const ctrl = this.refreshControllers.get(appId);
|
|
1244
|
+
if (ctrl) {
|
|
1245
|
+
ctrl.abort();
|
|
1246
|
+
this.refreshControllers.delete(appId);
|
|
1247
|
+
}
|
|
1248
|
+
} else {
|
|
1249
|
+
for (const ctrl of this.refreshControllers.values()) ctrl.abort();
|
|
1250
|
+
this.refreshControllers.clear();
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
/** Check whether background refresh is running. */
|
|
1254
|
+
isBackgroundRefreshRunning(appId) {
|
|
1255
|
+
if (appId) return this.refreshControllers.has(appId);
|
|
1256
|
+
return this.refreshControllers.size > 0;
|
|
1257
|
+
}
|
|
1258
|
+
async doFetchToken(appId, clientSecret) {
|
|
1259
|
+
this.logger?.debug?.(`[qqbot:token:${appId}] >>> POST ${TOKEN_URL}`);
|
|
1260
|
+
let response;
|
|
1261
|
+
try {
|
|
1262
|
+
response = await fetch(TOKEN_URL, {
|
|
1263
|
+
method: "POST",
|
|
1264
|
+
headers: {
|
|
1265
|
+
"Content-Type": "application/json",
|
|
1266
|
+
"User-Agent": this.resolveUserAgent()
|
|
1267
|
+
},
|
|
1268
|
+
body: JSON.stringify({
|
|
1269
|
+
appId,
|
|
1270
|
+
clientSecret
|
|
1271
|
+
})
|
|
1272
|
+
});
|
|
1273
|
+
} catch (err) {
|
|
1274
|
+
this.logger?.error?.(`[qqbot:token:${appId}] Network error: ${formatErrorMessage(err)}`);
|
|
1275
|
+
throw new Error(`Network error getting access_token: ${formatErrorMessage(err)}`, { cause: err });
|
|
1276
|
+
}
|
|
1277
|
+
const traceId = response.headers.get("x-tps-trace-id") ?? "";
|
|
1278
|
+
this.logger?.debug?.(`[qqbot:token:${appId}] <<< ${response.status}${traceId ? ` | TraceId: ${traceId}` : ""}`);
|
|
1279
|
+
let data;
|
|
1280
|
+
try {
|
|
1281
|
+
const rawBody = await response.text();
|
|
1282
|
+
const logBody = rawBody.replace(/"access_token"\s*:\s*"[^"]+"/g, "\"access_token\": \"***\"");
|
|
1283
|
+
this.logger?.debug?.(`[qqbot:token:${appId}] <<< Body: ${logBody}`);
|
|
1284
|
+
data = JSON.parse(rawBody);
|
|
1285
|
+
} catch (err) {
|
|
1286
|
+
throw new Error(`Failed to parse access_token response: ${formatErrorMessage(err)}`, { cause: err });
|
|
1287
|
+
}
|
|
1288
|
+
if (!data.access_token) throw new Error(`Failed to get access_token: ${JSON.stringify(data)}`);
|
|
1289
|
+
const expiresAt = Date.now() + (data.expires_in ?? 7200) * 1e3;
|
|
1290
|
+
this.cache.set(appId, {
|
|
1291
|
+
token: data.access_token,
|
|
1292
|
+
expiresAt,
|
|
1293
|
+
appId
|
|
1294
|
+
});
|
|
1295
|
+
this.logger?.debug?.(`[qqbot:token:${appId}] Cached, expires at: ${new Date(expiresAt).toISOString()}`);
|
|
1296
|
+
return data.access_token;
|
|
1297
|
+
}
|
|
1298
|
+
abortableSleep(ms, signal) {
|
|
1299
|
+
return new Promise((resolve, reject) => {
|
|
1300
|
+
const timer = setTimeout(resolve, ms);
|
|
1301
|
+
if (signal.aborted) {
|
|
1302
|
+
clearTimeout(timer);
|
|
1303
|
+
reject(/* @__PURE__ */ new Error("Aborted"));
|
|
1304
|
+
return;
|
|
1305
|
+
}
|
|
1306
|
+
const onAbort = () => {
|
|
1307
|
+
clearTimeout(timer);
|
|
1308
|
+
reject(/* @__PURE__ */ new Error("Aborted"));
|
|
1309
|
+
};
|
|
1310
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1311
|
+
});
|
|
1312
|
+
}
|
|
1313
|
+
};
|
|
1314
|
+
//#endregion
|
|
1315
|
+
//#region extensions/qqbot/src/engine/utils/log.ts
|
|
1316
|
+
/**
|
|
1317
|
+
* QQBot debug logging utilities.
|
|
1318
|
+
* QQBot 调试日志工具。
|
|
1319
|
+
*
|
|
1320
|
+
* Only outputs when the QQBOT_DEBUG environment variable is set,
|
|
1321
|
+
* preventing user message content from leaking in production logs.
|
|
1322
|
+
*
|
|
1323
|
+
* Self-contained within engine/ — no framework SDK dependency.
|
|
1324
|
+
*/
|
|
1325
|
+
const isDebug = () => !!process.env.QQBOT_DEBUG;
|
|
1326
|
+
const MAX_LOG_VALUE_CHARS = 4096;
|
|
1327
|
+
function sanitizeDebugLogValue(value) {
|
|
1328
|
+
let text;
|
|
1329
|
+
if (typeof value === "string") text = value;
|
|
1330
|
+
else if (value instanceof Error) text = value.stack || value.message;
|
|
1331
|
+
else try {
|
|
1332
|
+
text = JSON.stringify(value) ?? String(value);
|
|
1333
|
+
} catch {
|
|
1334
|
+
text = String(value);
|
|
1335
|
+
}
|
|
1336
|
+
const sanitized = text.replace(/\p{Cc}/gu, " ").replace(/\s+/g, " ").trim();
|
|
1337
|
+
if (sanitized.length <= MAX_LOG_VALUE_CHARS) return sanitized;
|
|
1338
|
+
return `${sanitized.slice(0, MAX_LOG_VALUE_CHARS)}...`;
|
|
1339
|
+
}
|
|
1340
|
+
function formatDebugLogArgs(args) {
|
|
1341
|
+
return args.map(sanitizeDebugLogValue).join(" ");
|
|
1342
|
+
}
|
|
1343
|
+
/** Debug-level log; only outputs when QQBOT_DEBUG is enabled. */
|
|
1344
|
+
function debugLog(...args) {
|
|
1345
|
+
if (isDebug()) console.log(formatDebugLogArgs(args).replace(/\n|\r/g, ""));
|
|
1346
|
+
}
|
|
1347
|
+
/** Debug-level warning; only outputs when QQBOT_DEBUG is enabled. */
|
|
1348
|
+
function debugWarn(...args) {
|
|
1349
|
+
if (isDebug()) console.warn(formatDebugLogArgs(args).replace(/\n|\r/g, ""));
|
|
1350
|
+
}
|
|
1351
|
+
/** Debug-level error; only outputs when QQBOT_DEBUG is enabled. */
|
|
1352
|
+
function debugError(...args) {
|
|
1353
|
+
if (isDebug()) console.error(formatDebugLogArgs(args).replace(/\n|\r/g, ""));
|
|
1354
|
+
}
|
|
1355
|
+
//#endregion
|
|
1356
|
+
//#region extensions/qqbot/src/engine/utils/upload-cache.ts
|
|
1357
|
+
/**
|
|
1358
|
+
* Cache `file_info` values returned by the QQ Bot API so identical uploads can be reused
|
|
1359
|
+
* before the server-side TTL expires.
|
|
1360
|
+
*/
|
|
1361
|
+
const cache = /* @__PURE__ */ new Map();
|
|
1362
|
+
const MAX_CACHE_SIZE = 500;
|
|
1363
|
+
/** Compute an MD5 hash used as part of the cache key. */
|
|
1364
|
+
function computeFileHash(data) {
|
|
1365
|
+
const content = typeof data === "string" ? data : data;
|
|
1366
|
+
return crypto$1.createHash("md5").update(content).digest("hex");
|
|
1367
|
+
}
|
|
1368
|
+
/** Build the in-memory cache key. */
|
|
1369
|
+
function buildCacheKey(contentHash, scope, targetId, fileType) {
|
|
1370
|
+
return `${contentHash}:${scope}:${targetId}:${fileType}`;
|
|
1371
|
+
}
|
|
1372
|
+
/** Look up a cached `file_info` value. */
|
|
1373
|
+
function getCachedFileInfo(contentHash, scope, targetId, fileType) {
|
|
1374
|
+
const key = buildCacheKey(contentHash, scope, targetId, fileType);
|
|
1375
|
+
const entry = cache.get(key);
|
|
1376
|
+
if (!entry) return null;
|
|
1377
|
+
if (Date.now() >= entry.expiresAt) {
|
|
1378
|
+
cache.delete(key);
|
|
1379
|
+
return null;
|
|
1380
|
+
}
|
|
1381
|
+
debugLog(`[upload-cache] Cache HIT: key=${key.slice(0, 40)}..., fileUuid=${entry.fileUuid}`);
|
|
1382
|
+
return entry.fileInfo;
|
|
1383
|
+
}
|
|
1384
|
+
/** Store an upload result in the cache. */
|
|
1385
|
+
function setCachedFileInfo(contentHash, scope, targetId, fileType, fileInfo, fileUuid, ttl) {
|
|
1386
|
+
if (cache.size >= MAX_CACHE_SIZE) {
|
|
1387
|
+
const now = Date.now();
|
|
1388
|
+
for (const [k, v] of cache) if (now >= v.expiresAt) cache.delete(k);
|
|
1389
|
+
if (cache.size >= MAX_CACHE_SIZE) {
|
|
1390
|
+
const keys = Array.from(cache.keys());
|
|
1391
|
+
for (let i = 0; i < keys.length / 2; i++) cache.delete(keys[i]);
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
const key = buildCacheKey(contentHash, scope, targetId, fileType);
|
|
1395
|
+
const effectiveTtl = Math.max(ttl - 60, 10);
|
|
1396
|
+
cache.set(key, {
|
|
1397
|
+
fileInfo,
|
|
1398
|
+
fileUuid,
|
|
1399
|
+
expiresAt: Date.now() + effectiveTtl * 1e3
|
|
1400
|
+
});
|
|
1401
|
+
debugLog(`[upload-cache] Cache SET: key=${key.slice(0, 40)}..., ttl=${effectiveTtl}s, uuid=${fileUuid}`);
|
|
1402
|
+
}
|
|
1403
|
+
//#endregion
|
|
1404
|
+
//#region extensions/qqbot/src/engine/messaging/media-source.ts
|
|
1405
|
+
/**
|
|
1406
|
+
* Unified media-source abstraction for the QQ Bot upload pipeline.
|
|
1407
|
+
*
|
|
1408
|
+
* All rich-media entry points (sender.ts#sendMedia, outbound.ts#send*,
|
|
1409
|
+
* reply-dispatcher.ts#handle*Payload) funnel through {@link normalizeSource}
|
|
1410
|
+
* before reaching the low-level {@link MediaApi}.
|
|
1411
|
+
*
|
|
1412
|
+
* ## Why four branches?
|
|
1413
|
+
*
|
|
1414
|
+
* - `url` — remote http(s) URL that the QQ server can fetch directly.
|
|
1415
|
+
* - `base64` — in-memory base64 string (typically from a `data:` URL).
|
|
1416
|
+
* - `localPath` — on-disk file; kept as a path so a future chunked-upload
|
|
1417
|
+
* implementation can stream it via `fs.createReadStream` without the 4/3×
|
|
1418
|
+
* base64 memory overhead.
|
|
1419
|
+
* - `buffer` — in-memory raw bytes (e.g. TTS output, downloaded url-fallback).
|
|
1420
|
+
*
|
|
1421
|
+
* ## Security baseline (localPath branch)
|
|
1422
|
+
*
|
|
1423
|
+
* `openLocalFile` is the single canonical implementation of "safely open a
|
|
1424
|
+
* local file for upload" across the plugin. It merges the previously
|
|
1425
|
+
* inconsistent strategies from `reply-dispatcher.ts` (O_NOFOLLOW + size check)
|
|
1426
|
+
* and `outbound.ts` (realpath + root containment). Callers are still
|
|
1427
|
+
* responsible for *root-whitelist* validation (via
|
|
1428
|
+
* `resolveQQBotPayloadLocalFilePath` / `resolveOutboundMediaPath`) before
|
|
1429
|
+
* passing the path in; this function enforces *file-level* safety only.
|
|
1430
|
+
*
|
|
1431
|
+
* Chunked upload is not implemented in this PR, but the contract here already
|
|
1432
|
+
* returns `size` metadata so `sendMediaInternal` can route by size without
|
|
1433
|
+
* reading the whole file first.
|
|
1434
|
+
*/
|
|
1435
|
+
const DATA_URL_RE = /^data:([^;,]+);base64,(.+)$/i;
|
|
1436
|
+
/**
|
|
1437
|
+
* Parse a `data:<mime>;base64,<payload>` URL.
|
|
1438
|
+
*
|
|
1439
|
+
* Returns `null` when the string is not a data URL or does not declare
|
|
1440
|
+
* base64 encoding. Non-base64 data URLs are intentionally rejected because
|
|
1441
|
+
* the QQ upload API ingests raw base64, not arbitrary URL-encoded payloads.
|
|
1442
|
+
*/
|
|
1443
|
+
function tryParseDataUrl(value) {
|
|
1444
|
+
if (!value.startsWith("data:")) return null;
|
|
1445
|
+
const m = value.match(DATA_URL_RE);
|
|
1446
|
+
if (!m) return null;
|
|
1447
|
+
return {
|
|
1448
|
+
mime: m[1],
|
|
1449
|
+
data: m[2]
|
|
1450
|
+
};
|
|
1451
|
+
}
|
|
1452
|
+
/**
|
|
1453
|
+
* Open a local file for upload with defense-in-depth:
|
|
1454
|
+
*
|
|
1455
|
+
* 1. `O_NOFOLLOW` refuses to traverse symlinks (prevents post-whitelist
|
|
1456
|
+
* symlink swaps / TOCTOU attacks).
|
|
1457
|
+
* 2. `fstat` on the opened descriptor — NOT `fs.stat` on the path —
|
|
1458
|
+
* so the size check applies to the exact byte stream we will read.
|
|
1459
|
+
* 3. Rejects non-regular files (sockets / devices / directories).
|
|
1460
|
+
* 4. Enforces a caller-specified `maxSize` (default {@link MAX_UPLOAD_SIZE})
|
|
1461
|
+
* at open time, so oversized files fail fast without allocating a
|
|
1462
|
+
* full buffer. Chunked upload callers should pass a larger ceiling
|
|
1463
|
+
* (e.g. `CHUNKED_UPLOAD_MAX_SIZE` from `utils/file-utils.js`).
|
|
1464
|
+
*
|
|
1465
|
+
* The caller receives the open handle plus validated size and is expected
|
|
1466
|
+
* to either {@link OpenedLocalFile.handle.readFile} (one-shot path) or
|
|
1467
|
+
* stream via `fs.createReadStream` (chunked path).
|
|
1468
|
+
*/
|
|
1469
|
+
async function openLocalFile(filePath, opts = {}) {
|
|
1470
|
+
const maxSize = opts.maxSize ?? 20971520;
|
|
1471
|
+
const openFlags = fs$1.constants.O_RDONLY | ("O_NOFOLLOW" in fs$1.constants ? fs$1.constants.O_NOFOLLOW : 0);
|
|
1472
|
+
const handle = await fs$1.promises.open(filePath, openFlags);
|
|
1473
|
+
try {
|
|
1474
|
+
const stat = await handle.stat();
|
|
1475
|
+
if (!stat.isFile()) throw new Error("Path is not a regular file");
|
|
1476
|
+
if (stat.size > maxSize) throw new Error(`File is too large (${formatFileSize(stat.size)}); QQ Bot API limit is ${formatFileSize(maxSize)}`);
|
|
1477
|
+
return {
|
|
1478
|
+
handle,
|
|
1479
|
+
size: stat.size,
|
|
1480
|
+
close: () => handle.close()
|
|
1481
|
+
};
|
|
1482
|
+
} catch (err) {
|
|
1483
|
+
await handle.close().catch(() => void 0);
|
|
1484
|
+
throw err;
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
/**
|
|
1488
|
+
* Normalize a {@link RawMediaSource} into a {@link MediaSource}.
|
|
1489
|
+
*
|
|
1490
|
+
* - Strings passed via `{ url }` that start with `data:` are auto-resolved
|
|
1491
|
+
* to a `base64` branch (this is the unified `data:` URL support that was
|
|
1492
|
+
* previously only implemented in `sendImage`).
|
|
1493
|
+
* - `localPath` branches open the file with {@link openLocalFile} solely to
|
|
1494
|
+
* validate size / regular-file / O_NOFOLLOW invariants. The handle is
|
|
1495
|
+
* closed immediately — actual reading is deferred to the uploader so
|
|
1496
|
+
* the chunked path can stream without double-reading.
|
|
1497
|
+
* - `buffer` branches enforce the same ceiling inline.
|
|
1498
|
+
*
|
|
1499
|
+
* `maxSize` defaults to {@link MAX_UPLOAD_SIZE} (20MB, one-shot upload limit).
|
|
1500
|
+
* Callers that dispatch to the chunked uploader should pass a larger ceiling
|
|
1501
|
+
* (e.g. `CHUNKED_UPLOAD_MAX_SIZE`, or a value derived from
|
|
1502
|
+
* `getMaxUploadSize(fileType)`).
|
|
1503
|
+
*
|
|
1504
|
+
* NOTE: Root-whitelist validation (i.e. "this path must live under the
|
|
1505
|
+
* allowed QQ Bot media directory") is a caller concern. This function
|
|
1506
|
+
* assumes the path has already passed such checks.
|
|
1507
|
+
*/
|
|
1508
|
+
async function normalizeSource(raw, opts = {}) {
|
|
1509
|
+
const maxSize = opts.maxSize ?? 20971520;
|
|
1510
|
+
if ("url" in raw) {
|
|
1511
|
+
const parsed = tryParseDataUrl(raw.url);
|
|
1512
|
+
if (parsed) return {
|
|
1513
|
+
kind: "base64",
|
|
1514
|
+
data: parsed.data,
|
|
1515
|
+
mime: parsed.mime
|
|
1516
|
+
};
|
|
1517
|
+
return {
|
|
1518
|
+
kind: "url",
|
|
1519
|
+
url: raw.url
|
|
1520
|
+
};
|
|
1521
|
+
}
|
|
1522
|
+
if ("base64" in raw) return {
|
|
1523
|
+
kind: "base64",
|
|
1524
|
+
data: raw.base64,
|
|
1525
|
+
mime: raw.mime
|
|
1526
|
+
};
|
|
1527
|
+
if ("localPath" in raw) {
|
|
1528
|
+
const opened = await openLocalFile(raw.localPath, { maxSize });
|
|
1529
|
+
try {
|
|
1530
|
+
return {
|
|
1531
|
+
kind: "localPath",
|
|
1532
|
+
path: raw.localPath,
|
|
1533
|
+
size: opened.size,
|
|
1534
|
+
mime: getMimeType(raw.localPath)
|
|
1535
|
+
};
|
|
1536
|
+
} finally {
|
|
1537
|
+
await opened.close();
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
if (raw.buffer.length > maxSize) throw new Error(`Buffer is too large (${formatFileSize(raw.buffer.length)}); QQ Bot API limit is ${formatFileSize(maxSize)}`);
|
|
1541
|
+
return {
|
|
1542
|
+
kind: "buffer",
|
|
1543
|
+
buffer: raw.buffer,
|
|
1544
|
+
fileName: raw.fileName,
|
|
1545
|
+
mime: raw.mime
|
|
1546
|
+
};
|
|
1547
|
+
}
|
|
1548
|
+
//#endregion
|
|
1549
|
+
//#region extensions/qqbot/src/engine/messaging/sender.ts
|
|
1550
|
+
/**
|
|
1551
|
+
* Unified message sender — per-account resource management + business function layer.
|
|
1552
|
+
*
|
|
1553
|
+
* This module is the **single entry point** for all QQ Bot API operations.
|
|
1554
|
+
*
|
|
1555
|
+
* ## Architecture
|
|
1556
|
+
*
|
|
1557
|
+
* Each account gets its own isolated resource stack:
|
|
1558
|
+
*
|
|
1559
|
+
* ```
|
|
1560
|
+
* _accountRegistry: Map<appId, AccountContext>
|
|
1561
|
+
*
|
|
1562
|
+
* AccountContext {
|
|
1563
|
+
* logger — per-account prefixed logger
|
|
1564
|
+
* client — per-account ApiClient
|
|
1565
|
+
* tokenMgr — per-account TokenManager
|
|
1566
|
+
* mediaApi — per-account MediaApi
|
|
1567
|
+
* messageApi — per-account MessageApi
|
|
1568
|
+
* }
|
|
1569
|
+
* ```
|
|
1570
|
+
*
|
|
1571
|
+
* Upper-layer callers (gateway, outbound, reply-dispatcher, proactive)
|
|
1572
|
+
* always go through exported functions that resolve the correct
|
|
1573
|
+
* `AccountContext` by appId.
|
|
1574
|
+
*/
|
|
1575
|
+
let _pluginVersion = "unknown";
|
|
1576
|
+
let _openclawVersion = "unknown";
|
|
1577
|
+
/** Build the User-Agent string from the current plugin and framework versions. */
|
|
1578
|
+
function buildUserAgent() {
|
|
1579
|
+
return `QQBotPlugin/${_pluginVersion} (Node/${process.versions.node}; ${os.platform()}; OpenClaw/${_openclawVersion})`;
|
|
1580
|
+
}
|
|
1581
|
+
/** Return the current User-Agent string. */
|
|
1582
|
+
function getPluginUserAgent() {
|
|
1583
|
+
return buildUserAgent();
|
|
1584
|
+
}
|
|
1585
|
+
/**
|
|
1586
|
+
* Initialize sender with the plugin version.
|
|
1587
|
+
* Must be called once during startup before any API calls.
|
|
1588
|
+
*/
|
|
1589
|
+
function initSender(options) {
|
|
1590
|
+
if (options.pluginVersion) _pluginVersion = options.pluginVersion;
|
|
1591
|
+
if (options.openclawVersion) _openclawVersion = options.openclawVersion;
|
|
1592
|
+
}
|
|
1593
|
+
/** Update the OpenClaw framework version in the User-Agent (called after runtime injection). */
|
|
1594
|
+
function setOpenClawVersion(version) {
|
|
1595
|
+
if (version) _openclawVersion = version;
|
|
1596
|
+
}
|
|
1597
|
+
/** Per-appId account registry — each account owns all its resources. */
|
|
1598
|
+
const _accountRegistry = /* @__PURE__ */ new Map();
|
|
1599
|
+
/** Fallback logger for unregistered accounts (CLI / test scenarios). */
|
|
1600
|
+
const _fallbackLogger = {
|
|
1601
|
+
info: (msg) => debugLog(msg),
|
|
1602
|
+
error: (msg) => debugError(msg),
|
|
1603
|
+
warn: (msg) => debugWarn(msg),
|
|
1604
|
+
debug: (msg) => debugLog(msg)
|
|
1605
|
+
};
|
|
1606
|
+
/**
|
|
1607
|
+
* Build a full resource stack for a given logger.
|
|
1608
|
+
*
|
|
1609
|
+
* Shared by both `registerAccount` (explicit registration) and
|
|
1610
|
+
* `resolveAccount` (lazy fallback for unregistered accounts).
|
|
1611
|
+
*/
|
|
1612
|
+
function buildAccountContext(logger, markdownSupport) {
|
|
1613
|
+
const client = new ApiClient({
|
|
1614
|
+
logger,
|
|
1615
|
+
userAgent: buildUserAgent
|
|
1616
|
+
});
|
|
1617
|
+
const tokenMgr = new TokenManager({
|
|
1618
|
+
logger,
|
|
1619
|
+
userAgent: buildUserAgent
|
|
1620
|
+
});
|
|
1621
|
+
const sharedUploadCache = {
|
|
1622
|
+
computeHash: computeFileHash,
|
|
1623
|
+
get: (hash, scope, targetId, fileType) => getCachedFileInfo(hash, scope, targetId, fileType),
|
|
1624
|
+
set: (hash, scope, targetId, fileType, fileInfo, fileUuid, ttl) => setCachedFileInfo(hash, scope, targetId, fileType, fileInfo, fileUuid, ttl)
|
|
1625
|
+
};
|
|
1626
|
+
return {
|
|
1627
|
+
logger,
|
|
1628
|
+
client,
|
|
1629
|
+
tokenMgr,
|
|
1630
|
+
mediaApi: new MediaApi(client, tokenMgr, {
|
|
1631
|
+
logger,
|
|
1632
|
+
uploadCache: sharedUploadCache,
|
|
1633
|
+
sanitizeFileName
|
|
1634
|
+
}),
|
|
1635
|
+
chunkedMediaApi: new ChunkedMediaApi(client, tokenMgr, {
|
|
1636
|
+
logger,
|
|
1637
|
+
uploadCache: sharedUploadCache,
|
|
1638
|
+
sanitizeFileName
|
|
1639
|
+
}),
|
|
1640
|
+
messageApi: new MessageApi(client, tokenMgr, {
|
|
1641
|
+
markdownSupport,
|
|
1642
|
+
logger
|
|
1643
|
+
}),
|
|
1644
|
+
markdownSupport
|
|
1645
|
+
};
|
|
1646
|
+
}
|
|
1647
|
+
/**
|
|
1648
|
+
* Register an account — atomically sets up all per-appId resources.
|
|
1649
|
+
*
|
|
1650
|
+
* Must be called once per account during gateway startup.
|
|
1651
|
+
* Creates a complete isolated resource stack (ApiClient, TokenManager,
|
|
1652
|
+
* MediaApi, MessageApi) with the per-account logger.
|
|
1653
|
+
*/
|
|
1654
|
+
function registerAccount(appId, options) {
|
|
1655
|
+
const key = appId.trim();
|
|
1656
|
+
const md = options.markdownSupport === true;
|
|
1657
|
+
_accountRegistry.set(key, buildAccountContext(options.logger, md));
|
|
1658
|
+
}
|
|
1659
|
+
/**
|
|
1660
|
+
* Initialize per-app API behavior such as markdown support.
|
|
1661
|
+
*
|
|
1662
|
+
* If the account was already registered via `registerAccount()`, updates its
|
|
1663
|
+
* MessageApi with the new markdown setting while preserving the existing
|
|
1664
|
+
* logger and resource stack. Otherwise creates a new context.
|
|
1665
|
+
*/
|
|
1666
|
+
function initApiConfig(appId, options) {
|
|
1667
|
+
const key = appId.trim();
|
|
1668
|
+
const md = options.markdownSupport === true;
|
|
1669
|
+
const existing = _accountRegistry.get(key);
|
|
1670
|
+
if (existing) {
|
|
1671
|
+
existing.messageApi = new MessageApi(existing.client, existing.tokenMgr, {
|
|
1672
|
+
markdownSupport: md,
|
|
1673
|
+
logger: existing.logger
|
|
1674
|
+
});
|
|
1675
|
+
existing.markdownSupport = md;
|
|
1676
|
+
} else _accountRegistry.set(key, buildAccountContext(_fallbackLogger, md));
|
|
1677
|
+
}
|
|
1678
|
+
/**
|
|
1679
|
+
* Resolve the AccountContext for a given appId.
|
|
1680
|
+
*
|
|
1681
|
+
* If the account was registered via `registerAccount()`, returns the
|
|
1682
|
+
* pre-built context. Otherwise lazily creates a fallback context.
|
|
1683
|
+
*/
|
|
1684
|
+
function resolveAccount(appId) {
|
|
1685
|
+
const key = appId.trim();
|
|
1686
|
+
let ctx = _accountRegistry.get(key);
|
|
1687
|
+
if (!ctx) {
|
|
1688
|
+
ctx = buildAccountContext(_fallbackLogger, false);
|
|
1689
|
+
_accountRegistry.set(key, ctx);
|
|
1690
|
+
}
|
|
1691
|
+
return ctx;
|
|
1692
|
+
}
|
|
1693
|
+
/** Get the MessageApi instance for the given appId. */
|
|
1694
|
+
function getMessageApi(appId) {
|
|
1695
|
+
return resolveAccount(appId).messageApi;
|
|
1696
|
+
}
|
|
1697
|
+
/** Register an outbound-message hook scoped to one appId. */
|
|
1698
|
+
function onMessageSent(appId, callback) {
|
|
1699
|
+
resolveAccount(appId).messageApi.onMessageSent(callback);
|
|
1700
|
+
}
|
|
1701
|
+
async function getAccessToken(appId, clientSecret) {
|
|
1702
|
+
return resolveAccount(appId).tokenMgr.getAccessToken(appId, clientSecret);
|
|
1703
|
+
}
|
|
1704
|
+
function clearTokenCache(appId) {
|
|
1705
|
+
if (appId) resolveAccount(appId).tokenMgr.clearCache(appId);
|
|
1706
|
+
else for (const ctx of _accountRegistry.values()) ctx.tokenMgr.clearCache();
|
|
1707
|
+
}
|
|
1708
|
+
function startBackgroundTokenRefresh(appId, clientSecret, options) {
|
|
1709
|
+
resolveAccount(appId).tokenMgr.startBackgroundRefresh(appId, clientSecret, options);
|
|
1710
|
+
}
|
|
1711
|
+
function stopBackgroundTokenRefresh(appId) {
|
|
1712
|
+
if (appId) resolveAccount(appId).tokenMgr.stopBackgroundRefresh(appId);
|
|
1713
|
+
else for (const ctx of _accountRegistry.values()) ctx.tokenMgr.stopBackgroundRefresh();
|
|
1714
|
+
}
|
|
1715
|
+
async function getGatewayUrl(accessToken, appId) {
|
|
1716
|
+
return (await resolveAccount(appId).client.request(accessToken, "GET", "/gateway")).url;
|
|
1717
|
+
}
|
|
1718
|
+
/** Acknowledge an INTERACTION_CREATE event via PUT /interactions/{id}. */
|
|
1719
|
+
async function acknowledgeInteraction(creds, interactionId, code = 0, data) {
|
|
1720
|
+
const ctx = resolveAccount(creds.appId);
|
|
1721
|
+
const token = await ctx.tokenMgr.getAccessToken(creds.appId, creds.clientSecret);
|
|
1722
|
+
await ctx.client.request(token, "PUT", `/interactions/${interactionId}`, {
|
|
1723
|
+
code,
|
|
1724
|
+
...data ? { data } : {}
|
|
1725
|
+
});
|
|
1726
|
+
}
|
|
1727
|
+
/**
|
|
1728
|
+
* Execute an API call with automatic token-retry on 401 errors.
|
|
1729
|
+
*
|
|
1730
|
+
* Primary signal is structured: `ApiError.httpStatus === 401`. A string
|
|
1731
|
+
* fallback remains for non-`ApiError` paths (e.g. synthetic errors from
|
|
1732
|
+
* custom adapters), but logs a warning so such cases can be surfaced.
|
|
1733
|
+
*/
|
|
1734
|
+
async function withTokenRetry(creds, sendFn, log, _accountId) {
|
|
1735
|
+
try {
|
|
1736
|
+
return await sendFn(await getAccessToken(creds.appId, creds.clientSecret));
|
|
1737
|
+
} catch (err) {
|
|
1738
|
+
if (err instanceof ApiError && err.httpStatus === 401) {
|
|
1739
|
+
log?.debug?.(`Token expired (ApiError 401), refreshing...`);
|
|
1740
|
+
clearTokenCache(creds.appId);
|
|
1741
|
+
return await sendFn(await getAccessToken(creds.appId, creds.clientSecret));
|
|
1742
|
+
}
|
|
1743
|
+
const errMsg = formatErrorMessage(err);
|
|
1744
|
+
if (errMsg.includes("401") || errMsg.includes("token") || errMsg.includes("access_token")) {
|
|
1745
|
+
log?.warn?.(`Token retry triggered by string heuristic (err is not ApiError). Consider propagating ApiError end-to-end. msg=${errMsg.slice(0, 120)}`);
|
|
1746
|
+
clearTokenCache(creds.appId);
|
|
1747
|
+
return await sendFn(await getAccessToken(creds.appId, creds.clientSecret));
|
|
1748
|
+
}
|
|
1749
|
+
throw err;
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
/**
|
|
1753
|
+
* Notify the MessageApi onMessageSent hook after a media send.
|
|
1754
|
+
*/
|
|
1755
|
+
function notifyMediaHook(appId, result, meta) {
|
|
1756
|
+
const refIdx = result.ext_info?.ref_idx;
|
|
1757
|
+
if (refIdx) resolveAccount(appId).messageApi.notifyMessageSent(refIdx, meta);
|
|
1758
|
+
}
|
|
1759
|
+
/**
|
|
1760
|
+
* Send a text message to any QQ target type.
|
|
1761
|
+
*
|
|
1762
|
+
* Automatically routes to the correct API method based on target type.
|
|
1763
|
+
* Handles passive (with msgId) and proactive (without msgId) modes.
|
|
1764
|
+
*/
|
|
1765
|
+
async function sendText(target, content, creds, opts) {
|
|
1766
|
+
const api = resolveAccount(creds.appId).messageApi;
|
|
1767
|
+
const c = {
|
|
1768
|
+
appId: creds.appId,
|
|
1769
|
+
clientSecret: creds.clientSecret
|
|
1770
|
+
};
|
|
1771
|
+
if (target.type === "c2c" || target.type === "group") {
|
|
1772
|
+
const scope = target.type;
|
|
1773
|
+
if (opts?.msgId) return api.sendMessage(scope, target.id, content, c, {
|
|
1774
|
+
msgId: opts.msgId,
|
|
1775
|
+
messageReference: opts.messageReference
|
|
1776
|
+
});
|
|
1777
|
+
return api.sendProactiveMessage(scope, target.id, content, c);
|
|
1778
|
+
}
|
|
1779
|
+
if (target.type === "dm") return api.sendDmMessage({
|
|
1780
|
+
guildId: target.id,
|
|
1781
|
+
content,
|
|
1782
|
+
creds: c,
|
|
1783
|
+
msgId: opts?.msgId
|
|
1784
|
+
});
|
|
1785
|
+
return api.sendChannelMessage({
|
|
1786
|
+
channelId: target.id,
|
|
1787
|
+
content,
|
|
1788
|
+
creds: c,
|
|
1789
|
+
msgId: opts?.msgId
|
|
1790
|
+
});
|
|
1791
|
+
}
|
|
1792
|
+
/**
|
|
1793
|
+
* Send a typing indicator to a C2C user.
|
|
1794
|
+
*/
|
|
1795
|
+
async function sendInputNotify(opts) {
|
|
1796
|
+
const api = resolveAccount(opts.creds.appId).messageApi;
|
|
1797
|
+
const c = {
|
|
1798
|
+
appId: opts.creds.appId,
|
|
1799
|
+
clientSecret: opts.creds.clientSecret
|
|
1800
|
+
};
|
|
1801
|
+
return api.sendInputNotify({
|
|
1802
|
+
openid: opts.openid,
|
|
1803
|
+
creds: c,
|
|
1804
|
+
msgId: opts.msgId,
|
|
1805
|
+
inputSecond: opts.inputSecond
|
|
1806
|
+
});
|
|
1807
|
+
}
|
|
1808
|
+
/**
|
|
1809
|
+
* Raw-token input notify — compatible with TypingKeepAlive's callback signature.
|
|
1810
|
+
*/
|
|
1811
|
+
function createRawInputNotifyFn(appId) {
|
|
1812
|
+
return async (token, openid, msgId, inputSecond) => {
|
|
1813
|
+
const msgSeq = msgId ? getNextMsgSeq(msgId) : 1;
|
|
1814
|
+
return resolveAccount(appId).client.request(token, "POST", `/v2/users/${openid}/messages`, {
|
|
1815
|
+
msg_type: 6,
|
|
1816
|
+
input_notify: {
|
|
1817
|
+
input_type: 1,
|
|
1818
|
+
input_second: inputSecond
|
|
1819
|
+
},
|
|
1820
|
+
msg_seq: msgSeq,
|
|
1821
|
+
...msgId ? { msg_id: msgId } : {}
|
|
1822
|
+
});
|
|
1823
|
+
};
|
|
1824
|
+
}
|
|
1825
|
+
/** Map a {@link MediaKind} to the wire-level {@link MediaFileType} code. */
|
|
1826
|
+
const KIND_TO_FILE_TYPE = {
|
|
1827
|
+
image: 1,
|
|
1828
|
+
voice: 3,
|
|
1829
|
+
video: 2,
|
|
1830
|
+
file: 4
|
|
1831
|
+
};
|
|
1832
|
+
/**
|
|
1833
|
+
* Upload and send a rich-media message to any C2C or Group target.
|
|
1834
|
+
*
|
|
1835
|
+
* This is the **single** rich-media entry point for the plugin. All adapter
|
|
1836
|
+
* layers (outbound.ts, reply-dispatcher.ts, outbound-deliver.ts,
|
|
1837
|
+
* bridge/commands, gateway/outbound-dispatch.ts) funnel through here.
|
|
1838
|
+
*
|
|
1839
|
+
* Dispatch structure:
|
|
1840
|
+
*
|
|
1841
|
+
* ```
|
|
1842
|
+
* sendMedia(opts)
|
|
1843
|
+
* └─ sendMediaInternal(ctx, opts)
|
|
1844
|
+
* ├─ normalizeSource ← unified data:URL parsing + O_NOFOLLOW file safety
|
|
1845
|
+
* ├─ uploadOnce ← one-shot upload via MediaApi (chunked hook TBD)
|
|
1846
|
+
* ├─ sendMediaMessage
|
|
1847
|
+
* └─ notifyMediaHook ← meta assembled per kind
|
|
1848
|
+
* ```
|
|
1849
|
+
*
|
|
1850
|
+
* Future chunked upload will slot into the dispatch without touching callers.
|
|
1851
|
+
*/
|
|
1852
|
+
async function sendMedia(opts) {
|
|
1853
|
+
if (!supportsRichMedia(opts.target.type)) throw new Error(`Media sending not supported for target type: ${opts.target.type}`);
|
|
1854
|
+
return sendMediaInternal(resolveAccount(opts.creds.appId), opts);
|
|
1855
|
+
}
|
|
1856
|
+
/**
|
|
1857
|
+
* Assemble an {@link OutboundMeta} record from the normalized source and the
|
|
1858
|
+
* caller-provided overrides.
|
|
1859
|
+
*
|
|
1860
|
+
* The meta layout is identical across kinds except:
|
|
1861
|
+
* - `image` / `video` carry `text` (the accompanying content string).
|
|
1862
|
+
* - `voice` carries `ttsText` (original TTS input, if any).
|
|
1863
|
+
*/
|
|
1864
|
+
function buildOutboundMeta(opts, source) {
|
|
1865
|
+
const meta = { mediaType: opts.kind };
|
|
1866
|
+
if (opts.kind === "image" || opts.kind === "video") {
|
|
1867
|
+
if (opts.content) meta.text = opts.content;
|
|
1868
|
+
}
|
|
1869
|
+
if (opts.kind === "voice" && opts.ttsText) meta.ttsText = opts.ttsText;
|
|
1870
|
+
const inferredUrl = source.kind === "url" ? source.url : void 0;
|
|
1871
|
+
const mediaUrl = opts.origUrlForMeta ?? inferredUrl;
|
|
1872
|
+
if (mediaUrl) meta.mediaUrl = mediaUrl;
|
|
1873
|
+
const inferredLocal = source.kind === "localPath" ? source.path : void 0;
|
|
1874
|
+
const mediaLocalPath = opts.localPathForMeta ?? inferredLocal;
|
|
1875
|
+
if (mediaLocalPath) meta.mediaLocalPath = mediaLocalPath;
|
|
1876
|
+
return meta;
|
|
1877
|
+
}
|
|
1878
|
+
/**
|
|
1879
|
+
* Core dispatch for rich media. Not exported — callers must go through
|
|
1880
|
+
* {@link sendMedia}.
|
|
1881
|
+
*
|
|
1882
|
+
* Upload dispatch lives in {@link dispatchUpload}: sources smaller than
|
|
1883
|
+
* {@link LARGE_FILE_THRESHOLD} (or not supporting chunked transport, i.e.
|
|
1884
|
+
* url/base64) go to {@link MediaApi.uploadMedia}; larger `localPath` /
|
|
1885
|
+
* `buffer` sources go to {@link ChunkedMediaApi.uploadChunked}.
|
|
1886
|
+
*/
|
|
1887
|
+
async function sendMediaInternal(ctx, opts) {
|
|
1888
|
+
const scope = opts.target.type;
|
|
1889
|
+
const c = {
|
|
1890
|
+
appId: opts.creds.appId,
|
|
1891
|
+
clientSecret: opts.creds.clientSecret
|
|
1892
|
+
};
|
|
1893
|
+
const source = await normalizeSource(opts.source, { maxSize: Number.MAX_SAFE_INTEGER });
|
|
1894
|
+
const uploadResult = await dispatchUpload(ctx, scope, opts.target.id, KIND_TO_FILE_TYPE[opts.kind], source, c, opts.fileName);
|
|
1895
|
+
const msgContent = opts.kind === "image" || opts.kind === "video" ? opts.content : void 0;
|
|
1896
|
+
const result = await ctx.mediaApi.sendMediaMessage(scope, opts.target.id, uploadResult.file_info, c, {
|
|
1897
|
+
msgId: opts.msgId,
|
|
1898
|
+
content: msgContent
|
|
1899
|
+
});
|
|
1900
|
+
notifyMediaHook(opts.creds.appId, result, buildOutboundMeta(opts, source));
|
|
1901
|
+
return result;
|
|
1902
|
+
}
|
|
1903
|
+
/**
|
|
1904
|
+
* Upload a {@link MediaSource} via the one-shot or chunked path, chosen by
|
|
1905
|
+
* size + kind.
|
|
1906
|
+
*
|
|
1907
|
+
* Routing rules (kept here as the single source of truth so callers need
|
|
1908
|
+
* not know which endpoint was used):
|
|
1909
|
+
*
|
|
1910
|
+
* - `url` / `base64`: always one-shot — the server accepts these directly
|
|
1911
|
+
* and the chunked endpoint has no representation for them.
|
|
1912
|
+
* - `localPath` / `buffer` with `size >= LARGE_FILE_THRESHOLD`: chunked.
|
|
1913
|
+
* - Everything else: one-shot.
|
|
1914
|
+
*/
|
|
1915
|
+
async function dispatchUpload(ctx, scope, targetId, fileType, source, creds, fileName) {
|
|
1916
|
+
switch (source.kind) {
|
|
1917
|
+
case "url": return ctx.mediaApi.uploadMedia(scope, targetId, fileType, creds, {
|
|
1918
|
+
url: source.url,
|
|
1919
|
+
fileName
|
|
1920
|
+
});
|
|
1921
|
+
case "base64": return ctx.mediaApi.uploadMedia(scope, targetId, fileType, creds, {
|
|
1922
|
+
fileData: source.data,
|
|
1923
|
+
fileName
|
|
1924
|
+
});
|
|
1925
|
+
case "localPath":
|
|
1926
|
+
if (source.size >= 5242880) return ctx.chunkedMediaApi.uploadChunked({
|
|
1927
|
+
scope,
|
|
1928
|
+
targetId,
|
|
1929
|
+
fileType,
|
|
1930
|
+
source,
|
|
1931
|
+
creds,
|
|
1932
|
+
fileName
|
|
1933
|
+
});
|
|
1934
|
+
return ctx.mediaApi.uploadMedia(scope, targetId, fileType, creds, {
|
|
1935
|
+
localPath: source.path,
|
|
1936
|
+
fileName
|
|
1937
|
+
});
|
|
1938
|
+
case "buffer":
|
|
1939
|
+
if (source.buffer.length >= 5242880) return ctx.chunkedMediaApi.uploadChunked({
|
|
1940
|
+
scope,
|
|
1941
|
+
targetId,
|
|
1942
|
+
fileType,
|
|
1943
|
+
source,
|
|
1944
|
+
creds,
|
|
1945
|
+
fileName: fileName ?? source.fileName
|
|
1946
|
+
});
|
|
1947
|
+
return ctx.mediaApi.uploadMedia(scope, targetId, fileType, creds, {
|
|
1948
|
+
buffer: source.buffer,
|
|
1949
|
+
fileName: fileName ?? source.fileName
|
|
1950
|
+
});
|
|
1951
|
+
default: throw new Error(`dispatchUpload: unsupported MediaSource kind: ${JSON.stringify(source)}`);
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
/** Build a DeliveryTarget from event context fields. */
|
|
1955
|
+
function buildDeliveryTarget(event) {
|
|
1956
|
+
switch (event.type) {
|
|
1957
|
+
case "c2c": return {
|
|
1958
|
+
type: "c2c",
|
|
1959
|
+
id: event.senderId
|
|
1960
|
+
};
|
|
1961
|
+
case "group": return {
|
|
1962
|
+
type: "group",
|
|
1963
|
+
id: event.groupOpenid
|
|
1964
|
+
};
|
|
1965
|
+
case "dm": return {
|
|
1966
|
+
type: "dm",
|
|
1967
|
+
id: event.guildId
|
|
1968
|
+
};
|
|
1969
|
+
default: return {
|
|
1970
|
+
type: "channel",
|
|
1971
|
+
id: event.channelId
|
|
1972
|
+
};
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
/** Build AccountCreds from a GatewayAccount. */
|
|
1976
|
+
function accountToCreds(account) {
|
|
1977
|
+
return {
|
|
1978
|
+
appId: account.appId,
|
|
1979
|
+
clientSecret: account.clientSecret
|
|
1980
|
+
};
|
|
1981
|
+
}
|
|
1982
|
+
/** Check whether a target type supports rich media (C2C and Group only). */
|
|
1983
|
+
function supportsRichMedia(targetType) {
|
|
1984
|
+
return targetType === "c2c" || targetType === "group";
|
|
1985
|
+
}
|
|
1986
|
+
//#endregion
|
|
1987
|
+
export { fileExistsAsync as A, StreamInputState as B, debugLog as C, UPLOAD_PREPARE_FALLBACK_CODE as D, getNextMsgSeq as E, readFileAsync as F, formatDuration as I, formatErrorMessage as L, getFileTypeName as M, getImageMimeType as N, checkFileSize as O, getMaxUploadSize as P, StreamContentType as R, debugError as S, UploadDailyLimitExceededError as T, setOpenClawVersion as _, createRawInputNotifyFn as a, withTokenRetry as b, getMessageApi as c, initSender as d, onMessageSent as f, sendText as g, sendMedia as h, clearTokenCache as i, formatFileSize as j, downloadFile as k, getPluginUserAgent as l, sendInputNotify as m, acknowledgeInteraction as n, getAccessToken as o, registerAccount as p, buildDeliveryTarget as r, getGatewayUrl as s, accountToCreds as t, initApiConfig as u, startBackgroundTokenRefresh as v, debugWarn as w, openLocalFile as x, stopBackgroundTokenRefresh as y, StreamInputMode as z };
|