fix(discord): gate voice ingress by allowlists (#58245)

* fix(discord): gate voice ingress by allowlists

* fix(discord): preserve voice allowlist context

* fix(discord): fetch guild metadata for voice allowlists

* fix(discord): reuse voice speaker context

* fix(discord): preserve cached speaker context

* fix(discord): tighten voice ingress authorization
This commit is contained in:
Vincent Koc 2026-03-31 21:29:13 +09:00 committed by GitHub
parent 25a3d37970
commit dba96e7507
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 590 additions and 94 deletions

View File

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

View File

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

View File

@ -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<true> | 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." };
}

View File

@ -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<APIApplicationCommandChannelOption["channel_types"]> = [
@ -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 };

View File

@ -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<typeof createClient>,
cfgOverride: ConstructorParameters<typeof managerModule.DiscordVoiceManager>[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<string, unknown> }).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);
});
});

View File

@ -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<typeof resolveAgentRoute>;
connection: import("@discordjs/voice").VoiceConnection;
@ -238,11 +240,13 @@ export class DiscordVoiceManager {
private readonly voiceEnabled: boolean;
private autoJoinTask: Promise<void> | 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: [] };
}
}
}