diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000000..0f96a04ecc --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,67 @@ +# Changelog + +Notable changes to this fork (TitanBot-REAPR_01-), deployed self-hosted for a small +Discord gaming community. + +## 2026-08 — Deployment sync + `/postembed` + +These changes were first applied on the running deployment and are captured here to +bring the repository in sync with the deployed reality. + +### Added +- **`/postembed`** (`src/commands/Tools/postembed.js`) — post a Discord embed from + raw JSON. Accepts **either** a pasted `json` string option **or** a `.txt`/`.json` + file attachment (mutually exclusive; errors if neither or both are given). Handles + both a bare embed object `{...}` and a Discohook/webhook payload + `{"content": ..., "embeds": [...]}` (up to 10 embeds). + - Gated by **Manage Messages** at the Discord layer *and* re-checked in code + (server admins can override the default-member-permission, so the in-code check + is the real backstop). + - Mentions neutralized (`allowedMentions: { parse: [] }`) so a pasted payload can't + become an `@everyone`/role ping. + - Attachment path defers the reply (network I/O would blow the 3s interaction + deadline), validates extension + size (≤100 KB), and fetches via Node 20's + built-in `fetch`. All errors reply ephemerally. + +### Removed +- **Music commands** (`src/commands/Music/`: `join`, `music`, `nowplaying`, `play`, + `queue`) — unused by this community, removed to free slots against Discord's + 100 global-command limit. Verified no static imports referenced these files + (`src/interactions/` loads command components via dynamic globbing with per-file + try/catch), so removal is boot-safe; any orphaned music component handlers simply + go dormant because nothing spawns them. + +### Nuances / lessons encountered +- **ESM module contract.** This repo is ES Modules (`import` / `export default` with + `data` + `execute`), **not** CommonJS. Generic `require()/module.exports` embed + snippets from tutorials will silently fail to load. `category` is auto-derived from + the folder name, so dropping a file in `src/commands/Tools/` tags it "Tools". +- **Ephemeral replies use `flags: MessageFlags.Ephemeral`**, not the deprecated + `ephemeral: true`. +- **Global vs guild command registration.** The loader registers commands + **globally** (`PUT /applications/{clientId}/commands`) and truncates if the total + exceeds 100 — so freeing slots (Music removal) matters. Global commands can take + **up to ~1 hour** to propagate on first registration; a client restart (Ctrl+R) + usually surfaces a new command sooner. For an instant appearance during testing you + can PUT to the guild-scoped endpoint + (`/applications/{clientId}/guilds/{guildId}/commands`), which overrides the global + command of the same name (no duplicate); clear it later by PUTting `[]`. +- **Slash-string length.** The `json` string option is capped at 6000 characters by + Discord — the `.txt`/`.json` attachment path exists for larger multi-embed + payloads. + +## Deployment notes + +- Runs as one Docker Compose stack (bot + PostgreSQL) in an unprivileged Proxmox LXC + on an isolated VLAN (full outbound WAN to Discord, zero inbound, no LAN infra + reach). Health port bound to loopback only. +- Build **from this repository**, not the upstream GHCR image referenced in the + README (that is a different project's code). +- On a ZFS Proxmox host, Docker needs the `fuse-overlayfs` storage driver and the LXC + `fuse=1` feature (plus `nesting=1,keyctl=1`). +- The `docker-compose.override.yml` must use `ports: !override` — Compose merges + `ports:` lists additively, so without the tag the base `0.0.0.0:3000` mapping and + the loopback `127.0.0.1:3000` mapping collide ("address already in use"). +- Use a URL-safe Postgres password (`openssl rand -hex 24`) — a base64 password can + contain `/`, `+`, `@`, `:` that corrupt the `postgres://...` connection string and + silently drop the bot into in-memory degraded mode. diff --git a/src/commands/Music/join.js b/src/commands/Music/join.js deleted file mode 100644 index 10e6174103..0000000000 --- a/src/commands/Music/join.js +++ /dev/null @@ -1,21 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { joinVoiceChannel, replyMusicSuccess } from '../../services/music/musicActions.js'; -import { deferMusicCommand } from '../../services/music/prefixSupport.js'; - -export default { - category: 'Music', - data: new SlashCommandBuilder() - .setName('join') - .setDescription('Join your voice channel without starting playback'), - - async execute(interaction, config, client) { - const deferred = await deferMusicCommand(interaction); - if (!deferred) { - return; - } - - const embed = await joinVoiceChannel(client, interaction); - await replyMusicSuccess(interaction, embed); - }, -}; diff --git a/src/commands/Music/music.js b/src/commands/Music/music.js deleted file mode 100644 index fa8d7cfef6..0000000000 --- a/src/commands/Music/music.js +++ /dev/null @@ -1,188 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { - skipTrack, - stopPlayback, - pausePlayback, - resumePlayback, - shuffleQueue, - setLoopMode, - setVolume, - seekTrack, - removeFromQueue, - moveInQueue, - clearQueue, - setTwentyFourSeven, - leaveVoiceChannel, - replyMusicSuccess, -} from '../../services/music/musicActions.js'; -import { deferMusicCommand } from '../../services/music/prefixSupport.js'; - -export default { - category: 'Music', - data: new SlashCommandBuilder() - .setName('music') - .setDescription('Manage playback, queue, and voice session settings') - .addSubcommand((sub) => - sub.setName('pause').setDescription('Pause playback'), - ) - .addSubcommand((sub) => - sub.setName('resume').setDescription('Resume playback'), - ) - .addSubcommand((sub) => - sub.setName('skip').setDescription('Skip the current track'), - ) - .addSubcommand((sub) => - sub.setName('stop').setDescription('Stop playback and clear the queue'), - ) - .addSubcommand((sub) => - sub.setName('shuffle').setDescription('Shuffle the queue'), - ) - .addSubcommand((sub) => - sub - .setName('loop') - .setDescription('Set loop mode') - .addStringOption((opt) => - opt - .setName('mode') - .setDescription('Loop mode') - .setRequired(true) - .addChoices( - { name: 'Off', value: 'none' }, - { name: 'Track', value: 'track' }, - { name: 'Queue', value: 'queue' }, - ), - ), - ) - .addSubcommand((sub) => - sub - .setName('volume') - .setDescription('Set playback volume') - .addIntegerOption((opt) => - opt.setName('level').setDescription('Volume (0-100)').setRequired(true).setMinValue(0).setMaxValue(100), - ), - ) - .addSubcommand((sub) => - sub - .setName('seek') - .setDescription('Seek to a position in the current track') - .addIntegerOption((opt) => - opt.setName('seconds').setDescription('Position in seconds').setRequired(true).setMinValue(0), - ), - ) - .addSubcommand((sub) => - sub - .setName('remove') - .setDescription('Remove a track from the queue') - .addIntegerOption((opt) => - opt.setName('position').setDescription('Queue position').setRequired(true).setMinValue(1), - ), - ) - .addSubcommand((sub) => - sub - .setName('move') - .setDescription('Move a track in the queue') - .addIntegerOption((opt) => - opt.setName('from').setDescription('Current position').setRequired(true).setMinValue(1), - ) - .addIntegerOption((opt) => - opt.setName('to').setDescription('New position').setRequired(true).setMinValue(1), - ), - ) - .addSubcommand((sub) => - sub.setName('clear').setDescription('Clear the queue'), - ) - .addSubcommand((sub) => - sub.setName('leave').setDescription('Disconnect the bot from the voice channel'), - ) - .addSubcommand((sub) => - sub - .setName('247') - .setDescription('Toggle 24/7 mode (stay in voice channel when idle)') - .addBooleanOption((opt) => - opt.setName('enabled').setDescription('Enable or disable 24/7 mode').setRequired(true), - ), - ), - - async execute(interaction, config, client) { - await deferMusicCommand(interaction); - const subcommand = interaction.options.getSubcommand(); - - switch (subcommand) { - case 'pause': { - const embed = await pausePlayback(client, interaction); - await replyMusicSuccess(interaction, embed); - break; - } - case 'resume': { - const embed = await resumePlayback(client, interaction); - await replyMusicSuccess(interaction, embed); - break; - } - case 'skip': { - const embed = await skipTrack(client, interaction); - await replyMusicSuccess(interaction, embed); - break; - } - case 'stop': { - const embed = await stopPlayback(client, interaction); - await replyMusicSuccess(interaction, embed); - break; - } - case 'shuffle': { - const embed = await shuffleQueue(client, interaction); - await replyMusicSuccess(interaction, embed); - break; - } - case 'loop': { - const embed = await setLoopMode(client, interaction, interaction.options.getString('mode')); - await replyMusicSuccess(interaction, embed); - break; - } - case 'volume': { - const embed = await setVolume(client, interaction, interaction.options.getInteger('level')); - await replyMusicSuccess(interaction, embed); - break; - } - case 'seek': { - const embed = await seekTrack(client, interaction, interaction.options.getInteger('seconds')); - await replyMusicSuccess(interaction, embed); - break; - } - case 'remove': { - const embed = await removeFromQueue(client, interaction, interaction.options.getInteger('position')); - await replyMusicSuccess(interaction, embed); - break; - } - case 'move': { - const embed = await moveInQueue( - client, - interaction, - interaction.options.getInteger('from'), - interaction.options.getInteger('to'), - ); - await replyMusicSuccess(interaction, embed); - break; - } - case 'clear': { - const embed = await clearQueue(client, interaction); - await replyMusicSuccess(interaction, embed); - break; - } - case 'leave': { - const embed = await leaveVoiceChannel(client, interaction); - await replyMusicSuccess(interaction, embed); - break; - } - case '247': { - const embed = await setTwentyFourSeven(client, interaction, interaction.options.getBoolean('enabled')); - await replyMusicSuccess(interaction, embed); - break; - } - default: - await InteractionHelper.safeEditReply(interaction, { - content: 'Unknown music subcommand.', - }); - } - }, -}; diff --git a/src/commands/Music/nowplaying.js b/src/commands/Music/nowplaying.js deleted file mode 100644 index e50c685122..0000000000 --- a/src/commands/Music/nowplaying.js +++ /dev/null @@ -1,17 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { buildNowPlayingReply } from '../../services/music/musicActions.js'; -import { deferMusicCommand } from '../../services/music/prefixSupport.js'; - -export default { - category: 'Music', - data: new SlashCommandBuilder() - .setName('nowplaying') - .setDescription('Show the currently playing track'), - - async execute(interaction, config, client) { - await deferMusicCommand(interaction); - const payload = buildNowPlayingReply(client, interaction.guild.id); - await InteractionHelper.safeEditReply(interaction, payload); - }, -}; diff --git a/src/commands/Music/play.js b/src/commands/Music/play.js deleted file mode 100644 index 1d07665382..0000000000 --- a/src/commands/Music/play.js +++ /dev/null @@ -1,24 +0,0 @@ -import { SlashCommandBuilder, MessageFlags } from 'discord.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { playQuery, replyMusicSuccess } from '../../services/music/musicActions.js'; - -export default { - slashOnly: true, - category: 'Music', - data: new SlashCommandBuilder() - .setName('play') - .setDescription('Play a song or add it to the queue') - .addStringOption((opt) => - opt.setName('query').setDescription('Song name or URL').setRequired(true), - ), - - async execute(interaction, config, client) { - const deferred = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); - if (!deferred) { - return; - } - - const result = await playQuery(client, interaction, interaction.options.getString('query')); - await replyMusicSuccess(interaction, result.embed); - }, -}; diff --git a/src/commands/Music/queue.js b/src/commands/Music/queue.js deleted file mode 100644 index 66faa4eb9d..0000000000 --- a/src/commands/Music/queue.js +++ /dev/null @@ -1,24 +0,0 @@ -import { SlashCommandBuilder, MessageFlags } from 'discord.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { buildQueueReply } from '../../services/music/musicActions.js'; - -export default { - slashOnly: true, - category: 'Music', - data: new SlashCommandBuilder() - .setName('queue') - .setDescription('Show the current music queue') - .addIntegerOption((opt) => - opt.setName('page').setDescription('Page number').setMinValue(1), - ), - - async execute(interaction, config, client) { - await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); - const page = (interaction.options.getInteger('page') || 1) - 1; - const payload = buildQueueReply(client, interaction.guild.id, page); - await InteractionHelper.safeEditReply(interaction, { - embeds: payload.embeds, - components: payload.components, - }); - }, -}; diff --git a/src/commands/Tools/postembed.js b/src/commands/Tools/postembed.js new file mode 100644 index 0000000000..bf55bb3164 --- /dev/null +++ b/src/commands/Tools/postembed.js @@ -0,0 +1,110 @@ +import { + SlashCommandBuilder, + PermissionFlagsBits, + EmbedBuilder, + MessageFlags, +} from 'discord.js'; + +const MAX_JSON_BYTES = 100_000; // embeds are tiny; reject anything larger + +export default { + slashOnly: true, + data: new SlashCommandBuilder() + .setName('postembed') + .setDescription('Post an embed from raw JSON - paste it, or attach a .txt/.json file') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages) + .addStringOption(o => + o.setName('json') + .setDescription('Embed JSON: a single embed object or {"content":..., "embeds":[...]}') + .setRequired(false)) + .addAttachmentOption(o => + o.setName('file') + .setDescription('A .txt or .json file with the embed JSON (alternative to the json option)') + .setRequired(false)), + + async execute(interaction) { + // Defense-in-depth: setDefaultMemberPermissions can be overridden in Server Settings. + if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageMessages)) { + return interaction.reply({ content: '⛔ You need the **Manage Messages** permission to use this.', flags: MessageFlags.Ephemeral }); + } + if (!interaction.channel) { + return interaction.reply({ content: '❌ Run this in a normal server text channel.', flags: MessageFlags.Ephemeral }); + } + + const jsonOpt = interaction.options.getString('json'); + const fileOpt = interaction.options.getAttachment('file'); + + if (!jsonOpt && !fileOpt) { + return interaction.reply({ content: '❌ Provide either the `json` text option or a `file` attachment.', flags: MessageFlags.Ephemeral }); + } + if (jsonOpt && fileOpt) { + return interaction.reply({ content: '❌ Provide only one of `json` or `file`, not both.', flags: MessageFlags.Ephemeral }); + } + + // Reading an attachment is network I/O -> defer so we don't hit the 3s interaction timeout. + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + let raw; + if (fileOpt) { + const name = (fileOpt.name || '').toLowerCase(); + if (!name.endsWith('.txt') && !name.endsWith('.json')) { + return interaction.editReply({ content: '❌ Attachment must be a `.txt` or `.json` file.' }); + } + if (fileOpt.size > MAX_JSON_BYTES) { + return interaction.editReply({ content: `❌ File too large (${fileOpt.size} bytes; max ${MAX_JSON_BYTES}).` }); + } + try { + const res = await fetch(fileOpt.url); + if (!res.ok) { + return interaction.editReply({ content: `❌ Couldn't download the attachment (HTTP ${res.status}).` }); + } + raw = await res.text(); + } catch (err) { + return interaction.editReply({ content: `❌ Couldn't read the attachment: ${err.message}` }); + } + } else { + raw = jsonOpt; + } + + // Parse + let parsed; + try { + parsed = JSON.parse(raw); + } catch (err) { + return interaction.editReply({ content: `❌ Invalid JSON: ${err.message}` }); + } + + // Normalize to an embeds array (accept Discohook/webhook payloads or a bare embed object) + let embedObjects; + let content; + if (parsed && Array.isArray(parsed.embeds)) { + embedObjects = parsed.embeds; + if (typeof parsed.content === 'string' && parsed.content.trim().length > 0) content = parsed.content; + } else if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + embedObjects = [parsed]; + } else { + return interaction.editReply({ content: '❌ JSON must be an embed object, or an object with an `embeds` array.' }); + } + + if (embedObjects.length === 0) return interaction.editReply({ content: '❌ No embed found in the JSON.' }); + if (embedObjects.length > 10) return interaction.editReply({ content: `❌ Discord allows at most 10 embeds per message (you provided ${embedObjects.length}).` }); + + // Build the embeds (final validation happens at send) + let embeds; + try { + embeds = embedObjects.map(o => EmbedBuilder.from(o)); + } catch (err) { + return interaction.editReply({ content: `❌ Invalid embed structure: ${err.message}` }); + } + + // Post to the current channel. allowedMentions neutralized so a crafted + // payload can't @everyone/@here/ping roles via the content field. + try { + await interaction.channel.send({ content, embeds, allowedMentions: { parse: [] } }); + } catch (err) { + return interaction.editReply({ content: `❌ Couldn't post here: ${err.message}\n(Confirm I have **Send Messages** + **Embed Links** in this channel.)` }); + } + + return interaction.editReply({ content: '✅ Embed posted.' }); + }, +};