Files
openclaw/test/transcripts-tool.discord-provider.integration.test.ts
Pavan Kumar Gondhi 8668aeb969 fix(discord): bind transcript capture to source account [AI] (#118579)
* fix(discord): bind transcript capture to source account

* style(agents): keep transcript tool wiring compact

* fix(transcripts): declare account binding channels

* fix(transcripts): report effective capture account

* fix(transcripts): enforce account lifecycle ownership

* fix(transcripts): preserve cross-surface control

* fix(copilot): preserve transcript channel context

* fix(transcripts): fail closed for legacy channel owners

* fix(transcripts): add trusted legacy recovery

* fix(transcripts): preserve auto-start cleanup ownership

* fix(transcripts): reject untrusted account starts

* fix(transcripts): keep persisted ownership authoritative

* fix(transcripts): harden legacy recovery

* fix(transcripts): preserve agent ownership boundary

* fix(transcripts): scope account binding to source channel

* fix(transcripts): preserve unattributed owner isolation

* fix(transcripts): own configured captures by account

* docs(plugins): clarify transcript auto-start ownership

* test(transcripts): cover account-less recovery

* docs(transcripts): scope legacy recovery by provider

* fix(discord): reuse eligible account ordering for transcripts

* test(discord): use neutral transcript account fixtures

* fix(transcripts): keep accountless recovery local

* fix(discord): resolve transcript accounts by voice capability

* fix(transcripts): bound account resolution failures

* fix(transcripts): bound account tool output

* fix(transcripts): honor unresolved provider accounts

* fix(transcripts): preserve binding when providers are missing

* fix(transcripts): fail closed on unknown binding provenance

* fix(transcripts): qualify account lifecycle capability

* fix(transcripts): normalize provable legacy owners

* fix(transcripts): bind scheduled capture to caller authority

* fix(transcripts): preserve scheduled caller identity tuple

* fix(transcripts): preserve channel-less scheduled authority

* fix(plugin-sdk): publish transcript provider types

* fix(transcripts): use exact lifecycle ownership tokens

* fix(transcripts): preserve local ownerless lifecycle access

* fix(transcripts): allow local configured capture control

* fix(transcripts): preserve scheduled caller channel

* fix(transcripts): retain named-agent legacy recovery

* fix(transcripts): deny unrelated remote channels

* fix(doctor): validate transcript owner inference

* fix(transcripts): restrict legacy remote recovery

* fix(ci): align transcript Doctor checks

* fix(transcripts): require Doctor-owned legacy metadata

* fix(transcripts): reject unowned remote capture starts

* fix(transcripts): reject unbound Discord lifecycle calls

* fix(transcripts): distinguish legacy owner rows

* test(discord): keep unavailable account fixture typed

* fix(transcripts): mark current imports for Doctor

* fix(transcripts): complete account ownership validation

* fix(discord): restore transcript package boundary

* fix(discord): preserve bundled transcript entry boundary

* docs(transcripts): clarify Discord auto-start account

* fix(transcripts): bind account-owned imports

* fix: preserve transcript and cron policy state

* fix(cron): preserve scheduled transcript authority

* fix(discord): keep legacy transcript rows local

* fix(transcripts): narrow account ownership boundary

* fix(transcripts): preserve trusted caller ownership

* fix(discord): enforce transcript source authorization

* fix(ci): bound Control UI gzip build variance

* test(qa): align transcript scenario contracts

* fix(agents): repair rebased caller context

* fix(discord): restore rebased account ownership

* test(discord): restore voice account fixtures

---------

Co-authored-by: joshavant <830519+joshavant@users.noreply.github.com>
2026-08-15 12:10:43 -07:00

268 lines
8.7 KiB
TypeScript

import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
discordVoiceTranscriptsSourceProvider,
setDiscordTranscriptsVoiceManager,
} from "../extensions/discord/test-api.js";
import { activeSessions } from "../src/agents/tools/transcripts-tool-runtime.js";
import { createTranscriptsTool } from "../src/agents/tools/transcripts-tool.js";
import type { OpenClawConfig } from "../src/config/types.openclaw.js";
import { createEmptyPluginRegistry } from "../src/plugins/registry-empty.js";
import { setActivePluginRegistry } from "../src/plugins/runtime.js";
import { closeOpenClawStateDatabaseForTest } from "../src/state/openclaw-state-db.js";
import { TranscriptsStore } from "../src/transcripts/store.js";
import { createTempDirTracker } from "./helpers/temp-dir.js";
type DiscordTranscriptsVoiceManager = NonNullable<
Parameters<typeof setDiscordTranscriptsVoiceManager>[0]["manager"]
>;
const tempDirs = createTempDirTracker();
const resolveAccessTarget = async (guildId: string, channelId: string) => ({
guild: { id: guildId, name: guildId },
channelName: channelId,
channelSlug: channelId,
scope: "channel" as const,
});
function createTool(params: {
accountId: string;
caller:
| { kind: "operator"; source: "channel-owner" | "local" | "scheduled" }
| {
kind: "channel";
channel: string;
accountId?: string;
senderId: string;
groupSpace?: string;
roleIds: readonly string[];
};
config: OpenClawConfig;
stateDir: string;
}) {
return createTranscriptsTool({
agentId: "main",
agentAccountId: params.accountId,
agentChannel: "discord",
caller: params.caller,
config: params.config,
stateDir: params.stateDir,
});
}
function storeFor(stateDir: string): TranscriptsStore {
return new TranscriptsStore(path.join(stateDir, "transcripts"), {
env: { ...process.env, OPENCLAW_STATE_DIR: stateDir },
});
}
describe("transcripts tool with the registered Discord provider", () => {
beforeEach(() => {
const registry = createEmptyPluginRegistry();
registry.transcriptSourceProviders.push({
pluginId: "discord",
provider: discordVoiceTranscriptsSourceProvider,
source: "extensions/discord/index.ts",
});
setActivePluginRegistry(registry, "discord-transcripts-tool-test");
});
afterEach(() => {
activeSessions.clear();
setDiscordTranscriptsVoiceManager({ accountId: "account-a", manager: null });
setDiscordTranscriptsVoiceManager({ accountId: "account-b", manager: null });
setActivePluginRegistry(createEmptyPluginRegistry(), "discord-transcripts-tool-test-cleanup");
closeOpenClawStateDatabaseForTest();
tempDirs.cleanup();
});
it("keeps a model-requested account switch on the trusted Discord account", async () => {
const stateDir = tempDirs.make("openclaw-transcripts-discord-provider-");
const accountAJoin = vi.fn(async () => ({ ok: true, message: "joined account-a" }));
const accountALeave = vi.fn(async () => ({ ok: true, message: "left account-a" }));
const accountBJoin = vi.fn(async () => ({ ok: true, message: "joined account-b" }));
const accountBLeave = vi.fn(async () => ({ ok: true, message: "left account-b" }));
setDiscordTranscriptsVoiceManager({
accountId: "account-a",
manager: {
join: accountAJoin,
leave: accountALeave,
resolveAccessTarget: ({ guildId, channelId }: { guildId: string; channelId: string }) =>
resolveAccessTarget(guildId, channelId),
} as unknown as DiscordTranscriptsVoiceManager,
});
setDiscordTranscriptsVoiceManager({
accountId: "account-b",
manager: {
join: accountBJoin,
leave: accountBLeave,
resolveAccessTarget: ({ guildId, channelId }: { guildId: string; channelId: string }) =>
resolveAccessTarget(guildId, channelId),
} as unknown as DiscordTranscriptsVoiceManager,
});
const config = {
channels: {
discord: {
accounts: {
"account-a": {
token: "token-a",
allowFrom: ["discord:allowed"],
voice: { enabled: true },
},
"account-b": { token: "token-b", voice: { enabled: true } },
},
},
},
transcripts: { enabled: true },
} satisfies OpenClawConfig;
const ownerTool = createTool({
accountId: "account-a",
caller: {
kind: "channel",
channel: "discord",
accountId: "account-a",
senderId: "allowed",
groupSpace: "guild-a",
roleIds: [],
},
config,
stateDir,
});
const otherAccountTool = createTool({
accountId: "account-b",
caller: {
kind: "channel",
channel: "discord",
accountId: "account-b",
senderId: "allowed",
groupSpace: "guild-a",
roleIds: [],
},
config,
stateDir,
});
const deniedSameAccountTool = createTool({
accountId: "account-a",
caller: {
kind: "channel",
channel: "discord",
accountId: "account-a",
senderId: "blocked",
groupSpace: "guild-a",
roleIds: [],
},
config,
stateDir,
});
const startResult = await ownerTool.execute("start-account-bound", {
action: "start",
providerId: "discord-voice",
accountId: "account-b",
guildId: "guild-a",
channelId: "voice-a",
sessionId: "account-bound",
});
expect(startResult.details).toMatchObject({
accountId: "account-a",
sessionId: "account-bound",
});
expect(accountAJoin).toHaveBeenCalledOnce();
expect(accountBJoin).not.toHaveBeenCalled();
await expect(storeFor(stateDir).readSession("account-bound")).resolves.toMatchObject({
source: { accountId: "account-a" },
metadata: { agentId: "main" },
});
await expect(
otherAccountTool.execute("status-other-account", { action: "status" }),
).resolves.toMatchObject({ details: { active: [] } });
await expect(
otherAccountTool.execute("stop-other-account", {
action: "stop",
sessionId: "account-bound",
}),
).rejects.toThrow("transcripts session not found: account-bound");
expect(accountBLeave).not.toHaveBeenCalled();
await expect(
deniedSameAccountTool.execute("status-denied-sender", { action: "status" }),
).resolves.toMatchObject({ details: { active: [] } });
await expect(
deniedSameAccountTool.execute("summarize-denied-sender", {
action: "summarize",
sessionId: "account-bound",
}),
).rejects.toThrow("transcripts session not found: account-bound");
await expect(
deniedSameAccountTool.execute("stop-denied-sender", {
action: "stop",
sessionId: "account-bound",
}),
).rejects.toThrow("transcripts session not found: account-bound");
expect(accountALeave).not.toHaveBeenCalled();
await expect(
ownerTool.execute("stop-owner-account", {
action: "stop",
sessionId: "account-bound",
}),
).resolves.toMatchObject({ details: { sessionId: "account-bound" } });
expect(accountALeave).toHaveBeenCalledOnce();
});
it("rejects a Discord sender that the voice command policy denies", async () => {
const stateDir = tempDirs.make("openclaw-transcripts-discord-provider-denied-");
const join = vi.fn(async () => ({ ok: true, message: "joined" }));
setDiscordTranscriptsVoiceManager({
accountId: "account-a",
manager: {
join,
resolveAccessTarget: ({ guildId, channelId }: { guildId: string; channelId: string }) =>
resolveAccessTarget(guildId, channelId),
} as unknown as DiscordTranscriptsVoiceManager,
});
const config = {
channels: {
discord: {
accounts: {
"account-a": {
token: "token-a",
allowFrom: ["discord:allowed"],
voice: { enabled: true },
},
},
},
},
transcripts: { enabled: true },
} satisfies OpenClawConfig;
const deniedTool = createTool({
accountId: "account-a",
caller: {
kind: "channel",
channel: "discord",
accountId: "account-a",
senderId: "blocked",
groupSpace: "guild-a",
roleIds: [],
},
config,
stateDir,
});
await expect(
deniedTool.execute("denied-sender", {
action: "start",
providerId: "discord-voice",
guildId: "guild-a",
channelId: "voice-a",
sessionId: "denied-sender",
}),
).rejects.toThrow("not authorized");
expect(join).not.toHaveBeenCalled();
await expect(storeFor(stateDir).readSession("denied-sender")).resolves.toBeUndefined();
});
});