@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,210 @@
1
+ /**
2
+ * Unified media-source abstraction for the QQ Bot upload pipeline.
3
+ *
4
+ * All rich-media entry points (sender.ts#sendMedia, outbound.ts#send*,
5
+ * reply-dispatcher.ts#handle*Payload) funnel through {@link normalizeSource}
6
+ * before reaching the low-level {@link MediaApi}.
7
+ *
8
+ * ## Why four branches?
9
+ *
10
+ * - `url` — remote http(s) URL that the QQ server can fetch directly.
11
+ * - `base64` — in-memory base64 string (typically from a `data:` URL).
12
+ * - `localPath` — on-disk file; kept as a path plus an optional verified
13
+ * descriptor so uploaders can avoid reopening a path after validation.
14
+ * - `buffer` — in-memory raw bytes (e.g. TTS output, downloaded url-fallback).
15
+ *
16
+ * ## Security baseline (localPath branch)
17
+ *
18
+ * `openLocalFile` is the single canonical implementation of "safely open a
19
+ * local file for upload" across the plugin. It merges the previously
20
+ * inconsistent strategies from `reply-dispatcher.ts` (O_NOFOLLOW + size check)
21
+ * and `outbound.ts` (realpath + root containment). Callers are still
22
+ * responsible for *root-whitelist* validation (via
23
+ * `resolveQQBotPayloadLocalFilePath` / `resolveOutboundMediaPath`) before
24
+ * passing the path in; this function enforces *file-level* safety only.
25
+ *
26
+ * Chunked upload is not implemented in this PR, but the contract here already
27
+ * returns `size` metadata so `sendMediaInternal` can route by size without
28
+ * reading the whole file first.
29
+ */
30
+
31
+ import type { FileHandle } from "node:fs/promises";
32
+ import { FsSafeError, openLocalFileSafely } from "autobot/plugin-sdk/security-runtime";
33
+ import { MAX_UPLOAD_SIZE, formatFileSize, getMimeType } from "../utils/file-utils.js";
34
+
35
+ // ============ Types ============
36
+
37
+ /**
38
+ * Fully normalized media source. Downstream uploaders switch on `kind`.
39
+ *
40
+ * - `url`: remote URL — upload via `file_data=null; url=...`.
41
+ * - `base64`: already-encoded base64 — upload via `file_data=...`.
42
+ * - `localPath`: on-disk file — uploaders should prefer `opened` when present
43
+ * and only reopen `path` for direct, already-normalized test/helper calls.
44
+ * - `buffer`: raw bytes in memory — same as above minus disk I/O.
45
+ */
46
+ export type MediaSource =
47
+ | { kind: "url"; url: string }
48
+ | { kind: "base64"; data: string; mime?: string }
49
+ | { kind: "localPath"; path: string; size: number; mime?: string; opened?: OpenedLocalFile }
50
+ | { kind: "buffer"; buffer: Buffer; fileName?: string; mime?: string };
51
+
52
+ /**
53
+ * Untyped media source accepted from callers.
54
+ *
55
+ * `url` may be either a remote `http(s)://...` URL or a `data:<mime>;base64,...`
56
+ * data URL — {@link normalizeSource} transparently resolves the latter to a
57
+ * `base64` branch.
58
+ */
59
+ export type RawMediaSource =
60
+ | { url: string }
61
+ | { base64: string; mime?: string }
62
+ | { localPath: string }
63
+ | { buffer: Buffer; fileName?: string; mime?: string };
64
+
65
+ // ============ data: URL ============
66
+
67
+ const DATA_URL_RE = /^data:([^;,]+);base64,(.+)$/i;
68
+
69
+ /**
70
+ * Parse a `data:<mime>;base64,<payload>` URL.
71
+ *
72
+ * Returns `null` when the string is not a data URL or does not declare
73
+ * base64 encoding. Non-base64 data URLs are intentionally rejected because
74
+ * the QQ upload API ingests raw base64, not arbitrary URL-encoded payloads.
75
+ */
76
+ function tryParseDataUrl(value: string): { mime: string; data: string } | null {
77
+ if (!value.startsWith("data:")) {
78
+ return null;
79
+ }
80
+ const m = value.match(DATA_URL_RE);
81
+ if (!m) {
82
+ return null;
83
+ }
84
+ return { mime: m[1], data: m[2] };
85
+ }
86
+
87
+ // ============ Local file safe open ============
88
+
89
+ /**
90
+ * Opened handle to a local file, with metadata already validated against
91
+ * QQ upload limits.
92
+ *
93
+ * Callers MUST call {@link OpenedLocalFile.close} (typically in a `finally`).
94
+ */
95
+ export interface OpenedLocalFile {
96
+ handle: FileHandle;
97
+ size: number;
98
+ close(): Promise<void>;
99
+ }
100
+
101
+ /**
102
+ * Open a local file for upload with defense-in-depth:
103
+ *
104
+ * 1. `O_NOFOLLOW` refuses to traverse symlinks (prevents post-whitelist
105
+ * symlink swaps / TOCTOU attacks).
106
+ * 2. `fstat` on the opened descriptor — NOT `fs.stat` on the path —
107
+ * so the size check applies to the exact byte stream we will read.
108
+ * 3. Rejects non-regular files (sockets / devices / directories).
109
+ * 4. Enforces a caller-specified `maxSize` (default {@link MAX_UPLOAD_SIZE})
110
+ * at open time, so oversized files fail fast without allocating a
111
+ * full buffer. Chunked upload callers should pass a larger ceiling
112
+ * (e.g. `CHUNKED_UPLOAD_MAX_SIZE` from `utils/file-utils.js`).
113
+ *
114
+ * The caller receives the open handle plus validated size and is expected
115
+ * to either {@link OpenedLocalFile.handle.readFile} (one-shot path) or
116
+ * stream via `fs.createReadStream` (chunked path).
117
+ */
118
+ export async function openLocalFile(
119
+ filePath: string,
120
+ opts: { maxSize?: number } = {},
121
+ ): Promise<OpenedLocalFile> {
122
+ const maxSize = opts.maxSize ?? MAX_UPLOAD_SIZE;
123
+ const opened = await openLocalFileSafely({ filePath }).catch((err: unknown) => {
124
+ if (err instanceof FsSafeError && err.code === "not-file") {
125
+ throw new Error("Path is not a regular file", { cause: err });
126
+ }
127
+ throw err;
128
+ });
129
+ try {
130
+ if (opened.stat.size > maxSize) {
131
+ throw new Error(
132
+ `File is too large (${formatFileSize(opened.stat.size)}); QQ Bot API limit is ${formatFileSize(maxSize)}`,
133
+ );
134
+ }
135
+ return {
136
+ handle: opened.handle,
137
+ size: opened.stat.size,
138
+ close: () => opened.handle.close(),
139
+ };
140
+ } catch (err) {
141
+ // Close the handle on any validation failure to avoid fd leaks.
142
+ await opened.handle.close().catch(() => undefined);
143
+ throw err;
144
+ }
145
+ }
146
+
147
+ // ============ Normalization ============
148
+
149
+ /**
150
+ * Normalize a {@link RawMediaSource} into a {@link MediaSource}.
151
+ *
152
+ * - Strings passed via `{ url }` that start with `data:` are auto-resolved
153
+ * to a `base64` branch (this is the unified `data:` URL support that was
154
+ * previously only implemented in `sendImage`).
155
+ * - `localPath` branches open the file with {@link openLocalFile} and carry
156
+ * that descriptor to the uploader, so later reads use the exact file that
157
+ * passed regular-file / O_NOFOLLOW / size validation.
158
+ * - `buffer` branches enforce the same ceiling inline.
159
+ *
160
+ * `maxSize` defaults to {@link MAX_UPLOAD_SIZE} (20MB, one-shot upload limit).
161
+ * Callers that dispatch to the chunked uploader should pass a larger ceiling
162
+ * (e.g. `CHUNKED_UPLOAD_MAX_SIZE`, or a value derived from
163
+ * `getMaxUploadSize(fileType)`).
164
+ *
165
+ * NOTE: Root-whitelist validation (i.e. "this path must live under the
166
+ * allowed QQ Bot media directory") is a caller concern. This function
167
+ * assumes the path has already passed such checks.
168
+ */
169
+ export async function normalizeSource(
170
+ raw: RawMediaSource,
171
+ opts: { maxSize?: number } = {},
172
+ ): Promise<MediaSource> {
173
+ const maxSize = opts.maxSize ?? MAX_UPLOAD_SIZE;
174
+
175
+ if ("url" in raw) {
176
+ const parsed = tryParseDataUrl(raw.url);
177
+ if (parsed) {
178
+ return { kind: "base64", data: parsed.data, mime: parsed.mime };
179
+ }
180
+ return { kind: "url", url: raw.url };
181
+ }
182
+
183
+ if ("base64" in raw) {
184
+ return { kind: "base64", data: raw.base64, mime: raw.mime };
185
+ }
186
+
187
+ if ("localPath" in raw) {
188
+ const opened = await openLocalFile(raw.localPath, { maxSize });
189
+ return {
190
+ kind: "localPath",
191
+ path: raw.localPath,
192
+ size: opened.size,
193
+ mime: getMimeType(raw.localPath),
194
+ opened,
195
+ };
196
+ }
197
+
198
+ // buffer branch
199
+ if (raw.buffer.length > maxSize) {
200
+ throw new Error(
201
+ `Buffer is too large (${formatFileSize(raw.buffer.length)}); QQ Bot API limit is ${formatFileSize(maxSize)}`,
202
+ );
203
+ }
204
+ return {
205
+ kind: "buffer",
206
+ buffer: raw.buffer,
207
+ fileName: raw.fileName,
208
+ mime: raw.mime,
209
+ };
210
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Media type detection — pure functions for classifying files by MIME or extension.
3
+ *
4
+ * These replace the inline `isImageFile` and `isVideoFile` helpers scattered
5
+ * across `outbound.ts`. Centralizing them here keeps detection consistent.
6
+ */
7
+
8
+ import { getFileExtension } from "autobot/plugin-sdk/media-mime";
9
+
10
+ const IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"]);
11
+ const VIDEO_EXTENSIONS = new Set([".mp4", ".mov", ".avi", ".mkv", ".webm", ".flv", ".wmv"]);
12
+
13
+ /** Check whether a file is an image using MIME first and extension as fallback. */
14
+ export function isImageFile(filePath: string, mimeType?: string): boolean {
15
+ if (mimeType?.startsWith("image/")) {
16
+ return true;
17
+ }
18
+ return IMAGE_EXTENSIONS.has(getFileExtension(filePath) ?? "");
19
+ }
20
+
21
+ /** Check whether a file is a video using MIME first and extension as fallback. */
22
+ export function isVideoFile(filePath: string, mimeType?: string): boolean {
23
+ if (mimeType?.startsWith("video/")) {
24
+ return true;
25
+ }
26
+ return VIDEO_EXTENSIONS.has(getFileExtension(filePath) ?? "");
27
+ }
@@ -0,0 +1,38 @@
1
+ import type { OutboundAudioPort } from "../adapter/audio.port.js";
2
+
3
+ let outboundAudioPort: OutboundAudioPort | null = null;
4
+
5
+ /**
6
+ * Initialize the outbound audio adapter. Called once by gateway startup
7
+ * via `adapters.outboundAudio`.
8
+ */
9
+ export function setOutboundAudioPort(port: OutboundAudioPort): void {
10
+ outboundAudioPort = port;
11
+ }
12
+
13
+ function getAudio(): OutboundAudioPort {
14
+ if (!outboundAudioPort) {
15
+ throw new Error("OutboundAudioPort not initialized — call setOutboundAudioPort first");
16
+ }
17
+ return outboundAudioPort;
18
+ }
19
+
20
+ export function audioFileToSilkBase64(p: string, f?: string[]): Promise<string | undefined> {
21
+ return getAudio().audioFileToSilkBase64(p, f);
22
+ }
23
+
24
+ export function isAudioFile(p: string, m?: string): boolean {
25
+ try {
26
+ return getAudio().isAudioFile(p, m);
27
+ } catch {
28
+ return false;
29
+ }
30
+ }
31
+
32
+ export function shouldTranscodeVoice(p: string): boolean {
33
+ return getAudio().shouldTranscodeVoice(p);
34
+ }
35
+
36
+ export function waitForFile(p: string, ms?: number): Promise<number> {
37
+ return getAudio().waitForFile(p, ms);
38
+ }