From 8236c1e0e7885217c99744575a3ac961d80fba5b Mon Sep 17 00:00:00 2001 From: chad Date: Fri, 10 Oct 2025 05:12:54 -0400 Subject: [PATCH] Fixed Invite Accordion --- backend/index.js | 33 ++++++++++- checklist.md | 8 +++ discord-bot/api.js | 58 ++++++++++++++++++- discord-bot/events/inviteCreate.js | 49 ++++++++++++++++ discord-bot/events/inviteDelete.js | 24 ++++++++ discord-bot/events/ready.js | 26 +++++++++ .../src/components/server/ServerSettings.js | 28 ++++++++- 7 files changed, 219 insertions(+), 7 deletions(-) create mode 100644 discord-bot/events/inviteCreate.js create mode 100644 discord-bot/events/inviteDelete.js diff --git a/backend/index.js b/backend/index.js index 3d3147e..2178d03 100644 --- a/backend/index.js +++ b/backend/index.js @@ -1049,7 +1049,36 @@ app.get('/api/servers/:guildId/invites', async (req, res) => { app.post('/api/servers/:guildId/invites', async (req, res) => { try { const { guildId } = req.params; - const { channelId, maxAge, maxUses, temporary } = req.body || {}; + const { code, url, channelId, maxAge, maxUses, temporary, createdAt } = req.body || {}; + + // If code is provided, this is an existing invite to store (from Discord events) + if (code) { + const item = { + code, + url: url || `https://discord.gg/${code}`, + channelId: channelId || '', + createdAt: createdAt || new Date().toISOString(), + maxUses: maxUses || 0, + maxAge: maxAge || 0, + temporary: !!temporary, + }; + + await pgClient.addInvite({ + code: item.code, + guildId, + url: item.url, + channelId: item.channelId, + createdAt: item.createdAt, + maxUses: item.maxUses, + maxAge: item.maxAge, + temporary: item.temporary + }); + + res.json({ success: true, invite: item }); + return; + } + + // Otherwise, create a new invite const guild = bot.client.guilds.cache.get(guildId); if (!guild) return res.status(404).json({ success: false, message: 'Guild not found' }); @@ -1089,7 +1118,7 @@ app.post('/api/servers/:guildId/invites', async (req, res) => { res.json({ success: true, invite: item }); } catch (error) { - console.error('Error creating invite:', error); + console.error('Error creating/storing invite:', error); res.status(500).json({ success: false, message: 'Internal Server Error' }); } }); diff --git a/checklist.md b/checklist.md index 80b6e25..4ed1bad 100644 --- a/checklist.md +++ b/checklist.md @@ -94,6 +94,14 @@ - [x] Admin logs properly display the username who called the command and the user they called it on for both bot slash commands and frontend moderation actions - [x] Bot command username logging fixed: uses correct Discord user properties (username/global_name instead of deprecated tag) - [x] Bot event handlers: added guildCreate and guildDelete events to publish SSE notifications for live dashboard updates + - [x] Invite synchronization: real-time sync between Discord server events and frontend + - [x] Discord event handlers for inviteCreate and inviteDelete events + - [x] Only bot-created invites are tracked and synchronized + - [x] Frontend SSE event listeners for inviteCreated and inviteDeleted events + - [x] Backend API updated to store existing invites from Discord events + - [x] Invite deletions from Discord server are immediately reflected in frontend + - [x] Offline reconciliation: bot detects and removes invites deleted while offline on startup + - [x] Automatic cleanup of stale invites from database and frontend when bot comes back online ## Database - [x] Postgres support via `DATABASE_URL` (backend auto-creates `servers`, `invites`, `users`) diff --git a/discord-bot/api.js b/discord-bot/api.js index 8ea2eab..ae68d3f 100644 --- a/discord-bot/api.js +++ b/discord-bot/api.js @@ -90,10 +90,24 @@ async function listInvites(guildId) { async function addInvite(guildId, invite) { const path = `/api/servers/${guildId}/invites`; try { + // If invite is an object with code property, it's already created - send full data + // If it's just channelId/maxAge/etc, it's for creation + const isExistingInvite = invite && typeof invite === 'object' && invite.code; + + const body = isExistingInvite ? { + code: invite.code, + url: invite.url, + channelId: invite.channelId, + maxUses: invite.maxUses, + maxAge: invite.maxAge, + temporary: invite.temporary, + createdAt: invite.createdAt + } : invite; + const res = await tryFetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(invite), + body: JSON.stringify(body), }); return res && res.ok; } catch (e) { @@ -208,4 +222,44 @@ async function getAutoroleSettings(guildId) { return json || { enabled: false, roleId: '' }; } -module.exports = { getServerSettings, upsertServerSettings, getCommands, toggleCommand, listInvites, addInvite, deleteInvite, getTwitchUsers, addTwitchUser, deleteTwitchUser, tryFetchTwitchStreams, _rawGetTwitchStreams, getKickUsers, addKickUser, deleteKickUser, getWelcomeLeaveSettings, getAutoroleSettings }; +async function reconcileInvites(guildId, currentDiscordInvites) { + try { + // Get invites from database + const dbInvites = await listInvites(guildId) || []; + + // Find invites in database that no longer exist in Discord + const discordInviteCodes = new Set(currentDiscordInvites.map(inv => inv.code)); + const deletedInvites = dbInvites.filter(dbInv => !discordInviteCodes.has(dbInv.code)); + + // Delete each invite that no longer exists + for (const invite of deletedInvites) { + console.log(`🗑️ Reconciling deleted invite ${invite.code} for guild ${guildId}`); + await deleteInvite(guildId, invite.code); + + // Publish SSE event for frontend update + try { + await tryFetch('/api/events/publish', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + event: 'inviteDeleted', + data: { code: invite.code, guildId } + }) + }); + } catch (sseErr) { + console.error('Failed to publish SSE event for reconciled invite deletion:', sseErr); + } + } + + if (deletedInvites.length > 0) { + console.log(`✅ Reconciled ${deletedInvites.length} deleted invites for guild ${guildId}`); + } + + return deletedInvites.length; + } catch (e) { + console.error(`Failed to reconcile invites for guild ${guildId}:`, e && e.message ? e.message : e); + return 0; + } +} + +module.exports = { getServerSettings, upsertServerSettings, getCommands, toggleCommand, listInvites, addInvite, deleteInvite, getTwitchUsers, addTwitchUser, deleteTwitchUser, tryFetchTwitchStreams, _rawGetTwitchStreams, getKickUsers, addKickUser, deleteKickUser, getWelcomeLeaveSettings, getAutoroleSettings, reconcileInvites }; diff --git a/discord-bot/events/inviteCreate.js b/discord-bot/events/inviteCreate.js new file mode 100644 index 0000000..dd767fc --- /dev/null +++ b/discord-bot/events/inviteCreate.js @@ -0,0 +1,49 @@ +const api = require('../api'); + +module.exports = { + name: 'inviteCreate', + async execute(invite) { + try { + // Only track invites created by the bot or in channels the bot can access + const guildId = invite.guild.id; + + // Check if this invite was created by our bot + const isBotCreated = invite.inviter && invite.inviter.id === invite.client.user.id; + + if (isBotCreated) { + // Add to database if created by bot + const inviteData = { + code: invite.code, + guildId: guildId, + url: invite.url, + channelId: invite.channel.id, + createdAt: invite.createdAt ? invite.createdAt.toISOString() : new Date().toISOString(), + maxUses: invite.maxUses || 0, + maxAge: invite.maxAge || 0, + temporary: invite.temporary || false + }; + + // Use the API to add the invite to database + await api.addInvite(inviteData); + + // Publish SSE event for real-time frontend updates + const bot = require('..'); + if (bot && bot.publishEvent) { + bot.publishEvent(guildId, 'inviteCreated', { + code: invite.code, + url: invite.url, + channelId: invite.channel.id, + maxUses: invite.maxUses || 0, + maxAge: invite.maxAge || 0, + temporary: invite.temporary || false, + createdAt: invite.createdAt ? invite.createdAt.toISOString() : new Date().toISOString() + }); + } + } + // Note: We don't automatically add invites created by other users to avoid spam + // Only bot-created invites are tracked for the web interface + } catch (error) { + console.error('Error handling inviteCreate:', error); + } + } +}; \ No newline at end of file diff --git a/discord-bot/events/inviteDelete.js b/discord-bot/events/inviteDelete.js new file mode 100644 index 0000000..8e3756f --- /dev/null +++ b/discord-bot/events/inviteDelete.js @@ -0,0 +1,24 @@ +const api = require('../api'); + +module.exports = { + name: 'inviteDelete', + async execute(invite) { + try { + const guildId = invite.guild.id; + const code = invite.code; + + // Remove from database + await api.deleteInvite(guildId, code); + + // Publish SSE event for real-time frontend updates + const bot = require('..'); + if (bot && bot.publishEvent) { + bot.publishEvent(guildId, 'inviteDeleted', { + code: code + }); + } + } catch (error) { + console.error('Error handling inviteDelete:', error); + } + } +}; \ No newline at end of file diff --git a/discord-bot/events/ready.js b/discord-bot/events/ready.js index 79cb676..554e096 100644 --- a/discord-bot/events/ready.js +++ b/discord-bot/events/ready.js @@ -1,5 +1,6 @@ const { ActivityType } = require('discord.js'); const deployCommands = require('../deploy-commands'); +const api = require('../api'); module.exports = { name: 'clientReady', @@ -16,6 +17,31 @@ module.exports = { } } + // Reconcile invites for all guilds to detect invites deleted while bot was offline + console.log('🔄 Reconciling invites for offline changes...'); + let totalReconciled = 0; + for (const guildId of guildIds) { + try { + const guild = client.guilds.cache.get(guildId); + if (!guild) continue; + + // Fetch current invites from Discord + const discordInvites = await guild.invites.fetch(); + const currentInvites = Array.from(discordInvites.values()); + + // Reconcile with database + const reconciled = await api.reconcileInvites(guildId, currentInvites); + totalReconciled += reconciled; + } catch (e) { + console.error(`Failed to reconcile invites for guild ${guildId}:`, e && e.message ? e.message : e); + } + } + if (totalReconciled > 0) { + console.log(`✅ Invite reconciliation complete: removed ${totalReconciled} stale invites`); + } else { + console.log('✅ Invite reconciliation complete: no stale invites found'); + } + const activities = [ { name: 'Watch EhChad Live!', type: ActivityType.Streaming, url: 'https://twitch.tv/ehchad' }, { name: 'Follow EhChad!', type: ActivityType.Streaming, url: 'https://twitch.tv/ehchad' }, diff --git a/frontend/src/components/server/ServerSettings.js b/frontend/src/components/server/ServerSettings.js index 3fe92f1..0cfe667 100644 --- a/frontend/src/components/server/ServerSettings.js +++ b/frontend/src/components/server/ServerSettings.js @@ -269,6 +269,22 @@ const ServerSettings = () => { setAdminLogs([]); }; + const onInviteCreated = (e) => { + const data = e.detail || {}; + if (!data) return; + if (data.guildId && data.guildId !== guildId) return; + // Add the new invite to the list + setInvites(prev => [...prev, data]); + }; + + const onInviteDeleted = (e) => { + const data = e.detail || {}; + if (!data) return; + if (data.guildId && data.guildId !== guildId) return; + // Remove the deleted invite from the list + setInvites(prev => prev.filter(invite => invite.code !== data.code)); + }; + eventTarget.addEventListener('twitchUsersUpdate', onTwitchUsers); eventTarget.addEventListener('kickUsersUpdate', onKickUsers); eventTarget.addEventListener('liveNotificationsUpdate', onLiveNotifications); @@ -276,6 +292,8 @@ const ServerSettings = () => { eventTarget.addEventListener('adminLogAdded', onAdminLogAdded); eventTarget.addEventListener('adminLogDeleted', onAdminLogDeleted); eventTarget.addEventListener('adminLogsCleared', onAdminLogsCleared); + eventTarget.addEventListener('inviteCreated', onInviteCreated); + eventTarget.addEventListener('inviteDeleted', onInviteDeleted); return () => { try { @@ -286,6 +304,8 @@ const ServerSettings = () => { eventTarget.removeEventListener('adminLogAdded', onAdminLogAdded); eventTarget.removeEventListener('adminLogDeleted', onAdminLogDeleted); eventTarget.removeEventListener('adminLogsCleared', onAdminLogsCleared); + eventTarget.removeEventListener('inviteCreated', onInviteCreated); + eventTarget.removeEventListener('inviteDeleted', onInviteDeleted); } catch (err) {} }; }, [eventTarget, guildId]); @@ -669,7 +689,9 @@ const ServerSettings = () => { @@ -778,7 +800,7 @@ const ServerSettings = () => { displayEmpty > Select a channel - {channels.map(channel => ( + {channels.map((channel) => ( {channel.name} ))} @@ -819,7 +841,7 @@ const ServerSettings = () => { displayEmpty > Select a channel - {channels.map(channel => ( + {channels.map((channel) => ( {channel.name} ))}