1
0
Fork 0

feat(bot): register signup/link/ping commands globally for DM use

Add a scope field to BotCommand so /signup, /link and /ping register globally (available in DMs — guild-scoped commands never appear there) while /announce stays guild-only. Migrate ephemeral replies to MessageFlags and make the /signup welcome message resilient to blocked DMs.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Jason Fraley 2026-08-13 17:00:44 -04:00
parent 4a96c57546
commit cabbe071d7
8 changed files with 105 additions and 44 deletions

View file

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

View file

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

View file

@ -8,6 +8,12 @@ import { announceCommand } from "./announce";
export interface BotCommand {
builder: SlashCommandBuilder;
execute: (interaction: ChatInputCommandInteraction, payload: Payload) => Promise<void> | 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];

View file

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

View file

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

View file

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

View file

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

View file

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