1
0
Fork 0

Compare commits

...

5 commits

Author SHA1 Message Date
c204581366 v0.2.32 - announce modal channel picker, strict announce default, changelog and env docs 2026-09-10 23:58:11 -04:00
4f160c624f docs: trim the v0.2.30 changelog wording
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-10 23:58:08 -04:00
c12bccca7c chore(env): label the roll call and app updates channel vars
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-10 23:58:08 -04:00
0125cd6d4d docs(bot): note the modal channel picker and announce default
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-10 23:58:08 -04:00
ecdbcbd15b feat(bot): pick the target channel inside the announce modal
- The compose modal gains an optional channel select (text and
  announcement channels), preselected with the configured default
- /announce now posts only to the announce channel by default: the ops
  fallback is gone, so an unconfigured announce channel errors instead
  of silently posting to ops
- Roll call was already ops-only and is unchanged

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-10 23:58:03 -04:00
5 changed files with 44 additions and 20 deletions

View file

@ -53,7 +53,10 @@ APP_PORT=3000
# are optional.
# DISCORD_TOKEN=
# DISCORD_GUILD_ID=
# Roll call: mission attendance embeds and roll-call posts go here (ops channel).
# DISCORD_OPS_CHANNEL_ID=
# App updates: where /announce posts when no channel is given; also the
# channel preselected in the /announce compose dialog.
# DISCORD_ANNOUNCE_CHANNEL_ID=
# DISCORD_STAFF_ROLE_IDS=
# DISCORD_ATTENDANCE_POLL_MS=

View file

@ -2,22 +2,22 @@
# v0.2.30
A big run for the training grounds: OPORD Recall joins the lineup, you can build your own minefields and radio tests, the Chronicle grows story beats, and the dashboard bends to your will.
A big run for the training grounds: OPORD Recall joins the lineup, you can build your own minefields and radio tests, and the dashboard bends to your will.
### Features
- **OPORD Recall**: a new briefing-recall minigame. Study an Arma-style briefing (grids in the 034-672 format), then answer questions about what you read. Tiers scale to 10, 15, or 20 questions, drawing on garrison details, ROE, intel, and secondary objectives. Each answer unlocks behind a 2-second countdown with a draining timer bar, aborting mid-run offers six contingency options, and Esc or R resets at any time. Per-tier best times sit on the leaderboard, beating the par time pays a speed bonus, and an anti-cheat pass keeps results honest.
- **OPORD Recall**: a new briefing-recall minigame. Study an Arma-style briefing, then answer questions about what you read. Tiers scale to 10, 15, or 20 questions, drawing on garrison details, ROE, intel, and secondary objectives. Each answer unlocks behind a 2-second countdown with a draining timer bar, aborting mid-run offers six contingency options, and Esc or R resets at any time. Per-tier best times sit on the leaderboard, while beating the par time pays a speed bonus.
- **Custom minefields**: author and share your own EOD minefields, with play and leaderboard included. Clear two sectors in the regular game to unlock them. Runs pay reduced XP and a slice goes to the field's creator. Authors decide whether edge mines are exposed up front, hidden cells show a question-mark fog, and a loss reveals only part of the field.
- **Custom radio tests**: build your own radio traffic tests with the same play-and-leaderboard setup, unlocked through the radio game.
- **Chronicle story beats**: the Chronicle now carries structured story beats with reactions, tracks what you've already read, and has a chapter table of contents with hover previews.
- **Chronicle story beats**: the Chronicle now carries structured story beats with reactions, tracks what you've already read, and has a chapter table of contents.
- **Personal dashboard**: rearrange the dashboard with drag-and-drop widgets instead of a fixed layout.
- **Campaigns rework**: the campaigns page splits into official and community sections. Official campaigns get the tactical card treatment, glitch flourish included, and the list can hide completed campaigns.
- **Voiceover**: recording pages gate script spoilers behind a hold-to-confirm, script authors can keep drafts hidden from players, staff get an audio preview in the admin panel, and approved reviews now pay out XP and notify you.
- **Campaigns rework**: the campaigns page splits into official and community sections.
- **Voiceover**: recording pages gate script spoilers behind a hold-to-confirm and approved reviews now pay out XP and notify you.
- **Missions**: a code name is no longer required, and missions can carry a custom cover image.
### Improvements
- Side op uploads accept mod lists pasted as plain text or HTML, and the narrative section stays collapsed until you open it.
- Side op uploads accept mod lists pasted as plain text or HTML, and the narrative section stays collapsed until you open it. Briefing sections are no longer required unless you open that section.
- All minigames moved under `/minigames/*`. Old links redirect, so bookmarks keep working.
### Fixes

View file

