From 2991a561478eca6b52f339371c152302bfb55e8e Mon Sep 17 00:00:00 2001 From: Hikari Date: Thu, 12 Mar 2026 23:01:19 -0700 Subject: [PATCH] feat: add /announcement owner-only slash command Triggers a modal with a content text input and category select menu, calls the announcement API, DMs generated copy as file attachments, and replies ephemerally with the platform recap. --- bot/commandJson.js | 7 + bot/prod.env | 3 +- bot/src/commands/announcement.ts | 72 +++++++++++ bot/src/events/interactionCreate.ts | 6 +- bot/src/events/modalInteractionCreate.ts | 24 ++++ bot/src/index.ts | 7 + bot/src/modules/handleAnnouncementModal.ts | 142 +++++++++++++++++++++ 7 files changed, 258 insertions(+), 3 deletions(-) create mode 100644 bot/src/commands/announcement.ts create mode 100644 bot/src/events/modalInteractionCreate.ts create mode 100644 bot/src/modules/handleAnnouncementModal.ts diff --git a/bot/commandJson.js b/bot/commandJson.js index a2de954..b9cfc91 100644 --- a/bot/commandJson.js +++ b/bot/commandJson.js @@ -6,6 +6,12 @@ const about = new SlashCommandBuilder() .setContexts([InteractionContextType.Guild, InteractionContextType.BotDM, InteractionContextType.PrivateChannel]) .setIntegrationTypes([ApplicationIntegrationType.UserInstall, ApplicationIntegrationType.GuildInstall]); +const announcement = new SlashCommandBuilder() + .setName("announcement") + .setDescription("Create a cross-platform announcement. (Owner only)") + .setContexts([InteractionContextType.BotDM, InteractionContextType.PrivateChannel]) + .setIntegrationTypes([ApplicationIntegrationType.UserInstall]); + const dm = new SlashCommandBuilder() .setName("dm") .setDescription("Trigger a DM response so you can find your DM channel.") @@ -14,5 +20,6 @@ const dm = new SlashCommandBuilder() console.log(JSON.stringify([ about.toJSON(), + announcement.toJSON(), dm.toJSON() ])) \ No newline at end of file diff --git a/bot/prod.env b/bot/prod.env index 1a9d383..ff1c13c 100644 --- a/bot/prod.env +++ b/bot/prod.env @@ -1,3 +1,4 @@ LOG_TOKEN="op://Environment Variables - Naomi/Alert Server/api_auth" DISCORD_TOKEN="op://Environment Variables - Naomi/Hikari/discord_token" -ANTHROPIC_KEY="op://Environment Variables - Naomi/Hikari/anthropic_key" \ No newline at end of file +ANTHROPIC_KEY="op://Environment Variables - Naomi/Hikari/anthropic_key" +ANNOUNCEMENT_TOKEN="op://Environment Variables - Naomi/Hikari/announcement_token" \ No newline at end of file diff --git a/bot/src/commands/announcement.ts b/bot/src/commands/announcement.ts new file mode 100644 index 0000000..c4daf63 --- /dev/null +++ b/bot/src/commands/announcement.ts @@ -0,0 +1,72 @@ +/** + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { + ActionRowBuilder, + ModalBuilder, + StringSelectMenuBuilder, + TextInputBuilder, + TextInputStyle, +} from "discord.js"; +import { entitledUsers } from "../config/entitlements.js"; +import { errorHandler } from "../utils/errorHandler.js"; +import type { Command } from "../interfaces/command.js"; + +/** + * Handles the `/announcement` command interaction. + * Owner-only command that opens a modal for creating cross-platform announcements. + * @param _hikari - Hikari's Discord instance (unused). + * @param interaction - The command interaction payload from Discord. + */ +export const announcement: Command = async(_hikari, interaction) => { + try { + if (!entitledUsers.includes(interaction.user.id)) { + await interaction.reply({ + content: "This command is restricted to the owner.", + ephemeral: true, + }); + return; + } + + const modal = new ModalBuilder(). + setCustomId("announcement_modal"). + setTitle("Create Announcement"); + + const contentInput = new TextInputBuilder(). + setCustomId("content"). + // eslint-disable-next-line deprecation/deprecation -- No V2 equivalent exists for modal text input labels + setLabel("Announcement Copy"). + setStyle(TextInputStyle.Paragraph). + setMaxLength(4000). + setRequired(true); + + const categorySelect = new StringSelectMenuBuilder(). + setCustomId("category"). + setPlaceholder("Select a category"). + addOptions([ + { label: "Products", value: "products" }, + { label: "Community", value: "community" }, + { label: "Company", value: "company" }, + ]); + + // eslint-disable-next-line deprecation/deprecation -- No V2 equivalent exists for modal component rows + modal.addComponents( + new ActionRowBuilder().addComponents(contentInput), + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- discord.js types don't yet reflect select menus in modals, but they're supported at the API level + new ActionRowBuilder().addComponents( + categorySelect, + ) as unknown as ActionRowBuilder, + ); + + await interaction.showModal(modal); + } catch (error) { + const id = await errorHandler(error, "announcement command"); + await interaction.reply({ + content: `An error occurred whilst processing your request. Error ID: \`${id}\``, + ephemeral: true, + }); + } +}; diff --git a/bot/src/events/interactionCreate.ts b/bot/src/events/interactionCreate.ts index f96fb7c..2fae47a 100644 --- a/bot/src/events/interactionCreate.ts +++ b/bot/src/events/interactionCreate.ts @@ -4,6 +4,7 @@ * @author Naomi Carrigan */ import { about } from "../commands/about.js"; +import { announcement } from "../commands/announcement.js"; import { dm } from "../commands/dm.js"; import { logger } from "../utils/logger.js"; import type { Command } from "../interfaces/command.js"; @@ -16,8 +17,9 @@ const handlers: { _default: Command } & Record = { ephemeral: true, }); }, - about: about, - dm: dm, + about: about, + announcement: announcement, + dm: dm, }; /** diff --git a/bot/src/events/modalInteractionCreate.ts b/bot/src/events/modalInteractionCreate.ts new file mode 100644 index 0000000..f2101d3 --- /dev/null +++ b/bot/src/events/modalInteractionCreate.ts @@ -0,0 +1,24 @@ +/** + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { handleAnnouncementModal } from "../modules/handleAnnouncementModal.js"; +import type { Client, ModalSubmitInteraction } from "discord.js"; + +/** + * Routes a modal submit interaction to the appropriate handler. + * @param _hikari - Hikari's Discord instance (unused). + * @param interaction - The modal submit interaction payload from Discord. + */ +const modalSubmitInteractionCreate = async( + _hikari: Client, + interaction: ModalSubmitInteraction, +): Promise => { + if (interaction.customId === "announcement_modal") { + await handleAnnouncementModal(interaction); + } +}; + +export { modalSubmitInteractionCreate }; diff --git a/bot/src/index.ts b/bot/src/index.ts index 721cf7a..a40b8b9 100644 --- a/bot/src/index.ts +++ b/bot/src/index.ts @@ -7,6 +7,9 @@ import { DiscordAnalytics } from "@nhcarrigan/discord-analytics"; import { Client, Events, GatewayIntentBits, Partials } from "discord.js"; import { chatInputInteractionCreate } from "./events/interactionCreate.js"; +import { + modalSubmitInteractionCreate, +} from "./events/modalInteractionCreate.js"; import { logger } from "./utils/logger.js"; /* @@ -51,6 +54,10 @@ hikari.once(Events.ClientReady, () => { hikari.on(Events.InteractionCreate, (interaction) => { if (interaction.isChatInputCommand()) { void chatInputInteractionCreate(hikari, interaction); + return; + } + if (interaction.isModalSubmit()) { + void modalSubmitInteractionCreate(hikari, interaction); } }); diff --git a/bot/src/modules/handleAnnouncementModal.ts b/bot/src/modules/handleAnnouncementModal.ts new file mode 100644 index 0000000..d931939 --- /dev/null +++ b/bot/src/modules/handleAnnouncementModal.ts @@ -0,0 +1,142 @@ +/** + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { AttachmentBuilder, type ModalSubmitInteraction } from "discord.js"; +import { errorHandler } from "../utils/errorHandler.js"; + +interface RawMarkdown { + content: string; + title: string; +} + +interface RawPost { + markdown: RawMarkdown; + plaintext: string; + threaded: Array; +} + +interface AnnouncementApiResponse { + alert: string; + cost: unknown; + message: string; + rawPost: RawPost; +} + +const isRawMarkdown = (value: unknown): value is RawMarkdown => { + if (typeof value !== "object" || value === null) { + return false; + } + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Necessary narrowing in type guard + const cast = value as Record; + return typeof cast.title === "string" && typeof cast.content === "string"; +}; + +const isRawPost = (value: unknown): value is RawPost => { + if (typeof value !== "object" || value === null) { + return false; + } + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Necessary narrowing in type guard + const cast = value as Record; + return ( + isRawMarkdown(cast.markdown) + && typeof cast.plaintext === "string" + && Array.isArray(cast.threaded) + ); +}; + +const isAnnouncementApiResponse = ( + value: unknown, +): value is AnnouncementApiResponse => { + if (typeof value !== "object" || value === null) { + return false; + } + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Necessary narrowing in type guard + const cast = value as Record; + return ( + typeof cast.message === "string" + && typeof cast.alert === "string" + && isRawPost(cast.rawPost) + ); +}; + +const buildAnnouncementFiles = (rawPost: RawPost): Array => { + const markdownFileContent = `# ${rawPost.markdown.title}\n\n${rawPost.markdown.content}`; + const threadedFileContent = rawPost.threaded.join("\n\n---\n\n"); + return [ + new AttachmentBuilder( + Buffer.from(markdownFileContent), + { name: "markdown.md" }, + ), + new AttachmentBuilder( + Buffer.from(rawPost.plaintext), + { name: "plaintext.txt" }, + ), + new AttachmentBuilder( + Buffer.from(threadedFileContent), + { name: "threaded.md" }, + ), + ]; +}; + +/** + * Handles the announcement modal submission. + * Calls the announcement API, sends the generated copy as file attachments + * to the owner's DMs, and replies ephemerally with the platform recap. + * @param interaction - The modal submit interaction payload from Discord. + */ +export const handleAnnouncementModal = async( + interaction: ModalSubmitInteraction, +): Promise => { + try { + await interaction.deferReply({ ephemeral: true }); + + const content = interaction.fields.getTextInputValue("content"); + const categoryValues = interaction.fields.getStringSelectValues("category"); + const type = categoryValues[0] ?? "company"; + + const response = await fetch("https://hikari.nhcarrigan.com/announcement", { + body: JSON.stringify({ content, type }), + headers: { + // eslint-disable-next-line @typescript-eslint/naming-convention -- HTTP header capitalisation convention + "Authorization": process.env.ANNOUNCEMENT_TOKEN ?? "", + // eslint-disable-next-line @typescript-eslint/naming-convention -- HTTP header naming convention + "Content-Type": "application/json", + }, + method: "POST", + }); + + if (!response.ok) { + await interaction.editReply({ + content: `The announcement server returned HTTP ${String(response.status)}.`, + }); + return; + } + + const body: unknown = await response.json(); + + if (!isAnnouncementApiResponse(body)) { + await interaction.editReply({ + // eslint-disable-next-line stylistic/max-len -- Error message needs sufficient context + content: "Received an unexpected response from the announcement server.", + }); + return; + } + + await interaction.user.send({ + content: "Here are the generated announcement files~", + files: buildAnnouncementFiles(body.rawPost), + }); + + await interaction.editReply({ + content: `**Announcement Recap**\n${body.message}\n\n⚠️ ${body.alert}`, + }); + } catch (error) { + const id = await errorHandler(error, "announcement modal"); + await interaction.editReply({ + content: `An error occurred whilst processing the announcement. Error ID: \`${id}\``, + }); + } +};