@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,84 @@
1
+ import {
2
+ AllowFromListSchema,
3
+ buildChannelConfigSchema,
4
+ } from "openclaw/plugin-sdk/channel-config-schema";
5
+ import { buildSecretInputSchema } from "openclaw/plugin-sdk/secret-input";
6
+ import { z } from "zod";
7
+
8
+ const AudioFormatPolicySchema = z
9
+ .object({
10
+ sttDirectFormats: z.array(z.string()).optional(),
11
+ uploadDirectFormats: z.array(z.string()).optional(),
12
+ transcodeEnabled: z.boolean().optional(),
13
+ })
14
+ .optional();
15
+
16
+ const QQBotSttSchema = z
17
+ .object({
18
+ enabled: z.boolean().optional(),
19
+ provider: z.string().optional(),
20
+ baseUrl: z.string().optional(),
21
+ apiKey: z.string().optional(),
22
+ model: z.string().optional(),
23
+ })
24
+ .strict()
25
+ .optional();
26
+
27
+ /** When `true`, same as `mode: "partial"` and `c2cStreamApi: true` for C2C. Object form kept for legacy configs. */
28
+ const QQBotStreamingSchema = z
29
+ .union([
30
+ z.boolean(),
31
+ z
32
+ .object({
33
+ /** "partial" (default) enables block streaming; "off" disables it. */
34
+ mode: z.enum(["off", "partial"]).default("partial"),
35
+ /** @deprecated Prefer `streaming: true`. */
36
+ c2cStreamApi: z.boolean().optional(),
37
+ })
38
+ .passthrough(),
39
+ ])
40
+ .optional();
41
+
42
+ const QQBotExecApprovalsSchema = z
43
+ .object({
44
+ enabled: z.union([z.boolean(), z.literal("auto")]).optional(),
45
+ approvers: z.array(z.string()).optional(),
46
+ agentFilter: z.array(z.string()).optional(),
47
+ sessionFilter: z.array(z.string()).optional(),
48
+ target: z.enum(["dm", "channel", "both"]).optional(),
49
+ })
50
+ .strict()
51
+ .optional();
52
+
53
+ const QQBotDmPolicySchema = z.enum(["open", "allowlist", "disabled"]).optional();
54
+ const QQBotGroupPolicySchema = z.enum(["open", "allowlist", "disabled"]).optional();
55
+
56
+ const QQBotAccountSchema = z
57
+ .object({
58
+ enabled: z.boolean().optional(),
59
+ name: z.string().optional(),
60
+ appId: z.string().optional(),
61
+ clientSecret: buildSecretInputSchema().optional(),
62
+ clientSecretFile: z.string().optional(),
63
+ allowFrom: AllowFromListSchema,
64
+ groupAllowFrom: AllowFromListSchema,
65
+ dmPolicy: QQBotDmPolicySchema,
66
+ groupPolicy: QQBotGroupPolicySchema,
67
+ systemPrompt: z.string().optional(),
68
+ markdownSupport: z.boolean().optional(),
69
+ voiceDirectUploadFormats: z.array(z.string()).optional(),
70
+ audioFormatPolicy: AudioFormatPolicySchema,
71
+ urlDirectUpload: z.boolean().optional(),
72
+ upgradeUrl: z.string().optional(),
73
+ upgradeMode: z.enum(["doc", "hot-reload"]).optional(),
74
+ streaming: QQBotStreamingSchema,
75
+ execApprovals: QQBotExecApprovalsSchema,
76
+ })
77
+ .passthrough();
78
+
79
+ export const QQBotConfigSchema = QQBotAccountSchema.extend({
80
+ stt: QQBotSttSchema,
81
+ accounts: z.object({}).catchall(QQBotAccountSchema.passthrough()).optional(),
82
+ defaultAccount: z.string().optional(),
83
+ }).passthrough();
84
+ export const qqbotChannelConfigSchema = buildChannelConfigSchema(QQBotConfigSchema);
@@ -0,0 +1,364 @@
1
+ import fs from "node:fs";
2
+ import { type JsonSchemaObject, validateJsonSchemaValue } from "openclaw/plugin-sdk/config-schema";
3
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
4
+ import { describe, expect, it } from "vitest";
5
+ import { qqbotSetupAdapterShared } from "./bridge/config-shared.js";
6
+ import {
7
+ DEFAULT_ACCOUNT_ID,
8
+ resolveDefaultQQBotAccountId,
9
+ resolveQQBotAccount,
10
+ } from "./bridge/config.js";
11
+ import { qqbotSetupPlugin } from "./channel.setup.js";
12
+ import { QQBotConfigSchema } from "./config-schema.js";
13
+ import { makeQqbotDefaultAccountConfig, makeQqbotSecretRefConfig } from "./qqbot-test-support.js";
14
+
15
+ describe("qqbot config", () => {
16
+ it("accepts top-level speech overrides in the manifest schema", () => {
17
+ const manifest = JSON.parse(
18
+ fs.readFileSync(new URL("../openclaw.plugin.json", import.meta.url), "utf-8"),
19
+ ) as { configSchema: JsonSchemaObject };
20
+
21
+ const result = validateJsonSchemaValue({
22
+ schema: manifest.configSchema,
23
+ cacheKey: "qqbot.manifest.speech-overrides",
24
+ value: {
25
+ stt: {
26
+ provider: "openai",
27
+ baseUrl: "https://example.com/v1",
28
+ apiKey: "stt-key",
29
+ model: "whisper-1",
30
+ },
31
+ },
32
+ });
33
+
34
+ expect(result.ok).toBe(true);
35
+ });
36
+
37
+ it("accepts defaultAccount in the manifest schema", () => {
38
+ const manifest = JSON.parse(
39
+ fs.readFileSync(new URL("../openclaw.plugin.json", import.meta.url), "utf-8"),
40
+ ) as { configSchema: JsonSchemaObject };
41
+
42
+ const result = validateJsonSchemaValue({
43
+ schema: manifest.configSchema,
44
+ cacheKey: "qqbot.manifest.default-account",
45
+ value: {
46
+ defaultAccount: "bot2",
47
+ accounts: {
48
+ bot2: {
49
+ appId: "654321",
50
+ },
51
+ },
52
+ },
53
+ });
54
+
55
+ expect(result.ok).toBe(true);
56
+ });
57
+
58
+ it("honors configured defaultAccount when resolving the default QQ Bot account id", () => {
59
+ const cfg = {
60
+ channels: {
61
+ qqbot: {
62
+ defaultAccount: "bot2",
63
+ accounts: {
64
+ bot2: {
65
+ appId: "654321",
66
+ },
67
+ },
68
+ },
69
+ },
70
+ } as OpenClawConfig;
71
+
72
+ expect(resolveDefaultQQBotAccountId(cfg)).toBe("bot2");
73
+ });
74
+
75
+ it("accepts SecretRef-backed credentials in the runtime schema", () => {
76
+ const parsed = QQBotConfigSchema.safeParse({
77
+ defaultAccount: "bot2",
78
+ appId: "123456",
79
+ clientSecret: {
80
+ source: "env",
81
+ provider: "default",
82
+ id: "QQBOT_CLIENT_SECRET",
83
+ },
84
+ allowFrom: ["*"],
85
+ audioFormatPolicy: {
86
+ sttDirectFormats: [".wav"],
87
+ uploadDirectFormats: [".mp3"],
88
+ transcodeEnabled: false,
89
+ },
90
+ urlDirectUpload: false,
91
+ upgradeUrl: "https://docs.openclaw.ai/channels/qqbot",
92
+ upgradeMode: "doc",
93
+ accounts: {
94
+ bot2: {
95
+ appId: "654321",
96
+ clientSecret: {
97
+ source: "env",
98
+ provider: "default",
99
+ id: "QQBOT_CLIENT_SECRET_BOT2",
100
+ },
101
+ allowFrom: ["user-1"],
102
+ },
103
+ },
104
+ });
105
+
106
+ expect(parsed.success).toBe(true);
107
+ });
108
+
109
+ it("accepts account-level speech overrides as forward-compatible config", () => {
110
+ const parsed = QQBotConfigSchema.safeParse({
111
+ accounts: {
112
+ bot2: {
113
+ appId: "654321",
114
+ stt: {
115
+ provider: "openai",
116
+ },
117
+ },
118
+ },
119
+ });
120
+
121
+ expect(parsed.success).toBe(true);
122
+ });
123
+
124
+ it("preserves top-level media and upgrade config on the default account", () => {
125
+ const cfg = {
126
+ channels: {
127
+ qqbot: {
128
+ appId: "123456",
129
+ clientSecret: "secret-value",
130
+ audioFormatPolicy: {
131
+ sttDirectFormats: [".wav"],
132
+ uploadDirectFormats: [".mp3"],
133
+ transcodeEnabled: false,
134
+ },
135
+ urlDirectUpload: false,
136
+ upgradeUrl: "https://docs.openclaw.ai/channels/qqbot",
137
+ upgradeMode: "hot-reload",
138
+ },
139
+ },
140
+ } as OpenClawConfig;
141
+
142
+ const resolved = resolveQQBotAccount(cfg, DEFAULT_ACCOUNT_ID);
143
+
144
+ expect(resolved.clientSecret).toBe("secret-value");
145
+ expect(resolved.config.audioFormatPolicy).toEqual({
146
+ sttDirectFormats: [".wav"],
147
+ uploadDirectFormats: [".mp3"],
148
+ transcodeEnabled: false,
149
+ });
150
+ expect(resolved.config.urlDirectUpload).toBe(false);
151
+ expect(resolved.config.upgradeUrl).toBe("https://docs.openclaw.ai/channels/qqbot");
152
+ expect(resolved.config.upgradeMode).toBe("hot-reload");
153
+ });
154
+
155
+ it("uses configured defaultAccount when accountId is omitted", () => {
156
+ const cfg = {
157
+ channels: {
158
+ qqbot: {
159
+ defaultAccount: "bot2",
160
+ accounts: {
161
+ bot2: {
162
+ appId: "654321",
163
+ clientSecret: "secret-value",
164
+ name: "Bot Two",
165
+ },
166
+ },
167
+ },
168
+ },
169
+ } as OpenClawConfig;
170
+
171
+ const resolved = resolveQQBotAccount(cfg);
172
+
173
+ expect(resolved.accountId).toBe("bot2");
174
+ expect(resolved.appId).toBe("654321");
175
+ expect(resolved.clientSecret).toBe("secret-value");
176
+ expect(resolved.name).toBe("Bot Two");
177
+ });
178
+
179
+ it("rejects unresolved SecretRefs on runtime resolution", () => {
180
+ const cfg = makeQqbotSecretRefConfig();
181
+
182
+ expect(() => resolveQQBotAccount(cfg, DEFAULT_ACCOUNT_ID)).toThrow(
183
+ 'channels.qqbot.clientSecret: unresolved SecretRef "env:default:QQBOT_CLIENT_SECRET"',
184
+ );
185
+ });
186
+
187
+ it("allows unresolved SecretRefs for setup/status flows", () => {
188
+ const cfg = makeQqbotSecretRefConfig();
189
+
190
+ const resolved = resolveQQBotAccount(cfg, DEFAULT_ACCOUNT_ID, {
191
+ allowUnresolvedSecretRef: true,
192
+ });
193
+
194
+ expect(resolved.clientSecret).toBe("");
195
+ expect(resolved.secretSource).toBe("config");
196
+ expect(qqbotSetupPlugin.config.isConfigured?.(resolved, cfg)).toBe(true);
197
+ expect(qqbotSetupPlugin.config.describeAccount?.(resolved, cfg)?.configured).toBe(true);
198
+ });
199
+
200
+ it.each([
201
+ {
202
+ accountId: DEFAULT_ACCOUNT_ID,
203
+ inputAccountId: DEFAULT_ACCOUNT_ID,
204
+ expectedPath: ["channels", "qqbot"],
205
+ },
206
+ {
207
+ accountId: "bot2",
208
+ inputAccountId: "bot2",
209
+ expectedPath: ["channels", "qqbot", "accounts", "bot2"],
210
+ },
211
+ ])("splits --token on the first colon for $accountId", ({ inputAccountId, expectedPath }) => {
212
+ const setup = qqbotSetupPlugin.setup;
213
+ expect(setup).toBeDefined();
214
+
215
+ const next = setup!.applyAccountConfig?.({
216
+ cfg: {} as OpenClawConfig,
217
+ accountId: inputAccountId,
218
+ input: {
219
+ token: "102905186:Oi2Mg1Mh2Ni3:Pl7TpBXuHe1OmAYwKi7W",
220
+ },
221
+ }) as Record<string, unknown>;
222
+
223
+ const accountConfig = expectedPath.reduce<unknown>((value, key) => {
224
+ if (!value || typeof value !== "object") {
225
+ return undefined;
226
+ }
227
+ return (value as Record<string, unknown>)[key];
228
+ }, next) as Record<string, unknown> | undefined;
229
+
230
+ expect(accountConfig).toMatchObject({
231
+ enabled: true,
232
+ appId: "102905186",
233
+ clientSecret: "Oi2Mg1Mh2Ni3:Pl7TpBXuHe1OmAYwKi7W",
234
+ });
235
+ });
236
+
237
+ it("rejects malformed --token consistently across setup paths", () => {
238
+ const runtimeSetup = qqbotSetupAdapterShared;
239
+ const lightweightSetup = qqbotSetupPlugin.setup;
240
+ expect(runtimeSetup).toBeDefined();
241
+ expect(lightweightSetup).toBeDefined();
242
+
243
+ const input = { token: "broken", name: "Bad" };
244
+
245
+ expect(
246
+ runtimeSetup.validateInput?.({
247
+ cfg: {} as OpenClawConfig,
248
+ accountId: DEFAULT_ACCOUNT_ID,
249
+ input,
250
+ } as never),
251
+ ).toBe("QQBot --token must be in appId:clientSecret format");
252
+ expect(
253
+ lightweightSetup!.validateInput?.({
254
+ cfg: {} as OpenClawConfig,
255
+ accountId: DEFAULT_ACCOUNT_ID,
256
+ input,
257
+ } as never),
258
+ ).toBe("QQBot --token must be in appId:clientSecret format");
259
+ expect(
260
+ runtimeSetup.applyAccountConfig?.({
261
+ cfg: {} as OpenClawConfig,
262
+ accountId: DEFAULT_ACCOUNT_ID,
263
+ input,
264
+ } as never),
265
+ ).toEqual({});
266
+ expect(
267
+ lightweightSetup!.applyAccountConfig?.({
268
+ cfg: {} as OpenClawConfig,
269
+ accountId: DEFAULT_ACCOUNT_ID,
270
+ input,
271
+ } as never),
272
+ ).toEqual({});
273
+ });
274
+
275
+ it("preserves the --use-env add flow across setup paths", () => {
276
+ const runtimeSetup = qqbotSetupAdapterShared;
277
+ const lightweightSetup = qqbotSetupPlugin.setup;
278
+ expect(runtimeSetup).toBeDefined();
279
+ expect(lightweightSetup).toBeDefined();
280
+
281
+ const input = { useEnv: true, name: "Env Bot" };
282
+
283
+ expect(
284
+ runtimeSetup.applyAccountConfig?.({
285
+ cfg: {} as OpenClawConfig,
286
+ accountId: DEFAULT_ACCOUNT_ID,
287
+ input,
288
+ } as never),
289
+ ).toMatchObject({
290
+ channels: {
291
+ qqbot: {
292
+ enabled: true,
293
+ allowFrom: ["*"],
294
+ name: "Env Bot",
295
+ },
296
+ },
297
+ });
298
+ expect(
299
+ lightweightSetup!.applyAccountConfig?.({
300
+ cfg: {} as OpenClawConfig,
301
+ accountId: DEFAULT_ACCOUNT_ID,
302
+ input,
303
+ } as never),
304
+ ).toMatchObject({
305
+ channels: {
306
+ qqbot: {
307
+ enabled: true,
308
+ allowFrom: ["*"],
309
+ name: "Env Bot",
310
+ },
311
+ },
312
+ });
313
+ });
314
+
315
+ it("uses configured defaultAccount when runtime setup accountId is omitted", () => {
316
+ const runtimeSetup = qqbotSetupAdapterShared;
317
+ expect(runtimeSetup).toBeDefined();
318
+
319
+ expect(
320
+ runtimeSetup.resolveAccountId?.({
321
+ cfg: makeQqbotDefaultAccountConfig(),
322
+ accountId: undefined,
323
+ } as never),
324
+ ).toBe("bot2");
325
+ });
326
+
327
+ it("rejects --use-env for named accounts across setup paths", () => {
328
+ const runtimeSetup = qqbotSetupAdapterShared;
329
+ const lightweightSetup = qqbotSetupPlugin.setup;
330
+ expect(runtimeSetup).toBeDefined();
331
+ expect(lightweightSetup).toBeDefined();
332
+
333
+ const input = { useEnv: true, name: "Env Bot" };
334
+
335
+ expect(
336
+ runtimeSetup.validateInput?.({
337
+ cfg: {} as OpenClawConfig,
338
+ accountId: "bot2",
339
+ input,
340
+ } as never),
341
+ ).toBe("QQBot --use-env only supports the default account");
342
+ expect(
343
+ lightweightSetup!.validateInput?.({
344
+ cfg: {} as OpenClawConfig,
345
+ accountId: "bot2",
346
+ input,
347
+ } as never),
348
+ ).toBe("QQBot --use-env only supports the default account");
349
+ expect(
350
+ runtimeSetup.applyAccountConfig?.({
351
+ cfg: {} as OpenClawConfig,
352
+ accountId: "bot2",
353
+ input,
354
+ } as never),
355
+ ).toEqual({});
356
+ expect(
357
+ lightweightSetup!.applyAccountConfig?.({
358
+ cfg: {} as OpenClawConfig,
359
+ accountId: "bot2",
360
+ input,
361
+ } as never),
362
+ ).toEqual({});
363
+ });
364
+ });
@@ -0,0 +1,198 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { resolveQQBotAccess } from "./access-control.js";
3
+ import { QQBOT_ACCESS_REASON } from "./types.js";
4
+
5
+ describe("resolveQQBotAccess", () => {
6
+ describe("DM scenarios", () => {
7
+ it("allows default-open DMs when allowFrom is omitted", () => {
8
+ const result = resolveQQBotAccess({ isGroup: false, senderId: "USER1" });
9
+ expect(result).toMatchObject({
10
+ decision: "allow",
11
+ reasonCode: QQBOT_ACCESS_REASON.DM_POLICY_OPEN,
12
+ dmPolicy: "open",
13
+ effectiveAllowFrom: ["*"],
14
+ });
15
+ });
16
+
17
+ it("allows default-open DMs when allowFrom is explicitly empty", () => {
18
+ const result = resolveQQBotAccess({
19
+ isGroup: false,
20
+ senderId: "USER1",
21
+ allowFrom: [],
22
+ });
23
+ expect(result).toMatchObject({
24
+ decision: "allow",
25
+ reasonCode: QQBOT_ACCESS_REASON.DM_POLICY_OPEN,
26
+ dmPolicy: "open",
27
+ effectiveAllowFrom: ["*"],
28
+ });
29
+ });
30
+
31
+ it("allows everyone with wildcard allowFrom", () => {
32
+ const result = resolveQQBotAccess({
33
+ isGroup: false,
34
+ senderId: "USER1",
35
+ allowFrom: ["*"],
36
+ });
37
+ expect(result.decision).toBe("allow");
38
+ expect(result.reasonCode).toBe(QQBOT_ACCESS_REASON.DM_POLICY_OPEN);
39
+ });
40
+
41
+ it("allows sender matching the allowlist", () => {
42
+ const result = resolveQQBotAccess({
43
+ isGroup: false,
44
+ senderId: "USER1",
45
+ allowFrom: ["USER1"],
46
+ });
47
+ expect(result.decision).toBe("allow");
48
+ expect(result.reasonCode).toBe(QQBOT_ACCESS_REASON.DM_POLICY_ALLOWLISTED);
49
+ expect(result.dmPolicy).toBe("allowlist");
50
+ });
51
+
52
+ it("allows open mode when sender matches restrictive allowFrom", () => {
53
+ const result = resolveQQBotAccess({
54
+ isGroup: false,
55
+ senderId: "USER1",
56
+ allowFrom: ["USER1"],
57
+ dmPolicy: "open",
58
+ });
59
+ expect(result.decision).toBe("allow");
60
+ expect(result.reasonCode).toBe(QQBOT_ACCESS_REASON.DM_POLICY_ALLOWLISTED);
61
+ expect(result.reason).toBe("dmPolicy=open (allowlisted)");
62
+ });
63
+
64
+ it("blocks sender not in allowlist", () => {
65
+ const result = resolveQQBotAccess({
66
+ isGroup: false,
67
+ senderId: "USER2",
68
+ allowFrom: ["USER1"],
69
+ });
70
+ expect(result.decision).toBe("block");
71
+ expect(result.reasonCode).toBe(QQBOT_ACCESS_REASON.DM_POLICY_NOT_ALLOWLISTED);
72
+ });
73
+
74
+ it("blocks DM when dmPolicy=disabled (even with wildcard)", () => {
75
+ const result = resolveQQBotAccess({
76
+ isGroup: false,
77
+ senderId: "USER1",
78
+ allowFrom: ["*"],
79
+ dmPolicy: "disabled",
80
+ });
81
+ expect(result.decision).toBe("block");
82
+ expect(result.reasonCode).toBe(QQBOT_ACCESS_REASON.DM_POLICY_DISABLED);
83
+ });
84
+
85
+ it("blocks DM with allowlist policy but empty allowlist", () => {
86
+ const result = resolveQQBotAccess({
87
+ isGroup: false,
88
+ senderId: "USER1",
89
+ dmPolicy: "allowlist",
90
+ });
91
+ expect(result.decision).toBe("block");
92
+ expect(result.reasonCode).toBe(QQBOT_ACCESS_REASON.DM_POLICY_EMPTY_ALLOWLIST);
93
+ });
94
+
95
+ it("normalizes qqbot: prefix and case when matching", () => {
96
+ const result = resolveQQBotAccess({
97
+ isGroup: false,
98
+ senderId: "qqbot:user1",
99
+ allowFrom: ["QQBot:USER1"],
100
+ });
101
+ expect(result.decision).toBe("allow");
102
+ });
103
+ });
104
+
105
+ describe("group scenarios", () => {
106
+ it("inherits allowFrom for group access when no groupAllowFrom is set", () => {
107
+ const allowed = resolveQQBotAccess({
108
+ isGroup: true,
109
+ senderId: "USER1",
110
+ allowFrom: ["USER1"],
111
+ });
112
+ expect(allowed.decision).toBe("allow");
113
+ expect(allowed.groupPolicy).toBe("allowlist");
114
+
115
+ const blocked = resolveQQBotAccess({
116
+ isGroup: true,
117
+ senderId: "USER2",
118
+ allowFrom: ["USER1"],
119
+ });
120
+ expect(blocked.decision).toBe("block");
121
+ expect(blocked.reasonCode).toBe(QQBOT_ACCESS_REASON.GROUP_POLICY_NOT_ALLOWLISTED);
122
+ });
123
+
124
+ it("uses groupAllowFrom when explicitly provided", () => {
125
+ const result = resolveQQBotAccess({
126
+ isGroup: true,
127
+ senderId: "USER2",
128
+ allowFrom: ["USER1"],
129
+ groupAllowFrom: ["USER2"],
130
+ });
131
+ expect(result.decision).toBe("allow");
132
+ });
133
+
134
+ it("blocks when groupPolicy=disabled", () => {
135
+ const result = resolveQQBotAccess({
136
+ isGroup: true,
137
+ senderId: "USER1",
138
+ allowFrom: ["*"],
139
+ groupPolicy: "disabled",
140
+ });
141
+ expect(result.decision).toBe("block");
142
+ expect(result.reasonCode).toBe(QQBOT_ACCESS_REASON.GROUP_POLICY_DISABLED);
143
+ });
144
+
145
+ it("allows anyone when groupPolicy=open", () => {
146
+ const result = resolveQQBotAccess({
147
+ isGroup: true,
148
+ senderId: "RANDOM_USER",
149
+ allowFrom: ["USER1"],
150
+ groupPolicy: "open",
151
+ });
152
+ expect(result.decision).toBe("allow");
153
+ expect(result.reasonCode).toBe(QQBOT_ACCESS_REASON.GROUP_POLICY_ALLOWED);
154
+ });
155
+
156
+ it("blocks when groupPolicy=allowlist but list is empty", () => {
157
+ const result = resolveQQBotAccess({
158
+ isGroup: true,
159
+ senderId: "USER1",
160
+ groupPolicy: "allowlist",
161
+ });
162
+ expect(result.decision).toBe("block");
163
+ expect(result.reasonCode).toBe(QQBOT_ACCESS_REASON.GROUP_POLICY_EMPTY_ALLOWLIST);
164
+ });
165
+ });
166
+
167
+ describe("backwards compatibility (legacy allowFrom-only configs)", () => {
168
+ it("legacy allowFrom=['*'] stays fully open for both DM and group", () => {
169
+ const dm = resolveQQBotAccess({
170
+ isGroup: false,
171
+ senderId: "RANDOM",
172
+ allowFrom: ["*"],
173
+ });
174
+ const group = resolveQQBotAccess({
175
+ isGroup: true,
176
+ senderId: "RANDOM",
177
+ allowFrom: ["*"],
178
+ });
179
+ expect(dm.decision).toBe("allow");
180
+ expect(group.decision).toBe("allow");
181
+ });
182
+
183
+ it("legacy allowFrom=['USER1'] locks down both DM and group to USER1", () => {
184
+ const allowedDm = resolveQQBotAccess({
185
+ isGroup: false,
186
+ senderId: "USER1",
187
+ allowFrom: ["USER1"],
188
+ });
189
+ const blockedGroup = resolveQQBotAccess({
190
+ isGroup: true,
191
+ senderId: "INTRUDER",
192
+ allowFrom: ["USER1"],
193
+ });
194
+ expect(allowedDm.decision).toBe("allow");
195
+ expect(blockedGroup.decision).toBe("block");
196
+ });
197
+ });
198
+ });