@ -1,6 +1,6 @@
{
"name": "polaris-task-force",
"version": "0.2.31",
"version": "0.2.32",
"description": "Web app for the Arma 3 unit Polaris Task Force. Uses Next.js for client- and server-side rendering, with a Postgres database and some other neat libraries.",
"license": "MIT",
"type": "module",

View file

@ -18,7 +18,7 @@ bot/
ping.ts # /ping (global, DM-usable)
signup.ts # /signup (DM-only, creates Payload user with temp password)
link.ts # /link (global, links discordId to existing user)
announce.ts # /announce (guild-only, staff only; message omitted = compose modal, handler in this file)
announce.ts # /announce (guild-only, staff only; message omitted = compose modal with channel picker, handler in this file)
remindEvaluations.ts # /remind-evaluations (guild-only, staff only, manual evaluation-reminder trigger)
events/

View file

@ -1,7 +1,9 @@
import {
ActionRowBuilder,
ChannelSelectMenuBuilder,
ChannelType,
ComponentType,
EmbedBuilder,
LabelBuilder,
MessageFlags,
ModalBuilder,
SlashCommandBuilder,
@ -20,6 +22,7 @@ const ANNOUNCE_COLOR = 0xf59e0b;
const ANNOUNCE_MODAL_PREFIX = "ptf-announce:";
const TITLE_INPUT_ID = "ptf-announce-title";
const BODY_INPUT_ID = "ptf-announce-body";
const CHANNEL_INPUT_ID = "ptf-announce-channel";
/** Modal paragraph inputs cap at 4000 chars; the embed description limit is 4096. */
const MAX_BODY_LENGTH = 4000;
@ -33,6 +36,12 @@ const modalText = (fields: ModalSubmitInteraction["fields"], customId: string):
return field?.type === ComponentType.TextInput ? field.value : "";
};
const modalChannelId = (fields: ModalSubmitInteraction["fields"]): string | null => {
const field = fields.fields.get(CHANNEL_INPUT_ID);
if (field?.type !== ComponentType.ChannelSelect) return null;
return field.channels?.first()?.id ?? null;
};
const postAnnouncement = async (
channel: TextBasedChannelFields,
title: string,
@ -75,33 +84,33 @@ export const announceCommand: BotCommand = {
const message = (interaction.options.getString("message") ?? "").trim();
const option = interaction.options.getChannel("channel");
const channelId = option?.id ?? botConfig.announceChannelId ?? botConfig.opsChannelId;
const channelId = option?.id ?? botConfig.announceChannelId;
if (!channelId) {
await interaction.reply(
ephemeral("No announce channel is configured and no channel was given."),
ephemeral(
"No announce channel is configured (set DISCORD_ANNOUNCE_CHANNEL_ID) 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.
// customId carries the default channel as the fallback: modal submits
// don't carry the command's options.
const modal = new ModalBuilder()
.setCustomId(`${ANNOUNCE_MODAL_PREFIX}${channelId}`)
.setTitle("Post an announcement");
modal.addComponents(
new ActionRowBuilder<TextInputBuilder>().addComponents(
modal.addLabelComponents(
new LabelBuilder().setLabel("Title (optional)").setTextInputComponent(
new TextInputBuilder()
.setCustomId(TITLE_INPUT_ID)
.setLabel("Title (optional)")
.setStyle(TextInputStyle.Short)
.setMaxLength(256)
.setRequired(false),
),
new ActionRowBuilder<TextInputBuilder>().addComponents(
new LabelBuilder().setLabel("Announcement text").setTextInputComponent(
new TextInputBuilder()
.setCustomId(BODY_INPUT_ID)
.setLabel("Announcement text")
.setStyle(TextInputStyle.Paragraph)
.setMaxLength(MAX_BODY_LENGTH)
.setRequired(true)
@ -109,6 +118,16 @@ export const announceCommand: BotCommand = {
"Paste your announcement. Markdown (headings, lists, bold, code) is supported.",
),
),
new LabelBuilder()
.setLabel("Channel (optional)")
.setDescription("Defaults to the configured app updates channel.")
.setChannelSelectMenuComponent(
new ChannelSelectMenuBuilder()
.setCustomId(CHANNEL_INPUT_ID)
.addChannelTypes(ChannelType.GuildText, ChannelType.GuildAnnouncement)
.setDefaultChannels(channelId)
.setRequired(false),
),
);
await interaction.showModal(modal);
return;
@ -145,9 +164,11 @@ export const handleAnnounceModal = async (
}
// The modal custom id is "ptf-announce:<channelId>", written when the modal
// was opened from the slash command. Fall back to the configured channel.
// was opened. A channel picked in the modal overrides it.
const customChannelId = interaction.customId.slice(ANNOUNCE_MODAL_PREFIX.length);
const resolvedId = customChannelId || botConfig.announceChannelId || botConfig.opsChannelId;
const resolvedId =
modalChannelId(interaction.fields) ??
(customChannelId || botConfig.announceChannelId);
if (!resolvedId) {
await interaction.reply(ephemeral("No announce channel is configured."));
return;