From f4f96dfe31abd4939cf1093f6ef3f810fabf06ab Mon Sep 17 00:00:00 2001 From: Claude Code Date: Wed, 15 Apr 2026 06:17:57 -0500 Subject: [PATCH] Task: reaction roles + Carl-bot migration (REQ-2026-04-15-reaction-roles) - src/discord/reactionRoles.js: REACTION_ROLE_MAP for 3 #get-roles messages (Paths, Notifications, Servers), handleReactionAdd/Remove with partial fetch + silent-fail - src/index.js: add GuildMessageReactions + DirectMessages intents, Partials for Message/Channel/Reaction (needed for pre-cache bot messages) - src/discord/events.js: wire messageReactionAdd/Remove handlers + guildMemberAdd (Wanderer role + welcome DM, silent-fail on closed DMs) - src/routes/stripe.js: post-checkout link-reminder DM for Awakened+ via req.app.locals.client, non-blocking + silent-fail --- services/arbiter-3.0/src/discord/events.js | 44 +++++++++ .../arbiter-3.0/src/discord/reactionRoles.js | 95 +++++++++++++++++++ services/arbiter-3.0/src/index.js | 14 ++- services/arbiter-3.0/src/routes/stripe.js | 20 ++++ 4 files changed, 171 insertions(+), 2 deletions(-) create mode 100644 services/arbiter-3.0/src/discord/reactionRoles.js diff --git a/services/arbiter-3.0/src/discord/events.js b/services/arbiter-3.0/src/discord/events.js index 0cc7dcd..6e8f734 100644 --- a/services/arbiter-3.0/src/discord/events.js +++ b/services/arbiter-3.0/src/discord/events.js @@ -3,8 +3,21 @@ const { handleCreateServerCommand } = require('./createserver'); const { handleDelServerCommand } = require('./delserver'); const { handleTasksCommand, handleTaskButton } = require('./tasks'); const { handleVerifyMvcCommand } = require('./verifymvc'); +const { handleReactionAdd, handleReactionRemove } = require('./reactionRoles'); const discordRoleSync = require('../services/discordRoleSync'); +// Carl-bot migration: Wanderer role assigned to every new join. +const WANDERER_ROLE_ID = '1487267974367805545'; + +const WELCOME_DM = (username) => + `Hey ${username}! Welcome to Firefrost Gaming!\n` + + `You just landed as a Wanderer — the door is open, come explore!\n\n` + + `Quick links:\n` + + `• Check out #rules first\n` + + `• Say hi in #introductions\n` + + `• Head to #get-roles to pick your path and grab your server channels\n\n` + + `Questions? We're here. Welcome to the family!`; + function registerEvents(client) { client.on('interactionCreate', async interaction => { // Button interactions @@ -33,6 +46,37 @@ function registerEvents(client) { } }); + // Reaction roles — #get-roles channel + client.on('messageReactionAdd', (reaction, user) => { + handleReactionAdd(reaction, user).catch(err => + console.error('[events] reaction add error:', err.message) + ); + }); + client.on('messageReactionRemove', (reaction, user) => { + handleReactionRemove(reaction, user).catch(err => + console.error('[events] reaction remove error:', err.message) + ); + }); + + // Carl-bot migration — new member onboarding + client.on('guildMemberAdd', async (member) => { + try { + await member.roles.add(WANDERER_ROLE_ID).catch(err => { + console.warn(`[Welcome] failed to add Wanderer to ${member.user.username}:`, err.message); + }); + console.log(`👋 [Welcome] ${member.user.username} joined → Wanderer`); + + // DM welcome (silent-fail if user has DMs closed) + try { + await member.send(WELCOME_DM(member.user.username)); + } catch (err) { + console.log(`[Welcome] DM to ${member.user.username} skipped: ${err.message}`); + } + } catch (err) { + console.error('[Welcome] guildMemberAdd error:', err.message); + } + }); + client.on('ready', () => { console.log(`Discord bot logged in as ${client.user.tag}`); // Initialize role sync service with the ready client diff --git a/services/arbiter-3.0/src/discord/reactionRoles.js b/services/arbiter-3.0/src/discord/reactionRoles.js new file mode 100644 index 0000000..496d6b9 --- /dev/null +++ b/services/arbiter-3.0/src/discord/reactionRoles.js @@ -0,0 +1,95 @@ +/** + * Reaction role handling — #get-roles channel. + * REQ-2026-04-15-reaction-roles (Chronicler #92) + * + * Three bot-owned messages. Each emoji maps to a Discord role ID. On add → + * assign role. On remove → remove role. Silent-fail on lookup errors so a bad + * reaction can never crash the gateway listener. + */ + +// Keyed by message ID → { emoji (unicode or custom name) → role ID } +const REACTION_ROLE_MAP = { + // Message 1 — Choose Your Path + '1493930565395681402': { + '🔥': '1482490890453782612', // Fire Path + '❄️': '1482491234378448946', // Frost Path + }, + // Message 2 — Notification Preferences + '1493930595435286548': { + '📢': '1491778391060381776', // Announcements + '🎉': '1491778662922457199', // Events + '🗒️': '1491778706312532171', // Patch Notes + }, + // Message 3 — Server Roles + '1493930614066253908': { + '🪨': '1491028769132253274', // Stoneblock 4 + '🏝️': '1491028496284258304', // All The Mods: To the Sky + '🔴': '1491029000108380170', // All The Mons + '🧙': '1491029070190870548', // Mythcraft 5 + '⚔️': '1491029454011629749', // Otherworld [D&D] + '🧟': '1491029615739801800', // DeceasedCraft + '🍽️': '1493352900997415134', // Farm Crossing 6 + '🏡': '1491030015746510939', // Homestead + '🌌': '1491028885981102270', // Society: Sunlit Valley + '🌊': '1491029215963906149', // Beyond Depth + '☁️': '1491029284159094904', // Beyond Ascension + '🏆': '1491029373640376330', // Wold's Vaults + '🤿': '1491029708878647356', // Submerged 2 + '🌙': '1491029870002569298', // Cottage Witch + '🌿': '1493924685170343978', // vanilla + } +}; + +async function resolveReaction(reaction) { + if (reaction.partial) { + try { await reaction.fetch(); } catch (e) { + console.warn('[ReactionRoles] failed to fetch partial:', e.message); + return null; + } + } + const map = REACTION_ROLE_MAP[reaction.message.id]; + if (!map) return null; + // Unicode emojis use .name; custom emojis fall back to id. + const key = reaction.emoji.name; + const roleId = map[key]; + if (!roleId) return null; + return { roleId }; +} + +async function handleReactionAdd(reaction, user) { + try { + if (user.bot) return; + const resolved = await resolveReaction(reaction); + if (!resolved) return; + const guild = reaction.message.guild; + if (!guild) return; + const member = await guild.members.fetch(user.id).catch(() => null); + if (!member) return; + await member.roles.add(resolved.roleId).catch(err => { + console.warn(`[ReactionRoles] add failed for ${user.username} → ${resolved.roleId}:`, err.message); + }); + console.log(`🎭 [ReactionRoles] +${resolved.roleId} → ${user.username}`); + } catch (err) { + console.error('[ReactionRoles] add handler error:', err.message); + } +} + +async function handleReactionRemove(reaction, user) { + try { + if (user.bot) return; + const resolved = await resolveReaction(reaction); + if (!resolved) return; + const guild = reaction.message.guild; + if (!guild) return; + const member = await guild.members.fetch(user.id).catch(() => null); + if (!member) return; + await member.roles.remove(resolved.roleId).catch(err => { + console.warn(`[ReactionRoles] remove failed for ${user.username} → ${resolved.roleId}:`, err.message); + }); + console.log(`🎭 [ReactionRoles] -${resolved.roleId} → ${user.username}`); + } catch (err) { + console.error('[ReactionRoles] remove handler error:', err.message); + } +} + +module.exports = { REACTION_ROLE_MAP, handleReactionAdd, handleReactionRemove }; diff --git a/services/arbiter-3.0/src/index.js b/services/arbiter-3.0/src/index.js index 65c17c2..208abaa 100644 --- a/services/arbiter-3.0/src/index.js +++ b/services/arbiter-3.0/src/index.js @@ -5,7 +5,7 @@ const session = require('express-session'); const PgSession = require('connect-pg-simple')(session); const passport = require('passport'); const DiscordStrategy = require('passport-discord').Strategy; -const { Client, GatewayIntentBits, REST, Routes } = require('discord.js'); +const { Client, GatewayIntentBits, Partials, REST, Routes } = require('discord.js'); const csrf = require('csurf'); const cors = require('cors'); const path = require('path'); @@ -36,7 +36,17 @@ const pgPool = new Pool({ }); // Initialize Discord Client -const client = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers] }); +const client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMembers, + GatewayIntentBits.GuildMessageReactions, + GatewayIntentBits.DirectMessages + ], + // Partials needed so reaction events fire for messages not in cache (bot-owned + // #get-roles messages are from before current session). + partials: [Partials.Message, Partials.Channel, Partials.Reaction] +}); registerEvents(client); // Passport Configuration diff --git a/services/arbiter-3.0/src/routes/stripe.js b/services/arbiter-3.0/src/routes/stripe.js index f6cf2db..c0c5968 100644 --- a/services/arbiter-3.0/src/routes/stripe.js +++ b/services/arbiter-3.0/src/routes/stripe.js @@ -296,6 +296,26 @@ router.post('/webhook', express.raw({ type: 'application/json' }), async (req, r console.log(`⚠️ LP meta sync skipped for ${discordId}: ${result.reason}`); } }).catch(err => console.error('[LPSync] Background sync error:', err.message)); + + // Carl-bot migration: link-reminder DM for Awakened+ subscribers (non-blocking, silent-fail) + // REQ-2026-04-15-reaction-roles (Chronicler #92) + (async () => { + try { + const discordClient = req.app.locals.client; + if (!discordClient) return; + const user = await discordClient.users.fetch(discordId).catch(() => null); + if (!user) return; + await user.send( + `Hey ${user.username}! You're now part of the Firefrost family! 🎉\n\n` + + `One quick step — head to #link-your-account and use the /link command ` + + `to connect your Minecraft account so we can whitelist you on our servers.\n\n` + + `See you in-game!` + ); + console.log(`💌 [LinkReminder] sent to ${user.username}`); + } catch (err) { + console.log(`[LinkReminder] DM to ${discordId} skipped: ${err.message}`); + } + })(); } break;