fix: add discord role allowlists (#10650) (thanks @Minidoracat)

This commit is contained in:
Shadow
2026-02-12 19:50:10 -06:00
committed by Shadow
parent f7adc21d31
commit 22fe30c1df
12 changed files with 293 additions and 122 deletions

View File

@@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai
- CLI: add `openclaw logs --local-time` to display log timestamps in local timezone. (#13818) Thanks @xialonglee.
- Telegram: render blockquotes as native `<blockquote>` tags instead of stripping them. (#14608)
- Discord: add role-based allowlists and role-based agent routing. (#10650) Thanks @Minidoracat.
- Config: avoid redacting `maxTokens`-like fields during config snapshot redaction, preventing round-trip validation failures in `/config`. (#14006) Thanks @constansino.
### Breaking

View File

@@ -28,7 +28,7 @@ Status: ready for DMs and guild channels via the official Discord gateway.
Create an application in the Discord Developer Portal, add a bot, then enable:
- **Message Content Intent**
- **Server Members Intent** (recommended for name-to-ID lookups and allowlist matching)
- **Server Members Intent** (required for role allowlists and role-based routing; recommended for name-to-ID allowlist matching)
</Step>
@@ -121,6 +121,7 @@ Token resolution is account-aware. Config token values win over env fallback. `D
`allowlist` behavior:
- guild must match `channels.discord.guilds` (`id` preferred, slug accepted)
- optional sender allowlists: `users` (IDs or names) and `roles` (role IDs only); if either is configured, senders are allowed when they match `users` OR `roles`
- if a guild has `channels` configured, non-listed channels are denied
- if a guild has no `channels` block, all channels in that allowlisted guild are allowed
@@ -135,6 +136,7 @@ Token resolution is account-aware. Config token values win over env fallback. `D
"123456789012345678": {
requireMention: true,
users: ["987654321098765432"],
roles: ["123456789012345678"],
channels: {
general: { allow: true },
help: { allow: true, requireMention: true },
@@ -169,6 +171,32 @@ Token resolution is account-aware. Config token values win over env fallback. `D
</Tab>
</Tabs>
### Role-based agent routing
Use `bindings[].match.roles` to route Discord guild members to different agents by role ID. Role-based bindings accept role IDs only and are evaluated after peer or parent-peer bindings and before guild-only bindings.
```json5
{
bindings: [
{
agentId: "opus",
match: {
channel: "discord",
guildId: "123456789012345678",
roles: ["111111111111111111"],
},
},
{
agentId: "sonnet",
match: {
channel: "discord",
guildId: "123456789012345678",
},
},
],
}
```
## Developer Portal setup
<AccordionGroup>

View File

@@ -78,6 +78,7 @@ export type AgentBinding = {
peer?: { kind: ChatType; id: string };
guildId?: string;
teamId?: string;
/** Discord role IDs used for role-based routing. */
roles?: string[];
};
};

View File

@@ -36,7 +36,7 @@ export type DiscordGuildChannelConfig = {
enabled?: boolean;
/** Optional allowlist for channel senders (ids or names). */
users?: Array<string | number>;
/** Optional allowlist for channel senders by role (ids or names). */
/** Optional allowlist for channel senders by role ID. */
roles?: Array<string | number>;
/** Optional system prompt snippet for this channel. */
systemPrompt?: string;
@@ -54,7 +54,9 @@ export type DiscordGuildEntry = {
toolsBySender?: GroupToolPolicyBySenderConfig;
/** Reaction notification mode (off|own|all|allowlist). Default: own. */
reactionNotifications?: DiscordReactionNotificationMode;
/** Optional allowlist for guild senders (ids or names). */
users?: Array<string | number>;
/** Optional allowlist for guild senders by role ID. */
roles?: Array<string | number>;
channels?: Record<string, DiscordGuildChannelConfig>;
};

View File

@@ -24,7 +24,7 @@ import {
resolveDiscordAllowListMatch,
resolveDiscordChannelConfigWithFallback,
resolveDiscordGuildEntry,
resolveDiscordUserAllowed,
resolveDiscordMemberAllowed,
} from "./allow-list.js";
import { formatDiscordUserTag } from "./format.js";
@@ -233,6 +233,9 @@ export class AgentComponentButton extends Button {
// when guild is not cached even though guild_id is present in rawData
const rawGuildId = interaction.rawData.guild_id;
const isDirectMessage = !rawGuildId;
const memberRoleIds = Array.isArray(interaction.rawData.member?.roles)
? interaction.rawData.member.roles.map((roleId: string) => String(roleId))
: [];
if (isDirectMessage) {
const authorized = await ensureDmComponentAuthorized({
@@ -294,25 +297,26 @@ export class AgentComponentButton extends Button {
});
const channelUsers = channelConfig?.users ?? guildInfo?.users;
if (Array.isArray(channelUsers) && channelUsers.length > 0) {
const userOk = resolveDiscordUserAllowed({
allowList: channelUsers,
userId,
userName: user.username,
userTag: user.discriminator ? `${user.username}#${user.discriminator}` : undefined,
});
if (!userOk) {
logVerbose(`agent button: blocked user ${userId} (not in allowlist)`);
try {
await interaction.reply({
content: "You are not authorized to use this button.",
ephemeral: true,
});
} catch {
// Interaction may have expired
}
return;
const channelRoles = channelConfig?.roles ?? guildInfo?.roles;
const memberAllowed = resolveDiscordMemberAllowed({
userAllowList: channelUsers,
roleAllowList: channelRoles,
memberRoleIds,
userId,
userName: user.username,
userTag: user.discriminator ? `${user.username}#${user.discriminator}` : undefined,
});
if (!memberAllowed) {
logVerbose(`agent button: blocked user ${userId} (not in users/roles allowlist)`);
try {
await interaction.reply({
content: "You are not authorized to use this button.",
ephemeral: true,
});
} catch {
// Interaction may have expired
}
return;
}
}
@@ -322,6 +326,7 @@ export class AgentComponentButton extends Button {
channel: "discord",
accountId: this.ctx.accountId,
guildId: rawGuildId,
memberRoleIds,
peer: {
kind: isDirectMessage ? "direct" : "channel",
id: isDirectMessage ? userId : channelId,
@@ -399,6 +404,9 @@ export class AgentSelectMenu extends StringSelectMenu {
// when guild is not cached even though guild_id is present in rawData
const rawGuildId = interaction.rawData.guild_id;
const isDirectMessage = !rawGuildId;
const memberRoleIds = Array.isArray(interaction.rawData.member?.roles)
? interaction.rawData.member.roles.map((roleId: string) => String(roleId))
: [];
if (isDirectMessage) {
const authorized = await ensureDmComponentAuthorized({
@@ -456,25 +464,26 @@ export class AgentSelectMenu extends StringSelectMenu {
});
const channelUsers = channelConfig?.users ?? guildInfo?.users;
if (Array.isArray(channelUsers) && channelUsers.length > 0) {
const userOk = resolveDiscordUserAllowed({
allowList: channelUsers,
userId,
userName: user.username,
userTag: user.discriminator ? `${user.username}#${user.discriminator}` : undefined,
});
if (!userOk) {
logVerbose(`agent select: blocked user ${userId} (not in allowlist)`);
try {
await interaction.reply({
content: "You are not authorized to use this select menu.",
ephemeral: true,
});
} catch {
// Interaction may have expired
}
return;
const channelRoles = channelConfig?.roles ?? guildInfo?.roles;
const memberAllowed = resolveDiscordMemberAllowed({
userAllowList: channelUsers,
roleAllowList: channelRoles,
memberRoleIds,
userId,
userName: user.username,
userTag: user.discriminator ? `${user.username}#${user.discriminator}` : undefined,
});
if (!memberAllowed) {
logVerbose(`agent select: blocked user ${userId} (not in users/roles allowlist)`);
try {
await interaction.reply({
content: "You are not authorized to use this select menu.",
ephemeral: true,
});
} catch {
// Interaction may have expired
}
return;
}
}
@@ -488,6 +497,7 @@ export class AgentSelectMenu extends StringSelectMenu {
channel: "discord",
accountId: this.ctx.accountId,
guildId: rawGuildId,
memberRoleIds,
peer: {
kind: isDirectMessage ? "direct" : "channel",
id: isDirectMessage ? userId : channelId,

View File

@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest";
import type { DiscordChannelConfigResolved } from "./allow-list.js";
import { resolveDiscordOwnerAllowFrom } from "./allow-list.js";
import {
resolveDiscordMemberAllowed,
resolveDiscordOwnerAllowFrom,
resolveDiscordRoleAllowed,
} from "./allow-list.js";
describe("resolveDiscordOwnerAllowFrom", () => {
it("returns undefined when no allowlist is configured", () => {
@@ -39,3 +43,87 @@ describe("resolveDiscordOwnerAllowFrom", () => {
expect(result).toEqual(["some-user"]);
});
});
describe("resolveDiscordRoleAllowed", () => {
it("allows when no role allowlist is configured", () => {
const allowed = resolveDiscordRoleAllowed({
allowList: undefined,
memberRoleIds: ["role-1"],
});
expect(allowed).toBe(true);
});
it("matches role IDs only", () => {
const allowed = resolveDiscordRoleAllowed({
allowList: ["123"],
memberRoleIds: ["123", "456"],
});
expect(allowed).toBe(true);
});
it("does not match non-ID role entries", () => {
const allowed = resolveDiscordRoleAllowed({
allowList: ["Admin"],
memberRoleIds: ["Admin"],
});
expect(allowed).toBe(false);
});
it("returns false when no matching role IDs", () => {
const allowed = resolveDiscordRoleAllowed({
allowList: ["456"],
memberRoleIds: ["123"],
});
expect(allowed).toBe(false);
});
});
describe("resolveDiscordMemberAllowed", () => {
it("allows when no user or role allowlists are configured", () => {
const allowed = resolveDiscordMemberAllowed({
userAllowList: undefined,
roleAllowList: undefined,
memberRoleIds: [],
userId: "u1",
});
expect(allowed).toBe(true);
});
it("allows when user allowlist matches", () => {
const allowed = resolveDiscordMemberAllowed({
userAllowList: ["123"],
roleAllowList: ["456"],
memberRoleIds: ["999"],
userId: "123",
});
expect(allowed).toBe(true);
});
it("allows when role allowlist matches", () => {
const allowed = resolveDiscordMemberAllowed({
userAllowList: ["999"],
roleAllowList: ["456"],
memberRoleIds: ["456"],
userId: "123",
});
expect(allowed).toBe(true);
});
it("denies when user and role allowlists do not match", () => {
const allowed = resolveDiscordMemberAllowed({
userAllowList: ["u2"],
roleAllowList: ["role-2"],
memberRoleIds: ["role-1"],
userId: "u1",
});
expect(allowed).toBe(false);
});
});

View File

@@ -157,6 +157,51 @@ export function resolveDiscordUserAllowed(params: {
});
}
export function resolveDiscordRoleAllowed(params: {
allowList?: Array<string | number>;
memberRoleIds: string[];
}) {
// Role allowlists accept role IDs only (string or number). Names are ignored.
const allowList = normalizeDiscordAllowList(params.allowList, ["role:"]);
if (!allowList) {
return true;
}
if (allowList.allowAll) {
return true;
}
return params.memberRoleIds.some((roleId) => allowList.ids.has(roleId));
}
export function resolveDiscordMemberAllowed(params: {
userAllowList?: Array<string | number>;
roleAllowList?: Array<string | number>;
memberRoleIds: string[];
userId: string;
userName?: string;
userTag?: string;
}) {
const hasUserRestriction = Array.isArray(params.userAllowList) && params.userAllowList.length > 0;
const hasRoleRestriction = Array.isArray(params.roleAllowList) && params.roleAllowList.length > 0;
if (!hasUserRestriction && !hasRoleRestriction) {
return true;
}
const userOk = hasUserRestriction
? resolveDiscordUserAllowed({
allowList: params.userAllowList,
userId: params.userId,
userName: params.userName,
userTag: params.userTag,
})
: false;
const roleOk = hasRoleRestriction
? resolveDiscordRoleAllowed({
allowList: params.roleAllowList,
memberRoleIds: params.memberRoleIds,
})
: false;
return userOk || roleOk;
}
export function resolveDiscordOwnerAllowFrom(params: {
channelConfig?: DiscordChannelConfigResolved | null;
guildInfo?: DiscordGuildEntryResolved | null;
@@ -184,20 +229,6 @@ export function resolveDiscordOwnerAllowFrom(params: {
return [match.matchKey];
}
export function resolveDiscordRoleAllowed(params: {
allowList?: Array<string | number>;
memberRoleIds: string[];
}) {
const allowList = normalizeDiscordAllowList(params.allowList, ["role:"]);
if (!allowList) {
return true;
}
if (allowList.allowAll) {
return true;
}
return params.memberRoleIds.some((roleId) => allowList.ids.has(roleId));
}
export function resolveDiscordCommandAuthorized(params: {
isDirectMessage: boolean;
allowFrom?: Array<string | number>;

View File

@@ -275,11 +275,15 @@ async function handleDiscordReactionEvent(params: {
const authorLabel = message?.author ? formatDiscordUserTag(message.author) : undefined;
const baseText = `Discord reaction ${action}: ${emojiLabel} by ${actorLabel} on ${guildSlug} ${channelLabel} msg ${data.message_id}`;
const text = authorLabel ? `${baseText} from ${authorLabel}` : baseText;
const memberRoleIds = Array.isArray(data.member?.roles)
? data.member.roles.map((roleId: string) => String(roleId))
: [];
const route = resolveAgentRoute({
cfg: params.cfg,
channel: "discord",
accountId: params.accountId,
guildId: data.guild_id ?? undefined,
memberRoleIds,
peer: {
kind: isDirectMessage ? "direct" : isGroupDm ? "group" : "channel",
id: isDirectMessage ? user.id : data.channel_id,

View File

@@ -38,9 +38,8 @@ import {
resolveDiscordAllowListMatch,
resolveDiscordChannelConfigWithFallback,
resolveDiscordGuildEntry,
resolveDiscordMemberAllowed,
resolveDiscordShouldRequireMention,
resolveDiscordRoleAllowed,
resolveDiscordUserAllowed,
resolveGroupDmAllow,
} from "./allow-list.js";
import {
@@ -221,8 +220,9 @@ export async function preflightDiscordMessage(
}
// Fresh config for bindings lookup; other routing inputs are payload-derived.
// member.roles is already string[] (Snowflake IDs) per Discord API types
const memberRoleIds: string[] = params.data.member?.roles ?? [];
const memberRoleIds = Array.isArray(params.data.member?.roles)
? params.data.member.roles.map((roleId: string) => String(roleId))
: [];
const route = resolveAgentRoute({
cfg: loadConfig(),
channel: "discord",
@@ -455,6 +455,19 @@ export async function preflightDiscordMessage(
surface: "discord",
});
const hasControlCommandInMessage = hasControlCommand(baseText, params.cfg);
const channelUsers = channelConfig?.users ?? guildInfo?.users;
const channelRoles = channelConfig?.roles ?? guildInfo?.roles;
const hasAccessRestrictions =
(Array.isArray(channelUsers) && channelUsers.length > 0) ||
(Array.isArray(channelRoles) && channelRoles.length > 0);
const memberAllowed = resolveDiscordMemberAllowed({
userAllowList: channelUsers,
roleAllowList: channelRoles,
memberRoleIds,
userId: sender.id,
userName: sender.name,
userTag: sender.tag,
});
if (!isDirectMessage) {
const ownerAllowList = normalizeDiscordAllowList(params.allowFrom, [
@@ -469,22 +482,12 @@ export async function preflightDiscordMessage(
tag: sender.tag,
})
: false;
const channelUsers = channelConfig?.users ?? guildInfo?.users;
const usersOk =
Array.isArray(channelUsers) && channelUsers.length > 0
? resolveDiscordUserAllowed({
allowList: channelUsers,
userId: sender.id,
userName: sender.name,
userTag: sender.tag,
})
: false;
const useAccessGroups = params.cfg.commands?.useAccessGroups !== false;
const commandGate = resolveControlCommandGate({
useAccessGroups,
authorizers: [
{ configured: ownerAllowList != null, allowed: ownerOk },
{ configured: Array.isArray(channelUsers) && channelUsers.length > 0, allowed: usersOk },
{ configured: hasAccessRestrictions, allowed: memberAllowed },
],
modeWhenAccessGroupsOff: "configured",
allowTextCommands,
@@ -536,35 +539,9 @@ export async function preflightDiscordMessage(
}
}
if (isGuildMessage) {
const channelUsers = channelConfig?.users ?? guildInfo?.users;
const channelRoles = channelConfig?.roles ?? guildInfo?.roles;
const hasUserRestriction = Array.isArray(channelUsers) && channelUsers.length > 0;
const hasRoleRestriction = Array.isArray(channelRoles) && channelRoles.length > 0;
if (hasUserRestriction || hasRoleRestriction) {
// member.roles is already string[] (Snowflake IDs) per Discord API types
const memberRoleIds: string[] = params.data.member?.roles ?? [];
const userOk = hasUserRestriction
? resolveDiscordUserAllowed({
allowList: channelUsers,
userId: sender.id,
userName: sender.name,
userTag: sender.tag,
})
: false;
const roleOk = hasRoleRestriction
? resolveDiscordRoleAllowed({
allowList: channelRoles,
memberRoleIds,
})
: false;
if (!userOk && !roleOk) {
logVerbose(`Blocked discord guild sender ${sender.id} (not in users/roles allowlist)`);
return null;
}
}
if (isGuildMessage && hasAccessRestrictions && !memberAllowed) {
logVerbose(`Blocked discord guild sender ${sender.id} (not in users/roles allowlist)`);
return null;
}
const systemLocation = resolveDiscordSystemLocation({

View File

@@ -50,8 +50,8 @@ import {
normalizeDiscordSlug,
resolveDiscordChannelConfigWithFallback,
resolveDiscordGuildEntry,
resolveDiscordMemberAllowed,
resolveDiscordOwnerAllowFrom,
resolveDiscordUserAllowed,
} from "./allow-list.js";
import { resolveDiscordChannelInfo } from "./message-utils.js";
import { resolveDiscordSenderIdentity } from "./sender-identity.js";
@@ -540,6 +540,9 @@ async function dispatchDiscordCommandInteraction(params: {
const channelName = channel && "name" in channel ? (channel.name as string) : undefined;
const channelSlug = channelName ? normalizeDiscordSlug(channelName) : "";
const rawChannelId = channel?.id ?? "";
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:",
@@ -662,21 +665,24 @@ async function dispatchDiscordCommandInteraction(params: {
}
if (!isDirectMessage) {
const channelUsers = channelConfig?.users ?? guildInfo?.users;
const hasUserAllowlist = Array.isArray(channelUsers) && channelUsers.length > 0;
const userOk = hasUserAllowlist
? resolveDiscordUserAllowed({
allowList: channelUsers,
userId: sender.id,
userName: sender.name,
userTag: sender.tag,
})
: false;
const channelRoles = channelConfig?.roles ?? guildInfo?.roles;
const hasAccessRestrictions =
(Array.isArray(channelUsers) && channelUsers.length > 0) ||
(Array.isArray(channelRoles) && channelRoles.length > 0);
const memberAllowed = resolveDiscordMemberAllowed({
userAllowList: channelUsers,
roleAllowList: channelRoles,
memberRoleIds,
userId: sender.id,
userName: sender.name,
userTag: sender.tag,
});
const authorizers = useAccessGroups
? [
{ configured: ownerAllowList != null, allowed: ownerOk },
{ configured: hasUserAllowlist, allowed: userOk },
{ configured: hasAccessRestrictions, allowed: memberAllowed },
]
: [{ configured: hasUserAllowlist, allowed: userOk }];
: [{ configured: hasAccessRestrictions, allowed: memberAllowed }];
commandAuthorized = resolveCommandAuthorizedFromAuthorizers({
useAccessGroups,
authorizers,
@@ -735,6 +741,7 @@ async function dispatchDiscordCommandInteraction(params: {
channel: "discord",
accountId,
guildId: interaction.guild?.id ?? undefined,
memberRoleIds,
peer: {
kind: isDirectMessage ? "direct" : isGroupDm ? "group" : "channel",
id: isDirectMessage ? user.id : channelId,

View File

@@ -507,7 +507,29 @@ describe("role-based agent routing", () => {
expect(route.matchedBy).toBe("binding.peer");
});
test("no memberRoleIds → guild+roles doesn't match", () => {
test("parent peer binding still beats guild+roles", () => {
const cfg: OpenClawConfig = {
bindings: [
{
agentId: "parent-agent",
match: { channel: "discord", peer: { kind: "channel", id: "parent-1" } },
},
{ agentId: "roles-agent", match: { channel: "discord", guildId: "g1", roles: ["r1"] } },
],
};
const route = resolveAgentRoute({
cfg,
channel: "discord",
guildId: "g1",
memberRoleIds: ["r1"],
peer: { kind: "channel", id: "thread-1" },
parentPeer: { kind: "channel", id: "parent-1" },
});
expect(route.agentId).toBe("parent-agent");
expect(route.matchedBy).toBe("binding.peer.parent");
});
test("no memberRoleIds means guild+roles doesn't match", () => {
const cfg: OpenClawConfig = {
bindings: [{ agentId: "opus", match: { channel: "discord", guildId: "g1", roles: ["r1"] } }],
};
@@ -554,7 +576,7 @@ describe("role-based agent routing", () => {
expect(route.matchedBy).toBe("binding.guild");
});
test("CRITICAL: guild+roles binding NOT matched as guild-only when roles don't match", () => {
test("guild+roles binding does not match as guild-only when roles do not match", () => {
const cfg: OpenClawConfig = {
bindings: [
{ agentId: "opus", match: { channel: "discord", guildId: "g1", roles: ["admin"] } },

View File

@@ -179,7 +179,7 @@ function matchesRoles(
if (!Array.isArray(roles) || roles.length === 0) {
return false;
}
return roles.some((r) => memberRoleIds.includes(r));
return roles.some((role) => memberRoleIds.includes(role));
}
export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentRoute {
@@ -234,15 +234,6 @@ export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentR
}
}
if (guildId && memberRoleIds.length > 0) {
const guildRolesMatch = bindings.find(
(b) => matchesGuild(b.match, guildId) && matchesRoles(b.match, memberRoleIds),
);
if (guildRolesMatch) {
return choose(guildRolesMatch.agentId, "binding.guild+roles");
}
}
// Thread parent inheritance: if peer (thread) didn't match, check parent peer binding
const parentPeer = input.parentPeer
? { kind: input.parentPeer.kind, id: normalizeId(input.parentPeer.id) }
@@ -254,6 +245,15 @@ export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentR
}
}
if (guildId && memberRoleIds.length > 0) {
const guildRolesMatch = bindings.find(
(b) => matchesGuild(b.match, guildId) && matchesRoles(b.match, memberRoleIds),
);
if (guildRolesMatch) {
return choose(guildRolesMatch.agentId, "binding.guild+roles");
}
}
if (guildId) {
const guildMatch = bindings.find(
(b) =>