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:
parent
4a96c57546
commit
cabbe071d7
8 changed files with 105 additions and 44 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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!");
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue