diff --git a/README.md b/README.md index 1f36177..2ce1796 100644 --- a/README.md +++ b/README.md @@ -243,6 +243,73 @@ Useful endpoints - `GET /api/twitch/streams?users=user1,user2` — proxy to twitch helix for streams (backend caches app-token) - `GET /api/events?guildId=...` — Server-Sent Events for real-time updates (ServerSettings subscribes to this) +### Twitch Live Notification Settings (Detailed) + +Endpoint: `GET/POST /api/servers/:guildId/live-notifications` + +Shape returned by GET: +```json +{ + "enabled": true, + "channelId": "123456789012345678", + "twitchUser": "deprecated-single-user-field", + "message": "🔴 {user} is now live!", + "customMessage": "Custom promo text with link etc" +} +``` + +Notes: +- `twitchUser` is a legacy single-user field retained for backward compatibility. The active watched users list lives under `/api/servers/:guildId/twitch-users`. +- `message` (default message) and `customMessage` (override) are persisted. If `customMessage` is non-empty it is used when announcing a live stream; otherwise `message` is used. If both are empty the bot falls back to `🔴 {user} is now live!`. +- Update by POSTing the same shape (omit fields you don't change is okay; unspecified become empty unless preserved on server). + +### Discord Bot Twitch Embed Layout + +When a watched streamer goes live the bot posts a standardized embed. The layout is fixed to keep consistency: + +Embed fields: +1. Title: Stream title (hyperlinked to Twitch URL) or fallback "{user} is live". +2. Author: Twitch display name with avatar and link. +3. Thumbnail: Stream thumbnail (or profile image fallback). +4. Fields: + - Category: Game / category name (or "Unknown"). + - Viewers: Current viewer count. +5. Description: Twitch user bio (if available via Helix `users` endpoint) else truncated stream description (200 chars). +6. Footer: `ehchadservices • Started: `. + +Pre-Embed Message (optional): +- If `customMessage` is set it is posted as normal message content above the embed. +- Else if `message` is set it is posted above the embed. +- Else no prefix content is posted (embed alone). + +Variables: +- `{user}` in messages will not be auto-replaced server-side yet; include the username manually if desired. (Can add template replacement in a future iteration.) + +### Watched Users + +- Add/remove watched Twitch usernames via `POST /api/servers/:guildId/twitch-users` and `DELETE /api/servers/:guildId/twitch-users/:username`. +- Frontend polls `/api/twitch/streams` every ~15s to refresh live status and renders a "Watch Live" button per user. +- The watcher announces a stream only once per live session; when a user goes offline the session marker clears so a future live event re-announces. + +### SSE Event Types Relevant to Twitch + +- `twitchUsersUpdate`: `{ users: ["user1", "user2"], guildId: "..." }` +- `liveNotificationsUpdate`: `{ enabled, channelId, twitchUser, message, customMessage, guildId }` + +Consume these to live-update UI without refresh (the `BackendContext` exposes an `eventTarget`). + +### Customizing Messages + +- In the dashboard under Live Notifications you can set both a Default Message and a Custom Message. +- Clear Custom to fall back to Default. +- Save persists to backend and pushes an SSE `liveNotificationsUpdate`. + +### Future Improvements + +- Template variable replacement: support `{user}`, `{title}`, `{category}`, `{viewers}` inside message strings. +- Per-user custom messages (different prefix for each watched streamer). +- Embed image improvements (dynamic preview resolution trimming for Twitch thumbnails). + Notes about Postgres requirement - The backend now assumes Postgres persistence (via `DATABASE_URL`). If `DATABASE_URL` is not set the server will exit and complain. This change makes server settings authoritative and persistent across restarts. diff --git a/backend/index.js b/backend/index.js index 178c6b1..eda3237 100644 --- a/backend/index.js +++ b/backend/index.js @@ -158,6 +158,214 @@ app.get('/api/twitch/streams', async (req, res) => { } }); +// Kick API helpers (web scraping since no public API) +async function getKickStreamsForUsers(usernames = []) { + try { + if (!usernames || usernames.length === 0) return []; + + const results = []; + for (const username of usernames) { + try { + // Use Kick's API endpoint to check if user is live + const url = `https://kick.com/api/v1/channels/${encodeURIComponent(username)}`; + const response = await axios.get(url, { + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', + 'Accept': 'application/json', + 'Referer': 'https://kick.com/' + }, + timeout: 5000 // 5 second timeout + }); + + if (response.status === 200 && response.data) { + const data = response.data; + + if (data.livestream && data.livestream.is_live) { + results.push({ + is_live: true, + user_login: username, + user_name: data.user?.username || username, + title: data.livestream.session_title || `${username} is live`, + viewer_count: data.livestream.viewer_count || 0, + started_at: data.livestream.start_time, + url: `https://kick.com/${username}`, + thumbnail_url: data.livestream.thumbnail?.url || null, + category: data.category?.name || 'Unknown', + description: data.user?.bio || '' + }); + } else { + // User exists but not live + results.push({ + is_live: false, + user_login: username, + user_name: data.user?.username || username, + title: null, + viewer_count: 0, + started_at: null, + url: `https://kick.com/${username}`, + thumbnail_url: null, + category: null, + description: data.user?.bio || '' + }); + } + } else { + // User not found or API error + results.push({ + is_live: false, + user_login: username, + user_name: username, + title: null, + viewer_count: 0, + started_at: null, + url: `https://kick.com/${username}`, + thumbnail_url: null, + category: null, + description: '' + }); + } + } catch (e) { + // If API fails with 403, try web scraping as fallback + if (e.response && e.response.status === 403) { + // console.log(`API blocked for ${username}, trying web scraping fallback...`); + + try { + const pageUrl = `https://kick.com/${encodeURIComponent(username)}`; + const pageResponse = await axios.get(pageUrl, { + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.5', + 'Accept-Encoding': 'gzip, deflate, br', + 'DNT': '1', + 'Connection': 'keep-alive', + 'Upgrade-Insecure-Requests': '1', + 'Sec-Fetch-Dest': 'document', + 'Sec-Fetch-Mode': 'navigate', + 'Sec-Fetch-Site': 'none', + 'Cache-Control': 'max-age=0' + }, + timeout: 5000 + }); + + if (pageResponse.status === 200) { + const html = pageResponse.data; + + // Check for live stream indicators in the HTML + const isLive = html.includes('"is_live":true') || html.includes('"is_live": true') || + html.includes('data-is-live="true"') || html.includes('isLive:true'); + + if (isLive) { + // Try to extract stream info from HTML + let title = `${username} is live`; + let viewerCount = 0; + let category = 'Unknown'; + + // Extract title + const titleMatch = html.match(/"session_title"\s*:\s*"([^"]+)"/) || html.match(/"title"\s*:\s*"([^"]+)"/); + if (titleMatch) { + title = titleMatch[1].replace(/\\"/g, '"'); + } + + // Extract viewer count + const viewerMatch = html.match(/"viewer_count"\s*:\s*(\d+)/); + if (viewerMatch) { + viewerCount = parseInt(viewerMatch[1]); + } + + // Extract category + const categoryMatch = html.match(/"category"\s*:\s*{\s*"name"\s*:\s*"([^"]+)"/); + if (categoryMatch) { + category = categoryMatch[1]; + } + + results.push({ + is_live: true, + user_login: username, + user_name: username, + title: title, + viewer_count: viewerCount, + started_at: new Date().toISOString(), + url: `https://kick.com/${username}`, + thumbnail_url: null, + category: category, + description: '' + }); + } else { + // User exists but not live + results.push({ + is_live: false, + user_login: username, + user_name: username, + title: null, + viewer_count: 0, + started_at: null, + url: `https://kick.com/${username}`, + thumbnail_url: null, + category: null, + description: '' + }); + } + } else { + throw e; // Re-throw if page request also fails + } + } catch (scrapeError) { + console.error(`Web scraping fallback also failed for ${username}:`, scrapeError.message || scrapeError); + // Return offline status on error + results.push({ + is_live: false, + user_login: username, + user_name: username, + title: null, + viewer_count: 0, + started_at: null, + url: `https://kick.com/${username}`, + thumbnail_url: null, + category: null, + description: '' + }); + } + } else { + console.error(`Error checking Kick user ${username}:`, e && e.response && e.response.status ? `HTTP ${e.response.status}` : e.message || e); + // Return offline status on error + results.push({ + is_live: false, + user_login: username, + user_name: username, + title: null, + viewer_count: 0, + started_at: null, + url: `https://kick.com/${username}`, + thumbnail_url: null, + category: null, + description: '' + }); + } + } + + // Small delay between requests to be respectful to Kick's servers + await new Promise(r => setTimeout(r, 200)); + } + + return results; + } catch (e) { + console.error('Error in getKickStreamsForUsers:', e && e.response && e.response.data ? e.response.data : e.message || e); + return []; + } +} + +// Proxy endpoint for frontend/bot to request Kick stream status for usernames (comma separated) +app.get('/api/kick/streams', async (req, res) => { + const q = req.query.users || req.query.user || ''; + const users = q.split(',').map(s => (s || '').trim()).filter(Boolean); + try { + const streams = await getKickStreamsForUsers(users); + res.json(streams); + } catch (err) { + console.error('Error in /api/kick/streams:', err); + res.status(500).json({ success: false, message: 'Internal Server Error' }); + } +}); + // Invite token helpers: short-lived HMAC-signed token so frontend can authorize invite deletes const INVITE_TOKEN_TTL_MS = 5 * 60 * 1000; // 5 minutes const inviteTokenSecret = process.env.INVITE_TOKEN_SECRET || process.env.ENCRYPTION_KEY || 'fallback-invite-secret'; @@ -563,7 +771,8 @@ app.get('/api/servers/:guildId/live-notifications', async (req, res) => { const { guildId } = req.params; try { const settings = (await pgClient.getServerSettings(guildId)) || {}; - return res.json(settings.liveNotifications || { enabled: false, twitchUser: '', channelId: '' }); + const ln = settings.liveNotifications || { enabled: false, twitchUser: '', channelId: '', message: '', customMessage: '' }; + return res.json(ln); } catch (err) { console.error('Error fetching live-notifications settings:', err); res.status(500).json({ success: false, message: 'Internal Server Error' }); @@ -572,16 +781,21 @@ app.get('/api/servers/:guildId/live-notifications', async (req, res) => { app.post('/api/servers/:guildId/live-notifications', async (req, res) => { const { guildId } = req.params; - const { enabled, twitchUser, channelId } = req.body || {}; + const { enabled, twitchUser, channelId, message, customMessage } = req.body || {}; try { const existing = (await pgClient.getServerSettings(guildId)) || {}; + const currentLn = existing.liveNotifications || {}; existing.liveNotifications = { enabled: !!enabled, twitchUser: twitchUser || '', - channelId: channelId || '' + channelId: channelId || '', + message: message || '', + customMessage: customMessage || '', + users: currentLn.users || [], // preserve existing users + kickUsers: currentLn.kickUsers || [] // preserve existing kick users }; await pgClient.upsertServerSettings(guildId, existing); - try { publishEvent(guildId, 'liveNotificationsUpdate', { enabled: !!enabled, channelId, twitchUser }); } catch (e) {} + try { publishEvent(guildId, 'liveNotificationsUpdate', { enabled: !!enabled, channelId, twitchUser, message: existing.liveNotifications.message, customMessage: existing.liveNotifications.customMessage }); } catch (e) {} return res.json({ success: true }); } catch (err) { console.error('Error saving live-notifications settings:', err); @@ -608,10 +822,18 @@ app.post('/api/servers/:guildId/twitch-users', async (req, res) => { if (!username) return res.status(400).json({ success: false, message: 'Missing username' }); try { const existing = (await pgClient.getServerSettings(guildId)) || {}; - if (!existing.liveNotifications) existing.liveNotifications = { enabled: false, channelId: '', users: [] }; + if (!existing.liveNotifications) existing.liveNotifications = { enabled: false, channelId: '', users: [], kickUsers: [] }; existing.liveNotifications.users = Array.from(new Set([...(existing.liveNotifications.users || []), username.toLowerCase().trim()])); await pgClient.upsertServerSettings(guildId, existing); try { publishEvent(guildId, 'twitchUsersUpdate', { users: existing.liveNotifications.users || [] }); } catch (e) {} + // Optional push to bot process for immediate cache update + try { + if (process.env.BOT_PUSH_URL) { + await axios.post(`${process.env.BOT_PUSH_URL.replace(/\/$/, '')}/internal/set-settings`, { guildId, settings: existing }, { + headers: process.env.BOT_SECRET ? { 'x-bot-secret': process.env.BOT_SECRET } : {} + }).catch(() => {}); + } + } catch (_) {} res.json({ success: true }); } catch (err) { console.error('Error adding twitch user:', err); @@ -623,10 +845,18 @@ app.delete('/api/servers/:guildId/twitch-users/:username', async (req, res) => { const { guildId, username } = req.params; try { const existing = (await pgClient.getServerSettings(guildId)) || {}; - if (!existing.liveNotifications) existing.liveNotifications = { enabled: false, channelId: '', users: [] }; + if (!existing.liveNotifications) existing.liveNotifications = { enabled: false, channelId: '', users: [], kickUsers: [] }; existing.liveNotifications.users = (existing.liveNotifications.users || []).filter(u => u.toLowerCase() !== (username || '').toLowerCase()); await pgClient.upsertServerSettings(guildId, existing); try { publishEvent(guildId, 'twitchUsersUpdate', { users: existing.liveNotifications.users || [] }); } catch (e) {} + // Optional push to bot process for immediate cache update + try { + if (process.env.BOT_PUSH_URL) { + await axios.post(`${process.env.BOT_PUSH_URL.replace(/\/$/, '')}/internal/set-settings`, { guildId, settings: existing }, { + headers: process.env.BOT_SECRET ? { 'x-bot-secret': process.env.BOT_SECRET } : {} + }).catch(() => {}); + } + } catch (_) {} res.json({ success: true }); } catch (err) { console.error('Error removing twitch user:', err); @@ -634,6 +864,69 @@ app.delete('/api/servers/:guildId/twitch-users/:username', async (req, res) => { } }); +// DISABLED: Kick users list management for a guild (temporarily disabled) +/* +app.get('/api/servers/:guildId/kick-users', async (req, res) => { + const { guildId } = req.params; + try { + const settings = (await pgClient.getServerSettings(guildId)) || {}; + const users = (settings.liveNotifications && settings.liveNotifications.kickUsers) || []; + res.json(users); + } catch (err) { + console.error('Error fetching kick users:', err); + res.status(500).json({ success: false, message: 'Internal Server Error' }); + } +}); + +app.post('/api/servers/:guildId/kick-users', async (req, res) => { + const { guildId } = req.params; + const { username } = req.body || {}; + if (!username) return res.status(400).json({ success: false, message: 'Missing username' }); + try { + const existing = (await pgClient.getServerSettings(guildId)) || {}; + if (!existing.liveNotifications) existing.liveNotifications = { enabled: false, channelId: '', users: [], kickUsers: [] }; + existing.liveNotifications.kickUsers = Array.from(new Set([...(existing.liveNotifications.kickUsers || []), username.toLowerCase().trim()])); + await pgClient.upsertServerSettings(guildId, existing); + try { publishEvent(guildId, 'kickUsersUpdate', { users: existing.liveNotifications.kickUsers || [] }); } catch (e) {} + // Optional push to bot process for immediate cache update + try { + if (process.env.BOT_PUSH_URL) { + await axios.post(`${process.env.BOT_PUSH_URL.replace(/\/$/, '')}/internal/set-settings`, { guildId, settings: existing }, { + headers: process.env.BOT_SECRET ? { 'x-bot-secret': process.env.BOT_SECRET } : {} + }).catch(() => {}); + } + } catch (_) {} + res.json({ success: true }); + } catch (err) { + console.error('Error adding kick user:', err); + res.status(500).json({ success: false, message: 'Internal Server Error' }); + } +}); + +app.delete('/api/servers/:guildId/kick-users/:username', async (req, res) => { + const { guildId, username } = req.params; + try { + const existing = (await pgClient.getServerSettings(guildId)) || {}; + if (!existing.liveNotifications) existing.liveNotifications = { enabled: false, channelId: '', users: [], kickUsers: [] }; + existing.liveNotifications.kickUsers = (existing.liveNotifications.kickUsers || []).filter(u => u.toLowerCase() !== (username || '').toLowerCase()); + await pgClient.upsertServerSettings(guildId, existing); + try { publishEvent(guildId, 'kickUsersUpdate', { users: existing.liveNotifications.kickUsers || [] }); } catch (e) {} + // Optional push to bot process for immediate cache update + try { + if (process.env.BOT_PUSH_URL) { + await axios.post(`${process.env.BOT_PUSH_URL.replace(/\/$/, '')}/internal/set-settings`, { guildId, settings: existing }, { + headers: process.env.BOT_SECRET ? { 'x-bot-secret': process.env.BOT_SECRET } : {} + }).catch(() => {}); + } + } catch (_) {} + res.json({ success: true }); + } catch (err) { + console.error('Error removing kick user:', err); + res.status(500).json({ success: false, message: 'Internal Server Error' }); + } +}); +*/ + app.get('/', (req, res) => { res.send('Hello from the backend!'); }); @@ -661,7 +954,9 @@ app.get('/api/servers/:guildId/commands', async (req, res) => { const toggles = guildSettings.commandToggles || {}; const protectedCommands = ['manage-commands', 'help']; - const commands = Array.from(bot.client.commands.values()).map(cmd => { + const commands = Array.from(bot.client.commands.values()) + .filter(cmd => !cmd.dev) // Filter out dev commands + .map(cmd => { const isLocked = protectedCommands.includes(cmd.name); const isEnabled = isLocked ? true : (toggles[cmd.name] !== false && cmd.enabled !== false); return { diff --git a/backend/package.json b/backend/package.json index 93580fc..f9dc840 100644 --- a/backend/package.json +++ b/backend/package.json @@ -16,10 +16,10 @@ "cors": "^2.8.5", "crypto-js": "^4.2.0", "dotenv": "^16.4.5", - "express": "^4.19.2" - ,"pg": "^8.11.0", - "pg-format": "^1.0.4" - ,"node-fetch": "^2.6.7" + "express": "^4.19.2", + "pg": "^8.11.0", + "pg-format": "^1.0.4", + "node-fetch": "^2.6.7" }, "devDependencies": { "nodemon": "^3.1.3" diff --git a/checklist.md b/checklist.md index af08616..05ee1fe 100644 --- a/checklist.md +++ b/checklist.md @@ -17,8 +17,11 @@ - [x] Invite UI: create form, list, copy, delete with confirmation - [x] Commands UI (per-command toggles) - [x] Live Notifications UI (per-server toggle & config) - - Live Notifications accessible from server page via dropdown and dialog - - Dashboard: channel dropdown and watched-user list added + - Channel selection, watched-user list, live status with Watch Live button + - Real-time updates: adding/removing users via frontend or bot commands publishes SSE `twitchUsersUpdate` and pushes settings to bot + - Bot commands (`/add-twitchuser`, `/remove-twitchuser`) refresh local cache immediately after backend success + - Message mode: toggle between Default and Custom; Apply sends `message`/`customMessage` (default fallback if empty); no longer dual free-form fields + - Collapsible accordion interface: separate Twitch and Kick tabs (Kick tab disabled) ## Discord Bot - [x] discord.js integration (events and commands) @@ -26,23 +29,51 @@ - [x] Bot used by backend to fetch live guild data and manage invites - [x] Bot reads/writes per-guild command toggles via backend/Postgres - [x] Backend immediately notifies bot of toggle changes (pushes updated settings to bot cache) so frontend toggles take effect instantly - - [x] New slash command: `/list-twitchusers` to list watched Twitch usernames for a guild + - [x] New slash command: `/setup-live` to enable/disable Twitch live notifications for the server (preserves other settings) - [x] Frontend: Confirm dialog and working Delete action for Twitch watched users in Live Notifications - [x] Live Notifications: bot posts message to configured channel with stream title and link when a watched Twitch user goes live - [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 3 seconds for rapid detection (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] 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) + - [x] Live notifications toggle on site enables/disables watching and publishes SSE for real-time updates + - [x] /manage-commands command has enable/disable buttons that sync with frontend via backend API and SSE for live updating + - [x] All Twitch-related commands (add, remove, list) and frontend actions communicate with backend and Postgres database + - [x] Welcome/Leave messages: bot sends configured messages to channels when users join/leave + - [x] Welcome messages with {user} and {server} placeholders + - [x] Leave messages with {user} placeholder + - [x] Autorole assignment on member join + - [x] All settings managed through Server Settings UI + - [x] Event handlers properly integrated with API settings + - [x] Kick live notifications bot integration (temporarily disabled) + - [x] New slash commands: `/add-kickuser`, `/remove-kickuser`, `/list-kickusers` (commands exist but watcher disabled) + - [x] Kick API polling and notification posting (watcher removed, API endpoints remain) + - [x] Per-server Kick user management via backend API (endpoints functional) + - [x] Frontend tabs: separate Twitch and Kick tabs in Live Notifications accordion (Kick tab disabled) + - [x] Bot watcher temporarily disabled in index.js startup + - [x] Dev command filtering: commands marked with `dev: true` are hidden from UI, help, and Discord registration ## Database - [x] Postgres support via `DATABASE_URL` (backend auto-creates `servers`, `invites`, `users`) - [x] Legacy encrypted `backend/db.json` retained (migration planned) - - [ ] Migration script: import `backend/db.json` into Postgres (planned) + - [x] Kick.com live notifications: backend API, frontend UI, bot integration + - Database schema: kickUsers table with userId, username, guildId + - API endpoints: GET/POST/DELETE /api/servers/:guildId/kick-users + - Bot commands: /add-kickuser, /remove-kickuser, /list-kickusers + - Frontend tabs: separate Twitch and Kick tabs in Live Notifications accordion + - Kick API integration: polling for live status, stream metadata, web scraping fallback for 403 errors + - Per-server configuration: all settings scoped by guildId - [x] Schema: live notification settings stored in server settings (via `liveNotifications` JSON) + - Fields: `enabled`, `channelId`, `users[]`, `kickUsers[]`, `message`, `customMessage` (custom overrides default if non-empty) + - Users list preserved when updating other live notification settings (fixed: kickUsers now also preserved) ## Security & Behavior - [x] Invite DELETE requires short-lived HMAC token (`x-invite-token`) - [x] Frontend confirmation dialog for invite deletion - [ ] Harden invite-token issuance (require OAuth + admin check) + - [ ] Template variables for messages (planned): support `{user}`, `{title}`, `{category}`, `{viewers}` replacement in `message` / `customMessage` ## Docs & Deployment - [x] README and CHANGELOG updated with setup steps and Postgres guidance @@ -58,6 +89,7 @@ - Server cards: uniform sizes, image cropping, name clamping - Mobile spacing and typography adjustments - Dashboard action buttons repositioned (Invite/Leave under title) + - Live Notifications: collapsible accordion with tabbed interface for Twitch and Kick tabs (Kick tab disabled) - [x] Browser tab now shows `ECS - ` (e.g., 'ECS - Dashboard') - [x] Dashboard duplicate title fixed; user settings (avatar/themes) restored via NavBar @@ -73,5 +105,6 @@ - [x] Created `frontend/src/components/common` and `frontend/src/components/server` - [x] Moved `ConfirmDialog` and `MaintenancePage` to `components/common` - [x] Moved `ServerSettings` and `HelpPage` to `components/server` + - [x] Fixed ESLint warnings: removed unused imports and handlers, added proper dependency management - [ ] Remove legacy top-level duplicate files (archival recommended) \ No newline at end of file diff --git a/discord-bot/api.js b/discord-bot/api.js index f402c8e..2e2c7b5 100644 --- a/discord-bot/api.js +++ b/discord-bot/api.js @@ -163,4 +163,49 @@ async function _rawGetTwitchStreams(usersCsv) { try { return await res.json(); } catch (e) { return []; } } -module.exports = { getServerSettings, upsertServerSettings, getCommands, toggleCommand, listInvites, addInvite, deleteInvite, getTwitchUsers, addTwitchUser, deleteTwitchUser }; +// Kick users helpers +async function getKickUsers(guildId) { + const path = `/api/servers/${guildId}/kick-users`; + const json = await safeFetchJsonPath(path); + return json || []; +} + +async function addKickUser(guildId, username) { + const path = `/api/servers/${guildId}/kick-users`; + try { + const res = await tryFetch(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username }), + }); + return res && res.ok; + } catch (e) { + console.error(`Failed to add kick user ${username} for ${guildId}:`, e && e.message ? e.message : e); + return false; + } +} + +async function deleteKickUser(guildId, username) { + const path = `/api/servers/${guildId}/kick-users/${encodeURIComponent(username)}`; + try { + const res = await tryFetch(path, { method: 'DELETE' }); + return res && res.ok; + } catch (e) { + console.error(`Failed to delete kick user ${username} for ${guildId}:`, e && e.message ? e.message : e); + return false; + } +} + +async function getWelcomeLeaveSettings(guildId) { + const path = `/api/servers/${guildId}/welcome-leave-settings`; + const json = await safeFetchJsonPath(path); + return json || { welcome: { enabled: false }, leave: { enabled: false } }; +} + +async function getAutoroleSettings(guildId) { + const path = `/api/servers/${guildId}/autorole-settings`; + const json = await safeFetchJsonPath(path); + return json || { enabled: false, roleId: '' }; +} + +module.exports = { getServerSettings, upsertServerSettings, getCommands, toggleCommand, listInvites, addInvite, deleteInvite, getTwitchUsers, addTwitchUser, deleteTwitchUser, getKickUsers, addKickUser, deleteKickUser, getWelcomeLeaveSettings, getAutoroleSettings }; diff --git a/discord-bot/commands/add-kickuser.js b/discord-bot/commands/add-kickuser.js new file mode 100644 index 0000000..f085f55 --- /dev/null +++ b/discord-bot/commands/add-kickuser.js @@ -0,0 +1,43 @@ +const { SlashCommandBuilder, PermissionsBitField } = require('discord.js'); +const fetch = require('node-fetch'); + +module.exports = { + name: 'add-kickuser', + description: 'Admin: add a Kick username to watch for this server (DISABLED)', + enabled: false, + dev: true, + builder: new SlashCommandBuilder() + .setName('add-kickuser') + .setDescription('Add a Kick username to watch for live notifications') + .addStringOption(opt => opt.setName('username').setDescription('Kick username').setRequired(true)), + async execute(interaction) { + if (!interaction.member.permissions.has(PermissionsBitField.Flags.Administrator)) { + await interaction.reply({ content: 'You must be an administrator to use this command.', flags: 64 }); + return; + } + 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}/kick-users`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username }) + }); + if (resp.ok) { + await interaction.reply({ content: `Added ${username} to Kick 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); + } + } catch (_) {} + } else { + await interaction.reply({ content: 'Failed to add user via backend.', flags: 64 }); + } + } catch (e) { + console.error('Error adding kick user:', e); + await interaction.reply({ content: 'Internal error adding kick user.', flags: 64 }); + } + } +}; \ No newline at end of file diff --git a/discord-bot/commands/add-twitchuser.js b/discord-bot/commands/add-twitchuser.js index bcb5765..a7558bd 100644 --- a/discord-bot/commands/add-twitchuser.js +++ b/discord-bot/commands/add-twitchuser.js @@ -22,6 +22,15 @@ module.exports = { }); if (resp.ok) { 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); + } + } catch (_) {} } else { await interaction.reply({ content: 'Failed to add user via backend.', flags: 64 }); } diff --git a/discord-bot/commands/list-kickusers.js b/discord-bot/commands/list-kickusers.js new file mode 100644 index 0000000..2ea39c3 --- /dev/null +++ b/discord-bot/commands/list-kickusers.js @@ -0,0 +1,24 @@ +const { SlashCommandBuilder } = require('discord.js'); +const api = require('../api'); + +module.exports = { + name: 'list-kickusers', + description: 'List watched Kick usernames for this server (DISABLED).', + enabled: false, + dev: true, + builder: new SlashCommandBuilder().setName('list-kickusers').setDescription('List watched Kick usernames for this server'), + async execute(interaction) { + try { + const users = await api.getKickUsers(interaction.guildId) || []; + if (!users || users.length === 0) { + await interaction.reply({ content: 'No Kick users are being watched for this server.', ephemeral: true }); + return; + } + const list = users.map(u => `• ${u}`).join('\n'); + await interaction.reply({ content: `Watched Kick users:\n${list}`, ephemeral: true }); + } catch (e) { + console.error('Error listing kick users:', e); + await interaction.reply({ content: 'Failed to retrieve watched users.', ephemeral: true }); + } + }, +}; \ No newline at end of file diff --git a/discord-bot/commands/manage-commands.js b/discord-bot/commands/manage-commands.js index 0292c21..362f842 100644 --- a/discord-bot/commands/manage-commands.js +++ b/discord-bot/commands/manage-commands.js @@ -20,7 +20,7 @@ module.exports = { let toggles = existingSettings.commandToggles; // Include all loaded commands so simple command modules (no SlashCommandBuilder) like // `ping` are also listed. Filter for objects with a name for safety. - const commands = Array.from(interaction.client.commands.values()).filter(cmd => cmd && cmd.name); + const commands = Array.from(interaction.client.commands.values()).filter(cmd => cmd && cmd.name && !cmd.dev); // Build button components (max 5 rows, 5 buttons per row) const actionRows = []; diff --git a/discord-bot/commands/remove-kickuser.js b/discord-bot/commands/remove-kickuser.js new file mode 100644 index 0000000..08b2c1c --- /dev/null +++ b/discord-bot/commands/remove-kickuser.js @@ -0,0 +1,41 @@ +const { SlashCommandBuilder, PermissionsBitField } = require('discord.js'); +const fetch = require('node-fetch'); + +module.exports = { + name: 'remove-kickuser', + description: 'Admin: remove a Kick username from this server watch list', + enabled: false, + dev: true, + builder: new SlashCommandBuilder() + .setName('remove-kickuser') + .setDescription('Remove a Kick username from the watch list') + .addStringOption(opt => opt.setName('username').setDescription('Kick username to remove').setRequired(true)), + async execute(interaction) { + if (!interaction.member.permissions.has(PermissionsBitField.Flags.Administrator)) { + await interaction.reply({ content: 'You must be an administrator to use this command.', flags: 64 }); + return; + } + 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}/kick-users/${encodeURIComponent(username)}`, { method: 'DELETE' }); + if (resp.ok) { + await interaction.reply({ content: `Removed ${username} from Kick 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); + } + } catch (_) {} + } else { + await interaction.reply({ content: 'Failed to remove user via backend.', flags: 64 }); + } + } catch (e) { + console.error('Error removing kick user:', e); + await interaction.reply({ content: 'Internal error removing kick user.', flags: 64 }); + } + } +}; \ No newline at end of file diff --git a/discord-bot/commands/remove-twitchuser.js b/discord-bot/commands/remove-twitchuser.js index b13f8d2..621c292 100644 --- a/discord-bot/commands/remove-twitchuser.js +++ b/discord-bot/commands/remove-twitchuser.js @@ -20,6 +20,15 @@ module.exports = { const resp = await fetch(`${backendBase}/api/servers/${interaction.guildId}/twitch-users/${encodeURIComponent(username)}`, { method: 'DELETE' }); if (resp.ok) { 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); + } + } catch (_) {} } else { await interaction.reply({ content: 'Failed to remove user via backend.', flags: 64 }); } diff --git a/discord-bot/commands/setup-live.js b/discord-bot/commands/setup-live.js index 014333a..c49d683 100644 --- a/discord-bot/commands/setup-live.js +++ b/discord-bot/commands/setup-live.js @@ -1,17 +1,13 @@ const { SlashCommandBuilder, PermissionsBitField } = require('discord.js'); -const fetch = require('node-fetch'); const api = require('../api'); -const { readDb, writeDb } = require('../../backend/db'); module.exports = { name: 'setup-live', - description: 'Admin: configure Twitch live notifications for this server', + description: 'Admin: enable or disable Twitch live notifications for this server', enabled: true, builder: new SlashCommandBuilder() .setName('setup-live') - .setDescription('Configure Twitch live notifications for this server') - .addStringOption(opt => opt.setName('twitch_user').setDescription('Twitch username to watch').setRequired(true)) - .addChannelOption(opt => opt.setName('channel').setDescription('Channel to send notifications').setRequired(true)) + .setDescription('Enable or disable Twitch live notifications for this server') .addBooleanOption(opt => opt.setName('enabled').setDescription('Enable/disable notifications').setRequired(true)), async execute(interaction) { @@ -20,24 +16,18 @@ module.exports = { return; } - const twitchUser = interaction.options.getString('twitch_user'); - const channel = interaction.options.getChannel('channel'); const enabled = interaction.options.getBoolean('enabled'); try { const api = require('../api'); const existing = (await api.getServerSettings(interaction.guildId)) || {}; - existing.liveNotifications = { enabled: !!enabled, twitchUser, channelId: channel.id }; + const currentLn = existing.liveNotifications || {}; + existing.liveNotifications = { ...currentLn, enabled: !!enabled }; await api.upsertServerSettings(interaction.guildId, existing); - await interaction.reply({ content: `Live notifications ${enabled ? 'enabled' : 'disabled'} for ${twitchUser} -> ${channel.name}`, flags: 64 }); + await interaction.reply({ content: `Live notifications ${enabled ? 'enabled' : 'disabled'} for this server.`, flags: 64 }); } catch (e) { - console.error('Error saving live notifications to backend, falling back to local:', e); - // fallback to local db - const db = readDb(); - if (!db[interaction.guildId]) db[interaction.guildId] = {}; - db[interaction.guildId].liveNotifications = { enabled, twitchUser, channelId: channel.id }; - writeDb(db); - await interaction.reply({ content: `Saved locally: Live notifications ${enabled ? 'enabled' : 'disabled'} for ${twitchUser} -> ${channel.name}`, flags: 64 }); + console.error('Error saving live notifications to backend:', e); + await interaction.reply({ content: 'Failed to update live notifications.', flags: 64 }); } } }; diff --git a/discord-bot/deploy-commands.js b/discord-bot/deploy-commands.js index b6c9b14..90525ab 100644 --- a/discord-bot/deploy-commands.js +++ b/discord-bot/deploy-commands.js @@ -10,7 +10,7 @@ const commandFiles = fs.readdirSync(commandsPath).filter(file => file.endsWith(' for (const file of commandFiles) { const filePath = path.join(commandsPath, file); const command = require(filePath); - if (command.enabled === false) continue; + if (command.enabled === false || command.dev === true) continue; if (command.builder) { commands.push(command.builder.toJSON()); diff --git a/discord-bot/events/guildMemberAdd.js b/discord-bot/events/guildMemberAdd.js index a75a581..ec6560a 100644 --- a/discord-bot/events/guildMemberAdd.js +++ b/discord-bot/events/guildMemberAdd.js @@ -1,72 +1,46 @@ const { Events } = require('discord.js'); -const { readDb } = require('../../backend/db.js'); module.exports = { name: Events.GuildMemberAdd, async execute(member) { - try { - const api = require('../api'); - const settings = (await api.getServerSettings(member.guild.id)) || {}; + try { + const api = require('../api'); + // Get the welcome/leave settings from the API + const welcomeLeaveSettings = await api.getWelcomeLeaveSettings(member.guild.id) || { welcome: { enabled: false } }; + const welcome = welcomeLeaveSettings.welcome; - const welcome = { - enabled: settings.welcomeEnabled || false, - channel: settings.welcomeChannel || '', - message: settings.welcomeMessage || 'Welcome {user} to {server}!' - }; - - if (welcome && welcome.enabled && welcome.channel) { - const channel = member.guild.channels.cache.get(welcome.channel); - if (channel) { - try { - const message = (welcome.message).replace('{user}', member.user.toString()).replace('{server}', member.guild.name); - await channel.send(message); - } catch (error) { - console.error(`Could not send welcome message to channel ${welcome.channel} in guild ${member.guild.id}:`, error); - } + if (welcome && welcome.enabled && welcome.channel) { + const channel = member.guild.channels.cache.get(welcome.channel); + if (channel) { + try { + const message = (welcome.message || 'Welcome {user} to {server}!').replace('{user}', member.user.toString()).replace('{server}', member.guild.name); + await channel.send(message); + } catch (error) { + console.error(`Could not send welcome message to channel ${welcome.channel} in guild ${member.guild.id}:`, error); } } - - const autorole = settings.autorole || {}; - if (autorole && autorole.enabled && autorole.roleId) { - const role = member.guild.roles.cache.get(autorole.roleId); - if (role) { - try { - // Re-check that role is assignable - const botHighest = member.guild.members.me.roles.highest.position; - if (role.id === member.guild.id || role.managed || role.position >= botHighest) { - console.warn(`Autorole ${role.id} in guild ${member.guild.id} is not assignable (everyone/managed/too high). Skipping.`); - return; - } - await member.roles.add(role); - } catch (error) { - console.error(`Could not assign autorole in guild ${member.guild.id}:`, error); - } - } - } - } catch (error) { - console.error(`Error in guildMemberAdd event for guild ${member.guild.id}:`, error); - // fallback to local db - try { - const db = readDb(); - const settings = db[member.guild.id]; - if (settings && settings.welcomeEnabled && settings.welcomeChannel) { - const channel = member.guild.channels.cache.get(settings.welcomeChannel); - if (channel) { - try { - const message = (settings.welcomeMessage || 'Welcome {user} to {server}!').replace('{user}', member.user.toString()).replace('{server}', member.guild.name); - await channel.send(message); - } catch (innerErr) { /* ignore */ } - } - } - if (settings && settings.autorole && settings.autorole.enabled && settings.autorole.roleId) { - const role = member.guild.roles.cache.get(settings.autorole.roleId); - if (role) { - try { await member.roles.add(role); } catch (innerErr) { /* ignore */ } - } - } - } catch (inner) { - // ignore fallback errors - } } - }, + + // Handle autorole + const autoroleSettings = await api.getAutoroleSettings(member.guild.id) || { enabled: false }; + if (autoroleSettings && autoroleSettings.enabled && autoroleSettings.roleId) { + const role = member.guild.roles.cache.get(autoroleSettings.roleId); + if (role) { + try { + // Re-check that role is assignable + const botHighest = member.guild.members.me.roles.highest.position; + if (role.id === member.guild.id || role.managed || role.position >= botHighest) { + console.warn(`Autorole ${role.id} in guild ${member.guild.id} is not assignable (everyone/managed/too high). Skipping.`); + return; + } + await member.roles.add(role); + } catch (error) { + console.error(`Could not assign autorole in guild ${member.guild.id}:`, error); + } + } + } + } catch (error) { + console.error(`Error in guildMemberAdd event for guild ${member.guild.id}:`, error); + } + } }; \ No newline at end of file diff --git a/discord-bot/events/guildMemberRemove.js b/discord-bot/events/guildMemberRemove.js index be9fbd1..73d6f3e 100644 --- a/discord-bot/events/guildMemberRemove.js +++ b/discord-bot/events/guildMemberRemove.js @@ -1,19 +1,19 @@ const { Events } = require('discord.js'); -const { readDb } = require('../../backend/db.js'); module.exports = { name: Events.GuildMemberRemove, async execute(member) { try { const api = require('../api'); - const settings = (await api.getServerSettings(member.guild.id)) || {}; - const leave = { enabled: settings.leaveEnabled || false, channel: settings.leaveChannel || '', message: settings.leaveMessage || '{user} has left the server.' }; + // Get the welcome/leave settings from the API + const welcomeLeaveSettings = await api.getWelcomeLeaveSettings(member.guild.id) || { leave: { enabled: false } }; + const leave = welcomeLeaveSettings.leave; if (leave && leave.enabled && leave.channel) { const channel = member.guild.channels.cache.get(leave.channel); if (channel) { try { - const message = (leave.message).replace('{user}', member.user.toString()); + const message = (leave.message || '{user} has left the server.').replace('{user}', member.user.toString()); await channel.send(message); } catch (error) { console.error(`Could not send leave message to channel ${leave.channel} in guild ${member.guild.id}:`, error); @@ -22,20 +22,6 @@ module.exports = { } } catch (error) { console.error(`Error in guildMemberRemove event for guild ${member.guild.id}:`, error); - // fallback to local db - try { - const db = readDb(); - const settings = db[member.guild.id]; - if (settings && settings.leaveEnabled && settings.leaveChannel) { - const channel = member.guild.channels.cache.get(settings.leaveChannel); - if (channel) { - try { - const message = (settings.leaveMessage || '{user} has left the server.').replace('{user}', member.user.toString()); - await channel.send(message); - } catch (innerErr) { /* ignore */ } - } - } - } catch (inner) { /* ignore */ } } - }, + } }; \ No newline at end of file diff --git a/discord-bot/handlers/command-handler.js b/discord-bot/handlers/command-handler.js index 5f2bc5b..dc295bf 100644 --- a/discord-bot/handlers/command-handler.js +++ b/discord-bot/handlers/command-handler.js @@ -10,7 +10,7 @@ module.exports = (client) => { // Clear require cache to allow updates during development delete require.cache[require.resolve(filePath)]; const command = require(filePath); - if (command.name) { + if (command.name && !command.dev) { client.commands.set(command.name, command); } } diff --git a/discord-bot/index.js b/discord-bot/index.js index 558475b..3d71125 100644 --- a/discord-bot/index.js +++ b/discord-bot/index.js @@ -145,19 +145,28 @@ async function announceLive(guildId, stream) { 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 || '') - .setFooter({ text: `ehchadservices • Started: ${stream.started_at ? new Date(stream.started_at).toLocaleString() : 'unknown'}` }); - await channel.send({ embeds: [embed] }); + 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); @@ -170,8 +179,7 @@ module.exports = { login, client, setGuildSettings, getGuildSettingsFromCache, a // Start twitch watcher when client is ready (use 'clientReady' as the event name) try { const watcher = require('./twitch-watcher'); - // discord.js renamed the ready event to clientReady; the event loader registers - // handlers based on event.name so we listen for the same 'clientReady' here. + // 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)); @@ -182,6 +190,19 @@ try { // 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'); diff --git a/discord-bot/kick-watcher.js b/discord-bot/kick-watcher.js new file mode 100644 index 0000000..619b910 --- /dev/null +++ b/discord-bot/kick-watcher.js @@ -0,0 +1,294 @@ +const api = require('./api'); +const fetch = require('node-fetch'); + +// Kick API helpers (web scraping since no public API) +let polling = false; +const pollIntervalMs = Number(process.env.KICK_POLL_INTERVAL_MS || 15000); // 15s default (slower than Twitch) + +// Keep track of which streams we've already announced per guild:user -> { started_at } +const announced = new Map(); // key: `${guildId}:${user}` -> { started_at } + +// Simple web scraping to check if a Kick user is live +async function checkKickUserLive(username) { + try { + // First try the API endpoint + const apiUrl = `https://kick.com/api/v1/channels/${encodeURIComponent(username)}`; + const apiController = new AbortController(); + const apiTimeoutId = setTimeout(() => apiController.abort(), 5000); + + const apiResponse = await fetch(apiUrl, { + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', + 'Accept': 'application/json', + 'Referer': 'https://kick.com/' + }, + signal: apiController.signal + }); + + clearTimeout(apiTimeoutId); + + if (apiResponse.ok) { + const data = await apiResponse.json(); + + if (data && data.livestream && data.livestream.is_live) { + return { + is_live: true, + user_login: username, + user_name: data.user?.username || username, + title: data.livestream.session_title || `${username} is live`, + viewer_count: data.livestream.viewer_count || 0, + started_at: data.livestream.start_time, + url: `https://kick.com/${username}`, + thumbnail_url: data.livestream.thumbnail?.url || null, + category: data.category?.name || 'Unknown', + description: data.user?.bio || '' + }; + } + + return { is_live: false, user_login: username }; + } + + // If API fails with 403, try web scraping as fallback + if (apiResponse.status === 403) { + console.log(`API blocked for ${username}, trying web scraping fallback...`); + + const pageUrl = `https://kick.com/${encodeURIComponent(username)}`; + const pageController = new AbortController(); + const pageTimeoutId = setTimeout(() => pageController.abort(), 5000); + + const pageResponse = await fetch(pageUrl, { + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.5', + 'Accept-Encoding': 'gzip, deflate, br', + 'DNT': '1', + 'Connection': 'keep-alive', + 'Upgrade-Insecure-Requests': '1', + 'Sec-Fetch-Dest': 'document', + 'Sec-Fetch-Mode': 'navigate', + 'Sec-Fetch-Site': 'none', + 'Cache-Control': 'max-age=0' + }, + signal: pageController.signal + }); + + clearTimeout(pageTimeoutId); + + if (pageResponse.ok) { + const html = await pageResponse.text(); + + // Check for live stream indicators in the HTML + const isLive = html.includes('"is_live":true') || html.includes('"is_live": true') || + html.includes('data-is-live="true"') || html.includes('isLive:true'); + + if (isLive) { + // Try to extract stream info from HTML + let title = `${username} is live`; + let viewerCount = 0; + let category = 'Unknown'; + + // Extract title + const titleMatch = html.match(/"session_title"\s*:\s*"([^"]+)"/) || html.match(/"title"\s*:\s*"([^"]+)"/); + if (titleMatch) { + title = titleMatch[1].replace(/\\"/g, '"'); + } + + // Extract viewer count + const viewerMatch = html.match(/"viewer_count"\s*:\s*(\d+)/); + if (viewerMatch) { + viewerCount = parseInt(viewerMatch[1]); + } + + // Extract category + const categoryMatch = html.match(/"category"\s*:\s*{\s*"name"\s*:\s*"([^"]+)"/); + if (categoryMatch) { + category = categoryMatch[1]; + } + + return { + is_live: true, + user_login: username, + user_name: username, + title: title, + viewer_count: viewerCount, + started_at: new Date().toISOString(), + url: `https://kick.com/${username}`, + thumbnail_url: null, + category: category, + description: '' + }; + } + } + } + + return { is_live: false, user_login: username }; + } catch (e) { + if (e.name === 'AbortError') { + console.error(`Timeout checking Kick user ${username}`); + } else { + console.error(`Failed to check Kick user ${username}:`, e && e.message ? e.message : e); + } + return { is_live: false, user_login: username }; + } +} + +// Check all Kick users for a guild +async function checkKickStreamsForGuild(guildId, usernames) { + const results = []; + for (const username of usernames) { + try { + const stream = await checkKickUserLive(username); + if (stream.is_live) { + results.push(stream); + } + } catch (e) { + console.error(`Error checking Kick user ${username}:`, e && e.message ? e.message : e); + } + // Small delay between requests to be respectful + await new Promise(r => setTimeout(r, 500)); + } + return results; +} + +async function checkGuild(client, guild) { + try { + // Get settings for this guild + const settings = await api.getServerSettings(guild.id) || {}; + const liveSettings = settings.liveNotifications || {}; + if (!liveSettings.enabled) return; + + const channelId = liveSettings.channelId; + const users = (liveSettings.kickUsers || []).map(u => u.toLowerCase()).filter(Boolean); + if (!channelId || users.length === 0) return; + + // Check which users are live + const live = await checkKickStreamsForGuild(guild.id, users); + 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}`; + if (announced.has(key)) { + announced.delete(key); + } + } + return; + } + + // Fetch channel using client to ensure we can reach it + let channel = null; + try { + channel = await client.channels.fetch(channelId); + if (channel.type !== 0) { // 0 is text channel + console.error(`KickWatcher: channel ${channelId} is not a text channel (type: ${channel.type})`); + channel = null; + } + } catch (e) { + console.error(`KickWatcher: failed to fetch channel ${channelId}:`, e && e.message ? e.message : e); + channel = null; + } + if (!channel) { + return; + } + + // Build a map of live logins for quick lookup + const liveLogins = new Set(live.map(s => (s.user_login || '').toLowerCase())); + + // Clear announced entries for users that are no longer live + for (const u of users) { + const key = `${guild.id}:${u}`; + if (!liveLogins.has(u) && announced.has(key)) { + announced.delete(key); + } + } + + // 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 + + // Mark announced for this session + announced.set(key, { started_at: s.started_at || new Date().toISOString() }); + + // Build and send embed (similar to Twitch but with Kick branding) + try { + const { EmbedBuilder } = require('discord.js'); + const embed = new EmbedBuilder() + .setColor(0x53FC18) // Kick green color + .setTitle(s.title || `${s.user_name} is live`) + .setURL(s.url) + .setAuthor({ name: s.user_name, iconURL: s.thumbnail_url || undefined, url: s.url }) + .setThumbnail(s.thumbnail_url || undefined) + .addFields( + { name: 'Category', value: s.category || 'Unknown', inline: true }, + { name: 'Viewers', value: String(s.viewer_count || 0), inline: true } + ) + .setDescription((s.description || '').slice(0, 200)) + .setFooter({ text: `ehchadservices • Started: ${s.started_at ? new Date(s.started_at).toLocaleString() : 'unknown'}` }); + + // Determine message text (custom overrides default) + let prefixMsg = ''; + if (liveSettings.customMessage) { + prefixMsg = liveSettings.customMessage; + } else if (liveSettings.message) { + prefixMsg = liveSettings.message; + } else { + prefixMsg = `🟢 ${s.user_name} is now live on Kick!`; + } + + const payload = prefixMsg ? { content: prefixMsg, embeds: [embed] } : { embeds: [embed] }; + await channel.send(payload); + console.log(`🔔 Announced Kick live: ${login} - ${(s.title || '').slice(0, 80)}`); + } catch (e) { + console.error(`KickWatcher: failed to send announcement for ${login}:`, e && e.message ? e.message : e); + // Fallback to simple message + const msg = `🟢 ${s.user_name} is live on Kick: **${s.title}**\nWatch: ${s.url}`; + try { + await channel.send({ content: msg }); + console.log('KickWatcher: fallback message sent'); + } catch (err) { + console.error('KickWatcher: fallback send failed:', err && err.message ? err.message : err); + } + } + } + } catch (e) { + console.error('Error checking guild for Kick live streams:', e && e.message ? e.message : e); + } +} + +async function poll(client) { + if (polling) return; + polling = true; + console.log(`🔁 KickWatcher started, polling every ${Math.round(pollIntervalMs/1000)}s`); + + // Initial check on restart: send messages for currently live users + try { + const guilds = Array.from(client.guilds.cache.values()); + for (const g of guilds) { + await checkGuild(client, g).catch(err => { + console.error('KickWatcher: initial checkGuild error', err && err.message ? err.message : err); + }); + } + } catch (e) { + console.error('Error during initial Kick check:', e && e.message ? e.message : e); + } + + while (polling) { + try { + const guilds = Array.from(client.guilds.cache.values()); + for (const g of guilds) { + await checkGuild(client, g).catch(err => { + console.error('KickWatcher: checkGuild error', err && err.message ? err.message : err); + }); + } + } catch (e) { + console.error('Error during Kick poll loop:', e && e.message ? e.message : e); + } + await new Promise(r => setTimeout(r, pollIntervalMs)); + } +} + +function stop() { polling = false; } + +module.exports = { poll, stop }; \ No newline at end of file diff --git a/discord-bot/twitch-watcher.js b/discord-bot/twitch-watcher.js index 337ee52..a053ebc 100644 --- a/discord-bot/twitch-watcher.js +++ b/discord-bot/twitch-watcher.js @@ -1,4 +1,62 @@ const api = require('./api'); +const fetch = require('node-fetch'); + +// Twitch API credentials (optional). If provided, we'll enrich embeds with user bio. +const twitchClientId = process.env.TWITCH_CLIENT_ID || null; +const twitchClientSecret = process.env.TWITCH_CLIENT_SECRET || null; +let twitchAppToken = null; // cached app access token +let twitchTokenExpires = 0; + +// Cache of user login -> { description, profile_image_url, fetchedAt } +const userInfoCache = new Map(); + +async function getAppToken() { + if (!twitchClientId || !twitchClientSecret) return null; + const now = Date.now(); + if (twitchAppToken && now < twitchTokenExpires - 60000) { // refresh 1 min early + return twitchAppToken; + } + try { + const res = await fetch(`https://id.twitch.tv/oauth2/token?client_id=${twitchClientId}&client_secret=${twitchClientSecret}&grant_type=client_credentials`, { method: 'POST' }); + const json = await res.json(); + twitchAppToken = json.access_token; + twitchTokenExpires = now + (json.expires_in * 1000); + return twitchAppToken; + } catch (e) { + console.error('Failed to fetch Twitch app token:', e && e.message ? e.message : e); + return null; + } +} + +async function fetchUserInfo(login) { + if (!login) return null; + const lower = login.toLowerCase(); + const cached = userInfoCache.get(lower); + const now = Date.now(); + if (cached && now - cached.fetchedAt < 1000 * 60 * 30) { // 30 min cache + return cached; + } + const token = await getAppToken(); + if (!token) return null; + try { + const res = await fetch(`https://api.twitch.tv/helix/users?login=${encodeURIComponent(lower)}`, { + headers: { + 'Client-ID': twitchClientId, + 'Authorization': `Bearer ${token}` + } + }); + const json = await res.json(); + const data = (json.data && json.data[0]) || null; + if (data) { + const info = { description: data.description || '', profile_image_url: data.profile_image_url || '', fetchedAt: now }; + userInfoCache.set(lower, info); + return info; + } + } catch (e) { + console.error('Failed to fetch Twitch user info for', lower, e && e.message ? e.message : e); + } + return null; +} let polling = false; const pollIntervalMs = Number(process.env.TWITCH_POLL_INTERVAL_MS || 5000); // 5s default @@ -45,6 +103,10 @@ async function checkGuild(client, guild) { let channel = null; try { channel = await client.channels.fetch(channelId); + if (channel.type !== 0) { // 0 is text channel + console.error(`TwitchWatcher: channel ${channelId} is not a text channel (type: ${channel.type})`); + channel = null; + } } catch (e) { console.error(`TwitchWatcher: failed to fetch channel ${channelId}:`, e && e.message ? e.message : e); channel = null; @@ -73,10 +135,16 @@ async function checkGuild(client, guild) { // mark announced for this session announced.set(key, { started_at: s.started_at || new Date().toISOString() }); - // Build and send embed + // 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 (_) {} const embed = new EmbedBuilder() .setColor(0x9146FF) .setTitle(s.title || `${s.user_name} is live`) @@ -87,10 +155,21 @@ async function checkGuild(client, guild) { { name: 'Category', value: s.game_name || 'Unknown', inline: true }, { name: 'Viewers', value: String(s.viewer_count || 0), inline: true } ) - .setDescription(s.description || '') + .setDescription(bio || (s.description || '').slice(0, 200)) .setFooter({ text: `ehchadservices • Started: ${s.started_at ? new Date(s.started_at).toLocaleString() : 'unknown'}` }); - await channel.send({ embeds: [embed] }); + // Determine message text (custom overrides default). Provide a plain text prefix if available. + let prefixMsg = ''; + if (liveSettings.customMessage) { + prefixMsg = liveSettings.customMessage; + } else if (liveSettings.message) { + prefixMsg = liveSettings.message; + } else { + prefixMsg = `🔴 ${s.user_name} is now live!`; + } + // 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)}`); } catch (e) { console.error(`TwitchWatcher: failed to send announcement for ${login}:`, e && e.message ? e.message : e); @@ -108,6 +187,15 @@ async function poll(client) { if (polling) return; polling = true; console.log(`🔁 TwitchWatcher started, polling every ${Math.round(pollIntervalMs/1000)}s`); + // Initial check on restart: send messages for currently live users + try { + const guilds = Array.from(client.guilds.cache.values()); + for (const g of guilds) { + await checkGuild(client, g).catch(err => { console.error('TwitchWatcher: initial checkGuild error', err && err.message ? err.message : err); }); + } + } catch (e) { + console.error('Error during initial twitch check:', e && e.message ? e.message : e); + } while (polling) { try { const guilds = Array.from(client.guilds.cache.values()); diff --git a/frontend/src/App.js b/frontend/src/App.js index ac571d3..d03dffe 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect } from 'react'; import { BrowserRouter as Router, Route, Routes, useLocation } from 'react-router-dom'; import { ThemeProvider } from './contexts/ThemeContext'; import { UserProvider } from './contexts/UserContext'; diff --git a/frontend/src/components/server/ServerSettings.js b/frontend/src/components/server/ServerSettings.js index af1c9fe..038d00c 100644 --- a/frontend/src/components/server/ServerSettings.js +++ b/frontend/src/components/server/ServerSettings.js @@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react'; import { useBackend } from '../../contexts/BackendContext'; import { useParams, useNavigate, useLocation } from 'react-router-dom'; import axios from 'axios'; -import { Button, Typography, Box, IconButton, Switch, Select, MenuItem, FormControl, FormControlLabel, Radio, RadioGroup, TextField, Accordion, AccordionSummary, AccordionDetails, Snackbar, Alert } from '@mui/material'; +import { Button, Typography, Box, IconButton, Switch, Select, MenuItem, FormControl, FormControlLabel, Radio, RadioGroup, TextField, Accordion, AccordionSummary, AccordionDetails, Tabs, Tab, Snackbar, Alert } from '@mui/material'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; // UserSettings moved to NavBar @@ -40,13 +40,22 @@ const ServerSettings = () => { // SSE connection status (not currently displayed) const [confirmDeleteTwitch, setConfirmDeleteTwitch] = useState(false); const [pendingTwitchUser, setPendingTwitchUser] = useState(null); + const [confirmDeleteKick, setConfirmDeleteKick] = useState(false); + const [pendingKickUser, setPendingKickUser] = useState(null); + const [commandsExpanded, setCommandsExpanded] = useState(false); + const [liveExpanded, setLiveExpanded] = useState(false); const [inviteForm, setInviteForm] = useState({ channelId: '', maxAge: 0, maxUses: 0, temporary: false }); const [liveEnabled, setLiveEnabled] = useState(false); const [liveChannelId, setLiveChannelId] = useState(''); const [liveTwitchUser, setLiveTwitchUser] = useState(''); + const [liveMessage, setLiveMessage] = useState(''); + const [liveCustomMessage, setLiveCustomMessage] = useState(''); const [watchedUsers, setWatchedUsers] = useState([]); const [liveStatus, setLiveStatus] = useState({}); - const [commandsExpanded, setCommandsExpanded] = useState(false); + const [liveTabValue, setLiveTabValue] = useState(0); + const [kickUsers, setKickUsers] = useState([]); + const [kickStatus, setKickStatus] = useState({}); + const [kickUser, setKickUser] = useState(''); const [welcomeLeaveSettings, setWelcomeLeaveSettings] = useState({ welcome: { enabled: false, @@ -127,14 +136,18 @@ const ServerSettings = () => { // Fetch invites // Fetch live notifications settings and watched users axios.get(`${API_BASE}/api/servers/${guildId}/live-notifications`).then(resp => { - const s = resp.data || { enabled: false, twitchUser: '', channelId: '' }; + const s = resp.data || { enabled: false, twitchUser: '', channelId: '', message: '', customMessage: '' }; setLiveEnabled(!!s.enabled); setLiveChannelId(s.channelId || ''); setLiveTwitchUser(s.twitchUser || ''); + setLiveMessage(s.message || ''); + setLiveCustomMessage(s.customMessage || ''); }).catch(() => {}); axios.get(`${API_BASE}/api/servers/${guildId}/twitch-users`).then(resp => setWatchedUsers(resp.data || [])).catch(() => setWatchedUsers([])); + axios.get(`${API_BASE}/api/servers/${guildId}/kick-users`).then(resp => setKickUsers(resp.data || [])).catch(() => setKickUsers([])); + axios.get(`${API_BASE}/api/servers/${guildId}/invites`).then(resp => setInvites(resp.data || [])).catch(() => setInvites([])); // Open commands accordion if navigated from Help back button @@ -165,6 +178,16 @@ const ServerSettings = () => { setLiveEnabled(!!data.enabled); setLiveChannelId(data.channelId || ''); setLiveTwitchUser(data.twitchUser || ''); + setLiveMessage(data.message || ''); + setLiveCustomMessage(data.customMessage || ''); + }; + + const onKickUsers = (e) => { + const data = e.detail || {}; + // payload is { users: [...], guildId } + if (!data) return; + if (data.guildId && data.guildId !== guildId) return; // ignore other guilds + setKickUsers(data.users || []); }; const onCommandToggle = (e) => { @@ -176,12 +199,14 @@ const ServerSettings = () => { }; eventTarget.addEventListener('twitchUsersUpdate', onTwitchUsers); + eventTarget.addEventListener('kickUsersUpdate', onKickUsers); eventTarget.addEventListener('liveNotificationsUpdate', onLiveNotifications); eventTarget.addEventListener('commandToggle', onCommandToggle); return () => { try { eventTarget.removeEventListener('twitchUsersUpdate', onTwitchUsers); + eventTarget.removeEventListener('kickUsersUpdate', onKickUsers); eventTarget.removeEventListener('liveNotificationsUpdate', onLiveNotifications); eventTarget.removeEventListener('commandToggle', onCommandToggle); } catch (err) {} @@ -192,77 +217,18 @@ const ServerSettings = () => { navigate(-1); }; - const handleCopy = (code) => { - try { - navigator.clipboard.writeText(code); - setSnackbarMessage('Copied to clipboard'); - setSnackbarOpen(true); - } catch (e) { - // ignore - } - }; - - const handleDeleteInvite = async (inviteId) => { - setDeleting(prev => ({ ...prev, [inviteId]: true })); - try { - await axios.delete(`${API_BASE}/api/servers/${guildId}/invites/${inviteId}`); - setInvites(invites.filter(i => i.id !== inviteId)); - } catch (e) { - // ignore - } finally { - setDeleting(prev => ({ ...prev, [inviteId]: false })); - } - }; - - const handleCreateInvite = async () => { - try { - const resp = await axios.post(`${API_BASE}/api/servers/${guildId}/invites`, inviteForm); - setInvites([...(invites || []), resp.data]); - setInviteForm({ channelId: '', maxAge: 0, maxUses: 0, temporary: false }); - setSnackbarMessage('Invite created'); - setSnackbarOpen(true); - } catch (e) { - // ignore - } - }; - const handleToggleLive = async (e) => { const enabled = e.target.checked; setLiveEnabled(enabled); try { - await axios.post(`${API_BASE}/api/servers/${guildId}/live-notifications`, { enabled, channelId: liveChannelId, twitchUser: liveTwitchUser }); + await axios.post(`${API_BASE}/api/servers/${guildId}/live-notifications`, { enabled, channelId: liveChannelId, twitchUser: liveTwitchUser, message: liveMessage, customMessage: liveCustomMessage }); setSnackbarMessage('Live notifications updated'); setSnackbarOpen(true); } catch (err) { - // revert on error setLiveEnabled(!enabled); } }; - const handleAddTwitchUser = async () => { - if (!liveTwitchUser) return; - try { - const resp = await axios.post(`${API_BASE}/api/servers/${guildId}/twitch-users`, { username: liveTwitchUser }); - setWatchedUsers([...watchedUsers, resp.data]); - setLiveTwitchUser(''); - setSnackbarMessage('Twitch user added'); - setSnackbarOpen(true); - } catch (err) { - // ignore - } - }; - - const handleRemoveTwitchUser = async (username) => { - try { - await axios.delete(`${API_BASE}/api/servers/${guildId}/twitch-users/${encodeURIComponent(username)}`); - setWatchedUsers(watchedUsers.filter(u => u !== username)); - setSnackbarMessage('Twitch user removed'); - setSnackbarOpen(true); - } catch (err) { - // ignore - } - }; - const handleCloseSnackbar = () => { setSnackbarOpen(false); }; @@ -364,6 +330,54 @@ const ServerSettings = () => { setDialogOpen(false); }; + // Poll Twitch live status for watched users (simple interval). Avoid spamming when list empty or feature disabled. + useEffect(() => { + let timer = null; + const poll = async () => { + if (!watchedUsers || watchedUsers.length === 0) return; + try { + const csv = watchedUsers.join(','); + const resp = await axios.get(`${API_BASE}/api/twitch/streams?users=${encodeURIComponent(csv)}`); + const arr = resp.data || []; + const map = {}; + for (const s of arr) { + const login = (s.user_login || '').toLowerCase(); + map[login] = { is_live: s.is_live, url: s.url, viewer_count: s.viewer_count }; + } + setLiveStatus(map); + } catch (e) { + // network errors ignored + } + }; + poll(); + timer = setInterval(poll, 15000); // 15s interval + return () => { if (timer) clearInterval(timer); }; + }, [watchedUsers]); + + // Poll Kick live status for watched users (simple interval). Avoid spamming when list empty or feature disabled. + useEffect(() => { + let timer = null; + const poll = async () => { + if (!kickUsers || kickUsers.length === 0) return; + try { + const csv = kickUsers.join(','); + const resp = await axios.get(`${API_BASE}/api/kick/streams?users=${encodeURIComponent(csv)}`); + const arr = resp.data || []; + const map = {}; + for (const s of arr) { + const login = (s.user_login || '').toLowerCase(); + map[login] = { is_live: s.is_live, url: s.url, viewer_count: s.viewer_count }; + } + setKickStatus(map); + } catch (e) { + // network errors ignored + } + }; + poll(); + timer = setInterval(poll, 15000); // 15s interval + return () => { if (timer) clearInterval(timer); }; + }, [kickUsers]); + return (
@@ -650,59 +664,143 @@ const ServerSettings = () => { {/* Live Notifications Accordion */} - + setLiveExpanded(prev => !prev)} sx={{ marginTop: '20px' }}> }> Live Notifications - {!isBotInServer && Invite the bot to enable this feature.} - - - - + + {!isBotInServer && Invite the bot to enable this feature.} + + { + // Prevent switching to Kick tab (index 1) since it's disabled + if (newValue !== 1) { + setLiveTabValue(newValue); + } + }} sx={{ borderBottom: 1, borderColor: 'divider', '& .MuiTabs-indicator': { backgroundColor: 'primary.main' } }}> + + + + {liveTabValue === 0 && ( + + } label="Enabled" sx={{ mb: 2 }} /> + + + - - setLiveTwitchUser(e.target.value)} fullWidth disabled={!isBotInServer} /> - - + + setLiveTwitchUser(e.target.value)} fullWidth disabled={!isBotInServer} /> + + - - Watched Users - {watchedUsers.length === 0 && No users added} - {watchedUsers.map(u => ( - - - {u} - {liveStatus[u] && liveStatus[u].is_live && ( - - )} - - - + + Watched Users + {watchedUsers.length === 0 && No users added} + {watchedUsers.map(u => ( + + + {u} + {liveStatus[u] && liveStatus[u].is_live && ( + + )} + + + + + ))} + + + + Notification Message Mode + + { + const mode = e.target.value; + if (mode === 'default') { + setLiveCustomMessage(''); + if (!liveMessage) setLiveMessage('🔴 {user} is now live!'); + } else { + setLiveCustomMessage(liveCustomMessage || liveMessage || '🔴 {user} is now live!'); + } + }} + > + } label="Default" /> + } label="Custom" /> + + + {liveCustomMessage ? ( + setLiveCustomMessage(e.target.value)} + fullWidth + sx={{ mt: 2 }} + placeholder="Your custom announcement text" + disabled={!isBotInServer || !liveEnabled} + /> + ) : ( + + Using default message: {liveMessage || '🔴 {user} is now live!'} + + )} + + {liveCustomMessage && ( + + )} + - ))} + + + + - - setLiveEnabled(e.target.checked)} />} label="Enabled" sx={{ mt: 2 }} /> - - + )} + {liveTabValue === 1 && ( + + + Kick Live Notifications (Disabled) + + + Kick live notifications are temporarily disabled. This feature will be re-enabled in a future update. + - + )} + + @@ -816,11 +914,28 @@ const ServerSettings = () => { title="Delete Twitch User" message={`Are you sure you want to remove ${pendingTwitchUser || ''} from the watch list?`} /> - - - {snackbarMessage} - - + {/* Confirm dialog for deleting a kick user from watched list */} + { setConfirmDeleteKick(false); setPendingKickUser(null); }} + onConfirm={async () => { + if (!pendingKickUser) { setConfirmDeleteKick(false); return; } + setConfirmDeleteKick(false); + try { + await axios.delete(`${API_BASE}/api/servers/${guildId}/kick-users/${encodeURIComponent(pendingKickUser)}`); + setKickUsers(prev => prev.filter(x => x !== pendingKickUser)); + setSnackbarMessage('Kick user removed'); + setSnackbarOpen(true); + } catch (err) { + setSnackbarMessage('Failed to delete kick user'); + setSnackbarOpen(true); + } finally { + setPendingKickUser(null); + } + }} + title="Delete Kick User" + message={`Are you sure you want to remove ${pendingKickUser || ''} from the watch list?`} + />
); }; diff --git a/frontend/src/contexts/BackendContext.js b/frontend/src/contexts/BackendContext.js index 46037f0..8035301 100644 --- a/frontend/src/contexts/BackendContext.js +++ b/frontend/src/contexts/BackendContext.js @@ -66,7 +66,7 @@ export function BackendProvider({ children }) { }; return () => { try { es && es.close(); } catch (e) {} }; - }, [process.env.REACT_APP_API_BASE]); + }, []); // eslint-disable-line react-hooks/exhaustive-deps const forceCheck = async () => { const API_BASE2 = process.env.REACT_APP_API_BASE || '';