diff --git a/src/channels/plugins/onboarding/discord.ts b/src/channels/plugins/onboarding/discord.ts index 612a3788a16..1594e671828 100644 --- a/src/channels/plugins/onboarding/discord.ts +++ b/src/channels/plugins/onboarding/discord.ts @@ -22,19 +22,20 @@ import { addWildcardAllowFrom, promptAccountId } from "./helpers.js"; const channel = "discord" as const; function setDiscordDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy) { - const allowFrom = - dmPolicy === "open" ? addWildcardAllowFrom(cfg.channels?.discord?.dm?.allowFrom) : undefined; + const existingAllowFrom = + cfg.channels?.discord?.allowFrom ?? cfg.channels?.discord?.dm?.allowFrom; + const allowFrom = dmPolicy === "open" ? addWildcardAllowFrom(existingAllowFrom) : undefined; return { ...cfg, channels: { ...cfg.channels, discord: { ...cfg.channels?.discord, + dmPolicy, + ...(allowFrom ? { allowFrom } : {}), dm: { ...cfg.channels?.discord?.dm, enabled: cfg.channels?.discord?.dm?.enabled ?? true, - policy: dmPolicy, - ...(allowFrom ? { allowFrom } : {}), }, }, }, @@ -156,10 +157,10 @@ function setDiscordAllowFrom(cfg: OpenClawConfig, allowFrom: string[]): OpenClaw ...cfg.channels, discord: { ...cfg.channels?.discord, + allowFrom, dm: { ...cfg.channels?.discord?.dm, enabled: cfg.channels?.discord?.dm?.enabled ?? true, - allowFrom, }, }, }, @@ -184,7 +185,8 @@ async function promptDiscordAllowFrom(params: { : resolveDefaultDiscordAccountId(params.cfg); const resolved = resolveDiscordAccount({ cfg: params.cfg, accountId }); const token = resolved.token; - const existing = params.cfg.channels?.discord?.dm?.allowFrom ?? []; + const existing = + params.cfg.channels?.discord?.allowFrom ?? params.cfg.channels?.discord?.dm?.allowFrom ?? []; await params.prompter.note( [ "Allowlist Discord DMs by username (we resolve to user ids).", @@ -263,9 +265,10 @@ async function promptDiscordAllowFrom(params: { const dmPolicy: ChannelOnboardingDmPolicy = { label: "Discord", channel, - policyKey: "channels.discord.dm.policy", - allowFromKey: "channels.discord.dm.allowFrom", - getCurrent: (cfg) => cfg.channels?.discord?.dm?.policy ?? "pairing", + policyKey: "channels.discord.dmPolicy", + allowFromKey: "channels.discord.allowFrom", + getCurrent: (cfg) => + cfg.channels?.discord?.dmPolicy ?? cfg.channels?.discord?.dm?.policy ?? "pairing", setPolicy: (cfg, policy) => setDiscordDmPolicy(cfg, policy), promptAllowFrom: promptDiscordAllowFrom, }; diff --git a/src/channels/plugins/onboarding/slack.ts b/src/channels/plugins/onboarding/slack.ts index 0919a35bf6a..45afa48ca2b 100644 --- a/src/channels/plugins/onboarding/slack.ts +++ b/src/channels/plugins/onboarding/slack.ts @@ -17,19 +17,19 @@ import { addWildcardAllowFrom, promptAccountId } from "./helpers.js"; const channel = "slack" as const; function setSlackDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy) { - const allowFrom = - dmPolicy === "open" ? addWildcardAllowFrom(cfg.channels?.slack?.dm?.allowFrom) : undefined; + const existingAllowFrom = cfg.channels?.slack?.allowFrom ?? cfg.channels?.slack?.dm?.allowFrom; + const allowFrom = dmPolicy === "open" ? addWildcardAllowFrom(existingAllowFrom) : undefined; return { ...cfg, channels: { ...cfg.channels, slack: { ...cfg.channels?.slack, + dmPolicy, + ...(allowFrom ? { allowFrom } : {}), dm: { ...cfg.channels?.slack?.dm, enabled: cfg.channels?.slack?.dm?.enabled ?? true, - policy: dmPolicy, - ...(allowFrom ? { allowFrom } : {}), }, }, }, @@ -208,10 +208,10 @@ function setSlackAllowFrom(cfg: OpenClawConfig, allowFrom: string[]): OpenClawCo ...cfg.channels, slack: { ...cfg.channels?.slack, + allowFrom, dm: { ...cfg.channels?.slack?.dm, enabled: cfg.channels?.slack?.dm?.enabled ?? true, - allowFrom, }, }, }, @@ -236,7 +236,8 @@ async function promptSlackAllowFrom(params: { : resolveDefaultSlackAccountId(params.cfg); const resolved = resolveSlackAccount({ cfg: params.cfg, accountId }); const token = resolved.config.userToken ?? resolved.config.botToken ?? ""; - const existing = params.cfg.channels?.slack?.dm?.allowFrom ?? []; + const existing = + params.cfg.channels?.slack?.allowFrom ?? params.cfg.channels?.slack?.dm?.allowFrom ?? []; await params.prompter.note( [ "Allowlist Slack DMs by username (we resolve to user ids).", @@ -313,9 +314,10 @@ async function promptSlackAllowFrom(params: { const dmPolicy: ChannelOnboardingDmPolicy = { label: "Slack", channel, - policyKey: "channels.slack.dm.policy", - allowFromKey: "channels.slack.dm.allowFrom", - getCurrent: (cfg) => cfg.channels?.slack?.dm?.policy ?? "pairing", + policyKey: "channels.slack.dmPolicy", + allowFromKey: "channels.slack.allowFrom", + getCurrent: (cfg) => + cfg.channels?.slack?.dmPolicy ?? cfg.channels?.slack?.dm?.policy ?? "pairing", setPolicy: (cfg, policy) => setSlackDmPolicy(cfg, policy), promptAllowFrom: promptSlackAllowFrom, }; diff --git a/src/config/config.dm-policy-alias.test.ts b/src/config/config.dm-policy-alias.test.ts new file mode 100644 index 00000000000..cc07614e927 --- /dev/null +++ b/src/config/config.dm-policy-alias.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { validateConfigObject } from "./config.js"; + +describe("DM policy aliases (Slack/Discord)", () => { + it('rejects discord dmPolicy="open" without allowFrom "*"', () => { + const res = validateConfigObject({ + channels: { discord: { dmPolicy: "open", allowFrom: ["123"] } }, + }); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.issues[0]?.path).toBe("channels.discord.allowFrom"); + } + }); + + it('accepts discord legacy dm.policy="open" with top-level allowFrom alias', () => { + const res = validateConfigObject({ + channels: { discord: { dm: { policy: "open", allowFrom: ["123"] }, allowFrom: ["*"] } }, + }); + expect(res.ok).toBe(true); + }); + + it('rejects slack dmPolicy="open" without allowFrom "*"', () => { + const res = validateConfigObject({ + channels: { slack: { dmPolicy: "open", allowFrom: ["U123"] } }, + }); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.issues[0]?.path).toBe("channels.slack.allowFrom"); + } + }); + + it('accepts slack legacy dm.policy="open" with top-level allowFrom alias', () => { + const res = validateConfigObject({ + channels: { slack: { dm: { policy: "open", allowFrom: ["U123"] }, allowFrom: ["*"] } }, + }); + expect(res.ok).toBe(true); + }); +}); diff --git a/src/config/config.legacy-config-detection.accepts-imessage-dmpolicy.e2e.test.ts b/src/config/config.legacy-config-detection.accepts-imessage-dmpolicy.e2e.test.ts index 07e3ec51762..2ba26ac6de3 100644 --- a/src/config/config.legacy-config-detection.accepts-imessage-dmpolicy.e2e.test.ts +++ b/src/config/config.legacy-config-detection.accepts-imessage-dmpolicy.e2e.test.ts @@ -87,6 +87,21 @@ describe("legacy config detection", () => { expect(res.issues[0]?.path).toBe("channels.discord.dm.allowFrom"); } }); + it('rejects discord.dmPolicy="open" without allowFrom "*"', async () => { + const res = validateConfigObject({ + channels: { discord: { dmPolicy: "open", allowFrom: ["123"] } }, + }); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.issues[0]?.path).toBe("channels.discord.allowFrom"); + } + }); + it('accepts discord dm.allowFrom="*" with top-level allowFrom alias', async () => { + const res = validateConfigObject({ + channels: { discord: { dm: { policy: "open", allowFrom: ["123"] }, allowFrom: ["*"] } }, + }); + expect(res.ok).toBe(true); + }); it('rejects slack.dm.policy="open" without allowFrom "*"', async () => { const res = validateConfigObject({ channels: { slack: { dm: { policy: "open", allowFrom: ["U123"] } } }, @@ -96,6 +111,21 @@ describe("legacy config detection", () => { expect(res.issues[0]?.path).toBe("channels.slack.dm.allowFrom"); } }); + it('rejects slack.dmPolicy="open" without allowFrom "*"', async () => { + const res = validateConfigObject({ + channels: { slack: { dmPolicy: "open", allowFrom: ["U123"] } }, + }); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.issues[0]?.path).toBe("channels.slack.allowFrom"); + } + }); + it('accepts slack dm.allowFrom="*" with top-level allowFrom alias', async () => { + const res = validateConfigObject({ + channels: { slack: { dm: { policy: "open", allowFrom: ["U123"] }, allowFrom: ["*"] } }, + }); + expect(res.ok).toBe(true); + }); it("rejects legacy agent.model string", async () => { const res = validateConfigObject({ agent: { model: "anthropic/claude-opus-4-5" }, diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 9f1fe795aff..14c735f9793 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -353,6 +353,8 @@ export const FIELD_HELP: Record = { 'Direct message access control ("pairing" recommended). "open" requires channels.imessage.allowFrom=["*"].', "channels.bluebubbles.dmPolicy": 'Direct message access control ("pairing" recommended). "open" requires channels.bluebubbles.allowFrom=["*"].', + "channels.discord.dmPolicy": + 'Direct message access control ("pairing" recommended). "open" requires channels.discord.allowFrom=["*"].', "channels.discord.dm.policy": 'Direct message access control ("pairing" recommended). "open" requires channels.discord.dm.allowFrom=["*"].', "channels.discord.retry.attempts": @@ -376,4 +378,6 @@ export const FIELD_HELP: Record = { "channels.discord.activityUrl": "Discord presence streaming URL (required for activityType=1).", "channels.slack.dm.policy": 'Direct message access control ("pairing" recommended). "open" requires channels.slack.dm.allowFrom=["*"].', + "channels.slack.dmPolicy": + 'Direct message access control ("pairing" recommended). "open" requires channels.slack.allowFrom=["*"].', }; diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 5f0b0a53528..c93aa5ceb70 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -249,6 +249,7 @@ export const FIELD_LABELS: Record = { "channels.signal.dmPolicy": "Signal DM Policy", "channels.imessage.dmPolicy": "iMessage DM Policy", "channels.bluebubbles.dmPolicy": "BlueBubbles DM Policy", + "channels.discord.dmPolicy": "Discord DM Policy", "channels.discord.dm.policy": "Discord DM Policy", "channels.discord.retry.attempts": "Discord Retry Attempts", "channels.discord.retry.minDelayMs": "Discord Retry Min Delay (ms)", @@ -264,6 +265,7 @@ export const FIELD_LABELS: Record = { "channels.discord.activityType": "Discord Presence Activity Type", "channels.discord.activityUrl": "Discord Presence Activity URL", "channels.slack.dm.policy": "Slack DM Policy", + "channels.slack.dmPolicy": "Slack DM Policy", "channels.slack.allowBots": "Slack Allow Bot Messages", "channels.discord.token": "Discord Bot Token", "channels.slack.botToken": "Slack Bot Token", diff --git a/src/config/types.discord.ts b/src/config/types.discord.ts index 9910d974b71..b2f248459f4 100644 --- a/src/config/types.discord.ts +++ b/src/config/types.discord.ts @@ -164,6 +164,16 @@ export type DiscordAccountConfig = { actions?: DiscordActionConfig; /** Control reply threading when reply tags are present (off|first|all). */ replyToMode?: ReplyToMode; + /** + * Alias for dm.policy (prefer this so it inherits cleanly via base->account shallow merge). + * Legacy key: channels.discord.dm.policy. + */ + dmPolicy?: DmPolicy; + /** + * Alias for dm.allowFrom (prefer this so it inherits cleanly via base->account shallow merge). + * Legacy key: channels.discord.dm.allowFrom. + */ + allowFrom?: Array; dm?: DiscordDmConfig; /** New per-guild config keyed by guild id or slug. */ guilds?: Record; diff --git a/src/config/types.slack.ts b/src/config/types.slack.ts index c79e031a8c2..a7f7bef2c80 100644 --- a/src/config/types.slack.ts +++ b/src/config/types.slack.ts @@ -140,6 +140,16 @@ export type SlackAccountConfig = { thread?: SlackThreadConfig; actions?: SlackActionConfig; slashCommand?: SlackSlashCommandConfig; + /** + * Alias for dm.policy (prefer this so it inherits cleanly via base->account shallow merge). + * Legacy key: channels.slack.dm.policy. + */ + dmPolicy?: DmPolicy; + /** + * Alias for dm.allowFrom (prefer this so it inherits cleanly via base->account shallow merge). + * Legacy key: channels.slack.dm.allowFrom. + */ + allowFrom?: Array; dm?: SlackDmConfig; channels?: Record; /** Heartbeat visibility settings for this channel. */ diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 8dae8786b02..632b86d1190 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -216,17 +216,7 @@ export const DiscordDmSchema = z groupEnabled: z.boolean().optional(), groupChannels: z.array(z.union([z.string(), z.number()])).optional(), }) - .strict() - .superRefine((value, ctx) => { - requireOpenAllowFrom({ - policy: value.policy, - allowFrom: value.allowFrom, - ctx, - path: ["allowFrom"], - message: - 'channels.discord.dm.policy="open" requires channels.discord.dm.allowFrom to include "*"', - }); - }); + .strict(); export const DiscordGuildChannelSchema = z .object({ @@ -304,6 +294,10 @@ export const DiscordAccountSchema = z .strict() .optional(), replyToMode: ReplyToModeSchema.optional(), + // 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(), dm: DiscordDmSchema.optional(), guilds: z.record(z.string(), DiscordGuildSchema.optional()).optional(), heartbeat: ChannelHeartbeatVisibilitySchema, @@ -371,6 +365,19 @@ export const DiscordAccountSchema = z path: ["activityType"], }); } + + const dmPolicy = value.dmPolicy ?? value.dm?.policy ?? "pairing"; + const allowFrom = value.allowFrom ?? value.dm?.allowFrom; + const allowFromPath = + value.allowFrom !== undefined ? (["allowFrom"] as const) : (["dm", "allowFrom"] as const); + requireOpenAllowFrom({ + policy: dmPolicy, + allowFrom, + ctx, + path: [...allowFromPath], + message: + 'channels.discord.dmPolicy="open" requires channels.discord.allowFrom (or channels.discord.dm.allowFrom) to include "*"', + }); }); export const DiscordConfigSchema = DiscordAccountSchema.extend({ @@ -458,17 +465,7 @@ export const SlackDmSchema = z groupChannels: z.array(z.union([z.string(), z.number()])).optional(), replyToMode: ReplyToModeSchema.optional(), }) - .strict() - .superRefine((value, ctx) => { - requireOpenAllowFrom({ - policy: value.policy, - allowFrom: value.allowFrom, - ctx, - path: ["allowFrom"], - message: - 'channels.slack.dm.policy="open" requires channels.slack.dm.allowFrom to include "*"', - }); - }); + .strict(); export const SlackChannelSchema = z .object({ @@ -553,14 +550,32 @@ export const SlackAccountSchema = z }) .strict() .optional(), + // Aliases for channels.slack.dm.policy / channels.slack.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(), dm: SlackDmSchema.optional(), channels: z.record(z.string(), SlackChannelSchema.optional()).optional(), heartbeat: ChannelHeartbeatVisibilitySchema, responsePrefix: z.string().optional(), }) - .strict(); + .strict() + .superRefine((value, ctx) => { + const dmPolicy = value.dmPolicy ?? value.dm?.policy ?? "pairing"; + const allowFrom = value.allowFrom ?? value.dm?.allowFrom; + const allowFromPath = + value.allowFrom !== undefined ? (["allowFrom"] as const) : (["dm", "allowFrom"] as const); + requireOpenAllowFrom({ + policy: dmPolicy, + allowFrom, + ctx, + path: [...allowFromPath], + message: + 'channels.slack.dmPolicy="open" requires channels.slack.allowFrom (or channels.slack.dm.allowFrom) to include "*"', + }); + }); -export const SlackConfigSchema = SlackAccountSchema.extend({ +export const SlackConfigSchema = SlackAccountSchema.safeExtend({ mode: z.enum(["socket", "http"]).optional().default("socket"), signingSecret: z.string().optional().register(sensitive), webhookPath: z.string().optional().default("/slack/events"), diff --git a/src/discord/monitor/message-handler.preflight.ts b/src/discord/monitor/message-handler.preflight.ts index b992b6904a1..6dba2ccecf2 100644 --- a/src/discord/monitor/message-handler.preflight.ts +++ b/src/discord/monitor/message-handler.preflight.ts @@ -118,7 +118,7 @@ export async function preflightDiscordMessage( return null; } - const dmPolicy = params.discordConfig?.dm?.policy ?? "pairing"; + const dmPolicy = params.discordConfig?.dmPolicy ?? params.discordConfig?.dm?.policy ?? "pairing"; let commandAuthorized = true; if (isDirectMessage) { if (dmPolicy === "disabled") { diff --git a/src/discord/monitor/native-command.ts b/src/discord/monitor/native-command.ts index e71b0beaa68..bb50f971276 100644 --- a/src/discord/monitor/native-command.ts +++ b/src/discord/monitor/native-command.ts @@ -543,11 +543,10 @@ async function dispatchDiscordCommandInteraction(params: { const memberRoleIds = Array.isArray(interaction.rawData.member?.roles) ? interaction.rawData.member.roles.map((roleId: string) => String(roleId)) : []; - const ownerAllowList = normalizeDiscordAllowList(discordConfig?.dm?.allowFrom ?? [], [ - "discord:", - "user:", - "pk:", - ]); + const ownerAllowList = normalizeDiscordAllowList( + discordConfig?.allowFrom ?? discordConfig?.dm?.allowFrom ?? [], + ["discord:", "user:", "pk:"], + ); const ownerOk = ownerAllowList && user ? allowListMatches(ownerAllowList, { @@ -616,7 +615,7 @@ async function dispatchDiscordCommandInteraction(params: { } } const dmEnabled = discordConfig?.dm?.enabled ?? true; - const dmPolicy = discordConfig?.dm?.policy ?? "pairing"; + const dmPolicy = discordConfig?.dmPolicy ?? discordConfig?.dm?.policy ?? "pairing"; let commandAuthorized = true; if (isDirectMessage) { if (!dmEnabled || dmPolicy === "disabled") { @@ -625,7 +624,10 @@ async function dispatchDiscordCommandInteraction(params: { } if (dmPolicy !== "open") { const storeAllowFrom = await readChannelAllowFromStore("discord").catch(() => []); - const effectiveAllowFrom = [...(discordConfig?.dm?.allowFrom ?? []), ...storeAllowFrom]; + const effectiveAllowFrom = [ + ...(discordConfig?.allowFrom ?? discordConfig?.dm?.allowFrom ?? []), + ...storeAllowFrom, + ]; const allowList = normalizeDiscordAllowList(effectiveAllowFrom, ["discord:", "user:", "pk:"]); const permitted = allowList ? allowListMatches(allowList, { diff --git a/src/discord/monitor/provider.ts b/src/discord/monitor/provider.ts index 0691e084879..62c2ba19f3c 100644 --- a/src/discord/monitor/provider.ts +++ b/src/discord/monitor/provider.ts @@ -156,7 +156,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { ), ); } - let allowFrom = dmConfig?.allowFrom; + let allowFrom = discordCfg.allowFrom ?? dmConfig?.allowFrom; const mediaMaxBytes = (opts.mediaMaxMb ?? discordCfg.mediaMaxMb ?? 8) * 1024 * 1024; const textLimit = resolveTextChunkLimit(cfg, "discord", account.accountId, { fallbackLimit: 2000, @@ -167,7 +167,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { ); const replyToMode = opts.replyToMode ?? discordCfg.replyToMode ?? "off"; const dmEnabled = dmConfig?.enabled ?? true; - const dmPolicy = dmConfig?.policy ?? "pairing"; + const dmPolicy = discordCfg.dmPolicy ?? dmConfig?.policy ?? "pairing"; const groupDmEnabled = dmConfig?.groupEnabled ?? false; const groupDmChannels = dmConfig?.groupChannels; const nativeEnabled = resolveNativeCommandsEnabled({ diff --git a/src/slack/monitor/provider.ts b/src/slack/monitor/provider.ts index 64768ee0ae3..275b9e37c51 100644 --- a/src/slack/monitor/provider.ts +++ b/src/slack/monitor/provider.ts @@ -87,8 +87,8 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { const dmConfig = slackCfg.dm; const dmEnabled = dmConfig?.enabled ?? true; - const dmPolicy = dmConfig?.policy ?? "pairing"; - let allowFrom = dmConfig?.allowFrom; + const dmPolicy = slackCfg.dmPolicy ?? dmConfig?.policy ?? "pairing"; + let allowFrom = slackCfg.allowFrom ?? dmConfig?.allowFrom; const groupDmEnabled = dmConfig?.groupEnabled ?? false; const groupDmChannels = dmConfig?.groupChannels; let channelsConfig = slackCfg.channels;