@openclaw/qqbot 2026.5.1-beta.1

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 (187) hide show
  1. package/api.ts +56 -0
  2. package/channel-plugin-api.ts +1 -0
  3. package/dist/.boundary-tsc.stamp +1 -0
  4. package/dist/.boundary-tsc.tsbuildinfo +1 -0
  5. package/index.ts +29 -0
  6. package/openclaw.plugin.json +814 -0
  7. package/package.json +66 -0
  8. package/runtime-api.ts +9 -0
  9. package/setup-entry.ts +9 -0
  10. package/setup-plugin-api.ts +3 -0
  11. package/skills/qqbot-channel/SKILL.md +262 -0
  12. package/skills/qqbot-channel/references/api_references.md +521 -0
  13. package/skills/qqbot-media/SKILL.md +37 -0
  14. package/skills/qqbot-remind/SKILL.md +153 -0
  15. package/src/bridge/approval/capability.ts +237 -0
  16. package/src/bridge/approval/handler-runtime.ts +204 -0
  17. package/src/bridge/bootstrap.ts +135 -0
  18. package/src/bridge/channel-entry.ts +18 -0
  19. package/src/bridge/commands/framework-context-adapter.ts +60 -0
  20. package/src/bridge/commands/framework-registration.ts +47 -0
  21. package/src/bridge/commands/from-parser.test.ts +86 -0
  22. package/src/bridge/commands/from-parser.ts +60 -0
  23. package/src/bridge/commands/result-dispatcher.ts +76 -0
  24. package/src/bridge/config-shared.ts +132 -0
  25. package/src/bridge/config.ts +111 -0
  26. package/src/bridge/gateway.ts +174 -0
  27. package/src/bridge/logger.ts +31 -0
  28. package/src/bridge/narrowing.ts +31 -0
  29. package/src/bridge/plugin-version.test.ts +146 -0
  30. package/src/bridge/plugin-version.ts +102 -0
  31. package/src/bridge/runtime.ts +25 -0
  32. package/src/bridge/sdk-adapter.ts +131 -0
  33. package/src/bridge/setup/finalize.ts +144 -0
  34. package/src/bridge/setup/surface.ts +34 -0
  35. package/src/bridge/tools/channel.ts +58 -0
  36. package/src/bridge/tools/index.ts +15 -0
  37. package/src/bridge/tools/remind.test.ts +124 -0
  38. package/src/bridge/tools/remind.ts +91 -0
  39. package/src/channel.setup.ts +33 -0
  40. package/src/channel.ts +288 -0
  41. package/src/command-auth.test.ts +62 -0
  42. package/src/config-schema.ts +84 -0
  43. package/src/config.test.ts +364 -0
  44. package/src/engine/access/access-control.test.ts +198 -0
  45. package/src/engine/access/access-control.ts +226 -0
  46. package/src/engine/access/index.ts +16 -0
  47. package/src/engine/access/resolve-policy.test.ts +59 -0
  48. package/src/engine/access/resolve-policy.ts +57 -0
  49. package/src/engine/access/sender-match.test.ts +60 -0
  50. package/src/engine/access/sender-match.ts +55 -0
  51. package/src/engine/access/types.ts +53 -0
  52. package/src/engine/adapter/audio.port.ts +27 -0
  53. package/src/engine/adapter/commands.port.ts +22 -0
  54. package/src/engine/adapter/history.port.ts +52 -0
  55. package/src/engine/adapter/index.ts +139 -0
  56. package/src/engine/adapter/mention-gate.port.ts +50 -0
  57. package/src/engine/adapter/types.ts +38 -0
  58. package/src/engine/api/api-client.ts +212 -0
  59. package/src/engine/api/media-chunked.test.ts +336 -0
  60. package/src/engine/api/media-chunked.ts +622 -0
  61. package/src/engine/api/media.ts +218 -0
  62. package/src/engine/api/messages.ts +293 -0
  63. package/src/engine/api/retry.ts +217 -0
  64. package/src/engine/api/routes.ts +95 -0
  65. package/src/engine/api/token.ts +271 -0
  66. package/src/engine/approval/index.test.ts +22 -0
  67. package/src/engine/approval/index.ts +224 -0
  68. package/src/engine/commands/builtin/log-helpers.ts +319 -0
  69. package/src/engine/commands/builtin/register-all.ts +17 -0
  70. package/src/engine/commands/builtin/register-approve.ts +201 -0
  71. package/src/engine/commands/builtin/register-basic.ts +95 -0
  72. package/src/engine/commands/builtin/register-clear-storage.ts +187 -0
  73. package/src/engine/commands/builtin/register-logs.ts +20 -0
  74. package/src/engine/commands/builtin/register-streaming.ts +137 -0
  75. package/src/engine/commands/builtin/state.ts +31 -0
  76. package/src/engine/commands/slash-command-auth.ts +48 -0
  77. package/src/engine/commands/slash-command-handler.ts +146 -0
  78. package/src/engine/commands/slash-commands-impl.test.ts +8 -0
  79. package/src/engine/commands/slash-commands-impl.ts +61 -0
  80. package/src/engine/commands/slash-commands.ts +199 -0
  81. package/src/engine/config/credential-backup.test.ts +88 -0
  82. package/src/engine/config/credential-backup.ts +107 -0
  83. package/src/engine/config/credentials.ts +76 -0
  84. package/src/engine/config/group.test.ts +234 -0
  85. package/src/engine/config/group.ts +299 -0
  86. package/src/engine/config/resolve.test.ts +152 -0
  87. package/src/engine/config/resolve.ts +283 -0
  88. package/src/engine/config/setup-logic.ts +84 -0
  89. package/src/engine/engine-import-boundary.test.ts +73 -0
  90. package/src/engine/gateway/codec.ts +47 -0
  91. package/src/engine/gateway/constants.ts +117 -0
  92. package/src/engine/gateway/event-dispatcher.ts +177 -0
  93. package/src/engine/gateway/gateway-connection.ts +371 -0
  94. package/src/engine/gateway/gateway.ts +291 -0
  95. package/src/engine/gateway/inbound-attachments.test.ts +126 -0
  96. package/src/engine/gateway/inbound-attachments.ts +360 -0
  97. package/src/engine/gateway/inbound-context.ts +195 -0
  98. package/src/engine/gateway/inbound-pipeline.self-echo.test.ts +218 -0
  99. package/src/engine/gateway/inbound-pipeline.ts +235 -0
  100. package/src/engine/gateway/interaction-handler.ts +220 -0
  101. package/src/engine/gateway/message-queue.test.ts +282 -0
  102. package/src/engine/gateway/message-queue.ts +499 -0
  103. package/src/engine/gateway/outbound-dispatch.test.ts +231 -0
  104. package/src/engine/gateway/outbound-dispatch.ts +575 -0
  105. package/src/engine/gateway/reconnect.ts +199 -0
  106. package/src/engine/gateway/stages/access-stage.ts +132 -0
  107. package/src/engine/gateway/stages/assembly-stage.ts +156 -0
  108. package/src/engine/gateway/stages/content-stage.test.ts +77 -0
  109. package/src/engine/gateway/stages/content-stage.ts +77 -0
  110. package/src/engine/gateway/stages/envelope-stage.test.ts +152 -0
  111. package/src/engine/gateway/stages/envelope-stage.ts +144 -0
  112. package/src/engine/gateway/stages/group-gate-stage.ts +292 -0
  113. package/src/engine/gateway/stages/index.ts +18 -0
  114. package/src/engine/gateway/stages/quote-stage.ts +113 -0
  115. package/src/engine/gateway/stages/refidx-stage.ts +62 -0
  116. package/src/engine/gateway/stages/stub-contexts.ts +116 -0
  117. package/src/engine/gateway/types.ts +264 -0
  118. package/src/engine/gateway/typing-keepalive.ts +79 -0
  119. package/src/engine/group/activation.test.ts +114 -0
  120. package/src/engine/group/activation.ts +147 -0
  121. package/src/engine/group/history.test.ts +314 -0
  122. package/src/engine/group/history.ts +321 -0
  123. package/src/engine/group/mention.test.ts +141 -0
  124. package/src/engine/group/mention.ts +197 -0
  125. package/src/engine/group/message-gating.test.ts +188 -0
  126. package/src/engine/group/message-gating.ts +216 -0
  127. package/src/engine/messaging/decode-media-path.ts +82 -0
  128. package/src/engine/messaging/media-source.ts +215 -0
  129. package/src/engine/messaging/media-type-detect.ts +37 -0
  130. package/src/engine/messaging/outbound-audio-port.ts +38 -0
  131. package/src/engine/messaging/outbound-deliver.ts +810 -0
  132. package/src/engine/messaging/outbound-media-send.ts +702 -0
  133. package/src/engine/messaging/outbound-reply.ts +27 -0
  134. package/src/engine/messaging/outbound-result-helpers.ts +54 -0
  135. package/src/engine/messaging/outbound-types.ts +45 -0
  136. package/src/engine/messaging/outbound.ts +485 -0
  137. package/src/engine/messaging/reply-dispatcher.ts +597 -0
  138. package/src/engine/messaging/reply-limiter.ts +164 -0
  139. package/src/engine/messaging/sender.ts +729 -0
  140. package/src/engine/messaging/streaming-c2c.ts +1192 -0
  141. package/src/engine/messaging/streaming-media-send.ts +544 -0
  142. package/src/engine/messaging/target-parser.ts +104 -0
  143. package/src/engine/ref/format-message-ref.ts +142 -0
  144. package/src/engine/ref/format-ref-entry.test.ts +60 -0
  145. package/src/engine/ref/format-ref-entry.ts +27 -0
  146. package/src/engine/ref/store.ts +224 -0
  147. package/src/engine/ref/types.ts +27 -0
  148. package/src/engine/session/known-users.ts +254 -0
  149. package/src/engine/session/session-store.ts +284 -0
  150. package/src/engine/tools/channel-api.ts +244 -0
  151. package/src/engine/tools/remind-logic.test.ts +280 -0
  152. package/src/engine/tools/remind-logic.ts +377 -0
  153. package/src/engine/types.ts +313 -0
  154. package/src/engine/utils/attachment-tags.test.ts +186 -0
  155. package/src/engine/utils/attachment-tags.ts +174 -0
  156. package/src/engine/utils/audio.test.ts +250 -0
  157. package/src/engine/utils/audio.ts +585 -0
  158. package/src/engine/utils/data-paths.ts +38 -0
  159. package/src/engine/utils/diagnostics.ts +109 -0
  160. package/src/engine/utils/file-utils.test.ts +72 -0
  161. package/src/engine/utils/file-utils.ts +225 -0
  162. package/src/engine/utils/format.test.ts +68 -0
  163. package/src/engine/utils/format.ts +70 -0
  164. package/src/engine/utils/image-size.test.ts +158 -0
  165. package/src/engine/utils/image-size.ts +249 -0
  166. package/src/engine/utils/log.test.ts +28 -0
  167. package/src/engine/utils/log.ts +61 -0
  168. package/src/engine/utils/media-tags.test.ts +32 -0
  169. package/src/engine/utils/media-tags.ts +177 -0
  170. package/src/engine/utils/payload.test.ts +68 -0
  171. package/src/engine/utils/payload.ts +145 -0
  172. package/src/engine/utils/platform-storage-laziness.test.ts +65 -0
  173. package/src/engine/utils/platform.test.ts +148 -0
  174. package/src/engine/utils/platform.ts +343 -0
  175. package/src/engine/utils/request-context.ts +60 -0
  176. package/src/engine/utils/string-normalize.ts +91 -0
  177. package/src/engine/utils/stt.test.ts +104 -0
  178. package/src/engine/utils/stt.ts +100 -0
  179. package/src/engine/utils/text-parsing.test.ts +29 -0
  180. package/src/engine/utils/text-parsing.ts +155 -0
  181. package/src/engine/utils/upload-cache.ts +96 -0
  182. package/src/engine/utils/voice-text.ts +15 -0
  183. package/src/exec-approvals.ts +218 -0
  184. package/src/manifest-schema.test.ts +56 -0
  185. package/src/qqbot-test-support.ts +29 -0
  186. package/src/types.ts +210 -0
  187. package/tsconfig.json +16 -0
