Skip to content

Commit 1f724bc

Browse files
authored
Gate QQBot streaming command auth [AI] (#76375)
* fix: gate QQBot streaming command * addressing codex review * addressing review-skill * addressing review-skill * addressing codex review * addressing claude review * docs: add changelog entry for PR merge
1 parent 5d9752b commit 1f724bc

11 files changed

Lines changed: 479 additions & 9 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ Docs: https://docs.openclaw.ai
6363

6464
### Fixes
6565

66+
- Gate QQBot streaming command auth [AI]. (#76375) Thanks @pgondhi987.
6667
- Plugins/release: make the published npm runtime verifier reject blank `openclaw.runtimeExtensions` entries instead of treating them as absent and passing via inferred outputs. Thanks @vincentkoc.
6768
- Web fetch: scope provider fallback cache entries by the selected fetch provider so config reloads cannot reuse another provider's cached fallback payload. Thanks @vincentkoc.
6869
- Web search: honor late-bound `tools.web.search.enabled: false` during tool execution so config reloads cannot leave an already-created `web_search` tool runnable. Thanks @vincentkoc.
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
2+
import type {
3+
OpenClawPluginApi,
4+
OpenClawPluginCommandDefinition,
5+
PluginCommandContext,
6+
} from "openclaw/plugin-sdk/plugin-entry";
7+
import { describe, expect, it } from "vitest";
8+
import {
9+
getWrittenQQBotConfig,
10+
installCommandRuntime,
11+
} from "../../engine/commands/slash-command-test-support.js";
12+
import { ensurePlatformAdapter } from "../bootstrap.js";
13+
import { registerQQBotFrameworkCommands } from "./framework-registration.js";
14+
15+
function createConfig(): OpenClawConfig {
16+
return {
17+
channels: {
18+
qqbot: {
19+
appId: "app",
20+
allowFrom: ["TRUSTED_OPENID"],
21+
streaming: false,
22+
accounts: {
23+
default: {
24+
allowFrom: ["TRUSTED_OPENID"],
25+
streaming: false,
26+
},
27+
},
28+
},
29+
},
30+
};
31+
}
32+
33+
function registerCommands(): OpenClawPluginCommandDefinition[] {
34+
ensurePlatformAdapter();
35+
const commands: OpenClawPluginCommandDefinition[] = [];
36+
const api = {
37+
logger: {},
38+
registerCommand: (command: OpenClawPluginCommandDefinition) => {
39+
commands.push(command);
40+
},
41+
} as unknown as OpenClawPluginApi;
42+
43+
registerQQBotFrameworkCommands(api);
44+
return commands;
45+
}
46+
47+
function findCommand(
48+
commands: OpenClawPluginCommandDefinition[],
49+
name: string,
50+
): OpenClawPluginCommandDefinition {
51+
const command = commands.find((entry) => entry.name === name);
52+
expect(command).toBeDefined();
53+
return command as OpenClawPluginCommandDefinition;
54+
}
55+
56+
function createCommandContext(
57+
config: OpenClawConfig,
58+
from: string | undefined,
59+
): PluginCommandContext {
60+
return {
61+
senderId: "TRUSTED_OPENID",
62+
channel: "qqbot",
63+
isAuthorizedSender: true,
64+
args: "on",
65+
commandBody: "/bot-streaming on",
66+
config,
67+
from,
68+
requestConversationBinding: async () => undefined,
69+
detachConversationBinding: async () => ({ removed: false }),
70+
getCurrentConversationBinding: async () => null,
71+
} as unknown as PluginCommandContext;
72+
}
73+
74+
describe("registerQQBotFrameworkCommands", () => {
75+
it("registers bot-streaming as an auth-gated framework command", () => {
76+
const command = findCommand(registerCommands(), "bot-streaming");
77+
78+
expect(command.requireAuth).toBe(true);
79+
});
80+
81+
it("preserves the private-chat guard for bot-streaming on generic framework calls", async () => {
82+
const config = createConfig();
83+
const writes: OpenClawConfig[] = [];
84+
installCommandRuntime(config, writes);
85+
const command = findCommand(registerCommands(), "bot-streaming");
86+
87+
const missingFromResult = await command.handler(createCommandContext(config, undefined));
88+
const nonQQBotResult = await command.handler(createCommandContext(config, "generic:dm:user"));
89+
const groupResult = await command.handler(
90+
createCommandContext(config, "qqbot:group:GROUP_OPENID"),
91+
);
92+
93+
expect(missingFromResult).toEqual({ text: "💡 请在私聊中使用此指令" });
94+
expect(nonQQBotResult).toEqual({ text: "💡 请在私聊中使用此指令" });
95+
expect(groupResult).toEqual({ text: "💡 请在私聊中使用此指令" });
96+
expect(writes).toHaveLength(0);
97+
});
98+
99+
it("allows bot-streaming on explicit QQBot private-chat framework calls", async () => {
100+
const config = createConfig();
101+
const writes: OpenClawConfig[] = [];
102+
installCommandRuntime(config, writes);
103+
const command = findCommand(registerCommands(), "bot-streaming");
104+
105+
const result = await command.handler(createCommandContext(config, "qqbot:c2c:TRUSTED_OPENID"));
106+
107+
const qqbot = getWrittenQQBotConfig(writes[0]);
108+
expect(result).toMatchObject({ text: expect.stringContaining("已开启") });
109+
expect(writes).toHaveLength(1);
110+
expect(qqbot?.streaming).toBe(true);
111+
expect(qqbot?.accounts?.default?.streaming).toBe(true);
112+
});
113+
});

extensions/qqbot/src/bridge/commands/framework-registration.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,20 @@ import { buildFrameworkSlashContext } from "./framework-context-adapter.js";
1818
import { parseQQBotFrom } from "./from-parser.js";
1919
import { dispatchFrameworkSlashResult } from "./result-dispatcher.js";
2020

21+
const PRIVATE_CHAT_ONLY_TEXT = "💡 请在私聊中使用此指令";
22+
23+
function isExplicitQQBotC2cFrom(from: string | undefined | null): boolean {
24+
const raw = (from ?? "").trim();
25+
const stripped = raw.replace(/^qqbot:/iu, "");
26+
const colonIdx = stripped.indexOf(":");
27+
if (colonIdx === -1) {
28+
return false;
29+
}
30+
const kind = stripped.slice(0, colonIdx).toLowerCase();
31+
const targetId = stripped.slice(colonIdx + 1).trim();
32+
return /^qqbot:/iu.test(raw) && kind === "c2c" && targetId.length > 0;
33+
}
34+
2135
export function registerQQBotFrameworkCommands(api: OpenClawPluginApi): void {
2236
for (const cmd of getFrameworkCommands()) {
2337
api.registerCommand({
@@ -26,6 +40,10 @@ export function registerQQBotFrameworkCommands(api: OpenClawPluginApi): void {
2640
requireAuth: true,
2741
acceptsArgs: true,
2842
handler: async (ctx: PluginCommandContext) => {
43+
if (cmd.c2cOnly && !isExplicitQQBotC2cFrom(ctx.from)) {
44+
return { text: PRIVATE_CHAT_ONLY_TEXT };
45+
}
46+
2947
const from = parseQQBotFrom(ctx.from);
3048
const account = resolveQQBotAccount(ctx.config, ctx.accountId ?? undefined);
3149
const slashCtx = buildFrameworkSlashContext({

extensions/qqbot/src/engine/commands/builtin/register-streaming.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export function registerStreamingCommands(registry: SlashCommandRegistry): void
3030
registry.register({
3131
name: "bot-streaming",
3232
description: "一键开关流式消息",
33+
requireAuth: true,
3334
c2cOnly: true,
3435
usage: [
3536
`/bot-streaming on 开启流式消息`,

extensions/qqbot/src/engine/commands/slash-command-auth.ts

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,53 @@
1313

1414
import { createQQBotSenderMatcher, normalizeQQBotAllowFrom } from "../access/index.js";
1515

16+
type SlashCommandAuthEntry = string | number;
17+
18+
function isSlashCommandAuthEntry(value: unknown): value is SlashCommandAuthEntry {
19+
return typeof value === "string" || typeof value === "number";
20+
}
21+
22+
function readSlashCommandAuthList(value: unknown): SlashCommandAuthEntry[] | undefined {
23+
if (!Array.isArray(value)) {
24+
return undefined;
25+
}
26+
return value.filter(isSlashCommandAuthEntry);
27+
}
28+
29+
/**
30+
* Resolve the command-specific QQBot allowlist from the root OpenClaw config.
31+
*
32+
* `commands.allowFrom.qqbot` takes precedence over the global
33+
* `commands.allowFrom["*"]`, matching the framework command authorization
34+
* contract used by registered plugin commands.
35+
*/
36+
export function resolveQQBotCommandsAllowFrom(cfg: unknown): SlashCommandAuthEntry[] | undefined {
37+
if (!cfg || typeof cfg !== "object") {
38+
return undefined;
39+
}
40+
const commands = (cfg as { commands?: unknown }).commands;
41+
if (!commands || typeof commands !== "object") {
42+
return undefined;
43+
}
44+
const allowFrom = (commands as { allowFrom?: unknown }).allowFrom;
45+
if (!allowFrom || typeof allowFrom !== "object" || Array.isArray(allowFrom)) {
46+
return undefined;
47+
}
48+
const byProvider = allowFrom as Record<string, unknown>;
49+
return readSlashCommandAuthList(byProvider.qqbot) ?? readSlashCommandAuthList(byProvider["*"]);
50+
}
51+
1652
/**
1753
* Determine whether `senderId` is authorized to execute `requireAuth`
1854
* slash commands for the given account configuration.
1955
*
2056
* Authorization rules:
57+
* - `commands.allowFrom.qqbot` / `commands.allowFrom["*"]` configured →
58+
* use that command-specific list instead of channel allowFrom
2159
* - `allowFrom` not configured / empty / only `["*"]` → **false**
2260
* (wildcard means "open to everyone", not explicit authorization)
2361
* - `allowFrom` contains at least one concrete entry AND sender
24-
* matches → **true**
62+
* matches a concrete entry → **true**
2563
* - Group messages use `groupAllowFrom` when present, falling back
2664
* to `allowFrom`.
2765
*/
@@ -30,19 +68,21 @@ export function resolveSlashCommandAuth(params: {
3068
isGroup: boolean;
3169
allowFrom?: Array<string | number>;
3270
groupAllowFrom?: Array<string | number>;
71+
commandsAllowFrom?: Array<string | number>;
3372
}): boolean {
3473
const rawList =
35-
params.isGroup && params.groupAllowFrom && params.groupAllowFrom.length > 0
74+
params.commandsAllowFrom ??
75+
(params.isGroup && params.groupAllowFrom && params.groupAllowFrom.length > 0
3676
? params.groupAllowFrom
37-
: params.allowFrom;
77+
: params.allowFrom);
3878

3979
const normalized = normalizeQQBotAllowFrom(rawList);
4080

41-
// Require at least one explicit (non-wildcard) entry.
42-
const hasExplicitEntry = normalized.some((entry) => entry !== "*");
43-
if (!hasExplicitEntry) {
81+
// Require and match only explicit (non-wildcard) entries.
82+
const explicitEntries = normalized.filter((entry) => entry !== "*");
83+
if (explicitEntries.length === 0) {
4484
return false;
4585
}
4686

47-
return createQQBotSenderMatcher(params.senderId)(normalized);
87+
return createQQBotSenderMatcher(params.senderId)(explicitEntries);
4888
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
2+
import { beforeEach, describe, expect, it, vi } from "vitest";
3+
import type { QueuedMessage } from "../gateway/message-queue.js";
4+
import type { GatewayAccount } from "../gateway/types.js";
5+
import { sendText } from "../messaging/sender.js";
6+
import { trySlashCommand } from "./slash-command-handler.js";
7+
import { getWrittenQQBotConfig, installCommandRuntime } from "./slash-command-test-support.js";
8+
9+
vi.mock("../messaging/outbound.js", () => ({
10+
sendDocument: vi.fn(async () => undefined),
11+
}));
12+
13+
vi.mock("../messaging/sender.js", () => ({
14+
accountToCreds: vi.fn(() => ({ appId: "app", clientSecret: "" })),
15+
buildDeliveryTarget: vi.fn(() => ({ targetType: "c2c", targetId: "TRUSTED_OPENID" })),
16+
sendText: vi.fn(async () => undefined),
17+
}));
18+
19+
function createStreamingMessage(): QueuedMessage {
20+
return {
21+
type: "c2c",
22+
senderId: "TRUSTED_OPENID",
23+
content: "/bot-streaming on",
24+
messageId: "msg-1",
25+
timestamp: "2026-01-01T00:00:00.000Z",
26+
};
27+
}
28+
29+
function createAccount(): GatewayAccount {
30+
return {
31+
accountId: "default",
32+
appId: "app",
33+
clientSecret: "",
34+
markdownSupport: true,
35+
config: {
36+
allowFrom: ["*"],
37+
streaming: false,
38+
},
39+
};
40+
}
41+
42+
describe("trySlashCommand", () => {
43+
beforeEach(() => {
44+
vi.mocked(sendText).mockClear();
45+
});
46+
47+
it("honors commands.allowFrom for pre-dispatch bot-streaming in open DM configs", async () => {
48+
const writes: OpenClawConfig[] = [];
49+
const config: OpenClawConfig = {
50+
commands: {
51+
allowFrom: {
52+
qqbot: ["TRUSTED_OPENID"],
53+
},
54+
},
55+
channels: {
56+
qqbot: {
57+
allowFrom: ["*"],
58+
streaming: false,
59+
},
60+
},
61+
};
62+
installCommandRuntime(config, writes);
63+
64+
const result = await trySlashCommand(createStreamingMessage(), {
65+
account: createAccount(),
66+
cfg: config,
67+
getMessagePeerId: () => "c2c:TRUSTED_OPENID",
68+
getQueueSnapshot: () => ({
69+
totalPending: 0,
70+
activeUsers: 0,
71+
maxConcurrentUsers: 1,
72+
senderPending: 0,
73+
}),
74+
});
75+
76+
const qqbot = getWrittenQQBotConfig(writes[0]);
77+
expect(result).toBe("handled");
78+
expect(writes).toHaveLength(1);
79+
expect(qqbot?.streaming).toBe(true);
80+
expect(vi.mocked(sendText).mock.calls[0]?.[1]).toContain("已开启");
81+
});
82+
});

extensions/qqbot/src/engine/commands/slash-command-handler.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,15 @@ import {
1313
buildDeliveryTarget,
1414
accountToCreds,
1515
} from "../messaging/sender.js";
16-
import { resolveSlashCommandAuth } from "./slash-command-auth.js";
16+
import { resolveQQBotCommandsAllowFrom, resolveSlashCommandAuth } from "./slash-command-auth.js";
1717
import { matchSlashCommand } from "./slash-commands-impl.js";
1818
import type { SlashCommandContext, QueueSnapshot } from "./slash-commands.js";
1919

2020
// ============ Types ============
2121

2222
export interface SlashCommandHandlerContext {
2323
account: GatewayAccount;
24+
cfg?: unknown;
2425
log?: EngineLogger;
2526
getMessagePeerId: (msg: QueuedMessage) => string;
2627
getQueueSnapshot: (peerId: string) => QueueSnapshot;
@@ -81,6 +82,7 @@ export async function trySlashCommand(
8182
isGroup: msg.type === "group" || msg.type === "guild",
8283
allowFrom: account.config?.allowFrom,
8384
groupAllowFrom: account.config?.groupAllowFrom,
85+
commandsAllowFrom: resolveQQBotCommandsAllowFrom(ctx.cfg),
8486
}),
8587
queueSnapshot: ctx.getQueueSnapshot(peerId),
8688
};
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
2+
import type { CommandsPort } from "../adapter/commands.port.js";
3+
import { initCommands } from "./slash-commands-impl.js";
4+
5+
type RuntimeConfigApi = ReturnType<NonNullable<CommandsPort["approveRuntimeGetter"]>>["config"];
6+
type ReplaceConfigFile = RuntimeConfigApi["replaceConfigFile"];
7+
type ReplaceConfigFileResult = Awaited<ReturnType<ReplaceConfigFile>>;
8+
9+
export type WrittenQQBotConfig = {
10+
streaming?: unknown;
11+
accounts?: { default?: { streaming?: unknown } };
12+
};
13+
14+
export function installCommandRuntime(
15+
currentConfig: OpenClawConfig,
16+
writes: OpenClawConfig[],
17+
): void {
18+
const replaceConfigFile: ReplaceConfigFile = async (params) => {
19+
writes.push(params.nextConfig);
20+
return undefined as unknown as ReplaceConfigFileResult;
21+
};
22+
23+
initCommands({
24+
resolveVersion: () => "test",
25+
pluginVersion: "0.0.0-test",
26+
approveRuntimeGetter: () => ({
27+
config: {
28+
current: () => currentConfig,
29+
replaceConfigFile,
30+
},
31+
}),
32+
});
33+
}
34+
35+
export function getWrittenQQBotConfig(
36+
write: OpenClawConfig | undefined,
37+
): WrittenQQBotConfig | undefined {
38+
return write?.channels?.qqbot as WrittenQQBotConfig | undefined;
39+
}

0 commit comments

Comments
 (0)