From 75c9feecec4123dc1bbe28326280d5bc137fdaf8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Apr 2026 22:48:44 +0000 Subject: [PATCH] Add Code bridge request: Arbiter native Discord role management --- .../REQ-2026-04-13-arbiter-discord-roles.md | 323 ++++++++++++++++++ 1 file changed, 323 insertions(+) create mode 100644 docs/code-bridge/requests/REQ-2026-04-13-arbiter-discord-roles.md diff --git a/docs/code-bridge/requests/REQ-2026-04-13-arbiter-discord-roles.md b/docs/code-bridge/requests/REQ-2026-04-13-arbiter-discord-roles.md new file mode 100644 index 0000000..153444d --- /dev/null +++ b/docs/code-bridge/requests/REQ-2026-04-13-arbiter-discord-roles.md @@ -0,0 +1,323 @@ +# Feature Request: Arbiter Native Discord Role Management + +**Date:** 2026-04-13 +**Topic:** Replace Carlbot with native Arbiter button roles, welcome messages, and automated get-roles message lifecycle +**Priority:** POST-LAUNCH — build and test in parallel with Carlbot, cut over week of April 20 +**Filed by:** Chronicler #86 + +--- + +## Background + +Carlbot currently handles three things for Firefrost Gaming: +1. Welcome messages on member join +2. Reaction roles in #get-roles +3. Wanderer role assignment on join + +Every time a Minecraft server is created (/createserver) or deleted (/delserver), staff must manually update Carlbot's reaction role config and add/remove emoji from the #get-roles message. This is error-prone and completely automatable. + +**Goal:** Arbiter owns all three functions natively. When Pterodactyl fires a server lifecycle webhook, the #get-roles message updates automatically — no manual steps. + +**Architecture review:** Two rounds of Gemini consultation completed. +- docs/consultations/gemini-arbiter-discord-roles-round-1-2026-04-13.md (in ops manual) +- docs/consultations/gemini-arbiter-discord-roles-round-2-2026-04-13.md (in ops manual) + +--- + +## 1. New File: src/discord/client.js + +Module-level singleton for the discord.js Client. Shared across the entire app. + +```javascript +const { Client, GatewayIntentBits } = require('discord.js'); + +const client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMembers, + GatewayIntentBits.GuildMessages, + ] +}); + +client.once('ready', () => { + console.log(`[Discord] Gateway connected as ${client.user.tag}`); +}); + +module.exports = client; +``` + +--- + +## 2. Modify: src/index.js (startup) + +Start Express and Discord concurrently — do NOT block Express on Discord ready. + +```javascript +const client = require('./discord/client'); +const discordEvents = require('./discord/events'); + +discordEvents.register(client); + +app.listen(PORT, () => { + console.log(`[Arbiter] Express listening on port ${PORT}`); +}); + +client.login(process.env.DISCORD_BOT_TOKEN).catch(err => { + console.error('[Discord] Gateway login failed:', err); +}); +``` + +If a Pterodactyl webhook fires before Gateway is ready, check `client.isReady()` and return 503 if not. + +--- + +## 3. New File: src/discord/events.js + +```javascript +const { sendWelcomeMessage } = require('./welcome'); +const { handleInteraction } = require('./interactions'); + +function register(client) { + client.on('guildMemberAdd', async (member) => { + if (process.env.WELCOME_MESSAGES_ENABLED !== 'true') return; + await sendWelcomeMessage(member); + }); + + client.on('interactionCreate', async (interaction) => { + await handleInteraction(interaction); + }); +} + +module.exports = { register }; +``` + +--- + +## 4. New File: src/discord/welcome.js + +```javascript +const WELCOME_CHANNEL_ID = process.env.DISCORD_WELCOME_CHANNEL_ID; + +async function sendWelcomeMessage(member) { + try { + const channel = await member.guild.channels.fetch(WELCOME_CHANNEL_ID); + if (!channel) return; + await channel.send({ + embeds: [{ + title: `Welcome to Firefrost Gaming, ${member.user.username}! 🔥❄️`, + description: `Head to <#${process.env.DISCORD_GET_ROLES_CHANNEL_ID}> to grab your server roles!`, + color: 0xFF6B35, + thumbnail: { url: member.user.displayAvatarURL() }, + timestamp: new Date().toISOString(), + }] + }); + } catch (err) { + console.error('[Welcome] Failed to send welcome message:', err); + } +} + +module.exports = { sendWelcomeMessage }; +``` + +--- + +## 5. New File: src/discord/getRolesMessage.js + +Manages the persistent #get-roles button message. Called by webhook.js on /createserver and /delserver. + +```javascript +const client = require('./client'); +const db = require('../db'); + +const GET_ROLES_CHANNEL_ID = process.env.DISCORD_GET_ROLES_CHANNEL_ID; + +async function updateGetRolesMessage(servers) { + if (!client.isReady()) { + console.warn('[GetRoles] Discord client not ready — skipping'); + return; + } + + const channel = await client.channels.fetch(GET_ROLES_CHANNEL_ID); + const embed = buildEmbed(servers); + const components = buildButtons(servers); + const storedMessageId = await db.getSetting('discord_get_roles_message_id'); + + if (storedMessageId) { + try { + await channel.messages.edit(storedMessageId, { embeds: [embed], components }); + return; + } catch (err) { + if (err.code !== 10008) throw err; + console.warn('[GetRoles] Stored message not found, reposting...'); + } + } + + const message = await channel.send({ embeds: [embed], components }); + await db.setSetting('discord_get_roles_message_id', message.id); +} + +function buildEmbed(servers) { + return { + title: '🎮 Choose Your Servers', + description: servers.length > 0 + ? 'Click a button to get access to a server channel. Click again to remove it.' + : 'No servers are currently active.', + color: 0x4ECDC4, + footer: { text: 'Firefrost Gaming — Role Assignment' }, + timestamp: new Date().toISOString(), + }; +} + +function buildButtons(servers) { + if (servers.length === 0) return []; + const { ActionRowBuilder, ButtonBuilder, ButtonStyle } = require('discord.js'); + const rows = []; + let currentRow = new ActionRowBuilder(); + let count = 0; + + for (const server of servers) { + if (count > 0 && count % 5 === 0) { + rows.push(currentRow); + currentRow = new ActionRowBuilder(); + } + currentRow.addComponents( + new ButtonBuilder() + .setCustomId(`toggle_role_${server.roleId}`) + .setLabel(server.name) + .setStyle(ButtonStyle.Secondary) + .setEmoji(server.emoji || '🎮') + ); + count++; + } + + if (count % 5 !== 0 || count === 0) rows.push(currentRow); + return rows; +} + +module.exports = { updateGetRolesMessage }; +``` + +--- + +## 6. New File: src/discord/interactions.js + +```javascript +async function handleInteraction(interaction) { + if (!interaction.isButton()) return; + if (interaction.customId.startsWith('toggle_role_')) { + await handleRoleToggle(interaction); + } +} + +async function handleRoleToggle(interaction) { + await interaction.deferReply({ ephemeral: true }); + const roleId = interaction.customId.replace('toggle_role_', ''); + const member = interaction.member; + const guild = interaction.guild; + + try { + const role = await guild.roles.fetch(roleId); + if (!role) { + await interaction.editReply({ content: '⚠️ Role not found. Please contact an admin.' }); + return; + } + const hasRole = member.roles.cache.has(roleId); + if (hasRole) { + await member.roles.remove(role); + await interaction.editReply({ content: `✅ Removed the **${role.name}** role.` }); + } else { + await member.roles.add(role); + await interaction.editReply({ content: `✅ You now have the **${role.name}** role!` }); + } + } catch (err) { + console.error('[Interactions] Role toggle failed:', err); + await interaction.editReply({ content: '⚠️ Something went wrong. Please try again.' }); + } +} + +module.exports = { handleInteraction }; +``` + +--- + +## 7. Modify: src/routes/webhook.js + +After successful server creation or deletion, call updateGetRolesMessage with the current active server list. + +--- + +## 8. Database: Settings Table + +```sql +CREATE TABLE IF NOT EXISTS settings ( + key VARCHAR(255) PRIMARY KEY, + value TEXT +); +``` + +Add getSetting/setSetting helpers to DB module. + +--- + +## 9. New .env Variables + +``` +DISCORD_GET_ROLES_CHANNEL_ID=1403980899464384572 +DISCORD_WELCOME_CHANNEL_ID=1403980049530490911 +WELCOME_MESSAGES_ENABLED=false +``` + +--- + +## 10. Server Role ID Reference + +| Server Name | Role ID | +|-------------|---------| +| All The Mods: To the Sky | 1491028496284258304 | +| Stoneblock 4 | 1491028769132253274 | +| Society: Sunlit Valley | 1491028885981102270 | +| All The Mons | 1491029000108380170 | +| Mythcraft 5 | 1491029070190870548 | +| Beyond Depth | 1491029215963906149 | +| Beyond Ascension | 1491029284159094904 | +| Wold's Vaults | 1491029373640376330 | +| Otherworld [Dungeons & Dragons] | 1491029454011629749 | +| DeceasedCraft | 1491029615739801800 | +| Submerged 2 | 1491029708878647356 | +| Sneak's Pirate Pack | 1491029809273508112 | +| Cottage Witch | 1491029870002569298 | +| Homestead | 1491030015746510939 | +| Farm Crossing 6 | 1493352900997415134 | + +--- + +## 11. Carlbot Cutover Sequence (Week of April 20) + +DO NOT execute before April 15 launch. + +1. Confirm Arbiter role system verified in test channel +2. Deploy final build to production +3. Disable Carlbot Reaction Roles for #get-roles in Carlbot dashboard +4. Delete old Carlbot #get-roles message +5. Trigger updateGetRolesMessage() to post new button message +6. Disable Carlbot Welcome module +7. Set WELCOME_MESSAGES_ENABLED=true in .env +8. Restart arbiter-3 +9. Verify welcome fires on test join +10. Remove Carlbot from server + +--- + +## Rate Limit Handling + +Native retry on 429. No queue needed at current scale. + +--- + +## Notes for Code + +- Feature flag (WELCOME_MESSAGES_ENABLED) is critical — don't skip it +- 404 fallback in updateGetRolesMessage is important — admins will delete that message +- Keep Discord client strictly in src/discord/ +- testserver role (1491487727928217815) exists — do NOT include in button list +- This is post-launch work — no rush, but nice change from MVC 😄