Compare commits

...

3 Commits

Author SHA1 Message Date
2ae7202445 Update backend, DB, Commands, Live Reloading 2025-10-09 02:17:33 -04:00
6a78ec6453 live updates and file organization 2025-10-06 14:47:05 -04:00
ca23c0ab8c swapped to a new db locally hosted 2025-10-06 00:25:29 -04:00
51 changed files with 3606 additions and 617 deletions

51
CHANGELOG.md Normal file
View File

@@ -0,0 +1,51 @@
# Changelog
All notable changes to this project are documented below. This file follows a lightweight "Keep a Changelog" style.
## 2025-10-05 — New features & changes
### Added
- Postgres persistence support for the backend (requires `DATABASE_URL`). Backend will auto-create `servers`, `invites`, and `users` tables on startup.
- `backend/pg.js` helper module to manage Postgres connection and CRUD helpers.
- `backend/.env.example` updated to show `DATABASE_URL` with a Tailscale IP example (100.111.50.59).
- Short-lived invite token flow (HMAC) for delete authorization (default, via `INVITE_TOKEN_SECRET`).
- Frontend invite UI fixes (copy/delete handlers, confirmation dialog) and various UI polish updates.
### Changed
- Backend now requires `DATABASE_URL` by default; no automatic fallback to encrypted `db.json` for persistence.
- `INVITE_API_KEY` static option deprecated in favor of short-lived invite tokens; token issuance endpoint remains unauthenticated (recommendation: restrict in production).
### Fixed
- Async/await handler fixes and small bug fixes across backend endpoints.
### Notes
- Existing encrypted `backend/db.json` is retained but no longer used by the running backend. A migration script to import old data into Postgres is planned but not yet implemented.
### Documentation
- README updated with setup steps, Postgres guidance, migration note, and expanded app description.
### Added
- Live Notifications: per-guild Twitch live notification settings (backend endpoints, frontend UI, and bot command to configure). Stored in server settings under `liveNotifications`.
## 2025-10-06 — Improvements & housekeeping
### Changed
- Backend now enforces `DATABASE_URL` as the primary persistence store. If `DATABASE_URL` is unset the server will refuse to start. This ensures all server settings and invites are stored in Postgres consistently.
- Server-Sent Events (SSE) endpoint `/api/events` added to allow the frontend to receive real-time updates (command toggles, twitch users updates, live-notifications updates) without refresh.
- `discord-bot/twitch-watcher.js` logging reduced to avoid per-guild spam; announcements are concise and errors still reported.
- Command deployment logs are aggregated (single summary line) to avoid flooding logs with per-guild messages.
### Added
- `/internal/test-live` dev endpoint (backend) & `announceLive` bot helper to force live announcements for debugging.
### Fixed
- Frontend `ServerSettings.js` now subscribes to SSE and updates commands, watched users and live settings in real-time when backend publishes events.
- Help command updated to produce a neat Embed and support `/help <command>` for detailed usage; help lists read from backend to remain future-proof.
## [Previous] - 2025-10-04
### Added
- Initial full-stack integration (React frontend, Express backend, Discord bot) and file-based encrypted persistence.
- Dashboard and server settings UI components.
- Invite creation, listing and deletion endpoints and UI.
---

195
README.md
View File

