diff --git a/extensions/qqbot/src/bridge/tools/channel.test.ts b/extensions/qqbot/src/bridge/tools/channel.test.ts new file mode 100644 index 000000000000..225d867f0d95 --- /dev/null +++ b/extensions/qqbot/src/bridge/tools/channel.test.ts @@ -0,0 +1,168 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import type { + AnyAgentTool, + OpenClawPluginApi, + OpenClawPluginToolContext, +} from "openclaw/plugin-sdk/core"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { fetchWithSsrFGuardMock, getAccessTokenMock } = vi.hoisted(() => ({ + fetchWithSsrFGuardMock: vi.fn(), + getAccessTokenMock: vi.fn(), +})); + +vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, fetchWithSsrFGuard: fetchWithSsrFGuardMock }; +}); + +vi.mock("../../engine/messaging/sender.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, getAccessToken: getAccessTokenMock }; +}); + +import { ensurePlatformAdapter } from "../bootstrap.js"; +import { registerChannelTool } from "./channel.js"; + +const cfg = { + channels: { + qqbot: { + appId: "app-a", + clientSecret: "secret-a", + accounts: { + bot2: { + appId: "app-b", + clientSecret: "secret-b", + }, + }, + }, + }, +} as OpenClawConfig; + +function registerToolFactory( + config: OpenClawConfig = cfg, +): (context: OpenClawPluginToolContext) => AnyAgentTool | null { + let factory: ((context: OpenClawPluginToolContext) => AnyAgentTool | null) | undefined; + const api = { + config, + registerTool( + tool: AnyAgentTool | ((context: OpenClawPluginToolContext) => AnyAgentTool | null), + ) { + if (typeof tool === "function") { + factory = tool; + } + }, + } as unknown as OpenClawPluginApi; + registerChannelTool(api); + if (!factory) { + throw new Error("Expected QQBot channel API tool factory"); + } + return factory; +} + +describe("bridge/tools/channel", () => { + beforeEach(() => { + ensurePlatformAdapter(); + getAccessTokenMock.mockImplementation( + async (appId: string, secret: string) => `token-for-${appId}-${secret}`, + ); + fetchWithSsrFGuardMock.mockResolvedValue({ + response: new Response(JSON.stringify([{ id: "guild-1" }]), { status: 200 }), + release: vi.fn(async () => {}), + }); + }); + + afterEach(() => { + getAccessTokenMock.mockReset(); + fetchWithSsrFGuardMock.mockReset(); + }); + + it("uses the active QQBot account for token acquisition and API authorization", async () => { + const tool = registerToolFactory()({ messageChannel: "qqbot", agentAccountId: "bot2" }); + expect(tool).not.toBeNull(); + + await tool?.execute("call-b", { method: "GET", path: "/users/@me/guilds" }); + + expect(getAccessTokenMock).toHaveBeenCalledWith("app-b", "secret-b"); + expect(getAccessTokenMock).not.toHaveBeenCalledWith("app-a", "secret-a"); + expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith( + expect.objectContaining({ + init: expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "QQBot token-for-app-b-secret-b", + }), + }), + }), + ); + }); + + it("uses the configured default account without an active account", async () => { + const configuredDefault = { + ...cfg, + channels: { + qqbot: { + ...cfg.channels?.qqbot, + defaultAccount: "bot2", + }, + }, + } as OpenClawConfig; + const tool = registerToolFactory(configuredDefault)({}); + expect(tool).not.toBeNull(); + + await tool?.execute("call-default", { method: "GET", path: "/users/@me/guilds" }); + + expect(getAccessTokenMock).toHaveBeenCalledWith("app-b", "secret-b"); + expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith( + expect.objectContaining({ + init: expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "QQBot token-for-app-b-secret-b", + }), + }), + }), + ); + }); + + it("does not expose the tool when the active account has no credentials", () => { + expect( + registerToolFactory()({ messageChannel: "qqbot", agentAccountId: "missing" }), + ).toBeNull(); + expect(getAccessTokenMock).not.toHaveBeenCalled(); + }); + + it("does not expose the tool when the active account is disabled", () => { + const disabledAccount = { + ...cfg, + channels: { + qqbot: { + ...cfg.channels?.qqbot, + accounts: { + bot2: { + ...cfg.channels?.qqbot?.accounts?.bot2, + enabled: false, + }, + }, + }, + }, + } as OpenClawConfig; + + expect( + registerToolFactory(disabledAccount)({ + messageChannel: "qqbot", + agentAccountId: "bot2", + }), + ).toBeNull(); + expect(getAccessTokenMock).not.toHaveBeenCalled(); + expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled(); + }); + + it("does not treat another channel's account ID as a QQBot account", async () => { + const tool = registerToolFactory()({ messageChannel: "discord", agentAccountId: "bot2" }); + expect(tool).not.toBeNull(); + + await tool?.execute("call-discord", { method: "GET", path: "/users/@me/guilds" }); + + expect(getAccessTokenMock).toHaveBeenCalledWith("app-a", "secret-a"); + expect(getAccessTokenMock).not.toHaveBeenCalledWith("app-b", "secret-b"); + }); +}); diff --git a/extensions/qqbot/src/bridge/tools/channel.ts b/extensions/qqbot/src/bridge/tools/channel.ts index 231723b35302..21ff8811df92 100644 --- a/extensions/qqbot/src/bridge/tools/channel.ts +++ b/extensions/qqbot/src/bridge/tools/channel.ts @@ -1,5 +1,9 @@ // Qqbot plugin module implements channel behavior. -import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import type { + AnyAgentTool, + OpenClawPluginApi, + OpenClawPluginToolContext, +} from "openclaw/plugin-sdk/core"; import { ChannelApiSchema, executeChannelApi } from "../../engine/tools/channel-api.js"; import type { ChannelApiParams } from "../../engine/tools/channel-api.js"; import { listQQBotAccountIds, resolveQQBotAccount } from "../config.js"; @@ -11,54 +15,62 @@ import { listQQBotAccountIds, resolveQQBotAccount } from "../config.js"; * channel APIs. Agents learn endpoint details from the skill docs and * send requests through this proxy. */ +function createChannelTool( + cfg: NonNullable, + context: OpenClawPluginToolContext, +): AnyAgentTool | null { + // Bind credentials when the per-run tool is built; a process-wide account + // selection would let one QQBot ingress account exercise another account's API authority. + const activeConfig = context.runtimeConfig ?? cfg; + const activeChannel = context.messageChannel ?? context.deliveryContext?.channel; + const account = resolveQQBotAccount( + activeConfig, + activeChannel === "qqbot" + ? (context.agentAccountId ?? context.deliveryContext?.accountId) + : undefined, + ); + if (!account.enabled || !account.appId || !account.clientSecret) { + return null; + } + + return { + name: "qqbot_channel_api", + label: "QQBot Channel API", + description: + "Authenticated HTTP proxy for QQ Open Platform channel APIs. " + + "Use write and delete endpoints only after explicit user intent; DELETE requires confirmed=true, and bulk deletes require bulkConfirmed=true after confirming the exact target. " + + "Common endpoints: " + + "list guilds GET /users/@me/guilds | " + + "list channels GET /guilds/{guild_id}/channels | " + + "get channel GET /channels/{channel_id} | " + + "create channel POST /guilds/{guild_id}/channels | " + + "list members GET /guilds/{guild_id}/members?after=0&limit=100 | " + + "get member GET /guilds/{guild_id}/members/{user_id} | " + + "list threads GET /channels/{channel_id}/threads | " + + "create thread PUT /channels/{channel_id}/threads | " + + "create announce POST /guilds/{guild_id}/announces | " + + "create schedule POST /channels/{channel_id}/schedules. " + + "See the qqbot-channel skill for full endpoint details.", + parameters: ChannelApiSchema, + 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, + cfg: activeConfig, + accountId: account.accountId, + }); + }, + }; +} + export function registerChannelTool(api: OpenClawPluginApi): void { const cfg = api.config; - if (!cfg) { + if (!cfg || listQQBotAccountIds(cfg).length === 0) { return; } - const accountIds = listQQBotAccountIds(cfg); - if (accountIds.length === 0) { - return; - } - - const firstAccountId = accountIds[0]; - const account = resolveQQBotAccount(cfg, firstAccountId); - - if (!account.appId || !account.clientSecret) { - return; - } - - api.registerTool( - { - name: "qqbot_channel_api", - label: "QQBot Channel API", - description: - "Authenticated HTTP proxy for QQ Open Platform channel APIs. " + - "Use write and delete endpoints only after explicit user intent; DELETE requires confirmed=true, and bulk deletes require bulkConfirmed=true after confirming the exact target. " + - "Common endpoints: " + - "list guilds GET /users/@me/guilds | " + - "list channels GET /guilds/{guild_id}/channels | " + - "get channel GET /channels/{channel_id} | " + - "create channel POST /guilds/{guild_id}/channels | " + - "list members GET /guilds/{guild_id}/members?after=0&limit=100 | " + - "get member GET /guilds/{guild_id}/members/{user_id} | " + - "list threads GET /channels/{channel_id}/threads | " + - "create thread PUT /channels/{channel_id}/threads | " + - "create announce POST /guilds/{guild_id}/announces | " + - "create schedule POST /channels/{channel_id}/schedules. " + - "See the qqbot-channel skill for full endpoint details.", - parameters: ChannelApiSchema, - 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, - cfg, - accountId: firstAccountId, - }); - }, - }, - { name: "qqbot_channel_api" }, - ); + api.registerTool((context) => createChannelTool(cfg, context), { + name: "qqbot_channel_api", + }); }