swapped to a new db locally hosted
This commit is contained in:
51
CHANGELOG.md
Normal file
51
CHANGELOG.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project are documented below. This file follows a lightweight "Keep a Changelog" style.
|
||||||
|
|
||||||
|
## 2025-10-05 — New features & changes
|
||||||
|
### Added
|
||||||
|
- Postgres persistence support for the backend (requires `DATABASE_URL`). Backend will auto-create `servers`, `invites`, and `users` tables on startup.
|
||||||
|
- `backend/pg.js` helper module to manage Postgres connection and CRUD helpers.
|
||||||
|
- `backend/.env.example` updated to show `DATABASE_URL` with a Tailscale IP example (100.111.50.59).
|
||||||
|
- Short-lived invite token flow (HMAC) for delete authorization (default, via `INVITE_TOKEN_SECRET`).
|
||||||
|
- Frontend invite UI fixes (copy/delete handlers, confirmation dialog) and various UI polish updates.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Backend now requires `DATABASE_URL` by default; no automatic fallback to encrypted `db.json` for persistence.
|
||||||
|
- `INVITE_API_KEY` static option deprecated in favor of short-lived invite tokens; token issuance endpoint remains unauthenticated (recommendation: restrict in production).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Async/await handler fixes and small bug fixes across backend endpoints.
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
- Existing encrypted `backend/db.json` is retained but no longer used by the running backend. A migration script to import old data into Postgres is planned but not yet implemented.
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- README updated with setup steps, Postgres guidance, migration note, and expanded app description.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Live Notifications: per-guild Twitch live notification settings (backend endpoints, frontend UI, and bot command to configure). Stored in server settings under `liveNotifications`.
|
||||||
|
|
||||||
|
## 2025-10-06 — Improvements & housekeeping
|
||||||
|
### Changed
|
||||||
|
- Backend now enforces `DATABASE_URL` as the primary persistence store. If `DATABASE_URL` is unset the server will refuse to start. This ensures all server settings and invites are stored in Postgres consistently.
|
||||||
|
- Server-Sent Events (SSE) endpoint `/api/events` added to allow the frontend to receive real-time updates (command toggles, twitch users updates, live-notifications updates) without refresh.
|
||||||
|
- `discord-bot/twitch-watcher.js` logging reduced to avoid per-guild spam; announcements are concise and errors still reported.
|
||||||
|
- Command deployment logs are aggregated (single summary line) to avoid flooding logs with per-guild messages.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `/internal/test-live` dev endpoint (backend) & `announceLive` bot helper to force live announcements for debugging.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Frontend `ServerSettings.js` now subscribes to SSE and updates commands, watched users and live settings in real-time when backend publishes events.
|
||||||
|
- Help command updated to produce a neat Embed and support `/help <command>` for detailed usage; help lists read from backend to remain future-proof.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## [Previous] - 2025-10-04
|
||||||
|
### Added
|
||||||
|
- Initial full-stack integration (React frontend, Express backend, Discord bot) and file-based encrypted persistence.
|
||||||
|
- Dashboard and server settings UI components.
|
||||||
|
- Invite creation, listing and deletion endpoints and UI.
|
||||||
|
|
||||||
|
---
|
||||||
126
README.md
126
README.md
@@ -4,6 +4,8 @@ A full-stack example project that integrates a React frontend, an Express backen
|
|||||||
|
|
||||||
This README documents how to get the project running, what environment variables are required, where to get Discord keys, how the invite token flow works, and basic troubleshooting tips.
|
This README documents how to get the project running, what environment variables are required, where to get Discord keys, how the invite token flow works, and basic troubleshooting tips.
|
||||||
|
|
||||||
|
Note: The backend has been updated to support Postgres persistence (see `CHANGELOG.md`). The backend now requires `DATABASE_URL` to run in the default configuration; if you prefer the legacy encrypted file store, see the notes under "Developer notes".
|
||||||
|
|
||||||
## Repository layout
|
## Repository layout
|
||||||
|
|
||||||
- `frontend/` — React (Create React App) frontend. Uses `REACT_APP_API_BASE` to communicate with the backend in dev and production.
|
- `frontend/` — React (Create React App) frontend. Uses `REACT_APP_API_BASE` to communicate with the backend in dev and production.
|
||||||
@@ -17,6 +19,16 @@ This README documents how to get the project running, what environment variables
|
|||||||
- Runs a Discord bot (discord.js) that performs moderation and server features. The backend and bot are closely integrated: the backend hosts the API and the bot client is shared to fetch guild data and manipulate invites/channels/roles.
|
- Runs a Discord bot (discord.js) that performs moderation and server features. The backend and bot are closely integrated: the backend hosts the API and the bot client is shared to fetch guild data and manipulate invites/channels/roles.
|
||||||
- Uses a short-lived token flow to authorize invite deletions from the frontend without embedding long-lived secrets in the client.
|
- Uses a short-lived token flow to authorize invite deletions from the frontend without embedding long-lived secrets in the client.
|
||||||
|
|
||||||
|
Expanded: what this app does
|
||||||
|
|
||||||
|
- Hosts a dashboard (React) that lists Discord guilds where the bot is present and lets server admins:
|
||||||
|
- create and manage invites (create invites with options, view persisted invites, copy and revoke)
|
||||||
|
- configure Welcome and Leave messages and channels
|
||||||
|
- enable/disable bot commands per server
|
||||||
|
- set autorole behavior for new members
|
||||||
|
- Provides a backend API (Express) that coordinates with a discord.js bot to perform live guild operations (fetch channels/roles, create invites, leave guilds)
|
||||||
|
- Stores configuration and invites in Postgres (recommended) or a legacy encrypted `db.json`
|
||||||
|
|
||||||
## Quickstart — prerequisites
|
## Quickstart — prerequisites
|
||||||
|
|
||||||
- Node.js (recommended 18.x or later) and npm
|
- Node.js (recommended 18.x or later) and npm
|
||||||
@@ -39,6 +51,9 @@ DISCORD_CLIENT_SECRET=your_discord_client_secret
|
|||||||
ENCRYPTION_KEY=a-32-byte-or-longer-secret
|
ENCRYPTION_KEY=a-32-byte-or-longer-secret
|
||||||
INVITE_TOKEN_SECRET=optional-second-secret-for-invite-tokens
|
INVITE_TOKEN_SECRET=optional-second-secret-for-invite-tokens
|
||||||
|
|
||||||
|
# Postgres example (optional but recommended)
|
||||||
|
# DATABASE_URL=postgres://dbuser:dbpass@100.111.50.59:5432/ecs_db
|
||||||
|
|
||||||
- `PORT` / `HOST`: where the backend listens.
|
- `PORT` / `HOST`: where the backend listens.
|
||||||
- `BACKEND_BASE` and `FRONTEND_BASE`: used for constructing OAuth redirect URIs and links.
|
- `BACKEND_BASE` and `FRONTEND_BASE`: used for constructing OAuth redirect URIs and links.
|
||||||
- `CORS_ORIGIN`: optional; set to your frontend origin to restrict CORS.
|
- `CORS_ORIGIN`: optional; set to your frontend origin to restrict CORS.
|
||||||
@@ -47,6 +62,27 @@ INVITE_TOKEN_SECRET=optional-second-secret-for-invite-tokens
|
|||||||
|
|
||||||
Note: This project previously supported an `INVITE_API_KEY` static secret; that requirement has been removed. Invite deletes are authorized via short-lived invite tokens by default.
|
Note: This project previously supported an `INVITE_API_KEY` static secret; that requirement has been removed. Invite deletes are authorized via short-lived invite tokens by default.
|
||||||
|
|
||||||
|
### Twitch Live Notifications (optional)
|
||||||
|
|
||||||
|
This project can detect when watched Twitch users go live and post notifications to a configured Discord channel for each guild. To enable this feature, add the following to `backend/.env`:
|
||||||
|
|
||||||
|
- `TWITCH_CLIENT_ID` — your Twitch app client id
|
||||||
|
- `TWITCH_CLIENT_SECRET` — your Twitch app client secret
|
||||||
|
- `TWITCH_POLL_INTERVAL_MS` — optional, poll interval in milliseconds (default 30000)
|
||||||
|
|
||||||
|
When configured, the backend exposes:
|
||||||
|
|
||||||
|
- GET /api/twitch/streams?users=user1,user2 — returns stream info for the listed usernames (used by the frontend and bot watcher)
|
||||||
|
|
||||||
|
The bot includes a watcher that polls watched usernames per-guild and posts a message to the configured channel when a streamer goes live. The message includes the stream title and a link to the Twitch stream.
|
||||||
|
|
||||||
|
If you run the backend and the bot on separate hosts, you can configure the backend to push setting updates to the bot so toggles and watched users propagate immediately:
|
||||||
|
|
||||||
|
- `BOT_PUSH_URL` — the URL the bot will expose for the backend to POST setting updates to (e.g., http://bot-host:4002)
|
||||||
|
- `BOT_SECRET` — a shared secret used by the backend and bot to secure push requests
|
||||||
|
- `BOT_PUSH_PORT` — optional, the port the bot listens on for push requests (if set the bot starts a small HTTP receiver)
|
||||||
|
|
||||||
|
|
||||||
### frontend/.env (example)
|
### frontend/.env (example)
|
||||||
|
|
||||||
HOST=0.0.0.0
|
HOST=0.0.0.0
|
||||||
@@ -90,6 +126,16 @@ npm install
|
|||||||
npm start
|
npm start
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Optional: using Postgres (recommended)
|
||||||
|
|
||||||
|
1. Create a Postgres database and user (pgAdmin or psql)
|
||||||
|
2. Set `DATABASE_URL` in `backend/.env`, e.g.:
|
||||||
|
DATABASE_URL=postgres://dbuser:dbpass@100.111.50.59:5432/ecs_db
|
||||||
|
3. Start the backend; on startup the backend will create simple tables if missing.
|
||||||
|
|
||||||
|
Migration note:
|
||||||
|
- If you have existing data in `backend/db.json`, a migration script is planned to import invites and server settings into Postgres. I can add that script on request.
|
||||||
|
|
||||||
2. Frontend
|
2. Frontend
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
@@ -106,6 +152,7 @@ npm run start
|
|||||||
## 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.
|
- Backend refuses to start or missing package.json: ensure you run `npm install` in the `backend` folder and run `npm start` from that folder.
|
||||||
|
- If the backend exits with "DATABASE_URL is not set": either set `DATABASE_URL` in `backend/.env` pointing to your Postgres DB, or restore the legacy behavior by editing `backend/index.js` to re-enable the encrypted `db.json` fallback (not recommended for production).
|
||||||
- CORS errors: verify `CORS_ORIGIN` and `REACT_APP_API_BASE` match your frontend origin.
|
- CORS errors: verify `CORS_ORIGIN` and `REACT_APP_API_BASE` match your frontend origin.
|
||||||
- Invite delete unauthorized: ensure backend `INVITE_TOKEN_SECRET` or `ENCRYPTION_KEY` is present and token TTL has not expired. Check the backend logs for validation details.
|
- Invite delete unauthorized: ensure backend `INVITE_TOKEN_SECRET` or `ENCRYPTION_KEY` is present and token TTL has not expired. Check the backend logs for validation details.
|
||||||
- Token issues: clock skew can cause tokens to appear expired — ensure server and client clocks are reasonably in sync.
|
- Token issues: clock skew can cause tokens to appear expired — ensure server and client clocks are reasonably in sync.
|
||||||
@@ -120,8 +167,87 @@ npm run start
|
|||||||
|
|
||||||
- Harden `/api/servers/:guildId/invite-token` to require an authenticated user and verify the user has admin permissions for the guild.
|
- Harden `/api/servers/:guildId/invite-token` to require an authenticated user and verify the user has admin permissions for the guild.
|
||||||
- Add rate-limiting to token issuance and optionally keep the old `INVITE_API_KEY` option for server-to-server automation.
|
- 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.
|
If you want, I can add step-by-step instructions to create the `.env` files from templates, or implement the production safe option of authenticating `/invite-token` requests. Tell me which you'd prefer.
|
||||||
|
|
||||||
---
|
---
|
||||||
Updated: Oct 4, 2025
|
Updated: Oct 4, 2025
|
||||||
|
|
||||||
|
## Full setup guide (detailed)
|
||||||
|
|
||||||
|
This section walks through the exact steps to get the project running locally or on a machine reachable via Tailscale/Nginx Proxy Manager.
|
||||||
|
|
||||||
|
Prerequisites
|
||||||
|
1. Node.js 18+ and npm
|
||||||
|
2. Postgres (local or remote) or use an existing Postgres server reachable over your network/Tailscale
|
||||||
|
3. Discord application + Bot credentials and (optional) Twitch app credentials
|
||||||
|
|
||||||
|
Database (Postgres) setup
|
||||||
|
1. Create a Postgres database and user. Example psql commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo -u postgres psql
|
||||||
|
CREATE DATABASE ecs_fullstack;
|
||||||
|
CREATE USER ecs_user WITH PASSWORD 'supersecret';
|
||||||
|
GRANT ALL PRIVILEGES ON DATABASE ecs_fullstack TO ecs_user;
|
||||||
|
\q
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Set the `DATABASE_URL` in `backend/.env`:
|
||||||
|
|
||||||
|
```
|
||||||
|
DATABASE_URL=postgres://ecs_user:supersecret@127.0.0.1:5432/ecs_fullstack
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Start the backend (it will run migrations / ensure tables at startup):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd backend
|
||||||
|
npm install
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
Backend configuration (.env)
|
||||||
|
- `DATABASE_URL` - required for Postgres persistence
|
||||||
|
- `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET`, `DISCORD_BOT_TOKEN` - from Discord Developer Portal
|
||||||
|
- `FRONTEND_BASE` - public frontend URL (used for OAuth redirect)
|
||||||
|
- `PORT`, `HOST` - where backend listens
|
||||||
|
- `CORS_ORIGIN` - optional restrict origin to your frontend URL
|
||||||
|
- `TWITCH_CLIENT_ID`, `TWITCH_CLIENT_SECRET` - optional for Twitch integration
|
||||||
|
|
||||||
|
Frontend configuration
|
||||||
|
1. In `frontend/.env` set:
|
||||||
|
|
||||||
|
```
|
||||||
|
REACT_APP_API_BASE=https://your-domain-or-ip:3002
|
||||||
|
```
|
||||||
|
|
||||||
|
2. For development behind an HTTPS domain (Nginx Proxy Manager), ensure the CRA dev client uses `wss` by setting the `WDS_SOCKET_*` variables in `frontend/.env` (see docs if using a TLS domain)
|
||||||
|
|
||||||
|
Start the frontend dev server:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
Bot behaviour and deployment
|
||||||
|
- The `backend` process will boot the Discord bot client when valid `DISCORD_BOT_TOKEN` is present. The bot registers slash commands per guild on startup and responds to backend pushes for setting updates.
|
||||||
|
- If you prefer to run the bot separately, you can run the `discord-bot` module separately; ensure `BOT_PUSH_URL`/`BOT_SECRET` are configured if backend and bot are on different hosts.
|
||||||
|
|
||||||
|
Useful endpoints
|
||||||
|
- `GET /api/servers/:guildId/commands` — returns the authoritative list of commands and per-guild enabled/locked status.
|
||||||
|
- `GET/POST /api/servers/:guildId/live-notifications` — get/update live notification settings
|
||||||
|
- `GET /api/twitch/streams?users=user1,user2` — proxy to twitch helix for streams (backend caches app-token)
|
||||||
|
- `GET /api/events?guildId=...` — Server-Sent Events for real-time updates (ServerSettings subscribes to this)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|||||||
@@ -1,3 +1,41 @@
|
|||||||
|
# Example backend/.env for ECS-FullStack
|
||||||
|
# Copy this file to backend/.env and fill values before running the backend
|
||||||
|
|
||||||
|
# Postgres connection (required)
|
||||||
|
# Example formats:
|
||||||
|
# postgres://user:password@host:5432/dbname
|
||||||
|
# postgresql://user:password@localhost:5432/dbname
|
||||||
|
DATABASE_URL=postgres://postgres:password@127.0.0.1:5432/ecs_fullstack
|
||||||
|
|
||||||
|
# Discord OAuth / Bot
|
||||||
|
DISCORD_CLIENT_ID=your_discord_client_id
|
||||||
|
DISCORD_CLIENT_SECRET=your_discord_client_secret
|
||||||
|
DISCORD_BOT_TOKEN=your_discord_bot_token
|
||||||
|
|
||||||
|
# Frontend base URL (where the frontend is served). Used for OAuth redirect and dashboard links.
|
||||||
|
FRONTEND_BASE=https://discordbot.YOURDOMAIN.com
|
||||||
|
|
||||||
|
# Host/port to bind the backend server (bind to 0.0.0.0 or your Tailscale IP as needed)
|
||||||
|
HOST=0.0.0.0
|
||||||
|
PORT=3002
|
||||||
|
|
||||||
|
# CORS origin - set to your frontend origin for tighter security (or '*' to allow all)
|
||||||
|
# Example: https://discordbot.YOURDOMAIN.com
|
||||||
|
CORS_ORIGIN=http://127.0.0.1:3001
|
||||||
|
|
||||||
|
# Twitch API (for the watcher and proxy)
|
||||||
|
TWITCH_CLIENT_ID=your_twitch_client_id
|
||||||
|
TWITCH_CLIENT_SECRET=your_twitch_client_secret
|
||||||
|
TWITCH_POLL_INTERVAL_MS=5000
|
||||||
|
|
||||||
|
# Optional bot push receiver settings - allows backend to notify a remote bot process
|
||||||
|
# BOT_PUSH_PORT if you run the bot on another host and want the backend to push settings
|
||||||
|
BOT_PUSH_PORT=
|
||||||
|
BOT_PUSH_URL=
|
||||||
|
BOT_SECRET=
|
||||||
|
|
||||||
|
# Optional logging level: debug | info | warn | error
|
||||||
|
LOG_LEVEL=info
|
||||||
# Example backend .env
|
# Example backend .env
|
||||||
# Set the host/interface to bind to (for Tailscale use your 100.x.y.z address)
|
# Set the host/interface to bind to (for Tailscale use your 100.x.y.z address)
|
||||||
HOST=0.0.0.0
|
HOST=0.0.0.0
|
||||||
@@ -10,13 +48,35 @@ FRONTEND_BASE=http://100.x.y.z:3000
|
|||||||
# CORS origin (frontend origin) - set to frontend base for tighter security
|
# CORS origin (frontend origin) - set to frontend base for tighter security
|
||||||
CORS_ORIGIN=http://100.x.y.z:3000
|
CORS_ORIGIN=http://100.x.y.z:3000
|
||||||
|
|
||||||
# Optional invite delete protection
|
# Postgres connection (replace user, password, host, port, and database name)
|
||||||
INVITE_API_KEY=replace-with-a-secret
|
# Example for your Tailscale IP 100.111.50.59:
|
||||||
|
DATABASE_URL=postgres://dbuser:dbpass@100.111.50.59:5432/ecs_db
|
||||||
|
|
||||||
|
# Invite token secret (short-lived HMAC tokens for invite delete protection)
|
||||||
|
INVITE_TOKEN_SECRET=replace-with-a-long-random-secret
|
||||||
|
|
||||||
# Discord credentials
|
# Discord credentials
|
||||||
DISCORD_CLIENT_ID=your_client_id
|
DISCORD_CLIENT_ID=your_client_id
|
||||||
DISCORD_CLIENT_SECRET=your_client_secret
|
DISCORD_CLIENT_SECRET=your_client_secret
|
||||||
DISCORD_BOT_TOKEN=your_bot_token
|
DISCORD_BOT_TOKEN=your_bot_token
|
||||||
|
|
||||||
# Encryption key for backend db.json
|
# Encryption key for backend db.json (only needed if you plan to decrypt/migrate old data)
|
||||||
ENCRYPTION_KEY=pick-a-long-random-string
|
ENCRYPTION_KEY=pick-a-long-random-string
|
||||||
|
|
||||||
|
# --- Twitch API (optional; required for Live Notifications)
|
||||||
|
# Register an application at https://dev.twitch.tv to obtain these
|
||||||
|
TWITCH_CLIENT_ID=your_twitch_client_id
|
||||||
|
TWITCH_CLIENT_SECRET=your_twitch_client_secret
|
||||||
|
# Poll interval in milliseconds for the bot watcher (default = 30000 = 30s)
|
||||||
|
TWITCH_POLL_INTERVAL_MS=30000
|
||||||
|
|
||||||
|
# --- Bot push (optional) - used when backend and bot run on different hosts
|
||||||
|
# If the bot runs on a separate host/process, set BOT_PUSH_URL to the public
|
||||||
|
# URL the bot exposes for receiving settings pushes (backend will POST there)
|
||||||
|
# Example: BOT_PUSH_URL=http://bot-host:4002
|
||||||
|
BOT_PUSH_URL=
|
||||||
|
# Shared secret used to secure backend -> bot pushes. Must match BOT_SECRET in the bot env.
|
||||||
|
BOT_SECRET=replace-with-a-long-random-secret
|
||||||
|
# When BOT_PUSH_PORT is set, the bot starts a small HTTP endpoint to accept pushes
|
||||||
|
# (only needed if bot runs separately and you want immediate pushes).
|
||||||
|
BOT_PUSH_PORT=4002
|
||||||
|
|||||||
480
backend/index.js
480
backend/index.js
@@ -21,6 +21,143 @@ app.use(express.json());
|
|||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
// Twitch API helpers (uses TWITCH_CLIENT_ID and TWITCH_CLIENT_SECRET from env)
|
||||||
|
let _twitchToken = null;
|
||||||
|
let _twitchTokenExpiry = 0;
|
||||||
|
async function getTwitchAppToken() {
|
||||||
|
if (_twitchToken && Date.now() < _twitchTokenExpiry - 60000) return _twitchToken;
|
||||||
|
const id = process.env.TWITCH_CLIENT_ID;
|
||||||
|
const secret = process.env.TWITCH_CLIENT_SECRET;
|
||||||
|
if (!id || !secret) return null;
|
||||||
|
try {
|
||||||
|
const resp = await axios.post('https://id.twitch.tv/oauth2/token', null, {
|
||||||
|
params: { client_id: id, client_secret: secret, grant_type: 'client_credentials' },
|
||||||
|
});
|
||||||
|
_twitchToken = resp.data.access_token;
|
||||||
|
const expiresIn = Number(resp.data.expires_in) || 3600;
|
||||||
|
_twitchTokenExpiry = Date.now() + expiresIn * 1000;
|
||||||
|
return _twitchToken;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch Twitch app token:', e && e.response && e.response.data ? e.response.data : e.message || e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getTwitchStreamsForUsers(usernames = []) {
|
||||||
|
try {
|
||||||
|
if (!usernames || usernames.length === 0) return [];
|
||||||
|
const token = await getTwitchAppToken();
|
||||||
|
const id = process.env.TWITCH_CLIENT_ID;
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
for (const u of usernames) params.append('user_login', u.toLowerCase());
|
||||||
|
const headers = {};
|
||||||
|
if (id) headers['Client-Id'] = id;
|
||||||
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
const url = `https://api.twitch.tv/helix/streams?${params.toString()}`;
|
||||||
|
const resp = await axios.get(url, { headers });
|
||||||
|
// resp.data.data is an array of live streams
|
||||||
|
const live = resp.data && resp.data.data ? resp.data.data : [];
|
||||||
|
// Map by user_login
|
||||||
|
// Fetch user info (bio, profile image) so we can include it in the response
|
||||||
|
const uniqueLogins = Array.from(new Set(usernames.map(u => (u || '').toLowerCase()))).filter(Boolean);
|
||||||
|
const users = {};
|
||||||
|
if (uniqueLogins.length > 0) {
|
||||||
|
const userParams = new URLSearchParams();
|
||||||
|
for (const u of uniqueLogins) userParams.append('login', u);
|
||||||
|
try {
|
||||||
|
const usersUrl = `https://api.twitch.tv/helix/users?${userParams.toString()}`;
|
||||||
|
const usersResp = await axios.get(usersUrl, { headers });
|
||||||
|
const usersData = usersResp.data && usersResp.data.data ? usersResp.data.data : [];
|
||||||
|
for (const u of usersData) {
|
||||||
|
users[(u.login || '').toLowerCase()] = {
|
||||||
|
id: u.id,
|
||||||
|
login: u.login,
|
||||||
|
display_name: u.display_name,
|
||||||
|
description: u.description,
|
||||||
|
profile_image_url: u.profile_image_url,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// ignore user fetch errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// collect game ids from live streams to resolve game names
|
||||||
|
const gameIds = Array.from(new Set((live || []).map(s => s.game_id).filter(Boolean)));
|
||||||
|
const games = {};
|
||||||
|
if (gameIds.length > 0) {
|
||||||
|
try {
|
||||||
|
const gamesParams = new URLSearchParams();
|
||||||
|
for (const idv of gameIds) gamesParams.append('id', idv);
|
||||||
|
const gamesUrl = `https://api.twitch.tv/helix/games?${gamesParams.toString()}`;
|
||||||
|
const gamesResp = await axios.get(gamesUrl, { headers });
|
||||||
|
const gamesData = gamesResp.data && gamesResp.data.data ? gamesResp.data.data : [];
|
||||||
|
for (const g of gamesData) {
|
||||||
|
games[g.id] = g.name;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build response for every requested username (include user info even when offline)
|
||||||
|
const map = {};
|
||||||
|
for (const u of uniqueLogins) {
|
||||||
|
map[u] = {
|
||||||
|
is_live: false,
|
||||||
|
user_login: u,
|
||||||
|
user_name: (users[u] && users[u].display_name) || u,
|
||||||
|
title: null,
|
||||||
|
viewer_count: 0,
|
||||||
|
started_at: null,
|
||||||
|
url: `https://www.twitch.tv/${u}`,
|
||||||
|
thumbnail_url: null,
|
||||||
|
description: (users[u] && users[u].description) || null,
|
||||||
|
profile_image_url: (users[u] && users[u].profile_image_url) || null,
|
||||||
|
game_name: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const s of live) {
|
||||||
|
const login = (s.user_login || '').toLowerCase();
|
||||||
|
// twitch returns thumbnail_url like ".../{width}x{height}.jpg" — replace with a sensible size
|
||||||
|
const rawThumb = s.thumbnail_url || null;
|
||||||
|
const thumb = rawThumb ? rawThumb.replace('{width}', '1280').replace('{height}', '720') : null;
|
||||||
|
map[login] = {
|
||||||
|
is_live: true,
|
||||||
|
user_login: s.user_login,
|
||||||
|
user_name: s.user_name,
|
||||||
|
title: s.title,
|
||||||
|
viewer_count: s.viewer_count,
|
||||||
|
started_at: s.started_at,
|
||||||
|
url: `https://www.twitch.tv/${s.user_login}`,
|
||||||
|
thumbnail_url: thumb,
|
||||||
|
description: (users[login] && users[login].description) || null,
|
||||||
|
profile_image_url: (users[login] && users[login].profile_image_url) || null,
|
||||||
|
game_name: (s.game_id && games[s.game_id]) || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.values(map);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error fetching twitch streams:', e && e.response && e.response.data ? e.response.data : e.message || e);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proxy endpoint for frontend/bot to request stream status for usernames (comma separated)
|
||||||
|
app.get('/api/twitch/streams', async (req, res) => {
|
||||||
|
const q = req.query.users || req.query.user || '';
|
||||||
|
const users = q.split(',').map(s => (s || '').trim()).filter(Boolean);
|
||||||
|
try {
|
||||||
|
const streams = await getTwitchStreamsForUsers(users);
|
||||||
|
res.json(streams);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error in /api/twitch/streams:', err);
|
||||||
|
res.status(500).json({ success: false, message: 'Internal Server Error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 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';
|
||||||
@@ -103,8 +240,14 @@ app.get('/auth/discord/callback', async (req, res) => {
|
|||||||
const adminGuilds = guildsResponse.data.filter(guild => (guild.permissions & 0x8) === 0x8);
|
const adminGuilds = guildsResponse.data.filter(guild => (guild.permissions & 0x8) === 0x8);
|
||||||
|
|
||||||
const user = userResponse.data;
|
const user = userResponse.data;
|
||||||
const db = readDb();
|
// fetch user data (theme, preferences) from Postgres
|
||||||
user.theme = db.users && db.users[user.id] ? db.users[user.id].theme : 'light';
|
try {
|
||||||
|
const udata = await (require('./pg')).getUserData(user.id);
|
||||||
|
user.theme = (udata && udata.theme) ? udata.theme : 'light';
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error fetching user data:', e);
|
||||||
|
user.theme = 'light';
|
||||||
|
}
|
||||||
const guilds = adminGuilds;
|
const guilds = adminGuilds;
|
||||||
res.redirect(`${FRONTEND_BASE}/dashboard?user=${encodeURIComponent(JSON.stringify(user))}&guilds=${encodeURIComponent(JSON.stringify(guilds))}`);
|
res.redirect(`${FRONTEND_BASE}/dashboard?user=${encodeURIComponent(JSON.stringify(user))}&guilds=${encodeURIComponent(JSON.stringify(guilds))}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -115,41 +258,117 @@ app.get('/auth/discord/callback', async (req, res) => {
|
|||||||
|
|
||||||
const { readDb, writeDb } = require('./db');
|
const { readDb, writeDb } = require('./db');
|
||||||
|
|
||||||
app.get('/api/servers/:guildId/settings', (req, res) => {
|
// Require DATABASE_URL for Postgres persistence (full transition)
|
||||||
const { guildId } = req.params;
|
if (!process.env.DATABASE_URL) {
|
||||||
const db = readDb();
|
console.error('DATABASE_URL is not set. The backend now requires a Postgres database. Set DATABASE_URL in backend/.env');
|
||||||
const settings = db[guildId] || { pingCommand: false };
|
process.exit(1);
|
||||||
res.json(settings);
|
}
|
||||||
|
const pgClient = require('./pg');
|
||||||
|
pgClient.ensureSchema().catch(err => {
|
||||||
|
console.error('Error ensuring PG schema:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
console.log('Postgres enabled for persistence');
|
||||||
|
|
||||||
|
// Simple Server-Sent Events (SSE) broadcaster
|
||||||
|
const sseClients = new Map(); // key: guildId or '*' -> array of res
|
||||||
|
function publishEvent(guildId, type, payload) {
|
||||||
|
const msg = `event: ${type}\ndata: ${JSON.stringify(payload)}\n\n`;
|
||||||
|
// send to guild-specific subscribers
|
||||||
|
const list = sseClients.get(guildId) || [];
|
||||||
|
for (const res of list.slice()) {
|
||||||
|
try { res.write(msg); } catch (e) { /* ignore write errors */ }
|
||||||
|
}
|
||||||
|
// send to global subscribers
|
||||||
|
const global = sseClients.get('*') || [];
|
||||||
|
for (const res of global.slice()) {
|
||||||
|
try { res.write(msg); } catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/api/events', (req, res) => {
|
||||||
|
const guildId = req.query.guildId || '*';
|
||||||
|
res.setHeader('Content-Type', 'text/event-stream');
|
||||||
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
|
res.setHeader('Connection', 'keep-alive');
|
||||||
|
res.flushHeaders && res.flushHeaders();
|
||||||
|
// send an initial ping
|
||||||
|
res.write(`event: connected\ndata: ${JSON.stringify({ guildId })}\n\n`);
|
||||||
|
if (!sseClients.has(guildId)) sseClients.set(guildId, []);
|
||||||
|
sseClients.get(guildId).push(res);
|
||||||
|
req.on('close', () => {
|
||||||
|
const arr = sseClients.get(guildId) || [];
|
||||||
|
const idx = arr.indexOf(res);
|
||||||
|
if (idx !== -1) arr.splice(idx, 1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/servers/:guildId/settings', (req, res) => {
|
app.get('/api/servers/:guildId/settings', async (req, res) => {
|
||||||
|
const { guildId } = req.params;
|
||||||
|
try {
|
||||||
|
const settings = await pgClient.getServerSettings(guildId);
|
||||||
|
return res.json(settings || { pingCommand: false });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching settings:', err);
|
||||||
|
res.status(500).json({ success: false, message: 'Internal Server Error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/servers/:guildId/settings', async (req, res) => {
|
||||||
const { guildId } = req.params;
|
const { guildId } = req.params;
|
||||||
const newSettings = req.body || {};
|
const newSettings = req.body || {};
|
||||||
const db = readDb();
|
try {
|
||||||
if (!db[guildId]) db[guildId] = {};
|
const existing = (await pgClient.getServerSettings(guildId)) || {};
|
||||||
// Merge incoming settings with existing settings to avoid overwriting unrelated keys
|
const merged = { ...existing, ...newSettings };
|
||||||
db[guildId] = { ...db[guildId], ...newSettings };
|
await pgClient.upsertServerSettings(guildId, merged);
|
||||||
writeDb(db);
|
return res.json({ success: true });
|
||||||
res.json({ success: true });
|
} catch (err) {
|
||||||
|
console.error('Error saving settings:', err);
|
||||||
|
res.status(500).json({ success: false, message: 'Internal Server Error' });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Toggle a single command for a guild (preserves other toggles)
|
// Toggle a single command for a guild (preserves other toggles)
|
||||||
app.post('/api/servers/:guildId/commands/:cmdName/toggle', (req, res) => {
|
app.post('/api/servers/:guildId/commands/:cmdName/toggle', async (req, res) => {
|
||||||
const { guildId, cmdName } = req.params;
|
const { guildId, cmdName } = req.params;
|
||||||
const { enabled } = req.body; // boolean
|
const { enabled } = req.body; // boolean
|
||||||
const protectedCommands = ['help', 'manage-commands'];
|
const protectedCommands = ['help', 'manage-commands'];
|
||||||
if (protectedCommands.includes(cmdName)) {
|
if (protectedCommands.includes(cmdName)) {
|
||||||
return res.status(403).json({ success: false, message: 'This command is locked and cannot be toggled.' });
|
return res.status(403).json({ success: false, message: 'This command is locked and cannot be toggled.' });
|
||||||
}
|
}
|
||||||
const db = readDb();
|
try {
|
||||||
if (!db[guildId]) db[guildId] = {};
|
if (typeof enabled !== 'boolean') return res.status(400).json({ success: false, message: 'Missing or invalid "enabled" boolean in request body' });
|
||||||
if (!db[guildId].commandToggles) db[guildId].commandToggles = {};
|
const existing = (await pgClient.getServerSettings(guildId)) || {};
|
||||||
if (typeof enabled === 'boolean') {
|
if (!existing.commandToggles) existing.commandToggles = {};
|
||||||
db[guildId].commandToggles[cmdName] = enabled;
|
existing.commandToggles[cmdName] = enabled;
|
||||||
writeDb(db);
|
await pgClient.upsertServerSettings(guildId, existing);
|
||||||
return res.json({ success: true, cmdName, enabled });
|
// notify SSE subscribers about command toggle change
|
||||||
|
try { publishEvent(guildId, 'commandToggle', { cmdName, enabled }); } catch (e) {}
|
||||||
|
try {
|
||||||
|
// if bot is loaded in same process, notify it to update cache
|
||||||
|
if (bot && bot.setGuildSettings) {
|
||||||
|
bot.setGuildSettings(guildId, existing);
|
||||||
|
}
|
||||||
|
} catch (notifyErr) {
|
||||||
|
// ignore if bot isn't accessible
|
||||||
|
}
|
||||||
|
// If a remote bot push URL is configured, notify it with the new settings
|
||||||
|
try {
|
||||||
|
const botPushUrl = process.env.BOT_PUSH_URL || null;
|
||||||
|
const botSecret = process.env.BOT_SECRET || null;
|
||||||
|
if (botPushUrl) {
|
||||||
|
const headers = { 'Content-Type': 'application/json' };
|
||||||
|
if (botSecret) headers['x-bot-secret'] = botSecret;
|
||||||
|
await axios.post(`${botPushUrl.replace(/\/$/, '')}/internal/set-settings`, { guildId, settings: existing }, { headers });
|
||||||
|
}
|
||||||
|
} catch (pushErr) {
|
||||||
|
// ignore push failures
|
||||||
|
}
|
||||||
|
return res.json({ success: true, cmdName, enabled });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error toggling command:', err);
|
||||||
|
res.status(500).json({ success: false, message: 'Internal Server Error' });
|
||||||
}
|
}
|
||||||
return res.status(400).json({ success: false, message: 'Missing or invalid "enabled" boolean in request body' });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/servers/:guildId/bot-status', (req, res) => {
|
app.get('/api/servers/:guildId/bot-status', (req, res) => {
|
||||||
@@ -166,18 +385,15 @@ app.get('/api/client-id', (req, res) => {
|
|||||||
res.json({ clientId: process.env.DISCORD_CLIENT_ID });
|
res.json({ clientId: process.env.DISCORD_CLIENT_ID });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/user/theme', (req, res) => {
|
app.post('/api/user/theme', async (req, res) => {
|
||||||
const { userId, theme } = req.body;
|
const { userId, theme } = req.body;
|
||||||
const db = readDb();
|
try {
|
||||||
if (!db.users) {
|
await pgClient.upsertUserData(userId, { theme });
|
||||||
db.users = {};
|
|
||||||
}
|
|
||||||
if (!db.users[userId]) {
|
|
||||||
db.users[userId] = {};
|
|
||||||
}
|
|
||||||
db.users[userId].theme = theme;
|
|
||||||
writeDb(db);
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error saving user theme:', err);
|
||||||
|
res.status(500).json({ success: false, message: 'Internal Server Error' });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/servers/:guildId/leave', async (req, res) => {
|
app.post('/api/servers/:guildId/leave', async (req, res) => {
|
||||||
@@ -212,11 +428,10 @@ app.get('/api/servers/:guildId/channels', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/servers/:guildId/welcome-leave-settings', (req, res) => {
|
app.get('/api/servers/:guildId/welcome-leave-settings', async (req, res) => {
|
||||||
const { guildId } = req.params;
|
const { guildId } = req.params;
|
||||||
const db = readDb();
|
try {
|
||||||
const settings = db[guildId] || {};
|
const settings = (await pgClient.getServerSettings(guildId)) || {};
|
||||||
|
|
||||||
const welcomeLeaveSettings = {
|
const welcomeLeaveSettings = {
|
||||||
welcome: {
|
welcome: {
|
||||||
enabled: settings.welcomeEnabled || false,
|
enabled: settings.welcomeEnabled || false,
|
||||||
@@ -233,30 +448,34 @@ app.get('/api/servers/:guildId/welcome-leave-settings', (req, res) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
res.json(welcomeLeaveSettings);
|
res.json(welcomeLeaveSettings);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching welcome/leave settings:', err);
|
||||||
|
res.status(500).json({ success: false, message: 'Internal Server Error' });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/servers/:guildId/welcome-leave-settings', (req, res) => {
|
app.post('/api/servers/:guildId/welcome-leave-settings', async (req, res) => {
|
||||||
const { guildId } = req.params;
|
const { guildId } = req.params;
|
||||||
const newSettings = req.body;
|
const newSettings = req.body;
|
||||||
const db = readDb();
|
try {
|
||||||
|
const existing = (await pgClient.getServerSettings(guildId)) || {};
|
||||||
|
const merged = { ...existing };
|
||||||
|
merged.welcomeEnabled = newSettings.welcome.enabled;
|
||||||
|
merged.welcomeChannel = newSettings.welcome.channel;
|
||||||
|
merged.welcomeMessage = newSettings.welcome.message;
|
||||||
|
merged.welcomeCustomMessage = newSettings.welcome.customMessage;
|
||||||
|
|
||||||
if (!db[guildId]) {
|
merged.leaveEnabled = newSettings.leave.enabled;
|
||||||
db[guildId] = {};
|
merged.leaveChannel = newSettings.leave.channel;
|
||||||
|
merged.leaveMessage = newSettings.leave.message;
|
||||||
|
merged.leaveCustomMessage = newSettings.leave.customMessage;
|
||||||
|
|
||||||
|
await pgClient.upsertServerSettings(guildId, merged);
|
||||||
|
return res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error saving welcome/leave settings:', err);
|
||||||
|
res.status(500).json({ success: false, message: 'Internal Server Error' });
|
||||||
}
|
}
|
||||||
|
|
||||||
db[guildId].welcomeEnabled = newSettings.welcome.enabled;
|
|
||||||
db[guildId].welcomeChannel = newSettings.welcome.channel;
|
|
||||||
db[guildId].welcomeMessage = newSettings.welcome.message;
|
|
||||||
db[guildId].welcomeCustomMessage = newSettings.welcome.customMessage;
|
|
||||||
|
|
||||||
db[guildId].leaveEnabled = newSettings.leave.enabled;
|
|
||||||
db[guildId].leaveChannel = newSettings.leave.channel;
|
|
||||||
db[guildId].leaveMessage = newSettings.leave.message;
|
|
||||||
db[guildId].leaveCustomMessage = newSettings.leave.customMessage;
|
|
||||||
|
|
||||||
writeDb(db);
|
|
||||||
|
|
||||||
res.json({ success: true });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/servers/:guildId/roles', async (req, res) => {
|
app.get('/api/servers/:guildId/roles', async (req, res) => {
|
||||||
@@ -280,26 +499,113 @@ app.get('/api/servers/:guildId/roles', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/servers/:guildId/autorole-settings', (req, res) => {
|
app.get('/api/servers/:guildId/autorole-settings', async (req, res) => {
|
||||||
const { guildId } = req.params;
|
const { guildId } = req.params;
|
||||||
const db = readDb();
|
try {
|
||||||
const settings = db[guildId] || {};
|
const settings = (await pgClient.getServerSettings(guildId)) || {};
|
||||||
const autoroleSettings = settings.autorole || { enabled: false, roleId: '' };
|
const autoroleSettings = settings.autorole || { enabled: false, roleId: '' };
|
||||||
res.json(autoroleSettings);
|
res.json(autoroleSettings);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching autorole settings:', err);
|
||||||
|
res.status(500).json({ success: false, message: 'Internal Server Error' });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/servers/:guildId/autorole-settings', (req, res) => {
|
app.post('/api/servers/:guildId/autorole-settings', async (req, res) => {
|
||||||
const { guildId } = req.params;
|
const { guildId } = req.params;
|
||||||
const { enabled, roleId } = req.body;
|
const { enabled, roleId } = req.body;
|
||||||
const db = readDb();
|
try {
|
||||||
|
if (pgClient) {
|
||||||
if (!db[guildId]) {
|
const existing = (await pgClient.getServerSettings(guildId)) || {};
|
||||||
db[guildId] = {};
|
existing.autorole = { enabled, roleId };
|
||||||
|
await pgClient.upsertServerSettings(guildId, existing);
|
||||||
|
return res.json({ success: true });
|
||||||
}
|
}
|
||||||
|
const db = readDb();
|
||||||
|
if (!db[guildId]) db[guildId] = {};
|
||||||
db[guildId].autorole = { enabled, roleId };
|
db[guildId].autorole = { enabled, roleId };
|
||||||
writeDb(db);
|
writeDb(db);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error saving autorole settings:', err);
|
||||||
|
res.status(500).json({ success: false, message: 'Internal Server Error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Live notifications (Twitch) - per-guild settings
|
||||||
|
app.get('/api/servers/:guildId/live-notifications', async (req, res) => {
|
||||||
|
const { guildId } = req.params;
|
||||||
|
try {
|
||||||
|
const settings = (await pgClient.getServerSettings(guildId)) || {};
|
||||||
|
return res.json(settings.liveNotifications || { enabled: false, twitchUser: '', channelId: '' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching live-notifications settings:', err);
|
||||||
|
res.status(500).json({ success: false, message: 'Internal Server Error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/servers/:guildId/live-notifications', async (req, res) => {
|
||||||
|
const { guildId } = req.params;
|
||||||
|
const { enabled, twitchUser, channelId } = req.body || {};
|
||||||
|
try {
|
||||||
|
const existing = (await pgClient.getServerSettings(guildId)) || {};
|
||||||
|
existing.liveNotifications = {
|
||||||
|
enabled: !!enabled,
|
||||||
|
twitchUser: twitchUser || '',
|
||||||
|
channelId: channelId || ''
|
||||||
|
};
|
||||||
|
await pgClient.upsertServerSettings(guildId, existing);
|
||||||
|
try { publishEvent(guildId, 'liveNotificationsUpdate', { enabled: !!enabled, channelId, twitchUser }); } catch (e) {}
|
||||||
|
return res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error saving live-notifications settings:', err);
|
||||||
|
res.status(500).json({ success: false, message: 'Internal Server Error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Twitch users list management for a guild
|
||||||
|
app.get('/api/servers/:guildId/twitch-users', async (req, res) => {
|
||||||
|
const { guildId } = req.params;
|
||||||
|
try {
|
||||||
|
const settings = (await pgClient.getServerSettings(guildId)) || {};
|
||||||
|
const users = (settings.liveNotifications && settings.liveNotifications.users) || [];
|
||||||
|
res.json(users);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching twitch users:', err);
|
||||||
|
res.status(500).json({ success: false, message: 'Internal Server Error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/servers/:guildId/twitch-users', async (req, res) => {
|
||||||
|
const { guildId } = req.params;
|
||||||
|
const { username } = req.body || {};
|
||||||
|
if (!username) return res.status(400).json({ success: false, message: 'Missing username' });
|
||||||
|
try {
|
||||||
|
const existing = (await pgClient.getServerSettings(guildId)) || {};
|
||||||
|
if (!existing.liveNotifications) existing.liveNotifications = { enabled: false, channelId: '', users: [] };
|
||||||
|
existing.liveNotifications.users = Array.from(new Set([...(existing.liveNotifications.users || []), username.toLowerCase().trim()]));
|
||||||
|
await pgClient.upsertServerSettings(guildId, existing);
|
||||||
|
try { publishEvent(guildId, 'twitchUsersUpdate', { users: existing.liveNotifications.users || [] }); } catch (e) {}
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error adding twitch user:', err);
|
||||||
|
res.status(500).json({ success: false, message: 'Internal Server Error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/servers/:guildId/twitch-users/:username', async (req, res) => {
|
||||||
|
const { guildId, username } = req.params;
|
||||||
|
try {
|
||||||
|
const existing = (await pgClient.getServerSettings(guildId)) || {};
|
||||||
|
if (!existing.liveNotifications) existing.liveNotifications = { enabled: false, channelId: '', users: [] };
|
||||||
|
existing.liveNotifications.users = (existing.liveNotifications.users || []).filter(u => u.toLowerCase() !== (username || '').toLowerCase());
|
||||||
|
await pgClient.upsertServerSettings(guildId, existing);
|
||||||
|
try { publishEvent(guildId, 'twitchUsersUpdate', { users: existing.liveNotifications.users || [] }); } catch (e) {}
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error removing twitch user:', err);
|
||||||
|
res.status(500).json({ success: false, message: 'Internal Server Error' });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/', (req, res) => {
|
app.get('/', (req, res) => {
|
||||||
@@ -307,11 +613,10 @@ app.get('/', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 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', (req, res) => {
|
app.get('/api/servers/:guildId/commands', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { guildId } = req.params;
|
const { guildId } = req.params;
|
||||||
const db = readDb();
|
const guildSettings = (await pgClient.getServerSettings(guildId)) || {};
|
||||||
const guildSettings = db[guildId] || {};
|
|
||||||
const toggles = guildSettings.commandToggles || {};
|
const toggles = guildSettings.commandToggles || {};
|
||||||
const protectedCommands = ['manage-commands', 'help'];
|
const protectedCommands = ['manage-commands', 'help'];
|
||||||
|
|
||||||
@@ -338,8 +643,7 @@ app.get('/api/servers/:guildId/commands', (req, res) => {
|
|||||||
app.get('/api/servers/:guildId/invites', async (req, res) => {
|
app.get('/api/servers/:guildId/invites', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { guildId } = req.params;
|
const { guildId } = req.params;
|
||||||
const db = readDb();
|
const saved = await pgClient.listInvites(guildId);
|
||||||
const saved = (db[guildId] && db[guildId].invites) ? db[guildId].invites : [];
|
|
||||||
|
|
||||||
// try to enrich with live data where possible
|
// try to enrich with live data where possible
|
||||||
const guild = bot.client.guilds.cache.get(guildId);
|
const guild = bot.client.guilds.cache.get(guildId);
|
||||||
@@ -397,9 +701,7 @@ app.post('/api/servers/:guildId/invites', async (req, res) => {
|
|||||||
|
|
||||||
const invite = await channel.createInvite(inviteOptions);
|
const invite = await channel.createInvite(inviteOptions);
|
||||||
|
|
||||||
const db = readDb();
|
// persist invite to Postgres
|
||||||
if (!db[guildId]) db[guildId] = {};
|
|
||||||
if (!db[guildId].invites) db[guildId].invites = [];
|
|
||||||
|
|
||||||
const item = {
|
const item = {
|
||||||
code: invite.code,
|
code: invite.code,
|
||||||
@@ -411,8 +713,7 @@ app.post('/api/servers/:guildId/invites', async (req, res) => {
|
|||||||
temporary: !!invite.temporary,
|
temporary: !!invite.temporary,
|
||||||
};
|
};
|
||||||
|
|
||||||
db[guildId].invites.push(item);
|
await pgClient.addInvite({ code: item.code, guildId, url: item.url, channelId: item.channelId, createdAt: item.createdAt, maxUses: item.maxUses, maxAge: item.maxAge, temporary: item.temporary });
|
||||||
writeDb(db);
|
|
||||||
|
|
||||||
res.json({ success: true, invite: item });
|
res.json({ success: true, invite: item });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -430,7 +731,6 @@ app.delete('/api/servers/:guildId/invites/:code', async (req, res) => {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const { guildId, code } = req.params;
|
const { guildId, code } = req.params;
|
||||||
const db = readDb();
|
|
||||||
const guild = bot.client.guilds.cache.get(guildId);
|
const guild = bot.client.guilds.cache.get(guildId);
|
||||||
|
|
||||||
// Try to delete on Discord if possible
|
// Try to delete on Discord if possible
|
||||||
@@ -445,10 +745,15 @@ app.delete('/api/servers/:guildId/invites/:code', async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pgClient) {
|
||||||
|
await pgClient.deleteInvite(guildId, code);
|
||||||
|
} else {
|
||||||
|
const db = readDb();
|
||||||
if (db[guildId] && db[guildId].invites) {
|
if (db[guildId] && db[guildId].invites) {
|
||||||
db[guildId].invites = db[guildId].invites.filter(i => i.code !== code);
|
db[guildId].invites = db[guildId].invites.filter(i => i.code !== code);
|
||||||
writeDb(db);
|
writeDb(db);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -461,6 +766,31 @@ const bot = require('../discord-bot');
|
|||||||
|
|
||||||
bot.login();
|
bot.login();
|
||||||
|
|
||||||
|
// Dev/testing endpoint: force a live announcement
|
||||||
|
app.post('/internal/test-live', express.json(), async (req, res) => {
|
||||||
|
const { guildId, username, title } = req.body || {};
|
||||||
|
if (!guildId || !username) return res.status(400).json({ success: false, message: 'guildId and username required' });
|
||||||
|
try {
|
||||||
|
const stream = {
|
||||||
|
user_login: username.toLowerCase(),
|
||||||
|
user_name: username,
|
||||||
|
title: title || `${username} is live (test)`,
|
||||||
|
viewer_count: 1,
|
||||||
|
started_at: new Date().toISOString(),
|
||||||
|
url: `https://www.twitch.tv/${username.toLowerCase()}`,
|
||||||
|
thumbnail_url: null,
|
||||||
|
description: 'Test notification',
|
||||||
|
profile_image_url: null,
|
||||||
|
game_name: 'Testing',
|
||||||
|
};
|
||||||
|
const result = await bot.announceLive(guildId, stream);
|
||||||
|
res.json(result);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error in /internal/test-live:', e && e.message ? e.message : e);
|
||||||
|
res.status(500).json({ success: false, message: 'Internal error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.listen(port, host, () => {
|
app.listen(port, host, () => {
|
||||||
console.log(`Server is running on ${host}:${port}`);
|
console.log(`Server is running on ${host}:${port}`);
|
||||||
});
|
});
|
||||||
|
|||||||
202
backend/package-lock.json
generated
202
backend/package-lock.json
generated
@@ -13,7 +13,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",
|
||||||
|
"node-fetch": "^2.6.7",
|
||||||
|
"pg": "^8.11.0",
|
||||||
|
"pg-format": "^1.0.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.1.3"
|
"nodemon": "^3.1.3"
|
||||||
@@ -871,6 +874,26 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-fetch": {
|
||||||
|
"version": "2.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||||
|
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"whatwg-url": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "4.x || >=6.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"encoding": "^0.1.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"encoding": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/nodemon": {
|
"node_modules/nodemon": {
|
||||||
"version": "3.1.10",
|
"version": "3.1.10",
|
||||||
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz",
|
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz",
|
||||||
@@ -983,6 +1006,104 @@
|
|||||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/pg": {
|
||||||
|
"version": "8.16.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
|
||||||
|
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pg-connection-string": "^2.9.1",
|
||||||
|
"pg-pool": "^3.10.1",
|
||||||
|
"pg-protocol": "^1.10.3",
|
||||||
|
"pg-types": "2.2.0",
|
||||||
|
"pgpass": "1.0.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"pg-cloudflare": "^1.2.7"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg-native": ">=3.0.1"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"pg-native": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-cloudflare": {
|
||||||
|
"version": "1.2.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz",
|
||||||
|
"integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/pg-connection-string": {
|
||||||
|
"version": "2.9.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz",
|
||||||
|
"integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/pg-format": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-format/-/pg-format-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-YyKEF78pEA6wwTAqOUaHIN/rWpfzzIuMh9KdAhc3rSLQ/7zkRFcCgYBAEGatDstLyZw4g0s9SNICmaTGnBVeyw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-int8": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-pool": {
|
||||||
|
"version": "3.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz",
|
||||||
|
"integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg": ">=8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-protocol": {
|
||||||
|
"version": "1.10.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz",
|
||||||
|
"integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/pg-types": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pg-int8": "1.0.1",
|
||||||
|
"postgres-array": "~2.0.0",
|
||||||
|
"postgres-bytea": "~1.0.0",
|
||||||
|
"postgres-date": "~1.0.4",
|
||||||
|
"postgres-interval": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pgpass": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"split2": "^4.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||||
@@ -996,6 +1117,45 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/postgres-array": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-bytea": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-date": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-interval": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"xtend": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/proxy-addr": {
|
"node_modules/proxy-addr": {
|
||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||||
@@ -1258,6 +1418,15 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/split2": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/statuses": {
|
"node_modules/statuses": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
||||||
@@ -1312,6 +1481,12 @@
|
|||||||
"nodetouch": "bin/nodetouch.js"
|
"nodetouch": "bin/nodetouch.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tr46": {
|
||||||
|
"version": "0.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||||
|
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/type-is": {
|
"node_modules/type-is": {
|
||||||
"version": "1.6.18",
|
"version": "1.6.18",
|
||||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||||
@@ -1358,6 +1533,31 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/webidl-conversions": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/whatwg-url": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tr46": "~0.0.3",
|
||||||
|
"webidl-conversions": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/xtend": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,9 @@
|
|||||||
"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-format": "^1.0.4"
|
||||||
|
,"node-fetch": "^2.6.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.1.3"
|
"nodemon": "^3.1.3"
|
||||||
|
|||||||
92
backend/pg.js
Normal file
92
backend/pg.js
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
const { Pool } = require('pg');
|
||||||
|
const format = require('pg-format');
|
||||||
|
|
||||||
|
let pool;
|
||||||
|
|
||||||
|
function initPool() {
|
||||||
|
if (pool) return pool;
|
||||||
|
const connectionString = process.env.DATABASE_URL;
|
||||||
|
if (!connectionString) throw new Error('DATABASE_URL is not set');
|
||||||
|
pool = new Pool({ connectionString });
|
||||||
|
return pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureSchema() {
|
||||||
|
const p = initPool();
|
||||||
|
// basic tables: servers (settings), invites
|
||||||
|
await p.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS servers (
|
||||||
|
guild_id TEXT PRIMARY KEY,
|
||||||
|
settings JSONB DEFAULT '{}'
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await p.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS invites (
|
||||||
|
code TEXT PRIMARY KEY,
|
||||||
|
guild_id TEXT NOT NULL,
|
||||||
|
url TEXT,
|
||||||
|
channel_id TEXT,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
|
||||||
|
max_uses INTEGER DEFAULT 0,
|
||||||
|
max_age INTEGER DEFAULT 0,
|
||||||
|
temporary BOOLEAN DEFAULT false
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await p.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
discord_id TEXT UNIQUE,
|
||||||
|
data JSONB DEFAULT '{}'
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Servers
|
||||||
|
async function getServerSettings(guildId) {
|
||||||
|
const p = initPool();
|
||||||
|
const res = await p.query('SELECT settings FROM servers WHERE guild_id = $1', [guildId]);
|
||||||
|
if (res.rowCount === 0) return null;
|
||||||
|
return res.rows[0].settings || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertServerSettings(guildId, settings) {
|
||||||
|
const p = initPool();
|
||||||
|
await p.query(`INSERT INTO servers(guild_id, settings) VALUES($1, $2)
|
||||||
|
ON CONFLICT (guild_id) DO UPDATE SET settings = $2`, [guildId, settings]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invites
|
||||||
|
async function listInvites(guildId) {
|
||||||
|
const p = initPool();
|
||||||
|
const res = await p.query('SELECT code, url, channel_id, created_at, max_uses, max_age, temporary FROM invites WHERE guild_id = $1 ORDER BY created_at DESC', [guildId]);
|
||||||
|
return res.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addInvite(inv) {
|
||||||
|
const p = initPool();
|
||||||
|
const q = `INSERT INTO invites(code, guild_id, url, channel_id, created_at, max_uses, max_age, temporary) VALUES($1,$2,$3,$4,$5,$6,$7,$8)
|
||||||
|
ON CONFLICT (code) DO UPDATE SET url = EXCLUDED.url, channel_id = EXCLUDED.channel_id, max_uses = EXCLUDED.max_uses, max_age = EXCLUDED.max_age, temporary = EXCLUDED.temporary, created_at = EXCLUDED.created_at`;
|
||||||
|
await p.query(q, [inv.code, inv.guildId, inv.url, inv.channelId, inv.createdAt ? new Date(inv.createdAt) : new Date(), inv.maxUses || 0, inv.maxAge || 0, inv.temporary || false]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteInvite(guildId, code) {
|
||||||
|
const p = initPool();
|
||||||
|
await p.query('DELETE FROM invites WHERE guild_id = $1 AND code = $2', [guildId, code]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Users
|
||||||
|
async function getUserData(discordId) {
|
||||||
|
const p = initPool();
|
||||||
|
const res = await p.query('SELECT data FROM users WHERE discord_id = $1', [discordId]);
|
||||||
|
if (res.rowCount === 0) return null;
|
||||||
|
return res.rows[0].data || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertUserData(discordId, data) {
|
||||||
|
const p = initPool();
|
||||||
|
await p.query(`INSERT INTO users(discord_id, data) VALUES($1, $2) ON CONFLICT (discord_id) DO UPDATE SET data = $2`, [discordId, data]);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { initPool, ensureSchema, getServerSettings, upsertServerSettings, listInvites, addInvite, deleteInvite, getUserData, upsertUserData };
|
||||||
168
checklist.md
168
checklist.md
@@ -1,126 +1,64 @@
|
|||||||
# Project Checklist (tidy & current)
|
# Project Checklist (tidy & current)
|
||||||
|
|
||||||
Below are the implemented features and current status as reflected in the repository. Items marked [x] are implemented and wired; unchecked items are pending.
|
Below are implemented features and pending items, grouped by area.
|
||||||
|
|
||||||
## Backend
|
## Backend
|
||||||
- [x] Basic Express server and project setup
|
- [x] Express API: OAuth, server settings, channel/role endpoints, leave
|
||||||
- [x] Discord OAuth2 endpoints
|
- [x] Invite endpoints (GET/POST/DELETE) and invite-token issuance
|
||||||
- [x] API endpoints for servers, channels, roles, leave, settings
|
- [x] Per-command toggles persistence and management
|
||||||
- [x] Persist encrypted data to `db.json`
|
- [x] Config endpoints for welcome/leave and autorole
|
||||||
|
|
||||||
## Frontend
|
## Frontend
|
||||||
- [x] Login page
|
- [x] Login, Dashboard, Server Settings pages
|
||||||
- [x] Dashboard page
|
- Login redirects to Dashboard after OAuth and user/guilds are persisted in localStorage
|
||||||
- [x] Backend connectivity for APIs
|
- Dashboard is protected: user must be logged in to view (redirects to login otherwise)
|
||||||
- [x] UI components using MUI
|
- [x] MUI components, responsive layout, mobile fixes
|
||||||
- [x] Server-specific settings pages
|
- [x] Theme switching (persist local) and user settings UI
|
||||||
- [x] Persist user data (localStorage + backend)
|
- [x] Invite UI: create form, list, copy, delete with confirmation
|
||||||
- [x] Logout
|
- [x] Commands UI (per-command toggles)
|
||||||
- [x] Responsive UI and improved styling
|
- [x] Live Notifications UI (per-server toggle & config)
|
||||||
- [x] Theme switching (light, dark, Discord grey)
|
- Live Notifications accessible from server page via dropdown and dialog
|
||||||
- [x] User settings menu
|
- Dashboard: channel dropdown and watched-user list added
|
||||||
- [x] Commands section in Server Settings (per-command toggles)
|
|
||||||
- [x] Commands list sorted alphabetically in Server Settings
|
|
||||||
- [x] Help → renamed to 'Commands List' and moved to dedicated page
|
|
||||||
- [x] NavBar redesigned (single-hamburger, title 'ECS - EHDCHADSWORTH')
|
|
||||||
- [x] Invite button on dashboard and server cards (with pre-invite check)
|
|
||||||
- [x] Invite button on dashboard (single action below the server title) and server cards (with pre-invite check)
|
|
||||||
- [x] Invite management UI in Server Settings (create/list/delete invites)
|
|
||||||
|
|
||||||
## Invite Management (implemented)
|
## Discord Bot
|
||||||
- [x] Backend endpoints: GET/POST/DELETE `/api/servers/:guildId/invites`
|
- [x] discord.js integration (events and commands)
|
||||||
- [x] Backend endpoints: GET/POST/DELETE `/api/servers/:guildId/invites` (supports optional `INVITE_API_KEY` or short-lived invite tokens via `/api/servers/:guildId/invite-token`)
|
- [x] Slash commands: `/create-invite`, `/list-invites`, `/manage-commands`, `/help`
|
||||||
- [x] Frontend: invite creation form (channel optional, expiry, max uses, temporary), labels added, mobile-friendly layout
|
- [x] Bot used by backend to fetch live guild data and manage invites
|
||||||
- [x] Frontend: invite list with Copy and Delete actions and metadata
|
- [x] Bot reads/writes per-guild command toggles via backend/Postgres
|
||||||
- [x] Frontend: invite list with Copy and Delete actions and metadata (copy/delete fixed UI handlers)
|
- [x] Backend immediately notifies bot of toggle changes (pushes updated settings to bot cache) so frontend toggles take effect instantly
|
||||||
- [x] Discord bot commands: `/create-invite`, `/list-invites` and interaction handlers for copy/delete
|
- [x] New slash command: `/list-twitchusers` to list watched Twitch usernames for a guild
|
||||||
- [x] Invites persisted in encrypted `db.json`
|
- [x] Frontend: Confirm dialog and working Delete action for Twitch watched users in Live Notifications
|
||||||
|
- [x] Live Notifications: bot posts message to configured channel with stream title and link when a watched Twitch user goes live
|
||||||
|
- [x] Live Notifications: bot posts rich embed to channel when a watched Twitch user goes live (thumbnail, clickable title, bio/description, category/game, viewers, footer with "ehchadservices" and start datetime)
|
||||||
|
- [x] Live Notifications polling frequency set to 3 seconds for rapid detection (configurable via `TWITCH_POLL_INTERVAL_MS`)
|
||||||
|
- [x] Frontend: show "Watch Live" button next to watched user when they are live (links to Twitch)
|
||||||
|
|
||||||
## Security
|
## Database
|
||||||
- [x] Invite DELETE route now requires a short-lived invite token issued by `/api/servers/:guildId/invite-token` and sent in the `x-invite-token` header. The old `INVITE_API_KEY` header is no longer used.
|
- [x] Postgres support via `DATABASE_URL` (backend auto-creates `servers`, `invites`, `users`)
|
||||||
- [x] Invite delete UI now shows a confirmation dialog before deleting an invite.
|
- [x] Legacy encrypted `backend/db.json` retained (migration planned)
|
||||||
|
- [ ] Migration script: import `backend/db.json` into Postgres (planned)
|
||||||
|
- [x] Schema: live notification settings stored in server settings (via `liveNotifications` JSON)
|
||||||
|
|
||||||
## Theme & UX
|
## Security & Behavior
|
||||||
- [x] Theme changes persist immediately (localStorage) and are applied across navigation
|
- [x] Invite DELETE requires short-lived HMAC token (`x-invite-token`)
|
||||||
- [x] Theme preference priority: local selection > server preference > default (default only used on first visit)
|
- [x] Frontend confirmation dialog for invite deletion
|
||||||
|
- [ ] Harden invite-token issuance (require OAuth + admin check)
|
||||||
|
|
||||||
## Discord Bot
|
## Docs & Deployment
|
||||||
- [x] Bot with event & command handlers
|
- [x] README and CHANGELOG updated with setup steps and Postgres guidance
|
||||||
- [x] Slash command registration and runtime enable/disable mechanism
|
- Core env vars: `DATABASE_URL`, `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET`, `DISCORD_BOT_TOKEN`, `INVITE_TOKEN_SECRET`, `ENCRYPTION_KEY`, `HOST`, `PORT`, `CORS_ORIGIN`
|
||||||
- [x] `/help` and `/manage-commands` (manage persists toggles to backend)
|
- Frontend: set `REACT_APP_API_BASE` to backend URL before build
|
||||||
- [x] Invite-related slash commands implemented (`/create-invite`, `/list-invites`)
|
- Tailscale: bind backend to your tailnet IP (100.x.y.z) and set `DATABASE_URL` to a Postgres reachable over the tailnet
|
||||||
|
|
||||||
## Features
|
Notes:
|
||||||
- [x] Welcome/Leave messages (frontend + backend + bot integration)
|
- `backend/.env.example` and `frontend/.env.example` are templates — copy to `.env` and fill values.
|
||||||
- [x] Autorole (frontend + backend + bot integration)
|
- Postgres / pgAdmin: create DB & user, set `DATABASE_URL`; backend auto-creates tables on startup.
|
||||||
|
|
||||||
## Pending / Suggested improvements
|
UI tweaks applied:
|
||||||
- [ ] Consider stronger auth for invite delete (e.g., require user auth or signed requests); currently an optional API key is supported via `INVITE_API_KEY`.
|
- Server cards: uniform sizes, image cropping, name clamping
|
||||||
|
- Mobile spacing and typography adjustments
|
||||||
|
- Dashboard action buttons repositioned (Invite/Leave under title)
|
||||||
|
|
||||||
## Deployment notes (VPS / Tailscale)
|
- [x] Browser tab now shows `ECS - <Page Name>` (e.g., 'ECS - Dashboard')
|
||||||
|
- [x] Dashboard duplicate title fixed; user settings (avatar/themes) restored via NavBar
|
||||||
|
|
||||||
Quick guidance to run the backend, frontend and bot on a VPS or make the API accessible over a Tailscale network:
|
|
||||||
|
|
||||||
- Environment variables you'll want to set (backend `.env`):
|
|
||||||
- `PORT` (e.g. 3002)
|
|
||||||
- `HOST` the bind address (e.g. `100.x.y.z` Tailscale IP for the VPS or `0.0.0.0` to bind all interfaces)
|
|
||||||
- `CORS_ORIGIN` origin allowed for cross-origin requests (e.g. `http://100.x.y.z:3000` or `*` during testing)
|
|
||||||
- `INVITE_API_KEY` (optional) secret to protect invite DELETE requests
|
|
||||||
- `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET`, `DISCORD_BOT_TOKEN`, `ENCRYPTION_KEY` (existing bot secrets)
|
|
||||||
|
|
||||||
- Frontend config:
|
|
||||||
- Build the frontend (`npm run build`) and serve it with a static server (nginx) or host separately.
|
|
||||||
- Configure `REACT_APP_API_BASE` before building to point to your backend (e.g. `http://100.x.y.z:3002`).
|
|
||||||
|
|
||||||
Current local dev hosts used in this workspace (update these values in your `.env` files if you change ports):
|
|
||||||
|
|
||||||
- Frontend dev server: `http://100.70.209.56:3001` (set in `frontend/.env` as HOST=100.70.209.56 and PORT=3001)
|
|
||||||
- Backend server: `http://100.70.209.56:3002` (set in `backend/.env` as HOST=100.70.209.56 and PORT=3002)
|
|
||||||
|
|
||||||
Discord Developer Portal settings (must match your BACKEND_BASE and FRONTEND_BASE):
|
|
||||||
|
|
||||||
- OAuth2 Redirect URI to add: `http://100.70.209.56:3002/auth/discord/callback`
|
|
||||||
- OAuth2 Allowed Origin (CORS / Application Origin): `http://100.70.209.56:3001`
|
|
||||||
|
|
||||||
- Tailscale notes:
|
|
||||||
- Ensure the VPS has Tailscale installed and is in your Tailnet.
|
|
||||||
- Use the VPS Tailscale IP (100.x.y.z) as `HOST` or to reach the API from other machines on the tailnet.
|
|
||||||
- For convenience and security, only expose ports on the Tailscale interface and avoid opening them to the public internet.
|
|
||||||
|
|
||||||
- Example systemd service (backend) on VPS (/etc/systemd/system/ecs-backend.service):
|
|
||||||
- Set `Environment=` entries for your `.env` or point to a `.env` file in the service unit, and run `node index.js` in the `backend` folder.
|
|
||||||
|
|
||||||
Where to change host/port and base URLs
|
|
||||||
- Backend: edit `backend/.env` (or set the environment variables) — key entries:
|
|
||||||
- `HOST` — bind address (e.g., your Tailscale IP like `100.x.y.z` or `0.0.0.0`)
|
|
||||||
- `PORT` — port the backend listens on (e.g., `3002`)
|
|
||||||
- `BACKEND_BASE` — optional fully-qualified base URL (defaults to `http://HOST:PORT`)
|
|
||||||
- `FRONTEND_BASE` — used for OAuth redirect to frontend (e.g., `http://100.x.y.z:3000`)
|
|
||||||
- Frontend: set `REACT_APP_API_BASE` in `frontend/.env` before running `npm run build` (or export at runtime for development). Example:
|
|
||||||
- `REACT_APP_API_BASE=http://100.x.y.z:3002`
|
|
||||||
|
|
||||||
I've added `backend/.env.example` and `frontend/.env.example` as templates — copy them to `.env` and fill in values for your environment.
|
|
||||||
|
|
||||||
- Example nginx (reverse proxy) snippet if you want to expose via a domain (optional):
|
|
||||||
- Proxy `https://yourdomain.example` to the backend (or to the frontend build directory) with TLS termination at nginx.
|
|
||||||
|
|
||||||
If you'd like, I can:
|
|
||||||
- Add a small `deploy.md` with exact steps and example `systemd` unit and `nginx` config.
|
|
||||||
- Update frontend to read a runtime-config file (useful when you don't want to rebuild to change API base).
|
|
||||||
- [ ] Add unit/integration tests for invite endpoints and ThemeContext behavior
|
|
||||||
- [ ] Accessibility improvements (ARIA attributes, focus styles) across the settings forms
|
|
||||||
- [ ] Small UI polish (spacing/visuals) for invite list items and commands list
|
|
||||||
|
|
||||||
If you'd like, I can immediately:
|
|
||||||
- Pin protected commands (e.g., `help`, `manage-commands`) to the top of the Commands list while keeping the rest alphabetical.
|
|
||||||
- Add ARIA labels and keyboard navigation tweaks for the invite dropdowns.
|
|
||||||
- Add tests for ThemeContext.
|
|
||||||
|
|
||||||
UI tweaks applied:
|
|
||||||
|
|
||||||
- Server cards on the Dashboard have been updated to enforce exact identical size per breakpoint (fixed heights), images are cropped uniformly (object-fit: cover) so icons are the same visible area across cards, and long server names are clamped to two lines to prevent layout differences.
|
|
||||||
- Mobile spacing, paddings, and typography adjusted for better legibility on small screens.
|
|
||||||
- Mobile fix: Title clamping and CardContent overflow were tightened so cards no longer expand on mobile; images use a background-image approach and white background to keep visible areas identical.
|
|
||||||
- Dashboard action buttons moved: Invite/Leave action now appears below the server title with a left label 'Invite:' or 'Leave:' and the action button to the right.
|
|
||||||
|
|
||||||
- [x] Browser tab now shows `ECS - <Page Name>` (e.g., 'ECS - Dashboard', 'ECS - Server Settings') for each page.
|
|
||||||
166
discord-bot/api.js
Normal file
166
discord-bot/api.js
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
const fetch = require('node-fetch');
|
||||||
|
|
||||||
|
// Resolve backend candidates (env or common local addresses). We'll try them in order
|
||||||
|
// for each request so the bot can still reach the backend even when it binds to
|
||||||
|
// a specific non-loopback IP.
|
||||||
|
const envBase = process.env.BACKEND_BASE ? process.env.BACKEND_BASE.replace(/\/$/, '') : null;
|
||||||
|
const host = process.env.BACKEND_HOST || process.env.HOST || '127.0.0.1';
|
||||||
|
const port = process.env.BACKEND_PORT || process.env.PORT || '3002';
|
||||||
|
const CANDIDATES = [envBase, `http://${host}:${port}`, `http://localhost:${port}`, `http://127.0.0.1:${port}`].filter(Boolean);
|
||||||
|
|
||||||
|
async function tryFetch(url, opts = {}) {
|
||||||
|
// Try each candidate base until one responds successfully
|
||||||
|
for (const base of CANDIDATES) {
|
||||||
|
const target = `${base.replace(/\/$/, '')}${url}`;
|
||||||
|
try {
|
||||||
|
const res = await fetch(target, opts);
|
||||||
|
if (res && (res.ok || res.status === 204)) {
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
// if this candidate returned a non-ok status, log and continue trying others
|
||||||
|
console.error(`Candidate ${base} returned ${res.status} ${res.statusText} for ${target}`);
|
||||||
|
} catch (e) {
|
||||||
|
// network error for this candidate; try next
|
||||||
|
// console.debug(`Candidate ${base} failed:`, e && e.message ? e.message : e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// none of the candidates succeeded
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function safeFetchJsonPath(path, opts = {}) {
|
||||||
|
const res = await tryFetch(path, opts);
|
||||||
|
if (!res) return null;
|
||||||
|
try {
|
||||||
|
return await res.json();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse JSON from backend response:', e && e.message ? e.message : e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getServerSettings(guildId) {
|
||||||
|
const path = `/api/servers/${guildId}/settings`;
|
||||||
|
const json = await safeFetchJsonPath(path);
|
||||||
|
return json || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertServerSettings(guildId, settings) {
|
||||||
|
const path = `/api/servers/${guildId}/settings`;
|
||||||
|
try {
|
||||||
|
const res = await tryFetch(path, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(settings),
|
||||||
|
});
|
||||||
|
return res && res.ok;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Failed to upsert settings for ${guildId}:`, e && e.message ? e.message : e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCommands(guildId) {
|
||||||
|
const path = `/api/servers/${guildId}/commands`;
|
||||||
|
const json = await safeFetchJsonPath(path);
|
||||||
|
return json || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleCommand(guildId, cmdName, enabled) {
|
||||||
|
const path = `/api/servers/${guildId}/commands/${cmdName}/toggle`;
|
||||||
|
try {
|
||||||
|
const res = await tryFetch(path, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ enabled }),
|
||||||
|
});
|
||||||
|
return res && res.ok;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Failed to toggle command ${cmdName} for ${guildId}:`, e && e.message ? e.message : e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listInvites(guildId) {
|
||||||
|
const path = `/api/servers/${guildId}/invites`;
|
||||||
|
const json = await safeFetchJsonPath(path);
|
||||||
|
return json || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addInvite(guildId, invite) {
|
||||||
|
const path = `/api/servers/${guildId}/invites`;
|
||||||
|
try {
|
||||||
|
const res = await tryFetch(path, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(invite),
|
||||||
|
});
|
||||||
|
return res && res.ok;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Failed to add invite for ${guildId}:`, e && e.message ? e.message : e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteInvite(guildId, code) {
|
||||||
|
const path = `/api/servers/${guildId}/invites/${code}`;
|
||||||
|
try {
|
||||||
|
const res = await tryFetch(path, { method: 'DELETE' });
|
||||||
|
return res && res.ok;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Failed to delete invite ${code} for ${guildId}:`, e && e.message ? e.message : e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getServerSettings, upsertServerSettings, getCommands, toggleCommand, listInvites, addInvite, deleteInvite };
|
||||||
|
// Twitch users helpers
|
||||||
|
async function getTwitchUsers(guildId) {
|
||||||
|
const path = `/api/servers/${guildId}/twitch-users`;
|
||||||
|
const json = await safeFetchJsonPath(path);
|
||||||
|
return json || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addTwitchUser(guildId, username) {
|
||||||
|
const path = `/api/servers/${guildId}/twitch-users`;
|
||||||
|
try {
|
||||||
|
const res = await tryFetch(path, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username }),
|
||||||
|
});
|
||||||
|
return res && res.ok;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Failed to add twitch user ${username} for ${guildId}:`, e && e.message ? e.message : e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteTwitchUser(guildId, username) {
|
||||||
|
const path = `/api/servers/${guildId}/twitch-users/${encodeURIComponent(username)}`;
|
||||||
|
try {
|
||||||
|
const res = await tryFetch(path, { method: 'DELETE' });
|
||||||
|
return res && res.ok;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Failed to delete twitch user ${username} for ${guildId}:`, e && e.message ? e.message : e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch stream status via backend proxy endpoint /api/twitch/streams?users=a,b,c
|
||||||
|
async function tryFetchTwitchStreams(usersCsv) {
|
||||||
|
const path = `/api/twitch/streams?users=${encodeURIComponent(usersCsv || '')}`;
|
||||||
|
const json = await safeFetchJsonPath(path);
|
||||||
|
return json || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raw direct call helper (not used in most environments) — kept for legacy watcher
|
||||||
|
async function _rawGetTwitchStreams(usersCsv) {
|
||||||
|
// Try direct backend candidate first
|
||||||
|
const path = `/api/twitch/streams?users=${encodeURIComponent(usersCsv || '')}`;
|
||||||
|
const res = await tryFetch(path);
|
||||||
|
if (!res) return [];
|
||||||
|
try { return await res.json(); } catch (e) { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getServerSettings, upsertServerSettings, getCommands, toggleCommand, listInvites, addInvite, deleteInvite, getTwitchUsers, addTwitchUser, deleteTwitchUser };
|
||||||
33
discord-bot/commands/add-twitchuser.js
Normal file
33
discord-bot/commands/add-twitchuser.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
const { SlashCommandBuilder, PermissionsBitField } = require('discord.js');
|
||||||
|
const fetch = require('node-fetch');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
name: 'add-twitchuser',
|
||||||
|
description: 'Admin: add a Twitch username to watch for this server',
|
||||||
|
enabled: true,
|
||||||
|
builder: new SlashCommandBuilder()
|
||||||
|
.setName('add-twitchuser')
|
||||||
|
.setDescription('Add a Twitch username to watch for live notifications')
|
||||||
|
.addStringOption(opt => opt.setName('username').setDescription('Twitch username').setRequired(true)),
|
||||||
|
async execute(interaction) {
|
||||||
|
if (!interaction.member.permissions.has(PermissionsBitField.Flags.Administrator)) {
|
||||||
|
await interaction.reply({ content: 'You must be an administrator to use this command.', flags: 64 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const username = interaction.options.getString('username').toLowerCase().trim();
|
||||||
|
try {
|
||||||
|
const backendBase = process.env.BACKEND_BASE || `http://${process.env.HOST || '127.0.0.1'}:${process.env.PORT || 3002}`;
|
||||||
|
const resp = await fetch(`${backendBase}/api/servers/${interaction.guildId}/twitch-users`, {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username })
|
||||||
|
});
|
||||||
|
if (resp.ok) {
|
||||||
|
await interaction.reply({ content: `Added ${username} to watch list.`, flags: 64 });
|
||||||
|
} else {
|
||||||
|
await interaction.reply({ content: 'Failed to add user via backend.', flags: 64 });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error adding twitch user:', e);
|
||||||
|
await interaction.reply({ content: 'Internal error adding twitch user.', flags: 64 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -28,22 +28,21 @@ module.exports = {
|
|||||||
|
|
||||||
const invite = await targetChannel.createInvite({ maxAge, maxUses, temporary, unique: true });
|
const invite = await targetChannel.createInvite({ maxAge, maxUses, temporary, unique: true });
|
||||||
|
|
||||||
const db = readDb();
|
const api = require('../api');
|
||||||
if (!db[interaction.guildId]) db[interaction.guildId] = {};
|
|
||||||
if (!db[interaction.guildId].invites) db[interaction.guildId].invites = [];
|
|
||||||
|
|
||||||
const item = {
|
const item = {
|
||||||
code: invite.code,
|
code: invite.code,
|
||||||
url: invite.url,
|
url: invite.url,
|
||||||
channelId: targetChannel.id,
|
channel_id: targetChannel.id,
|
||||||
createdAt: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
maxUses: invite.maxUses || maxUses || 0,
|
max_uses: invite.maxUses || maxUses || 0,
|
||||||
maxAge: invite.maxAge || maxAge || 0,
|
max_age: invite.maxAge || maxAge || 0,
|
||||||
temporary: !!invite.temporary,
|
temporary: !!invite.temporary,
|
||||||
};
|
};
|
||||||
|
try {
|
||||||
db[interaction.guildId].invites.push(item);
|
await api.addInvite(interaction.guildId, { channelId: targetChannel.id, maxAge, maxUses, temporary });
|
||||||
writeDb(db);
|
} catch (e) {
|
||||||
|
console.error('Error saving invite to backend:', e);
|
||||||
|
}
|
||||||
|
|
||||||
await interaction.reply({ content: `Invite created: ${invite.url}`, ephemeral: true });
|
await interaction.reply({ content: `Invite created: ${invite.url}`, ephemeral: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const { SlashCommandBuilder } = require('discord.js');
|
const { SlashCommandBuilder, EmbedBuilder } = require('discord.js');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
name: 'help',
|
name: 'help',
|
||||||
@@ -6,20 +6,62 @@ module.exports = {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
builder: new SlashCommandBuilder()
|
builder: new SlashCommandBuilder()
|
||||||
.setName('help')
|
.setName('help')
|
||||||
.setDescription('List available bot commands and what they do.'),
|
.setDescription('List available bot commands and what they do.')
|
||||||
|
.addStringOption(opt => opt.setName('command').setDescription('Get detailed help for a specific command').setRequired(false)),
|
||||||
async execute(interaction) {
|
async execute(interaction) {
|
||||||
const commands = Array.from(interaction.client.commands.values()).filter(cmd => !!cmd.builder);
|
try {
|
||||||
let text = '**Available Commands:**\n\n';
|
const api = require('../api');
|
||||||
const db = require('../../backend/db').readDb();
|
// fetch authoritative commands list for this guild
|
||||||
const guildSettings = db[interaction.guildId] || {};
|
const commands = await api.getCommands(interaction.guildId) || [];
|
||||||
const toggles = guildSettings.commandToggles || {};
|
|
||||||
const protectedCommands = ['manage-commands', 'help'];
|
|
||||||
|
|
||||||
for (const cmd of commands) {
|
const target = interaction.options.getString('command');
|
||||||
const isEnabled = protectedCommands.includes(cmd.name) ? true : (toggles[cmd.name] !== false && cmd.enabled !== false);
|
if (target) {
|
||||||
text += `/${cmd.name} — ${cmd.description || 'No description.'} — ${isEnabled ? 'Enabled' : 'Disabled'}${protectedCommands.includes(cmd.name) ? ' (locked)' : ''}\n`;
|
const found = commands.find(c => c.name.toLowerCase() === target.toLowerCase());
|
||||||
|
if (!found) {
|
||||||
|
return await interaction.reply({ content: `No command named "/${target}" found.`, flags: 64 });
|
||||||
|
}
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle(`/${found.name} — ${found.locked ? 'Locked' : (found.enabled ? 'Enabled' : 'Disabled')}`)
|
||||||
|
.setDescription(found.description || 'No description available.')
|
||||||
|
.setColor(found.enabled ? 0x22c55e : 0xe11d48)
|
||||||
|
.addFields(
|
||||||
|
{ name: 'Usage', value: `/${found.name} ${(found.usage || '').trim() || ''}` },
|
||||||
|
{ name: 'Status', value: found.locked ? 'Locked (cannot be toggled)' : (found.enabled ? 'Enabled' : 'Disabled'), inline: true },
|
||||||
|
{ name: 'Has Slash Builder', value: found.hasSlashBuilder ? 'Yes' : 'No', inline: true }
|
||||||
|
)
|
||||||
|
.setFooter({ text: 'Use /help <command> to view detailed info about a command.' });
|
||||||
|
return await interaction.reply({ embeds: [embed], flags: 64 });
|
||||||
}
|
}
|
||||||
|
|
||||||
await interaction.reply({ content: text, flags: 64 });
|
// Build a neat embed listing commands grouped by status
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle('Available Commands')
|
||||||
|
.setDescription('Use `/help <command>` to get detailed info on a specific command.')
|
||||||
|
.setColor(0x5865f2);
|
||||||
|
|
||||||
|
// Sort commands: enabled first, then disabled, locked last
|
||||||
|
const sorted = commands.slice().sort((a, b) => {
|
||||||
|
const ka = a.locked ? 2 : (a.enabled ? 0 : 1);
|
||||||
|
const kb = b.locked ? 2 : (b.enabled ? 0 : 1);
|
||||||
|
if (ka !== kb) return ka - kb;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build a concise field list (max 25 fields in Discord embed)
|
||||||
|
const fields = [];
|
||||||
|
for (const cmd of sorted) {
|
||||||
|
const status = cmd.locked ? '🔒 Locked' : (cmd.enabled ? '✅ Enabled' : '⛔ Disabled');
|
||||||
|
fields.push({ name: `/${cmd.name}`, value: `${cmd.description || 'No description.'}\n${status}`, inline: false });
|
||||||
|
if (fields.length >= 24) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fields.length > 0) embed.addFields(fields);
|
||||||
|
else embed.setDescription('No commands available.');
|
||||||
|
|
||||||
|
return await interaction.reply({ embeds: [embed], flags: 64 });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error in help command:', e && e.message ? e.message : e);
|
||||||
|
return await interaction.reply({ content: 'Failed to retrieve commands. Try again later.', flags: 64 });
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const { SlashCommandBuilder, PermissionFlagsBits, ActionRowBuilder, ButtonBuilder, ButtonStyle } = require('discord.js');
|
const { SlashCommandBuilder, PermissionFlagsBits, ActionRowBuilder, ButtonBuilder, ButtonStyle } = require('discord.js');
|
||||||
const { readDb } = require('../../backend/db');
|
const api = require('../api');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
name: 'list-invites',
|
name: 'list-invites',
|
||||||
@@ -11,8 +11,7 @@ module.exports = {
|
|||||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||||
async execute(interaction) {
|
async execute(interaction) {
|
||||||
try {
|
try {
|
||||||
const db = readDb();
|
const invites = await api.listInvites(interaction.guildId) || [];
|
||||||
const invites = (db[interaction.guildId] && db[interaction.guildId].invites) ? db[interaction.guildId].invites : [];
|
|
||||||
|
|
||||||
if (!invites.length) {
|
if (!invites.length) {
|
||||||
await interaction.reply({ content: 'No invites created by the bot in this server.', ephemeral: true });
|
await interaction.reply({ content: 'No invites created by the bot in this server.', ephemeral: true });
|
||||||
|
|||||||
23
discord-bot/commands/list-twitchusers.js
Normal file
23
discord-bot/commands/list-twitchusers.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
const { SlashCommandBuilder } = require('discord.js');
|
||||||
|
const api = require('../api');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
name: 'list-twitchusers',
|
||||||
|
description: 'List watched Twitch usernames for this server (Live Notifications).',
|
||||||
|
enabled: true,
|
||||||
|
builder: new SlashCommandBuilder().setName('list-twitchusers').setDescription('List watched Twitch usernames for this server'),
|
||||||
|
async execute(interaction) {
|
||||||
|
try {
|
||||||
|
const users = await api.getTwitchUsers(interaction.guildId) || [];
|
||||||
|
if (!users || users.length === 0) {
|
||||||
|
await interaction.reply({ content: 'No Twitch users are being watched for this server.', ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const list = users.map(u => `• ${u}`).join('\n');
|
||||||
|
await interaction.reply({ content: `Watched Twitch users:\n${list}`, ephemeral: true });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error listing twitch users:', e);
|
||||||
|
await interaction.reply({ content: 'Failed to retrieve watched users.', ephemeral: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
const { SlashCommandBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, PermissionsBitField } = require('discord.js');
|
const { SlashCommandBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, PermissionsBitField } = require('discord.js');
|
||||||
const { readDb, writeDb } = require('../../backend/db.js');
|
const api = require('../api');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
name: 'manage-commands',
|
name: 'manage-commands',
|
||||||
@@ -15,11 +15,9 @@ module.exports = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const db = readDb();
|
const existingSettings = (await api.getServerSettings(interaction.guildId)) || {};
|
||||||
if (!db[interaction.guildId]) db[interaction.guildId] = {};
|
if (!existingSettings.commandToggles) existingSettings.commandToggles = {};
|
||||||
if (!db[interaction.guildId].commandToggles) db[interaction.guildId].commandToggles = {};
|
let toggles = existingSettings.commandToggles;
|
||||||
|
|
||||||
const toggles = db[interaction.guildId].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);
|
||||||
@@ -67,9 +65,19 @@ module.exports = {
|
|||||||
|
|
||||||
collector.on('collect', async i => {
|
collector.on('collect', async i => {
|
||||||
const cmdName = i.customId.replace('toggle_cmd_', '');
|
const cmdName = i.customId.replace('toggle_cmd_', '');
|
||||||
toggles[cmdName] = !(toggles[cmdName] !== false);
|
const newVal = !(toggles[cmdName] !== false);
|
||||||
writeDb(db);
|
// persist via backend API
|
||||||
|
try {
|
||||||
|
await api.toggleCommand(interaction.guildId, cmdName, newVal);
|
||||||
|
// fetch authoritative list to rebuild buttons
|
||||||
|
const fresh = await api.getCommands(interaction.guildId);
|
||||||
|
toggles = {};
|
||||||
|
for (const c of fresh) {
|
||||||
|
toggles[c.name] = c.enabled;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error persisting command toggle:', e);
|
||||||
|
}
|
||||||
// rebuild buttons to reflect new state
|
// rebuild buttons to reflect new state
|
||||||
const updatedRows = [];
|
const updatedRows = [];
|
||||||
let r = new ActionRowBuilder();
|
let r = new ActionRowBuilder();
|
||||||
|
|||||||
@@ -1,17 +1,10 @@
|
|||||||
const { readDb } = require('../../backend/db.js');
|
// ping uses backend settings via API
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
name: 'ping',
|
name: 'ping',
|
||||||
description: 'Replies with Pong!',
|
description: 'Replies with Pong!',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
execute(interaction) {
|
async execute(interaction) {
|
||||||
const db = readDb();
|
await interaction.reply('Pong!');
|
||||||
const settings = db[interaction.guildId] || { pingCommand: false };
|
|
||||||
|
|
||||||
if (settings.pingCommand) {
|
|
||||||
interaction.reply('Pong!');
|
|
||||||
} else {
|
|
||||||
interaction.reply('The ping command is disabled on this server.');
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
31
discord-bot/commands/remove-twitchuser.js
Normal file
31
discord-bot/commands/remove-twitchuser.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
const { SlashCommandBuilder, PermissionsBitField } = require('discord.js');
|
||||||
|
const fetch = require('node-fetch');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
name: 'remove-twitchuser',
|
||||||
|
description: 'Admin: remove a Twitch username from this server watch list',
|
||||||
|
enabled: true,
|
||||||
|
builder: new SlashCommandBuilder()
|
||||||
|
.setName('remove-twitchuser')
|
||||||
|
.setDescription('Remove a Twitch username from the watch list')
|
||||||
|
.addStringOption(opt => opt.setName('username').setDescription('Twitch username to remove').setRequired(true)),
|
||||||
|
async execute(interaction) {
|
||||||
|
if (!interaction.member.permissions.has(PermissionsBitField.Flags.Administrator)) {
|
||||||
|
await interaction.reply({ content: 'You must be an administrator to use this command.', flags: 64 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const username = interaction.options.getString('username').toLowerCase().trim();
|
||||||
|
try {
|
||||||
|
const backendBase = process.env.BACKEND_BASE || `http://${process.env.HOST || '127.0.0.1'}:${process.env.PORT || 3002}`;
|
||||||
|
const resp = await fetch(`${backendBase}/api/servers/${interaction.guildId}/twitch-users/${encodeURIComponent(username)}`, { method: 'DELETE' });
|
||||||
|
if (resp.ok) {
|
||||||
|
await interaction.reply({ content: `Removed ${username} from watch list.`, flags: 64 });
|
||||||
|
} else {
|
||||||
|
await interaction.reply({ content: 'Failed to remove user via backend.', flags: 64 });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error removing twitch user:', e);
|
||||||
|
await interaction.reply({ content: 'Internal error removing twitch user.', flags: 64 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -9,13 +9,8 @@ module.exports = {
|
|||||||
.setName('setup-autorole')
|
.setName('setup-autorole')
|
||||||
.setDescription('Interactively set up the autorole for this server.'),
|
.setDescription('Interactively set up the autorole for this server.'),
|
||||||
async execute(interaction) {
|
async execute(interaction) {
|
||||||
const db = readDb();
|
|
||||||
const guildId = interaction.guildId;
|
const guildId = interaction.guildId;
|
||||||
|
|
||||||
if (!db[guildId]) {
|
|
||||||
db[guildId] = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const roleSelect = new RoleSelectMenuBuilder()
|
const roleSelect = new RoleSelectMenuBuilder()
|
||||||
.setCustomId('autorole_role_select')
|
.setCustomId('autorole_role_select')
|
||||||
.setPlaceholder('Select the role to assign on join.');
|
.setPlaceholder('Select the role to assign on join.');
|
||||||
@@ -45,11 +40,20 @@ module.exports = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
db[guildId].autorole = {
|
// persist to backend
|
||||||
enabled: true,
|
try {
|
||||||
roleId: roleId,
|
const api = require('../api');
|
||||||
};
|
const existing = await api.getServerSettings(guildId) || {};
|
||||||
|
existing.autorole = { enabled: true, roleId };
|
||||||
|
await api.upsertServerSettings(guildId, existing);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error persisting autorole to backend, falling back to local:', e);
|
||||||
|
const { readDb, writeDb } = require('../../backend/db.js');
|
||||||
|
const db = readDb();
|
||||||
|
if (!db[guildId]) db[guildId] = {};
|
||||||
|
db[guildId].autorole = { enabled: true, roleId };
|
||||||
writeDb(db);
|
writeDb(db);
|
||||||
|
}
|
||||||
|
|
||||||
await roleConfirmation.update({
|
await roleConfirmation.update({
|
||||||
content: `Autorole setup complete! New members will be assigned the **${role.name}** role.`,
|
content: `Autorole setup complete! New members will be assigned the **${role.name}** role.`,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const { SlashCommandBuilder, ActionRowBuilder, ChannelSelectMenuBuilder, StringSelectMenuBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, ComponentType } = require('discord.js');
|
const { SlashCommandBuilder, ActionRowBuilder, ChannelSelectMenuBuilder, StringSelectMenuBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, ComponentType } = require('discord.js');
|
||||||
|
const api = require('../api');
|
||||||
const { readDb, writeDb } = require('../../backend/db.js');
|
const { readDb, writeDb } = require('../../backend/db.js');
|
||||||
|
|
||||||
const defaultLeaveMessages = ["{user} has left the server.", "Goodbye, {user}.", "We'll miss you, {user}."];
|
const defaultLeaveMessages = ["{user} has left the server.", "Goodbye, {user}.", "We'll miss you, {user}."];
|
||||||
@@ -38,8 +39,19 @@ module.exports = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const channelId = channelConfirmation.values[0];
|
const channelId = channelConfirmation.values[0];
|
||||||
|
try {
|
||||||
|
const existing = (await api.getServerSettings(guildId)) || {};
|
||||||
|
existing.leaveEnabled = true;
|
||||||
|
existing.leaveChannel = channelId;
|
||||||
|
await api.upsertServerSettings(guildId, existing);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error persisting leave settings to backend, falling back to local:', e);
|
||||||
|
const db = readDb();
|
||||||
|
if (!db[guildId]) db[guildId] = {};
|
||||||
db[guildId].leaveChannel = channelId;
|
db[guildId].leaveChannel = channelId;
|
||||||
db[guildId].leaveEnabled = true;
|
db[guildId].leaveEnabled = true;
|
||||||
|
writeDb(db);
|
||||||
|
}
|
||||||
|
|
||||||
const messageOptions = defaultLeaveMessages.map(msg => ({
|
const messageOptions = defaultLeaveMessages.map(msg => ({
|
||||||
label: msg.length > 100 ? msg.substring(0, 97) + '...' : msg,
|
label: msg.length > 100 ? msg.substring(0, 97) + '...' : msg,
|
||||||
@@ -92,8 +104,17 @@ module.exports = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const customMessage = modalSubmit.fields.getTextInputValue('custom_message_input');
|
const customMessage = modalSubmit.fields.getTextInputValue('custom_message_input');
|
||||||
|
try {
|
||||||
|
const existing = (await api.getServerSettings(guildId)) || {};
|
||||||
|
existing.leaveMessage = customMessage;
|
||||||
|
await api.upsertServerSettings(guildId, existing);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error persisting leave message to backend, falling back to local:', e);
|
||||||
|
const db = readDb();
|
||||||
|
if (!db[guildId]) db[guildId] = {};
|
||||||
db[guildId].leaveMessage = customMessage;
|
db[guildId].leaveMessage = customMessage;
|
||||||
writeDb(db);
|
writeDb(db);
|
||||||
|
}
|
||||||
|
|
||||||
await modalSubmit.reply({
|
await modalSubmit.reply({
|
||||||
content: `Leave message setup complete! Channel: <#${channelId}>, Message: "${customMessage}"`,
|
content: `Leave message setup complete! Channel: <#${channelId}>, Message: "${customMessage}"`,
|
||||||
@@ -101,8 +122,17 @@ module.exports = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
|
try {
|
||||||
|
const existing = (await api.getServerSettings(guildId)) || {};
|
||||||
|
existing.leaveMessage = selectedMessage;
|
||||||
|
await api.upsertServerSettings(guildId, existing);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error persisting leave message to backend, falling back to local:', e);
|
||||||
|
const db = readDb();
|
||||||
|
if (!db[guildId]) db[guildId] = {};
|
||||||
db[guildId].leaveMessage = selectedMessage;
|
db[guildId].leaveMessage = selectedMessage;
|
||||||
writeDb(db);
|
writeDb(db);
|
||||||
|
}
|
||||||
await messageConfirmation.update({
|
await messageConfirmation.update({
|
||||||
content: `Leave message setup complete! Channel: <#${channelId}>, Message: "${selectedMessage}"`,
|
content: `Leave message setup complete! Channel: <#${channelId}>, Message: "${selectedMessage}"`,
|
||||||
components: [],
|
components: [],
|
||||||
|
|||||||
43
discord-bot/commands/setup-live.js
Normal file
43
discord-bot/commands/setup-live.js
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
const { SlashCommandBuilder, PermissionsBitField } = require('discord.js');
|
||||||
|
const fetch = require('node-fetch');
|
||||||
|
const api = require('../api');
|
||||||
|
const { readDb, writeDb } = require('../../backend/db');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
name: 'setup-live',
|
||||||
|
description: 'Admin: configure Twitch live notifications for this server',
|
||||||
|
enabled: true,
|
||||||
|
builder: new SlashCommandBuilder()
|
||||||
|
.setName('setup-live')
|
||||||
|
.setDescription('Configure Twitch live notifications for this server')
|
||||||
|
.addStringOption(opt => opt.setName('twitch_user').setDescription('Twitch username to watch').setRequired(true))
|
||||||
|
.addChannelOption(opt => opt.setName('channel').setDescription('Channel to send notifications').setRequired(true))
|
||||||
|
.addBooleanOption(opt => opt.setName('enabled').setDescription('Enable/disable notifications').setRequired(true)),
|
||||||
|
|
||||||
|
async execute(interaction) {
|
||||||
|
if (!interaction.member.permissions.has(PermissionsBitField.Flags.Administrator)) {
|
||||||
|
await interaction.reply({ content: 'You must be a server administrator to configure live notifications.', flags: 64 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const twitchUser = interaction.options.getString('twitch_user');
|
||||||
|
const channel = interaction.options.getChannel('channel');
|
||||||
|
const enabled = interaction.options.getBoolean('enabled');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = require('../api');
|
||||||
|
const existing = (await api.getServerSettings(interaction.guildId)) || {};
|
||||||
|
existing.liveNotifications = { enabled: !!enabled, twitchUser, channelId: channel.id };
|
||||||
|
await api.upsertServerSettings(interaction.guildId, existing);
|
||||||
|
await interaction.reply({ content: `Live notifications ${enabled ? 'enabled' : 'disabled'} for ${twitchUser} -> ${channel.name}`, flags: 64 });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error saving live notifications to backend, falling back to local:', e);
|
||||||
|
// fallback to local db
|
||||||
|
const db = readDb();
|
||||||
|
if (!db[interaction.guildId]) db[interaction.guildId] = {};
|
||||||
|
db[interaction.guildId].liveNotifications = { enabled, twitchUser, channelId: channel.id };
|
||||||
|
writeDb(db);
|
||||||
|
await interaction.reply({ content: `Saved locally: Live notifications ${enabled ? 'enabled' : 'disabled'} for ${twitchUser} -> ${channel.name}`, flags: 64 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
const { SlashCommandBuilder, ActionRowBuilder, ChannelSelectMenuBuilder, StringSelectMenuBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, ComponentType } = require('discord.js');
|
const { SlashCommandBuilder, ActionRowBuilder, ChannelSelectMenuBuilder, StringSelectMenuBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, ComponentType } = require('discord.js');
|
||||||
|
const api = require('../api');
|
||||||
const { readDb, writeDb } = require('../../backend/db.js');
|
const { readDb, writeDb } = require('../../backend/db.js');
|
||||||
|
|
||||||
const defaultWelcomeMessages = ["Welcome to the server, {user}!", "Hey {user}, welcome!", "{user} has joined the party!"];
|
const defaultWelcomeMessages = ["Welcome to the server, {user}!", "Hey {user}, welcome!", "{user} has joined the party!"];
|
||||||
@@ -38,8 +39,20 @@ module.exports = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const channelId = channelConfirmation.values[0];
|
const channelId = channelConfirmation.values[0];
|
||||||
|
// persist via backend
|
||||||
|
try {
|
||||||
|
const existing = (await api.getServerSettings(guildId)) || {};
|
||||||
|
existing.welcomeEnabled = true;
|
||||||
|
existing.welcomeChannel = channelId;
|
||||||
|
await api.upsertServerSettings(guildId, existing);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error persisting welcome settings to backend, falling back to local:', e);
|
||||||
|
const db = readDb();
|
||||||
|
if (!db[guildId]) db[guildId] = {};
|
||||||
db[guildId].welcomeChannel = channelId;
|
db[guildId].welcomeChannel = channelId;
|
||||||
db[guildId].welcomeEnabled = true;
|
db[guildId].welcomeEnabled = true;
|
||||||
|
writeDb(db);
|
||||||
|
}
|
||||||
|
|
||||||
const messageOptions = defaultWelcomeMessages.map(msg => ({
|
const messageOptions = defaultWelcomeMessages.map(msg => ({
|
||||||
label: msg.length > 100 ? msg.substring(0, 97) + '...' : msg,
|
label: msg.length > 100 ? msg.substring(0, 97) + '...' : msg,
|
||||||
@@ -92,8 +105,17 @@ module.exports = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const customMessage = modalSubmit.fields.getTextInputValue('custom_message_input');
|
const customMessage = modalSubmit.fields.getTextInputValue('custom_message_input');
|
||||||
|
try {
|
||||||
|
const existing = (await api.getServerSettings(guildId)) || {};
|
||||||
|
existing.welcomeMessage = customMessage;
|
||||||
|
await api.upsertServerSettings(guildId, existing);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error persisting welcome message to backend, falling back to local:', e);
|
||||||
|
const db = readDb();
|
||||||
|
if (!db[guildId]) db[guildId] = {};
|
||||||
db[guildId].welcomeMessage = customMessage;
|
db[guildId].welcomeMessage = customMessage;
|
||||||
writeDb(db);
|
writeDb(db);
|
||||||
|
}
|
||||||
|
|
||||||
await modalSubmit.reply({
|
await modalSubmit.reply({
|
||||||
content: `Welcome message setup complete! Channel: <#${channelId}>, Message: "${customMessage}"`,
|
content: `Welcome message setup complete! Channel: <#${channelId}>, Message: "${customMessage}"`,
|
||||||
@@ -101,8 +123,17 @@ module.exports = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
|
try {
|
||||||
|
const existing = (await api.getServerSettings(guildId)) || {};
|
||||||
|
existing.welcomeMessage = selectedMessage;
|
||||||
|
await api.upsertServerSettings(guildId, existing);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error persisting welcome message to backend, falling back to local:', e);
|
||||||
|
const db = readDb();
|
||||||
|
if (!db[guildId]) db[guildId] = {};
|
||||||
db[guildId].welcomeMessage = selectedMessage;
|
db[guildId].welcomeMessage = selectedMessage;
|
||||||
writeDb(db);
|
writeDb(db);
|
||||||
|
}
|
||||||
await messageConfirmation.update({
|
await messageConfirmation.update({
|
||||||
content: `Welcome message setup complete! Channel: <#${channelId}>, Message: "${selectedMessage}"`,
|
content: `Welcome message setup complete! Channel: <#${channelId}>, Message: "${selectedMessage}"`,
|
||||||
components: [],
|
components: [],
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const { SlashCommandBuilder } = require('discord.js');
|
const { SlashCommandBuilder } = require('discord.js');
|
||||||
const { readDb } = require('../../backend/db.js');
|
const api = require('../api');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
name: 'view-autorole',
|
name: 'view-autorole',
|
||||||
@@ -9,9 +9,8 @@ module.exports = {
|
|||||||
.setName('view-autorole')
|
.setName('view-autorole')
|
||||||
.setDescription('View the current autorole configuration for this server.'),
|
.setDescription('View the current autorole configuration for this server.'),
|
||||||
async execute(interaction) {
|
async execute(interaction) {
|
||||||
const db = readDb();
|
|
||||||
const guildId = interaction.guildId;
|
const guildId = interaction.guildId;
|
||||||
const settings = db[guildId] || {};
|
const settings = (await api.getServerSettings(guildId)) || {};
|
||||||
const autorole = settings.autorole || { enabled: false, roleId: '' };
|
const autorole = settings.autorole || { enabled: false, roleId: '' };
|
||||||
|
|
||||||
if (!autorole.enabled) {
|
if (!autorole.enabled) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const { SlashCommandBuilder } = require('discord.js');
|
const { SlashCommandBuilder } = require('discord.js');
|
||||||
const { readDb } = require('../../backend/db.js');
|
const api = require('../api');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
name: 'view-welcome-leave',
|
name: 'view-welcome-leave',
|
||||||
@@ -9,9 +9,8 @@ module.exports = {
|
|||||||
.setName('view-welcome-leave')
|
.setName('view-welcome-leave')
|
||||||
.setDescription('View the current welcome and leave message configuration.'),
|
.setDescription('View the current welcome and leave message configuration.'),
|
||||||
async execute(interaction) {
|
async execute(interaction) {
|
||||||
const db = readDb();
|
|
||||||
const guildId = interaction.guildId;
|
const guildId = interaction.guildId;
|
||||||
const settings = db[guildId] || {};
|
const settings = (await api.getServerSettings(guildId)) || {};
|
||||||
|
|
||||||
const welcomeChannel = settings.welcomeChannel ? `<#${settings.welcomeChannel}>` : 'Not set';
|
const welcomeChannel = settings.welcomeChannel ? `<#${settings.welcomeChannel}>` : 'Not set';
|
||||||
const welcomeMessage = settings.welcomeMessage || 'Not set';
|
const welcomeMessage = settings.welcomeMessage || 'Not set';
|
||||||
|
|||||||
@@ -23,16 +23,17 @@ const rest = new REST({ version: '10' }).setToken(process.env.DISCORD_BOT_TOKEN)
|
|||||||
|
|
||||||
const deployCommands = async (guildId) => {
|
const deployCommands = async (guildId) => {
|
||||||
try {
|
try {
|
||||||
console.log(`Started refreshing application (/) commands for guild ${guildId}.`);
|
// Minimal logging: indicate a refresh is happening (no per-guild spam)
|
||||||
|
console.log('🔁 Refreshing application commands...');
|
||||||
|
|
||||||
await rest.put(
|
await rest.put(
|
||||||
Routes.applicationGuildCommands(process.env.DISCORD_CLIENT_ID, guildId),
|
Routes.applicationGuildCommands(process.env.DISCORD_CLIENT_ID, guildId),
|
||||||
{ body: commands },
|
{ body: commands },
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(`Successfully reloaded application (/) commands for guild ${guildId}.`);
|
console.log(`✅ Reloaded application commands (${commands.length} commands)`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error('Failed to deploy commands:', error && error.message ? error.message : error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,23 +5,30 @@ module.exports = {
|
|||||||
name: Events.GuildMemberAdd,
|
name: Events.GuildMemberAdd,
|
||||||
async execute(member) {
|
async execute(member) {
|
||||||
try {
|
try {
|
||||||
const db = readDb();
|
const api = require('../api');
|
||||||
const settings = db[member.guild.id];
|
const settings = (await api.getServerSettings(member.guild.id)) || {};
|
||||||
|
|
||||||
if (settings && settings.welcomeEnabled && settings.welcomeChannel) {
|
const welcome = {
|
||||||
const channel = member.guild.channels.cache.get(settings.welcomeChannel);
|
enabled: settings.welcomeEnabled || false,
|
||||||
|
channel: settings.welcomeChannel || '',
|
||||||
|
message: settings.welcomeMessage || 'Welcome {user} to {server}!'
|
||||||
|
};
|
||||||
|
|
||||||
|
if (welcome && welcome.enabled && welcome.channel) {
|
||||||
|
const channel = member.guild.channels.cache.get(welcome.channel);
|
||||||
if (channel) {
|
if (channel) {
|
||||||
try {
|
try {
|
||||||
const message = (settings.welcomeMessage || 'Welcome {user} to {server}!').replace('{user}', member.user.toString()).replace('{server}', member.guild.name);
|
const message = (welcome.message).replace('{user}', member.user.toString()).replace('{server}', member.guild.name);
|
||||||
await channel.send(message);
|
await channel.send(message);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Could not send welcome message to channel ${settings.welcomeChannel} in guild ${member.guild.id}:`, error);
|
console.error(`Could not send welcome message to channel ${welcome.channel} in guild ${member.guild.id}:`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (settings && settings.autorole && settings.autorole.enabled && settings.autorole.roleId) {
|
const autorole = settings.autorole || {};
|
||||||
const role = member.guild.roles.cache.get(settings.autorole.roleId);
|
if (autorole && autorole.enabled && autorole.roleId) {
|
||||||
|
const role = member.guild.roles.cache.get(autorole.roleId);
|
||||||
if (role) {
|
if (role) {
|
||||||
try {
|
try {
|
||||||
// Re-check that role is assignable
|
// Re-check that role is assignable
|
||||||
@@ -38,6 +45,28 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error in guildMemberAdd event for guild ${member.guild.id}:`, 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -5,39 +5,37 @@ module.exports = {
|
|||||||
name: Events.GuildMemberRemove,
|
name: Events.GuildMemberRemove,
|
||||||
async execute(member) {
|
async execute(member) {
|
||||||
try {
|
try {
|
||||||
const db = readDb();
|
const api = require('../api');
|
||||||
const settings = db[member.guild.id];
|
const settings = (await api.getServerSettings(member.guild.id)) || {};
|
||||||
|
const leave = { enabled: settings.leaveEnabled || false, channel: settings.leaveChannel || '', message: settings.leaveMessage || '{user} has left the server.' };
|
||||||
|
|
||||||
if (settings && settings.leaveEnabled && settings.leaveChannel) {
|
if (leave && leave.enabled && leave.channel) {
|
||||||
let channel = member.guild.channels.cache.get(settings.leaveChannel);
|
const channel = member.guild.channels.cache.get(leave.channel);
|
||||||
if (!channel) {
|
if (channel) {
|
||||||
try {
|
try {
|
||||||
channel = await member.guild.channels.fetch(settings.leaveChannel);
|
const message = (leave.message).replace('{user}', member.user.toString());
|
||||||
} catch (err) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (channel && channel.isTextBased && channel.isTextBased()) {
|
|
||||||
try {
|
|
||||||
const me = member.guild.members.me;
|
|
||||||
const perms = channel.permissionsFor(me);
|
|
||||||
if (!perms || !perms.has('ViewChannel') || !perms.has('SendMessages')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const userMention = member.user ? (member.user.toString ? member.user.toString() : member.user.tag) : 'A user';
|
|
||||||
const message = (settings.leaveMessage || '{user} has left the server.').replace('{user}', userMention).replace('{server}', member.guild.name);
|
|
||||||
await channel.send(message);
|
await channel.send(message);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Could not send leave message to channel ${settings.leaveChannel} in guild ${member.guild.id}:`, error);
|
console.error(`Could not send leave message to channel ${leave.channel} in guild ${member.guild.id}:`, error);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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 */ }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -5,11 +5,15 @@ module.exports = {
|
|||||||
name: 'clientReady',
|
name: 'clientReady',
|
||||||
once: true,
|
once: true,
|
||||||
async execute(client) {
|
async execute(client) {
|
||||||
console.log('ECS - Full Stack Bot Online!');
|
const guildIds = client.guilds.cache.map(guild => guild.id);
|
||||||
|
if (guildIds.length > 0) {
|
||||||
const guilds = client.guilds.cache.map(guild => guild.id);
|
// Deploy commands for all guilds in parallel, but only log a single summary
|
||||||
for (const guildId of guilds) {
|
try {
|
||||||
await deployCommands(guildId);
|
await Promise.all(guildIds.map(id => deployCommands(id)));
|
||||||
|
console.log(`🔁 Refreshed application commands for ${guildIds.length} guild(s)`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error refreshing application commands:', e && e.message ? e.message : e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const activities = [
|
const activities = [
|
||||||
@@ -26,5 +30,8 @@ module.exports = {
|
|||||||
client.user.setActivity(activity.name, { type: activity.type, url: activity.url });
|
client.user.setActivity(activity.name, { type: activity.type, url: activity.url });
|
||||||
activityIndex = (activityIndex + 1) % activities.length;
|
activityIndex = (activityIndex + 1) % activities.length;
|
||||||
}, 3000);
|
}, 3000);
|
||||||
|
|
||||||
|
// Signal that startup is complete
|
||||||
|
console.log('✅ ECS - Full Stack Bot Online!');
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,12 +2,25 @@ const { Client, GatewayIntentBits, Collection } = require('discord.js');
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const deployCommands = require('./deploy-commands');
|
const deployCommands = require('./deploy-commands');
|
||||||
const { readDb } = require('../backend/db');
|
// legacy local db is available as a fallback in some commands via require('../../backend/db')
|
||||||
|
const api = require('./api');
|
||||||
|
|
||||||
const client = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers] });
|
const client = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers] });
|
||||||
|
|
||||||
client.commands = new Collection();
|
client.commands = new Collection();
|
||||||
|
|
||||||
|
// In-memory cache of server settings to allow backend to push updates and make toggles immediate
|
||||||
|
const guildSettingsCache = new Map();
|
||||||
|
|
||||||
|
function setGuildSettings(guildId, settings) {
|
||||||
|
if (!guildId) return;
|
||||||
|
guildSettingsCache.set(guildId, settings || {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGuildSettingsFromCache(guildId) {
|
||||||
|
return guildSettingsCache.get(guildId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
const commandHandler = require('./handlers/command-handler');
|
const commandHandler = require('./handlers/command-handler');
|
||||||
const eventHandler = require('./handlers/event-handler');
|
const eventHandler = require('./handlers/event-handler');
|
||||||
|
|
||||||
@@ -20,14 +33,18 @@ client.on('interactionCreate', async interaction => {
|
|||||||
const id = interaction.customId || '';
|
const id = interaction.customId || '';
|
||||||
if (id.startsWith('copy_inv_')) {
|
if (id.startsWith('copy_inv_')) {
|
||||||
const code = id.replace('copy_inv_', '');
|
const code = id.replace('copy_inv_', '');
|
||||||
const db = readDb();
|
try {
|
||||||
const invites = (db[interaction.guildId] && db[interaction.guildId].invites) ? db[interaction.guildId].invites : [];
|
const invites = await api.listInvites(interaction.guildId);
|
||||||
const inv = invites.find(i => i.code === code);
|
const inv = (invites || []).find(i => i.code === code);
|
||||||
if (inv) {
|
if (inv) {
|
||||||
await interaction.reply({ content: `Invite: ${inv.url}`, ephemeral: true });
|
await interaction.reply({ content: `Invite: ${inv.url}`, ephemeral: true });
|
||||||
} else {
|
} else {
|
||||||
await interaction.reply({ content: 'Invite not found.', ephemeral: true });
|
await interaction.reply({ content: 'Invite not found.', ephemeral: true });
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error fetching invites via API:', e);
|
||||||
|
await interaction.reply({ content: 'Invite not found.', ephemeral: true });
|
||||||
|
}
|
||||||
} else if (id.startsWith('delete_inv_')) {
|
} else if (id.startsWith('delete_inv_')) {
|
||||||
const code = id.replace('delete_inv_', '');
|
const code = id.replace('delete_inv_', '');
|
||||||
// permission check: admin only
|
// permission check: admin only
|
||||||
@@ -37,12 +54,13 @@ client.on('interactionCreate', async interaction => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
// call backend delete endpoint
|
// call backend delete endpoint via helper
|
||||||
const fetch = require('node-fetch');
|
const ok = await api.deleteInvite(interaction.guildId, code);
|
||||||
const backendBase = process.env.BACKEND_BASE || `http://${process.env.HOST || '127.0.0.1'}:${process.env.PORT || 3002}`;
|
if (ok) {
|
||||||
const url = `${backendBase}/api/servers/${interaction.guildId}/invites/${code}`;
|
|
||||||
await fetch(url, { method: 'DELETE' });
|
|
||||||
await interaction.reply({ content: 'Invite deleted.', ephemeral: true });
|
await interaction.reply({ content: 'Invite deleted.', ephemeral: true });
|
||||||
|
} else {
|
||||||
|
await interaction.reply({ content: 'Failed to delete invite via API.', ephemeral: true });
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error deleting invite via API:', e);
|
console.error('Error deleting invite via API:', e);
|
||||||
await interaction.reply({ content: 'Failed to delete invite.', ephemeral: true });
|
await interaction.reply({ content: 'Failed to delete invite.', ephemeral: true });
|
||||||
@@ -57,11 +75,28 @@ client.on('interactionCreate', async interaction => {
|
|||||||
|
|
||||||
if (!command) return;
|
if (!command) return;
|
||||||
|
|
||||||
// Check per-guild toggles
|
// Check per-guild toggles via Postgres (directly) for lower latency and reliability
|
||||||
try {
|
try {
|
||||||
const db = readDb();
|
// authoritative path: always try the backend HTTP API first so separate processes stay in sync
|
||||||
const guildSettings = db[interaction.guildId] || {};
|
let guildSettings = await api.getServerSettings(interaction.guildId) || {};
|
||||||
const toggles = guildSettings.commandToggles || {};
|
// if API didn't return anything useful, try in-memory cache then direct DB as fallbacks
|
||||||
|
if (!guildSettings || Object.keys(guildSettings).length === 0) {
|
||||||
|
guildSettings = getGuildSettingsFromCache(interaction.guildId) || {};
|
||||||
|
if (!guildSettings || Object.keys(guildSettings).length === 0) {
|
||||||
|
try {
|
||||||
|
const pg = require('../backend/pg');
|
||||||
|
guildSettings = (await pg.getServerSettings(interaction.guildId)) || {};
|
||||||
|
} catch (pgErr) {
|
||||||
|
// leave guildSettings empty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Normalize legacy flags into commandToggles for backward compatibility
|
||||||
|
const toggles = { ...(guildSettings.commandToggles || {}) };
|
||||||
|
// Example legacy flag mapping: pingCommand -> commandToggles.ping
|
||||||
|
if (typeof guildSettings.pingCommand !== 'undefined') {
|
||||||
|
toggles.ping = !!guildSettings.pingCommand;
|
||||||
|
}
|
||||||
const protectedCommands = ['manage-commands', 'help'];
|
const protectedCommands = ['manage-commands', 'help'];
|
||||||
|
|
||||||
// If command is protected, always allow
|
// If command is protected, always allow
|
||||||
@@ -97,4 +132,82 @@ const login = () => {
|
|||||||
client.login(process.env.DISCORD_BOT_TOKEN);
|
client.login(process.env.DISCORD_BOT_TOKEN);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { login, client };
|
// Allow backend to trigger a live announcement for debugging
|
||||||
|
async function announceLive(guildId, stream) {
|
||||||
|
try {
|
||||||
|
const guild = await client.guilds.fetch(guildId).catch(() => null);
|
||||||
|
if (!guild) return { success: false, message: 'Guild not found' };
|
||||||
|
const settings = await (require('../backend/pg')).getServerSettings(guildId).catch(() => null) || await (require('./api')).getServerSettings(guildId).catch(() => ({}));
|
||||||
|
const liveSettings = (settings && settings.liveNotifications) || {};
|
||||||
|
if (!liveSettings.enabled) return { success: false, message: 'Live notifications disabled' };
|
||||||
|
const channelId = liveSettings.channelId;
|
||||||
|
if (!channelId) return { success: false, message: 'No channel configured' };
|
||||||
|
const channel = await guild.channels.fetch(channelId).catch(() => null);
|
||||||
|
if (!channel) return { success: false, message: 'Channel not found' };
|
||||||
|
const { EmbedBuilder } = require('discord.js');
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setColor(0x9146FF)
|
||||||
|
.setTitle(stream.title || `${stream.user_name} is live`)
|
||||||
|
.setURL(stream.url)
|
||||||
|
.setAuthor({ name: stream.user_name, iconURL: stream.profile_image_url || undefined, url: stream.url })
|
||||||
|
.setThumbnail(stream.thumbnail_url || stream.profile_image_url || undefined)
|
||||||
|
.addFields(
|
||||||
|
{ name: 'Category', value: stream.game_name || 'Unknown', inline: true },
|
||||||
|
{ name: 'Viewers', value: String(stream.viewer_count || 0), inline: true }
|
||||||
|
)
|
||||||
|
.setDescription(stream.description || '')
|
||||||
|
.setFooter({ text: `ehchadservices • Started: ${stream.started_at ? new Date(stream.started_at).toLocaleString() : 'unknown'}` });
|
||||||
|
await channel.send({ embeds: [embed] });
|
||||||
|
return { success: true };
|
||||||
|
} catch (e) {
|
||||||
|
console.error('announceLive failed:', e && e.message ? e.message : e);
|
||||||
|
return { success: false, message: e && e.message ? e.message : 'unknown error' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { login, client, setGuildSettings, getGuildSettingsFromCache, announceLive };
|
||||||
|
|
||||||
|
// Start twitch watcher when client is ready (use 'clientReady' as the event name)
|
||||||
|
try {
|
||||||
|
const watcher = require('./twitch-watcher');
|
||||||
|
// discord.js renamed the ready event to clientReady; the event loader registers
|
||||||
|
// handlers based on event.name so we listen for the same 'clientReady' here.
|
||||||
|
client.once('clientReady', () => {
|
||||||
|
// start polling in background
|
||||||
|
watcher.poll(client).catch(err => console.error('Twitch watcher failed to start:', err));
|
||||||
|
});
|
||||||
|
process.on('exit', () => { watcher.stop(); });
|
||||||
|
process.on('SIGINT', () => { watcher.stop(); process.exit(); });
|
||||||
|
} catch (e) {
|
||||||
|
// ignore if watcher not available
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Optional push receiver (so backend can notify a remote bot process) ---
|
||||||
|
try {
|
||||||
|
const express = require('express');
|
||||||
|
const bodyParser = require('body-parser');
|
||||||
|
const botPort = process.env.BOT_PUSH_PORT || process.env.BOT_PORT || null;
|
||||||
|
const botSecret = process.env.BOT_SECRET || null;
|
||||||
|
if (botPort) {
|
||||||
|
const app = express();
|
||||||
|
app.use(bodyParser.json());
|
||||||
|
app.post('/internal/set-settings', (req, res) => {
|
||||||
|
try {
|
||||||
|
if (botSecret) {
|
||||||
|
const provided = req.headers['x-bot-secret'];
|
||||||
|
if (!provided || provided !== botSecret) return res.status(401).json({ success: false, message: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
const { guildId, settings } = req.body || {};
|
||||||
|
if (!guildId) return res.status(400).json({ success: false, message: 'Missing guildId' });
|
||||||
|
setGuildSettings(guildId, settings || {});
|
||||||
|
return res.json({ success: true });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error in bot push receiver:', e);
|
||||||
|
return res.status(500).json({ success: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
app.listen(botPort, () => console.log(`Bot push receiver listening on port ${botPort}`));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// ignore if express isn't available in this environment
|
||||||
|
}
|
||||||
45
discord-bot/package-lock.json
generated
45
discord-bot/package-lock.json
generated
@@ -11,7 +11,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"crypto-js": "^4.2.0",
|
"crypto-js": "^4.2.0",
|
||||||
"discord.js": "^14.22.1",
|
"discord.js": "^14.22.1",
|
||||||
"dotenv": "^16.4.5"
|
"dotenv": "^16.4.5",
|
||||||
|
"node-fetch": "^2.6.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@discordjs/builders": {
|
"node_modules/@discordjs/builders": {
|
||||||
@@ -314,6 +315,32 @@
|
|||||||
"integrity": "sha512-ThQLOhN86ZkJ7qemtVRGYM+gRgR8GEXNli9H/PMvpnZsE44Xfh3wx9kGJaldg314v85m+bFW6WBMaVHJc/c3zA==",
|
"integrity": "sha512-ThQLOhN86ZkJ7qemtVRGYM+gRgR8GEXNli9H/PMvpnZsE44Xfh3wx9kGJaldg314v85m+bFW6WBMaVHJc/c3zA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/node-fetch": {
|
||||||
|
"version": "2.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||||
|
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"whatwg-url": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "4.x || >=6.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"encoding": "^0.1.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"encoding": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tr46": {
|
||||||
|
"version": "0.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||||
|
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ts-mixer": {
|
"node_modules/ts-mixer": {
|
||||||
"version": "6.0.4",
|
"version": "6.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz",
|
||||||
@@ -341,6 +368,22 @@
|
|||||||
"integrity": "sha512-Ov2Rr9Sx+fRgagJ5AX0qvItZG/JKKoBRAVITs1zk7IqZGTJUwgUr7qoYBpWwakpWilTZFM98rG/AFRocu10iIQ==",
|
"integrity": "sha512-Ov2Rr9Sx+fRgagJ5AX0qvItZG/JKKoBRAVITs1zk7IqZGTJUwgUr7qoYBpWwakpWilTZFM98rG/AFRocu10iIQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/webidl-conversions": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/whatwg-url": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tr46": "~0.0.3",
|
||||||
|
"webidl-conversions": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ws": {
|
"node_modules/ws": {
|
||||||
"version": "8.18.3",
|
"version": "8.18.3",
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||||
|
|||||||
@@ -13,5 +13,6 @@
|
|||||||
"crypto-js": "^4.2.0",
|
"crypto-js": "^4.2.0",
|
||||||
"discord.js": "^14.22.1",
|
"discord.js": "^14.22.1",
|
||||||
"dotenv": "^16.4.5"
|
"dotenv": "^16.4.5"
|
||||||
|
,"node-fetch": "^2.6.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
126
discord-bot/twitch-watcher.js
Normal file
126
discord-bot/twitch-watcher.js
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
const api = require('./api');
|
||||||
|
|
||||||
|
let polling = false;
|
||||||
|
const pollIntervalMs = Number(process.env.TWITCH_POLL_INTERVAL_MS || 5000); // 5s default
|
||||||
|
|
||||||
|
// Keep track of which streams we've already announced per guild:user -> { started_at }
|
||||||
|
const announced = new Map(); // key: `${guildId}:${user}` -> { started_at }
|
||||||
|
|
||||||
|
async function checkGuild(client, guild) {
|
||||||
|
try {
|
||||||
|
// Intentionally quiet: per-guild checking logs are suppressed to avoid spam
|
||||||
|
const settings = await api.getServerSettings(guild.id) || {};
|
||||||
|
const liveSettings = settings.liveNotifications || {};
|
||||||
|
if (!liveSettings.enabled) return;
|
||||||
|
const channelId = liveSettings.channelId;
|
||||||
|
const users = (liveSettings.users || []).map(u => u.toLowerCase()).filter(Boolean);
|
||||||
|
if (!channelId || users.length === 0) return;
|
||||||
|
// ask backend for current live streams
|
||||||
|
const query = users.join(',');
|
||||||
|
const streams = await api._rawGetTwitchStreams ? api._rawGetTwitchStreams(query) : null;
|
||||||
|
// If the helper isn't available, try backend proxy
|
||||||
|
let live = [];
|
||||||
|
if (streams) live = streams.filter(s => s.is_live);
|
||||||
|
else {
|
||||||
|
try {
|
||||||
|
const resp = await api.tryFetchTwitchStreams(query);
|
||||||
|
live = (resp || []).filter(s => s.is_live);
|
||||||
|
} catch (e) {
|
||||||
|
live = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!live || live.length === 0) {
|
||||||
|
// No live streams: ensure any announced keys for these users are cleared so they can be re-announced later
|
||||||
|
for (const u of users) {
|
||||||
|
const key = `${guild.id}:${u}`;
|
||||||
|
if (announced.has(key)) {
|
||||||
|
announced.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// fetch channel using client to ensure we can reach it
|
||||||
|
let channel = null;
|
||||||
|
try {
|
||||||
|
channel = await client.channels.fetch(channelId);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`TwitchWatcher: failed to fetch channel ${channelId}:`, e && e.message ? e.message : e);
|
||||||
|
channel = null;
|
||||||
|
}
|
||||||
|
if (!channel) {
|
||||||
|
// Channel not found or inaccessible; skip
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a map of live logins for quick lookup
|
||||||
|
const liveLogins = new Set(live.map(s => (s.user_login || '').toLowerCase()));
|
||||||
|
|
||||||
|
// Clear announced entries for users that are no longer live
|
||||||
|
for (const u of users) {
|
||||||
|
const key = `${guild.id}:${u}`;
|
||||||
|
if (!liveLogins.has(u) && announced.has(key)) {
|
||||||
|
announced.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Announce each live once per live session
|
||||||
|
for (const s of live) {
|
||||||
|
const login = (s.user_login || '').toLowerCase();
|
||||||
|
const key = `${guild.id}:${login}`;
|
||||||
|
if (announced.has(key)) continue; // already announced for this live session
|
||||||
|
// mark announced for this session
|
||||||
|
announced.set(key, { started_at: s.started_at || new Date().toISOString() });
|
||||||
|
|
||||||
|
// Build and send embed
|
||||||
|
try {
|
||||||
|
// Announce without per-guild log spam
|
||||||
|
const { EmbedBuilder } = require('discord.js');
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setColor(0x9146FF)
|
||||||
|
.setTitle(s.title || `${s.user_name} is live`)
|
||||||
|
.setURL(s.url)
|
||||||
|
.setAuthor({ name: s.user_name, iconURL: s.profile_image_url || undefined, url: s.url })
|
||||||
|
.setThumbnail(s.thumbnail_url || s.profile_image_url || undefined)
|
||||||
|
.addFields(
|
||||||
|
{ name: 'Category', value: s.game_name || 'Unknown', inline: true },
|
||||||
|
{ name: 'Viewers', value: String(s.viewer_count || 0), inline: true }
|
||||||
|
)
|
||||||
|
.setDescription(s.description || '')
|
||||||
|
.setFooter({ text: `ehchadservices • Started: ${s.started_at ? new Date(s.started_at).toLocaleString() : 'unknown'}` });
|
||||||
|
|
||||||
|
await channel.send({ embeds: [embed] });
|
||||||
|
console.log(`🔔 Announced live: ${login} - ${(s.title || '').slice(0, 80)}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`TwitchWatcher: failed to send announcement for ${login}:`, e && e.message ? e.message : e);
|
||||||
|
// fallback
|
||||||
|
const msg = `🔴 ${s.user_name} is live: **${s.title}**\nWatch: ${s.url}`;
|
||||||
|
try { await channel.send({ content: msg }); console.log('TwitchWatcher: fallback message sent'); } catch (err) { console.error('TwitchWatcher: fallback send failed:', err && err.message ? err.message : err); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error checking guild for live streams:', e && e.message ? e.message : e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function poll(client) {
|
||||||
|
if (polling) return;
|
||||||
|
polling = true;
|
||||||
|
console.log(`🔁 TwitchWatcher started, polling every ${Math.round(pollIntervalMs/1000)}s`);
|
||||||
|
while (polling) {
|
||||||
|
try {
|
||||||
|
const guilds = Array.from(client.guilds.cache.values());
|
||||||
|
for (const g of guilds) {
|
||||||
|
await checkGuild(client, g).catch(err => { console.error('TwitchWatcher: checkGuild error', err && err.message ? err.message : err); });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error during twitch poll loop:', e && e.message ? e.message : e);
|
||||||
|
}
|
||||||
|
await new Promise(r => setTimeout(r, pollIntervalMs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop() { polling = false; }
|
||||||
|
|
||||||
|
module.exports = { poll, stop };
|
||||||
68
errors.md
68
errors.md
@@ -1,68 +0,0 @@
|
|||||||
# Error Log
|
|
||||||
|
|
||||||
## ENOENT: no such file or directory, open 'F:\Projects\Github\ECS-Discordweb\discord-bot\events\...ackenddb.json'
|
|
||||||
|
|
||||||
**Context:** This error occurs in the `guildMemberAdd` and `guildMemberRemove` event handlers in the Discord bot when trying to read the `db.json` file.
|
|
||||||
|
|
||||||
**Initial Fix Attempts:**
|
|
||||||
|
|
||||||
1. **`path.join` with relative path:** Initially, the path was constructed using `path.join(__dirname, '..\..\backend\db.json')`. This was incorrect as it didn't go up enough directories.
|
|
||||||
2. **`path.join` with corrected relative path:** The path was corrected to `path.join(__dirname, '..\..\..\backend\db.json')`. This seemed logically correct, but still resulted in the same error, with a strange `......` in the error path.
|
|
||||||
3. **`path.resolve`:** The path construction was changed to use `path.resolve(__dirname, '..\..\..\backend\db.json')` to ensure an absolute path was resolved. This also failed with the same error.
|
|
||||||
|
|
||||||
**Root Cause Analysis:**
|
|
||||||
|
|
||||||
The exact cause of the path resolution failure is unclear, but it seems to be related to how `__dirname` and relative paths are handled when the bot module is required by the backend server. The inconsistent working directory might be the source of the problem.
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
|
|
||||||
To provide a more robust and centralized solution, the database path will be managed within the `backend/db.js` file.
|
|
||||||
|
|
||||||
1. The `dbPath` will be exported from `backend/db.js`.
|
|
||||||
2. The `guildMemberAdd.js` and `guildMemberRemove.js` event handlers will import the `dbPath` directly from `backend/db.js`, eliminating the need for path resolution in the event handlers themselves.
|
|
||||||
|
|
||||||
## SyntaxError: Unexpected token 'U', "U2FsdGVkX1"... is not valid JSON
|
|
||||||
|
|
||||||
**Context:** This error occurs in the `guildMemberAdd` and `guildMemberRemove` event handlers when trying to parse the content of `db.json`.
|
|
||||||
|
|
||||||
**Root Cause Analysis:**
|
|
||||||
|
|
||||||
The `db.json` file is encrypted, and the event handlers were reading the file content directly and trying to parse it as JSON. The encrypted string starts with "U2FsdGVkX1", which is not a valid JSON token, causing the `JSON.parse` to fail.
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
|
|
||||||
The `backend/db.js` module already provides a `readDb` function that handles reading the file and decrypting its content. The event handlers must be updated to use this function instead of reading the file directly. This ensures that the encrypted data is correctly decrypted before being parsed as JSON.
|
|
||||||
|
|
||||||
## Outdated Discord.js Practices
|
|
||||||
|
|
||||||
**Context:** The `discord-bot` is using a slightly outdated version of `discord.js` (`^14.15.3` instead of the latest `^14.22.1`). Additionally, the `package.json` includes `@discordjs/rest` and `discord-api-types` as explicit dependencies, which are now bundled with `discord.js` v14 and can cause conflicts.
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
|
|
||||||
1. Update the `discord.js` dependency to the latest stable version (`^14.22.1`).
|
|
||||||
2. Remove the `@discordjs/rest` and `discord-api-types` dependencies from `package.json`.
|
|
||||||
3. Run `npm install` to apply the changes and ensure all packages are up-to-date.
|
|
||||||
|
|
||||||
## Error: Cannot find module 'discord-api-types/v9'
|
|
||||||
|
|
||||||
**Context:** After removing the `discord-api-types` package, the application fails to start due to a missing module error in `discord-bot/deploy-commands.js`.
|
|
||||||
|
|
||||||
**Root Cause Analysis:**
|
|
||||||
|
|
||||||
The `deploy-commands.js` file was still referencing `discord-api-types/v9` for API-related enums or types. Since this package is no longer an explicit dependency (as it is bundled with `discord.js` v14), the import fails.
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
|
|
||||||
The `deploy-commands.js` file needs to be updated to import the necessary modules, such as `Routes`, directly from `discord.js` instead of the now-removed `discord-api-types` package.
|
|
||||||
|
|
||||||
## DiscordAPIError[10004]: Unknown Guild
|
|
||||||
|
|
||||||
**Context:** The backend throws an "Unknown Guild" error when the frontend tries to fetch the channels for a server that the bot is not a member of.
|
|
||||||
|
|
||||||
**Root Cause Analysis:**
|
|
||||||
|
|
||||||
The `/api/servers/:guildId/channels` endpoint was attempting to fetch guild information regardless of whether the bot was actually in that guild. This caused a Discord API error when the guild was not found.
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
|
|
||||||
The endpoint will be updated to first check if the guild exists in the bot's cache using `bot.client.guilds.cache.get(guildId)`. If the guild is not found, it will return an empty array, preventing the API error.
|
|
||||||
16
frontend/.env.example
Normal file
16
frontend/.env.example
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# Example frontend/.env for developing behind an HTTPS domain and Nginx Proxy Manager
|
||||||
|
# Bind the dev server to the machine/Tailscale IP you want (or omit to use localhost)
|
||||||
|
HOST=0.0.0.0
|
||||||
|
PORT=3001
|
||||||
|
|
||||||
|
# Production API base (use https when serving through nginx with TLS)
|
||||||
|
REACT_APP_API_BASE=https://discordbot.YOURDOMAIN.com
|
||||||
|
|
||||||
|
# Force CRA dev client to use secure websocket to the domain
|
||||||
|
WDS_SOCKET_PROTOCOL=wss
|
||||||
|
WDS_SOCKET_HOST=discordbot.YOURDOMAIN.com
|
||||||
|
WDS_SOCKET_PORT=443
|
||||||
|
WDS_SOCKET_PATH=/ws
|
||||||
|
|
||||||
|
# If CRA uses sockjs-node, set WDS_SOCKET_PATH accordingly:
|
||||||
|
# WDS_SOCKET_PATH=/sockjs-node
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
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 } from '@mui/material';
|
import { Grid, Card, CardContent, Typography, Box, IconButton, Snackbar, Alert, Menu, MenuItem, Button } from '@mui/material';
|
||||||
|
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||||
import { UserContext } from '../contexts/UserContext';
|
import { UserContext } from '../contexts/UserContext';
|
||||||
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';
|
||||||
@@ -11,7 +12,7 @@ import ConfirmDialog from './ConfirmDialog';
|
|||||||
const Dashboard = () => {
|
const Dashboard = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { user } = useContext(UserContext);
|
const { user, setUser } = useContext(UserContext);
|
||||||
|
|
||||||
const [guilds, setGuilds] = useState([]);
|
const [guilds, setGuilds] = useState([]);
|
||||||
const [botStatus, setBotStatus] = useState({});
|
const [botStatus, setBotStatus] = useState({});
|
||||||
@@ -19,31 +20,57 @@ const Dashboard = () => {
|
|||||||
const [snackbarMessage, setSnackbarMessage] = useState('');
|
const [snackbarMessage, setSnackbarMessage] = useState('');
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [selectedGuild, setSelectedGuild] = useState(null);
|
const [selectedGuild, setSelectedGuild] = useState(null);
|
||||||
|
const [menuAnchor, setMenuAnchor] = useState(null);
|
||||||
|
const [menuGuild, setMenuGuild] = useState(null);
|
||||||
|
// Live notifications are managed on the Server Settings page; keep dashboard lightweight
|
||||||
|
|
||||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3002';
|
const API_BASE = process.env.REACT_APP_API_BASE || '';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams(location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
|
const userParam = params.get('user');
|
||||||
|
if (userParam) {
|
||||||
|
try {
|
||||||
|
const parsedUser = JSON.parse(decodeURIComponent(userParam));
|
||||||
|
setUser(parsedUser);
|
||||||
|
localStorage.setItem('user', JSON.stringify(parsedUser));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to parse user from URL", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const guildsParam = params.get('guilds');
|
const guildsParam = params.get('guilds');
|
||||||
if (guildsParam) {
|
if (guildsParam) {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(decodeURIComponent(guildsParam));
|
const parsedGuilds = JSON.parse(decodeURIComponent(guildsParam));
|
||||||
setGuilds(parsed || []);
|
setGuilds(parsedGuilds || []);
|
||||||
localStorage.setItem('guilds', JSON.stringify(parsed || []));
|
localStorage.setItem('guilds', JSON.stringify(parsedGuilds || []));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const stored = localStorage.getItem('guilds');
|
const storedGuilds = localStorage.getItem('guilds');
|
||||||
if (stored) {
|
if (storedGuilds) {
|
||||||
try {
|
try {
|
||||||
setGuilds(JSON.parse(stored));
|
setGuilds(JSON.parse(storedGuilds));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setGuilds([]);
|
setGuilds([]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [location.search]);
|
}, [location.search, setUser]);
|
||||||
|
|
||||||
|
// Protect this route: if no user in context or localStorage, redirect to login
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) {
|
||||||
|
const stored = localStorage.getItem('user');
|
||||||
|
if (!stored) {
|
||||||
|
navigate('/');
|
||||||
|
} else {
|
||||||
|
try { setUser(JSON.parse(stored)); } catch (e) { navigate('/'); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [user, navigate, setUser]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!guilds || guilds.length === 0) return;
|
if (!guilds || guilds.length === 0) return;
|
||||||
@@ -62,6 +89,10 @@ const Dashboard = () => {
|
|||||||
fetchStatuses();
|
fetchStatuses();
|
||||||
}, [guilds, API_BASE]);
|
}, [guilds, API_BASE]);
|
||||||
|
|
||||||
|
// Dashboard no longer loads live settings; that's on the server settings page
|
||||||
|
|
||||||
|
// Live notifications handlers were removed from Dashboard
|
||||||
|
|
||||||
const handleCardClick = (guild) => {
|
const handleCardClick = (guild) => {
|
||||||
navigate(`/server/${guild.id}`, { state: { guild } });
|
navigate(`/server/${guild.id}`, { state: { guild } });
|
||||||
};
|
};
|
||||||
@@ -109,88 +140,63 @@ const Dashboard = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 20 }}>
|
<div style={{ padding: 20 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<Box>
|
||||||
<Typography variant="h4" gutterBottom>Dashboard</Typography>
|
<Typography variant="h4" gutterBottom>Dashboard</Typography>
|
||||||
{user && <Typography variant="h6" gutterBottom>Welcome, {user.username}</Typography>}
|
{user && <Typography variant="h6" gutterBottom>Welcome, {user.username}</Typography>}
|
||||||
|
</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}>
|
<Grid item xs={12} sm={6} md={4} lg={3} key={guild.id} sx={{ display: 'flex', justifyContent: 'center' }}>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
|
<Box sx={{ display: 'flex', justifyContent: 'center', width: '100%' }}>
|
||||||
<Card
|
<Card
|
||||||
onClick={() => handleCardClick(guild)}
|
onClick={() => handleCardClick(guild)}
|
||||||
sx={{
|
sx={{
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
borderRadius: 2,
|
borderRadius: '16px',
|
||||||
boxShadow: '0 6px 12px rgba(0,0,0,0.10)',
|
boxShadow: '0 8px 16px rgba(0,0,0,0.2)',
|
||||||
transition: 'transform 0.18s ease-in-out, box-shadow 0.18s',
|
transition: 'transform 0.2s ease-in-out, box-shadow 0.2s',
|
||||||
height: { xs: 320, sm: 260 },
|
'&:hover': {
|
||||||
minHeight: { xs: 320, sm: 260 },
|
transform: 'translateY(-5px)',
|
||||||
maxHeight: { xs: 320, sm: 260 },
|
boxShadow: '0 12px 24px rgba(0,0,0,0.3)',
|
||||||
width: { xs: '100%', sm: 260 },
|
},
|
||||||
minWidth: { xs: '100%', sm: 260 },
|
width: '100%',
|
||||||
maxWidth: { xs: '100%', sm: 260 },
|
height: '100%',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'space-between',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
boxSizing: 'border-box'
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* slightly larger image area for better visibility */}
|
<CardContent sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', p: 2 }}>
|
||||||
<Box sx={{ height: { xs: 196, sm: 168 }, width: '100%', bgcolor: '#fff', backgroundSize: 'cover', backgroundPosition: 'center', backgroundRepeat: 'no-repeat', backgroundImage: `url(${guild.icon ? `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png` : 'https://cdn.discordapp.com/embed/avatars/0.png'})`, boxSizing: 'border-box' }} />
|
|
||||||
|
|
||||||
<Box sx={{ height: { xs: 72, sm: 56 }, display: 'flex', alignItems: 'center', justifyContent: 'center', px: 2, boxSizing: 'border-box' }}>
|
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, textAlign: 'center', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden', textOverflow: 'ellipsis', lineHeight: '1.1rem', maxHeight: { xs: '2.2rem', sm: '2.2rem' } }}>{guild.name}</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box sx={{ height: { xs: 64, sm: 48 }, display: 'flex', alignItems: 'center', justifyContent: 'flex-start', gap: 1, px: 2, boxSizing: 'border-box' }}>
|
|
||||||
{botStatus[guild.id] ? (
|
|
||||||
<>
|
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600, mr: 1 }}>Leave:</Typography>
|
|
||||||
<IconButton aria-label={`Make bot leave ${guild.name}`} size="small" onClick={(e) => handleLeaveBot(e, guild)} sx={{ flexShrink: 0 }}>
|
|
||||||
<RemoveCircleOutlineIcon />
|
|
||||||
</IconButton>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600, mr: 1 }}>Invite:</Typography>
|
|
||||||
<IconButton aria-label={`Invite bot to ${guild.name}`} size="small" onClick={(e) => handleInviteBot(e, guild)} sx={{ flexShrink: 0 }}>
|
|
||||||
<PersonAddIcon />
|
|
||||||
</IconButton>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* CardContent reduced a bit to compensate for larger image */}
|
|
||||||
<CardContent sx={{ height: { xs: '124px', sm: '92px' }, boxSizing: 'border-box', py: { xs: 1, sm: 1.5 }, px: { xs: 1.25, sm: 2 } }}>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexDirection: { xs: 'column', sm: 'row' }, height: '100%', overflow: 'hidden' }}>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, width: '100%', justifyContent: { xs: 'center', sm: 'flex-start' } }}>
|
|
||||||
<Box
|
<Box
|
||||||
title={guild.name}
|
component="img"
|
||||||
|
src={guild.icon ? `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png` : 'https://cdn.discordapp.com/embed/avatars/0.png'}
|
||||||
sx={{
|
sx={{
|
||||||
px: { xs: 1, sm: 2 },
|
width: 80,
|
||||||
py: 0.5,
|
height: 80,
|
||||||
borderRadius: '999px',
|
borderRadius: '50%',
|
||||||
fontWeight: 700,
|
mb: 2,
|
||||||
fontSize: { xs: '0.95rem', sm: '1rem' },
|
boxShadow: '0 4px 8px rgba(0,0,0,0.2)',
|
||||||
bgcolor: 'rgba(0,0,0,0.04)',
|
|
||||||
maxWidth: '100%',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
display: '-webkit-box',
|
|
||||||
WebkitLineClamp: 2,
|
|
||||||
WebkitBoxOrient: 'vertical',
|
|
||||||
whiteSpace: 'normal',
|
|
||||||
textAlign: { xs: 'center', sm: 'left' },
|
|
||||||
lineHeight: '1.2rem',
|
|
||||||
maxHeight: { xs: '2.4rem', sm: '2.4rem', md: '2.4rem' }
|
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
{guild.name}
|
<Typography variant="h6" sx={{ fontWeight: 700, textAlign: 'center', mb: 1 }}>{guild.name}</Typography>
|
||||||
</Box>
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1 }}>
|
||||||
</Box>
|
{botStatus[guild.id] ? (
|
||||||
|
<Button variant="contained" color="error" size="small" onClick={(e) => handleLeaveBot(e, guild)} startIcon={<RemoveCircleOutlineIcon />}>
|
||||||
{/* Button removed from this location to avoid duplication; action is the labeled button above the CardContent */}
|
Leave
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button variant="contained" color="success" size="small" onClick={(e) => handleInviteBot(e, guild)} startIcon={<PersonAddIcon />}>
|
||||||
|
Invite
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<IconButton size="small" onClick={(e) => { e.stopPropagation(); setMenuAnchor(e.currentTarget); setMenuGuild(guild); }} aria-label="server menu">
|
||||||
|
<MoreVertIcon />
|
||||||
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
||||||
@@ -200,6 +206,12 @@ const Dashboard = () => {
|
|||||||
))}
|
))}
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
<Menu anchorEl={menuAnchor} open={!!menuAnchor} onClose={() => { setMenuAnchor(null); setMenuGuild(null); }}>
|
||||||
|
<MenuItem onClick={() => { setMenuAnchor(null); if (menuGuild) window.location.href = `/server/${menuGuild.id}`; }}>Open Server Settings</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
|
||||||
|
{/* Live Notifications dialog removed from Dashboard — available on Server Settings page */}
|
||||||
|
|
||||||
<Snackbar open={snackbarOpen} autoHideDuration={6000} onClose={handleSnackbarClose}>
|
<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}
|
||||||
|
|||||||
@@ -10,9 +10,15 @@ const HelpPage = () => {
|
|||||||
const [commands, setCommands] = useState([]);
|
const [commands, setCommands] = useState([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3002';
|
const API_BASE = process.env.REACT_APP_API_BASE || '';
|
||||||
axios.get(`${API_BASE}/api/servers/${guildId}/commands`)
|
axios.get(`${API_BASE}/api/servers/${guildId}/commands`)
|
||||||
.then(res => setCommands(res.data || []))
|
.then(res => {
|
||||||
|
const cmds = res.data || [];
|
||||||
|
// sort: locked commands first (preserve relative order), then others alphabetically
|
||||||
|
const locked = cmds.filter(c => c.locked);
|
||||||
|
const others = cmds.filter(c => !c.locked).sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
setCommands([...locked, ...others]);
|
||||||
|
})
|
||||||
.catch(() => setCommands([]));
|
.catch(() => setCommands([]));
|
||||||
}, [guildId]);
|
}, [guildId]);
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const Login = () => {
|
|||||||
}, [navigate]);
|
}, [navigate]);
|
||||||
|
|
||||||
const handleLogin = () => {
|
const handleLogin = () => {
|
||||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3002';
|
const API_BASE = process.env.REACT_APP_API_BASE || '';
|
||||||
window.location.href = `${API_BASE}/auth/discord`;
|
window.location.href = `${API_BASE}/auth/discord`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const NavBar = () => {
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Typography variant="h6" component="div" sx={{ fontWeight: 800 }} onClick={() => { navigate('/dashboard'); closeMenu(); }}>
|
<Typography variant="h6" component="div" sx={{ fontWeight: 800, display: { xs: 'none', sm: 'block' } }} onClick={() => { navigate('/dashboard'); closeMenu(); }}>
|
||||||
ECS - EHDCHADSWORTH
|
ECS - EHDCHADSWORTH
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
|
|||||||
@@ -9,10 +9,15 @@ import ConfirmDialog from './ConfirmDialog';
|
|||||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||||
import DeleteIcon from '@mui/icons-material/Delete';
|
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 ServerSettings = () => {
|
||||||
const { guildId } = useParams();
|
const { guildId } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
// settings state removed (not used) to avoid lint warnings
|
// settings state removed (not used) to avoid lint warnings
|
||||||
const [isBotInServer, setIsBotInServer] = useState(false);
|
const [isBotInServer, setIsBotInServer] = useState(false);
|
||||||
const [clientId, setClientId] = useState(null);
|
const [clientId, setClientId] = useState(null);
|
||||||
@@ -31,7 +36,15 @@ const ServerSettings = () => {
|
|||||||
const [pendingDeleteInvite, setPendingDeleteInvite] = useState(null);
|
const [pendingDeleteInvite, setPendingDeleteInvite] = useState(null);
|
||||||
const [snackbarOpen, setSnackbarOpen] = useState(false);
|
const [snackbarOpen, setSnackbarOpen] = useState(false);
|
||||||
const [snackbarMessage, setSnackbarMessage] = useState('');
|
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 [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 [commandsExpanded, setCommandsExpanded] = useState(false);
|
||||||
const [welcomeLeaveSettings, setWelcomeLeaveSettings] = useState({
|
const [welcomeLeaveSettings, setWelcomeLeaveSettings] = useState({
|
||||||
welcome: {
|
welcome: {
|
||||||
@@ -63,8 +76,6 @@ const ServerSettings = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3002';
|
|
||||||
|
|
||||||
// Fetch settings (not used directly in this component)
|
// Fetch settings (not used directly in this component)
|
||||||
axios.get(`${API_BASE}/api/servers/${guildId}/settings`).catch(() => {});
|
axios.get(`${API_BASE}/api/servers/${guildId}/settings`).catch(() => {});
|
||||||
|
|
||||||
@@ -116,6 +127,14 @@ const ServerSettings = () => {
|
|||||||
.catch(() => setCommandsList([]));
|
.catch(() => setCommandsList([]));
|
||||||
|
|
||||||
// Fetch invites
|
// 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`)
|
axios.get(`${API_BASE}/api/servers/${guildId}/invites`)
|
||||||
.then(resp => setInvites(resp.data || []))
|
.then(resp => setInvites(resp.data || []))
|
||||||
.catch(() => setInvites([]));
|
.catch(() => setInvites([]));
|
||||||
@@ -127,8 +146,92 @@ const ServerSettings = () => {
|
|||||||
|
|
||||||
}, [guildId, location.state]);
|
}, [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) => {
|
const handleAutoroleSettingUpdate = (newSettings) => {
|
||||||
axios.post(`${process.env.REACT_APP_API_BASE || 'http://localhost:3002'}/api/servers/${guildId}/autorole-settings`, newSettings)
|
axios.post(`${API_BASE}/api/servers/${guildId}/autorole-settings`, newSettings)
|
||||||
.then(response => {
|
.then(response => {
|
||||||
if (response.data.success) {
|
if (response.data.success) {
|
||||||
setAutoroleSettings(newSettings);
|
setAutoroleSettings(newSettings);
|
||||||
@@ -147,7 +250,7 @@ const ServerSettings = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSettingUpdate = (newSettings) => {
|
const handleSettingUpdate = (newSettings) => {
|
||||||
axios.post(`${process.env.REACT_APP_API_BASE || 'http://localhost:3002'}/api/servers/${guildId}/welcome-leave-settings`, newSettings)
|
axios.post(`${API_BASE}/api/servers/${guildId}/welcome-leave-settings`, newSettings)
|
||||||
.then(response => {
|
.then(response => {
|
||||||
if (response.data.success) {
|
if (response.data.success) {
|
||||||
setWelcomeLeaveSettings(newSettings);
|
setWelcomeLeaveSettings(newSettings);
|
||||||
@@ -214,7 +317,7 @@ const ServerSettings = () => {
|
|||||||
|
|
||||||
const handleConfirmLeave = async () => {
|
const handleConfirmLeave = async () => {
|
||||||
try {
|
try {
|
||||||
await axios.post(`${process.env.REACT_APP_API_BASE || 'http://localhost:3002'}/api/servers/${guildId}/leave`);
|
await axios.post(`${API_BASE}/api/servers/${guildId}/leave`);
|
||||||
setIsBotInServer(false);
|
setIsBotInServer(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error leaving server:', error);
|
console.error('Error leaving server:', error);
|
||||||
@@ -237,13 +340,17 @@ const ServerSettings = () => {
|
|||||||
{server ? `Server Settings for ${server.name}` : 'Loading...'}
|
{server ? `Server Settings for ${server.name}` : 'Loading...'}
|
||||||
</Typography>
|
</Typography>
|
||||||
{isBotInServer ? (
|
{isBotInServer ? (
|
||||||
|
<>
|
||||||
<Button variant="contained" size="small" color="error" onClick={handleLeaveBot}>
|
<Button variant="contained" size="small" color="error" onClick={handleLeaveBot}>
|
||||||
Leave Server
|
Leave Server
|
||||||
</Button>
|
</Button>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
<Button variant="contained" size="small" onClick={handleInviteBot} disabled={!clientId}>
|
<Button variant="contained" size="small" onClick={handleInviteBot} disabled={!clientId}>
|
||||||
Invite Bot
|
Invite Bot
|
||||||
</Button>
|
</Button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
{/* UserSettings moved to NavBar */}
|
{/* UserSettings moved to NavBar */}
|
||||||
@@ -290,12 +397,18 @@ const ServerSettings = () => {
|
|||||||
// optimistic update
|
// optimistic update
|
||||||
setCommandsList(prev => prev.map(c => c.name === cmd.name ? { ...c, enabled: newVal } : c));
|
setCommandsList(prev => prev.map(c => c.name === cmd.name ? { ...c, enabled: newVal } : c));
|
||||||
try {
|
try {
|
||||||
await axios.post(`${process.env.REACT_APP_API_BASE || 'http://localhost:3002'}/api/servers/${guildId}/commands/${cmd.name}/toggle`, { enabled: newVal });
|
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) {
|
} catch (err) {
|
||||||
// revert on error
|
// revert on error and notify
|
||||||
setCommandsList(prev => prev.map(c => c.name === cmd.name ? { ...c, enabled: cmd.enabled } : c));
|
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'} />}
|
}} disabled={!isBotInServer} />}
|
||||||
|
label={cmd.enabled ? 'Enabled' : 'Disabled'}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -306,6 +419,8 @@ const ServerSettings = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
</AccordionDetails>
|
</AccordionDetails>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
{/* Live Notifications dialog */}
|
||||||
|
{/* header live dialog removed; Live Notifications is managed in its own accordion below */}
|
||||||
{/* Invite creation and list */}
|
{/* Invite creation and list */}
|
||||||
<Accordion sx={{ marginTop: '20px', opacity: isBotInServer ? 1 : 0.5 }}>
|
<Accordion sx={{ marginTop: '20px', opacity: isBotInServer ? 1 : 0.5 }}>
|
||||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||||
@@ -354,7 +469,7 @@ const ServerSettings = () => {
|
|||||||
<Box sx={{ display: 'flex', alignItems: 'flex-end' }}>
|
<Box sx={{ display: 'flex', alignItems: 'flex-end' }}>
|
||||||
<Button variant="contained" onClick={async () => {
|
<Button variant="contained" onClick={async () => {
|
||||||
try {
|
try {
|
||||||
const resp = await axios.post(`${process.env.REACT_APP_API_BASE || 'http://localhost:3002'}/api/servers/${guildId}/invites`, inviteForm);
|
const resp = await axios.post(`${API_BASE}/api/servers/${guildId}/invites`, inviteForm);
|
||||||
if (resp.data && resp.data.success) {
|
if (resp.data && resp.data.success) {
|
||||||
setInvites(prev => [...prev, resp.data.invite]);
|
setInvites(prev => [...prev, resp.data.invite]);
|
||||||
}
|
}
|
||||||
@@ -498,6 +613,62 @@ const ServerSettings = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
</AccordionDetails>
|
</AccordionDetails>
|
||||||
</Accordion>
|
</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 }}>
|
<Accordion sx={{ marginTop: '20px', opacity: isBotInServer ? 1 : 0.5 }}>
|
||||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||||
<Typography variant="h6">Autorole</Typography>
|
<Typography variant="h6">Autorole</Typography>
|
||||||
@@ -553,7 +724,6 @@ const ServerSettings = () => {
|
|||||||
setConfirmOpen(false);
|
setConfirmOpen(false);
|
||||||
setDeleting(prev => ({ ...prev, [code]: true }));
|
setDeleting(prev => ({ ...prev, [code]: true }));
|
||||||
try {
|
try {
|
||||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3002';
|
|
||||||
// fetch token (one retry)
|
// fetch token (one retry)
|
||||||
let token = null;
|
let token = null;
|
||||||
try {
|
try {
|
||||||
@@ -589,8 +759,31 @@ const ServerSettings = () => {
|
|||||||
title="Delete Invite"
|
title="Delete Invite"
|
||||||
message={`Are you sure you want to delete invite ${pendingDeleteInvite ? pendingDeleteInvite.url : ''}?`}
|
message={`Are you sure you want to delete invite ${pendingDeleteInvite ? pendingDeleteInvite.url : ''}?`}
|
||||||
/>
|
/>
|
||||||
<Snackbar open={snackbarOpen} autoHideDuration={4000} onClose={() => setSnackbarOpen(false)}>
|
{/* Confirm dialog for deleting a twitch user from watched list */}
|
||||||
<Alert onClose={() => setSnackbarOpen(false)} severity="info" sx={{ width: '100%' }}>
|
<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}
|
{snackbarMessage}
|
||||||
</Alert>
|
</Alert>
|
||||||
</Snackbar>
|
</Snackbar>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useContext } from 'react';
|
import React, { useState, useContext, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Avatar, Menu, MenuItem, Button, Typography } from '@mui/material';
|
import { Avatar, Menu, MenuItem, Button, Typography } from '@mui/material';
|
||||||
import { UserContext } from '../contexts/UserContext';
|
import { UserContext } from '../contexts/UserContext';
|
||||||
@@ -11,6 +11,17 @@ const UserSettings = () => {
|
|||||||
const [anchorEl, setAnchorEl] = useState(null);
|
const [anchorEl, setAnchorEl] = useState(null);
|
||||||
const [themeMenuAnchorEl, setThemeMenuAnchorEl] = useState(null);
|
const [themeMenuAnchorEl, setThemeMenuAnchorEl] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const storedUser = localStorage.getItem('user');
|
||||||
|
if (storedUser) {
|
||||||
|
try {
|
||||||
|
setUser(JSON.parse(storedUser));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to parse user from localStorage", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [setUser]);
|
||||||
|
|
||||||
const handleMenu = (event) => {
|
const handleMenu = (event) => {
|
||||||
setAnchorEl(event.currentTarget);
|
setAnchorEl(event.currentTarget);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 || 'http://localhost:3002'}/api/user/theme`, { userId: user.id, theme: name });
|
axios.post(`${process.env.REACT_APP_API_BASE || ''}/api/user/theme`, { userId: user.id, theme: name });
|
||||||
}
|
}
|
||||||
localStorage.setItem('themeName', name);
|
localStorage.setItem('themeName', name);
|
||||||
setThemeName(name);
|
setThemeName(name);
|
||||||
|
|||||||
Reference in New Issue
Block a user