const { Client, GatewayIntentBits, Collection } = require('discord.js'); const fs = require('fs'); const path = require('path'); const deployCommands = require('./deploy-commands'); // legacy local db is available as a fallback in some commands via require('../../backend/db') const api = require('./api'); const client = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers] }); client.commands = new Collection(); // In-memory cache of server settings to allow backend to push updates and make toggles immediate const guildSettingsCache = new Map(); function setGuildSettings(guildId, settings) { if (!guildId) return; guildSettingsCache.set(guildId, settings || {}); } function getGuildSettingsFromCache(guildId) { return guildSettingsCache.get(guildId) || null; } const commandHandler = require('./handlers/command-handler'); const eventHandler = require('./handlers/event-handler'); commandHandler(client); eventHandler(client); client.on('interactionCreate', async interaction => { // Handle button/component interactions for invites if (interaction.isButton && interaction.isButton()) { const id = interaction.customId || ''; if (id.startsWith('copy_inv_')) { const code = id.replace('copy_inv_', ''); try { const invites = await api.listInvites(interaction.guildId); const inv = (invites || []).find(i => i.code === code); if (inv) { await interaction.reply({ content: `Invite: ${inv.url}`, ephemeral: true }); } else { await interaction.reply({ content: 'Invite not found.', ephemeral: true }); } } catch (e) { console.error('Error fetching invites via API:', e); await interaction.reply({ content: 'Invite not found.', ephemeral: true }); } } else if (id.startsWith('delete_inv_')) { const code = id.replace('delete_inv_', ''); // permission check: admin only const member = interaction.member; if (!member.permissions.has('Administrator')) { await interaction.reply({ content: 'You must be an administrator to delete invites.', ephemeral: true }); return; } try { // call backend delete endpoint via helper const ok = await api.deleteInvite(interaction.guildId, code); if (ok) { await interaction.reply({ content: 'Invite deleted.', ephemeral: true }); } else { await interaction.reply({ content: 'Failed to delete invite via API.', ephemeral: true }); } } catch (e) { console.error('Error deleting invite via API:', e); await interaction.reply({ content: 'Failed to delete invite.', ephemeral: true }); } } return; } // Reaction role button handling if (interaction.isButton && interaction.customId && interaction.customId.startsWith('rr_')) { // customId format: rr__ const parts = interaction.customId.split('_'); if (parts.length >= 3) { const rrId = parts[1]; const roleId = parts[2]; try { const rr = await api.safeFetchJsonPath(`/api/servers/${interaction.guildId}/reaction-roles`); // rr is array; find by id const found = (rr || []).find(r => String(r.id) === String(rrId)); if (!found) { await interaction.reply({ content: 'Reaction role configuration not found.', ephemeral: true }); return; } const button = (found.buttons || []).find(b => String(b.roleId) === String(roleId)); if (!button) { await interaction.reply({ content: 'Button config not found.', ephemeral: true }); return; } const roleId = button.roleId || button.role_id || button.role; const member = interaction.member; if (!member) return; // Validate role hierarchy: bot must be higher than role, and member must be lower than role const guild = interaction.guild; const role = guild.roles.cache.get(roleId) || null; if (!role) { await interaction.reply({ content: 'Configured role no longer exists.', ephemeral: true }); return; } const botMember = await guild.members.fetchMe(); const botHighest = botMember.roles.highest; const targetPosition = role.position || 0; if (botHighest.position <= targetPosition) { await interaction.reply({ content: 'Cannot assign role: bot lacks sufficient role hierarchy (move bot role higher).', ephemeral: true }); return; } const memberHighest = member.roles.highest; if (memberHighest.position >= targetPosition) { await interaction.reply({ content: 'Cannot assign role: your highest role is higher or equal to the role to be assigned.', ephemeral: true }); return; } const hasRole = member.roles.cache.has(roleId); if (hasRole) { await member.roles.remove(roleId, `Reaction role button toggled by user ${interaction.user.id}`); await interaction.reply({ content: `Removed role ${role.name}.`, ephemeral: true }); } else { await member.roles.add(roleId, `Reaction role button toggled by user ${interaction.user.id}`); await interaction.reply({ content: `Assigned role ${role.name}.`, ephemeral: true }); } } catch (e) { console.error('Error handling reaction role button:', e); try { await interaction.reply({ content: 'Failed to process reaction role.', ephemeral: true }); } catch(e){} } } return; } if (!interaction.isCommand()) return; const command = client.commands.get(interaction.commandName); if (!command) return; // Check per-guild toggles via Postgres (directly) for lower latency and reliability try { // authoritative path: always try the backend HTTP API first so separate processes stay in sync let guildSettings = await api.getServerSettings(interaction.guildId) || {}; // if API didn't return anything useful, try in-memory cache then direct DB as fallbacks if (!guildSettings || Object.keys(guildSettings).length === 0) { guildSettings = getGuildSettingsFromCache(interaction.guildId) || {}; if (!guildSettings || Object.keys(guildSettings).length === 0) { try { const pg = require('../backend/pg'); guildSettings = (await pg.getServerSettings(interaction.guildId)) || {}; } catch (pgErr) { // leave guildSettings empty } } } // Normalize legacy flags into commandToggles for backward compatibility const toggles = { ...(guildSettings.commandToggles || {}) }; // Example legacy flag mapping: pingCommand -> commandToggles.ping if (typeof guildSettings.pingCommand !== 'undefined') { toggles.ping = !!guildSettings.pingCommand; } const protectedCommands = ['manage-commands', 'help']; // If command is protected, always allow if (!protectedCommands.includes(command.name)) { if (toggles[command.name] === false) { await interaction.reply({ content: 'This command has been disabled on this server.', flags: 64 }); return; } // If the module-level enabled flag is false, treat as disabled too if (command.enabled === false) { await interaction.reply({ content: 'This command is currently disabled globally.', flags: 64 }); return; } } try { await command.execute(interaction); } catch (error) { console.error(error); await interaction.reply({ content: 'There was an error while executing this command!', flags: 64 }); } } catch (error) { console.error('Error checking command toggles:', error); await interaction.reply({ content: 'Internal error occurred.', flags: 64 }); } }); client.on('guildCreate', guild => { deployCommands(guild.id); }); const login = () => { client.login(process.env.DISCORD_BOT_TOKEN); } // Allow backend to trigger a live announcement for debugging async function announceLive(guildId, stream) { try { const guild = await client.guilds.fetch(guildId).catch(() => null); if (!guild) return { success: false, message: 'Guild not found' }; const settings = await (require('../backend/pg')).getServerSettings(guildId).catch(() => null) || await (require('./api')).getServerSettings(guildId).catch(() => ({})); const liveSettings = (settings && settings.liveNotifications) || {}; if (!liveSettings.enabled) return { success: false, message: 'Live notifications disabled' }; const channelId = liveSettings.channelId; if (!channelId) return { success: false, message: 'No channel configured' }; const channel = await guild.channels.fetch(channelId).catch(() => null); if (!channel) return { success: false, message: 'Channel not found' }; const { EmbedBuilder } = require('discord.js'); const embed = new EmbedBuilder() .setColor(0x9146FF) .setTitle(stream.title || `${stream.user_name} is live`) .setURL(stream.url) .setAuthor({ name: stream.user_name, iconURL: stream.profile_image_url || undefined, url: stream.url }) .setThumbnail(stream.thumbnail_url || stream.profile_image_url || undefined) .addFields( { name: 'Category', value: stream.game_name || 'Unknown', inline: true }, { name: 'Viewers', value: String(stream.viewer_count || 0), inline: true } ) .setDescription((stream.description || '').slice(0, 200)) .setFooter({ text: `ehchadservices • Started: ${stream.started_at ? new Date(stream.started_at).toLocaleString() : 'unknown'}` }); let prefixMsg = ''; if (liveSettings.customMessage) { prefixMsg = liveSettings.customMessage; } else if (liveSettings.message) { prefixMsg = liveSettings.message; } else { prefixMsg = `🔴 ${stream.user_name} is now live!`; } const payload = prefixMsg ? { content: prefixMsg, embeds: [embed] } : { embeds: [embed] }; await channel.send(payload); return { success: true }; } catch (e) { console.error('announceLive failed:', e && e.message ? e.message : e); return { success: false, message: e && e.message ? e.message : 'unknown error' }; } } module.exports = { login, client, setGuildSettings, getGuildSettingsFromCache, announceLive }; async function postReactionRoleMessage(guildId, reactionRole) { try { const guild = client.guilds.cache.get(guildId); if (!guild) return { success: false, message: 'Guild not found' }; const channel = await guild.channels.fetch(reactionRole.channel_id || reactionRole.channelId).catch(() => null); if (!channel) return { success: false, message: 'Channel not found' }; // Build buttons const { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } = require('discord.js'); const row = new ActionRowBuilder(); const buttons = reactionRole.buttons || []; for (let i = 0; i < buttons.length; i++) { const b = buttons[i]; const customId = `rr_${reactionRole.id}_${b.roleId}`; const btn = new ButtonBuilder().setCustomId(customId).setLabel(b.label || b.name || `Button ${i+1}`).setStyle(ButtonStyle.Primary); row.addComponents(btn); } const embedData = reactionRole.embed || reactionRole.embed || {}; const embed = new EmbedBuilder(); if (embedData.title) embed.setTitle(embedData.title); if (embedData.description) embed.setDescription(embedData.description); if (embedData.color) embed.setColor(embedData.color); if (embedData.thumbnail) embed.setThumbnail(embedData.thumbnail); if (embedData.fields && Array.isArray(embedData.fields)) { for (const f of embedData.fields) { if (f.name && f.value) embed.addFields({ name: f.name, value: f.value, inline: false }); } } const sent = await channel.send({ embeds: [embed], components: [row] }); // update backend with message id try { const api = require('./api'); await api.updateReactionRole(guildId, reactionRole.id, { messageId: sent.id }); } catch (e) { console.error('Failed to update reaction role message id in backend:', e); } return { success: true, messageId: sent.id }; } catch (e) { console.error('postReactionRoleMessage failed:', e && e.message ? e.message : e); return { success: false, message: e && e.message ? e.message : 'unknown error' }; } } module.exports.postReactionRoleMessage = postReactionRoleMessage; // Start twitch watcher when client is ready (use 'clientReady' as the event name) try { const watcher = require('./twitch-watcher'); // discord.js uses 'clientReady' event client.once('clientReady', () => { // start polling in background watcher.poll(client).catch(err => console.error('Twitch watcher failed to start:', err)); }); process.on('exit', () => { watcher.stop(); }); process.on('SIGINT', () => { watcher.stop(); process.exit(); }); } catch (e) { // ignore if watcher not available } try { const kickWatcher = require('./kick-watcher'); client.once('clientReady', () => { // TEMPORARILY DISABLED: Kick watcher removed for now // kickWatcher.poll(client).catch(err => console.error('Kick watcher failed to start:', err)); console.log('Kick watcher: temporarily disabled'); }); // process.on('exit', () => { kickWatcher.stop(); }); // process.on('SIGINT', () => { kickWatcher.stop(); process.exit(); }); } catch (e) { // ignore if kick watcher not available } // --- Optional push receiver (so backend can notify a remote bot process) --- try { const express = require('express'); const bodyParser = require('body-parser'); const botPort = process.env.BOT_PUSH_PORT || process.env.BOT_PORT || null; const botSecret = process.env.BOT_SECRET || null; if (botPort) { const app = express(); app.use(bodyParser.json()); app.post('/internal/set-settings', (req, res) => { try { if (botSecret) { const provided = req.headers['x-bot-secret']; if (!provided || provided !== botSecret) return res.status(401).json({ success: false, message: 'Unauthorized' }); } const { guildId, settings } = req.body || {}; if (!guildId) return res.status(400).json({ success: false, message: 'Missing guildId' }); setGuildSettings(guildId, settings || {}); return res.json({ success: true }); } catch (e) { console.error('Error in bot push receiver:', e); return res.status(500).json({ success: false }); } }); app.listen(botPort, () => console.log(`Bot push receiver listening on port ${botPort}`)); } } catch (e) { // ignore if express isn't available in this environment }