@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,27 @@
1
+ /**
2
+ * Audio port — abstracts inbound + outbound audio conversion operations.
3
+ *
4
+ * The engine defines this interface; the bridge layer provides an
5
+ * implementation backed by `engine/utils/audio.js` functions.
6
+ */
7
+
8
+ /** Inbound audio conversion (SILK→WAV, voice detection, duration formatting). */
9
+ export interface AudioConvertPort {
10
+ convertSilkToWav(
11
+ silkPath: string,
12
+ outputDir: string,
13
+ ): Promise<{ wavPath: string; duration: number } | null>;
14
+ isVoiceAttachment(att: { content_type: string; filename?: string }): boolean;
15
+ formatDuration(seconds: number): string;
16
+ }
17
+
18
+ /** Outbound audio conversion (WAV→SILK, audio detection, transcoding). */
19
+ export interface OutboundAudioPort {
20
+ audioFileToSilkBase64(
21
+ audioPath: string,
22
+ directUploadFormats?: string[],
23
+ ): Promise<string | undefined>;
24
+ isAudioFile(pathOrUrl: string, mimeType?: string): boolean;
25
+ shouldTranscodeVoice(filePath: string): boolean;
26
+ waitForFile(filePath: string, maxWaitMs?: number): Promise<number>;
27
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Commands port — abstracts slash-command dependencies injected by the
3
+ * bridge layer (version resolvers, approve runtime getter).
4
+ *
5
+ * Eliminates global `register*` singletons in `slash-commands-impl.ts`.
6
+ */
7
+
8
+ import type { PluginRuntime } from "autobot/plugin-sdk/core";
9
+
10
+ /** Runtime getter shape for the `/bot-approve` command. */
11
+ export type ApproveRuntimeGetter = () => {
12
+ config: Pick<PluginRuntime["config"], "current" | "replaceConfigFile">;
13
+ };
14
+
15
+ export interface CommandsPort {
16
+ /** Resolve the framework runtime version string. */
17
+ resolveVersion: () => string;
18
+ /** Plugin version string (e.g. "1.2.3"). */
19
+ pluginVersion: string;
20
+ /** Runtime getter for `/bot-approve` config management. */
21
+ approveRuntimeGetter?: ApproveRuntimeGetter;
22
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * History port — abstracts the group history cache operations.
3
+ *
4
+ * The engine defines this interface; the bridge layer provides an
5
+ * implementation backed by SDK `reply-history` functions. The engine's
6
+ * built-in implementation in `group/history.ts` is used as the default
7
+ * when no adapter is injected (standalone build).
8
+ */
9
+
10
+ /** Minimal history entry shape expected by the port. */
11
+ export interface HistoryEntryLike {
12
+ sender: string;
13
+ body: string;
14
+ timestamp?: number;
15
+ messageId?: string;
16
+ }
17
+
18
+ export interface HistoryPort {
19
+ /**
20
+ * Record a non-@ message into the pending history buffer.
21
+ * No-op when `limit <= 0` or `entry` is missing.
22
+ */
23
+ recordPendingHistoryEntry<T extends HistoryEntryLike>(params: {
24
+ historyMap: Map<string, T[]>;
25
+ historyKey: string;
26
+ entry?: T | null;
27
+ limit: number;
28
+ }): T[];
29
+
30
+ /**
31
+ * Build the full user-message string prefixed with buffered history.
32
+ * Returns `currentMessage` unchanged when no history exists.
33
+ */
34
+ buildPendingHistoryContext(params: {
35
+ historyMap: Map<string, HistoryEntryLike[]>;
36
+ historyKey: string;
37
+ limit: number;
38
+ currentMessage: string;
39
+ formatEntry: (entry: HistoryEntryLike) => string;
40
+ lineBreak?: string;
41
+ }): string;
42
+
43
+ /**
44
+ * Clear a group's pending history buffer.
45
+ * No-op when `limit <= 0`.
46
+ */
47
+ clearPendingHistory(params: {
48
+ historyMap: Map<string, HistoryEntryLike[]>;
49
+ historyKey: string;
50
+ limit: number;
51
+ }): void;
52
+ }
@@ -0,0 +1,76 @@
1
+ import type { ResolvedChannelMessageIngress } from "autobot/plugin-sdk/channel-ingress-runtime";
2
+ import type { EffectivePolicyInput } from "../access/resolve-policy.js";
3
+ import type { FetchMediaOptions, FetchMediaResult, SecretInputRef } from "./types.js";
4
+
5
+ export type QQBotInboundAccess = ResolvedChannelMessageIngress;
6
+
7
+ export interface AccessPort {
8
+ resolveInboundAccess(
9
+ input: EffectivePolicyInput & {
10
+ cfg: unknown;
11
+ accountId: string;
12
+ isGroup: boolean;
13
+ senderId: string;
14
+ conversationId: string;
15
+ },
16
+ ): QQBotInboundAccess | Promise<QQBotInboundAccess>;
17
+
18
+ resolveSlashCommandAuthorization(input: {
19
+ cfg: unknown;
20
+ accountId: string;
21
+ isGroup: boolean;
22
+ senderId: string;
23
+ conversationId: string;
24
+ allowFrom?: Array<string | number>;
25
+ groupAllowFrom?: Array<string | number>;
26
+ commandsAllowFrom?: Array<string | number>;
27
+ }): boolean | Promise<boolean>;
28
+ }
29
+
30
+ export interface EngineAdapters {
31
+ history: import("./history.port.js").HistoryPort;
32
+ mentionGate: import("./mention-gate.port.js").MentionGatePort;
33
+ access: AccessPort;
34
+ audioConvert: import("./audio.port.js").AudioConvertPort;
35
+ outboundAudio: import("./audio.port.js").OutboundAudioPort;
36
+ commands: import("./commands.port.js").CommandsPort;
37
+ }
38
+
39
+ export interface PlatformAdapter {
40
+ validateRemoteUrl(url: string, options?: { allowPrivate?: boolean }): Promise<void>;
41
+ resolveSecret(value: string | SecretInputRef | undefined): Promise<string | undefined>;
42
+ downloadFile(url: string, destDir: string, filename?: string): Promise<string>;
43
+ fetchMedia(options: FetchMediaOptions): Promise<FetchMediaResult>;
44
+ getTempDir(): string;
45
+ hasConfiguredSecret(value: unknown): boolean;
46
+ normalizeSecretInputString(value: unknown): string | undefined;
47
+ resolveSecretInputString(params: { value: unknown; path: string }): string | undefined;
48
+ resolveApproval?(approvalId: string, decision: string): Promise<boolean>;
49
+ }
50
+
51
+ let platformAdapter: PlatformAdapter | null = null;
52
+ let platformAdapterFactory: (() => PlatformAdapter) | null = null;
53
+
54
+ export function registerPlatformAdapter(adapter: PlatformAdapter): void {
55
+ platformAdapter = adapter;
56
+ }
57
+
58
+ export function registerPlatformAdapterFactory(factory: () => PlatformAdapter): void {
59
+ platformAdapterFactory = factory;
60
+ }
61
+
62
+ export function getPlatformAdapter(): PlatformAdapter {
63
+ if (!platformAdapter && platformAdapterFactory) {
64
+ platformAdapter = platformAdapterFactory();
65
+ }
66
+ if (!platformAdapter) {
67
+ throw new Error(
68
+ "PlatformAdapter not registered. Call registerPlatformAdapter() during bootstrap.",
69
+ );
70
+ }
71
+ return platformAdapter;
72
+ }
73
+
74
+ export function hasPlatformAdapter(): boolean {
75
+ return platformAdapter !== null || platformAdapterFactory !== null;
76
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Mention gate port — abstracts the SDK's `resolveInboundMentionDecision`
3
+ * + `resolveControlCommandGate` into a single interface.
4
+ *
5
+ * The engine's `resolveGroupMessageGate` (Layer 1: ignoreOtherMentions)
6
+ * is QQ-specific and stays in `group/message-gating.ts`. Layer 2+3
7
+ * (command gating + mention gating + command bypass) delegate to this port.
8
+ */
9
+
10
+ /** Implicit mention kind aligned with SDK's `InboundImplicitMentionKind`. */
11
+ type ImplicitMentionKind = "reply_to_bot" | "quoted_bot" | "bot_thread_participant" | "native";
12
+
13
+ /** Facts about the current message's mention state. */
14
+ export interface MentionFacts {
15
+ canDetectMention: boolean;
16
+ wasMentioned: boolean;
17
+ hasAnyMention?: boolean;
18
+ implicitMentionKinds?: readonly ImplicitMentionKind[];
19
+ }
20
+
21
+ /** Policy configuration for the mention gate. */
22
+ export interface MentionPolicy {
23
+ isGroup: boolean;
24
+ requireMention: boolean;
25
+ allowTextCommands: boolean;
26
+ hasControlCommand: boolean;
27
+ commandAuthorized: boolean;
28
+ }
29
+
30
+ /** Result of the mention gate evaluation. */
31
+ export interface MentionGateDecision {
32
+ effectiveWasMentioned: boolean;
33
+ shouldSkip: boolean;
34
+ shouldBypassMention: boolean;
35
+ implicitMention: boolean;
36
+ }
37
+
38
+ export interface MentionGatePort {
39
+ /**
40
+ * Evaluate whether the message should be skipped based on mention
41
+ * policy, command bypass, and implicit mention rules.
42
+ *
43
+ * Equivalent to SDK's `resolveInboundMentionDecision` with the
44
+ * command-bypass logic folded in.
45
+ */
46
+ resolveInboundMentionDecision(params: {
47
+ facts: MentionFacts;
48
+ policy: MentionPolicy;
49
+ }): MentionGateDecision;
50
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Shared types used by the PlatformAdapter interface.
3
+ */
4
+
5
+ /** Reference to a secret stored in the platform's secret management system. */
6
+ export interface SecretInputRef {
7
+ source: "env" | "file" | "config";
8
+ id: string;
9
+ }
10
+
11
+ /** Options for fetching remote media through the platform adapter. */
12
+ export interface FetchMediaOptions {
13
+ url: string;
14
+ /** Hint for the local filename when saving. */
15
+ filePathHint?: string;
16
+ /** Maximum bytes to download. */
17
+ maxBytes?: number;
18
+ /** Maximum redirects to follow. */
19
+ maxRedirects?: number;
20
+ /** SSRF policy configuration. */
21
+ ssrfPolicy?: SsrfPolicyConfig;
22
+ /** Extra fetch() RequestInit options. */
23
+ requestInit?: RequestInit;
24
+ }
25
+
26
+ /** Result of a remote media fetch operation. */
27
+ export interface FetchMediaResult {
28
+ buffer: Buffer;
29
+ fileName?: string;
30
+ }
31
+
32
+ /** SSRF policy configuration — platform-agnostic subset. */
33
+ export interface SsrfPolicyConfig {
34
+ /** Hostnames that are always allowed (supports `*.example.com` wildcards). */
35
+ hostnameAllowlist?: string[];
36
+ /** Whether to allow RFC 2544 benchmark ranges (198.18.0.0/15). */
37
+ allowRfc2544BenchmarkRange?: boolean;
38
+ }
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Core HTTP client for the QQ Open Platform REST API.
3
+ *
4
+ * Key improvements over the old `src/api.ts#apiRequest`:
5
+ * - `ApiClient` is an **instance** — config (baseUrl, timeout, logger, UA)
6
+ * is injected via the constructor, eliminating module-level globals.
7
+ * - Throws structured `ApiError` with httpStatus, bizCode, and path fields.
8
+ * - Detects HTML error pages from CDN/gateway and returns user-friendly messages.
9
+ * - `redactBodyKeys` replaces the hardcoded `file_data` redaction.
10
+ */
11
+
12
+ import { ApiError, type ApiClientConfig, type EngineLogger } from "../types.js";
13
+ import { formatErrorMessage } from "../utils/format.js";
14
+
15
+ const DEFAULT_BASE_URL = "https://api.sgroup.qq.com";
16
+ const DEFAULT_TIMEOUT_MS = 30_000;
17
+ const FILE_UPLOAD_TIMEOUT_MS = 120_000;
18
+
19
+ interface RequestOptions {
20
+ /** Request timeout override in milliseconds. */
21
+ timeoutMs?: number;
22
+ /** Body keys to redact in debug logs (e.g. `['file_data']`). */
23
+ redactBodyKeys?: string[];
24
+ /**
25
+ * Mark the request as a file-upload call.
26
+ *
27
+ * Triggers the longer `fileUploadTimeoutMs` (default 120s) instead of the
28
+ * standard `defaultTimeoutMs` (default 30s). Prefer this flag over
29
+ * inspecting the request path; it keeps the timeout policy independent of
30
+ * route naming conventions.
31
+ */
32
+ uploadRequest?: boolean;
33
+ }
34
+
35
+ /**
36
+ * Stateful HTTP client for the QQ Open Platform.
37
+ *
38
+ * Usage:
39
+ * ```ts
40
+ * const client = new ApiClient({ logger, userAgent: 'QQBotPlugin/1.0' });
41
+ * const data = await client.request<{ url: string }>(token, 'GET', '/gateway');
42
+ * ```
43
+ */
44
+ export class ApiClient {
45
+ private readonly baseUrl: string;
46
+ private readonly defaultTimeoutMs: number;
47
+ private readonly fileUploadTimeoutMs: number;
48
+ private readonly logger?: EngineLogger;
49
+ private readonly resolveUserAgent: () => string;
50
+
51
+ constructor(config: ApiClientConfig = {}) {
52
+ this.baseUrl = config.baseUrl ?? DEFAULT_BASE_URL;
53
+ this.defaultTimeoutMs = config.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS;
54
+ this.fileUploadTimeoutMs = config.fileUploadTimeoutMs ?? FILE_UPLOAD_TIMEOUT_MS;
55
+ this.logger = config.logger;
56
+ const ua = config.userAgent ?? "QQBotPlugin/unknown";
57
+ this.resolveUserAgent = typeof ua === "function" ? ua : () => ua;
58
+ }
59
+
60
+ /**
61
+ * Send an authenticated JSON request to the QQ Open Platform.
62
+ *
63
+ * @param accessToken - Bearer token (`QQBot {token}`).
64
+ * @param method - HTTP method.
65
+ * @param path - API path (appended to baseUrl).
66
+ * @param body - Optional JSON body.
67
+ * @param options - Optional request overrides.
68
+ * @returns Parsed JSON response.
69
+ * @throws {ApiError} On HTTP or parse errors.
70
+ */
71
+ async request<T = unknown>(
72
+ accessToken: string,
73
+ method: string,
74
+ path: string,
75
+ body?: unknown,
76
+ options?: RequestOptions,
77
+ ): Promise<T> {
78
+ const url = `${this.baseUrl}${path}`;
79
+
80
+ const headers: Record<string, string> = {
81
+ Authorization: `QQBot ${accessToken}`,
82
+ "Content-Type": "application/json",
83
+ "User-Agent": this.resolveUserAgent(),
84
+ };
85
+
86
+ const isFileUpload =
87
+ options?.uploadRequest === true ||
88
+ // Back-compat: legacy callers that predate the explicit `uploadRequest`
89
+ // flag still get the long timeout when hitting file endpoints. New
90
+ // code should always pass `uploadRequest: true` explicitly.
91
+ path.includes("/files") ||
92
+ path.includes("/upload_prepare") ||
93
+ path.includes("/upload_part_finish");
94
+ const timeout =
95
+ options?.timeoutMs ?? (isFileUpload ? this.fileUploadTimeoutMs : this.defaultTimeoutMs);
96
+
97
+ const controller = new AbortController();
98
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
99
+
100
+ const fetchInit: RequestInit = {
101
+ method,
102
+ headers,
103
+ signal: controller.signal,
104
+ };
105
+
106
+ if (body) {
107
+ fetchInit.body = JSON.stringify(body);
108
+ }
109
+
110
+ // Debug logging with optional body redaction.
111
+ this.logger?.debug?.(`[qqbot:api] >>> ${method} ${url} (timeout: ${timeout}ms)`);
112
+ if (body && this.logger?.debug) {
113
+ const logBody = { ...(body as Record<string, unknown>) };
114
+ for (const key of options?.redactBodyKeys ?? ["file_data"]) {
115
+ if (typeof logBody[key] === "string") {
116
+ logBody[key] = `<redacted ${logBody[key].length} chars>`;
117
+ }
118
+ }
119
+ this.logger.debug(`[qqbot:api] >>> Body: ${JSON.stringify(logBody)}`);
120
+ }
121
+
122
+ let res: Response;
123
+ try {
124
+ res = await fetch(url, fetchInit);
125
+ } catch (err) {
126
+ clearTimeout(timeoutId);
127
+ if (err instanceof Error && err.name === "AbortError") {
128
+ this.logger?.error?.(`[qqbot:api] <<< Timeout after ${timeout}ms`);
129
+ throw new ApiError(`Request timeout [${path}]: exceeded ${timeout}ms`, 0, path);
130
+ }
131
+ this.logger?.error?.(`[qqbot:api] <<< Network error: ${formatErrorMessage(err)}`);
132
+ throw new ApiError(`Network error [${path}]: ${formatErrorMessage(err)}`, 0, path);
133
+ } finally {
134
+ clearTimeout(timeoutId);
135
+ }
136
+
137
+ // Log response status and trace ID.
138
+ const traceId = res.headers.get("x-tps-trace-id") ?? "";
139
+ this.logger?.info?.(
140
+ `[qqbot:api] <<< Status: ${res.status} ${res.statusText}${traceId ? ` | TraceId: ${traceId}` : ""}`,
141
+ );
142
+
143
+ let rawBody: string;
144
+ try {
145
+ rawBody = await res.text();
146
+ } catch (err) {
147
+ throw new ApiError(
148
+ `Failed to read response [${path}]: ${formatErrorMessage(err)}`,
149
+ res.status,
150
+ path,
151
+ );
152
+ }
153
+ this.logger?.debug?.(`[qqbot:api] <<< Body: ${rawBody}`);
154
+
155
+ // Detect non-JSON responses (HTML gateway errors, CDN rate-limit pages).
156
+ const contentType = res.headers.get("content-type") ?? "";
157
+ const isHtmlResponse = contentType.includes("text/html") || rawBody.trimStart().startsWith("<");
158
+
159
+ if (!res.ok) {
160
+ if (isHtmlResponse) {
161
+ const statusHint =
162
+ res.status === 502 || res.status === 503 || res.status === 504
163
+ ? "调用发生异常,请稍候重试"
164
+ : res.status === 429
165
+ ? "请求过于频繁,已被限流"
166
+ : `开放平台返回 HTTP ${res.status}`;
167
+ throw new ApiError(`${statusHint}(${path}),请稍后重试`, res.status, path);
168
+ }
169
+
170
+ // JSON error response.
171
+ try {
172
+ const error = JSON.parse(rawBody) as {
173
+ message?: string;
174
+ code?: number;
175
+ err_code?: number;
176
+ };
177
+ const bizCode = error.code ?? error.err_code;
178
+ throw new ApiError(
179
+ `API Error [${path}]: ${error.message ?? rawBody}`,
180
+ res.status,
181
+ path,
182
+ bizCode,
183
+ error.message,
184
+ );
185
+ } catch (parseErr) {
186
+ if (parseErr instanceof ApiError) {
187
+ throw parseErr;
188
+ }
189
+ throw new ApiError(
190
+ `API Error [${path}] HTTP ${res.status}: ${rawBody.slice(0, 200)}`,
191
+ res.status,
192
+ path,
193
+ );
194
+ }
195
+ }
196
+
197
+ // Successful response but not JSON (extreme edge case).
198
+ if (isHtmlResponse) {
199
+ throw new ApiError(
200
+ `QQ 服务端返回了非 JSON 响应(${path}),可能是临时故障,请稍后重试`,
201
+ res.status,
202
+ path,
203
+ );
204
+ }
205
+
206
+ try {
207
+ return JSON.parse(rawBody) as T;
208
+ } catch {
209
+ throw new ApiError(`开放平台响应格式异常(${path}),请稍后重试`, res.status, path);
210
+ }
211
+ }
212
+ }