Compare commits

...

2 Commits

Author SHA1 Message Date
8236c1e0e7 Fixed Invite Accordion 2025-10-10 05:12:54 -04:00
900ce85e2c Fixed Twitch Live notis 2025-10-09 19:24:02 -04:00
10 changed files with 390 additions and 80 deletions

View File

@@ -1049,7 +1049,36 @@ app.get('/api/servers/:guildId/invites', async (req, res) => {
app.post('/api/servers/:guildId/invites', async (req, res) => { app.post('/api/servers/:guildId/invites', async (req, res) => {
try { try {
const { guildId } = req.params; 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); const guild = bot.client.guilds.cache.get(guildId);
if (!guild) return res.status(404).json({ success: false, message: 'Guild not found' }); 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 }); res.json({ success: true, invite: item });
} catch (error) { } 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' }); res.status(500).json({ success: false, message: 'Internal Server Error' });
} }
}); });

View File

@@ -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: 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] 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] 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] 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] 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) - [x] Bot adds/removes users via backend endpoints ensuring single source of truth (Postgres)
@@ -86,7 +92,16 @@
- [x] Frontend delete buttons for individual logs and delete all logs with confirm dialogs - [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] 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] 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] 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 ## Database
- [x] Postgres support via `DATABASE_URL` (backend auto-creates `servers`, `invites`, `users`) - [x] Postgres support via `DATABASE_URL` (backend auto-creates `servers`, `invites`, `users`)
@@ -155,4 +170,3 @@
- [x] Fixed ESLint warnings: removed unused imports and handlers, added proper dependency management - [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] 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 - [x] Navbar visibility: enhanced with solid background and stronger border for better visibility across all themes

View File