@@ -4,6 +4,8 @@ A full-stack example project that integrates a React frontend, an Express backen
This README documents how to get the project running, what environment variables are required, where to get Discord keys, how the invite token flow works, and basic troubleshooting tips.
Note: The backend has been updated to support Postgres persistence (see `CHANGELOG.md`). The backend now requires `DATABASE_URL` to run in the default configuration; if you prefer the legacy encrypted file store, see the notes under "Developer notes".
## Repository layout
- `frontend/` — React (Create React App) frontend. Uses `REACT_APP_API_BASE` to communicate with the backend in dev and production.
@@ -17,6 +19,16 @@ This README documents how to get the project running, what environment variables
- Runs a Discord bot (discord.js) that performs moderation and server features. The backend and bot are closely integrated: the backend hosts the API and the bot client is shared to fetch guild data and manipulate invites/channels/roles.
- Uses a short-lived token flow to authorize invite deletions from the frontend without embedding long-lived secrets in the client.
Expanded: what this app does
- Hosts a dashboard (React) that lists Discord guilds where the bot is present and lets server admins:
- create and manage invites (create invites with options, view persisted invites, copy and revoke)
- configure Welcome and Leave messages and channels
- enable/disable bot commands per server
- set autorole behavior for new members
- Provides a backend API (Express) that coordinates with a discord.js bot to perform live guild operations (fetch channels/roles, create invites, leave guilds)
- Stores configuration and invites in Postgres (recommended) or a legacy encrypted `db.json`
## Quickstart — prerequisites
- Node.js (recommended 18.x or later) and npm
@@ -39,6 +51,9 @@ DISCORD_CLIENT_SECRET=your_discord_client_secret
ENCRYPTION_KEY=a-32-byte-or-longer-secret
INVITE_TOKEN_SECRET=optional-second-secret-for-invite-tokens
# Postgres example (optional but recommended)
# DATABASE_URL=postgres://dbuser:dbpass@100.111.50.59:5432/ecs_db
- `PORT` / `HOST`: where the backend listens.
- `BACKEND_BASE` and `FRONTEND_BASE`: used for constructing OAuth redirect URIs and links.
- `CORS_ORIGIN`: optional; set to your frontend origin to restrict CORS.
@@ -47,6 +62,27 @@ INVITE_TOKEN_SECRET=optional-second-secret-for-invite-tokens
Note: This project previously supported an `INVITE_API_KEY` static secret; that requirement has been removed. Invite deletes are authorized via short-lived invite tokens by default.
### Twitch Live Notifications (optional)
This project can detect when watched Twitch users go live and post notifications to a configured Discord channel for each guild. To enable this feature, add the following to `backend/.env`:
- `TWITCH_CLIENT_ID` — your Twitch app client id
- `TWITCH_CLIENT_SECRET` — your Twitch app client secret
- `TWITCH_POLL_INTERVAL_MS` — optional, poll interval in milliseconds (default 30000)
When configured, the backend exposes:
- GET /api/twitch/streams?users=user1,user2 — returns stream info for the listed usernames (used by the frontend and bot watcher)
The bot includes a watcher that polls watched usernames per-guild and posts a message to the configured channel when a streamer goes live. The message includes the stream title and a link to the Twitch stream.
If you run the backend and the bot on separate hosts, you can configure the backend to push setting updates to the bot so toggles and watched users propagate immediately:
- `BOT_PUSH_URL` — the URL the bot will expose for the backend to POST setting updates to (e.g., http://bot-host:4002)
- `BOT_SECRET` — a shared secret used by the backend and bot to secure push requests
- `BOT_PUSH_PORT` — optional, the port the bot listens on for push requests (if set the bot starts a small HTTP receiver)
### frontend/.env (example)
HOST=0.0.0.0
@@ -90,6 +126,16 @@ npm install
npm start
```
Optional: using Postgres (recommended)
1. Create a Postgres database and user (pgAdmin or psql)
2. Set `DATABASE_URL` in `backend/.env`, e.g.:
DATABASE_URL=postgres://dbuser:dbpass@100.111.50.59:5432/ecs_db
3. Start the backend; on startup the backend will create simple tables if missing.
Migration note:
- If you have existing data in `backend/db.json`, a migration script is planned to import invites and server settings into Postgres. I can add that script on request.
2. Frontend
```powershell
@@ -106,6 +152,7 @@ npm run start
## Troubleshooting
- Backend refuses to start or missing package.json: ensure you run `npm install` in the `backend` folder and run `npm start` from that folder.
- If the backend exits with "DATABASE_URL is not set": either set `DATABASE_URL` in `backend/.env` pointing to your Postgres DB, or restore the legacy behavior by editing `backend/index.js` to re-enable the encrypted `db.json` fallback (not recommended for production).
- CORS errors: verify `CORS_ORIGIN` and `REACT_APP_API_BASE` match your frontend origin.
- Invite delete unauthorized: ensure backend `INVITE_TOKEN_SECRET` or `ENCRYPTION_KEY` is present and token TTL has not expired. Check the backend logs for validation details.
- Token issues: clock skew can cause tokens to appear expired — ensure server and client clocks are reasonably in sync.
@@ -120,8 +167,154 @@ npm run start
- Harden `/api/servers/:guildId/invite-token` to require an authenticated user and verify the user has admin permissions for the guild.
- Add rate-limiting to token issuance and optionally keep the old `INVITE_API_KEY` option for server-to-server automation.
- Updated docs: the README and CHANGELOG were updated to reflect Postgres integration and recent frontend/backend changes. See `CHANGELOG.md` and `checklist.md` for details.
If you want, I can add step-by-step instructions to create the `.env` files from templates, or implement the production safe option of authenticating `/invite-token` requests. Tell me which you'd prefer.
---
Updated: Oct 4, 2025
Updated: Oct 4, 2025
## Full setup guide (detailed)
This section walks through the exact steps to get the project running locally or on a machine reachable via Tailscale/Nginx Proxy Manager.
Prerequisites
1. Node.js 18+ and npm
2. Postgres (local or remote) or use an existing Postgres server reachable over your network/Tailscale
3. Discord application + Bot credentials and (optional) Twitch app credentials
Database (Postgres) setup
1. Create a Postgres database and user. Example psql commands:
```bash
sudo -u postgres psql
CREATE DATABASE ecs_fullstack;
CREATE USER ecs_user WITH PASSWORD 'supersecret';
GRANT ALL PRIVILEGES ON DATABASE ecs_fullstack TO ecs_user;
\q
```
2. Set the `DATABASE_URL` in `backend/.env`:
```
DATABASE_URL=postgres://ecs_user:supersecret@127.0.0.1:5432/ecs_fullstack
```
3. Start the backend (it will run migrations / ensure tables at startup):
```powershell
cd backend
npm install
npm start
```
Backend configuration (.env)
- `DATABASE_URL` - required for Postgres persistence
- `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET`, `DISCORD_BOT_TOKEN` - from Discord Developer Portal
- `FRONTEND_BASE` - public frontend URL (used for OAuth redirect)
- `PORT`, `HOST` - where backend listens
- `CORS_ORIGIN` - optional restrict origin to your frontend URL
- `TWITCH_CLIENT_ID`, `TWITCH_CLIENT_SECRET` - optional for Twitch integration
Frontend configuration
1. In `frontend/.env` set:
```
REACT_APP_API_BASE=https://your-domain-or-ip:3002
```
2. For development behind an HTTPS domain (Nginx Proxy Manager), ensure the CRA dev client uses `wss` by setting the `WDS_SOCKET_*` variables in `frontend/.env` (see docs if using a TLS domain)
Start the frontend dev server:
```powershell
cd frontend
npm install
npm start
```
Bot behaviour and deployment
- The `backend` process will boot the Discord bot client when valid `DISCORD_BOT_TOKEN` is present. The bot registers slash commands per guild on startup and responds to backend pushes for setting updates.
- If you prefer to run the bot separately, you can run the `discord-bot` module separately; ensure `BOT_PUSH_URL`/`BOT_SECRET` are configured if backend and bot are on different hosts.
Useful endpoints
- `GET /api/servers/:guildId/commands` — returns the authoritative list of commands and per-guild enabled/locked status.
- `GET/POST /api/servers/:guildId/live-notifications` — get/update live notification settings
- `GET /api/twitch/streams?users=user1,user2` — proxy to twitch helix for streams (backend caches app-token)
- `GET /api/events?guildId=...` — Server-Sent Events for real-time updates (ServerSettings subscribes to this)
### Twitch Live Notification Settings (Detailed)
Endpoint: `GET/POST /api/servers/:guildId/live-notifications`
Shape returned by GET:
```json
{
"enabled": true,
"channelId": "123456789012345678",
"twitchUser": "deprecated-single-user-field",
"message": "🔴 {user} is now live!",
"customMessage": "Custom promo text with link etc"
}
```
Notes:
- `twitchUser` is a legacy single-user field retained for backward compatibility. The active watched users list lives under `/api/servers/:guildId/twitch-users`.
- `message` (default message) and `customMessage` (override) are persisted. If `customMessage` is non-empty it is used when announcing a live stream; otherwise `message` is used. If both are empty the bot falls back to `🔴 {user} is now live!`.
- Update by POSTing the same shape (omit fields you don't change is okay; unspecified become empty unless preserved on server).
### Discord Bot Twitch Embed Layout
When a watched streamer goes live the bot posts a standardized embed. The layout is fixed to keep consistency:
Embed fields:
1. Title: Stream title (hyperlinked to Twitch URL) or fallback "{user} is live".
2. Author: Twitch display name with avatar and link.
3. Thumbnail: Stream thumbnail (or profile image fallback).
4. Fields:
- Category: Game / category name (or "Unknown").
- Viewers: Current viewer count.
5. Description: Twitch user bio (if available via Helix `users` endpoint) else truncated stream description (200 chars).
6. Footer: `ehchadservices • Started: <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
- 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.
Logs and verbosity
- The bot and watcher log messages have been reduced to avoid per-guild spam. You will see concise messages like "🔁 TwitchWatcher started" and "✅ ECS - Full Stack Bot Online!" rather than one-line-per-guild spam.
Troubleshooting
- If you see mixed-content errors in the browser when using a TLS domain with the CRA dev server, configure Nginx to proxy websockets and set CRA `WDS_SOCKET_*` env vars (see docs/nginx-proxy-manager.md)

View File

@@ -1,3 +1,41 @@
# Example backend/.env for ECS-FullStack
# Copy this file to backend/.env and fill values before running the backend
# Postgres connection (required)
# Example formats:
# postgres://user:password@host:5432/dbname
# postgresql://user:password@localhost:5432/dbname
DATABASE_URL=postgres://postgres:password@127.0.0.1:5432/ecs_fullstack
# Discord OAuth / Bot
DISCORD_CLIENT_ID=your_discord_client_id
DISCORD_CLIENT_SECRET=your_discord_client_secret
DISCORD_BOT_TOKEN=your_discord_bot_token
# Frontend base URL (where the frontend is served). Used for OAuth redirect and dashboard links.
FRONTEND_BASE=https://discordbot.YOURDOMAIN.com
# Host/port to bind the backend server (bind to 0.0.0.0 or your Tailscale IP as needed)
HOST=0.0.0.0
PORT=3002
# CORS origin - set to your frontend origin for tighter security (or '*' to allow all)
# Example: https://discordbot.YOURDOMAIN.com
CORS_ORIGIN=http://127.0.0.1:3001
# Twitch API (for the watcher and proxy)
TWITCH_CLIENT_ID=your_twitch_client_id
TWITCH_CLIENT_SECRET=your_twitch_client_secret
TWITCH_POLL_INTERVAL_MS=5000
# Optional bot push receiver settings - allows backend to notify a remote bot process
# BOT_PUSH_PORT if you run the bot on another host and want the backend to push settings
BOT_PUSH_PORT=
BOT_PUSH_URL=
BOT_SECRET=
# Optional logging level: debug | info | warn | error
LOG_LEVEL=info
# Example backend .env
# Set the host/interface to bind to (for Tailscale use your 100.x.y.z address)
HOST=0.0.0.0
@@ -10,13 +48,35 @@ FRONTEND_BASE=http://100.x.y.z:3000
# CORS origin (frontend origin) - set to frontend base for tighter security
CORS_ORIGIN=http://100.x.y.z:3000
# Optional invite delete protection
INVITE_API_KEY=replace-with-a-secret
# Postgres connection (replace user, password, host, port, and database name)
# Example for your Tailscale IP 100.111.50.59:
DATABASE_URL=postgres://dbuser:dbpass@100.111.50.59:5432/ecs_db
# Invite token secret (short-lived HMAC tokens for invite delete protection)
INVITE_TOKEN_SECRET=replace-with-a-long-random-secret
# Discord credentials
DISCORD_CLIENT_ID=your_client_id
DISCORD_CLIENT_SECRET=your_client_secret
DISCORD_BOT_TOKEN=your_bot_token
# Encryption key for backend db.json
# Encryption key for backend db.json (only needed if you plan to decrypt/migrate old data)
ENCRYPTION_KEY=pick-a-long-random-string
# --- Twitch API (optional; required for Live Notifications)
# Register an application at https://dev.twitch.tv to obtain these
TWITCH_CLIENT_ID=your_twitch_client_id
TWITCH_CLIENT_SECRET=your_twitch_client_secret
# Poll interval in milliseconds for the bot watcher (default = 30000 = 30s)
TWITCH_POLL_INTERVAL_MS=30000
# --- Bot push (optional) - used when backend and bot run on different hosts
# If the bot runs on a separate host/process, set BOT_PUSH_URL to the public
# URL the bot exposes for receiving settings pushes (backend will POST there)
# Example: BOT_PUSH_URL=http://bot-host:4002
BOT_PUSH_URL=
# Shared secret used to secure backend -> bot pushes. Must match BOT_SECRET in the bot env.
BOT_SECRET=replace-with-a-long-random-secret
# When BOT_PUSH_PORT is set, the bot starts a small HTTP endpoint to accept pushes
# (only needed if bot runs separately and you want immediate pushes).
BOT_PUSH_PORT=4002

View File

@@ -21,6 +21,351 @@ app.use(express.json());
const axios = require('axios');
const crypto = require('crypto');
// Twitch API helpers (uses TWITCH_CLIENT_ID and TWITCH_CLIENT_SECRET from env)
let _twitchToken = null;
let _twitchTokenExpiry = 0;
async function getTwitchAppToken() {
if (_twitchToken && Date.now() < _twitchTokenExpiry - 60000) return _twitchToken;
const id = process.env.TWITCH_CLIENT_ID;
const secret = process.env.TWITCH_CLIENT_SECRET;
if (!id || !secret) return null;
try {
const resp = await axios.post('https://id.twitch.tv/oauth2/token', null, {
params: { client_id: id, client_secret: secret, grant_type: 'client_credentials' },
});
_twitchToken = resp.data.access_token;
const expiresIn = Number(resp.data.expires_in) || 3600;
_twitchTokenExpiry = Date.now() + expiresIn * 1000;
return _twitchToken;
} catch (e) {
console.error('Failed to fetch Twitch app token:', e && e.response && e.response.data ? e.response.data : e.message || e);
return null;
}
}
async function getTwitchStreamsForUsers(usernames = []) {
try {
if (!usernames || usernames.length === 0) return [];
const token = await getTwitchAppToken();
const id = process.env.TWITCH_CLIENT_ID;
const params = new URLSearchParams();
for (const u of usernames) params.append('user_login', u.toLowerCase());
const headers = {};
if (id) headers['Client-Id'] = id;
if (token) headers['Authorization'] = `Bearer ${token}`;
const url = `https://api.twitch.tv/helix/streams?${params.toString()}`;
const resp = await axios.get(url, { headers });
// resp.data.data is an array of live streams
const live = resp.data && resp.data.data ? resp.data.data : [];
// Map by user_login
// Fetch user info (bio, profile image) so we can include it in the response
const uniqueLogins = Array.from(new Set(usernames.map(u => (u || '').toLowerCase()))).filter(Boolean);
const users = {};
if (uniqueLogins.length > 0) {
const userParams = new URLSearchParams();
for (const u of uniqueLogins) userParams.append('login', u);
try {
const usersUrl = `https://api.twitch.tv/helix/users?${userParams.toString()}`;
const usersResp = await axios.get(usersUrl, { headers });
const usersData = usersResp.data && usersResp.data.data ? usersResp.data.data : [];
for (const u of usersData) {
users[(u.login || '').toLowerCase()] = {
id: u.id,
login: u.login,
display_name: u.display_name,
description: u.description,
profile_image_url: u.profile_image_url,
};
}
} catch (e) {
// ignore user fetch errors
}
}
// collect game ids from live streams to resolve game names
const gameIds = Array.from(new Set((live || []).map(s => s.game_id).filter(Boolean)));
const games = {};
if (gameIds.length > 0) {
try {
const gamesParams = new URLSearchParams();
for (const idv of gameIds) gamesParams.append('id', idv);
const gamesUrl = `https://api.twitch.tv/helix/games?${gamesParams.toString()}`;
const gamesResp = await axios.get(gamesUrl, { headers });
const gamesData = gamesResp.data && gamesResp.data.data ? gamesResp.data.data : [];
for (const g of gamesData) {
games[g.id] = g.name;
}
} catch (e) {
// ignore
}
}
// Build response for every requested username (include user info even when offline)
const map = {};
for (const u of uniqueLogins) {
map[u] = {
is_live: false,
user_login: u,
user_name: (users[u] && users[u].display_name) || u,
title: null,
viewer_count: 0,
started_at: null,
url: `https://www.twitch.tv/${u}`,
thumbnail_url: null,
description: (users[u] && users[u].description) || null,
profile_image_url: (users[u] && users[u].profile_image_url) || null,
game_name: null,
};
}
for (const s of live) {
const login = (s.user_login || '').toLowerCase();
// twitch returns thumbnail_url like ".../{width}x{height}.jpg" — replace with a sensible size
const rawThumb = s.thumbnail_url || null;
const thumb = rawThumb ? rawThumb.replace('{width}', '1280').replace('{height}', '720') : null;
map[login] = {
is_live: true,
user_login: s.user_login,
user_name: s.user_name,
title: s.title,
viewer_count: s.viewer_count,
started_at: s.started_at,
url: `https://www.twitch.tv/${s.user_login}`,
thumbnail_url: thumb,
description: (users[login] && users[login].description) || null,
profile_image_url: (users[login] && users[login].profile_image_url) || null,
game_name: (s.game_id && games[s.game_id]) || null,
};
}
return Object.values(map);
} catch (e) {
console.error('Error fetching twitch streams:', e && e.response && e.response.data ? e.response.data : e.message || e);
return [];
}
}
// Proxy endpoint for frontend/bot to request stream status for usernames (comma separated)
app.get('/api/twitch/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 getTwitchStreamsForUsers(users);
res.json(streams);
} catch (err) {
console.error('Error in /api/twitch/streams:', err);
res.status(500).json({ success: false, message: 'Internal Server Error' });
}
});
// Kick API helpers (web scraping since no public API)
async function getKickStreamsForUsers(usernames = []) {
try {
if (!usernames || usernames.length === 0) return [];
const results = [];
for (const username of usernames) {
try {
// Use Kick's API endpoint to check if user is live
const url = `https://kick.com/api/v1/channels/${encodeURIComponent(username)}`;
const response = await axios.get(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
'Accept': 'application/json',
'Referer': 'https://kick.com/'
},
timeout: 5000 // 5 second timeout
});
if (response.status === 200 && response.data) {
const data = response.data;
if (data.livestream && data.livestream.is_live) {
results.push({
is_live: true,
user_login: username,
user_name: data.user?.username || username,
title: data.livestream.session_title || `${username} is live`,
viewer_count: data.livestream.viewer_count || 0,
started_at: data.livestream.start_time,
url: `https://kick.com/${username}`,
thumbnail_url: data.livestream.thumbnail?.url || null,
category: data.category?.name || 'Unknown',
description: data.user?.bio || ''
});
} else {
// User exists but not live
results.push({
is_live: false,
user_login: username,
user_name: data.user?.username || username,
title: null,
viewer_count: 0,
started_at: null,
url: `https://kick.com/${username}`,
thumbnail_url: null,
category: null,
description: data.user?.bio || ''
});
}
} else {
// User not found or API error
results.push({
is_live: false,
user_login: username,
user_name: username,
title: null,
viewer_count: 0,
started_at: null,
url: `https://kick.com/${username}`,
thumbnail_url: null,
category: null,
description: ''
});
}
} catch (e) {
// If API fails with 403, try web scraping as fallback
if (e.response && e.response.status === 403) {
// console.log(`API blocked for ${username}, trying web scraping fallback...`);
try {
const pageUrl = `https://kick.com/${encodeURIComponent(username)}`;
const pageResponse = await axios.get(pageUrl, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.5',
'Accept-Encoding': 'gzip, deflate, br',
'DNT': '1',
'Connection': 'keep-alive',
'Upgrade-Insecure-Requests': '1',
'Sec-Fetch-Dest': 'document',
'Sec-Fetch-Mode': 'navigate',
'Sec-Fetch-Site': 'none',
'Cache-Control': 'max-age=0'
},
timeout: 5000
});
if (pageResponse.status === 200) {
const html = pageResponse.data;
// Check for live stream indicators in the HTML
const isLive = html.includes('"is_live":true') || html.includes('"is_live": true') ||
html.includes('data-is-live="true"') || html.includes('isLive:true');
if (isLive) {
// Try to extract stream info from HTML
let title = `${username} is live`;
let viewerCount = 0;
let category = 'Unknown';
// Extract title
const titleMatch = html.match(/"session_title"\s*:\s*"([^"]+)"/) || html.match(/"title"\s*:\s*"([^"]+)"/);
if (titleMatch) {
title = titleMatch[1].replace(/\\"/g, '"');
}
// Extract viewer count
const viewerMatch = html.match(/"viewer_count"\s*:\s*(\d+)/);
if (viewerMatch) {
viewerCount = parseInt(viewerMatch[1]);
}
// Extract category
const categoryMatch = html.match(/"category"\s*:\s*{\s*"name"\s*:\s*"([^"]+)"/);
if (categoryMatch) {
category = categoryMatch[1];
}
results.push({
is_live: true,
user_login: username,
user_name: username,
title: title,
viewer_count: viewerCount,
started_at: new Date().toISOString(),
url: `https://kick.com/${username}`,
thumbnail_url: null,
category: category,
description: ''
});
} else {
// User exists but not live
results.push({
is_live: false,
user_login: username,
user_name: username,
title: null,
viewer_count: 0,
started_at: null,
url: `https://kick.com/${username}`,
thumbnail_url: null,
category: null,
description: ''
});
}
} else {
throw e; // Re-throw if page request also fails
}
} catch (scrapeError) {
console.error(`Web scraping fallback also failed for ${username}:`, scrapeError.message || scrapeError);
// Return offline status on error
results.push({
is_live: false,
user_login: username,
user_name: username,
title: null,
viewer_count: 0,
started_at: null,
url: `https://kick.com/${username}`,
thumbnail_url: null,
category: null,
description: ''
});
}
} else {
console.error(`Error checking Kick user ${username}:`, e && e.response && e.response.status ? `HTTP ${e.response.status}` : e.message || e);
// Return offline status on error
results.push({
is_live: false,
user_login: username,
user_name: username,
title: null,
viewer_count: 0,
started_at: null,
url: `https://kick.com/${username}`,
thumbnail_url: null,
category: null,
description: ''
});
}
}
// Small delay between requests to be respectful to Kick's servers
await new Promise(r => setTimeout(r, 200));
}
return results;
} catch (e) {
console.error('Error in getKickStreamsForUsers:', e && e.response && e.response.data ? e.response.data : e.message || e);
return [];
}
}
// Proxy endpoint for frontend/bot to request Kick stream status for usernames (comma separated)
app.get('/api/kick/streams', async (req, res) => {
const q = req.query.users || req.query.user || '';
const users = q.split(',').map(s => (s || '').trim()).filter(Boolean);
try {
const streams = await getKickStreamsForUsers(users);
res.json(streams);
} catch (err) {
console.error('Error in /api/kick/streams:', err);
res.status(500).json({ success: false, message: 'Internal Server Error' });
}
});
// Invite token helpers: short-lived HMAC-signed token so frontend can authorize invite deletes
const INVITE_TOKEN_TTL_MS = 5 * 60 * 1000; // 5 minutes
const inviteTokenSecret = process.env.INVITE_TOKEN_SECRET || process.env.ENCRYPTION_KEY || 'fallback-invite-secret';
@@ -103,8 +448,14 @@ app.get('/auth/discord/callback', async (req, res) => {
const adminGuilds = guildsResponse.data.filter(guild => (guild.permissions & 0x8) === 0x8);
const user = userResponse.data;
const db = readDb();
user.theme = db.users && db.users[user.id] ? db.users[user.id].theme : 'light';
// fetch user data (theme, preferences) from Postgres
try {
const udata = await (require('./pg')).getUserData(user.id);
user.theme = (udata && udata.theme) ? udata.theme : 'light';
} catch (e) {
console.error('Error fetching user data:', e);
user.theme = 'light';
}
const guilds = adminGuilds;
res.redirect(`${FRONTEND_BASE}/dashboard?user=${encodeURIComponent(JSON.stringify(user))}&guilds=${encodeURIComponent(JSON.stringify(guilds))}`);
} catch (error) {
@@ -115,41 +466,143 @@ app.get('/auth/discord/callback', async (req, res) => {
const { readDb, writeDb } = require('./db');
app.get('/api/servers/:guildId/settings', (req, res) => {
const { guildId } = req.params;
const db = readDb();
const settings = db[guildId] || { pingCommand: false };
res.json(settings);
// Require DATABASE_URL for Postgres persistence (full transition)
if (!process.env.DATABASE_URL) {
console.error('DATABASE_URL is not set. The backend now requires a Postgres database. Set DATABASE_URL in backend/.env');
process.exit(1);
}
const pgClient = require('./pg');
pgClient.ensureSchema().catch(err => {
console.error('Error ensuring PG schema:', err);
process.exit(1);
});
console.log('Postgres enabled for persistence');
// Simple Server-Sent Events (SSE) broadcaster
const sseClients = new Map(); // key: guildId or '*' -> array of res
function publishEvent(guildId, type, payload) {
const enriched = Object.assign({}, payload || {}, { guildId });
const msg = `event: ${type}\ndata: ${JSON.stringify(enriched)}\n\n`;
// send to guild-specific subscribers
const list = sseClients.get(guildId) || [];
for (const res of list.slice()) {
try { res.write(msg); } catch (e) { /* ignore write errors */ }
}
// send to global subscribers
const global = sseClients.get('*') || [];
for (const res of global.slice()) {
try { res.write(msg); } catch (e) { /* ignore */ }
}
}
app.get('/api/events', (req, res) => {
const guildId = req.query.guildId || '*';
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders && res.flushHeaders();
// send an initial ping
res.write(`event: connected\ndata: ${JSON.stringify({ guildId })}\n\n`);
if (!sseClients.has(guildId)) sseClients.set(guildId, []);
sseClients.get(guildId).push(res);
req.on('close', () => {
const arr = sseClients.get(guildId) || [];
const idx = arr.indexOf(res);
if (idx !== -1) arr.splice(idx, 1);
});
});
app.post('/api/servers/:guildId/settings', (req, res) => {
// Health endpoint used by frontend to detect backend availability
app.get('/api/servers/health', async (req, res) => {
try {
// Basic checks: server is running; optionally check DB connectivity
const health = { ok: true, db: null, bot: null };
try {
// if pgClient is available, attempt a simple query
if (pgClient && typeof pgClient.query === 'function') {
await pgClient.query('SELECT 1');
health.db = true;
}
} catch (e) {
health.db = false;
}
try {
health.bot = (bot && bot.client && bot.client.user) ? true : false;
} catch (e) {
health.bot = false;
}
res.json(health);
} catch (e) {
res.status(500).json({ ok: false });
}
});
app.get('/api/servers/:guildId/settings', async (req, res) => {
const { guildId } = req.params;
try {
const settings = await pgClient.getServerSettings(guildId);
return res.json(settings || { pingCommand: false });
} catch (err) {
console.error('Error fetching settings:', err);
res.status(500).json({ success: false, message: 'Internal Server Error' });
}
});
app.post('/api/servers/:guildId/settings', async (req, res) => {
const { guildId } = req.params;
const newSettings = req.body || {};
const db = readDb();
if (!db[guildId]) db[guildId] = {};
// Merge incoming settings with existing settings to avoid overwriting unrelated keys
db[guildId] = { ...db[guildId], ...newSettings };
writeDb(db);
res.json({ success: true });
try {
const existing = (await pgClient.getServerSettings(guildId)) || {};
const merged = { ...existing, ...newSettings };
await pgClient.upsertServerSettings(guildId, merged);
return res.json({ success: true });
} catch (err) {
console.error('Error saving settings:', err);
res.status(500).json({ success: false, message: 'Internal Server Error' });
}
});
// Toggle a single command for a guild (preserves other toggles)
app.post('/api/servers/:guildId/commands/:cmdName/toggle', (req, res) => {
app.post('/api/servers/:guildId/commands/:cmdName/toggle', async (req, res) => {
const { guildId, cmdName } = req.params;
const { enabled } = req.body; // boolean
const protectedCommands = ['help', 'manage-commands'];
if (protectedCommands.includes(cmdName)) {
return res.status(403).json({ success: false, message: 'This command is locked and cannot be toggled.' });
}
const db = readDb();
if (!db[guildId]) db[guildId] = {};
if (!db[guildId].commandToggles) db[guildId].commandToggles = {};
if (typeof enabled === 'boolean') {
db[guildId].commandToggles[cmdName] = enabled;
writeDb(db);
try {
if (typeof enabled !== 'boolean') return res.status(400).json({ success: false, message: 'Missing or invalid "enabled" boolean in request body' });
const existing = (await pgClient.getServerSettings(guildId)) || {};
if (!existing.commandToggles) existing.commandToggles = {};
existing.commandToggles[cmdName] = enabled;
await pgClient.upsertServerSettings(guildId, existing);
// notify SSE subscribers about command toggle change
try { publishEvent(guildId, 'commandToggle', { cmdName, enabled }); } catch (e) {}
try {
// if bot is loaded in same process, notify it to update cache
if (bot && bot.setGuildSettings) {
bot.setGuildSettings(guildId, existing);
}
} catch (notifyErr) {
// ignore if bot isn't accessible
}
// If a remote bot push URL is configured, notify it with the new settings
try {
const botPushUrl = process.env.BOT_PUSH_URL || null;
const botSecret = process.env.BOT_SECRET || null;
if (botPushUrl) {
const headers = { 'Content-Type': 'application/json' };
if (botSecret) headers['x-bot-secret'] = botSecret;
await axios.post(`${botPushUrl.replace(/\/$/, '')}/internal/set-settings`, { guildId, settings: existing }, { headers });
}
} catch (pushErr) {
// ignore push failures
}
return res.json({ success: true, cmdName, enabled });
} catch (err) {
console.error('Error toggling command:', err);
res.status(500).json({ success: false, message: 'Internal Server Error' });
}
return res.status(400).json({ success: false, message: 'Missing or invalid "enabled" boolean in request body' });
});
app.get('/api/servers/:guildId/bot-status', (req, res) => {
@@ -166,18 +619,15 @@ app.get('/api/client-id', (req, res) => {
res.json({ clientId: process.env.DISCORD_CLIENT_ID });
});
app.post('/api/user/theme', (req, res) => {
app.post('/api/user/theme', async (req, res) => {
const { userId, theme } = req.body;
const db = readDb();
if (!db.users) {
db.users = {};
try {
await pgClient.upsertUserData(userId, { theme });
res.json({ success: true });
} catch (err) {
console.error('Error saving user theme:', err);
res.status(500).json({ success: false, message: 'Internal Server Error' });
}
if (!db.users[userId]) {
db.users[userId] = {};
}
db.users[userId].theme = theme;
writeDb(db);
res.json({ success: true });
});
app.post('/api/servers/:guildId/leave', async (req, res) => {
@@ -212,51 +662,54 @@ app.get('/api/servers/:guildId/channels', async (req, res) => {
}
});
app.get('/api/servers/:guildId/welcome-leave-settings', (req, res) => {
app.get('/api/servers/:guildId/welcome-leave-settings', async (req, res) => {
const { guildId } = req.params;
const db = readDb();
const settings = db[guildId] || {};
try {
const settings = (await pgClient.getServerSettings(guildId)) || {};
const welcomeLeaveSettings = {
welcome: {
enabled: settings.welcomeEnabled || false,
channel: settings.welcomeChannel || '',
message: settings.welcomeMessage || 'Welcome to the server, {user}!',
customMessage: settings.welcomeCustomMessage || '',
},
leave: {
enabled: settings.leaveEnabled || false,
channel: settings.leaveChannel || '',
message: settings.leaveMessage || '{user} has left the server.',
customMessage: settings.leaveCustomMessage || '',
},
};
const welcomeLeaveSettings = {
welcome: {
enabled: settings.welcomeEnabled || false,
channel: settings.welcomeChannel || '',
message: settings.welcomeMessage || 'Welcome to the server, {user}!',
customMessage: settings.welcomeCustomMessage || '',
},
leave: {
enabled: settings.leaveEnabled || false,
channel: settings.leaveChannel || '',
message: settings.leaveMessage || '{user} has left the server.',
customMessage: settings.leaveCustomMessage || '',
},
};
res.json(welcomeLeaveSettings);
res.json(welcomeLeaveSettings);
} catch (err) {
console.error('Error fetching welcome/leave settings:', err);
res.status(500).json({ success: false, message: 'Internal Server Error' });
}
});
app.post('/api/servers/:guildId/welcome-leave-settings', (req, res) => {
app.post('/api/servers/:guildId/welcome-leave-settings', async (req, res) => {
const { guildId } = req.params;
const newSettings = req.body;
const db = readDb();
try {
const existing = (await pgClient.getServerSettings(guildId)) || {};
const merged = { ...existing };
merged.welcomeEnabled = newSettings.welcome.enabled;
merged.welcomeChannel = newSettings.welcome.channel;
merged.welcomeMessage = newSettings.welcome.message;
merged.welcomeCustomMessage = newSettings.welcome.customMessage;
if (!db[guildId]) {
db[guildId] = {};
merged.leaveEnabled = newSettings.leave.enabled;
merged.leaveChannel = newSettings.leave.channel;
merged.leaveMessage = newSettings.leave.message;
merged.leaveCustomMessage = newSettings.leave.customMessage;
await pgClient.upsertServerSettings(guildId, merged);
return res.json({ success: true });
} catch (err) {
console.error('Error saving welcome/leave settings:', err);
res.status(500).json({ success: false, message: 'Internal Server Error' });
}
db[guildId].welcomeEnabled = newSettings.welcome.enabled;
db[guildId].welcomeChannel = newSettings.welcome.channel;
db[guildId].welcomeMessage = newSettings.welcome.message;
db[guildId].welcomeCustomMessage = newSettings.welcome.customMessage;
db[guildId].leaveEnabled = newSettings.leave.enabled;
db[guildId].leaveChannel = newSettings.leave.channel;
db[guildId].leaveMessage = newSettings.leave.message;
db[guildId].leaveCustomMessage = newSettings.leave.customMessage;
writeDb(db);
res.json({ success: true });
});
app.get('/api/servers/:guildId/roles', async (req, res) => {
@@ -280,42 +733,230 @@ app.get('/api/servers/:guildId/roles', async (req, res) => {
}
});
app.get('/api/servers/:guildId/autorole-settings', (req, res) => {
app.get('/api/servers/:guildId/autorole-settings', async (req, res) => {
const { guildId } = req.params;
const db = readDb();
const settings = db[guildId] || {};
const autoroleSettings = settings.autorole || { enabled: false, roleId: '' };
res.json(autoroleSettings);
try {
const settings = (await pgClient.getServerSettings(guildId)) || {};
const autoroleSettings = settings.autorole || { enabled: false, roleId: '' };
res.json(autoroleSettings);
} catch (err) {
console.error('Error fetching autorole settings:', err);
res.status(500).json({ success: false, message: 'Internal Server Error' });
}
});
app.post('/api/servers/:guildId/autorole-settings', (req, res) => {
app.post('/api/servers/:guildId/autorole-settings', async (req, res) => {
const { guildId } = req.params;
const { enabled, roleId } = req.body;
const db = readDb();
if (!db[guildId]) {
db[guildId] = {};
try {
if (pgClient) {
const existing = (await pgClient.getServerSettings(guildId)) || {};
existing.autorole = { enabled, roleId };
await pgClient.upsertServerSettings(guildId, existing);
return res.json({ success: true });
}
const db = readDb();
if (!db[guildId]) db[guildId] = {};
db[guildId].autorole = { enabled, roleId };
writeDb(db);
res.json({ success: true });
} catch (err) {
console.error('Error saving autorole settings:', err);
res.status(500).json({ success: false, message: 'Internal Server Error' });
}
db[guildId].autorole = { enabled, roleId };
writeDb(db);
res.json({ success: true });
});
// Live notifications (Twitch) - per-guild settings
app.get('/api/servers/:guildId/live-notifications', async (req, res) => {
const { guildId } = req.params;
try {
const settings = (await pgClient.getServerSettings(guildId)) || {};
const ln = settings.liveNotifications || { enabled: false, twitchUser: '', channelId: '', message: '', customMessage: '' };
return res.json(ln);
} catch (err) {
console.error('Error fetching live-notifications settings:', err);
res.status(500).json({ success: false, message: 'Internal Server Error' });
}
});
app.post('/api/servers/:guildId/live-notifications', async (req, res) => {
const { guildId } = req.params;
const { enabled, twitchUser, channelId, message, customMessage } = req.body || {};
try {
const existing = (await pgClient.getServerSettings(guildId)) || {};
const currentLn = existing.liveNotifications || {};
existing.liveNotifications = {
enabled: !!enabled,
twitchUser: twitchUser || '',
channelId: channelId || '',
message: message || '',
customMessage: customMessage || '',
users: currentLn.users || [], // preserve existing users
kickUsers: currentLn.kickUsers || [] // preserve existing kick users
};
await pgClient.upsertServerSettings(guildId, existing);
try { publishEvent(guildId, 'liveNotificationsUpdate', { enabled: !!enabled, channelId, twitchUser, message: existing.liveNotifications.message, customMessage: existing.liveNotifications.customMessage }); } catch (e) {}
return res.json({ success: true });
} catch (err) {
console.error('Error saving live-notifications settings:', err);
res.status(500).json({ success: false, message: 'Internal Server Error' });
}
});
// Twitch users list management for a guild
app.get('/api/servers/:guildId/twitch-users', async (req, res) => {
const { guildId } = req.params;
try {
const settings = (await pgClient.getServerSettings(guildId)) || {};
const users = (settings.liveNotifications && settings.liveNotifications.users) || [];
res.json(users);
} catch (err) {
console.error('Error fetching twitch users:', err);
res.status(500).json({ success: false, message: 'Internal Server Error' });
}
});
app.post('/api/servers/:guildId/twitch-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.users = Array.from(new Set([...(existing.liveNotifications.users || []), username.toLowerCase().trim()]));
await pgClient.upsertServerSettings(guildId, existing);
try { publishEvent(guildId, 'twitchUsersUpdate', { users: existing.liveNotifications.users || [] }); } catch (e) {}
// Optional push to bot process for immediate cache update
try {
if (process.env.BOT_PUSH_URL) {
await axios.post(`${process.env.BOT_PUSH_URL.replace(/\/$/, '')}/internal/set-settings`, { guildId, settings: existing }, {
headers: process.env.BOT_SECRET ? { 'x-bot-secret': process.env.BOT_SECRET } : {}
}).catch(() => {});
}
} catch (_) {}
res.json({ success: true });
} catch (err) {
console.error('Error adding twitch user:', err);
res.status(500).json({ success: false, message: 'Internal Server Error' });
}
});
app.delete('/api/servers/:guildId/twitch-users/:username', async (req, res) => {
const { guildId, username } = req.params;
try {
const existing = (await pgClient.getServerSettings(guildId)) || {};
if (!existing.liveNotifications) existing.liveNotifications = { enabled: false, channelId: '', users: [], kickUsers: [] };
existing.liveNotifications.users = (existing.liveNotifications.users || []).filter(u => u.toLowerCase() !== (username || '').toLowerCase());
await pgClient.upsertServerSettings(guildId, existing);
try { publishEvent(guildId, 'twitchUsersUpdate', { users: existing.liveNotifications.users || [] }); } catch (e) {}
// Optional push to bot process for immediate cache update
try {
if (process.env.BOT_PUSH_URL) {
await axios.post(`${process.env.BOT_PUSH_URL.replace(/\/$/, '')}/internal/set-settings`, { guildId, settings: existing }, {
headers: process.env.BOT_SECRET ? { 'x-bot-secret': process.env.BOT_SECRET } : {}
}).catch(() => {});
}
} catch (_) {}
res.json({ success: true });
} catch (err) {
console.error('Error removing twitch user:', err);
res.status(500).json({ success: false, message: 'Internal Server Error' });
}
});
// DISABLED: Kick users list management for a guild (temporarily disabled)
/*
app.get('/api/servers/:guildId/kick-users', async (req, res) => {
const { guildId } = req.params;
try {
const settings = (await pgClient.getServerSettings(guildId)) || {};
const users = (settings.liveNotifications && settings.liveNotifications.kickUsers) || [];
res.json(users);
} catch (err) {
console.error('Error fetching kick users:', err);
res.status(500).json({ success: false, message: 'Internal Server Error' });
}
});
app.post('/api/servers/:guildId/kick-users', async (req, res) => {
const { guildId } = req.params;
const { username } = req.body || {};
if (!username) return res.status(400).json({ success: false, message: 'Missing username' });
try {
const existing = (await pgClient.getServerSettings(guildId)) || {};
if (!existing.liveNotifications) existing.liveNotifications = { enabled: false, channelId: '', users: [], kickUsers: [] };
existing.liveNotifications.kickUsers = Array.from(new Set([...(existing.liveNotifications.kickUsers || []), username.toLowerCase().trim()]));
await pgClient.upsertServerSettings(guildId, existing);
try { publishEvent(guildId, 'kickUsersUpdate', { users: existing.liveNotifications.kickUsers || [] }); } catch (e) {}
// Optional push to bot process for immediate cache update
try {
if (process.env.BOT_PUSH_URL) {
await axios.post(`${process.env.BOT_PUSH_URL.replace(/\/$/, '')}/internal/set-settings`, { guildId, settings: existing }, {
headers: process.env.BOT_SECRET ? { 'x-bot-secret': process.env.BOT_SECRET } : {}
}).catch(() => {});
}
} catch (_) {}
res.json({ success: true });
} catch (err) {
console.error('Error adding kick user:', err);
res.status(500).json({ success: false, message: 'Internal Server Error' });
}
});
app.delete('/api/servers/:guildId/kick-users/:username', async (req, res) => {
const { guildId, username } = req.params;
try {
const existing = (await pgClient.getServerSettings(guildId)) || {};
if (!existing.liveNotifications) existing.liveNotifications = { enabled: false, channelId: '', users: [], kickUsers: [] };
existing.liveNotifications.kickUsers = (existing.liveNotifications.kickUsers || []).filter(u => u.toLowerCase() !== (username || '').toLowerCase());
await pgClient.upsertServerSettings(guildId, existing);
try { publishEvent(guildId, 'kickUsersUpdate', { users: existing.liveNotifications.kickUsers || [] }); } catch (e) {}
// Optional push to bot process for immediate cache update
try {
if (process.env.BOT_PUSH_URL) {
await axios.post(`${process.env.BOT_PUSH_URL.replace(/\/$/, '')}/internal/set-settings`, { guildId, settings: existing }, {
headers: process.env.BOT_SECRET ? { 'x-bot-secret': process.env.BOT_SECRET } : {}
}).catch(() => {});
}
} catch (_) {}
res.json({ success: true });
} catch (err) {
console.error('Error removing kick user:', err);
res.status(500).json({ success: false, message: 'Internal Server Error' });
}
});
*/
app.get('/', (req, res) => {
res.send('Hello from the backend!');
});
// Return list of bot commands and per-guild enabled/disabled status
app.get('/api/servers/:guildId/commands', (req, res) => {
// Debug helper: publish an arbitrary SSE event for a guild (guarded by DEBUG_SSE env var)
app.post('/api/servers/:guildId/debug/publish', express.json(), (req, res) => {
if (!process.env.DEBUG_SSE || process.env.DEBUG_SSE === '0') return res.status(404).json({ success: false, message: 'Not found' });
try {
const { guildId } = req.params;
const db = readDb();
const guildSettings = db[guildId] || {};
const { type, payload } = req.body || {};
if (!type) return res.status(400).json({ success: false, message: 'Missing event type' });
publishEvent(guildId, type, payload || {});
return res.json({ success: true });
} catch (e) {
console.error('Debug publish failed:', e);
return res.status(500).json({ success: false });
}
});
// Return list of bot commands and per-guild enabled/disabled status
app.get('/api/servers/:guildId/commands', async (req, res) => {
try {
const { guildId } = req.params;
const guildSettings = (await pgClient.getServerSettings(guildId)) || {};
const toggles = guildSettings.commandToggles || {};
const protectedCommands = ['manage-commands', 'help'];
const commands = Array.from(bot.client.commands.values()).map(cmd => {
const commands = Array.from(bot.client.commands.values())
.filter(cmd => !cmd.dev) // Filter out dev commands
.map(cmd => {
const isLocked = protectedCommands.includes(cmd.name);
const isEnabled = isLocked ? true : (toggles[cmd.name] !== false && cmd.enabled !== false);
return {
@@ -338,8 +979,7 @@ app.get('/api/servers/:guildId/commands', (req, res) => {
app.get('/api/servers/:guildId/invites', async (req, res) => {
try {
const { guildId } = req.params;
const db = readDb();
const saved = (db[guildId] && db[guildId].invites) ? db[guildId].invites : [];
const saved = await pgClient.listInvites(guildId);
// try to enrich with live data where possible
const guild = bot.client.guilds.cache.get(guildId);
@@ -377,7 +1017,7 @@ app.post('/api/servers/:guildId/invites', async (req, res) => {
const guild = bot.client.guilds.cache.get(guildId);
if (!guild) return res.status(404).json({ success: false, message: 'Guild not found' });
let channel = null;
let channel = null;
if (channelId) {
try { channel = await guild.channels.fetch(channelId); } catch (e) { channel = null; }
}
@@ -397,9 +1037,7 @@ app.post('/api/servers/:guildId/invites', async (req, res) => {
const invite = await channel.createInvite(inviteOptions);
const db = readDb();
if (!db[guildId]) db[guildId] = {};
if (!db[guildId].invites) db[guildId].invites = [];
// persist invite to Postgres
const item = {
code: invite.code,
@@ -411,8 +1049,7 @@ app.post('/api/servers/:guildId/invites', async (req, res) => {
temporary: !!invite.temporary,
};
db[guildId].invites.push(item);
writeDb(db);
await pgClient.addInvite({ code: item.code, guildId, url: item.url, channelId: item.channelId, createdAt: item.createdAt, maxUses: item.maxUses, maxAge: item.maxAge, temporary: item.temporary });
res.json({ success: true, invite: item });
} catch (error) {
@@ -430,7 +1067,6 @@ app.delete('/api/servers/:guildId/invites/:code', async (req, res) => {
}
try {
const { guildId, code } = req.params;
const db = readDb();
const guild = bot.client.guilds.cache.get(guildId);
// Try to delete on Discord if possible
@@ -445,9 +1081,14 @@ app.delete('/api/servers/:guildId/invites/:code', async (req, res) => {
}
}
if (db[guildId] && db[guildId].invites) {
db[guildId].invites = db[guildId].invites.filter(i => i.code !== code);
writeDb(db);
if (pgClient) {
await pgClient.deleteInvite(guildId, code);
} else {
const db = readDb();
if (db[guildId] && db[guildId].invites) {
db[guildId].invites = db[guildId].invites.filter(i => i.code !== code);
writeDb(db);
}
}
res.json({ success: true });
@@ -461,6 +1102,31 @@ const bot = require('../discord-bot');
bot.login();
// Dev/testing endpoint: force a live announcement
app.post('/internal/test-live', express.json(), async (req, res) => {
const { guildId, username, title } = req.body || {};
if (!guildId || !username) return res.status(400).json({ success: false, message: 'guildId and username required' });
try {
const stream = {
user_login: username.toLowerCase(),
user_name: username,
title: title || `${username} is live (test)`,
viewer_count: 1,
started_at: new Date().toISOString(),
url: `https://www.twitch.tv/${username.toLowerCase()}`,
thumbnail_url: null,
description: 'Test notification',
profile_image_url: null,
game_name: 'Testing',
};
const result = await bot.announceLive(guildId, stream);
res.json(result);
} catch (e) {
console.error('Error in /internal/test-live:', e && e.message ? e.message : e);
res.status(500).json({ success: false, message: 'Internal error' });
}
});
app.listen(port, host, () => {
console.log(`Server is running on ${host}:${port}`);
});

View File

@@ -13,7 +13,10 @@
"cors": "^2.8.5",
"crypto-js": "^4.2.0",
"dotenv": "^16.4.5",
"express": "^4.19.2"
"express": "^4.19.2",
"node-fetch": "^2.6.7",
"pg": "^8.11.0",
"pg-format": "^1.0.4"
},
"devDependencies": {
"nodemon": "^3.1.3"
@@ -871,6 +874,26 @@
"node": ">= 0.6"
}
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/nodemon": {
"version": "3.1.10",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz",
@@ -983,6 +1006,104 @@
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT"
},
"node_modules/pg": {
"version": "8.16.3",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.9.1",
"pg-pool": "^3.10.1",
"pg-protocol": "^1.10.3",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.2.7"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz",
"integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz",
"integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==",
"license": "MIT"
},
"node_modules/pg-format": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/pg-format/-/pg-format-1.0.4.tgz",
"integrity": "sha512-YyKEF78pEA6wwTAqOUaHIN/rWpfzzIuMh9KdAhc3rSLQ/7zkRFcCgYBAEGatDstLyZw4g0s9SNICmaTGnBVeyw==",
"license": "MIT",
"engines": {
"node": ">=4.0"
}
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz",
"integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.10.3",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz",
"integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
@@ -996,6 +1117,45 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz",
"integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -1258,6 +1418,15 @@
"node": ">=10"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
@@ -1312,6 +1481,12 @@
"nodetouch": "bin/nodetouch.js"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
@@ -1358,6 +1533,31 @@
"engines": {
"node": ">= 0.8"
}
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
}
}
}

View File

@@ -16,7 +16,10 @@
"cors": "^2.8.5",
"crypto-js": "^4.2.0",
"dotenv": "^16.4.5",
"express": "^4.19.2"
"express": "^4.19.2",
"pg": "^8.11.0",
"pg-format": "^1.0.4",
"node-fetch": "^2.6.7"
},
"devDependencies": {
"nodemon": "^3.1.3"

92
backend/pg.js Normal file
View File

@@ -0,0 +1,92 @@
const { Pool } = require('pg');
const format = require('pg-format');
let pool;
function initPool() {
if (pool) return pool;
const connectionString = process.env.DATABASE_URL;
if (!connectionString) throw new Error('DATABASE_URL is not set');
pool = new Pool({ connectionString });
return pool;
}
async function ensureSchema() {
const p = initPool();
// basic tables: servers (settings), invites
await p.query(`
CREATE TABLE IF NOT EXISTS servers (
guild_id TEXT PRIMARY KEY,
settings JSONB DEFAULT '{}'
);
`);
await p.query(`
CREATE TABLE IF NOT EXISTS invites (
code TEXT PRIMARY KEY,
guild_id TEXT NOT NULL,
url TEXT,
channel_id TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
max_uses INTEGER DEFAULT 0,
max_age INTEGER DEFAULT 0,
temporary BOOLEAN DEFAULT false
);
`);
await p.query(`
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
discord_id TEXT UNIQUE,
data JSONB DEFAULT '{}'
);
`);
}
// Servers
async function getServerSettings(guildId) {
const p = initPool();
const res = await p.query('SELECT settings FROM servers WHERE guild_id = $1', [guildId]);
if (res.rowCount === 0) return null;
return res.rows[0].settings || {};
}
async function upsertServerSettings(guildId, settings) {
const p = initPool();
await p.query(`INSERT INTO servers(guild_id, settings) VALUES($1, $2)
ON CONFLICT (guild_id) DO UPDATE SET settings = $2`, [guildId, settings]);
}
// Invites
async function listInvites(guildId) {
const p = initPool();
const res = await p.query('SELECT code, url, channel_id, created_at, max_uses, max_age, temporary FROM invites WHERE guild_id = $1 ORDER BY created_at DESC', [guildId]);
return res.rows;
}
async function addInvite(inv) {
const p = initPool();
const q = `INSERT INTO invites(code, guild_id, url, channel_id, created_at, max_uses, max_age, temporary) VALUES($1,$2,$3,$4,$5,$6,$7,$8)
ON CONFLICT (code) DO UPDATE SET url = EXCLUDED.url, channel_id = EXCLUDED.channel_id, max_uses = EXCLUDED.max_uses, max_age = EXCLUDED.max_age, temporary = EXCLUDED.temporary, created_at = EXCLUDED.created_at`;
await p.query(q, [inv.code, inv.guildId, inv.url, inv.channelId, inv.createdAt ? new Date(inv.createdAt) : new Date(), inv.maxUses || 0, inv.maxAge || 0, inv.temporary || false]);
}
async function deleteInvite(guildId, code) {
const p = initPool();
await p.query('DELETE FROM invites WHERE guild_id = $1 AND code = $2', [guildId, code]);
}
// Users
async function getUserData(discordId) {
const p = initPool();
const res = await p.query('SELECT data FROM users WHERE discord_id = $1', [discordId]);
if (res.rowCount === 0) return null;
return res.rows[0].data || {};
}
async function upsertUserData(discordId, data) {
const p = initPool();
await p.query(`INSERT INTO users(discord_id, data) VALUES($1, $2) ON CONFLICT (discord_id) DO UPDATE SET data = $2`, [discordId, data]);
}
module.exports = { initPool, ensureSchema, getServerSettings, upsertServerSettings, listInvites, addInvite, deleteInvite, getUserData, upsertUserData };

View File

@@ -0,0 +1,12 @@
const { Pool } = require('pg');
(async function(){
try{
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const res = await pool.query('SELECT guild_id FROM servers LIMIT 10');
console.log('servers:', JSON.stringify(res.rows, null, 2));
await pool.end();
}catch(err){
console.error('ERR', err && err.message ? err.message : err);
process.exit(1);
}
})();

View File

@@ -1,126 +1,110 @@
# Project Checklist (tidy & current)
# Project Checklist (tidy & current)
Below are the implemented features and current status as reflected in the repository. Items marked [x] are implemented and wired; unchecked items are pending.
Below are implemented features and pending items, grouped by area.
## Backend
- [x] Basic Express server and project setup
- [x] Discord OAuth2 endpoints
- [x] API endpoints for servers, channels, roles, leave, settings
- [x] Persist encrypted data to `db.json`
## Backend
- [x] Express API: OAuth, server settings, channel/role endpoints, leave
- [x] Invite endpoints (GET/POST/DELETE) and invite-token issuance
- [x] Per-command toggles persistence and management
- [x] Config endpoints for welcome/leave and autorole
## Frontend
- [x] Login page
- [x] Dashboard page
- [x] Backend connectivity for APIs
- [x] UI components using MUI
- [x] Server-specific settings pages
- [x] Persist user data (localStorage + backend)
- [x] Logout
- [x] Responsive UI and improved styling
- [x] Theme switching (light, dark, Discord grey)
- [x] User settings menu
- [x] Commands section in Server Settings (per-command toggles)
- [x] Commands list sorted alphabetically in Server Settings
- [x] Help → renamed to 'Commands List' and moved to dedicated page
- [x] NavBar redesigned (single-hamburger, title 'ECS - EHDCHADSWORTH')
- [x] Invite button on dashboard and server cards (with pre-invite check)
- [x] Invite button on dashboard (single action below the server title) and server cards (with pre-invite check)
- [x] Invite management UI in Server Settings (create/list/delete invites)
## Frontend
- [x] Login, Dashboard, Server Settings pages
- Login redirects to Dashboard after OAuth and user/guilds are persisted in localStorage
- Dashboard is protected: user must be logged in to view (redirects to login otherwise)
- [x] MUI components, responsive layout, mobile fixes
- [x] Theme switching (persist local) and user settings UI
- [x] Invite UI: create form, list, copy, delete with confirmation
- [x] Commands UI (per-command toggles)
- [x] Live Notifications UI (per-server toggle & config)
- Channel selection, watched-user list, live status with Watch Live button
- Real-time updates: adding/removing users via frontend or bot commands publishes SSE `twitchUsersUpdate` and pushes settings to bot
- Bot commands (`/add-twitchuser`, `/remove-twitchuser`) refresh local cache immediately after backend success
- Message mode: toggle between Default and Custom; Apply sends `message`/`customMessage` (default fallback if empty); no longer dual free-form fields
- Collapsible accordion interface: separate Twitch and Kick tabs (Kick tab disabled)
## Invite Management (implemented)
- [x] Backend endpoints: GET/POST/DELETE `/api/servers/:guildId/invites`
- [x] Backend endpoints: GET/POST/DELETE `/api/servers/:guildId/invites` (supports optional `INVITE_API_KEY` or short-lived invite tokens via `/api/servers/:guildId/invite-token`)
- [x] Frontend: invite creation form (channel optional, expiry, max uses, temporary), labels added, mobile-friendly layout
- [x] Frontend: invite list with Copy and Delete actions and metadata
- [x] Frontend: invite list with Copy and Delete actions and metadata (copy/delete fixed UI handlers)
- [x] Discord bot commands: `/create-invite`, `/list-invites` and interaction handlers for copy/delete
- [x] Invites persisted in encrypted `db.json`
## Discord Bot
- [x] discord.js integration (events and commands)
- [x] Slash commands: `/create-invite`, `/list-invites`, `/manage-commands`, `/help`
- [x] Bot used by backend to fetch live guild data and manage invites
- [x] Bot reads/writes per-guild command toggles via backend/Postgres
- [x] Backend immediately notifies bot of toggle changes (pushes updated settings to bot cache) so frontend toggles take effect instantly
- [x] New slash command: `/setup-live` to enable/disable Twitch live notifications for the server (preserves other settings)
- [x] Frontend: Confirm dialog and working Delete action for Twitch watched users in Live Notifications
- [x] Live Notifications: bot posts message to configured channel with stream title and link when a watched Twitch user goes live
- [x] Live Notifications: bot posts rich embed to channel when a watched Twitch user goes live (thumbnail, clickable title, bio/description, category/game, viewers, footer with "ehchadservices" and start datetime)
- [x] Live Notifications polling frequency set to 5 seconds (configurable via `TWITCH_POLL_INTERVAL_MS`)
- [x] On bot restart, sends messages for currently live watched users; then sends for new streams once per session
- [x] Frontend: show "Watch Live" button next to watched user when they are live (links to Twitch)
- [x] Bi-directional sync: backend POST/DELETE for twitch-users now also pushes new settings to bot process (when `BOT_PUSH_URL` configured)
- [x] Bot adds/removes users via backend endpoints ensuring single source of truth (Postgres)
- [x] Live notifications toggle on site enables/disables watching and publishes SSE for real-time updates
- [x] /manage-commands command has enable/disable buttons that sync with frontend via backend API and SSE for live updating
- [x] All Twitch-related commands (add, remove, list) and frontend actions communicate with backend and Postgres database
- [x] Welcome/Leave messages: bot sends configured messages to channels when users join/leave
- [x] Welcome messages with {user} and {server} placeholders
- [x] Leave messages with {user} placeholder
- [x] Autorole assignment on member join
- [x] All settings managed through Server Settings UI
- [x] Event handlers properly integrated with API settings
- [x] Kick live notifications bot integration (temporarily disabled)
- [x] New slash commands: `/add-kickuser`, `/remove-kickuser`, `/list-kickusers` (commands exist but watcher disabled)
- [x] Kick API polling and notification posting (watcher removed, API endpoints remain)
- [x] Per-server Kick user management via backend API (endpoints functional)
- [x] Frontend tabs: separate Twitch and Kick tabs in Live Notifications accordion (Kick tab disabled)
- [x] Bot watcher temporarily disabled in index.js startup
- [x] Dev command filtering: commands marked with `dev: true` are hidden from UI, help, and Discord registration
## Security
- [x] Invite DELETE route now requires a short-lived invite token issued by `/api/servers/:guildId/invite-token` and sent in the `x-invite-token` header. The old `INVITE_API_KEY` header is no longer used.
- [x] Invite delete UI now shows a confirmation dialog before deleting an invite.
## Database
- [x] Postgres support via `DATABASE_URL` (backend auto-creates `servers`, `invites`, `users`)
- [x] Legacy encrypted `backend/db.json` retained (migration planned)
- [x] Kick.com live notifications: backend API, frontend UI, bot integration
- Database schema: kickUsers table with userId, username, guildId
- API endpoints: GET/POST/DELETE /api/servers/:guildId/kick-users
- Bot commands: /add-kickuser, /remove-kickuser, /list-kickusers
- Frontend tabs: separate Twitch and Kick tabs in Live Notifications accordion
- Kick API integration: polling for live status, stream metadata, web scraping fallback for 403 errors
- Per-server configuration: all settings scoped by guildId
- [x] Schema: live notification settings stored in server settings (via `liveNotifications` JSON)
- Fields: `enabled`, `channelId`, `users[]`, `kickUsers[]`, `message`, `customMessage` (custom overrides default if non-empty)
- Users list preserved when updating other live notification settings (fixed: kickUsers now also preserved)
## Theme & UX
- [x] Theme changes persist immediately (localStorage) and are applied across navigation
- [x] Theme preference priority: local selection > server preference > default (default only used on first visit)
## Security & Behavior
- [x] Invite DELETE requires short-lived HMAC token (`x-invite-token`)
- [x] Frontend confirmation dialog for invite deletion
- [ ] Harden invite-token issuance (require OAuth + admin check)
- [ ] Template variables for messages (planned): support `{user}`, `{title}`, `{category}`, `{viewers}` replacement in `message` / `customMessage`
## Discord Bot
- [x] Bot with event & command handlers
- [x] Slash command registration and runtime enable/disable mechanism
- [x] `/help` and `/manage-commands` (manage persists toggles to backend)
- [x] Invite-related slash commands implemented (`/create-invite`, `/list-invites`)
## Docs & Deployment
- [x] README and CHANGELOG updated with setup steps and Postgres guidance
- Core env vars: `DATABASE_URL`, `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET`, `DISCORD_BOT_TOKEN`, `INVITE_TOKEN_SECRET`, `ENCRYPTION_KEY`, `HOST`, `PORT`, `CORS_ORIGIN`
- Frontend: set `REACT_APP_API_BASE` to backend URL before build
- Tailscale: bind backend to your tailnet IP (100.x.y.z) and set `DATABASE_URL` to a Postgres reachable over the tailnet
## Features
- [x] Welcome/Leave messages (frontend + backend + bot integration)
- [x] Autorole (frontend + backend + bot integration)
Notes:
- `backend/.env.example` and `frontend/.env.example` are templates — copy to `.env` and fill values.
- Postgres / pgAdmin: create DB & user, set `DATABASE_URL`; backend auto-creates tables on startup.
## Pending / Suggested improvements
- [ ] Consider stronger auth for invite delete (e.g., require user auth or signed requests); currently an optional API key is supported via `INVITE_API_KEY`.
UI tweaks applied:
- Server cards: uniform sizes, image cropping, name clamping
- Mobile spacing and typography adjustments
- Dashboard action buttons repositioned (Invite/Leave under title)
- Live Notifications: collapsible accordion with tabbed interface for Twitch and Kick tabs (Kick tab disabled)
## Deployment notes (VPS / Tailscale)
Quick guidance to run the backend, frontend and bot on a VPS or make the API accessible over a Tailscale network:
- Environment variables you'll want to set (backend `.env`):
- `PORT` (e.g. 3002)
- `HOST` the bind address (e.g. `100.x.y.z` Tailscale IP for the VPS or `0.0.0.0` to bind all interfaces)
- `CORS_ORIGIN` origin allowed for cross-origin requests (e.g. `http://100.x.y.z:3000` or `*` during testing)
- `INVITE_API_KEY` (optional) secret to protect invite DELETE requests
- `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET`, `DISCORD_BOT_TOKEN`, `ENCRYPTION_KEY` (existing bot secrets)
- Frontend config:
- Build the frontend (`npm run build`) and serve it with a static server (nginx) or host separately.
- Configure `REACT_APP_API_BASE` before building to point to your backend (e.g. `http://100.x.y.z:3002`).
Current local dev hosts used in this workspace (update these values in your `.env` files if you change ports):
- Frontend dev server: `http://100.70.209.56:3001` (set in `frontend/.env` as HOST=100.70.209.56 and PORT=3001)
- Backend server: `http://100.70.209.56:3002` (set in `backend/.env` as HOST=100.70.209.56 and PORT=3002)
Discord Developer Portal settings (must match your BACKEND_BASE and FRONTEND_BASE):
- OAuth2 Redirect URI to add: `http://100.70.209.56:3002/auth/discord/callback`
- OAuth2 Allowed Origin (CORS / Application Origin): `http://100.70.209.56:3001`
- Tailscale notes:
- Ensure the VPS has Tailscale installed and is in your Tailnet.
- Use the VPS Tailscale IP (100.x.y.z) as `HOST` or to reach the API from other machines on the tailnet.
- For convenience and security, only expose ports on the Tailscale interface and avoid opening them to the public internet.
- Example systemd service (backend) on VPS (/etc/systemd/system/ecs-backend.service):
- Set `Environment=` entries for your `.env` or point to a `.env` file in the service unit, and run `node index.js` in the `backend` folder.
Where to change host/port and base URLs
- Backend: edit `backend/.env` (or set the environment variables) — key entries:
- `HOST` — bind address (e.g., your Tailscale IP like `100.x.y.z` or `0.0.0.0`)
- `PORT` — port the backend listens on (e.g., `3002`)
- `BACKEND_BASE` — optional fully-qualified base URL (defaults to `http://HOST:PORT`)
- `FRONTEND_BASE` — used for OAuth redirect to frontend (e.g., `http://100.x.y.z:3000`)
- Frontend: set `REACT_APP_API_BASE` in `frontend/.env` before running `npm run build` (or export at runtime for development). Example:
- `REACT_APP_API_BASE=http://100.x.y.z:3002`
I've added `backend/.env.example` and `frontend/.env.example` as templates — copy them to `.env` and fill in values for your environment.
- Example nginx (reverse proxy) snippet if you want to expose via a domain (optional):
- Proxy `https://yourdomain.example` to the backend (or to the frontend build directory) with TLS termination at nginx.
If you'd like, I can:
- Add a small `deploy.md` with exact steps and example `systemd` unit and `nginx` config.
- Update frontend to read a runtime-config file (useful when you don't want to rebuild to change API base).
- [ ] Add unit/integration tests for invite endpoints and ThemeContext behavior
- [ ] Accessibility improvements (ARIA attributes, focus styles) across the settings forms
- [ ] Small UI polish (spacing/visuals) for invite list items and commands list
If you'd like, I can immediately:
- Pin protected commands (e.g., `help`, `manage-commands`) to the top of the Commands list while keeping the rest alphabetical.
- Add ARIA labels and keyboard navigation tweaks for the invite dropdowns.
- Add tests for ThemeContext.
UI tweaks applied:
- Server cards on the Dashboard have been updated to enforce exact identical size per breakpoint (fixed heights), images are cropped uniformly (object-fit: cover) so icons are the same visible area across cards, and long server names are clamped to two lines to prevent layout differences.
- Mobile spacing, paddings, and typography adjusted for better legibility on small screens.
- Mobile fix: Title clamping and CardContent overflow were tightened so cards no longer expand on mobile; images use a background-image approach and white background to keep visible areas identical.
- Dashboard action buttons moved: Invite/Leave action now appears below the server title with a left label 'Invite:' or 'Leave:' and the action button to the right.
- [x] Browser tab now shows `ECS - <Page Name>` (e.g., 'ECS - Dashboard', 'ECS - Server Settings') for each page.
- [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] Maintenance page
- Frontend displays a maintenance page with a loading indicator when the backend is offline; it polls the backend and reloads UI immediately when the backend is available.
- [x] Global backend health & SSE
- [x] Added `BackendContext` to centralize health polling and a single shared EventSource
- [x] Pages (including `ServerSettings`) use the shared event bus for live updates so the whole site receives changes in real-time
- [ ] Frontend file re-organization
- [ ] Verify guild-scoped SSE payloads include guildId and frontend filters events by guild (in-progress)
- [ ] Add debug SSE publish endpoint to help validate real-time flows (done, guarded by DEBUG_SSE)
- [x] Created `frontend/src/lib/api.js` and refactored some modules to use it
- [x] Created `frontend/src/components/common` and `frontend/src/components/server`
- [x] Moved `ConfirmDialog` and `MaintenancePage` to `components/common`
- [x] Moved `ServerSettings` and `HelpPage` to `components/server`
- [x] Fixed ESLint warnings: removed unused imports and handlers, added proper dependency management
- [ ] Remove legacy top-level duplicate files (archival recommended)

211
discord-bot/api.js Normal file
View File

@@ -0,0 +1,211 @@
const fetch = require('node-fetch');
// Resolve backend candidates (env or common local addresses). We'll try them in order
// for each request so the bot can still reach the backend even when it binds to
// a specific non-loopback IP.
const envBase = process.env.BACKEND_BASE ? process.env.BACKEND_BASE.replace(/\/$/, '') : null;
const host = process.env.BACKEND_HOST || process.env.HOST || '127.0.0.1';
const port = process.env.BACKEND_PORT || process.env.PORT || '3002';
const CANDIDATES = [envBase, `http://${host}:${port}`, `http://localhost:${port}`, `http://127.0.0.1:${port}`].filter(Boolean);
async function tryFetch(url, opts = {}) {
// Try each candidate base until one responds successfully
for (const base of CANDIDATES) {
const target = `${base.replace(/\/$/, '')}${url}`;
try {
const res = await fetch(target, opts);
if (res && (res.ok || res.status === 204)) {
return res;
}
// if this candidate returned a non-ok status, log and continue trying others
console.error(`Candidate ${base} returned ${res.status} ${res.statusText} for ${target}`);
} catch (e) {
// network error for this candidate; try next
// console.debug(`Candidate ${base} failed:`, e && e.message ? e.message : e);
}
}
// none of the candidates succeeded
return null;
}
async function safeFetchJsonPath(path, opts = {}) {
const res = await tryFetch(path, opts);
if (!res) return null;
try {
return await res.json();
} catch (e) {
console.error('Failed to parse JSON from backend response:', e && e.message ? e.message : e);
return null;
}
}
async function getServerSettings(guildId) {
const path = `/api/servers/${guildId}/settings`;
const json = await safeFetchJsonPath(path);
return json || {};
}
async function upsertServerSettings(guildId, settings) {
const path = `/api/servers/${guildId}/settings`;
try {
const res = await tryFetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings),
});
return res && res.ok;
} catch (e) {
console.error(`Failed to upsert settings for ${guildId}:`, e && e.message ? e.message : e);
return false;
}
}
async function getCommands(guildId) {
const path = `/api/servers/${guildId}/commands`;
const json = await safeFetchJsonPath(path);
return json || [];
}
async function toggleCommand(guildId, cmdName, enabled) {
const path = `/api/servers/${guildId}/commands/${cmdName}/toggle`;
try {
const res = await tryFetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled }),
});
return res && res.ok;
} catch (e) {
console.error(`Failed to toggle command ${cmdName} for ${guildId}:`, e && e.message ? e.message : e);
return false;
}
}
async function listInvites(guildId) {
const path = `/api/servers/${guildId}/invites`;
const json = await safeFetchJsonPath(path);
return json || [];
}
async function addInvite(guildId, invite) {
const path = `/api/servers/${guildId}/invites`;
try {
const res = await tryFetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(invite),
});
return res && res.ok;
} catch (e) {
console.error(`Failed to add invite for ${guildId}:`, e && e.message ? e.message : e);
return false;
}
}
async function deleteInvite(guildId, code) {
const path = `/api/servers/${guildId}/invites/${code}`;
try {
const res = await tryFetch(path, { method: 'DELETE' });
return res && res.ok;
} catch (e) {
console.error(`Failed to delete invite ${code} for ${guildId}:`, e && e.message ? e.message : e);
return false;
}
}
module.exports = { getServerSettings, upsertServerSettings, getCommands, toggleCommand, listInvites, addInvite, deleteInvite };
// Twitch users helpers
async function getTwitchUsers(guildId) {
const path = `/api/servers/${guildId}/twitch-users`;
const json = await safeFetchJsonPath(path);
return json || [];
}
async function addTwitchUser(guildId, username) {
const path = `/api/servers/${guildId}/twitch-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 twitch user ${username} for ${guildId}:`, e && e.message ? e.message : e);
return false;
}
}
async function deleteTwitchUser(guildId, username) {
const path = `/api/servers/${guildId}/twitch-users/${encodeURIComponent(username)}`;
try {
const res = await tryFetch(path, { method: 'DELETE' });
return res && res.ok;
} catch (e) {
console.error(`Failed to delete twitch user ${username} for ${guildId}:`, e && e.message ? e.message : e);
return false;
}
}
// Fetch stream status via backend proxy endpoint /api/twitch/streams?users=a,b,c
async function tryFetchTwitchStreams(usersCsv) {
const path = `/api/twitch/streams?users=${encodeURIComponent(usersCsv || '')}`;
const json = await safeFetchJsonPath(path);
return json || [];
}
// Raw direct call helper (not used in most environments) — kept for legacy watcher
async function _rawGetTwitchStreams(usersCsv) {
// Try direct backend candidate first
const path = `/api/twitch/streams?users=${encodeURIComponent(usersCsv || '')}`;
const res = await tryFetch(path);
if (!res) return [];
try { return await res.json(); } catch (e) { return []; }
}
// 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 };

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

View File

@@ -0,0 +1,42 @@
const { SlashCommandBuilder, PermissionsBitField } = require('discord.js');
const fetch = require('node-fetch');
module.exports = {
name: 'add-twitchuser',
description: 'Admin: add a Twitch username to watch for this server',
enabled: true,
builder: new SlashCommandBuilder()
.setName('add-twitchuser')
.setDescription('Add a Twitch username to watch for live notifications')
.addStringOption(opt => opt.setName('username').setDescription('Twitch 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}/twitch-users`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username })
});
if (resp.ok) {
await interaction.reply({ content: `Added ${username} to watch list.`, flags: 64 });
// 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 twitch user:', e);
await interaction.reply({ content: 'Internal error adding twitch user.', flags: 64 });
}
}
};

View File

@@ -28,22 +28,21 @@ module.exports = {
const invite = await targetChannel.createInvite({ maxAge, maxUses, temporary, unique: true });
const db = readDb();
if (!db[interaction.guildId]) db[interaction.guildId] = {};
if (!db[interaction.guildId].invites) db[interaction.guildId].invites = [];
const api = require('../api');
const item = {
code: invite.code,
url: invite.url,
channelId: targetChannel.id,
createdAt: new Date().toISOString(),
maxUses: invite.maxUses || maxUses || 0,
maxAge: invite.maxAge || maxAge || 0,
channel_id: targetChannel.id,
created_at: new Date().toISOString(),
max_uses: invite.maxUses || maxUses || 0,
max_age: invite.maxAge || maxAge || 0,
temporary: !!invite.temporary,
};
db[interaction.guildId].invites.push(item);
writeDb(db);
try {
await api.addInvite(interaction.guildId, { channelId: targetChannel.id, maxAge, maxUses, temporary });
} catch (e) {
console.error('Error saving invite to backend:', e);
}
await interaction.reply({ content: `Invite created: ${invite.url}`, ephemeral: true });
} catch (error) {

View File

@@ -1,4 +1,4 @@
const { SlashCommandBuilder } = require('discord.js');
const { SlashCommandBuilder, EmbedBuilder } = require('discord.js');
module.exports = {
name: 'help',
@@ -6,20 +6,62 @@ module.exports = {
enabled: true,
builder: new SlashCommandBuilder()
.setName('help')
.setDescription('List available bot commands and what they do.'),
.setDescription('List available bot commands and what they do.')
.addStringOption(opt => opt.setName('command').setDescription('Get detailed help for a specific command').setRequired(false)),
async execute(interaction) {
const commands = Array.from(interaction.client.commands.values()).filter(cmd => !!cmd.builder);
let text = '**Available Commands:**\n\n';
const db = require('../../backend/db').readDb();
const guildSettings = db[interaction.guildId] || {};
const toggles = guildSettings.commandToggles || {};
const protectedCommands = ['manage-commands', 'help'];
try {
const api = require('../api');
// fetch authoritative commands list for this guild
const commands = await api.getCommands(interaction.guildId) || [];
for (const cmd of commands) {
const isEnabled = protectedCommands.includes(cmd.name) ? true : (toggles[cmd.name] !== false && cmd.enabled !== false);
text += `/${cmd.name}${cmd.description || 'No description.'}${isEnabled ? 'Enabled' : 'Disabled'}${protectedCommands.includes(cmd.name) ? ' (locked)' : ''}\n`;
const target = interaction.options.getString('command');
if (target) {
const found = commands.find(c => c.name.toLowerCase() === target.toLowerCase());
if (!found) {
return await interaction.reply({ content: `No command named "/${target}" found.`, flags: 64 });
}
const embed = new EmbedBuilder()
.setTitle(`/${found.name}${found.locked ? 'Locked' : (found.enabled ? 'Enabled' : 'Disabled')}`)
.setDescription(found.description || 'No description available.')
.setColor(found.enabled ? 0x22c55e : 0xe11d48)
.addFields(
{ name: 'Usage', value: `/${found.name} ${(found.usage || '').trim() || ''}` },
{ name: 'Status', value: found.locked ? 'Locked (cannot be toggled)' : (found.enabled ? 'Enabled' : 'Disabled'), inline: true },
{ name: 'Has Slash Builder', value: found.hasSlashBuilder ? 'Yes' : 'No', inline: true }
)
.setFooter({ text: 'Use /help <command> to view detailed info about a command.' });
return await interaction.reply({ embeds: [embed], flags: 64 });
}
// Build a neat embed listing commands grouped by status
const embed = new EmbedBuilder()
.setTitle('Available Commands')
.setDescription('Use `/help <command>` to get detailed info on a specific command.')
.setColor(0x5865f2);
// Sort commands: enabled first, then disabled, locked last
const sorted = commands.slice().sort((a, b) => {
const ka = a.locked ? 2 : (a.enabled ? 0 : 1);
const kb = b.locked ? 2 : (b.enabled ? 0 : 1);
if (ka !== kb) return ka - kb;
return a.name.localeCompare(b.name);
});
// Build a concise field list (max 25 fields in Discord embed)
const fields = [];
for (const cmd of sorted) {
const status = cmd.locked ? '🔒 Locked' : (cmd.enabled ? '✅ Enabled' : '⛔ Disabled');
fields.push({ name: `/${cmd.name}`, value: `${cmd.description || 'No description.'}\n${status}`, inline: false });
if (fields.length >= 24) break;
}
if (fields.length > 0) embed.addFields(fields);
else embed.setDescription('No commands available.');
return await interaction.reply({ embeds: [embed], flags: 64 });
} catch (e) {
console.error('Error in help command:', e && e.message ? e.message : e);
return await interaction.reply({ content: 'Failed to retrieve commands. Try again later.', flags: 64 });
}
await interaction.reply({ content: text, flags: 64 });
},
};

View File

@@ -1,5 +1,5 @@
const { SlashCommandBuilder, PermissionFlagsBits, ActionRowBuilder, ButtonBuilder, ButtonStyle } = require('discord.js');
const { readDb } = require('../../backend/db');
const api = require('../api');
module.exports = {
name: 'list-invites',
@@ -11,8 +11,7 @@ module.exports = {
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
async execute(interaction) {
try {
const db = readDb();
const invites = (db[interaction.guildId] && db[interaction.guildId].invites) ? db[interaction.guildId].invites : [];
const invites = await api.listInvites(interaction.guildId) || [];
if (!invites.length) {
await interaction.reply({ content: 'No invites created by the bot in this server.', ephemeral: true });

View 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 });
}
},
};

View File

@@ -0,0 +1,23 @@
const { SlashCommandBuilder } = require('discord.js');
const api = require('../api');
module.exports = {
name: 'list-twitchusers',
description: 'List watched Twitch usernames for this server (Live Notifications).',
enabled: true,
builder: new SlashCommandBuilder().setName('list-twitchusers').setDescription('List watched Twitch usernames for this server'),
async execute(interaction) {
try {
const users = await api.getTwitchUsers(interaction.guildId) || [];
if (!users || users.length === 0) {
await interaction.reply({ content: 'No Twitch users are being watched for this server.', ephemeral: true });
return;
}
const list = users.map(u => `${u}`).join('\n');
await interaction.reply({ content: `Watched Twitch users:\n${list}`, ephemeral: true });
} catch (e) {
console.error('Error listing twitch users:', e);
await interaction.reply({ content: 'Failed to retrieve watched users.', ephemeral: true });
}
},
};

View File

@@ -1,5 +1,5 @@
const { SlashCommandBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, PermissionsBitField } = require('discord.js');
const { readDb, writeDb } = require('../../backend/db.js');
const api = require('../api');
module.exports = {
name: 'manage-commands',
@@ -15,14 +15,12 @@ module.exports = {
return;
}
const db = readDb();
if (!db[interaction.guildId]) db[interaction.guildId] = {};
if (!db[interaction.guildId].commandToggles) db[interaction.guildId].commandToggles = {};
const toggles = db[interaction.guildId].commandToggles;
const existingSettings = (await api.getServerSettings(interaction.guildId)) || {};
if (!existingSettings.commandToggles) existingSettings.commandToggles = {};
let toggles = existingSettings.commandToggles;
// Include all loaded commands so simple command modules (no SlashCommandBuilder) like
// `ping` are also listed. Filter for objects with a name for safety.
const commands = Array.from(interaction.client.commands.values()).filter(cmd => cmd && cmd.name);
const commands = Array.from(interaction.client.commands.values()).filter(cmd => cmd && cmd.name && !cmd.dev);
// Build button components (max 5 rows, 5 buttons per row)
const actionRows = [];
@@ -67,9 +65,19 @@ module.exports = {
collector.on('collect', async i => {
const cmdName = i.customId.replace('toggle_cmd_', '');
toggles[cmdName] = !(toggles[cmdName] !== false);
writeDb(db);
const newVal = !(toggles[cmdName] !== false);
// persist via backend API
try {
await api.toggleCommand(interaction.guildId, cmdName, newVal);
// fetch authoritative list to rebuild buttons
const fresh = await api.getCommands(interaction.guildId);
toggles = {};
for (const c of fresh) {
toggles[c.name] = c.enabled;
}
} catch (e) {
console.error('Error persisting command toggle:', e);
}
// rebuild buttons to reflect new state
const updatedRows = [];
let r = new ActionRowBuilder();

View File

@@ -1,17 +1,10 @@
const { readDb } = require('../../backend/db.js');
// ping uses backend settings via API
module.exports = {
name: 'ping',
description: 'Replies with Pong!',
enabled: true,
execute(interaction) {
const db = readDb();
const settings = db[interaction.guildId] || { pingCommand: false };
if (settings.pingCommand) {
interaction.reply('Pong!');
} else {
interaction.reply('The ping command is disabled on this server.');
}
async execute(interaction) {
await interaction.reply('Pong!');
},
};

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

View File

@@ -0,0 +1,40 @@
const { SlashCommandBuilder, PermissionsBitField } = require('discord.js');
const fetch = require('node-fetch');
module.exports = {
name: 'remove-twitchuser',
description: 'Admin: remove a Twitch username from this server watch list',
enabled: true,
builder: new SlashCommandBuilder()
.setName('remove-twitchuser')
.setDescription('Remove a Twitch username from the watch list')
.addStringOption(opt => opt.setName('username').setDescription('Twitch 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}/twitch-users/${encodeURIComponent(username)}`, { method: 'DELETE' });
if (resp.ok) {
await interaction.reply({ content: `Removed ${username} from watch list.`, flags: 64 });
// Refresh cached settings from backend
try {
const settingsResp = await fetch(`${backendBase}/api/servers/${interaction.guildId}/settings`);
if (settingsResp.ok) {
const json = await settingsResp.json();
const bot = require('..');
if (bot && bot.setGuildSettings) bot.setGuildSettings(interaction.guildId, json);
}
} catch (_) {}
} else {
await interaction.reply({ content: 'Failed to remove user via backend.', flags: 64 });
}
} catch (e) {
console.error('Error removing twitch user:', e);
await interaction.reply({ content: 'Internal error removing twitch user.', flags: 64 });
}
}
};

View File

@@ -9,13 +9,8 @@ module.exports = {
.setName('setup-autorole')
.setDescription('Interactively set up the autorole for this server.'),
async execute(interaction) {
const db = readDb();
const guildId = interaction.guildId;
if (!db[guildId]) {
db[guildId] = {};
}
const roleSelect = new RoleSelectMenuBuilder()
.setCustomId('autorole_role_select')
.setPlaceholder('Select the role to assign on join.');
@@ -45,11 +40,20 @@ module.exports = {
return;
}
db[guildId].autorole = {
enabled: true,
roleId: roleId,
};
writeDb(db);
// persist to backend
try {
const api = require('../api');
const existing = await api.getServerSettings(guildId) || {};
existing.autorole = { enabled: true, roleId };
await api.upsertServerSettings(guildId, existing);
} catch (e) {
console.error('Error persisting autorole to backend, falling back to local:', e);
const { readDb, writeDb } = require('../../backend/db.js');
const db = readDb();
if (!db[guildId]) db[guildId] = {};
db[guildId].autorole = { enabled: true, roleId };
writeDb(db);
}
await roleConfirmation.update({
content: `Autorole setup complete! New members will be assigned the **${role.name}** role.`,

View File

@@ -1,4 +1,5 @@
const { SlashCommandBuilder, ActionRowBuilder, ChannelSelectMenuBuilder, StringSelectMenuBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, ComponentType } = require('discord.js');
const api = require('../api');
const { readDb, writeDb } = require('../../backend/db.js');
const defaultLeaveMessages = ["{user} has left the server.", "Goodbye, {user}.", "We'll miss you, {user}."];
@@ -38,8 +39,19 @@ module.exports = {
});
const channelId = channelConfirmation.values[0];
db[guildId].leaveChannel = channelId;
db[guildId].leaveEnabled = true;
try {
const existing = (await api.getServerSettings(guildId)) || {};
existing.leaveEnabled = true;
existing.leaveChannel = channelId;
await api.upsertServerSettings(guildId, existing);
} catch (e) {
console.error('Error persisting leave settings to backend, falling back to local:', e);
const db = readDb();
if (!db[guildId]) db[guildId] = {};
db[guildId].leaveChannel = channelId;
db[guildId].leaveEnabled = true;
writeDb(db);
}
const messageOptions = defaultLeaveMessages.map(msg => ({
label: msg.length > 100 ? msg.substring(0, 97) + '...' : msg,
@@ -92,8 +104,17 @@ module.exports = {
});
const customMessage = modalSubmit.fields.getTextInputValue('custom_message_input');
db[guildId].leaveMessage = customMessage;
writeDb(db);
try {
const existing = (await api.getServerSettings(guildId)) || {};
existing.leaveMessage = customMessage;
await api.upsertServerSettings(guildId, existing);
} catch (e) {
console.error('Error persisting leave message to backend, falling back to local:', e);
const db = readDb();
if (!db[guildId]) db[guildId] = {};
db[guildId].leaveMessage = customMessage;
writeDb(db);
}
await modalSubmit.reply({
content: `Leave message setup complete! Channel: <#${channelId}>, Message: "${customMessage}"`,
@@ -101,8 +122,17 @@ module.exports = {
});
} else {
db[guildId].leaveMessage = selectedMessage;
writeDb(db);
try {
const existing = (await api.getServerSettings(guildId)) || {};
existing.leaveMessage = selectedMessage;
await api.upsertServerSettings(guildId, existing);
} catch (e) {
console.error('Error persisting leave message to backend, falling back to local:', e);
const db = readDb();
if (!db[guildId]) db[guildId] = {};
db[guildId].leaveMessage = selectedMessage;
writeDb(db);
}
await messageConfirmation.update({
content: `Leave message setup complete! Channel: <#${channelId}>, Message: "${selectedMessage}"`,
components: [],

View File

@@ -0,0 +1,33 @@
const { SlashCommandBuilder, PermissionsBitField } = require('discord.js');
const api = require('../api');
module.exports = {
name: 'setup-live',
description: 'Admin: enable or disable Twitch live notifications for this server',
enabled: true,
builder: new SlashCommandBuilder()
.setName('setup-live')
.setDescription('Enable or disable Twitch live notifications for this server')
.addBooleanOption(opt => opt.setName('enabled').setDescription('Enable/disable notifications').setRequired(true)),
async execute(interaction) {
if (!interaction.member.permissions.has(PermissionsBitField.Flags.Administrator)) {
await interaction.reply({ content: 'You must be a server administrator to configure live notifications.', flags: 64 });
return;
}
const enabled = interaction.options.getBoolean('enabled');
try {
const api = require('../api');
const existing = (await api.getServerSettings(interaction.guildId)) || {};
const currentLn = existing.liveNotifications || {};
existing.liveNotifications = { ...currentLn, enabled: !!enabled };
await api.upsertServerSettings(interaction.guildId, existing);
await interaction.reply({ content: `Live notifications ${enabled ? 'enabled' : 'disabled'} for this server.`, flags: 64 });
} catch (e) {
console.error('Error saving live notifications to backend:', e);
await interaction.reply({ content: 'Failed to update live notifications.', flags: 64 });
}
}
};

View File

@@ -1,4 +1,5 @@
const { SlashCommandBuilder, ActionRowBuilder, ChannelSelectMenuBuilder, StringSelectMenuBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, ComponentType } = require('discord.js');
const api = require('../api');
const { readDb, writeDb } = require('../../backend/db.js');
const defaultWelcomeMessages = ["Welcome to the server, {user}!", "Hey {user}, welcome!", "{user} has joined the party!"];
@@ -38,8 +39,20 @@ module.exports = {
});
const channelId = channelConfirmation.values[0];
db[guildId].welcomeChannel = channelId;
db[guildId].welcomeEnabled = true;
// persist via backend
try {
const existing = (await api.getServerSettings(guildId)) || {};
existing.welcomeEnabled = true;
existing.welcomeChannel = channelId;
await api.upsertServerSettings(guildId, existing);
} catch (e) {
console.error('Error persisting welcome settings to backend, falling back to local:', e);
const db = readDb();
if (!db[guildId]) db[guildId] = {};
db[guildId].welcomeChannel = channelId;
db[guildId].welcomeEnabled = true;
writeDb(db);
}
const messageOptions = defaultWelcomeMessages.map(msg => ({
label: msg.length > 100 ? msg.substring(0, 97) + '...' : msg,
@@ -92,8 +105,17 @@ module.exports = {
});
const customMessage = modalSubmit.fields.getTextInputValue('custom_message_input');
db[guildId].welcomeMessage = customMessage;
writeDb(db);
try {
const existing = (await api.getServerSettings(guildId)) || {};
existing.welcomeMessage = customMessage;
await api.upsertServerSettings(guildId, existing);
} catch (e) {
console.error('Error persisting welcome message to backend, falling back to local:', e);
const db = readDb();
if (!db[guildId]) db[guildId] = {};
db[guildId].welcomeMessage = customMessage;
writeDb(db);
}
await modalSubmit.reply({
content: `Welcome message setup complete! Channel: <#${channelId}>, Message: "${customMessage}"`,
@@ -101,8 +123,17 @@ module.exports = {
});
} else {
db[guildId].welcomeMessage = selectedMessage;
writeDb(db);
try {
const existing = (await api.getServerSettings(guildId)) || {};
existing.welcomeMessage = selectedMessage;
await api.upsertServerSettings(guildId, existing);
} catch (e) {
console.error('Error persisting welcome message to backend, falling back to local:', e);
const db = readDb();
if (!db[guildId]) db[guildId] = {};
db[guildId].welcomeMessage = selectedMessage;
writeDb(db);
}
await messageConfirmation.update({
content: `Welcome message setup complete! Channel: <#${channelId}>, Message: "${selectedMessage}"`,
components: [],

View File

@@ -1,5 +1,5 @@
const { SlashCommandBuilder } = require('discord.js');
const { readDb } = require('../../backend/db.js');
const api = require('../api');
module.exports = {
name: 'view-autorole',
@@ -9,10 +9,9 @@ module.exports = {
.setName('view-autorole')
.setDescription('View the current autorole configuration for this server.'),
async execute(interaction) {
const db = readDb();
const guildId = interaction.guildId;
const settings = db[guildId] || {};
const autorole = settings.autorole || { enabled: false, roleId: '' };
const guildId = interaction.guildId;
const settings = (await api.getServerSettings(guildId)) || {};
const autorole = settings.autorole || { enabled: false, roleId: '' };
if (!autorole.enabled) {
await interaction.reply({ content: 'Autorole is currently disabled for this server.', flags: 64 });

View File

@@ -1,5 +1,5 @@
const { SlashCommandBuilder } = require('discord.js');
const { readDb } = require('../../backend/db.js');
const api = require('../api');
module.exports = {
name: 'view-welcome-leave',
@@ -9,9 +9,8 @@ module.exports = {
.setName('view-welcome-leave')
.setDescription('View the current welcome and leave message configuration.'),
async execute(interaction) {
const db = readDb();
const guildId = interaction.guildId;
const settings = db[guildId] || {};
const guildId = interaction.guildId;
const settings = (await api.getServerSettings(guildId)) || {};
const welcomeChannel = settings.welcomeChannel ? `<#${settings.welcomeChannel}>` : 'Not set';
const welcomeMessage = settings.welcomeMessage || 'Not set';

View File

@@ -10,7 +10,7 @@ const commandFiles = fs.readdirSync(commandsPath).filter(file => file.endsWith('
for (const file of commandFiles) {
const filePath = path.join(commandsPath, file);
const command = require(filePath);
if (command.enabled === false) continue;
if (command.enabled === false || command.dev === true) continue;
if (command.builder) {
commands.push(command.builder.toJSON());
@@ -23,16 +23,17 @@ const rest = new REST({ version: '10' }).setToken(process.env.DISCORD_BOT_TOKEN)
const deployCommands = async (guildId) => {
try {
console.log(`Started refreshing application (/) commands for guild ${guildId}.`);
// Minimal logging: indicate a refresh is happening (no per-guild spam)
console.log('🔁 Refreshing application commands...');
await rest.put(
Routes.applicationGuildCommands(process.env.DISCORD_CLIENT_ID, guildId),
{ body: commands },
);
console.log(`Successfully reloaded application (/) commands for guild ${guildId}.`);
console.log(`✅ Reloaded application commands (${commands.length} commands)`);
} catch (error) {
console.error(error);
console.error('Failed to deploy commands:', error && error.message ? error.message : error);
}
};

View File

@@ -1,27 +1,30 @@
const { Events } = require('discord.js');
const { readDb } = require('../../backend/db.js');
module.exports = {
name: Events.GuildMemberAdd,
async execute(member) {
try {
const db = readDb();
const settings = db[member.guild.id];
const api = require('../api');
// Get the welcome/leave settings from the API
const welcomeLeaveSettings = await api.getWelcomeLeaveSettings(member.guild.id) || { welcome: { enabled: false } };
const welcome = welcomeLeaveSettings.welcome;
if (settings && settings.welcomeEnabled && settings.welcomeChannel) {
const channel = member.guild.channels.cache.get(settings.welcomeChannel);
if (welcome && welcome.enabled && welcome.channel) {
const channel = member.guild.channels.cache.get(welcome.channel);
if (channel) {
try {
const message = (settings.welcomeMessage || 'Welcome {user} to {server}!').replace('{user}', member.user.toString()).replace('{server}', member.guild.name);
const message = (welcome.message || 'Welcome {user} to {server}!').replace('{user}', member.user.toString()).replace('{server}', member.guild.name);
await channel.send(message);
} catch (error) {
console.error(`Could not send welcome message to channel ${settings.welcomeChannel} in guild ${member.guild.id}:`, error);
console.error(`Could not send welcome message to channel ${welcome.channel} in guild ${member.guild.id}:`, error);
}
}
}
if (settings && settings.autorole && settings.autorole.enabled && settings.autorole.roleId) {
const role = member.guild.roles.cache.get(settings.autorole.roleId);
// 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
@@ -39,5 +42,5 @@ module.exports = {
} catch (error) {
console.error(`Error in guildMemberAdd event for guild ${member.guild.id}:`, error);
}
},
}
};

View File

@@ -1,43 +1,27 @@
const { Events } = require('discord.js');
const { readDb } = require('../../backend/db.js');
module.exports = {
name: Events.GuildMemberRemove,
async execute(member) {
try {
const db = readDb();
const settings = db[member.guild.id];
const api = require('../api');
// Get the welcome/leave settings from the API
const welcomeLeaveSettings = await api.getWelcomeLeaveSettings(member.guild.id) || { leave: { enabled: false } };
const leave = welcomeLeaveSettings.leave;
if (settings && settings.leaveEnabled && settings.leaveChannel) {
let channel = member.guild.channels.cache.get(settings.leaveChannel);
if (!channel) {
if (leave && leave.enabled && leave.channel) {
const channel = member.guild.channels.cache.get(leave.channel);
if (channel) {
try {
channel = await member.guild.channels.fetch(settings.leaveChannel);
} catch (err) {
return;
}
}
if (channel && channel.isTextBased && channel.isTextBased()) {
try {
const me = member.guild.members.me;
const perms = channel.permissionsFor(me);
if (!perms || !perms.has('ViewChannel') || !perms.has('SendMessages')) {
return;
}
const userMention = member.user ? (member.user.toString ? member.user.toString() : member.user.tag) : 'A user';
const message = (settings.leaveMessage || '{user} has left the server.').replace('{user}', userMention).replace('{server}', member.guild.name);
const message = (leave.message || '{user} has left the server.').replace('{user}', member.user.toString());
await channel.send(message);
} catch (error) {
console.error(`Could not send leave message to channel ${settings.leaveChannel} in guild ${member.guild.id}:`, error);
console.error(`Could not send leave message to channel ${leave.channel} in guild ${member.guild.id}:`, error);
}
} else {
return;
}
}
} catch (error) {
console.error(`Error in guildMemberRemove event for guild ${member.guild.id}:`, error);
}
},
}
};

View File

@@ -5,11 +5,15 @@ module.exports = {
name: 'clientReady',
once: true,
async execute(client) {
console.log('ECS - Full Stack Bot Online!');
const guilds = client.guilds.cache.map(guild => guild.id);
for (const guildId of guilds) {
await deployCommands(guildId);
const guildIds = client.guilds.cache.map(guild => guild.id);
if (guildIds.length > 0) {
// Deploy commands for all guilds in parallel, but only log a single summary
try {
await Promise.all(guildIds.map(id => deployCommands(id)));
console.log(`🔁 Refreshed application commands for ${guildIds.length} guild(s)`);
} catch (e) {
console.error('Error refreshing application commands:', e && e.message ? e.message : e);
}
}
const activities = [
@@ -26,5 +30,8 @@ module.exports = {
client.user.setActivity(activity.name, { type: activity.type, url: activity.url });
activityIndex = (activityIndex + 1) % activities.length;
}, 3000);
// Signal that startup is complete
console.log('✅ ECS - Full Stack Bot Online!');
},
};

View File

@@ -10,7 +10,7 @@ module.exports = (client) => {
// Clear require cache to allow updates during development
delete require.cache[require.resolve(filePath)];
const command = require(filePath);
if (command.name) {
if (command.name && !command.dev) {
client.commands.set(command.name, command);
}
}

View File

@@ -2,12 +2,25 @@ const { Client, GatewayIntentBits, Collection } = require('discord.js');
const fs = require('fs');
const path = require('path');
const deployCommands = require('./deploy-commands');
const { readDb } = require('../backend/db');
// legacy local db is available as a fallback in some commands via require('../../backend/db')
const api = require('./api');
const client = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers] });
client.commands = new Collection();
// In-memory cache of server settings to allow backend to push updates and make toggles immediate
const guildSettingsCache = new Map();
function setGuildSettings(guildId, settings) {
if (!guildId) return;
guildSettingsCache.set(guildId, settings || {});
}
function getGuildSettingsFromCache(guildId) {
return guildSettingsCache.get(guildId) || null;
}
const commandHandler = require('./handlers/command-handler');
const eventHandler = require('./handlers/event-handler');
@@ -20,12 +33,16 @@ client.on('interactionCreate', async interaction => {
const id = interaction.customId || '';
if (id.startsWith('copy_inv_')) {
const code = id.replace('copy_inv_', '');
const db = readDb();
const invites = (db[interaction.guildId] && db[interaction.guildId].invites) ? db[interaction.guildId].invites : [];
const inv = invites.find(i => i.code === code);
if (inv) {
await interaction.reply({ content: `Invite: ${inv.url}`, ephemeral: true });
} else {
try {
const invites = await api.listInvites(interaction.guildId);
const inv = (invites || []).find(i => i.code === code);
if (inv) {
await interaction.reply({ content: `Invite: ${inv.url}`, ephemeral: true });
} else {
await interaction.reply({ content: 'Invite not found.', ephemeral: true });
}
} catch (e) {
console.error('Error fetching invites via API:', e);
await interaction.reply({ content: 'Invite not found.', ephemeral: true });
}
} else if (id.startsWith('delete_inv_')) {
@@ -37,12 +54,13 @@ client.on('interactionCreate', async interaction => {
return;
}
try {
// call backend delete endpoint
const fetch = require('node-fetch');
const backendBase = process.env.BACKEND_BASE || `http://${process.env.HOST || '127.0.0.1'}:${process.env.PORT || 3002}`;
const url = `${backendBase}/api/servers/${interaction.guildId}/invites/${code}`;
await fetch(url, { method: 'DELETE' });
await interaction.reply({ content: 'Invite deleted.', ephemeral: true });
// call backend delete endpoint via helper
const ok = await api.deleteInvite(interaction.guildId, code);
if (ok) {
await interaction.reply({ content: 'Invite deleted.', ephemeral: true });
} else {
await interaction.reply({ content: 'Failed to delete invite via API.', ephemeral: true });
}
} catch (e) {
console.error('Error deleting invite via API:', e);
await interaction.reply({ content: 'Failed to delete invite.', ephemeral: true });
@@ -57,11 +75,28 @@ client.on('interactionCreate', async interaction => {
if (!command) return;
// Check per-guild toggles
// Check per-guild toggles via Postgres (directly) for lower latency and reliability
try {
const db = readDb();
const guildSettings = db[interaction.guildId] || {};
const toggles = guildSettings.commandToggles || {};
// authoritative path: always try the backend HTTP API first so separate processes stay in sync
let guildSettings = await api.getServerSettings(interaction.guildId) || {};
// if API didn't return anything useful, try in-memory cache then direct DB as fallbacks
if (!guildSettings || Object.keys(guildSettings).length === 0) {
guildSettings = getGuildSettingsFromCache(interaction.guildId) || {};
if (!guildSettings || Object.keys(guildSettings).length === 0) {
try {
const pg = require('../backend/pg');
guildSettings = (await pg.getServerSettings(interaction.guildId)) || {};
} catch (pgErr) {
// leave guildSettings empty
}
}
}
// Normalize legacy flags into commandToggles for backward compatibility
const toggles = { ...(guildSettings.commandToggles || {}) };
// Example legacy flag mapping: pingCommand -> commandToggles.ping
if (typeof guildSettings.pingCommand !== 'undefined') {
toggles.ping = !!guildSettings.pingCommand;
}
const protectedCommands = ['manage-commands', 'help'];
// If command is protected, always allow
@@ -97,4 +132,103 @@ const login = () => {
client.login(process.env.DISCORD_BOT_TOKEN);
}
module.exports = { login, client };
// Allow backend to trigger a live announcement for debugging
async function announceLive(guildId, stream) {
try {
const guild = await client.guilds.fetch(guildId).catch(() => null);
if (!guild) return { success: false, message: 'Guild not found' };
const settings = await (require('../backend/pg')).getServerSettings(guildId).catch(() => null) || await (require('./api')).getServerSettings(guildId).catch(() => ({}));
const liveSettings = (settings && settings.liveNotifications) || {};
if (!liveSettings.enabled) return { success: false, message: 'Live notifications disabled' };
const channelId = liveSettings.channelId;
if (!channelId) return { success: false, message: 'No channel configured' };
const channel = await guild.channels.fetch(channelId).catch(() => null);
if (!channel) return { success: false, message: 'Channel not found' };
const { EmbedBuilder } = require('discord.js');
const embed = new EmbedBuilder()
.setColor(0x9146FF)
.setTitle(stream.title || `${stream.user_name} is live`)
.setURL(stream.url)
.setAuthor({ name: stream.user_name, iconURL: stream.profile_image_url || undefined, url: stream.url })
.setThumbnail(stream.thumbnail_url || stream.profile_image_url || undefined)
.addFields(
{ name: 'Category', value: stream.game_name || 'Unknown', inline: true },
{ name: 'Viewers', value: String(stream.viewer_count || 0), inline: true }
)
.setDescription((stream.description || '').slice(0, 200))
.setFooter({ text: `ehchadservices • Started: ${stream.started_at ? new Date(stream.started_at).toLocaleString() : 'unknown'}` });
let prefixMsg = '';
if (liveSettings.customMessage) {
prefixMsg = liveSettings.customMessage;
} else if (liveSettings.message) {
prefixMsg = liveSettings.message;
} else {
prefixMsg = `🔴 ${stream.user_name} is now live!`;
}
const payload = prefixMsg ? { content: prefixMsg, embeds: [embed] } : { embeds: [embed] };
await channel.send(payload);
return { success: true };
} catch (e) {
console.error('announceLive failed:', e && e.message ? e.message : e);
return { success: false, message: e && e.message ? e.message : 'unknown error' };
}
}
module.exports = { login, client, setGuildSettings, getGuildSettingsFromCache, announceLive };
// Start twitch watcher when client is ready (use 'clientReady' as the event name)
try {
const watcher = require('./twitch-watcher');
// discord.js uses 'clientReady' event
client.once('clientReady', () => {
// start polling in background
watcher.poll(client).catch(err => console.error('Twitch watcher failed to start:', err));
});
process.on('exit', () => { watcher.stop(); });
process.on('SIGINT', () => { watcher.stop(); process.exit(); });
} catch (e) {
// ignore if watcher not available
}
try {
const kickWatcher = require('./kick-watcher');
client.once('clientReady', () => {
// TEMPORARILY DISABLED: Kick watcher removed for now
// kickWatcher.poll(client).catch(err => console.error('Kick watcher failed to start:', err));
console.log('Kick watcher: temporarily disabled');
});
// process.on('exit', () => { kickWatcher.stop(); });
// process.on('SIGINT', () => { kickWatcher.stop(); process.exit(); });
} catch (e) {
// ignore if kick watcher not available
}
// --- Optional push receiver (so backend can notify a remote bot process) ---
try {
const express = require('express');
const bodyParser = require('body-parser');
const botPort = process.env.BOT_PUSH_PORT || process.env.BOT_PORT || null;
const botSecret = process.env.BOT_SECRET || null;
if (botPort) {
const app = express();
app.use(bodyParser.json());
app.post('/internal/set-settings', (req, res) => {
try {
if (botSecret) {
const provided = req.headers['x-bot-secret'];
if (!provided || provided !== botSecret) return res.status(401).json({ success: false, message: 'Unauthorized' });
}
const { guildId, settings } = req.body || {};
if (!guildId) return res.status(400).json({ success: false, message: 'Missing guildId' });
setGuildSettings(guildId, settings || {});
return res.json({ success: true });
} catch (e) {
console.error('Error in bot push receiver:', e);
return res.status(500).json({ success: false });
}
});
app.listen(botPort, () => console.log(`Bot push receiver listening on port ${botPort}`));
}
} catch (e) {
// ignore if express isn't available in this environment
}

294
discord-bot/kick-watcher.js Normal file
View 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 };

View File

@@ -11,7 +11,8 @@
"dependencies": {
"crypto-js": "^4.2.0",
"discord.js": "^14.22.1",
"dotenv": "^16.4.5"
"dotenv": "^16.4.5",
"node-fetch": "^2.6.7"
}
},
"node_modules/@discordjs/builders": {
@@ -314,6 +315,32 @@
"integrity": "sha512-ThQLOhN86ZkJ7qemtVRGYM+gRgR8GEXNli9H/PMvpnZsE44Xfh3wx9kGJaldg314v85m+bFW6WBMaVHJc/c3zA==",
"license": "MIT"
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/ts-mixer": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz",
@@ -341,6 +368,22 @@
"integrity": "sha512-Ov2Rr9Sx+fRgagJ5AX0qvItZG/JKKoBRAVITs1zk7IqZGTJUwgUr7qoYBpWwakpWilTZFM98rG/AFRocu10iIQ==",
"license": "MIT"
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",

View File

@@ -13,5 +13,6 @@
"crypto-js": "^4.2.0",
"discord.js": "^14.22.1",
"dotenv": "^16.4.5"
,"node-fetch": "^2.6.7"
}
}

View File

@@ -0,0 +1,214 @@
const api = require('./api');
const fetch = require('node-fetch');
// Twitch API credentials (optional). If provided, we'll enrich embeds with user bio.
const twitchClientId = process.env.TWITCH_CLIENT_ID || null;
const twitchClientSecret = process.env.TWITCH_CLIENT_SECRET || null;
let twitchAppToken = null; // cached app access token
let twitchTokenExpires = 0;
// Cache of user login -> { description, profile_image_url, fetchedAt }
const userInfoCache = new Map();
async function getAppToken() {
if (!twitchClientId || !twitchClientSecret) return null;
const now = Date.now();
if (twitchAppToken && now < twitchTokenExpires - 60000) { // refresh 1 min early
return twitchAppToken;
}
try {
const res = await fetch(`https://id.twitch.tv/oauth2/token?client_id=${twitchClientId}&client_secret=${twitchClientSecret}&grant_type=client_credentials`, { method: 'POST' });
const json = await res.json();
twitchAppToken = json.access_token;
twitchTokenExpires = now + (json.expires_in * 1000);
return twitchAppToken;
} catch (e) {
console.error('Failed to fetch Twitch app token:', e && e.message ? e.message : e);
return null;
}
}
async function fetchUserInfo(login) {
if (!login) return null;
const lower = login.toLowerCase();
const cached = userInfoCache.get(lower);
const now = Date.now();
if (cached && now - cached.fetchedAt < 1000 * 60 * 30) { // 30 min cache
return cached;
}
const token = await getAppToken();
if (!token) return null;
try {
const res = await fetch(`https://api.twitch.tv/helix/users?login=${encodeURIComponent(lower)}`, {
headers: {
'Client-ID': twitchClientId,
'Authorization': `Bearer ${token}`
}
});
const json = await res.json();
const data = (json.data && json.data[0]) || null;
if (data) {
const info = { description: data.description || '', profile_image_url: data.profile_image_url || '', fetchedAt: now };
userInfoCache.set(lower, info);
return info;
}
} catch (e) {
console.error('Failed to fetch Twitch user info for', lower, e && e.message ? e.message : e);
}
return null;
}
let polling = false;
const pollIntervalMs = Number(process.env.TWITCH_POLL_INTERVAL_MS || 5000); // 5s default
// Keep track of which streams we've already announced per guild:user -> { started_at }
const announced = new Map(); // key: `${guildId}:${user}` -> { started_at }
async function checkGuild(client, guild) {
try {
// Intentionally quiet: per-guild checking logs are suppressed to avoid spam
const settings = await api.getServerSettings(guild.id) || {};
const liveSettings = settings.liveNotifications || {};
if (!liveSettings.enabled) return;
const channelId = liveSettings.channelId;
const users = (liveSettings.users || []).map(u => u.toLowerCase()).filter(Boolean);
if (!channelId || users.length === 0) return;
// ask backend for current live streams
const query = users.join(',');
const streams = await api._rawGetTwitchStreams ? api._rawGetTwitchStreams(query) : null;
// If the helper isn't available, try backend proxy
let live = [];
if (streams) live = streams.filter(s => s.is_live);
else {
try {
const resp = await api.tryFetchTwitchStreams(query);
live = (resp || []).filter(s => s.is_live);
} catch (e) {
live = [];
}
}
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(`TwitchWatcher: channel ${channelId} is not a text channel (type: ${channel.type})`);
channel = null;
}
} catch (e) {
console.error(`TwitchWatcher: failed to fetch channel ${channelId}:`, e && e.message ? e.message : e);
channel = null;
}
if (!channel) {
// Channel not found or inaccessible; skip
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 (standardized layout)
try {
// Announce without per-guild log spam
const { EmbedBuilder } = require('discord.js');
// Attempt to enrich with user bio (description) if available
let bio = '';
try {
const info = await fetchUserInfo(login);
if (info && info.description) bio = info.description.slice(0, 200);
} catch (_) {}
const embed = new EmbedBuilder()
.setColor(0x9146FF)
.setTitle(s.title || `${s.user_name} is live`)
.setURL(s.url)
.setAuthor({ name: s.user_name, iconURL: s.profile_image_url || undefined, url: s.url })
.setThumbnail(s.thumbnail_url || s.profile_image_url || undefined)
.addFields(
{ name: 'Category', value: s.game_name || 'Unknown', inline: true },
{ name: 'Viewers', value: String(s.viewer_count || 0), inline: true }
)
.setDescription(bio || (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). Provide a plain text prefix if available.
let prefixMsg = '';
if (liveSettings.customMessage) {
prefixMsg = liveSettings.customMessage;
} else if (liveSettings.message) {
prefixMsg = liveSettings.message;
} else {
prefixMsg = `🔴 ${s.user_name} is now live!`;
}
// Ensure we always hyperlink the title via embed; prefix is optional add above embed
const payload = prefixMsg ? { content: prefixMsg, embeds: [embed] } : { embeds: [embed] };
await channel.send(payload);
console.log(`🔔 Announced live: ${login} - ${(s.title || '').slice(0, 80)}`);
} catch (e) {
console.error(`TwitchWatcher: failed to send announcement for ${login}:`, e && e.message ? e.message : e);
// fallback
const msg = `🔴 ${s.user_name} is live: **${s.title}**\nWatch: ${s.url}`;
try { await channel.send({ content: msg }); console.log('TwitchWatcher: fallback message sent'); } catch (err) { console.error('TwitchWatcher: fallback send failed:', err && err.message ? err.message : err); }
}
}
} catch (e) {
console.error('Error checking guild for live streams:', e && e.message ? e.message : e);
}
}
async function poll(client) {
if (polling) return;
polling = true;
console.log(`🔁 TwitchWatcher started, polling every ${Math.round(pollIntervalMs/1000)}s`);
// Initial check on restart: send messages for currently live users
try {
const guilds = Array.from(client.guilds.cache.values());
for (const g of guilds) {
await checkGuild(client, g).catch(err => { console.error('TwitchWatcher: initial checkGuild error', err && err.message ? err.message : err); });
}
} catch (e) {
console.error('Error during initial twitch check:', e && e.message ? e.message : e);
}
while (polling) {
try {
const guilds = Array.from(client.guilds.cache.values());
for (const g of guilds) {
await checkGuild(client, g).catch(err => { console.error('TwitchWatcher: checkGuild error', err && err.message ? err.message : err); });
}
} catch (e) {
console.error('Error during twitch poll loop:', e && e.message ? e.message : e);
}
await new Promise(r => setTimeout(r, pollIntervalMs));
}
}
function stop() { polling = false; }
module.exports = { poll, stop };

View File

@@ -1,68 +0,0 @@
# Error Log
## ENOENT: no such file or directory, open 'F:\Projects\Github\ECS-Discordweb\discord-bot\events\...ackenddb.json'
**Context:** This error occurs in the `guildMemberAdd` and `guildMemberRemove` event handlers in the Discord bot when trying to read the `db.json` file.
**Initial Fix Attempts:**
1. **`path.join` with relative path:** Initially, the path was constructed using `path.join(__dirname, '..\..\backend\db.json')`. This was incorrect as it didn't go up enough directories.
2. **`path.join` with corrected relative path:** The path was corrected to `path.join(__dirname, '..\..\..\backend\db.json')`. This seemed logically correct, but still resulted in the same error, with a strange `......` in the error path.
3. **`path.resolve`:** The path construction was changed to use `path.resolve(__dirname, '..\..\..\backend\db.json')` to ensure an absolute path was resolved. This also failed with the same error.
**Root Cause Analysis:**
The exact cause of the path resolution failure is unclear, but it seems to be related to how `__dirname` and relative paths are handled when the bot module is required by the backend server. The inconsistent working directory might be the source of the problem.
**Solution:**
To provide a more robust and centralized solution, the database path will be managed within the `backend/db.js` file.
1. The `dbPath` will be exported from `backend/db.js`.
2. The `guildMemberAdd.js` and `guildMemberRemove.js` event handlers will import the `dbPath` directly from `backend/db.js`, eliminating the need for path resolution in the event handlers themselves.
## SyntaxError: Unexpected token 'U', "U2FsdGVkX1"... is not valid JSON
**Context:** This error occurs in the `guildMemberAdd` and `guildMemberRemove` event handlers when trying to parse the content of `db.json`.
**Root Cause Analysis:**
The `db.json` file is encrypted, and the event handlers were reading the file content directly and trying to parse it as JSON. The encrypted string starts with "U2FsdGVkX1", which is not a valid JSON token, causing the `JSON.parse` to fail.
**Solution:**
The `backend/db.js` module already provides a `readDb` function that handles reading the file and decrypting its content. The event handlers must be updated to use this function instead of reading the file directly. This ensures that the encrypted data is correctly decrypted before being parsed as JSON.
## Outdated Discord.js Practices
**Context:** The `discord-bot` is using a slightly outdated version of `discord.js` (`^14.15.3` instead of the latest `^14.22.1`). Additionally, the `package.json` includes `@discordjs/rest` and `discord-api-types` as explicit dependencies, which are now bundled with `discord.js` v14 and can cause conflicts.
**Solution:**
1. Update the `discord.js` dependency to the latest stable version (`^14.22.1`).
2. Remove the `@discordjs/rest` and `discord-api-types` dependencies from `package.json`.
3. Run `npm install` to apply the changes and ensure all packages are up-to-date.
## Error: Cannot find module 'discord-api-types/v9'
**Context:** After removing the `discord-api-types` package, the application fails to start due to a missing module error in `discord-bot/deploy-commands.js`.
**Root Cause Analysis:**
The `deploy-commands.js` file was still referencing `discord-api-types/v9` for API-related enums or types. Since this package is no longer an explicit dependency (as it is bundled with `discord.js` v14), the import fails.
**Solution:**
The `deploy-commands.js` file needs to be updated to import the necessary modules, such as `Routes`, directly from `discord.js` instead of the now-removed `discord-api-types` package.
## DiscordAPIError[10004]: Unknown Guild
**Context:** The backend throws an "Unknown Guild" error when the frontend tries to fetch the channels for a server that the bot is not a member of.
**Root Cause Analysis:**
The `/api/servers/:guildId/channels` endpoint was attempting to fetch guild information regardless of whether the bot was actually in that guild. This caused a Discord API error when the guild was not found.
**Solution:**
The endpoint will be updated to first check if the guild exists in the bot's cache using `bot.client.guilds.cache.get(guildId)`. If the guild is not found, it will return an empty array, preventing the API error.

16
frontend/.env.example Normal file
View File

@@ -0,0 +1,16 @@
# Example frontend/.env for developing behind an HTTPS domain and Nginx Proxy Manager
# Bind the dev server to the machine/Tailscale IP you want (or omit to use localhost)
HOST=0.0.0.0
PORT=3001
# Production API base (use https when serving through nginx with TLS)
REACT_APP_API_BASE=https://discordbot.YOURDOMAIN.com
# Force CRA dev client to use secure websocket to the domain
WDS_SOCKET_PROTOCOL=wss
WDS_SOCKET_HOST=discordbot.YOURDOMAIN.com
WDS_SOCKET_PORT=443
WDS_SOCKET_PATH=/ws
# If CRA uses sockjs-node, set WDS_SOCKET_PATH accordingly:
# WDS_SOCKET_PATH=/sockjs-node

View File

@@ -2,35 +2,61 @@ import React, { useEffect } from 'react';
import { BrowserRouter as Router, Route, Routes, useLocation } from 'react-router-dom';
import { ThemeProvider } from './contexts/ThemeContext';
import { UserProvider } from './contexts/UserContext';
import { BackendProvider, useBackend } from './contexts/BackendContext';
import { CssBaseline } from '@mui/material';
import Login from './components/Login';
import Dashboard from './components/Dashboard';
import ServerSettings from './components/ServerSettings';
import ServerSettings from './components/server/ServerSettings';
import NavBar from './components/NavBar';
import HelpPage from './components/HelpPage';
import HelpPage from './components/server/HelpPage';
import DiscordPage from './components/DiscordPage';
import MaintenancePage from './components/common/MaintenancePage';
function AppInner() {
const { backendOnline, checking, forceCheck } = useBackend();
const handleRetry = async () => {
await forceCheck();
};
function App() {
return (
<UserProvider>
<ThemeProvider>
<CssBaseline />
<Router>
<TitleSetter />
<NavBar />
<Routes>
<Route path="/" element={<Login />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/server/:guildId" element={<ServerSettings />} />
<Route path="/server/:guildId/help" element={<HelpPage />} />
<Route path="/discord" element={<DiscordPage />} />
</Routes>
{!backendOnline ? (
<MaintenancePage onRetry={handleRetry} checking={checking} />
) : (
<>
<NavBar />
<Routes>
<Route path="/" element={<Login />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/server/:guildId" element={<ServerSettings />} />
<Route path="/server/:guildId/help" element={<HelpPage />} />
<Route path="/discord" element={<DiscordPage />} />
</Routes>
</>
)}
</Router>
</ThemeProvider>
</UserProvider>
);
}
function App() {
return (
<UserProvider>
<ThemeProvider>
<BackendProvider>
<AppInner />
</BackendProvider>
</ThemeProvider>
</UserProvider>
);
}
export default App;
// small helper component to set the browser tab title based on current route

View File

@@ -1,17 +1,18 @@
import React, { useState, useEffect, useContext } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { Grid, Card, CardContent, Typography, Box, IconButton, Snackbar, Alert } from '@mui/material';
import { Grid, Card, CardContent, Typography, Box, IconButton, Snackbar, Alert, Menu, MenuItem, Button } from '@mui/material';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import { UserContext } from '../contexts/UserContext';
import PersonAddIcon from '@mui/icons-material/PersonAdd';
import RemoveCircleOutlineIcon from '@mui/icons-material/RemoveCircleOutline';
import axios from 'axios';
import { get, post } from '../lib/api';
import ConfirmDialog from './ConfirmDialog';
import ConfirmDialog from './common/ConfirmDialog';
const Dashboard = () => {
const navigate = useNavigate();
const location = useLocation();
const { user } = useContext(UserContext);
const { user, setUser } = useContext(UserContext);
const [guilds, setGuilds] = useState([]);
const [botStatus, setBotStatus] = useState({});
@@ -19,31 +20,57 @@ const Dashboard = () => {
const [snackbarMessage, setSnackbarMessage] = useState('');
const [dialogOpen, setDialogOpen] = useState(false);
const [selectedGuild, setSelectedGuild] = useState(null);
const [menuAnchor, setMenuAnchor] = useState(null);
const [menuGuild, setMenuGuild] = useState(null);
// Live notifications are managed on the Server Settings page; keep dashboard lightweight
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3002';
const API_BASE = process.env.REACT_APP_API_BASE || '';
useEffect(() => {
const params = new URLSearchParams(location.search);
const userParam = params.get('user');
if (userParam) {
try {
const parsedUser = JSON.parse(decodeURIComponent(userParam));
setUser(parsedUser);
localStorage.setItem('user', JSON.stringify(parsedUser));
} catch (err) {
console.error("Failed to parse user from URL", err);
}
}
const guildsParam = params.get('guilds');
if (guildsParam) {
try {
const parsed = JSON.parse(decodeURIComponent(guildsParam));
setGuilds(parsed || []);
localStorage.setItem('guilds', JSON.stringify(parsed || []));
const parsedGuilds = JSON.parse(decodeURIComponent(guildsParam));
setGuilds(parsedGuilds || []);
localStorage.setItem('guilds', JSON.stringify(parsedGuilds || []));
} catch (err) {
// ignore
}
} else {
const stored = localStorage.getItem('guilds');
if (stored) {
const storedGuilds = localStorage.getItem('guilds');
if (storedGuilds) {
try {
setGuilds(JSON.parse(stored));
setGuilds(JSON.parse(storedGuilds));
} catch (err) {
setGuilds([]);
}
}
}
}, [location.search]);
}, [location.search, setUser]);
// Protect this route: if no user in context or localStorage, redirect to login
useEffect(() => {
if (!user) {
const stored = localStorage.getItem('user');
if (!stored) {
navigate('/');
} else {
try { setUser(JSON.parse(stored)); } catch (e) { navigate('/'); }
}
}
}, [user, navigate, setUser]);
useEffect(() => {
if (!guilds || guilds.length === 0) return;
@@ -51,7 +78,7 @@ const Dashboard = () => {
const statuses = {};
await Promise.all(guilds.map(async (g) => {
try {
const resp = await axios.get(`${API_BASE}/api/servers/${g.id}/bot-status`);
const resp = await get(`/api/servers/${g.id}/bot-status`);
statuses[g.id] = resp.data.isBotInServer;
} catch (err) {
statuses[g.id] = false;
@@ -62,13 +89,17 @@ const Dashboard = () => {
fetchStatuses();
}, [guilds, API_BASE]);
// Dashboard no longer loads live settings; that's on the server settings page
// Live notifications handlers were removed from Dashboard
const handleCardClick = (guild) => {
navigate(`/server/${guild.id}`, { state: { guild } });
};
const handleInviteBot = (e, guild) => {
e.stopPropagation();
axios.get(`${API_BASE}/api/client-id`).then(resp => {
get('/api/client-id').then(resp => {
const clientId = resp.data.clientId;
if (!clientId) {
setSnackbarMessage('No client ID available');
@@ -93,7 +124,7 @@ const Dashboard = () => {
const handleConfirmLeave = async () => {
if (!selectedGuild) return;
try {
await axios.post(`${API_BASE}/api/servers/${selectedGuild.id}/leave`);
await post(`/api/servers/${selectedGuild.id}/leave`);
setBotStatus(prev => ({ ...prev, [selectedGuild.id]: false }));
setSnackbarMessage('Bot left the server');
setSnackbarOpen(true);
@@ -109,88 +140,63 @@ const Dashboard = () => {
return (
<div style={{ padding: 20 }}>
<Typography variant="h4" gutterBottom>Dashboard</Typography>
{user && <Typography variant="h6" gutterBottom>Welcome, {user.username}</Typography>}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box>
<Typography variant="h4" gutterBottom>Dashboard</Typography>
{user && <Typography variant="h6" gutterBottom>Welcome, {user.username}</Typography>}
</Box>
</Box>
<Typography variant="h6" gutterBottom>Your Admin Servers:</Typography>
<Grid container spacing={3} justifyContent="center">
{guilds.map(guild => (
<Grid item xs={12} sm={6} md={4} lg={3} key={guild.id}>
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
<Grid item xs={12} sm={6} md={4} lg={3} key={guild.id} sx={{ display: 'flex', justifyContent: 'center' }}>
<Box sx={{ display: 'flex', justifyContent: 'center', width: '100%' }}>
<Card
onClick={() => handleCardClick(guild)}
sx={{
cursor: 'pointer',
borderRadius: 2,
boxShadow: '0 6px 12px rgba(0,0,0,0.10)',
transition: 'transform 0.18s ease-in-out, box-shadow 0.18s',
height: { xs: 320, sm: 260 },
minHeight: { xs: 320, sm: 260 },
maxHeight: { xs: 320, sm: 260 },
width: { xs: '100%', sm: 260 },
minWidth: { xs: '100%', sm: 260 },
maxWidth: { xs: '100%', sm: 260 },
borderRadius: '16px',
boxShadow: '0 8px 16px rgba(0,0,0,0.2)',
transition: 'transform 0.2s ease-in-out, box-shadow 0.2s',
'&:hover': {
transform: 'translateY(-5px)',
boxShadow: '0 12px 24px rgba(0,0,0,0.3)',
},
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
overflow: 'hidden',
boxSizing: 'border-box'
}}
>
{/* slightly larger image area for better visibility */}
<Box sx={{ height: { xs: 196, sm: 168 }, width: '100%', bgcolor: '#fff', backgroundSize: 'cover', backgroundPosition: 'center', backgroundRepeat: 'no-repeat', backgroundImage: `url(${guild.icon ? `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png` : 'https://cdn.discordapp.com/embed/avatars/0.png'})`, boxSizing: 'border-box' }} />
<Box sx={{ height: { xs: 72, sm: 56 }, display: 'flex', alignItems: 'center', justifyContent: 'center', px: 2, boxSizing: 'border-box' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, textAlign: 'center', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden', textOverflow: 'ellipsis', lineHeight: '1.1rem', maxHeight: { xs: '2.2rem', sm: '2.2rem' } }}>{guild.name}</Typography>
</Box>
<Box sx={{ height: { xs: 64, sm: 48 }, display: 'flex', alignItems: 'center', justifyContent: 'flex-start', gap: 1, px: 2, boxSizing: 'border-box' }}>
{botStatus[guild.id] ? (
<>
<Typography variant="body2" sx={{ fontWeight: 600, mr: 1 }}>Leave:</Typography>
<IconButton aria-label={`Make bot leave ${guild.name}`} size="small" onClick={(e) => handleLeaveBot(e, guild)} sx={{ flexShrink: 0 }}>
<RemoveCircleOutlineIcon />
</IconButton>
</>
) : (
<>
<Typography variant="body2" sx={{ fontWeight: 600, mr: 1 }}>Invite:</Typography>
<IconButton aria-label={`Invite bot to ${guild.name}`} size="small" onClick={(e) => handleInviteBot(e, guild)} sx={{ flexShrink: 0 }}>
<PersonAddIcon />
</IconButton>
</>
)}
</Box>
{/* CardContent reduced a bit to compensate for larger image */}
<CardContent sx={{ height: { xs: '124px', sm: '92px' }, boxSizing: 'border-box', py: { xs: 1, sm: 1.5 }, px: { xs: 1.25, sm: 2 } }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexDirection: { xs: 'column', sm: 'row' }, height: '100%', overflow: 'hidden' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, width: '100%', justifyContent: { xs: 'center', sm: 'flex-start' } }}>
<Box
title={guild.name}
sx={{
px: { xs: 1, sm: 2 },
py: 0.5,
borderRadius: '999px',
fontWeight: 700,
fontSize: { xs: '0.95rem', sm: '1rem' },
bgcolor: 'rgba(0,0,0,0.04)',
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
whiteSpace: 'normal',
textAlign: { xs: 'center', sm: 'left' },
lineHeight: '1.2rem',
maxHeight: { xs: '2.4rem', sm: '2.4rem', md: '2.4rem' }
}}
>
{guild.name}
</Box>
</Box>
{/* Button removed from this location to avoid duplication; action is the labeled button above the CardContent */}
<CardContent sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', p: 2 }}>
<Box
component="img"
src={guild.icon ? `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png` : 'https://cdn.discordapp.com/embed/avatars/0.png'}
sx={{
width: 80,
height: 80,
borderRadius: '50%',
mb: 2,
boxShadow: '0 4px 8px rgba(0,0,0,0.2)',
}}
/>
<Typography variant="h6" sx={{ fontWeight: 700, textAlign: 'center', mb: 1 }}>{guild.name}</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1 }}>
{botStatus[guild.id] ? (
<Button variant="contained" color="error" size="small" onClick={(e) => handleLeaveBot(e, guild)} startIcon={<RemoveCircleOutlineIcon />}>
Leave
</Button>
) : (
<Button variant="contained" color="success" size="small" onClick={(e) => handleInviteBot(e, guild)} startIcon={<PersonAddIcon />}>
Invite
</Button>
)}
<IconButton size="small" onClick={(e) => { e.stopPropagation(); setMenuAnchor(e.currentTarget); setMenuGuild(guild); }} aria-label="server menu">
<MoreVertIcon />
</IconButton>
</Box>
</CardContent>
@@ -200,6 +206,12 @@ const Dashboard = () => {
))}
</Grid>
<Menu anchorEl={menuAnchor} open={!!menuAnchor} onClose={() => { setMenuAnchor(null); setMenuGuild(null); }}>
<MenuItem onClick={() => { setMenuAnchor(null); if (menuGuild) window.location.href = `/server/${menuGuild.id}`; }}>Open Server Settings</MenuItem>
</Menu>
{/* Live Notifications dialog removed from Dashboard — available on Server Settings page */}
<Snackbar open={snackbarOpen} autoHideDuration={6000} onClose={handleSnackbarClose}>
<Alert onClose={handleSnackbarClose} severity="info" sx={{ width: '100%' }}>
{snackbarMessage}
@@ -217,4 +229,4 @@ const Dashboard = () => {
);
};
export default Dashboard;
export default Dashboard;

View File

@@ -13,7 +13,7 @@ const Login = () => {
}, [navigate]);
const handleLogin = () => {
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3002';
const API_BASE = process.env.REACT_APP_API_BASE || '';
window.location.href = `${API_BASE}/auth/discord`;
};

View File

@@ -33,7 +33,7 @@ const NavBar = () => {
</IconButton>
</Box>
<Typography variant="h6" component="div" sx={{ fontWeight: 800 }} onClick={() => { navigate('/dashboard'); closeMenu(); }}>
<Typography variant="h6" component="div" sx={{ fontWeight: 800, display: { xs: 'none', sm: 'block' } }} onClick={() => { navigate('/dashboard'); closeMenu(); }}>
ECS - EHDCHADSWORTH
</Typography>
@@ -58,4 +58,4 @@ const NavBar = () => {
);
};
export default NavBar;
export default NavBar;

View File

@@ -1,4 +1,4 @@
import React, { useState, useContext } from 'react';
import React, { useState, useContext, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Avatar, Menu, MenuItem, Button, Typography } from '@mui/material';
import { UserContext } from '../contexts/UserContext';
@@ -11,6 +11,17 @@ const UserSettings = () => {
const [anchorEl, setAnchorEl] = useState(null);
const [themeMenuAnchorEl, setThemeMenuAnchorEl] = useState(null);
useEffect(() => {
const storedUser = localStorage.getItem('user');
if (storedUser) {
try {
setUser(JSON.parse(storedUser));
} catch (error) {
console.error("Failed to parse user from localStorage", error);
}
}
}, [setUser]);
const handleMenu = (event) => {
setAnchorEl(event.currentTarget);
};

View File

@@ -0,0 +1,22 @@
import React from 'react';
import { Box, Typography, Button, CircularProgress } from '@mui/material';
const MaintenancePage = ({ onRetry, checking }) => {
return (
<Box sx={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: 'background.default', p: 3 }}>
<Box sx={{ maxWidth: 640, textAlign: 'center' }}>
<Typography variant="h4" sx={{ mb: 2, fontWeight: 'bold' }}>EhChadServices is currently under maintenance</Typography>
<Typography sx={{ mb: 2 }}>We're checking the service status and will reload automatically when the backend is available. Please check back in a few moments.</Typography>
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: 2, mt: 2 }}>
<CircularProgress size={40} />
<Typography>{checking ? 'Checking...' : 'Idle'}</Typography>
</Box>
<Box sx={{ mt: 3 }}>
<Button variant="contained" onClick={onRetry}>Retry now</Button>
</Box>
</Box>
</Box>
);
};
export default MaintenancePage;

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import axios from 'axios';
import { get } from '../../lib/api';
import { Box, IconButton, Typography } from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
@@ -10,9 +10,13 @@ const HelpPage = () => {
const [commands, setCommands] = useState([]);
useEffect(() => {
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3002';
axios.get(`${API_BASE}/api/servers/${guildId}/commands`)
.then(res => setCommands(res.data || []))
get(`/api/servers/${guildId}/commands`).then(res => {
const cmds = res.data || [];
// sort: locked commands first (preserve relative order), then others alphabetically
const locked = cmds.filter(c => c.locked);
const others = cmds.filter(c => !c.locked).sort((a, b) => a.name.localeCompare(b.name));
setCommands([...locked, ...others]);
})
.catch(() => setCommands([]));
}, [guildId]);

View File

@@ -1,18 +1,24 @@
import React, { useState, useEffect } from 'react';
import { useBackend } from '../../contexts/BackendContext';
import { useParams, useNavigate, useLocation } from 'react-router-dom';
import axios from 'axios';
import { Button, Typography, Box, IconButton, Switch, Select, MenuItem, FormControl, FormControlLabel, Radio, RadioGroup, TextField, Accordion, AccordionSummary, AccordionDetails, Snackbar, Alert } from '@mui/material';
import { Button, Typography, Box, IconButton, Switch, Select, MenuItem, FormControl, FormControlLabel, Radio, RadioGroup, TextField, Accordion, AccordionSummary, AccordionDetails, Tabs, Tab, Snackbar, Alert } from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
// UserSettings moved to NavBar
import ConfirmDialog from './ConfirmDialog';
import ConfirmDialog from '../common/ConfirmDialog';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import DeleteIcon from '@mui/icons-material/Delete';
// Use a relative API base by default so the frontend talks to the same origin that served it.
// In development you can set REACT_APP_API_BASE to a full URL if needed.
const API_BASE = process.env.REACT_APP_API_BASE || '';
const ServerSettings = () => {
const { guildId } = useParams();
const navigate = useNavigate();
const location = useLocation();
// settings state removed (not used) to avoid lint warnings
const [isBotInServer, setIsBotInServer] = useState(false);
const [clientId, setClientId] = useState(null);
@@ -31,8 +37,25 @@ const ServerSettings = () => {
const [pendingDeleteInvite, setPendingDeleteInvite] = useState(null);
const [snackbarOpen, setSnackbarOpen] = useState(false);
const [snackbarMessage, setSnackbarMessage] = useState('');
const [inviteForm, setInviteForm] = useState({ channelId: '', maxAge: 0, maxUses: 0, temporary: false });
// SSE connection status (not currently displayed)
const [confirmDeleteTwitch, setConfirmDeleteTwitch] = useState(false);
const [pendingTwitchUser, setPendingTwitchUser] = useState(null);
const [confirmDeleteKick, setConfirmDeleteKick] = useState(false);
const [pendingKickUser, setPendingKickUser] = useState(null);
const [commandsExpanded, setCommandsExpanded] = useState(false);
const [liveExpanded, setLiveExpanded] = useState(false);
const [inviteForm, setInviteForm] = useState({ channelId: '', maxAge: 0, maxUses: 0, temporary: false });
const [liveEnabled, setLiveEnabled] = useState(false);
const [liveChannelId, setLiveChannelId] = useState('');
const [liveTwitchUser, setLiveTwitchUser] = useState('');
const [liveMessage, setLiveMessage] = useState('');
const [liveCustomMessage, setLiveCustomMessage] = useState('');
const [watchedUsers, setWatchedUsers] = useState([]);
const [liveStatus, setLiveStatus] = useState({});
const [liveTabValue, setLiveTabValue] = useState(0);
const [kickUsers, setKickUsers] = useState([]);
const [kickStatus, setKickStatus] = useState({});
const [kickUser, setKickUser] = useState('');
const [welcomeLeaveSettings, setWelcomeLeaveSettings] = useState({
welcome: {
enabled: false,
@@ -63,62 +86,69 @@ const ServerSettings = () => {
}
}
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3002';
// Fetch settings (not used directly in this component)
axios.get(`${API_BASE}/api/servers/${guildId}/settings`).catch(() => {});
// Check if bot is in server
axios.get(`${API_BASE}/api/servers/${guildId}/bot-status`)
.then(response => {
setIsBotInServer(response.data.isBotInServer);
});
axios.get(`${API_BASE}/api/servers/${guildId}/bot-status`).then(response => {
setIsBotInServer(response.data.isBotInServer);
}).catch(() => {
// Backend is likely down. Don't spam console with network errors — show friendly UI instead.
setIsBotInServer(false);
});
// Fetch client ID
axios.get(`${API_BASE}/api/client-id`)
.then(response => {
setClientId(response.data.clientId);
});
axios.get(`${API_BASE}/api/client-id`).then(response => {
setClientId(response.data.clientId);
}).catch(() => {
// ignore when offline
});
// Fetch channels
axios.get(`${API_BASE}/api/servers/${guildId}/channels`)
.then(response => {
setChannels(response.data);
});
axios.get(`${API_BASE}/api/servers/${guildId}/channels`).then(response => {
setChannels(response.data);
}).catch(() => {
setChannels([]);
});
// Fetch welcome/leave settings
axios.get(`${API_BASE}/api/servers/${guildId}/welcome-leave-settings`)
.then(response => {
if (response.data) {
setWelcomeLeaveSettings(response.data);
}
});
axios.get(`${API_BASE}/api/servers/${guildId}/welcome-leave-settings`).then(response => {
if (response.data) setWelcomeLeaveSettings(response.data);
}).catch(() => {
// ignore
});
// Fetch roles
axios.get(`${API_BASE}/api/servers/${guildId}/roles`)
.then(response => {
setRoles(response.data);
});
axios.get(`${API_BASE}/api/servers/${guildId}/roles`).then(response => {
setRoles(response.data);
}).catch(() => setRoles([]));
// Fetch autorole settings
axios.get(`${API_BASE}/api/servers/${guildId}/autorole-settings`)
.then(response => {
if (response.data) {
setAutoroleSettings(response.data);
}
});
axios.get(`${API_BASE}/api/servers/${guildId}/autorole-settings`).then(response => {
if (response.data) setAutoroleSettings(response.data);
}).catch(() => {});
// Fetch commands/help list
axios.get(`${API_BASE}/api/servers/${guildId}/commands`)
.then(response => {
setCommandsList(response.data || []);
})
.catch(() => setCommandsList([]));
axios.get(`${API_BASE}/api/servers/${guildId}/commands`).then(response => {
setCommandsList(response.data || []);
}).catch(() => setCommandsList([]));
// Fetch invites
axios.get(`${API_BASE}/api/servers/${guildId}/invites`)
.then(resp => setInvites(resp.data || []))
.catch(() => setInvites([]));
// Fetch live notifications settings and watched users
axios.get(`${API_BASE}/api/servers/${guildId}/live-notifications`).then(resp => {
const s = resp.data || { enabled: false, twitchUser: '', channelId: '', message: '', customMessage: '' };
setLiveEnabled(!!s.enabled);
setLiveChannelId(s.channelId || '');
setLiveTwitchUser(s.twitchUser || '');
setLiveMessage(s.message || '');
setLiveCustomMessage(s.customMessage || '');
}).catch(() => {});
axios.get(`${API_BASE}/api/servers/${guildId}/twitch-users`).then(resp => setWatchedUsers(resp.data || [])).catch(() => setWatchedUsers([]));
axios.get(`${API_BASE}/api/servers/${guildId}/kick-users`).then(resp => setKickUsers(resp.data || [])).catch(() => setKickUsers([]));
axios.get(`${API_BASE}/api/servers/${guildId}/invites`).then(resp => setInvites(resp.data || [])).catch(() => setInvites([]));
// Open commands accordion if navigated from Help back button
if (location.state && location.state.openCommands) {
@@ -127,13 +157,89 @@ const ServerSettings = () => {
}, [guildId, location.state]);
// Listen to backend events for live notifications and twitch user updates
const { eventTarget } = useBackend();
useEffect(() => {
if (!eventTarget) return;
const onTwitchUsers = (e) => {
const data = e.detail || {};
// payload is { users: [...], guildId }
if (!data) return;
if (data.guildId && data.guildId !== guildId) return; // ignore other guilds
setWatchedUsers(data.users || []);
};
const onLiveNotifications = (e) => {
const data = e.detail || {};
if (!data) return;
if (data.guildId && data.guildId !== guildId) return;
setLiveEnabled(!!data.enabled);
setLiveChannelId(data.channelId || '');
setLiveTwitchUser(data.twitchUser || '');
setLiveMessage(data.message || '');
setLiveCustomMessage(data.customMessage || '');
};
const onKickUsers = (e) => {
const data = e.detail || {};
// payload is { users: [...], guildId }
if (!data) return;
if (data.guildId && data.guildId !== guildId) return; // ignore other guilds
setKickUsers(data.users || []);
};
const onCommandToggle = (e) => {
const data = e.detail || {};
if (!data) return;
if (data.guildId && data.guildId !== guildId) return;
// refresh authoritative command list
axios.get(`${API_BASE}/api/servers/${guildId}/commands`).then(resp => setCommandsList(resp.data || [])).catch(() => {});
};
eventTarget.addEventListener('twitchUsersUpdate', onTwitchUsers);
eventTarget.addEventListener('kickUsersUpdate', onKickUsers);
eventTarget.addEventListener('liveNotificationsUpdate', onLiveNotifications);
eventTarget.addEventListener('commandToggle', onCommandToggle);
return () => {
try {
eventTarget.removeEventListener('twitchUsersUpdate', onTwitchUsers);
eventTarget.removeEventListener('kickUsersUpdate', onKickUsers);
eventTarget.removeEventListener('liveNotificationsUpdate', onLiveNotifications);
eventTarget.removeEventListener('commandToggle', onCommandToggle);
} catch (err) {}
};
}, [eventTarget, guildId]);
const handleBack = () => {
navigate(-1);
};
const handleToggleLive = async (e) => {
const enabled = e.target.checked;
setLiveEnabled(enabled);
try {
await axios.post(`${API_BASE}/api/servers/${guildId}/live-notifications`, { enabled, channelId: liveChannelId, twitchUser: liveTwitchUser, message: liveMessage, customMessage: liveCustomMessage });
setSnackbarMessage('Live notifications updated');
setSnackbarOpen(true);
} catch (err) {
setLiveEnabled(!enabled);
}
};
const handleCloseSnackbar = () => {
setSnackbarOpen(false);
};
const handleAutoroleSettingUpdate = (newSettings) => {
axios.post(`${process.env.REACT_APP_API_BASE || 'http://localhost:3002'}/api/servers/${guildId}/autorole-settings`, newSettings)
axios.post(`${API_BASE}/api/servers/${guildId}/autorole-settings`, newSettings)
.then(response => {
if (response.data.success) {
setAutoroleSettings(newSettings);
}
});
}).catch(() => {});
};
const handleAutoroleToggleChange = (event) => {
@@ -147,12 +253,12 @@ const ServerSettings = () => {
};
const handleSettingUpdate = (newSettings) => {
axios.post(`${process.env.REACT_APP_API_BASE || 'http://localhost:3002'}/api/servers/${guildId}/welcome-leave-settings`, newSettings)
axios.post(`${API_BASE}/api/servers/${guildId}/welcome-leave-settings`, newSettings)
.then(response => {
if (response.data.success) {
setWelcomeLeaveSettings(newSettings);
}
});
}).catch(() => {});
}
const handleToggleChange = (type) => (event) => {
@@ -214,17 +320,63 @@ const ServerSettings = () => {
const handleConfirmLeave = async () => {
try {
await axios.post(`${process.env.REACT_APP_API_BASE || 'http://localhost:3002'}/api/servers/${guildId}/leave`);
await axios.post(`${API_BASE}/api/servers/${guildId}/leave`);
setIsBotInServer(false);
} catch (error) {
console.error('Error leaving server:', error);
// show friendly message instead of noisy console error
setSnackbarMessage('Failed to make the bot leave the server. Is the backend online?');
setSnackbarOpen(true);
}
setDialogOpen(false);
};
const handleBack = () => {
navigate('/dashboard');
}
// 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 (
<div style={{ padding: '20px' }}>
@@ -237,13 +389,17 @@ const ServerSettings = () => {
{server ? `Server Settings for ${server.name}` : 'Loading...'}
</Typography>
{isBotInServer ? (
<Button variant="contained" size="small" color="error" onClick={handleLeaveBot}>
Leave Server
</Button>
<>
<Button variant="contained" size="small" color="error" onClick={handleLeaveBot}>
Leave Server
</Button>
</>
) : (
<Button variant="contained" size="small" onClick={handleInviteBot} disabled={!clientId}>
Invite Bot
</Button>
<>
<Button variant="contained" size="small" onClick={handleInviteBot} disabled={!clientId}>
Invite Bot
</Button>
</>
)}
</Box>
{/* UserSettings moved to NavBar */}
@@ -285,17 +441,23 @@ const ServerSettings = () => {
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<FormControlLabel
control={<Switch checked={cmd.enabled} onChange={async (e) => {
const newVal = e.target.checked;
// optimistic update
setCommandsList(prev => prev.map(c => c.name === cmd.name ? { ...c, enabled: newVal } : c));
try {
await axios.post(`${process.env.REACT_APP_API_BASE || 'http://localhost:3002'}/api/servers/${guildId}/commands/${cmd.name}/toggle`, { enabled: newVal });
} catch (err) {
// revert on error
setCommandsList(prev => prev.map(c => c.name === cmd.name ? { ...c, enabled: cmd.enabled } : c));
}
}} disabled={!isBotInServer} label={cmd.enabled ? 'Enabled' : 'Disabled'} />}
control={<Switch checked={cmd.enabled} onChange={async (e) => {
const newVal = e.target.checked;
// optimistic update
setCommandsList(prev => prev.map(c => c.name === cmd.name ? { ...c, enabled: newVal } : c));
try {
await axios.post(`${API_BASE}/api/servers/${guildId}/commands/${cmd.name}/toggle`, { enabled: newVal });
// refresh authoritative state from backend
const resp = await axios.get(`${API_BASE}/api/servers/${guildId}/commands`);
setCommandsList(resp.data || []);
} catch (err) {
// revert on error and notify
setCommandsList(prev => prev.map(c => c.name === cmd.name ? { ...c, enabled: cmd.enabled } : c));
setSnackbarMessage('Failed to update command toggle');
setSnackbarOpen(true);
}
}} disabled={!isBotInServer} />}
label={cmd.enabled ? 'Enabled' : 'Disabled'}
/>
</Box>
</Box>
@@ -306,6 +468,8 @@ const ServerSettings = () => {
</Box>
</AccordionDetails>
</Accordion>
{/* Live Notifications dialog */}
{/* header live dialog removed; Live Notifications is managed in its own accordion below */}
{/* Invite creation and list */}
<Accordion sx={{ marginTop: '20px', opacity: isBotInServer ? 1 : 0.5 }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
@@ -354,12 +518,13 @@ const ServerSettings = () => {
<Box sx={{ display: 'flex', alignItems: 'flex-end' }}>
<Button variant="contained" onClick={async () => {
try {
const resp = await axios.post(`${process.env.REACT_APP_API_BASE || 'http://localhost:3002'}/api/servers/${guildId}/invites`, inviteForm);
const resp = await axios.post(`${API_BASE}/api/servers/${guildId}/invites`, inviteForm);
if (resp.data && resp.data.success) {
setInvites(prev => [...prev, resp.data.invite]);
}
} catch (err) {
console.error('Error creating invite:', err);
setSnackbarMessage('Failed to create invite. Backend may be offline.');
setSnackbarOpen(true);
}
}} disabled={!isBotInServer}>Create Invite</Button>
</Box>
@@ -384,6 +549,7 @@ const ServerSettings = () => {
const input = document.createElement('input');
input.value = inv.url;
document.body.appendChild(input);
document.body.appendChild(input);
input.select();
document.execCommand('copy');
document.body.removeChild(input);
@@ -391,7 +557,6 @@ const ServerSettings = () => {
setSnackbarMessage('Copied invite URL to clipboard');
setSnackbarOpen(true);
} catch (err) {
console.error('Clipboard copy failed:', err);
setSnackbarMessage('Failed to copy — please copy manually');
setSnackbarOpen(true);
}
@@ -498,6 +663,146 @@ const ServerSettings = () => {
</Box>
</AccordionDetails>
</Accordion>
{/* Live Notifications Accordion */}
<Accordion expanded={liveExpanded} onChange={() => setLiveExpanded(prev => !prev)} sx={{ marginTop: '20px' }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="h6">Live Notifications</Typography>
</AccordionSummary>
<AccordionDetails>
<Box sx={{ opacity: isBotInServer ? 1 : 0.5 }}>
{!isBotInServer && <Typography sx={{ mb: 2 }}>Invite the bot to enable this feature.</Typography>}
<Box sx={{ border: 1, borderColor: 'divider', borderRadius: 1 }}>
<Tabs value={liveTabValue} onChange={(e, newValue) => {
// Prevent switching to Kick tab (index 1) since it's disabled
if (newValue !== 1) {
setLiveTabValue(newValue);
}
}} sx={{ borderBottom: 1, borderColor: 'divider', '& .MuiTabs-indicator': { backgroundColor: 'primary.main' } }}>
<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 }}>
<TextField label="Twitch username" value={liveTwitchUser} onChange={(e) => setLiveTwitchUser(e.target.value)} fullWidth disabled={!isBotInServer} />
<Button variant="contained" onClick={async () => {
if (!liveTwitchUser) return;
try {
await axios.post(`${API_BASE}/api/servers/${guildId}/twitch-users`, { username: liveTwitchUser });
setWatchedUsers(prev => [...prev.filter(u => u !== liveTwitchUser.toLowerCase()), liveTwitchUser.toLowerCase()]);
setLiveTwitchUser('');
} catch (err) { setSnackbarMessage('Failed to add Twitch user (backend offline?)'); setSnackbarOpen(true); }
}} disabled={!isBotInServer}>Add</Button>
</Box>
<Box sx={{ mt: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 1 }}>Watched Users</Typography>
{watchedUsers.length === 0 && <Typography>No users added</Typography>}
{watchedUsers.map(u => (
<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 }}>
<Typography>{u}</Typography>
{liveStatus[u] && liveStatus[u].is_live && (
<Button size="small" color="error" href={liveStatus[u].url} target="_blank" rel="noopener">Watch Live</Button>
)}
</Box>
<Box>
<Button size="small" onClick={() => { setPendingTwitchUser(u); setConfirmDeleteTwitch(true); }}>Delete</Button>
</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 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>
)}
{liveTabValue === 1 && (
<Box sx={{ p: 3, textAlign: 'center' }}>
<Typography variant="h6" sx={{ mb: 2, color: 'text.secondary' }}>
Kick Live Notifications (Disabled)
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
Kick live notifications are temporarily disabled. This feature will be re-enabled in a future update.
</Typography>
</Box>
)}
</Box>
</Box>
</AccordionDetails>
</Accordion>
<Accordion sx={{ marginTop: '20px', opacity: isBotInServer ? 1 : 0.5 }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="h6">Autorole</Typography>
@@ -543,7 +848,7 @@ const ServerSettings = () => {
<ConfirmDialog
open={confirmOpen}
onClose={() => { setConfirmOpen(false); setPendingDeleteInvite(null); }}
onConfirm={async () => {
onConfirm={async () => {
// perform deletion for pendingDeleteInvite
if (!pendingDeleteInvite) {
setConfirmOpen(false);
@@ -553,7 +858,6 @@ const ServerSettings = () => {
setConfirmOpen(false);
setDeleting(prev => ({ ...prev, [code]: true }));
try {
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3002';
// fetch token (one retry)
let token = null;
try {
@@ -573,7 +877,6 @@ const ServerSettings = () => {
setSnackbarMessage('Invite deleted');
setSnackbarOpen(true);
} catch (err) {
console.error('Error deleting invite:', err);
const msg = (err && err.message) || (err && err.response && err.response.data && err.response.data.message) || 'Failed to delete invite';
setSnackbarMessage(msg);
setSnackbarOpen(true);
@@ -589,11 +892,50 @@ const ServerSettings = () => {
title="Delete Invite"
message={`Are you sure you want to delete invite ${pendingDeleteInvite ? pendingDeleteInvite.url : ''}?`}
/>
<Snackbar open={snackbarOpen} autoHideDuration={4000} onClose={() => setSnackbarOpen(false)}>
<Alert onClose={() => setSnackbarOpen(false)} severity="info" sx={{ width: '100%' }}>
{snackbarMessage}
</Alert>
</Snackbar>
{/* Confirm dialog for deleting a twitch user from watched list */}
<ConfirmDialog
open={confirmDeleteTwitch}
onClose={() => { setConfirmDeleteTwitch(false); setPendingTwitchUser(null); }}
onConfirm={async () => {
if (!pendingTwitchUser) { setConfirmDeleteTwitch(false); return; }
setConfirmDeleteTwitch(false);
try {
await axios.delete(`${API_BASE}/api/servers/${guildId}/twitch-users/${encodeURIComponent(pendingTwitchUser)}`);
setWatchedUsers(prev => prev.filter(x => x !== pendingTwitchUser));
setSnackbarMessage('Twitch user removed');
setSnackbarOpen(true);
} catch (err) {
setSnackbarMessage('Failed to delete twitch user');
setSnackbarOpen(true);
} finally {
setPendingTwitchUser(null);
}
}}
title="Delete Twitch User"
message={`Are you sure you want to remove ${pendingTwitchUser || ''} from the watch list?`}
/>
{/* Confirm dialog for deleting a kick user from watched list */}
<ConfirmDialog
open={confirmDeleteKick}
onClose={() => { setConfirmDeleteKick(false); setPendingKickUser(null); }}
onConfirm={async () => {
if (!pendingKickUser) { setConfirmDeleteKick(false); return; }
setConfirmDeleteKick(false);
try {
await axios.delete(`${API_BASE}/api/servers/${guildId}/kick-users/${encodeURIComponent(pendingKickUser)}`);
setKickUsers(prev => prev.filter(x => x !== pendingKickUser));
setSnackbarMessage('Kick user removed');
setSnackbarOpen(true);
} catch (err) {
setSnackbarMessage('Failed to delete kick user');
setSnackbarOpen(true);
} finally {
setPendingKickUser(null);
}
}}
title="Delete Kick User"
message={`Are you sure you want to remove ${pendingKickUser || ''} from the watch list?`}
/>
</div>
);
};

View File

@@ -0,0 +1,98 @@
import React, { createContext, useContext, useEffect, useRef, useState } from 'react';
const BackendContext = createContext(null);
export function useBackend() {
return useContext(BackendContext);
}
export function BackendProvider({ children }) {
const API_BASE = process.env.REACT_APP_API_BASE || '';
const [backendOnline, setBackendOnline] = useState(false);
const [checking, setChecking] = useState(true);
const esRef = useRef(null);
const eventTargetRef = useRef(new EventTarget());
useEffect(() => {
let mounted = true;
const check = async () => {
if (!mounted) return;
setChecking(true);
try {
const resp = await fetch(`${API_BASE}/api/servers/health`);
if (!mounted) return;
setBackendOnline(!!(resp && resp.ok));
} catch (e) {
if (!mounted) return;
setBackendOnline(false);
} finally {
if (mounted) setChecking(false);
}
};
check();
const iv = setInterval(check, 5000);
return () => { mounted = false; clearInterval(iv); };
}, [API_BASE]);
// Single shared EventSource forwarded into a DOM EventTarget
useEffect(() => {
if (typeof window === 'undefined' || typeof EventSource === 'undefined') return;
const url = `${API_BASE}/api/events`;
let es = null;
try {
es = new EventSource(url);
esRef.current = es;
} catch (err) {
// silently ignore
return;
}
const forward = (type) => (e) => {
try {
const evt = new CustomEvent(type, { detail: e.data ? JSON.parse(e.data) : null });
eventTargetRef.current.dispatchEvent(evt);
} catch (err) {
// ignore parse errors
}
};
es.addEventListener('connected', forward('connected'));
es.addEventListener('commandToggle', forward('commandToggle'));
es.addEventListener('twitchUsersUpdate', forward('twitchUsersUpdate'));
es.addEventListener('liveNotificationsUpdate', forward('liveNotificationsUpdate'));
es.onerror = () => {
// Let consumers react to backendOnline state changes instead of surfacing connection errors
};
return () => { try { es && es.close(); } catch (e) {} };
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const forceCheck = async () => {
const API_BASE2 = process.env.REACT_APP_API_BASE || '';
try {
setChecking(true);
const resp = await fetch(`${API_BASE2}/api/servers/health`);
setBackendOnline(!!(resp && resp.ok));
} catch (e) {
setBackendOnline(false);
} finally {
setChecking(false);
}
};
const value = {
backendOnline,
checking,
eventTarget: eventTargetRef.current,
forceCheck,
};
return (
<BackendContext.Provider value={value}>
{children}
</BackendContext.Provider>
);
}
export default BackendContext;

View File

@@ -2,7 +2,7 @@ import React, { createContext, useState, useMemo, useContext, useEffect } from '
import { ThemeProvider as MuiThemeProvider } from '@mui/material/styles';
import { lightTheme, darkTheme, discordTheme } from '../themes';
import { UserContext } from './UserContext';
import axios from 'axios';
import { post } from '../lib/api';
export const ThemeContext = createContext();
@@ -45,7 +45,7 @@ export const ThemeProvider = ({ children }) => {
const changeTheme = (name) => {
if (user) {
axios.post(`${process.env.REACT_APP_API_BASE || 'http://localhost:3002'}/api/user/theme`, { userId: user.id, theme: name });
post('/api/user/theme', { userId: user.id, theme: name }).catch(() => {});
}
localStorage.setItem('themeName', name);
setThemeName(name);

23
frontend/src/lib/api.js Normal file
View File

@@ -0,0 +1,23 @@
import axios from 'axios';
const API_BASE = process.env.REACT_APP_API_BASE || '';
const client = axios.create({
baseURL: API_BASE,
// optional: set a short timeout for UI requests
timeout: 8000,
});
export async function get(path, config) {
return client.get(path, config);
}
export async function post(path, data, config) {
return client.post(path, data, config);
}
export async function del(path, config) {
return client.delete(path, config);
}
export default client;