Config: require Discord ID strings (#18220)

This commit is contained in:
Shadow
2026-02-16 12:22:58 -06:00
committed by GitHub
parent 5d40d47501
commit 1b7301051b
12 changed files with 371 additions and 43 deletions

View File

@@ -120,4 +120,113 @@ describe("doctor config flow", () => {
vi.unstubAllGlobals();
}
});
it("converts numeric discord ids to strings on repair", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".openclaw");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "openclaw.json"),
JSON.stringify(
{
channels: {
discord: {
allowFrom: [123],
dm: { allowFrom: [456], groupChannels: [789] },
execApprovals: { approvers: [321] },
guilds: {
"100": {
users: [111],
roles: [222],
channels: {
general: { users: [333], roles: [444] },
},
},
},
accounts: {
work: {
allowFrom: [555],
dm: { allowFrom: [666], groupChannels: [777] },
execApprovals: { approvers: [888] },
guilds: {
"200": {
users: [999],
roles: [1010],
channels: {
help: { users: [1111], roles: [1212] },
},
},
},
},
},
},
},
},
null,
2,
),
"utf-8",
);
const result = await loadAndMaybeMigrateDoctorConfig({
options: { nonInteractive: true, repair: true },
confirm: async () => false,
});
const cfg = result.cfg as unknown as {
channels: {
discord: {
allowFrom: string[];
dm: { allowFrom: string[]; groupChannels: string[] };
execApprovals: { approvers: string[] };
guilds: Record<
string,
{
users: string[];
roles: string[];
channels: Record<string, { users: string[]; roles: string[] }>;
}
>;
accounts: Record<
string,
{
allowFrom: string[];
dm: { allowFrom: string[]; groupChannels: string[] };
execApprovals: { approvers: string[] };
guilds: Record<
string,
{
users: string[];
roles: string[];
channels: Record<string, { users: string[]; roles: string[] }>;
}
>;
}
>;
};
};
};
expect(cfg.channels.discord.allowFrom).toEqual(["123"]);
expect(cfg.channels.discord.dm.allowFrom).toEqual(["456"]);
expect(cfg.channels.discord.dm.groupChannels).toEqual(["789"]);
expect(cfg.channels.discord.execApprovals.approvers).toEqual(["321"]);
expect(cfg.channels.discord.guilds["100"].users).toEqual(["111"]);
expect(cfg.channels.discord.guilds["100"].roles).toEqual(["222"]);
expect(cfg.channels.discord.guilds["100"].channels.general.users).toEqual(["333"]);
expect(cfg.channels.discord.guilds["100"].channels.general.roles).toEqual(["444"]);
expect(cfg.channels.discord.accounts.work.allowFrom).toEqual(["555"]);
expect(cfg.channels.discord.accounts.work.dm.allowFrom).toEqual(["666"]);
expect(cfg.channels.discord.accounts.work.dm.groupChannels).toEqual(["777"]);
expect(cfg.channels.discord.accounts.work.execApprovals.approvers).toEqual(["888"]);
expect(cfg.channels.discord.accounts.work.guilds["200"].users).toEqual(["999"]);
expect(cfg.channels.discord.accounts.work.guilds["200"].roles).toEqual(["1010"]);
expect(cfg.channels.discord.accounts.work.guilds["200"].channels.help.users).toEqual([
"1111",
]);
expect(cfg.channels.discord.accounts.work.guilds["200"].channels.help.roles).toEqual([
"1212",
]);
});
});
});

View File

