@openclaw/qqbot 2026.5.2 → 2026.5.3-beta.2

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 (203) hide show
  1. package/dist/api.js +646 -0
  2. package/dist/approval-cg0SVahb.js +94 -0
  3. package/dist/channel-N6Y_Rcjp.js +551 -0
  4. package/dist/channel-plugin-api.js +2 -0
  5. package/dist/channel.setup-ByCRAe5H.js +25 -0
  6. package/dist/config-D6545NkC.js +94 -0
  7. package/dist/config-schema-DFcjQw73.js +309 -0
  8. package/dist/exec-approvals-COUsM6wZ.js +138 -0
  9. package/dist/gateway-CmSUJKSt.js +6726 -0
  10. package/dist/handler-runtime-Bqm6N0WG.js +120 -0
  11. package/dist/index.js +29 -0
  12. package/dist/narrowing-BoieBTIU.js +25 -0
  13. package/dist/outbound-BJfhwrPg.js +1357 -0
  14. package/dist/request-context-DXtpwWui.js +921 -0
  15. package/dist/resolve-D_06fV6-.js +284 -0
  16. package/dist/runtime-BJAS3eXW.js +18 -0
  17. package/dist/runtime-api.js +2 -0
  18. package/dist/secret-contract-api.js +56 -0
  19. package/dist/sender-p-B14eLG.js +1987 -0
  20. package/dist/setup-entry.js +15 -0
  21. package/dist/setup-plugin-api.js +2 -0
  22. package/dist/string-normalize-Ci6NM5DE.js +98 -0
  23. package/dist/target-parser-Y0prnrXD.js +245 -0
  24. package/package.json +15 -6
  25. package/api.ts +0 -56
  26. package/channel-plugin-api.ts +0 -1
  27. package/index.ts +0 -29
  28. package/runtime-api.ts +0 -9
  29. package/setup-entry.ts +0 -9
  30. package/setup-plugin-api.ts +0 -3
  31. package/src/bridge/approval/capability.ts +0 -237
  32. package/src/bridge/approval/handler-runtime.ts +0 -204
  33. package/src/bridge/bootstrap.ts +0 -135
  34. package/src/bridge/channel-entry.ts +0 -18
  35. package/src/bridge/commands/framework-context-adapter.ts +0 -60
  36. package/src/bridge/commands/framework-registration.ts +0 -47
  37. package/src/bridge/commands/from-parser.test.ts +0 -86
  38. package/src/bridge/commands/from-parser.ts +0 -60
  39. package/src/bridge/commands/result-dispatcher.ts +0 -76
  40. package/src/bridge/config-shared.ts +0 -132
  41. package/src/bridge/config.ts +0 -111
  42. package/src/bridge/gateway.ts +0 -174
  43. package/src/bridge/logger.ts +0 -31
  44. package/src/bridge/narrowing.ts +0 -31
  45. package/src/bridge/plugin-version.test.ts +0 -146
  46. package/src/bridge/plugin-version.ts +0 -102
  47. package/src/bridge/runtime.ts +0 -25
  48. package/src/bridge/sdk-adapter.ts +0 -131
  49. package/src/bridge/setup/finalize.ts +0 -144
  50. package/src/bridge/setup/surface.ts +0 -34
  51. package/src/bridge/tools/channel.ts +0 -58
  52. package/src/bridge/tools/index.ts +0 -15
  53. package/src/bridge/tools/remind.test.ts +0 -124
  54. package/src/bridge/tools/remind.ts +0 -91
  55. package/src/channel.setup.ts +0 -33
  56. package/src/channel.ts +0 -288
  57. package/src/command-auth.test.ts +0 -62
  58. package/src/config-schema.ts +0 -84
  59. package/src/config.test.ts +0 -364
  60. package/src/engine/access/access-control.test.ts +0 -198
  61. package/src/engine/access/access-control.ts +0 -226
  62. package/src/engine/access/index.ts +0 -16
  63. package/src/engine/access/resolve-policy.test.ts +0 -59
  64. package/src/engine/access/resolve-policy.ts +0 -57
  65. package/src/engine/access/sender-match.test.ts +0 -60
  66. package/src/engine/access/sender-match.ts +0 -55
  67. package/src/engine/access/types.ts +0 -53
  68. package/src/engine/adapter/audio.port.ts +0 -27
  69. package/src/engine/adapter/commands.port.ts +0 -22
  70. package/src/engine/adapter/history.port.ts +0 -52
  71. package/src/engine/adapter/index.ts +0 -139
  72. package/src/engine/adapter/mention-gate.port.ts +0 -50
  73. package/src/engine/adapter/types.ts +0 -38
  74. package/src/engine/api/api-client.ts +0 -212
  75. package/src/engine/api/media-chunked.test.ts +0 -336
  76. package/src/engine/api/media-chunked.ts +0 -622
  77. package/src/engine/api/media.ts +0 -218
  78. package/src/engine/api/messages.ts +0 -293
  79. package/src/engine/api/retry.ts +0 -217
  80. package/src/engine/api/routes.ts +0 -95
  81. package/src/engine/api/token.ts +0 -271
  82. package/src/engine/approval/index.test.ts +0 -22
  83. package/src/engine/approval/index.ts +0 -224
  84. package/src/engine/commands/builtin/log-helpers.ts +0 -319
  85. package/src/engine/commands/builtin/register-all.ts +0 -17
  86. package/src/engine/commands/builtin/register-approve.ts +0 -201
  87. package/src/engine/commands/builtin/register-basic.ts +0 -95
  88. package/src/engine/commands/builtin/register-clear-storage.ts +0 -187
  89. package/src/engine/commands/builtin/register-logs.ts +0 -20
  90. package/src/engine/commands/builtin/register-streaming.ts +0 -137
  91. package/src/engine/commands/builtin/state.ts +0 -31
  92. package/src/engine/commands/slash-command-auth.ts +0 -48
  93. package/src/engine/commands/slash-command-handler.ts +0 -146
  94. package/src/engine/commands/slash-commands-impl.test.ts +0 -8
  95. package/src/engine/commands/slash-commands-impl.ts +0 -61
  96. package/src/engine/commands/slash-commands.ts +0 -199
  97. package/src/engine/config/credential-backup.test.ts +0 -88
  98. package/src/engine/config/credential-backup.ts +0 -107
  99. package/src/engine/config/credentials.ts +0 -76
  100. package/src/engine/config/group.test.ts +0 -234
  101. package/src/engine/config/group.ts +0 -299
  102. package/src/engine/config/resolve.test.ts +0 -152
  103. package/src/engine/config/resolve.ts +0 -283
  104. package/src/engine/config/setup-logic.ts +0 -84
  105. package/src/engine/engine-import-boundary.test.ts +0 -73
  106. package/src/engine/gateway/codec.ts +0 -47
  107. package/src/engine/gateway/constants.ts +0 -117
  108. package/src/engine/gateway/event-dispatcher.ts +0 -177
  109. package/src/engine/gateway/gateway-connection.ts +0 -371
  110. package/src/engine/gateway/gateway.ts +0 -291
  111. package/src/engine/gateway/inbound-attachments.test.ts +0 -126
  112. package/src/engine/gateway/inbound-attachments.ts +0 -360
  113. package/src/engine/gateway/inbound-context.ts +0 -195
  114. package/src/engine/gateway/inbound-pipeline.self-echo.test.ts +0 -218
  115. package/src/engine/gateway/inbound-pipeline.ts +0 -235
  116. package/src/engine/gateway/interaction-handler.ts +0 -220
  117. package/src/engine/gateway/message-queue.test.ts +0 -282
  118. package/src/engine/gateway/message-queue.ts +0 -499
  119. package/src/engine/gateway/outbound-dispatch.test.ts +0 -231
  120. package/src/engine/gateway/outbound-dispatch.ts +0 -575
  121. package/src/engine/gateway/reconnect.ts +0 -199
  122. package/src/engine/gateway/stages/access-stage.ts +0 -132
  123. package/src/engine/gateway/stages/assembly-stage.ts +0 -156
  124. package/src/engine/gateway/stages/content-stage.test.ts +0 -77
  125. package/src/engine/gateway/stages/content-stage.ts +0 -77
  126. package/src/engine/gateway/stages/envelope-stage.test.ts +0 -152
  127. package/src/engine/gateway/stages/envelope-stage.ts +0 -144
  128. package/src/engine/gateway/stages/group-gate-stage.ts +0 -292
  129. package/src/engine/gateway/stages/index.ts +0 -18
  130. package/src/engine/gateway/stages/quote-stage.ts +0 -113
  131. package/src/engine/gateway/stages/refidx-stage.ts +0 -62
  132. package/src/engine/gateway/stages/stub-contexts.ts +0 -116
  133. package/src/engine/gateway/types.ts +0 -264
  134. package/src/engine/gateway/typing-keepalive.ts +0 -79
  135. package/src/engine/group/activation.test.ts +0 -114
  136. package/src/engine/group/activation.ts +0 -147
  137. package/src/engine/group/history.test.ts +0 -314
  138. package/src/engine/group/history.ts +0 -321
  139. package/src/engine/group/mention.test.ts +0 -141
  140. package/src/engine/group/mention.ts +0 -197
  141. package/src/engine/group/message-gating.test.ts +0 -188
  142. package/src/engine/group/message-gating.ts +0 -216
  143. package/src/engine/messaging/decode-media-path.ts +0 -82
  144. package/src/engine/messaging/media-source.ts +0 -215
  145. package/src/engine/messaging/media-type-detect.ts +0 -37
  146. package/src/engine/messaging/outbound-audio-port.ts +0 -38
  147. package/src/engine/messaging/outbound-deliver.ts +0 -810
  148. package/src/engine/messaging/outbound-media-send.ts +0 -702
  149. package/src/engine/messaging/outbound-reply.ts +0 -27
  150. package/src/engine/messaging/outbound-result-helpers.ts +0 -54
  151. package/src/engine/messaging/outbound-types.ts +0 -45
  152. package/src/engine/messaging/outbound.ts +0 -485
  153. package/src/engine/messaging/reply-dispatcher.ts +0 -597
  154. package/src/engine/messaging/reply-limiter.ts +0 -164
  155. package/src/engine/messaging/sender.ts +0 -729
  156. package/src/engine/messaging/streaming-c2c.ts +0 -1192
  157. package/src/engine/messaging/streaming-media-send.ts +0 -544
  158. package/src/engine/messaging/target-parser.ts +0 -104
  159. package/src/engine/ref/format-message-ref.ts +0 -142
  160. package/src/engine/ref/format-ref-entry.test.ts +0 -60
  161. package/src/engine/ref/format-ref-entry.ts +0 -27
  162. package/src/engine/ref/store.ts +0 -208
  163. package/src/engine/ref/types.ts +0 -27
  164. package/src/engine/session/known-users.ts +0 -137
  165. package/src/engine/session/session-store.ts +0 -204
  166. package/src/engine/tools/channel-api.ts +0 -244
  167. package/src/engine/tools/remind-logic.test.ts +0 -280
  168. package/src/engine/tools/remind-logic.ts +0 -377
  169. package/src/engine/types.ts +0 -313
  170. package/src/engine/utils/attachment-tags.test.ts +0 -186
  171. package/src/engine/utils/attachment-tags.ts +0 -174
  172. package/src/engine/utils/audio.test.ts +0 -250
  173. package/src/engine/utils/audio.ts +0 -585
  174. package/src/engine/utils/data-paths.ts +0 -38
  175. package/src/engine/utils/diagnostics.ts +0 -109
  176. package/src/engine/utils/file-utils.test.ts +0 -72
  177. package/src/engine/utils/file-utils.ts +0 -225
  178. package/src/engine/utils/format.test.ts +0 -68
  179. package/src/engine/utils/format.ts +0 -70
  180. package/src/engine/utils/image-size.test.ts +0 -158
  181. package/src/engine/utils/image-size.ts +0 -249
  182. package/src/engine/utils/log.test.ts +0 -28
  183. package/src/engine/utils/log.ts +0 -61
  184. package/src/engine/utils/media-tags.test.ts +0 -32
  185. package/src/engine/utils/media-tags.ts +0 -177
  186. package/src/engine/utils/payload.test.ts +0 -68
  187. package/src/engine/utils/payload.ts +0 -145
  188. package/src/engine/utils/platform-storage-laziness.test.ts +0 -63
  189. package/src/engine/utils/platform.test.ts +0 -148
  190. package/src/engine/utils/platform.ts +0 -343
  191. package/src/engine/utils/request-context.ts +0 -60
  192. package/src/engine/utils/string-normalize.ts +0 -91
  193. package/src/engine/utils/stt.test.ts +0 -104
  194. package/src/engine/utils/stt.ts +0 -100
  195. package/src/engine/utils/text-parsing.test.ts +0 -29
  196. package/src/engine/utils/text-parsing.ts +0 -155
  197. package/src/engine/utils/upload-cache.ts +0 -96
  198. package/src/engine/utils/voice-text.ts +0 -15
  199. package/src/exec-approvals.ts +0 -218
  200. package/src/manifest-schema.test.ts +0 -56
  201. package/src/qqbot-test-support.ts +0 -29
  202. package/src/types.ts +0 -210
  203. package/tsconfig.json +0 -16
@@ -1,188 +0,0 @@
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
- });
@@ -1,216 +0,0 @@
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
- }
@@ -1,82 +0,0 @@
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
- }
@@ -1,215 +0,0 @@
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
- }