From bfffb5fdd3e14037a29e73c360f0ace781b8baf3 Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Thu, 10 Sep 2026 21:33:55 -0400 Subject: [PATCH] feat(bot): compose announcements in a paste-friendly modal - /announce with no message opens a modal: optional title field plus a 4000-char paragraph body for multi-line markdown - Target channel rides in the modal customId; staff is re-checked on submit and the post goes out as the same amber Announcement embed - Inline message flow unchanged; modal submits route under ptf-announce: Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/bot/commands/announce.ts | 164 ++++++++++++++++++++++++++++------- src/bot/index.ts | 5 ++ 2 files changed, 138 insertions(+), 31 deletions(-) diff --git a/src/bot/commands/announce.ts b/src/bot/commands/announce.ts index 2b566ce..13027e9 100644 --- a/src/bot/commands/announce.ts +++ b/src/bot/commands/announce.ts @@ -1,78 +1,180 @@ import { + ActionRowBuilder, + ComponentType, EmbedBuilder, MessageFlags, + ModalBuilder, SlashCommandBuilder, + TextInputBuilder, + TextInputStyle, + type InteractionReplyOptions, + type ModalSubmitInteraction, type TextBasedChannelFields, } from "discord.js"; +import type { Payload } from "payload"; import type { BotCommand } from "./index"; import { botConfig } from "@/bot/config"; import { isStaff } from "@/bot/lib/roles"; -import { hasPermission } from "@/utils/access-control/hasPermission"; const ANNOUNCE_COLOR = 0xf59e0b; +const ANNOUNCE_MODAL_PREFIX = "ptf-announce:"; +const TITLE_INPUT_ID = "ptf-announce-title"; +const BODY_INPUT_ID = "ptf-announce-body"; +/** Modal paragraph inputs cap at 4000 chars; the embed description limit is 4096. */ +const MAX_BODY_LENGTH = 4000; + +const ephemeral = (content: string): InteractionReplyOptions => ({ + content, + flags: MessageFlags.Ephemeral, +}); + +const modalText = (fields: ModalSubmitInteraction["fields"], customId: string): string => { + const field = fields.fields.get(customId); + return field?.type === ComponentType.TextInput ? field.value : ""; +}; + +const postAnnouncement = async ( + channel: TextBasedChannelFields, + title: string, + message: string, +): Promise => { + await channel.send({ + embeds: [ + new EmbedBuilder() + .setTitle(title || "Announcement") + .setDescription(message) + .setColor(ANNOUNCE_COLOR), + ], + }); +}; export const announceCommand: BotCommand = { builder: new SlashCommandBuilder() .setName("announce") .setDescription("Post an announcement to a channel") .addStringOption((option) => - option.setName("message").setDescription("The announcement text").setRequired(true), + option + .setName("message") + .setDescription("Announcement text, or leave blank to compose in a dialog") + .setRequired(false), ) .addChannelOption((option) => option.setName("channel").setDescription("Channel to post to"), ) as SlashCommandBuilder, execute: async (interaction, payload) => { if (!interaction.inGuild() || !interaction.guild) { - await interaction.reply({ - content: "This command only works in a server.", - flags: MessageFlags.Ephemeral, - }); + await interaction.reply(ephemeral("This command only works in a server.")); return; } const member = await interaction.guild.members.fetch(interaction.user.id); if (!(await isStaff(member, payload))) { - await interaction.reply({ - content: "You don't have permission to announce.", - flags: MessageFlags.Ephemeral, - }); + await interaction.reply(ephemeral("You don't have permission to announce.")); return; } - const message = interaction.options.getString("message", true); + const message = (interaction.options.getString("message") ?? "").trim(); const option = interaction.options.getChannel("channel"); const channelId = option?.id ?? botConfig.announceChannelId ?? botConfig.opsChannelId; if (!channelId) { - await interaction.reply({ - content: "No announce channel is configured and no channel was given.", - flags: MessageFlags.Ephemeral, - }); + await interaction.reply( + ephemeral("No announce channel is configured and no channel was given."), + ); + return; + } + + if (!message) { + // Compose in a modal. The resolved channel rides along in the custom id, + // because modal submit interactions do not carry the command's options. + const modal = new ModalBuilder() + .setCustomId(`${ANNOUNCE_MODAL_PREFIX}${channelId}`) + .setTitle("Post an announcement"); + modal.addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId(TITLE_INPUT_ID) + .setLabel("Title (optional)") + .setStyle(TextInputStyle.Short) + .setMaxLength(256) + .setRequired(false), + ), + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId(BODY_INPUT_ID) + .setLabel("Announcement text") + .setStyle(TextInputStyle.Paragraph) + .setMaxLength(MAX_BODY_LENGTH) + .setRequired(true) + .setPlaceholder( + "Paste your announcement. Markdown (headings, lists, bold, code) is supported.", + ), + ), + ); + await interaction.showModal(modal); return; } 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.", - flags: MessageFlags.Ephemeral, - }); + await interaction.reply(ephemeral("That channel isn't a text channel.")); return; } - await (channel as TextBasedChannelFields).send({ - embeds: [ - new EmbedBuilder() - .setTitle("Announcement") - .setDescription(message) - .setColor(ANNOUNCE_COLOR), - ], - }); + await postAnnouncement(channel as TextBasedChannelFields, "Announcement", message); const name = (channel as unknown as { name?: string | null }).name ?? channelId; - await interaction.reply({ - content: `Announcement posted to #${name}.`, - flags: MessageFlags.Ephemeral, - }); + await interaction.reply(ephemeral(`Announcement posted to #${name}.`)); }, }; + +export const handleAnnounceModal = async ( + interaction: ModalSubmitInteraction, + payload: Payload, +): Promise => { + try { + if (!interaction.inGuild() || !interaction.guild) { + await interaction.reply(ephemeral("This command only works in a server.")); + return; + } + + const member = await interaction.guild.members.fetch(interaction.user.id); + if (!(await isStaff(member, payload))) { + await interaction.reply(ephemeral("You don't have permission to announce.")); + return; + } + + // The modal custom id is "ptf-announce:", written when the modal + // was opened from the slash command. Fall back to the configured channel. + const customChannelId = interaction.customId.slice(ANNOUNCE_MODAL_PREFIX.length); + const resolvedId = customChannelId || botConfig.announceChannelId || botConfig.opsChannelId; + if (!resolvedId) { + await interaction.reply(ephemeral("No announce channel is configured.")); + return; + } + + const channel = await interaction.guild.channels.fetch(resolvedId); + if (!channel || !("send" in channel)) { + await interaction.reply(ephemeral("That channel isn't a text channel.")); + return; + } + + const title = modalText(interaction.fields, TITLE_INPUT_ID).trim(); + const message = modalText(interaction.fields, BODY_INPUT_ID).trim(); + if (!message) { + await interaction.reply(ephemeral("The announcement text was empty.")); + return; + } + + await postAnnouncement(channel as TextBasedChannelFields, title, message); + + const name = (channel as unknown as { name?: string | null }).name ?? resolvedId; + await interaction.reply(ephemeral(`Announcement posted to #${name}.`)); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + payload.logger.error(`[Bot] Announce modal failed: ${message}`); + if (!interaction.replied && !interaction.deferred) { + await interaction.reply(ephemeral("Something went wrong while posting the announcement.")); + } + } +}; diff --git a/src/bot/index.ts b/src/bot/index.ts index a78e5ae..adddd3c 100644 --- a/src/bot/index.ts +++ b/src/bot/index.ts @@ -3,6 +3,7 @@ import { getPayload } from "payload"; import config from "@payload-config"; import { botConfig } from "./config"; import { commands } from "./commands"; +import { handleAnnounceModal } from "./commands/announce"; import { services } from "./services"; import { handleButtonInteraction } from "./events/interactionCreate"; import { handleTransferInteraction } from "./events/transferInteractions"; @@ -80,6 +81,10 @@ const main = async () => { await handleTransferInteraction(interaction, payload); return; } + if (interaction.isModalSubmit() && interaction.customId.startsWith("ptf-announce:")) { + await handleAnnounceModal(interaction, payload); + return; + } if (!interaction.isChatInputCommand()) { return; }