mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
6d88ec5248
commit
4170e11be9
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user