@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,226 @@
1
+ /**
2
+ * QQBot inbound access decision.
3
+ *
4
+ * This module is the single place where the QQBot engine decides
5
+ * whether an inbound message from a given sender is allowed to
6
+ * proceed into the outbound pipeline. The implementation mirrors the
7
+ * semantics of the framework-wide `resolveDmGroupAccessDecision`
8
+ * (`src/security/dm-policy-shared.ts`) but is kept standalone so the
9
+ * `engine/` layer does not pull in `openclaw/plugin-sdk/*` modules —
10
+ * a hard constraint shared with the standalone `openclaw-qqbot` build.
11
+ *
12
+ * If in the future we lift the zero-dependency rule in the engine
13
+ * layer, this file can be replaced by a thin adapter around the
14
+ * framework API with identical semantics.
15
+ */
16
+
17
+ import { resolveQQBotEffectivePolicies, type EffectivePolicyInput } from "./resolve-policy.js";
18
+ import { createQQBotSenderMatcher, normalizeQQBotAllowFrom } from "./sender-match.js";
19
+ import {
20
+ QQBOT_ACCESS_REASON,
21
+ type QQBotAccessResult,
22
+ type QQBotDmPolicy,
23
+ type QQBotGroupPolicy,
24
+ } from "./types.js";
25
+
26
+ interface QQBotAccessInput extends EffectivePolicyInput {
27
+ /** Whether the inbound originated in a group (or guild) chat. */
28
+ isGroup: boolean;
29
+ /** The raw inbound sender id as provided by the QQ event. */
30
+ senderId: string;
31
+ }
32
+
33
+ /**
34
+ * Evaluate the inbound access policy.
35
+ *
36
+ * Semantics (aligned with `resolveDmGroupAccessDecision`):
37
+ * - Group message:
38
+ * - `groupPolicy=disabled` → block
39
+ * - `groupPolicy=open` → allow
40
+ * - `groupPolicy=allowlist`:
41
+ * - empty effectiveGroupAllowFrom → block (empty_allowlist)
42
+ * - sender not in list → block (not_allowlisted)
43
+ * - otherwise → allow
44
+ * - Direct message:
45
+ * - `dmPolicy=disabled` → block
46
+ * - `dmPolicy=open` → allow wildcard, legacy empty allowFrom, or matching allowFrom
47
+ * - `dmPolicy=allowlist`:
48
+ * - empty effectiveAllowFrom → block (empty_allowlist)
49
+ * - sender not in list → block (not_allowlisted)
50
+ * - otherwise → allow
51
+ *
52
+ * The function never throws; callers can rely on the returned
53
+ * `decision`/`reasonCode` pair for branching.
54
+ */
55
+ export function resolveQQBotAccess(input: QQBotAccessInput): QQBotAccessResult {
56
+ const { dmPolicy, groupPolicy } = resolveQQBotEffectivePolicies(input);
57
+
58
+ // Per framework convention: groupAllowFrom falls back to allowFrom
59
+ // when not provided. We preserve that behaviour so a single
60
+ // `allowFrom` entry locks down both DM and group.
61
+ const rawGroupAllowFrom =
62
+ input.groupAllowFrom && input.groupAllowFrom.length > 0
63
+ ? input.groupAllowFrom
64
+ : (input.allowFrom ?? []);
65
+
66
+ const normalizedAllowFrom = normalizeQQBotAllowFrom(input.allowFrom);
67
+ const effectiveAllowFrom =
68
+ dmPolicy === "open" && normalizedAllowFrom.length === 0 ? ["*"] : normalizedAllowFrom;
69
+ const effectiveGroupAllowFrom = normalizeQQBotAllowFrom(rawGroupAllowFrom);
70
+
71
+ const isSenderAllowed = createQQBotSenderMatcher(input.senderId);
72
+
73
+ if (input.isGroup) {
74
+ return evaluateGroupDecision({
75
+ groupPolicy,
76
+ dmPolicy,
77
+ effectiveAllowFrom,
78
+ effectiveGroupAllowFrom,
79
+ isSenderAllowed,
80
+ });
81
+ }
82
+
83
+ return evaluateDmDecision({
84
+ groupPolicy,
85
+ dmPolicy,
86
+ effectiveAllowFrom,
87
+ effectiveGroupAllowFrom,
88
+ isSenderAllowed,
89
+ });
90
+ }
91
+
92
+ // ---- internal helpers ------------------------------------------------
93
+
94
+ interface DecisionContext {
95
+ dmPolicy: QQBotDmPolicy;
96
+ groupPolicy: QQBotGroupPolicy;
97
+ effectiveAllowFrom: string[];
98
+ effectiveGroupAllowFrom: string[];
99
+ isSenderAllowed: (allowFrom: string[]) => boolean;
100
+ }
101
+
102
+ function evaluateGroupDecision(ctx: DecisionContext): QQBotAccessResult {
103
+ const base = buildResultBase(ctx);
104
+
105
+ if (ctx.groupPolicy === "disabled") {
106
+ return {
107
+ ...base,
108
+ decision: "block",
109
+ reasonCode: QQBOT_ACCESS_REASON.GROUP_POLICY_DISABLED,
110
+ reason: "groupPolicy=disabled",
111
+ };
112
+ }
113
+
114
+ if (ctx.groupPolicy === "open") {
115
+ return {
116
+ ...base,
117
+ decision: "allow",
118
+ reasonCode: QQBOT_ACCESS_REASON.GROUP_POLICY_ALLOWED,
119
+ reason: "groupPolicy=open",
120
+ };
121
+ }
122
+
123
+ // groupPolicy === "allowlist"
124
+ if (ctx.effectiveGroupAllowFrom.length === 0) {
125
+ return {
126
+ ...base,
127
+ decision: "block",
128
+ reasonCode: QQBOT_ACCESS_REASON.GROUP_POLICY_EMPTY_ALLOWLIST,
129
+ reason: "groupPolicy=allowlist (empty allowlist)",
130
+ };
131
+ }
132
+
133
+ if (!ctx.isSenderAllowed(ctx.effectiveGroupAllowFrom)) {
134
+ return {
135
+ ...base,
136
+ decision: "block",
137
+ reasonCode: QQBOT_ACCESS_REASON.GROUP_POLICY_NOT_ALLOWLISTED,
138
+ reason: "groupPolicy=allowlist (not allowlisted)",
139
+ };
140
+ }
141
+
142
+ return {
143
+ ...base,
144
+ decision: "allow",
145
+ reasonCode: QQBOT_ACCESS_REASON.GROUP_POLICY_ALLOWED,
146
+ reason: "groupPolicy=allowlist (allowlisted)",
147
+ };
148
+ }
149
+
150
+ function evaluateDmDecision(ctx: DecisionContext): QQBotAccessResult {
151
+ const base = buildResultBase(ctx);
152
+
153
+ if (ctx.dmPolicy === "disabled") {
154
+ return {
155
+ ...base,
156
+ decision: "block",
157
+ reasonCode: QQBOT_ACCESS_REASON.DM_POLICY_DISABLED,
158
+ reason: "dmPolicy=disabled",
159
+ };
160
+ }
161
+
162
+ if (ctx.dmPolicy === "open") {
163
+ if (ctx.effectiveAllowFrom.includes("*")) {
164
+ return {
165
+ ...base,
166
+ decision: "allow",
167
+ reasonCode: QQBOT_ACCESS_REASON.DM_POLICY_OPEN,
168
+ reason: "dmPolicy=open",
169
+ };
170
+ }
171
+ if (ctx.isSenderAllowed(ctx.effectiveAllowFrom)) {
172
+ return {
173
+ ...base,
174
+ decision: "allow",
175
+ reasonCode: QQBOT_ACCESS_REASON.DM_POLICY_ALLOWLISTED,
176
+ reason: "dmPolicy=open (allowlisted)",
177
+ };
178
+ }
179
+ return {
180
+ ...base,
181
+ decision: "block",
182
+ reasonCode: QQBOT_ACCESS_REASON.DM_POLICY_NOT_ALLOWLISTED,
183
+ reason: "dmPolicy=open (not allowlisted)",
184
+ };
185
+ }
186
+
187
+ // dmPolicy === "allowlist"
188
+ if (ctx.effectiveAllowFrom.length === 0) {
189
+ return {
190
+ ...base,
191
+ decision: "block",
192
+ reasonCode: QQBOT_ACCESS_REASON.DM_POLICY_EMPTY_ALLOWLIST,
193
+ reason: "dmPolicy=allowlist (empty allowlist)",
194
+ };
195
+ }
196
+
197
+ if (!ctx.isSenderAllowed(ctx.effectiveAllowFrom)) {
198
+ return {
199
+ ...base,
200
+ decision: "block",
201
+ reasonCode: QQBOT_ACCESS_REASON.DM_POLICY_NOT_ALLOWLISTED,
202
+ reason: "dmPolicy=allowlist (not allowlisted)",
203
+ };
204
+ }
205
+
206
+ return {
207
+ ...base,
208
+ decision: "allow",
209
+ reasonCode: QQBOT_ACCESS_REASON.DM_POLICY_ALLOWLISTED,
210
+ reason: "dmPolicy=allowlist (allowlisted)",
211
+ };
212
+ }
213
+
214
+ function buildResultBase(
215
+ ctx: DecisionContext,
216
+ ): Pick<
217
+ QQBotAccessResult,
218
+ "effectiveAllowFrom" | "effectiveGroupAllowFrom" | "dmPolicy" | "groupPolicy"
219
+ > {
220
+ return {
221
+ effectiveAllowFrom: ctx.effectiveAllowFrom,
222
+ effectiveGroupAllowFrom: ctx.effectiveGroupAllowFrom,
223
+ dmPolicy: ctx.dmPolicy,
224
+ groupPolicy: ctx.groupPolicy,
225
+ };
226
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * QQBot inbound access control — public entry points.
3
+ *
4
+ * Consumers (inbound-pipeline and future adapters) should import from
5
+ * this barrel to keep the internal module layout opaque.
6
+ */
7
+
8
+ export { resolveQQBotAccess } from "./access-control.js";
9
+ export { createQQBotSenderMatcher, normalizeQQBotAllowFrom } from "./sender-match.js";
10
+ export {
11
+ type QQBotAccessDecision,
12
+ type QQBotAccessReasonCode,
13
+ type QQBotAccessResult,
14
+ type QQBotDmPolicy,
15
+ type QQBotGroupPolicy,
16
+ } from "./types.js";
@@ -0,0 +1,59 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { resolveQQBotEffectivePolicies } from "./resolve-policy.js";
3
+
4
+ describe("resolveQQBotEffectivePolicies", () => {
5
+ describe("backwards-compatible inference", () => {
6
+ it("defaults to open when no allowFrom is configured", () => {
7
+ expect(resolveQQBotEffectivePolicies({})).toEqual({
8
+ dmPolicy: "open",
9
+ groupPolicy: "open",
10
+ });
11
+ });
12
+
13
+ it("defaults to open when allowFrom only contains wildcard", () => {
14
+ expect(resolveQQBotEffectivePolicies({ allowFrom: ["*"] })).toEqual({
15
+ dmPolicy: "open",
16
+ groupPolicy: "open",
17
+ });
18
+ });
19
+
20
+ it("infers allowlist when allowFrom has a concrete entry", () => {
21
+ expect(resolveQQBotEffectivePolicies({ allowFrom: ["USER1"] })).toEqual({
22
+ dmPolicy: "allowlist",
23
+ groupPolicy: "allowlist",
24
+ });
25
+ });
26
+
27
+ it("infers group=allowlist when only groupAllowFrom is restricted", () => {
28
+ expect(
29
+ resolveQQBotEffectivePolicies({ allowFrom: ["*"], groupAllowFrom: ["USER1"] }),
30
+ ).toEqual({
31
+ dmPolicy: "open",
32
+ groupPolicy: "allowlist",
33
+ });
34
+ });
35
+ });
36
+
37
+ describe("explicit policy precedence", () => {
38
+ it("honours explicit dmPolicy over inference", () => {
39
+ expect(
40
+ resolveQQBotEffectivePolicies({ allowFrom: ["USER1"], dmPolicy: "open" }),
41
+ ).toMatchObject({ dmPolicy: "open" });
42
+ });
43
+
44
+ it("honours explicit groupPolicy over inference", () => {
45
+ expect(
46
+ resolveQQBotEffectivePolicies({
47
+ allowFrom: ["USER1"],
48
+ groupPolicy: "disabled",
49
+ }),
50
+ ).toMatchObject({ groupPolicy: "disabled" });
51
+ });
52
+
53
+ it("allows dmPolicy=disabled to cut off DM entirely", () => {
54
+ expect(resolveQQBotEffectivePolicies({ dmPolicy: "disabled" })).toMatchObject({
55
+ dmPolicy: "disabled",
56
+ });
57
+ });
58
+ });
59
+ });
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Effective-policy resolver.
3
+ *
4
+ * Maps a raw `QQBotAccountConfig` to the concrete `dmPolicy`/`groupPolicy`
5
+ * values that the access engine consumes. Provides backwards-compatible
6
+ * defaults for accounts that only have the legacy `allowFrom` field:
7
+ *
8
+ * - Empty `allowFrom` or containing `"*"` → `"open"` (the historical
9
+ * behaviour before P0/P1 landed).
10
+ * - Non-empty `allowFrom` without `"*"` → `"allowlist"` (what a
11
+ * security-conscious operator almost certainly meant).
12
+ *
13
+ * An explicit `dmPolicy`/`groupPolicy` always wins over the inference.
14
+ */
15
+
16
+ import type { QQBotDmPolicy, QQBotGroupPolicy } from "./types.js";
17
+
18
+ /** Subset of the account config fields this resolver actually reads. */
19
+ export interface EffectivePolicyInput {
20
+ allowFrom?: Array<string | number> | null;
21
+ groupAllowFrom?: Array<string | number> | null;
22
+ dmPolicy?: QQBotDmPolicy | null;
23
+ groupPolicy?: QQBotGroupPolicy | null;
24
+ }
25
+
26
+ function hasRealRestriction(list: Array<string | number> | null | undefined): boolean {
27
+ if (!list || list.length === 0) {
28
+ return false;
29
+ }
30
+ // A list that only contains `"*"` is logically equivalent to open.
31
+ return !list.every((entry) => String(entry).trim() === "*");
32
+ }
33
+
34
+ /**
35
+ * Derive the effective dmPolicy and groupPolicy applied at runtime.
36
+ *
37
+ * Caller should pass the raw `QQBotAccountConfig`. The resolver does
38
+ * not look at `groups[id]` overrides — per-group overrides are layered
39
+ * on top elsewhere (see `inbound-pipeline` mention gating).
40
+ */
41
+ export function resolveQQBotEffectivePolicies(input: EffectivePolicyInput): {
42
+ dmPolicy: QQBotDmPolicy;
43
+ groupPolicy: QQBotGroupPolicy;
44
+ } {
45
+ const allowFromRestricted = hasRealRestriction(input.allowFrom);
46
+ const groupAllowFromRestricted = hasRealRestriction(input.groupAllowFrom);
47
+
48
+ const dmPolicy: QQBotDmPolicy = input.dmPolicy ?? (allowFromRestricted ? "allowlist" : "open");
49
+
50
+ // groupPolicy defaults: if an explicit groupAllowFrom is provided and
51
+ // restricts, enforce allowlist. Otherwise fall back to the same rule
52
+ // as DM (so a single `allowFrom` entry locks down both DM and group).
53
+ const groupPolicy: QQBotGroupPolicy =
54
+ input.groupPolicy ?? (groupAllowFromRestricted || allowFromRestricted ? "allowlist" : "open");
55
+
56
+ return { dmPolicy, groupPolicy };
57
+ }
@@ -0,0 +1,60 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ createQQBotSenderMatcher,
4
+ normalizeQQBotAllowFrom,
5
+ normalizeQQBotSenderId,
6
+ } from "./sender-match.js";
7
+
8
+ describe("normalizeQQBotSenderId", () => {
9
+ it("uppercases and strips qqbot: prefix", () => {
10
+ expect(normalizeQQBotSenderId("qqbot:abc123")).toBe("ABC123");
11
+ expect(normalizeQQBotSenderId("QQBot:abc123")).toBe("ABC123");
12
+ });
13
+
14
+ it("trims whitespace", () => {
15
+ expect(normalizeQQBotSenderId(" USER1 ")).toBe("USER1");
16
+ });
17
+
18
+ it("returns empty string for non-string input", () => {
19
+ expect(normalizeQQBotSenderId(undefined as unknown as string)).toBe("");
20
+ expect(normalizeQQBotSenderId(null as unknown as string)).toBe("");
21
+ expect(normalizeQQBotSenderId({} as unknown as string)).toBe("");
22
+ });
23
+
24
+ it("accepts numeric input", () => {
25
+ expect(normalizeQQBotSenderId(42)).toBe("42");
26
+ });
27
+ });
28
+
29
+ describe("normalizeQQBotAllowFrom", () => {
30
+ it("normalizes all entries and drops empty ones", () => {
31
+ expect(normalizeQQBotAllowFrom(["qqbot:user1", "USER2", "", " "])).toEqual(["USER1", "USER2"]);
32
+ });
33
+
34
+ it("returns empty array for undefined/null", () => {
35
+ expect(normalizeQQBotAllowFrom(undefined)).toEqual([]);
36
+ expect(normalizeQQBotAllowFrom(null)).toEqual([]);
37
+ });
38
+ });
39
+
40
+ describe("createQQBotSenderMatcher", () => {
41
+ it("matches wildcard regardless of sender", () => {
42
+ expect(createQQBotSenderMatcher("USER1")(["*"])).toBe(true);
43
+ expect(createQQBotSenderMatcher("")(["*"])).toBe(true);
44
+ });
45
+
46
+ it("matches case-insensitive with qqbot: prefix", () => {
47
+ const match = createQQBotSenderMatcher("qqbot:USER1");
48
+ expect(match(["qqbot:user1"])).toBe(true);
49
+ expect(match(["USER1"])).toBe(true);
50
+ expect(match(["USER2"])).toBe(false);
51
+ });
52
+
53
+ it("returns false on empty allowlist", () => {
54
+ expect(createQQBotSenderMatcher("USER1")([])).toBe(false);
55
+ });
56
+
57
+ it("returns false for empty sender against non-wildcard list", () => {
58
+ expect(createQQBotSenderMatcher("")(["USER1"])).toBe(false);
59
+ });
60
+ });
@@ -0,0 +1,55 @@
1
+ /**
2
+ * QQBot sender normalization and allowlist matching.
3
+ *
4
+ * Keeps QQ-specific quirks (the `qqbot:` prefix, uppercase-insensitive
5
+ * comparison) localized to this module so the policy engine itself can
6
+ * stay channel-agnostic.
7
+ */
8
+
9
+ /** Normalize a single entry (openid): strip `qqbot:` prefix, uppercase, trim. */
10
+ export function normalizeQQBotSenderId(raw: unknown): string {
11
+ if (typeof raw !== "string" && typeof raw !== "number") {
12
+ return "";
13
+ }
14
+ return String(raw)
15
+ .trim()
16
+ .replace(/^qqbot:/i, "")
17
+ .toUpperCase();
18
+ }
19
+
20
+ /** Normalize an entire allowFrom list, dropping empty entries. */
21
+ export function normalizeQQBotAllowFrom(list: Array<string | number> | undefined | null): string[] {
22
+ if (!list || list.length === 0) {
23
+ return [];
24
+ }
25
+ const out: string[] = [];
26
+ for (const entry of list) {
27
+ const normalized = normalizeQQBotSenderId(entry);
28
+ if (normalized) {
29
+ out.push(normalized);
30
+ }
31
+ }
32
+ return out;
33
+ }
34
+
35
+ /**
36
+ * Build a matcher closure suitable for passing to the policy engine's
37
+ * `isSenderAllowed` callback. The caller supplies the sender once, and
38
+ * the returned function can be invoked against different allowlists
39
+ * (DM allowlist vs group allowlist) without repeating normalization.
40
+ */
41
+ export function createQQBotSenderMatcher(senderId: string): (allowFrom: string[]) => boolean {
42
+ const normalizedSender = normalizeQQBotSenderId(senderId);
43
+ return (allowFrom: string[]) => {
44
+ if (allowFrom.length === 0) {
45
+ return false;
46
+ }
47
+ if (allowFrom.includes("*")) {
48
+ return true;
49
+ }
50
+ if (!normalizedSender) {
51
+ return false;
52
+ }
53
+ return allowFrom.some((entry) => normalizeQQBotSenderId(entry) === normalizedSender);
54
+ };
55
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * QQBot access-control primitive types.
3
+ *
4
+ * Mirrors the semantics of the framework-shared `DmPolicy` and
5
+ * `DmGroupAccessDecision` types while staying zero-dependency so the
6
+ * engine layer remains portable across the built-in and standalone
7
+ * plugin builds.
8
+ *
9
+ * The reason codes here intentionally match
10
+ * `src/security/dm-policy-shared.ts::DM_GROUP_ACCESS_REASON` so metric
11
+ * dashboards can treat QQBot decisions identically to WhatsApp /
12
+ * Telegram / Discord decisions.
13
+ */
14
+
15
+ /** DM-level policy selecting between open / allowlist / disabled gating. */
16
+ export type QQBotDmPolicy = "open" | "allowlist" | "disabled";
17
+
18
+ /** Group-level policy selecting between open / allowlist / disabled gating. */
19
+ export type QQBotGroupPolicy = "open" | "allowlist" | "disabled";
20
+
21
+ /** High-level outcome returned by the access gate. */
22
+ export type QQBotAccessDecision = "allow" | "block";
23
+
24
+ /** Structured reason codes used in logs and metrics. */
25
+ export const QQBOT_ACCESS_REASON = {
26
+ DM_POLICY_OPEN: "dm_policy_open",
27
+ DM_POLICY_DISABLED: "dm_policy_disabled",
28
+ DM_POLICY_ALLOWLISTED: "dm_policy_allowlisted",
29
+ DM_POLICY_NOT_ALLOWLISTED: "dm_policy_not_allowlisted",
30
+ DM_POLICY_EMPTY_ALLOWLIST: "dm_policy_empty_allowlist",
31
+ GROUP_POLICY_ALLOWED: "group_policy_allowed",
32
+ GROUP_POLICY_DISABLED: "group_policy_disabled",
33
+ GROUP_POLICY_EMPTY_ALLOWLIST: "group_policy_empty_allowlist",
34
+ GROUP_POLICY_NOT_ALLOWLISTED: "group_policy_not_allowlisted",
35
+ BOT_SELF_ECHO: "bot_self_echo",
36
+ } as const;
37
+
38
+ export type QQBotAccessReasonCode = (typeof QQBOT_ACCESS_REASON)[keyof typeof QQBOT_ACCESS_REASON];
39
+
40
+ /** Result of the access gate evaluation. */
41
+ export interface QQBotAccessResult {
42
+ decision: QQBotAccessDecision;
43
+ reasonCode: QQBotAccessReasonCode;
44
+ /** Human-readable reason suitable for logging. */
45
+ reason: string;
46
+ /** The allowFrom list that was actually compared against. */
47
+ effectiveAllowFrom: string[];
48
+ /** The groupAllowFrom list that was actually compared against. */
49
+ effectiveGroupAllowFrom: string[];
50
+ /** The dm/group policies that were used (after defaults were applied). */
51
+ dmPolicy: QQBotDmPolicy;
52
+ groupPolicy: QQBotGroupPolicy;
53
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Audio port — abstracts inbound + outbound audio conversion operations.
3
+ *
4
+ * The engine defines this interface; the bridge layer provides an
5
+ * implementation backed by `engine/utils/audio.js` functions.
6
+ */
7
+
8
+ /** Inbound audio conversion (SILK→WAV, voice detection, duration formatting). */
9
+ export interface AudioConvertPort {
10
+ convertSilkToWav(
11
+ silkPath: string,
12
+ outputDir: string,
13
+ ): Promise<{ wavPath: string; duration: number } | null>;
14
+ isVoiceAttachment(att: { content_type: string; filename?: string }): boolean;
15
+ formatDuration(seconds: number): string;
16
+ }
17
+
18
+ /** Outbound audio conversion (WAV→SILK, audio detection, transcoding). */
19
+ export interface OutboundAudioPort {
20
+ audioFileToSilkBase64(
21
+ audioPath: string,
22
+ directUploadFormats?: string[],
23
+ ): Promise<string | undefined>;
24
+ isAudioFile(pathOrUrl: string, mimeType?: string): boolean;
25
+ shouldTranscodeVoice(filePath: string): boolean;
26
+ waitForFile(filePath: string, maxWaitMs?: number): Promise<number>;
27
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Commands port — abstracts slash-command dependencies injected by the
3
+ * bridge layer (version resolvers, approve runtime getter).
4
+ *
5
+ * Eliminates global `register*` singletons in `slash-commands-impl.ts`.
6
+ */
7
+
8
+ import type { PluginRuntime } from "openclaw/plugin-sdk/core";
9
+
10
+ /** Runtime getter shape for the `/bot-approve` command. */
11
+ export type ApproveRuntimeGetter = () => {
12
+ config: Pick<PluginRuntime["config"], "current" | "replaceConfigFile">;
13
+ };
14
+
15
+ export interface CommandsPort {
16
+ /** Resolve the framework runtime version string. */
17
+ resolveVersion: () => string;
18
+ /** Plugin version string (e.g. "1.2.3"). */
19
+ pluginVersion: string;
20
+ /** Runtime getter for `/bot-approve` config management. */
21
+ approveRuntimeGetter?: ApproveRuntimeGetter;
22
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * History port — abstracts the group history cache operations.
3
+ *
4
+ * The engine defines this interface; the bridge layer provides an
5
+ * implementation backed by SDK `reply-history` functions. The engine's
6
+ * built-in implementation in `group/history.ts` is used as the default
7
+ * when no adapter is injected (standalone build).
8
+ */
9
+
10
+ /** Minimal history entry shape expected by the port. */
11
+ export interface HistoryEntryLike {
12
+ sender: string;
13
+ body: string;
14
+ timestamp?: number;
15
+ messageId?: string;
16
+ }
17
+
18
+ export interface HistoryPort {
19
+ /**
20
+ * Record a non-@ message into the pending history buffer.
21
+ * No-op when `limit <= 0` or `entry` is missing.
22
+ */
23
+ recordPendingHistoryEntry<T extends HistoryEntryLike>(params: {
24
+ historyMap: Map<string, T[]>;
25
+ historyKey: string;
26
+ entry?: T | null;
27
+ limit: number;
28
+ }): T[];
29
+
30
+ /**
31
+ * Build the full user-message string prefixed with buffered history.
32
+ * Returns `currentMessage` unchanged when no history exists.
33
+ */
34
+ buildPendingHistoryContext(params: {
35
+ historyMap: Map<string, HistoryEntryLike[]>;
36
+ historyKey: string;
37
+ limit: number;
38
+ currentMessage: string;
39
+ formatEntry: (entry: HistoryEntryLike) => string;
40
+ lineBreak?: string;
41
+ }): string;
42
+
43
+ /**
44
+ * Clear a group's pending history buffer.
45
+ * No-op when `limit <= 0`.
46
+ */
47
+ clearPendingHistory(params: {
48
+ historyMap: Map<string, HistoryEntryLike[]>;
49
+ historyKey: string;
50
+ limit: number;
51
+ }): void;
52
+ }