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
This commit is contained in:
Pavan Kumar Gondhi
2026-05-04 14:50:58 +05:30
committed by GitHub
parent 5d9752ba18
commit 1f724bc50b
11 changed files with 479 additions and 9 deletions

View File

@@ -63,6 +63,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Gate QQBot streaming command auth [AI]. (#76375) Thanks @pgondhi987.
- 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.
- 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.
- 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.

View File

@@ -0,0 +1,113 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import type {
OpenClawPluginApi,
OpenClawPluginCommandDefinition,
PluginCommandContext,
} from "openclaw/plugin-sdk/plugin-entry";
import { describe, expect, it } from "vitest";
import {
getWrittenQQBotConfig,
installCommandRuntime,
} from "../../engine/commands/slash-command-test-support.js";
import { ensurePlatformAdapter } from "../bootstrap.js";
import { registerQQBotFrameworkCommands } from "./framework-registration.js";
function createConfig(): OpenClawConfig {
return {
channels: {
qqbot: {
appId: "app",
allowFrom: ["TRUSTED_OPENID"],
streaming: false,
accounts: {
default: {
allowFrom: ["TRUSTED_OPENID"],
streaming: false,
},
},
},
},
};
}
function registerCommands(): OpenClawPluginCommandDefinition[] {
ensurePlatformAdapter();
const commands: OpenClawPluginCommandDefinition[] = [];
const api = {
logger: {},
registerCommand: (command: OpenClawPluginCommandDefinition) => {
commands.push(command);
},
} as unknown as OpenClawPluginApi;
registerQQBotFrameworkCommands(api);
return commands;
}
function findCommand(
commands: OpenClawPluginCommandDefinition[],
name: string,
): OpenClawPluginCommandDefinition {
const command = commands.find((entry) => entry.name === name);
expect(command).toBeDefined();
return command as OpenClawPluginCommandDefinition;
}
function createCommandContext(
config: OpenClawConfig,
from: string | undefined,
): PluginCommandContext {
return {
senderId: "TRUSTED_OPENID",
channel: "qqbot",
isAuthorizedSender: true,
args: "on",
commandBody: "/bot-streaming on",
config,
from,
requestConversationBinding: async () => undefined,
detachConversationBinding: async () => ({ removed: false }),
getCurrentConversationBinding: async () => null,
} as unknown as PluginCommandContext;
}
describe("registerQQBotFrameworkCommands", () => {
it("registers bot-streaming as an auth-gated framework command", () => {
const command = findCommand(registerCommands(), "bot-streaming");
expect(command.requireAuth).toBe(true);
});
it("preserves the private-chat guard for bot-streaming on generic framework calls", async () => {
const config = createConfig();
const writes: OpenClawConfig[] = [];
installCommandRuntime(config, writes);
const command = findCommand(registerCommands(), "bot-streaming");
const missingFromResult = await command.handler(createCommandContext(config, undefined));
const nonQQBotResult = await command.handler(createCommandContext(config, "generic:dm:user"));
const groupResult = await command.handler(
createCommandContext(config, "qqbot:group:GROUP_OPENID"),
);
expect(missingFromResult).toEqual({ text: "💡 请在私聊中使用此指令" });
expect(nonQQBotResult).toEqual({ text: "💡 请在私聊中使用此指令" });
expect(groupResult).toEqual({ text: "💡 请在私聊中使用此指令" });
expect(writes).toHaveLength(0);
});
it("allows bot-streaming on explicit QQBot private-chat framework calls", async () => {
const config = createConfig();
const writes: OpenClawConfig[] = [];
installCommandRuntime(config, writes);
const command = findCommand(registerCommands(), "bot-streaming");
const result = await command.handler(createCommandContext(config, "qqbot:c2c:TRUSTED_OPENID"));
const qqbot = getWrittenQQBotConfig(writes[0]);
expect(result).toMatchObject({ text: expect.stringContaining("已开启") });
expect(writes).toHaveLength(1);
expect(qqbot?.streaming).toBe(true);
expect(qqbot?.accounts?.default?.streaming).toBe(true);
});
});

View File

@@ -18,6 +18,20 @@ import { buildFrameworkSlashContext } from "./framework-context-adapter.js";
import { parseQQBotFrom } from "./from-parser.js";
import { dispatchFrameworkSlashResult } from "./result-dispatcher.js";
const PRIVATE_CHAT_ONLY_TEXT = "💡 请在私聊中使用此指令";
function isExplicitQQBotC2cFrom(from: string | undefined | null): boolean {
const raw = (from ?? "").trim();
const stripped = raw.replace(/^qqbot:/iu, "");
const colonIdx = stripped.indexOf(":");
if (colonIdx === -1) {
return false;
}
const kind = stripped.slice(0, colonIdx).toLowerCase();
const targetId = stripped.slice(colonIdx + 1).trim();
return /^qqbot:/iu.test(raw) && kind === "c2c" && targetId.length > 0;
}
export function registerQQBotFrameworkCommands(api: OpenClawPluginApi): void {
for (const cmd of getFrameworkCommands()) {
api.registerCommand({
@@ -26,6 +40,10 @@ export function registerQQBotFrameworkCommands(api: OpenClawPluginApi): void {
requireAuth: true,
acceptsArgs: true,
handler: async (ctx: PluginCommandContext) => {
if (cmd.c2cOnly && !isExplicitQQBotC2cFrom(ctx.from)) {
return { text: PRIVATE_CHAT_ONLY_TEXT };
}
const from = parseQQBotFrom(ctx.from);
const account = resolveQQBotAccount(ctx.config, ctx.accountId ?? undefined);
const slashCtx = buildFrameworkSlashContext({

View File

@@ -30,6 +30,7 @@ export function registerStreamingCommands(registry: SlashCommandRegistry): void
registry.register({
name: "bot-streaming",
description: "一键开关流式消息",
requireAuth: true,
c2cOnly: true,
usage: [
`/bot-streaming on 开启流式消息`,

View File

@@ -13,15 +13,53 @@
import { createQQBotSenderMatcher, normalizeQQBotAllowFrom } from "../access/index.js";
type SlashCommandAuthEntry = string | number;
function isSlashCommandAuthEntry(value: unknown): value is SlashCommandAuthEntry {
return typeof value === "string" || typeof value === "number";
}
function readSlashCommandAuthList(value: unknown): SlashCommandAuthEntry[] | undefined {
if (!Array.isArray(value)) {
return undefined;
}
return value.filter(isSlashCommandAuthEntry);
}
/**
* Resolve the command-specific QQBot allowlist from the root OpenClaw config.
*
* `commands.allowFrom.qqbot` takes precedence over the global
* `commands.allowFrom["*"]`, matching the framework command authorization
* contract used by registered plugin commands.
*/
export function resolveQQBotCommandsAllowFrom(cfg: unknown): SlashCommandAuthEntry[] | undefined {
if (!cfg || typeof cfg !== "object") {
return undefined;
}
const commands = (cfg as { commands?: unknown }).commands;
if (!commands || typeof commands !== "object") {
return undefined;
}
const allowFrom = (commands as { allowFrom?: unknown }).allowFrom;
if (!allowFrom || typeof allowFrom !== "object" || Array.isArray(allowFrom)) {
return undefined;
}
const byProvider = allowFrom as Record<string, unknown>;
return readSlashCommandAuthList(byProvider.qqbot) ?? readSlashCommandAuthList(byProvider["*"]);
}
/**
* Determine whether `senderId` is authorized to execute `requireAuth`
* slash commands for the given account configuration.
*
* Authorization rules:
* - `commands.allowFrom.qqbot` / `commands.allowFrom["*"]` configured →
* use that command-specific list instead of channel allowFrom
* - `allowFrom` not configured / empty / only `["*"]` → **false**
* (wildcard means "open to everyone", not explicit authorization)
* - `allowFrom` contains at least one concrete entry AND sender
* matches → **true**
* matches a concrete entry → **true**
* - Group messages use `groupAllowFrom` when present, falling back
* to `allowFrom`.
*/
@@ -30,19 +68,21 @@ export function resolveSlashCommandAuth(params: {
isGroup: boolean;
allowFrom?: Array<string | number>;
groupAllowFrom?: Array<string | number>;
commandsAllowFrom?: Array<string | number>;
}): boolean {
const rawList =
params.isGroup && params.groupAllowFrom && params.groupAllowFrom.length > 0
params.commandsAllowFrom ??
(params.isGroup && params.groupAllowFrom && params.groupAllowFrom.length > 0
? params.groupAllowFrom
: params.allowFrom;
: params.allowFrom);
const normalized = normalizeQQBotAllowFrom(rawList);
// Require at least one explicit (non-wildcard) entry.
const hasExplicitEntry = normalized.some((entry) => entry !== "*");
if (!hasExplicitEntry) {
// Require and match only explicit (non-wildcard) entries.
const explicitEntries = normalized.filter((entry) => entry !== "*");
if (explicitEntries.length === 0) {
return false;
}
return createQQBotSenderMatcher(params.senderId)(normalized);
return createQQBotSenderMatcher(params.senderId)(explicitEntries);
}

View File

@@ -0,0 +1,82 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { QueuedMessage } from "../gateway/message-queue.js";
import type { GatewayAccount } from "../gateway/types.js";
import { sendText } from "../messaging/sender.js";
import { trySlashCommand } from "./slash-command-handler.js";
import { getWrittenQQBotConfig, installCommandRuntime } from "./slash-command-test-support.js";
vi.mock("../messaging/outbound.js", () => ({
sendDocument: vi.fn(async () => undefined),
}));
vi.mock("../messaging/sender.js", () => ({
accountToCreds: vi.fn(() => ({ appId: "app", clientSecret: "" })),
buildDeliveryTarget: vi.fn(() => ({ targetType: "c2c", targetId: "TRUSTED_OPENID" })),
sendText: vi.fn(async () => undefined),
}));
function createStreamingMessage(): QueuedMessage {
return {
type: "c2c",
senderId: "TRUSTED_OPENID",
content: "/bot-streaming on",
messageId: "msg-1",
timestamp: "2026-01-01T00:00:00.000Z",
};
}
function createAccount(): GatewayAccount {
return {
accountId: "default",
appId: "app",
clientSecret: "",
markdownSupport: true,
config: {
allowFrom: ["*"],
streaming: false,
},
};
}
describe("trySlashCommand", () => {
beforeEach(() => {
vi.mocked(sendText).mockClear();
});
it("honors commands.allowFrom for pre-dispatch bot-streaming in open DM configs", async () => {
const writes: OpenClawConfig[] = [];
const config: OpenClawConfig = {
commands: {
allowFrom: {
qqbot: ["TRUSTED_OPENID"],
},
},
channels: {
qqbot: {
allowFrom: ["*"],
streaming: false,
},
},
};
installCommandRuntime(config, writes);
const result = await trySlashCommand(createStreamingMessage(), {
account: createAccount(),
cfg: config,
getMessagePeerId: () => "c2c:TRUSTED_OPENID",
getQueueSnapshot: () => ({
totalPending: 0,
activeUsers: 0,
maxConcurrentUsers: 1,
senderPending: 0,
}),
});
const qqbot = getWrittenQQBotConfig(writes[0]);
expect(result).toBe("handled");
expect(writes).toHaveLength(1);
expect(qqbot?.streaming).toBe(true);
expect(vi.mocked(sendText).mock.calls[0]?.[1]).toContain("已开启");
});
});

View File

@@ -13,7 +13,7 @@ import {
buildDeliveryTarget,
accountToCreds,
} from "../messaging/sender.js";
import { resolveSlashCommandAuth } from "./slash-command-auth.js";
import { resolveQQBotCommandsAllowFrom, resolveSlashCommandAuth } from "./slash-command-auth.js";
import { matchSlashCommand } from "./slash-commands-impl.js";
import type { SlashCommandContext, QueueSnapshot } from "./slash-commands.js";
@@ -21,6 +21,7 @@ import type { SlashCommandContext, QueueSnapshot } from "./slash-commands.js";
export interface SlashCommandHandlerContext {
account: GatewayAccount;
cfg?: unknown;
log?: EngineLogger;
getMessagePeerId: (msg: QueuedMessage) => string;
getQueueSnapshot: (peerId: string) => QueueSnapshot;
@@ -81,6 +82,7 @@ export async function trySlashCommand(
isGroup: msg.type === "group" || msg.type === "guild",
allowFrom: account.config?.allowFrom,
groupAllowFrom: account.config?.groupAllowFrom,
commandsAllowFrom: resolveQQBotCommandsAllowFrom(ctx.cfg),
}),
queueSnapshot: ctx.getQueueSnapshot(peerId),
};

View File

@@ -0,0 +1,39 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import type { CommandsPort } from "../adapter/commands.port.js";
import { initCommands } from "./slash-commands-impl.js";
type RuntimeConfigApi = ReturnType<NonNullable<CommandsPort["approveRuntimeGetter"]>>["config"];
type ReplaceConfigFile = RuntimeConfigApi["replaceConfigFile"];
type ReplaceConfigFileResult = Awaited<ReturnType<ReplaceConfigFile>>;
export type WrittenQQBotConfig = {
streaming?: unknown;
accounts?: { default?: { streaming?: unknown } };
};
export function installCommandRuntime(
currentConfig: OpenClawConfig,
writes: OpenClawConfig[],
): void {
const replaceConfigFile: ReplaceConfigFile = async (params) => {
writes.push(params.nextConfig);
return undefined as unknown as ReplaceConfigFileResult;
};
initCommands({
resolveVersion: () => "test",
pluginVersion: "0.0.0-test",
approveRuntimeGetter: () => ({
config: {
current: () => currentConfig,
replaceConfigFile,
},
}),
});
}
export function getWrittenQQBotConfig(
write: OpenClawConfig | undefined,
): WrittenQQBotConfig | undefined {
return write?.channels?.qqbot as WrittenQQBotConfig | undefined;
}

View File

@@ -1,8 +1,179 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { describe, expect, it } from "vitest";
import { getFrameworkCommands } from "./slash-commands-impl.js";
import { resolveQQBotCommandsAllowFrom, resolveSlashCommandAuth } from "./slash-command-auth.js";
import { getWrittenQQBotConfig, installCommandRuntime } from "./slash-command-test-support.js";
import { getFrameworkCommands, matchSlashCommand } from "./slash-commands-impl.js";
import type { SlashCommandContext } from "./slash-commands.js";
function createStreamingContext(overrides: Partial<SlashCommandContext> = {}): SlashCommandContext {
return {
type: "c2c",
senderId: "UNTRUSTED_OPENID",
messageId: "msg-1",
eventTimestamp: "2026-01-01T00:00:00.000Z",
receivedAt: 1,
rawContent: "/bot-streaming on",
args: "",
accountId: "default",
appId: "app",
accountConfig: { allowFrom: ["*"], streaming: false },
commandAuthorized: false,
queueSnapshot: {
totalPending: 0,
activeUsers: 0,
maxConcurrentUsers: 1,
senderPending: 0,
},
...overrides,
};
}
describe("QQBot framework slash commands", () => {
it("routes bot-approve through the auth-gated framework registry", () => {
expect(getFrameworkCommands().map((command) => command.name)).toContain("bot-approve");
});
it("routes bot-streaming through the auth-gated framework registry", () => {
expect(getFrameworkCommands().map((command) => command.name)).toContain("bot-streaming");
});
it("does not write streaming config when the sender is not command-authorized", async () => {
const writes: OpenClawConfig[] = [];
installCommandRuntime(
{
channels: {
qqbot: {
allowFrom: ["*"],
streaming: false,
},
},
},
writes,
);
const result = await matchSlashCommand(createStreamingContext());
expect(result).toContain("权限不足");
expect(writes).toHaveLength(0);
});
it("does not write streaming config when allowFrom mixes wildcard with another sender", async () => {
const writes: OpenClawConfig[] = [];
const allowFrom = ["*", "TRUSTED_OPENID"];
installCommandRuntime(
{
channels: {
qqbot: {
allowFrom,
streaming: false,
},
},
},
writes,
);
const commandAuthorized = resolveSlashCommandAuth({
senderId: "UNTRUSTED_OPENID",
isGroup: false,
allowFrom,
});
const result = await matchSlashCommand(
createStreamingContext({
accountConfig: { allowFrom, streaming: false },
commandAuthorized,
}),
);
expect(commandAuthorized).toBe(false);
expect(result).toContain("权限不足");
expect(writes).toHaveLength(0);
});
it("writes streaming config when commands.allowFrom grants the sender in open DM configs", async () => {
const writes: OpenClawConfig[] = [];
installCommandRuntime(
{
commands: {
allowFrom: {
qqbot: ["TRUSTED_OPENID"],
},
},
channels: {
qqbot: {
allowFrom: ["*"],
streaming: false,
},
},
},
writes,
);
const commandAuthorized = resolveSlashCommandAuth({
senderId: "TRUSTED_OPENID",
isGroup: false,
allowFrom: ["*"],
commandsAllowFrom: resolveQQBotCommandsAllowFrom({
commands: {
allowFrom: {
qqbot: ["TRUSTED_OPENID"],
},
},
}),
});
const result = await matchSlashCommand(
createStreamingContext({
senderId: "TRUSTED_OPENID",
accountConfig: { allowFrom: ["*"], streaming: false },
commandAuthorized,
}),
);
const qqbot = getWrittenQQBotConfig(writes[0]);
expect(commandAuthorized).toBe(true);
expect(result).toContain("已开启");
expect(writes).toHaveLength(1);
expect(qqbot?.streaming).toBe(true);
});
it("writes streaming config when the sender is command-authorized", async () => {
const writes: OpenClawConfig[] = [];
const allowFrom = ["*", "TRUSTED_OPENID"];
installCommandRuntime(
{
channels: {
qqbot: {
allowFrom,
streaming: false,
accounts: {
default: {
allowFrom,
streaming: false,
},
},
},
},
},
writes,
);
const commandAuthorized = resolveSlashCommandAuth({
senderId: "TRUSTED_OPENID",
isGroup: false,
allowFrom,
});
const result = await matchSlashCommand(
createStreamingContext({
senderId: "TRUSTED_OPENID",
accountConfig: { allowFrom, streaming: false },
commandAuthorized,
}),
);
const qqbot = getWrittenQQBotConfig(writes[0]);
expect(commandAuthorized).toBe(true);
expect(result).toContain("已开启");
expect(writes).toHaveLength(1);
expect(qqbot?.streaming).toBe(true);
expect(qqbot?.accounts?.default?.streaming).toBe(true);
});
});

View File

@@ -85,6 +85,7 @@ export interface QQBotFrameworkCommand {
name: string;
description: string;
usage?: string;
c2cOnly?: boolean;
handler: (ctx: SlashCommandContext) => SlashCommandResult | Promise<SlashCommandResult>;
}
@@ -125,6 +126,7 @@ export class SlashCommandRegistry {
name: cmd.name,
description: cmd.description,
usage: cmd.usage,
c2cOnly: cmd.c2cOnly,
handler: cmd.handler,
}));
}

View File

@@ -197,6 +197,7 @@ export class GatewayConnection {
// ---- Slash command interception ----
const slashCtx: SlashCommandHandlerContext = {
account,
cfg: this.ctx.cfg,
log,
getMessagePeerId: (msg) => this.msgQueue.getMessagePeerId(msg),
getQueueSnapshot: (peerId) => this.msgQueue.getSnapshot(peerId),