@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,356 @@
1
+ import WebSocket from "ws";
2
+ import type { EngineAdapters } from "../adapter/index.js";
3
+ import {
4
+ trySlashCommand,
5
+ type SlashCommandHandlerContext,
6
+ } from "../commands/slash-command-handler.js";
7
+ import {
8
+ clearTokenCache,
9
+ getAccessToken,
10
+ getGatewayUrl,
11
+ getPluginUserAgent,
12
+ startBackgroundTokenRefresh,
13
+ stopBackgroundTokenRefresh,
14
+ } from "../messaging/sender.js";
15
+ import { flushRefIndex } from "../ref/store.js";
16
+ import { flushKnownUsers } from "../session/known-users.js";
17
+ import { clearSession, loadSession, saveSession } from "../session/session-store.js";
18
+ import type { InteractionEvent } from "../types.js";
19
+ import { decodeGatewayMessageData } from "./codec.js";
20
+ import { FULL_INTENTS, RATE_LIMIT_DELAY, GatewayOp } from "./constants.js";
21
+ import { dispatchEvent } from "./event-dispatcher.js";
22
+ import { createMessageQueue, type QueuedMessage } from "./message-queue.js";
23
+ import { ReconnectState } from "./reconnect.js";
24
+ import type { GatewayAccount, EngineLogger, GatewayPluginRuntime, WSPayload } from "./types.js";
25
+ import { createQQWSClient } from "./ws-client.js";
26
+
27
+ interface GatewayConnectionContext {
28
+ account: GatewayAccount;
29
+ abortSignal: AbortSignal;
30
+ cfg: unknown;
31
+ log?: EngineLogger;
32
+ runtime: GatewayPluginRuntime;
33
+ adapters: EngineAdapters;
34
+ onReady?: (data: unknown) => void;
35
+ onResumed?: (data: unknown) => void;
36
+ onError?: (error: Error) => void;
37
+ handleMessage: (event: QueuedMessage) => Promise<void>;
38
+ onInteraction?: (event: InteractionEvent) => void;
39
+ }
40
+
41
+ export class GatewayConnection {
42
+ private isAborted = false;
43
+ private currentWs: WebSocket | null = null;
44
+ private heartbeatInterval: ReturnType<typeof setInterval> | null = null;
45
+ private sessionId: string | null = null;
46
+ private lastSeq: number | null = null;
47
+ private isConnecting = false;
48
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
49
+ private shouldRefreshToken = false;
50
+
51
+ private readonly reconnect: ReconnectState;
52
+ private readonly msgQueue;
53
+ private readonly ctx: GatewayConnectionContext;
54
+
55
+ constructor(ctx: GatewayConnectionContext) {
56
+ this.ctx = ctx;
57
+ this.reconnect = new ReconnectState(ctx.account.accountId, ctx.log);
58
+ this.msgQueue = createMessageQueue({
59
+ accountId: ctx.account.accountId,
60
+ log: ctx.log,
61
+ isAborted: () => this.isAborted,
62
+ });
63
+ }
64
+
65
+ async start(): Promise<void> {
66
+ this.restoreSession();
67
+ this.registerAbortHandler();
68
+ await this.connect();
69
+ return new Promise<void>((resolve) => {
70
+ this.ctx.abortSignal.addEventListener("abort", () => resolve());
71
+ });
72
+ }
73
+
74
+ private restoreSession(): void {
75
+ const { account, log } = this.ctx;
76
+ const saved = loadSession(account.accountId, account.appId);
77
+ if (saved) {
78
+ this.sessionId = saved.sessionId;
79
+ this.lastSeq = saved.lastSeq;
80
+ log?.info(`Restored session: sessionId=${this.sessionId}, lastSeq=${this.lastSeq}`);
81
+ }
82
+ }
83
+
84
+ private saveCurrentSession(): void {
85
+ const { account } = this.ctx;
86
+ if (!this.sessionId) {
87
+ return;
88
+ }
89
+ saveSession({
90
+ sessionId: this.sessionId,
91
+ lastSeq: this.lastSeq,
92
+ lastConnectedAt: Date.now(),
93
+ intentLevelIndex: 0,
94
+ accountId: account.accountId,
95
+ savedAt: Date.now(),
96
+ appId: account.appId,
97
+ });
98
+ }
99
+
100
+ private registerAbortHandler(): void {
101
+ const { account, abortSignal, log: _log } = this.ctx;
102
+ abortSignal.addEventListener("abort", () => {
103
+ this.isAborted = true;
104
+ if (this.reconnectTimer) {
105
+ clearTimeout(this.reconnectTimer);
106
+ this.reconnectTimer = null;
107
+ }
108
+ this.cleanup();
109
+ stopBackgroundTokenRefresh(account.appId);
110
+ flushKnownUsers();
111
+ flushRefIndex();
112
+ });
113
+ }
114
+
115
+ private cleanup(): void {
116
+ if (this.heartbeatInterval) {
117
+ clearInterval(this.heartbeatInterval);
118
+ this.heartbeatInterval = null;
119
+ }
120
+ if (
121
+ this.currentWs &&
122
+ (this.currentWs.readyState === WebSocket.OPEN ||
123
+ this.currentWs.readyState === WebSocket.CONNECTING)
124
+ ) {
125
+ this.currentWs.close();
126
+ }
127
+ this.currentWs = null;
128
+ }
129
+
130
+ private scheduleReconnect(customDelay?: number): void {
131
+ const { account: _account, log } = this.ctx;
132
+ if (this.isAborted || this.reconnect.isExhausted()) {
133
+ log?.error(`Max reconnect attempts reached or aborted`);
134
+ return;
135
+ }
136
+ if (this.reconnectTimer) {
137
+ clearTimeout(this.reconnectTimer);
138
+ this.reconnectTimer = null;
139
+ }
140
+ const delay = this.reconnect.getNextDelay(customDelay);
141
+ this.reconnectTimer = setTimeout(() => {
142
+ this.reconnectTimer = null;
143
+ if (!this.isAborted) {
144
+ void this.connect();
145
+ }
146
+ }, delay);
147
+ }
148
+
149
+ private async connect(): Promise<void> {
150
+ const { account, log } = this.ctx;
151
+
152
+ if (this.isConnecting) {
153
+ log?.debug?.(`Already connecting, skip`);
154
+ return;
155
+ }
156
+ this.isConnecting = true;
157
+
158
+ try {
159
+ this.cleanup();
160
+ if (this.shouldRefreshToken) {
161
+ log?.debug?.(`Refreshing token...`);
162
+ clearTokenCache(account.appId);
163
+ this.shouldRefreshToken = false;
164
+ }
165
+
166
+ const accessToken = await getAccessToken(account.appId, account.clientSecret);
167
+ log?.info(`✅ Access token obtained successfully`);
168
+ const gatewayUrl = await getGatewayUrl(accessToken, account.appId);
169
+ log?.info(`Connecting to ${gatewayUrl}`);
170
+ const ws = await createQQWSClient({
171
+ gatewayUrl,
172
+ userAgent: getPluginUserAgent(),
173
+ });
174
+ this.currentWs = ws;
175
+
176
+ const slashCtx: SlashCommandHandlerContext = {
177
+ account,
178
+ cfg: this.ctx.cfg,
179
+ log,
180
+ getMessagePeerId: (msg) => this.msgQueue.getMessagePeerId(msg),
181
+ getQueueSnapshot: (peerId) => this.msgQueue.getSnapshot(peerId),
182
+ resolveCommandAuthorized: (params) =>
183
+ this.ctx.adapters.access.resolveSlashCommandAuthorization({
184
+ cfg: this.ctx.cfg,
185
+ accountId: account.accountId,
186
+ ...params,
187
+ }),
188
+ };
189
+
190
+ const trySlashCommandOrEnqueue = async (msg: QueuedMessage): Promise<void> => {
191
+ const result = await trySlashCommand(msg, slashCtx);
192
+ if (result === "enqueue") {
193
+ this.msgQueue.enqueue(msg);
194
+ } else if (result === "urgent") {
195
+ const peerId = this.msgQueue.getMessagePeerId(msg);
196
+ this.msgQueue.clearUserQueue(peerId);
197
+ this.msgQueue.executeImmediate(msg);
198
+ }
199
+ // "handled" — command executed, nothing to queue.
200
+ };
201
+
202
+ // ---- WebSocket: open ----
203
+ ws.on("open", () => {
204
+ log?.info(`WebSocket connected`);
205
+ this.isConnecting = false;
206
+ this.reconnect.onConnected();
207
+ this.msgQueue.startProcessor(this.ctx.handleMessage);
208
+ startBackgroundTokenRefresh(account.appId, account.clientSecret, { log });
209
+ });
210
+
211
+ // ---- WebSocket: message ----
212
+ ws.on("message", async (data) => {
213
+ try {
214
+ const rawData = decodeGatewayMessageData(data);
215
+ const payload = JSON.parse(rawData) as WSPayload;
216
+ const { op, d, s, t } = payload;
217
+
218
+ if (s) {
219
+ this.lastSeq = s;
220
+ this.saveCurrentSession();
221
+ }
222
+
223
+ switch (op) {
224
+ case GatewayOp.HELLO:
225
+ this.handleHello(ws, d, accessToken);
226
+ break;
227
+
228
+ case GatewayOp.DISPATCH: {
229
+ log?.debug?.(`Dispatch event: t=${t}, d=${JSON.stringify(d)}`);
230
+ const result = dispatchEvent(t ?? "", d, account.accountId, log);
231
+ if (result.action === "ready") {
232
+ this.sessionId = result.sessionId;
233
+ this.saveCurrentSession();
234
+ this.ctx.onReady?.(result.data);
235
+ } else if (result.action === "resumed") {
236
+ (this.ctx.onResumed ?? this.ctx.onReady)?.(result.data);
237
+ this.saveCurrentSession();
238
+ } else if (result.action === "interaction") {
239
+ this.ctx.onInteraction?.(result.event);
240
+ } else if (result.action === "message") {
241
+ void trySlashCommandOrEnqueue(result.msg);
242
+ }
243
+ break;
244
+ }
245
+
246
+ case GatewayOp.HEARTBEAT_ACK:
247
+ break;
248
+
249
+ case GatewayOp.RECONNECT:
250
+ this.cleanup();
251
+ this.scheduleReconnect();
252
+ break;
253
+
254
+ case GatewayOp.INVALID_SESSION: {
255
+ const canResume = d as boolean;
256
+ if (!canResume) {
257
+ this.sessionId = null;
258
+ this.lastSeq = null;
259
+ clearSession(account.accountId);
260
+ this.shouldRefreshToken = true;
261
+ }
262
+ this.cleanup();
263
+ this.scheduleReconnect(3000);
264
+ break;
265
+ }
266
+ }
267
+ } catch (err) {
268
+ log?.error(`Message parse error: ${err instanceof Error ? err.message : String(err)}`);
269
+ }
270
+ });
271
+
272
+ // ---- WebSocket: close ----
273
+ ws.on("close", (code, reason) => {
274
+ log?.info(`WebSocket closed: ${code} ${reason.toString()}`);
275
+ this.isConnecting = false;
276
+ this.handleClose(code);
277
+ });
278
+
279
+ // ---- WebSocket: error ----
280
+ ws.on("error", (err) => {
281
+ log?.error(`WebSocket error: ${err.message}`);
282
+ this.ctx.onError?.(err);
283
+ });
284
+ } catch (err) {
285
+ this.isConnecting = false;
286
+ const errMsg = err instanceof Error ? err.message : String(err);
287
+ log?.error(`Connection failed: ${errMsg}`);
288
+ if (errMsg.includes("Too many requests") || errMsg.includes("100001")) {
289
+ this.scheduleReconnect(RATE_LIMIT_DELAY);
290
+ } else {
291
+ this.scheduleReconnect();
292
+ }
293
+ }
294
+ }
295
+
296
+ // ============ Protocol handlers ============
297
+
298
+ private handleHello(ws: WebSocket, d: unknown, accessToken: string): void {
299
+ if (this.sessionId && this.lastSeq !== null) {
300
+ ws.send(
301
+ JSON.stringify({
302
+ op: GatewayOp.RESUME,
303
+ d: {
304
+ token: `QQBot ${accessToken}`,
305
+ session_id: this.sessionId,
306
+ seq: this.lastSeq,
307
+ },
308
+ }),
309
+ );
310
+ } else {
311
+ ws.send(
312
+ JSON.stringify({
313
+ op: GatewayOp.IDENTIFY,
314
+ d: {
315
+ token: `QQBot ${accessToken}`,
316
+ intents: FULL_INTENTS,
317
+ shard: [0, 1],
318
+ },
319
+ }),
320
+ );
321
+ }
322
+
323
+ const interval = (d as { heartbeat_interval: number }).heartbeat_interval;
324
+ if (this.heartbeatInterval) {
325
+ clearInterval(this.heartbeatInterval);
326
+ }
327
+ this.heartbeatInterval = setInterval(() => {
328
+ if (ws.readyState === WebSocket.OPEN) {
329
+ ws.send(JSON.stringify({ op: GatewayOp.HEARTBEAT, d: this.lastSeq }));
330
+ }
331
+ }, interval);
332
+ }
333
+
334
+ private handleClose(code: number): void {
335
+ const { account } = this.ctx;
336
+ const action = this.reconnect.handleClose(code, this.isAborted);
337
+
338
+ if (action.clearSession) {
339
+ this.sessionId = null;
340
+ this.lastSeq = null;
341
+ clearSession(account.accountId);
342
+ }
343
+ if (action.refreshToken) {
344
+ this.shouldRefreshToken = true;
345
+ }
346
+
347
+ this.cleanup();
348
+
349
+ if (action.fatal) {
350
+ return;
351
+ }
352
+ if (action.shouldReconnect) {
353
+ this.scheduleReconnect(action.reconnectDelay);
354
+ }
355
+ }
356
+ }
@@ -0,0 +1,267 @@
1
+ import path from "node:path";
2
+ import { initCommands } from "../commands/slash-commands-impl.js";
3
+ import { createNodeSessionStoreReader } from "../group/activation.js";
4
+ import type { HistoryEntry } from "../group/history.js";
5
+ import { setOutboundAudioPort } from "../messaging/outbound.js";
6
+ import {
7
+ clearTokenCache,
8
+ getAccessToken,
9
+ initApiConfig,
10
+ onMessageSent,
11
+ sendInputNotify as senderSendInputNotify,
12
+ createRawInputNotifyFn,
13
+ accountToCreds,
14
+ } from "../messaging/sender.js";
15
+ import { setRefIndex } from "../ref/store.js";
16
+ import { runDiagnostics } from "../utils/diagnostics.js";
17
+ import { runWithRequestContext } from "../utils/request-context.js";
18
+ import { createActiveCfgProvider } from "./active-cfg.js";
19
+ import { GatewayConnection } from "./gateway-connection.js";
20
+ import { buildInboundContext, clearGroupPendingHistory } from "./inbound-pipeline.js";
21
+ import { createInteractionHandler } from "./interaction-handler.js";
22
+ import type { QueuedMessage } from "./message-queue.js";
23
+ import { dispatchOutbound } from "./outbound-dispatch.js";
24
+ import type {
25
+ CoreGatewayContext,
26
+ GatewayAccount,
27
+ EngineLogger,
28
+ RefAttachmentSummary,
29
+ } from "./types.js";
30
+ import { TypingKeepAlive, TYPING_INPUT_SECOND } from "./typing-keepalive.js";
31
+
32
+ export type { CoreGatewayContext } from "./types.js";
33
+
34
+ export async function startGateway(ctx: CoreGatewayContext): Promise<void> {
35
+ const { account, log, runtime, adapters } = ctx;
36
+
37
+ setOutboundAudioPort(adapters.outboundAudio);
38
+ initCommands(adapters.commands);
39
+
40
+ if (!account.appId || !account.clientSecret) {
41
+ throw new Error("QQBot not configured (missing appId or clientSecret)");
42
+ }
43
+
44
+ const diag = await runDiagnostics();
45
+ if (diag.warnings.length > 0) {
46
+ for (const w of diag.warnings) {
47
+ log?.info(w);
48
+ }
49
+ }
50
+
51
+ initApiConfig(account.appId, { markdownSupport: account.markdownSupport });
52
+ log?.debug?.(`API config: markdownSupport=${account.markdownSupport}`);
53
+
54
+ onMessageSent(account.appId, (refIdx, meta) => {
55
+ log?.info(
56
+ `onMessageSent called: refIdx=${refIdx}, mediaType=${meta.mediaType}, ttsText=${meta.ttsText?.slice(0, 30)}`,
57
+ );
58
+ const attachments: RefAttachmentSummary[] = [];
59
+ if (meta.mediaType) {
60
+ const localPath = meta.mediaLocalPath;
61
+ const filename = localPath ? path.basename(localPath) : undefined;
62
+ const attachment: RefAttachmentSummary = {
63
+ type: meta.mediaType,
64
+ ...(localPath ? { localPath } : {}),
65
+ ...(filename ? { filename } : {}),
66
+ ...(meta.mediaUrl ? { url: meta.mediaUrl } : {}),
67
+ };
68
+ if (meta.mediaType === "voice" && meta.ttsText) {
69
+ attachment.transcript = meta.ttsText;
70
+ attachment.transcriptSource = "tts";
71
+ }
72
+ attachments.push(attachment);
73
+ }
74
+ setRefIndex(refIdx, {
75
+ content: meta.text ?? "",
76
+ senderId: account.accountId,
77
+ senderName: account.accountId,
78
+ timestamp: Date.now(),
79
+ isBot: true,
80
+ ...(attachments.length > 0 ? { attachments } : {}),
81
+ });
82
+ });
83
+
84
+ const groupOpts = {
85
+ enabled: ctx.group?.enabled ?? true,
86
+ allowTextCommands: ctx.group?.allowTextCommands,
87
+ isControlCommand: ctx.group?.isControlCommand,
88
+ resolveIntroHint: ctx.group?.resolveIntroHint,
89
+ sessionStoreReader: ctx.group?.sessionStoreReader,
90
+ };
91
+ const groupChatEnabled = groupOpts.enabled;
92
+ const groupHistories: Map<string, HistoryEntry[]> | undefined = groupChatEnabled
93
+ ? new Map()
94
+ : undefined;
95
+ const sessionStoreReader = groupChatEnabled
96
+ ? (groupOpts.sessionStoreReader ?? createNodeSessionStoreReader())
97
+ : undefined;
98
+
99
+ // Live config provider: per-inbound lookup so binding edits applied
100
+ // through the CLI take effect without a gateway restart (#69546).
101
+ const activeCfgProvider = createActiveCfgProvider({ fallback: ctx.cfg });
102
+
103
+ // ---- 7. Message handler ----
104
+ const handleMessage = async (event: QueuedMessage): Promise<void> => {
105
+ log?.info(`Processing message from ${event.senderId}: ${event.content}`, {
106
+ accountId: account.accountId,
107
+ messageId: event.messageId,
108
+ senderId: event.senderId,
109
+ type: event.type,
110
+ groupOpenid: event.groupOpenid,
111
+ });
112
+
113
+ runtime.channel.activity.record({
114
+ channel: "qqbot",
115
+ accountId: account.accountId,
116
+ direction: "inbound",
117
+ });
118
+
119
+ const activeCfg = activeCfgProvider.getActiveCfg();
120
+
121
+ const inbound = await buildInboundContext(event, {
122
+ account,
123
+ cfg: activeCfg,
124
+ log,
125
+ runtime,
126
+ startTyping: (ev) => startTypingForEvent(ev, account, log),
127
+ groupHistories,
128
+ sessionStoreReader,
129
+ allowTextCommands: groupOpts.allowTextCommands,
130
+ isControlCommand: groupOpts.isControlCommand,
131
+ resolveGroupIntroHint: groupOpts.resolveIntroHint,
132
+ adapters,
133
+ });
134
+
135
+ if (inbound.blocked) {
136
+ log?.info(`Dropped inbound qqbot message: ${inbound.blockReason ?? "blocked by allowFrom"}`, {
137
+ accountId: account.accountId,
138
+ messageId: event.messageId,
139
+ blockReason: inbound.blockReason,
140
+ });
141
+ inbound.typing.keepAlive?.stop();
142
+ return;
143
+ }
144
+
145
+ if (inbound.skipped) {
146
+ log?.info(
147
+ `Skipped group inbound: reason=${inbound.skipReason ?? "unknown"} group=${event.groupOpenid ?? ""}`,
148
+ {
149
+ accountId: account.accountId,
150
+ messageId: event.messageId,
151
+ skipReason: inbound.skipReason,
152
+ groupOpenid: event.groupOpenid,
153
+ },
154
+ );
155
+ inbound.typing.keepAlive?.stop();
156
+ return;
157
+ }
158
+
159
+ try {
160
+ await runWithRequestContext(
161
+ {
162
+ accountId: account.accountId,
163
+ target: inbound.qualifiedTarget,
164
+ targetId: inbound.peerId,
165
+ chatType: event.type,
166
+ },
167
+ () => dispatchOutbound(inbound, { runtime, cfg: activeCfg, account, log }),
168
+ );
169
+ } catch (err) {
170
+ log?.error(`Message processing failed: ${err instanceof Error ? err.message : String(err)}`);
171
+ } finally {
172
+ inbound.typing.keepAlive?.stop();
173
+ if (event.type === "group" && event.groupOpenid && inbound.group) {
174
+ clearGroupPendingHistory({
175
+ historyMap: groupHistories,
176
+ groupOpenid: event.groupOpenid,
177
+ historyLimit: inbound.group.historyLimit,
178
+ historyPort: adapters.history,
179
+ });
180
+ }
181
+ }
182
+ };
183
+
184
+ const handleInteraction = createInteractionHandler(account, ctx.runtime, log, {
185
+ getActiveCfg: () => activeCfgProvider.getActiveCfg(),
186
+ });
187
+
188
+ const connection = new GatewayConnection({
189
+ account,
190
+ abortSignal: ctx.abortSignal,
191
+ cfg: ctx.cfg,
192
+ log,
193
+ runtime,
194
+ adapters,
195
+ onReady: ctx.onReady,
196
+ onResumed: ctx.onResumed,
197
+ onError: ctx.onError,
198
+ onInteraction: handleInteraction,
199
+ handleMessage,
200
+ });
201
+
202
+ await connection.start();
203
+ }
204
+
205
+ // ============ Typing helper ============
206
+
207
+ /**
208
+ * Start typing indicator for a C2C event.
209
+ * Returns the refIdx from InputNotify and a TypingKeepAlive handle.
210
+ */
211
+ async function startTypingForEvent(
212
+ event: QueuedMessage,
213
+ account: GatewayAccount,
214
+ log?: EngineLogger,
215
+ ): Promise<{ refIdx?: string; keepAlive: TypingKeepAlive | null }> {
216
+ const isC2C = event.type === "c2c" || event.type === "dm";
217
+ if (!isC2C) {
218
+ return { keepAlive: null };
219
+ }
220
+ try {
221
+ const creds = accountToCreds(account);
222
+ const rawNotifyFn = createRawInputNotifyFn(account.appId);
223
+ try {
224
+ const resp = await senderSendInputNotify({
225
+ openid: event.senderId,
226
+ creds,
227
+ msgId: event.messageId,
228
+ inputSecond: TYPING_INPUT_SECOND,
229
+ });
230
+ const keepAlive = new TypingKeepAlive(
231
+ () => getAccessToken(account.appId, account.clientSecret),
232
+ () => clearTokenCache(account.appId),
233
+ rawNotifyFn,
234
+ event.senderId,
235
+ event.messageId,
236
+ log,
237
+ );
238
+ keepAlive.start();
239
+ return { refIdx: resp.refIdx, keepAlive };
240
+ } catch (notifyErr) {
241
+ const errMsg = String(notifyErr);
242
+ if (errMsg.includes("token") || errMsg.includes("401") || errMsg.includes("11244")) {
243
+ clearTokenCache(account.appId);
244
+ const resp = await senderSendInputNotify({
245
+ openid: event.senderId,
246
+ creds,
247
+ msgId: event.messageId,
248
+ inputSecond: TYPING_INPUT_SECOND,
249
+ });
250
+ const keepAlive = new TypingKeepAlive(
251
+ () => getAccessToken(account.appId, account.clientSecret),
252
+ () => clearTokenCache(account.appId),
253
+ rawNotifyFn,
254
+ event.senderId,
255
+ event.messageId,
256
+ log,
257
+ );
258
+ keepAlive.start();
259
+ return { refIdx: resp.refIdx, keepAlive };
260
+ }
261
+ throw notifyErr;
262
+ }
263
+ } catch (err) {
264
+ log?.error(`sendInputNotify error: ${err instanceof Error ? err.message : String(err)}`);
265
+ return { keepAlive: null };
266
+ }
267
+ }