@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,217 @@
1
+ /**
2
+ * Generic retry engine for QQ Bot API requests.
3
+ *
4
+ * Replaces the three separate retry implementations in the old `api.ts`:
5
+ * - `apiRequestWithRetry` (upload retry with exponential backoff)
6
+ * - `partFinishWithRetry` (part-finish retry + persistent retry on specific biz codes)
7
+ * - `completeUploadWithRetry` (unconditional retry for complete-upload)
8
+ *
9
+ * All three patterns are expressed as a single `withRetry` function
10
+ * parameterized by `RetryPolicy` and optional `PersistentRetryPolicy`.
11
+ */
12
+
13
+ import type { EngineLogger } from "../types.js";
14
+ import { formatErrorMessage } from "../utils/format.js";
15
+
16
+ /** Standard retry policy with exponential or fixed backoff. */
17
+ interface RetryPolicy {
18
+ /** Maximum retry attempts (excluding the initial attempt). */
19
+ maxRetries: number;
20
+ /** Base delay in milliseconds. */
21
+ baseDelayMs: number;
22
+ /** Backoff strategy. */
23
+ backoff: "exponential" | "fixed";
24
+ /**
25
+ * Predicate to decide whether an error is retryable.
26
+ * Return `false` to immediately rethrow.
27
+ * Defaults to always-retry when omitted.
28
+ */
29
+ shouldRetry?: (error: Error, attempt: number) => boolean;
30
+ }
31
+
32
+ /**
33
+ * Persistent retry policy for specific business error codes.
34
+ *
35
+ * When `shouldPersistRetry` returns true, the engine switches from
36
+ * the standard retry loop into a tight fixed-interval loop bounded
37
+ * only by the total timeout.
38
+ */
39
+ interface PersistentRetryPolicy {
40
+ /** Total timeout in milliseconds for the persistent retry loop. */
41
+ timeoutMs: number;
42
+ /** Fixed interval between retries in milliseconds. */
43
+ intervalMs: number;
44
+ /** Predicate to decide whether an error triggers persistent retry. */
45
+ shouldPersistRetry: (error: Error) => boolean;
46
+ }
47
+
48
+ /**
49
+ * Execute an async operation with configurable retry semantics.
50
+ *
51
+ * @param fn - The async operation to retry.
52
+ * @param policy - Standard retry configuration.
53
+ * @param persistentPolicy - Optional persistent retry for specific error codes.
54
+ * @param logger - Optional logger for retry diagnostics.
55
+ * @returns The result of the first successful invocation.
56
+ */
57
+ export async function withRetry<T>(
58
+ fn: () => Promise<T>,
59
+ policy: RetryPolicy,
60
+ persistentPolicy?: PersistentRetryPolicy,
61
+ logger?: EngineLogger,
62
+ ): Promise<T> {
63
+ let lastError: Error | null = null;
64
+
65
+ for (let attempt = 0; attempt <= policy.maxRetries; attempt++) {
66
+ try {
67
+ return await fn();
68
+ } catch (err) {
69
+ lastError = err instanceof Error ? err : new Error(formatErrorMessage(err));
70
+
71
+ // Check for persistent-retry trigger before standard retry logic.
72
+ if (persistentPolicy?.shouldPersistRetry(lastError)) {
73
+ (logger?.warn ?? logger?.error)?.(
74
+ `[qqbot:retry] Hit persistent-retry trigger, entering persistent loop (timeout=${persistentPolicy.timeoutMs / 1000}s)`,
75
+ );
76
+ return await persistentRetryLoop(fn, persistentPolicy, logger);
77
+ }
78
+
79
+ // Check whether this error is retryable under the standard policy.
80
+ if (policy.shouldRetry?.(lastError, attempt) === false) {
81
+ throw lastError;
82
+ }
83
+
84
+ // Schedule the next retry with the configured backoff.
85
+ if (attempt < policy.maxRetries) {
86
+ const delay =
87
+ policy.backoff === "exponential" ? policy.baseDelayMs * 2 ** attempt : policy.baseDelayMs;
88
+
89
+ logger?.debug?.(
90
+ `[qqbot:retry] Attempt ${attempt + 1} failed, retrying in ${delay}ms: ${lastError.message.slice(0, 100)}`,
91
+ );
92
+ await sleep(delay);
93
+ }
94
+ }
95
+ }
96
+
97
+ throw lastError!;
98
+ }
99
+
100
+ /**
101
+ * Persistent retry loop: fixed-interval retries bounded by a total timeout.
102
+ *
103
+ * Used for `upload_part_finish` when the server returns specific business
104
+ * error codes indicating the backend is still processing.
105
+ */
106
+ async function persistentRetryLoop<T>(
107
+ fn: () => Promise<T>,
108
+ policy: PersistentRetryPolicy,
109
+ logger?: EngineLogger,
110
+ ): Promise<T> {
111
+ const deadline = Date.now() + policy.timeoutMs;
112
+ let attempt = 0;
113
+ let lastError: Error | null = null;
114
+
115
+ while (Date.now() < deadline) {
116
+ try {
117
+ const result = await fn();
118
+ logger?.debug?.(`[qqbot:retry] Persistent retry succeeded after ${attempt} retries`);
119
+ return result;
120
+ } catch (err) {
121
+ lastError = err instanceof Error ? err : new Error(formatErrorMessage(err));
122
+
123
+ // If the error is no longer retryable, abort immediately.
124
+ if (!policy.shouldPersistRetry(lastError)) {
125
+ logger?.error?.(`[qqbot:retry] Persistent retry: error is no longer retryable, aborting`);
126
+ throw lastError;
127
+ }
128
+
129
+ attempt++;
130
+ const remaining = deadline - Date.now();
131
+ if (remaining <= 0) {
132
+ break;
133
+ }
134
+
135
+ const actualDelay = Math.min(policy.intervalMs, remaining);
136
+ (logger?.warn ?? logger?.error)?.(
137
+ `[qqbot:retry] Persistent retry #${attempt}: retrying in ${actualDelay}ms (remaining=${Math.round(remaining / 1000)}s)`,
138
+ );
139
+ await sleep(actualDelay);
140
+ }
141
+ }
142
+
143
+ logger?.error?.(
144
+ `[qqbot:retry] Persistent retry timed out after ${policy.timeoutMs / 1000}s (${attempt} attempts)`,
145
+ );
146
+ throw lastError ?? new Error(`Persistent retry timed out (${policy.timeoutMs / 1000}s)`);
147
+ }
148
+
149
+ function sleep(ms: number): Promise<void> {
150
+ return new Promise((resolve) => setTimeout(resolve, ms));
151
+ }
152
+
153
+ // ============ Pre-built Retry Policies ============
154
+
155
+ /** Standard upload retry: exponential backoff, skip 400/401/timeout errors. */
156
+ export const UPLOAD_RETRY_POLICY: RetryPolicy = {
157
+ maxRetries: 2,
158
+ baseDelayMs: 1000,
159
+ backoff: "exponential",
160
+ shouldRetry: (error) => {
161
+ const msg = error.message;
162
+ return !(
163
+ msg.includes("400") ||
164
+ msg.includes("401") ||
165
+ msg.includes("Invalid") ||
166
+ msg.includes("timeout") ||
167
+ msg.includes("Timeout")
168
+ );
169
+ },
170
+ };
171
+
172
+ /** Complete-upload retry: unconditional retry with exponential backoff. */
173
+ export const COMPLETE_UPLOAD_RETRY_POLICY: RetryPolicy = {
174
+ maxRetries: 2,
175
+ baseDelayMs: 2000,
176
+ backoff: "exponential",
177
+ // Always retry — complete-upload failures are often transient server-side.
178
+ };
179
+
180
+ /** Part-finish standard retry policy. */
181
+ export const PART_FINISH_RETRY_POLICY: RetryPolicy = {
182
+ maxRetries: 2,
183
+ baseDelayMs: 1000,
184
+ backoff: "exponential",
185
+ };
186
+
187
+ /**
188
+ * Build a persistent retry policy for part-finish with a specific timeout.
189
+ *
190
+ * @param retryTimeoutMs - Total timeout (defaults to 2 minutes).
191
+ * @param retryableCodes - Business error codes that trigger persistent retry.
192
+ */
193
+ export function buildPartFinishPersistentPolicy(
194
+ retryTimeoutMs?: number,
195
+ retryableCodes: Set<number> = PART_FINISH_RETRYABLE_CODES,
196
+ ): PersistentRetryPolicy {
197
+ return {
198
+ timeoutMs: retryTimeoutMs ?? 2 * 60 * 1000,
199
+ intervalMs: 1000,
200
+ shouldPersistRetry: (error) => {
201
+ if (retryableCodes.size === 0) {
202
+ return false;
203
+ }
204
+ // Check for ApiError with matching bizCode.
205
+ if ("bizCode" in error && typeof (error as { bizCode?: number }).bizCode === "number") {
206
+ return retryableCodes.has((error as { bizCode: number }).bizCode);
207
+ }
208
+ return false;
209
+ },
210
+ };
211
+ }
212
+
213
+ /** Business error codes that trigger persistent part-finish retry. */
214
+ const PART_FINISH_RETRYABLE_CODES: Set<number> = new Set([40093001]);
215
+
216
+ /** upload_prepare error code indicating daily limit exceeded. */
217
+ export const UPLOAD_PREPARE_FALLBACK_CODE = 40093002;
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Centralized API route templates for the QQ Open Platform.
3
+ *
4
+ * Eliminates C2C/Group path duplication by parameterizing on `ChatScope`.
5
+ * Inspired by `bot-node-sdk/src/openapi/v1/resource.ts`.
6
+ */
7
+
8
+ import type { ChatScope } from "../types.js";
9
+
10
+ /**
11
+ * Build the message-send path for C2C or Group.
12
+ *
13
+ * - C2C: `/v2/users/{id}/messages`
14
+ * - Group: `/v2/groups/{id}/messages`
15
+ */
16
+ export function messagePath(scope: ChatScope, targetId: string): string {
17
+ return scope === "c2c" ? `/v2/users/${targetId}/messages` : `/v2/groups/${targetId}/messages`;
18
+ }
19
+
20
+ /** Channel message path. */
21
+ export function channelMessagePath(channelId: string): string {
22
+ return `/channels/${channelId}/messages`;
23
+ }
24
+
25
+ /** DM (direct message inside a guild) path. */
26
+ export function dmMessagePath(guildId: string): string {
27
+ return `/dms/${guildId}/messages`;
28
+ }
29
+
30
+ /**
31
+ * Build the media upload (small-file) path for C2C or Group.
32
+ *
33
+ * - C2C: `/v2/users/{id}/files`
34
+ * - Group: `/v2/groups/{id}/files`
35
+ */
36
+ export function mediaUploadPath(scope: ChatScope, targetId: string): string {
37
+ return scope === "c2c" ? `/v2/users/${targetId}/files` : `/v2/groups/${targetId}/files`;
38
+ }
39
+
40
+ /**
41
+ * Build the upload_prepare path for C2C or Group.
42
+ *
43
+ * - C2C: `/v2/users/{id}/upload_prepare`
44
+ * - Group: `/v2/groups/{id}/upload_prepare`
45
+ */
46
+ export function uploadPreparePath(scope: ChatScope, targetId: string): string {
47
+ return scope === "c2c"
48
+ ? `/v2/users/${targetId}/upload_prepare`
49
+ : `/v2/groups/${targetId}/upload_prepare`;
50
+ }
51
+
52
+ /**
53
+ * Build the upload_part_finish path for C2C or Group.
54
+ */
55
+ export function uploadPartFinishPath(scope: ChatScope, targetId: string): string {
56
+ return scope === "c2c"
57
+ ? `/v2/users/${targetId}/upload_part_finish`
58
+ : `/v2/groups/${targetId}/upload_part_finish`;
59
+ }
60
+
61
+ /**
62
+ * Build the complete-upload (files) path for C2C or Group.
63
+ * (Same as mediaUploadPath — the complete endpoint reuses the files path.)
64
+ */
65
+ export function uploadCompletePath(scope: ChatScope, targetId: string): string {
66
+ return mediaUploadPath(scope, targetId);
67
+ }
68
+
69
+ /** Stream message path (C2C only). */
70
+ export function streamMessagePath(openid: string): string {
71
+ return `/v2/users/${openid}/stream_messages`;
72
+ }
73
+
74
+ /** Gateway URL path. */
75
+ export function gatewayPath(): string {
76
+ return "/gateway";
77
+ }
78
+
79
+ /** Interaction acknowledgement path. */
80
+ export function interactionPath(interactionId: string): string {
81
+ return `/interactions/${interactionId}`;
82
+ }
83
+
84
+ // ============ Shared Helpers ============
85
+
86
+ /**
87
+ * Generate a message sequence number in the 0..65535 range.
88
+ *
89
+ * Used by both `messages.ts` and `media.ts` to avoid duplicate definitions.
90
+ */
91
+ export function getNextMsgSeq(_msgId: string): number {
92
+ const timePart = Date.now() % 100_000_000;
93
+ const random = Math.floor(Math.random() * 65536);
94
+ return (timePart ^ random) % 65536;
95
+ }
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Token management for the QQ Open Platform.
3
+ *
4
+ * All state (cache, singleflight promises, background refresh controllers)
5
+ * is encapsulated in the `TokenManager` class instance — no module-level
6
+ * globals, fully supporting multi-account concurrent operation.
7
+ */
8
+
9
+ import type { EngineLogger } from "../types.js";
10
+ import { formatErrorMessage } from "../utils/format.js";
11
+
12
+ const TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken";
13
+
14
+ interface CachedToken {
15
+ token: string;
16
+ expiresAt: number;
17
+ appId: string;
18
+ }
19
+
20
+ interface BackgroundRefreshOptions {
21
+ refreshAheadMs?: number;
22
+ randomOffsetMs?: number;
23
+ minRefreshIntervalMs?: number;
24
+ retryDelayMs?: number;
25
+ }
26
+
27
+ /**
28
+ * Per-appId token manager with caching, singleflight, and background refresh.
29
+ *
30
+ * Usage:
31
+ * ```ts
32
+ * const tm = new TokenManager({ logger, userAgent: 'QQBotPlugin/1.0' });
33
+ * const token = await tm.getAccessToken('appId', 'secret');
34
+ * ```
35
+ */
36
+ export class TokenManager {
37
+ private readonly cache = new Map<string, CachedToken>();
38
+ private readonly fetchPromises = new Map<string, Promise<string>>();
39
+ private readonly refreshControllers = new Map<string, AbortController>();
40
+ private readonly logger?: EngineLogger;
41
+ private readonly resolveUserAgent: () => string;
42
+
43
+ constructor(config?: { logger?: EngineLogger; userAgent?: string | (() => string) }) {
44
+ this.logger = config?.logger;
45
+ const ua = config?.userAgent ?? "QQBotPlugin/unknown";
46
+ this.resolveUserAgent = typeof ua === "function" ? ua : () => ua;
47
+ }
48
+
49
+ /**
50
+ * Obtain an access token with caching and singleflight semantics.
51
+ *
52
+ * When multiple callers request a token for the same appId concurrently,
53
+ * only one actual HTTP request is made — the others await the same promise.
54
+ */
55
+ async getAccessToken(appId: string, clientSecret: string): Promise<string> {
56
+ const normalizedId = appId.trim();
57
+ const cached = this.cache.get(normalizedId);
58
+
59
+ // Refresh slightly before expiry without making short-lived tokens unusable.
60
+ const refreshAheadMs = cached
61
+ ? Math.min(5 * 60 * 1000, (cached.expiresAt - Date.now()) / 3)
62
+ : 0;
63
+
64
+ if (cached && Date.now() < cached.expiresAt - refreshAheadMs) {
65
+ return cached.token;
66
+ }
67
+
68
+ // Singleflight: reuse an in-progress fetch.
69
+ let pending = this.fetchPromises.get(normalizedId);
70
+ if (pending) {
71
+ this.logger?.debug?.(`[qqbot:token:${normalizedId}] Fetch in progress, reusing promise`);
72
+ return pending;
73
+ }
74
+
75
+ pending = (async () => {
76
+ try {
77
+ return await this.doFetchToken(normalizedId, clientSecret);
78
+ } finally {
79
+ this.fetchPromises.delete(normalizedId);
80
+ }
81
+ })();
82
+
83
+ this.fetchPromises.set(normalizedId, pending);
84
+ return pending;
85
+ }
86
+
87
+ /** Clear the cached token for one appId, or all. */
88
+ clearCache(appId?: string): void {
89
+ if (appId) {
90
+ this.cache.delete(appId.trim());
91
+ this.logger?.debug?.(`[qqbot:token:${appId}] Cache cleared`);
92
+ } else {
93
+ this.cache.clear();
94
+ this.logger?.debug?.(`[token] All caches cleared`);
95
+ }
96
+ }
97
+
98
+ /** Return token status for diagnostics. */
99
+ getStatus(appId: string): {
100
+ status: "valid" | "expired" | "refreshing" | "none";
101
+ expiresAt: number | null;
102
+ } {
103
+ if (this.fetchPromises.has(appId)) {
104
+ return { status: "refreshing", expiresAt: this.cache.get(appId)?.expiresAt ?? null };
105
+ }
106
+ const cached = this.cache.get(appId);
107
+ if (!cached) {
108
+ return { status: "none", expiresAt: null };
109
+ }
110
+ const remaining = cached.expiresAt - Date.now();
111
+ const isValid = remaining > Math.min(5 * 60 * 1000, remaining / 3);
112
+ return { status: isValid ? "valid" : "expired", expiresAt: cached.expiresAt };
113
+ }
114
+
115
+ /** Start a background token refresh loop for one appId. */
116
+ startBackgroundRefresh(
117
+ appId: string,
118
+ clientSecret: string,
119
+ options?: BackgroundRefreshOptions,
120
+ ): void {
121
+ if (this.refreshControllers.has(appId)) {
122
+ this.logger?.info?.(`[qqbot:token:${appId}] Background refresh already running`);
123
+ return;
124
+ }
125
+
126
+ const {
127
+ refreshAheadMs = 5 * 60 * 1000,
128
+ randomOffsetMs = 30 * 1000,
129
+ minRefreshIntervalMs = 60 * 1000,
130
+ retryDelayMs = 5 * 1000,
131
+ } = options ?? {};
132
+
133
+ const controller = new AbortController();
134
+ this.refreshControllers.set(appId, controller);
135
+ const { signal } = controller;
136
+
137
+ const loop = async () => {
138
+ this.logger?.info?.(`[qqbot:token:${appId}] Background refresh started`);
139
+
140
+ while (!signal.aborted) {
141
+ try {
142
+ await this.getAccessToken(appId, clientSecret);
143
+ const cached = this.cache.get(appId);
144
+
145
+ if (cached) {
146
+ const expiresIn = cached.expiresAt - Date.now();
147
+ const randomOffset = Math.random() * randomOffsetMs;
148
+ const refreshIn = Math.max(
149
+ expiresIn - refreshAheadMs - randomOffset,
150
+ minRefreshIntervalMs,
151
+ );
152
+ this.logger?.debug?.(
153
+ `[qqbot:token:${appId}] Next refresh in ${Math.round(refreshIn / 1000)}s`,
154
+ );
155
+ await this.abortableSleep(refreshIn, signal);
156
+ } else {
157
+ await this.abortableSleep(minRefreshIntervalMs, signal);
158
+ }
159
+ } catch (err) {
160
+ if (signal.aborted) {
161
+ break;
162
+ }
163
+ this.logger?.error?.(
164
+ `[qqbot:token:${appId}] Background refresh failed: ${formatErrorMessage(err)}`,
165
+ );
166
+ await this.abortableSleep(retryDelayMs, signal);
167
+ }
168
+ }
169
+
170
+ this.refreshControllers.delete(appId);
171
+ this.logger?.info?.(`[qqbot:token:${appId}] Background refresh stopped`);
172
+ };
173
+
174
+ loop().catch((err) => {
175
+ this.refreshControllers.delete(appId);
176
+ this.logger?.error?.(`[qqbot:token:${appId}] Background refresh crashed: ${err}`);
177
+ });
178
+ }
179
+
180
+ /** Stop background refresh for one appId, or all. */
181
+ stopBackgroundRefresh(appId?: string): void {
182
+ if (appId) {
183
+ const ctrl = this.refreshControllers.get(appId);
184
+ if (ctrl) {
185
+ ctrl.abort();
186
+ this.refreshControllers.delete(appId);
187
+ }
188
+ } else {
189
+ for (const ctrl of this.refreshControllers.values()) {
190
+ ctrl.abort();
191
+ }
192
+ this.refreshControllers.clear();
193
+ }
194
+ }
195
+
196
+ /** Check whether background refresh is running. */
197
+ isBackgroundRefreshRunning(appId?: string): boolean {
198
+ if (appId) {
199
+ return this.refreshControllers.has(appId);
200
+ }
201
+ return this.refreshControllers.size > 0;
202
+ }
203
+
204
+ // ---- Internal ----
205
+
206
+ private async doFetchToken(appId: string, clientSecret: string): Promise<string> {
207
+ this.logger?.debug?.(`[qqbot:token:${appId}] >>> POST ${TOKEN_URL}`);
208
+
209
+ let response: Response;
210
+ try {
211
+ response = await fetch(TOKEN_URL, {
212
+ method: "POST",
213
+ headers: {
214
+ "Content-Type": "application/json",
215
+ "User-Agent": this.resolveUserAgent(),
216
+ },
217
+ body: JSON.stringify({ appId, clientSecret }),
218
+ });
219
+ } catch (err) {
220
+ this.logger?.error?.(`[qqbot:token:${appId}] Network error: ${formatErrorMessage(err)}`);
221
+ throw new Error(`Network error getting access_token: ${formatErrorMessage(err)}`, {
222
+ cause: err,
223
+ });
224
+ }
225
+
226
+ const traceId = response.headers.get("x-tps-trace-id") ?? "";
227
+ this.logger?.debug?.(
228
+ `[qqbot:token:${appId}] <<< ${response.status}${traceId ? ` | TraceId: ${traceId}` : ""}`,
229
+ );
230
+
231
+ let rawBody: string;
232
+ try {
233
+ rawBody = await response.text();
234
+ } catch (err) {
235
+ throw new Error(`Failed to read access_token response: ${formatErrorMessage(err)}`, {
236
+ cause: err,
237
+ });
238
+ }
239
+ const logBody = rawBody.replace(/"access_token"\s*:\s*"[^"]+"/g, '"access_token": "***"');
240
+ this.logger?.debug?.(`[qqbot:token:${appId}] <<< Body: ${logBody}`);
241
+
242
+ let data: { access_token?: string; expires_in?: number };
243
+ try {
244
+ data = JSON.parse(rawBody);
245
+ } catch {
246
+ throw new Error("QQBot access_token response was malformed JSON");
247
+ }
248
+
249
+ if (!data.access_token) {
250
+ throw new Error(`Failed to get access_token: ${JSON.stringify(data)}`);
251
+ }
252
+
253
+ const expiresAt = Date.now() + (data.expires_in ?? 7200) * 1000;
254
+ this.cache.set(appId, { token: data.access_token, expiresAt, appId });
255
+ this.logger?.debug?.(
256
+ `[qqbot:token:${appId}] Cached, expires at: ${new Date(expiresAt).toISOString()}`,
257
+ );
258
+
259
+ return data.access_token;
260
+ }
261
+
262
+ private abortableSleep(ms: number, signal: AbortSignal): Promise<void> {
263
+ return new Promise((resolve, reject) => {
264
+ const timer = setTimeout(resolve, ms);
265
+ if (signal.aborted) {
266
+ clearTimeout(timer);
267
+ reject(new Error("Aborted"));
268
+ return;
269
+ }
270
+ const onAbort = () => {
271
+ clearTimeout(timer);
272
+ reject(new Error("Aborted"));
273
+ };
274
+ signal.addEventListener("abort", onAbort, { once: true });
275
+ });
276
+ }
277
+ }