1
0
Fork 0

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 <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Jason Fraley 2026-09-10 21:33:55 -04:00
parent cb4aa0bf7e
commit bfffb5fdd3
2 changed files with 138 additions and 31 deletions

View file

@ -1,78 +1,180 @@
import { import {
ActionRowBuilder,
ComponentType,
EmbedBuilder, EmbedBuilder,
MessageFlags, MessageFlags,
ModalBuilder,
SlashCommandBuilder, SlashCommandBuilder,
TextInputBuilder,
TextInputStyle,
type InteractionReplyOptions,
type ModalSubmitInteraction,
type TextBasedChannelFields, type TextBasedChannelFields,
} from "discord.js"; } from "discord.js";
import type { Payload } from "payload";
import type { BotCommand } from "./index"; import type { BotCommand } from "./index";
import { botConfig } from "@/bot/config"; import { botConfig } from "@/bot/config";
import { isStaff } from "@/bot/lib/roles"; import { isStaff } from "@/bot/lib/roles";
import { hasPermission } from "@/utils/access-control/hasPermission";
const ANNOUNCE_COLOR = 0xf59e0b; 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<void> => {
await channel.send({
embeds: [
new EmbedBuilder()
.setTitle(title || "Announcement")
.setDescription(message)
.setColor(ANNOUNCE_COLOR),
],
});
};
export const announceCommand: BotCommand = { export const announceCommand: BotCommand = {
builder: new SlashCommandBuilder() builder: new SlashCommandBuilder()
.setName("announce") .setName("announce")
.setDescription("Post an announcement to a channel") .setDescription("Post an announcement to a channel")
.addStringOption((option) => .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) => .addChannelOption((option) =>
option.setName("channel").setDescription("Channel to post to"), option.setName("channel").setDescription("Channel to post to"),
) as SlashCommandBuilder, ) as SlashCommandBuilder,
execute: async (interaction, payload) => { execute: async (interaction, payload) => {
if (!interaction.inGuild() || !interaction.guild) { if (!interaction.inGuild() || !interaction.guild) {
await interaction.reply({ await interaction.reply(ephemeral("This command only works in a server."));
content: "This command only works in a server.",
flags: MessageFlags.Ephemeral,
});
return; return;
} }
const member = await interaction.guild.members.fetch(interaction.user.id); const member = await interaction.guild.members.fetch(interaction.user.id);
if (!(await isStaff(member, payload))) { if (!(await isStaff(member, payload))) {
await interaction.reply({ await interaction.reply(ephemeral("You don't have permission to announce."));
content: "You don't have permission to announce.",
flags: MessageFlags.Ephemeral,
});
return; return;
} }
const message = interaction.options.getString("message", true); const message = (interaction.options.getString("message") ?? "").trim();
const option = interaction.options.getChannel("channel"); const option = interaction.options.getChannel("channel");
const channelId = option?.id ?? botConfig.announceChannelId ?? botConfig.opsChannelId; const channelId = option?.id ?? botConfig.announceChannelId ?? botConfig.opsChannelId;
if (!channelId) { if (!channelId) {
await interaction.reply({ await interaction.reply(
content: "No announce channel is configured and no channel was given.", ephemeral("No announce channel is configured and no channel was given."),
flags: MessageFlags.Ephemeral, );
}); 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<TextInputBuilder>().addComponents(
new TextInputBuilder()
.setCustomId(TITLE_INPUT_ID)
.setLabel("Title (optional)")
.setStyle(TextInputStyle.Short)
.setMaxLength(256)
.setRequired(false),
),
new ActionRowBuilder<TextInputBuilder>().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; return;
} }
const optionChannel = option && "send" in option ? (option as TextBasedChannelFields) : null; const optionChannel = option && "send" in option ? (option as TextBasedChannelFields) : null;
const channel = optionChannel ?? (await interaction.guild.channels.fetch(channelId)); const channel = optionChannel ?? (await interaction.guild.channels.fetch(channelId));
if (!channel || !("send" in channel)) { if (!channel || !("send" in channel)) {
await interaction.reply({ await interaction.reply(ephemeral("That channel isn't a text channel."));
content: "That channel isn't a text channel.",
flags: MessageFlags.Ephemeral,
});
return; return;
} }
await (channel as TextBasedChannelFields).send({ await postAnnouncement(channel as TextBasedChannelFields, "Announcement", message);
embeds: [
new EmbedBuilder()
.setTitle("Announcement")
.setDescription(message)
.setColor(ANNOUNCE_COLOR),
],
});
const name = (channel as unknown as { name?: string | null }).name ?? channelId; const name = (channel as unknown as { name?: string | null }).name ?? channelId;
await interaction.reply({ await interaction.reply(ephemeral(`Announcement posted to #${name}.`));
content: `Announcement posted to #${name}.`,
flags: MessageFlags.Ephemeral,
});
}, },
}; };
export const handleAnnounceModal = async (
interaction: ModalSubmitInteraction,
payload: Payload,
): Promise<void> => {
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:<channelId>", 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."));
}
}
};

View file

@ -3,6 +3,7 @@ import { getPayload } from "payload";
import config from "@payload-config"; import config from "@payload-config";
import { botConfig } from "./config"; import { botConfig } from "./config";
import { commands } from "./commands"; import { commands } from "./commands";
import { handleAnnounceModal } from "./commands/announce";
import { services } from "./services"; import { services } from "./services";
import { handleButtonInteraction } from "./events/interactionCreate"; import { handleButtonInteraction } from "./events/interactionCreate";
import { handleTransferInteraction } from "./events/transferInteractions"; import { handleTransferInteraction } from "./events/transferInteractions";
@ -80,6 +81,10 @@ const main = async () => {
await handleTransferInteraction(interaction, payload); await handleTransferInteraction(interaction, payload);
return; return;
} }
if (interaction.isModalSubmit() && interaction.customId.startsWith("ptf-announce:")) {
await handleAnnounceModal(interaction, payload);
return;
}
if (!interaction.isChatInputCommand()) { if (!interaction.isChatInputCommand()) {
return; return;
} }