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:
parent
cb4aa0bf7e
commit
bfffb5fdd3
2 changed files with 138 additions and 31 deletions
|
|
@ -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."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue