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
This commit is contained in:
Claude Code
2026-04-15 06:17:57 -05:00
parent b329951719
commit f4f96dfe31
4 changed files with 171 additions and 2 deletions

View File

@@ -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

View File

@@ -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 };

View File

@@ -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

View File

@@ -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;