# 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 😄
