diff --git a/CHANGELOG.md b/CHANGELOG.md index 09ca90a94aa..8b65f36efcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/extensions/qqbot/src/bridge/commands/framework-registration.test.ts b/extensions/qqbot/src/bridge/commands/framework-registration.test.ts new file mode 100644 index 00000000000..b710196461c --- /dev/null +++ b/extensions/qqbot/src/bridge/commands/framework-registration.test.ts @@ -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); + }); +}); diff --git a/extensions/qqbot/src/bridge/commands/framework-registration.ts b/extensions/qqbot/src/bridge/commands/framework-registration.ts index 62db4135039..7109d3293d5 100644 --- a/extensions/qqbot/src/bridge/commands/framework-registration.ts +++ b/extensions/qqbot/src/bridge/commands/framework-registration.ts @@ -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({ diff --git a/extensions/qqbot/src/engine/commands/builtin/register-streaming.ts b/extensions/qqbot/src/engine/commands/builtin/register-streaming.ts index 73a8c99394b..21ba895604f 100644 --- a/extensions/qqbot/src/engine/commands/builtin/register-streaming.ts +++ b/extensions/qqbot/src/engine/commands/builtin/register-streaming.ts @@ -30,6 +30,7 @@ export function registerStreamingCommands(registry: SlashCommandRegistry): void registry.register({ name: "bot-streaming", description: "一键开关流式消息", + requireAuth: true, c2cOnly: true, usage: [ `/bot-streaming on 开启流式消息`, diff --git a/extensions/qqbot/src/engine/commands/slash-command-auth.ts b/extensions/qqbot/src/engine/commands/slash-command-auth.ts index 6c25fa3a272..678fa0187c1 100644 --- a/extensions/qqbot/src/engine/commands/slash-command-auth.ts +++ b/extensions/qqbot/src/engine/commands/slash-command-auth.ts @@ -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; + 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; groupAllowFrom?: Array; + commandsAllowFrom?: Array; }): 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); } diff --git a/extensions/qqbot/src/engine/commands/slash-command-handler.test.ts b/extensions/qqbot/src/engine/commands/slash-command-handler.test.ts new file mode 100644 index 00000000000..feb66ea2ef4 --- /dev/null +++ b/extensions/qqbot/src/engine/commands/slash-command-handler.test.ts @@ -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("已开启"); + }); +}); diff --git a/extensions/qqbot/src/engine/commands/slash-command-handler.ts b/extensions/qqbot/src/engine/commands/slash-command-handler.ts index bf9aef9572b..3b2c5cd7f72 100644 --- a/extensions/qqbot/src/engine/commands/slash-command-handler.ts +++ b/extensions/qqbot/src/engine/commands/slash-command-handler.ts @@ -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), }; diff --git a/extensions/qqbot/src/engine/commands/slash-command-test-support.ts b/extensions/qqbot/src/engine/commands/slash-command-test-support.ts new file mode 100644 index 00000000000..0fcce6f812a --- /dev/null +++ b/extensions/qqbot/src/engine/commands/slash-command-test-support.ts @@ -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>["config"]; +type ReplaceConfigFile = RuntimeConfigApi["replaceConfigFile"]; +type ReplaceConfigFileResult = Awaited>; + +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; +} diff --git a/extensions/qqbot/src/engine/commands/slash-commands-impl.test.ts b/extensions/qqbot/src/engine/commands/slash-commands-impl.test.ts index 27bca5a8554..e108f1be424 100644 --- a/extensions/qqbot/src/engine/commands/slash-commands-impl.test.ts +++ b/extensions/qqbot/src/engine/commands/slash-commands-impl.test.ts @@ -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 { + 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); + }); }); diff --git a/extensions/qqbot/src/engine/commands/slash-commands.ts b/extensions/qqbot/src/engine/commands/slash-commands.ts index d313f4545b5..09bf943ab9c 100644 --- a/extensions/qqbot/src/engine/commands/slash-commands.ts +++ b/extensions/qqbot/src/engine/commands/slash-commands.ts @@ -85,6 +85,7 @@ export interface QQBotFrameworkCommand { name: string; description: string; usage?: string; + c2cOnly?: boolean; handler: (ctx: SlashCommandContext) => SlashCommandResult | Promise; } @@ -125,6 +126,7 @@ export class SlashCommandRegistry { name: cmd.name, description: cmd.description, usage: cmd.usage, + c2cOnly: cmd.c2cOnly, handler: cmd.handler, })); } diff --git a/extensions/qqbot/src/engine/gateway/gateway-connection.ts b/extensions/qqbot/src/engine/gateway/gateway-connection.ts index 841f03edf84..8131bb1df6a 100644 --- a/extensions/qqbot/src/engine/gateway/gateway-connection.ts +++ b/extensions/qqbot/src/engine/gateway/gateway-connection.ts @@ -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),