diff --git a/docs/bot/design.md b/docs/bot/design.md index 46aa5e0..c27bee9 100644 --- a/docs/bot/design.md +++ b/docs/bot/design.md @@ -69,7 +69,7 @@ src/bot/ config.ts # env parsing (see Env below); fails fast on missing required vars commands/ # slash command definitions (builders) + execute handlers signup.ts # /signup (DM) - link.ts # /link (DM) + link.ts # /link (works in servers and DMs — credential-free, ephemeral reply only) announce.ts # /announce (staff only) events/ interactionCreate.ts # routes buttons + commands; the RSVP button handler lives here @@ -93,7 +93,7 @@ Dependency: `discord.js` ^14 (add to root `package.json`). Script: `"bot": "bun - Validate: username free (not taken in `users.username` **or** `users.discordUsername` — both are set to it), snowflake not already linked, steamId is a 17-digit numeric string. - Create user: `username`, `discordUsername` (same value), `discordId` (snowflake), `displayName`, `steamId`, `roles: ["user"]`, `password` = cryptographically random temp (16 chars, `crypto.randomBytes`). - DM the temp password + `APP_URL` login instructions + "change it in Account settings". -- **`/link`** (DM): for people with an existing site account. Look up user by `discordUsername` == caller's Discord username; set `discordId`. No match → "no account with that Discord username — use /signup". Already linked to a different snowflake → error, contact staff. +- **`/link`** (works in servers and DMs — takes no credentials and only replies ephemerally, so nothing sensitive is exposed; guild-usable so users with DMs disabled can still link, which unblocks the RSVP buttons): for people with an existing site account. Look up user by `discordUsername` == caller's Discord username; set `discordId`. No match → "no account with that Discord username — use /signup". Already linked to a different snowflake → error, contact staff. ### B. Attendance embeds (feature 2) diff --git a/src/bot/commands/announce.ts b/src/bot/commands/announce.ts index 8c5ea6e..1bbb29a 100644 --- a/src/bot/commands/announce.ts +++ b/src/bot/commands/announce.ts @@ -1,4 +1,9 @@ -import { EmbedBuilder, SlashCommandBuilder, type TextBasedChannelFields } from "discord.js"; +import { + EmbedBuilder, + MessageFlags, + SlashCommandBuilder, + type TextBasedChannelFields, +} from "discord.js"; import type { BotCommand } from "./index"; import { botConfig } from "@/bot/config"; import { isStaff } from "@/bot/lib/roles"; @@ -17,7 +22,7 @@ export const announceCommand: BotCommand = { ) as SlashCommandBuilder, execute: async (interaction, payload) => { if (!interaction.inGuild() || !interaction.guild) { - await interaction.reply({ content: "This command only works in a server.", ephemeral: true }); + await interaction.reply({ content: "This command only works in a server.", flags: MessageFlags.Ephemeral }); return; } @@ -25,7 +30,7 @@ export const announceCommand: BotCommand = { if (!(await isStaff(member, payload))) { await interaction.reply({ content: "You don't have permission to announce.", - ephemeral: true, + flags: MessageFlags.Ephemeral, }); return; } @@ -36,7 +41,7 @@ export const announceCommand: BotCommand = { if (!channelId) { await interaction.reply({ content: "No announce channel is configured and no channel was given.", - ephemeral: true, + flags: MessageFlags.Ephemeral, }); return; } @@ -44,7 +49,7 @@ export const announceCommand: BotCommand = { const optionChannel = option && "send" in option ? (option as TextBasedChannelFields) : null; const channel = optionChannel ?? (await interaction.guild.channels.fetch(channelId)); if (!channel || !("send" in channel)) { - await interaction.reply({ content: "That channel isn't a text channel.", ephemeral: true }); + await interaction.reply({ content: "That channel isn't a text channel.", flags: MessageFlags.Ephemeral }); return; } @@ -60,7 +65,7 @@ export const announceCommand: BotCommand = { const name = (channel as unknown as { name?: string | null }).name ?? channelId; await interaction.reply({ content: `Announcement posted to #${name}.`, - ephemeral: true, + flags: MessageFlags.Ephemeral, }); }, }; diff --git a/src/bot/commands/index.ts b/src/bot/commands/index.ts index 77e5c1b..bb91f11 100644 --- a/src/bot/commands/index.ts +++ b/src/bot/commands/index.ts @@ -8,6 +8,12 @@ import { announceCommand } from "./announce"; export interface BotCommand { builder: SlashCommandBuilder; execute: (interaction: ChatInputCommandInteraction, payload: Payload) => Promise | void; + /** + * Registration scope. "global" = available in DMs and every guild the bot is + * in; "guild" (default) = only the configured guild. Discord never exposes + * guild-scoped commands in DMs, so DM-only commands like /signup must be global. + */ + scope?: "global" | "guild"; } export const commands: BotCommand[] = [pingCommand, signupCommand, linkCommand, announceCommand]; diff --git a/src/bot/commands/link.ts b/src/bot/commands/link.ts index 53a366c..1f7a947 100644 --- a/src/bot/commands/link.ts +++ b/src/bot/commands/link.ts @@ -1,19 +1,17 @@ -import { SlashCommandBuilder } from "discord.js"; +import { MessageFlags, SlashCommandBuilder } from "discord.js"; import type { BotCommand } from "./index"; import { linkDiscordAccount, SignupError } from "@/bot/services/signup"; -const DM_ONLY_MESSAGE = "This command only works in direct messages."; - export const linkCommand: BotCommand = { builder: new SlashCommandBuilder() .setName("link") .setDescription("Link your existing Polaris account to your Discord account"), + scope: "global", execute: async (interaction, payload) => { - if (interaction.inGuild()) { - await interaction.reply({ content: DM_ONLY_MESSAGE, ephemeral: true }); - return; - } - + // Usable in guild channels on purpose: /link takes no credentials and only + // replies ephemerally, so nothing sensitive is exposed. Users with DMs + // disabled (who can never open a DM with the bot) can still link, which + // also unblocks the RSVP buttons on attendance embeds. try { const { user, wasLinked } = await linkDiscordAccount( payload, @@ -24,11 +22,11 @@ export const linkCommand: BotCommand = { content: wasLinked ? `Your Discord account is now linked to ${user.username}.` : `Your Discord account is already linked to ${user.username}.`, - ephemeral: true, + flags: MessageFlags.Ephemeral, }); } catch (error) { if (error instanceof SignupError) { - await interaction.reply({ content: error.message, ephemeral: true }); + await interaction.reply({ content: error.message, flags: MessageFlags.Ephemeral }); return; } throw error; diff --git a/src/bot/commands/ping.ts b/src/bot/commands/ping.ts index 4ecbb73..4d57ee6 100644 --- a/src/bot/commands/ping.ts +++ b/src/bot/commands/ping.ts @@ -3,6 +3,7 @@ import type { BotCommand } from "./index"; export const pingCommand: BotCommand = { builder: new SlashCommandBuilder().setName("ping").setDescription("Replies with Pong!"), + scope: "global", execute: async (interaction) => { await interaction.reply("Pong!"); }, diff --git a/src/bot/commands/signup.ts b/src/bot/commands/signup.ts index 9495636..f9b595a 100644 --- a/src/bot/commands/signup.ts +++ b/src/bot/commands/signup.ts @@ -1,9 +1,34 @@ -import { SlashCommandBuilder } from "discord.js"; +import { MessageFlags, SlashCommandBuilder } from "discord.js"; import type { BotCommand } from "./index"; import { botConfig } from "@/bot/config"; import { createDiscordUser, SignupError, validateSignup } from "@/bot/services/signup"; -const DM_ONLY_MESSAGE = "This command only works in direct messages."; +/** + * DM-only: the temp password is delivered through the DM. The most common + * blocker is Discord's "Allow direct messages from server members" privacy + * setting, which blocks DMs in both directions. + */ +export const DM_ONLY_MESSAGE = [ + "This command only works in a direct message with the bot.", + "", + "To use it:", + "1. Open Discord Settings → Privacy & Safety and enable **Allow direct messages from server members**.", + "2. Right-click the bot in the member list → **Message** (the bot will then appear in your DMs).", + "3. Run /signup in that DM.", +].join("\n"); + +const errorMessage = (error: unknown): string => + error instanceof Error ? error.message : "Unknown error"; + +const buildWelcomeMessage = (username: string, tempPassword: string): string => + [ + "Your Polaris Task Force account has been created.", + "", + `Username: ${username}`, + `Temporary password: ${tempPassword}`, + "", + `Log in at ${botConfig.appUrl} and change your password in Account settings.`, + ].join("\n"); export const signupCommand: BotCommand = { builder: new SlashCommandBuilder() @@ -18,9 +43,10 @@ export const signupCommand: BotCommand = { .setDescription("Your Steam64 ID (17-digit numeric)") .setRequired(true), ) as SlashCommandBuilder, + scope: "global", execute: async (interaction, payload) => { if (interaction.inGuild()) { - await interaction.reply({ content: DM_ONLY_MESSAGE, ephemeral: true }); + await interaction.reply({ content: DM_ONLY_MESSAGE, flags: MessageFlags.Ephemeral }); return; } @@ -33,7 +59,7 @@ export const signupCommand: BotCommand = { await validateSignup(payload, { username, discordId, steamId, displayName }); } catch (error) { if (error instanceof SignupError) { - await interaction.reply({ content: error.message, ephemeral: true }); + await interaction.reply({ content: error.message, flags: MessageFlags.Ephemeral }); return; } throw error; @@ -46,19 +72,31 @@ export const signupCommand: BotCommand = { steamId, }); - const passwordMessage = [ - "Your Polaris Task Force account has been created.", - "", - `Username: ${user.username}`, - `Temporary password: ${tempPassword}`, - "", - `Log in at ${botConfig.appUrl} and change your password in Account settings.`, - ].join("\n"); - await interaction.user.send(passwordMessage); + const welcomeMessage = buildWelcomeMessage(user.username, tempPassword); + + // Best-effort copy in the DM channel. The ephemeral reply below is the + // guaranteed delivery path: the command is DM-only, so the reply IS in the + // user's DM and cannot be blocked by privacy settings. If this send fails, + // the account is not orphaned — the reply carries the password. + let dmSent = false; + try { + await interaction.user.send(welcomeMessage); + dmSent = true; + } catch (error) { + payload.logger.warn( + `[Bot] Could not DM the /signup welcome message to ${user.username}: ${errorMessage(error)}`, + ); + } await interaction.reply({ - content: `Account created for ${user.username}. Your temporary password was sent to this DM.`, - ephemeral: true, + content: dmSent + ? `Account created for ${user.username}. Your temporary password was sent to this DM.` + : [ + welcomeMessage, + "", + "I couldn't send you a separate DM. If this keeps happening, enable “Allow direct messages from server members” in Discord Settings → Privacy & Safety.", + ].join("\n"), + flags: MessageFlags.Ephemeral, }); }, }; diff --git a/src/bot/events/interactionCreate.ts b/src/bot/events/interactionCreate.ts index 8eaf7c4..03afbbe 100644 --- a/src/bot/events/interactionCreate.ts +++ b/src/bot/events/interactionCreate.ts @@ -1,4 +1,4 @@ -import type { ButtonInteraction } from "discord.js"; +import { MessageFlags, type ButtonInteraction } from "discord.js"; import type { Payload } from "payload"; import { findUserByDiscordId } from "@/bot/lib/resolve"; import { @@ -44,7 +44,7 @@ export const handleButtonInteraction = async ( try { const user = await findUserByDiscordId(payload, interaction.user.id); if (!user) { - await interaction.reply({ content: "Run /signup or /link first.", ephemeral: true }); + await interaction.reply({ content: "Run /signup or /link first.", flags: MessageFlags.Ephemeral }); return; } @@ -56,7 +56,7 @@ export const handleButtonInteraction = async ( }); if (isMissionPast(mission.classification.startDateTime)) { - await interaction.reply({ content: attendanceClosedMessage, ephemeral: true }); + await interaction.reply({ content: attendanceClosedMessage, flags: MessageFlags.Ephemeral }); return; } @@ -78,7 +78,7 @@ export const handleButtonInteraction = async ( await interaction.reply({ content: `You are marked as ${ATTENDANCE_LABELS[response]} for ${mission.name}.`, - ephemeral: true, + flags: MessageFlags.Ephemeral, }); } catch (error) { payload.logger.error( @@ -87,7 +87,7 @@ export const handleButtonInteraction = async ( if (!interaction.replied && !interaction.deferred) { await interaction.reply({ content: "Something went wrong while updating your attendance.", - ephemeral: true, + flags: MessageFlags.Ephemeral, }); } } diff --git a/src/bot/index.ts b/src/bot/index.ts index 02aea06..56dc010 100644 --- a/src/bot/index.ts +++ b/src/bot/index.ts @@ -1,4 +1,4 @@ -import { Client, Events, GatewayIntentBits } from "discord.js"; +import { Client, Events, GatewayIntentBits, MessageFlags } from "discord.js"; import { getPayload } from "payload"; import config from "@payload-config"; import { botConfig } from "./config"; @@ -21,11 +21,24 @@ const main = async () => { client.once(Events.ClientReady, async (readyClient) => { payload.logger.info(`[Bot] Logged in as ${readyClient.user.tag}`); - await readyClient.application.commands.set( - commands.map((command) => command.builder.toJSON()), - botConfig.guildId, + const globalCommands = commands.filter((command) => command.scope === "global"); + const guildCommands = commands.filter((command) => command.scope !== "global"); + // Guild-scoped commands are never exposed in DMs — /signup and /link must + // be global so they actually appear (and run) in a DM with the bot. + if (globalCommands.length > 0) { + await readyClient.application.commands.set( + globalCommands.map((command) => command.builder.toJSON()), + ); + } + if (guildCommands.length > 0) { + await readyClient.application.commands.set( + guildCommands.map((command) => command.builder.toJSON()), + botConfig.guildId, + ); + } + payload.logger.info( + `[Bot] Registered ${globalCommands.length} global and ${guildCommands.length} guild command(s)`, ); - payload.logger.info(`[Bot] Registered ${commands.length} guild command(s)`); for (const start of services) { await start(readyClient, payload); } @@ -51,7 +64,7 @@ const main = async () => { if (!interaction.replied && !interaction.deferred) { await interaction.reply({ content: "Something went wrong while running this command.", - ephemeral: true, + flags: MessageFlags.Ephemeral, }); } }