Compare commits

..

4 Commits

Author SHA1 Message Date
70979cdd27 Laptop push 2025-10-21 08:11:58 -04:00
ff10bb3183 Moderation Update 2025-10-09 06:13:48 -04:00
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
44 changed files with 4280 additions and 1285 deletions

340
README.md
View File

@@ -1,253 +1,161 @@
# ECS Full Stack # ECS Full Stack
A full-stack example project that integrates a React frontend, an Express backend, and a Discord bot. The app provides a dashboard for server admins to manage bot settings and invites, plus Discord moderation/integration features via a bot running with discord.js. A full-stack Discord bot management dashboard with React frontend, Express backend, and Discord.js bot integration. Server admins can manage bot settings, invites, moderation, and live notifications through a modern web interface.
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. ## Features
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". - **Dashboard**: View Discord servers and manage per-server settings
- **Invite Management**: Create, list, and revoke server invites with custom options
- **Moderation**: Direct ban/kick/timeout actions from web interface with user autocomplete
- **Live Notifications**: Twitch stream notifications with rich embeds
- **Admin Logs**: Complete moderation action logging with real-time updates
- **Theme Support**: Light, dark, and Discord-themed UI options
## Repository layout ## Quick Start
- `frontend/` — React (Create React App) frontend. Uses `REACT_APP_API_BASE` to communicate with the backend in dev and production. ### Prerequisites
- `backend/` — Express backend and API server that also coordinates with the `discord-bot` library to manage guilds, invites, and settings. Uses environment variables for configuration. - Node.js 18+
- `discord-bot/` — small wrapper that logs the bot in and exposes the discord.js client used by the backend. - PostgreSQL database
- `checklist.md`, `README.md`, other docs and small scripts at repo root. - Discord application with bot user
## What this project does ### Setup
- Provides a React dashboard where a user can view servers the bot is connected to and manage per-server settings (welcome/leave messages, autorole, toggling commands, invite creation/listing/deletion). 1. **Clone and install dependencies:**
- 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. ```bash
- Uses a short-lived token flow to authorize invite deletions from the frontend without embedding long-lived secrets in the client. git clone <repository-url>
cd ECS-FullStack
npm install # Run in both frontend/ and backend/ directories
```
Expanded: what this app does 2. **Configure Discord App:**
- Go to [Discord Developer Portal](https://discord.com/developers/applications)
- Create new application and bot user
- Copy Client ID, Client Secret, and Bot Token
- Hosts a dashboard (React) that lists Discord guilds where the bot is present and lets server admins: 3. **Database Setup:**
- create and manage invites (create invites with options, view persisted invites, copy and revoke) ```sql
- configure Welcome and Leave messages and channels CREATE DATABASE ecs_fullstack;
- enable/disable bot commands per server CREATE USER ecs_user WITH PASSWORD 'your_password';
- set autorole behavior for new members GRANT ALL PRIVILEGES ON DATABASE ecs_fullstack TO ecs_user;
- 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 4. **Environment Configuration:**
- Node.js (recommended 18.x or later) and npm **backend/.env:**
- A Discord application with a Bot user (to get `DISCORD_CLIENT_ID` and `DISCORD_CLIENT_SECRET`) — see below for setup steps ```env
- Optional: a VPS or Tailscale IP if you want to run the frontend/backend on a non-localhost address DATABASE_URL=postgres://ecs_user:password@localhost:5432/ecs_fullstack
DISCORD_CLIENT_ID=your_client_id
DISCORD_CLIENT_SECRET=your_client_secret
DISCORD_BOT_TOKEN=your_bot_token
ENCRYPTION_KEY=your_32_byte_secret
BACKEND_BASE=http://localhost:3002
FRONTEND_BASE=http://localhost:3001
```
## Environment configuration (.env) **frontend/.env:**
```env
REACT_APP_API_BASE=http://localhost:3002
```
There are env files used by the backend and frontend. Create `.env` files in the `backend/` and `frontend/` folders for local development. Examples follow. 5. **Start the application:**
```bash
# Backend (includes Discord bot)
cd backend && npm start
### backend/.env (example) # Frontend (separate terminal)
cd frontend && npm start
```
PORT=3002 6. **Invite Bot to Server:**
HOST=0.0.0.0 - Use OAuth2 URL Generator in Discord Developer Portal
BACKEND_BASE=http://your-server-or-ip:3002 - Select `bot` and `applications.commands` scopes
FRONTEND_BASE=http://your-server-or-ip:3001 - Choose appropriate permissions
CORS_ORIGIN=http://your-server-or-ip:3001 - Visit generated URL to invite bot
DISCORD_CLIENT_ID=your_discord_client_id
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) ## Project Structure
# 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. ECS-FullStack/
- `CORS_ORIGIN`: optional; set to your frontend origin to restrict CORS. ├── frontend/ # React dashboard
- `DISCORD_CLIENT_ID` / `DISCORD_CLIENT_SECRET`: from the Discord Developer Portal (see below). ├── backend/ # Express API + Discord bot
- `ENCRYPTION_KEY` or `INVITE_TOKEN_SECRET`: used to sign short-lived invite tokens. Keep this secret. ├── discord-bot/ # Bot wrapper
├── checklist.md # Feature tracking
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. └── README.md
### 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
PORT=3001
REACT_APP_API_BASE=http://your-server-or-ip:3002
Set `REACT_APP_API_BASE` to point at the backend so the frontend can call API endpoints.
## Create a Discord Application and Bot (short)
1. Go to the Discord Developer Portal: https://discord.com/developers/applications
2. Create a new application.
3. Under "OAuth2" -> "General", add your redirect URI:
- For dev: `http://your-server-or-ip:3002/auth/discord/callback`
- Make sure `BACKEND_BASE` matches the host/port you set in `backend/.env`.
4. Under "Bot" create a Bot user and copy the Bot token (NOT committed to source).
5. Under "OAuth2" -> "URL Generator" select scopes `bot` and `applications.commands` and select permissions (e.g., Administrator if you want full access for testing). Use the generated URL to invite the bot to your guild during testing.
Store the Client ID / Client Secret in your `backend/.env` as `DISCORD_CLIENT_ID` and `DISCORD_CLIENT_SECRET`.
## Invite token flow (why and how)
- To avoid embedding long-lived secrets in a web client, invite deletions are authorized with a short-lived HMAC-signed token.
- The frontend requests a token with:
GET /api/servers/:guildId/invite-token
- The backend returns `{ token: '...' }`. The frontend then calls
DELETE /api/servers/:guildId/invites/:code
with header `x-invite-token: <token>`
- Token TTL is short (default 5 minutes) and is signed using `INVITE_TOKEN_SECRET` or `ENCRYPTION_KEY` from backend `.env`.
Security note: Currently the `/invite-token` endpoint issues tokens to any caller. For production you should restrict this endpoint by requiring OAuth authentication and checking that the requesting user is authorized for the target guild.
## Run the app locally
1. Backend
```powershell
cd backend
npm install
# create backend/.env from the example above
npm start
``` ```
Optional: using Postgres (recommended) ## API Endpoints
1. Create a Postgres database and user (pgAdmin or psql) ### Server Management
2. Set `DATABASE_URL` in `backend/.env`, e.g.: - `GET /api/servers/:guildId` - Server info and settings
DATABASE_URL=postgres://dbuser:dbpass@100.111.50.59:5432/ecs_db - `GET /api/servers/:guildId/members` - Server member list
3. Start the backend; on startup the backend will create simple tables if missing. - `GET /api/servers/:guildId/channels` - Text channels
- `GET /api/servers/:guildId/roles` - Server roles
Migration note: ### Invites
- 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. - `GET /api/servers/:guildId/invites` - List invites
- `POST /api/servers/:guildId/invites` - Create invite
- `DELETE /api/servers/:guildId/invites/:code` - Delete invite
2. Frontend ### Moderation
- `POST /api/servers/:guildId/moderate` - Ban/kick/timeout users
- `GET /api/servers/:guildId/admin-logs` - View moderation logs
```powershell ### Live Notifications
cd frontend - `GET/POST /api/servers/:guildId/live-notifications` - Settings
npm install - `GET/POST /api/servers/:guildId/twitch-users` - Watched users
# create frontend/.env with REACT_APP_API_BASE pointing to the backend
npm run start ## Environment Variables
### Required
- `DATABASE_URL` - PostgreSQL connection string
- `DISCORD_CLIENT_ID` - Discord app client ID
- `DISCORD_CLIENT_SECRET` - Discord app client secret
- `DISCORD_BOT_TOKEN` - Bot token
### Optional
- `TWITCH_CLIENT_ID` - Twitch app client ID
- `TWITCH_CLIENT_SECRET` - Twitch app client secret
- `BOT_PUSH_URL` - For separate bot/backend deployment
- `CORS_ORIGIN` - Restrict API access
## Development
### Running Tests
```bash
cd frontend && npm test
cd backend && npm test
``` ```
3. Discord bot ### Building for Production
```bash
- The backend boots the bot client (see `discord-bot/`), so if the backend is started and credentials are correct, the bot will log in and register slash commands. You can also run the `discord-bot` project separately if you prefer. cd frontend && npm run build
cd backend && npm run build # If applicable
```
## Troubleshooting ## 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. ### Common Issues
- 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). - **Database connection failed**: Verify `DATABASE_URL` format and credentials
- CORS errors: verify `CORS_ORIGIN` and `REACT_APP_API_BASE` match your frontend origin. - **CORS errors**: Check `CORS_ORIGIN` matches your frontend URL
- 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. - **Bot not responding**: Ensure bot has proper permissions in server
- Token issues: clock skew can cause tokens to appear expired — ensure server and client clocks are reasonably in sync. - **Invite deletion fails**: Check `ENCRYPTION_KEY` is set
## Developer notes ### Logs
- Backend logs Discord bot status and API requests
- Frontend console shows API calls and errors
- Check browser Network tab for failed requests
- The dashboard UI is in `frontend/src/components/` (notable files: `Dashboard.js`, `ServerSettings.js`, `Login.js`). ## Contributing
- The Express API is in `backend/index.js` and uses `discord-bot` (discord.js client) to operate on guilds, invites, channels and roles.
- Invite delete flow: frontend fetches a short-lived token then requests DELETE with header `x-invite-token`.
## Next steps / suggestions 1. Fork the repository
2. Create feature branch
3. Make changes with tests
4. Submit pull request
- Harden `/api/servers/:guildId/invite-token` to require an authenticated user and verify the user has admin permissions for the guild. ## License
- 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. MIT License - see LICENSE file for details.
--- ---
Updated: Oct 4, 2025
## Full setup guide (detailed) **Updated**: October 9, 2025
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)
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

@@ -158,6 +158,214 @@ app.get('/api/twitch/streams', async (req, res) => {
} }
}); });
// Kick API helpers (web scraping since no public API)
async function getKickStreamsForUsers(usernames = []) {
try {
if (!usernames || usernames.length === 0) return [];
const results = [];
for (const username of usernames) {
try {
// Use Kick's API endpoint to check if user is live
const url = `https://kick.com/api/v1/channels/${encodeURIComponent(username)}`;
const response = await axios.get(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
'Accept': 'application/json',
'Referer': 'https://kick.com/'
},
timeout: 5000 // 5 second timeout
});
if (response.status === 200 && response.data) {
const data = response.data;
if (data.livestream && data.livestream.is_live) {
results.push({
is_live: true,
user_login: username,
user_name: data.user?.username || username,
title: data.livestream.session_title || `${username} is live`,
viewer_count: data.livestream.viewer_count || 0,
started_at: data.livestream.start_time,
url: `https://kick.com/${username}`,
thumbnail_url: data.livestream.thumbnail?.url || null,
category: data.category?.name || 'Unknown',
description: data.user?.bio || ''
});
} else {
// User exists but not live
results.push({
is_live: false,
user_login: username,
user_name: data.user?.username || username,
title: null,
viewer_count: 0,
started_at: null,
url: `https://kick.com/${username}`,
thumbnail_url: null,
category: null,
description: data.user?.bio || ''
});
}
} else {
// User not found or API error
results.push({
is_live: false,
user_login: username,
user_name: username,
title: null,
viewer_count: 0,
started_at: null,
url: `https://kick.com/${username}`,
thumbnail_url: null,
category: null,
description: ''
});
}
} catch (e) {
// If API fails with 403, try web scraping as fallback
if (e.response && e.response.status === 403) {
// console.log(`API blocked for ${username}, trying web scraping fallback...`);
try {
const pageUrl = `https://kick.com/${encodeURIComponent(username)}`;
const pageResponse = await axios.get(pageUrl, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.5',
'Accept-Encoding': 'gzip, deflate, br',
'DNT': '1',
'Connection': 'keep-alive',
'Upgrade-Insecure-Requests': '1',
'Sec-Fetch-Dest': 'document',
'Sec-Fetch-Mode': 'navigate',
'Sec-Fetch-Site': 'none',
'Cache-Control': 'max-age=0'
},
timeout: 5000
});
if (pageResponse.status === 200) {
const html = pageResponse.data;
// Check for live stream indicators in the HTML
const isLive = html.includes('"is_live":true') || html.includes('"is_live": true') ||
html.includes('data-is-live="true"') || html.includes('isLive:true');
if (isLive) {
// Try to extract stream info from HTML
let title = `${username} is live`;
let viewerCount = 0;
let category = 'Unknown';
// Extract title
const titleMatch = html.match(/"session_title"\s*:\s*"([^"]+)"/) || html.match(/"title"\s*:\s*"([^"]+)"/);
if (titleMatch) {
title = titleMatch[1].replace(/\\"/g, '"');
}
// Extract viewer count
const viewerMatch = html.match(/"viewer_count"\s*:\s*(\d+)/);
if (viewerMatch) {
viewerCount = parseInt(viewerMatch[1]);
}
// Extract category
const categoryMatch = html.match(/"category"\s*:\s*{\s*"name"\s*:\s*"([^"]+)"/);
if (categoryMatch) {
category = categoryMatch[1];
}
results.push({
is_live: true,
user_login: username,
user_name: username,
title: title,
viewer_count: viewerCount,
started_at: new Date().toISOString(),
url: `https://kick.com/${username}`,
thumbnail_url: null,
category: category,
description: ''
});
} else {
// User exists but not live
results.push({
is_live: false,
user_login: username,
user_name: username,
title: null,
viewer_count: 0,
started_at: null,
url: `https://kick.com/${username}`,
thumbnail_url: null,
category: null,
description: ''
});
}
} else {
throw e; // Re-throw if page request also fails
}
} catch (scrapeError) {
console.error(`Web scraping fallback also failed for ${username}:`, scrapeError.message || scrapeError);
// Return offline status on error
results.push({
is_live: false,
user_login: username,
user_name: username,
title: null,
viewer_count: 0,
started_at: null,
url: `https://kick.com/${username}`,
thumbnail_url: null,
category: null,
description: ''
});
}
} else {
console.error(`Error checking Kick user ${username}:`, e && e.response && e.response.status ? `HTTP ${e.response.status}` : e.message || e);
// Return offline status on error
results.push({
is_live: false,
user_login: username,
user_name: username,
title: null,
viewer_count: 0,
started_at: null,
url: `https://kick.com/${username}`,
thumbnail_url: null,
category: null,
description: ''
});
}
}
// Small delay between requests to be respectful to Kick's servers
await new Promise(r => setTimeout(r, 200));
}
return results;
} catch (e) {
console.error('Error in getKickStreamsForUsers:', e && e.response && e.response.data ? e.response.data : e.message || e);
return [];
}
}
// Proxy endpoint for frontend/bot to request Kick stream status for usernames (comma separated)
app.get('/api/kick/streams', async (req, res) => {
const q = req.query.users || req.query.user || '';
const users = q.split(',').map(s => (s || '').trim()).filter(Boolean);
try {
const streams = await getKickStreamsForUsers(users);
res.json(streams);
} catch (err) {
console.error('Error in /api/kick/streams:', err);
res.status(500).json({ success: false, message: 'Internal Server Error' });
}
});
// Invite token helpers: short-lived HMAC-signed token so frontend can authorize invite deletes // Invite token helpers: short-lived HMAC-signed token so frontend can authorize invite deletes
const INVITE_TOKEN_TTL_MS = 5 * 60 * 1000; // 5 minutes const INVITE_TOKEN_TTL_MS = 5 * 60 * 1000; // 5 minutes
const inviteTokenSecret = process.env.INVITE_TOKEN_SECRET || process.env.ENCRYPTION_KEY || 'fallback-invite-secret'; const inviteTokenSecret = process.env.INVITE_TOKEN_SECRET || process.env.ENCRYPTION_KEY || 'fallback-invite-secret';
@@ -273,7 +481,8 @@ console.log('Postgres enabled for persistence');
// Simple Server-Sent Events (SSE) broadcaster // Simple Server-Sent Events (SSE) broadcaster
const sseClients = new Map(); // key: guildId or '*' -> array of res const sseClients = new Map(); // key: guildId or '*' -> array of res
function publishEvent(guildId, type, payload) { function publishEvent(guildId, type, payload) {
const msg = `event: ${type}\ndata: ${JSON.stringify(payload)}\n\n`; const enriched = Object.assign({}, payload || {}, { guildId });
const msg = `event: ${type}\ndata: ${JSON.stringify(enriched)}\n\n`;
// send to guild-specific subscribers // send to guild-specific subscribers
const list = sseClients.get(guildId) || []; const list = sseClients.get(guildId) || [];
for (const res of list.slice()) { for (const res of list.slice()) {
@@ -303,6 +512,31 @@ app.get('/api/events', (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) => { app.get('/api/servers/:guildId/settings', async (req, res) => {
const { guildId } = req.params; const { guildId } = req.params;
try { try {
@@ -402,6 +636,8 @@ app.post('/api/servers/:guildId/leave', async (req, res) => {
const guild = await bot.client.guilds.fetch(guildId); const guild = await bot.client.guilds.fetch(guildId);
if (guild) { if (guild) {
await guild.leave(); await guild.leave();
// Publish event for bot status change
publishEvent('*', 'botStatusUpdate', { guildId, isBotInServer: false });
res.json({ success: true }); res.json({ success: true });
} else { } else {
res.status(404).json({ success: false, message: 'Bot is not in the specified server' }); res.status(404).json({ success: false, message: 'Bot is not in the specified server' });
@@ -420,7 +656,7 @@ app.get('/api/servers/:guildId/channels', async (req, res) => {
} }
try { try {
const channels = await guild.channels.fetch(); const channels = await guild.channels.fetch();
const textChannels = channels.filter(channel => channel.type === 0).map(channel => ({ id: channel.id, name: channel.name })); const textChannels = channels.filter(channel => channel.type === 0).map(channel => ({ id: channel.id, name: channel.name, type: channel.type }));
res.json(textChannels); res.json(textChannels);
} catch (error) { } catch (error) {
console.error('Error fetching channels:', error); console.error('Error fetching channels:', error);
@@ -428,6 +664,40 @@ app.get('/api/servers/:guildId/channels', async (req, res) => {
} }
}); });
app.get('/api/servers/:guildId/members', async (req, res) => {
const { guildId } = req.params;
const guild = bot.client.guilds.cache.get(guildId);
if (!guild) {
return res.json([]);
}
try {
// Get the requesting user from the session/token
// For now, we'll assume the frontend sends the user ID in a header or we get it from OAuth
// This is a simplified version - in production you'd want proper authentication
const members = await guild.members.fetch();
// Filter to members the bot can interact with and format for frontend
const bannableMembers = members
.filter(member => !member.user.bot) // Exclude bots
.map(member => ({
id: member.user.id,
username: member.user.username,
globalName: member.user.globalName,
displayName: member.displayName,
avatar: member.user.avatar,
joinedAt: member.joinedAt,
roles: member.roles.cache.map(role => ({ id: role.id, name: role.name, position: role.position }))
}))
.sort((a, b) => a.username.localeCompare(b.username));
res.json(bannableMembers);
} catch (error) {
console.error('Error fetching members:', error);
res.status(500).json({ success: false, message: 'Internal Server Error' });
}
});
app.get('/api/servers/:guildId/welcome-leave-settings', async (req, res) => { app.get('/api/servers/:guildId/welcome-leave-settings', async (req, res) => {
const { guildId } = req.params; const { guildId } = req.params;
try { try {
@@ -537,7 +807,8 @@ app.get('/api/servers/:guildId/live-notifications', async (req, res) => {
const { guildId } = req.params; const { guildId } = req.params;
try { try {
const settings = (await pgClient.getServerSettings(guildId)) || {}; const settings = (await pgClient.getServerSettings(guildId)) || {};
return res.json(settings.liveNotifications || { enabled: false, twitchUser: '', channelId: '' }); const ln = settings.liveNotifications || { enabled: false, twitchUser: '', channelId: '', message: '', customMessage: '' };
return res.json(ln);
} catch (err) { } catch (err) {
console.error('Error fetching live-notifications settings:', err); console.error('Error fetching live-notifications settings:', err);
res.status(500).json({ success: false, message: 'Internal Server Error' }); res.status(500).json({ success: false, message: 'Internal Server Error' });
@@ -546,16 +817,21 @@ app.get('/api/servers/:guildId/live-notifications', async (req, res) => {
app.post('/api/servers/:guildId/live-notifications', async (req, res) => { app.post('/api/servers/:guildId/live-notifications', async (req, res) => {
const { guildId } = req.params; const { guildId } = req.params;
const { enabled, twitchUser, channelId } = req.body || {}; const { enabled, twitchUser, channelId, message, customMessage } = req.body || {};
try { try {
const existing = (await pgClient.getServerSettings(guildId)) || {}; const existing = (await pgClient.getServerSettings(guildId)) || {};
const currentLn = existing.liveNotifications || {};
existing.liveNotifications = { existing.liveNotifications = {
enabled: !!enabled, enabled: !!enabled,
twitchUser: twitchUser || '', twitchUser: twitchUser || '',
channelId: channelId || '' channelId: channelId || '',
message: message || '',
customMessage: customMessage || '',
users: currentLn.users || [], // preserve existing users
kickUsers: currentLn.kickUsers || [] // preserve existing kick users
}; };
await pgClient.upsertServerSettings(guildId, existing); await pgClient.upsertServerSettings(guildId, existing);
try { publishEvent(guildId, 'liveNotificationsUpdate', { enabled: !!enabled, channelId, twitchUser }); } catch (e) {} try { publishEvent(guildId, 'liveNotificationsUpdate', { enabled: !!enabled, channelId, twitchUser, message: existing.liveNotifications.message, customMessage: existing.liveNotifications.customMessage }); } catch (e) {}
return res.json({ success: true }); return res.json({ success: true });
} catch (err) { } catch (err) {
console.error('Error saving live-notifications settings:', err); console.error('Error saving live-notifications settings:', err);
@@ -582,10 +858,18 @@ app.post('/api/servers/:guildId/twitch-users', async (req, res) => {
if (!username) return res.status(400).json({ success: false, message: 'Missing username' }); if (!username) return res.status(400).json({ success: false, message: 'Missing username' });
try { try {
const existing = (await pgClient.getServerSettings(guildId)) || {}; const existing = (await pgClient.getServerSettings(guildId)) || {};
if (!existing.liveNotifications) existing.liveNotifications = { enabled: false, channelId: '', users: [] }; if (!existing.liveNotifications) existing.liveNotifications = { enabled: false, channelId: '', users: [], kickUsers: [] };
existing.liveNotifications.users = Array.from(new Set([...(existing.liveNotifications.users || []), username.toLowerCase().trim()])); existing.liveNotifications.users = Array.from(new Set([...(existing.liveNotifications.users || []), username.toLowerCase().trim()]));
await pgClient.upsertServerSettings(guildId, existing); await pgClient.upsertServerSettings(guildId, existing);
try { publishEvent(guildId, 'twitchUsersUpdate', { users: existing.liveNotifications.users || [] }); } catch (e) {} try { publishEvent(guildId, 'twitchUsersUpdate', { users: existing.liveNotifications.users || [] }); } catch (e) {}
// Optional push to bot process for immediate cache update
try {
if (process.env.BOT_PUSH_URL) {
await axios.post(`${process.env.BOT_PUSH_URL.replace(/\/$/, '')}/internal/set-settings`, { guildId, settings: existing }, {
headers: process.env.BOT_SECRET ? { 'x-bot-secret': process.env.BOT_SECRET } : {}
}).catch(() => {});
}
} catch (_) {}
res.json({ success: true }); res.json({ success: true });
} catch (err) { } catch (err) {
console.error('Error adding twitch user:', err); console.error('Error adding twitch user:', err);
@@ -597,10 +881,18 @@ app.delete('/api/servers/:guildId/twitch-users/:username', async (req, res) => {
const { guildId, username } = req.params; const { guildId, username } = req.params;
try { try {
const existing = (await pgClient.getServerSettings(guildId)) || {}; const existing = (await pgClient.getServerSettings(guildId)) || {};
if (!existing.liveNotifications) existing.liveNotifications = { enabled: false, channelId: '', users: [] }; if (!existing.liveNotifications) existing.liveNotifications = { enabled: false, channelId: '', users: [], kickUsers: [] };
existing.liveNotifications.users = (existing.liveNotifications.users || []).filter(u => u.toLowerCase() !== (username || '').toLowerCase()); existing.liveNotifications.users = (existing.liveNotifications.users || []).filter(u => u.toLowerCase() !== (username || '').toLowerCase());
await pgClient.upsertServerSettings(guildId, existing); await pgClient.upsertServerSettings(guildId, existing);
try { publishEvent(guildId, 'twitchUsersUpdate', { users: existing.liveNotifications.users || [] }); } catch (e) {} try { publishEvent(guildId, 'twitchUsersUpdate', { users: existing.liveNotifications.users || [] }); } catch (e) {}
// Optional push to bot process for immediate cache update
try {
if (process.env.BOT_PUSH_URL) {
await axios.post(`${process.env.BOT_PUSH_URL.replace(/\/$/, '')}/internal/set-settings`, { guildId, settings: existing }, {
headers: process.env.BOT_SECRET ? { 'x-bot-secret': process.env.BOT_SECRET } : {}
}).catch(() => {});
}
} catch (_) {}
res.json({ success: true }); res.json({ success: true });
} catch (err) { } catch (err) {
console.error('Error removing twitch user:', err); console.error('Error removing twitch user:', err);
@@ -608,10 +900,88 @@ app.delete('/api/servers/:guildId/twitch-users/:username', async (req, res) => {
} }
}); });
// DISABLED: Kick users list management for a guild (temporarily disabled)
/*
app.get('/api/servers/:guildId/kick-users', async (req, res) => {
const { guildId } = req.params;
try {
const settings = (await pgClient.getServerSettings(guildId)) || {};
const users = (settings.liveNotifications && settings.liveNotifications.kickUsers) || [];
res.json(users);
} catch (err) {
console.error('Error fetching kick users:', err);
res.status(500).json({ success: false, message: 'Internal Server Error' });
}
});
app.post('/api/servers/:guildId/kick-users', async (req, res) => {
const { guildId } = req.params;
const { username } = req.body || {};
if (!username) return res.status(400).json({ success: false, message: 'Missing username' });
try {
const existing = (await pgClient.getServerSettings(guildId)) || {};
if (!existing.liveNotifications) existing.liveNotifications = { enabled: false, channelId: '', users: [], kickUsers: [] };
existing.liveNotifications.kickUsers = Array.from(new Set([...(existing.liveNotifications.kickUsers || []), username.toLowerCase().trim()]));
await pgClient.upsertServerSettings(guildId, existing);
try { publishEvent(guildId, 'kickUsersUpdate', { users: existing.liveNotifications.kickUsers || [] }); } catch (e) {}
// Optional push to bot process for immediate cache update
try {
if (process.env.BOT_PUSH_URL) {
await axios.post(`${process.env.BOT_PUSH_URL.replace(/\/$/, '')}/internal/set-settings`, { guildId, settings: existing }, {
headers: process.env.BOT_SECRET ? { 'x-bot-secret': process.env.BOT_SECRET } : {}
}).catch(() => {});
}
} catch (_) {}
res.json({ success: true });
} catch (err) {
console.error('Error adding kick user:', err);
res.status(500).json({ success: false, message: 'Internal Server Error' });
}
});
app.delete('/api/servers/:guildId/kick-users/:username', async (req, res) => {
const { guildId, username } = req.params;
try {
const existing = (await pgClient.getServerSettings(guildId)) || {};
if (!existing.liveNotifications) existing.liveNotifications = { enabled: false, channelId: '', users: [], kickUsers: [] };
existing.liveNotifications.kickUsers = (existing.liveNotifications.kickUsers || []).filter(u => u.toLowerCase() !== (username || '').toLowerCase());
await pgClient.upsertServerSettings(guildId, existing);
try { publishEvent(guildId, 'kickUsersUpdate', { users: existing.liveNotifications.kickUsers || [] }); } catch (e) {}
// Optional push to bot process for immediate cache update
try {
if (process.env.BOT_PUSH_URL) {
await axios.post(`${process.env.BOT_PUSH_URL.replace(/\/$/, '')}/internal/set-settings`, { guildId, settings: existing }, {
headers: process.env.BOT_SECRET ? { 'x-bot-secret': process.env.BOT_SECRET } : {}
}).catch(() => {});
}
} catch (_) {}
res.json({ success: true });
} catch (err) {
console.error('Error removing kick user:', err);
res.status(500).json({ success: false, message: 'Internal Server Error' });
}
});
*/
app.get('/', (req, res) => { app.get('/', (req, res) => {
res.send('Hello from the backend!'); res.send('Hello from the backend!');
}); });
// 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 { 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 // Return list of bot commands and per-guild enabled/disabled status
app.get('/api/servers/:guildId/commands', async (req, res) => { app.get('/api/servers/:guildId/commands', async (req, res) => {
try { try {
@@ -620,7 +990,9 @@ app.get('/api/servers/:guildId/commands', async (req, res) => {
const toggles = guildSettings.commandToggles || {}; const toggles = guildSettings.commandToggles || {};
const protectedCommands = ['manage-commands', 'help']; const protectedCommands = ['manage-commands', 'help'];
const commands = Array.from(bot.client.commands.values()).map(cmd => { const commands = Array.from(bot.client.commands.values())
.filter(cmd => !cmd.dev) // Filter out dev commands
.map(cmd => {
const isLocked = protectedCommands.includes(cmd.name); const isLocked = protectedCommands.includes(cmd.name);
const isEnabled = isLocked ? true : (toggles[cmd.name] !== false && cmd.enabled !== false); const isEnabled = isLocked ? true : (toggles[cmd.name] !== false && cmd.enabled !== false);
return { return {
@@ -762,6 +1134,351 @@ app.delete('/api/servers/:guildId/invites/:code', async (req, res) => {
} }
}); });
// ADMIN LOGS: configuration and retrieval
app.get('/api/servers/:guildId/admin-logs-settings', async (req, res) => {
try {
const { guildId } = req.params;
const settings = (await pgClient.getServerSettings(guildId)) || {};
const adminLogsSettings = settings.adminLogs || { enabled: false, channelId: '', commands: { kick: true, ban: true, timeout: true } };
res.json(adminLogsSettings);
} catch (error) {
console.error('Error fetching admin logs settings:', error);
res.status(500).json({ success: false, message: 'Internal Server Error' });
}
});
app.post('/api/servers/:guildId/admin-logs-settings', async (req, res) => {
try {
const { guildId } = req.params;
const newSettings = req.body || {};
const existing = (await pgClient.getServerSettings(guildId)) || {};
const merged = { ...existing };
merged.adminLogs = {
enabled: newSettings.enabled || false,
channelId: newSettings.channelId || '',
commands: newSettings.commands || { kick: true, ban: true, timeout: true }
};
await pgClient.upsertServerSettings(guildId, merged);
// Notify bot of settings change
if (bot && bot.setGuildSettings) {
bot.setGuildSettings(guildId, merged);
}
// If a remote bot push URL is configured, notify it with the new settings
if (process.env.BOT_PUSH_URL) {
try {
const headers = {};
if (process.env.INTERNAL_API_KEY) {
headers['x-api-key'] = process.env.INTERNAL_API_KEY;
}
await axios.post(`${process.env.BOT_PUSH_URL.replace(/\/$/, '')}/internal/set-settings`, { guildId, settings: merged }, { headers });
} catch (e) {
console.error('Failed to push admin logs settings to bot:', e.message);
}
}
res.json({ success: true, settings: merged.adminLogs });
} catch (error) {
console.error('Error saving admin logs settings:', error);
res.status(500).json({ success: false, message: 'Internal Server Error' });
}
});
app.get('/api/servers/:guildId/admin-logs', async (req, res) => {
try {
const { guildId } = req.params;
const { action, limit } = req.query;
const limitNum = limit ? parseInt(limit) : 50;
let logs;
if (action) {
logs = await pgClient.getAdminLogsByAction(guildId, action, limitNum);
} else {
logs = await pgClient.getAdminLogs(guildId, limitNum);
}
// Transform snake_case to camelCase for frontend compatibility
logs = logs.map(log => ({
id: log.id,
guildId: log.guild_id,
action: log.action,
targetUserId: log.target_user_id,
targetUsername: log.target_username,
moderatorUserId: log.moderator_user_id,
moderatorUsername: log.moderator_username,
reason: log.reason,
duration: log.duration,
endDate: log.end_date,
timestamp: log.timestamp
}));
res.json(logs);
} catch (error) {
console.error('Error fetching admin logs:', error);
res.status(500).json({ success: false, message: 'Internal Server Error' });
}
});
app.delete('/api/servers/:guildId/admin-logs/:logId', async (req, res) => {
try {
const { guildId, logId } = req.params;
await pgClient.deleteAdminLog(guildId, parseInt(logId));
// Publish SSE event for live updates
publishEvent(guildId, 'adminLogDeleted', { logId: parseInt(logId) });
res.json({ success: true });
} catch (error) {
console.error('Error deleting admin log:', error);
res.status(500).json({ success: false, message: 'Internal Server Error' });
}
});
app.delete('/api/servers/:guildId/admin-logs', async (req, res) => {
try {
const { guildId } = req.params;
await pgClient.deleteAllAdminLogs(guildId);
// Publish SSE event for live updates
publishEvent(guildId, 'adminLogsCleared', {});
res.json({ success: true });
} catch (error) {
console.error('Error deleting all admin logs:', error);
res.status(500).json({ success: false, message: 'Internal Server Error' });
}
});
// Internal endpoint for logging moderation actions
app.post('/internal/log-moderation', express.json(), async (req, res) => {
try {
const { guildId, action, targetUserId, targetUsername, moderatorUserId, moderatorUsername, reason, duration, endDate } = req.body;
if (!guildId || !action || !targetUserId || !moderatorUserId || !reason) {
return res.status(400).json({ success: false, message: 'Missing required fields' });
}
// Save to database
await pgClient.addAdminLog({
guildId,
action,
targetUserId,
targetUsername,
moderatorUserId,
moderatorUsername,
reason,
duration,
endDate
});
// Check if logging is enabled for this action and send to Discord channel
const settings = (await pgClient.getServerSettings(guildId)) || {};
const adminLogs = settings.adminLogs || { enabled: false, channelId: '', commands: { kick: true, ban: true, timeout: true } };
if (adminLogs.enabled && adminLogs.channelId && adminLogs.commands[action]) {
const guild = bot.client.guilds.cache.get(guildId);
if (guild) {
const channel = guild.channels.cache.get(adminLogs.channelId);
if (channel && channel.type === 0) { // GUILD_TEXT
const embed = {
color: action === 'kick' ? 0xffa500 : action === 'ban' ? 0xff0000 : 0x0000ff,
title: `🚨 ${action.charAt(0).toUpperCase() + action.slice(1)} Action`,
fields: [
{
name: '👤 Target',
value: `${targetUsername} (${targetUserId})`,
inline: true
},
{
name: '👮 Moderator',
value: `${moderatorUsername} (${moderatorUserId})`,
inline: true
},
{
name: '📝 Reason',
value: reason,
inline: false
}
],
timestamp: new Date().toISOString(),
footer: {
text: 'ECS Admin Logs'
}
};
if (duration) {
embed.fields.push({
name: '⏱️ Duration',
value: duration,
inline: true
});
}
if (endDate) {
embed.fields.push({
name: '📅 End Date',
value: new Date(endDate).toLocaleString(),
inline: true
});
}
try {
await channel.send({ embeds: [embed] });
} catch (error) {
console.error('Failed to send admin log to Discord:', error);
}
}
}
}
// Publish SSE event for live updates
publishEvent(guildId, 'adminLogAdded', {
log: {
guildId,
action,
targetUserId,
targetUsername,
moderatorUserId,
moderatorUsername,
reason,
duration,
endDate,
timestamp: new Date().toISOString()
}
});
res.json({ success: true });
} catch (error) {
console.error('Error logging moderation action:', error);
res.status(500).json({ success: false, message: 'Internal Server Error' });
}
});
// MODERATION: frontend moderation actions
app.post('/api/servers/:guildId/moderate', express.json(), async (req, res) => {
try {
const { guildId } = req.params;
const { action, target, reason, duration, moderator } = req.body;
if (!action || !target || !reason) {
return res.status(400).json({ success: false, message: 'Missing required fields: action, target, reason' });
}
// Validate reason has at least 3 words
const reasonWords = reason.trim().split(/\s+/);
if (reasonWords.length < 3) {
return res.status(400).json({ success: false, message: 'Reason must be at least 3 words long' });
}
const guild = bot.client.guilds.cache.get(guildId);
if (!guild) {
return res.status(404).json({ success: false, message: 'Guild not found' });
}
// Find the target user
let targetUser = null;
let targetMember = null;
// Try to find by ID first
try {
targetUser = await bot.client.users.fetch(target);
targetMember = guild.members.cache.get(target);
} catch (e) {
// Try to find by username/mention
const members = await guild.members.fetch();
targetMember = members.find(m =>
m.user.username.toLowerCase().includes(target.toLowerCase()) ||
m.user.tag.toLowerCase().includes(target.toLowerCase()) ||
(target.startsWith('<@') && target.includes(m.user.id))
);
if (targetMember) {
targetUser = targetMember.user;
}
}
if (!targetUser) {
return res.status(404).json({ success: false, message: 'User not found in this server' });
}
// Perform the moderation action
let result = null;
let durationString = null;
let endDate = null;
switch (action) {
case 'kick':
if (!targetMember) {
return res.status(400).json({ success: false, message: 'User is not in this server' });
}
result = await targetMember.kick(reason);
break;
case 'ban':
result = await guild.members.ban(targetUser, { reason });
break;
case 'timeout':
if (!targetMember) {
return res.status(400).json({ success: false, message: 'User is not in this server' });
}
if (!duration || duration < 1 || duration > 40320) {
return res.status(400).json({ success: false, message: 'Invalid timeout duration (1-40320 minutes)' });
}
const timeoutMs = duration * 60 * 1000;
endDate = new Date(Date.now() + timeoutMs);
result = await targetMember.timeout(timeoutMs, reason);
// Format duration string
if (duration >= 1440) {
durationString = `${Math.floor(duration / 1440)}d ${Math.floor((duration % 1440) / 60)}h ${duration % 60}m`;
} else if (duration >= 60) {
durationString = `${Math.floor(duration / 60)}h ${duration % 60}m`;
} else {
durationString = `${duration}m`;
}
break;
default:
return res.status(400).json({ success: false, message: 'Invalid action' });
}
// Log the moderation action
const moderatorUsername = moderator ? (moderator.global_name || moderator.username || 'Unknown User') : 'Web Interface';
try {
const logData = {
guildId,
action,
targetUserId: targetUser.id,
targetUsername: targetUser.global_name || targetUser.username || 'Unknown User',
moderatorUserId: moderator?.id || 'web-interface',
moderatorUsername,
reason,
duration: durationString,
endDate
};
await fetch(`${BACKEND_BASE}/internal/log-moderation`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(logData)
});
} catch (logError) {
console.error('Failed to log moderation action:', logError);
}
res.json({ success: true, message: `${action} action completed successfully` });
} catch (error) {
console.error('Error performing moderation action:', error);
res.status(500).json({ success: false, message: error.message || 'Internal server error' });
}
});
const bot = require('../discord-bot'); const bot = require('../discord-bot');
bot.login(); bot.login();

View File

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

View File

@@ -41,6 +41,22 @@ async function ensureSchema() {
data JSONB DEFAULT '{}' data JSONB DEFAULT '{}'
); );
`); `);
await p.query(`
CREATE TABLE IF NOT EXISTS admin_logs (
id SERIAL PRIMARY KEY,
guild_id TEXT NOT NULL,
action TEXT NOT NULL, -- 'kick', 'ban', 'timeout'
target_user_id TEXT NOT NULL,
target_username TEXT NOT NULL,
moderator_user_id TEXT NOT NULL,
moderator_username TEXT NOT NULL,
reason TEXT NOT NULL,
duration TEXT, -- for timeout/ban (e.g., '1d', '30m', 'permanent')
end_date TIMESTAMP WITH TIME ZONE, -- calculated end date for timeout/ban
timestamp TIMESTAMP WITH TIME ZONE DEFAULT now()
);
`);
} }
// Servers // Servers
@@ -76,6 +92,46 @@ async function deleteInvite(guildId, code) {
await p.query('DELETE FROM invites WHERE guild_id = $1 AND code = $2', [guildId, code]); await p.query('DELETE FROM invites WHERE guild_id = $1 AND code = $2', [guildId, code]);
} }
// Admin Logs
async function addAdminLog(logData) {
const p = initPool();
const q = `INSERT INTO admin_logs(guild_id, action, target_user_id, target_username, moderator_user_id, moderator_username, reason, duration, end_date)
VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9)`;
await p.query(q, [
logData.guildId,
logData.action,
logData.targetUserId,
logData.targetUsername,
logData.moderatorUserId,
logData.moderatorUsername,
logData.reason,
logData.duration || null,
logData.endDate || null
]);
}
async function getAdminLogs(guildId, limit = 50) {
const p = initPool();
const res = await p.query('SELECT * FROM admin_logs WHERE guild_id = $1 ORDER BY timestamp DESC LIMIT $2', [guildId, limit]);
return res.rows;
}
async function getAdminLogsByAction(guildId, action, limit = 50) {
const p = initPool();
const res = await p.query('SELECT * FROM admin_logs WHERE guild_id = $1 AND action = $2 ORDER BY timestamp DESC LIMIT $3', [guildId, action, limit]);
return res.rows;
}
async function deleteAdminLog(guildId, logId) {
const p = initPool();
await p.query('DELETE FROM admin_logs WHERE guild_id = $1 AND id = $2', [guildId, logId]);
}
async function deleteAllAdminLogs(guildId) {
const p = initPool();
await p.query('DELETE FROM admin_logs WHERE guild_id = $1', [guildId]);
}
// Users // Users
async function getUserData(discordId) { async function getUserData(discordId) {
const p = initPool(); const p = initPool();
@@ -89,4 +145,4 @@ async function upsertUserData(discordId, data) {
await p.query(`INSERT INTO users(discord_id, data) VALUES($1, $2) ON CONFLICT (discord_id) DO UPDATE SET data = $2`, [discordId, data]); 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 }; module.exports = { initPool, ensureSchema, getServerSettings, upsertServerSettings, listInvites, addInvite, deleteInvite, getUserData, upsertUserData, addAdminLog, getAdminLogs, getAdminLogsByAction, deleteAdminLog, deleteAllAdminLogs };

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,24 +1,43 @@
# Project Checklist (tidy & current) # Project Checklist (tidy & current)
Below are implemented features and pending items, grouped by area. Below are implemented features - [x] Front - [x] Live updates between bot and frontend using SSE events for real-time log synchronization (admin logs update immediately when moderation actions occur)nd UI for admin logs configuration in Server Settings
- [x] Database schema for storing moderation action logs
- [x] Require reason field (minimum 3 words) for all moderation commands
- [x] Admin Logs UI: added logs display section showing recent moderation actions with detailsd pending items, grouped by area.
## Backend ## Backend
- [x] Express API: OAuth, server settings, channel/role endpoints, leave - [x] Express API: OAuth, server settings, channel/role endpoints, leave
- [x] Invite endpoints (GET/POST/DELETE) and invite-token issuance - [x] Invite endpoints (GET/POST/DELETE) and invite-token issuance
- [x] Per-command toggles persistence and management - [x] Per-command toggles persistence and management
- [x] Config endpoints for welcome/leave and autorole - [x] Config endpoints for welcome/leave and autorole
- [x] Admin Logs API endpoints: GET/POST for admin logs configuration, GET for retrieving moderation action logs
- [x] Frontend Moderation API: POST endpoint for direct ban/kick/timeout actions from web interface
- [x] Server Members API: GET endpoint for fetching server members for moderation user selection
- [x] SSE events: added botStatusUpdate events for real-time bot join/leave notifications
## Frontend ## Frontend
- [x] Login, Dashboard, Server Settings pages - [x] Login, Dashboard, Server Settings pages
- Login redirects to Dashboard after OAuth and user/guilds are persisted in localStorage - 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) - Dashboard is protected: user must be logged in to view (redirects to login otherwise)
- [x] MUI components, responsive layout, mobile fixes - [x] MUI components, responsive layout, mobile fixes
- [x] Theme switching (persist local) and user settings UI - [x] Theme switching (persist local) and user settings UI with adjusted light theme background
- [x] Invite UI: create form, list, copy, delete with confirmation - [x] Invite UI: create form, list, copy, delete with confirmation
- [x] Commands UI (per-command toggles) - [x] Commands UI (per-command toggles)
- [x] Admin commands (kick/ban/timeout) removed from regular commands list, only shown in Admin Commands section
- [x] Live Notifications UI (per-server toggle & config) - [x] Live Notifications UI (per-server toggle & config)
- Live Notifications accessible from server page via dropdown and dialog - Channel selection, watched-user list, live status with Watch Live button
- Dashboard: channel dropdown and watched-user list added - Real-time updates: adding/removing users via frontend or bot commands publishes SSE `twitchUsersUpdate` and pushes settings to bot
- Bot commands (`/add-twitchuser`, `/remove-twitchuser`) refresh local cache immediately after backend success
- Message mode: toggle between Default and Custom; Apply sends `message`/`customMessage` (default fallback if empty); no longer dual free-form fields
- Collapsible accordion interface: separate Twitch and Kick tabs (Kick tab disabled)
- [x] Admin Commands UI: dedicated section for moderation commands with toggle controls
- [x] Moderation Commands (`/kick`, `/ban`, `/timeout`) displayed with permission requirements and toggle switches
- [x] Admin Logs Configuration UI: channel selection and per-command enable/disable toggles
- [x] Frontend Moderation Actions: direct ban/kick/timeout functionality from web interface with user autocomplete dropdown
- [x] User permission validation and reason requirements (minimum 3 words)
- [x] Integration with backend moderation API and admin logging system
- [x] Admin Logs channel selection: shows all server text channels (not just channels where bot has permission) and updates immediately when changed
- [x] Admin logs properly save moderator usernames for both bot slash commands and frontend moderation actions, and persist across page refreshes
## Discord Bot ## Discord Bot
- [x] discord.js integration (events and commands) - [x] discord.js integration (events and commands)
@@ -26,23 +45,72 @@
- [x] Bot used by backend to fetch live guild data and manage invites - [x] Bot used by backend to fetch live guild data and manage invites
- [x] Bot reads/writes per-guild command toggles via backend/Postgres - [x] Bot reads/writes per-guild command toggles via backend/Postgres
- [x] Backend immediately notifies bot of toggle changes (pushes updated settings to bot cache) so frontend toggles take effect instantly - [x] Backend immediately notifies bot of toggle changes (pushes updated settings to bot cache) so frontend toggles take effect instantly
- [x] New slash command: `/list-twitchusers` to list watched Twitch usernames for a guild - [x] New slash command: `/setup-live` to enable/disable Twitch live notifications for the server (preserves other settings)
- [x] Frontend: Confirm dialog and working Delete action for Twitch watched users in Live Notifications - [x] Frontend: Confirm dialog and working Delete action for Twitch watched users in Live Notifications
- [x] Live Notifications: bot posts message to configured channel with stream title and link when a watched Twitch user goes live - [x] Live Notifications: bot posts message to configured channel with stream title and link when a watched Twitch user goes live
- [x] Live Notifications: bot posts rich embed to channel when a watched Twitch user goes live (thumbnail, clickable title, bio/description, category/game, viewers, footer with "ehchadservices" and start datetime) - [x] Live Notifications: bot posts rich embed to channel when a watched Twitch user goes live (thumbnail, clickable title, bio/description, category/game, viewers, footer with "ehchadservices" and start datetime)
- [x] Live Notifications polling frequency set to 3 seconds for rapid detection (configurable via `TWITCH_POLL_INTERVAL_MS`) - [x] Live Notifications polling frequency set to 5 seconds (configurable via `TWITCH_POLL_INTERVAL_MS`)
- [x] On bot restart, sends messages for currently live watched users; then sends for new streams once per session
- [x] Frontend: show "Watch Live" button next to watched user when they are live (links to Twitch) - [x] Frontend: show "Watch Live" button next to watched user when they are live (links to Twitch)
- [x] Bi-directional sync: backend POST/DELETE for twitch-users now also pushes new settings to bot process (when `BOT_PUSH_URL` configured)
- [x] Bot adds/removes users via backend endpoints ensuring single source of truth (Postgres)
- [x] Live notifications toggle on site enables/disables watching and publishes SSE for real-time updates
- [x] /manage-commands command has enable/disable buttons that sync with frontend via backend API and SSE for live updating
- [x] All Twitch-related commands (add, remove, list) and frontend actions communicate with backend and Postgres database
- [x] Welcome/Leave messages: bot sends configured messages to channels when users join/leave
- [x] Welcome messages with {user} and {server} placeholders
- [x] Leave messages with {user} placeholder
- [x] Autorole assignment on member join
- [x] All settings managed through Server Settings UI
- [x] Event handlers properly integrated with API settings
- [x] Kick live notifications bot integration (temporarily disabled)
- [x] New slash commands: `/add-kickuser`, `/remove-kickuser`, `/list-kickusers` (commands exist but watcher disabled)
- [x] Kick API polling and notification posting (watcher removed, API endpoints remain)
- [x] Per-server Kick user management via backend API (endpoints functional)
- [x] Frontend tabs: separate Twitch and Kick tabs in Live Notifications accordion (Kick tab disabled)
- [x] Bot watcher temporarily disabled in index.js startup
- [x] Dev command filtering: commands marked with `dev: true` are hidden from UI, help, and Discord registration
- [x] Admin Moderation Commands: `/kick`, `/ban`, `/timeout` with proper permission checks and role hierarchy validation
- [x] Commands accept user mentions or user IDs as input to allow targeting any user (not limited by Discord's user selection filtering)
- [x] Frontend integration: web interface moderation actions with permission validation
- [x] Moderation actions are logged to postgres database with reasons and automatically posted to configured admin logs channel
- [x] Admin logs properly capture and display the moderator who performed the action (both from bot slash commands and frontend)
- [x] Admin Logs System: event logging for moderation actions
- [x] New slash command: `/setup-adminlogs` to configure logging channel and per-command enable/disable
- [x] Bot posts detailed moderation logs to configured channel showing: command used, target user, moderator, date/time, reason (required min 3 words), duration, end date
- [x] Backend API endpoints for admin logs configuration and retrieval
- [x] Frontend UI for admin logs configuration in Server Settings
- [x] Database schema for storing moderation action logs
- [x] Require reason field (minimum 3 words) for all moderation commands
- [x] Admin logs are unique to each guild and stored in postgres database
- [x] Frontend delete buttons for individual logs and delete all logs with confirm dialogs
- [x] Live updates between bot and frontend using SSE events for real-time log synchronization
- [x] Admin logs properly display the username who called the command and the user they called it on for both bot slash commands and frontend moderation actions
- [x] Bot event handlers: added guildCreate and guildDelete events to publish SSE notifications for live dashboard updates
## Database ## Database
- [x] Postgres support via `DATABASE_URL` (backend auto-creates `servers`, `invites`, `users`) - [x] Postgres support via `DATABASE_URL` (backend auto-creates `servers`, `invites`, `users`)
- [x] Legacy encrypted `backend/db.json` retained (migration planned) - [x] Legacy encrypted `backend/db.json` retained (migration planned)
- [ ] Migration script: import `backend/db.json` into Postgres (planned) - [x] Kick.com live notifications: backend API, frontend UI, bot integration
- Database schema: kickUsers table with userId, username, guildId
- API endpoints: GET/POST/DELETE /api/servers/:guildId/kick-users
- Bot commands: /add-kickuser, /remove-kickuser, /list-kickusers
- Frontend tabs: separate Twitch and Kick tabs in Live Notifications accordion
- Kick API integration: polling for live status, stream metadata, web scraping fallback for 403 errors
- Per-server configuration: all settings scoped by guildId
- [x] Schema: live notification settings stored in server settings (via `liveNotifications` JSON) - [x] Schema: live notification settings stored in server settings (via `liveNotifications` JSON)
- Fields: `enabled`, `channelId`, `users[]`, `kickUsers[]`, `message`, `customMessage` (custom overrides default if non-empty)
- Users list preserved when updating other live notification settings (fixed: kickUsers now also preserved)
- [x] Admin Logs Database Schema: new table for storing moderation action logs
- Fields: id, guildId, action (kick/ban/timeout), targetUserId, targetUsername, moderatorUserId, moderatorUsername, reason, duration, endDate, timestamp
## Security & Behavior ## Security & Behavior
- [x] Invite DELETE requires short-lived HMAC token (`x-invite-token`) - [x] Invite DELETE requires short-lived HMAC token (`x-invite-token`)
- [x] Frontend confirmation dialog for invite deletion - [x] Frontend confirmation dialog for invite deletion
- [ ] Harden invite-token issuance (require OAuth + admin check) - [ ] Harden invite-token issuance (require OAuth + admin check)
- [ ] Template variables for messages (planned): support `{user}`, `{title}`, `{category}`, `{viewers}` replacement in `message` / `customMessage`
- [x] Moderation Command Requirements: require reason field (minimum 3 words) for all moderation commands (`/kick`, `/ban`, `/timeout`)
- [x] ServerSettings back button: fixed to navigate to dashboard instead of browser history to prevent accidental accordion opening
## Docs & Deployment ## Docs & Deployment
- [x] README and CHANGELOG updated with setup steps and Postgres guidance - [x] README and CHANGELOG updated with setup steps and Postgres guidance
@@ -58,7 +126,33 @@
- Server cards: uniform sizes, image cropping, name clamping - Server cards: uniform sizes, image cropping, name clamping
- Mobile spacing and typography adjustments - Mobile spacing and typography adjustments
- Dashboard action buttons repositioned (Invite/Leave under title) - Dashboard action buttons repositioned (Invite/Leave under title)
- Live Notifications: collapsible accordion with tabbed interface for Twitch and Kick tabs (Kick tab disabled)
- [x] All accordions in ServerSettings: consistently grayed out (opacity 0.5) when bot is not in server
- [x] Footer component: added global footer showing "© ehchadservices.com 2025" on all pages
- [x] Dashboard live reloading: real-time updates when bot joins/leaves servers via SSE events
- [x] Responsive design: mobile-friendly layout with adaptive padding, typography, and component sizing
- [x] Ultra-wide screen support: max-width constraints and overflow prevention
- [x] Sticky footer: footer positioned at bottom of viewport regardless of content height
- [x] Navbar branding: title shows "ECS" on mobile, "EhChadServices" on desktop
- [x] Dashboard welcome text: updated to "Welcome back, {username}" with even larger typography (h3/h2 variants) and increased spacing; title also enlarged (h4/h3) for better proportion and explicit margin-bottom for clear line separation
- [x] Browser tab now shows `ECS - <Page Name>` (e.g., 'ECS - Dashboard') - [x] Browser tab now shows `ECS - <Page Name>` (e.g., 'ECS - Dashboard')
- [x] Dashboard duplicate title fixed; user settings (avatar/themes) restored via NavBar - [x] Dashboard duplicate title fixed; user settings (avatar/themes) restored via NavBar
- [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
- [x] Fixed compilation errors: added missing MUI imports and Snackbar component
- [x] Navbar visibility: enhanced with solid background and stronger border for better visibility across all themes

View File

@@ -163,4 +163,49 @@ async function _rawGetTwitchStreams(usersCsv) {
try { return await res.json(); } catch (e) { return []; } try { return await res.json(); } catch (e) { return []; }
} }
module.exports = { getServerSettings, upsertServerSettings, getCommands, toggleCommand, listInvites, addInvite, deleteInvite, getTwitchUsers, addTwitchUser, deleteTwitchUser }; // Kick users helpers
async function getKickUsers(guildId) {
const path = `/api/servers/${guildId}/kick-users`;
const json = await safeFetchJsonPath(path);
return json || [];
}
async function addKickUser(guildId, username) {
const path = `/api/servers/${guildId}/kick-users`;
try {
const res = await tryFetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username }),
});
return res && res.ok;
} catch (e) {
console.error(`Failed to add kick user ${username} for ${guildId}:`, e && e.message ? e.message : e);
return false;
}
}
async function deleteKickUser(guildId, username) {
const path = `/api/servers/${guildId}/kick-users/${encodeURIComponent(username)}`;
try {
const res = await tryFetch(path, { method: 'DELETE' });
return res && res.ok;
} catch (e) {
console.error(`Failed to delete kick user ${username} for ${guildId}:`, e && e.message ? e.message : e);
return false;
}
}
async function getWelcomeLeaveSettings(guildId) {
const path = `/api/servers/${guildId}/welcome-leave-settings`;
const json = await safeFetchJsonPath(path);
return json || { welcome: { enabled: false }, leave: { enabled: false } };
}
async function getAutoroleSettings(guildId) {
const path = `/api/servers/${guildId}/autorole-settings`;
const json = await safeFetchJsonPath(path);
return json || { enabled: false, roleId: '' };
}
module.exports = { getServerSettings, upsertServerSettings, getCommands, toggleCommand, listInvites, addInvite, deleteInvite, getTwitchUsers, addTwitchUser, deleteTwitchUser, getKickUsers, addKickUser, deleteKickUser, getWelcomeLeaveSettings, getAutoroleSettings };

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

@@ -22,6 +22,15 @@ module.exports = {
}); });
if (resp.ok) { if (resp.ok) {
await interaction.reply({ content: `Added ${username} to watch list.`, flags: 64 }); await interaction.reply({ content: `Added ${username} to watch list.`, flags: 64 });
// Refresh cached settings from backend so watcher sees new user immediately
try {
const settingsResp = await fetch(`${backendBase}/api/servers/${interaction.guildId}/settings`);
if (settingsResp.ok) {
const json = await settingsResp.json();
const bot = require('..');
if (bot && bot.setGuildSettings) bot.setGuildSettings(interaction.guildId, json);
}
} catch (_) {}
} else { } else {
await interaction.reply({ content: 'Failed to add user via backend.', flags: 64 }); await interaction.reply({ content: 'Failed to add user via backend.', flags: 64 });
} }

172
discord-bot/commands/ban.js Normal file
View File

@@ -0,0 +1,172 @@
const { SlashCommandBuilder, PermissionsBitField } = require('discord.js');
// Helper function to parse user from mention or ID
function parseUser(input, guild) {
// Check if it's a mention <@123456> or <@!123456>
const mentionMatch = input.match(/^<@!?(\d+)>$/);
if (mentionMatch) {
return guild.members.cache.get(mentionMatch[1])?.user;
}
// Check if it's a user ID
if (/^\d{15,20}$/.test(input)) {
return guild.members.cache.get(input)?.user;
}
// Try to find by username or global name
const member = guild.members.cache.find(m =>
(m.user.global_name && m.user.global_name.toLowerCase().includes(input.toLowerCase())) ||
m.user.username.toLowerCase().includes(input.toLowerCase()) ||
(m.user.global_name && m.user.global_name.toLowerCase() === input.toLowerCase()) ||
m.user.username.toLowerCase() === input.toLowerCase()
);
return member?.user;
}
// Helper function to log moderation actions
async function logModerationAction(guildId, action, targetUserId, targetUsername, moderatorUserId, moderatorUsername, reason, duration = null, endDate = null) {
try {
const logData = {
guildId,
action,
targetUserId,
targetUsername,
moderatorUserId,
moderatorUsername,
reason,
duration,
endDate
};
const response = await fetch(`${process.env.BACKEND_BASE || 'http://localhost:3001'}/internal/log-moderation`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(logData)
});
if (!response.ok) {
console.error('Failed to log moderation action:', response.statusText);
}
} catch (error) {
console.error('Error logging moderation action:', error);
}
}
module.exports = {
name: 'ban',
description: 'Ban a user from the server',
enabled: true,
builder: new SlashCommandBuilder()
.setName('ban')
.setDescription('Ban a user from the server')
.addStringOption(option =>
option.setName('user')
.setDescription('The user to ban (mention or user ID)')
.setRequired(true))
.addStringOption(option =>
option.setName('reason')
.setDescription('Reason for the ban (minimum 3 words)')
.setRequired(true))
.addIntegerOption(option =>
option.setName('days')
.setDescription('Number of days of messages to delete (0-7)')
.setRequired(false)
.setMinValue(0)
.setMaxValue(7)),
async execute(interaction) {
// Check if user has ban permissions
if (!interaction.member.permissions.has(PermissionsBitField.Flags.BanMembers)) {
return await interaction.reply({
content: 'You do not have permission to ban members.',
flags: 64
});
}
// Check if bot has ban permissions
if (!interaction.guild.members.me.permissions.has(PermissionsBitField.Flags.BanMembers)) {
return await interaction.reply({
content: 'I do not have permission to ban members.',
flags: 64
});
}
const userInput = interaction.options.getString('user');
const reason = interaction.options.getString('reason');
const days = interaction.options.getInteger('days') || 0;
// Parse the user from the input
const user = parseUser(userInput, interaction.guild);
if (!user) {
return await interaction.reply({
content: 'Could not find that user. Please provide a valid user mention or user ID.',
flags: 64
});
}
// Validate reason has at least 3 words
const reasonWords = reason.trim().split(/\s+/);
if (reasonWords.length < 3) {
return await interaction.reply({
content: 'Reason must be at least 3 words long.',
flags: 64
});
}
// Cannot ban yourself
if (user.id === interaction.user.id) {
return await interaction.reply({
content: 'You cannot ban yourself.',
flags: 64
});
}
// Cannot ban the bot
if (user.id === interaction.guild.members.me.id) {
return await interaction.reply({
content: 'I cannot ban myself.',
flags: 64
});
}
// Check if user is in the server
const member = interaction.guild.members.cache.get(user.id);
if (member) {
// Check role hierarchy
if (member.roles.highest.position >= interaction.member.roles.highest.position && interaction.user.id !== interaction.guild.ownerId) {
return await interaction.reply({
content: 'You cannot ban a member with a higher or equal role.',
flags: 64
});
}
if (member.roles.highest.position >= interaction.guild.members.me.roles.highest.position) {
return await interaction.reply({
content: 'I cannot ban a member with a higher or equal role.',
flags: 64
});
}
}
try {
await interaction.guild.members.ban(user, {
reason: reason,
deleteMessageDays: days
});
await interaction.reply({
content: `Successfully banned ${user.global_name || user.username} for: ${reason}${days > 0 ? ` (deleted ${days} days of messages)` : ''}`,
flags: 64
});
// Log the action
await logModerationAction(interaction.guildId, 'ban', user.id, user.global_name || user.username, interaction.user.id, interaction.user.global_name || interaction.user.username, reason, 'permanent');
} catch (error) {
console.error('Error banning user:', error);
await interaction.reply({
content: 'Failed to ban the user. Please try again.',
flags: 64
});
}
}
};

View File

@@ -0,0 +1,167 @@
const { SlashCommandBuilder, PermissionsBitField } = require('discord.js');
// Helper function to parse user from mention or ID
function parseUser(input, guild) {
// Check if it's a mention <@123456> or <@!123456>
const mentionMatch = input.match(/^<@!?(\d+)>$/);
if (mentionMatch) {
return guild.members.cache.get(mentionMatch[1])?.user;
}
// Check if it's a user ID
if (/^\d{15,20}$/.test(input)) {
return guild.members.cache.get(input)?.user;
}
// Try to find by username or global name
const member = guild.members.cache.find(m =>
(m.user.global_name && m.user.global_name.toLowerCase().includes(input.toLowerCase())) ||
m.user.username.toLowerCase().includes(input.toLowerCase()) ||
(m.user.global_name && m.user.global_name.toLowerCase() === input.toLowerCase()) ||
m.user.username.toLowerCase() === input.toLowerCase()
);
return member?.user;
}
// Helper function to log moderation actions
async function logModerationAction(guildId, action, targetUserId, targetUsername, moderatorUserId, moderatorUsername, reason, duration = null, endDate = null) {
try {
const logData = {
guildId,
action,
targetUserId,
targetUsername,
moderatorUserId,
moderatorUsername,
reason,
duration,
endDate
};
const response = await fetch(`${process.env.BACKEND_BASE || 'http://localhost:3001'}/internal/log-moderation`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(logData)
});
if (!response.ok) {
console.error('Failed to log moderation action:', response.statusText);
}
} catch (error) {
console.error('Error logging moderation action:', error);
}
}
module.exports = {
name: 'kick',
description: 'Kick a user from the server',
enabled: true,
builder: new SlashCommandBuilder()
.setName('kick')
.setDescription('Kick a user from the server')
.addStringOption(option =>
option.setName('user')
.setDescription('The user to kick (mention or user ID)')
.setRequired(true))
.addStringOption(option =>
option.setName('reason')
.setDescription('Reason for the kick (minimum 3 words)')
.setRequired(true)),
async execute(interaction) {
// Check if user has kick permissions
if (!interaction.member.permissions.has(PermissionsBitField.Flags.KickMembers)) {
return await interaction.reply({
content: 'You do not have permission to kick members.',
flags: 64
});
}
// Check if bot has kick permissions
if (!interaction.guild.members.me.permissions.has(PermissionsBitField.Flags.KickMembers)) {
return await interaction.reply({
content: 'I do not have permission to kick members.',
flags: 64
});
}
const userInput = interaction.options.getString('user');
const reason = interaction.options.getString('reason');
// Parse the user from the input
const user = parseUser(userInput, interaction.guild);
if (!user) {
return await interaction.reply({
content: 'Could not find that user. Please provide a valid user mention or user ID.',
flags: 64
});
}
// Validate reason has at least 3 words
const reasonWords = reason.trim().split(/\s+/);
if (reasonWords.length < 3) {
return await interaction.reply({
content: 'Reason must be at least 3 words long.',
flags: 64
});
}
// Cannot kick yourself
if (user.id === interaction.user.id) {
return await interaction.reply({
content: 'You cannot kick yourself.',
flags: 64
});
}
// Cannot kick the bot
if (user.id === interaction.guild.members.me.id) {
return await interaction.reply({
content: 'I cannot kick myself.',
flags: 64
});
}
// Check if user is in the server
const member = interaction.guild.members.cache.get(user.id);
if (!member) {
return await interaction.reply({
content: 'That user is not in this server.',
flags: 64
});
}
// Check role hierarchy
if (member.roles.highest.position >= interaction.member.roles.highest.position && interaction.user.id !== interaction.guild.ownerId) {
return await interaction.reply({
content: 'You cannot kick a member with a higher or equal role.',
flags: 64
});
}
if (member.roles.highest.position >= interaction.guild.members.me.roles.highest.position) {
return await interaction.reply({
content: 'I cannot kick a member with a higher or equal role.',
flags: 64
});
}
try {
await member.kick(reason);
await interaction.reply({
content: `Successfully kicked ${user.global_name || user.username} for: ${reason}`,
flags: 64
});
// Log the action
await logModerationAction(interaction.guildId, 'kick', user.id, user.global_name || user.username, interaction.user.id, interaction.user.global_name || interaction.user.username, reason);
} catch (error) {
console.error('Error kicking user:', error);
await interaction.reply({
content: 'Failed to kick the user. Please try again.',
flags: 64
});
}
}
};

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

@@ -20,7 +20,7 @@ module.exports = {
let toggles = existingSettings.commandToggles; let toggles = existingSettings.commandToggles;
// Include all loaded commands so simple command modules (no SlashCommandBuilder) like // Include all loaded commands so simple command modules (no SlashCommandBuilder) like
// `ping` are also listed. Filter for objects with a name for safety. // `ping` are also listed. Filter for objects with a name for safety.
const commands = Array.from(interaction.client.commands.values()).filter(cmd => cmd && cmd.name); const commands = Array.from(interaction.client.commands.values()).filter(cmd => cmd && cmd.name && !cmd.dev);
// Build button components (max 5 rows, 5 buttons per row) // Build button components (max 5 rows, 5 buttons per row)
const actionRows = []; const actionRows = [];

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

@@ -20,6 +20,15 @@ module.exports = {
const resp = await fetch(`${backendBase}/api/servers/${interaction.guildId}/twitch-users/${encodeURIComponent(username)}`, { method: 'DELETE' }); const resp = await fetch(`${backendBase}/api/servers/${interaction.guildId}/twitch-users/${encodeURIComponent(username)}`, { method: 'DELETE' });
if (resp.ok) { if (resp.ok) {
await interaction.reply({ content: `Removed ${username} from watch list.`, flags: 64 }); await interaction.reply({ content: `Removed ${username} from watch list.`, flags: 64 });
// Refresh cached settings from backend
try {
const settingsResp = await fetch(`${backendBase}/api/servers/${interaction.guildId}/settings`);
if (settingsResp.ok) {
const json = await settingsResp.json();
const bot = require('..');
if (bot && bot.setGuildSettings) bot.setGuildSettings(interaction.guildId, json);
}
} catch (_) {}
} else { } else {
await interaction.reply({ content: 'Failed to remove user via backend.', flags: 64 }); await interaction.reply({ content: 'Failed to remove user via backend.', flags: 64 });
} }

View File

@@ -0,0 +1,123 @@
const { SlashCommandBuilder, PermissionsBitField } = require('discord.js');
module.exports = {
name: 'setup-adminlogs',
description: 'Configure admin moderation logging settings',
enabled: true,
builder: new SlashCommandBuilder()
.setName('setup-adminlogs')
.setDescription('Configure admin moderation logging settings')
.addChannelOption(option =>
option.setName('channel')
.setDescription('Channel to send admin logs to')
.setRequired(false))
.addBooleanOption(option =>
option.setName('enabled')
.setDescription('Enable or disable admin logging')
.setRequired(false))
.addBooleanOption(option =>
option.setName('kick_logs')
.setDescription('Log kick actions')
.setRequired(false))
.addBooleanOption(option =>
option.setName('ban_logs')
.setDescription('Log ban actions')
.setRequired(false))
.addBooleanOption(option =>
option.setName('timeout_logs')
.setDescription('Log timeout actions')
.setRequired(false)),
async execute(interaction) {
// Check if user has administrator permissions
if (!interaction.member.permissions.has(PermissionsBitField.Flags.Administrator)) {
return interaction.reply({
content: 'You need Administrator permissions to configure admin logs.',
flags: 64
});
}
const channel = interaction.options.getChannel('channel');
const enabled = interaction.options.getBoolean('enabled');
const kickLogs = interaction.options.getBoolean('kick_logs');
const banLogs = interaction.options.getBoolean('ban_logs');
const timeoutLogs = interaction.options.getBoolean('timeout_logs');
try {
// Get current settings
const response = await fetch(`${process.env.BACKEND_BASE || 'http://localhost:3001'}/api/servers/${interaction.guildId}/admin-logs-settings`);
if (!response.ok) {
throw new Error('Failed to fetch current settings');
}
const currentSettings = await response.json();
// Update settings
const updatedSettings = { ...currentSettings };
if (enabled !== null) {
updatedSettings.enabled = enabled;
}
if (channel) {
// Check if it's a text channel
if (channel.type !== 0) { // 0 = GUILD_TEXT
return interaction.reply({
content: 'Please select a text channel for admin logs.',
flags: 64
});
}
updatedSettings.channelId = channel.id;
}
// Update command-specific settings
updatedSettings.commands = { ...updatedSettings.commands };
if (kickLogs !== null) {
updatedSettings.commands.kick = kickLogs;
}
if (banLogs !== null) {
updatedSettings.commands.ban = banLogs;
}
if (timeoutLogs !== null) {
updatedSettings.commands.timeout = timeoutLogs;
}
// Save settings
const saveResponse = await fetch(`${process.env.BACKEND_BASE || 'http://localhost:3001'}/api/servers/${interaction.guildId}/admin-logs-settings`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(updatedSettings)
});
if (!saveResponse.ok) {
throw new Error('Failed to save settings');
}
const result = await saveResponse.json();
// Create response message
let responseMessage = 'Admin logs settings updated!\n\n';
responseMessage += `**Enabled:** ${result.settings.enabled ? 'Yes' : 'No'}\n`;
if (result.settings.channelId) {
responseMessage += `**Channel:** <#${result.settings.channelId}>\n`;
} else {
responseMessage += `**Channel:** Not set\n`;
}
responseMessage += `**Kick Logs:** ${result.settings.commands.kick ? 'Enabled' : 'Disabled'}\n`;
responseMessage += `**Ban Logs:** ${result.settings.commands.ban ? 'Enabled' : 'Disabled'}\n`;
responseMessage += `**Timeout Logs:** ${result.settings.commands.timeout ? 'Enabled' : 'Disabled'}`;
await interaction.reply({
content: responseMessage,
flags: 64
});
} catch (error) {
console.error('Error configuring admin logs:', error);
await interaction.reply({
content: 'An error occurred while configuring admin logs. Please try again later.',
flags: 64
});
}
}
};

View File

@@ -1,17 +1,13 @@
const { SlashCommandBuilder, PermissionsBitField } = require('discord.js'); const { SlashCommandBuilder, PermissionsBitField } = require('discord.js');
const fetch = require('node-fetch');
const api = require('../api'); const api = require('../api');
const { readDb, writeDb } = require('../../backend/db');
module.exports = { module.exports = {
name: 'setup-live', name: 'setup-live',
description: 'Admin: configure Twitch live notifications for this server', description: 'Admin: enable or disable Twitch live notifications for this server',
enabled: true, enabled: true,
builder: new SlashCommandBuilder() builder: new SlashCommandBuilder()
.setName('setup-live') .setName('setup-live')
.setDescription('Configure Twitch live notifications for this server') .setDescription('Enable or disable Twitch live notifications for this server')
.addStringOption(opt => opt.setName('twitch_user').setDescription('Twitch username to watch').setRequired(true))
.addChannelOption(opt => opt.setName('channel').setDescription('Channel to send notifications').setRequired(true))
.addBooleanOption(opt => opt.setName('enabled').setDescription('Enable/disable notifications').setRequired(true)), .addBooleanOption(opt => opt.setName('enabled').setDescription('Enable/disable notifications').setRequired(true)),
async execute(interaction) { async execute(interaction) {
@@ -20,24 +16,18 @@ module.exports = {
return; return;
} }
const twitchUser = interaction.options.getString('twitch_user');
const channel = interaction.options.getChannel('channel');
const enabled = interaction.options.getBoolean('enabled'); const enabled = interaction.options.getBoolean('enabled');
try { try {
const api = require('../api'); const api = require('../api');
const existing = (await api.getServerSettings(interaction.guildId)) || {}; const existing = (await api.getServerSettings(interaction.guildId)) || {};
existing.liveNotifications = { enabled: !!enabled, twitchUser, channelId: channel.id }; const currentLn = existing.liveNotifications || {};
existing.liveNotifications = { ...currentLn, enabled: !!enabled };
await api.upsertServerSettings(interaction.guildId, existing); await api.upsertServerSettings(interaction.guildId, existing);
await interaction.reply({ content: `Live notifications ${enabled ? 'enabled' : 'disabled'} for ${twitchUser} -> ${channel.name}`, flags: 64 }); await interaction.reply({ content: `Live notifications ${enabled ? 'enabled' : 'disabled'} for this server.`, flags: 64 });
} catch (e) { } catch (e) {
console.error('Error saving live notifications to backend, falling back to local:', e); console.error('Error saving live notifications to backend:', e);
// fallback to local db await interaction.reply({ content: 'Failed to update live notifications.', flags: 64 });
const db = readDb();
if (!db[interaction.guildId]) db[interaction.guildId] = {};
db[interaction.guildId].liveNotifications = { enabled, twitchUser, channelId: channel.id };
writeDb(db);
await interaction.reply({ content: `Saved locally: Live notifications ${enabled ? 'enabled' : 'disabled'} for ${twitchUser} -> ${channel.name}`, flags: 64 });
} }
} }
}; };

View File

@@ -0,0 +1,187 @@
const { SlashCommandBuilder, PermissionsBitField } = require('discord.js');
// Helper function to parse user from mention or ID
function parseUser(input, guild) {
// Check if it's a mention <@123456> or <@!123456>
const mentionMatch = input.match(/^<@!?(\d+)>$/);
if (mentionMatch) {
return guild.members.cache.get(mentionMatch[1])?.user;
}
// Check if it's a user ID
if (/^\d{15,20}$/.test(input)) {
return guild.members.cache.get(input)?.user;
}
// Try to find by username or global name
const member = guild.members.cache.find(m =>
(m.user.global_name && m.user.global_name.toLowerCase().includes(input.toLowerCase())) ||
m.user.username.toLowerCase().includes(input.toLowerCase()) ||
(m.user.global_name && m.user.global_name.toLowerCase() === input.toLowerCase()) ||
m.user.username.toLowerCase() === input.toLowerCase()
);
return member?.user;
}
// Helper function to log moderation actions
async function logModerationAction(guildId, action, targetUserId, targetUsername, moderatorUserId, moderatorUsername, reason, duration = null, endDate = null) {
try {
const logData = {
guildId,
action,
targetUserId,
targetUsername,
moderatorUserId,
moderatorUsername,
reason,
duration,
endDate
};
const response = await fetch(`${process.env.BACKEND_BASE || 'http://localhost:3001'}/internal/log-moderation`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(logData)
});
if (!response.ok) {
console.error('Failed to log moderation action:', response.statusText);
}
} catch (error) {
console.error('Error logging moderation action:', error);
}
}
module.exports = {
name: 'timeout',
description: 'Timeout a user in the server',
enabled: true,
builder: new SlashCommandBuilder()
.setName('timeout')
.setDescription('Timeout a user in the server')
.addStringOption(option =>
option.setName('user')
.setDescription('The user to timeout (mention or user ID)')
.setRequired(true))
.addIntegerOption(option =>
option.setName('duration')
.setDescription('Duration in minutes (max 40320 minutes = 28 days)')
.setRequired(true)
.setMinValue(1)
.setMaxValue(40320))
.addStringOption(option =>
option.setName('reason')
.setDescription('Reason for the timeout (minimum 3 words)')
.setRequired(true)),
async execute(interaction) {
// Check if user has moderate members permissions (required for timeout)
if (!interaction.member.permissions.has(PermissionsBitField.Flags.ModerateMembers)) {
return await interaction.reply({
content: 'You do not have permission to timeout members.',
flags: 64
});
}
// Check if bot has moderate members permissions
if (!interaction.guild.members.me.permissions.has(PermissionsBitField.Flags.ModerateMembers)) {
return await interaction.reply({
content: 'I do not have permission to timeout members.',
flags: 64
});
}
const userInput = interaction.options.getString('user');
const duration = interaction.options.getInteger('duration');
const reason = interaction.options.getString('reason');
// Parse the user from the input
const user = parseUser(userInput, interaction.guild);
if (!user) {
return await interaction.reply({
content: 'Could not find that user. Please provide a valid user mention or user ID.',
flags: 64
});
}
// Validate reason has at least 3 words
const reasonWords = reason.trim().split(/\s+/);
if (reasonWords.length < 3) {
return await interaction.reply({
content: 'Reason must be at least 3 words long.',
flags: 64
});
}
// Cannot timeout yourself
if (user.id === interaction.user.id) {
return await interaction.reply({
content: 'You cannot timeout yourself.',
flags: 64
});
}
// Cannot timeout the bot
if (user.id === interaction.guild.members.me.id) {
return await interaction.reply({
content: 'I cannot timeout myself.',
flags: 64
});
}
// Check if user is in the server
const member = interaction.guild.members.cache.get(user.id);
if (!member) {
return await interaction.reply({
content: 'That user is not in this server.',
flags: 64
});
}
// Check role hierarchy
if (member.roles.highest.position >= interaction.member.roles.highest.position && interaction.user.id !== interaction.guild.ownerId) {
return await interaction.reply({
content: 'You cannot timeout a member with a higher or equal role.',
flags: 64
});
}
if (member.roles.highest.position >= interaction.guild.members.me.roles.highest.position) {
return await interaction.reply({
content: 'I cannot timeout a member with a higher or equal role.',
flags: 64
});
}
// Check if user is already timed out
if (member.communicationDisabledUntil) {
return await interaction.reply({
content: 'This user is already timed out.',
flags: 64
});
}
try {
const timeoutDuration = duration * 60 * 1000; // Convert minutes to milliseconds
const timeoutUntil = new Date(Date.now() + timeoutDuration);
await member.timeout(timeoutDuration, reason);
await interaction.reply({
content: `Successfully timed out ${user.global_name || user.username} for ${duration} minutes. Reason: ${reason}`,
flags: 64
});
// Log the action
const durationString = duration >= 1440 ? `${Math.floor(duration / 1440)}d ${Math.floor((duration % 1440) / 60)}h ${duration % 60}m` :
duration >= 60 ? `${Math.floor(duration / 60)}h ${duration % 60}m` : `${duration}m`;
await logModerationAction(interaction.guildId, 'timeout', user.id, user.global_name || user.username, interaction.user.id, interaction.user.global_name || interaction.user.username, reason, durationString, timeoutUntil);
} catch (error) {
console.error('Error timing out user:', error);
await interaction.reply({
content: 'Failed to timeout the user. Please try again.',
flags: 64
});
}
}
};

View File

@@ -10,7 +10,7 @@ const commandFiles = fs.readdirSync(commandsPath).filter(file => file.endsWith('
for (const file of commandFiles) { for (const file of commandFiles) {
const filePath = path.join(commandsPath, file); const filePath = path.join(commandsPath, file);
const command = require(filePath); const command = require(filePath);
if (command.enabled === false) continue; if (command.enabled === false || command.dev === true) continue;
if (command.builder) { if (command.builder) {
commands.push(command.builder.toJSON()); commands.push(command.builder.toJSON());
@@ -37,4 +37,24 @@ const deployCommands = async (guildId) => {
} }
}; };
// Standalone execution
if (require.main === module) {
const { Client, GatewayIntentBits } = require('discord.js');
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
client.once('ready', async () => {
console.log(`Logged in as ${client.user.tag}`);
console.log(`Deploying commands to ${client.guilds.cache.size} guilds...`);
for (const [guildId, guild] of client.guilds.cache) {
await deployCommands(guildId);
}
console.log('All commands deployed!');
client.destroy();
});
client.login(process.env.DISCORD_BOT_TOKEN);
}
module.exports = deployCommands; module.exports = deployCommands;

View File

@@ -0,0 +1,15 @@
const api = require('../api');
module.exports = {
name: 'guildCreate',
execute: async (guild, client) => {
console.log(`Bot joined guild: ${guild.name} (${guild.id})`);
try {
// Publish SSE event for bot status change
await api.publishEvent('*', 'botStatusUpdate', { guildId: guild.id, isBotInServer: true });
} catch (error) {
console.error('Error publishing bot join event:', error);
}
},
};

View File

@@ -0,0 +1,15 @@
const api = require('../api');
module.exports = {
name: 'guildDelete',
execute: async (guild, client) => {
console.log(`Bot left guild: ${guild.name} (${guild.id})`);
try {
// Publish SSE event for bot status change
await api.publishEvent('*', 'botStatusUpdate', { guildId: guild.id, isBotInServer: false });
} catch (error) {
console.error('Error publishing bot leave event:', error);
}
},
};

View File

@@ -1,72 +1,46 @@
const { Events } = require('discord.js'); const { Events } = require('discord.js');
const { readDb } = require('../../backend/db.js');
module.exports = { module.exports = {
name: Events.GuildMemberAdd, name: Events.GuildMemberAdd,
async execute(member) { async execute(member) {
try { try {
const api = require('../api'); const api = require('../api');
const settings = (await api.getServerSettings(member.guild.id)) || {}; // Get the welcome/leave settings from the API
const welcomeLeaveSettings = await api.getWelcomeLeaveSettings(member.guild.id) || { welcome: { enabled: false } };
const welcome = welcomeLeaveSettings.welcome;
const welcome = { if (welcome && welcome.enabled && welcome.channel) {
enabled: settings.welcomeEnabled || false, const channel = member.guild.channels.cache.get(welcome.channel);
channel: settings.welcomeChannel || '', if (channel) {
message: settings.welcomeMessage || 'Welcome {user} to {server}!' try {
}; const message = (welcome.message || 'Welcome {user} to {server}!').replace('{user}', member.user.toString()).replace('{server}', member.guild.name);
await channel.send(message);
if (welcome && welcome.enabled && welcome.channel) { } catch (error) {
const channel = member.guild.channels.cache.get(welcome.channel); console.error(`Could not send welcome message to channel ${welcome.channel} in guild ${member.guild.id}:`, error);
if (channel) {
try {
const message = (welcome.message).replace('{user}', member.user.toString()).replace('{server}', member.guild.name);
await channel.send(message);
} catch (error) {
console.error(`Could not send welcome message to channel ${welcome.channel} in guild ${member.guild.id}:`, error);
}
} }
} }
const autorole = settings.autorole || {};
if (autorole && autorole.enabled && autorole.roleId) {
const role = member.guild.roles.cache.get(autorole.roleId);
if (role) {
try {
// Re-check that role is assignable
const botHighest = member.guild.members.me.roles.highest.position;
if (role.id === member.guild.id || role.managed || role.position >= botHighest) {
console.warn(`Autorole ${role.id} in guild ${member.guild.id} is not assignable (everyone/managed/too high). Skipping.`);
return;
}
await member.roles.add(role);
} catch (error) {
console.error(`Could not assign autorole in guild ${member.guild.id}:`, error);
}
}
}
} catch (error) {
console.error(`Error in guildMemberAdd event for guild ${member.guild.id}:`, error);
// fallback to local db
try {
const db = readDb();
const settings = db[member.guild.id];
if (settings && settings.welcomeEnabled && settings.welcomeChannel) {
const channel = member.guild.channels.cache.get(settings.welcomeChannel);
if (channel) {
try {
const message = (settings.welcomeMessage || 'Welcome {user} to {server}!').replace('{user}', member.user.toString()).replace('{server}', member.guild.name);
await channel.send(message);
} catch (innerErr) { /* ignore */ }
}
}
if (settings && settings.autorole && settings.autorole.enabled && settings.autorole.roleId) {
const role = member.guild.roles.cache.get(settings.autorole.roleId);
if (role) {
try { await member.roles.add(role); } catch (innerErr) { /* ignore */ }
}
}
} catch (inner) {
// ignore fallback errors
}
} }
},
// Handle autorole
const autoroleSettings = await api.getAutoroleSettings(member.guild.id) || { enabled: false };
if (autoroleSettings && autoroleSettings.enabled && autoroleSettings.roleId) {
const role = member.guild.roles.cache.get(autoroleSettings.roleId);
if (role) {
try {
// Re-check that role is assignable
const botHighest = member.guild.members.me.roles.highest.position;
if (role.id === member.guild.id || role.managed || role.position >= botHighest) {
console.warn(`Autorole ${role.id} in guild ${member.guild.id} is not assignable (everyone/managed/too high). Skipping.`);
return;
}
await member.roles.add(role);
} catch (error) {
console.error(`Could not assign autorole in guild ${member.guild.id}:`, error);
}
}
}
} catch (error) {
console.error(`Error in guildMemberAdd event for guild ${member.guild.id}:`, error);
}
}
}; };

View File

@@ -1,19 +1,19 @@
const { Events } = require('discord.js'); const { Events } = require('discord.js');
const { readDb } = require('../../backend/db.js');
module.exports = { module.exports = {
name: Events.GuildMemberRemove, name: Events.GuildMemberRemove,
async execute(member) { async execute(member) {
try { try {
const api = require('../api'); const api = require('../api');
const settings = (await api.getServerSettings(member.guild.id)) || {}; // Get the welcome/leave settings from the API
const leave = { enabled: settings.leaveEnabled || false, channel: settings.leaveChannel || '', message: settings.leaveMessage || '{user} has left the server.' }; const welcomeLeaveSettings = await api.getWelcomeLeaveSettings(member.guild.id) || { leave: { enabled: false } };
const leave = welcomeLeaveSettings.leave;
if (leave && leave.enabled && leave.channel) { if (leave && leave.enabled && leave.channel) {
const channel = member.guild.channels.cache.get(leave.channel); const channel = member.guild.channels.cache.get(leave.channel);
if (channel) { if (channel) {
try { try {
const message = (leave.message).replace('{user}', member.user.toString()); const message = (leave.message || '{user} has left the server.').replace('{user}', member.user.toString());
await channel.send(message); await channel.send(message);
} catch (error) { } catch (error) {
console.error(`Could not send leave message to channel ${leave.channel} in guild ${member.guild.id}:`, error); console.error(`Could not send leave message to channel ${leave.channel} in guild ${member.guild.id}:`, error);
@@ -22,20 +22,6 @@ module.exports = {
} }
} catch (error) { } catch (error) {
console.error(`Error in guildMemberRemove event for guild ${member.guild.id}:`, error); console.error(`Error in guildMemberRemove event for guild ${member.guild.id}:`, error);
// fallback to local db
try {
const db = readDb();
const settings = db[member.guild.id];
if (settings && settings.leaveEnabled && settings.leaveChannel) {
const channel = member.guild.channels.cache.get(settings.leaveChannel);
if (channel) {
try {
const message = (settings.leaveMessage || '{user} has left the server.').replace('{user}', member.user.toString());
await channel.send(message);
} catch (innerErr) { /* ignore */ }
}
}
} catch (inner) { /* ignore */ }
} }
}, }
}; };

View File

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

View File

@@ -145,19 +145,28 @@ async function announceLive(guildId, stream) {
const channel = await guild.channels.fetch(channelId).catch(() => null); const channel = await guild.channels.fetch(channelId).catch(() => null);
if (!channel) return { success: false, message: 'Channel not found' }; if (!channel) return { success: false, message: 'Channel not found' };
const { EmbedBuilder } = require('discord.js'); const { EmbedBuilder } = require('discord.js');
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setColor(0x9146FF) .setColor(0x9146FF)
.setTitle(stream.title || `${stream.user_name} is live`) .setTitle(stream.title || `${stream.user_name} is live`)
.setURL(stream.url) .setURL(stream.url)
.setAuthor({ name: stream.user_name, iconURL: stream.profile_image_url || undefined, url: stream.url }) .setAuthor({ name: stream.user_name, iconURL: stream.profile_image_url || undefined, url: stream.url })
.setThumbnail(stream.thumbnail_url || stream.profile_image_url || undefined) .setThumbnail(stream.thumbnail_url || stream.profile_image_url || undefined)
.addFields( .addFields(
{ name: 'Category', value: stream.game_name || 'Unknown', inline: true }, { name: 'Category', value: stream.game_name || 'Unknown', inline: true },
{ name: 'Viewers', value: String(stream.viewer_count || 0), inline: true } { name: 'Viewers', value: String(stream.viewer_count || 0), inline: true }
) )
.setDescription(stream.description || '') .setDescription((stream.description || '').slice(0, 200))
.setFooter({ text: `ehchadservices • Started: ${stream.started_at ? new Date(stream.started_at).toLocaleString() : 'unknown'}` }); .setFooter({ text: `ehchadservices • Started: ${stream.started_at ? new Date(stream.started_at).toLocaleString() : 'unknown'}` });
await channel.send({ embeds: [embed] }); let prefixMsg = '';
if (liveSettings.customMessage) {
prefixMsg = liveSettings.customMessage;
} else if (liveSettings.message) {
prefixMsg = liveSettings.message;
} else {
prefixMsg = `🔴 ${stream.user_name} is now live!`;
}
const payload = prefixMsg ? { content: prefixMsg, embeds: [embed] } : { embeds: [embed] };
await channel.send(payload);
return { success: true }; return { success: true };
} catch (e) { } catch (e) {
console.error('announceLive failed:', e && e.message ? e.message : e); console.error('announceLive failed:', e && e.message ? e.message : e);
@@ -170,8 +179,7 @@ module.exports = { login, client, setGuildSettings, getGuildSettingsFromCache, a
// Start twitch watcher when client is ready (use 'clientReady' as the event name) // Start twitch watcher when client is ready (use 'clientReady' as the event name)
try { try {
const watcher = require('./twitch-watcher'); const watcher = require('./twitch-watcher');
// discord.js renamed the ready event to clientReady; the event loader registers // discord.js uses 'clientReady' event
// handlers based on event.name so we listen for the same 'clientReady' here.
client.once('clientReady', () => { client.once('clientReady', () => {
// start polling in background // start polling in background
watcher.poll(client).catch(err => console.error('Twitch watcher failed to start:', err)); watcher.poll(client).catch(err => console.error('Twitch watcher failed to start:', err));
@@ -182,6 +190,19 @@ try {
// ignore if watcher not available // ignore if watcher not available
} }
try {
const kickWatcher = require('./kick-watcher');
client.once('clientReady', () => {
// TEMPORARILY DISABLED: Kick watcher removed for now
// kickWatcher.poll(client).catch(err => console.error('Kick watcher failed to start:', err));
console.log('Kick watcher: temporarily disabled');
});
// process.on('exit', () => { kickWatcher.stop(); });
// process.on('SIGINT', () => { kickWatcher.stop(); process.exit(); });
} catch (e) {
// ignore if kick watcher not available
}
// --- Optional push receiver (so backend can notify a remote bot process) --- // --- Optional push receiver (so backend can notify a remote bot process) ---
try { try {
const express = require('express'); const express = require('express');

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

@@ -1,4 +1,62 @@
const api = require('./api'); const api = require('./api');
const fetch = require('node-fetch');
// Twitch API credentials (optional). If provided, we'll enrich embeds with user bio.
const twitchClientId = process.env.TWITCH_CLIENT_ID || null;
const twitchClientSecret = process.env.TWITCH_CLIENT_SECRET || null;
let twitchAppToken = null; // cached app access token
let twitchTokenExpires = 0;
// Cache of user login -> { description, profile_image_url, fetchedAt }
const userInfoCache = new Map();
async function getAppToken() {
if (!twitchClientId || !twitchClientSecret) return null;
const now = Date.now();
if (twitchAppToken && now < twitchTokenExpires - 60000) { // refresh 1 min early
return twitchAppToken;
}
try {
const res = await fetch(`https://id.twitch.tv/oauth2/token?client_id=${twitchClientId}&client_secret=${twitchClientSecret}&grant_type=client_credentials`, { method: 'POST' });
const json = await res.json();
twitchAppToken = json.access_token;
twitchTokenExpires = now + (json.expires_in * 1000);
return twitchAppToken;
} catch (e) {
console.error('Failed to fetch Twitch app token:', e && e.message ? e.message : e);
return null;
}
}
async function fetchUserInfo(login) {
if (!login) return null;
const lower = login.toLowerCase();
const cached = userInfoCache.get(lower);
const now = Date.now();
if (cached && now - cached.fetchedAt < 1000 * 60 * 30) { // 30 min cache
return cached;
}
const token = await getAppToken();
if (!token) return null;
try {
const res = await fetch(`https://api.twitch.tv/helix/users?login=${encodeURIComponent(lower)}`, {
headers: {
'Client-ID': twitchClientId,
'Authorization': `Bearer ${token}`
}
});
const json = await res.json();
const data = (json.data && json.data[0]) || null;
if (data) {
const info = { description: data.description || '', profile_image_url: data.profile_image_url || '', fetchedAt: now };
userInfoCache.set(lower, info);
return info;
}
} catch (e) {
console.error('Failed to fetch Twitch user info for', lower, e && e.message ? e.message : e);
}
return null;
}
let polling = false; let polling = false;
const pollIntervalMs = Number(process.env.TWITCH_POLL_INTERVAL_MS || 5000); // 5s default const pollIntervalMs = Number(process.env.TWITCH_POLL_INTERVAL_MS || 5000); // 5s default
@@ -45,6 +103,10 @@ async function checkGuild(client, guild) {
let channel = null; let channel = null;
try { try {
channel = await client.channels.fetch(channelId); channel = await client.channels.fetch(channelId);
if (channel.type !== 0) { // 0 is text channel
console.error(`TwitchWatcher: channel ${channelId} is not a text channel (type: ${channel.type})`);
channel = null;
}
} catch (e) { } catch (e) {
console.error(`TwitchWatcher: failed to fetch channel ${channelId}:`, e && e.message ? e.message : e); console.error(`TwitchWatcher: failed to fetch channel ${channelId}:`, e && e.message ? e.message : e);
channel = null; channel = null;
@@ -73,10 +135,16 @@ async function checkGuild(client, guild) {
// mark announced for this session // mark announced for this session
announced.set(key, { started_at: s.started_at || new Date().toISOString() }); announced.set(key, { started_at: s.started_at || new Date().toISOString() });
// Build and send embed // Build and send embed (standardized layout)
try { try {
// Announce without per-guild log spam // Announce without per-guild log spam
const { EmbedBuilder } = require('discord.js'); const { EmbedBuilder } = require('discord.js');
// Attempt to enrich with user bio (description) if available
let bio = '';
try {
const info = await fetchUserInfo(login);
if (info && info.description) bio = info.description.slice(0, 200);
} catch (_) {}
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setColor(0x9146FF) .setColor(0x9146FF)
.setTitle(s.title || `${s.user_name} is live`) .setTitle(s.title || `${s.user_name} is live`)
@@ -87,10 +155,21 @@ async function checkGuild(client, guild) {
{ name: 'Category', value: s.game_name || 'Unknown', inline: true }, { name: 'Category', value: s.game_name || 'Unknown', inline: true },
{ name: 'Viewers', value: String(s.viewer_count || 0), inline: true } { name: 'Viewers', value: String(s.viewer_count || 0), inline: true }
) )
.setDescription(s.description || '') .setDescription(bio || (s.description || '').slice(0, 200))
.setFooter({ text: `ehchadservices • Started: ${s.started_at ? new Date(s.started_at).toLocaleString() : 'unknown'}` }); .setFooter({ text: `ehchadservices • Started: ${s.started_at ? new Date(s.started_at).toLocaleString() : 'unknown'}` });
await channel.send({ embeds: [embed] }); // Determine message text (custom overrides default). Provide a plain text prefix if available.
let prefixMsg = '';
if (liveSettings.customMessage) {
prefixMsg = liveSettings.customMessage;
} else if (liveSettings.message) {
prefixMsg = liveSettings.message;
} else {
prefixMsg = `🔴 ${s.user_name} is now live!`;
}
// Ensure we always hyperlink the title via embed; prefix is optional add above embed
const payload = prefixMsg ? { content: prefixMsg, embeds: [embed] } : { embeds: [embed] };
await channel.send(payload);
console.log(`🔔 Announced live: ${login} - ${(s.title || '').slice(0, 80)}`); console.log(`🔔 Announced live: ${login} - ${(s.title || '').slice(0, 80)}`);
} catch (e) { } catch (e) {
console.error(`TwitchWatcher: failed to send announcement for ${login}:`, e && e.message ? e.message : e); console.error(`TwitchWatcher: failed to send announcement for ${login}:`, e && e.message ? e.message : e);
@@ -108,6 +187,15 @@ async function poll(client) {
if (polling) return; if (polling) return;
polling = true; polling = true;
console.log(`🔁 TwitchWatcher started, polling every ${Math.round(pollIntervalMs/1000)}s`); console.log(`🔁 TwitchWatcher started, polling every ${Math.round(pollIntervalMs/1000)}s`);
// Initial check on restart: send messages for currently live users
try {
const guilds = Array.from(client.guilds.cache.values());
for (const g of guilds) {
await checkGuild(client, g).catch(err => { console.error('TwitchWatcher: initial checkGuild error', err && err.message ? err.message : err); });
}
} catch (e) {
console.error('Error during initial twitch check:', e && e.message ? e.message : e);
}
while (polling) { while (polling) {
try { try {
const guilds = Array.from(client.guilds.cache.values()); const guilds = Array.from(client.guilds.cache.values());

View File

@@ -3580,9 +3580,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@rushstack/eslint-patch": { "node_modules/@rushstack/eslint-patch": {
"version": "1.12.0", "version": "1.14.0",
"resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.12.0.tgz", "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.14.0.tgz",
"integrity": "sha512-5EwMtOqvJMMa3HbmxLlF74e+3/HhwBTMcvt3nqVJgGCozO6hzIPOBlwm8mGVNR9SN2IJpxSnlxczyDjcn7qIyw==", "integrity": "sha512-WJFej426qe4RWOm9MMtP4V3CV4AucXolQty+GRgAWLgQXmpCuwzs7hEpxxhSc/znXUSxum9d/P/32MW0FlAAlA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@sinclair/typebox": { "node_modules/@sinclair/typebox": {
@@ -4080,9 +4080,9 @@
} }
}, },
"node_modules/@types/express-serve-static-core": { "node_modules/@types/express-serve-static-core": {
"version": "5.0.7", "version": "5.1.0",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.7.tgz", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.0.tgz",
"integrity": "sha512-R+33OsgWw7rOhD1emjU7dzCDHucJrgJXMA5PYCzJxVil0dsyx5iBEPHqpPfiKNJQb7lZ1vxwoLR4Z87bBUpeGQ==", "integrity": "sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/node": "*", "@types/node": "*",
@@ -4092,9 +4092,9 @@
} }
}, },
"node_modules/@types/express/node_modules/@types/express-serve-static-core": { "node_modules/@types/express/node_modules/@types/express-serve-static-core": {
"version": "4.19.6", "version": "4.19.7",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.7.tgz",
"integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==", "integrity": "sha512-FvPtiIf1LfhzsaIXhv/PHan/2FeQBbtBDtfX2QfvPxdUelMDEckK08SM6nqo1MIZY3RUlfA+HV8+hFUSio78qg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/node": "*", "@types/node": "*",
@@ -4176,12 +4176,12 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "24.6.2", "version": "24.9.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.6.2.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz",
"integrity": "sha512-d2L25Y4j+W3ZlNAeMKcy7yDsK425ibcAOO2t7aPTz6gNMH0z2GThtwENCDc0d/Pw9wgyRqE5Px1wkV7naz8ang==", "integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~7.13.0" "undici-types": "~7.16.0"
} }
}, },
"node_modules/@types/node-forge": { "node_modules/@types/node-forge": {
@@ -4270,12 +4270,11 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/send": { "node_modules/@types/send": {
"version": "0.17.5", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz", "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.0.tgz",
"integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==", "integrity": "sha512-zBF6vZJn1IaMpg3xUF25VK3gd3l8zwE0ZLRX7dsQyQi+jp4E8mMDJNGDYnYse+bQhYwWERTxVwHpi3dMOq7RKQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/mime": "^1",
"@types/node": "*" "@types/node": "*"
} }
}, },
@@ -4289,14 +4288,24 @@
} }
}, },
"node_modules/@types/serve-static": { "node_modules/@types/serve-static": {
"version": "1.15.8", "version": "1.15.9",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.8.tgz", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.9.tgz",
"integrity": "sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==", "integrity": "sha512-dOTIuqpWLyl3BBXU3maNQsS4A3zuuoYRNIvYSxxhebPfXg2mzWQEPne/nlJ37yOse6uGgR386uTpdsx4D0QZWA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/http-errors": "*", "@types/http-errors": "*",
"@types/node": "*", "@types/node": "*",
"@types/send": "*" "@types/send": "<1"
}
},
"node_modules/@types/serve-static/node_modules/@types/send": {
"version": "0.17.5",
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz",
"integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==",
"license": "MIT",
"dependencies": {
"@types/mime": "^1",
"@types/node": "*"
} }
}, },
"node_modules/@types/sockjs": { "node_modules/@types/sockjs": {
@@ -5330,9 +5339,9 @@
} }
}, },
"node_modules/axe-core": { "node_modules/axe-core": {
"version": "4.10.3", "version": "4.11.0",
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.10.3.tgz", "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.0.tgz",
"integrity": "sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg==", "integrity": "sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ==",
"license": "MPL-2.0", "license": "MPL-2.0",
"engines": { "engines": {
"node": ">=4" "node": ">=4"
@@ -5635,9 +5644,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.8.10", "version": "2.8.18",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.10.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.18.tgz",
"integrity": "sha512-uLfgBi+7IBNay8ECBO2mVMGZAc1VgZWEChxm4lv+TobGdG82LnXMjuNGo/BSSZZL4UmkWhxEHP2f5ziLNwGWMA==", "integrity": "sha512-UYmTpOBwgPScZpS4A+YbapwWuBwasxvO/2IOHArSsAhL/+ZdmATBXTex3t+l2hXwLVYK382ibr/nKoY9GKe86w==",
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"baseline-browser-mapping": "dist/cli.js" "baseline-browser-mapping": "dist/cli.js"
@@ -5956,9 +5965,9 @@
} }
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001746", "version": "1.0.30001751",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001746.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz",
"integrity": "sha512-eA7Ys/DGw+pnkWWSE/id29f2IcPHVoE8wxtvE5JdvD2V28VTDPy1yEeo11Guz0sJ4ZeGRcm3uaTcAqK1LXaphA==", "integrity": "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==",
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@@ -6218,9 +6227,9 @@
} }
}, },
"node_modules/collect-v8-coverage": { "node_modules/collect-v8-coverage": {
"version": "1.0.2", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz",
"integrity": "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==", "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/color-convert": { "node_modules/color-convert": {
@@ -6398,9 +6407,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/core-js": { "node_modules/core-js": {
"version": "3.45.1", "version": "3.46.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.45.1.tgz", "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.46.0.tgz",
"integrity": "sha512-L4NPsJlCfZsPeXukyzHFlg/i7IIVwHSItR0wg0FLNqYClJ4MQYTYLbC7EkjKYRLZF2iof2MUgN0EGy7MdQFChg==", "integrity": "sha512-vDMm9B0xnqqZ8uSBpZ8sNtRtOdmfShrvT6h2TuQGLs0Is+cR0DYbj/KWP6ALVNbWPpqA/qPLoOuppJN07humpA==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
@@ -6409,12 +6418,12 @@
} }
}, },
"node_modules/core-js-compat": { "node_modules/core-js-compat": {
"version": "3.45.1", "version": "3.46.0",
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.45.1.tgz", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.46.0.tgz",
"integrity": "sha512-tqTt5T4PzsMIZ430XGviK4vzYSoeNJ6CXODi6c/voxOT6IZqBht5/EKaSNnYiEjjRYxjVz7DQIsOsY0XNi8PIA==", "integrity": "sha512-p9hObIIEENxSV8xIu+V68JjSeARg6UVMG5mR+JEUguG3sI6MsiS1njz2jHmyJDvA+8jX/sytkBHup6kxhM9law==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"browserslist": "^4.25.3" "browserslist": "^4.26.3"
}, },
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
@@ -6422,9 +6431,9 @@
} }
}, },
"node_modules/core-js-pure": { "node_modules/core-js-pure": {
"version": "3.45.1", "version": "3.46.0",
"resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.45.1.tgz", "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.46.0.tgz",
"integrity": "sha512-OHnWFKgTUshEU8MK+lOs1H8kC8GkTi9Z1tvNkxrCcw9wl3MJIO7q2ld77wjWn4/xuGrVu2X+nME1iIIPBSdyEQ==", "integrity": "sha512-NMCW30bHNofuhwLhYPt66OLOKTMbOhgTTatKVbaQC3KRHpTCiRIBYvtshr+NBYSnBxwAFhjW/RfJ0XbIjS16rw==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
@@ -7344,9 +7353,9 @@
} }
}, },
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.229", "version": "1.5.237",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.229.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.237.tgz",
"integrity": "sha512-cwhDcZKGcT/rEthLRJ9eBlMDkh1sorgsuk+6dpsehV0g9CABsIqBxU4rLRjG+d/U6pYU1s37A4lSKrVc5lSQYg==", "integrity": "sha512-icUt1NvfhGLar5lSWH3tHNzablaA5js3HVHacQimfP8ViEBOQv+L7DKEuHdbTZ0SKCO1ogTJTIL1Gwk9S6Qvcg==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/emittery": { "node_modules/emittery": {
@@ -11648,12 +11657,16 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/loader-runner": { "node_modules/loader-runner": {
"version": "4.3.0", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz",
"integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", "integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6.11.5" "node": ">=6.11.5"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
} }
}, },
"node_modules/loader-utils": { "node_modules/loader-utils": {
@@ -12101,9 +12114,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/node-releases": { "node_modules/node-releases": {
"version": "2.0.21", "version": "2.0.26",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.21.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.26.tgz",
"integrity": "sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw==", "integrity": "sha512-S2M9YimhSjBSvYnlr5/+umAnPHE++ODwt5e2Ij6FoX45HA/s4vHdkDx1eax2pAPeAOqu4s9b7ppahsyEFdVqQA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/normalize-path": { "node_modules/normalize-path": {
@@ -15197,9 +15210,9 @@
} }
}, },
"node_modules/semver": { "node_modules/semver": {
"version": "7.7.2", "version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"license": "ISC", "license": "ISC",
"bin": { "bin": {
"semver": "bin/semver.js" "semver": "bin/semver.js"
@@ -16951,9 +16964,9 @@
} }
}, },
"node_modules/typescript": { "node_modules/typescript": {
"version": "4.9.5", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true, "peer": true,
"bin": { "bin": {
@@ -16961,7 +16974,7 @@
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
}, },
"engines": { "engines": {
"node": ">=4.2.0" "node": ">=14.17"
} }
}, },
"node_modules/unbox-primitive": { "node_modules/unbox-primitive": {
@@ -16989,9 +17002,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "7.13.0", "version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.13.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Ov2Rr9Sx+fRgagJ5AX0qvItZG/JKKoBRAVITs1zk7IqZGTJUwgUr7qoYBpWwakpWilTZFM98rG/AFRocu10iIQ==", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/unicode-canonical-property-names-ecmascript": { "node_modules/unicode-canonical-property-names-ecmascript": {
@@ -17272,9 +17285,9 @@
} }
}, },
"node_modules/webpack": { "node_modules/webpack": {
"version": "5.102.0", "version": "5.102.1",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.102.0.tgz", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.102.1.tgz",
"integrity": "sha512-hUtqAR3ZLVEYDEABdBioQCIqSoguHbFn1K7WlPPWSuXmx0031BD73PSE35jKyftdSh4YLDoQNgK4pqBt5Q82MA==", "integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/eslint-scope": "^3.7.7", "@types/eslint-scope": "^3.7.7",
@@ -17285,7 +17298,7 @@
"@webassemblyjs/wasm-parser": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1",
"acorn": "^8.15.0", "acorn": "^8.15.0",
"acorn-import-phases": "^1.0.3", "acorn-import-phases": "^1.0.3",
"browserslist": "^4.24.5", "browserslist": "^4.26.3",
"chrome-trace-event": "^1.0.2", "chrome-trace-event": "^1.0.2",
"enhanced-resolve": "^5.17.3", "enhanced-resolve": "^5.17.3",
"es-module-lexer": "^1.2.1", "es-module-lexer": "^1.2.1",
@@ -17297,8 +17310,8 @@
"loader-runner": "^4.2.0", "loader-runner": "^4.2.0",
"mime-types": "^2.1.27", "mime-types": "^2.1.27",
"neo-async": "^2.6.2", "neo-async": "^2.6.2",
"schema-utils": "^4.3.2", "schema-utils": "^4.3.3",
"tapable": "^2.2.3", "tapable": "^2.3.0",
"terser-webpack-plugin": "^5.3.11", "terser-webpack-plugin": "^5.3.11",
"watchpack": "^2.4.4", "watchpack": "^2.4.4",
"webpack-sources": "^3.3.3" "webpack-sources": "^3.3.3"

View File

@@ -14,8 +14,8 @@
"axios": "^1.12.2", "axios": "^1.12.2",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-router-dom": "^7.9.3", "react-router-dom": "^7.9.3",
"react-scripts": "5.0.1", "react-scripts": "5.0.1",
"web-vitals": "^2.1.4" "web-vitals": "^2.1.4"
}, },
"scripts": { "scripts": {

View File

@@ -2,30 +2,75 @@ import React, { useEffect } from 'react';
import { BrowserRouter as Router, Route, Routes, useLocation } from 'react-router-dom'; import { BrowserRouter as Router, Route, Routes, useLocation } from 'react-router-dom';
import { ThemeProvider } from './contexts/ThemeContext'; import { ThemeProvider } from './contexts/ThemeContext';
import { UserProvider } from './contexts/UserContext'; import { UserProvider } from './contexts/UserContext';
import { CssBaseline } from '@mui/material'; import { BackendProvider, useBackend } from './contexts/BackendContext';
import { CssBaseline, Box } from '@mui/material';
import Login from './components/Login'; import Login from './components/Login';
import Dashboard from './components/Dashboard'; import Dashboard from './components/Dashboard';
import ServerSettings from './components/ServerSettings'; import ServerSettings from './components/server/ServerSettings';
import NavBar from './components/NavBar'; import NavBar from './components/NavBar';
import HelpPage from './components/HelpPage'; import HelpPage from './components/server/HelpPage';
import DiscordPage from './components/DiscordPage'; import DiscordPage from './components/DiscordPage';
import MaintenancePage from './components/common/MaintenancePage';
import Footer from './components/common/Footer';
function AppInner() {
const { backendOnline, checking, forceCheck } = useBackend();
const handleRetry = async () => {
await forceCheck();
};
return (
<UserProvider>
<ThemeProvider>
<CssBaseline />
<Box
sx={{
display: 'flex',
flexDirection: 'column',
minHeight: '100vh',
}}
>
<Router>
<TitleSetter />
{!backendOnline ? (
<MaintenancePage onRetry={handleRetry} checking={checking} />
) : (
<>
<NavBar />
<Box
component="main"
sx={{
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
}}
>
<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>
</Box>
<Footer />
</>
)}
</Router>
</Box>
</ThemeProvider>
</UserProvider>
);
}
function App() { function App() {
return ( return (
<UserProvider> <UserProvider>
<ThemeProvider> <ThemeProvider>
<CssBaseline /> <BackendProvider>
<Router> <AppInner />
<TitleSetter /> </BackendProvider>
<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> </ThemeProvider>
</UserProvider> </UserProvider>
); );

View File

@@ -1,18 +1,22 @@
import React, { useState, useEffect, useContext } from 'react'; import React, { useState, useEffect, useContext } from 'react';
import { useNavigate, useLocation } from 'react-router-dom'; import { useNavigate, useLocation } from 'react-router-dom';
import { Grid, Card, CardContent, Typography, Box, IconButton, Snackbar, Alert, Menu, MenuItem, Button } from '@mui/material'; import { Grid, Card, CardContent, Typography, Box, IconButton, Snackbar, Alert, Menu, MenuItem, Button, Container } from '@mui/material';
import MoreVertIcon from '@mui/icons-material/MoreVert'; import MoreVertIcon from '@mui/icons-material/MoreVert';
import { UserContext } from '../contexts/UserContext'; import { UserContext } from '../contexts/UserContext';
import { useBackend } from '../contexts/BackendContext';
import PersonAddIcon from '@mui/icons-material/PersonAdd'; import PersonAddIcon from '@mui/icons-material/PersonAdd';
import RemoveCircleOutlineIcon from '@mui/icons-material/RemoveCircleOutline'; import RemoveCircleOutlineIcon from '@mui/icons-material/RemoveCircleOutline';
import axios from 'axios'; import DashboardIcon from '@mui/icons-material/Dashboard';
import WavingHandIcon from '@mui/icons-material/WavingHand';
import { get, post } from '../lib/api';
import ConfirmDialog from './ConfirmDialog'; import ConfirmDialog from './common/ConfirmDialog';
const Dashboard = () => { const Dashboard = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const { user, setUser } = useContext(UserContext); const { user, setUser } = useContext(UserContext);
const { eventTarget } = useBackend();
const [guilds, setGuilds] = useState([]); const [guilds, setGuilds] = useState([]);
const [botStatus, setBotStatus] = useState({}); const [botStatus, setBotStatus] = useState({});
@@ -78,7 +82,7 @@ const Dashboard = () => {
const statuses = {}; const statuses = {};
await Promise.all(guilds.map(async (g) => { await Promise.all(guilds.map(async (g) => {
try { 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; statuses[g.id] = resp.data.isBotInServer;
} catch (err) { } catch (err) {
statuses[g.id] = false; statuses[g.id] = false;
@@ -89,6 +93,25 @@ const Dashboard = () => {
fetchStatuses(); fetchStatuses();
}, [guilds, API_BASE]); }, [guilds, API_BASE]);
// Listen for bot status updates
useEffect(() => {
if (!eventTarget) return;
const onBotStatusUpdate = (event) => {
const data = JSON.parse(event.data);
setBotStatus(prev => ({
...prev,
[data.guildId]: data.isBotInServer
}));
};
eventTarget.addEventListener('botStatusUpdate', onBotStatusUpdate);
return () => {
eventTarget.removeEventListener('botStatusUpdate', onBotStatusUpdate);
};
}, [eventTarget]);
// Dashboard no longer loads live settings; that's on the server settings page // Dashboard no longer loads live settings; that's on the server settings page
// Live notifications handlers were removed from Dashboard // Live notifications handlers were removed from Dashboard
@@ -99,7 +122,7 @@ const Dashboard = () => {
const handleInviteBot = (e, guild) => { const handleInviteBot = (e, guild) => {
e.stopPropagation(); e.stopPropagation();
axios.get(`${API_BASE}/api/client-id`).then(resp => { get('/api/client-id').then(resp => {
const clientId = resp.data.clientId; const clientId = resp.data.clientId;
if (!clientId) { if (!clientId) {
setSnackbarMessage('No client ID available'); setSnackbarMessage('No client ID available');
@@ -124,7 +147,7 @@ const Dashboard = () => {
const handleConfirmLeave = async () => { const handleConfirmLeave = async () => {
if (!selectedGuild) return; if (!selectedGuild) return;
try { try {
await axios.post(`${API_BASE}/api/servers/${selectedGuild.id}/leave`); await post(`/api/servers/${selectedGuild.id}/leave`);
setBotStatus(prev => ({ ...prev, [selectedGuild.id]: false })); setBotStatus(prev => ({ ...prev, [selectedGuild.id]: false }));
setSnackbarMessage('Bot left the server'); setSnackbarMessage('Bot left the server');
setSnackbarOpen(true); setSnackbarOpen(true);
@@ -139,18 +162,32 @@ const Dashboard = () => {
const handleSnackbarClose = () => setSnackbarOpen(false); const handleSnackbarClose = () => setSnackbarOpen(false);
return ( return (
<div style={{ padding: 20 }}> <Container maxWidth="lg" sx={{ padding: { xs: 2, sm: 3, md: 5 } }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <Box sx={{ mb: 2 }}>
<Box> <Box>
<Typography variant="h4" gutterBottom>Dashboard</Typography> <Box sx={{ display: 'flex', alignItems: 'center', mb: 3 }}>
{user && <Typography variant="h6" gutterBottom>Welcome, {user.username}</Typography>} <DashboardIcon sx={{ mr: 1, fontSize: { xs: '2rem', sm: '2.5rem' } }} />
<Typography variant={{ xs: 'h4', sm: 'h3' }}>Dashboard</Typography>
</Box>
{user && <Box sx={{ display: 'flex', alignItems: 'center', mt: { xs: 4, sm: 5 }, mb: { xs: 4, sm: 5 } }}>
<WavingHandIcon sx={{ mr: 1, color: 'text.secondary' }} />
<Typography
variant={{ xs: 'h3', sm: 'h2' }}
sx={{
fontWeight: 300,
color: 'text.secondary'
}}
>
Welcome back, {user.username}
</Typography>
</Box>}
</Box> </Box>
</Box> </Box>
<Typography variant="h6" gutterBottom>Your Admin Servers:</Typography> <Typography variant="h6" gutterBottom>Your Admin Servers:</Typography>
<Grid container spacing={3} justifyContent="center"> <Grid container spacing={3} justifyContent="center">
{guilds.map(guild => ( {guilds.map(guild => (
<Grid item xs={12} sm={6} md={4} lg={3} key={guild.id} sx={{ display: 'flex', justifyContent: 'center' }}> <Grid item xs={12} sm={6} md={6} lg={4} key={guild.id} sx={{ display: 'flex', justifyContent: 'center' }}>
<Box sx={{ display: 'flex', justifyContent: 'center', width: '100%' }}> <Box sx={{ display: 'flex', justifyContent: 'center', width: '100%' }}>
<Card <Card
onClick={() => handleCardClick(guild)} onClick={() => handleCardClick(guild)}
@@ -163,6 +200,11 @@ const Dashboard = () => {
transform: 'translateY(-5px)', transform: 'translateY(-5px)',
boxShadow: '0 12px 24px rgba(0,0,0,0.3)', boxShadow: '0 12px 24px rgba(0,0,0,0.3)',
}, },
'&:active': {
transform: 'translateY(-2px) scale(0.98)',
transition: 'transform 0.1s ease-in-out',
boxShadow: '0 8px 16px rgba(0,0,0,0.4)',
},
width: '100%', width: '100%',
height: '100%', height: '100%',
display: 'flex', display: 'flex',
@@ -171,30 +213,66 @@ const Dashboard = () => {
overflow: 'hidden', overflow: 'hidden',
}} }}
> >
<CardContent sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', p: 2 }}> <CardContent sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', p: { xs: 1.5, sm: 2 } }}>
<Box <Box
component="img" component="img"
src={guild.icon ? `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png` : 'https://cdn.discordapp.com/embed/avatars/0.png'} src={guild.icon ? `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png` : 'https://cdn.discordapp.com/embed/avatars/0.png'}
sx={{ sx={{
width: 80, width: { xs: 60, sm: 80 },
height: 80, height: { xs: 60, sm: 80 },
borderRadius: '50%', borderRadius: '50%',
mb: 2, mb: 2,
boxShadow: '0 4px 8px rgba(0,0,0,0.2)', boxShadow: '0 4px 8px rgba(0,0,0,0.2)',
}} }}
/> />
<Typography variant="h6" sx={{ fontWeight: 700, textAlign: 'center', mb: 1 }}>{guild.name}</Typography> <Typography
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1 }}> variant="h6"
sx={{
fontWeight: 700,
textAlign: 'center',
mb: 1,
fontSize: { xs: '1rem', sm: '1.25rem' },
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
lineHeight: 1.2,
minHeight: { xs: '2.4rem', sm: '2.5rem' },
}}
>
{guild.name}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1, flexWrap: 'wrap' }}>
{botStatus[guild.id] ? ( {botStatus[guild.id] ? (
<Button variant="contained" color="error" size="small" onClick={(e) => handleLeaveBot(e, guild)} startIcon={<RemoveCircleOutlineIcon />}> <Button
variant="contained"
color="error"
size="small"
onClick={(e) => handleLeaveBot(e, guild)}
startIcon={<RemoveCircleOutlineIcon />}
sx={{ fontSize: { xs: '0.75rem', sm: '0.875rem' } }}
>
Leave Leave
</Button> </Button>
) : ( ) : (
<Button variant="contained" color="success" size="small" onClick={(e) => handleInviteBot(e, guild)} startIcon={<PersonAddIcon />}> <Button
variant="contained"
color="success"
size="small"
onClick={(e) => handleInviteBot(e, guild)}
startIcon={<PersonAddIcon />}
sx={{ fontSize: { xs: '0.75rem', sm: '0.875rem' } }}
>
Invite Invite
</Button> </Button>
)} )}
<IconButton size="small" onClick={(e) => { e.stopPropagation(); setMenuAnchor(e.currentTarget); setMenuGuild(guild); }} aria-label="server menu"> <IconButton
size="small"
onClick={(e) => { e.stopPropagation(); setMenuAnchor(e.currentTarget); setMenuGuild(guild); }}
aria-label="server menu"
sx={{ ml: { xs: 0, sm: 1 } }}
>
<MoreVertIcon /> <MoreVertIcon />
</IconButton> </IconButton>
</Box> </Box>
@@ -206,26 +284,26 @@ const Dashboard = () => {
))} ))}
</Grid> </Grid>
<Menu anchorEl={menuAnchor} open={!!menuAnchor} onClose={() => { setMenuAnchor(null); setMenuGuild(null); }}> <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> <MenuItem onClick={() => { setMenuAnchor(null); if (menuGuild) window.location.href = `/server/${menuGuild.id}`; }}>Open Server Settings</MenuItem>
</Menu> </Menu>
{/* Live Notifications dialog removed from Dashboard — available on Server Settings page */} {/* Live Notifications dialog removed from Dashboard — available on Server Settings page */}
<Snackbar open={snackbarOpen} autoHideDuration={6000} onClose={handleSnackbarClose}> <Snackbar open={snackbarOpen} autoHideDuration={6000} onClose={handleSnackbarClose}>
<Alert onClose={handleSnackbarClose} severity="info" sx={{ width: '100%' }}> <Alert onClose={handleSnackbarClose} severity="info" sx={{ width: '100%' }}>
{snackbarMessage} {snackbarMessage}
</Alert> </Alert>
</Snackbar> </Snackbar>
<ConfirmDialog <ConfirmDialog
open={dialogOpen} open={dialogOpen}
onClose={() => setDialogOpen(false)} onClose={() => setDialogOpen(false)}
onConfirm={handleConfirmLeave} onConfirm={handleConfirmLeave}
title="Confirm Leave" title="Confirm Leave"
message={`Are you sure you want the bot to leave ${selectedGuild?.name}?`} message={`Are you sure you want the bot to leave ${selectedGuild?.name}?`}
/> />
</div> </Container>
); );
}; };

View File

@@ -25,7 +25,7 @@ const NavBar = () => {
const closeMenu = () => { setAnchorEl(null); setOpen(false); }; const closeMenu = () => { setAnchorEl(null); setOpen(false); };
return ( return (
<AppBar position="static" color="transparent" elevation={0} sx={{ mb: 2, borderBottom: '1px solid rgba(0,0,0,0.06)' }}> <AppBar position="static" color="default" elevation={1} sx={{ mb: 2, borderBottom: '1px solid rgba(0,0,0,0.12)' }}>
<Toolbar sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', px: { xs: 2, sm: 4 } }}> <Toolbar sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', px: { xs: 2, sm: 4 } }}>
<Box> <Box>
<IconButton onClick={toggleOpen} aria-label="menu" size="large" sx={{ bgcolor: open ? 'primary.main' : 'transparent', color: open ? 'white' : 'text.primary' }}> <IconButton onClick={toggleOpen} aria-label="menu" size="large" sx={{ bgcolor: open ? 'primary.main' : 'transparent', color: open ? 'white' : 'text.primary' }}>
@@ -33,8 +33,12 @@ const NavBar = () => {
</IconButton> </IconButton>
</Box> </Box>
<Typography variant="h6" component="div" sx={{ fontWeight: 800, display: { xs: 'block', sm: 'none' } }} onClick={() => { navigate('/dashboard'); closeMenu(); }}>
ECS
</Typography>
<Typography variant="h6" component="div" sx={{ fontWeight: 800, display: { xs: 'none', sm: 'block' } }} onClick={() => { navigate('/dashboard'); closeMenu(); }}> <Typography variant="h6" component="div" sx={{ fontWeight: 800, display: { xs: 'none', sm: 'block' } }} onClick={() => { navigate('/dashboard'); closeMenu(); }}>
ECS - EHDCHADSWORTH EhChadServices
</Typography> </Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>

View File

@@ -1,794 +0,0 @@
import React, { useState, useEffect } from 'react';
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 ArrowBackIcon from '@mui/icons-material/ArrowBack';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
// UserSettings moved to NavBar
import ConfirmDialog from './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);
const [server, setServer] = useState(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [channels, setChannels] = useState([]);
const [roles, setRoles] = useState([]);
const [autoroleSettings, setAutoroleSettings] = useState({
enabled: false,
roleId: '',
});
const [commandsList, setCommandsList] = useState([]);
const [invites, setInvites] = useState([]);
const [deleting, setDeleting] = useState({});
const [confirmOpen, setConfirmOpen] = useState(false);
const [pendingDeleteInvite, setPendingDeleteInvite] = useState(null);
const [snackbarOpen, setSnackbarOpen] = useState(false);
const [snackbarMessage, setSnackbarMessage] = useState('');
// SSE connection status (not currently displayed)
const [confirmDeleteTwitch, setConfirmDeleteTwitch] = useState(false);
const [pendingTwitchUser, setPendingTwitchUser] = useState(null);
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 [watchedUsers, setWatchedUsers] = useState([]);
const [liveStatus, setLiveStatus] = useState({});
const [commandsExpanded, setCommandsExpanded] = useState(false);
const [welcomeLeaveSettings, setWelcomeLeaveSettings] = useState({
welcome: {
enabled: false,
channel: '',
message: 'Welcome to the server, {user}!',
customMessage: '',
},
leave: {
enabled: false,
channel: '',
message: '{user} has left the server.',
customMessage: '',
},
});
const defaultWelcomeMessages = ["Welcome to the server, {user}!", "Hey {user}, welcome!", "{user} has joined the party!"];
const defaultLeaveMessages = ["{user} has left the server.", "Goodbye, {user}.", "We'll miss you, {user}."];
useEffect(() => {
if (location.state && location.state.guild) {
setServer(location.state.guild);
} else {
// Fallback if guild data is not passed in state
const storedGuilds = localStorage.getItem('guilds');
if (storedGuilds) {
const guild = JSON.parse(storedGuilds).find(g => g.id === guildId);
setServer(guild);
}
}
// 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);
});
// Fetch client ID
axios.get(`${API_BASE}/api/client-id`)
.then(response => {
setClientId(response.data.clientId);
});
// Fetch channels
axios.get(`${API_BASE}/api/servers/${guildId}/channels`)
.then(response => {
setChannels(response.data);
});
// Fetch welcome/leave settings
axios.get(`${API_BASE}/api/servers/${guildId}/welcome-leave-settings`)
.then(response => {
if (response.data) {
setWelcomeLeaveSettings(response.data);
}
});
// Fetch roles
axios.get(`${API_BASE}/api/servers/${guildId}/roles`)
.then(response => {
setRoles(response.data);
});
// Fetch autorole settings
axios.get(`${API_BASE}/api/servers/${guildId}/autorole-settings`)
.then(response => {
if (response.data) {
setAutoroleSettings(response.data);
}
});
// Fetch commands/help list
axios.get(`${API_BASE}/api/servers/${guildId}/commands`)
.then(response => {
setCommandsList(response.data || []);
})
.catch(() => setCommandsList([]));
// Fetch invites
// Fetch live notifications settings and watched users
axios.get(`${API_BASE}/api/servers/${guildId}/live-notifications`).then(resp => {
const s = resp.data || { enabled: false, twitchUser: '', channelId: '' };
setLiveEnabled(!!s.enabled);
setLiveChannelId(s.channelId || '');
setLiveTwitchUser(s.twitchUser || '');
}).catch(() => {});
axios.get(`${API_BASE}/api/servers/${guildId}/twitch-users`).then(resp => setWatchedUsers(resp.data || [])).catch(() => setWatchedUsers([]));
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) {
setCommandsExpanded(true);
}
}, [guildId, location.state]);
// Subscribe to backend Server-Sent Events for real-time updates
useEffect(() => {
if (!guildId) return;
if (typeof window === 'undefined' || typeof EventSource === 'undefined') return;
const url = `${API_BASE}/api/events?guildId=${encodeURIComponent(guildId)}`;
let es = null;
try {
es = new EventSource(url);
} catch (err) {
console.warn('EventSource not available or failed to connect', err);
return;
}
es.addEventListener('connected', (e) => {
setSnackbarMessage('Real-time updates connected');
setSnackbarOpen(true);
});
es.addEventListener('commandToggle', (e) => {
try {
// refresh commands list to keep authoritative state
axios.get(`${API_BASE}/api/servers/${guildId}/commands`).then(resp => setCommandsList(resp.data || [])).catch(() => {});
} catch (err) {}
});
es.addEventListener('twitchUsersUpdate', (e) => {
try {
const data = JSON.parse(e.data || '{}');
if (data && data.users) setWatchedUsers(data.users || []);
// also refresh live status
if (data && data.users && data.users.length > 0) {
const usersCsv = data.users.join(',');
axios.get(`${API_BASE}/api/twitch/streams?users=${encodeURIComponent(usersCsv)}`)
.then(resp => {
const arr = resp.data || [];
const map = {};
for (const s of arr) if (s && s.user_login) map[s.user_login.toLowerCase()] = s;
setLiveStatus(map);
})
.catch(() => {});
} else {
setLiveStatus({});
}
} catch (err) {}
});
es.addEventListener('liveNotificationsUpdate', (e) => {
try {
const data = JSON.parse(e.data || '{}');
if (typeof data.enabled !== 'undefined') setLiveEnabled(!!data.enabled);
if (data.channelId) setLiveChannelId(data.channelId || '');
if (data.twitchUser) setLiveTwitchUser(data.twitchUser || '');
} catch (err) {}
});
es.onerror = (err) => {
setSnackbarMessage('Real-time updates disconnected. Retrying...');
setSnackbarOpen(true);
// attempt reconnects handled by EventSource automatically
};
return () => { try { es && es.close(); } catch (e) {} };
}, [guildId]);
const handleCloseSnackbar = () => {
setSnackbarOpen(false);
};
// Fetch live status for watched users
useEffect(() => {
let mounted = true;
if (!watchedUsers || watchedUsers.length === 0) {
setLiveStatus({});
return;
}
const usersCsv = watchedUsers.join(',');
axios.get(`${API_BASE}/api/twitch/streams?users=${encodeURIComponent(usersCsv)}`)
.then(resp => {
if (!mounted) return;
const arr = resp.data || [];
const map = {};
for (const s of arr) {
if (s && s.user_login) map[s.user_login.toLowerCase()] = s;
}
setLiveStatus(map);
})
.catch(() => setLiveStatus({}));
return () => { mounted = false; };
}, [watchedUsers]);
const handleAutoroleSettingUpdate = (newSettings) => {
axios.post(`${API_BASE}/api/servers/${guildId}/autorole-settings`, newSettings)
.then(response => {
if (response.data.success) {
setAutoroleSettings(newSettings);
}
});
};
const handleAutoroleToggleChange = (event) => {
const newSettings = { ...autoroleSettings, enabled: event.target.checked };
handleAutoroleSettingUpdate(newSettings);
};
const handleAutoroleRoleChange = (event) => {
const newSettings = { ...autoroleSettings, roleId: event.target.value };
handleAutoroleSettingUpdate(newSettings);
};
const handleSettingUpdate = (newSettings) => {
axios.post(`${API_BASE}/api/servers/${guildId}/welcome-leave-settings`, newSettings)
.then(response => {
if (response.data.success) {
setWelcomeLeaveSettings(newSettings);
}
});
}
const handleToggleChange = (type) => (event) => {
const newSettings = { ...welcomeLeaveSettings };
newSettings[type].enabled = event.target.checked;
handleSettingUpdate(newSettings);
};
const handleChannelChange = (type) => (event) => {
const newSettings = { ...welcomeLeaveSettings };
newSettings[type].channel = event.target.value;
handleSettingUpdate(newSettings);
};
const handleMessageOptionChange = (type) => (event) => {
const newSettings = { ...welcomeLeaveSettings };
if (event.target.value !== 'custom') {
newSettings[type].message = event.target.value;
handleSettingUpdate(newSettings);
} else {
const tempSettings = { ...welcomeLeaveSettings };
// Set message to custom message to get the radio button to select custom
tempSettings[type].message = tempSettings[type].customMessage;
setWelcomeLeaveSettings(tempSettings);
}
};
const handleCustomMessageChange = (type) => (event) => {
const newSettings = { ...welcomeLeaveSettings };
newSettings[type].customMessage = event.target.value;
setWelcomeLeaveSettings(newSettings);
};
const handleApplyCustomMessage = (type) => () => {
const newSettings = { ...welcomeLeaveSettings };
newSettings[type].message = newSettings[type].customMessage;
handleSettingUpdate(newSettings);
};
const getMessageValue = (type) => {
const currentMessage = welcomeLeaveSettings[type].message;
const defaultMessages = type === 'welcome' ? defaultWelcomeMessages : defaultLeaveMessages;
if (defaultMessages.includes(currentMessage)) {
return currentMessage;
}
return 'custom';
}
const handleInviteBot = () => {
if (!clientId) return;
const permissions = 8; // Administrator
const url = `https://discord.com/api/oauth2/authorize?client_id=${clientId}&permissions=${permissions}&scope=bot%20applications.commands&guild_id=${guildId}&disable_guild_select=true`;
window.open(url, '_blank');
};
const handleLeaveBot = () => {
setDialogOpen(true);
};
const handleConfirmLeave = async () => {
try {
await axios.post(`${API_BASE}/api/servers/${guildId}/leave`);
setIsBotInServer(false);
} catch (error) {
console.error('Error leaving server:', error);
}
setDialogOpen(false);
};
const handleBack = () => {
navigate('/dashboard');
}
return (
<div style={{ padding: '20px' }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<IconButton onClick={handleBack} sx={{ borderRadius: '50%', boxShadow: '0 8px 16px 0 rgba(0,0,0,0.2)' }}>
<ArrowBackIcon />
</IconButton>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Typography variant="h4" component="h1" sx={{ margin: 0 }}>
{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" onClick={handleInviteBot} disabled={!clientId}>
Invite Bot
</Button>
</>
)}
</Box>
{/* UserSettings moved to NavBar */}
</Box>
<Accordion sx={{ marginTop: '20px', opacity: isBotInServer ? 1 : 0.5 }} expanded={commandsExpanded} onChange={() => setCommandsExpanded(prev => !prev)}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="h6">Commands</Typography>
</AccordionSummary>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', padding: 1 }}>
<Button variant="text" onClick={() => navigate(`/server/${guildId}/help`)} disabled={!isBotInServer}>Commands List</Button>
</Box>
<AccordionDetails>
{!isBotInServer && <Typography>Invite the bot to enable commands.</Typography>}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, marginTop: '10px' }}>
{/** Render protected commands first in a fixed order **/}
{(() => {
const protectedOrder = ['help', 'manage-commands'];
const protectedCmds = protectedOrder.map(name => commandsList.find(c => c.name === name)).filter(Boolean);
const otherCmds = (commandsList || []).filter(c => !protectedOrder.includes(c.name)).sort((a,b) => a.name.localeCompare(b.name, undefined, {sensitivity: 'base'}));
return (
<>
{protectedCmds.map(cmd => (
<Box key={cmd.name} sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: 1, border: '1px solid #eee', borderRadius: 1 }}>
<Box>
<Typography sx={{ fontWeight: 'bold' }}>{cmd.name}</Typography>
<Typography variant="body2">{cmd.description}</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<FormControlLabel control={<Switch checked={true} disabled />} label="Locked" />
</Box>
</Box>
))}
{otherCmds.map(cmd => (
<Box key={cmd.name} sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: 1, border: '1px solid #eee', borderRadius: 1 }}>
<Box>
<Typography sx={{ fontWeight: 'bold' }}>{cmd.name}</Typography>
<Typography variant="body2">{cmd.description}</Typography>
</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(`${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>
))}
</>
);
})()}
</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 />}>
<Typography variant="h6">Invites</Typography>
</AccordionSummary>
<AccordionDetails>
{!isBotInServer && <Typography>Invite features require the bot to be in the server.</Typography>}
<Box sx={{ display: 'flex', gap: 2, flexDirection: { xs: 'column', sm: 'row' }, marginTop: 1 }}>
<Box sx={{ width: { xs: '100%', sm: '40%' } }}>
<Typography variant="subtitle2" sx={{ mb: 1 }}>Channel (optional)</Typography>
<FormControl fullWidth>
<Select value={inviteForm.channelId} onChange={(e) => setInviteForm(f => ({ ...f, channelId: e.target.value }))} displayEmpty>
<MenuItem value="">(Any channel)</MenuItem>
{channels.map(ch => (<MenuItem key={ch.id} value={ch.id}>{ch.name}</MenuItem>))}
</Select>
</FormControl>
</Box>
<Box sx={{ width: { xs: '100%', sm: '20%' } }}>
<Typography variant="subtitle2" sx={{ mb: 1 }}>Expiry</Typography>
<FormControl fullWidth>
<Select value={inviteForm.maxAge} onChange={(e) => setInviteForm(f => ({ ...f, maxAge: Number(e.target.value) }))}>
<MenuItem value={0}>Never expire</MenuItem>
<MenuItem value={3600}>1 hour</MenuItem>
<MenuItem value={86400}>1 day</MenuItem>
<MenuItem value={604800}>7 days</MenuItem>
</Select>
</FormControl>
</Box>
<Box sx={{ width: { xs: '100%', sm: '20%' } }}>
<Typography variant="subtitle2" sx={{ mb: 1 }}>Max Uses</Typography>
<FormControl fullWidth>
<Select value={inviteForm.maxUses} onChange={(e) => setInviteForm(f => ({ ...f, maxUses: Number(e.target.value) }))}>
<MenuItem value={0}>Unlimited</MenuItem>
<MenuItem value={1}>1</MenuItem>
<MenuItem value={5}>5</MenuItem>
<MenuItem value={10}>10</MenuItem>
</Select>
</FormControl>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, width: { xs: '100%', sm: '20%' } }}>
<Box sx={{ width: '100%' }}>
<Typography variant="subtitle2" sx={{ mb: 1 }}>Temporary</Typography>
<FormControlLabel control={<Switch checked={inviteForm.temporary} onChange={(e) => setInviteForm(f => ({ ...f, temporary: e.target.checked }))} />} label="" />
</Box>
</Box>
<Box sx={{ display: 'flex', alignItems: 'flex-end' }}>
<Button variant="contained" onClick={async () => {
try {
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);
}
}} disabled={!isBotInServer}>Create Invite</Button>
</Box>
</Box>
<Box sx={{ marginTop: 2 }}>
{invites.length === 0 && <Typography>No invites created by the bot.</Typography>}
{invites.map(inv => (
<Box key={inv.code} sx={{ border: '1px solid #eee', borderRadius: 1, padding: 1, marginTop: 1, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Box>
<Typography>{inv.url}</Typography>
<Typography variant="caption">Created: {new Date(inv.createdAt).toLocaleString()} Uses: {inv.uses || 0} MaxUses: {inv.maxUses || 0} MaxAge(s): {inv.maxAge || 0} Temporary: {inv.temporary ? 'Yes' : 'No'}</Typography>
</Box>
<Box>
<Button startIcon={<ContentCopyIcon />} onClick={async () => {
// robust clipboard copy with fallback
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(inv.url);
} else {
// fallback for older browsers
const input = document.createElement('input');
input.value = inv.url;
document.body.appendChild(input);
input.select();
document.execCommand('copy');
document.body.removeChild(input);
}
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);
}
}}>Copy</Button>
<Button startIcon={<DeleteIcon />} color="error" disabled={!!deleting[inv.code]} onClick={() => {
// open confirm dialog for this invite
setPendingDeleteInvite(inv);
setConfirmOpen(true);
}}>Delete</Button>
</Box>
</Box>
))}
</Box>
</AccordionDetails>
</Accordion>
{/* Help moved to dedicated Help page */}
<Accordion sx={{ marginTop: '20px', opacity: isBotInServer ? 1 : 0.5 }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="h6">Welcome/Leave</Typography>
</AccordionSummary>
<AccordionDetails>
{!isBotInServer && <Typography>Invite the bot to enable this feature.</Typography>}
<Box sx={{ marginTop: '10px' }}>
<Typography variant="subtitle1">Welcome Messages</Typography>
<FormControlLabel
control={<Switch checked={welcomeLeaveSettings.welcome.enabled} onChange={handleToggleChange('welcome')} disabled={!isBotInServer} />}
label="Enable Welcome Messages"
/>
<FormControl fullWidth sx={{ marginTop: '10px' }} disabled={!isBotInServer || !welcomeLeaveSettings.welcome.enabled}>
<Select
value={welcomeLeaveSettings.welcome.channel}
onChange={handleChannelChange('welcome')}
displayEmpty
>
<MenuItem value="" disabled>Select a channel</MenuItem>
{channels.map(channel => (
<MenuItem key={channel.id} value={channel.id}>{channel.name}</MenuItem>
))}
</Select>
</FormControl>
<FormControl component="fieldset" sx={{ marginTop: '10px' }} disabled={!isBotInServer || !welcomeLeaveSettings.welcome.enabled}>
<RadioGroup
value={getMessageValue('welcome')}
onChange={handleMessageOptionChange('welcome')}
>
{defaultWelcomeMessages.map(msg => (
<FormControlLabel key={msg} value={msg} control={<Radio />} label={msg} />
))}
<FormControlLabel value="custom" control={<Radio />} label="Custom" />
</RadioGroup>
</FormControl>
<Box sx={{ display: 'flex', alignItems: 'center', marginTop: '10px' }} >
<TextField
fullWidth
variant="outlined"
placeholder="Your custom message"
value={welcomeLeaveSettings.welcome.customMessage}
onChange={handleCustomMessageChange('welcome')}
disabled={!isBotInServer || !welcomeLeaveSettings.welcome.enabled || getMessageValue('welcome') !== 'custom'}
/>
<Button onClick={handleApplyCustomMessage('welcome')} disabled={!isBotInServer || !welcomeLeaveSettings.welcome.enabled || getMessageValue('welcome') !== 'custom'}>Apply</Button>
</Box>
</Box>
<Box sx={{ marginTop: '20px' }}>
<Typography variant="subtitle1">Leave Messages</Typography>
<FormControlLabel
control={<Switch checked={welcomeLeaveSettings.leave.enabled} onChange={handleToggleChange('leave')} disabled={!isBotInServer} />}
label="Enable Leave Messages"
/>
<FormControl fullWidth sx={{ marginTop: '10px' }} disabled={!isBotInServer || !welcomeLeaveSettings.leave.enabled}>
<Select
value={welcomeLeaveSettings.leave.channel}
onChange={handleChannelChange('leave')}
displayEmpty
>
<MenuItem value="" disabled>Select a channel</MenuItem>
{channels.map(channel => (
<MenuItem key={channel.id} value={channel.id}>{channel.name}</MenuItem>
))}
</Select>
</FormControl>
<FormControl component="fieldset" sx={{ marginTop: '10px' }} disabled={!isBotInServer || !welcomeLeaveSettings.leave.enabled}>
<RadioGroup
value={getMessageValue('leave')}
onChange={handleMessageOptionChange('leave')}
>
{defaultLeaveMessages.map(msg => (
<FormControlLabel key={msg} value={msg} control={<Radio />} label={msg} />
))}
<FormControlLabel value="custom" control={<Radio />} label="Custom" />
</RadioGroup>
</FormControl>
<Box sx={{ display: 'flex', alignItems: 'center', marginTop: '10px' }} >
<TextField
fullWidth
variant="outlined"
placeholder="Your custom message"
value={welcomeLeaveSettings.leave.customMessage}
onChange={handleCustomMessageChange('leave')}
disabled={!isBotInServer || !welcomeLeaveSettings.leave.enabled || getMessageValue('leave') !== 'custom'}
/>
<Button onClick={handleApplyCustomMessage('leave')} disabled={!isBotInServer || !welcomeLeaveSettings.leave.enabled || getMessageValue('leave') !== 'custom'}>Apply</Button>
</Box>
</Box>
</AccordionDetails>
</Accordion>
{/* Live Notifications Accordion */}
<Accordion sx={{ marginTop: '20px', opacity: isBotInServer ? 1 : 0.5 }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="h6">Live Notifications</Typography>
</AccordionSummary>
<AccordionDetails>
{!isBotInServer && <Typography>Invite the bot to enable this feature.</Typography>}
<Box sx={{ marginTop: '10px' }}>
<FormControl fullWidth 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) { console.error('Failed to add twitch user', err); }
}} disabled={!isBotInServer}>Add</Button>
</Box>
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle2">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 }}>
<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>
<FormControlLabel control={<Switch checked={liveEnabled} onChange={(e) => setLiveEnabled(e.target.checked)} />} label="Enabled" sx={{ mt: 2 }} />
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 2 }}>
<Button variant="contained" onClick={async () => {
try {
await axios.post(`${API_BASE}/api/servers/${guildId}/live-notifications`, { enabled: liveEnabled, twitchUser: '', channelId: liveChannelId });
} catch (err) { console.error('Failed to save live settings', err); }
}} disabled={!isBotInServer}>Save</Button>
</Box>
</Box>
</AccordionDetails>
</Accordion>
<Accordion sx={{ marginTop: '20px', opacity: isBotInServer ? 1 : 0.5 }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="h6">Autorole</Typography>
</AccordionSummary>
<AccordionDetails>
{!isBotInServer && <Typography>Invite the bot to enable this feature.</Typography>}
<Box sx={{ marginTop: '10px' }}>
<FormControlLabel
control={<Switch checked={autoroleSettings.enabled} onChange={handleAutoroleToggleChange} disabled={!isBotInServer} />}
label="Enable Autorole"
/>
<FormControl fullWidth sx={{ marginTop: '10px' }} disabled={!isBotInServer || !autoroleSettings.enabled}>
<Select
value={autoroleSettings.roleId}
onChange={handleAutoroleRoleChange}
displayEmpty
>
<MenuItem value="" disabled>Select a role</MenuItem>
{roles.map(role => (
<MenuItem key={role.id} value={role.id}><span style={{ color: role.color }}>{role.name}</span></MenuItem>
))}
</Select>
</FormControl>
</Box>
</AccordionDetails>
</Accordion>
<Accordion sx={{ marginTop: '20px', opacity: isBotInServer ? 1 : 0.5 }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="h6">Admin Commands</Typography>
</AccordionSummary>
<AccordionDetails>
<Typography>Coming soon...</Typography>
</AccordionDetails>
</Accordion>
<ConfirmDialog
open={dialogOpen}
onClose={() => setDialogOpen(false)}
onConfirm={handleConfirmLeave}
title="Confirm Leave"
message={`Are you sure you want the bot to leave ${server?.name}?`}
/>
{/* Confirm dialog for invite deletion */}
<ConfirmDialog
open={confirmOpen}
onClose={() => { setConfirmOpen(false); setPendingDeleteInvite(null); }}
onConfirm={async () => {
// perform deletion for pendingDeleteInvite
if (!pendingDeleteInvite) {
setConfirmOpen(false);
return;
}
const code = pendingDeleteInvite.code;
setConfirmOpen(false);
setDeleting(prev => ({ ...prev, [code]: true }));
try {
// fetch token (one retry)
let token = null;
try {
const tokenResp = await axios.get(`${API_BASE}/api/servers/${guildId}/invite-token`);
token = tokenResp && tokenResp.data && tokenResp.data.token;
} catch (tErr) {
try {
const tokenResp2 = await axios.get(`${API_BASE}/api/servers/${guildId}/invite-token`);
token = tokenResp2 && tokenResp2.data && tokenResp2.data.token;
} catch (tErr2) {
throw new Error('Failed to obtain delete token from server');
}
}
if (!token) throw new Error('No delete token received from server');
await axios.delete(`${API_BASE}/api/servers/${guildId}/invites/${code}`, { headers: { 'x-invite-token': token } });
setInvites(prev => prev.filter(i => i.code !== code));
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);
} finally {
setDeleting(prev => {
const copy = { ...prev };
delete copy[pendingDeleteInvite?.code];
return copy;
});
setPendingDeleteInvite(null);
}
}}
title="Delete Invite"
message={`Are you sure you want to delete invite ${pendingDeleteInvite ? pendingDeleteInvite.url : ''}?`}
/>
{/* 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) {
console.error('Failed to delete twitch user', 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?`}
/>
<Snackbar open={snackbarOpen} autoHideDuration={4000} onClose={handleCloseSnackbar}>
<Alert onClose={handleCloseSnackbar} severity="info" sx={{ width: '100%' }}>
{snackbarMessage}
</Alert>
</Snackbar>
</div>
);
};
export default ServerSettings;

View File

@@ -0,0 +1,32 @@
import React from 'react';
import { Box, Typography } from '@mui/material';
const Footer = () => {
return (
<Box
component="footer"
sx={{
py: { xs: 1, sm: 2 },
px: { xs: 1, sm: 2 },
backgroundColor: (theme) =>
theme.palette.mode === 'light'
? theme.palette.grey[200]
: theme.palette.grey[800],
textAlign: 'center',
borderTop: (theme) => `1px solid ${theme.palette.divider}`,
}}
>
<Typography
variant="body2"
color="text.secondary"
sx={{
fontSize: { xs: '0.75rem', sm: '0.875rem' },
}}
>
© ehchadservices.com 2025
</Typography>
</Box>
);
};;
export default Footer;

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 React, { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import axios from 'axios'; import { get } from '../../lib/api';
import { Box, IconButton, Typography } from '@mui/material'; import { Box, IconButton, Typography } from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import ArrowBackIcon from '@mui/icons-material/ArrowBack';
@@ -10,9 +10,7 @@ const HelpPage = () => {
const [commands, setCommands] = useState([]); const [commands, setCommands] = useState([]);
useEffect(() => { useEffect(() => {
const API_BASE = process.env.REACT_APP_API_BASE || ''; get(`/api/servers/${guildId}/commands`).then(res => {
axios.get(`${API_BASE}/api/servers/${guildId}/commands`)
.then(res => {
const cmds = res.data || []; const cmds = res.data || [];
// sort: locked commands first (preserve relative order), then others alphabetically // sort: locked commands first (preserve relative order), then others alphabetically
const locked = cmds.filter(c => c.locked); const locked = cmds.filter(c => c.locked);
@@ -28,10 +26,10 @@ const HelpPage = () => {
} }
return ( return (
<div style={{ padding: 20 }}> <Box sx={{ padding: { xs: 2, sm: 3, md: 5 } }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: { xs: 1, sm: 2 }, mb: 2 }}>
<IconButton onClick={handleBack}><ArrowBackIcon /></IconButton> <IconButton onClick={handleBack}><ArrowBackIcon /></IconButton>
<Typography variant="h5">Commands List</Typography> <Typography variant={{ xs: 'h5', sm: 'h5' }}>Commands List</Typography>
</Box> </Box>
<Box sx={{ marginTop: 2 }}> <Box sx={{ marginTop: 2 }}>
{commands.length === 0 && <Typography>No commands available.</Typography>} {commands.length === 0 && <Typography>No commands available.</Typography>}
@@ -45,7 +43,7 @@ const HelpPage = () => {
</Box> </Box>
))} ))}
</Box> </Box>
</div> </Box>
); );
} }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,101 @@
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.addEventListener('adminLogAdded', forward('adminLogAdded'));
es.addEventListener('adminLogDeleted', forward('adminLogDeleted'));
es.addEventListener('adminLogsCleared', forward('adminLogsCleared'));
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 { ThemeProvider as MuiThemeProvider } from '@mui/material/styles';
import { lightTheme, darkTheme, discordTheme } from '../themes'; import { lightTheme, darkTheme, discordTheme } from '../themes';
import { UserContext } from './UserContext'; import { UserContext } from './UserContext';
import axios from 'axios'; import { post } from '../lib/api';
export const ThemeContext = createContext(); export const ThemeContext = createContext();
@@ -45,7 +45,7 @@ export const ThemeProvider = ({ children }) => {
const changeTheme = (name) => { const changeTheme = (name) => {
if (user) { if (user) {
axios.post(`${process.env.REACT_APP_API_BASE || ''}/api/user/theme`, { userId: user.id, theme: name }); post('/api/user/theme', { userId: user.id, theme: name }).catch(() => {});
} }
localStorage.setItem('themeName', name); localStorage.setItem('themeName', name);
setThemeName(name); setThemeName(name);

View File

@@ -11,3 +11,28 @@ code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace; monospace;
} }
/* Global responsive styles */
#root {
max-width: 100vw;
overflow-x: hidden;
}
/* Ensure content doesn't overflow on ultra-wide screens */
* {
box-sizing: border-box;
}
/* Responsive typography adjustments */
@media (max-width: 600px) {
body {
font-size: 14px;
}
}
@media (min-width: 1200px) {
/* Ultra-wide screen adjustments */
.MuiContainer-root {
max-width: 1200px !important;
}
}

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;

View File

@@ -3,6 +3,13 @@ import { createTheme } from '@mui/material/styles';
export const lightTheme = createTheme({ export const lightTheme = createTheme({
palette: { palette: {
mode: 'light', mode: 'light',
background: {
default: '#e8e8e8', // More greyish background, less bright white
paper: '#ffffff',
},
primary: {
main: '#1565c0', // Slightly darker blue for less brightness
},
}, },
}); });

6
package-lock.json generated Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "ECS-FullStack",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}