@@ -0,0 +1,188 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ resolveGroupMessageGate,
4
+ type GroupMessageGateInput,
5
+ type GroupMessageGateResult,
6
+ } from "./message-gating.js";
7
+
8
+ // Compose a full input so each test can override just the interesting axis.
9
+ function input(overrides: Partial<GroupMessageGateInput>): GroupMessageGateInput {
10
+ return {
11
+ ignoreOtherMentions: false,
12
+ hasAnyMention: false,
13
+ wasMentioned: false,
14
+ implicitMention: false,
15
+ allowTextCommands: true,
16
+ isControlCommand: false,
17
+ commandAuthorized: false,
18
+ requireMention: true,
19
+ canDetectMention: true,
20
+ ...overrides,
21
+ };
22
+ }
23
+
24
+ function expectAction(
25
+ result: GroupMessageGateResult,
26
+ action: GroupMessageGateResult["action"],
27
+ ): void {
28
+ expect(result.action).toBe(action);
29
+ }
30
+
31
+ describe("engine/group/message-gating", () => {
32
+ describe("Layer 1: ignoreOtherMentions", () => {
33
+ it("drops messages that @other users when enabled", () => {
34
+ const result = resolveGroupMessageGate(
35
+ input({ ignoreOtherMentions: true, hasAnyMention: true }),
36
+ );
37
+ expectAction(result, "drop_other_mention");
38
+ });
39
+
40
+ it("does NOT drop when the bot itself was @-ed", () => {
41
+ const result = resolveGroupMessageGate(
42
+ input({ ignoreOtherMentions: true, hasAnyMention: true, wasMentioned: true }),
43
+ );
44
+ expectAction(result, "pass");
45
+ });
46
+
47
+ it("does NOT drop when implicitly mentioned via quote", () => {
48
+ const result = resolveGroupMessageGate(
49
+ input({ ignoreOtherMentions: true, hasAnyMention: true, implicitMention: true }),
50
+ );
51
+ expectAction(result, "pass");
52
+ });
53
+
54
+ it("is inactive when ignoreOtherMentions is off", () => {
55
+ const result = resolveGroupMessageGate(
56
+ input({ ignoreOtherMentions: false, hasAnyMention: true }),
57
+ );
58
+ // Falls through to mention gate — requireMention on, so skipped.
59
+ expectAction(result, "skip_no_mention");
60
+ });
61
+ });
62
+
63
+ describe("Layer 2: unauthorized control command", () => {
64
+ it("silently blocks an unauthorized /stop", () => {
65
+ const result = resolveGroupMessageGate(
66
+ input({ isControlCommand: true, commandAuthorized: false }),
67
+ );
68
+ expectAction(result, "block_unauthorized_command");
69
+ });
70
+
71
+ it("passes through when sender is authorized", () => {
72
+ const result = resolveGroupMessageGate(
73
+ input({ isControlCommand: true, commandAuthorized: true, wasMentioned: true }),
74
+ );
75
+ expectAction(result, "pass");
76
+ });
77
+
78
+ it("does not trigger when text commands are disabled", () => {
79
+ const result = resolveGroupMessageGate(
80
+ input({
81
+ allowTextCommands: false,
82
+ isControlCommand: true,
83
+ commandAuthorized: false,
84
+ wasMentioned: true,
85
+ }),
86
+ );
87
+ // allowTextCommands=false skips the block, so the mention gate decides.
88
+ expectAction(result, "pass");
89
+ });
90
+ });
91
+
92
+ describe("Layer 3: mention gating", () => {
93
+ it("requires @bot when requireMention is on", () => {
94
+ const result = resolveGroupMessageGate(input({ requireMention: true }));
95
+ expectAction(result, "skip_no_mention");
96
+ expect(result.effectiveWasMentioned).toBe(false);
97
+ });
98
+
99
+ it("passes through when explicitly mentioned", () => {
100
+ const result = resolveGroupMessageGate(input({ requireMention: true, wasMentioned: true }));
101
+ expectAction(result, "pass");
102
+ expect(result.effectiveWasMentioned).toBe(true);
103
+ });
104
+
105
+ it("passes through on implicit mention", () => {
106
+ const result = resolveGroupMessageGate(
107
+ input({ requireMention: true, implicitMention: true }),
108
+ );
109
+ expectAction(result, "pass");
110
+ expect(result.effectiveWasMentioned).toBe(true);
111
+ });
112
+
113
+ it("passes through when requireMention is off", () => {
114
+ const result = resolveGroupMessageGate(input({ requireMention: false }));
115
+ expectAction(result, "pass");
116
+ });
117
+
118
+ it("passes through when mention cannot be detected (DMs)", () => {
119
+ const result = resolveGroupMessageGate(
120
+ input({ requireMention: true, canDetectMention: false }),
121
+ );
122
+ expectAction(result, "pass");
123
+ });
124
+ });
125
+
126
+ describe("command bypass", () => {
127
+ it("bypasses mention gate for an authorized control command", () => {
128
+ const result = resolveGroupMessageGate(
129
+ input({
130
+ requireMention: true,
131
+ isControlCommand: true,
132
+ commandAuthorized: true,
133
+ allowTextCommands: true,
134
+ }),
135
+ );
136
+ expectAction(result, "pass");
137
+ expect(result.shouldBypassMention).toBe(true);
138
+ expect(result.effectiveWasMentioned).toBe(true);
139
+ });
140
+
141
+ it("does NOT bypass when the command @-s another user", () => {
142
+ const result = resolveGroupMessageGate(
143
+ input({
144
+ requireMention: true,
145
+ isControlCommand: true,
146
+ commandAuthorized: true,
147
+ hasAnyMention: true,
148
+ }),
149
+ );
150
+ expectAction(result, "skip_no_mention");
151
+ expect(result.shouldBypassMention).toBe(false);
152
+ });
153
+
154
+ it("is a no-op when requireMention is off", () => {
155
+ const result = resolveGroupMessageGate(
156
+ input({
157
+ requireMention: false,
158
+ isControlCommand: true,
159
+ commandAuthorized: true,
160
+ }),
161
+ );
162
+ expectAction(result, "pass");
163
+ // requireMention=false means bypass is unnecessary (condition 1 fails).
164
+ expect(result.shouldBypassMention).toBe(false);
165
+ });
166
+ });
167
+
168
+ describe("priority ordering", () => {
169
+ it("layer 1 wins over layer 2 (ignoreOtherMentions before block)", () => {
170
+ const result = resolveGroupMessageGate(
171
+ input({
172
+ ignoreOtherMentions: true,
173
+ hasAnyMention: true,
174
+ isControlCommand: true,
175
+ commandAuthorized: false,
176
+ }),
177
+ );
178
+ expectAction(result, "drop_other_mention");
179
+ });
180
+
181
+ it("layer 2 wins over layer 3 (unauthorized command before skip)", () => {
182
+ const result = resolveGroupMessageGate(
183
+ input({ requireMention: true, isControlCommand: true, commandAuthorized: false }),
184
+ );
185
+ expectAction(result, "block_unauthorized_command");
186
+ });
187
+ });
188
+ });
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Group message gate — unified entry point for group inbound gating.
3
+ *
4
+ * Collapses three orthogonal rules that previously lived in ad-hoc spots
5
+ * of the standalone gateway into a single pure function. Callers pass in
6
+ * the message's mention state plus the resolved configuration, and get
7
+ * back a structured action telling them how to handle the message.
8
+ *
9
+ * Evaluation order (short-circuit at the first match):
10
+ * 1. `ignoreOtherMentions` — message @-s someone else but not the bot
11
+ * → `drop_other_mention` (record to history,
12
+ * then drop). Implicit mentions (e.g. quoting
13
+ * a bot reply) still count as @bot.
14
+ * 2. `block_unauthorized_command` — sender is not allowed to run control
15
+ * commands (text starts with `/xxx`)
16
+ * → silently drop.
17
+ * 3. `mention gating` — when `requireMention` is on, non-@bot messages
18
+ * are `skip_no_mention`'d (still buffered to
19
+ * history). Authorized control commands can
20
+ * **bypass** the gate as long as the message does
21
+ * not @anyone else at the same time.
22
+ * 4. Otherwise → `pass` (the message will reach the AI pipeline).
23
+ *
24
+ * All inputs are plain data; there is no I/O and no mutation, so the
25
+ * function is safe to share between the built-in and standalone builds.
26
+ */
27
+
28
+ // ────────────────────── Types ──────────────────────
29
+
30
+ /**
31
+ * Structured action returned by {@link resolveGroupMessageGate}.
32
+ *
33
+ * - `drop_other_mention` — message @-s another user but not the bot;
34
+ * record to the group history cache and
35
+ * drop without hitting the AI.
36
+ * - `block_unauthorized_command` — silently refuse a control command from
37
+ * an unauthorized sender (no history
38
+ * write, no AI call).
39
+ * - `skip_no_mention` — `requireMention` is on and the message
40
+ * does not @bot; record to history but
41
+ * skip AI dispatch.
42
+ * - `pass` — forward the message to the AI pipeline.
43
+ */
44
+ type GroupMessageGateAction =
45
+ | "drop_other_mention"
46
+ | "block_unauthorized_command"
47
+ | "skip_no_mention"
48
+ | "pass";
49
+
50
+ /** Gate evaluation result. */
51
+ export interface GroupMessageGateResult {
52
+ /** The action the caller should take. */
53
+ action: GroupMessageGateAction;
54
+ /**
55
+ * Effective mention state after combining raw mention detection with
56
+ * implicit / bypass signals. Only meaningful when `action === "pass"`.
57
+ */
58
+ effectiveWasMentioned: boolean;
59
+ /**
60
+ * Whether the control-command bypass was applied to flip a missing
61
+ * mention into `pass`. Only meaningful when `action === "pass"`.
62
+ */
63
+ shouldBypassMention: boolean;
64
+ }
65
+
66
+ /** Input for {@link resolveGroupMessageGate}. */
67
+ export interface GroupMessageGateInput {
68
+ // ---- ignoreOtherMentions layer ----
69
+ /** Per-group config: drop messages that @someone other than the bot. */
70
+ ignoreOtherMentions: boolean;
71
+ /** Whether the message contains *any* @mention (including @other-user). */
72
+ hasAnyMention: boolean;
73
+ /**
74
+ * Whether the QQ event explicitly @-s the bot (via `mentions[].is_you`
75
+ * or `GROUP_AT_MESSAGE_CREATE`).
76
+ */
77
+ wasMentioned: boolean;
78
+ /**
79
+ * Implicit mention — e.g. the message quotes an earlier bot reply.
80
+ * Treated as equivalent to an explicit @bot for gating purposes.
81
+ */
82
+ implicitMention: boolean;
83
+
84
+ // ---- Control-command layer ----
85
+ /** Whether text-based control commands are enabled globally. */
86
+ allowTextCommands: boolean;
87
+ /** Whether the current message is recognised as a control command. */
88
+ isControlCommand: boolean;
89
+ /** Whether the sender is authorised to run control commands. */
90
+ commandAuthorized: boolean;
91
+
92
+ // ---- Mention gating layer ----
93
+ /** Per-group config: `requireMention` — bot only replies when @-ed. */
94
+ requireMention: boolean;
95
+ /**
96
+ * Whether the channel can reliably detect @-mentions at all. In C2C chat
97
+ * this should be `false` (DMs don't have mentions); in group chat it
98
+ * should be `true`.
99
+ */
100
+ canDetectMention: boolean;
101
+ }
102
+
103
+ // ────────────────────── Core logic ──────────────────────
104
+
105
+ /**
106
+ * Base mention-gate evaluation.
107
+ *
108
+ * `effectiveWasMentioned = wasMentioned || implicitMention || bypass`.
109
+ * `shouldSkip = requireMention && canDetectMention && !effectiveWasMentioned`.
110
+ */
111
+ function resolveMentionGating(input: {
112
+ requireMention: boolean;
113
+ canDetectMention: boolean;
114
+ wasMentioned: boolean;
115
+ implicitMention: boolean;
116
+ shouldBypassMention: boolean;
117
+ }): { effectiveWasMentioned: boolean; shouldSkip: boolean } {
118
+ const effectiveWasMentioned =
119
+ input.wasMentioned || input.implicitMention || input.shouldBypassMention;
120
+ const shouldSkip = input.requireMention && input.canDetectMention && !effectiveWasMentioned;
121
+ return { effectiveWasMentioned, shouldSkip };
122
+ }
123
+
124
+ /**
125
+ * Decide whether an authorized control command may bypass the mention gate.
126
+ *
127
+ * All of the following must hold:
128
+ * 1. `requireMention` is on (gate is active)
129
+ * 2. The bot was NOT directly @-ed (otherwise no bypass is needed)
130
+ * 3. The message does NOT @anyone (a `@other-user /stop` should NOT pass
131
+ * — the command wasn't aimed at us)
132
+ * 4. Text commands are enabled
133
+ * 5. Sender is authorised
134
+ * 6. The content is a valid control command
135
+ */
136
+ function resolveCommandBypass(input: {
137
+ requireMention: boolean;
138
+ wasMentioned: boolean;
139
+ hasAnyMention: boolean;
140
+ allowTextCommands: boolean;
141
+ commandAuthorized: boolean;
142
+ isControlCommand: boolean;
143
+ }): boolean {
144
+ return (
145
+ input.requireMention &&
146
+ !input.wasMentioned &&
147
+ !input.hasAnyMention &&
148
+ input.allowTextCommands &&
149
+ input.commandAuthorized &&
150
+ input.isControlCommand
151
+ );
152
+ }
153
+
154
+ // ────────────────────── Unified gate ──────────────────────
155
+
156
+ /**
157
+ * Evaluate the group-message gate.
158
+ *
159
+ * See the module-level docs for the ordering and semantics.
160
+ */
161
+ export function resolveGroupMessageGate(input: GroupMessageGateInput): GroupMessageGateResult {
162
+ // ---- Layer 1: ignoreOtherMentions ----
163
+ if (
164
+ input.ignoreOtherMentions &&
165
+ input.hasAnyMention &&
166
+ !input.wasMentioned &&
167
+ !input.implicitMention
168
+ ) {
169
+ return {
170
+ action: "drop_other_mention",
171
+ effectiveWasMentioned: false,
172
+ shouldBypassMention: false,
173
+ };
174
+ }
175
+
176
+ // ---- Layer 2: unauthorized control command ----
177
+ if (input.allowTextCommands && input.isControlCommand && !input.commandAuthorized) {
178
+ return {
179
+ action: "block_unauthorized_command",
180
+ effectiveWasMentioned: false,
181
+ shouldBypassMention: false,
182
+ };
183
+ }
184
+
185
+ // ---- Layer 3: mention gate + command bypass ----
186
+ const shouldBypassMention = resolveCommandBypass({
187
+ requireMention: input.requireMention,
188
+ wasMentioned: input.wasMentioned,
189
+ hasAnyMention: input.hasAnyMention,
190
+ allowTextCommands: input.allowTextCommands,
191
+ commandAuthorized: input.commandAuthorized,
192
+ isControlCommand: input.isControlCommand,
193
+ });
194
+
195
+ const mentionGate = resolveMentionGating({
196
+ requireMention: input.requireMention,
197
+ canDetectMention: input.canDetectMention,
198
+ wasMentioned: input.wasMentioned,
199
+ implicitMention: input.implicitMention,
200
+ shouldBypassMention,
201
+ });
202
+
203
+ if (mentionGate.shouldSkip) {
204
+ return {
205
+ action: "skip_no_mention",
206
+ effectiveWasMentioned: mentionGate.effectiveWasMentioned,
207
+ shouldBypassMention,
208
+ };
209
+ }
210
+
211
+ return {
212
+ action: "pass",
213
+ effectiveWasMentioned: mentionGate.effectiveWasMentioned,
214
+ shouldBypassMention,
215
+ };
216
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Media path decoding utility.
3
+ *
4
+ * Extracted from `outbound-deliver.ts` — handles the `MEDIA:` prefix stripping,
5
+ * tilde expansion, octal escape / UTF-8 byte-sequence decoding, and backslash
6
+ * unescaping that media tags require.
7
+ *
8
+ * Zero external dependencies.
9
+ */
10
+
11
+ import type { EngineLogger } from "../types.js";
12
+
13
+ /**
14
+ * Normalize a file path by expanding `~` to the home directory and trimming.
15
+ *
16
+ * This is a minimal re-implementation of `utils/platform.ts#normalizePath`
17
+ * so that `core/` remains self-contained.
18
+ */
19
+ function normalizePath(p: string): string {
20
+ let result = p.trim();
21
+ if (result.startsWith("~/") || result === "~") {
22
+ const home =
23
+ typeof process !== "undefined" ? (process.env.HOME ?? process.env.USERPROFILE) : undefined;
24
+ if (home) {
25
+ result = result === "~" ? home : `${home}${result.slice(1)}`;
26
+ }
27
+ }
28
+ return result;
29
+ }
30
+
31
+ /**
32
+ * Decode a media path by stripping `MEDIA:`, expanding `~`, and unescaping
33
+ * octal/UTF-8 byte sequences.
34
+ *
35
+ * @param raw - Raw path string from a media tag.
36
+ * @param log - Optional logger for decode diagnostics.
37
+ * @returns The decoded, normalized media path.
38
+ */
39
+ export function decodeMediaPath(raw: string, log?: EngineLogger): string {
40
+ let mediaPath = raw;
41
+ if (mediaPath.startsWith("MEDIA:")) {
42
+ mediaPath = mediaPath.slice("MEDIA:".length);
43
+ }
44
+ mediaPath = normalizePath(mediaPath);
45
+ mediaPath = mediaPath.replace(/\\\\/g, "\\");
46
+
47
+ // Skip octal escape decoding for Windows local paths (e.g. C:\Users\1\file.txt)
48
+ // where backslash-digit sequences like \1, \2 ... \7 are directory separators,
49
+ // not octal escape sequences.
50
+ const isWinLocal = /^[a-zA-Z]:[\\/]/.test(mediaPath) || mediaPath.startsWith("\\\\");
51
+ try {
52
+ const hasOctal = /\\[0-7]{1,3}/.test(mediaPath);
53
+ const hasNonASCII = /[\u0080-\u00FF]/.test(mediaPath);
54
+
55
+ if (!isWinLocal && (hasOctal || hasNonASCII)) {
56
+ log?.debug?.(`Decoding path with mixed encoding: ${mediaPath}`);
57
+ const decoded = mediaPath.replace(/\\([0-7]{1,3})/g, (_: string, octal: string) => {
58
+ return String.fromCharCode(Number.parseInt(octal, 8));
59
+ });
60
+ const bytes: number[] = [];
61
+ for (let i = 0; i < decoded.length; i++) {
62
+ const code = decoded.charCodeAt(i);
63
+ if (code <= 0xff) {
64
+ bytes.push(code);
65
+ } else {
66
+ const charBytes = Buffer.from(decoded[i], "utf8");
67
+ bytes.push(...charBytes);
68
+ }
69
+ }
70
+ const buffer = Buffer.from(bytes);
71
+ const utf8Decoded = buffer.toString("utf8");
72
+ if (!utf8Decoded.includes("\uFFFD") || utf8Decoded.length < decoded.length) {
73
+ mediaPath = utf8Decoded;
74
+ log?.debug?.(`Successfully decoded path: ${mediaPath}`);
75
+ }
76
+ }
77
+ } catch (decodeErr) {
78
+ log?.error(`Path decode error: ${String(decodeErr)}`);
79
+ }
80
+
81
+ return mediaPath;
82
+ }
@@ -0,0 +1,215 @@
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 so a future chunked-upload
13
+ * implementation can stream it via `fs.createReadStream` without the 4/3×
14
+ * base64 memory overhead.
15
+ * - `buffer` — in-memory raw bytes (e.g. TTS output, downloaded url-fallback).
16
+ *
17
+ * ## Security baseline (localPath branch)
18
+ *
19
+ * `openLocalFile` is the single canonical implementation of "safely open a
20
+ * local file for upload" across the plugin. It merges the previously
21
+ * inconsistent strategies from `reply-dispatcher.ts` (O_NOFOLLOW + size check)
22
+ * and `outbound.ts` (realpath + root containment). Callers are still
23
+ * responsible for *root-whitelist* validation (via
24
+ * `resolveQQBotPayloadLocalFilePath` / `resolveOutboundMediaPath`) before
25
+ * passing the path in; this function enforces *file-level* safety only.
26
+ *
27
+ * Chunked upload is not implemented in this PR, but the contract here already
28
+ * returns `size` metadata so `sendMediaInternal` can route by size without
29
+ * reading the whole file first.
30
+ */
31
+
32
+ import * as fs from "node:fs";
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 — one-shot path reads it into a buffer;
43
+ * chunked path (future) streams it via `fs.createReadStream`.
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 }
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
+ interface OpenedLocalFile {
96
+ handle: fs.promises.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 openFlags =
124
+ fs.constants.O_RDONLY | ("O_NOFOLLOW" in fs.constants ? fs.constants.O_NOFOLLOW : 0);
125
+ const handle = await fs.promises.open(filePath, openFlags);
126
+ try {
127
+ const stat = await handle.stat();
128
+ if (!stat.isFile()) {
129
+ throw new Error("Path is not a regular file");
130
+ }
131
+ if (stat.size > maxSize) {
132
+ throw new Error(
133
+ `File is too large (${formatFileSize(stat.size)}); QQ Bot API limit is ${formatFileSize(maxSize)}`,
134
+ );
135
+ }
136
+ return {
137
+ handle,
138
+ size: stat.size,
139
+ close: () => handle.close(),
140
+ };
141
+ } catch (err) {
142
+ // Close the handle on any validation failure to avoid fd leaks.
143
+ await handle.close().catch(() => undefined);
144
+ throw err;
145
+ }
146
+ }
147
+
148
+ // ============ Normalization ============
149
+
150
+ /**
151
+ * Normalize a {@link RawMediaSource} into a {@link MediaSource}.
152
+ *
153
+ * - Strings passed via `{ url }` that start with `data:` are auto-resolved
154
+ * to a `base64` branch (this is the unified `data:` URL support that was
155
+ * previously only implemented in `sendImage`).
156
+ * - `localPath` branches open the file with {@link openLocalFile} solely to
157
+ * validate size / regular-file / O_NOFOLLOW invariants. The handle is
158
+ * closed immediately — actual reading is deferred to the uploader so
159
+ * the chunked path can stream without double-reading.
160
+ * - `buffer` branches enforce the same ceiling inline.
161
+ *
162
+ * `maxSize` defaults to {@link MAX_UPLOAD_SIZE} (20MB, one-shot upload limit).
163
+ * Callers that dispatch to the chunked uploader should pass a larger ceiling
164
+ * (e.g. `CHUNKED_UPLOAD_MAX_SIZE`, or a value derived from
165
+ * `getMaxUploadSize(fileType)`).
166
+ *
167
+ * NOTE: Root-whitelist validation (i.e. "this path must live under the
168
+ * allowed QQ Bot media directory") is a caller concern. This function
169
+ * assumes the path has already passed such checks.
170
+ */
171
+ export async function normalizeSource(
172
+ raw: RawMediaSource,
173
+ opts: { maxSize?: number } = {},
174
+ ): Promise<MediaSource> {
175
+ const maxSize = opts.maxSize ?? MAX_UPLOAD_SIZE;
176
+
177
+ if ("url" in raw) {
178
+ const parsed = tryParseDataUrl(raw.url);
179
+ if (parsed) {
180
+ return { kind: "base64", data: parsed.data, mime: parsed.mime };
181
+ }
182
+ return { kind: "url", url: raw.url };
183
+ }
184
+
185
+ if ("base64" in raw) {
186
+ return { kind: "base64", data: raw.base64, mime: raw.mime };
187
+ }
188
+
189
+ if ("localPath" in raw) {
190
+ const opened = await openLocalFile(raw.localPath, { maxSize });
191
+ try {
192
+ return {
193
+ kind: "localPath",
194
+ path: raw.localPath,
195
+ size: opened.size,
196
+ mime: getMimeType(raw.localPath),
197
+ };
198
+ } finally {
199
+ await opened.close();
200
+ }
201
+ }
202
+
203
+ // buffer branch
204
+ if (raw.buffer.length > maxSize) {
205
+ throw new Error(
206
+ `Buffer is too large (${formatFileSize(raw.buffer.length)}); QQ Bot API limit is ${formatFileSize(maxSize)}`,
207
+ );
208
+ }
209
+ return {
210
+ kind: "buffer",
211
+ buffer: raw.buffer,
212
+ fileName: raw.fileName,
213
+ mime: raw.mime,
214
+ };
215
+ }