@@ -90,10 +90,24 @@ async function listInvites(guildId) {
async function addInvite(guildId, invite) { async function addInvite(guildId, invite) {
const path = `/api/servers/${guildId}/invites`; const path = `/api/servers/${guildId}/invites`;
try { 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, { const res = await tryFetch(path, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(invite), body: JSON.stringify(body),
}); });
return res && res.ok; return res && res.ok;
} catch (e) { } catch (e) {
@@ -208,4 +222,44 @@ async function getAutoroleSettings(guildId) {
return json || { enabled: false, roleId: '' }; return json || { enabled: false, roleId: '' };
} }
module.exports = { getServerSettings, upsertServerSettings, getCommands, toggleCommand, listInvites, addInvite, deleteInvite, getTwitchUsers, addTwitchUser, deleteTwitchUser, 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 };

View File

@@ -1,5 +1,5 @@
const { SlashCommandBuilder, PermissionsBitField } = require('discord.js'); const { SlashCommandBuilder, PermissionsBitField } = require('discord.js');
const fetch = require('node-fetch'); const api = require('../api');
module.exports = { module.exports = {
name: 'add-twitchuser', name: 'add-twitchuser',
@@ -16,20 +16,14 @@ module.exports = {
} }
const username = interaction.options.getString('username').toLowerCase().trim(); const username = interaction.options.getString('username').toLowerCase().trim();
try { try {
const backendBase = process.env.BACKEND_BASE || `http://${process.env.HOST || '127.0.0.1'}:${process.env.PORT || 3002}`; const success = await api.addTwitchUser(interaction.guildId, username);
const resp = await fetch(`${backendBase}/api/servers/${interaction.guildId}/twitch-users`, { if (success) {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username })
});
if (resp.ok) {
await interaction.reply({ content: `Added ${username} to watch list.`, flags: 64 }); await interaction.reply({ content: `Added ${username} to watch list.`, flags: 64 });
// Refresh cached settings from backend so watcher sees new user immediately // Refresh cached settings from backend so watcher sees new user immediately
try { try {
const settingsResp = await fetch(`${backendBase}/api/servers/${interaction.guildId}/settings`); const settings = await api.getServerSettings(interaction.guildId);
if (settingsResp.ok) {
const json = await settingsResp.json();
const bot = require('..'); const bot = require('..');
if (bot && bot.setGuildSettings) bot.setGuildSettings(interaction.guildId, json); if (bot && bot.setGuildSettings) bot.setGuildSettings(interaction.guildId, settings);
}
} catch (_) {} } catch (_) {}
} else { } else {
await interaction.reply({ content: 'Failed to add user via backend.', flags: 64 }); await interaction.reply({ content: 'Failed to add user via backend.', flags: 64 });

View File

@@ -1,5 +1,5 @@
const { SlashCommandBuilder, PermissionsBitField } = require('discord.js'); const { SlashCommandBuilder, PermissionsBitField } = require('discord.js');
const fetch = require('node-fetch'); const api = require('../api');
module.exports = { module.exports = {
name: 'remove-twitchuser', name: 'remove-twitchuser',
@@ -16,18 +16,14 @@ module.exports = {
} }
const username = interaction.options.getString('username').toLowerCase().trim(); const username = interaction.options.getString('username').toLowerCase().trim();
try { try {
const backendBase = process.env.BACKEND_BASE || `http://${process.env.HOST || '127.0.0.1'}:${process.env.PORT || 3002}`; const success = await api.deleteTwitchUser(interaction.guildId, username);
const resp = await fetch(`${backendBase}/api/servers/${interaction.guildId}/twitch-users/${encodeURIComponent(username)}`, { method: 'DELETE' }); if (success) {
if (resp.ok) {
await interaction.reply({ content: `Removed ${username} from watch list.`, flags: 64 }); await interaction.reply({ content: `Removed ${username} from watch list.`, flags: 64 });
// Refresh cached settings from backend // Refresh cached settings from backend
try { try {
const settingsResp = await fetch(`${backendBase}/api/servers/${interaction.guildId}/settings`); const settings = await api.getServerSettings(interaction.guildId);
if (settingsResp.ok) {
const json = await settingsResp.json();
const bot = require('..'); const bot = require('..');
if (bot && bot.setGuildSettings) bot.setGuildSettings(interaction.guildId, json); if (bot && bot.setGuildSettings) bot.setGuildSettings(interaction.guildId, settings);
}
} catch (_) {} } catch (_) {}
} else { } else {
await interaction.reply({ content: 'Failed to remove user via backend.', flags: 64 }); await interaction.reply({ content: 'Failed to remove user via backend.', flags: 64 });

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
const { ActivityType } = require('discord.js'); const { ActivityType } = require('discord.js');
const deployCommands = require('../deploy-commands'); const deployCommands = require('../deploy-commands');
const api = require('../api');
module.exports = { module.exports = {
name: 'clientReady', 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 = [ const activities = [
{ name: 'Watch EhChad Live!', type: ActivityType.Streaming, url: 'https://twitch.tv/ehchad' }, { name: 'Watch EhChad Live!', type: ActivityType.Streaming, url: 'https://twitch.tv/ehchad' },
{ name: 'Follow EhChad!', type: ActivityType.Streaming, url: 'https://twitch.tv/ehchad' }, { name: 'Follow EhChad!', type: ActivityType.Streaming, url: 'https://twitch.tv/ehchad' },

View File

@@ -60,41 +60,86 @@ async function fetchUserInfo(login) {
let polling = false; let polling = false;
const pollIntervalMs = Number(process.env.TWITCH_POLL_INTERVAL_MS || 5000); // 5s default 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 } // Keep track of which streams we've already announced per guild:user -> { started_at }
const announced = new Map(); // key: `${guildId}:${user}` -> { started_at } const announced = new Map(); // key: `${guildId}:${user}` -> { started_at }
async function checkGuild(client, guild) { async function checkGuild(client, guild) {
const guildId = guild.id;
const guildName = guild.name;
try { try {
// Intentionally quiet: per-guild checking logs are suppressed to avoid spam if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Checking guild ${guildName} (${guildId})`);
const settings = await api.getServerSettings(guild.id) || {};
const settings = await api.getServerSettings(guildId) || {};
const liveSettings = settings.liveNotifications || {}; 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 channelId = liveSettings.channelId;
const users = (liveSettings.users || []).map(u => u.toLowerCase()).filter(Boolean); 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 // ask backend for current live streams
const query = users.join(','); const query = users.join(',');
if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Fetching streams for query: ${query}`);
const streams = await api._rawGetTwitchStreams ? api._rawGetTwitchStreams(query) : null; const streams = await api._rawGetTwitchStreams ? api._rawGetTwitchStreams(query) : null;
// If the helper isn't available, try backend proxy // If the helper isn't available, try backend proxy
let live = []; let live = [];
if (streams) live = streams.filter(s => s.is_live); if (streams && Array.isArray(streams)) {
else { 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 { try {
const resp = await api.tryFetchTwitchStreams(query); 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) { } catch (e) {
console.error(`❌ TwitchWatcher: Failed to fetch streams for ${guildName}:`, e && e.message ? e.message : e);
live = []; 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) { if (!live || live.length === 0) {
// No live streams: ensure any announced keys for these users are cleared so they can be re-announced later // No live streams: ensure any announced keys for these users are cleared so they can be re-announced later
for (const u of users) { for (const u of users) {
const key = `${guild.id}:${u}`; const key = `${guildId}:${u}`;
if (announced.has(key)) { if (announced.has(key)) {
announced.delete(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; return;
} }
@@ -103,16 +148,28 @@ async function checkGuild(client, guild) {
let channel = null; let channel = null;
try { try {
channel = await client.channels.fetch(channelId); 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 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; 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) { } 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; channel = null;
} }
if (!channel) { if (!channel) {
// Channel not found or inaccessible; skip // Channel not found or inaccessible; skip
if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Skipping announcements for ${guildName} - channel unavailable`);
return; return;
} }
@@ -121,40 +178,51 @@ async function checkGuild(client, guild) {
// Clear announced entries for users that are no longer live // Clear announced entries for users that are no longer live
for (const u of users) { for (const u of users) {
const key = `${guild.id}:${u}`; const key = `${guildId}:${u}`;
if (!liveLogins.has(u) && announced.has(key)) { if (!liveLogins.has(u) && announced.has(key)) {
announced.delete(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 // Announce each live once per live session
for (const s of live) { for (const s of live) {
const login = (s.user_login || '').toLowerCase(); const login = (s.user_login || '').toLowerCase();
const key = `${guild.id}:${login}`; const key = `${guildId}:${login}`;
if (announced.has(key)) continue; // already announced for this live session 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 // mark announced for this session
announced.set(key, { started_at: s.started_at || new Date().toISOString() }); announced.set(key, { started_at: s.started_at || new Date().toISOString() });
// Build and send embed (standardized layout) // Build and send embed (standardized layout)
try { try {
// Announce without per-guild log spam
const { EmbedBuilder } = require('discord.js'); const { EmbedBuilder } = require('discord.js');
// Attempt to enrich with user bio (description) if available // Attempt to enrich with user bio (description) if available
let bio = ''; let bio = '';
try { try {
const info = await fetchUserInfo(login); const info = await fetchUserInfo(login);
if (info && info.description) bio = info.description.slice(0, 200); 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() 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`) .setTitle(s.title || `${s.user_name} is live`)
.setURL(s.url) .setURL(s.url)
.setAuthor({ name: s.user_name, iconURL: s.profile_image_url || undefined, url: s.url }) .setThumbnail(s.profile_image_url || undefined)
.setThumbnail(s.thumbnail_url || s.profile_image_url || undefined)
.addFields( .addFields(
{ name: 'Category', value: s.game_name || 'Unknown', inline: true }, { name: 'Category', value: s.game_name || 'Unknown', inline: true },
{ name: 'Viewers', value: String(s.viewer_count || 0), 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)) .setDescription(bio || (s.description || '').slice(0, 200))
.setFooter({ text: `ehchadservices • Started: ${s.started_at ? new Date(s.started_at).toLocaleString() : 'unknown'}` }); .setFooter({ text: `ehchadservices • Started: ${s.started_at ? new Date(s.started_at).toLocaleString() : 'unknown'}` });
@@ -167,43 +235,75 @@ async function checkGuild(client, guild) {
} else { } else {
prefixMsg = `🔴 ${s.user_name} is now live!`; 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 // Ensure we always hyperlink the title via embed; prefix is optional add above embed
const payload = prefixMsg ? { content: prefixMsg, embeds: [embed] } : { embeds: [embed] }; const payload = prefixMsg ? { content: prefixMsg, embeds: [embed] } : { embeds: [embed] };
await channel.send(payload); 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) { } 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 // fallback
const msg = `🔴 ${s.user_name} is live: **${s.title}**\nWatch: ${s.url}`; 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) { } 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) { async function poll(client) {
if (polling) return; if (polling) return;
polling = true; 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 // Initial check on restart: send messages for currently live users
try { try {
const guilds = Array.from(client.guilds.cache.values()); 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) { 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) { } 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) { while (polling) {
try { try {
const guilds = Array.from(client.guilds.cache.values()); 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) { 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) { } 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)); await new Promise(r => setTimeout(r, pollIntervalMs));
} }

View File

@@ -269,6 +269,22 @@ const ServerSettings = () => {
setAdminLogs([]); 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('twitchUsersUpdate', onTwitchUsers);
eventTarget.addEventListener('kickUsersUpdate', onKickUsers); eventTarget.addEventListener('kickUsersUpdate', onKickUsers);
eventTarget.addEventListener('liveNotificationsUpdate', onLiveNotifications); eventTarget.addEventListener('liveNotificationsUpdate', onLiveNotifications);
@@ -276,6 +292,8 @@ const ServerSettings = () => {
eventTarget.addEventListener('adminLogAdded', onAdminLogAdded); eventTarget.addEventListener('adminLogAdded', onAdminLogAdded);
eventTarget.addEventListener('adminLogDeleted', onAdminLogDeleted); eventTarget.addEventListener('adminLogDeleted', onAdminLogDeleted);
eventTarget.addEventListener('adminLogsCleared', onAdminLogsCleared); eventTarget.addEventListener('adminLogsCleared', onAdminLogsCleared);
eventTarget.addEventListener('inviteCreated', onInviteCreated);
eventTarget.addEventListener('inviteDeleted', onInviteDeleted);
return () => { return () => {
try { try {
@@ -286,6 +304,8 @@ const ServerSettings = () => {
eventTarget.removeEventListener('adminLogAdded', onAdminLogAdded); eventTarget.removeEventListener('adminLogAdded', onAdminLogAdded);
eventTarget.removeEventListener('adminLogDeleted', onAdminLogDeleted); eventTarget.removeEventListener('adminLogDeleted', onAdminLogDeleted);
eventTarget.removeEventListener('adminLogsCleared', onAdminLogsCleared); eventTarget.removeEventListener('adminLogsCleared', onAdminLogsCleared);
eventTarget.removeEventListener('inviteCreated', onInviteCreated);
eventTarget.removeEventListener('inviteDeleted', onInviteDeleted);
} catch (err) {} } catch (err) {}
}; };
}, [eventTarget, guildId]); }, [eventTarget, guildId]);
@@ -669,7 +689,9 @@ const ServerSettings = () => {
<FormControl fullWidth> <FormControl fullWidth>
<Select value={inviteForm.channelId} onChange={(e) => setInviteForm(f => ({ ...f, channelId: e.target.value }))} displayEmpty> <Select value={inviteForm.channelId} onChange={(e) => setInviteForm(f => ({ ...f, channelId: e.target.value }))} displayEmpty>
<MenuItem value="">(Any channel)</MenuItem> <MenuItem value="">(Any channel)</MenuItem>
{channels.map(ch => (<MenuItem key={ch.id} value={ch.id}>{ch.name}</MenuItem>))} {channels.map((channel) => (
<MenuItem key={channel.id} value={channel.id}>{channel.name}</MenuItem>
))}
</Select> </Select>
</FormControl> </FormControl>
</Box> </Box>
@@ -778,7 +800,7 @@ const ServerSettings = () => {
displayEmpty displayEmpty
> >
<MenuItem value="" disabled>Select a channel</MenuItem> <MenuItem value="" disabled>Select a channel</MenuItem>
{channels.map(channel => ( {channels.map((channel) => (
<MenuItem key={channel.id} value={channel.id}>{channel.name}</MenuItem> <MenuItem key={channel.id} value={channel.id}>{channel.name}</MenuItem>
))} ))}
</Select> </Select>
@@ -819,7 +841,7 @@ const ServerSettings = () => {
displayEmpty displayEmpty
> >
<MenuItem value="" disabled>Select a channel</MenuItem> <MenuItem value="" disabled>Select a channel</MenuItem>
{channels.map(channel => ( {channels.map((channel) => (
<MenuItem key={channel.id} value={channel.id}>{channel.name}</MenuItem> <MenuItem key={channel.id} value={channel.id}>{channel.name}</MenuItem>
))} ))}
</Select> </Select>
@@ -1151,7 +1173,8 @@ const ServerSettings = () => {
{adminLogs.length === 0 ? ( {adminLogs.length === 0 ? (
<Typography>No logs available.</Typography> <Typography>No logs available.</Typography>
) : ( ) : (
adminLogs.map(log => ( <React.Fragment>
{adminLogs.map((log) => (
<Box key={log.id} sx={{ p: 1, border: '1px solid #eee', mb: 1, borderRadius: 1, bgcolor: 'background.paper' }}> <Box key={log.id} sx={{ p: 1, border: '1px solid #eee', mb: 1, borderRadius: 1, bgcolor: 'background.paper' }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}> <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<Box sx={{ flex: 1 }}> <Box sx={{ flex: 1 }}>
@@ -1174,7 +1197,8 @@ const ServerSettings = () => {
</IconButton> </IconButton>
</Box> </Box>
</Box> </Box>
)) ))}
</React.Fragment>
)} )}
</Box> </Box>
</Box> </Box>