@gakr-gakr/qqbot 0.1.0

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 (149) hide show
  1. package/api.ts +56 -0
  2. package/autobot.plugin.json +167 -0
  3. package/channel-plugin-api.ts +1 -0
  4. package/index.ts +33 -0
  5. package/package.json +64 -0
  6. package/runtime-api.ts +9 -0
  7. package/secret-contract-api.ts +5 -0
  8. package/setup-entry.ts +13 -0
  9. package/setup-plugin-api.ts +3 -0
  10. package/skills/qqbot-channel/SKILL.md +262 -0
  11. package/skills/qqbot-channel/references/api_references.md +521 -0
  12. package/skills/qqbot-media/SKILL.md +37 -0
  13. package/skills/qqbot-remind/SKILL.md +153 -0
  14. package/src/bridge/approval/capability.ts +225 -0
  15. package/src/bridge/approval/handler-runtime.ts +204 -0
  16. package/src/bridge/bootstrap.ts +135 -0
  17. package/src/bridge/channel-entry.ts +18 -0
  18. package/src/bridge/commands/framework-context-adapter.ts +60 -0
  19. package/src/bridge/commands/framework-registration.ts +66 -0
  20. package/src/bridge/commands/from-parser.ts +60 -0
  21. package/src/bridge/commands/result-dispatcher.ts +76 -0
  22. package/src/bridge/config-shared.ts +132 -0
  23. package/src/bridge/config.ts +176 -0
  24. package/src/bridge/gateway.ts +178 -0
  25. package/src/bridge/logger.ts +31 -0
  26. package/src/bridge/narrowing.ts +31 -0
  27. package/src/bridge/plugin-version.ts +102 -0
  28. package/src/bridge/runtime.ts +25 -0
  29. package/src/bridge/sdk-adapter.ts +164 -0
  30. package/src/bridge/setup/finalize.ts +144 -0
  31. package/src/bridge/setup/surface.ts +34 -0
  32. package/src/bridge/tools/channel.ts +58 -0
  33. package/src/bridge/tools/index.ts +15 -0
  34. package/src/bridge/tools/remind.ts +91 -0
  35. package/src/channel.setup.ts +33 -0
  36. package/src/channel.ts +399 -0
  37. package/src/config-schema.ts +84 -0
  38. package/src/engine/access/index.ts +2 -0
  39. package/src/engine/access/resolve-policy.ts +30 -0
  40. package/src/engine/access/sender-match.ts +55 -0
  41. package/src/engine/access/types.ts +2 -0
  42. package/src/engine/adapter/audio.port.ts +27 -0
  43. package/src/engine/adapter/commands.port.ts +22 -0
  44. package/src/engine/adapter/history.port.ts +52 -0
  45. package/src/engine/adapter/index.ts +76 -0
  46. package/src/engine/adapter/mention-gate.port.ts +50 -0
  47. package/src/engine/adapter/types.ts +38 -0
  48. package/src/engine/api/api-client.ts +212 -0
  49. package/src/engine/api/media-chunked.ts +644 -0
  50. package/src/engine/api/media.ts +218 -0
  51. package/src/engine/api/messages.ts +293 -0
  52. package/src/engine/api/retry.ts +217 -0
  53. package/src/engine/api/routes.ts +95 -0
  54. package/src/engine/api/token.ts +277 -0
  55. package/src/engine/approval/index.ts +224 -0
  56. package/src/engine/commands/builtin/log-helpers.ts +341 -0
  57. package/src/engine/commands/builtin/register-all.ts +17 -0
  58. package/src/engine/commands/builtin/register-approve.ts +201 -0
  59. package/src/engine/commands/builtin/register-basic.ts +95 -0
  60. package/src/engine/commands/builtin/register-clear-storage.ts +187 -0
  61. package/src/engine/commands/builtin/register-logs.ts +20 -0
  62. package/src/engine/commands/builtin/register-streaming.ts +138 -0
  63. package/src/engine/commands/builtin/state.ts +31 -0
  64. package/src/engine/commands/slash-command-auth.ts +88 -0
  65. package/src/engine/commands/slash-command-handler.ts +168 -0
  66. package/src/engine/commands/slash-command-test-support.ts +39 -0
  67. package/src/engine/commands/slash-commands-impl.ts +61 -0
  68. package/src/engine/commands/slash-commands.ts +202 -0
  69. package/src/engine/config/credential-backup.ts +108 -0
  70. package/src/engine/config/credentials.ts +76 -0
  71. package/src/engine/config/group.ts +227 -0
  72. package/src/engine/config/resolve.ts +283 -0
  73. package/src/engine/config/setup-logic.ts +84 -0
  74. package/src/engine/gateway/active-cfg.ts +52 -0
  75. package/src/engine/gateway/codec.ts +47 -0
  76. package/src/engine/gateway/constants.ts +117 -0
  77. package/src/engine/gateway/event-dispatcher.ts +177 -0
  78. package/src/engine/gateway/gateway-connection.ts +356 -0
  79. package/src/engine/gateway/gateway.ts +267 -0
  80. package/src/engine/gateway/inbound-attachments.ts +360 -0
  81. package/src/engine/gateway/inbound-context.ts +82 -0
  82. package/src/engine/gateway/inbound-pipeline.ts +171 -0
  83. package/src/engine/gateway/interaction-handler.ts +345 -0
  84. package/src/engine/gateway/message-queue.ts +404 -0
  85. package/src/engine/gateway/outbound-dispatch.ts +590 -0
  86. package/src/engine/gateway/reconnect.ts +199 -0
  87. package/src/engine/gateway/stages/access-stage.ts +99 -0
  88. package/src/engine/gateway/stages/assembly-stage.ts +156 -0
  89. package/src/engine/gateway/stages/content-stage.ts +77 -0
  90. package/src/engine/gateway/stages/envelope-stage.ts +144 -0
  91. package/src/engine/gateway/stages/group-gate-stage.ts +223 -0
  92. package/src/engine/gateway/stages/index.ts +18 -0
  93. package/src/engine/gateway/stages/quote-stage.ts +113 -0
  94. package/src/engine/gateway/stages/refidx-stage.ts +62 -0
  95. package/src/engine/gateway/stages/stub-contexts.ts +77 -0
  96. package/src/engine/gateway/types.ts +230 -0
  97. package/src/engine/gateway/typing-keepalive.ts +102 -0
  98. package/src/engine/gateway/ws-client.ts +16 -0
  99. package/src/engine/group/activation.ts +88 -0
  100. package/src/engine/group/history.ts +321 -0
  101. package/src/engine/group/mention.ts +114 -0
  102. package/src/engine/group/message-gating.ts +108 -0
  103. package/src/engine/messaging/decode-media-path.ts +82 -0
  104. package/src/engine/messaging/media-source.ts +210 -0
  105. package/src/engine/messaging/media-type-detect.ts +27 -0
  106. package/src/engine/messaging/outbound-audio-port.ts +38 -0
  107. package/src/engine/messaging/outbound-deliver.ts +810 -0
  108. package/src/engine/messaging/outbound-media-send.ts +658 -0
  109. package/src/engine/messaging/outbound-reply.ts +27 -0
  110. package/src/engine/messaging/outbound-result-helpers.ts +54 -0
  111. package/src/engine/messaging/outbound-types.ts +47 -0
  112. package/src/engine/messaging/outbound.ts +485 -0
  113. package/src/engine/messaging/reply-dispatcher.ts +597 -0
  114. package/src/engine/messaging/reply-limiter.ts +164 -0
  115. package/src/engine/messaging/sender.ts +741 -0
  116. package/src/engine/messaging/streaming-c2c.ts +1192 -0
  117. package/src/engine/messaging/streaming-media-send.ts +544 -0
  118. package/src/engine/messaging/target-parser.ts +104 -0
  119. package/src/engine/ref/format-message-ref.ts +142 -0
  120. package/src/engine/ref/format-ref-entry.ts +27 -0
  121. package/src/engine/ref/store.ts +211 -0
  122. package/src/engine/ref/types.ts +27 -0
  123. package/src/engine/session/known-users.ts +138 -0
  124. package/src/engine/session/session-store.ts +207 -0
  125. package/src/engine/tools/channel-api.ts +244 -0
  126. package/src/engine/tools/remind-logic.ts +377 -0
  127. package/src/engine/types.ts +313 -0
  128. package/src/engine/utils/attachment-tags.ts +174 -0
  129. package/src/engine/utils/audio.ts +525 -0
  130. package/src/engine/utils/data-paths.ts +38 -0
  131. package/src/engine/utils/diagnostics.ts +93 -0
  132. package/src/engine/utils/file-utils.ts +215 -0
  133. package/src/engine/utils/format.ts +70 -0
  134. package/src/engine/utils/image-size.ts +249 -0
  135. package/src/engine/utils/log.ts +77 -0
  136. package/src/engine/utils/media-tags.ts +177 -0
  137. package/src/engine/utils/payload.ts +157 -0
  138. package/src/engine/utils/platform.ts +265 -0
  139. package/src/engine/utils/request-context.ts +60 -0
  140. package/src/engine/utils/string-normalize.ts +91 -0
  141. package/src/engine/utils/stt.ts +103 -0
  142. package/src/engine/utils/text-parsing.ts +155 -0
  143. package/src/engine/utils/upload-cache.ts +96 -0
  144. package/src/engine/utils/voice-text.ts +15 -0
  145. package/src/exec-approvals.ts +237 -0
  146. package/src/qqbot-test-support.ts +29 -0
  147. package/src/secret-contract.ts +82 -0
  148. package/src/types.ts +210 -0
  149. package/tsconfig.json +16 -0
