mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 04:15:48 -06:00
8668aeb969
* 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>
735 lines
22 KiB
TypeScript
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");
|
|
});
|
|
});
|