mirror of
https://github.com/farcasclaudiu/openclaw.git
synced 2026-06-29 05:02:04 +03:00
fix: add discord role allowlists (#10650) (thanks @Minidoracat)
This commit is contained in:
@@ -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.
|
- 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)
|
- 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.
|
- Config: avoid redacting `maxTokens`-like fields during config snapshot redaction, preventing round-trip validation failures in `/config`. (#14006) Thanks @constansino.
|
||||||
|
|
||||||
### Breaking
|
### Breaking
|
||||||
|
|||||||
@@ -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:
|
Create an application in the Discord Developer Portal, add a bot, then enable:
|
||||||
|
|
||||||
- **Message Content Intent**
|
- **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>
|
</Step>
|
||||||
|
|
||||||
@@ -121,6 +121,7 @@ Token resolution is account-aware. Config token values win over env fallback. `D
|
|||||||
`allowlist` behavior:
|
`allowlist` behavior:
|
||||||
|
|
||||||
- guild must match `channels.discord.guilds` (`id` preferred, slug accepted)
|
- 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 `channels` configured, non-listed channels are denied
|
||||||
- if a guild has no `channels` block, all channels in that allowlisted guild are allowed
|
- 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": {
|
"123456789012345678": {
|
||||||
requireMention: true,
|
requireMention: true,
|
||||||
users: ["987654321098765432"],
|
users: ["987654321098765432"],
|
||||||
|
roles: ["123456789012345678"],
|
||||||
channels: {
|
channels: {
|
||||||
general: { allow: true },
|
general: { allow: true },
|
||||||
help: { allow: true, requireMention: true },
|
help: { allow: true, requireMention: true },
|
||||||
@@ -169,6 +171,32 @@ Token resolution is account-aware. Config token values win over env fallback. `D
|
|||||||
</Tab>
|
</Tab>
|
||||||
</Tabs>
|
</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
|
## Developer Portal setup
|
||||||
|
|
||||||
<AccordionGroup>
|
<AccordionGroup>
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ export type AgentBinding = {
|
|||||||
peer?: { kind: ChatType; id: string };
|
peer?: { kind: ChatType; id: string };
|
||||||
guildId?: string;
|
guildId?: string;
|
||||||
teamId?: string;
|
teamId?: string;
|
||||||
|
/** Discord role IDs used for role-based routing. */
|
||||||
roles?: string[];
|
roles?: string[];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export type DiscordGuildChannelConfig = {
|
|||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
/** Optional allowlist for channel senders (ids or names). */
|
/** Optional allowlist for channel senders (ids or names). */
|
||||||
users?: Array<string | number>;
|
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>;
|
roles?: Array<string | number>;
|
||||||
/** Optional system prompt snippet for this channel. */
|
/** Optional system prompt snippet for this channel. */
|
||||||
systemPrompt?: string;
|
systemPrompt?: string;
|
||||||
@@ -54,7 +54,9 @@ export type DiscordGuildEntry = {
|
|||||||
toolsBySender?: GroupToolPolicyBySenderConfig;
|
toolsBySender?: GroupToolPolicyBySenderConfig;
|
||||||
/** Reaction notification mode (off|own|all|allowlist). Default: own. */
|
/** Reaction notification mode (off|own|all|allowlist). Default: own. */
|
||||||
reactionNotifications?: DiscordReactionNotificationMode;
|
reactionNotifications?: DiscordReactionNotificationMode;
|
||||||
|
/** Optional allowlist for guild senders (ids or names). */
|
||||||
users?: Array<string | number>;
|
users?: Array<string | number>;
|
||||||
|
/** Optional allowlist for guild senders by role ID. */
|
||||||
roles?: Array<string | number>;
|
roles?: Array<string | number>;
|
||||||
channels?: Record<string, DiscordGuildChannelConfig>;
|
channels?: Record<string, DiscordGuildChannelConfig>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
resolveDiscordAllowListMatch,
|
resolveDiscordAllowListMatch,
|
||||||
resolveDiscordChannelConfigWithFallback,
|
resolveDiscordChannelConfigWithFallback,
|
||||||
resolveDiscordGuildEntry,
|
resolveDiscordGuildEntry,
|
||||||
resolveDiscordUserAllowed,
|
resolveDiscordMemberAllowed,
|
||||||
} from "./allow-list.js";
|
} from "./allow-list.js";
|
||||||
import { formatDiscordUserTag } from "./format.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
|
// when guild is not cached even though guild_id is present in rawData
|
||||||
const rawGuildId = interaction.rawData.guild_id;
|
const rawGuildId = interaction.rawData.guild_id;
|
||||||
const isDirectMessage = !rawGuildId;
|
const isDirectMessage = !rawGuildId;
|
||||||
|
const memberRoleIds = Array.isArray(interaction.rawData.member?.roles)
|
||||||
|
? interaction.rawData.member.roles.map((roleId: string) => String(roleId))
|
||||||
|
: [];
|
||||||
|
|
||||||
if (isDirectMessage) {
|
if (isDirectMessage) {
|
||||||
const authorized = await ensureDmComponentAuthorized({
|
const authorized = await ensureDmComponentAuthorized({
|
||||||
@@ -294,15 +297,17 @@ export class AgentComponentButton extends Button {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const channelUsers = channelConfig?.users ?? guildInfo?.users;
|
const channelUsers = channelConfig?.users ?? guildInfo?.users;
|
||||||
if (Array.isArray(channelUsers) && channelUsers.length > 0) {
|
const channelRoles = channelConfig?.roles ?? guildInfo?.roles;
|
||||||
const userOk = resolveDiscordUserAllowed({
|
const memberAllowed = resolveDiscordMemberAllowed({
|
||||||
allowList: channelUsers,
|
userAllowList: channelUsers,
|
||||||
|
roleAllowList: channelRoles,
|
||||||
|
memberRoleIds,
|
||||||
userId,
|
userId,
|
||||||
userName: user.username,
|
userName: user.username,
|
||||||
userTag: user.discriminator ? `${user.username}#${user.discriminator}` : undefined,
|
userTag: user.discriminator ? `${user.username}#${user.discriminator}` : undefined,
|
||||||
});
|
});
|
||||||
if (!userOk) {
|
if (!memberAllowed) {
|
||||||
logVerbose(`agent button: blocked user ${userId} (not in allowlist)`);
|
logVerbose(`agent button: blocked user ${userId} (not in users/roles allowlist)`);
|
||||||
try {
|
try {
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
content: "You are not authorized to use this button.",
|
content: "You are not authorized to use this button.",
|
||||||
@@ -314,7 +319,6 @@ export class AgentComponentButton extends Button {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve route with full context (guildId, proper peer kind, parentPeer)
|
// Resolve route with full context (guildId, proper peer kind, parentPeer)
|
||||||
const route = resolveAgentRoute({
|
const route = resolveAgentRoute({
|
||||||
@@ -322,6 +326,7 @@ export class AgentComponentButton extends Button {
|
|||||||
channel: "discord",
|
channel: "discord",
|
||||||
accountId: this.ctx.accountId,
|
accountId: this.ctx.accountId,
|
||||||
guildId: rawGuildId,
|
guildId: rawGuildId,
|
||||||
|
memberRoleIds,
|
||||||
peer: {
|
peer: {
|
||||||
kind: isDirectMessage ? "direct" : "channel",
|
kind: isDirectMessage ? "direct" : "channel",
|
||||||
id: isDirectMessage ? userId : channelId,
|
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
|
// when guild is not cached even though guild_id is present in rawData
|
||||||
const rawGuildId = interaction.rawData.guild_id;
|
const rawGuildId = interaction.rawData.guild_id;
|
||||||
const isDirectMessage = !rawGuildId;
|
const isDirectMessage = !rawGuildId;
|
||||||
|
const memberRoleIds = Array.isArray(interaction.rawData.member?.roles)
|
||||||
|
? interaction.rawData.member.roles.map((roleId: string) => String(roleId))
|
||||||
|
: [];
|
||||||
|
|
||||||
if (isDirectMessage) {
|
if (isDirectMessage) {
|
||||||
const authorized = await ensureDmComponentAuthorized({
|
const authorized = await ensureDmComponentAuthorized({
|
||||||
@@ -456,15 +464,17 @@ export class AgentSelectMenu extends StringSelectMenu {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const channelUsers = channelConfig?.users ?? guildInfo?.users;
|
const channelUsers = channelConfig?.users ?? guildInfo?.users;
|
||||||
if (Array.isArray(channelUsers) && channelUsers.length > 0) {
|
const channelRoles = channelConfig?.roles ?? guildInfo?.roles;
|
||||||
const userOk = resolveDiscordUserAllowed({
|
const memberAllowed = resolveDiscordMemberAllowed({
|
||||||
allowList: channelUsers,
|
userAllowList: channelUsers,
|
||||||
|
roleAllowList: channelRoles,
|
||||||
|
memberRoleIds,
|
||||||
userId,
|
userId,
|
||||||
userName: user.username,
|
userName: user.username,
|
||||||
userTag: user.discriminator ? `${user.username}#${user.discriminator}` : undefined,
|
userTag: user.discriminator ? `${user.username}#${user.discriminator}` : undefined,
|
||||||
});
|
});
|
||||||
if (!userOk) {
|
if (!memberAllowed) {
|
||||||
logVerbose(`agent select: blocked user ${userId} (not in allowlist)`);
|
logVerbose(`agent select: blocked user ${userId} (not in users/roles allowlist)`);
|
||||||
try {
|
try {
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
content: "You are not authorized to use this select menu.",
|
content: "You are not authorized to use this select menu.",
|
||||||
@@ -476,7 +486,6 @@ export class AgentSelectMenu extends StringSelectMenu {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Extract selected values
|
// Extract selected values
|
||||||
const values = interaction.values ?? [];
|
const values = interaction.values ?? [];
|
||||||
@@ -488,6 +497,7 @@ export class AgentSelectMenu extends StringSelectMenu {
|
|||||||
channel: "discord",
|
channel: "discord",
|
||||||
accountId: this.ctx.accountId,
|
accountId: this.ctx.accountId,
|
||||||
guildId: rawGuildId,
|
guildId: rawGuildId,
|
||||||
|
memberRoleIds,
|
||||||
peer: {
|
peer: {
|
||||||
kind: isDirectMessage ? "direct" : "channel",
|
kind: isDirectMessage ? "direct" : "channel",
|
||||||
id: isDirectMessage ? userId : channelId,
|
id: isDirectMessage ? userId : channelId,
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { DiscordChannelConfigResolved } from "./allow-list.js";
|
import type { DiscordChannelConfigResolved } from "./allow-list.js";
|
||||||
import { resolveDiscordOwnerAllowFrom } from "./allow-list.js";
|
import {
|
||||||
|
resolveDiscordMemberAllowed,
|
||||||
|
resolveDiscordOwnerAllowFrom,
|
||||||
|
resolveDiscordRoleAllowed,
|
||||||
|
} from "./allow-list.js";
|
||||||
|
|
||||||
describe("resolveDiscordOwnerAllowFrom", () => {
|
describe("resolveDiscordOwnerAllowFrom", () => {
|
||||||
it("returns undefined when no allowlist is configured", () => {
|
it("returns undefined when no allowlist is configured", () => {
|
||||||
@@ -39,3 +43,87 @@ describe("resolveDiscordOwnerAllowFrom", () => {
|
|||||||
expect(result).toEqual(["some-user"]);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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: {
|
export function resolveDiscordOwnerAllowFrom(params: {
|
||||||
channelConfig?: DiscordChannelConfigResolved | null;
|
channelConfig?: DiscordChannelConfigResolved | null;
|
||||||
guildInfo?: DiscordGuildEntryResolved | null;
|
guildInfo?: DiscordGuildEntryResolved | null;
|
||||||
@@ -184,20 +229,6 @@ export function resolveDiscordOwnerAllowFrom(params: {
|
|||||||
return [match.matchKey];
|
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: {
|
export function resolveDiscordCommandAuthorized(params: {
|
||||||
isDirectMessage: boolean;
|
isDirectMessage: boolean;
|
||||||
allowFrom?: Array<string | number>;
|
allowFrom?: Array<string | number>;
|
||||||
|
|||||||
@@ -275,11 +275,15 @@ async function handleDiscordReactionEvent(params: {
|
|||||||
const authorLabel = message?.author ? formatDiscordUserTag(message.author) : undefined;
|
const authorLabel = message?.author ? formatDiscordUserTag(message.author) : undefined;
|
||||||
const baseText = `Discord reaction ${action}: ${emojiLabel} by ${actorLabel} on ${guildSlug} ${channelLabel} msg ${data.message_id}`;
|
const baseText = `Discord reaction ${action}: ${emojiLabel} by ${actorLabel} on ${guildSlug} ${channelLabel} msg ${data.message_id}`;
|
||||||
const text = authorLabel ? `${baseText} from ${authorLabel}` : baseText;
|
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({
|
const route = resolveAgentRoute({
|
||||||
cfg: params.cfg,
|
cfg: params.cfg,
|
||||||
channel: "discord",
|
channel: "discord",
|
||||||
accountId: params.accountId,
|
accountId: params.accountId,
|
||||||
guildId: data.guild_id ?? undefined,
|
guildId: data.guild_id ?? undefined,
|
||||||
|
memberRoleIds,
|
||||||
peer: {
|
peer: {
|
||||||
kind: isDirectMessage ? "direct" : isGroupDm ? "group" : "channel",
|
kind: isDirectMessage ? "direct" : isGroupDm ? "group" : "channel",
|
||||||
id: isDirectMessage ? user.id : data.channel_id,
|
id: isDirectMessage ? user.id : data.channel_id,
|
||||||
|
|||||||
@@ -38,9 +38,8 @@ import {
|
|||||||
resolveDiscordAllowListMatch,
|
resolveDiscordAllowListMatch,
|
||||||
resolveDiscordChannelConfigWithFallback,
|
resolveDiscordChannelConfigWithFallback,
|
||||||
resolveDiscordGuildEntry,
|
resolveDiscordGuildEntry,
|
||||||
|
resolveDiscordMemberAllowed,
|
||||||
resolveDiscordShouldRequireMention,
|
resolveDiscordShouldRequireMention,
|
||||||
resolveDiscordRoleAllowed,
|
|
||||||
resolveDiscordUserAllowed,
|
|
||||||
resolveGroupDmAllow,
|
resolveGroupDmAllow,
|
||||||
} from "./allow-list.js";
|
} from "./allow-list.js";
|
||||||
import {
|
import {
|
||||||
@@ -221,8 +220,9 @@ export async function preflightDiscordMessage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fresh config for bindings lookup; other routing inputs are payload-derived.
|
// Fresh config for bindings lookup; other routing inputs are payload-derived.
|
||||||
// member.roles is already string[] (Snowflake IDs) per Discord API types
|
const memberRoleIds = Array.isArray(params.data.member?.roles)
|
||||||
const memberRoleIds: string[] = params.data.member?.roles ?? [];
|
? params.data.member.roles.map((roleId: string) => String(roleId))
|
||||||
|
: [];
|
||||||
const route = resolveAgentRoute({
|
const route = resolveAgentRoute({
|
||||||
cfg: loadConfig(),
|
cfg: loadConfig(),
|
||||||
channel: "discord",
|
channel: "discord",
|
||||||
@@ -455,6 +455,19 @@ export async function preflightDiscordMessage(
|
|||||||
surface: "discord",
|
surface: "discord",
|
||||||
});
|
});
|
||||||
const hasControlCommandInMessage = hasControlCommand(baseText, params.cfg);
|
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) {
|
if (!isDirectMessage) {
|
||||||
const ownerAllowList = normalizeDiscordAllowList(params.allowFrom, [
|
const ownerAllowList = normalizeDiscordAllowList(params.allowFrom, [
|
||||||
@@ -469,22 +482,12 @@ export async function preflightDiscordMessage(
|
|||||||
tag: sender.tag,
|
tag: sender.tag,
|
||||||
})
|
})
|
||||||
: false;
|
: 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 useAccessGroups = params.cfg.commands?.useAccessGroups !== false;
|
||||||
const commandGate = resolveControlCommandGate({
|
const commandGate = resolveControlCommandGate({
|
||||||
useAccessGroups,
|
useAccessGroups,
|
||||||
authorizers: [
|
authorizers: [
|
||||||
{ configured: ownerAllowList != null, allowed: ownerOk },
|
{ configured: ownerAllowList != null, allowed: ownerOk },
|
||||||
{ configured: Array.isArray(channelUsers) && channelUsers.length > 0, allowed: usersOk },
|
{ configured: hasAccessRestrictions, allowed: memberAllowed },
|
||||||
],
|
],
|
||||||
modeWhenAccessGroupsOff: "configured",
|
modeWhenAccessGroupsOff: "configured",
|
||||||
allowTextCommands,
|
allowTextCommands,
|
||||||
@@ -536,36 +539,10 @@ export async function preflightDiscordMessage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isGuildMessage) {
|
if (isGuildMessage && hasAccessRestrictions && !memberAllowed) {
|
||||||
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)`);
|
logVerbose(`Blocked discord guild sender ${sender.id} (not in users/roles allowlist)`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const systemLocation = resolveDiscordSystemLocation({
|
const systemLocation = resolveDiscordSystemLocation({
|
||||||
isDirectMessage,
|
isDirectMessage,
|
||||||
|
|||||||
@@ -50,8 +50,8 @@ import {
|
|||||||
normalizeDiscordSlug,
|
normalizeDiscordSlug,
|
||||||
resolveDiscordChannelConfigWithFallback,
|
resolveDiscordChannelConfigWithFallback,
|
||||||
resolveDiscordGuildEntry,
|
resolveDiscordGuildEntry,
|
||||||
|
resolveDiscordMemberAllowed,
|
||||||
resolveDiscordOwnerAllowFrom,
|
resolveDiscordOwnerAllowFrom,
|
||||||
resolveDiscordUserAllowed,
|
|
||||||
} from "./allow-list.js";
|
} from "./allow-list.js";
|
||||||
import { resolveDiscordChannelInfo } from "./message-utils.js";
|
import { resolveDiscordChannelInfo } from "./message-utils.js";
|
||||||
import { resolveDiscordSenderIdentity } from "./sender-identity.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 channelName = channel && "name" in channel ? (channel.name as string) : undefined;
|
||||||
const channelSlug = channelName ? normalizeDiscordSlug(channelName) : "";
|
const channelSlug = channelName ? normalizeDiscordSlug(channelName) : "";
|
||||||
const rawChannelId = channel?.id ?? "";
|
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 ?? [], [
|
const ownerAllowList = normalizeDiscordAllowList(discordConfig?.dm?.allowFrom ?? [], [
|
||||||
"discord:",
|
"discord:",
|
||||||
"user:",
|
"user:",
|
||||||
@@ -662,21 +665,24 @@ async function dispatchDiscordCommandInteraction(params: {
|
|||||||
}
|
}
|
||||||
if (!isDirectMessage) {
|
if (!isDirectMessage) {
|
||||||
const channelUsers = channelConfig?.users ?? guildInfo?.users;
|
const channelUsers = channelConfig?.users ?? guildInfo?.users;
|
||||||
const hasUserAllowlist = Array.isArray(channelUsers) && channelUsers.length > 0;
|
const channelRoles = channelConfig?.roles ?? guildInfo?.roles;
|
||||||
const userOk = hasUserAllowlist
|
const hasAccessRestrictions =
|
||||||
? resolveDiscordUserAllowed({
|
(Array.isArray(channelUsers) && channelUsers.length > 0) ||
|
||||||
allowList: channelUsers,
|
(Array.isArray(channelRoles) && channelRoles.length > 0);
|
||||||
|
const memberAllowed = resolveDiscordMemberAllowed({
|
||||||
|
userAllowList: channelUsers,
|
||||||
|
roleAllowList: channelRoles,
|
||||||
|
memberRoleIds,
|
||||||
userId: sender.id,
|
userId: sender.id,
|
||||||
userName: sender.name,
|
userName: sender.name,
|
||||||
userTag: sender.tag,
|
userTag: sender.tag,
|
||||||
})
|
});
|
||||||
: false;
|
|
||||||
const authorizers = useAccessGroups
|
const authorizers = useAccessGroups
|
||||||
? [
|
? [
|
||||||
{ configured: ownerAllowList != null, allowed: ownerOk },
|
{ configured: ownerAllowList != null, allowed: ownerOk },
|
||||||
{ configured: hasUserAllowlist, allowed: userOk },
|
{ configured: hasAccessRestrictions, allowed: memberAllowed },
|
||||||
]
|
]
|
||||||
: [{ configured: hasUserAllowlist, allowed: userOk }];
|
: [{ configured: hasAccessRestrictions, allowed: memberAllowed }];
|
||||||
commandAuthorized = resolveCommandAuthorizedFromAuthorizers({
|
commandAuthorized = resolveCommandAuthorizedFromAuthorizers({
|
||||||
useAccessGroups,
|
useAccessGroups,
|
||||||
authorizers,
|
authorizers,
|
||||||
@@ -735,6 +741,7 @@ async function dispatchDiscordCommandInteraction(params: {
|
|||||||
channel: "discord",
|
channel: "discord",
|
||||||
accountId,
|
accountId,
|
||||||
guildId: interaction.guild?.id ?? undefined,
|
guildId: interaction.guild?.id ?? undefined,
|
||||||
|
memberRoleIds,
|
||||||
peer: {
|
peer: {
|
||||||
kind: isDirectMessage ? "direct" : isGroupDm ? "group" : "channel",
|
kind: isDirectMessage ? "direct" : isGroupDm ? "group" : "channel",
|
||||||
id: isDirectMessage ? user.id : channelId,
|
id: isDirectMessage ? user.id : channelId,
|
||||||
|
|||||||
@@ -507,7 +507,29 @@ describe("role-based agent routing", () => {
|
|||||||
expect(route.matchedBy).toBe("binding.peer");
|
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 = {
|
const cfg: OpenClawConfig = {
|
||||||
bindings: [{ agentId: "opus", match: { channel: "discord", guildId: "g1", roles: ["r1"] } }],
|
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");
|
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 = {
|
const cfg: OpenClawConfig = {
|
||||||
bindings: [
|
bindings: [
|
||||||
{ agentId: "opus", match: { channel: "discord", guildId: "g1", roles: ["admin"] } },
|
{ agentId: "opus", match: { channel: "discord", guildId: "g1", roles: ["admin"] } },
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ function matchesRoles(
|
|||||||
if (!Array.isArray(roles) || roles.length === 0) {
|
if (!Array.isArray(roles) || roles.length === 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return roles.some((r) => memberRoleIds.includes(r));
|
return roles.some((role) => memberRoleIds.includes(role));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentRoute {
|
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
|
// Thread parent inheritance: if peer (thread) didn't match, check parent peer binding
|
||||||
const parentPeer = input.parentPeer
|
const parentPeer = input.parentPeer
|
||||||
? { kind: input.parentPeer.kind, id: normalizeId(input.parentPeer.id) }
|
? { 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) {
|
if (guildId) {
|
||||||
const guildMatch = bindings.find(
|
const guildMatch = bindings.find(
|
||||||
(b) =>
|
(b) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user