@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.
Files changed (203) hide show
  1. package/dist/api.js +646 -0
  2. package/dist/approval-cg0SVahb.js +94 -0
  3. package/dist/channel-N6Y_Rcjp.js +551 -0
  4. package/dist/channel-plugin-api.js +2 -0
  5. package/dist/channel.setup-ByCRAe5H.js +25 -0
  6. package/dist/config-D6545NkC.js +94 -0
  7. package/dist/config-schema-DFcjQw73.js +309 -0
  8. package/dist/exec-approvals-COUsM6wZ.js +138 -0
  9. package/dist/gateway-CmSUJKSt.js +6726 -0
  10. package/dist/handler-runtime-Bqm6N0WG.js +120 -0
  11. package/dist/index.js +29 -0
  12. package/dist/narrowing-BoieBTIU.js +25 -0
  13. package/dist/outbound-BJfhwrPg.js +1357 -0
  14. package/dist/request-context-DXtpwWui.js +921 -0
  15. package/dist/resolve-D_06fV6-.js +284 -0
  16. package/dist/runtime-BJAS3eXW.js +18 -0
  17. package/dist/runtime-api.js +2 -0
  18. package/dist/secret-contract-api.js +56 -0
  19. package/dist/sender-p-B14eLG.js +1987 -0
  20. package/dist/setup-entry.js +15 -0
  21. package/dist/setup-plugin-api.js +2 -0
  22. package/dist/string-normalize-Ci6NM5DE.js +98 -0
  23. package/dist/target-parser-Y0prnrXD.js +245 -0
  24. package/package.json +15 -6
  25. package/api.ts +0 -56
  26. package/channel-plugin-api.ts +0 -1
  27. package/index.ts +0 -29
  28. package/runtime-api.ts +0 -9
  29. package/setup-entry.ts +0 -9
  30. package/setup-plugin-api.ts +0 -3
  31. package/src/bridge/approval/capability.ts +0 -237
  32. package/src/bridge/approval/handler-runtime.ts +0 -204
  33. package/src/bridge/bootstrap.ts +0 -135
  34. package/src/bridge/channel-entry.ts +0 -18
  35. package/src/bridge/commands/framework-context-adapter.ts +0 -60
  36. package/src/bridge/commands/framework-registration.ts +0 -47
  37. package/src/bridge/commands/from-parser.test.ts +0 -86
  38. package/src/bridge/commands/from-parser.ts +0 -60
  39. package/src/bridge/commands/result-dispatcher.ts +0 -76
  40. package/src/bridge/config-shared.ts +0 -132
  41. package/src/bridge/config.ts +0 -111
  42. package/src/bridge/gateway.ts +0 -174
  43. package/src/bridge/logger.ts +0 -31
  44. package/src/bridge/narrowing.ts +0 -31
  45. package/src/bridge/plugin-version.test.ts +0 -146
  46. package/src/bridge/plugin-version.ts +0 -102
  47. package/src/bridge/runtime.ts +0 -25
  48. package/src/bridge/sdk-adapter.ts +0 -131
  49. package/src/bridge/setup/finalize.ts +0 -144
  50. package/src/bridge/setup/surface.ts +0 -34
  51. package/src/bridge/tools/channel.ts +0 -58
  52. package/src/bridge/tools/index.ts +0 -15
  53. package/src/bridge/tools/remind.test.ts +0 -124
  54. package/src/bridge/tools/remind.ts +0 -91
  55. package/src/channel.setup.ts +0 -33
  56. package/src/channel.ts +0 -288
  57. package/src/command-auth.test.ts +0 -62
  58. package/src/config-schema.ts +0 -84
  59. package/src/config.test.ts +0 -364
  60. package/src/engine/access/access-control.test.ts +0 -198
  61. package/src/engine/access/access-control.ts +0 -226
  62. package/src/engine/access/index.ts +0 -16
  63. package/src/engine/access/resolve-policy.test.ts +0 -59
  64. package/src/engine/access/resolve-policy.ts +0 -57
  65. package/src/engine/access/sender-match.test.ts +0 -60
  66. package/src/engine/access/sender-match.ts +0 -55
  67. package/src/engine/access/types.ts +0 -53
  68. package/src/engine/adapter/audio.port.ts +0 -27
  69. package/src/engine/adapter/commands.port.ts +0 -22
  70. package/src/engine/adapter/history.port.ts +0 -52
  71. package/src/engine/adapter/index.ts +0 -139
  72. package/src/engine/adapter/mention-gate.port.ts +0 -50
  73. package/src/engine/adapter/types.ts +0 -38
  74. package/src/engine/api/api-client.ts +0 -212
  75. package/src/engine/api/media-chunked.test.ts +0 -336
  76. package/src/engine/api/media-chunked.ts +0 -622
  77. package/src/engine/api/media.ts +0 -218
  78. package/src/engine/api/messages.ts +0 -293
  79. package/src/engine/api/retry.ts +0 -217
  80. package/src/engine/api/routes.ts +0 -95
  81. package/src/engine/api/token.ts +0 -271
  82. package/src/engine/approval/index.test.ts +0 -22
  83. package/src/engine/approval/index.ts +0 -224
  84. package/src/engine/commands/builtin/log-helpers.ts +0 -319
  85. package/src/engine/commands/builtin/register-all.ts +0 -17
  86. package/src/engine/commands/builtin/register-approve.ts +0 -201
  87. package/src/engine/commands/builtin/register-basic.ts +0 -95
  88. package/src/engine/commands/builtin/register-clear-storage.ts +0 -187
  89. package/src/engine/commands/builtin/register-logs.ts +0 -20
  90. package/src/engine/commands/builtin/register-streaming.ts +0 -137
  91. package/src/engine/commands/builtin/state.ts +0 -31
  92. package/src/engine/commands/slash-command-auth.ts +0 -48
  93. package/src/engine/commands/slash-command-handler.ts +0 -146
  94. package/src/engine/commands/slash-commands-impl.test.ts +0 -8
  95. package/src/engine/commands/slash-commands-impl.ts +0 -61
  96. package/src/engine/commands/slash-commands.ts +0 -199
  97. package/src/engine/config/credential-backup.test.ts +0 -88
  98. package/src/engine/config/credential-backup.ts +0 -107
  99. package/src/engine/config/credentials.ts +0 -76
  100. package/src/engine/config/group.test.ts +0 -234
  101. package/src/engine/config/group.ts +0 -299
  102. package/src/engine/config/resolve.test.ts +0 -152
  103. package/src/engine/config/resolve.ts +0 -283
  104. package/src/engine/config/setup-logic.ts +0 -84
  105. package/src/engine/engine-import-boundary.test.ts +0 -73
  106. package/src/engine/gateway/codec.ts +0 -47
  107. package/src/engine/gateway/constants.ts +0 -117
  108. package/src/engine/gateway/event-dispatcher.ts +0 -177
  109. package/src/engine/gateway/gateway-connection.ts +0 -371
  110. package/src/engine/gateway/gateway.ts +0 -291
  111. package/src/engine/gateway/inbound-attachments.test.ts +0 -126
  112. package/src/engine/gateway/inbound-attachments.ts +0 -360
  113. package/src/engine/gateway/inbound-context.ts +0 -195
  114. package/src/engine/gateway/inbound-pipeline.self-echo.test.ts +0 -218
  115. package/src/engine/gateway/inbound-pipeline.ts +0 -235
  116. package/src/engine/gateway/interaction-handler.ts +0 -220
  117. package/src/engine/gateway/message-queue.test.ts +0 -282
  118. package/src/engine/gateway/message-queue.ts +0 -499
  119. package/src/engine/gateway/outbound-dispatch.test.ts +0 -231
  120. package/src/engine/gateway/outbound-dispatch.ts +0 -575
  121. package/src/engine/gateway/reconnect.ts +0 -199
  122. package/src/engine/gateway/stages/access-stage.ts +0 -132
  123. package/src/engine/gateway/stages/assembly-stage.ts +0 -156
  124. package/src/engine/gateway/stages/content-stage.test.ts +0 -77
  125. package/src/engine/gateway/stages/content-stage.ts +0 -77
  126. package/src/engine/gateway/stages/envelope-stage.test.ts +0 -152
  127. package/src/engine/gateway/stages/envelope-stage.ts +0 -144
  128. package/src/engine/gateway/stages/group-gate-stage.ts +0 -292
  129. package/src/engine/gateway/stages/index.ts +0 -18
  130. package/src/engine/gateway/stages/quote-stage.ts +0 -113
  131. package/src/engine/gateway/stages/refidx-stage.ts +0 -62
  132. package/src/engine/gateway/stages/stub-contexts.ts +0 -116
  133. package/src/engine/gateway/types.ts +0 -264
  134. package/src/engine/gateway/typing-keepalive.ts +0 -79
  135. package/src/engine/group/activation.test.ts +0 -114
  136. package/src/engine/group/activation.ts +0 -147
  137. package/src/engine/group/history.test.ts +0 -314
  138. package/src/engine/group/history.ts +0 -321
  139. package/src/engine/group/mention.test.ts +0 -141
  140. package/src/engine/group/mention.ts +0 -197
  141. package/src/engine/group/message-gating.test.ts +0 -188
  142. package/src/engine/group/message-gating.ts +0 -216
  143. package/src/engine/messaging/decode-media-path.ts +0 -82
  144. package/src/engine/messaging/media-source.ts +0 -215
  145. package/src/engine/messaging/media-type-detect.ts +0 -37
  146. package/src/engine/messaging/outbound-audio-port.ts +0 -38
  147. package/src/engine/messaging/outbound-deliver.ts +0 -810
  148. package/src/engine/messaging/outbound-media-send.ts +0 -702
  149. package/src/engine/messaging/outbound-reply.ts +0 -27
  150. package/src/engine/messaging/outbound-result-helpers.ts +0 -54
  151. package/src/engine/messaging/outbound-types.ts +0 -45
  152. package/src/engine/messaging/outbound.ts +0 -485
  153. package/src/engine/messaging/reply-dispatcher.ts +0 -597
  154. package/src/engine/messaging/reply-limiter.ts +0 -164
  155. package/src/engine/messaging/sender.ts +0 -729
  156. package/src/engine/messaging/streaming-c2c.ts +0 -1192
  157. package/src/engine/messaging/streaming-media-send.ts +0 -544
  158. package/src/engine/messaging/target-parser.ts +0 -104
  159. package/src/engine/ref/format-message-ref.ts +0 -142
  160. package/src/engine/ref/format-ref-entry.test.ts +0 -60
  161. package/src/engine/ref/format-ref-entry.ts +0 -27
  162. package/src/engine/ref/store.ts +0 -208
  163. package/src/engine/ref/types.ts +0 -27
  164. package/src/engine/session/known-users.ts +0 -137
  165. package/src/engine/session/session-store.ts +0 -204
  166. package/src/engine/tools/channel-api.ts +0 -244
  167. package/src/engine/tools/remind-logic.test.ts +0 -280
  168. package/src/engine/tools/remind-logic.ts +0 -377
  169. package/src/engine/types.ts +0 -313
  170. package/src/engine/utils/attachment-tags.test.ts +0 -186
  171. package/src/engine/utils/attachment-tags.ts +0 -174
  172. package/src/engine/utils/audio.test.ts +0 -250
  173. package/src/engine/utils/audio.ts +0 -585
  174. package/src/engine/utils/data-paths.ts +0 -38
  175. package/src/engine/utils/diagnostics.ts +0 -109
  176. package/src/engine/utils/file-utils.test.ts +0 -72
  177. package/src/engine/utils/file-utils.ts +0 -225
  178. package/src/engine/utils/format.test.ts +0 -68
  179. package/src/engine/utils/format.ts +0 -70
  180. package/src/engine/utils/image-size.test.ts +0 -158
  181. package/src/engine/utils/image-size.ts +0 -249
  182. package/src/engine/utils/log.test.ts +0 -28
  183. package/src/engine/utils/log.ts +0 -61
  184. package/src/engine/utils/media-tags.test.ts +0 -32
  185. package/src/engine/utils/media-tags.ts +0 -177
  186. package/src/engine/utils/payload.test.ts +0 -68
  187. package/src/engine/utils/payload.ts +0 -145
  188. package/src/engine/utils/platform-storage-laziness.test.ts +0 -63
  189. package/src/engine/utils/platform.test.ts +0 -148
  190. package/src/engine/utils/platform.ts +0 -343
  191. package/src/engine/utils/request-context.ts +0 -60
  192. package/src/engine/utils/string-normalize.ts +0 -91
  193. package/src/engine/utils/stt.test.ts +0 -104
  194. package/src/engine/utils/stt.ts +0 -100
  195. package/src/engine/utils/text-parsing.test.ts +0 -29
  196. package/src/engine/utils/text-parsing.ts +0 -155
  197. package/src/engine/utils/upload-cache.ts +0 -96
  198. package/src/engine/utils/voice-text.ts +0 -15
  199. package/src/exec-approvals.ts +0 -218
  200. package/src/manifest-schema.test.ts +0 -56
  201. package/src/qqbot-test-support.ts +0 -29
  202. package/src/types.ts +0 -210
  203. 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 };