diff --git a/extensions/qqbot/src/bridge/tools/channel.ts b/extensions/qqbot/src/bridge/tools/channel.ts index 6bfece68f6c1..231723b35302 100644 --- a/extensions/qqbot/src/bridge/tools/channel.ts +++ b/extensions/qqbot/src/bridge/tools/channel.ts @@ -52,7 +52,11 @@ export function registerChannelTool(api: OpenClawPluginApi): void { async execute(_toolCallId, params) { const { getAccessToken } = await import("../../engine/messaging/sender.js"); const accessToken = await getAccessToken(account.appId, account.clientSecret); - return executeChannelApi(params as ChannelApiParams, { accessToken }); + return executeChannelApi(params as ChannelApiParams, { + accessToken, + cfg, + accountId: firstAccountId, + }); }, }, { name: "qqbot_channel_api" }, diff --git a/extensions/qqbot/src/engine/tools/channel-api.test.ts b/extensions/qqbot/src/engine/tools/channel-api.test.ts index 4b45d1a29f77..987a25600d65 100644 --- a/extensions/qqbot/src/engine/tools/channel-api.test.ts +++ b/extensions/qqbot/src/engine/tools/channel-api.test.ts @@ -1,4 +1,6 @@ // Qqbot tests cover channel-api tool behavior. + +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createStreamingResponse } from "../../../../test-support/streaming-error-response.js"; @@ -14,6 +16,10 @@ vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => { import { executeChannelApi } from "./channel-api.js"; +function qqbotCfg(qqbot: Record): OpenClawConfig { + return { channels: { qqbot } } as OpenClawConfig; +} + function cancelTrackedResponse( text: string, init: ResponseInit, @@ -79,6 +85,110 @@ describe("executeChannelApi", () => { }); }); + it("blocks guild listing when qqbot groups are scoped", async () => { + const result = await executeChannelApi( + { method: "GET", path: "/users/@me/guilds" }, + { + accessToken: "token-1", + cfg: qqbotCfg({ groups: { G1: {} } }), + }, + ); + + expect(result.details).toEqual({ + error: "QQ channel API guild listing is unavailable while qqbot groups are scoped.", + path: "/users/@me/guilds", + }); + expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled(); + }); + + it("blocks guild paths when qqbot groups are scoped", async () => { + const result = await executeChannelApi( + { method: "GET", path: "/guilds/G1/channels" }, + { + accessToken: "token-1", + cfg: qqbotCfg({ groups: { G1: {} } }), + }, + ); + + expect(result.details).toEqual({ + error: "QQ channel API guild paths are unavailable while qqbot groups are scoped.", + path: "/guilds/G1/channels", + }); + expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled(); + }); + + it("blocks channel paths when qqbot groups are scoped", async () => { + const result = await executeChannelApi( + { method: "GET", path: "/channels/C1/threads" }, + { + accessToken: "token-1", + cfg: qqbotCfg({ groups: { C1: {} } }), + }, + ); + + expect(result.details).toEqual({ + error: "QQ channel API channel paths are unavailable while qqbot groups are scoped.", + path: "/channels/C1/threads", + }); + expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled(); + }); + + it("allows guild paths with wildcard qqbot groups", async () => { + const release = vi.fn(async () => {}); + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: new Response(JSON.stringify({ id: "channel-1" }), { status: 200 }), + release, + }); + + const result = await executeChannelApi( + { method: "GET", path: "/guilds/G1/channels" }, + { + accessToken: "token-1", + cfg: qqbotCfg({ groups: { "*": {} } }), + }, + ); + + expect(result.details).toMatchObject({ + success: true, + status: 200, + path: "/guilds/G1/channels", + }); + expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith( + expect.objectContaining({ + url: "https://api.sgroup.qq.com/guilds/G1/channels", + }), + ); + expect(release).toHaveBeenCalledTimes(1); + }); + + it("allows global guild listing with wildcard qqbot groups", async () => { + const release = vi.fn(async () => {}); + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: new Response(JSON.stringify([{ id: "guild-1" }]), { status: 200 }), + release, + }); + + const result = await executeChannelApi( + { method: "GET", path: "/users/@me/guilds" }, + { + accessToken: "token-1", + cfg: qqbotCfg({ groups: { "*": {} } }), + }, + ); + + expect(result.details).toMatchObject({ + success: true, + status: 200, + path: "/users/@me/guilds", + }); + expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith( + expect.objectContaining({ + url: "https://api.sgroup.qq.com/users/@me/guilds", + }), + ); + expect(release).toHaveBeenCalledTimes(1); + }); + it("bounds error bodies without using response.text()", async () => { const release = vi.fn(async () => {}); const tracked = cancelTrackedResponse(`${"channel api unavailable ".repeat(1024)}tail`, { diff --git a/extensions/qqbot/src/engine/tools/channel-api.ts b/extensions/qqbot/src/engine/tools/channel-api.ts index 14e9c6c0ce68..6b37a741ffc3 100644 --- a/extensions/qqbot/src/engine/tools/channel-api.ts +++ b/extensions/qqbot/src/engine/tools/channel-api.ts @@ -8,6 +8,8 @@ * validation, fetch, and structured response formatting. */ +import { resolveChannelGroupPolicy } from "openclaw/plugin-sdk/channel-policy"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { readProviderTextResponse, readResponseTextLimited, @@ -152,6 +154,70 @@ function decodePathSegments(path: string): string[] | null { } } +type ChannelApiPathTarget = + | { kind: "guild-list" } + | { kind: "guild"; id: string } + | { kind: "channel"; id: string } + | { kind: "unverified" }; + +function resolvePathTarget(path: string): ChannelApiPathTarget { + const segments = decodePathSegments(path); + if (!segments || segments.length === 0) { + return { kind: "unverified" }; + } + + const [scope, firstId, second] = segments; + if ( + scope?.toLowerCase() === "users" && + firstId?.toLowerCase() === "@me" && + second?.toLowerCase() === "guilds" + ) { + return { kind: "guild-list" }; + } + if (scope?.toLowerCase() === "guilds" && firstId) { + return { kind: "guild", id: firstId }; + } + if (scope?.toLowerCase() === "channels" && firstId) { + return { kind: "channel", id: firstId }; + } + return { kind: "unverified" }; +} + +function validateConfiguredTargetScope( + path: string, + options: ChannelApiExecuteOptions, +): string | null { + if (!options.cfg) { + return null; + } + + const basePolicy = resolveChannelGroupPolicy({ + cfg: options.cfg, + channel: "qqbot", + accountId: options.accountId, + groupIdCaseInsensitive: true, + }); + if (!basePolicy.allowlistEnabled && basePolicy.allowed) { + return null; + } + + const target = resolvePathTarget(path); + if (target.kind === "guild-list") { + return basePolicy.allowed + ? null + : "QQ channel API guild listing is unavailable while qqbot groups are scoped."; + } + if (target.kind === "unverified") { + return basePolicy.allowed + ? null + : "QQ channel API path target cannot be verified against configured qqbot groups."; + } + + return basePolicy.allowed + ? null + : `QQ channel API ${target.kind} paths are unavailable while qqbot groups are scoped.`; +} + function isBulkAnnouncementDeletePath(path: string): boolean { const segments = decodePathSegments(path); return Boolean( @@ -182,6 +248,8 @@ function validateDeleteConfirmation(params: ChannelApiParams): string | null { */ interface ChannelApiExecuteOptions { accessToken: string; + cfg?: OpenClawConfig; + accountId?: string | null; } /** @@ -215,6 +283,11 @@ export async function executeChannelApi( return json({ error: pathError }); } + const scopeError = validateConfiguredTargetScope(params.path, options); + if (scopeError) { + return json({ error: scopeError, path: params.path }); + } + const confirmationError = validateDeleteConfirmation({ ...params, method }); if (confirmationError) { return json({ error: confirmationError, path: params.path });