refactor(config): add dmPolicy aliases for Slack/Discord

This commit is contained in:
Peter Steinberger
2026-02-14 20:32:12 +01:00
parent b9d14855d0
commit 47b6cde8ca
13 changed files with 170 additions and 54 deletions

View File

@@ -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,
};

View File

@@ -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,
};

View File

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

View File

@@ -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" },

View File

@@ -353,6 +353,8 @@ export const FIELD_HELP: Record<string, string> = {
'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<string, string> = {
"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=["*"].',
};

View File

@@ -249,6 +249,7 @@ export const FIELD_LABELS: Record<string, string> = {
"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<string, string> = {
"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",

View File

@@ -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<string | number>;
dm?: DiscordDmConfig;
/** New per-guild config keyed by guild id or slug. */
guilds?: Record<string, DiscordGuildEntry>;

View File

@@ -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<string | number>;
dm?: SlackDmConfig;
channels?: Record<string, SlackChannelConfig>;
/** Heartbeat visibility settings for this channel. */

View File

@@ -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"),

View File

@@ -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") {

View File

@@ -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, {

View File

@@ -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({

View File

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