diff --git a/CHANGELOG.md b/CHANGELOG.md index bf34fb27eaf..23ee58ba4a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -130,6 +130,7 @@ Docs: https://docs.openclaw.ai - Gateway/device tokens: disconnect active device sessions after token rotation so newly rotated credentials revoke existing live connections immediately instead of waiting for those sockets to close naturally. Thanks @zsxsoft and @vincentkoc. - Gateway/OpenAI HTTP: restore default operator scopes for bearer-authenticated requests that omit `x-openclaw-scopes`, so headless `/v1/chat/completions` and session-history callers work again after the recent method-scope hardening. (#57596) Thanks @openperf. - Gateway/attachments: offload large inbound images without leaking `media://` markers into text-only runs, preserve mixed attachment order for model input/transcripts, and fail closed when model image capability cannot be resolved. (#55513) Thanks @Syysean. +- Discord/voice: enforce the same guild channel and member allowlist checks on spoken voice ingress before transcription, so joined voice channels no longer accept speech from users outside the configured Discord access policy. Thanks @cyjhhh and @vincentkoc. - Agents/subagents: fix interim subagent runtime display so `/subagents list` and `/subagents info` stop inflating short runtimes and show second-level durations correctly. (#57739) Thanks @samzong. - Diffs/config: preserve schema-shaped plugin config parsing from `diffsPluginConfigSchema.safeParse()`, so direct callers keep `defaults` and `security` sections instead of receiving flattened tool defaults. (#57904) Thanks @gumadeiras. - Feishu/groups: keep quoted replies and topic bootstrap context aligned with group sender allowlists so only allowlisted thread messages seed agent context. Thanks @AntAISecurityLab and @vincentkoc. diff --git a/extensions/discord/src/voice/access.test.ts b/extensions/discord/src/voice/access.test.ts new file mode 100644 index 00000000000..107dc757da5 --- /dev/null +++ b/extensions/discord/src/voice/access.test.ts @@ -0,0 +1,188 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import type { DiscordAccountConfig } from "openclaw/plugin-sdk/config-runtime"; +import { describe, expect, it } from "vitest"; +import { authorizeDiscordVoiceIngress } from "./access.js"; + +const baseCfg = { commands: { useAccessGroups: true } } as OpenClawConfig; + +describe("authorizeDiscordVoiceIngress", () => { + it("blocks speakers outside the configured channel user allowlist", async () => { + const access = await authorizeDiscordVoiceIngress({ + cfg: baseCfg, + discordConfig: { + guilds: { + g1: { + channels: { + c1: { + users: ["discord:u-owner"], + }, + }, + }, + }, + } as DiscordAccountConfig, + groupPolicy: "allowlist", + guildId: "g1", + channelId: "c1", + channelSlug: "", + memberRoleIds: [], + sender: { + id: "u-guest", + name: "guest", + }, + }); + + expect(access).toEqual({ + ok: false, + message: "You are not authorized to use this command.", + }); + }); + + it("allows speakers that match the configured channel user allowlist", async () => { + const access = await authorizeDiscordVoiceIngress({ + cfg: baseCfg, + discordConfig: { + guilds: { + g1: { + channels: { + c1: { + users: ["discord:u-owner"], + }, + }, + }, + }, + } as DiscordAccountConfig, + groupPolicy: "allowlist", + guildId: "g1", + channelId: "c1", + channelSlug: "", + memberRoleIds: [], + sender: { + id: "u-owner", + name: "owner", + }, + }); + + expect(access).toEqual({ ok: true }); + }); + + it("allows slug-keyed guild configs when manager context only has guild name", async () => { + const access = await authorizeDiscordVoiceIngress({ + cfg: baseCfg, + discordConfig: { + guilds: { + "guild-one": { + channels: { + "*": { + users: ["discord:u-owner"], + }, + }, + }, + }, + } as DiscordAccountConfig, + groupPolicy: "allowlist", + guildId: "g1", + guildName: "Guild One", + channelId: "c1", + channelSlug: "", + memberRoleIds: [], + sender: { + id: "u-owner", + name: "owner", + }, + }); + + expect(access).toEqual({ ok: true }); + }); + + it("allows wildcard guild configs when only the guild id is available", async () => { + const access = await authorizeDiscordVoiceIngress({ + cfg: baseCfg, + discordConfig: { + guilds: { + "*": { + channels: { + "*": { + users: ["discord:u-owner"], + }, + }, + }, + }, + } as DiscordAccountConfig, + groupPolicy: "allowlist", + guildId: "g1", + channelId: "c1", + channelSlug: "", + memberRoleIds: [], + sender: { + id: "u-owner", + name: "owner", + }, + }); + + expect(access).toEqual({ ok: true }); + }); + + it("blocks commands when channel id is unavailable for an allowlisted channel", async () => { + const access = await authorizeDiscordVoiceIngress({ + cfg: baseCfg, + discordConfig: { + guilds: { + g1: { + users: ["discord:u-owner"], + channels: { + c1: { + users: ["discord:u-owner"], + }, + }, + }, + }, + } as DiscordAccountConfig, + groupPolicy: "allowlist", + guildId: "g1", + channelId: "", + channelSlug: "", + memberRoleIds: [], + sender: { + id: "u-owner", + name: "owner", + }, + }); + + expect(access).toEqual({ + ok: false, + message: "This channel is not allowlisted for voice commands.", + }); + }); + + it("ignores dangerous name matching for voice ingress", async () => { + const access = await authorizeDiscordVoiceIngress({ + cfg: baseCfg, + discordConfig: { + dangerouslyAllowNameMatching: true, + guilds: { + g1: { + channels: { + c1: { + users: ["owner"], + }, + }, + }, + }, + } as DiscordAccountConfig, + groupPolicy: "allowlist", + guildId: "g1", + channelId: "c1", + channelSlug: "", + memberRoleIds: [], + sender: { + id: "u-guest", + name: "owner", + }, + }); + + expect(access).toEqual({ + ok: false, + message: "You are not authorized to use this command.", + }); + }); +}); diff --git a/extensions/discord/src/voice/access.ts b/extensions/discord/src/voice/access.ts new file mode 100644 index 00000000000..c858e9d47fe --- /dev/null +++ b/extensions/discord/src/voice/access.ts @@ -0,0 +1,121 @@ +import type { Guild } from "@buape/carbon"; +import { resolveCommandAuthorizedFromAuthorizers } from "openclaw/plugin-sdk/command-auth"; +import { resolveOpenProviderRuntimeGroupPolicy } from "openclaw/plugin-sdk/config-runtime"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import type { DiscordAccountConfig } from "openclaw/plugin-sdk/config-runtime"; +import { + isDiscordGroupAllowedByPolicy, + resolveDiscordChannelConfigWithFallback, + resolveDiscordGuildEntry, + resolveDiscordMemberAccessState, + resolveDiscordOwnerAccess, +} from "../monitor/allow-list.js"; + +export async function authorizeDiscordVoiceIngress(params: { + cfg: OpenClawConfig; + discordConfig: DiscordAccountConfig; + groupPolicy?: "open" | "disabled" | "allowlist"; + useAccessGroups?: boolean; + guild?: Guild | Guild | null; + guildName?: string; + guildId: string; + channelId: string; + channelName?: string; + channelSlug: string; + parentId?: string; + parentName?: string; + parentSlug?: string; + scope?: "channel" | "thread"; + channelLabel?: string; + memberRoleIds: string[]; + sender: { id: string; name?: string; tag?: string }; +}): Promise<{ ok: true } | { ok: false; message: string }> { + const groupPolicy = + params.groupPolicy ?? + resolveOpenProviderRuntimeGroupPolicy({ + providerConfigPresent: params.cfg.channels?.discord !== undefined, + groupPolicy: params.discordConfig.groupPolicy, + defaultGroupPolicy: params.cfg.channels?.defaults?.groupPolicy, + }).groupPolicy; + const guild = + params.guild ?? + ({ id: params.guildId, ...(params.guildName ? { name: params.guildName } : {}) } as Guild); + const guildInfo = resolveDiscordGuildEntry({ + guild, + guildId: params.guildId, + guildEntries: params.discordConfig.guilds, + }); + const channelConfig = params.channelId + ? resolveDiscordChannelConfigWithFallback({ + guildInfo, + channelId: params.channelId, + channelName: params.channelName, + channelSlug: params.channelSlug, + parentId: params.parentId, + parentName: params.parentName, + parentSlug: params.parentSlug, + scope: params.scope, + }) + : null; + + if (channelConfig?.enabled === false) { + return { ok: false, message: "This channel is disabled." }; + } + + const channelAllowlistConfigured = + Boolean(guildInfo?.channels) && Object.keys(guildInfo?.channels ?? {}).length > 0; + if (!params.channelId && groupPolicy === "allowlist" && channelAllowlistConfigured) { + return { + ok: false, + message: `${params.channelLabel ?? "This channel"} is not allowlisted for voice commands.`, + }; + } + + const channelAllowed = channelConfig + ? channelConfig.allowed !== false + : !channelAllowlistConfigured; + if ( + !isDiscordGroupAllowedByPolicy({ + groupPolicy, + guildAllowlisted: Boolean(guildInfo), + channelAllowlistConfigured, + channelAllowed, + }) || + channelConfig?.allowed === false + ) { + return { + ok: false, + message: `${params.channelLabel ?? "This channel"} is not allowlisted for voice commands.`, + }; + } + + const { hasAccessRestrictions, memberAllowed } = resolveDiscordMemberAccessState({ + channelConfig, + guildInfo, + memberRoleIds: params.memberRoleIds, + sender: params.sender, + allowNameMatching: false, + }); + + const { ownerAllowList, ownerAllowed } = resolveDiscordOwnerAccess({ + allowFrom: params.discordConfig.allowFrom ?? params.discordConfig.dm?.allowFrom ?? [], + sender: params.sender, + allowNameMatching: false, + }); + + const useAccessGroups = params.useAccessGroups ?? params.cfg.commands?.useAccessGroups !== false; + const authorizers = useAccessGroups + ? [ + { configured: ownerAllowList != null, allowed: ownerAllowed }, + { configured: hasAccessRestrictions, allowed: memberAllowed }, + ] + : [{ configured: hasAccessRestrictions, allowed: memberAllowed }]; + + return resolveCommandAuthorizedFromAuthorizers({ + useAccessGroups, + authorizers, + modeWhenAccessGroupsOff: "configured", + }) + ? { ok: true } + : { ok: false, message: "You are not authorized to use this command." }; +} diff --git a/extensions/discord/src/voice/command.ts b/extensions/discord/src/voice/command.ts index 0d9bf5124d6..88b0a3df173 100644 --- a/extensions/discord/src/voice/command.ts +++ b/extensions/discord/src/voice/command.ts @@ -10,22 +10,14 @@ import { ChannelType as DiscordChannelType, type APIApplicationCommandChannelOption, } from "discord-api-types/v10"; -import { resolveCommandAuthorizedFromAuthorizers } from "openclaw/plugin-sdk/command-auth"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; -import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runtime"; import type { DiscordAccountConfig } from "openclaw/plugin-sdk/config-runtime"; import { formatMention } from "../mentions.js"; -import { - isDiscordGroupAllowedByPolicy, - normalizeDiscordSlug, - resolveDiscordOwnerAccess, - resolveDiscordChannelConfigWithFallback, - resolveDiscordGuildEntry, - resolveDiscordMemberAccessState, -} from "../monitor/allow-list.js"; +import { normalizeDiscordSlug } from "../monitor/allow-list.js"; import { resolveDiscordChannelInfo } from "../monitor/message-utils.js"; import { resolveDiscordSenderIdentity } from "../monitor/sender-identity.js"; import { resolveDiscordThreadParentInfo } from "../monitor/threading.js"; +import { authorizeDiscordVoiceIngress } from "./access.js"; import type { DiscordVoiceManager } from "./manager.js"; const VOICE_CHANNEL_TYPES: NonNullable = [ @@ -105,87 +97,34 @@ async function authorizeVoiceCommand( parentSlug = parentName ? normalizeDiscordSlug(parentName) : undefined; } - const guildInfo = resolveDiscordGuildEntry({ - guild: interaction.guild ?? undefined, - guildId: interaction.guild?.id ?? interaction.rawData.guild_id ?? undefined, - guildEntries: params.discordConfig.guilds, - }); - - const channelConfig = channelId - ? resolveDiscordChannelConfigWithFallback({ - guildInfo, - channelId, - channelName, - channelSlug, - parentId, - parentName, - parentSlug, - scope: isThreadChannel ? "thread" : "channel", - }) - : null; - - if (channelConfig?.enabled === false) { - return { ok: false, message: "This channel is disabled." }; - } - - const channelAllowlistConfigured = - Boolean(guildInfo?.channels) && Object.keys(guildInfo?.channels ?? {}).length > 0; - const channelAllowed = channelConfig?.allowed !== false; - if ( - !isDiscordGroupAllowedByPolicy({ - groupPolicy: params.groupPolicy, - guildAllowlisted: Boolean(guildInfo), - channelAllowlistConfigured, - channelAllowed, - }) || - channelConfig?.allowed === false - ) { - const channelId = channelOverride?.id ?? channel?.id; - const channelLabel = channelId ? formatMention({ channelId }) : "This channel"; - return { - ok: false, - message: `${channelLabel} is not allowlisted for voice commands.`, - }; - } - const memberRoleIds = Array.isArray(interaction.rawData.member?.roles) ? interaction.rawData.member.roles.map((roleId: string) => String(roleId)) : []; const sender = resolveDiscordSenderIdentity({ author: user, member: interaction.rawData.member }); - - const { hasAccessRestrictions, memberAllowed } = resolveDiscordMemberAccessState({ - channelConfig, - guildInfo, + const access = await authorizeDiscordVoiceIngress({ + cfg: params.cfg, + discordConfig: params.discordConfig, + groupPolicy: params.groupPolicy, + useAccessGroups: params.useAccessGroups, + guild: interaction.guild, + guildId: interaction.guild.id, + channelId, + channelName, + channelSlug, + parentId, + parentName, + parentSlug, + scope: isThreadChannel ? "thread" : "channel", + channelLabel: channelId ? formatMention({ channelId }) : "This channel", memberRoleIds, - sender, - allowNameMatching: isDangerousNameMatchingEnabled(params.discordConfig), - }); - - const { ownerAllowList, ownerAllowed: ownerOk } = resolveDiscordOwnerAccess({ - allowFrom: params.discordConfig.allowFrom ?? params.discordConfig.dm?.allowFrom ?? [], sender: { id: sender.id, name: sender.name, tag: sender.tag, }, - allowNameMatching: isDangerousNameMatchingEnabled(params.discordConfig), }); - - const authorizers = params.useAccessGroups - ? [ - { configured: ownerAllowList != null, allowed: ownerOk }, - { configured: hasAccessRestrictions, allowed: memberAllowed }, - ] - : [{ configured: hasAccessRestrictions, allowed: memberAllowed }]; - - const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({ - useAccessGroups: params.useAccessGroups, - authorizers, - modeWhenAccessGroupsOff: "configured", - }); - - if (!commandAuthorized) { - return { ok: false, message: "You are not authorized to use this command." }; + if (!access.ok) { + return { ok: false, message: access.message }; } return { ok: true, guildId: interaction.guild.id }; diff --git a/extensions/discord/src/voice/manager.e2e.test.ts b/extensions/discord/src/voice/manager.e2e.test.ts index 8e3b91209c3..1d23b83eabf 100644 --- a/extensions/discord/src/voice/manager.e2e.test.ts +++ b/extensions/discord/src/voice/manager.e2e.test.ts @@ -114,8 +114,13 @@ function createClient() { fetchChannel: vi.fn(async (channelId: string) => ({ id: channelId, guildId: "g1", + guild: { id: "g1", name: "Guild One" }, type: ChannelType.GuildVoice, })), + fetchGuild: vi.fn(async (guildId: string) => ({ + id: guildId, + name: "Guild One", + })), getPlugin: vi.fn(() => ({ getGatewayAdapterCreator: vi.fn(() => vi.fn()), })), @@ -155,10 +160,11 @@ describe("DiscordVoiceManager", () => { typeof managerModule.DiscordVoiceManager >[0]["discordConfig"] = {}, clientOverride?: ReturnType, + cfgOverride: ConstructorParameters[0]["cfg"] = {}, ) => new managerModule.DiscordVoiceManager({ client: (clientOverride ?? createClient()) as never, - cfg: {}, + cfg: cfgOverride, discordConfig, accountId: "default", runtime: createRuntime(), @@ -205,8 +211,8 @@ describe("DiscordVoiceManager", () => { await (manager as unknown as ProcessSegmentInvoker).processSegment({ entry: { guildId: "g1", - channelId: "c1", - route: { sessionKey: "discord:g1:c1", agentId: "agent-1" }, + channelId: "1001", + route: { sessionKey: "discord:g1:1001", agentId: "agent-1" }, }, wavPath: "/tmp/test.wav", userId, @@ -286,6 +292,17 @@ describe("DiscordVoiceManager", () => { ); }); + it("stores guild metadata on joined voice sessions", async () => { + const manager = createManager(); + + await manager.join({ guildId: "g1", channelId: "1001" }); + + const entry = (manager as unknown as { sessions: Map }).sessions.get("g1") as + | { guildName?: string } + | undefined; + expect(entry?.guildName).toBe("Guild One"); + }); + it("attempts rejoin after repeated decrypt failures", async () => { const manager = createManager(); @@ -311,7 +328,7 @@ describe("DiscordVoiceManager", () => { discriminator: "1234", }, }); - const manager = createManager({ allowFrom: ["discord:u-owner"] }, client); + const manager = createManager({ groupPolicy: "open", allowFrom: ["discord:u-owner"] }, client); await processVoiceSegment(manager, "u-owner"); const commandArgs = agentCommandMock.mock.calls.at(-1)?.[0] as @@ -331,7 +348,9 @@ describe("DiscordVoiceManager", () => { discriminator: "4321", }, }); - const manager = createManager({ allowFrom: ["discord:u-owner"] }, client); + const manager = createManager({ groupPolicy: "open", allowFrom: ["discord:u-owner"] }, client, { + commands: { useAccessGroups: false }, + }); await processVoiceSegment(manager, "u-guest"); const commandArgs = agentCommandMock.mock.calls.at(-1)?.[0] as @@ -357,6 +376,161 @@ describe("DiscordVoiceManager", () => { await runSegment(); await runSegment(); - expect(client.fetchMember).toHaveBeenCalledTimes(1); + expect(client.fetchMember).toHaveBeenCalledTimes(3); + }); + + it("persists full speaker context in cache writes", async () => { + const client = createClient(); + client.fetchMember.mockResolvedValue({ + nickname: "Role Speaker", + roles: ["role-voice"], + user: { + id: "u-role", + username: "role", + globalName: "Role", + discriminator: "2222", + }, + }); + const manager = createManager( + { + groupPolicy: "allowlist", + guilds: { + g1: { + channels: { + "1001": { + roles: ["role:role-voice"], + }, + }, + }, + }, + }, + client, + ); + + await processVoiceSegment(manager, "u-role"); + + const cache = ( + manager as unknown as { + speakerContextCache: Map< + string, + { + id?: string; + label: string; + name?: string; + tag?: string; + senderIsOwner: boolean; + expiresAt: number; + } + >; + } + ).speakerContextCache; + const cached = cache.get("g1:u-role"); + + expect(cached).toEqual( + expect.objectContaining({ + id: "u-role", + label: "Role Speaker", + }), + ); + }); + + it("re-fetches member roles for repeated voice auth checks", async () => { + const client = createClient(); + client.fetchMember + .mockResolvedValueOnce({ + nickname: "Role Speaker", + roles: ["role-voice"], + user: { + id: "u-role", + username: "role", + globalName: "Role", + discriminator: "2222", + }, + }) + .mockResolvedValueOnce({ + nickname: "Role Speaker", + roles: ["role-voice"], + user: { + id: "u-role", + username: "role", + globalName: "Role", + discriminator: "2222", + }, + }) + .mockResolvedValueOnce({ + nickname: "Role Speaker", + roles: [], + user: { + id: "u-role", + username: "role", + globalName: "Role", + discriminator: "2222", + }, + }) + .mockResolvedValue({ + nickname: "Role Speaker", + roles: [], + user: { + id: "u-role", + username: "role", + globalName: "Role", + discriminator: "2222", + }, + }); + const manager = createManager( + { + groupPolicy: "allowlist", + guilds: { + g1: { + channels: { + "1001": { + roles: ["role:role-voice"], + }, + }, + }, + }, + }, + client, + ); + + await processVoiceSegment(manager, "u-role"); + await processVoiceSegment(manager, "u-role"); + + expect(agentCommandMock).toHaveBeenCalledTimes(1); + expect(client.fetchMember).toHaveBeenCalledTimes(3); + }); + + it("fetches guild metadata before allowlist checks when the session lacks a guild name", async () => { + const client = createClient(); + client.fetchGuild.mockResolvedValue({ id: "g1", name: "Guild One" }); + client.fetchMember.mockResolvedValue({ + nickname: "Owner Nick", + user: { + id: "u-owner", + username: "owner", + globalName: "Owner", + discriminator: "1234", + }, + }); + const manager = createManager( + { + groupPolicy: "allowlist", + guilds: { + "guild-one": { + channels: { + "*": { + users: ["discord:u-owner"], + }, + }, + }, + }, + }, + client, + ); + + await processVoiceSegment(manager, "u-owner"); + + expect(client.fetchGuild).toHaveBeenCalledWith("g1"); + expect(agentCommandMock).toHaveBeenCalledTimes(1); }); }); diff --git a/extensions/discord/src/voice/manager.ts b/extensions/discord/src/voice/manager.ts index b4cdc185a00..4f30f3b65cc 100644 --- a/extensions/discord/src/voice/manager.ts +++ b/extensions/discord/src/voice/manager.ts @@ -9,7 +9,6 @@ import { resolveAgentDir } from "openclaw/plugin-sdk/agent-runtime"; import { agentCommandFromIngress } from "openclaw/plugin-sdk/agent-runtime"; import { resolveTtsConfig, type ResolvedTtsConfig } from "openclaw/plugin-sdk/agent-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; -import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runtime"; import type { DiscordAccountConfig, TtsConfig } from "openclaw/plugin-sdk/config-runtime"; import { transcribeAudioFile } from "openclaw/plugin-sdk/media-understanding-runtime"; import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; @@ -21,8 +20,9 @@ import { textToSpeech } from "openclaw/plugin-sdk/speech-runtime"; import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime"; import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path"; import { formatMention } from "../mentions.js"; -import { resolveDiscordOwnerAccess } from "../monitor/allow-list.js"; +import { normalizeDiscordSlug, resolveDiscordOwnerAccess } from "../monitor/allow-list.js"; import { formatDiscordUserTag } from "../monitor/format.js"; +import { authorizeDiscordVoiceIngress } from "./access.js"; import { loadDiscordVoiceSdk } from "./sdk-runtime.js"; const require = createRequire(import.meta.url); @@ -54,7 +54,9 @@ type VoiceOperationResult = { type VoiceSessionEntry = { guildId: string; + guildName?: string; channelId: string; + channelName?: string; sessionChannelId: string; route: ReturnType; connection: import("@discordjs/voice").VoiceConnection; @@ -238,11 +240,13 @@ export class DiscordVoiceManager { private readonly voiceEnabled: boolean; private autoJoinTask: Promise | null = null; private readonly ownerAllowFrom: string[]; - private readonly allowDangerousNameMatching: boolean; private readonly speakerContextCache = new Map< string, { + id: string; label: string; + name?: string; + tag?: string; senderIsOwner: boolean; expiresAt: number; } @@ -262,7 +266,6 @@ export class DiscordVoiceManager { this.voiceEnabled = params.discordConfig.voice?.enabled !== false; this.ownerAllowFrom = params.discordConfig.allowFrom ?? params.discordConfig.dm?.allowFrom ?? []; - this.allowDangerousNameMatching = isDangerousNameMatchingEnabled(params.discordConfig); } setBotUserId(id?: string) { @@ -425,7 +428,18 @@ export class DiscordVoiceManager { const entry: VoiceSessionEntry = { guildId, + guildName: + channelInfo && + "guild" in channelInfo && + channelInfo.guild && + typeof channelInfo.guild.name === "string" + ? channelInfo.guild.name + : undefined, channelId, + channelName: + channelInfo && "name" in channelInfo && typeof channelInfo.name === "string" + ? channelInfo.name + : undefined, sessionChannelId, route, connection, @@ -596,6 +610,36 @@ export class DiscordVoiceManager { logVoiceVerbose( `segment processing (${durationSeconds.toFixed(2)}s): guild ${entry.guildId} channel ${entry.channelId}`, ); + if (!entry.guildName) { + const guild = await this.params.client.fetchGuild(entry.guildId).catch(() => null); + if (guild && typeof guild.name === "string" && guild.name.trim()) { + entry.guildName = guild.name; + } + } + const speaker = await this.resolveSpeakerContext(entry.guildId, userId); + const speakerIdentity = await this.resolveSpeakerIdentity(entry.guildId, userId); + const access = await authorizeDiscordVoiceIngress({ + cfg: this.params.cfg, + discordConfig: this.params.discordConfig, + guildName: entry.guildName, + guildId: entry.guildId, + channelId: entry.channelId, + channelName: entry.channelName, + channelSlug: entry.channelName ? normalizeDiscordSlug(entry.channelName) : "", + channelLabel: formatMention({ channelId: entry.channelId }), + memberRoleIds: speakerIdentity.memberRoleIds, + sender: { + id: speakerIdentity.id, + name: speakerIdentity.name, + tag: speakerIdentity.tag, + }, + }); + if (!access.ok) { + logVoiceVerbose( + `segment unauthorized: guild ${entry.guildId} channel ${entry.channelId} user ${userId} reason=${access.message}`, + ); + return; + } const transcript = await transcribeAudio({ cfg: this.params.cfg, agentId: entry.route.agentId, @@ -611,7 +655,6 @@ export class DiscordVoiceManager { `transcription ok (${transcript.length} chars): guild ${entry.guildId} channel ${entry.channelId}`, ); - const speaker = await this.resolveSpeakerContext(entry.guildId, userId); const prompt = speaker.label ? `${speaker.label}: ${transcript}` : transcript; const result = await agentCommandFromIngress( @@ -757,7 +800,7 @@ export class DiscordVoiceManager { name: params.name, tag: params.tag, }, - allowNameMatching: this.allowDangerousNameMatching, + allowNameMatching: false, }).ownerAllowed; } @@ -770,7 +813,10 @@ export class DiscordVoiceManager { userId: string, ): | { + id: string; label: string; + name?: string; + tag?: string; senderIsOwner: boolean; } | undefined { @@ -784,7 +830,10 @@ export class DiscordVoiceManager { return undefined; } return { + id: cached.id, label: cached.label, + name: cached.name, + tag: cached.tag, senderIsOwner: cached.senderIsOwner, }; } @@ -792,11 +841,20 @@ export class DiscordVoiceManager { private setCachedSpeakerContext( guildId: string, userId: string, - context: { label: string; senderIsOwner: boolean }, + context: { + id: string; + label: string; + name?: string; + tag?: string; + senderIsOwner: boolean; + }, ): void { const key = this.resolveSpeakerContextCacheKey(guildId, userId); this.speakerContextCache.set(key, { + id: context.id, label: context.label, + name: context.name, + tag: context.tag, senderIsOwner: context.senderIsOwner, expiresAt: Date.now() + SPEAKER_CONTEXT_CACHE_TTL_MS, }); @@ -806,7 +864,10 @@ export class DiscordVoiceManager { guildId: string, userId: string, ): Promise<{ + id: string; label: string; + name?: string; + tag?: string; senderIsOwner: boolean; }> { const cached = this.getCachedSpeakerContext(guildId, userId); @@ -815,7 +876,10 @@ export class DiscordVoiceManager { } const identity = await this.resolveSpeakerIdentity(guildId, userId); const context = { + id: identity.id, label: identity.label, + name: identity.name, + tag: identity.tag, senderIsOwner: this.resolveSpeakerIsOwner({ id: identity.id, name: identity.name, @@ -834,6 +898,7 @@ export class DiscordVoiceManager { label: string; name?: string; tag?: string; + memberRoleIds: string[]; }> { try { const member = await this.params.client.fetchMember(guildId, userId); @@ -843,6 +908,13 @@ export class DiscordVoiceManager { label: member.nickname ?? member.user?.globalName ?? username ?? userId, name: username, tag: member.user ? formatDiscordUserTag(member.user) : undefined, + memberRoleIds: Array.isArray(member.roles) + ? member.roles + .map((role) => + typeof role === "string" ? role : typeof role?.id === "string" ? role.id : "", + ) + .filter(Boolean) + : [], }; } catch { try { @@ -853,9 +925,10 @@ export class DiscordVoiceManager { label: user.globalName ?? username ?? userId, name: username, tag: formatDiscordUserTag(user), + memberRoleIds: [], }; } catch { - return { id: userId, label: userId }; + return { id: userId, label: userId, memberRoleIds: [] }; } } }