fix(qqbot): keep channel API calls on the active account [AI] (#118575)

* fix(qqbot): bind channel API tool to active account

* fix(qqbot): scope active account binding to QQ turns

* fix(qqbot): reject disabled channel API accounts
This commit is contained in:
Pavan Kumar Gondhi
2026-08-04 17:28:08 +05:30
committed by GitHub
parent 6d88ec5248
commit 4170e11be9
2 changed files with 226 additions and 46 deletions
@@ -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<typeof import("openclaw/plugin-sdk/ssrf-runtime")>();
return { ...actual, fetchWithSsrFGuard: fetchWithSsrFGuardMock };
});
vi.mock("../../engine/messaging/sender.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../engine/messaging/sender.js")>();
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");
});
});
+58 -46
View File
@@ -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<OpenClawPluginApi["config"]>,
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",
});
}