diff --git a/.env.example b/.env.example index f7f5c75..846f90c 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,10 @@ NODE_ENV=production BOT_TOKEN= BOT_CLIENT_ID= BOT_CLIENT_SECRET= +# Optional: register slash commands only on this guild (instant sync for dev). +# Leave empty in production so commands are global. When set, global commands +# are cleared; when empty, leftover guild-scoped commands are cleared on startup +# so Discord does not show every command twice. BOT_GUILD_ID= DATABASE_URL=postgresql://nexumi:nexumi@postgres:5432/nexumi REDIS_URL=redis://redis:6379 diff --git a/apps/bot/src/commands.ts b/apps/bot/src/commands.ts index 4f3b501..e74e256 100644 --- a/apps/bot/src/commands.ts +++ b/apps/bot/src/commands.ts @@ -69,38 +69,82 @@ const commands: SlashCommand[] = [ const map = new Map(commands.map((c) => [c.data.name, c])); const contextMenuMap = new Map(utilityContextMenus.map((c) => [c.data.name, c])); +function buildCommandBody(): object[] { + const seen = new Set(); + const body: object[] = []; + for (const command of [...commands, ...utilityContextMenus]) { + const json = command.data.toJSON() as { name: string; type?: number }; + const key = `${json.type ?? 1}:${json.name}`; + if (seen.has(key)) { + logger.warn({ name: json.name, type: json.type }, 'Skipping duplicate command definition'); + continue; + } + seen.add(key); + body.push(json); + } + return body; +} + +/** + * Guild-scoped leftovers + global commands both appear in Discord's picker. + * Always clear the unused scope so each command shows once. + */ +async function clearGuildCommandLeftovers(rest: REST, clientId: string): Promise { + const guilds = (await rest.get(Routes.userGuilds())) as Array<{ id: string }>; + let cleared = 0; + for (const guild of guilds) { + const existing = (await rest.get( + Routes.applicationGuildCommands(clientId, guild.id) + )) as unknown[]; + if (existing.length === 0) { + continue; + } + await rest.put(Routes.applicationGuildCommands(clientId, guild.id), { body: [] }); + cleared += 1; + } + return cleared; +} + export async function registerCommands() { const rest = new REST({ version: '10' }).setToken(env.BOT_TOKEN); - const body = [ - ...commands.map((c) => c.data.toJSON()), - ...utilityContextMenus.map((c) => c.data.toJSON()) - ]; + const body = buildCommandBody(); // Guild commands sync instantly; global can take up to ~1h. if (env.BOT_GUILD_ID) { await rest.put(Routes.applicationGuildCommands(env.BOT_CLIENT_ID, env.BOT_GUILD_ID), { body }); + // Remove global copies so the guild does not show every command twice. + await rest.put(Routes.applicationCommands(env.BOT_CLIENT_ID), { body: [] }); logger.info( { scope: 'guild', guildId: env.BOT_GUILD_ID, - count: commands.length, - contextMenus: utilityContextMenus.length + count: body.length, + clearedGlobal: true }, - 'Registered guild application commands' + 'Registered guild application commands and cleared global commands' ); return; } await rest.put(Routes.applicationCommands(env.BOT_CLIENT_ID), { body }); + + // Wipe leftover guild-scoped registrations from earlier BOT_GUILD_ID / shard runs. + let clearedGuilds = 0; + try { + clearedGuilds = await clearGuildCommandLeftovers(rest, env.BOT_CLIENT_ID); + } catch (error) { + logger.warn({ error }, 'Failed to clear leftover guild application commands'); + } + logger.info( { scope: 'global', - count: commands.length, - contextMenus: utilityContextMenus.length + count: body.length, + clearedGuilds }, - 'Registered global application commands' + 'Registered global application commands and cleared guild-scoped leftovers' ); }