diff --git a/src/channels/allowlists/resolve-utils.test.ts b/src/channels/allowlists/resolve-utils.test.ts index 807e7c06877..346cd182787 100644 --- a/src/channels/allowlists/resolve-utils.test.ts +++ b/src/channels/allowlists/resolve-utils.test.ts @@ -27,6 +27,15 @@ describe("buildAllowlistResolutionSummary", () => { }); expect(result.mapping).toEqual(["a→1 (note)"]); }); + + it("supports custom unresolved formatting", () => { + const resolvedUsers = [{ input: "a", resolved: false, note: "missing" }]; + const result = buildAllowlistResolutionSummary(resolvedUsers, { + formatUnresolved: (entry) => + `${entry.input}${(entry as { note?: string }).note ? " (missing)" : ""}`, + }); + expect(result.unresolved).toEqual(["a (missing)"]); + }); }); describe("addAllowlistUserEntriesFromConfigEntry", () => { diff --git a/src/channels/allowlists/resolve-utils.ts b/src/channels/allowlists/resolve-utils.ts index fdfef0fa0e0..63dfa2be492 100644 --- a/src/channels/allowlists/resolve-utils.ts +++ b/src/channels/allowlists/resolve-utils.ts @@ -36,7 +36,7 @@ export function mergeAllowlist(params: { export function buildAllowlistResolutionSummary( resolvedUsers: T[], - opts?: { formatResolved?: (entry: T) => string }, + opts?: { formatResolved?: (entry: T) => string; formatUnresolved?: (entry: T) => string }, ): { resolvedMap: Map; mapping: string[]; @@ -46,14 +46,13 @@ export function buildAllowlistResolutionSummary [entry.input, entry])); const resolvedOk = (entry: T) => Boolean(entry.resolved && entry.id); const formatResolved = opts?.formatResolved ?? ((entry: T) => `${entry.input}→${entry.id}`); + const formatUnresolved = opts?.formatUnresolved ?? ((entry: T) => entry.input); const mapping = resolvedUsers.filter(resolvedOk).map(formatResolved); const additions = resolvedUsers .filter(resolvedOk) .map((entry) => entry.id) .filter((entry): entry is string => Boolean(entry)); - const unresolved = resolvedUsers - .filter((entry) => !resolvedOk(entry)) - .map((entry) => entry.input); + const unresolved = resolvedUsers.filter((entry) => !resolvedOk(entry)).map(formatUnresolved); return { resolvedMap, mapping, unresolved, additions }; } diff --git a/src/discord/monitor/provider.allowlist.test.ts b/src/discord/monitor/provider.allowlist.test.ts index 63b4b01708d..efa9085a44f 100644 --- a/src/discord/monitor/provider.allowlist.test.ts +++ b/src/discord/monitor/provider.allowlist.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it, vi } from "vitest"; import type { RuntimeEnv } from "../../runtime.js"; const { resolveDiscordChannelAllowlistMock, resolveDiscordUserAllowlistMock } = vi.hoisted(() => ({ - resolveDiscordChannelAllowlistMock: vi.fn(async () => []), + resolveDiscordChannelAllowlistMock: vi.fn( + async (_params: { entries: string[] }) => [] as Array>, + ), resolveDiscordUserAllowlistMock: vi.fn(async (params: { entries: string[] }) => params.entries.map((entry) => { switch (entry) { @@ -12,6 +14,8 @@ const { resolveDiscordChannelAllowlistMock, resolveDiscordUserAllowlistMock } = return { input: entry, resolved: true, id: "222" }; case "Carol": return { input: entry, resolved: false }; + case "387": + return { input: entry, resolved: true, id: "387", name: "Peter" }; default: return { input: entry, resolved: true, id: entry }; } @@ -54,4 +58,39 @@ describe("resolveDiscordAllowlistConfig", () => { expect(result.guildEntries?.["*"]?.channels?.["*"]?.users).toEqual(["Carol", "888"]); expect(resolveDiscordUserAllowlistMock).toHaveBeenCalledTimes(2); }); + + it("logs discord name metadata for resolved and unresolved allowlist entries", async () => { + resolveDiscordChannelAllowlistMock.mockResolvedValueOnce([ + { + input: "145/c404", + resolved: false, + guildId: "145", + guildName: "Ops", + channelName: "missing-room", + }, + ]); + const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() } as unknown as RuntimeEnv; + + await resolveDiscordAllowlistConfig({ + token: "token", + allowFrom: ["387"], + guildEntries: { + "145": { + channels: { + c404: {}, + }, + }, + }, + fetcher: vi.fn() as unknown as typeof fetch, + runtime, + }); + + const logs = (runtime.log as ReturnType).mock.calls + .map(([line]) => String(line)) + .join("\n"); + expect(logs).toContain( + "discord channels unresolved: 145/c404 (guild:Ops; channel:missing-room)", + ); + expect(logs).toContain("discord users resolved: 387→387 (name:Peter)"); + }); }); diff --git a/src/discord/monitor/provider.allowlist.ts b/src/discord/monitor/provider.allowlist.ts index 556a3da3305..49d4068dfa8 100644 --- a/src/discord/monitor/provider.allowlist.ts +++ b/src/discord/monitor/provider.allowlist.ts @@ -13,6 +13,71 @@ import { resolveDiscordUserAllowlist } from "../resolve-users.js"; type GuildEntries = Record; type ChannelResolutionInput = { input: string; guildKey: string; channelKey?: string }; +type DiscordChannelLogEntry = { + input: string; + guildId?: string; + guildName?: string; + channelId?: string; + channelName?: string; + note?: string; +}; +type DiscordUserLogEntry = { + input: string; + id?: string; + name?: string; + guildName?: string; + note?: string; +}; + +function formatResolutionLogDetails(base: string, details: Array): string { + const nonEmpty = details + .map((value) => value?.trim()) + .filter((value): value is string => Boolean(value)); + return nonEmpty.length > 0 ? `${base} (${nonEmpty.join("; ")})` : base; +} + +function formatDiscordChannelResolved(entry: DiscordChannelLogEntry): string { + const target = entry.channelId ? `${entry.guildId}/${entry.channelId}` : entry.guildId; + const base = `${entry.input}→${target}`; + return formatResolutionLogDetails(base, [ + entry.guildName ? `guild:${entry.guildName}` : undefined, + entry.channelName ? `channel:${entry.channelName}` : undefined, + entry.note, + ]); +} + +function formatDiscordChannelUnresolved(entry: DiscordChannelLogEntry): string { + return formatResolutionLogDetails(entry.input, [ + entry.guildName + ? `guild:${entry.guildName}` + : entry.guildId + ? `guildId:${entry.guildId}` + : undefined, + entry.channelName + ? `channel:${entry.channelName}` + : entry.channelId + ? `channelId:${entry.channelId}` + : undefined, + entry.note, + ]); +} + +function formatDiscordUserResolved(entry: DiscordUserLogEntry): string { + const base = `${entry.input}→${entry.id}`; + return formatResolutionLogDetails(base, [ + entry.name ? `name:${entry.name}` : undefined, + entry.guildName ? `guild:${entry.guildName}` : undefined, + entry.note, + ]); +} + +function formatDiscordUserUnresolved(entry: DiscordUserLogEntry): string { + return formatResolutionLogDetails(entry.input, [ + entry.name ? `name:${entry.name}` : undefined, + entry.guildName ? `guild:${entry.guildName}` : undefined, + entry.note, + ]); +} function toGuildEntries(value: unknown): GuildEntries { if (!value || typeof value !== "object") { @@ -90,14 +155,10 @@ async function resolveGuildEntriesByChannelAllowlist(params: { } const sourceGuild = params.guildEntries[source.guildKey] ?? {}; if (!entry.resolved || !entry.guildId) { - unresolved.push(entry.input); + unresolved.push(formatDiscordChannelUnresolved(entry)); continue; } - mapping.push( - entry.channelId - ? `${entry.input}→${entry.guildId}/${entry.channelId}` - : `${entry.input}→${entry.guildId}`, - ); + mapping.push(formatDiscordChannelResolved(entry)); const existing = nextGuilds[entry.guildId] ?? {}; const mergedChannels = { ...sourceGuild.channels, @@ -153,7 +214,10 @@ async function resolveAllowFromByUserAllowlist(params: { entries: allowEntries.map((entry) => String(entry)), fetcher: params.fetcher, }); - const { resolvedMap, mapping, unresolved } = buildAllowlistResolutionSummary(resolvedUsers); + const { resolvedMap, mapping, unresolved } = buildAllowlistResolutionSummary(resolvedUsers, { + formatResolved: formatDiscordUserResolved, + formatUnresolved: formatDiscordUserUnresolved, + }); const allowFrom = canonicalizeAllowlistWithResolvedIds({ existing: params.allowFrom, resolvedMap, @@ -199,7 +263,10 @@ async function resolveGuildEntriesByUserAllowlist(params: { entries: Array.from(userEntries), fetcher: params.fetcher, }); - const { resolvedMap, mapping, unresolved } = buildAllowlistResolutionSummary(resolvedUsers); + const { resolvedMap, mapping, unresolved } = buildAllowlistResolutionSummary(resolvedUsers, { + formatResolved: formatDiscordUserResolved, + formatUnresolved: formatDiscordUserUnresolved, + }); const nextGuilds = { ...params.guildEntries }; for (const [guildKey, guildConfig] of Object.entries(params.guildEntries)) { if (!guildConfig || typeof guildConfig !== "object") { diff --git a/src/discord/monitor/provider.ts b/src/discord/monitor/provider.ts index 977995ab68e..016a18b77ba 100644 --- a/src/discord/monitor/provider.ts +++ b/src/discord/monitor/provider.ts @@ -549,6 +549,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const logger = createSubsystemLogger("discord/monitor"); const guildHistories = new Map(); let botUserId: string | undefined; + let botUserName: string | undefined; let voiceManager: DiscordVoiceManager | null = null; if (nativeDisabledExplicit) { @@ -562,6 +563,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { try { const botUser = await client.fetchUser("@me"); botUserId = botUser?.id; + botUserName = botUser?.username?.trim() || botUser?.globalName?.trim() || undefined; } catch (err) { runtime.error?.(danger(`discord: failed to fetch bot identity: ${String(err)}`)); } @@ -657,7 +659,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { runtime.log?.("discord: GuildPresences intent enabled — presence listener registered"); } - runtime.log?.(`logged in to discord${botUserId ? ` as ${botUserId}` : ""}`); + const botIdentity = + botUserId && botUserName ? `${botUserId} (${botUserName})` : (botUserId ?? botUserName ?? ""); + runtime.log?.(`logged in to discord${botIdentity ? ` as ${botIdentity}` : ""}`); lifecycleStarted = true; await runDiscordGatewayLifecycle({ diff --git a/src/discord/resolve-channels.test.ts b/src/discord/resolve-channels.test.ts index 6d6de498b0b..f0445a80086 100644 --- a/src/discord/resolve-channels.test.ts +++ b/src/discord/resolve-channels.test.ts @@ -53,6 +53,66 @@ describe("resolveDiscordChannelAllowlist", () => { expect(res[0]?.channelId).toBe("123"); }); + it("resolves guildId/channelId entries via channel lookup", async () => { + const fetcher = withFetchPreconnect(async (input: RequestInfo | URL) => { + const url = urlToString(input); + if (url.endsWith("/users/@me/guilds")) { + return jsonResponse([{ id: "111", name: "Guild One" }]); + } + if (url.endsWith("/channels/222")) { + return jsonResponse({ id: "222", name: "general", guild_id: "111", type: 0 }); + } + return new Response("not found", { status: 404 }); + }); + + const res = await resolveDiscordChannelAllowlist({ + token: "test", + entries: ["111/222"], + fetcher, + }); + + expect(res[0]).toMatchObject({ + input: "111/222", + resolved: true, + guildId: "111", + channelId: "222", + channelName: "general", + guildName: "Guild One", + }); + }); + + it("reports unresolved when channel id belongs to a different guild", async () => { + const fetcher = withFetchPreconnect(async (input: RequestInfo | URL) => { + const url = urlToString(input); + if (url.endsWith("/users/@me/guilds")) { + return jsonResponse([ + { id: "111", name: "Guild One" }, + { id: "333", name: "Guild Two" }, + ]); + } + if (url.endsWith("/channels/222")) { + return jsonResponse({ id: "222", name: "general", guild_id: "333", type: 0 }); + } + return new Response("not found", { status: 404 }); + }); + + const res = await resolveDiscordChannelAllowlist({ + token: "test", + entries: ["111/222"], + fetcher, + }); + + expect(res[0]).toMatchObject({ + input: "111/222", + resolved: false, + guildId: "111", + guildName: "Guild One", + channelId: "222", + channelName: "general", + note: "channel belongs to guild Guild Two", + }); + }); + it("resolves guild: prefixed id as guild (not channel)", async () => { const fetcher = withFetchPreconnect(async (input: RequestInfo | URL) => { const url = urlToString(input); diff --git a/src/discord/resolve-channels.ts b/src/discord/resolve-channels.ts index 857b2e47533..10b8818b44b 100644 --- a/src/discord/resolve-channels.ts +++ b/src/discord/resolve-channels.ts @@ -61,6 +61,9 @@ function parseDiscordChannelInput(raw: string): { return guild ? { guild: guild.trim(), guildOnly: true } : {}; } if (guild && /^\d+$/.test(guild)) { + if (/^\d+$/.test(channel)) { + return { guildId: guild, channelId: channel }; + } return { guildId: guild, channel }; } return { guild, channel }; @@ -191,6 +194,22 @@ export async function resolveDiscordChannelAllowlist(params: { if (parsed.channelId) { const channel = await fetchChannel(token, fetcher, parsed.channelId); if (channel?.guildId) { + if (parsed.guildId && parsed.guildId !== channel.guildId) { + const expectedGuild = guilds.find((entry) => entry.id === parsed.guildId); + const actualGuild = guilds.find((entry) => entry.id === channel.guildId); + results.push({ + input, + resolved: false, + guildId: parsed.guildId, + guildName: expectedGuild?.name, + channelId: parsed.channelId, + channelName: channel.name, + note: actualGuild?.name + ? `channel belongs to guild ${actualGuild.name}` + : "channel belongs to a different guild", + }); + continue; + } const guild = guilds.find((entry) => entry.id === channel.guildId); results.push({ input,