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

@@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai
- Security/Sessions: create new session transcript JSONL files with user-only (`0o600`) permissions and extend `openclaw security audit --fix` to remediate existing transcript file permissions.
- Infra/Fetch: ensure foreign abort-signal listener cleanup never masks original fetch successes/failures, while still preventing detached-finally unhandled rejection noise in `wrapFetchWithAbortSignal`. Thanks @Jackten.
- Gateway/Config: prevent `config.patch` object-array merges from falling back to full-array replacement when some patch entries lack `id`, so partial `agents.list` updates no longer drop unrelated agents. (#17989) Thanks @stakeswky.
- Config/Discord: require string IDs in Discord allowlists, keep onboarding inputs string-only, and add doctor repair for numeric entries. (#18220) Thanks @thewilloftheshadow.
- Agents/Models: probe the primary model when its auth-profile cooldown is near expiry (with per-provider throttling), so runs recover from temporary rate limits without staying on fallback models until restart. (#17478) Thanks @PlayerGhost.
- Agents/Tools: scope the `message` tool schema to the active channel so Telegram uses `buttons` and Discord uses `components`. (#18215) Thanks @obviyus.
- Telegram: keep draft-stream preview replies attached to the user message for `replyToMode: "all"` in groups and DMs, preserving threaded reply context from preview through finalization. (#17880) Thanks @yinghaosang.

View File

@@ -17,14 +17,23 @@ import { resolveDiscordUserAllowlist } from "../../../discord/resolve-users.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../routing/session-key.js";
import { formatDocsLink } from "../../../terminal/links.js";
import { promptChannelAccessConfig } from "./channel-access.js";
import { addWildcardAllowFrom, promptAccountId } from "./helpers.js";
import { promptAccountId } from "./helpers.js";
function addDiscordWildcardAllowFrom(allowFrom?: string[] | null): string[] {
const next = (allowFrom ?? []).map((entry) => entry.trim()).filter(Boolean);
if (!next.includes("*")) {
next.push("*");
}
return next;
}
const channel = "discord" as const;
function setDiscordDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy) {
const existingAllowFrom =
cfg.channels?.discord?.allowFrom ?? cfg.channels?.discord?.dm?.allowFrom;
const allowFrom = dmPolicy === "open" ? addWildcardAllowFrom(existingAllowFrom) : undefined;
const allowFrom =
dmPolicy === "open" ? addDiscordWildcardAllowFrom(existingAllowFrom) : undefined;
return {
...cfg,
channels: {

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);

View File

@@ -1,7 +1,7 @@
import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { loadConfig } from "./config.js";
import { loadConfig, validateConfigObject } from "./config.js";
import { withTempHome } from "./test-helpers.js";
describe("config discord", () => {
@@ -68,4 +68,32 @@ describe("config discord", () => {
expect(cfg.channels?.discord?.guilds?.["123"]?.channels?.general?.allow).toBe(true);
});
});
it("rejects numeric discord allowlist entries", () => {
const res = validateConfigObject({
channels: {
discord: {
allowFrom: [123],
dm: { allowFrom: [456], groupChannels: [789] },
guilds: {
"123": {
users: [111],
roles: [222],
channels: {
general: { users: [333], roles: [444] },
},
},
},
execApprovals: { approvers: [555] },
},
},
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(
res.issues.some((issue) => issue.message.includes("Discord IDs must be strings")),
).toBe(true);
}
});
});

View File

@@ -17,11 +17,11 @@ export type DiscordDmConfig = {
/** Direct message access policy (default: pairing). */
policy?: DmPolicy;
/** Allowlist for DM senders (ids or names). */
allowFrom?: Array<string | number>;
allowFrom?: string[];
/** If true, allow group DMs (default: false). */
groupEnabled?: boolean;
/** Optional allowlist for group DM channels (ids or slugs). */
groupChannels?: Array<string | number>;
groupChannels?: string[];
};
export type DiscordGuildChannelConfig = {
@@ -35,9 +35,9 @@ export type DiscordGuildChannelConfig = {
/** If false, disable the bot for this channel. */
enabled?: boolean;
/** Optional allowlist for channel senders (ids or names). */
users?: Array<string | number>;
users?: string[];
/** Optional allowlist for channel senders by role ID. */
roles?: Array<string | number>;
roles?: string[];
/** Optional system prompt snippet for this channel. */
systemPrompt?: string;
/** If false, omit thread starter context for this channel (default: true). */
@@ -55,9 +55,9 @@ export type DiscordGuildEntry = {
/** Reaction notification mode (off|own|all|allowlist). Default: own. */
reactionNotifications?: DiscordReactionNotificationMode;
/** Optional allowlist for guild senders (ids or names). */
users?: Array<string | number>;
users?: string[];
/** Optional allowlist for guild senders by role ID. */
roles?: Array<string | number>;
roles?: string[];
channels?: Record<string, DiscordGuildChannelConfig>;
};
@@ -95,7 +95,7 @@ export type DiscordExecApprovalConfig = {
/** Enable exec approval forwarding to Discord DMs. Default: false. */
enabled?: boolean;
/** Discord user IDs to receive approval prompts. Required if enabled. */
approvers?: Array<string | number>;
approvers?: string[];
/** Only forward approvals for these agent IDs. Omit = all agents. */
agentFilter?: string[];
/** Only forward approvals matching these session key patterns (substring or regex). */
@@ -182,7 +182,7 @@ export type DiscordAccountConfig = {
* Alias for dm.allowFrom (prefer this so it inherits cleanly via base->account shallow merge).
* Legacy key: channels.discord.dm.allowFrom.
*/
allowFrom?: Array<string | number>;
allowFrom?: string[];
dm?: DiscordDmConfig;
/** New per-guild config keyed by guild id or slug. */
guilds?: Record<string, DiscordGuildEntry>;

View File

@@ -25,6 +25,13 @@ import { sensitive } from "./zod-schema.sensitive.js";
const ToolPolicyBySenderSchema = z.record(z.string(), ToolPolicySchema).optional();
const DiscordIdSchema = z
.union([z.string(), z.number()])
.refine((value) => typeof value === "string", {
message: "Discord IDs must be strings (wrap numeric IDs in quotes).",
});
const DiscordIdListSchema = z.array(DiscordIdSchema);
const TelegramInlineButtonsScopeSchema = z.enum(["off", "dm", "group", "all", "allowlist"]);
const TelegramCapabilitiesSchema = z.union([
@@ -214,9 +221,9 @@ export const DiscordDmSchema = z
.object({
enabled: z.boolean().optional(),
policy: DmPolicySchema.optional(),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
allowFrom: DiscordIdListSchema.optional(),
groupEnabled: z.boolean().optional(),
groupChannels: z.array(z.union([z.string(), z.number()])).optional(),
groupChannels: DiscordIdListSchema.optional(),
})
.strict();
@@ -228,8 +235,8 @@ export const DiscordGuildChannelSchema = z
toolsBySender: ToolPolicyBySenderSchema,
skills: z.array(z.string()).optional(),
enabled: z.boolean().optional(),
users: z.array(z.union([z.string(), z.number()])).optional(),
roles: z.array(z.union([z.string(), z.number()])).optional(),
users: DiscordIdListSchema.optional(),
roles: DiscordIdListSchema.optional(),
systemPrompt: z.string().optional(),
includeThreadStarter: z.boolean().optional(),
autoThread: z.boolean().optional(),
@@ -243,8 +250,8 @@ export const DiscordGuildSchema = z
tools: ToolPolicySchema,
toolsBySender: ToolPolicyBySenderSchema,
reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(),
users: z.array(z.union([z.string(), z.number()])).optional(),
roles: z.array(z.union([z.string(), z.number()])).optional(),
users: DiscordIdListSchema.optional(),
roles: DiscordIdListSchema.optional(),
channels: z.record(z.string(), DiscordGuildChannelSchema.optional()).optional(),
})
.strict();
@@ -311,14 +318,14 @@ export const DiscordAccountSchema = z
// Aliases for channels.discord.dm.policy / channels.discord.dm.allowFrom. Prefer these for
// inheritance in multi-account setups (shallow merge works; nested dm object doesn't).
dmPolicy: DmPolicySchema.optional(),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
allowFrom: DiscordIdListSchema.optional(),
dm: DiscordDmSchema.optional(),
guilds: z.record(z.string(), DiscordGuildSchema.optional()).optional(),
heartbeat: ChannelHeartbeatVisibilitySchema,
execApprovals: z
.object({
enabled: z.boolean().optional(),
approvers: z.array(z.union([z.string(), z.number()])).optional(),
approvers: DiscordIdListSchema.optional(),
agentFilter: z.array(z.string()).optional(),
sessionFilter: z.array(z.string()).optional(),
cleanupAfterResolve: z.boolean().optional(),

View File

@@ -334,7 +334,7 @@ export type AgentComponentContext = {
token?: string;
guildEntries?: Record<string, DiscordGuildEntryResolved>;
/** DM allowlist (from allowFrom config; legacy: dm.allowFrom) */
allowFrom?: Array<string | number>;
allowFrom?: string[];
/** DM policy (default: "pairing") */
dmPolicy?: "open" | "pairing" | "allowlist" | "disabled";
};

View File

@@ -21,8 +21,8 @@ export type DiscordGuildEntryResolved = {
slug?: string;
requireMention?: boolean;
reactionNotifications?: "off" | "own" | "all" | "allowlist";
users?: Array<string | number>;
roles?: Array<string | number>;
users?: string[];
roles?: string[];
channels?: Record<
string,
{
@@ -30,8 +30,8 @@ export type DiscordGuildEntryResolved = {
requireMention?: boolean;
skills?: string[];
enabled?: boolean;
users?: Array<string | number>;
roles?: Array<string | number>;
users?: string[];
roles?: string[];
systemPrompt?: string;
includeThreadStarter?: boolean;
autoThread?: boolean;
@@ -44,8 +44,8 @@ export type DiscordChannelConfigResolved = {
requireMention?: boolean;
skills?: string[];
enabled?: boolean;
users?: Array<string | number>;
roles?: Array<string | number>;
users?: string[];
roles?: string[];
systemPrompt?: string;
includeThreadStarter?: boolean;
autoThread?: boolean;
@@ -53,10 +53,7 @@ export type DiscordChannelConfigResolved = {
matchSource?: ChannelMatchSource;
};
export function normalizeDiscordAllowList(
raw: Array<string | number> | undefined,
prefixes: string[],
) {
export function normalizeDiscordAllowList(raw: string[] | undefined, prefixes: string[]) {
if (!raw || raw.length === 0) {
return null;
}
@@ -141,7 +138,7 @@ export function resolveDiscordAllowListMatch(params: {
}
export function resolveDiscordUserAllowed(params: {
allowList?: Array<string | number>;
allowList?: string[];
userId: string;
userName?: string;
userTag?: string;
@@ -158,10 +155,10 @@ export function resolveDiscordUserAllowed(params: {
}
export function resolveDiscordRoleAllowed(params: {
allowList?: Array<string | number>;
allowList?: string[];
memberRoleIds: string[];
}) {
// Role allowlists accept role IDs only (string or number). Names are ignored.
// Role allowlists accept role IDs only. Names are ignored.
const allowList = normalizeDiscordAllowList(params.allowList, ["role:"]);
if (!allowList) {
return true;
@@ -173,8 +170,8 @@ export function resolveDiscordRoleAllowed(params: {
}
export function resolveDiscordMemberAllowed(params: {
userAllowList?: Array<string | number>;
roleAllowList?: Array<string | number>;
userAllowList?: string[];
roleAllowList?: string[];
memberRoleIds: string[];
userId: string;
userName?: string;
@@ -253,7 +250,7 @@ export function resolveDiscordOwnerAllowFrom(params: {
export function resolveDiscordCommandAuthorized(params: {
isDirectMessage: boolean;
allowFrom?: Array<string | number>;
allowFrom?: string[];
guildInfo?: DiscordGuildEntryResolved | null;
author: User;
}) {
@@ -478,7 +475,7 @@ export function isDiscordGroupAllowedByPolicy(params: {
}
export function resolveGroupDmAllow(params: {
channels?: Array<string | number>;
channels?: string[];
channelId: string;
channelName?: string;
channelSlug: string;
@@ -503,7 +500,7 @@ export function shouldEmitDiscordReactionNotification(params: {
userId: string;
userName?: string;
userTag?: string;
allowlist?: Array<string | number>;
allowlist?: string[];
}) {
const mode = params.mode ?? "own";
if (mode === "off") {

View File

@@ -721,7 +721,7 @@ export class DiscordExecApprovalHandler {
}
/** Return the list of configured approver IDs. */
getApprovers(): Array<string | number> {
getApprovers(): string[] {
return this.opts.config.approvers ?? [];
}
}

View File

@@ -95,8 +95,8 @@ export type DiscordMessagePreflightParams = {
replyToMode: ReplyToMode;
dmEnabled: boolean;
groupDmEnabled: boolean;
groupDmChannels?: Array<string | number>;
allowFrom?: Array<string | number>;
groupDmChannels?: string[];
allowFrom?: string[];
guildEntries?: Record<string, DiscordGuildEntryResolved>;
ackReactionScope: DiscordMessagePreflightContext["ackReactionScope"];
groupPolicy: DiscordMessagePreflightContext["groupPolicy"];

View File

@@ -77,7 +77,7 @@ export type MonitorDiscordOpts = {
replyToMode?: ReplyToMode;
};
function summarizeAllowList(list?: Array<string | number>) {
function summarizeAllowList(list?: string[]) {
if (!list || list.length === 0) {
return "any";
}
@@ -352,7 +352,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
continue;
}
const nextGuild = { ...guildConfig } as Record<string, unknown>;
const users = (guildConfig as { users?: Array<string | number> }).users;
const users = (guildConfig as { users?: string[] }).users;
if (Array.isArray(users) && users.length > 0) {
const additions = resolveAllowlistIdAdditions({ existing: users, resolvedMap });
nextGuild.users = mergeAllowlist({ existing: users, additions });