Add Code bridge request: Arbiter native Discord role management
This commit is contained in:
@@ -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 😄
|
||||
Reference in New Issue
Block a user