Files
2025-10-10 18:51:23 -04:00

333 lines
16 KiB
JavaScript

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_<reactionRoleId>_<roleId>
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
}