Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

### Fixed

- Normalize whitespace and `#` display prefixes in MCP server and channel identifiers before lookup.
- Prevent mixed reaction replies from narrating the bot's internal choice to react while preserving natural reaction-plus-text responses.
- Run Discord-initiated Claude login in a pseudo-terminal so the CLI accepts submitted OAuth codes.
- Isolate saved history and summaries by Discord channel ID in dedicated storage namespaces so same-named channels do not share automatic context.
Expand Down
39 changes: 25 additions & 14 deletions src/discord/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,20 @@
import { TextChannel } from "discord.js";
import { client } from "./client.js";

export function normalizeGuildIdentifier(
guildIdentifier?: string,
): string | undefined {
const normalized = guildIdentifier?.trim();
return normalized || undefined;
}

export function normalizeChannelIdentifier(channelIdentifier: string): string {
return channelIdentifier.trim().replace(/^#+/, "");
}

export async function findGuild(guildIdentifier?: string) {
if (!guildIdentifier) {
const normalizedGuildIdentifier = normalizeGuildIdentifier(guildIdentifier);
if (!normalizedGuildIdentifier) {
if (client.guilds.cache.size === 1) {
return client.guilds.cache.first()!;
}
Expand All @@ -15,53 +27,52 @@ export async function findGuild(guildIdentifier?: string) {
}

try {
const guild = await client.guilds.fetch(guildIdentifier);
const guild = await client.guilds.fetch(normalizedGuildIdentifier);
if (guild) return guild;
} catch {
const guilds = client.guilds.cache.filter(
(g) => g.name.toLowerCase() === guildIdentifier.toLowerCase(),
(g) => g.name.toLowerCase() === normalizedGuildIdentifier.toLowerCase(),
);

if (guilds.size === 0) {
const availableGuilds = Array.from(client.guilds.cache.values())
.map((g) => `"${g.name}"`)
.join(", ");
throw new Error(
`Server "${guildIdentifier}" not found. Available servers: ${availableGuilds}`,
`Server "${normalizedGuildIdentifier}" not found. Available servers: ${availableGuilds}`,
);
}
if (guilds.size > 1) {
const guildList = guilds
.map((g) => `${g.name} (ID: ${g.id})`)
.join(", ");
throw new Error(
`Multiple servers found with name "${guildIdentifier}": ${guildList}. Please specify the server ID.`,
`Multiple servers found with name "${normalizedGuildIdentifier}": ${guildList}. Please specify the server ID.`,
);
}
return guilds.first()!;
}
throw new Error(`Server "${guildIdentifier}" not found`);
throw new Error(`Server "${normalizedGuildIdentifier}" not found`);
}

export async function findChannel(
channelIdentifier: string,
guildIdentifier?: string,
): Promise<TextChannel> {
const normalizedChannelIdentifier = normalizeChannelIdentifier(channelIdentifier);
const guild = await findGuild(guildIdentifier);

try {
const channel = await client.channels.fetch(channelIdentifier);
const channel = await client.channels.fetch(normalizedChannelIdentifier);
if (channel instanceof TextChannel && channel.guild.id === guild.id) {
return channel;
}
} catch {
const normalizedLower = normalizedChannelIdentifier.toLowerCase();
const channels = guild.channels.cache.filter(
(channel): channel is TextChannel =>
channel instanceof TextChannel &&
(channel.name.toLowerCase() ===
channelIdentifier.toLowerCase() ||
channel.name.toLowerCase() ===
channelIdentifier.toLowerCase().replace("#", "")),
channel.name.toLowerCase() === normalizedLower,
);

if (channels.size === 0) {
Expand All @@ -70,20 +81,20 @@ export async function findChannel(
.map((c) => `"#${c.name}"`)
.join(", ");
throw new Error(
`Channel "${channelIdentifier}" not found in server "${guild.name}". Available channels: ${availableChannels}`,
`Channel "${normalizedChannelIdentifier}" not found in server "${guild.name}". Available channels: ${availableChannels}`,
);
}
if (channels.size > 1) {
const channelList = channels
.map((c) => `#${c.name} (${c.id})`)
.join(", ");
throw new Error(
`Multiple channels found with name "${channelIdentifier}" in server "${guild.name}": ${channelList}. Please specify the channel ID.`,
`Multiple channels found with name "${normalizedChannelIdentifier}" in server "${guild.name}": ${channelList}. Please specify the channel ID.`,
);
}
return channels.first()!;
}
throw new Error(
`Channel "${channelIdentifier}" is not a text channel or not found in server "${guild.name}"`,
`Channel "${normalizedChannelIdentifier}" is not a text channel or not found in server "${guild.name}"`,
);
}
56 changes: 56 additions & 0 deletions tests/identifierNormalization.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import assert from "node:assert/strict";
import test from "node:test";

import { ChannelType, Collection, TextChannel } from "discord.js";
import {
findChannel,
normalizeChannelIdentifier,
normalizeGuildIdentifier,
} from "../build/discord/helpers.js";

test("normalizes optional server identifiers before lookup", () => {
assert.equal(normalizeGuildIdentifier(" Project Server "), "Project Server");
assert.equal(normalizeGuildIdentifier(" "), undefined);
assert.equal(normalizeGuildIdentifier(undefined), undefined);
});

test("finds a channel when MCP-style identifiers have display whitespace", async () => {
assert.equal(normalizeChannelIdentifier(" #general "), "general");
const [{ client }] = await Promise.all([
import("../build/discord/client.js"),
]);
const guild = {
id: "111111111111111111",
name: "Project Server",
channels: { cache: new Collection() },
};
const channel = Object.create(TextChannel.prototype);
Object.defineProperties(channel, {
id: { value: "222222222222222222" },
type: { value: ChannelType.GuildText },
name: { value: "general" },
guild: { value: guild },
});
guild.channels.cache.set(channel.id, channel);

const originalGuildFetch = client.guilds.fetch;
const originalChannelFetch = client.channels.fetch;
client.guilds.cache.set(guild.id, guild);
client.guilds.fetch = async () => {
throw new Error("Not a guild ID");
};
client.channels.fetch = async () => {
throw new Error("Not a channel ID");
};

try {
assert.equal(
await findChannel(" #general ", " Project Server "),
channel,
);
} finally {
client.guilds.fetch = originalGuildFetch;
client.channels.fetch = originalChannelFetch;
client.guilds.cache.delete(guild.id);
}
});