@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,360 @@
1
+ import type { AudioConvertPort } from "../adapter/audio.port.js";
2
+ import { downloadFile } from "../utils/file-utils.js";
3
+ import { getQQBotMediaDir } from "../utils/platform.js";
4
+ import { normalizeOptionalString } from "../utils/string-normalize.js";
5
+ import { transcribeAudio, resolveSTTConfig } from "../utils/stt.js";
6
+
7
+ // Re-export the port type for convenience.
8
+ export type { AudioConvertPort } from "../adapter/audio.port.js";
9
+
10
+ interface RawAttachment {
11
+ content_type: string;
12
+ url: string;
13
+ filename?: string;
14
+ voice_wav_url?: string;
15
+ asr_refer_text?: string;
16
+ }
17
+
18
+ type TranscriptSource = "stt" | "asr" | "fallback";
19
+
20
+ /** Normalized attachment output consumed by the gateway. */
21
+ export interface ProcessedAttachments {
22
+ attachmentInfo: string;
23
+ imageUrls: string[];
24
+ imageMediaTypes: string[];
25
+ voiceAttachmentPaths: string[];
26
+ voiceAttachmentUrls: string[];
27
+ voiceAsrReferTexts: string[];
28
+ voiceTranscripts: string[];
29
+ voiceTranscriptSources: TranscriptSource[];
30
+ attachmentLocalPaths: Array<string | null>;
31
+ }
32
+
33
+ interface ProcessContext {
34
+ accountId: string;
35
+ cfg: unknown;
36
+ audioConvert: AudioConvertPort;
37
+ log?: {
38
+ info: (msg: string) => void;
39
+ error: (msg: string) => void;
40
+ debug?: (msg: string) => void;
41
+ };
42
+ }
43
+
44
+ const EMPTY_RESULT: ProcessedAttachments = {
45
+ attachmentInfo: "",
46
+ imageUrls: [],
47
+ imageMediaTypes: [],
48
+ voiceAttachmentPaths: [],
49
+ voiceAttachmentUrls: [],
50
+ voiceAsrReferTexts: [],
51
+ voiceTranscripts: [],
52
+ voiceTranscriptSources: [],
53
+ attachmentLocalPaths: [],
54
+ };
55
+
56
+ /** Download, convert, transcribe, and classify inbound attachments. */
57
+ export async function processAttachments(
58
+ attachments: RawAttachment[] | undefined,
59
+ ctx: ProcessContext,
60
+ ): Promise<ProcessedAttachments> {
61
+ if (!attachments?.length) {
62
+ return EMPTY_RESULT;
63
+ }
64
+
65
+ const { accountId: _accountId, cfg, log, audioConvert } = ctx;
66
+ const downloadDir = getQQBotMediaDir("downloads");
67
+
68
+ const imageUrls: string[] = [];
69
+ const imageMediaTypes: string[] = [];
70
+ const voiceAttachmentPaths: string[] = [];
71
+ const voiceAttachmentUrls: string[] = [];
72
+ const voiceAsrReferTexts: string[] = [];
73
+ const voiceTranscripts: string[] = [];
74
+ const voiceTranscriptSources: TranscriptSource[] = [];
75
+ const attachmentLocalPaths: Array<string | null> = [];
76
+ const otherAttachments: string[] = [];
77
+
78
+ // Phase 1: download all attachments in parallel.
79
+ const downloadTasks = attachments.map(async (att) => {
80
+ const attUrl = att.url?.startsWith("//") ? `https:${att.url}` : att.url;
81
+ const isVoice = audioConvert.isVoiceAttachment(att);
82
+ const wavUrl =
83
+ isVoice && att.voice_wav_url
84
+ ? att.voice_wav_url.startsWith("//")
85
+ ? `https:${att.voice_wav_url}`
86
+ : att.voice_wav_url
87
+ : "";
88
+
89
+ let localPath: string | null = null;
90
+ let audioPath: string | null = null;
91
+
92
+ if (isVoice && wavUrl) {
93
+ const wavLocalPath = await downloadFile(wavUrl, downloadDir);
94
+ if (wavLocalPath) {
95
+ localPath = wavLocalPath;
96
+ audioPath = wavLocalPath;
97
+ log?.debug?.(`Voice attachment: ${att.filename}, downloaded WAV directly (skip SILK→WAV)`);
98
+ } else {
99
+ log?.error(`Failed to download voice_wav_url, falling back to original URL`);
100
+ }
101
+ }
102
+
103
+ if (!localPath) {
104
+ localPath = await downloadFile(attUrl, downloadDir, att.filename);
105
+ }
106
+
107
+ return { att, attUrl, isVoice, localPath, audioPath };
108
+ });
109
+
110
+ const downloadResults = await Promise.all(downloadTasks);
111
+
112
+ // Phase 2: convert/transcribe voice attachments and classify everything else.
113
+ const processTasks = downloadResults.map(
114
+ async ({ att, attUrl, isVoice, localPath, audioPath }) => {
115
+ const asrReferText = normalizeOptionalString(att.asr_refer_text) ?? "";
116
+ const wavUrl =
117
+ isVoice && att.voice_wav_url
118
+ ? att.voice_wav_url.startsWith("//")
119
+ ? `https:${att.voice_wav_url}`
120
+ : att.voice_wav_url
121
+ : "";
122
+ const voiceSourceUrl = wavUrl || attUrl;
123
+
124
+ const meta = {
125
+ voiceUrl: isVoice && voiceSourceUrl ? voiceSourceUrl : undefined,
126
+ asrReferText: isVoice && asrReferText ? asrReferText : undefined,
127
+ };
128
+
129
+ if (localPath) {
130
+ if (att.content_type?.startsWith("image/")) {
131
+ log?.debug?.(`Downloaded attachment to: ${localPath}`);
132
+ return { localPath, type: "image" as const, contentType: att.content_type, meta };
133
+ }
134
+ if (isVoice) {
135
+ log?.debug?.(`Downloaded attachment to: ${localPath}`);
136
+ return processVoiceAttachment(
137
+ localPath,
138
+ audioPath,
139
+ att,
140
+ asrReferText,
141
+ cfg,
142
+ downloadDir,
143
+ audioConvert,
144
+ log,
145
+ );
146
+ }
147
+ log?.debug?.(`Downloaded attachment to: ${localPath}`);
148
+ return { localPath, type: "other" as const, filename: att.filename, meta };
149
+ }
150
+ log?.error(`Failed to download: ${attUrl}`);
151
+ if (att.content_type?.startsWith("image/")) {
152
+ return {
153
+ localPath: null,
154
+ type: "image-fallback" as const,
155
+ attUrl,
156
+ contentType: att.content_type,
157
+ meta,
158
+ };
159
+ }
160
+ if (isVoice && asrReferText) {
161
+ log?.info(`Voice attachment download failed, using asr_refer_text fallback`);
162
+ return {
163
+ localPath: null,
164
+ type: "voice-fallback" as const,
165
+ transcript: asrReferText,
166
+ meta,
167
+ };
168
+ }
169
+ return {
170
+ localPath: null,
171
+ type: "other-fallback" as const,
172
+ filename: att.filename ?? att.content_type,
173
+ meta,
174
+ };
175
+ },
176
+ );
177
+
178
+ const processResults = await Promise.all(processTasks);
179
+
180
+ // Phase 3: collect results in the original attachment order.
181
+ for (const result of processResults) {
182
+ if (result.meta.voiceUrl) {
183
+ voiceAttachmentUrls.push(result.meta.voiceUrl);
184
+ }
185
+ if (result.meta.asrReferText) {
186
+ voiceAsrReferTexts.push(result.meta.asrReferText);
187
+ }
188
+
189
+ if (result.type === "image" && result.localPath) {
190
+ imageUrls.push(result.localPath);
191
+ imageMediaTypes.push(result.contentType);
192
+ attachmentLocalPaths.push(result.localPath);
193
+ } else if (result.type === "voice" && result.localPath) {
194
+ voiceAttachmentPaths.push(result.localPath);
195
+ voiceTranscripts.push(result.transcript);
196
+ voiceTranscriptSources.push(result.transcriptSource);
197
+ attachmentLocalPaths.push(result.localPath);
198
+ } else if (result.type === "other" && result.localPath) {
199
+ otherAttachments.push(`[Attachment: ${result.localPath}]`);
200
+ attachmentLocalPaths.push(result.localPath);
201
+ } else if (result.type === "image-fallback") {
202
+ imageUrls.push(result.attUrl);
203
+ imageMediaTypes.push(result.contentType);
204
+ attachmentLocalPaths.push(null);
205
+ } else if (result.type === "voice-fallback") {
206
+ voiceTranscripts.push(result.transcript);
207
+ voiceTranscriptSources.push("asr");
208
+ attachmentLocalPaths.push(null);
209
+ } else if (result.type === "other-fallback") {
210
+ otherAttachments.push(`[Attachment: ${result.filename}] (download failed)`);
211
+ attachmentLocalPaths.push(null);
212
+ }
213
+ }
214
+
215
+ const attachmentInfo = otherAttachments.length > 0 ? "\n" + otherAttachments.join("\n") : "";
216
+
217
+ return {
218
+ attachmentInfo,
219
+ imageUrls,
220
+ imageMediaTypes,
221
+ voiceAttachmentPaths,
222
+ voiceAttachmentUrls,
223
+ voiceAsrReferTexts,
224
+ voiceTranscripts,
225
+ voiceTranscriptSources,
226
+ attachmentLocalPaths,
227
+ };
228
+ }
229
+
230
+ // formatVoiceText is now in core/utils/voice-text.ts (re-exported above).
231
+
232
+ // Internal helpers.
233
+
234
+ type VoiceResult =
235
+ | {
236
+ localPath: string;
237
+ type: "voice";
238
+ transcript: string;
239
+ transcriptSource: TranscriptSource;
240
+ meta: { voiceUrl?: string; asrReferText?: string };
241
+ }
242
+ | {
243
+ localPath: string;
244
+ type: "voice";
245
+ transcript: string;
246
+ transcriptSource: TranscriptSource;
247
+ meta: { voiceUrl?: string; asrReferText?: string };
248
+ };
249
+
250
+ async function processVoiceAttachment(
251
+ localPath: string,
252
+ audioPath: string | null,
253
+ att: RawAttachment,
254
+ asrReferText: string,
255
+ cfg: unknown,
256
+ downloadDir: string,
257
+ audioConvert: AudioConvertPort,
258
+ log: ProcessContext["log"],
259
+ ): Promise<VoiceResult> {
260
+ const wavUrl = att.voice_wav_url
261
+ ? att.voice_wav_url.startsWith("//")
262
+ ? `https:${att.voice_wav_url}`
263
+ : att.voice_wav_url
264
+ : "";
265
+ const attUrl = att.url?.startsWith("//") ? `https:${att.url}` : att.url;
266
+ const voiceSourceUrl = wavUrl || attUrl;
267
+ const meta = {
268
+ voiceUrl: voiceSourceUrl || undefined,
269
+ asrReferText: asrReferText || undefined,
270
+ };
271
+
272
+ const sttCfg = resolveSTTConfig(cfg as Record<string, unknown>);
273
+ if (!sttCfg) {
274
+ if (asrReferText) {
275
+ log?.debug?.(
276
+ `Voice attachment: ${att.filename} (STT not configured, using asr_refer_text fallback)`,
277
+ );
278
+ return { localPath, type: "voice", transcript: asrReferText, transcriptSource: "asr", meta };
279
+ }
280
+ log?.debug?.(`Voice attachment: ${att.filename} (STT not configured, skipping transcription)`);
281
+ return {
282
+ localPath,
283
+ type: "voice",
284
+ transcript: "[Voice message - transcription unavailable because STT is not configured]",
285
+ transcriptSource: "fallback",
286
+ meta,
287
+ };
288
+ }
289
+
290
+ // Convert SILK input to WAV before STT when necessary.
291
+ if (!audioPath) {
292
+ log?.debug?.(`Voice attachment: ${att.filename}, converting SILK→WAV...`);
293
+ try {
294
+ const wavResult = await audioConvert.convertSilkToWav(localPath, downloadDir);
295
+ if (wavResult) {
296
+ audioPath = wavResult.wavPath;
297
+ log?.debug?.(
298
+ `Voice converted: ${wavResult.wavPath} (${audioConvert.formatDuration(wavResult.duration)})`,
299
+ );
300
+ } else {
301
+ audioPath = localPath;
302
+ }
303
+ } catch (convertErr) {
304
+ log?.error(
305
+ `Voice conversion failed: ${
306
+ convertErr instanceof Error ? convertErr.message : JSON.stringify(convertErr)
307
+ }`,
308
+ );
309
+ if (asrReferText) {
310
+ return {
311
+ localPath,
312
+ type: "voice",
313
+ transcript: asrReferText,
314
+ transcriptSource: "asr",
315
+ meta,
316
+ };
317
+ }
318
+ return {
319
+ localPath,
320
+ type: "voice",
321
+ transcript: "[Voice message - format conversion failed]",
322
+ transcriptSource: "fallback",
323
+ meta,
324
+ };
325
+ }
326
+ }
327
+
328
+ // Run speech-to-text on the prepared audio file.
329
+ try {
330
+ const transcript = await transcribeAudio(audioPath, cfg as Record<string, unknown>);
331
+ if (transcript) {
332
+ log?.debug?.(`STT transcript: ${transcript.slice(0, 100)}...`);
333
+ return { localPath, type: "voice", transcript, transcriptSource: "stt", meta };
334
+ }
335
+ if (asrReferText) {
336
+ log?.debug?.(`STT returned empty result, using asr_refer_text fallback`);
337
+ return { localPath, type: "voice", transcript: asrReferText, transcriptSource: "asr", meta };
338
+ }
339
+ log?.debug?.(`STT returned empty result`);
340
+ return {
341
+ localPath,
342
+ type: "voice",
343
+ transcript: "[Voice message - transcription returned an empty result]",
344
+ transcriptSource: "fallback",
345
+ meta,
346
+ };
347
+ } catch (sttErr) {
348
+ log?.error(`STT failed: ${sttErr instanceof Error ? sttErr.message : JSON.stringify(sttErr)}`);
349
+ if (asrReferText) {
350
+ return { localPath, type: "voice", transcript: asrReferText, transcriptSource: "asr", meta };
351
+ }
352
+ return {
353
+ localPath,
354
+ type: "voice",
355
+ transcript: "[Voice message - transcription failed]",
356
+ transcriptSource: "fallback",
357
+ meta,
358
+ };
359
+ }
360
+ }
@@ -0,0 +1,82 @@
1
+ import type { ChannelIngressDecision } from "autobot/plugin-sdk/channel-ingress-runtime";
2
+ import type { EngineAdapters } from "../adapter/index.js";
3
+ import type { GroupActivationMode, SessionStoreReader } from "../group/activation.js";
4
+ import type { HistoryEntry } from "../group/history.js";
5
+ import type { GroupMessageGateResult } from "../group/message-gating.js";
6
+ import type { QueuedMessage } from "./message-queue.js";
7
+ import type { GatewayAccount, EngineLogger, GatewayPluginRuntime } from "./types.js";
8
+ import type { TypingKeepAlive } from "./typing-keepalive.js";
9
+
10
+ export interface ReplyToInfo {
11
+ id: string;
12
+ body?: string;
13
+ sender?: string;
14
+ isQuote: boolean;
15
+ }
16
+
17
+ export interface InboundGroupInfo {
18
+ gate: GroupMessageGateResult;
19
+ activation: GroupActivationMode;
20
+ historyLimit: number;
21
+ isMerged: boolean;
22
+ mergedMessages?: readonly QueuedMessage[];
23
+ display: {
24
+ groupName: string;
25
+ senderLabel: string;
26
+ introHint?: string;
27
+ behaviorPrompt?: string;
28
+ };
29
+ }
30
+
31
+ export interface InboundContext {
32
+ event: QueuedMessage;
33
+ route: { sessionKey: string; accountId: string; agentId?: string };
34
+ isGroupChat: boolean;
35
+ peerId: string;
36
+ qualifiedTarget: string;
37
+ fromAddress: string;
38
+ agentBody: string;
39
+ body: string;
40
+ groupSystemPrompt?: string;
41
+ localMediaPaths: string[];
42
+ localMediaTypes: string[];
43
+ remoteMediaUrls: string[];
44
+ uniqueVoicePaths: string[];
45
+ uniqueVoiceUrls: string[];
46
+ uniqueVoiceAsrReferTexts: string[];
47
+ voiceMediaTypes: string[];
48
+ hasAsrReferFallback: boolean;
49
+ voiceTranscriptSources: string[];
50
+ replyTo?: ReplyToInfo;
51
+ commandAuthorized: boolean;
52
+ group?: InboundGroupInfo;
53
+ blocked: boolean;
54
+ blockReason?: string;
55
+ blockReasonCode?: string;
56
+ accessDecision?: ChannelIngressDecision["decision"];
57
+ skipped: boolean;
58
+ skipReason?: "drop_other_mention" | "block_unauthorized_command" | "skip_no_mention";
59
+ typing: { keepAlive: TypingKeepAlive | null };
60
+ inputNotifyRefIdx?: string;
61
+ }
62
+
63
+ export interface InboundPipelineDeps {
64
+ account: GatewayAccount;
65
+ cfg: unknown;
66
+ log?: EngineLogger;
67
+ runtime: GatewayPluginRuntime;
68
+ startTyping: (event: QueuedMessage) => Promise<{
69
+ refIdx?: string;
70
+ keepAlive: TypingKeepAlive | null;
71
+ }>;
72
+ groupHistories?: Map<string, HistoryEntry[]>;
73
+ sessionStoreReader?: SessionStoreReader;
74
+ allowTextCommands?: boolean;
75
+ isControlCommand?: (content: string) => boolean;
76
+ resolveGroupIntroHint?: (params: {
77
+ cfg: unknown;
78
+ accountId: string;
79
+ groupId: string;
80
+ }) => string | undefined;
81
+ adapters: EngineAdapters;
82
+ }
@@ -0,0 +1,171 @@
1
+ import type { HistoryPort } from "../adapter/history.port.js";
2
+ import type { HistoryEntry } from "../group/history.js";
3
+ import { processAttachments } from "./inbound-attachments.js";
4
+ import type { InboundContext, InboundPipelineDeps } from "./inbound-context.js";
5
+ import type { QueuedMessage } from "./message-queue.js";
6
+ import {
7
+ buildAgentBody,
8
+ buildBody,
9
+ buildDynamicCtx,
10
+ buildGroupSystemPrompt,
11
+ buildQuotePart,
12
+ buildSkippedInboundContext,
13
+ buildUserContent,
14
+ buildUserMessage,
15
+ classifyMedia,
16
+ resolveQuote,
17
+ runAccessStage,
18
+ runGroupGateStage,
19
+ writeRefIndex,
20
+ } from "./stages/index.js";
21
+
22
+ export async function buildInboundContext(
23
+ event: QueuedMessage,
24
+ deps: InboundPipelineDeps,
25
+ ): Promise<InboundContext> {
26
+ const { account, log } = deps;
27
+
28
+ const accessResult = await runAccessStage(event, deps);
29
+ if (accessResult.kind === "block") {
30
+ return accessResult.context;
31
+ }
32
+ const { isGroupChat, peerId, qualifiedTarget, fromAddress, route, access } = accessResult;
33
+
34
+ const typingPromise = deps.startTyping(event);
35
+
36
+ const processed = await processAttachments(event.attachments, {
37
+ accountId: account.accountId,
38
+ cfg: deps.cfg,
39
+ audioConvert: deps.adapters.audioConvert,
40
+ log,
41
+ });
42
+
43
+ const { parsedContent, userContent } = buildUserContent({
44
+ event,
45
+ attachmentInfo: processed.attachmentInfo,
46
+ voiceTranscripts: processed.voiceTranscripts,
47
+ });
48
+
49
+ const replyTo = await resolveQuote(event, deps);
50
+
51
+ const typingResult = await typingPromise;
52
+ writeRefIndex({
53
+ event,
54
+ parsedContent,
55
+ processed,
56
+ inputNotifyRefIdx: typingResult.refIdx,
57
+ });
58
+
59
+ let groupInfo: InboundContext["group"];
60
+ if (event.type === "group" && event.groupOpenid) {
61
+ const gateOutcome = runGroupGateStage({
62
+ event,
63
+ deps,
64
+ accountId: account.accountId,
65
+ agentId: route.agentId,
66
+ sessionKey: route.sessionKey,
67
+ userContent,
68
+ processedAttachments: processed,
69
+ access,
70
+ });
71
+
72
+ if (gateOutcome.kind === "skip") {
73
+ typingResult.keepAlive?.stop();
74
+ return buildSkippedInboundContext({
75
+ event,
76
+ route,
77
+ isGroupChat: true,
78
+ peerId,
79
+ qualifiedTarget,
80
+ fromAddress,
81
+ group: gateOutcome.groupInfo,
82
+ skipReason: gateOutcome.skipReason,
83
+ access,
84
+ typing: { keepAlive: typingResult.keepAlive },
85
+ inputNotifyRefIdx: typingResult.refIdx,
86
+ });
87
+ }
88
+ groupInfo = gateOutcome.groupInfo;
89
+ }
90
+
91
+ const body = buildBody({
92
+ event,
93
+ deps,
94
+ userContent,
95
+ isGroupChat,
96
+ imageUrls: processed.imageUrls,
97
+ });
98
+ const quotePart = buildQuotePart(replyTo);
99
+ const media = classifyMedia(processed);
100
+ const dynamicCtx = buildDynamicCtx({
101
+ imageUrls: processed.imageUrls,
102
+ uniqueVoicePaths: media.uniqueVoicePaths,
103
+ uniqueVoiceUrls: media.uniqueVoiceUrls,
104
+ uniqueVoiceAsrReferTexts: media.uniqueVoiceAsrReferTexts,
105
+ });
106
+
107
+ const userMessage = buildUserMessage({
108
+ event,
109
+ userContent,
110
+ quotePart,
111
+ isGroupChat,
112
+ groupInfo,
113
+ });
114
+ const agentBody = buildAgentBody({
115
+ event,
116
+ userContent,
117
+ userMessage,
118
+ dynamicCtx,
119
+ isGroupChat,
120
+ groupInfo,
121
+ deps,
122
+ });
123
+
124
+ const accountSystemInstruction = account.systemPrompt ?? "";
125
+ const groupSystemPrompt = buildGroupSystemPrompt(accountSystemInstruction, groupInfo);
126
+
127
+ return {
128
+ event,
129
+ route,
130
+ isGroupChat,
131
+ peerId,
132
+ qualifiedTarget,
133
+ fromAddress,
134
+ agentBody,
135
+ body,
136
+ groupSystemPrompt,
137
+ localMediaPaths: media.localMediaPaths,
138
+ localMediaTypes: media.localMediaTypes,
139
+ remoteMediaUrls: media.remoteMediaUrls,
140
+ uniqueVoicePaths: media.uniqueVoicePaths,
141
+ uniqueVoiceUrls: media.uniqueVoiceUrls,
142
+ uniqueVoiceAsrReferTexts: media.uniqueVoiceAsrReferTexts,
143
+ voiceMediaTypes: media.voiceMediaTypes,
144
+ hasAsrReferFallback: media.hasAsrReferFallback,
145
+ voiceTranscriptSources: media.voiceTranscriptSources,
146
+ replyTo,
147
+ commandAuthorized: access.commandAccess.authorized,
148
+ group: groupInfo,
149
+ blocked: false,
150
+ skipped: false,
151
+ accessDecision: access.senderAccess.decision,
152
+ typing: { keepAlive: typingResult.keepAlive },
153
+ inputNotifyRefIdx: typingResult.refIdx,
154
+ };
155
+ }
156
+
157
+ export function clearGroupPendingHistory(params: {
158
+ historyMap: Map<string, HistoryEntry[]> | undefined;
159
+ groupOpenid: string | undefined;
160
+ historyLimit: number;
161
+ historyPort: HistoryPort;
162
+ }): void {
163
+ if (!params.historyMap || !params.groupOpenid) {
164
+ return;
165
+ }
166
+ params.historyPort.clearPendingHistory({
167
+ historyMap: params.historyMap,
168
+ historyKey: params.groupOpenid,
169
+ limit: params.historyLimit,
170
+ });
171
+ }