From 900ce85e2c959187a31c0e50e080f40d7d606540 Mon Sep 17 00:00:00 2001 From: chad Date: Thu, 9 Oct 2025 19:24:02 -0400 Subject: [PATCH] Fixed Twitch Live notis --- checklist.md | 10 +- discord-bot/api.js | 2 +- discord-bot/commands/add-twitchuser.js | 18 +- discord-bot/commands/remove-twitchuser.js | 16 +- discord-bot/twitch-watcher.js | 154 +++++++++++++++--- .../src/components/server/ServerSettings.js | 46 +++--- 6 files changed, 172 insertions(+), 74 deletions(-) diff --git a/checklist.md b/checklist.md index e4a4445..80b6e25 100644 --- a/checklist.md +++ b/checklist.md @@ -1,4 +1,4 @@ - # Project Checklist (tidy & current) +# Project Checklist (tidy & current) Below are implemented features - [x] Front - [x] Live updates between bot and frontend using SSE events for real-time log synchronization (admin logs update immediately when moderation actions occur)nd UI for admin logs configuration in Server Settings - [x] Database schema for storing moderation action logs @@ -51,6 +51,12 @@ - [x] Live Notifications: bot posts rich embed to channel when a watched Twitch user goes live (thumbnail, clickable title, bio/description, category/game, viewers, footer with "ehchadservices" and start datetime) - [x] Live Notifications polling frequency set to 5 seconds (configurable via `TWITCH_POLL_INTERVAL_MS`) - [x] On bot restart, sends messages for currently live watched users; then sends for new streams once per session + - [x] Twitch Watcher Debug Logging: comprehensive debug mode added (enable with `TWITCH_WATCHER_DEBUG=true`) to track guild checks, settings retrieval, stream fetching, channel permissions, and message sending for troubleshooting live notification issues + - [x] Twitch API Functions Export Fix: added missing `tryFetchTwitchStreams` and `_rawGetTwitchStreams` to api.js module exports to resolve "is not a function" errors + - [x] Twitch Streams Array Safety: added `Array.isArray()` checks in twitch-watcher.js to prevent "filter is not a function" errors when API returns unexpected data types + - [x] Twitch Commands Postgres Integration: updated all Discord bot Twitch commands (`/add-twitchuser`, `/remove-twitchuser`) to use api.js functions for consistent Postgres backend communication + - [x] Twitch Message Template Variables: added support for `{user}`, `{title}`, `{category}`, and `{viewers}` template variables in custom live notification messages for dynamic content insertion + - [x] Frontend JSX Syntax Fix: fixed React Fragment wrapping for admin logs map to resolve build compilation errors - [x] Frontend: show "Watch Live" button next to watched user when they are live (links to Twitch) - [x] Bi-directional sync: backend POST/DELETE for twitch-users now also pushes new settings to bot process (when `BOT_PUSH_URL` configured) - [x] Bot adds/removes users via backend endpoints ensuring single source of truth (Postgres) @@ -86,6 +92,7 @@ - [x] Frontend delete buttons for individual logs and delete all logs with confirm dialogs - [x] Live updates between bot and frontend using SSE events for real-time log synchronization - [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 ## Database @@ -155,4 +162,3 @@ - [x] Fixed ESLint warnings: removed unused imports and handlers, added proper dependency management - [x] Fixed compilation errors: added missing MUI imports and Snackbar component - [x] Navbar visibility: enhanced with solid background and stronger border for better visibility across all themes - \ No newline at end of file diff --git a/discord-bot/api.js b/discord-bot/api.js index 2e2c7b5..8ea2eab 100644 --- a/discord-bot/api.js +++ b/discord-bot/api.js @@ -208,4 +208,4 @@ async function getAutoroleSettings(guildId) { return json || { enabled: false, roleId: '' }; } -module.exports = { getServerSettings, upsertServerSettings, getCommands, toggleCommand, listInvites, addInvite, deleteInvite, getTwitchUsers, addTwitchUser, deleteTwitchUser, getKickUsers, addKickUser, deleteKickUser, getWelcomeLeaveSettings, getAutoroleSettings }; +module.exports = { getServerSettings, upsertServerSettings, getCommands, toggleCommand, listInvites, addInvite, deleteInvite, getTwitchUsers, addTwitchUser, deleteTwitchUser, tryFetchTwitchStreams, _rawGetTwitchStreams, getKickUsers, addKickUser, deleteKickUser, getWelcomeLeaveSettings, getAutoroleSettings }; diff --git a/discord-bot/commands/add-twitchuser.js b/discord-bot/commands/add-twitchuser.js index a7558bd..f77d6c0 100644 --- a/discord-bot/commands/add-twitchuser.js +++ b/discord-bot/commands/add-twitchuser.js @@ -1,5 +1,5 @@ const { SlashCommandBuilder, PermissionsBitField } = require('discord.js'); -const fetch = require('node-fetch'); +const api = require('../api'); module.exports = { name: 'add-twitchuser', @@ -16,20 +16,14 @@ module.exports = { } const username = interaction.options.getString('username').toLowerCase().trim(); try { - const backendBase = process.env.BACKEND_BASE || `http://${process.env.HOST || '127.0.0.1'}:${process.env.PORT || 3002}`; - const resp = await fetch(`${backendBase}/api/servers/${interaction.guildId}/twitch-users`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username }) - }); - if (resp.ok) { + const success = await api.addTwitchUser(interaction.guildId, username); + if (success) { await interaction.reply({ content: `Added ${username} to watch list.`, flags: 64 }); // Refresh cached settings from backend so watcher sees new user immediately try { - const settingsResp = await fetch(`${backendBase}/api/servers/${interaction.guildId}/settings`); - if (settingsResp.ok) { - const json = await settingsResp.json(); - const bot = require('..'); - if (bot && bot.setGuildSettings) bot.setGuildSettings(interaction.guildId, json); - } + const settings = await api.getServerSettings(interaction.guildId); + const bot = require('..'); + if (bot && bot.setGuildSettings) bot.setGuildSettings(interaction.guildId, settings); } catch (_) {} } else { await interaction.reply({ content: 'Failed to add user via backend.', flags: 64 }); diff --git a/discord-bot/commands/remove-twitchuser.js b/discord-bot/commands/remove-twitchuser.js index 621c292..f21c71e 100644 --- a/discord-bot/commands/remove-twitchuser.js +++ b/discord-bot/commands/remove-twitchuser.js @@ -1,5 +1,5 @@ const { SlashCommandBuilder, PermissionsBitField } = require('discord.js'); -const fetch = require('node-fetch'); +const api = require('../api'); module.exports = { name: 'remove-twitchuser', @@ -16,18 +16,14 @@ module.exports = { } const username = interaction.options.getString('username').toLowerCase().trim(); try { - const backendBase = process.env.BACKEND_BASE || `http://${process.env.HOST || '127.0.0.1'}:${process.env.PORT || 3002}`; - const resp = await fetch(`${backendBase}/api/servers/${interaction.guildId}/twitch-users/${encodeURIComponent(username)}`, { method: 'DELETE' }); - if (resp.ok) { + const success = await api.deleteTwitchUser(interaction.guildId, username); + if (success) { await interaction.reply({ content: `Removed ${username} from watch list.`, flags: 64 }); // Refresh cached settings from backend try { - const settingsResp = await fetch(`${backendBase}/api/servers/${interaction.guildId}/settings`); - if (settingsResp.ok) { - const json = await settingsResp.json(); - const bot = require('..'); - if (bot && bot.setGuildSettings) bot.setGuildSettings(interaction.guildId, json); - } + const settings = await api.getServerSettings(interaction.guildId); + const bot = require('..'); + if (bot && bot.setGuildSettings) bot.setGuildSettings(interaction.guildId, settings); } catch (_) {} } else { await interaction.reply({ content: 'Failed to remove user via backend.', flags: 64 }); diff --git a/discord-bot/twitch-watcher.js b/discord-bot/twitch-watcher.js index a053ebc..24f713f 100644 --- a/discord-bot/twitch-watcher.js +++ b/discord-bot/twitch-watcher.js @@ -60,41 +60,86 @@ async function fetchUserInfo(login) { let polling = false; const pollIntervalMs = Number(process.env.TWITCH_POLL_INTERVAL_MS || 5000); // 5s default +const debugMode = false; // Debug logging disabled // Keep track of which streams we've already announced per guild:user -> { started_at } const announced = new Map(); // key: `${guildId}:${user}` -> { started_at } async function checkGuild(client, guild) { + const guildId = guild.id; + const guildName = guild.name; + try { - // Intentionally quiet: per-guild checking logs are suppressed to avoid spam - const settings = await api.getServerSettings(guild.id) || {}; + if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Checking guild ${guildName} (${guildId})`); + + const settings = await api.getServerSettings(guildId) || {}; const liveSettings = settings.liveNotifications || {}; - if (!liveSettings.enabled) return; + + if (debugMode) { + console.log(`🔍 [DEBUG] TwitchWatcher: Guild ${guildName} settings:`, { + enabled: liveSettings.enabled, + channelId: liveSettings.channelId, + usersCount: (liveSettings.users || []).length, + hasCustomMessage: !!liveSettings.customMessage, + hasDefaultMessage: !!liveSettings.message + }); + } + + if (!liveSettings.enabled) { + if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Live notifications disabled for ${guildName}`); + return; + } + const channelId = liveSettings.channelId; const users = (liveSettings.users || []).map(u => u.toLowerCase()).filter(Boolean); - if (!channelId || users.length === 0) return; + + if (debugMode) { + console.log(`🔍 [DEBUG] TwitchWatcher: Guild ${guildName} - Channel: ${channelId}, Users: [${users.join(', ')}]`); + } + + if (!channelId || users.length === 0) { + if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Skipping ${guildName} - ${!channelId ? 'No channel configured' : 'No users configured'}`); + return; + } + // ask backend for current live streams const query = users.join(','); + if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Fetching streams for query: ${query}`); + const streams = await api._rawGetTwitchStreams ? api._rawGetTwitchStreams(query) : null; // If the helper isn't available, try backend proxy let live = []; - if (streams) live = streams.filter(s => s.is_live); - else { + if (streams && Array.isArray(streams)) { + live = streams.filter(s => s.is_live); + if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Found ${live.length} live streams via _rawGetTwitchStreams`); + } else { + if (debugMode && streams) { + console.log(`🔍 [DEBUG] TwitchWatcher: _rawGetTwitchStreams returned non-array:`, typeof streams, streams); + } try { const resp = await api.tryFetchTwitchStreams(query); - live = (resp || []).filter(s => s.is_live); + live = (Array.isArray(resp) ? resp : []).filter(s => s.is_live); + if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Found ${live.length} live streams via tryFetchTwitchStreams`); } catch (e) { + console.error(`❌ TwitchWatcher: Failed to fetch streams for ${guildName}:`, e && e.message ? e.message : e); live = []; } } + + if (debugMode && live.length > 0) { + console.log(`🔍 [DEBUG] TwitchWatcher: Live streams:`, live.map(s => `${s.user_login} (${s.viewer_count} viewers)`)); + } + if (!live || live.length === 0) { // No live streams: ensure any announced keys for these users are cleared so they can be re-announced later for (const u of users) { - const key = `${guild.id}:${u}`; + const key = `${guildId}:${u}`; if (announced.has(key)) { announced.delete(key); + if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Cleared announcement for ${u} in ${guildName} (no longer live)`); } } + if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: No live streams found for ${guildName}`); return; } @@ -103,16 +148,28 @@ async function checkGuild(client, guild) { let channel = null; try { channel = await client.channels.fetch(channelId); + if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Successfully fetched channel ${channel.name} (${channelId}) in ${guildName}`); + if (channel.type !== 0) { // 0 is text channel - console.error(`TwitchWatcher: channel ${channelId} is not a text channel (type: ${channel.type})`); + console.error(`❌ TwitchWatcher: Channel ${channelId} in ${guildName} is not a text channel (type: ${channel.type})`); channel = null; + } else { + // Check if bot has permission to send messages + const permissions = channel.permissionsFor(client.user); + if (!permissions || !permissions.has('SendMessages')) { + console.error(`❌ TwitchWatcher: Bot lacks SendMessages permission in channel ${channel.name} (${channelId}) for ${guildName}`); + channel = null; + } else if (debugMode) { + console.log(`🔍 [DEBUG] TwitchWatcher: Bot has SendMessages permission in ${channel.name}`); + } } } catch (e) { - console.error(`TwitchWatcher: failed to fetch channel ${channelId}:`, e && e.message ? e.message : e); + console.error(`❌ TwitchWatcher: Failed to fetch channel ${channelId} for ${guildName}:`, e && e.message ? e.message : e); channel = null; } if (!channel) { // Channel not found or inaccessible; skip + if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Skipping announcements for ${guildName} - channel unavailable`); return; } @@ -121,40 +178,51 @@ async function checkGuild(client, guild) { // Clear announced entries for users that are no longer live for (const u of users) { - const key = `${guild.id}:${u}`; + const key = `${guildId}:${u}`; if (!liveLogins.has(u) && announced.has(key)) { announced.delete(key); + if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Cleared announcement for ${u} in ${guildName} (stream ended)`); } } // Announce each live once per live session for (const s of live) { const login = (s.user_login || '').toLowerCase(); - const key = `${guild.id}:${login}`; - if (announced.has(key)) continue; // already announced for this live session + const key = `${guildId}:${login}`; + if (announced.has(key)) { + if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Skipping ${login} in ${guildName} - already announced`); + continue; // already announced for this live session + } + + if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Preparing announcement for ${login} in ${guildName}`); + // mark announced for this session announced.set(key, { started_at: s.started_at || new Date().toISOString() }); // Build and send embed (standardized layout) try { - // Announce without per-guild log spam const { EmbedBuilder } = require('discord.js'); // Attempt to enrich with user bio (description) if available let bio = ''; try { const info = await fetchUserInfo(login); if (info && info.description) bio = info.description.slice(0, 200); - } catch (_) {} + if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Fetched user info for ${login} - bio length: ${bio.length}`); + } catch (e) { + if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Failed to fetch user info for ${login}:`, e && e.message ? e.message : e); + } + const embed = new EmbedBuilder() - .setColor(0x9146FF) + .setColor('#6441A5') // Twitch purple + .setAuthor({ name: s.user_name, iconURL: s.profile_image_url || undefined, url: s.url }) .setTitle(s.title || `${s.user_name} is live`) .setURL(s.url) - .setAuthor({ name: s.user_name, iconURL: s.profile_image_url || undefined, url: s.url }) - .setThumbnail(s.thumbnail_url || s.profile_image_url || undefined) + .setThumbnail(s.profile_image_url || undefined) .addFields( { name: 'Category', value: s.game_name || 'Unknown', inline: true }, { name: 'Viewers', value: String(s.viewer_count || 0), inline: true } ) + .setImage(s.thumbnail_url ? s.thumbnail_url.replace('{width}', '640').replace('{height}', '360') + `?t=${Date.now()}` : null) .setDescription(bio || (s.description || '').slice(0, 200)) .setFooter({ text: `ehchadservices • Started: ${s.started_at ? new Date(s.started_at).toLocaleString() : 'unknown'}` }); @@ -167,43 +235,75 @@ async function checkGuild(client, guild) { } else { prefixMsg = `🔴 ${s.user_name} is now live!`; } + + // Replace template variables in custom messages + prefixMsg = prefixMsg + .replace(/\{user\}/g, s.user_name || login) + .replace(/\{title\}/g, s.title || 'Untitled Stream') + .replace(/\{category\}/g, s.game_name || 'Unknown') + .replace(/\{viewers\}/g, String(s.viewer_count || 0)); + + if (debugMode) { + console.log(`🔍 [DEBUG] TwitchWatcher: Sending announcement for ${login} in ${guildName} to #${channel.name}`); + console.log(`🔍 [DEBUG] TwitchWatcher: Message content: "${prefixMsg}"`); + } + // Ensure we always hyperlink the title via embed; prefix is optional add above embed const payload = prefixMsg ? { content: prefixMsg, embeds: [embed] } : { embeds: [embed] }; await channel.send(payload); - console.log(`🔔 Announced live: ${login} - ${(s.title || '').slice(0, 80)}`); + console.log(`🔔 TwitchWatcher: Successfully announced ${login} in ${guildName} - "${(s.title || '').slice(0, 80)}"`); } catch (e) { - console.error(`TwitchWatcher: failed to send announcement for ${login}:`, e && e.message ? e.message : e); + console.error(`❌ TwitchWatcher: Failed to send announcement for ${login} in ${guildName}:`, e && e.message ? e.message : e); // fallback const msg = `🔴 ${s.user_name} is live: **${s.title}**\nWatch: ${s.url}`; - try { await channel.send({ content: msg }); console.log('TwitchWatcher: fallback message sent'); } catch (err) { console.error('TwitchWatcher: fallback send failed:', err && err.message ? err.message : err); } + try { + if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Attempting fallback message for ${login} in ${guildName}`); + await channel.send({ content: msg }); + console.log(`🔔 TwitchWatcher: Fallback message sent for ${login} in ${guildName}`); + } catch (err) { + console.error(`❌ TwitchWatcher: Fallback send failed for ${login} in ${guildName}:`, err && err.message ? err.message : err); + } } } } catch (e) { - console.error('Error checking guild for live streams:', e && e.message ? e.message : e); + console.error(`❌ TwitchWatcher: Error checking guild ${guildName} (${guildId}) for live streams:`, e && e.message ? e.message : e); } } async function poll(client) { if (polling) return; polling = true; - console.log(`🔁 TwitchWatcher started, polling every ${Math.round(pollIntervalMs/1000)}s`); + console.log(`🔁 TwitchWatcher started, polling every ${Math.round(pollIntervalMs/1000)}s${debugMode ? ' (DEBUG MODE ENABLED)' : ''}`); + // Initial check on restart: send messages for currently live users try { const guilds = Array.from(client.guilds.cache.values()); + if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Initial check for ${guilds.length} guilds`); + for (const g of guilds) { - await checkGuild(client, g).catch(err => { console.error('TwitchWatcher: initial checkGuild error', err && err.message ? err.message : err); }); + if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Initial check for guild ${g.name} (${g.id})`); + await checkGuild(client, g).catch(err => { + console.error(`❌ TwitchWatcher: Initial checkGuild error for ${g.name}:`, err && err.message ? err.message : err); + }); } } catch (e) { - console.error('Error during initial twitch check:', e && e.message ? e.message : e); + console.error('❌ TwitchWatcher: Error during initial twitch check:', e && e.message ? e.message : e); } + while (polling) { try { const guilds = Array.from(client.guilds.cache.values()); + if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Polling cycle starting for ${guilds.length} guilds`); + for (const g of guilds) { - await checkGuild(client, g).catch(err => { console.error('TwitchWatcher: checkGuild error', err && err.message ? err.message : err); }); + await checkGuild(client, g).catch(err => { + console.error(`❌ TwitchWatcher: checkGuild error for ${g.name}:`, err && err.message ? err.message : err); + }); } + + if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Polling cycle completed, waiting ${Math.round(pollIntervalMs/1000)}s`); } catch (e) { - console.error('Error during twitch poll loop:', e && e.message ? e.message : e); + console.error('❌ TwitchWatcher: Error during twitch poll loop:', e && e.message ? e.message : e); } await new Promise(r => setTimeout(r, pollIntervalMs)); } diff --git a/frontend/src/components/server/ServerSettings.js b/frontend/src/components/server/ServerSettings.js index 776d1d8..3fe92f1 100644 --- a/frontend/src/components/server/ServerSettings.js +++ b/frontend/src/components/server/ServerSettings.js @@ -1151,30 +1151,32 @@ const ServerSettings = () => { {adminLogs.length === 0 ? ( No logs available. ) : ( - adminLogs.map(log => ( - - - - - {log.action.toUpperCase()} - {log.targetUsername || 'Unknown User'} by {log.moderatorUsername || 'Unknown User'} - - Reason: {log.reason} - {log.duration && Duration: {log.duration}} - - {new Date(log.timestamp).toLocaleString()} - + + {adminLogs.map((log) => ( + + + + + {log.action.toUpperCase()} - {log.targetUsername || 'Unknown User'} by {log.moderatorUsername || 'Unknown User'} + + Reason: {log.reason} + {log.duration && Duration: {log.duration}} + + {new Date(log.timestamp).toLocaleString()} + + + setDeleteLogDialog({ open: true, logId: log.id, logAction: log.action })} + sx={{ ml: 1 }} + > + + - setDeleteLogDialog({ open: true, logId: log.id, logAction: log.action })} - sx={{ ml: 1 }} - > - - - - )) + ))} + )}