Files
openclaw/extensions/discord/src/doctor.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

735 lines
22 KiB
TypeScript

// Discord tests cover doctor plugin behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { describe, expect, it } from "vitest";
import {
collectDiscordMissingEnvTokenWarnings,
collectDiscordNumericIdWarnings,
discordDoctor,
maybeRepairDiscordNumericIds,
scanDiscordNumericIdEntries,
} from "./doctor.js";
import { resolveDiscordPreviewStreamMode } from "./preview-streaming.js";
function getDiscordCompatibilityNormalizer(): NonNullable<
typeof discordDoctor.normalizeCompatibilityConfig
> {
const normalize = discordDoctor.normalizeCompatibilityConfig;
if (!normalize) {
throw new Error("Expected discord doctor to expose normalizeCompatibilityConfig");
}
return normalize;
}
describe("discord doctor", () => {
it("promotes shipped nested DM access at root and account scope", () => {
const normalize = getDiscordCompatibilityNormalizer();
const result = normalize({
cfg: {
channels: {
discord: {
dm: { enabled: false, policy: "allowlist", allowFrom: ["123"] },
accounts: {
work: {
dm: { groupEnabled: true, policy: "open", allowFrom: ["*"] },
},
},
},
},
} as never,
});
expect(result.config.channels?.discord).toEqual({
dm: { enabled: false },
dmPolicy: "allowlist",
allowFrom: ["123"],
accounts: {
work: {
dm: { groupEnabled: true },
dmPolicy: "open",
allowFrom: ["*"],
},
},
});
});
it("strips retired gateway, queue, and retry tuning at root and account scope", () => {
const normalize = getDiscordCompatibilityNormalizer();
const result = normalize({
cfg: {
channels: {
discord: {
gatewayInfoTimeoutMs: 1,
gatewayReadyTimeoutMs: 2,
gatewayRuntimeReadyTimeoutMs: 3,
eventQueue: { listenerTimeout: 4 },
retry: { attempts: 5 },
voice: {
realtime: {
providers: {
custom: { retry: { attempts: 9 }, eventQueue: { maxConcurrency: 2 } },
},
},
},
accounts: {
work: { eventQueue: { maxConcurrency: 6 }, retry: { attempts: 7 } },
},
},
},
} as never,
});
expect(result.config.channels?.discord).toEqual({
voice: {
realtime: {
providers: { custom: { retry: { attempts: 9 }, eventQueue: { maxConcurrency: 2 } } },
},
},
accounts: { work: {} },
});
expect(result.changes).toContain("Removed retired Discord tuning knobs.");
});
it("normalizes legacy discord streaming aliases for runtime config", () => {
const normalize = getDiscordCompatibilityNormalizer();
const result = normalize({
cfg: {
channels: {
discord: {
streamMode: "block",
chunkMode: "newline",
blockStreaming: true,
draftChunk: {
minChars: 120,
},
accounts: {
work: {
streaming: false,
blockStreamingCoalesce: {
idleMs: 250,
},
},
},
},
},
} as never,
});
expect(result.config.channels?.discord).toEqual({
streaming: {
mode: "block",
chunkMode: "newline",
block: {
enabled: true,
},
preview: {
chunk: {
minChars: 120,
},
},
},
accounts: {
work: {
streaming: {
mode: "off",
chunkMode: "newline",
block: {
enabled: true,
coalesce: {
idleMs: 250,
},
},
preview: {
chunk: {
minChars: 120,
},
},
},
},
},
});
expect(result.changes).toEqual([
"Moved channels.discord.streamMode → channels.discord.streaming.mode (block).",
"Moved channels.discord.chunkMode → channels.discord.streaming.chunkMode.",
"Moved channels.discord.blockStreaming → channels.discord.streaming.block.enabled.",
"Moved channels.discord.draftChunk → channels.discord.streaming.preview.chunk.",
"Moved channels.discord.accounts.work.streaming (boolean) → channels.discord.accounts.work.streaming.mode (off).",
"Moved channels.discord.accounts.work.blockStreamingCoalesce → channels.discord.accounts.work.streaming.block.coalesce.",
"Copied flat channels.discord delivery keys into channels.discord.accounts.work.streaming to keep inherited settings while migrating flat streaming keys.",
]);
});
it("keeps delivery-only aliases mode-free and preserves explicit legacy progress", () => {
const normalize = getDiscordCompatibilityNormalizer();
const deliveryOnly = normalize({
cfg: {
channels: {
discord: { blockStreaming: true },
},
} as never,
});
const deliveryOnlyMigrated = deliveryOnly.config.channels?.discord as Record<string, unknown>;
expect(deliveryOnlyMigrated).toEqual({
streaming: { block: { enabled: true } },
});
expect(resolveDiscordPreviewStreamMode(deliveryOnlyMigrated)).toBe("off");
expect(deliveryOnly.changes).toEqual([
"Moved channels.discord.blockStreaming → channels.discord.streaming.block.enabled.",
]);
const explicitProgress = normalize({
cfg: {
channels: {
discord: { streamMode: "progress", blockStreaming: true },
},
} as never,
});
const explicitProgressMigrated = explicitProgress.config.channels?.discord as Record<
string,
unknown
>;
expect(explicitProgressMigrated).toEqual({
streaming: { mode: "progress", block: { enabled: true } },
});
expect(resolveDiscordPreviewStreamMode(explicitProgressMigrated)).toBe("progress");
expect(explicitProgress.changes).toEqual([
"Moved channels.discord.streamMode → channels.discord.streaming.mode (progress).",
"Moved channels.discord.blockStreaming → channels.discord.streaming.block.enabled.",
]);
});
it("seeds the inherited root streaming settings when migrating account delivery aliases", () => {
const normalize = getDiscordCompatibilityNormalizer();
// Account `streaming` objects replace the root object wholesale on merge,
// so the migrated account must carry the settings it previously inherited.
const result = normalize({
cfg: {
channels: {
discord: {
streaming: { mode: "off", block: { coalesce: { idleMs: 5 } } },
accounts: { work: { chunkMode: "newline" } },
},
},
} as never,
});
expect(result.config.channels?.discord).toEqual({
streaming: { mode: "off", block: { coalesce: { idleMs: 5 } } },
accounts: {
work: {
streaming: {
mode: "off",
chunkMode: "newline",
block: { coalesce: { idleMs: 5 } },
},
},
},
});
expect(result.changes).toEqual([
"Moved channels.discord.accounts.work.chunkMode → channels.discord.accounts.work.streaming.chunkMode.",
"Copied channels.discord.streaming into channels.discord.accounts.work.streaming to keep inherited settings while migrating flat streaming keys.",
]);
});
it("moves account voice.tts.edge into providers.microsoft", () => {
const normalize = getDiscordCompatibilityNormalizer();
const result = normalize({
cfg: {
channels: {
discord: {
accounts: {
main: {
voice: {
tts: {
edge: {
voice: "en-US-JennyNeural",
},
},
},
},
},
},
},
} as never,
});
expect(result.changes).toContain(
"Moved channels.discord.accounts.main.voice.tts.edge → channels.discord.accounts.main.voice.tts.providers.microsoft.",
);
const mainTts = result.config.channels?.discord?.accounts?.main?.voice?.tts as
| Record<string, unknown>
| undefined;
expect(mainTts?.providers).toEqual({
microsoft: {
voice: "en-US-JennyNeural",
},
});
expect(mainTts?.edge).toBeUndefined();
});
it("does not move unsupported root and account tts provider aliases", () => {
const normalize = getDiscordCompatibilityNormalizer();
const result = normalize({
cfg: {
channels: {
discord: {
tts: {
edge: {
voice: "en-US-RootNeural",
},
},
voice: {
tts: {
edge: {
voice: "en-US-VoiceNeural",
},
},
},
accounts: {
main: {
tts: {
edge: {
voice: "en-US-AccountNeural",
},
},
voice: {
tts: {
edge: {
voice: "en-US-AccountVoiceNeural",
},
},
},
},
},
},
},
} as never,
});
expect(result.changes).toEqual([
"Moved channels.discord.accounts.main.voice.tts.edge → channels.discord.accounts.main.voice.tts.providers.microsoft.",
"Moved channels.discord.voice.tts.edge → channels.discord.voice.tts.providers.microsoft.",
]);
const discordConfig = result.config.channels?.discord as
| {
tts?: Record<string, unknown>;
voice?: { tts?: Record<string, unknown> };
accounts?: {
main?: {
tts?: Record<string, unknown>;
voice?: { tts?: Record<string, unknown> };
};
};
}
| undefined;
expect(discordConfig?.tts).toEqual({
edge: {
voice: "en-US-RootNeural",
},
});
expect(discordConfig?.accounts?.main?.tts).toEqual({
edge: {
voice: "en-US-AccountNeural",
},
});
expect(discordConfig?.voice?.tts).toEqual({
providers: {
microsoft: {
voice: "en-US-VoiceNeural",
},
},
});
expect(discordConfig?.accounts?.main?.voice?.tts).toEqual({
providers: {
microsoft: {
voice: "en-US-AccountVoiceNeural",
},
},
});
});
it("removes unsupported Discord realtime wake names", () => {
const normalize = getDiscordCompatibilityNormalizer();
const result = normalize({
cfg: {
channels: {
discord: {
voice: {
realtime: {
wakeNames: ["Claw", "Claw Bot Helper", "Open Claw"],
},
},
accounts: {
work: {
voice: {
realtime: {
wakeNames: ["Work Bot Helper", "Work Bot"],
},
},
},
invalid: {
voice: {
realtime: {
wakeNames: ["Only Three Words"],
},
},
},
empty: {
voice: {
realtime: {
wakeNames: [],
},
},
},
},
},
},
} as never,
});
expect(result.changes).toEqual([
"Shortened 1 unsupported channels.discord.accounts.work.voice.realtime.wakeNames entries to one or two words.",
"Shortened 1 unsupported channels.discord.accounts.invalid.voice.realtime.wakeNames entries to one or two words.",
"Removed empty channels.discord.accounts.empty.voice.realtime.wakeNames; unset wake names use the default agent/OpenClaw fallback.",
"Shortened 1 unsupported channels.discord.voice.realtime.wakeNames entries to one or two words.",
]);
expect(result.config.channels?.discord?.voice?.realtime?.wakeNames).toEqual([
"Claw",
"Claw Bot",
"Open Claw",
]);
expect(result.config.channels?.discord?.accounts?.work?.voice?.realtime?.wakeNames).toEqual([
"Work Bot",
]);
expect(result.config.channels?.discord?.accounts?.invalid?.voice?.realtime?.wakeNames).toEqual([
"Only Three",
]);
expect(result.config.channels?.discord?.accounts?.empty?.voice?.realtime?.wakeNames).toBe(
undefined,
);
});
it("moves legacy guild channel allow toggles into enabled", () => {
const normalize = getDiscordCompatibilityNormalizer();
const result = normalize({
cfg: {
channels: {
discord: {
guilds: {
"100": {
channels: {
general: {
allow: false,
},
},
},
},
accounts: {
work: {
guilds: {
"200": {
channels: {
help: {
allow: true,
},
},
},
},
},
},
},
},
} as never,
});
expect(result.changes).toEqual([
"Moved channels.discord.guilds.100.channels.general.allow → channels.discord.guilds.100.channels.general.enabled.",
"Moved channels.discord.accounts.work.guilds.200.channels.help.allow → channels.discord.accounts.work.guilds.200.channels.help.enabled.",
]);
expect(result.config.channels?.discord?.guilds?.["100"]?.channels?.general).toEqual({
enabled: false,
});
expect(
result.config.channels?.discord?.accounts?.work?.guilds?.["200"]?.channels?.help,
).toEqual({
enabled: true,
});
});
it("moves legacy guild channel agentId into a top-level route binding", () => {
const normalize = getDiscordCompatibilityNormalizer();
const result = normalize({
cfg: {
channels: {
discord: {
guilds: {
"100": {
channels: {
"200": {
requireMention: false,
agentId: "video",
},
},
},
},
},
},
} as never,
});
expect(result.changes).toEqual([
"Moved channels.discord.guilds.100.channels.200.agentId → top-level bindings[] route for Discord channel 200.",
]);
expect(result.config.channels?.discord?.guilds?.["100"]?.channels?.["200"]).toEqual({
requireMention: false,
});
expect(result.config.bindings).toEqual([
{
agentId: "video",
match: {
channel: "discord",
guildId: "100",
peer: { kind: "channel", id: "200" },
},
},
]);
});
it("moves account-scoped guild channel agentId into an account-scoped route binding", () => {
const normalize = getDiscordCompatibilityNormalizer();
const result = normalize({
cfg: {
channels: {
discord: {
accounts: {
work: {
guilds: {
"100": {
channels: {
"200": {
agentId: "support",
},
},
},
},
},
},
},
},
bindings: [{ agentId: "main", match: { channel: "discord" } }],
} as never,
});
expect(result.changes).toEqual([
"Moved channels.discord.accounts.work.guilds.100.channels.200.agentId → top-level bindings[] route for Discord channel 200.",
]);
expect(
result.config.channels?.discord?.accounts?.work?.guilds?.["100"]?.channels?.["200"],
).toStrictEqual({});
expect(result.config.bindings).toEqual([
{ agentId: "main", match: { channel: "discord" } },
{
agentId: "support",
match: {
channel: "discord",
accountId: "work",
guildId: "100",
peer: { kind: "channel", id: "200" },
},
},
]);
});
it("removes legacy guild channel agentId when a matching route binding already exists", () => {
const normalize = getDiscordCompatibilityNormalizer();
const existingBinding = {
agentId: "video",
match: {
channel: "discord",
guildId: "100",
peer: { kind: "channel", id: "200" },
},
};
const result = normalize({
cfg: {
channels: {
discord: {
guilds: {
"100": {
channels: {
"200": {
agentId: "video",
},
},
},
},
},
},
bindings: [existingBinding],
} as never,
});
expect(result.changes).toEqual([
"Removed channels.discord.guilds.100.channels.200.agentId; a matching top-level bindings[] route already exists for Discord channel 200.",
]);
expect(result.config.channels?.discord?.guilds?.["100"]?.channels?.["200"]).toStrictEqual({});
expect(result.config.bindings).toEqual([existingBinding]);
});
it("finds numeric id entries across discord scopes", () => {
const cfg = {
channels: {
discord: {
allowFrom: [123],
dm: { allowFrom: ["ok"], groupChannels: [456] },
execApprovals: { approvers: [789] },
guilds: {
main: {
users: [111],
roles: [222],
channels: { general: { users: [333], roles: [444] } },
},
},
},
},
} as unknown as OpenClawConfig;
const hits = scanDiscordNumericIdEntries(cfg);
expect(hits.map((hit) => hit.path)).toEqual([
"channels.discord.allowFrom[0]",
"channels.discord.dm.groupChannels[0]",
"channels.discord.execApprovals.approvers[0]",
"channels.discord.guilds.main.users[0]",
"channels.discord.guilds.main.roles[0]",
"channels.discord.guilds.main.channels.general.users[0]",
"channels.discord.guilds.main.channels.general.roles[0]",
]);
});
it("repairs safe numeric ids into strings and warns for unsafe lists", () => {
const cfg = {
channels: {
discord: {
allowFrom: [123],
dm: { allowFrom: [99] },
guilds: { main: { users: [111], roles: [222] } },
},
},
} as unknown as OpenClawConfig;
const result = maybeRepairDiscordNumericIds(cfg, "openclaw doctor --fix");
expect(result.config.channels?.discord?.allowFrom).toEqual(["123"]);
expect(
(result.config.channels?.discord?.dm as { allowFrom?: string[] } | undefined)?.allowFrom,
).toEqual(["99"]);
expect(result.config.channels?.discord?.guilds?.main?.users).toEqual(["111"]);
expect(result.config.channels?.discord?.guilds?.main?.roles).toEqual(["222"]);
expect(result.changes).not.toHaveLength(0);
expect(result.warnings).toStrictEqual([]);
});
it("formats repair guidance for unsafe numeric ids", () => {
const warnings = collectDiscordNumericIdWarnings({
hits: [{ path: "channels.discord.allowFrom[0]", entry: 106232522769186816, safe: false }],
doctorFixCommand: "openclaw doctor --fix",
});
expect(warnings[0]).toContain("cannot be auto-repaired");
expect(warnings[1]).toContain("openclaw doctor --fix");
});
it("warns when default env fallback token is missing after migration", async () => {
const cfg = {
channels: {
discord: {
allowFrom: ["123"],
},
},
} as unknown as OpenClawConfig;
const missingTokenWarning =
"- channels.discord: default account has no available bot token, and DISCORD_BOT_TOKEN is absent in this doctor environment. After migration, verify DISCORD_BOT_TOKEN is present in the state-dir .env or configure channels.discord.token / channels.discord.accounts.default.token as a SecretRef.";
expect(collectDiscordMissingEnvTokenWarnings({ cfg, env: {} })).toStrictEqual([
missingTokenWarning,
]);
expect(
collectDiscordMissingEnvTokenWarnings({ cfg, env: { DISCORD_BOT_TOKEN: "Bot tok" } }),
).toStrictEqual([]);
expect(
await discordDoctor.collectPreviewWarnings?.({
cfg,
doctorFixCommand: "openclaw doctor --fix",
env: {},
}),
).toStrictEqual([missingTokenWarning]);
});
it("does not warn about DISCORD_BOT_TOKEN when a non-default account is selected", () => {
const cfg = {
channels: {
discord: {
accounts: {
work: {
token: "Bot work-token",
},
},
},
},
} as unknown as OpenClawConfig;
expect(collectDiscordMissingEnvTokenWarnings({ cfg, env: {} })).toStrictEqual([]);
});
it("warns when Discord transcript auto-start cannot choose between voice accounts", async () => {
const cfg = {
transcripts: {
autoStart: [
{
providerId: "discord-voice",
guildId: "guild-1",
channelId: "channel-1",
},
{
providerId: "discord-voice",
accountId: "alpha",
guildId: "guild-1",
channelId: "channel-2",
},
{ providerId: "meeting", meetingUrl: "https://meet.example.test/standup" },
],
},
channels: {
discord: {
accounts: {
alpha: { token: "alpha-token", voice: { enabled: true } },
bravo: { token: "bravo-token", voice: { enabled: true } },
},
},
},
} as unknown as OpenClawConfig;
const warnings =
(await discordDoctor.collectPreviewWarnings?.({
cfg,
doctorFixCommand: "openclaw doctor --fix",
env: {},
})) ?? [];
expect(warnings).toHaveLength(1);
expect(warnings[0]).toContain("transcripts.autoStart[0]");
expect(warnings[0]).toContain("Multiple Discord accounts are enabled for voice");
expect(warnings[0]).toContain("transcripts.autoStart[0].accountId");
expect(warnings[0]).toContain("channels.discord.defaultAccount");
});
});