@@ -0,0 +1,741 @@
1
+ /**
2
+ * Unified message sender — per-account resource management + business function layer.
3
+ *
4
+ * This module is the **single entry point** for all QQ Bot API operations.
5
+ *
6
+ * ## Architecture
7
+ *
8
+ * Each account gets its own isolated resource stack:
9
+ *
10
+ * ```
11
+ * accountRegistry: Map<appId, AccountContext>
12
+ *
13
+ * AccountContext {
14
+ * logger — per-account prefixed logger
15
+ * client — per-account ApiClient
16
+ * tokenMgr — per-account TokenManager
17
+ * mediaApi — per-account MediaApi
18
+ * messageApi — per-account MessageApi
19
+ * }
20
+ * ```
21
+ *
22
+ * Upper-layer callers (gateway, outbound, reply-dispatcher, proactive)
23
+ * always go through exported functions that resolve the correct
24
+ * `AccountContext` by appId.
25
+ */
26
+
27
+ import os from "node:os";
28
+ import { ApiClient } from "../api/api-client.js";
29
+ import { ChunkedMediaApi as ChunkedMediaApiClass } from "../api/media-chunked.js";
30
+ import { MediaApi as MediaApiClass } from "../api/media.js";
31
+ import type { Credentials } from "../api/messages.js";
32
+ import { MessageApi as MessageApiClass } from "../api/messages.js";
33
+ import { getNextMsgSeq } from "../api/routes.js";
34
+ import { TokenManager } from "../api/token.js";
35
+ import {
36
+ ApiError,
37
+ MediaFileType,
38
+ type ChatScope,
39
+ type EngineLogger,
40
+ type MessageResponse,
41
+ type OutboundMeta,
42
+ type UploadMediaResponse,
43
+ } from "../types.js";
44
+ import { LARGE_FILE_THRESHOLD } from "../utils/file-utils.js";
45
+ import { formatErrorMessage } from "../utils/format.js";
46
+ import { debugLog, debugError, debugWarn } from "../utils/log.js";
47
+ import { sanitizeFileName } from "../utils/string-normalize.js";
48
+ import { computeFileHash, getCachedFileInfo, setCachedFileInfo } from "../utils/upload-cache.js";
49
+ import { normalizeSource, type MediaSource, type RawMediaSource } from "./media-source.js";
50
+
51
+ // ============ Re-exported types ============
52
+
53
+ export { UploadDailyLimitExceededError } from "../api/media-chunked.js";
54
+
55
+ // ============ Plugin User-Agent ============
56
+
57
+ let pluginVersion = "unknown";
58
+ let autobotVersion = "unknown";
59
+
60
+ /** Build the User-Agent string from the current plugin and framework versions. */
61
+ function buildUserAgent(): string {
62
+ return `QQBotPlugin/${pluginVersion} (Node/${process.versions.node}; ${os.platform()}; AutoBot/${autobotVersion})`;
63
+ }
64
+
65
+ /** Return the current User-Agent string. */
66
+ export function getPluginUserAgent(): string {
67
+ return buildUserAgent();
68
+ }
69
+
70
+ /**
71
+ * Initialize sender with the plugin version.
72
+ * Must be called once during startup before any API calls.
73
+ */
74
+ export function initSender(options: { pluginVersion?: string; autobotVersion?: string }): void {
75
+ if (options.pluginVersion) {
76
+ pluginVersion = options.pluginVersion;
77
+ }
78
+ if (options.autobotVersion) {
79
+ autobotVersion = options.autobotVersion;
80
+ }
81
+ }
82
+
83
+ /** Update the AutoBot framework version in the User-Agent (called after runtime injection). */
84
+ export function setAutoBotVersion(version: string): void {
85
+ if (version) {
86
+ autobotVersion = version;
87
+ }
88
+ }
89
+
90
+ // ============ Per-account resource management ============
91
+
92
+ /** Complete resource context for a single account. */
93
+ interface AccountContext {
94
+ logger: EngineLogger;
95
+ client: ApiClient;
96
+ tokenMgr: TokenManager;
97
+ mediaApi: MediaApiClass;
98
+ chunkedMediaApi: ChunkedMediaApiClass;
99
+ messageApi: MessageApiClass;
100
+ markdownSupport: boolean;
101
+ }
102
+
103
+ /** Per-appId account registry — each account owns all its resources. */
104
+ const accountRegistry = new Map<string, AccountContext>();
105
+
106
+ /** Fallback logger for unregistered accounts (CLI / test scenarios). */
107
+ const fallbackLogger: EngineLogger = {
108
+ info: (msg: string) => debugLog(msg),
109
+ error: (msg: string) => debugError(msg),
110
+ warn: (msg: string) => debugWarn(msg),
111
+ debug: (msg: string) => debugLog(msg),
112
+ };
113
+
114
+ /**
115
+ * Build a full resource stack for a given logger.
116
+ *
117
+ * Shared by both `registerAccount` (explicit registration) and
118
+ * `resolveAccount` (lazy fallback for unregistered accounts).
119
+ */
120
+ function buildAccountContext(logger: EngineLogger, markdownSupport: boolean): AccountContext {
121
+ const client = new ApiClient({ logger, userAgent: buildUserAgent });
122
+ const tokenMgr = new TokenManager({ logger, userAgent: buildUserAgent });
123
+ // The one-shot and chunked uploaders share the same cache adapter so repeat
124
+ // sends of identical bytes hit the same `file_info` regardless of which
125
+ // path the first send used.
126
+ const sharedUploadCache = {
127
+ computeHash: computeFileHash,
128
+ get: (hash: string, scope: string, targetId: string, fileType: number) =>
129
+ getCachedFileInfo(hash, scope as ChatScope, targetId, fileType),
130
+ set: (
131
+ hash: string,
132
+ scope: string,
133
+ targetId: string,
134
+ fileType: number,
135
+ fileInfo: string,
136
+ fileUuid: string,
137
+ ttl: number,
138
+ ) => setCachedFileInfo(hash, scope as ChatScope, targetId, fileType, fileInfo, fileUuid, ttl),
139
+ };
140
+ const mediaApi = new MediaApiClass(client, tokenMgr, {
141
+ logger,
142
+ uploadCache: sharedUploadCache,
143
+ sanitizeFileName,
144
+ });
145
+ const chunkedMediaApi = new ChunkedMediaApiClass(client, tokenMgr, {
146
+ logger,
147
+ uploadCache: sharedUploadCache,
148
+ sanitizeFileName,
149
+ });
150
+ const messageApi = new MessageApiClass(client, tokenMgr, {
151
+ markdownSupport,
152
+ logger,
153
+ });
154
+
155
+ return { logger, client, tokenMgr, mediaApi, chunkedMediaApi, messageApi, markdownSupport };
156
+ }
157
+
158
+ /**
159
+ * Register an account — atomically sets up all per-appId resources.
160
+ *
161
+ * Must be called once per account during gateway startup.
162
+ * Creates a complete isolated resource stack (ApiClient, TokenManager,
163
+ * MediaApi, MessageApi) with the per-account logger.
164
+ */
165
+ export function registerAccount(
166
+ appId: string,
167
+ options: {
168
+ logger: EngineLogger;
169
+ markdownSupport?: boolean;
170
+ },
171
+ ): void {
172
+ const key = appId.trim();
173
+ const md = options.markdownSupport === true;
174
+ accountRegistry.set(key, buildAccountContext(options.logger, md));
175
+ }
176
+
177
+ /**
178
+ * Initialize per-app API behavior such as markdown support.
179
+ *
180
+ * If the account was already registered via `registerAccount()`, updates its
181
+ * MessageApi with the new markdown setting while preserving the existing
182
+ * logger and resource stack. Otherwise creates a new context.
183
+ */
184
+ export function initApiConfig(appId: string, options: { markdownSupport?: boolean }): void {
185
+ const key = appId.trim();
186
+ const md = options.markdownSupport === true;
187
+ const existing = accountRegistry.get(key);
188
+ if (existing) {
189
+ // Re-create only MessageApi with updated config, reuse existing stack.
190
+ existing.messageApi = new MessageApiClass(existing.client, existing.tokenMgr, {
191
+ markdownSupport: md,
192
+ logger: existing.logger,
193
+ });
194
+ existing.markdownSupport = md;
195
+ } else {
196
+ accountRegistry.set(key, buildAccountContext(fallbackLogger, md));
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Resolve the AccountContext for a given appId.
202
+ *
203
+ * If the account was registered via `registerAccount()`, returns the
204
+ * pre-built context. Otherwise lazily creates a fallback context.
205
+ */
206
+ function resolveAccount(appId: string): AccountContext {
207
+ const key = appId.trim();
208
+ let ctx = accountRegistry.get(key);
209
+ if (!ctx) {
210
+ ctx = buildAccountContext(fallbackLogger, false);
211
+ accountRegistry.set(key, ctx);
212
+ }
213
+ return ctx;
214
+ }
215
+
216
+ // ============ Instance getters (for advanced callers) ============
217
+
218
+ /** Get the MessageApi instance for the given appId. */
219
+ export function getMessageApi(appId: string): MessageApiClass {
220
+ return resolveAccount(appId).messageApi;
221
+ }
222
+
223
+ // ============ Per-appId config ============
224
+
225
+ type OnMessageSentCallback = (refIdx: string, meta: OutboundMeta) => void;
226
+
227
+ /** Register an outbound-message hook scoped to one appId. */
228
+ export function onMessageSent(appId: string, callback: OnMessageSentCallback): void {
229
+ resolveAccount(appId).messageApi.onMessageSent(callback);
230
+ }
231
+
232
+ // ============ Token management ============
233
+
234
+ export async function getAccessToken(appId: string, clientSecret: string): Promise<string> {
235
+ return resolveAccount(appId).tokenMgr.getAccessToken(appId, clientSecret);
236
+ }
237
+
238
+ export function clearTokenCache(appId?: string): void {
239
+ if (appId) {
240
+ resolveAccount(appId).tokenMgr.clearCache(appId);
241
+ } else {
242
+ for (const ctx of accountRegistry.values()) {
243
+ ctx.tokenMgr.clearCache();
244
+ }
245
+ }
246
+ }
247
+
248
+ export function startBackgroundTokenRefresh(
249
+ appId: string,
250
+ clientSecret: string,
251
+ options?: {
252
+ refreshAheadMs?: number;
253
+ randomOffsetMs?: number;
254
+ minRefreshIntervalMs?: number;
255
+ retryDelayMs?: number;
256
+ log?: {
257
+ info: (msg: string) => void;
258
+ error: (msg: string) => void;
259
+ debug?: (msg: string) => void;
260
+ };
261
+ },
262
+ ): void {
263
+ resolveAccount(appId).tokenMgr.startBackgroundRefresh(appId, clientSecret, options);
264
+ }
265
+
266
+ export function stopBackgroundTokenRefresh(appId?: string): void {
267
+ if (appId) {
268
+ resolveAccount(appId).tokenMgr.stopBackgroundRefresh(appId);
269
+ } else {
270
+ for (const ctx of accountRegistry.values()) {
271
+ ctx.tokenMgr.stopBackgroundRefresh();
272
+ }
273
+ }
274
+ }
275
+
276
+ // ============ Gateway URL ============
277
+
278
+ export async function getGatewayUrl(accessToken: string, appId: string): Promise<string> {
279
+ const data = await resolveAccount(appId).client.request<{ url: string }>(
280
+ accessToken,
281
+ "GET",
282
+ "/gateway",
283
+ );
284
+ return data.url;
285
+ }
286
+
287
+ // ============ Interaction ============
288
+
289
+ /** Acknowledge an INTERACTION_CREATE event via PUT /interactions/{id}. */
290
+ export async function acknowledgeInteraction(
291
+ creds: AccountCreds,
292
+ interactionId: string,
293
+ code: 0 | 1 | 2 | 3 | 4 | 5 = 0,
294
+ data?: Record<string, unknown>,
295
+ ): Promise<void> {
296
+ const ctx = resolveAccount(creds.appId);
297
+ const token = await ctx.tokenMgr.getAccessToken(creds.appId, creds.clientSecret);
298
+ await ctx.client.request(token, "PUT", `/interactions/${interactionId}`, {
299
+ code,
300
+ ...(data ? { data } : {}),
301
+ });
302
+ }
303
+
304
+ // ============ Types ============
305
+
306
+ /** Delivery target resolved from event context. */
307
+ export interface DeliveryTarget {
308
+ type: "c2c" | "group" | "channel" | "dm";
309
+ id: string;
310
+ }
311
+
312
+ /** Account credentials for API authentication. */
313
+ interface AccountCreds {
314
+ appId: string;
315
+ clientSecret: string;
316
+ }
317
+
318
+ // ============ Token retry ============
319
+
320
+ /**
321
+ * Execute an API call with automatic token-retry on 401 errors.
322
+ *
323
+ * Primary signal is structured: `ApiError.httpStatus === 401`. A string
324
+ * fallback remains for non-`ApiError` paths (e.g. synthetic errors from
325
+ * custom adapters), but logs a warning so such cases can be surfaced.
326
+ */
327
+ export async function withTokenRetry<T>(
328
+ creds: AccountCreds,
329
+ sendFn: (token: string) => Promise<T>,
330
+ log?: EngineLogger,
331
+ _accountId?: string,
332
+ ): Promise<T> {
333
+ try {
334
+ const token = await getAccessToken(creds.appId, creds.clientSecret);
335
+ return await sendFn(token);
336
+ } catch (err) {
337
+ const isStructured401 = err instanceof ApiError && err.httpStatus === 401;
338
+ if (isStructured401) {
339
+ log?.debug?.(`Token expired (ApiError 401), refreshing...`);
340
+ clearTokenCache(creds.appId);
341
+ const newToken = await getAccessToken(creds.appId, creds.clientSecret);
342
+ return await sendFn(newToken);
343
+ }
344
+
345
+ // String fallback — retain for non-ApiError code paths but make it visible.
346
+ const errMsg = formatErrorMessage(err);
347
+ const looksLike401 =
348
+ errMsg.includes("401") || errMsg.includes("token") || errMsg.includes("access_token");
349
+ if (looksLike401) {
350
+ log?.warn?.(
351
+ `Token retry triggered by string heuristic (err is not ApiError). ` +
352
+ `Consider propagating ApiError end-to-end. msg=${errMsg.slice(0, 120)}`,
353
+ );
354
+ clearTokenCache(creds.appId);
355
+ const newToken = await getAccessToken(creds.appId, creds.clientSecret);
356
+ return await sendFn(newToken);
357
+ }
358
+ throw err;
359
+ }
360
+ }
361
+
362
+ // ============ Media hook helper ============
363
+
364
+ /**
365
+ * Notify the MessageApi onMessageSent hook after a media send.
366
+ */
367
+ function notifyMediaHook(appId: string, result: MessageResponse, meta: OutboundMeta): void {
368
+ const refIdx = result.ext_info?.ref_idx;
369
+ if (refIdx) {
370
+ resolveAccount(appId).messageApi.notifyMessageSent(refIdx, meta);
371
+ }
372
+ }
373
+
374
+ // ============ Text sending ============
375
+
376
+ /**
377
+ * Send a text message to any QQ target type.
378
+ *
379
+ * Automatically routes to the correct API method based on target type.
380
+ * Handles passive (with msgId) and proactive (without msgId) modes.
381
+ */
382
+ export async function sendText(
383
+ target: DeliveryTarget,
384
+ content: string,
385
+ creds: AccountCreds,
386
+ opts?: { msgId?: string; messageReference?: string },
387
+ ): Promise<MessageResponse> {
388
+ const api = resolveAccount(creds.appId).messageApi;
389
+ const c: Credentials = { appId: creds.appId, clientSecret: creds.clientSecret };
390
+
391
+ if (target.type === "c2c" || target.type === "group") {
392
+ const scope: ChatScope = target.type;
393
+ if (opts?.msgId) {
394
+ return api.sendMessage(scope, target.id, content, c, {
395
+ msgId: opts.msgId,
396
+ messageReference: opts.messageReference,
397
+ });
398
+ }
399
+ return api.sendProactiveMessage(scope, target.id, content, c);
400
+ }
401
+
402
+ if (target.type === "dm") {
403
+ return api.sendDmMessage({ guildId: target.id, content, creds: c, msgId: opts?.msgId });
404
+ }
405
+
406
+ return api.sendChannelMessage({ channelId: target.id, content, creds: c, msgId: opts?.msgId });
407
+ }
408
+
409
+ // ============ Input notify ============
410
+
411
+ /**
412
+ * Send a typing indicator to a C2C user.
413
+ */
414
+ export async function sendInputNotify(opts: {
415
+ openid: string;
416
+ creds: AccountCreds;
417
+ msgId?: string;
418
+ inputSecond?: number;
419
+ }): Promise<{ refIdx?: string }> {
420
+ const api = resolveAccount(opts.creds.appId).messageApi;
421
+ const c: Credentials = { appId: opts.creds.appId, clientSecret: opts.creds.clientSecret };
422
+ return api.sendInputNotify({
423
+ openid: opts.openid,
424
+ creds: c,
425
+ msgId: opts.msgId,
426
+ inputSecond: opts.inputSecond,
427
+ });
428
+ }
429
+
430
+ /**
431
+ * Raw-token input notify — compatible with TypingKeepAlive's callback signature.
432
+ */
433
+ export function createRawInputNotifyFn(
434
+ appId: string,
435
+ ): (
436
+ token: string,
437
+ openid: string,
438
+ msgId: string | undefined,
439
+ inputSecond: number,
440
+ ) => Promise<unknown> {
441
+ return async (token, openid, msgId, inputSecond) => {
442
+ const msgSeq = msgId ? getNextMsgSeq(msgId) : 1;
443
+ return resolveAccount(appId).client.request(token, "POST", `/v2/users/${openid}/messages`, {
444
+ msg_type: 6,
445
+ input_notify: { input_type: 1, input_second: inputSecond },
446
+ msg_seq: msgSeq,
447
+ ...(msgId ? { msg_id: msgId } : {}),
448
+ });
449
+ };
450
+ }
451
+
452
+ // ============ Media sending (unified) ============
453
+
454
+ /** Rich-media kind accepted by {@link sendMedia}. */
455
+ type MediaKind = "image" | "voice" | "video" | "file";
456
+
457
+ /** Map a {@link MediaKind} to the wire-level {@link MediaFileType} code. */
458
+ const KIND_TO_FILE_TYPE: Record<MediaKind, MediaFileType> = {
459
+ image: MediaFileType.IMAGE,
460
+ voice: MediaFileType.VOICE,
461
+ video: MediaFileType.VIDEO,
462
+ file: MediaFileType.FILE,
463
+ };
464
+
465
+ /**
466
+ * Options for the unified {@link sendMedia} API.
467
+ *
468
+ * This replaces the legacy four-method surface
469
+ * (`sendImage / sendVoiceMessage / sendVideoMessage / sendFileMessage`).
470
+ */
471
+ interface SendMediaOptions {
472
+ /** Delivery target. Only `c2c` and `group` support rich media. */
473
+ target: DeliveryTarget;
474
+ /** Account credentials. */
475
+ creds: AccountCreds;
476
+ /** Media kind (drives `file_type`, meta, and content semantics). */
477
+ kind: MediaKind;
478
+ /** Media source — URL, base64, on-disk path, or in-memory buffer. */
479
+ source: RawMediaSource;
480
+ /** Passive reply message ID; omit for proactive sends. */
481
+ msgId?: string;
482
+ /**
483
+ * Accompanying text. Only honored for `image` / `video` kinds — the QQ
484
+ * API ignores it for voice/file.
485
+ */
486
+ content?: string;
487
+ /** Override the server-visible file name (FILE kind only). */
488
+ fileName?: string;
489
+ /** Original TTS text — recorded in {@link OutboundMeta.ttsText} for voice. */
490
+ ttsText?: string;
491
+ /**
492
+ * Local path to record in {@link OutboundMeta.mediaLocalPath}. Usually set
493
+ * by adapters that already downloaded the source to disk; otherwise
494
+ * inferred automatically when `source` is `{ localPath }`.
495
+ */
496
+ localPathForMeta?: string;
497
+ /**
498
+ * Original URL to record in {@link OutboundMeta.mediaUrl}. Usually set by
499
+ * adapters that downloaded a remote URL before uploading; otherwise
500
+ * inferred automatically when `source` is `{ url }` (non-data URL).
501
+ */
502
+ origUrlForMeta?: string;
503
+ }
504
+
505
+ /**
506
+ * Upload and send a rich-media message to any C2C or Group target.
507
+ *
508
+ * This is the **single** rich-media entry point for the plugin. All adapter
509
+ * layers (outbound.ts, reply-dispatcher.ts, outbound-deliver.ts,
510
+ * bridge/commands, gateway/outbound-dispatch.ts) funnel through here.
511
+ *
512
+ * Dispatch structure:
513
+ *
514
+ * ```
515
+ * sendMedia(opts)
516
+ * └─ sendMediaInternal(ctx, opts)
517
+ * ├─ normalizeSource ← unified data:URL parsing + O_NOFOLLOW file safety
518
+ * ├─ uploadOnce ← one-shot upload via MediaApi (chunked hook TBD)
519
+ * ├─ sendMediaMessage
520
+ * └─ notifyMediaHook ← meta assembled per kind
521
+ * ```
522
+ *
523
+ * Future chunked upload will slot into the dispatch without touching callers.
524
+ */
525
+ export async function sendMedia(opts: SendMediaOptions): Promise<MessageResponse> {
526
+ if (!supportsRichMedia(opts.target.type)) {
527
+ throw new Error(`Media sending not supported for target type: ${opts.target.type}`);
528
+ }
529
+ const ctx = resolveAccount(opts.creds.appId);
530
+ return sendMediaInternal(ctx, opts);
531
+ }
532
+
533
+ /**
534
+ * Assemble an {@link OutboundMeta} record from the normalized source and the
535
+ * caller-provided overrides.
536
+ *
537
+ * The meta layout is identical across kinds except:
538
+ * - `image` / `video` carry `text` (the accompanying content string).
539
+ * - `voice` carries `ttsText` (original TTS input, if any).
540
+ */
541
+ function buildOutboundMeta(opts: SendMediaOptions, source: MediaSource): OutboundMeta {
542
+ const meta: OutboundMeta = {
543
+ mediaType: opts.kind,
544
+ };
545
+
546
+ if (opts.kind === "image" || opts.kind === "video") {
547
+ if (opts.content) {
548
+ meta.text = opts.content;
549
+ }
550
+ }
551
+ if (opts.kind === "voice" && opts.ttsText) {
552
+ meta.ttsText = opts.ttsText;
553
+ }
554
+
555
+ // Prefer explicit caller overrides; otherwise derive from the source.
556
+ const inferredUrl = source.kind === "url" ? source.url : undefined;
557
+ const mediaUrl = opts.origUrlForMeta ?? inferredUrl;
558
+ if (mediaUrl) {
559
+ meta.mediaUrl = mediaUrl;
560
+ }
561
+
562
+ const inferredLocal = source.kind === "localPath" ? source.path : undefined;
563
+ const mediaLocalPath = opts.localPathForMeta ?? inferredLocal;
564
+ if (mediaLocalPath) {
565
+ meta.mediaLocalPath = mediaLocalPath;
566
+ }
567
+
568
+ return meta;
569
+ }
570
+
571
+ /**
572
+ * Core dispatch for rich media. Not exported — callers must go through
573
+ * {@link sendMedia}.
574
+ *
575
+ * Upload dispatch lives in {@link dispatchUpload}: sources smaller than
576
+ * {@link LARGE_FILE_THRESHOLD} (or not supporting chunked transport, i.e.
577
+ * url/base64) go to {@link MediaApi.uploadMedia}; larger `localPath` /
578
+ * `buffer` sources go to {@link ChunkedMediaApi.uploadChunked}.
579
+ */
580
+ async function sendMediaInternal(
581
+ ctx: AccountContext,
582
+ opts: SendMediaOptions,
583
+ ): Promise<MessageResponse> {
584
+ const scope: ChatScope = opts.target.type as ChatScope;
585
+ const c: Credentials = {
586
+ appId: opts.creds.appId,
587
+ clientSecret: opts.creds.clientSecret,
588
+ };
589
+
590
+ // The outbound layer enforces per-file-type ceilings; normalizeSource's
591
+ // default is the smaller one-shot limit. We pass the chunked limit here
592
+ // to let the dispatcher decide per source.size whether to route to the
593
+ // chunked uploader. Upstream (outbound/sendPhoto etc.) remains the
594
+ // authoritative size-by-file-type gate.
595
+ const source = await normalizeSource(opts.source, {
596
+ maxSize: Number.MAX_SAFE_INTEGER,
597
+ });
598
+
599
+ try {
600
+ const uploadResult = await dispatchUpload(
601
+ ctx,
602
+ scope,
603
+ opts.target.id,
604
+ KIND_TO_FILE_TYPE[opts.kind],
605
+ source,
606
+ c,
607
+ opts.fileName,
608
+ );
609
+
610
+ // Content is semantically meaningful only for image / video — the voice
611
+ // and file APIs ignore it.
612
+ const msgContent = opts.kind === "image" || opts.kind === "video" ? opts.content : undefined;
613
+
614
+ const result = await ctx.mediaApi.sendMediaMessage(
615
+ scope,
616
+ opts.target.id,
617
+ uploadResult.file_info,
618
+ c,
619
+ {
620
+ msgId: opts.msgId,
621
+ content: msgContent,
622
+ },
623
+ );
624
+
625
+ notifyMediaHook(opts.creds.appId, result, buildOutboundMeta(opts, source));
626
+ return result;
627
+ } finally {
628
+ if (source.kind === "localPath") {
629
+ await source.opened?.close().catch(() => undefined);
630
+ }
631
+ }
632
+ }
633
+
634
+ /**
635
+ * Upload a {@link MediaSource} via the one-shot or chunked path, chosen by
636
+ * size + kind.
637
+ *
638
+ * Routing rules (kept here as the single source of truth so callers need
639
+ * not know which endpoint was used):
640
+ *
641
+ * - `url` / `base64`: always one-shot — the server accepts these directly
642
+ * and the chunked endpoint has no representation for them.
643
+ * - `localPath` / `buffer` with `size >= LARGE_FILE_THRESHOLD`: chunked.
644
+ * - Everything else: one-shot.
645
+ */
646
+ async function dispatchUpload(
647
+ ctx: AccountContext,
648
+ scope: ChatScope,
649
+ targetId: string,
650
+ fileType: MediaFileType,
651
+ source: MediaSource,
652
+ creds: Credentials,
653
+ fileName?: string,
654
+ ): Promise<UploadMediaResponse> {
655
+ switch (source.kind) {
656
+ case "url":
657
+ return ctx.mediaApi.uploadMedia(scope, targetId, fileType, creds, {
658
+ url: source.url,
659
+ fileName,
660
+ });
661
+ case "base64":
662
+ return ctx.mediaApi.uploadMedia(scope, targetId, fileType, creds, {
663
+ fileData: source.data,
664
+ fileName,
665
+ });
666
+ case "localPath":
667
+ if (source.size >= LARGE_FILE_THRESHOLD) {
668
+ return ctx.chunkedMediaApi.uploadChunked({
669
+ scope,
670
+ targetId,
671
+ fileType,
672
+ source,
673
+ creds,
674
+ fileName,
675
+ });
676
+ }
677
+ if (source.opened) {
678
+ return ctx.mediaApi.uploadMedia(scope, targetId, fileType, creds, {
679
+ buffer: await source.opened.handle.readFile(),
680
+ fileName,
681
+ });
682
+ }
683
+ return ctx.mediaApi.uploadMedia(scope, targetId, fileType, creds, {
684
+ localPath: source.path,
685
+ fileName,
686
+ });
687
+ case "buffer":
688
+ if (source.buffer.length >= LARGE_FILE_THRESHOLD) {
689
+ return ctx.chunkedMediaApi.uploadChunked({
690
+ scope,
691
+ targetId,
692
+ fileType,
693
+ source,
694
+ creds,
695
+ fileName: fileName ?? source.fileName,
696
+ });
697
+ }
698
+ return ctx.mediaApi.uploadMedia(scope, targetId, fileType, creds, {
699
+ buffer: source.buffer,
700
+ fileName: fileName ?? source.fileName,
701
+ });
702
+ default: {
703
+ const exhaustive: never = source;
704
+ throw new Error(
705
+ `dispatchUpload: unsupported MediaSource kind: ${JSON.stringify(exhaustive)}`,
706
+ );
707
+ }
708
+ }
709
+ }
710
+
711
+ // ============ Helpers ============
712
+
713
+ /** Build a DeliveryTarget from event context fields. */
714
+ export function buildDeliveryTarget(event: {
715
+ type: "c2c" | "guild" | "dm" | "group";
716
+ senderId: string;
717
+ channelId?: string;
718
+ guildId?: string;
719
+ groupOpenid?: string;
720
+ }): DeliveryTarget {
721
+ switch (event.type) {
722
+ case "c2c":
723
+ return { type: "c2c", id: event.senderId };
724
+ case "group":
725
+ return { type: "group", id: event.groupOpenid! };
726
+ case "dm":
727
+ return { type: "dm", id: event.guildId! };
728
+ default:
729
+ return { type: "channel", id: event.channelId! };
730
+ }
731
+ }
732
+
733
+ /** Build AccountCreds from a GatewayAccount. */
734
+ export function accountToCreds(account: { appId: string; clientSecret: string }): AccountCreds {
735
+ return { appId: account.appId, clientSecret: account.clientSecret };
736
+ }
737
+
738
+ /** Check whether a target type supports rich media (C2C and Group only). */
739
+ function supportsRichMedia(targetType: string): boolean {
740
+ return targetType === "c2c" || targetType === "group";
741
+ }