@@ -395,6 +395,164 @@ async function maybeRepairTelegramAllowFromUsernames(cfg: OpenClawConfig): Promi
return { config: next, changes };
}
type DiscordNumericIdHit = { path: string; entry: number };
type DiscordIdListRef = {
pathLabel: string;
holder: Record<string, unknown>;
key: string;
};
function collectDiscordAccountScopes(
cfg: OpenClawConfig,
): Array<{ prefix: string; account: Record<string, unknown> }> {
const scopes: Array<{ prefix: string; account: Record<string, unknown> }> = [];
const discord = asObjectRecord(cfg.channels?.discord);
if (!discord) {
return scopes;
}
scopes.push({ prefix: "channels.discord", account: discord });
const accounts = asObjectRecord(discord.accounts);
if (!accounts) {
return scopes;
}
for (const key of Object.keys(accounts)) {
const account = asObjectRecord(accounts[key]);
if (!account) {
continue;
}
scopes.push({ prefix: `channels.discord.accounts.${key}`, account });
}
return scopes;
}
function collectDiscordIdLists(
prefix: string,
account: Record<string, unknown>,
): DiscordIdListRef[] {
const refs: DiscordIdListRef[] = [
{ pathLabel: `${prefix}.allowFrom`, holder: account, key: "allowFrom" },
];
const dm = asObjectRecord(account.dm);
if (dm) {
refs.push({ pathLabel: `${prefix}.dm.allowFrom`, holder: dm, key: "allowFrom" });
refs.push({ pathLabel: `${prefix}.dm.groupChannels`, holder: dm, key: "groupChannels" });
}
const execApprovals = asObjectRecord(account.execApprovals);
if (execApprovals) {
refs.push({
pathLabel: `${prefix}.execApprovals.approvers`,
holder: execApprovals,
key: "approvers",
});
}
const guilds = asObjectRecord(account.guilds);
if (!guilds) {
return refs;
}
for (const guildId of Object.keys(guilds)) {
const guild = asObjectRecord(guilds[guildId]);
if (!guild) {
continue;
}
refs.push({ pathLabel: `${prefix}.guilds.${guildId}.users`, holder: guild, key: "users" });
refs.push({ pathLabel: `${prefix}.guilds.${guildId}.roles`, holder: guild, key: "roles" });
const channels = asObjectRecord(guild.channels);
if (!channels) {
continue;
}
for (const channelId of Object.keys(channels)) {
const channel = asObjectRecord(channels[channelId]);
if (!channel) {
continue;
}
refs.push({
pathLabel: `${prefix}.guilds.${guildId}.channels.${channelId}.users`,
holder: channel,
key: "users",
});
refs.push({
pathLabel: `${prefix}.guilds.${guildId}.channels.${channelId}.roles`,
holder: channel,
key: "roles",
});
}
}
return refs;
}
function scanDiscordNumericIdEntries(cfg: OpenClawConfig): DiscordNumericIdHit[] {
const hits: DiscordNumericIdHit[] = [];
const scanList = (pathLabel: string, list: unknown) => {
if (!Array.isArray(list)) {
return;
}
for (const [index, entry] of list.entries()) {
if (typeof entry !== "number") {
continue;
}
hits.push({ path: `${pathLabel}[${index}]`, entry });
}
};
for (const scope of collectDiscordAccountScopes(cfg)) {
for (const ref of collectDiscordIdLists(scope.prefix, scope.account)) {
scanList(ref.pathLabel, ref.holder[ref.key]);
}
}
return hits;
}
function maybeRepairDiscordNumericIds(cfg: OpenClawConfig): {
config: OpenClawConfig;
changes: string[];
} {
const hits = scanDiscordNumericIdEntries(cfg);
if (hits.length === 0) {
return { config: cfg, changes: [] };
}
const next = structuredClone(cfg);
const changes: string[] = [];
const repairList = (pathLabel: string, holder: Record<string, unknown>, key: string) => {
const raw = holder[key];
if (!Array.isArray(raw)) {
return;
}
let converted = 0;
const updated = raw.map((entry) => {
if (typeof entry === "number") {
converted += 1;
return String(entry);
}
return entry;
});
if (converted === 0) {
return;
}
holder[key] = updated;
changes.push(
`- ${pathLabel}: converted ${converted} numeric ${converted === 1 ? "entry" : "entries"} to strings`,
);
};
for (const scope of collectDiscordAccountScopes(next)) {
for (const ref of collectDiscordIdLists(scope.prefix, scope.account)) {
repairList(ref.pathLabel, ref.holder, ref.key);
}
}
if (changes.length === 0) {
return { config: cfg, changes: [] };
}
return { config: next, changes };
}
async function maybeMigrateLegacyConfig(): Promise<string[]> {
const changes: string[] = [];
const home = resolveHomeDir();
@@ -533,6 +691,14 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
pendingChanges = true;
cfg = repair.config;
}
const discordRepair = maybeRepairDiscordNumericIds(candidate);
if (discordRepair.changes.length > 0) {
note(discordRepair.changes.join("\n"), "Doctor changes");
candidate = discordRepair.config;
pendingChanges = true;
cfg = discordRepair.config;
}
} else {
const hits = scanTelegramAllowFromUsernameEntries(candidate);
if (hits.length > 0) {
@@ -544,6 +710,17 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
"Doctor warnings",
);
}
const discordHits = scanDiscordNumericIdEntries(candidate);
if (discordHits.length > 0) {
note(
[
`- Discord allowlists contain ${discordHits.length} numeric entries (e.g. ${discordHits[0]?.path}=${discordHits[0]?.entry}).`,
`- Discord IDs must be strings; run "${formatCliCommand("openclaw doctor --fix")}" to convert numeric IDs to quoted strings.`,
].join("\n"),
"Doctor warnings",
);
}
}
const unknown = stripUnknownConfigKeys(candidate);