diff --git a/CHANGELOG.md b/CHANGELOG.md index c076183a0..6112bc27d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `
` 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 diff --git a/docs/channels/discord.md b/docs/channels/discord.md index ca6d53da5..c232a042f 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -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) @@ -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 +### 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 diff --git a/src/config/types.agents.ts b/src/config/types.agents.ts index 22c00874f..2816d33a7 100644 --- a/src/config/types.agents.ts +++ b/src/config/types.agents.ts @@ -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[]; }; }; diff --git a/src/config/types.discord.ts b/src/config/types.discord.ts index 5f056d1bc..b01f45532 100644 --- a/src/config/types.discord.ts +++ b/src/config/types.discord.ts @@ -36,7 +36,7 @@ export type DiscordGuildChannelConfig = { enabled?: boolean; /** Optional allowlist for channel senders (ids or names). */ users?: Array; - /** Optional allowlist for channel senders by role (ids or names). */ + /** Optional allowlist for channel senders by role ID. */ roles?: Array; /** 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; + /** Optional allowlist for guild senders by role ID. */ roles?: Array; channels?: Record; }; diff --git a/src/discord/monitor/agent-components.ts b/src/discord/monitor/agent-components.ts index 39508423e..10c31918b 100644 --- a/src/discord/monitor/agent-components.ts +++ b/src/discord/monitor/agent-components.ts @@ -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, diff --git a/src/discord/monitor/allow-list.test.ts b/src/discord/monitor/allow-list.test.ts index 75f9c4d32..c620bd71a 100644 --- a/src/discord/monitor/allow-list.test.ts +++ b/src/discord/monitor/allow-list.test.ts @@ -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); + }); +}); diff --git a/src/discord/monitor/allow-list.ts b/src/discord/monitor/allow-list.ts index 0d792673f..35590ce28 100644 --- a/src/discord/monitor/allow-list.ts +++ b/src/discord/monitor/allow-list.ts @@ -157,6 +157,51 @@ export function resolveDiscordUserAllowed(params: { }); } +export function resolveDiscordRoleAllowed(params: { + allowList?: Array; + 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; + roleAllowList?: Array; + 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; - 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; diff --git a/src/discord/monitor/listeners.ts b/src/discord/monitor/listeners.ts index ea51c4535..f8bdc0e09 100644 --- a/src/discord/monitor/listeners.ts +++ b/src/discord/monitor/listeners.ts @@ -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, diff --git a/src/discord/monitor/message-handler.preflight.ts b/src/discord/monitor/message-handler.preflight.ts index a7e7f04df..ca1a4bd81 100644 --- a/src/discord/monitor/message-handler.preflight.ts +++ b/src/discord/monitor/message-handler.preflight.ts @@ -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({ diff --git a/src/discord/monitor/native-command.ts b/src/discord/monitor/native-command.ts index f9d4d4f92..e71b0beaa 100644 --- a/src/discord/monitor/native-command.ts +++ b/src/discord/monitor/native-command.ts @@ -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, diff --git a/src/routing/resolve-route.test.ts b/src/routing/resolve-route.test.ts index 131a6a5b9..412e002ff 100644 --- a/src/routing/resolve-route.test.ts +++ b/src/routing/resolve-route.test.ts @@ -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"] } }, diff --git a/src/routing/resolve-route.ts b/src/routing/resolve-route.ts index 2ffa0fbac..55c7d5e47 100644 --- a/src/routing/resolve-route.ts +++ b/src/routing/resolve-route.ts @@ -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) =>