Update backend, DB, Commands, Live Reloading
This commit is contained in:
67
README.md
67
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/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)
|
- `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: <localized start time>`.
|
||||||
|
|
||||||
|
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
|
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.
|
- 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.
|
||||||
|
|
||||||
|
|||||||
309
backend/index.js
309
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
|
// 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 INVITE_TOKEN_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||||
const inviteTokenSecret = process.env.INVITE_TOKEN_SECRET || process.env.ENCRYPTION_KEY || 'fallback-invite-secret';
|
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;
|
const { guildId } = req.params;
|
||||||
try {
|
try {
|
||||||
const settings = (await pgClient.getServerSettings(guildId)) || {};
|
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) {
|
} catch (err) {
|
||||||
console.error('Error fetching live-notifications settings:', err);
|
console.error('Error fetching live-notifications settings:', err);
|
||||||
res.status(500).json({ success: false, message: 'Internal Server Error' });
|
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) => {
|
app.post('/api/servers/:guildId/live-notifications', async (req, res) => {
|
||||||
const { guildId } = req.params;
|
const { guildId } = req.params;
|
||||||
const { enabled, twitchUser, channelId } = req.body || {};
|
const { enabled, twitchUser, channelId, message, customMessage } = req.body || {};
|
||||||
try {
|
try {
|
||||||
const existing = (await pgClient.getServerSettings(guildId)) || {};
|
const existing = (await pgClient.getServerSettings(guildId)) || {};
|
||||||
|
const currentLn = existing.liveNotifications || {};
|
||||||
existing.liveNotifications = {
|
existing.liveNotifications = {
|
||||||
enabled: !!enabled,
|
enabled: !!enabled,
|
||||||
twitchUser: twitchUser || '',
|
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);
|
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 });
|
return res.json({ success: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error saving live-notifications settings:', 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' });
|
if (!username) return res.status(400).json({ success: false, message: 'Missing username' });
|
||||||
try {
|
try {
|
||||||
const existing = (await pgClient.getServerSettings(guildId)) || {};
|
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()]));
|
existing.liveNotifications.users = Array.from(new Set([...(existing.liveNotifications.users || []), username.toLowerCase().trim()]));
|
||||||
await pgClient.upsertServerSettings(guildId, existing);
|
await pgClient.upsertServerSettings(guildId, existing);
|
||||||
try { publishEvent(guildId, 'twitchUsersUpdate', { users: existing.liveNotifications.users || [] }); } catch (e) {}
|
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 });
|
res.json({ success: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error adding twitch user:', 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;
|
const { guildId, username } = req.params;
|
||||||
try {
|
try {
|
||||||
const existing = (await pgClient.getServerSettings(guildId)) || {};
|
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());
|
existing.liveNotifications.users = (existing.liveNotifications.users || []).filter(u => u.toLowerCase() !== (username || '').toLowerCase());
|
||||||
await pgClient.upsertServerSettings(guildId, existing);
|
await pgClient.upsertServerSettings(guildId, existing);
|
||||||
try { publishEvent(guildId, 'twitchUsersUpdate', { users: existing.liveNotifications.users || [] }); } catch (e) {}
|
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 });
|
res.json({ success: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error removing twitch user:', 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) => {
|
app.get('/', (req, res) => {
|
||||||
res.send('Hello from the backend!');
|
res.send('Hello from the backend!');
|
||||||
});
|
});
|
||||||
@@ -661,7 +954,9 @@ app.get('/api/servers/:guildId/commands', async (req, res) => {
|
|||||||
const toggles = guildSettings.commandToggles || {};
|
const toggles = guildSettings.commandToggles || {};
|
||||||
const protectedCommands = ['manage-commands', 'help'];
|
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 isLocked = protectedCommands.includes(cmd.name);
|
||||||
const isEnabled = isLocked ? true : (toggles[cmd.name] !== false && cmd.enabled !== false);
|
const isEnabled = isLocked ? true : (toggles[cmd.name] !== false && cmd.enabled !== false);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -16,10 +16,10 @@
|
|||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"crypto-js": "^4.2.0",
|
"crypto-js": "^4.2.0",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"express": "^4.19.2"
|
"express": "^4.19.2",
|
||||||
,"pg": "^8.11.0",
|
"pg": "^8.11.0",
|
||||||
"pg-format": "^1.0.4"
|
"pg-format": "^1.0.4",
|
||||||
,"node-fetch": "^2.6.7"
|
"node-fetch": "^2.6.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.1.3"
|
"nodemon": "^3.1.3"
|
||||||
|
|||||||
43
checklist.md
43
checklist.md
@@ -17,8 +17,11 @@
|
|||||||
- [x] Invite UI: create form, list, copy, delete with confirmation
|
- [x] Invite UI: create form, list, copy, delete with confirmation
|
||||||
- [x] Commands UI (per-command toggles)
|
- [x] Commands UI (per-command toggles)
|
||||||
- [x] Live Notifications UI (per-server toggle & config)
|
- [x] Live Notifications UI (per-server toggle & config)
|
||||||
- Live Notifications accessible from server page via dropdown and dialog
|
- Channel selection, watched-user list, live status with Watch Live button
|
||||||
- Dashboard: channel dropdown and watched-user list added
|
- 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
|
## Discord Bot
|
||||||
- [x] discord.js integration (events and commands)
|
- [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 used by backend to fetch live guild data and manage invites
|
||||||
- [x] Bot reads/writes per-guild command toggles via backend/Postgres
|
- [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] 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] 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 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: 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] 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
|
## 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`)
|
||||||
- [x] Legacy encrypted `backend/db.json` retained (migration planned)
|
- [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)
|
- [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
|
## Security & Behavior
|
||||||
- [x] Invite DELETE requires short-lived HMAC token (`x-invite-token`)
|
- [x] Invite DELETE requires short-lived HMAC token (`x-invite-token`)
|
||||||
- [x] Frontend confirmation dialog for invite deletion
|
- [x] Frontend confirmation dialog for invite deletion
|
||||||
- [ ] Harden invite-token issuance (require OAuth + admin check)
|
- [ ] Harden invite-token issuance (require OAuth + admin check)
|
||||||
|
- [ ] Template variables for messages (planned): support `{user}`, `{title}`, `{category}`, `{viewers}` replacement in `message` / `customMessage`
|
||||||
|
|
||||||
## Docs & Deployment
|
## Docs & Deployment
|
||||||
- [x] README and CHANGELOG updated with setup steps and Postgres guidance
|
- [x] README and CHANGELOG updated with setup steps and Postgres guidance
|
||||||
@@ -58,6 +89,7 @@
|
|||||||
- Server cards: uniform sizes, image cropping, name clamping
|
- Server cards: uniform sizes, image cropping, name clamping
|
||||||
- Mobile spacing and typography adjustments
|
- Mobile spacing and typography adjustments
|
||||||
- Dashboard action buttons repositioned (Invite/Leave under title)
|
- 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 - <Page Name>` (e.g., 'ECS - Dashboard')
|
- [x] Browser tab now shows `ECS - <Page Name>` (e.g., 'ECS - Dashboard')
|
||||||
- [x] Dashboard duplicate title fixed; user settings (avatar/themes) restored via NavBar
|
- [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] Created `frontend/src/components/common` and `frontend/src/components/server`
|
||||||
- [x] Moved `ConfirmDialog` and `MaintenancePage` to `components/common`
|
- [x] Moved `ConfirmDialog` and `MaintenancePage` to `components/common`
|
||||||
- [x] Moved `ServerSettings` and `HelpPage` to `components/server`
|
- [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)
|
- [ ] Remove legacy top-level duplicate files (archival recommended)
|
||||||
|
|
||||||
@@ -163,4 +163,49 @@ async function _rawGetTwitchStreams(usersCsv) {
|
|||||||
try { return await res.json(); } catch (e) { return []; }
|
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 };
|
||||||
|
|||||||
43
discord-bot/commands/add-kickuser.js
Normal file
43
discord-bot/commands/add-kickuser.js
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -22,6 +22,15 @@ module.exports = {
|
|||||||
});
|
});
|
||||||
if (resp.ok) {
|
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
|
||||||
|
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 {
|
} 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 });
|
||||||
}
|
}
|
||||||
|
|||||||
24
discord-bot/commands/list-kickusers.js
Normal file
24
discord-bot/commands/list-kickusers.js
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -20,7 +20,7 @@ module.exports = {
|
|||||||
let toggles = existingSettings.commandToggles;
|
let toggles = existingSettings.commandToggles;
|
||||||
// Include all loaded commands so simple command modules (no SlashCommandBuilder) like
|
// Include all loaded commands so simple command modules (no SlashCommandBuilder) like
|
||||||
// `ping` are also listed. Filter for objects with a name for safety.
|
// `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)
|
// Build button components (max 5 rows, 5 buttons per row)
|
||||||
const actionRows = [];
|
const actionRows = [];
|
||||||
|
|||||||
41
discord-bot/commands/remove-kickuser.js
Normal file
41
discord-bot/commands/remove-kickuser.js
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -20,6 +20,15 @@ module.exports = {
|
|||||||
const resp = await fetch(`${backendBase}/api/servers/${interaction.guildId}/twitch-users/${encodeURIComponent(username)}`, { method: 'DELETE' });
|
const resp = await fetch(`${backendBase}/api/servers/${interaction.guildId}/twitch-users/${encodeURIComponent(username)}`, { method: 'DELETE' });
|
||||||
if (resp.ok) {
|
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
|
||||||
|
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 {
|
} 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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,13 @@
|
|||||||
const { SlashCommandBuilder, PermissionsBitField } = require('discord.js');
|
const { SlashCommandBuilder, PermissionsBitField } = require('discord.js');
|
||||||
const fetch = require('node-fetch');
|
|
||||||
const api = require('../api');
|
const api = require('../api');
|
||||||
const { readDb, writeDb } = require('../../backend/db');
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
name: 'setup-live',
|
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,
|
enabled: true,
|
||||||
builder: new SlashCommandBuilder()
|
builder: new SlashCommandBuilder()
|
||||||
.setName('setup-live')
|
.setName('setup-live')
|
||||||
.setDescription('Configure Twitch live notifications for this server')
|
.setDescription('Enable or disable 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))
|
|
||||||
.addBooleanOption(opt => opt.setName('enabled').setDescription('Enable/disable notifications').setRequired(true)),
|
.addBooleanOption(opt => opt.setName('enabled').setDescription('Enable/disable notifications').setRequired(true)),
|
||||||
|
|
||||||
async execute(interaction) {
|
async execute(interaction) {
|
||||||
@@ -20,24 +16,18 @@ module.exports = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const twitchUser = interaction.options.getString('twitch_user');
|
|
||||||
const channel = interaction.options.getChannel('channel');
|
|
||||||
const enabled = interaction.options.getBoolean('enabled');
|
const enabled = interaction.options.getBoolean('enabled');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const api = require('../api');
|
const api = require('../api');
|
||||||
const existing = (await api.getServerSettings(interaction.guildId)) || {};
|
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 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) {
|
} catch (e) {
|
||||||
console.error('Error saving live notifications to backend, falling back to local:', e);
|
console.error('Error saving live notifications to backend:', e);
|
||||||
// fallback to local db
|
await interaction.reply({ content: 'Failed to update live notifications.', flags: 64 });
|
||||||
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 });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const commandFiles = fs.readdirSync(commandsPath).filter(file => file.endsWith('
|
|||||||
for (const file of commandFiles) {
|
for (const file of commandFiles) {
|
||||||
const filePath = path.join(commandsPath, file);
|
const filePath = path.join(commandsPath, file);
|
||||||
const command = require(filePath);
|
const command = require(filePath);
|
||||||
if (command.enabled === false) continue;
|
if (command.enabled === false || command.dev === true) continue;
|
||||||
|
|
||||||
if (command.builder) {
|
if (command.builder) {
|
||||||
commands.push(command.builder.toJSON());
|
commands.push(command.builder.toJSON());
|
||||||
|
|||||||
@@ -1,72 +1,46 @@
|
|||||||
const { Events } = require('discord.js');
|
const { Events } = require('discord.js');
|
||||||
const { readDb } = require('../../backend/db.js');
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
name: Events.GuildMemberAdd,
|
name: Events.GuildMemberAdd,
|
||||||
async execute(member) {
|
async execute(member) {
|
||||||
try {
|
try {
|
||||||
const api = require('../api');
|
const api = require('../api');
|
||||||
const settings = (await api.getServerSettings(member.guild.id)) || {};
|
// 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 = {
|
if (welcome && welcome.enabled && welcome.channel) {
|
||||||
enabled: settings.welcomeEnabled || false,
|
const channel = member.guild.channels.cache.get(welcome.channel);
|
||||||
channel: settings.welcomeChannel || '',
|
if (channel) {
|
||||||
message: settings.welcomeMessage || 'Welcome {user} to {server}!'
|
try {
|
||||||
};
|
const message = (welcome.message || 'Welcome {user} to {server}!').replace('{user}', member.user.toString()).replace('{server}', member.guild.name);
|
||||||
|
await channel.send(message);
|
||||||
if (welcome && welcome.enabled && welcome.channel) {
|
} catch (error) {
|
||||||
const channel = member.guild.channels.cache.get(welcome.channel);
|
console.error(`Could not send welcome message to channel ${welcome.channel} in guild ${member.guild.id}:`, error);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
@@ -1,19 +1,19 @@
|
|||||||
const { Events } = require('discord.js');
|
const { Events } = require('discord.js');
|
||||||
const { readDb } = require('../../backend/db.js');
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
name: Events.GuildMemberRemove,
|
name: Events.GuildMemberRemove,
|
||||||
async execute(member) {
|
async execute(member) {
|
||||||
try {
|
try {
|
||||||
const api = require('../api');
|
const api = require('../api');
|
||||||
const settings = (await api.getServerSettings(member.guild.id)) || {};
|
// Get the welcome/leave settings from the API
|
||||||
const leave = { enabled: settings.leaveEnabled || false, channel: settings.leaveChannel || '', message: settings.leaveMessage || '{user} has left the server.' };
|
const welcomeLeaveSettings = await api.getWelcomeLeaveSettings(member.guild.id) || { leave: { enabled: false } };
|
||||||
|
const leave = welcomeLeaveSettings.leave;
|
||||||
|
|
||||||
if (leave && leave.enabled && leave.channel) {
|
if (leave && leave.enabled && leave.channel) {
|
||||||
const channel = member.guild.channels.cache.get(leave.channel);
|
const channel = member.guild.channels.cache.get(leave.channel);
|
||||||
if (channel) {
|
if (channel) {
|
||||||
try {
|
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);
|
await channel.send(message);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Could not send leave message to channel ${leave.channel} in guild ${member.guild.id}:`, 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) {
|
} catch (error) {
|
||||||
console.error(`Error in guildMemberRemove event for guild ${member.guild.id}:`, 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 */ }
|
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
};
|
};
|
||||||
@@ -10,7 +10,7 @@ module.exports = (client) => {
|
|||||||
// Clear require cache to allow updates during development
|
// Clear require cache to allow updates during development
|
||||||
delete require.cache[require.resolve(filePath)];
|
delete require.cache[require.resolve(filePath)];
|
||||||
const command = require(filePath);
|
const command = require(filePath);
|
||||||
if (command.name) {
|
if (command.name && !command.dev) {
|
||||||
client.commands.set(command.name, command);
|
client.commands.set(command.name, command);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,19 +145,28 @@ async function announceLive(guildId, stream) {
|
|||||||
const channel = await guild.channels.fetch(channelId).catch(() => null);
|
const channel = await guild.channels.fetch(channelId).catch(() => null);
|
||||||
if (!channel) return { success: false, message: 'Channel not found' };
|
if (!channel) return { success: false, message: 'Channel not found' };
|
||||||
const { EmbedBuilder } = require('discord.js');
|
const { EmbedBuilder } = require('discord.js');
|
||||||
const embed = new EmbedBuilder()
|
const embed = new EmbedBuilder()
|
||||||
.setColor(0x9146FF)
|
.setColor(0x9146FF)
|
||||||
.setTitle(stream.title || `${stream.user_name} is live`)
|
.setTitle(stream.title || `${stream.user_name} is live`)
|
||||||
.setURL(stream.url)
|
.setURL(stream.url)
|
||||||
.setAuthor({ name: stream.user_name, iconURL: stream.profile_image_url || undefined, url: 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)
|
.setThumbnail(stream.thumbnail_url || stream.profile_image_url || undefined)
|
||||||
.addFields(
|
.addFields(
|
||||||
{ name: 'Category', value: stream.game_name || 'Unknown', inline: true },
|
{ name: 'Category', value: stream.game_name || 'Unknown', inline: true },
|
||||||
{ name: 'Viewers', value: String(stream.viewer_count || 0), inline: true }
|
{ name: 'Viewers', value: String(stream.viewer_count || 0), inline: true }
|
||||||
)
|
)
|
||||||
.setDescription(stream.description || '')
|
.setDescription((stream.description || '').slice(0, 200))
|
||||||
.setFooter({ text: `ehchadservices • Started: ${stream.started_at ? new Date(stream.started_at).toLocaleString() : 'unknown'}` });
|
.setFooter({ text: `ehchadservices • Started: ${stream.started_at ? new Date(stream.started_at).toLocaleString() : 'unknown'}` });
|
||||||
await channel.send({ embeds: [embed] });
|
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 };
|
return { success: true };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('announceLive failed:', e && e.message ? e.message : 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)
|
// Start twitch watcher when client is ready (use 'clientReady' as the event name)
|
||||||
try {
|
try {
|
||||||
const watcher = require('./twitch-watcher');
|
const watcher = require('./twitch-watcher');
|
||||||
// discord.js renamed the ready event to clientReady; the event loader registers
|
// discord.js uses 'clientReady' event
|
||||||
// handlers based on event.name so we listen for the same 'clientReady' here.
|
|
||||||
client.once('clientReady', () => {
|
client.once('clientReady', () => {
|
||||||
// start polling in background
|
// start polling in background
|
||||||
watcher.poll(client).catch(err => console.error('Twitch watcher failed to start:', err));
|
watcher.poll(client).catch(err => console.error('Twitch watcher failed to start:', err));
|
||||||
@@ -182,6 +190,19 @@ try {
|
|||||||
// ignore if watcher not available
|
// 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) ---
|
// --- Optional push receiver (so backend can notify a remote bot process) ---
|
||||||
try {
|
try {
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
|||||||
294
discord-bot/kick-watcher.js
Normal file
294
discord-bot/kick-watcher.js
Normal file
@@ -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 };
|
||||||
@@ -1,4 +1,62 @@
|
|||||||
const api = require('./api');
|
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;
|
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
|
||||||
@@ -45,6 +103,10 @@ 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 (channel.type !== 0) { // 0 is text channel
|
||||||
|
console.error(`TwitchWatcher: channel ${channelId} is not a text channel (type: ${channel.type})`);
|
||||||
|
channel = null;
|
||||||
|
}
|
||||||
} 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}:`, e && e.message ? e.message : e);
|
||||||
channel = null;
|
channel = null;
|
||||||
@@ -73,10 +135,16 @@ async function checkGuild(client, guild) {
|
|||||||
// 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
|
// Build and send embed (standardized layout)
|
||||||
try {
|
try {
|
||||||
// Announce without per-guild log spam
|
// 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
|
||||||
|
let bio = '';
|
||||||
|
try {
|
||||||
|
const info = await fetchUserInfo(login);
|
||||||
|
if (info && info.description) bio = info.description.slice(0, 200);
|
||||||
|
} catch (_) {}
|
||||||
const embed = new EmbedBuilder()
|
const embed = new EmbedBuilder()
|
||||||
.setColor(0x9146FF)
|
.setColor(0x9146FF)
|
||||||
.setTitle(s.title || `${s.user_name} is live`)
|
.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: '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 }
|
||||||
)
|
)
|
||||||
.setDescription(s.description || '')
|
.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'}` });
|
||||||
|
|
||||||
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)}`);
|
console.log(`🔔 Announced live: ${login} - ${(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}:`, e && e.message ? e.message : e);
|
||||||
@@ -108,6 +187,15 @@ 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`);
|
||||||
|
// 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) {
|
while (polling) {
|
||||||
try {
|
try {
|
||||||
const guilds = Array.from(client.guilds.cache.values());
|
const guilds = Array.from(client.guilds.cache.values());
|
||||||
|
|||||||
@@ -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 { BrowserRouter as Router, Route, Routes, useLocation } from 'react-router-dom';
|
||||||
import { ThemeProvider } from './contexts/ThemeContext';
|
import { ThemeProvider } from './contexts/ThemeContext';
|
||||||
import { UserProvider } from './contexts/UserContext';
|
import { UserProvider } from './contexts/UserContext';
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import { useBackend } from '../../contexts/BackendContext';
|
import { useBackend } from '../../contexts/BackendContext';
|
||||||
import { useParams, useNavigate, useLocation } from 'react-router-dom';
|
import { useParams, useNavigate, useLocation } from 'react-router-dom';
|
||||||
import axios from 'axios';
|
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 ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||||
// UserSettings moved to NavBar
|
// UserSettings moved to NavBar
|
||||||
@@ -40,13 +40,22 @@ const ServerSettings = () => {
|
|||||||
// SSE connection status (not currently displayed)
|
// SSE connection status (not currently displayed)
|
||||||
const [confirmDeleteTwitch, setConfirmDeleteTwitch] = useState(false);
|
const [confirmDeleteTwitch, setConfirmDeleteTwitch] = useState(false);
|
||||||
const [pendingTwitchUser, setPendingTwitchUser] = useState(null);
|
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 [inviteForm, setInviteForm] = useState({ channelId: '', maxAge: 0, maxUses: 0, temporary: false });
|
||||||
const [liveEnabled, setLiveEnabled] = useState(false);
|
const [liveEnabled, setLiveEnabled] = useState(false);
|
||||||
const [liveChannelId, setLiveChannelId] = useState('');
|
const [liveChannelId, setLiveChannelId] = useState('');
|
||||||
const [liveTwitchUser, setLiveTwitchUser] = useState('');
|
const [liveTwitchUser, setLiveTwitchUser] = useState('');
|
||||||
|
const [liveMessage, setLiveMessage] = useState('');
|
||||||
|
const [liveCustomMessage, setLiveCustomMessage] = useState('');
|
||||||
const [watchedUsers, setWatchedUsers] = useState([]);
|
const [watchedUsers, setWatchedUsers] = useState([]);
|
||||||
const [liveStatus, setLiveStatus] = 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({
|
const [welcomeLeaveSettings, setWelcomeLeaveSettings] = useState({
|
||||||
welcome: {
|
welcome: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
@@ -127,14 +136,18 @@ const ServerSettings = () => {
|
|||||||
// Fetch invites
|
// Fetch invites
|
||||||
// Fetch live notifications settings and watched users
|
// Fetch live notifications settings and watched users
|
||||||
axios.get(`${API_BASE}/api/servers/${guildId}/live-notifications`).then(resp => {
|
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);
|
setLiveEnabled(!!s.enabled);
|
||||||
setLiveChannelId(s.channelId || '');
|
setLiveChannelId(s.channelId || '');
|
||||||
setLiveTwitchUser(s.twitchUser || '');
|
setLiveTwitchUser(s.twitchUser || '');
|
||||||
|
setLiveMessage(s.message || '');
|
||||||
|
setLiveCustomMessage(s.customMessage || '');
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
|
||||||
axios.get(`${API_BASE}/api/servers/${guildId}/twitch-users`).then(resp => setWatchedUsers(resp.data || [])).catch(() => setWatchedUsers([]));
|
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([]));
|
axios.get(`${API_BASE}/api/servers/${guildId}/invites`).then(resp => setInvites(resp.data || [])).catch(() => setInvites([]));
|
||||||
|
|
||||||
// Open commands accordion if navigated from Help back button
|
// Open commands accordion if navigated from Help back button
|
||||||
@@ -165,6 +178,16 @@ const ServerSettings = () => {
|
|||||||
setLiveEnabled(!!data.enabled);
|
setLiveEnabled(!!data.enabled);
|
||||||
setLiveChannelId(data.channelId || '');
|
setLiveChannelId(data.channelId || '');
|
||||||
setLiveTwitchUser(data.twitchUser || '');
|
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) => {
|
const onCommandToggle = (e) => {
|
||||||
@@ -176,12 +199,14 @@ const ServerSettings = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
eventTarget.addEventListener('twitchUsersUpdate', onTwitchUsers);
|
eventTarget.addEventListener('twitchUsersUpdate', onTwitchUsers);
|
||||||
|
eventTarget.addEventListener('kickUsersUpdate', onKickUsers);
|
||||||
eventTarget.addEventListener('liveNotificationsUpdate', onLiveNotifications);
|
eventTarget.addEventListener('liveNotificationsUpdate', onLiveNotifications);
|
||||||
eventTarget.addEventListener('commandToggle', onCommandToggle);
|
eventTarget.addEventListener('commandToggle', onCommandToggle);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
try {
|
try {
|
||||||
eventTarget.removeEventListener('twitchUsersUpdate', onTwitchUsers);
|
eventTarget.removeEventListener('twitchUsersUpdate', onTwitchUsers);
|
||||||
|
eventTarget.removeEventListener('kickUsersUpdate', onKickUsers);
|
||||||
eventTarget.removeEventListener('liveNotificationsUpdate', onLiveNotifications);
|
eventTarget.removeEventListener('liveNotificationsUpdate', onLiveNotifications);
|
||||||
eventTarget.removeEventListener('commandToggle', onCommandToggle);
|
eventTarget.removeEventListener('commandToggle', onCommandToggle);
|
||||||
} catch (err) {}
|
} catch (err) {}
|
||||||
@@ -192,77 +217,18 @@ const ServerSettings = () => {
|
|||||||
navigate(-1);
|
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 handleToggleLive = async (e) => {
|
||||||
const enabled = e.target.checked;
|
const enabled = e.target.checked;
|
||||||
setLiveEnabled(enabled);
|
setLiveEnabled(enabled);
|
||||||
try {
|
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');
|
setSnackbarMessage('Live notifications updated');
|
||||||
setSnackbarOpen(true);
|
setSnackbarOpen(true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// revert on error
|
|
||||||
setLiveEnabled(!enabled);
|
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 = () => {
|
const handleCloseSnackbar = () => {
|
||||||
setSnackbarOpen(false);
|
setSnackbarOpen(false);
|
||||||
};
|
};
|
||||||
@@ -364,6 +330,54 @@ const ServerSettings = () => {
|
|||||||
setDialogOpen(false);
|
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 (
|
return (
|
||||||
<div style={{ padding: '20px' }}>
|
<div style={{ padding: '20px' }}>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
@@ -650,59 +664,143 @@ const ServerSettings = () => {
|
|||||||
</AccordionDetails>
|
</AccordionDetails>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
{/* Live Notifications Accordion */}
|
{/* Live Notifications Accordion */}
|
||||||
<Accordion sx={{ marginTop: '20px', opacity: isBotInServer ? 1 : 0.5 }}>
|
<Accordion expanded={liveExpanded} onChange={() => setLiveExpanded(prev => !prev)} sx={{ marginTop: '20px' }}>
|
||||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||||
<Typography variant="h6">Live Notifications</Typography>
|
<Typography variant="h6">Live Notifications</Typography>
|
||||||
</AccordionSummary>
|
</AccordionSummary>
|
||||||
<AccordionDetails>
|
<AccordionDetails>
|
||||||
{!isBotInServer && <Typography>Invite the bot to enable this feature.</Typography>}
|
<Box sx={{ opacity: isBotInServer ? 1 : 0.5 }}>
|
||||||
<Box sx={{ marginTop: '10px' }}>
|
{!isBotInServer && <Typography sx={{ mb: 2 }}>Invite the bot to enable this feature.</Typography>}
|
||||||
<FormControl fullWidth disabled={!isBotInServer}>
|
<Box sx={{ border: 1, borderColor: 'divider', borderRadius: 1 }}>
|
||||||
<Select value={liveChannelId} onChange={(e) => setLiveChannelId(e.target.value)} displayEmpty>
|
<Tabs value={liveTabValue} onChange={(e, newValue) => {
|
||||||
<MenuItem value="">(Select channel)</MenuItem>
|
// Prevent switching to Kick tab (index 1) since it's disabled
|
||||||
{channels.map(ch => (<MenuItem key={ch.id} value={ch.id}>{ch.name}</MenuItem>))}
|
if (newValue !== 1) {
|
||||||
</Select>
|
setLiveTabValue(newValue);
|
||||||
</FormControl>
|
}
|
||||||
|
}} sx={{ borderBottom: 1, borderColor: 'divider', '& .MuiTabs-indicator': { backgroundColor: 'primary.main' } }}>
|
||||||
|
<Tab label="Twitch" sx={{ textTransform: 'none', fontWeight: 'medium' }} />
|
||||||
|
<Tab label="Kick (Disabled)" sx={{ textTransform: 'none', fontWeight: 'medium', opacity: 0.5, cursor: 'not-allowed' }} disabled />
|
||||||
|
</Tabs>
|
||||||
|
{liveTabValue === 0 && (
|
||||||
|
<Box sx={{ p: 3 }}>
|
||||||
|
<FormControlLabel control={<Switch checked={liveEnabled} onChange={handleToggleLive} />} label="Enabled" sx={{ mb: 2 }} />
|
||||||
|
<FormControl fullWidth sx={{ mb: 2 }} disabled={!isBotInServer}>
|
||||||
|
<Select value={liveChannelId} onChange={(e) => setLiveChannelId(e.target.value)} displayEmpty>
|
||||||
|
<MenuItem value="">(Select channel)</MenuItem>
|
||||||
|
{channels.map(ch => (<MenuItem key={ch.id} value={ch.id}>{ch.name}</MenuItem>))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', gap: 1, mt: 2 }}>
|
<Box sx={{ display: 'flex', gap: 1, mt: 2 }}>
|
||||||
<TextField label="Twitch username" value={liveTwitchUser} onChange={(e) => setLiveTwitchUser(e.target.value)} fullWidth disabled={!isBotInServer} />
|
<TextField label="Twitch username" value={liveTwitchUser} onChange={(e) => setLiveTwitchUser(e.target.value)} fullWidth disabled={!isBotInServer} />
|
||||||
<Button variant="contained" onClick={async () => {
|
<Button variant="contained" onClick={async () => {
|
||||||
if (!liveTwitchUser) return;
|
if (!liveTwitchUser) return;
|
||||||
try {
|
try {
|
||||||
await axios.post(`${API_BASE}/api/servers/${guildId}/twitch-users`, { username: liveTwitchUser });
|
await axios.post(`${API_BASE}/api/servers/${guildId}/twitch-users`, { username: liveTwitchUser });
|
||||||
setWatchedUsers(prev => [...prev.filter(u => u !== liveTwitchUser.toLowerCase()), liveTwitchUser.toLowerCase()]);
|
setWatchedUsers(prev => [...prev.filter(u => u !== liveTwitchUser.toLowerCase()), liveTwitchUser.toLowerCase()]);
|
||||||
setLiveTwitchUser('');
|
setLiveTwitchUser('');
|
||||||
} catch (err) { setSnackbarMessage('Failed to add Twitch user (backend offline?)'); setSnackbarOpen(true); }
|
} catch (err) { setSnackbarMessage('Failed to add Twitch user (backend offline?)'); setSnackbarOpen(true); }
|
||||||
}} disabled={!isBotInServer}>Add</Button>
|
}} disabled={!isBotInServer}>Add</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ mt: 2 }}>
|
<Box sx={{ mt: 3 }}>
|
||||||
<Typography variant="subtitle2">Watched Users</Typography>
|
<Typography variant="subtitle2" sx={{ mb: 1 }}>Watched Users</Typography>
|
||||||
{watchedUsers.length === 0 && <Typography>No users added</Typography>}
|
{watchedUsers.length === 0 && <Typography>No users added</Typography>}
|
||||||
{watchedUsers.map(u => (
|
{watchedUsers.map(u => (
|
||||||
<Box key={u} sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mt: 1 }}>
|
<Box key={u} sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mt: 1, p: 1, border: 1, borderColor: 'divider', borderRadius: 1 }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
<Typography>{u}</Typography>
|
<Typography>{u}</Typography>
|
||||||
{liveStatus[u] && liveStatus[u].is_live && (
|
{liveStatus[u] && liveStatus[u].is_live && (
|
||||||
<Button size="small" color="error" href={liveStatus[u].url} target="_blank" rel="noopener">Watch Live</Button>
|
<Button size="small" color="error" href={liveStatus[u].url} target="_blank" rel="noopener">Watch Live</Button>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
<Box>
|
<Box>
|
||||||
<Button size="small" onClick={() => { setPendingTwitchUser(u); setConfirmDeleteTwitch(true); }}>Delete</Button>
|
<Button size="small" onClick={() => { setPendingTwitchUser(u); setConfirmDeleteTwitch(true); }}>Delete</Button>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ mt: 3 }}>
|
||||||
|
<Typography variant="subtitle2">Notification Message Mode</Typography>
|
||||||
|
<FormControl component="fieldset" sx={{ mt: 1 }} disabled={!isBotInServer || !liveEnabled}>
|
||||||
|
<RadioGroup
|
||||||
|
row
|
||||||
|
value={liveCustomMessage ? 'custom' : 'default'}
|
||||||
|
onChange={(e) => {
|
||||||
|
const mode = e.target.value;
|
||||||
|
if (mode === 'default') {
|
||||||
|
setLiveCustomMessage('');
|
||||||
|
if (!liveMessage) setLiveMessage('🔴 {user} is now live!');
|
||||||
|
} else {
|
||||||
|
setLiveCustomMessage(liveCustomMessage || liveMessage || '🔴 {user} is now live!');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormControlLabel value="default" control={<Radio />} label="Default" />
|
||||||
|
<FormControlLabel value="custom" control={<Radio />} label="Custom" />
|
||||||
|
</RadioGroup>
|
||||||
|
</FormControl>
|
||||||
|
{liveCustomMessage ? (
|
||||||
|
<TextField
|
||||||
|
label="Custom Message"
|
||||||
|
value={liveCustomMessage}
|
||||||
|
onChange={(e) => setLiveCustomMessage(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
sx={{ mt: 2 }}
|
||||||
|
placeholder="Your custom announcement text"
|
||||||
|
disabled={!isBotInServer || !liveEnabled}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Typography variant="body2" sx={{ mt: 2 }}>
|
||||||
|
Using default message: <strong>{liveMessage || '🔴 {user} is now live!'}</strong>
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 2, gap: 1 }}>
|
||||||
|
{liveCustomMessage && (
|
||||||
|
<Button variant="text" size="small" onClick={() => setLiveCustomMessage('')} disabled={!isBotInServer || !liveEnabled}>Use Default</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="outlined" size="small" onClick={async () => {
|
||||||
|
try {
|
||||||
|
await axios.post(`${API_BASE}/api/servers/${guildId}/live-notifications`, {
|
||||||
|
enabled: liveEnabled,
|
||||||
|
channelId: liveChannelId,
|
||||||
|
twitchUser: '',
|
||||||
|
message: liveMessage || '🔴 {user} is now live!',
|
||||||
|
customMessage: liveCustomMessage
|
||||||
|
});
|
||||||
|
setSnackbarMessage('Notification message updated');
|
||||||
|
setSnackbarOpen(true);
|
||||||
|
} catch (err) {
|
||||||
|
setSnackbarMessage('Failed to update message');
|
||||||
|
setSnackbarOpen(true);
|
||||||
|
}
|
||||||
|
}} disabled={!isBotInServer || !liveEnabled}>Apply</Button>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 3 }}>
|
||||||
|
<Button variant="contained" onClick={async () => {
|
||||||
|
try {
|
||||||
|
await axios.post(`${API_BASE}/api/servers/${guildId}/live-notifications`, { enabled: liveEnabled, twitchUser: '', channelId: liveChannelId, message: liveMessage, customMessage: liveCustomMessage });
|
||||||
|
setSnackbarMessage('Live notification settings saved');
|
||||||
|
setSnackbarOpen(true);
|
||||||
|
} catch (err) { setSnackbarMessage('Failed to save live settings (backend offline?)'); setSnackbarOpen(true); }
|
||||||
|
}} disabled={!isBotInServer}>Save</Button>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
)}
|
||||||
<FormControlLabel control={<Switch checked={liveEnabled} onChange={(e) => setLiveEnabled(e.target.checked)} />} label="Enabled" sx={{ mt: 2 }} />
|
{liveTabValue === 1 && (
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 2 }}>
|
<Box sx={{ p: 3, textAlign: 'center' }}>
|
||||||
<Button variant="contained" onClick={async () => {
|
<Typography variant="h6" sx={{ mb: 2, color: 'text.secondary' }}>
|
||||||
try {
|
Kick Live Notifications (Disabled)
|
||||||
await axios.post(`${API_BASE}/api/servers/${guildId}/live-notifications`, { enabled: liveEnabled, twitchUser: '', channelId: liveChannelId });
|
</Typography>
|
||||||
} catch (err) { setSnackbarMessage('Failed to save live settings (backend offline?)'); setSnackbarOpen(true); }
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||||
}} disabled={!isBotInServer}>Save</Button>
|
Kick live notifications are temporarily disabled. This feature will be re-enabled in a future update.
|
||||||
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
</AccordionDetails>
|
</AccordionDetails>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
<Accordion sx={{ marginTop: '20px', opacity: isBotInServer ? 1 : 0.5 }}>
|
<Accordion sx={{ marginTop: '20px', opacity: isBotInServer ? 1 : 0.5 }}>
|
||||||
@@ -816,11 +914,28 @@ const ServerSettings = () => {
|
|||||||
title="Delete Twitch User"
|
title="Delete Twitch User"
|
||||||
message={`Are you sure you want to remove ${pendingTwitchUser || ''} from the watch list?`}
|
message={`Are you sure you want to remove ${pendingTwitchUser || ''} from the watch list?`}
|
||||||
/>
|
/>
|
||||||
<Snackbar open={snackbarOpen} autoHideDuration={4000} onClose={handleCloseSnackbar}>
|
{/* Confirm dialog for deleting a kick user from watched list */}
|
||||||
<Alert onClose={handleCloseSnackbar} severity="info" sx={{ width: '100%' }}>
|
<ConfirmDialog
|
||||||
{snackbarMessage}
|
open={confirmDeleteKick}
|
||||||
</Alert>
|
onClose={() => { setConfirmDeleteKick(false); setPendingKickUser(null); }}
|
||||||
</Snackbar>
|
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?`}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export function BackendProvider({ children }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return () => { try { es && es.close(); } catch (e) {} };
|
return () => { try { es && es.close(); } catch (e) {} };
|
||||||
}, [process.env.REACT_APP_API_BASE]);
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const forceCheck = async () => {
|
const forceCheck = async () => {
|
||||||
const API_BASE2 = process.env.REACT_APP_API_BASE || '';
|
const API_BASE2 = process.env.REACT_APP_API_BASE || '';
|
||||||
|
|||||||
Reference in New Issue
Block a user