Compare commits
6 Commits
ca23c0ab8c
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 61ab1e1d9e | |||
| 8236c1e0e7 | |||
| 900ce85e2c | |||
| ff10bb3183 | |||
| 2ae7202445 | |||
| 6a78ec6453 |
340
README.md
340
README.md
@@ -1,253 +1,161 @@
|
||||
# ECS Full Stack
|
||||
|
||||
A full-stack example project that integrates a React frontend, an Express backend, and a Discord bot. The app provides a dashboard for server admins to manage bot settings and invites, plus Discord moderation/integration features via a bot running with discord.js.
|
||||
A full-stack Discord bot management dashboard with React frontend, Express backend, and Discord.js bot integration. Server admins can manage bot settings, invites, moderation, and live notifications through a modern web interface.
|
||||
|
||||
This README documents how to get the project running, what environment variables are required, where to get Discord keys, how the invite token flow works, and basic troubleshooting tips.
|
||||
## Features
|
||||
|
||||
Note: The backend has been updated to support Postgres persistence (see `CHANGELOG.md`). The backend now requires `DATABASE_URL` to run in the default configuration; if you prefer the legacy encrypted file store, see the notes under "Developer notes".
|
||||
- **Dashboard**: View Discord servers and manage per-server settings
|
||||
- **Invite Management**: Create, list, and revoke server invites with custom options
|
||||
- **Moderation**: Direct ban/kick/timeout actions from web interface with user autocomplete
|
||||
- **Live Notifications**: Twitch stream notifications with rich embeds
|
||||
- **Admin Logs**: Complete moderation action logging with real-time updates
|
||||
- **Theme Support**: Light, dark, and Discord-themed UI options
|
||||
|
||||
## Repository layout
|
||||
## Quick Start
|
||||
|
||||
- `frontend/` — React (Create React App) frontend. Uses `REACT_APP_API_BASE` to communicate with the backend in dev and production.
|
||||
- `backend/` — Express backend and API server that also coordinates with the `discord-bot` library to manage guilds, invites, and settings. Uses environment variables for configuration.
|
||||
- `discord-bot/` — small wrapper that logs the bot in and exposes the discord.js client used by the backend.
|
||||
- `checklist.md`, `README.md`, other docs and small scripts at repo root.
|
||||
### Prerequisites
|
||||
- Node.js 18+
|
||||
- PostgreSQL database
|
||||
- Discord application with bot user
|
||||
|
||||
## What this project does
|
||||
### Setup
|
||||
|
||||
- Provides a React dashboard where a user can view servers the bot is connected to and manage per-server settings (welcome/leave messages, autorole, toggling commands, invite creation/listing/deletion).
|
||||
- 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.
|
||||
1. **Clone and install dependencies:**
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd ECS-FullStack
|
||||
npm install # Run in both frontend/ and backend/ directories
|
||||
```
|
||||
|
||||
Expanded: what this app does
|
||||
2. **Configure Discord App:**
|
||||
- Go to [Discord Developer Portal](https://discord.com/developers/applications)
|
||||
- Create new application and bot user
|
||||
- Copy Client ID, Client Secret, and Bot Token
|
||||
|
||||
- Hosts a dashboard (React) that lists Discord guilds where the bot is present and lets server admins:
|
||||
- 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`
|
||||
3. **Database Setup:**
|
||||
```sql
|
||||
CREATE DATABASE ecs_fullstack;
|
||||
CREATE USER ecs_user WITH PASSWORD 'your_password';
|
||||
GRANT ALL PRIVILEGES ON DATABASE ecs_fullstack TO ecs_user;
|
||||
```
|
||||
|
||||
## Quickstart — prerequisites
|
||||
4. **Environment Configuration:**
|
||||
|
||||
- Node.js (recommended 18.x or later) and npm
|
||||
- A Discord application with a Bot user (to get `DISCORD_CLIENT_ID` and `DISCORD_CLIENT_SECRET`) — see below for setup steps
|
||||
- Optional: a VPS or Tailscale IP if you want to run the frontend/backend on a non-localhost address
|
||||
**backend/.env:**
|
||||
```env
|
||||
DATABASE_URL=postgres://ecs_user:password@localhost:5432/ecs_fullstack
|
||||
DISCORD_CLIENT_ID=your_client_id
|
||||
DISCORD_CLIENT_SECRET=your_client_secret
|
||||
DISCORD_BOT_TOKEN=your_bot_token
|
||||
ENCRYPTION_KEY=your_32_byte_secret
|
||||
BACKEND_BASE=http://localhost:3002
|
||||
FRONTEND_BASE=http://localhost:3001
|
||||
```
|
||||
|
||||
## Environment configuration (.env)
|
||||
**frontend/.env:**
|
||||
```env
|
||||
REACT_APP_API_BASE=http://localhost:3002
|
||||
```
|
||||
|
||||
There are env files used by the backend and frontend. Create `.env` files in the `backend/` and `frontend/` folders for local development. Examples follow.
|
||||
5. **Start the application:**
|
||||
```bash
|
||||
# Backend (includes Discord bot)
|
||||
cd backend && npm start
|
||||
|
||||
### backend/.env (example)
|
||||
# Frontend (separate terminal)
|
||||
cd frontend && npm start
|
||||
```
|
||||
|
||||
PORT=3002
|
||||
HOST=0.0.0.0
|
||||
BACKEND_BASE=http://your-server-or-ip:3002
|
||||
FRONTEND_BASE=http://your-server-or-ip:3001
|
||||
CORS_ORIGIN=http://your-server-or-ip:3001
|
||||
DISCORD_CLIENT_ID=your_discord_client_id
|
||||
DISCORD_CLIENT_SECRET=your_discord_client_secret
|
||||
ENCRYPTION_KEY=a-32-byte-or-longer-secret
|
||||
INVITE_TOKEN_SECRET=optional-second-secret-for-invite-tokens
|
||||
6. **Invite Bot to Server:**
|
||||
- Use OAuth2 URL Generator in Discord Developer Portal
|
||||
- Select `bot` and `applications.commands` scopes
|
||||
- Choose appropriate permissions
|
||||
- Visit generated URL to invite bot
|
||||
|
||||
# Postgres example (optional but recommended)
|
||||
# DATABASE_URL=postgres://dbuser:dbpass@100.111.50.59:5432/ecs_db
|
||||
## Project Structure
|
||||
|
||||
- `PORT` / `HOST`: where the backend listens.
|
||||
- `BACKEND_BASE` and `FRONTEND_BASE`: used for constructing OAuth redirect URIs and links.
|
||||
- `CORS_ORIGIN`: optional; set to your frontend origin to restrict CORS.
|
||||
- `DISCORD_CLIENT_ID` / `DISCORD_CLIENT_SECRET`: from the Discord Developer Portal (see below).
|
||||
- `ENCRYPTION_KEY` or `INVITE_TOKEN_SECRET`: used to sign short-lived invite tokens. Keep this secret.
|
||||
|
||||
Note: This project previously supported an `INVITE_API_KEY` static secret; that requirement has been removed. Invite deletes are authorized via short-lived invite tokens by default.
|
||||
|
||||
### Twitch Live Notifications (optional)
|
||||
|
||||
This project can detect when watched Twitch users go live and post notifications to a configured Discord channel for each guild. To enable this feature, add the following to `backend/.env`:
|
||||
|
||||
- `TWITCH_CLIENT_ID` — your Twitch app client id
|
||||
- `TWITCH_CLIENT_SECRET` — your Twitch app client secret
|
||||
- `TWITCH_POLL_INTERVAL_MS` — optional, poll interval in milliseconds (default 30000)
|
||||
|
||||
When configured, the backend exposes:
|
||||
|
||||
- GET /api/twitch/streams?users=user1,user2 — returns stream info for the listed usernames (used by the frontend and bot watcher)
|
||||
|
||||
The bot includes a watcher that polls watched usernames per-guild and posts a message to the configured channel when a streamer goes live. The message includes the stream title and a link to the Twitch stream.
|
||||
|
||||
If you run the backend and the bot on separate hosts, you can configure the backend to push setting updates to the bot so toggles and watched users propagate immediately:
|
||||
|
||||
- `BOT_PUSH_URL` — the URL the bot will expose for the backend to POST setting updates to (e.g., http://bot-host:4002)
|
||||
- `BOT_SECRET` — a shared secret used by the backend and bot to secure push requests
|
||||
- `BOT_PUSH_PORT` — optional, the port the bot listens on for push requests (if set the bot starts a small HTTP receiver)
|
||||
|
||||
|
||||
### frontend/.env (example)
|
||||
|
||||
HOST=0.0.0.0
|
||||
PORT=3001
|
||||
REACT_APP_API_BASE=http://your-server-or-ip:3002
|
||||
|
||||
Set `REACT_APP_API_BASE` to point at the backend so the frontend can call API endpoints.
|
||||
|
||||
## Create a Discord Application and Bot (short)
|
||||
|
||||
1. Go to the Discord Developer Portal: https://discord.com/developers/applications
|
||||
2. Create a new application.
|
||||
3. Under "OAuth2" -> "General", add your redirect URI:
|
||||
- For dev: `http://your-server-or-ip:3002/auth/discord/callback`
|
||||
- Make sure `BACKEND_BASE` matches the host/port you set in `backend/.env`.
|
||||
4. Under "Bot" create a Bot user and copy the Bot token (NOT committed to source).
|
||||
5. Under "OAuth2" -> "URL Generator" select scopes `bot` and `applications.commands` and select permissions (e.g., Administrator if you want full access for testing). Use the generated URL to invite the bot to your guild during testing.
|
||||
|
||||
Store the Client ID / Client Secret in your `backend/.env` as `DISCORD_CLIENT_ID` and `DISCORD_CLIENT_SECRET`.
|
||||
|
||||
## Invite token flow (why and how)
|
||||
|
||||
- To avoid embedding long-lived secrets in a web client, invite deletions are authorized with a short-lived HMAC-signed token.
|
||||
- The frontend requests a token with:
|
||||
GET /api/servers/:guildId/invite-token
|
||||
- The backend returns `{ token: '...' }`. The frontend then calls
|
||||
DELETE /api/servers/:guildId/invites/:code
|
||||
with header `x-invite-token: <token>`
|
||||
- Token TTL is short (default 5 minutes) and is signed using `INVITE_TOKEN_SECRET` or `ENCRYPTION_KEY` from backend `.env`.
|
||||
|
||||
Security note: Currently the `/invite-token` endpoint issues tokens to any caller. For production you should restrict this endpoint by requiring OAuth authentication and checking that the requesting user is authorized for the target guild.
|
||||
|
||||
## Run the app locally
|
||||
|
||||
1. Backend
|
||||
|
||||
```powershell
|
||||
cd backend
|
||||
npm install
|
||||
# create backend/.env from the example above
|
||||
npm start
|
||||
```
|
||||
ECS-FullStack/
|
||||
├── frontend/ # React dashboard
|
||||
├── backend/ # Express API + Discord bot
|
||||
├── discord-bot/ # Bot wrapper
|
||||
├── checklist.md # Feature tracking
|
||||
└── README.md
|
||||
```
|
||||
|
||||
Optional: using Postgres (recommended)
|
||||
## API Endpoints
|
||||
|
||||
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.
|
||||
### Server Management
|
||||
- `GET /api/servers/:guildId` - Server info and settings
|
||||
- `GET /api/servers/:guildId/members` - Server member list
|
||||
- `GET /api/servers/:guildId/channels` - Text channels
|
||||
- `GET /api/servers/:guildId/roles` - Server roles
|
||||
|
||||
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.
|
||||
### Invites
|
||||
- `GET /api/servers/:guildId/invites` - List invites
|
||||
- `POST /api/servers/:guildId/invites` - Create invite
|
||||
- `DELETE /api/servers/:guildId/invites/:code` - Delete invite
|
||||
|
||||
2. Frontend
|
||||
### Moderation
|
||||
- `POST /api/servers/:guildId/moderate` - Ban/kick/timeout users
|
||||
- `GET /api/servers/:guildId/admin-logs` - View moderation logs
|
||||
|
||||
```powershell
|
||||
cd frontend
|
||||
npm install
|
||||
# create frontend/.env with REACT_APP_API_BASE pointing to the backend
|
||||
npm run start
|
||||
### Live Notifications
|
||||
- `GET/POST /api/servers/:guildId/live-notifications` - Settings
|
||||
- `GET/POST /api/servers/:guildId/twitch-users` - Watched users
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Required
|
||||
- `DATABASE_URL` - PostgreSQL connection string
|
||||
- `DISCORD_CLIENT_ID` - Discord app client ID
|
||||
- `DISCORD_CLIENT_SECRET` - Discord app client secret
|
||||
- `DISCORD_BOT_TOKEN` - Bot token
|
||||
|
||||
### Optional
|
||||
- `TWITCH_CLIENT_ID` - Twitch app client ID
|
||||
- `TWITCH_CLIENT_SECRET` - Twitch app client secret
|
||||
- `BOT_PUSH_URL` - For separate bot/backend deployment
|
||||
- `CORS_ORIGIN` - Restrict API access
|
||||
|
||||
## Development
|
||||
|
||||
### Running Tests
|
||||
```bash
|
||||
cd frontend && npm test
|
||||
cd backend && npm test
|
||||
```
|
||||
|
||||
3. Discord bot
|
||||
|
||||
- The backend boots the bot client (see `discord-bot/`), so if the backend is started and credentials are correct, the bot will log in and register slash commands. You can also run the `discord-bot` project separately if you prefer.
|
||||
### Building for Production
|
||||
```bash
|
||||
cd frontend && npm run build
|
||||
cd backend && npm run build # If applicable
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Backend refuses to start or missing package.json: ensure you run `npm install` in the `backend` folder and run `npm start` from that folder.
|
||||
- If the backend exits with "DATABASE_URL is not set": either set `DATABASE_URL` in `backend/.env` pointing to your Postgres DB, or restore the legacy behavior by editing `backend/index.js` to re-enable the encrypted `db.json` fallback (not recommended for production).
|
||||
- CORS errors: verify `CORS_ORIGIN` and `REACT_APP_API_BASE` match your frontend origin.
|
||||
- Invite delete unauthorized: ensure backend `INVITE_TOKEN_SECRET` or `ENCRYPTION_KEY` is present and token TTL has not expired. Check the backend logs for validation details.
|
||||
- Token issues: clock skew can cause tokens to appear expired — ensure server and client clocks are reasonably in sync.
|
||||
### Common Issues
|
||||
- **Database connection failed**: Verify `DATABASE_URL` format and credentials
|
||||
- **CORS errors**: Check `CORS_ORIGIN` matches your frontend URL
|
||||
- **Bot not responding**: Ensure bot has proper permissions in server
|
||||
- **Invite deletion fails**: Check `ENCRYPTION_KEY` is set
|
||||
|
||||
## Developer notes
|
||||
### Logs
|
||||
- Backend logs Discord bot status and API requests
|
||||
- Frontend console shows API calls and errors
|
||||
- Check browser Network tab for failed requests
|
||||
|
||||
- The dashboard UI is in `frontend/src/components/` (notable files: `Dashboard.js`, `ServerSettings.js`, `Login.js`).
|
||||
- The Express API is in `backend/index.js` and uses `discord-bot` (discord.js client) to operate on guilds, invites, channels and roles.
|
||||
- Invite delete flow: frontend fetches a short-lived token then requests DELETE with header `x-invite-token`.
|
||||
## Contributing
|
||||
|
||||
## Next steps / suggestions
|
||||
1. Fork the repository
|
||||
2. Create feature branch
|
||||
3. Make changes with tests
|
||||
4. Submit pull request
|
||||
|
||||
- Harden `/api/servers/:guildId/invite-token` to require an authenticated user and verify the user has admin permissions for the guild.
|
||||
- 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.
|
||||
## License
|
||||
|
||||
If you want, I can add step-by-step instructions to create the `.env` files from templates, or implement the production safe option of authenticating `/invite-token` requests. Tell me which you'd prefer.
|
||||
MIT License - see LICENSE file for details.
|
||||
|
||||
---
|
||||
Updated: Oct 4, 2025
|
||||
|
||||
## Full setup guide (detailed)
|
||||
|
||||
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)
|
||||
**Updated**: October 9, 2025
|
||||
|
||||
871
backend/index.js
871
backend/index.js
@@ -158,6 +158,214 @@ app.get('/api/twitch/streams', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Kick API helpers (web scraping since no public API)
|
||||
async function getKickStreamsForUsers(usernames = []) {
|
||||
try {
|
||||
if (!usernames || usernames.length === 0) return [];
|
||||
|
||||
const results = [];
|
||||
for (const username of usernames) {
|
||||
try {
|
||||
// Use Kick's API endpoint to check if user is live
|
||||
const url = `https://kick.com/api/v1/channels/${encodeURIComponent(username)}`;
|
||||
const response = await axios.get(url, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
||||
'Accept': 'application/json',
|
||||
'Referer': 'https://kick.com/'
|
||||
},
|
||||
timeout: 5000 // 5 second timeout
|
||||
});
|
||||
|
||||
if (response.status === 200 && response.data) {
|
||||
const data = response.data;
|
||||
|
||||
if (data.livestream && data.livestream.is_live) {
|
||||
results.push({
|
||||
is_live: true,
|
||||
user_login: username,
|
||||
user_name: data.user?.username || username,
|
||||
title: data.livestream.session_title || `${username} is live`,
|
||||
viewer_count: data.livestream.viewer_count || 0,
|
||||
started_at: data.livestream.start_time,
|
||||
url: `https://kick.com/${username}`,
|
||||
thumbnail_url: data.livestream.thumbnail?.url || null,
|
||||
category: data.category?.name || 'Unknown',
|
||||
description: data.user?.bio || ''
|
||||
});
|
||||
} else {
|
||||
// User exists but not live
|
||||
results.push({
|
||||
is_live: false,
|
||||
user_login: username,
|
||||
user_name: data.user?.username || username,
|
||||
title: null,
|
||||
viewer_count: 0,
|
||||
started_at: null,
|
||||
url: `https://kick.com/${username}`,
|
||||
thumbnail_url: null,
|
||||
category: null,
|
||||
description: data.user?.bio || ''
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// User not found or API error
|
||||
results.push({
|
||||
is_live: false,
|
||||
user_login: username,
|
||||
user_name: username,
|
||||
title: null,
|
||||
viewer_count: 0,
|
||||
started_at: null,
|
||||
url: `https://kick.com/${username}`,
|
||||
thumbnail_url: null,
|
||||
category: null,
|
||||
description: ''
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// If API fails with 403, try web scraping as fallback
|
||||
if (e.response && e.response.status === 403) {
|
||||
// console.log(`API blocked for ${username}, trying web scraping fallback...`);
|
||||
|
||||
try {
|
||||
const pageUrl = `https://kick.com/${encodeURIComponent(username)}`;
|
||||
const pageResponse = await axios.get(pageUrl, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.5',
|
||||
'Accept-Encoding': 'gzip, deflate, br',
|
||||
'DNT': '1',
|
||||
'Connection': 'keep-alive',
|
||||
'Upgrade-Insecure-Requests': '1',
|
||||
'Sec-Fetch-Dest': 'document',
|
||||
'Sec-Fetch-Mode': 'navigate',
|
||||
'Sec-Fetch-Site': 'none',
|
||||
'Cache-Control': 'max-age=0'
|
||||
},
|
||||
timeout: 5000
|
||||
});
|
||||
|
||||
if (pageResponse.status === 200) {
|
||||
const html = pageResponse.data;
|
||||
|
||||
// Check for live stream indicators in the HTML
|
||||
const isLive = html.includes('"is_live":true') || html.includes('"is_live": true') ||
|
||||
html.includes('data-is-live="true"') || html.includes('isLive:true');
|
||||
|
||||
if (isLive) {
|
||||
// Try to extract stream info from HTML
|
||||
let title = `${username} is live`;
|
||||
let viewerCount = 0;
|
||||
let category = 'Unknown';
|
||||
|
||||
// Extract title
|
||||
const titleMatch = html.match(/"session_title"\s*:\s*"([^"]+)"/) || html.match(/"title"\s*:\s*"([^"]+)"/);
|
||||
if (titleMatch) {
|
||||
title = titleMatch[1].replace(/\\"/g, '"');
|
||||
}
|
||||
|
||||
// Extract viewer count
|
||||
const viewerMatch = html.match(/"viewer_count"\s*:\s*(\d+)/);
|
||||
if (viewerMatch) {
|
||||
viewerCount = parseInt(viewerMatch[1]);
|
||||
}
|
||||
|
||||
// Extract category
|
||||
const categoryMatch = html.match(/"category"\s*:\s*{\s*"name"\s*:\s*"([^"]+)"/);
|
||||
if (categoryMatch) {
|
||||
category = categoryMatch[1];
|
||||
}
|
||||
|
||||
results.push({
|
||||
is_live: true,
|
||||
user_login: username,
|
||||
user_name: username,
|
||||
title: title,
|
||||
viewer_count: viewerCount,
|
||||
started_at: new Date().toISOString(),
|
||||
url: `https://kick.com/${username}`,
|
||||
thumbnail_url: null,
|
||||
category: category,
|
||||
description: ''
|
||||
});
|
||||
} else {
|
||||
// User exists but not live
|
||||
results.push({
|
||||
is_live: false,
|
||||
user_login: username,
|
||||
user_name: username,
|
||||
title: null,
|
||||
viewer_count: 0,
|
||||
started_at: null,
|
||||
url: `https://kick.com/${username}`,
|
||||
thumbnail_url: null,
|
||||
category: null,
|
||||
description: ''
|
||||
});
|
||||
}
|
||||
} else {
|
||||
throw e; // Re-throw if page request also fails
|
||||
}
|
||||
} catch (scrapeError) {
|
||||
console.error(`Web scraping fallback also failed for ${username}:`, scrapeError.message || scrapeError);
|
||||
// Return offline status on error
|
||||
results.push({
|
||||
is_live: false,
|
||||
user_login: username,
|
||||
user_name: username,
|
||||
title: null,
|
||||
viewer_count: 0,
|
||||
started_at: null,
|
||||
url: `https://kick.com/${username}`,
|
||||
thumbnail_url: null,
|
||||
category: null,
|
||||
description: ''
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.error(`Error checking Kick user ${username}:`, e && e.response && e.response.status ? `HTTP ${e.response.status}` : e.message || e);
|
||||
// Return offline status on error
|
||||
results.push({
|
||||
is_live: false,
|
||||
user_login: username,
|
||||
user_name: username,
|
||||
title: null,
|
||||
viewer_count: 0,
|
||||
started_at: null,
|
||||
url: `https://kick.com/${username}`,
|
||||
thumbnail_url: null,
|
||||
category: null,
|
||||
description: ''
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Small delay between requests to be respectful to Kick's servers
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
}
|
||||
|
||||
return results;
|
||||
} catch (e) {
|
||||
console.error('Error in getKickStreamsForUsers:', e && e.response && e.response.data ? e.response.data : e.message || e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Proxy endpoint for frontend/bot to request Kick stream status for usernames (comma separated)
|
||||
app.get('/api/kick/streams', async (req, res) => {
|
||||
const q = req.query.users || req.query.user || '';
|
||||
const users = q.split(',').map(s => (s || '').trim()).filter(Boolean);
|
||||
try {
|
||||
const streams = await getKickStreamsForUsers(users);
|
||||
res.json(streams);
|
||||
} catch (err) {
|
||||
console.error('Error in /api/kick/streams:', err);
|
||||
res.status(500).json({ success: false, message: 'Internal Server Error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Invite token helpers: short-lived HMAC-signed token so frontend can authorize invite deletes
|
||||
const INVITE_TOKEN_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
const inviteTokenSecret = process.env.INVITE_TOKEN_SECRET || process.env.ENCRYPTION_KEY || 'fallback-invite-secret';
|
||||
@@ -273,7 +481,8 @@ 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`;
|
||||
const enriched = Object.assign({}, payload || {}, { guildId });
|
||||
const msg = `event: ${type}\ndata: ${JSON.stringify(enriched)}\n\n`;
|
||||
// send to guild-specific subscribers
|
||||
const list = sseClients.get(guildId) || [];
|
||||
for (const res of list.slice()) {
|
||||
@@ -303,6 +512,31 @@ app.get('/api/events', (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// Health endpoint used by frontend to detect backend availability
|
||||
app.get('/api/servers/health', async (req, res) => {
|
||||
try {
|
||||
// Basic checks: server is running; optionally check DB connectivity
|
||||
const health = { ok: true, db: null, bot: null };
|
||||
try {
|
||||
// if pgClient is available, attempt a simple query
|
||||
if (pgClient && typeof pgClient.query === 'function') {
|
||||
await pgClient.query('SELECT 1');
|
||||
health.db = true;
|
||||
}
|
||||
} catch (e) {
|
||||
health.db = false;
|
||||
}
|
||||
try {
|
||||
health.bot = (bot && bot.client && bot.client.user) ? true : false;
|
||||
} catch (e) {
|
||||
health.bot = false;
|
||||
}
|
||||
res.json(health);
|
||||
} catch (e) {
|
||||
res.status(500).json({ ok: false });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/servers/:guildId/settings', async (req, res) => {
|
||||
const { guildId } = req.params;
|
||||
try {
|
||||
@@ -402,6 +636,8 @@ app.post('/api/servers/:guildId/leave', async (req, res) => {
|
||||
const guild = await bot.client.guilds.fetch(guildId);
|
||||
if (guild) {
|
||||
await guild.leave();
|
||||
// Publish event for bot status change
|
||||
publishEvent('*', 'botStatusUpdate', { guildId, isBotInServer: false });
|
||||
res.json({ success: true });
|
||||
} else {
|
||||
res.status(404).json({ success: false, message: 'Bot is not in the specified server' });
|
||||
@@ -420,7 +656,7 @@ app.get('/api/servers/:guildId/channels', async (req, res) => {
|
||||
}
|
||||
try {
|
||||
const channels = await guild.channels.fetch();
|
||||
const textChannels = channels.filter(channel => channel.type === 0).map(channel => ({ id: channel.id, name: channel.name }));
|
||||
const textChannels = channels.filter(channel => channel.type === 0).map(channel => ({ id: channel.id, name: channel.name, type: channel.type }));
|
||||
res.json(textChannels);
|
||||
} catch (error) {
|
||||
console.error('Error fetching channels:', error);
|
||||
@@ -428,6 +664,40 @@ app.get('/api/servers/:guildId/channels', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/servers/:guildId/members', async (req, res) => {
|
||||
const { guildId } = req.params;
|
||||
const guild = bot.client.guilds.cache.get(guildId);
|
||||
if (!guild) {
|
||||
return res.json([]);
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the requesting user from the session/token
|
||||
// For now, we'll assume the frontend sends the user ID in a header or we get it from OAuth
|
||||
// This is a simplified version - in production you'd want proper authentication
|
||||
const members = await guild.members.fetch();
|
||||
|
||||
// Filter to members the bot can interact with and format for frontend
|
||||
const bannableMembers = members
|
||||
.filter(member => !member.user.bot) // Exclude bots
|
||||
.map(member => ({
|
||||
id: member.user.id,
|
||||
username: member.user.username,
|
||||
globalName: member.user.globalName,
|
||||
displayName: member.displayName,
|
||||
avatar: member.user.avatar,
|
||||
joinedAt: member.joinedAt,
|
||||
roles: member.roles.cache.map(role => ({ id: role.id, name: role.name, position: role.position }))
|
||||
}))
|
||||
.sort((a, b) => a.username.localeCompare(b.username));
|
||||
|
||||
res.json(bannableMembers);
|
||||
} catch (error) {
|
||||
console.error('Error fetching members:', error);
|
||||
res.status(500).json({ success: false, message: 'Internal Server Error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/servers/:guildId/welcome-leave-settings', async (req, res) => {
|
||||
const { guildId } = req.params;
|
||||
try {
|
||||
@@ -537,7 +807,8 @@ app.get('/api/servers/:guildId/live-notifications', async (req, res) => {
|
||||
const { guildId } = req.params;
|
||||
try {
|
||||
const settings = (await pgClient.getServerSettings(guildId)) || {};
|
||||
return res.json(settings.liveNotifications || { enabled: false, twitchUser: '', channelId: '' });
|
||||
const ln = settings.liveNotifications || { enabled: false, twitchUser: '', channelId: '', message: '', customMessage: '' };
|
||||
return res.json(ln);
|
||||
} catch (err) {
|
||||
console.error('Error fetching live-notifications settings:', err);
|
||||
res.status(500).json({ success: false, message: 'Internal Server Error' });
|
||||
@@ -546,16 +817,21 @@ app.get('/api/servers/:guildId/live-notifications', async (req, res) => {
|
||||
|
||||
app.post('/api/servers/:guildId/live-notifications', async (req, res) => {
|
||||
const { guildId } = req.params;
|
||||
const { enabled, twitchUser, channelId } = req.body || {};
|
||||
const { enabled, twitchUser, channelId, message, customMessage } = req.body || {};
|
||||
try {
|
||||
const existing = (await pgClient.getServerSettings(guildId)) || {};
|
||||
const currentLn = existing.liveNotifications || {};
|
||||
existing.liveNotifications = {
|
||||
enabled: !!enabled,
|
||||
twitchUser: twitchUser || '',
|
||||
channelId: channelId || ''
|
||||
channelId: channelId || '',
|
||||
message: message || '',
|
||||
customMessage: customMessage || '',
|
||||
users: currentLn.users || [], // preserve existing users
|
||||
kickUsers: currentLn.kickUsers || [] // preserve existing kick users
|
||||
};
|
||||
await pgClient.upsertServerSettings(guildId, existing);
|
||||
try { publishEvent(guildId, 'liveNotificationsUpdate', { enabled: !!enabled, channelId, twitchUser }); } catch (e) {}
|
||||
try { publishEvent(guildId, 'liveNotificationsUpdate', { enabled: !!enabled, channelId, twitchUser, message: existing.liveNotifications.message, customMessage: existing.liveNotifications.customMessage }); } catch (e) {}
|
||||
return res.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error('Error saving live-notifications settings:', err);
|
||||
@@ -582,10 +858,18 @@ app.post('/api/servers/:guildId/twitch-users', async (req, res) => {
|
||||
if (!username) return res.status(400).json({ success: false, message: 'Missing username' });
|
||||
try {
|
||||
const existing = (await pgClient.getServerSettings(guildId)) || {};
|
||||
if (!existing.liveNotifications) existing.liveNotifications = { enabled: false, channelId: '', users: [] };
|
||||
if (!existing.liveNotifications) existing.liveNotifications = { enabled: false, channelId: '', users: [], kickUsers: [] };
|
||||
existing.liveNotifications.users = Array.from(new Set([...(existing.liveNotifications.users || []), username.toLowerCase().trim()]));
|
||||
await pgClient.upsertServerSettings(guildId, existing);
|
||||
try { publishEvent(guildId, 'twitchUsersUpdate', { users: existing.liveNotifications.users || [] }); } catch (e) {}
|
||||
// Optional push to bot process for immediate cache update
|
||||
try {
|
||||
if (process.env.BOT_PUSH_URL) {
|
||||
await axios.post(`${process.env.BOT_PUSH_URL.replace(/\/$/, '')}/internal/set-settings`, { guildId, settings: existing }, {
|
||||
headers: process.env.BOT_SECRET ? { 'x-bot-secret': process.env.BOT_SECRET } : {}
|
||||
}).catch(() => {});
|
||||
}
|
||||
} catch (_) {}
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error('Error adding twitch user:', err);
|
||||
@@ -597,10 +881,18 @@ app.delete('/api/servers/:guildId/twitch-users/:username', async (req, res) => {
|
||||
const { guildId, username } = req.params;
|
||||
try {
|
||||
const existing = (await pgClient.getServerSettings(guildId)) || {};
|
||||
if (!existing.liveNotifications) existing.liveNotifications = { enabled: false, channelId: '', users: [] };
|
||||
if (!existing.liveNotifications) existing.liveNotifications = { enabled: false, channelId: '', users: [], kickUsers: [] };
|
||||
existing.liveNotifications.users = (existing.liveNotifications.users || []).filter(u => u.toLowerCase() !== (username || '').toLowerCase());
|
||||
await pgClient.upsertServerSettings(guildId, existing);
|
||||
try { publishEvent(guildId, 'twitchUsersUpdate', { users: existing.liveNotifications.users || [] }); } catch (e) {}
|
||||
// Optional push to bot process for immediate cache update
|
||||
try {
|
||||
if (process.env.BOT_PUSH_URL) {
|
||||
await axios.post(`${process.env.BOT_PUSH_URL.replace(/\/$/, '')}/internal/set-settings`, { guildId, settings: existing }, {
|
||||
headers: process.env.BOT_SECRET ? { 'x-bot-secret': process.env.BOT_SECRET } : {}
|
||||
}).catch(() => {});
|
||||
}
|
||||
} catch (_) {}
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error('Error removing twitch user:', err);
|
||||
@@ -608,10 +900,88 @@ app.delete('/api/servers/:guildId/twitch-users/:username', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// DISABLED: Kick users list management for a guild (temporarily disabled)
|
||||
/*
|
||||
app.get('/api/servers/:guildId/kick-users', async (req, res) => {
|
||||
const { guildId } = req.params;
|
||||
try {
|
||||
const settings = (await pgClient.getServerSettings(guildId)) || {};
|
||||
const users = (settings.liveNotifications && settings.liveNotifications.kickUsers) || [];
|
||||
res.json(users);
|
||||
} catch (err) {
|
||||
console.error('Error fetching kick users:', err);
|
||||
res.status(500).json({ success: false, message: 'Internal Server Error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/servers/:guildId/kick-users', async (req, res) => {
|
||||
const { guildId } = req.params;
|
||||
const { username } = req.body || {};
|
||||
if (!username) return res.status(400).json({ success: false, message: 'Missing username' });
|
||||
try {
|
||||
const existing = (await pgClient.getServerSettings(guildId)) || {};
|
||||
if (!existing.liveNotifications) existing.liveNotifications = { enabled: false, channelId: '', users: [], kickUsers: [] };
|
||||
existing.liveNotifications.kickUsers = Array.from(new Set([...(existing.liveNotifications.kickUsers || []), username.toLowerCase().trim()]));
|
||||
await pgClient.upsertServerSettings(guildId, existing);
|
||||
try { publishEvent(guildId, 'kickUsersUpdate', { users: existing.liveNotifications.kickUsers || [] }); } catch (e) {}
|
||||
// Optional push to bot process for immediate cache update
|
||||
try {
|
||||
if (process.env.BOT_PUSH_URL) {
|
||||
await axios.post(`${process.env.BOT_PUSH_URL.replace(/\/$/, '')}/internal/set-settings`, { guildId, settings: existing }, {
|
||||
headers: process.env.BOT_SECRET ? { 'x-bot-secret': process.env.BOT_SECRET } : {}
|
||||
}).catch(() => {});
|
||||
}
|
||||
} catch (_) {}
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error('Error adding kick user:', err);
|
||||
res.status(500).json({ success: false, message: 'Internal Server Error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/servers/:guildId/kick-users/:username', async (req, res) => {
|
||||
const { guildId, username } = req.params;
|
||||
try {
|
||||
const existing = (await pgClient.getServerSettings(guildId)) || {};
|
||||
if (!existing.liveNotifications) existing.liveNotifications = { enabled: false, channelId: '', users: [], kickUsers: [] };
|
||||
existing.liveNotifications.kickUsers = (existing.liveNotifications.kickUsers || []).filter(u => u.toLowerCase() !== (username || '').toLowerCase());
|
||||
await pgClient.upsertServerSettings(guildId, existing);
|
||||
try { publishEvent(guildId, 'kickUsersUpdate', { users: existing.liveNotifications.kickUsers || [] }); } catch (e) {}
|
||||
// Optional push to bot process for immediate cache update
|
||||
try {
|
||||
if (process.env.BOT_PUSH_URL) {
|
||||
await axios.post(`${process.env.BOT_PUSH_URL.replace(/\/$/, '')}/internal/set-settings`, { guildId, settings: existing }, {
|
||||
headers: process.env.BOT_SECRET ? { 'x-bot-secret': process.env.BOT_SECRET } : {}
|
||||
}).catch(() => {});
|
||||
}
|
||||
} catch (_) {}
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error('Error removing kick user:', err);
|
||||
res.status(500).json({ success: false, message: 'Internal Server Error' });
|
||||
}
|
||||
});
|
||||
*/
|
||||
|
||||
app.get('/', (req, res) => {
|
||||
res.send('Hello from the backend!');
|
||||
});
|
||||
|
||||
// Debug helper: publish an arbitrary SSE event for a guild (guarded by DEBUG_SSE env var)
|
||||
app.post('/api/servers/:guildId/debug/publish', express.json(), (req, res) => {
|
||||
if (!process.env.DEBUG_SSE || process.env.DEBUG_SSE === '0') return res.status(404).json({ success: false, message: 'Not found' });
|
||||
try {
|
||||
const { guildId } = req.params;
|
||||
const { type, payload } = req.body || {};
|
||||
if (!type) return res.status(400).json({ success: false, message: 'Missing event type' });
|
||||
publishEvent(guildId, type, payload || {});
|
||||
return res.json({ success: true });
|
||||
} catch (e) {
|
||||
console.error('Debug publish failed:', e);
|
||||
return res.status(500).json({ success: false });
|
||||
}
|
||||
});
|
||||
|
||||
// Return list of bot commands and per-guild enabled/disabled status
|
||||
app.get('/api/servers/:guildId/commands', async (req, res) => {
|
||||
try {
|
||||
@@ -620,7 +990,9 @@ app.get('/api/servers/:guildId/commands', async (req, res) => {
|
||||
const toggles = guildSettings.commandToggles || {};
|
||||
const protectedCommands = ['manage-commands', 'help'];
|
||||
|
||||
const commands = Array.from(bot.client.commands.values()).map(cmd => {
|
||||
const commands = Array.from(bot.client.commands.values())
|
||||
.filter(cmd => !cmd.dev) // Filter out dev commands
|
||||
.map(cmd => {
|
||||
const isLocked = protectedCommands.includes(cmd.name);
|
||||
const isEnabled = isLocked ? true : (toggles[cmd.name] !== false && cmd.enabled !== false);
|
||||
return {
|
||||
@@ -677,7 +1049,36 @@ app.get('/api/servers/:guildId/invites', async (req, res) => {
|
||||
app.post('/api/servers/:guildId/invites', async (req, res) => {
|
||||
try {
|
||||
const { guildId } = req.params;
|
||||
const { channelId, maxAge, maxUses, temporary } = req.body || {};
|
||||
const { code, url, channelId, maxAge, maxUses, temporary, createdAt } = req.body || {};
|
||||
|
||||
// If code is provided, this is an existing invite to store (from Discord events)
|
||||
if (code) {
|
||||
const item = {
|
||||
code,
|
||||
url: url || `https://discord.gg/${code}`,
|
||||
channelId: channelId || '',
|
||||
createdAt: createdAt || new Date().toISOString(),
|
||||
maxUses: maxUses || 0,
|
||||
maxAge: maxAge || 0,
|
||||
temporary: !!temporary,
|
||||
};
|
||||
|
||||
await pgClient.addInvite({
|
||||
code: item.code,
|
||||
guildId,
|
||||
url: item.url,
|
||||
channelId: item.channelId,
|
||||
createdAt: item.createdAt,
|
||||
maxUses: item.maxUses,
|
||||
maxAge: item.maxAge,
|
||||
temporary: item.temporary
|
||||
});
|
||||
|
||||
res.json({ success: true, invite: item });
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, create a new invite
|
||||
const guild = bot.client.guilds.cache.get(guildId);
|
||||
if (!guild) return res.status(404).json({ success: false, message: 'Guild not found' });
|
||||
|
||||
@@ -717,7 +1118,7 @@ app.post('/api/servers/:guildId/invites', async (req, res) => {
|
||||
|
||||
res.json({ success: true, invite: item });
|
||||
} catch (error) {
|
||||
console.error('Error creating invite:', error);
|
||||
console.error('Error creating/storing invite:', error);
|
||||
res.status(500).json({ success: false, message: 'Internal Server Error' });
|
||||
}
|
||||
});
|
||||
@@ -762,6 +1163,420 @@ app.delete('/api/servers/:guildId/invites/:code', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ADMIN LOGS: configuration and retrieval
|
||||
app.get('/api/servers/:guildId/admin-logs-settings', async (req, res) => {
|
||||
try {
|
||||
const { guildId } = req.params;
|
||||
const settings = (await pgClient.getServerSettings(guildId)) || {};
|
||||
const adminLogsSettings = settings.adminLogs || { enabled: false, channelId: '', commands: { kick: true, ban: true, timeout: true } };
|
||||
res.json(adminLogsSettings);
|
||||
} catch (error) {
|
||||
console.error('Error fetching admin logs settings:', error);
|
||||
res.status(500).json({ success: false, message: 'Internal Server Error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/servers/:guildId/admin-logs-settings', async (req, res) => {
|
||||
try {
|
||||
const { guildId } = req.params;
|
||||
const newSettings = req.body || {};
|
||||
|
||||
const existing = (await pgClient.getServerSettings(guildId)) || {};
|
||||
const merged = { ...existing };
|
||||
merged.adminLogs = {
|
||||
enabled: newSettings.enabled || false,
|
||||
channelId: newSettings.channelId || '',
|
||||
commands: newSettings.commands || { kick: true, ban: true, timeout: true }
|
||||
};
|
||||
|
||||
await pgClient.upsertServerSettings(guildId, merged);
|
||||
|
||||
// Notify bot of settings change
|
||||
if (bot && bot.setGuildSettings) {
|
||||
bot.setGuildSettings(guildId, merged);
|
||||
}
|
||||
|
||||
// If a remote bot push URL is configured, notify it with the new settings
|
||||
if (process.env.BOT_PUSH_URL) {
|
||||
try {
|
||||
const headers = {};
|
||||
if (process.env.INTERNAL_API_KEY) {
|
||||
headers['x-api-key'] = process.env.INTERNAL_API_KEY;
|
||||
}
|
||||
await axios.post(`${process.env.BOT_PUSH_URL.replace(/\/$/, '')}/internal/set-settings`, { guildId, settings: merged }, { headers });
|
||||
} catch (e) {
|
||||
console.error('Failed to push admin logs settings to bot:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ success: true, settings: merged.adminLogs });
|
||||
} catch (error) {
|
||||
console.error('Error saving admin logs settings:', error);
|
||||
res.status(500).json({ success: false, message: 'Internal Server Error' });
|
||||
}
|
||||
});
|
||||
|
||||
// REACTION ROLES: CRUD
|
||||
app.get('/api/servers/:guildId/reaction-roles', async (req, res) => {
|
||||
try {
|
||||
const { guildId } = req.params;
|
||||
const rows = await pgClient.listReactionRoles(guildId);
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
console.error('Error listing reaction roles:', err);
|
||||
res.status(500).json({ success: false, message: 'Internal Server Error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/servers/:guildId/reaction-roles', async (req, res) => {
|
||||
try {
|
||||
const { guildId } = req.params;
|
||||
const { channelId, name, embed, buttons, messageId } = req.body || {};
|
||||
if (!channelId || !name || !embed || !Array.isArray(buttons) || buttons.length === 0) {
|
||||
return res.status(400).json({ success: false, message: 'channelId, name, embed, and at least one button are required' });
|
||||
}
|
||||
const created = await pgClient.createReactionRole({ guildId, channelId, name, embed, buttons, messageId });
|
||||
// publish SSE
|
||||
publishEvent(guildId, 'reactionRolesUpdate', { action: 'create', reactionRole: created });
|
||||
res.json({ success: true, reactionRole: created });
|
||||
} catch (err) {
|
||||
console.error('Error creating reaction role:', err && err.message ? err.message : err);
|
||||
// If the pg helper threw a validation error, return 400 with message
|
||||
if (err && err.message && err.message.startsWith('Invalid reaction role payload')) {
|
||||
return res.status(400).json({ success: false, message: err.message });
|
||||
}
|
||||
res.status(500).json({ success: false, message: 'Internal Server Error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/servers/:guildId/reaction-roles/:id', async (req, res) => {
|
||||
try {
|
||||
const { guildId, id } = req.params;
|
||||
const updates = req.body || {};
|
||||
const existing = await pgClient.getReactionRole(id);
|
||||
if (!existing || existing.guild_id !== guildId) return res.status(404).json({ success: false, message: 'Not found' });
|
||||
const mapped = {
|
||||
channel_id: updates.channelId || existing.channel_id,
|
||||
message_id: typeof updates.messageId !== 'undefined' ? updates.messageId : existing.message_id,
|
||||
name: typeof updates.name !== 'undefined' ? updates.name : existing.name,
|
||||
embed: typeof updates.embed !== 'undefined' ? updates.embed : existing.embed,
|
||||
buttons: typeof updates.buttons !== 'undefined' ? updates.buttons : existing.buttons
|
||||
};
|
||||
const updated = await pgClient.updateReactionRole(id, mapped);
|
||||
publishEvent(guildId, 'reactionRolesUpdate', { action: 'update', reactionRole: updated });
|
||||
res.json({ success: true, reactionRole: updated });
|
||||
} catch (err) {
|
||||
console.error('Error updating reaction role:', err);
|
||||
res.status(500).json({ success: false, message: 'Internal Server Error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/servers/:guildId/reaction-roles/:id', async (req, res) => {
|
||||
try {
|
||||
const { guildId, id } = req.params;
|
||||
const existing = await pgClient.getReactionRole(id);
|
||||
if (!existing || existing.guild_id !== guildId) return res.status(404).json({ success: false, message: 'Not found' });
|
||||
await pgClient.deleteReactionRole(id);
|
||||
publishEvent(guildId, 'reactionRolesUpdate', { action: 'delete', id });
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error('Error deleting reaction role:', err);
|
||||
res.status(500).json({ success: false, message: 'Internal Server Error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/servers/:guildId/admin-logs', async (req, res) => {
|
||||
try {
|
||||
const { guildId } = req.params;
|
||||
const { action, limit } = req.query;
|
||||
const limitNum = limit ? parseInt(limit) : 50;
|
||||
|
||||
let logs;
|
||||
if (action) {
|
||||
logs = await pgClient.getAdminLogsByAction(guildId, action, limitNum);
|
||||
} else {
|
||||
logs = await pgClient.getAdminLogs(guildId, limitNum);
|
||||
}
|
||||
|
||||
// Transform snake_case to camelCase for frontend compatibility
|
||||
logs = logs.map(log => ({
|
||||
id: log.id,
|
||||
guildId: log.guild_id,
|
||||
action: log.action,
|
||||
targetUserId: log.target_user_id,
|
||||
targetUsername: log.target_username,
|
||||
moderatorUserId: log.moderator_user_id,
|
||||
moderatorUsername: log.moderator_username,
|
||||
reason: log.reason,
|
||||
duration: log.duration,
|
||||
endDate: log.end_date,
|
||||
timestamp: log.timestamp
|
||||
}));
|
||||
|
||||
res.json(logs);
|
||||
} catch (error) {
|
||||
console.error('Error fetching admin logs:', error);
|
||||
res.status(500).json({ success: false, message: 'Internal Server Error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/servers/:guildId/admin-logs/:logId', async (req, res) => {
|
||||
try {
|
||||
const { guildId, logId } = req.params;
|
||||
await pgClient.deleteAdminLog(guildId, parseInt(logId));
|
||||
|
||||
// Publish SSE event for live updates
|
||||
publishEvent(guildId, 'adminLogDeleted', { logId: parseInt(logId) });
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error deleting admin log:', error);
|
||||
res.status(500).json({ success: false, message: 'Internal Server Error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/servers/:guildId/admin-logs', async (req, res) => {
|
||||
try {
|
||||
const { guildId } = req.params;
|
||||
await pgClient.deleteAllAdminLogs(guildId);
|
||||
|
||||
// Publish SSE event for live updates
|
||||
publishEvent(guildId, 'adminLogsCleared', {});
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error deleting all admin logs:', error);
|
||||
res.status(500).json({ success: false, message: 'Internal Server Error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Internal endpoint for logging moderation actions
|
||||
app.post('/internal/log-moderation', express.json(), async (req, res) => {
|
||||
try {
|
||||
const { guildId, action, targetUserId, targetUsername, moderatorUserId, moderatorUsername, reason, duration, endDate } = req.body;
|
||||
|
||||
if (!guildId || !action || !targetUserId || !moderatorUserId || !reason) {
|
||||
return res.status(400).json({ success: false, message: 'Missing required fields' });
|
||||
}
|
||||
|
||||
// Save to database
|
||||
await pgClient.addAdminLog({
|
||||
guildId,
|
||||
action,
|
||||
targetUserId,
|
||||
targetUsername,
|
||||
moderatorUserId,
|
||||
moderatorUsername,
|
||||
reason,
|
||||
duration,
|
||||
endDate
|
||||
});
|
||||
|
||||
// Check if logging is enabled for this action and send to Discord channel
|
||||
const settings = (await pgClient.getServerSettings(guildId)) || {};
|
||||
const adminLogs = settings.adminLogs || { enabled: false, channelId: '', commands: { kick: true, ban: true, timeout: true } };
|
||||
|
||||
if (adminLogs.enabled && adminLogs.channelId && adminLogs.commands[action]) {
|
||||
const guild = bot.client.guilds.cache.get(guildId);
|
||||
if (guild) {
|
||||
const channel = guild.channels.cache.get(adminLogs.channelId);
|
||||
if (channel && channel.type === 0) { // GUILD_TEXT
|
||||
const embed = {
|
||||
color: action === 'kick' ? 0xffa500 : action === 'ban' ? 0xff0000 : 0x0000ff,
|
||||
title: `🚨 ${action.charAt(0).toUpperCase() + action.slice(1)} Action`,
|
||||
fields: [
|
||||
{
|
||||
name: '👤 Target',
|
||||
value: `${targetUsername} (${targetUserId})`,
|
||||
inline: true
|
||||
},
|
||||
{
|
||||
name: '👮 Moderator',
|
||||
value: `${moderatorUsername} (${moderatorUserId})`,
|
||||
inline: true
|
||||
},
|
||||
{
|
||||
name: '📝 Reason',
|
||||
value: reason,
|
||||
inline: false
|
||||
}
|
||||
],
|
||||
timestamp: new Date().toISOString(),
|
||||
footer: {
|
||||
text: 'ECS Admin Logs'
|
||||
}
|
||||
};
|
||||
|
||||
if (duration) {
|
||||
embed.fields.push({
|
||||
name: '⏱️ Duration',
|
||||
value: duration,
|
||||
inline: true
|
||||
});
|
||||
}
|
||||
|
||||
if (endDate) {
|
||||
embed.fields.push({
|
||||
name: '📅 End Date',
|
||||
value: new Date(endDate).toLocaleString(),
|
||||
inline: true
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await channel.send({ embeds: [embed] });
|
||||
} catch (error) {
|
||||
console.error('Failed to send admin log to Discord:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Publish SSE event for live updates
|
||||
publishEvent(guildId, 'adminLogAdded', {
|
||||
log: {
|
||||
guildId,
|
||||
action,
|
||||
targetUserId,
|
||||
targetUsername,
|
||||
moderatorUserId,
|
||||
moderatorUsername,
|
||||
reason,
|
||||
duration,
|
||||
endDate,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error logging moderation action:', error);
|
||||
res.status(500).json({ success: false, message: 'Internal Server Error' });
|
||||
}
|
||||
});
|
||||
|
||||
// MODERATION: frontend moderation actions
|
||||
app.post('/api/servers/:guildId/moderate', express.json(), async (req, res) => {
|
||||
try {
|
||||
const { guildId } = req.params;
|
||||
const { action, target, reason, duration, moderator } = req.body;
|
||||
|
||||
if (!action || !target || !reason) {
|
||||
return res.status(400).json({ success: false, message: 'Missing required fields: action, target, reason' });
|
||||
}
|
||||
|
||||
// Validate reason has at least 3 words
|
||||
const reasonWords = reason.trim().split(/\s+/);
|
||||
if (reasonWords.length < 3) {
|
||||
return res.status(400).json({ success: false, message: 'Reason must be at least 3 words long' });
|
||||
}
|
||||
|
||||
const guild = bot.client.guilds.cache.get(guildId);
|
||||
if (!guild) {
|
||||
return res.status(404).json({ success: false, message: 'Guild not found' });
|
||||
}
|
||||
|
||||
// Find the target user
|
||||
let targetUser = null;
|
||||
let targetMember = null;
|
||||
|
||||
// Try to find by ID first
|
||||
try {
|
||||
targetUser = await bot.client.users.fetch(target);
|
||||
targetMember = guild.members.cache.get(target);
|
||||
} catch (e) {
|
||||
// Try to find by username/mention
|
||||
const members = await guild.members.fetch();
|
||||
targetMember = members.find(m =>
|
||||
m.user.username.toLowerCase().includes(target.toLowerCase()) ||
|
||||
m.user.tag.toLowerCase().includes(target.toLowerCase()) ||
|
||||
(target.startsWith('<@') && target.includes(m.user.id))
|
||||
);
|
||||
if (targetMember) {
|
||||
targetUser = targetMember.user;
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetUser) {
|
||||
return res.status(404).json({ success: false, message: 'User not found in this server' });
|
||||
}
|
||||
|
||||
// Perform the moderation action
|
||||
let result = null;
|
||||
let durationString = null;
|
||||
let endDate = null;
|
||||
|
||||
switch (action) {
|
||||
case 'kick':
|
||||
if (!targetMember) {
|
||||
return res.status(400).json({ success: false, message: 'User is not in this server' });
|
||||
}
|
||||
result = await targetMember.kick(reason);
|
||||
break;
|
||||
|
||||
case 'ban':
|
||||
result = await guild.members.ban(targetUser, { reason });
|
||||
break;
|
||||
|
||||
case 'timeout':
|
||||
if (!targetMember) {
|
||||
return res.status(400).json({ success: false, message: 'User is not in this server' });
|
||||
}
|
||||
if (!duration || duration < 1 || duration > 40320) {
|
||||
return res.status(400).json({ success: false, message: 'Invalid timeout duration (1-40320 minutes)' });
|
||||
}
|
||||
const timeoutMs = duration * 60 * 1000;
|
||||
endDate = new Date(Date.now() + timeoutMs);
|
||||
result = await targetMember.timeout(timeoutMs, reason);
|
||||
|
||||
// Format duration string
|
||||
if (duration >= 1440) {
|
||||
durationString = `${Math.floor(duration / 1440)}d ${Math.floor((duration % 1440) / 60)}h ${duration % 60}m`;
|
||||
} else if (duration >= 60) {
|
||||
durationString = `${Math.floor(duration / 60)}h ${duration % 60}m`;
|
||||
} else {
|
||||
durationString = `${duration}m`;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
return res.status(400).json({ success: false, message: 'Invalid action' });
|
||||
}
|
||||
|
||||
// Log the moderation action
|
||||
const moderatorUsername = moderator ? (moderator.global_name || moderator.username || 'Unknown User') : 'Web Interface';
|
||||
try {
|
||||
const logData = {
|
||||
guildId,
|
||||
action,
|
||||
targetUserId: targetUser.id,
|
||||
targetUsername: targetUser.global_name || targetUser.username || 'Unknown User',
|
||||
moderatorUserId: moderator?.id || 'web-interface',
|
||||
moderatorUsername,
|
||||
reason,
|
||||
duration: durationString,
|
||||
endDate
|
||||
};
|
||||
|
||||
await fetch(`${BACKEND_BASE}/internal/log-moderation`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(logData)
|
||||
});
|
||||
} catch (logError) {
|
||||
console.error('Failed to log moderation action:', logError);
|
||||
}
|
||||
|
||||
res.json({ success: true, message: `${action} action completed successfully` });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error performing moderation action:', error);
|
||||
res.status(500).json({ success: false, message: error.message || 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
const bot = require('../discord-bot');
|
||||
|
||||
bot.login();
|
||||
@@ -791,6 +1606,40 @@ app.post('/internal/test-live', express.json(), async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Internal: ask bot to publish a reaction role message for a reaction role ID
|
||||
app.post('/internal/publish-reaction-role', express.json(), async (req, res) => {
|
||||
try {
|
||||
// If BOT_SECRET is configured, require the request to include it in the header
|
||||
const requiredSecret = process.env.BOT_SECRET;
|
||||
if (requiredSecret) {
|
||||
const provided = (req.get('x-bot-secret') || req.get('X-Bot-Secret') || '').toString();
|
||||
if (!provided || provided !== requiredSecret) {
|
||||
console.warn('/internal/publish-reaction-role: missing or invalid x-bot-secret header');
|
||||
return res.status(401).json({ success: false, message: 'Unauthorized' });
|
||||
}
|
||||
}
|
||||
|
||||
const { guildId, id } = req.body || {};
|
||||
if (!guildId || !id) return res.status(400).json({ success: false, message: 'guildId and id required' });
|
||||
const rr = await pgClient.getReactionRole(id);
|
||||
if (!rr || rr.guild_id !== guildId) return res.status(404).json({ success: false, message: 'Not found' });
|
||||
const result = await bot.postReactionRoleMessage(guildId, rr);
|
||||
if (result && result.success) {
|
||||
// update db already attempted by bot; publish SSE update
|
||||
publishEvent(guildId, 'reactionRolesUpdate', { action: 'posted', id, messageId: result.messageId });
|
||||
} else {
|
||||
// If the channel or message cannot be created because it no longer exists, remove the DB entry
|
||||
if (result && result.message && result.message.toLowerCase && (result.message.includes('Channel not found') || result.message.includes('Guild not found'))) {
|
||||
try { await pgClient.deleteReactionRole(id); publishEvent(guildId, 'reactionRolesUpdate', { action: 'delete', id }); } catch(e){}
|
||||
}
|
||||
}
|
||||
res.json(result);
|
||||
} catch (e) {
|
||||
console.error('Error in /internal/publish-reaction-role:', e);
|
||||
res.status(500).json({ success: false, message: 'Internal error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.listen(port, host, () => {
|
||||
console.log(`Server is running on ${host}:${port}`);
|
||||
});
|
||||
|
||||
4
backend/jest.config.js
Normal file
4
backend/jest.config.js
Normal file
@@ -0,0 +1,4 @@
|
||||
module.exports = {
|
||||
testEnvironment: 'node',
|
||||
testTimeout: 20000,
|
||||
};
|
||||
3689
backend/package-lock.json
generated
3689
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"test": "jest --runInBand",
|
||||
"start": "node index.js",
|
||||
"dev": "nodemon index.js"
|
||||
},
|
||||
@@ -16,12 +16,14 @@
|
||||
"cors": "^2.8.5",
|
||||
"crypto-js": "^4.2.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.19.2"
|
||||
,"pg": "^8.11.0",
|
||||
"pg-format": "^1.0.4"
|
||||
,"node-fetch": "^2.6.7"
|
||||
"express": "^4.19.2",
|
||||
"pg": "^8.11.0",
|
||||
"pg-format": "^1.0.4",
|
||||
"node-fetch": "^2.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.3"
|
||||
"nodemon": "^3.1.3",
|
||||
"jest": "^29.6.1",
|
||||
"supertest": "^6.3.3"
|
||||
}
|
||||
}
|
||||
|
||||
182
backend/pg.js
182
backend/pg.js
@@ -41,6 +41,35 @@ async function ensureSchema() {
|
||||
data JSONB DEFAULT '{}'
|
||||
);
|
||||
`);
|
||||
|
||||
await p.query(`
|
||||
CREATE TABLE IF NOT EXISTS admin_logs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
guild_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL, -- 'kick', 'ban', 'timeout'
|
||||
target_user_id TEXT NOT NULL,
|
||||
target_username TEXT NOT NULL,
|
||||
moderator_user_id TEXT NOT NULL,
|
||||
moderator_username TEXT NOT NULL,
|
||||
reason TEXT NOT NULL,
|
||||
duration TEXT, -- for timeout/ban (e.g., '1d', '30m', 'permanent')
|
||||
end_date TIMESTAMP WITH TIME ZONE, -- calculated end date for timeout/ban
|
||||
timestamp TIMESTAMP WITH TIME ZONE DEFAULT now()
|
||||
);
|
||||
`);
|
||||
|
||||
await p.query(`
|
||||
CREATE TABLE IF NOT EXISTS reaction_roles (
|
||||
id SERIAL PRIMARY KEY,
|
||||
guild_id TEXT NOT NULL,
|
||||
channel_id TEXT NOT NULL,
|
||||
message_id TEXT, -- message created in channel (optional until created)
|
||||
name TEXT NOT NULL,
|
||||
embed JSONB NOT NULL,
|
||||
buttons JSONB NOT NULL, -- array of { customId, label, roleId }
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT now()
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
// Servers
|
||||
@@ -76,6 +105,156 @@ async function deleteInvite(guildId, code) {
|
||||
await p.query('DELETE FROM invites WHERE guild_id = $1 AND code = $2', [guildId, code]);
|
||||
}
|
||||
|
||||
// Admin Logs
|
||||
async function addAdminLog(logData) {
|
||||
const p = initPool();
|
||||
const q = `INSERT INTO admin_logs(guild_id, action, target_user_id, target_username, moderator_user_id, moderator_username, reason, duration, end_date)
|
||||
VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9)`;
|
||||
await p.query(q, [
|
||||
logData.guildId,
|
||||
logData.action,
|
||||
logData.targetUserId,
|
||||
logData.targetUsername,
|
||||
logData.moderatorUserId,
|
||||
logData.moderatorUsername,
|
||||
logData.reason,
|
||||
logData.duration || null,
|
||||
logData.endDate || null
|
||||
]);
|
||||
}
|
||||
|
||||
async function getAdminLogs(guildId, limit = 50) {
|
||||
const p = initPool();
|
||||
const res = await p.query('SELECT * FROM admin_logs WHERE guild_id = $1 ORDER BY timestamp DESC LIMIT $2', [guildId, limit]);
|
||||
return res.rows;
|
||||
}
|
||||
|
||||
async function getAdminLogsByAction(guildId, action, limit = 50) {
|
||||
const p = initPool();
|
||||
const res = await p.query('SELECT * FROM admin_logs WHERE guild_id = $1 AND action = $2 ORDER BY timestamp DESC LIMIT $3', [guildId, action, limit]);
|
||||
return res.rows;
|
||||
}
|
||||
|
||||
async function deleteAdminLog(guildId, logId) {
|
||||
const p = initPool();
|
||||
await p.query('DELETE FROM admin_logs WHERE guild_id = $1 AND id = $2', [guildId, logId]);
|
||||
}
|
||||
|
||||
async function deleteAllAdminLogs(guildId) {
|
||||
const p = initPool();
|
||||
await p.query('DELETE FROM admin_logs WHERE guild_id = $1', [guildId]);
|
||||
}
|
||||
|
||||
// Reaction Roles
|
||||
async function listReactionRoles(guildId) {
|
||||
const p = initPool();
|
||||
const res = await p.query('SELECT id, guild_id, channel_id, message_id, name, embed, buttons, created_at FROM reaction_roles WHERE guild_id = $1 ORDER BY created_at DESC', [guildId]);
|
||||
return res.rows;
|
||||
}
|
||||
|
||||
async function getReactionRole(id) {
|
||||
const p = initPool();
|
||||
const res = await p.query('SELECT id, guild_id, channel_id, message_id, name, embed, buttons, created_at FROM reaction_roles WHERE id = $1', [id]);
|
||||
return res.rows[0] || null;
|
||||
}
|
||||
|
||||
async function createReactionRole(rr) {
|
||||
const p = initPool();
|
||||
const q = `INSERT INTO reaction_roles(guild_id, channel_id, message_id, name, embed, buttons) VALUES($1,$2,$3,$4,$5,$6) RETURNING *`;
|
||||
// Ensure embed/buttons are proper JSON objects/arrays (some clients may send them as JSON strings)
|
||||
let embed = rr.embed || {};
|
||||
let buttons = rr.buttons || [];
|
||||
// If the payload is double-encoded (string containing a JSON string), keep parsing until it's a non-string
|
||||
try {
|
||||
while (typeof embed === 'string') {
|
||||
embed = JSON.parse(embed);
|
||||
}
|
||||
} catch (e) {
|
||||
// fall through and let Postgres reject invalid JSON if it's still malformed
|
||||
}
|
||||
try {
|
||||
while (typeof buttons === 'string') {
|
||||
buttons = JSON.parse(buttons);
|
||||
}
|
||||
// If buttons is an array but elements are themselves JSON strings, parse each element
|
||||
if (Array.isArray(buttons)) {
|
||||
buttons = buttons.map(b => {
|
||||
if (typeof b === 'string') {
|
||||
try {
|
||||
let parsed = b;
|
||||
while (typeof parsed === 'string') {
|
||||
parsed = JSON.parse(parsed);
|
||||
}
|
||||
return parsed;
|
||||
} catch (e) {
|
||||
return b; // leave as-is
|
||||
}
|
||||
}
|
||||
return b;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// leave as-is
|
||||
}
|
||||
|
||||
// Validate shapes before inserting to DB to avoid Postgres JSON errors
|
||||
if (!embed || typeof embed !== 'object' || Array.isArray(embed)) {
|
||||
throw new Error('Invalid reaction role payload: `embed` must be a JSON object');
|
||||
}
|
||||
if (!Array.isArray(buttons) || buttons.length === 0 || !buttons.every(b => b && typeof b === 'object')) {
|
||||
throw new Error('Invalid reaction role payload: `buttons` must be a non-empty array of objects');
|
||||
}
|
||||
|
||||
const res = await p.query(q, [rr.guildId, rr.channelId, rr.messageId || null, rr.name, embed, buttons]);
|
||||
return res.rows[0];
|
||||
}
|
||||
|
||||
async function updateReactionRole(id, updates) {
|
||||
const p = initPool();
|
||||
const parts = [];
|
||||
const vals = [];
|
||||
let idx = 1;
|
||||
for (const k of ['channel_id','message_id','name','embed','buttons']) {
|
||||
if (typeof updates[k] !== 'undefined') {
|
||||
parts.push(`${k} = $${idx}`);
|
||||
// coerce JSON strings to objects for JSONB columns
|
||||
if ((k === 'embed' || k === 'buttons') && typeof updates[k] === 'string') {
|
||||
try {
|
||||
vals.push(JSON.parse(updates[k]));
|
||||
} catch (e) {
|
||||
vals.push(updates[k]);
|
||||
}
|
||||
} else {
|
||||
vals.push(updates[k]);
|
||||
}
|
||||
idx++;
|
||||
}
|
||||
}
|
||||
if (parts.length === 0) return getReactionRole(id);
|
||||
const q = `UPDATE reaction_roles SET ${parts.join(', ')} WHERE id = $${idx} RETURNING *`;
|
||||
vals.push(id);
|
||||
// Validate embed/buttons if they are being updated
|
||||
if (typeof updates.embed !== 'undefined') {
|
||||
const embed = vals[parts.indexOf('embed = $' + (parts.findIndex(p => p.startsWith('embed')) + 1))];
|
||||
if (!embed || typeof embed !== 'object' || Array.isArray(embed)) {
|
||||
throw new Error('Invalid reaction role payload: `embed` must be a JSON object');
|
||||
}
|
||||
}
|
||||
if (typeof updates.buttons !== 'undefined') {
|
||||
const buttons = vals[parts.indexOf('buttons = $' + (parts.findIndex(p => p.startsWith('buttons')) + 1))];
|
||||
if (!Array.isArray(buttons) || buttons.length === 0 || !buttons.every(b => b && typeof b === 'object')) {
|
||||
throw new Error('Invalid reaction role payload: `buttons` must be a non-empty array of objects');
|
||||
}
|
||||
}
|
||||
const res = await p.query(q, vals);
|
||||
return res.rows[0] || null;
|
||||
}
|
||||
|
||||
async function deleteReactionRole(id) {
|
||||
const p = initPool();
|
||||
await p.query('DELETE FROM reaction_roles WHERE id = $1', [id]);
|
||||
}
|
||||
|
||||
// Users
|
||||
async function getUserData(discordId) {
|
||||
const p = initPool();
|
||||
@@ -89,4 +268,5 @@ async function upsertUserData(discordId, data) {
|
||||
await p.query(`INSERT INTO users(discord_id, data) VALUES($1, $2) ON CONFLICT (discord_id) DO UPDATE SET data = $2`, [discordId, data]);
|
||||
}
|
||||
|
||||
module.exports = { initPool, ensureSchema, getServerSettings, upsertServerSettings, listInvites, addInvite, deleteInvite, getUserData, upsertUserData };
|
||||
module.exports = { initPool, ensureSchema, getServerSettings, upsertServerSettings, listInvites, addInvite, deleteInvite, getUserData, upsertUserData, addAdminLog, getAdminLogs, getAdminLogsByAction, deleteAdminLog, deleteAllAdminLogs, listReactionRoles, getReactionRole, createReactionRole, updateReactionRole, deleteReactionRole };
|
||||
|
||||
|
||||
12
backend/scripts/listServers.js
Normal file
12
backend/scripts/listServers.js
Normal file
@@ -0,0 +1,12 @@
|
||||
const { Pool } = require('pg');
|
||||
(async function(){
|
||||
try{
|
||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||
const res = await pool.query('SELECT guild_id FROM servers LIMIT 10');
|
||||
console.log('servers:', JSON.stringify(res.rows, null, 2));
|
||||
await pool.end();
|
||||
}catch(err){
|
||||
console.error('ERR', err && err.message ? err.message : err);
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
62
backend/tests/pg.reactionroles.test.js
Normal file
62
backend/tests/pg.reactionroles.test.js
Normal file
@@ -0,0 +1,62 @@
|
||||
const pg = require('../pg');
|
||||
|
||||
// These tests are optional: they run only if TEST_DATABASE_URL is set in env.
|
||||
// They are intentionally lightweight and will skip when not configured.
|
||||
|
||||
const TEST_DB = process.env.TEST_DATABASE_URL;
|
||||
|
||||
describe('pg reaction_roles helpers (integration)', () => {
|
||||
if (!TEST_DB) {
|
||||
test('skipped - no TEST_DATABASE_URL', () => {
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.DATABASE_URL = TEST_DB;
|
||||
await pg.initPool();
|
||||
await pg.ensureSchema();
|
||||
});
|
||||
|
||||
let created;
|
||||
|
||||
test('createReactionRole -> returns created record', async () => {
|
||||
const rr = {
|
||||
guildId: 'test-guild',
|
||||
channelId: 'test-channel',
|
||||
name: 'Test RR',
|
||||
embed: { title: 'Hello' },
|
||||
buttons: [{ label: 'One', roleId: 'role1' }]
|
||||
};
|
||||
created = await pg.createReactionRole(rr);
|
||||
expect(created).toBeTruthy();
|
||||
expect(created.id).toBeGreaterThan(0);
|
||||
expect(created.guild_id).toBe('test-guild');
|
||||
});
|
||||
|
||||
test('listReactionRoles -> includes created', async () => {
|
||||
const list = await pg.listReactionRoles('test-guild');
|
||||
expect(Array.isArray(list)).toBe(true);
|
||||
const found = list.find(r => r.id === created.id);
|
||||
expect(found).toBeTruthy();
|
||||
});
|
||||
|
||||
test('getReactionRole -> returns record by id', async () => {
|
||||
const got = await pg.getReactionRole(created.id);
|
||||
expect(got).toBeTruthy();
|
||||
expect(got.id).toBe(created.id);
|
||||
});
|
||||
|
||||
test('updateReactionRole -> updates and returns', async () => {
|
||||
const updated = await pg.updateReactionRole(created.id, { name: 'Updated' });
|
||||
expect(updated).toBeTruthy();
|
||||
expect(updated.name).toBe('Updated');
|
||||
});
|
||||
|
||||
test('deleteReactionRole -> removes record', async () => {
|
||||
await pg.deleteReactionRole(created.id);
|
||||
const after = await pg.getReactionRole(created.id);
|
||||
expect(after).toBeNull();
|
||||
});
|
||||
});
|
||||
138
checklist.md
138
checklist.md
@@ -1,24 +1,43 @@
|
||||
# Project Checklist (tidy & current)
|
||||
# Project Checklist (tidy & current)
|
||||
|
||||
Below are implemented features and pending items, grouped by area.
|
||||
Below are implemented features - [x] Front - [x] Live updates between bot and frontend using SSE events for real-time log synchronization (admin logs update immediately when moderation actions occur)nd UI for admin logs configuration in Server Settings
|
||||
- [x] Database schema for storing moderation action logs
|
||||
- [x] Require reason field (minimum 3 words) for all moderation commands
|
||||
- [x] Admin Logs UI: added logs display section showing recent moderation actions with detailsd pending items, grouped by area.
|
||||
|
||||
## Backend
|
||||
- [x] Express API: OAuth, server settings, channel/role endpoints, leave
|
||||
- [x] Invite endpoints (GET/POST/DELETE) and invite-token issuance
|
||||
- [x] Per-command toggles persistence and management
|
||||
- [x] Config endpoints for welcome/leave and autorole
|
||||
- [x] Admin Logs API endpoints: GET/POST for admin logs configuration, GET for retrieving moderation action logs
|
||||
- [x] Frontend Moderation API: POST endpoint for direct ban/kick/timeout actions from web interface
|
||||
- [x] Server Members API: GET endpoint for fetching server members for moderation user selection
|
||||
- [x] SSE events: added botStatusUpdate events for real-time bot join/leave notifications
|
||||
|
||||
## Frontend
|
||||
- [x] Login, Dashboard, Server Settings pages
|
||||
- Login redirects to Dashboard after OAuth and user/guilds are persisted in localStorage
|
||||
- Dashboard is protected: user must be logged in to view (redirects to login otherwise)
|
||||
- [x] MUI components, responsive layout, mobile fixes
|
||||
- [x] Theme switching (persist local) and user settings UI
|
||||
- [x] Theme switching (persist local) and user settings UI with adjusted light theme background
|
||||
- [x] Invite UI: create form, list, copy, delete with confirmation
|
||||
- [x] Commands UI (per-command toggles)
|
||||
- [x] Admin commands (kick/ban/timeout) removed from regular commands list, only shown in Admin Commands section
|
||||
- [x] Live Notifications UI (per-server toggle & config)
|
||||
- Live Notifications accessible from server page via dropdown and dialog
|
||||
- Dashboard: channel dropdown and watched-user list added
|
||||
- Channel selection, watched-user list, live status with Watch Live button
|
||||
- Real-time updates: adding/removing users via frontend or bot commands publishes SSE `twitchUsersUpdate` and pushes settings to bot
|
||||
- Bot commands (`/add-twitchuser`, `/remove-twitchuser`) refresh local cache immediately after backend success
|
||||
- Message mode: toggle between Default and Custom; Apply sends `message`/`customMessage` (default fallback if empty); no longer dual free-form fields
|
||||
- Collapsible accordion interface: separate Twitch and Kick tabs (Kick tab disabled)
|
||||
- [x] Admin Commands UI: dedicated section for moderation commands with toggle controls
|
||||
- [x] Moderation Commands (`/kick`, `/ban`, `/timeout`) displayed with permission requirements and toggle switches
|
||||
- [x] Admin Logs Configuration UI: channel selection and per-command enable/disable toggles
|
||||
- [x] Frontend Moderation Actions: direct ban/kick/timeout functionality from web interface with user autocomplete dropdown
|
||||
- [x] User permission validation and reason requirements (minimum 3 words)
|
||||
- [x] Integration with backend moderation API and admin logging system
|
||||
- [x] Admin Logs channel selection: shows all server text channels (not just channels where bot has permission) and updates immediately when changed
|
||||
- [x] Admin logs properly save moderator usernames for both bot slash commands and frontend moderation actions, and persist across page refreshes
|
||||
|
||||
## Discord Bot
|
||||
- [x] discord.js integration (events and commands)
|
||||
@@ -26,23 +45,99 @@
|
||||
- [x] Bot used by backend to fetch live guild data and manage invites
|
||||
- [x] Bot reads/writes per-guild command toggles via backend/Postgres
|
||||
- [x] Backend immediately notifies bot of toggle changes (pushes updated settings to bot cache) so frontend toggles take effect instantly
|
||||
- [x] New slash command: `/list-twitchusers` to list watched Twitch usernames for a guild
|
||||
- [x] New slash command: `/setup-live` to enable/disable Twitch live notifications for the server (preserves other settings)
|
||||
- [x] Frontend: Confirm dialog and working Delete action for Twitch watched users in Live Notifications
|
||||
- [x] Live Notifications: bot posts message to configured channel with stream title and link when a watched Twitch user goes live
|
||||
- [x] Live Notifications: bot posts rich embed to channel when a watched Twitch user goes live (thumbnail, clickable title, bio/description, category/game, viewers, footer with "ehchadservices" and start datetime)
|
||||
- [x] Live Notifications polling frequency set to 3 seconds for rapid detection (configurable via `TWITCH_POLL_INTERVAL_MS`)
|
||||
- [x] Live Notifications polling frequency set to 5 seconds (configurable via `TWITCH_POLL_INTERVAL_MS`)
|
||||
- [x] On bot restart, sends messages for currently live watched users; then sends for new streams once per session
|
||||
- [x] Twitch Watcher Debug Logging: comprehensive debug mode added (enable with `TWITCH_WATCHER_DEBUG=true`) to track guild checks, settings retrieval, stream fetching, channel permissions, and message sending for troubleshooting live notification issues
|
||||
- [x] Twitch API Functions Export Fix: added missing `tryFetchTwitchStreams` and `_rawGetTwitchStreams` to api.js module exports to resolve "is not a function" errors
|
||||
- [x] Twitch Streams Array Safety: added `Array.isArray()` checks in twitch-watcher.js to prevent "filter is not a function" errors when API returns unexpected data types
|
||||
- [x] Twitch Commands Postgres Integration: updated all Discord bot Twitch commands (`/add-twitchuser`, `/remove-twitchuser`) to use api.js functions for consistent Postgres backend communication
|
||||
- [x] Twitch Message Template Variables: added support for `{user}`, `{title}`, `{category}`, and `{viewers}` template variables in custom live notification messages for dynamic content insertion
|
||||
- [x] Frontend JSX Syntax Fix: fixed React Fragment wrapping for admin logs map to resolve build compilation errors
|
||||
- [x] Frontend: show "Watch Live" button next to watched user when they are live (links to Twitch)
|
||||
- [x] Bi-directional sync: backend POST/DELETE for twitch-users now also pushes new settings to bot process (when `BOT_PUSH_URL` configured)
|
||||
- [x] Bot adds/removes users via backend endpoints ensuring single source of truth (Postgres)
|
||||
- [x] Live notifications toggle on site enables/disables watching and publishes SSE for real-time updates
|
||||
- [x] /manage-commands command has enable/disable buttons that sync with frontend via backend API and SSE for live updating
|
||||
- [x] All Twitch-related commands (add, remove, list) and frontend actions communicate with backend and Postgres database
|
||||
- [x] Welcome/Leave messages: bot sends configured messages to channels when users join/leave
|
||||
- [x] Welcome messages with {user} and {server} placeholders
|
||||
- [x] Leave messages with {user} placeholder
|
||||
- [x] Autorole assignment on member join
|
||||
- [x] All settings managed through Server Settings UI
|
||||
- [x] Event handlers properly integrated with API settings
|
||||
- [x] Kick live notifications bot integration (temporarily disabled)
|
||||
- [x] New slash commands: `/add-kickuser`, `/remove-kickuser`, `/list-kickusers` (commands exist but watcher disabled)
|
||||
- [x] Kick API polling and notification posting (watcher removed, API endpoints remain)
|
||||
- [x] Per-server Kick user management via backend API (endpoints functional)
|
||||
- [x] Frontend tabs: separate Twitch and Kick tabs in Live Notifications accordion (Kick tab disabled)
|
||||
- [x] Bot watcher temporarily disabled in index.js startup
|
||||
- [x] Dev command filtering: commands marked with `dev: true` are hidden from UI, help, and Discord registration
|
||||
- [x] Admin Moderation Commands: `/kick`, `/ban`, `/timeout` with proper permission checks and role hierarchy validation
|
||||
- [x] Commands accept user mentions or user IDs as input to allow targeting any user (not limited by Discord's user selection filtering)
|
||||
- [x] Frontend integration: web interface moderation actions with permission validation
|
||||
- [x] Moderation actions are logged to postgres database with reasons and automatically posted to configured admin logs channel
|
||||
- [x] Admin logs properly capture and display the moderator who performed the action (both from bot slash commands and frontend)
|
||||
- [x] Admin Logs System: event logging for moderation actions
|
||||
- [x] New slash command: `/setup-adminlogs` to configure logging channel and per-command enable/disable
|
||||
- [x] Bot posts detailed moderation logs to configured channel showing: command used, target user, moderator, date/time, reason (required min 3 words), duration, end date
|
||||
- [x] Backend API endpoints for admin logs configuration and retrieval
|
||||
- [x] Frontend UI for admin logs configuration in Server Settings
|
||||
- [x] Database schema for storing moderation action logs
|
||||
- [x] Require reason field (minimum 3 words) for all moderation commands
|
||||
- [x] Admin logs are unique to each guild and stored in postgres database
|
||||
- [x] Frontend delete buttons for individual logs and delete all logs with confirm dialogs
|
||||
- [x] Live updates between bot and frontend using SSE events for real-time log synchronization
|
||||
- [x] Admin logs properly display the username who called the command and the user they called it on for both bot slash commands and frontend moderation actions
|
||||
- [x] Bot command username logging fixed: uses correct Discord user properties (username/global_name instead of deprecated tag)
|
||||
- [x] Bot event handlers: added guildCreate and guildDelete events to publish SSE notifications for live dashboard updates
|
||||
- [x] Invite synchronization: real-time sync between Discord server events and frontend
|
||||
- [x] Discord event handlers for inviteCreate and inviteDelete events
|
||||
- [x] Only bot-created invites are tracked and synchronized
|
||||
- [x] Frontend SSE event listeners for inviteCreated and inviteDeleted events
|
||||
- [x] Backend API updated to store existing invites from Discord events
|
||||
- [x] Invite deletions from Discord server are immediately reflected in frontend
|
||||
- [x] Offline reconciliation: bot detects and removes invites deleted while offline on startup
|
||||
- [x] Automatic cleanup of stale invites from database and frontend when bot comes back online
|
||||
|
||||
- [x] Reaction Roles: configurable reaction-role messages with buttons
|
||||
- [x] Backend table `reaction_roles` and CRUD endpoints
|
||||
- [x] Frontend accordion UI to create/edit/delete reaction role configurations (channel, named buttons, role picker, embed)
|
||||
- [x] Live SSE updates when reaction roles are created/updated/deleted
|
||||
- [x] Bot posts embedded message with buttons and toggles roles on button press
|
||||
- [x] Replacement of confirm() with app `ConfirmDialog` and role picker dropdown in UI
|
||||
- [x] Initial and periodic reconciliation: bot removes DB entries when the message or channel is missing
|
||||
- [x] Backend: tolerate JSON string payloads for `embed` and `buttons` when creating/updating reaction roles (auto-parse before inserting JSONB)
|
||||
- [x] Slash command `/post-reaction-role <id>` for admins to post a reaction role message from Discord
|
||||
- [x] Frontend edit functionality for existing reaction roles
|
||||
- [x] Button ID stability: customId uses roleId instead of array index for robustness
|
||||
|
||||
## Database
|
||||
- [x] Postgres support via `DATABASE_URL` (backend auto-creates `servers`, `invites`, `users`)
|
||||
- [x] Legacy encrypted `backend/db.json` retained (migration planned)
|
||||
- [ ] Migration script: import `backend/db.json` into Postgres (planned)
|
||||
- [x] Kick.com live notifications: backend API, frontend UI, bot integration
|
||||
- Database schema: kickUsers table with userId, username, guildId
|
||||
- API endpoints: GET/POST/DELETE /api/servers/:guildId/kick-users
|
||||
- Bot commands: /add-kickuser, /remove-kickuser, /list-kickusers
|
||||
- Frontend tabs: separate Twitch and Kick tabs in Live Notifications accordion
|
||||
- Kick API integration: polling for live status, stream metadata, web scraping fallback for 403 errors
|
||||
- Per-server configuration: all settings scoped by guildId
|
||||
- [x] Schema: live notification settings stored in server settings (via `liveNotifications` JSON)
|
||||
- Fields: `enabled`, `channelId`, `users[]`, `kickUsers[]`, `message`, `customMessage` (custom overrides default if non-empty)
|
||||
- Users list preserved when updating other live notification settings (fixed: kickUsers now also preserved)
|
||||
- [x] Admin Logs Database Schema: new table for storing moderation action logs
|
||||
- Fields: id, guildId, action (kick/ban/timeout), targetUserId, targetUsername, moderatorUserId, moderatorUsername, reason, duration, endDate, timestamp
|
||||
|
||||
## Security & Behavior
|
||||
- [x] Invite DELETE requires short-lived HMAC token (`x-invite-token`)
|
||||
- [x] Frontend confirmation dialog for invite deletion
|
||||
- [ ] Harden invite-token issuance (require OAuth + admin check)
|
||||
- [ ] Template variables for messages (planned): support `{user}`, `{title}`, `{category}`, `{viewers}` replacement in `message` / `customMessage`
|
||||
- [x] Moderation Command Requirements: require reason field (minimum 3 words) for all moderation commands (`/kick`, `/ban`, `/timeout`)
|
||||
- [x] ServerSettings back button: fixed to navigate to dashboard instead of browser history to prevent accidental accordion opening
|
||||
|
||||
## Docs & Deployment
|
||||
- [x] README and CHANGELOG updated with setup steps and Postgres guidance
|
||||
@@ -58,7 +153,32 @@
|
||||
- Server cards: uniform sizes, image cropping, name clamping
|
||||
- Mobile spacing and typography adjustments
|
||||
- Dashboard action buttons repositioned (Invite/Leave under title)
|
||||
- Live Notifications: collapsible accordion with tabbed interface for Twitch and Kick tabs (Kick tab disabled)
|
||||
- [x] All accordions in ServerSettings: consistently grayed out (opacity 0.5) when bot is not in server
|
||||
|
||||
- [x] Footer component: added global footer showing "© ehchadservices.com 2025" on all pages
|
||||
- [x] Dashboard live reloading: real-time updates when bot joins/leaves servers via SSE events
|
||||
- [x] Responsive design: mobile-friendly layout with adaptive padding, typography, and component sizing
|
||||
- [x] Ultra-wide screen support: max-width constraints and overflow prevention
|
||||
- [x] Sticky footer: footer positioned at bottom of viewport regardless of content height
|
||||
|
||||
- [x] Navbar branding: title shows "ECS" on mobile, "EhChadServices" on desktop
|
||||
- [x] Dashboard welcome text: updated to "Welcome back, {username}" with even larger typography (h3/h2 variants) and increased spacing; title also enlarged (h4/h3) for better proportion and explicit margin-bottom for clear line separation
|
||||
|
||||
- [x] Browser tab now shows `ECS - <Page Name>` (e.g., 'ECS - Dashboard')
|
||||
- [x] Dashboard duplicate title fixed; user settings (avatar/themes) restored via NavBar
|
||||
|
||||
- [x] Maintenance page
|
||||
- Frontend displays a maintenance page with a loading indicator when the backend is offline; it polls the backend and reloads UI immediately when the backend is available.
|
||||
- [x] Global backend health & SSE
|
||||
- [x] Added `BackendContext` to centralize health polling and a single shared EventSource
|
||||
- [x] Pages (including `ServerSettings`) use the shared event bus for live updates so the whole site receives changes in real-time
|
||||
- [ ] Frontend file re-organization
|
||||
- [ ] Verify guild-scoped SSE payloads include guildId and frontend filters events by guild (in-progress)
|
||||
- [ ] Add debug SSE publish endpoint to help validate real-time flows (done, guarded by DEBUG_SSE)
|
||||
- [x] Created `frontend/src/lib/api.js` and refactored some modules to use it
|
||||
- [x] Created `frontend/src/components/common` and `frontend/src/components/server`
|
||||
- [x] Moved `ConfirmDialog` and `MaintenancePage` to `components/common`
|
||||
- [x] Moved `ServerSettings` and `HelpPage` to `components/server`
|
||||
- [x] Fixed ESLint warnings: removed unused imports and handlers, added proper dependency management
|
||||
- [x] Fixed compilation errors: added missing MUI imports and Snackbar component
|
||||
- [x] Navbar visibility: enhanced with solid background and stronger border for better visibility across all themes
|
||||
|
||||
@@ -87,13 +87,33 @@ async function listInvites(guildId) {
|
||||
return json || [];
|
||||
}
|
||||
|
||||
async function listReactionRoles(guildId) {
|
||||
const path = `/api/servers/${guildId}/reaction-roles`;
|
||||
const json = await safeFetchJsonPath(path);
|
||||
return json || [];
|
||||
}
|
||||
|
||||
async function addInvite(guildId, invite) {
|
||||
const path = `/api/servers/${guildId}/invites`;
|
||||
try {
|
||||
// If invite is an object with code property, it's already created - send full data
|
||||
// If it's just channelId/maxAge/etc, it's for creation
|
||||
const isExistingInvite = invite && typeof invite === 'object' && invite.code;
|
||||
|
||||
const body = isExistingInvite ? {
|
||||
code: invite.code,
|
||||
url: invite.url,
|
||||
channelId: invite.channelId,
|
||||
maxUses: invite.maxUses,
|
||||
maxAge: invite.maxAge,
|
||||
temporary: invite.temporary,
|
||||
createdAt: invite.createdAt
|
||||
} : invite;
|
||||
|
||||
const res = await tryFetch(path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(invite),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return res && res.ok;
|
||||
} catch (e) {
|
||||
@@ -113,6 +133,33 @@ async function deleteInvite(guildId, code) {
|
||||
}
|
||||
}
|
||||
|
||||
async function updateReactionRole(guildId, id, updates) {
|
||||
const path = `/api/servers/${guildId}/reaction-roles/${id}`;
|
||||
try {
|
||||
const res = await tryFetch(path, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
if (!res) return null;
|
||||
try { return await res.json(); } catch (e) { return null; }
|
||||
} catch (e) {
|
||||
console.error(`Failed to update reaction role ${id} for ${guildId}:`, e && e.message ? e.message : e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteReactionRole(guildId, id) {
|
||||
const path = `/api/servers/${guildId}/reaction-roles/${id}`;
|
||||
try {
|
||||
const res = await tryFetch(path, { method: 'DELETE' });
|
||||
return res && res.ok;
|
||||
} catch (e) {
|
||||
console.error(`Failed to delete reaction role ${id} 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) {
|
||||
@@ -163,4 +210,89 @@ async function _rawGetTwitchStreams(usersCsv) {
|
||||
try { return await res.json(); } catch (e) { return []; }
|
||||
}
|
||||
|
||||
module.exports = { getServerSettings, upsertServerSettings, getCommands, toggleCommand, listInvites, addInvite, deleteInvite, getTwitchUsers, addTwitchUser, deleteTwitchUser };
|
||||
// Kick users helpers
|
||||
async function getKickUsers(guildId) {
|
||||
const path = `/api/servers/${guildId}/kick-users`;
|
||||
const json = await safeFetchJsonPath(path);
|
||||
return json || [];
|
||||
}
|
||||
|
||||
async function addKickUser(guildId, username) {
|
||||
const path = `/api/servers/${guildId}/kick-users`;
|
||||
try {
|
||||
const res = await tryFetch(path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username }),
|
||||
});
|
||||
return res && res.ok;
|
||||
} catch (e) {
|
||||
console.error(`Failed to add kick user ${username} for ${guildId}:`, e && e.message ? e.message : e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteKickUser(guildId, username) {
|
||||
const path = `/api/servers/${guildId}/kick-users/${encodeURIComponent(username)}`;
|
||||
try {
|
||||
const res = await tryFetch(path, { method: 'DELETE' });
|
||||
return res && res.ok;
|
||||
} catch (e) {
|
||||
console.error(`Failed to delete kick user ${username} for ${guildId}:`, e && e.message ? e.message : e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function getWelcomeLeaveSettings(guildId) {
|
||||
const path = `/api/servers/${guildId}/welcome-leave-settings`;
|
||||
const json = await safeFetchJsonPath(path);
|
||||
return json || { welcome: { enabled: false }, leave: { enabled: false } };
|
||||
}
|
||||
|
||||
async function getAutoroleSettings(guildId) {
|
||||
const path = `/api/servers/${guildId}/autorole-settings`;
|
||||
const json = await safeFetchJsonPath(path);
|
||||
return json || { enabled: false, roleId: '' };
|
||||
}
|
||||
|
||||
async function reconcileInvites(guildId, currentDiscordInvites) {
|
||||
try {
|
||||
// Get invites from database
|
||||
const dbInvites = await listInvites(guildId) || [];
|
||||
|
||||
// Find invites in database that no longer exist in Discord
|
||||
const discordInviteCodes = new Set(currentDiscordInvites.map(inv => inv.code));
|
||||
const deletedInvites = dbInvites.filter(dbInv => !discordInviteCodes.has(dbInv.code));
|
||||
|
||||
// Delete each invite that no longer exists
|
||||
for (const invite of deletedInvites) {
|
||||
console.log(`🗑️ Reconciling deleted invite ${invite.code} for guild ${guildId}`);
|
||||
await deleteInvite(guildId, invite.code);
|
||||
|
||||
// Publish SSE event for frontend update
|
||||
try {
|
||||
await tryFetch('/api/events/publish', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
event: 'inviteDeleted',
|
||||
data: { code: invite.code, guildId }
|
||||
})
|
||||
});
|
||||
} catch (sseErr) {
|
||||
console.error('Failed to publish SSE event for reconciled invite deletion:', sseErr);
|
||||
}
|
||||
}
|
||||
|
||||
if (deletedInvites.length > 0) {
|
||||
console.log(`✅ Reconciled ${deletedInvites.length} deleted invites for guild ${guildId}`);
|
||||
}
|
||||
|
||||
return deletedInvites.length;
|
||||
} catch (e) {
|
||||
console.error(`Failed to reconcile invites for guild ${guildId}:`, e && e.message ? e.message : e);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getServerSettings, upsertServerSettings, getCommands, toggleCommand, listInvites, addInvite, deleteInvite, listReactionRoles, updateReactionRole, deleteReactionRole, getTwitchUsers, addTwitchUser, deleteTwitchUser, tryFetchTwitchStreams, _rawGetTwitchStreams, getKickUsers, addKickUser, deleteKickUser, getWelcomeLeaveSettings, getAutoroleSettings, reconcileInvites };
|
||||
|
||||
43
discord-bot/commands/add-kickuser.js
Normal file
43
discord-bot/commands/add-kickuser.js
Normal file
@@ -0,0 +1,43 @@
|
||||
const { SlashCommandBuilder, PermissionsBitField } = require('discord.js');
|
||||
const fetch = require('node-fetch');
|
||||
|
||||
module.exports = {
|
||||
name: 'add-kickuser',
|
||||
description: 'Admin: add a Kick username to watch for this server (DISABLED)',
|
||||
enabled: false,
|
||||
dev: true,
|
||||
builder: new SlashCommandBuilder()
|
||||
.setName('add-kickuser')
|
||||
.setDescription('Add a Kick username to watch for live notifications')
|
||||
.addStringOption(opt => opt.setName('username').setDescription('Kick username').setRequired(true)),
|
||||
async execute(interaction) {
|
||||
if (!interaction.member.permissions.has(PermissionsBitField.Flags.Administrator)) {
|
||||
await interaction.reply({ content: 'You must be an administrator to use this command.', flags: 64 });
|
||||
return;
|
||||
}
|
||||
const username = interaction.options.getString('username').toLowerCase().trim();
|
||||
try {
|
||||
const backendBase = process.env.BACKEND_BASE || `http://${process.env.HOST || '127.0.0.1'}:${process.env.PORT || 3002}`;
|
||||
const resp = await fetch(`${backendBase}/api/servers/${interaction.guildId}/kick-users`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username })
|
||||
});
|
||||
if (resp.ok) {
|
||||
await interaction.reply({ content: `Added ${username} to Kick watch list.`, flags: 64 });
|
||||
// Refresh cached settings from backend so watcher sees new user immediately
|
||||
try {
|
||||
const settingsResp = await fetch(`${backendBase}/api/servers/${interaction.guildId}/settings`);
|
||||
if (settingsResp.ok) {
|
||||
const json = await settingsResp.json();
|
||||
const bot = require('..');
|
||||
if (bot && bot.setGuildSettings) bot.setGuildSettings(interaction.guildId, json);
|
||||
}
|
||||
} catch (_) {}
|
||||
} else {
|
||||
await interaction.reply({ content: 'Failed to add user via backend.', flags: 64 });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error adding kick user:', e);
|
||||
await interaction.reply({ content: 'Internal error adding kick user.', flags: 64 });
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
const { SlashCommandBuilder, PermissionsBitField } = require('discord.js');
|
||||
const fetch = require('node-fetch');
|
||||
const api = require('../api');
|
||||
|
||||
module.exports = {
|
||||
name: 'add-twitchuser',
|
||||
@@ -16,12 +16,15 @@ module.exports = {
|
||||
}
|
||||
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) {
|
||||
const success = await api.addTwitchUser(interaction.guildId, username);
|
||||
if (success) {
|
||||
await interaction.reply({ content: `Added ${username} to watch list.`, flags: 64 });
|
||||
// Refresh cached settings from backend so watcher sees new user immediately
|
||||
try {
|
||||
const settings = await api.getServerSettings(interaction.guildId);
|
||||
const bot = require('..');
|
||||
if (bot && bot.setGuildSettings) bot.setGuildSettings(interaction.guildId, settings);
|
||||
} catch (_) {}
|
||||
} else {
|
||||
await interaction.reply({ content: 'Failed to add user via backend.', flags: 64 });
|
||||
}
|
||||
|
||||
172
discord-bot/commands/ban.js
Normal file
172
discord-bot/commands/ban.js
Normal file
@@ -0,0 +1,172 @@
|
||||
const { SlashCommandBuilder, PermissionsBitField } = require('discord.js');
|
||||
|
||||
// Helper function to parse user from mention or ID
|
||||
function parseUser(input, guild) {
|
||||
// Check if it's a mention <@123456> or <@!123456>
|
||||
const mentionMatch = input.match(/^<@!?(\d+)>$/);
|
||||
if (mentionMatch) {
|
||||
return guild.members.cache.get(mentionMatch[1])?.user;
|
||||
}
|
||||
|
||||
// Check if it's a user ID
|
||||
if (/^\d{15,20}$/.test(input)) {
|
||||
return guild.members.cache.get(input)?.user;
|
||||
}
|
||||
|
||||
// Try to find by username or global name
|
||||
const member = guild.members.cache.find(m =>
|
||||
(m.user.global_name && m.user.global_name.toLowerCase().includes(input.toLowerCase())) ||
|
||||
m.user.username.toLowerCase().includes(input.toLowerCase()) ||
|
||||
(m.user.global_name && m.user.global_name.toLowerCase() === input.toLowerCase()) ||
|
||||
m.user.username.toLowerCase() === input.toLowerCase()
|
||||
);
|
||||
return member?.user;
|
||||
}
|
||||
|
||||
// Helper function to log moderation actions
|
||||
async function logModerationAction(guildId, action, targetUserId, targetUsername, moderatorUserId, moderatorUsername, reason, duration = null, endDate = null) {
|
||||
try {
|
||||
const logData = {
|
||||
guildId,
|
||||
action,
|
||||
targetUserId,
|
||||
targetUsername,
|
||||
moderatorUserId,
|
||||
moderatorUsername,
|
||||
reason,
|
||||
duration,
|
||||
endDate
|
||||
};
|
||||
|
||||
const response = await fetch(`${process.env.BACKEND_BASE || 'http://localhost:3001'}/internal/log-moderation`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(logData)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Failed to log moderation action:', response.statusText);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error logging moderation action:', error);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
name: 'ban',
|
||||
description: 'Ban a user from the server',
|
||||
enabled: true,
|
||||
builder: new SlashCommandBuilder()
|
||||
.setName('ban')
|
||||
.setDescription('Ban a user from the server')
|
||||
.addStringOption(option =>
|
||||
option.setName('user')
|
||||
.setDescription('The user to ban (mention or user ID)')
|
||||
.setRequired(true))
|
||||
.addStringOption(option =>
|
||||
option.setName('reason')
|
||||
.setDescription('Reason for the ban (minimum 3 words)')
|
||||
.setRequired(true))
|
||||
.addIntegerOption(option =>
|
||||
option.setName('days')
|
||||
.setDescription('Number of days of messages to delete (0-7)')
|
||||
.setRequired(false)
|
||||
.setMinValue(0)
|
||||
.setMaxValue(7)),
|
||||
async execute(interaction) {
|
||||
// Check if user has ban permissions
|
||||
if (!interaction.member.permissions.has(PermissionsBitField.Flags.BanMembers)) {
|
||||
return await interaction.reply({
|
||||
content: 'You do not have permission to ban members.',
|
||||
flags: 64
|
||||
});
|
||||
}
|
||||
|
||||
// Check if bot has ban permissions
|
||||
if (!interaction.guild.members.me.permissions.has(PermissionsBitField.Flags.BanMembers)) {
|
||||
return await interaction.reply({
|
||||
content: 'I do not have permission to ban members.',
|
||||
flags: 64
|
||||
});
|
||||
}
|
||||
|
||||
const userInput = interaction.options.getString('user');
|
||||
const reason = interaction.options.getString('reason');
|
||||
const days = interaction.options.getInteger('days') || 0;
|
||||
|
||||
// Parse the user from the input
|
||||
const user = parseUser(userInput, interaction.guild);
|
||||
if (!user) {
|
||||
return await interaction.reply({
|
||||
content: 'Could not find that user. Please provide a valid user mention or user ID.',
|
||||
flags: 64
|
||||
});
|
||||
}
|
||||
|
||||
// Validate reason has at least 3 words
|
||||
const reasonWords = reason.trim().split(/\s+/);
|
||||
if (reasonWords.length < 3) {
|
||||
return await interaction.reply({
|
||||
content: 'Reason must be at least 3 words long.',
|
||||
flags: 64
|
||||
});
|
||||
}
|
||||
|
||||
// Cannot ban yourself
|
||||
if (user.id === interaction.user.id) {
|
||||
return await interaction.reply({
|
||||
content: 'You cannot ban yourself.',
|
||||
flags: 64
|
||||
});
|
||||
}
|
||||
|
||||
// Cannot ban the bot
|
||||
if (user.id === interaction.guild.members.me.id) {
|
||||
return await interaction.reply({
|
||||
content: 'I cannot ban myself.',
|
||||
flags: 64
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user is in the server
|
||||
const member = interaction.guild.members.cache.get(user.id);
|
||||
if (member) {
|
||||
// Check role hierarchy
|
||||
if (member.roles.highest.position >= interaction.member.roles.highest.position && interaction.user.id !== interaction.guild.ownerId) {
|
||||
return await interaction.reply({
|
||||
content: 'You cannot ban a member with a higher or equal role.',
|
||||
flags: 64
|
||||
});
|
||||
}
|
||||
|
||||
if (member.roles.highest.position >= interaction.guild.members.me.roles.highest.position) {
|
||||
return await interaction.reply({
|
||||
content: 'I cannot ban a member with a higher or equal role.',
|
||||
flags: 64
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await interaction.guild.members.ban(user, {
|
||||
reason: reason,
|
||||
deleteMessageDays: days
|
||||
});
|
||||
await interaction.reply({
|
||||
content: `Successfully banned ${user.global_name || user.username} for: ${reason}${days > 0 ? ` (deleted ${days} days of messages)` : ''}`,
|
||||
flags: 64
|
||||
});
|
||||
|
||||
// Log the action
|
||||
await logModerationAction(interaction.guildId, 'ban', user.id, user.global_name || user.username, interaction.user.id, interaction.user.global_name || interaction.user.username, reason, 'permanent');
|
||||
} catch (error) {
|
||||
console.error('Error banning user:', error);
|
||||
await interaction.reply({
|
||||
content: 'Failed to ban the user. Please try again.',
|
||||
flags: 64
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
167
discord-bot/commands/kick.js
Normal file
167
discord-bot/commands/kick.js
Normal file
@@ -0,0 +1,167 @@
|
||||
const { SlashCommandBuilder, PermissionsBitField } = require('discord.js');
|
||||
|
||||
// Helper function to parse user from mention or ID
|
||||
function parseUser(input, guild) {
|
||||
// Check if it's a mention <@123456> or <@!123456>
|
||||
const mentionMatch = input.match(/^<@!?(\d+)>$/);
|
||||
if (mentionMatch) {
|
||||
return guild.members.cache.get(mentionMatch[1])?.user;
|
||||
}
|
||||
|
||||
// Check if it's a user ID
|
||||
if (/^\d{15,20}$/.test(input)) {
|
||||
return guild.members.cache.get(input)?.user;
|
||||
}
|
||||
|
||||
// Try to find by username or global name
|
||||
const member = guild.members.cache.find(m =>
|
||||
(m.user.global_name && m.user.global_name.toLowerCase().includes(input.toLowerCase())) ||
|
||||
m.user.username.toLowerCase().includes(input.toLowerCase()) ||
|
||||
(m.user.global_name && m.user.global_name.toLowerCase() === input.toLowerCase()) ||
|
||||
m.user.username.toLowerCase() === input.toLowerCase()
|
||||
);
|
||||
return member?.user;
|
||||
}
|
||||
|
||||
// Helper function to log moderation actions
|
||||
async function logModerationAction(guildId, action, targetUserId, targetUsername, moderatorUserId, moderatorUsername, reason, duration = null, endDate = null) {
|
||||
try {
|
||||
const logData = {
|
||||
guildId,
|
||||
action,
|
||||
targetUserId,
|
||||
targetUsername,
|
||||
moderatorUserId,
|
||||
moderatorUsername,
|
||||
reason,
|
||||
duration,
|
||||
endDate
|
||||
};
|
||||
|
||||
const response = await fetch(`${process.env.BACKEND_BASE || 'http://localhost:3001'}/internal/log-moderation`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(logData)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Failed to log moderation action:', response.statusText);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error logging moderation action:', error);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
name: 'kick',
|
||||
description: 'Kick a user from the server',
|
||||
enabled: true,
|
||||
builder: new SlashCommandBuilder()
|
||||
.setName('kick')
|
||||
.setDescription('Kick a user from the server')
|
||||
.addStringOption(option =>
|
||||
option.setName('user')
|
||||
.setDescription('The user to kick (mention or user ID)')
|
||||
.setRequired(true))
|
||||
.addStringOption(option =>
|
||||
option.setName('reason')
|
||||
.setDescription('Reason for the kick (minimum 3 words)')
|
||||
.setRequired(true)),
|
||||
async execute(interaction) {
|
||||
// Check if user has kick permissions
|
||||
if (!interaction.member.permissions.has(PermissionsBitField.Flags.KickMembers)) {
|
||||
return await interaction.reply({
|
||||
content: 'You do not have permission to kick members.',
|
||||
flags: 64
|
||||
});
|
||||
}
|
||||
|
||||
// Check if bot has kick permissions
|
||||
if (!interaction.guild.members.me.permissions.has(PermissionsBitField.Flags.KickMembers)) {
|
||||
return await interaction.reply({
|
||||
content: 'I do not have permission to kick members.',
|
||||
flags: 64
|
||||
});
|
||||
}
|
||||
|
||||
const userInput = interaction.options.getString('user');
|
||||
const reason = interaction.options.getString('reason');
|
||||
|
||||
// Parse the user from the input
|
||||
const user = parseUser(userInput, interaction.guild);
|
||||
if (!user) {
|
||||
return await interaction.reply({
|
||||
content: 'Could not find that user. Please provide a valid user mention or user ID.',
|
||||
flags: 64
|
||||
});
|
||||
}
|
||||
|
||||
// Validate reason has at least 3 words
|
||||
const reasonWords = reason.trim().split(/\s+/);
|
||||
if (reasonWords.length < 3) {
|
||||
return await interaction.reply({
|
||||
content: 'Reason must be at least 3 words long.',
|
||||
flags: 64
|
||||
});
|
||||
}
|
||||
|
||||
// Cannot kick yourself
|
||||
if (user.id === interaction.user.id) {
|
||||
return await interaction.reply({
|
||||
content: 'You cannot kick yourself.',
|
||||
flags: 64
|
||||
});
|
||||
}
|
||||
|
||||
// Cannot kick the bot
|
||||
if (user.id === interaction.guild.members.me.id) {
|
||||
return await interaction.reply({
|
||||
content: 'I cannot kick myself.',
|
||||
flags: 64
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user is in the server
|
||||
const member = interaction.guild.members.cache.get(user.id);
|
||||
if (!member) {
|
||||
return await interaction.reply({
|
||||
content: 'That user is not in this server.',
|
||||
flags: 64
|
||||
});
|
||||
}
|
||||
|
||||
// Check role hierarchy
|
||||
if (member.roles.highest.position >= interaction.member.roles.highest.position && interaction.user.id !== interaction.guild.ownerId) {
|
||||
return await interaction.reply({
|
||||
content: 'You cannot kick a member with a higher or equal role.',
|
||||
flags: 64
|
||||
});
|
||||
}
|
||||
|
||||
if (member.roles.highest.position >= interaction.guild.members.me.roles.highest.position) {
|
||||
return await interaction.reply({
|
||||
content: 'I cannot kick a member with a higher or equal role.',
|
||||
flags: 64
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await member.kick(reason);
|
||||
await interaction.reply({
|
||||
content: `Successfully kicked ${user.global_name || user.username} for: ${reason}`,
|
||||
flags: 64
|
||||
});
|
||||
|
||||
// Log the action
|
||||
await logModerationAction(interaction.guildId, 'kick', user.id, user.global_name || user.username, interaction.user.id, interaction.user.global_name || interaction.user.username, reason);
|
||||
} catch (error) {
|
||||
console.error('Error kicking user:', error);
|
||||
await interaction.reply({
|
||||
content: 'Failed to kick the user. Please try again.',
|
||||
flags: 64
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
24
discord-bot/commands/list-kickusers.js
Normal file
24
discord-bot/commands/list-kickusers.js
Normal file
@@ -0,0 +1,24 @@
|
||||
const { SlashCommandBuilder } = require('discord.js');
|
||||
const api = require('../api');
|
||||
|
||||
module.exports = {
|
||||
name: 'list-kickusers',
|
||||
description: 'List watched Kick usernames for this server (DISABLED).',
|
||||
enabled: false,
|
||||
dev: true,
|
||||
builder: new SlashCommandBuilder().setName('list-kickusers').setDescription('List watched Kick usernames for this server'),
|
||||
async execute(interaction) {
|
||||
try {
|
||||
const users = await api.getKickUsers(interaction.guildId) || [];
|
||||
if (!users || users.length === 0) {
|
||||
await interaction.reply({ content: 'No Kick users are being watched for this server.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
const list = users.map(u => `• ${u}`).join('\n');
|
||||
await interaction.reply({ content: `Watched Kick users:\n${list}`, ephemeral: true });
|
||||
} catch (e) {
|
||||
console.error('Error listing kick users:', e);
|
||||
await interaction.reply({ content: 'Failed to retrieve watched users.', ephemeral: true });
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -20,7 +20,7 @@ module.exports = {
|
||||
let toggles = existingSettings.commandToggles;
|
||||
// Include all loaded commands so simple command modules (no SlashCommandBuilder) like
|
||||
// `ping` are also listed. Filter for objects with a name for safety.
|
||||
const commands = Array.from(interaction.client.commands.values()).filter(cmd => cmd && cmd.name);
|
||||
const commands = Array.from(interaction.client.commands.values()).filter(cmd => cmd && cmd.name && !cmd.dev);
|
||||
|
||||
// Build button components (max 5 rows, 5 buttons per row)
|
||||
const actionRows = [];
|
||||
|
||||
21
discord-bot/commands/post-reaction-role.js
Normal file
21
discord-bot/commands/post-reaction-role.js
Normal file
@@ -0,0 +1,21 @@
|
||||
module.exports = {
|
||||
name: 'post-reaction-role',
|
||||
description: 'Post a reaction role message for the given reaction role ID',
|
||||
builder: (builder) => builder.setName('post-reaction-role').setDescription('Post a reaction role message').addIntegerOption(opt => opt.setName('id').setDescription('Reaction role ID').setRequired(true)),
|
||||
async execute(interaction) {
|
||||
const id = interaction.options.getInteger('id');
|
||||
try {
|
||||
const api = require('../api');
|
||||
const rrList = await api.listReactionRoles(interaction.guildId) || [];
|
||||
const rr = rrList.find(r => Number(r.id) === Number(id));
|
||||
if (!rr) return interaction.reply({ content: 'Reaction role not found', ephemeral: true });
|
||||
const bot = require('../index');
|
||||
const result = await bot.postReactionRoleMessage(interaction.guildId, rr);
|
||||
if (result && result.success) return interaction.reply({ content: 'Posted reaction role message', ephemeral: true });
|
||||
return interaction.reply({ content: 'Failed to post message', ephemeral: true });
|
||||
} catch (e) {
|
||||
console.error('post-reaction-role command error:', e);
|
||||
return interaction.reply({ content: 'Internal error', ephemeral: true });
|
||||
}
|
||||
}
|
||||
};
|
||||
41
discord-bot/commands/remove-kickuser.js
Normal file
41
discord-bot/commands/remove-kickuser.js
Normal file
@@ -0,0 +1,41 @@
|
||||
const { SlashCommandBuilder, PermissionsBitField } = require('discord.js');
|
||||
const fetch = require('node-fetch');
|
||||
|
||||
module.exports = {
|
||||
name: 'remove-kickuser',
|
||||
description: 'Admin: remove a Kick username from this server watch list',
|
||||
enabled: false,
|
||||
dev: true,
|
||||
builder: new SlashCommandBuilder()
|
||||
.setName('remove-kickuser')
|
||||
.setDescription('Remove a Kick username from the watch list')
|
||||
.addStringOption(opt => opt.setName('username').setDescription('Kick username to remove').setRequired(true)),
|
||||
async execute(interaction) {
|
||||
if (!interaction.member.permissions.has(PermissionsBitField.Flags.Administrator)) {
|
||||
await interaction.reply({ content: 'You must be an administrator to use this command.', flags: 64 });
|
||||
return;
|
||||
}
|
||||
const username = interaction.options.getString('username').toLowerCase().trim();
|
||||
try {
|
||||
const backendBase = process.env.BACKEND_BASE || `http://${process.env.HOST || '127.0.0.1'}:${process.env.PORT || 3002}`;
|
||||
const resp = await fetch(`${backendBase}/api/servers/${interaction.guildId}/kick-users/${encodeURIComponent(username)}`, { method: 'DELETE' });
|
||||
if (resp.ok) {
|
||||
await interaction.reply({ content: `Removed ${username} from Kick watch list.`, flags: 64 });
|
||||
// Refresh cached settings from backend
|
||||
try {
|
||||
const settingsResp = await fetch(`${backendBase}/api/servers/${interaction.guildId}/settings`);
|
||||
if (settingsResp.ok) {
|
||||
const json = await settingsResp.json();
|
||||
const bot = require('..');
|
||||
if (bot && bot.setGuildSettings) bot.setGuildSettings(interaction.guildId, json);
|
||||
}
|
||||
} catch (_) {}
|
||||
} else {
|
||||
await interaction.reply({ content: 'Failed to remove user via backend.', flags: 64 });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error removing kick user:', e);
|
||||
await interaction.reply({ content: 'Internal error removing kick user.', flags: 64 });
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
const { SlashCommandBuilder, PermissionsBitField } = require('discord.js');
|
||||
const fetch = require('node-fetch');
|
||||
const api = require('../api');
|
||||
|
||||
module.exports = {
|
||||
name: 'remove-twitchuser',
|
||||
@@ -16,10 +16,15 @@ module.exports = {
|
||||
}
|
||||
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) {
|
||||
const success = await api.deleteTwitchUser(interaction.guildId, username);
|
||||
if (success) {
|
||||
await interaction.reply({ content: `Removed ${username} from watch list.`, flags: 64 });
|
||||
// Refresh cached settings from backend
|
||||
try {
|
||||
const settings = await api.getServerSettings(interaction.guildId);
|
||||
const bot = require('..');
|
||||
if (bot && bot.setGuildSettings) bot.setGuildSettings(interaction.guildId, settings);
|
||||
} catch (_) {}
|
||||
} else {
|
||||
await interaction.reply({ content: 'Failed to remove user via backend.', flags: 64 });
|
||||
}
|
||||
|
||||
123
discord-bot/commands/setup-adminlogs.js
Normal file
123
discord-bot/commands/setup-adminlogs.js
Normal file
@@ -0,0 +1,123 @@
|
||||
const { SlashCommandBuilder, PermissionsBitField } = require('discord.js');
|
||||
|
||||
module.exports = {
|
||||
name: 'setup-adminlogs',
|
||||
description: 'Configure admin moderation logging settings',
|
||||
enabled: true,
|
||||
builder: new SlashCommandBuilder()
|
||||
.setName('setup-adminlogs')
|
||||
.setDescription('Configure admin moderation logging settings')
|
||||
.addChannelOption(option =>
|
||||
option.setName('channel')
|
||||
.setDescription('Channel to send admin logs to')
|
||||
.setRequired(false))
|
||||
.addBooleanOption(option =>
|
||||
option.setName('enabled')
|
||||
.setDescription('Enable or disable admin logging')
|
||||
.setRequired(false))
|
||||
.addBooleanOption(option =>
|
||||
option.setName('kick_logs')
|
||||
.setDescription('Log kick actions')
|
||||
.setRequired(false))
|
||||
.addBooleanOption(option =>
|
||||
option.setName('ban_logs')
|
||||
.setDescription('Log ban actions')
|
||||
.setRequired(false))
|
||||
.addBooleanOption(option =>
|
||||
option.setName('timeout_logs')
|
||||
.setDescription('Log timeout actions')
|
||||
.setRequired(false)),
|
||||
async execute(interaction) {
|
||||
// Check if user has administrator permissions
|
||||
if (!interaction.member.permissions.has(PermissionsBitField.Flags.Administrator)) {
|
||||
return interaction.reply({
|
||||
content: 'You need Administrator permissions to configure admin logs.',
|
||||
flags: 64
|
||||
});
|
||||
}
|
||||
|
||||
const channel = interaction.options.getChannel('channel');
|
||||
const enabled = interaction.options.getBoolean('enabled');
|
||||
const kickLogs = interaction.options.getBoolean('kick_logs');
|
||||
const banLogs = interaction.options.getBoolean('ban_logs');
|
||||
const timeoutLogs = interaction.options.getBoolean('timeout_logs');
|
||||
|
||||
try {
|
||||
// Get current settings
|
||||
const response = await fetch(`${process.env.BACKEND_BASE || 'http://localhost:3001'}/api/servers/${interaction.guildId}/admin-logs-settings`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch current settings');
|
||||
}
|
||||
const currentSettings = await response.json();
|
||||
|
||||
// Update settings
|
||||
const updatedSettings = { ...currentSettings };
|
||||
|
||||
if (enabled !== null) {
|
||||
updatedSettings.enabled = enabled;
|
||||
}
|
||||
|
||||
if (channel) {
|
||||
// Check if it's a text channel
|
||||
if (channel.type !== 0) { // 0 = GUILD_TEXT
|
||||
return interaction.reply({
|
||||
content: 'Please select a text channel for admin logs.',
|
||||
flags: 64
|
||||
});
|
||||
}
|
||||
updatedSettings.channelId = channel.id;
|
||||
}
|
||||
|
||||
// Update command-specific settings
|
||||
updatedSettings.commands = { ...updatedSettings.commands };
|
||||
if (kickLogs !== null) {
|
||||
updatedSettings.commands.kick = kickLogs;
|
||||
}
|
||||
if (banLogs !== null) {
|
||||
updatedSettings.commands.ban = banLogs;
|
||||
}
|
||||
if (timeoutLogs !== null) {
|
||||
updatedSettings.commands.timeout = timeoutLogs;
|
||||
}
|
||||
|
||||
// Save settings
|
||||
const saveResponse = await fetch(`${process.env.BACKEND_BASE || 'http://localhost:3001'}/api/servers/${interaction.guildId}/admin-logs-settings`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(updatedSettings)
|
||||
});
|
||||
|
||||
if (!saveResponse.ok) {
|
||||
throw new Error('Failed to save settings');
|
||||
}
|
||||
|
||||
const result = await saveResponse.json();
|
||||
|
||||
// Create response message
|
||||
let responseMessage = 'Admin logs settings updated!\n\n';
|
||||
responseMessage += `**Enabled:** ${result.settings.enabled ? 'Yes' : 'No'}\n`;
|
||||
if (result.settings.channelId) {
|
||||
responseMessage += `**Channel:** <#${result.settings.channelId}>\n`;
|
||||
} else {
|
||||
responseMessage += `**Channel:** Not set\n`;
|
||||
}
|
||||
responseMessage += `**Kick Logs:** ${result.settings.commands.kick ? 'Enabled' : 'Disabled'}\n`;
|
||||
responseMessage += `**Ban Logs:** ${result.settings.commands.ban ? 'Enabled' : 'Disabled'}\n`;
|
||||
responseMessage += `**Timeout Logs:** ${result.settings.commands.timeout ? 'Enabled' : 'Disabled'}`;
|
||||
|
||||
await interaction.reply({
|
||||
content: responseMessage,
|
||||
flags: 64
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error configuring admin logs:', error);
|
||||
await interaction.reply({
|
||||
content: 'An error occurred while configuring admin logs. Please try again later.',
|
||||
flags: 64
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,17 +1,13 @@
|
||||
const { SlashCommandBuilder, PermissionsBitField } = require('discord.js');
|
||||
const fetch = require('node-fetch');
|
||||
const api = require('../api');
|
||||
const { readDb, writeDb } = require('../../backend/db');
|
||||
|
||||
module.exports = {
|
||||
name: 'setup-live',
|
||||
description: 'Admin: configure Twitch live notifications for this server',
|
||||
description: 'Admin: enable or disable Twitch live notifications for this server',
|
||||
enabled: true,
|
||||
builder: new SlashCommandBuilder()
|
||||
.setName('setup-live')
|
||||
.setDescription('Configure Twitch live notifications for this server')
|
||||
.addStringOption(opt => opt.setName('twitch_user').setDescription('Twitch username to watch').setRequired(true))
|
||||
.addChannelOption(opt => opt.setName('channel').setDescription('Channel to send notifications').setRequired(true))
|
||||
.setDescription('Enable or disable Twitch live notifications for this server')
|
||||
.addBooleanOption(opt => opt.setName('enabled').setDescription('Enable/disable notifications').setRequired(true)),
|
||||
|
||||
async execute(interaction) {
|
||||
@@ -20,24 +16,18 @@ module.exports = {
|
||||
return;
|
||||
}
|
||||
|
||||
const twitchUser = interaction.options.getString('twitch_user');
|
||||
const channel = interaction.options.getChannel('channel');
|
||||
const enabled = interaction.options.getBoolean('enabled');
|
||||
|
||||
try {
|
||||
const api = require('../api');
|
||||
const existing = (await api.getServerSettings(interaction.guildId)) || {};
|
||||
existing.liveNotifications = { enabled: !!enabled, twitchUser, channelId: channel.id };
|
||||
const currentLn = existing.liveNotifications || {};
|
||||
existing.liveNotifications = { ...currentLn, enabled: !!enabled };
|
||||
await api.upsertServerSettings(interaction.guildId, existing);
|
||||
await interaction.reply({ content: `Live notifications ${enabled ? 'enabled' : 'disabled'} for ${twitchUser} -> ${channel.name}`, flags: 64 });
|
||||
await interaction.reply({ content: `Live notifications ${enabled ? 'enabled' : 'disabled'} for this server.`, flags: 64 });
|
||||
} catch (e) {
|
||||
console.error('Error saving live notifications to backend, falling back to local:', e);
|
||||
// fallback to local db
|
||||
const db = readDb();
|
||||
if (!db[interaction.guildId]) db[interaction.guildId] = {};
|
||||
db[interaction.guildId].liveNotifications = { enabled, twitchUser, channelId: channel.id };
|
||||
writeDb(db);
|
||||
await interaction.reply({ content: `Saved locally: Live notifications ${enabled ? 'enabled' : 'disabled'} for ${twitchUser} -> ${channel.name}`, flags: 64 });
|
||||
console.error('Error saving live notifications to backend:', e);
|
||||
await interaction.reply({ content: 'Failed to update live notifications.', flags: 64 });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
187
discord-bot/commands/timeout.js
Normal file
187
discord-bot/commands/timeout.js
Normal file
@@ -0,0 +1,187 @@
|
||||
const { SlashCommandBuilder, PermissionsBitField } = require('discord.js');
|
||||
|
||||
// Helper function to parse user from mention or ID
|
||||
function parseUser(input, guild) {
|
||||
// Check if it's a mention <@123456> or <@!123456>
|
||||
const mentionMatch = input.match(/^<@!?(\d+)>$/);
|
||||
if (mentionMatch) {
|
||||
return guild.members.cache.get(mentionMatch[1])?.user;
|
||||
}
|
||||
|
||||
// Check if it's a user ID
|
||||
if (/^\d{15,20}$/.test(input)) {
|
||||
return guild.members.cache.get(input)?.user;
|
||||
}
|
||||
|
||||
// Try to find by username or global name
|
||||
const member = guild.members.cache.find(m =>
|
||||
(m.user.global_name && m.user.global_name.toLowerCase().includes(input.toLowerCase())) ||
|
||||
m.user.username.toLowerCase().includes(input.toLowerCase()) ||
|
||||
(m.user.global_name && m.user.global_name.toLowerCase() === input.toLowerCase()) ||
|
||||
m.user.username.toLowerCase() === input.toLowerCase()
|
||||
);
|
||||
return member?.user;
|
||||
}
|
||||
|
||||
// Helper function to log moderation actions
|
||||
async function logModerationAction(guildId, action, targetUserId, targetUsername, moderatorUserId, moderatorUsername, reason, duration = null, endDate = null) {
|
||||
try {
|
||||
const logData = {
|
||||
guildId,
|
||||
action,
|
||||
targetUserId,
|
||||
targetUsername,
|
||||
moderatorUserId,
|
||||
moderatorUsername,
|
||||
reason,
|
||||
duration,
|
||||
endDate
|
||||
};
|
||||
|
||||
const response = await fetch(`${process.env.BACKEND_BASE || 'http://localhost:3001'}/internal/log-moderation`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(logData)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Failed to log moderation action:', response.statusText);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error logging moderation action:', error);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
name: 'timeout',
|
||||
description: 'Timeout a user in the server',
|
||||
enabled: true,
|
||||
builder: new SlashCommandBuilder()
|
||||
.setName('timeout')
|
||||
.setDescription('Timeout a user in the server')
|
||||
.addStringOption(option =>
|
||||
option.setName('user')
|
||||
.setDescription('The user to timeout (mention or user ID)')
|
||||
.setRequired(true))
|
||||
.addIntegerOption(option =>
|
||||
option.setName('duration')
|
||||
.setDescription('Duration in minutes (max 40320 minutes = 28 days)')
|
||||
.setRequired(true)
|
||||
.setMinValue(1)
|
||||
.setMaxValue(40320))
|
||||
.addStringOption(option =>
|
||||
option.setName('reason')
|
||||
.setDescription('Reason for the timeout (minimum 3 words)')
|
||||
.setRequired(true)),
|
||||
async execute(interaction) {
|
||||
// Check if user has moderate members permissions (required for timeout)
|
||||
if (!interaction.member.permissions.has(PermissionsBitField.Flags.ModerateMembers)) {
|
||||
return await interaction.reply({
|
||||
content: 'You do not have permission to timeout members.',
|
||||
flags: 64
|
||||
});
|
||||
}
|
||||
|
||||
// Check if bot has moderate members permissions
|
||||
if (!interaction.guild.members.me.permissions.has(PermissionsBitField.Flags.ModerateMembers)) {
|
||||
return await interaction.reply({
|
||||
content: 'I do not have permission to timeout members.',
|
||||
flags: 64
|
||||
});
|
||||
}
|
||||
|
||||
const userInput = interaction.options.getString('user');
|
||||
const duration = interaction.options.getInteger('duration');
|
||||
const reason = interaction.options.getString('reason');
|
||||
|
||||
// Parse the user from the input
|
||||
const user = parseUser(userInput, interaction.guild);
|
||||
if (!user) {
|
||||
return await interaction.reply({
|
||||
content: 'Could not find that user. Please provide a valid user mention or user ID.',
|
||||
flags: 64
|
||||
});
|
||||
}
|
||||
|
||||
// Validate reason has at least 3 words
|
||||
const reasonWords = reason.trim().split(/\s+/);
|
||||
if (reasonWords.length < 3) {
|
||||
return await interaction.reply({
|
||||
content: 'Reason must be at least 3 words long.',
|
||||
flags: 64
|
||||
});
|
||||
}
|
||||
|
||||
// Cannot timeout yourself
|
||||
if (user.id === interaction.user.id) {
|
||||
return await interaction.reply({
|
||||
content: 'You cannot timeout yourself.',
|
||||
flags: 64
|
||||
});
|
||||
}
|
||||
|
||||
// Cannot timeout the bot
|
||||
if (user.id === interaction.guild.members.me.id) {
|
||||
return await interaction.reply({
|
||||
content: 'I cannot timeout myself.',
|
||||
flags: 64
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user is in the server
|
||||
const member = interaction.guild.members.cache.get(user.id);
|
||||
if (!member) {
|
||||
return await interaction.reply({
|
||||
content: 'That user is not in this server.',
|
||||
flags: 64
|
||||
});
|
||||
}
|
||||
|
||||
// Check role hierarchy
|
||||
if (member.roles.highest.position >= interaction.member.roles.highest.position && interaction.user.id !== interaction.guild.ownerId) {
|
||||
return await interaction.reply({
|
||||
content: 'You cannot timeout a member with a higher or equal role.',
|
||||
flags: 64
|
||||
});
|
||||
}
|
||||
|
||||
if (member.roles.highest.position >= interaction.guild.members.me.roles.highest.position) {
|
||||
return await interaction.reply({
|
||||
content: 'I cannot timeout a member with a higher or equal role.',
|
||||
flags: 64
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user is already timed out
|
||||
if (member.communicationDisabledUntil) {
|
||||
return await interaction.reply({
|
||||
content: 'This user is already timed out.',
|
||||
flags: 64
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const timeoutDuration = duration * 60 * 1000; // Convert minutes to milliseconds
|
||||
const timeoutUntil = new Date(Date.now() + timeoutDuration);
|
||||
|
||||
await member.timeout(timeoutDuration, reason);
|
||||
await interaction.reply({
|
||||
content: `Successfully timed out ${user.global_name || user.username} for ${duration} minutes. Reason: ${reason}`,
|
||||
flags: 64
|
||||
});
|
||||
|
||||
// Log the action
|
||||
const durationString = duration >= 1440 ? `${Math.floor(duration / 1440)}d ${Math.floor((duration % 1440) / 60)}h ${duration % 60}m` :
|
||||
duration >= 60 ? `${Math.floor(duration / 60)}h ${duration % 60}m` : `${duration}m`;
|
||||
await logModerationAction(interaction.guildId, 'timeout', user.id, user.global_name || user.username, interaction.user.id, interaction.user.global_name || interaction.user.username, reason, durationString, timeoutUntil);
|
||||
} catch (error) {
|
||||
console.error('Error timing out user:', error);
|
||||
await interaction.reply({
|
||||
content: 'Failed to timeout the user. Please try again.',
|
||||
flags: 64
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -10,13 +10,29 @@ const commandFiles = fs.readdirSync(commandsPath).filter(file => file.endsWith('
|
||||
for (const file of commandFiles) {
|
||||
const filePath = path.join(commandsPath, file);
|
||||
const command = require(filePath);
|
||||
if (command.enabled === false) continue;
|
||||
if (command.enabled === false || command.dev === true) continue;
|
||||
|
||||
if (command.builder) {
|
||||
if (command.builder) {
|
||||
try {
|
||||
// Some command modules export builder as a function (builder => builder...) or as an instance
|
||||
if (typeof command.builder === 'function') {
|
||||
// create a temporary SlashCommandBuilder by requiring it from discord.js
|
||||
const { SlashCommandBuilder } = require('discord.js');
|
||||
const built = command.builder(new SlashCommandBuilder());
|
||||
if (built && typeof built.toJSON === 'function') commands.push(built.toJSON());
|
||||
else commands.push({ name: command.name, description: command.description });
|
||||
} else if (command.builder && typeof command.builder.toJSON === 'function') {
|
||||
commands.push(command.builder.toJSON());
|
||||
} else {
|
||||
} else {
|
||||
commands.push({ name: command.name, description: command.description });
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`Failed to build command ${command.name}:`, e && e.message ? e.message : e);
|
||||
commands.push({ name: command.name, description: command.description });
|
||||
}
|
||||
} else {
|
||||
commands.push({ name: command.name, description: command.description });
|
||||
}
|
||||
}
|
||||
|
||||
const rest = new REST({ version: '10' }).setToken(process.env.DISCORD_BOT_TOKEN);
|
||||
@@ -37,4 +53,24 @@ const deployCommands = async (guildId) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Standalone execution
|
||||
if (require.main === module) {
|
||||
const { Client, GatewayIntentBits } = require('discord.js');
|
||||
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
|
||||
|
||||
client.once('ready', async () => {
|
||||
console.log(`Logged in as ${client.user.tag}`);
|
||||
console.log(`Deploying commands to ${client.guilds.cache.size} guilds...`);
|
||||
|
||||
for (const [guildId, guild] of client.guilds.cache) {
|
||||
await deployCommands(guildId);
|
||||
}
|
||||
|
||||
console.log('All commands deployed!');
|
||||
client.destroy();
|
||||
});
|
||||
|
||||
client.login(process.env.DISCORD_BOT_TOKEN);
|
||||
}
|
||||
|
||||
module.exports = deployCommands;
|
||||
|
||||
15
discord-bot/events/guildCreate.js
Normal file
15
discord-bot/events/guildCreate.js
Normal file
@@ -0,0 +1,15 @@
|
||||
const api = require('../api');
|
||||
|
||||
module.exports = {
|
||||
name: 'guildCreate',
|
||||
execute: async (guild, client) => {
|
||||
console.log(`Bot joined guild: ${guild.name} (${guild.id})`);
|
||||
|
||||
try {
|
||||
// Publish SSE event for bot status change
|
||||
await api.publishEvent('*', 'botStatusUpdate', { guildId: guild.id, isBotInServer: true });
|
||||
} catch (error) {
|
||||
console.error('Error publishing bot join event:', error);
|
||||
}
|
||||
},
|
||||
};
|
||||
15
discord-bot/events/guildDelete.js
Normal file
15
discord-bot/events/guildDelete.js
Normal file
@@ -0,0 +1,15 @@
|
||||
const api = require('../api');
|
||||
|
||||
module.exports = {
|
||||
name: 'guildDelete',
|
||||
execute: async (guild, client) => {
|
||||
console.log(`Bot left guild: ${guild.name} (${guild.id})`);
|
||||
|
||||
try {
|
||||
// Publish SSE event for bot status change
|
||||
await api.publishEvent('*', 'botStatusUpdate', { guildId: guild.id, isBotInServer: false });
|
||||
} catch (error) {
|
||||
console.error('Error publishing bot leave event:', error);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -1,72 +1,46 @@
|
||||
const { Events } = require('discord.js');
|
||||
const { readDb } = require('../../backend/db.js');
|
||||
|
||||
module.exports = {
|
||||
name: Events.GuildMemberAdd,
|
||||
async execute(member) {
|
||||
try {
|
||||
const api = require('../api');
|
||||
const settings = (await api.getServerSettings(member.guild.id)) || {};
|
||||
try {
|
||||
const api = require('../api');
|
||||
// Get the welcome/leave settings from the API
|
||||
const welcomeLeaveSettings = await api.getWelcomeLeaveSettings(member.guild.id) || { welcome: { enabled: false } };
|
||||
const welcome = welcomeLeaveSettings.welcome;
|
||||
|
||||
const welcome = {
|
||||
enabled: settings.welcomeEnabled || false,
|
||||
channel: settings.welcomeChannel || '',
|
||||
message: settings.welcomeMessage || 'Welcome {user} to {server}!'
|
||||
};
|
||||
|
||||
if (welcome && welcome.enabled && welcome.channel) {
|
||||
const channel = member.guild.channels.cache.get(welcome.channel);
|
||||
if (channel) {
|
||||
try {
|
||||
const message = (welcome.message).replace('{user}', member.user.toString()).replace('{server}', member.guild.name);
|
||||
await channel.send(message);
|
||||
} catch (error) {
|
||||
console.error(`Could not send welcome message to channel ${welcome.channel} in guild ${member.guild.id}:`, error);
|
||||
}
|
||||
if (welcome && welcome.enabled && welcome.channel) {
|
||||
const channel = member.guild.channels.cache.get(welcome.channel);
|
||||
if (channel) {
|
||||
try {
|
||||
const message = (welcome.message || 'Welcome {user} to {server}!').replace('{user}', member.user.toString()).replace('{server}', member.guild.name);
|
||||
await channel.send(message);
|
||||
} catch (error) {
|
||||
console.error(`Could not send welcome message to channel ${welcome.channel} in guild ${member.guild.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
const autorole = settings.autorole || {};
|
||||
if (autorole && autorole.enabled && autorole.roleId) {
|
||||
const role = member.guild.roles.cache.get(autorole.roleId);
|
||||
if (role) {
|
||||
try {
|
||||
// Re-check that role is assignable
|
||||
const botHighest = member.guild.members.me.roles.highest.position;
|
||||
if (role.id === member.guild.id || role.managed || role.position >= botHighest) {
|
||||
console.warn(`Autorole ${role.id} in guild ${member.guild.id} is not assignable (everyone/managed/too high). Skipping.`);
|
||||
return;
|
||||
}
|
||||
await member.roles.add(role);
|
||||
} catch (error) {
|
||||
console.error(`Could not assign autorole in guild ${member.guild.id}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error in guildMemberAdd event for guild ${member.guild.id}:`, error);
|
||||
// fallback to local db
|
||||
try {
|
||||
const db = readDb();
|
||||
const settings = db[member.guild.id];
|
||||
if (settings && settings.welcomeEnabled && settings.welcomeChannel) {
|
||||
const channel = member.guild.channels.cache.get(settings.welcomeChannel);
|
||||
if (channel) {
|
||||
try {
|
||||
const message = (settings.welcomeMessage || 'Welcome {user} to {server}!').replace('{user}', member.user.toString()).replace('{server}', member.guild.name);
|
||||
await channel.send(message);
|
||||
} catch (innerErr) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
if (settings && settings.autorole && settings.autorole.enabled && settings.autorole.roleId) {
|
||||
const role = member.guild.roles.cache.get(settings.autorole.roleId);
|
||||
if (role) {
|
||||
try { await member.roles.add(role); } catch (innerErr) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
} catch (inner) {
|
||||
// ignore fallback errors
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Handle autorole
|
||||
const autoroleSettings = await api.getAutoroleSettings(member.guild.id) || { enabled: false };
|
||||
if (autoroleSettings && autoroleSettings.enabled && autoroleSettings.roleId) {
|
||||
const role = member.guild.roles.cache.get(autoroleSettings.roleId);
|
||||
if (role) {
|
||||
try {
|
||||
// Re-check that role is assignable
|
||||
const botHighest = member.guild.members.me.roles.highest.position;
|
||||
if (role.id === member.guild.id || role.managed || role.position >= botHighest) {
|
||||
console.warn(`Autorole ${role.id} in guild ${member.guild.id} is not assignable (everyone/managed/too high). Skipping.`);
|
||||
return;
|
||||
}
|
||||
await member.roles.add(role);
|
||||
} catch (error) {
|
||||
console.error(`Could not assign autorole in guild ${member.guild.id}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error in guildMemberAdd event for guild ${member.guild.id}:`, error);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,19 +1,19 @@
|
||||
const { Events } = require('discord.js');
|
||||
const { readDb } = require('../../backend/db.js');
|
||||
|
||||
module.exports = {
|
||||
name: Events.GuildMemberRemove,
|
||||
async execute(member) {
|
||||
try {
|
||||
const api = require('../api');
|
||||
const settings = (await api.getServerSettings(member.guild.id)) || {};
|
||||
const leave = { enabled: settings.leaveEnabled || false, channel: settings.leaveChannel || '', message: settings.leaveMessage || '{user} has left the server.' };
|
||||
// Get the welcome/leave settings from the API
|
||||
const welcomeLeaveSettings = await api.getWelcomeLeaveSettings(member.guild.id) || { leave: { enabled: false } };
|
||||
const leave = welcomeLeaveSettings.leave;
|
||||
|
||||
if (leave && leave.enabled && leave.channel) {
|
||||
const channel = member.guild.channels.cache.get(leave.channel);
|
||||
if (channel) {
|
||||
try {
|
||||
const message = (leave.message).replace('{user}', member.user.toString());
|
||||
const message = (leave.message || '{user} has left the server.').replace('{user}', member.user.toString());
|
||||
await channel.send(message);
|
||||
} catch (error) {
|
||||
console.error(`Could not send leave message to channel ${leave.channel} in guild ${member.guild.id}:`, error);
|
||||
@@ -22,20 +22,6 @@ module.exports = {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error in guildMemberRemove event for guild ${member.guild.id}:`, error);
|
||||
// fallback to local db
|
||||
try {
|
||||
const db = readDb();
|
||||
const settings = db[member.guild.id];
|
||||
if (settings && settings.leaveEnabled && settings.leaveChannel) {
|
||||
const channel = member.guild.channels.cache.get(settings.leaveChannel);
|
||||
if (channel) {
|
||||
try {
|
||||
const message = (settings.leaveMessage || '{user} has left the server.').replace('{user}', member.user.toString());
|
||||
await channel.send(message);
|
||||
} catch (innerErr) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
} catch (inner) { /* ignore */ }
|
||||
}
|
||||
},
|
||||
}
|
||||
};
|
||||
49
discord-bot/events/inviteCreate.js
Normal file
49
discord-bot/events/inviteCreate.js
Normal file
@@ -0,0 +1,49 @@
|
||||
const api = require('../api');
|
||||
|
||||
module.exports = {
|
||||
name: 'inviteCreate',
|
||||
async execute(invite) {
|
||||
try {
|
||||
// Only track invites created by the bot or in channels the bot can access
|
||||
const guildId = invite.guild.id;
|
||||
|
||||
// Check if this invite was created by our bot
|
||||
const isBotCreated = invite.inviter && invite.inviter.id === invite.client.user.id;
|
||||
|
||||
if (isBotCreated) {
|
||||
// Add to database if created by bot
|
||||
const inviteData = {
|
||||
code: invite.code,
|
||||
guildId: guildId,
|
||||
url: invite.url,
|
||||
channelId: invite.channel.id,
|
||||
createdAt: invite.createdAt ? invite.createdAt.toISOString() : new Date().toISOString(),
|
||||
maxUses: invite.maxUses || 0,
|
||||
maxAge: invite.maxAge || 0,
|
||||
temporary: invite.temporary || false
|
||||
};
|
||||
|
||||
// Use the API to add the invite to database
|
||||
await api.addInvite(inviteData);
|
||||
|
||||
// Publish SSE event for real-time frontend updates
|
||||
const bot = require('..');
|
||||
if (bot && bot.publishEvent) {
|
||||
bot.publishEvent(guildId, 'inviteCreated', {
|
||||
code: invite.code,
|
||||
url: invite.url,
|
||||
channelId: invite.channel.id,
|
||||
maxUses: invite.maxUses || 0,
|
||||
maxAge: invite.maxAge || 0,
|
||||
temporary: invite.temporary || false,
|
||||
createdAt: invite.createdAt ? invite.createdAt.toISOString() : new Date().toISOString()
|
||||
});
|
||||
}
|
||||
}
|
||||
// Note: We don't automatically add invites created by other users to avoid spam
|
||||
// Only bot-created invites are tracked for the web interface
|
||||
} catch (error) {
|
||||
console.error('Error handling inviteCreate:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
24
discord-bot/events/inviteDelete.js
Normal file
24
discord-bot/events/inviteDelete.js
Normal file
@@ -0,0 +1,24 @@
|
||||
const api = require('../api');
|
||||
|
||||
module.exports = {
|
||||
name: 'inviteDelete',
|
||||
async execute(invite) {
|
||||
try {
|
||||
const guildId = invite.guild.id;
|
||||
const code = invite.code;
|
||||
|
||||
// Remove from database
|
||||
await api.deleteInvite(guildId, code);
|
||||
|
||||
// Publish SSE event for real-time frontend updates
|
||||
const bot = require('..');
|
||||
if (bot && bot.publishEvent) {
|
||||
bot.publishEvent(guildId, 'inviteDeleted', {
|
||||
code: code
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error handling inviteDelete:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
const { ActivityType } = require('discord.js');
|
||||
const deployCommands = require('../deploy-commands');
|
||||
const api = require('../api');
|
||||
|
||||
module.exports = {
|
||||
name: 'clientReady',
|
||||
@@ -16,6 +17,97 @@ module.exports = {
|
||||
}
|
||||
}
|
||||
|
||||
// Reconcile invites for all guilds to detect invites deleted while bot was offline
|
||||
console.log('🔄 Reconciling invites for offline changes...');
|
||||
let totalReconciled = 0;
|
||||
for (const guildId of guildIds) {
|
||||
try {
|
||||
const guild = client.guilds.cache.get(guildId);
|
||||
if (!guild) continue;
|
||||
|
||||
// Fetch current invites from Discord
|
||||
const discordInvites = await guild.invites.fetch();
|
||||
const currentInvites = Array.from(discordInvites.values());
|
||||
|
||||
// Reconcile with database
|
||||
const reconciled = await api.reconcileInvites(guildId, currentInvites);
|
||||
totalReconciled += reconciled;
|
||||
} catch (e) {
|
||||
console.error(`Failed to reconcile invites for guild ${guildId}:`, e && e.message ? e.message : e);
|
||||
}
|
||||
}
|
||||
if (totalReconciled > 0) {
|
||||
console.log(`✅ Invite reconciliation complete: removed ${totalReconciled} stale invites`);
|
||||
} else {
|
||||
console.log('✅ Invite reconciliation complete: no stale invites found');
|
||||
}
|
||||
|
||||
// Reconcile reaction roles: ensure stored message IDs still exist, remove stale configs
|
||||
console.log('🔄 Reconciling reaction roles (initial check)...');
|
||||
try {
|
||||
for (const guildId of guildIds) {
|
||||
try {
|
||||
const rrList = await api.listReactionRoles(guildId) || [];
|
||||
for (const rr of rrList) {
|
||||
if (!rr.message_id) continue; // not posted yet
|
||||
try {
|
||||
const guild = client.guilds.cache.get(guildId);
|
||||
if (!guild) continue;
|
||||
const channel = await guild.channels.fetch(rr.channel_id || rr.channelId).catch(() => null);
|
||||
if (!channel) {
|
||||
// channel missing -> delete RR
|
||||
await api.deleteReactionRole(guildId, rr.id);
|
||||
continue;
|
||||
}
|
||||
const msg = await channel.messages.fetch(rr.message_id).catch(() => null);
|
||||
if (!msg) {
|
||||
// message missing -> delete RR
|
||||
await api.deleteReactionRole(guildId, rr.id);
|
||||
continue;
|
||||
}
|
||||
} catch (inner) {
|
||||
// ignore per-item errors
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore guild-level errors
|
||||
}
|
||||
}
|
||||
console.log('✅ Reaction role initial reconciliation complete');
|
||||
} catch (e) {
|
||||
console.error('Failed reaction role reconciliation:', e && e.message ? e.message : e);
|
||||
}
|
||||
|
||||
// Periodic reconciliation every 10 minutes
|
||||
setInterval(async () => {
|
||||
try {
|
||||
for (const guildId of client.guilds.cache.map(g => g.id)) {
|
||||
const rrList = await api.listReactionRoles(guildId) || [];
|
||||
for (const rr of rrList) {
|
||||
if (!rr.message_id) continue;
|
||||
try {
|
||||
const guild = client.guilds.cache.get(guildId);
|
||||
if (!guild) continue;
|
||||
const channel = await guild.channels.fetch(rr.channel_id || rr.channelId).catch(() => null);
|
||||
if (!channel) {
|
||||
await api.deleteReactionRole(guildId, rr.id);
|
||||
continue;
|
||||
}
|
||||
const msg = await channel.messages.fetch(rr.message_id).catch(() => null);
|
||||
if (!msg) {
|
||||
await api.deleteReactionRole(guildId, rr.id);
|
||||
continue;
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}, 10 * 60 * 1000);
|
||||
|
||||
const activities = [
|
||||
{ name: 'Watch EhChad Live!', type: ActivityType.Streaming, url: 'https://twitch.tv/ehchad' },
|
||||
{ name: 'Follow EhChad!', type: ActivityType.Streaming, url: 'https://twitch.tv/ehchad' },
|
||||
|
||||
@@ -10,7 +10,7 @@ module.exports = (client) => {
|
||||
// Clear require cache to allow updates during development
|
||||
delete require.cache[require.resolve(filePath)];
|
||||
const command = require(filePath);
|
||||
if (command.name) {
|
||||
if (command.name && !command.dev) {
|
||||
client.commands.set(command.name, command);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,61 @@ client.on('interactionCreate', async interaction => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reaction role button handling
|
||||
if (interaction.isButton && interaction.customId && interaction.customId.startsWith('rr_')) {
|
||||
// customId format: rr_<reactionRoleId>_<roleId>
|
||||
const parts = interaction.customId.split('_');
|
||||
if (parts.length >= 3) {
|
||||
const rrId = parts[1];
|
||||
const roleId = parts[2];
|
||||
try {
|
||||
const rr = await api.safeFetchJsonPath(`/api/servers/${interaction.guildId}/reaction-roles`);
|
||||
// rr is array; find by id
|
||||
const found = (rr || []).find(r => String(r.id) === String(rrId));
|
||||
if (!found) {
|
||||
await interaction.reply({ content: 'Reaction role configuration not found.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
const button = (found.buttons || []).find(b => String(b.roleId) === String(roleId));
|
||||
if (!button) {
|
||||
await interaction.reply({ content: 'Button config not found.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
const roleId = button.roleId || button.role_id || button.role;
|
||||
const member = interaction.member;
|
||||
if (!member) return;
|
||||
// Validate role hierarchy: bot must be higher than role, and member must be lower than role
|
||||
const guild = interaction.guild;
|
||||
const role = guild.roles.cache.get(roleId) || null;
|
||||
if (!role) { await interaction.reply({ content: 'Configured role no longer exists.', ephemeral: true }); return; }
|
||||
const botMember = await guild.members.fetchMe();
|
||||
const botHighest = botMember.roles.highest;
|
||||
const targetPosition = role.position || 0;
|
||||
if (botHighest.position <= targetPosition) {
|
||||
await interaction.reply({ content: 'Cannot assign role: bot lacks sufficient role hierarchy (move bot role higher).', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
const memberHighest = member.roles.highest;
|
||||
if (memberHighest.position >= targetPosition) {
|
||||
await interaction.reply({ content: 'Cannot assign role: your highest role is higher or equal to the role to be assigned.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
const hasRole = member.roles.cache.has(roleId);
|
||||
if (hasRole) {
|
||||
await member.roles.remove(roleId, `Reaction role button toggled by user ${interaction.user.id}`);
|
||||
await interaction.reply({ content: `Removed role ${role.name}.`, ephemeral: true });
|
||||
} else {
|
||||
await member.roles.add(roleId, `Reaction role button toggled by user ${interaction.user.id}`);
|
||||
await interaction.reply({ content: `Assigned role ${role.name}.`, ephemeral: true });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error handling reaction role button:', e);
|
||||
try { await interaction.reply({ content: 'Failed to process reaction role.', ephemeral: true }); } catch(e){}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!interaction.isCommand()) return;
|
||||
|
||||
const command = client.commands.get(interaction.commandName);
|
||||
@@ -145,19 +200,28 @@ async function announceLive(guildId, stream) {
|
||||
const channel = await guild.channels.fetch(channelId).catch(() => null);
|
||||
if (!channel) return { success: false, message: 'Channel not found' };
|
||||
const { EmbedBuilder } = require('discord.js');
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(0x9146FF)
|
||||
.setTitle(stream.title || `${stream.user_name} is live`)
|
||||
.setURL(stream.url)
|
||||
.setAuthor({ name: stream.user_name, iconURL: stream.profile_image_url || undefined, url: stream.url })
|
||||
.setThumbnail(stream.thumbnail_url || stream.profile_image_url || undefined)
|
||||
.addFields(
|
||||
{ name: 'Category', value: stream.game_name || 'Unknown', inline: true },
|
||||
{ name: 'Viewers', value: String(stream.viewer_count || 0), inline: true }
|
||||
)
|
||||
.setDescription(stream.description || '')
|
||||
.setFooter({ text: `ehchadservices • Started: ${stream.started_at ? new Date(stream.started_at).toLocaleString() : 'unknown'}` });
|
||||
await channel.send({ embeds: [embed] });
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(0x9146FF)
|
||||
.setTitle(stream.title || `${stream.user_name} is live`)
|
||||
.setURL(stream.url)
|
||||
.setAuthor({ name: stream.user_name, iconURL: stream.profile_image_url || undefined, url: stream.url })
|
||||
.setThumbnail(stream.thumbnail_url || stream.profile_image_url || undefined)
|
||||
.addFields(
|
||||
{ name: 'Category', value: stream.game_name || 'Unknown', inline: true },
|
||||
{ name: 'Viewers', value: String(stream.viewer_count || 0), inline: true }
|
||||
)
|
||||
.setDescription((stream.description || '').slice(0, 200))
|
||||
.setFooter({ text: `ehchadservices • Started: ${stream.started_at ? new Date(stream.started_at).toLocaleString() : 'unknown'}` });
|
||||
let prefixMsg = '';
|
||||
if (liveSettings.customMessage) {
|
||||
prefixMsg = liveSettings.customMessage;
|
||||
} else if (liveSettings.message) {
|
||||
prefixMsg = liveSettings.message;
|
||||
} else {
|
||||
prefixMsg = `🔴 ${stream.user_name} is now live!`;
|
||||
}
|
||||
const payload = prefixMsg ? { content: prefixMsg, embeds: [embed] } : { embeds: [embed] };
|
||||
await channel.send(payload);
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
console.error('announceLive failed:', e && e.message ? e.message : e);
|
||||
@@ -167,11 +231,54 @@ async function announceLive(guildId, stream) {
|
||||
|
||||
module.exports = { login, client, setGuildSettings, getGuildSettingsFromCache, announceLive };
|
||||
|
||||
async function postReactionRoleMessage(guildId, reactionRole) {
|
||||
try {
|
||||
const guild = client.guilds.cache.get(guildId);
|
||||
if (!guild) return { success: false, message: 'Guild not found' };
|
||||
const channel = await guild.channels.fetch(reactionRole.channel_id || reactionRole.channelId).catch(() => null);
|
||||
if (!channel) return { success: false, message: 'Channel not found' };
|
||||
// Build buttons
|
||||
const { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } = require('discord.js');
|
||||
const row = new ActionRowBuilder();
|
||||
const buttons = reactionRole.buttons || [];
|
||||
for (let i = 0; i < buttons.length; i++) {
|
||||
const b = buttons[i];
|
||||
const customId = `rr_${reactionRole.id}_${b.roleId}`;
|
||||
const btn = new ButtonBuilder().setCustomId(customId).setLabel(b.label || b.name || `Button ${i+1}`).setStyle(ButtonStyle.Primary);
|
||||
row.addComponents(btn);
|
||||
}
|
||||
const embedData = reactionRole.embed || reactionRole.embed || {};
|
||||
const embed = new EmbedBuilder();
|
||||
if (embedData.title) embed.setTitle(embedData.title);
|
||||
if (embedData.description) embed.setDescription(embedData.description);
|
||||
if (embedData.color) embed.setColor(embedData.color);
|
||||
if (embedData.thumbnail) embed.setThumbnail(embedData.thumbnail);
|
||||
if (embedData.fields && Array.isArray(embedData.fields)) {
|
||||
for (const f of embedData.fields) {
|
||||
if (f.name && f.value) embed.addFields({ name: f.name, value: f.value, inline: false });
|
||||
}
|
||||
}
|
||||
const sent = await channel.send({ embeds: [embed], components: [row] });
|
||||
// update backend with message id
|
||||
try {
|
||||
const api = require('./api');
|
||||
await api.updateReactionRole(guildId, reactionRole.id, { messageId: sent.id });
|
||||
} catch (e) {
|
||||
console.error('Failed to update reaction role message id in backend:', e);
|
||||
}
|
||||
return { success: true, messageId: sent.id };
|
||||
} catch (e) {
|
||||
console.error('postReactionRoleMessage failed:', e && e.message ? e.message : e);
|
||||
return { success: false, message: e && e.message ? e.message : 'unknown error' };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports.postReactionRoleMessage = postReactionRoleMessage;
|
||||
|
||||
// Start twitch watcher when client is ready (use 'clientReady' as the event name)
|
||||
try {
|
||||
const watcher = require('./twitch-watcher');
|
||||
// discord.js renamed the ready event to clientReady; the event loader registers
|
||||
// handlers based on event.name so we listen for the same 'clientReady' here.
|
||||
// discord.js uses 'clientReady' event
|
||||
client.once('clientReady', () => {
|
||||
// start polling in background
|
||||
watcher.poll(client).catch(err => console.error('Twitch watcher failed to start:', err));
|
||||
@@ -182,6 +289,19 @@ try {
|
||||
// ignore if watcher not available
|
||||
}
|
||||
|
||||
try {
|
||||
const kickWatcher = require('./kick-watcher');
|
||||
client.once('clientReady', () => {
|
||||
// TEMPORARILY DISABLED: Kick watcher removed for now
|
||||
// kickWatcher.poll(client).catch(err => console.error('Kick watcher failed to start:', err));
|
||||
console.log('Kick watcher: temporarily disabled');
|
||||
});
|
||||
// process.on('exit', () => { kickWatcher.stop(); });
|
||||
// process.on('SIGINT', () => { kickWatcher.stop(); process.exit(); });
|
||||
} catch (e) {
|
||||
// ignore if kick watcher not available
|
||||
}
|
||||
|
||||
// --- Optional push receiver (so backend can notify a remote bot process) ---
|
||||
try {
|
||||
const express = require('express');
|
||||
|
||||
294
discord-bot/kick-watcher.js
Normal file
294
discord-bot/kick-watcher.js
Normal file
@@ -0,0 +1,294 @@
|
||||
const api = require('./api');
|
||||
const fetch = require('node-fetch');
|
||||
|
||||
// Kick API helpers (web scraping since no public API)
|
||||
let polling = false;
|
||||
const pollIntervalMs = Number(process.env.KICK_POLL_INTERVAL_MS || 15000); // 15s default (slower than Twitch)
|
||||
|
||||
// Keep track of which streams we've already announced per guild:user -> { started_at }
|
||||
const announced = new Map(); // key: `${guildId}:${user}` -> { started_at }
|
||||
|
||||
// Simple web scraping to check if a Kick user is live
|
||||
async function checkKickUserLive(username) {
|
||||
try {
|
||||
// First try the API endpoint
|
||||
const apiUrl = `https://kick.com/api/v1/channels/${encodeURIComponent(username)}`;
|
||||
const apiController = new AbortController();
|
||||
const apiTimeoutId = setTimeout(() => apiController.abort(), 5000);
|
||||
|
||||
const apiResponse = await fetch(apiUrl, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
||||
'Accept': 'application/json',
|
||||
'Referer': 'https://kick.com/'
|
||||
},
|
||||
signal: apiController.signal
|
||||
});
|
||||
|
||||
clearTimeout(apiTimeoutId);
|
||||
|
||||
if (apiResponse.ok) {
|
||||
const data = await apiResponse.json();
|
||||
|
||||
if (data && data.livestream && data.livestream.is_live) {
|
||||
return {
|
||||
is_live: true,
|
||||
user_login: username,
|
||||
user_name: data.user?.username || username,
|
||||
title: data.livestream.session_title || `${username} is live`,
|
||||
viewer_count: data.livestream.viewer_count || 0,
|
||||
started_at: data.livestream.start_time,
|
||||
url: `https://kick.com/${username}`,
|
||||
thumbnail_url: data.livestream.thumbnail?.url || null,
|
||||
category: data.category?.name || 'Unknown',
|
||||
description: data.user?.bio || ''
|
||||
};
|
||||
}
|
||||
|
||||
return { is_live: false, user_login: username };
|
||||
}
|
||||
|
||||
// If API fails with 403, try web scraping as fallback
|
||||
if (apiResponse.status === 403) {
|
||||
console.log(`API blocked for ${username}, trying web scraping fallback...`);
|
||||
|
||||
const pageUrl = `https://kick.com/${encodeURIComponent(username)}`;
|
||||
const pageController = new AbortController();
|
||||
const pageTimeoutId = setTimeout(() => pageController.abort(), 5000);
|
||||
|
||||
const pageResponse = await fetch(pageUrl, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.5',
|
||||
'Accept-Encoding': 'gzip, deflate, br',
|
||||
'DNT': '1',
|
||||
'Connection': 'keep-alive',
|
||||
'Upgrade-Insecure-Requests': '1',
|
||||
'Sec-Fetch-Dest': 'document',
|
||||
'Sec-Fetch-Mode': 'navigate',
|
||||
'Sec-Fetch-Site': 'none',
|
||||
'Cache-Control': 'max-age=0'
|
||||
},
|
||||
signal: pageController.signal
|
||||
});
|
||||
|
||||
clearTimeout(pageTimeoutId);
|
||||
|
||||
if (pageResponse.ok) {
|
||||
const html = await pageResponse.text();
|
||||
|
||||
// Check for live stream indicators in the HTML
|
||||
const isLive = html.includes('"is_live":true') || html.includes('"is_live": true') ||
|
||||
html.includes('data-is-live="true"') || html.includes('isLive:true');
|
||||
|
||||
if (isLive) {
|
||||
// Try to extract stream info from HTML
|
||||
let title = `${username} is live`;
|
||||
let viewerCount = 0;
|
||||
let category = 'Unknown';
|
||||
|
||||
// Extract title
|
||||
const titleMatch = html.match(/"session_title"\s*:\s*"([^"]+)"/) || html.match(/"title"\s*:\s*"([^"]+)"/);
|
||||
if (titleMatch) {
|
||||
title = titleMatch[1].replace(/\\"/g, '"');
|
||||
}
|
||||
|
||||
// Extract viewer count
|
||||
const viewerMatch = html.match(/"viewer_count"\s*:\s*(\d+)/);
|
||||
if (viewerMatch) {
|
||||
viewerCount = parseInt(viewerMatch[1]);
|
||||
}
|
||||
|
||||
// Extract category
|
||||
const categoryMatch = html.match(/"category"\s*:\s*{\s*"name"\s*:\s*"([^"]+)"/);
|
||||
if (categoryMatch) {
|
||||
category = categoryMatch[1];
|
||||
}
|
||||
|
||||
return {
|
||||
is_live: true,
|
||||
user_login: username,
|
||||
user_name: username,
|
||||
title: title,
|
||||
viewer_count: viewerCount,
|
||||
started_at: new Date().toISOString(),
|
||||
url: `https://kick.com/${username}`,
|
||||
thumbnail_url: null,
|
||||
category: category,
|
||||
description: ''
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { is_live: false, user_login: username };
|
||||
} catch (e) {
|
||||
if (e.name === 'AbortError') {
|
||||
console.error(`Timeout checking Kick user ${username}`);
|
||||
} else {
|
||||
console.error(`Failed to check Kick user ${username}:`, e && e.message ? e.message : e);
|
||||
}
|
||||
return { is_live: false, user_login: username };
|
||||
}
|
||||
}
|
||||
|
||||
// Check all Kick users for a guild
|
||||
async function checkKickStreamsForGuild(guildId, usernames) {
|
||||
const results = [];
|
||||
for (const username of usernames) {
|
||||
try {
|
||||
const stream = await checkKickUserLive(username);
|
||||
if (stream.is_live) {
|
||||
results.push(stream);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Error checking Kick user ${username}:`, e && e.message ? e.message : e);
|
||||
}
|
||||
// Small delay between requests to be respectful
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async function checkGuild(client, guild) {
|
||||
try {
|
||||
// Get settings for this guild
|
||||
const settings = await api.getServerSettings(guild.id) || {};
|
||||
const liveSettings = settings.liveNotifications || {};
|
||||
if (!liveSettings.enabled) return;
|
||||
|
||||
const channelId = liveSettings.channelId;
|
||||
const users = (liveSettings.kickUsers || []).map(u => u.toLowerCase()).filter(Boolean);
|
||||
if (!channelId || users.length === 0) return;
|
||||
|
||||
// Check which users are live
|
||||
const live = await checkKickStreamsForGuild(guild.id, users);
|
||||
if (!live || live.length === 0) {
|
||||
// No live streams: ensure any announced keys for these users are cleared so they can be re-announced later
|
||||
for (const u of users) {
|
||||
const key = `${guild.id}:${u}`;
|
||||
if (announced.has(key)) {
|
||||
announced.delete(key);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch channel using client to ensure we can reach it
|
||||
let channel = null;
|
||||
try {
|
||||
channel = await client.channels.fetch(channelId);
|
||||
if (channel.type !== 0) { // 0 is text channel
|
||||
console.error(`KickWatcher: channel ${channelId} is not a text channel (type: ${channel.type})`);
|
||||
channel = null;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`KickWatcher: failed to fetch channel ${channelId}:`, e && e.message ? e.message : e);
|
||||
channel = null;
|
||||
}
|
||||
if (!channel) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build a map of live logins for quick lookup
|
||||
const liveLogins = new Set(live.map(s => (s.user_login || '').toLowerCase()));
|
||||
|
||||
// Clear announced entries for users that are no longer live
|
||||
for (const u of users) {
|
||||
const key = `${guild.id}:${u}`;
|
||||
if (!liveLogins.has(u) && announced.has(key)) {
|
||||
announced.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Announce each live once per live session
|
||||
for (const s of live) {
|
||||
const login = (s.user_login || '').toLowerCase();
|
||||
const key = `${guild.id}:${login}`;
|
||||
if (announced.has(key)) continue; // already announced for this live session
|
||||
|
||||
// Mark announced for this session
|
||||
announced.set(key, { started_at: s.started_at || new Date().toISOString() });
|
||||
|
||||
// Build and send embed (similar to Twitch but with Kick branding)
|
||||
try {
|
||||
const { EmbedBuilder } = require('discord.js');
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(0x53FC18) // Kick green color
|
||||
.setTitle(s.title || `${s.user_name} is live`)
|
||||
.setURL(s.url)
|
||||
.setAuthor({ name: s.user_name, iconURL: s.thumbnail_url || undefined, url: s.url })
|
||||
.setThumbnail(s.thumbnail_url || undefined)
|
||||
.addFields(
|
||||
{ name: 'Category', value: s.category || 'Unknown', inline: true },
|
||||
{ name: 'Viewers', value: String(s.viewer_count || 0), inline: true }
|
||||
)
|
||||
.setDescription((s.description || '').slice(0, 200))
|
||||
.setFooter({ text: `ehchadservices • Started: ${s.started_at ? new Date(s.started_at).toLocaleString() : 'unknown'}` });
|
||||
|
||||
// Determine message text (custom overrides default)
|
||||
let prefixMsg = '';
|
||||
if (liveSettings.customMessage) {
|
||||
prefixMsg = liveSettings.customMessage;
|
||||
} else if (liveSettings.message) {
|
||||
prefixMsg = liveSettings.message;
|
||||
} else {
|
||||
prefixMsg = `🟢 ${s.user_name} is now live on Kick!`;
|
||||
}
|
||||
|
||||
const payload = prefixMsg ? { content: prefixMsg, embeds: [embed] } : { embeds: [embed] };
|
||||
await channel.send(payload);
|
||||
console.log(`🔔 Announced Kick live: ${login} - ${(s.title || '').slice(0, 80)}`);
|
||||
} catch (e) {
|
||||
console.error(`KickWatcher: failed to send announcement for ${login}:`, e && e.message ? e.message : e);
|
||||
// Fallback to simple message
|
||||
const msg = `🟢 ${s.user_name} is live on Kick: **${s.title}**\nWatch: ${s.url}`;
|
||||
try {
|
||||
await channel.send({ content: msg });
|
||||
console.log('KickWatcher: fallback message sent');
|
||||
} catch (err) {
|
||||
console.error('KickWatcher: fallback send failed:', err && err.message ? err.message : err);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error checking guild for Kick live streams:', e && e.message ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
async function poll(client) {
|
||||
if (polling) return;
|
||||
polling = true;
|
||||
console.log(`🔁 KickWatcher started, polling every ${Math.round(pollIntervalMs/1000)}s`);
|
||||
|
||||
// Initial check on restart: send messages for currently live users
|
||||
try {
|
||||
const guilds = Array.from(client.guilds.cache.values());
|
||||
for (const g of guilds) {
|
||||
await checkGuild(client, g).catch(err => {
|
||||
console.error('KickWatcher: initial checkGuild error', err && err.message ? err.message : err);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error during initial Kick check:', e && e.message ? e.message : e);
|
||||
}
|
||||
|
||||
while (polling) {
|
||||
try {
|
||||
const guilds = Array.from(client.guilds.cache.values());
|
||||
for (const g of guilds) {
|
||||
await checkGuild(client, g).catch(err => {
|
||||
console.error('KickWatcher: checkGuild error', err && err.message ? err.message : err);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error during Kick poll loop:', e && e.message ? e.message : e);
|
||||
}
|
||||
await new Promise(r => setTimeout(r, pollIntervalMs));
|
||||
}
|
||||
}
|
||||
|
||||
function stop() { polling = false; }
|
||||
|
||||
module.exports = { poll, stop };
|
||||
@@ -1,42 +1,145 @@
|
||||
const api = require('./api');
|
||||
const fetch = require('node-fetch');
|
||||
|
||||
// Twitch API credentials (optional). If provided, we'll enrich embeds with user bio.
|
||||
const twitchClientId = process.env.TWITCH_CLIENT_ID || null;
|
||||
const twitchClientSecret = process.env.TWITCH_CLIENT_SECRET || null;
|
||||
let twitchAppToken = null; // cached app access token
|
||||
let twitchTokenExpires = 0;
|
||||
|
||||
// Cache of user login -> { description, profile_image_url, fetchedAt }
|
||||
const userInfoCache = new Map();
|
||||
|
||||
async function getAppToken() {
|
||||
if (!twitchClientId || !twitchClientSecret) return null;
|
||||
const now = Date.now();
|
||||
if (twitchAppToken && now < twitchTokenExpires - 60000) { // refresh 1 min early
|
||||
return twitchAppToken;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`https://id.twitch.tv/oauth2/token?client_id=${twitchClientId}&client_secret=${twitchClientSecret}&grant_type=client_credentials`, { method: 'POST' });
|
||||
const json = await res.json();
|
||||
twitchAppToken = json.access_token;
|
||||
twitchTokenExpires = now + (json.expires_in * 1000);
|
||||
return twitchAppToken;
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch Twitch app token:', e && e.message ? e.message : e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUserInfo(login) {
|
||||
if (!login) return null;
|
||||
const lower = login.toLowerCase();
|
||||
const cached = userInfoCache.get(lower);
|
||||
const now = Date.now();
|
||||
if (cached && now - cached.fetchedAt < 1000 * 60 * 30) { // 30 min cache
|
||||
return cached;
|
||||
}
|
||||
const token = await getAppToken();
|
||||
if (!token) return null;
|
||||
try {
|
||||
const res = await fetch(`https://api.twitch.tv/helix/users?login=${encodeURIComponent(lower)}`, {
|
||||
headers: {
|
||||
'Client-ID': twitchClientId,
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
const json = await res.json();
|
||||
const data = (json.data && json.data[0]) || null;
|
||||
if (data) {
|
||||
const info = { description: data.description || '', profile_image_url: data.profile_image_url || '', fetchedAt: now };
|
||||
userInfoCache.set(lower, info);
|
||||
return info;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch Twitch user info for', lower, e && e.message ? e.message : e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
let polling = false;
|
||||
const pollIntervalMs = Number(process.env.TWITCH_POLL_INTERVAL_MS || 5000); // 5s default
|
||||
const debugMode = false; // Debug logging disabled
|
||||
|
||||
// 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) {
|
||||
const guildId = guild.id;
|
||||
const guildName = guild.name;
|
||||
|
||||
try {
|
||||
// Intentionally quiet: per-guild checking logs are suppressed to avoid spam
|
||||
const settings = await api.getServerSettings(guild.id) || {};
|
||||
if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Checking guild ${guildName} (${guildId})`);
|
||||
|
||||
const settings = await api.getServerSettings(guildId) || {};
|
||||
const liveSettings = settings.liveNotifications || {};
|
||||
if (!liveSettings.enabled) return;
|
||||
|
||||
if (debugMode) {
|
||||
console.log(`🔍 [DEBUG] TwitchWatcher: Guild ${guildName} settings:`, {
|
||||
enabled: liveSettings.enabled,
|
||||
channelId: liveSettings.channelId,
|
||||
usersCount: (liveSettings.users || []).length,
|
||||
hasCustomMessage: !!liveSettings.customMessage,
|
||||
hasDefaultMessage: !!liveSettings.message
|
||||
});
|
||||
}
|
||||
|
||||
if (!liveSettings.enabled) {
|
||||
if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Live notifications disabled for ${guildName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const channelId = liveSettings.channelId;
|
||||
const users = (liveSettings.users || []).map(u => u.toLowerCase()).filter(Boolean);
|
||||
if (!channelId || users.length === 0) return;
|
||||
|
||||
if (debugMode) {
|
||||
console.log(`🔍 [DEBUG] TwitchWatcher: Guild ${guildName} - Channel: ${channelId}, Users: [${users.join(', ')}]`);
|
||||
}
|
||||
|
||||
if (!channelId || users.length === 0) {
|
||||
if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Skipping ${guildName} - ${!channelId ? 'No channel configured' : 'No users configured'}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// ask backend for current live streams
|
||||
const query = users.join(',');
|
||||
if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Fetching streams for query: ${query}`);
|
||||
|
||||
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 {
|
||||
if (streams && Array.isArray(streams)) {
|
||||
live = streams.filter(s => s.is_live);
|
||||
if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Found ${live.length} live streams via _rawGetTwitchStreams`);
|
||||
} else {
|
||||
if (debugMode && streams) {
|
||||
console.log(`🔍 [DEBUG] TwitchWatcher: _rawGetTwitchStreams returned non-array:`, typeof streams, streams);
|
||||
}
|
||||
try {
|
||||
const resp = await api.tryFetchTwitchStreams(query);
|
||||
live = (resp || []).filter(s => s.is_live);
|
||||
live = (Array.isArray(resp) ? resp : []).filter(s => s.is_live);
|
||||
if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Found ${live.length} live streams via tryFetchTwitchStreams`);
|
||||
} catch (e) {
|
||||
console.error(`❌ TwitchWatcher: Failed to fetch streams for ${guildName}:`, e && e.message ? e.message : e);
|
||||
live = [];
|
||||
}
|
||||
}
|
||||
|
||||
if (debugMode && live.length > 0) {
|
||||
console.log(`🔍 [DEBUG] TwitchWatcher: Live streams:`, live.map(s => `${s.user_login} (${s.viewer_count} viewers)`));
|
||||
}
|
||||
|
||||
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}`;
|
||||
const key = `${guildId}:${u}`;
|
||||
if (announced.has(key)) {
|
||||
announced.delete(key);
|
||||
if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Cleared announcement for ${u} in ${guildName} (no longer live)`);
|
||||
}
|
||||
}
|
||||
if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: No live streams found for ${guildName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -45,12 +148,28 @@ async function checkGuild(client, guild) {
|
||||
let channel = null;
|
||||
try {
|
||||
channel = await client.channels.fetch(channelId);
|
||||
if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Successfully fetched channel ${channel.name} (${channelId}) in ${guildName}`);
|
||||
|
||||
if (channel.type !== 0) { // 0 is text channel
|
||||
console.error(`❌ TwitchWatcher: Channel ${channelId} in ${guildName} is not a text channel (type: ${channel.type})`);
|
||||
channel = null;
|
||||
} else {
|
||||
// Check if bot has permission to send messages
|
||||
const permissions = channel.permissionsFor(client.user);
|
||||
if (!permissions || !permissions.has('SendMessages')) {
|
||||
console.error(`❌ TwitchWatcher: Bot lacks SendMessages permission in channel ${channel.name} (${channelId}) for ${guildName}`);
|
||||
channel = null;
|
||||
} else if (debugMode) {
|
||||
console.log(`🔍 [DEBUG] TwitchWatcher: Bot has SendMessages permission in ${channel.name}`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`TwitchWatcher: failed to fetch channel ${channelId}:`, e && e.message ? e.message : e);
|
||||
console.error(`❌ TwitchWatcher: Failed to fetch channel ${channelId} for ${guildName}:`, e && e.message ? e.message : e);
|
||||
channel = null;
|
||||
}
|
||||
if (!channel) {
|
||||
// Channel not found or inaccessible; skip
|
||||
if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Skipping announcements for ${guildName} - channel unavailable`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -59,63 +178,132 @@ async function checkGuild(client, guild) {
|
||||
|
||||
// Clear announced entries for users that are no longer live
|
||||
for (const u of users) {
|
||||
const key = `${guild.id}:${u}`;
|
||||
const key = `${guildId}:${u}`;
|
||||
if (!liveLogins.has(u) && announced.has(key)) {
|
||||
announced.delete(key);
|
||||
if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Cleared announcement for ${u} in ${guildName} (stream ended)`);
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
const key = `${guildId}:${login}`;
|
||||
if (announced.has(key)) {
|
||||
if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Skipping ${login} in ${guildName} - already announced`);
|
||||
continue; // already announced for this live session
|
||||
}
|
||||
|
||||
if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Preparing announcement for ${login} in ${guildName}`);
|
||||
|
||||
// mark announced for this session
|
||||
announced.set(key, { started_at: s.started_at || new Date().toISOString() });
|
||||
|
||||
// Build and send embed
|
||||
// Build and send embed (standardized layout)
|
||||
try {
|
||||
// Announce without per-guild log spam
|
||||
const { EmbedBuilder } = require('discord.js');
|
||||
// Attempt to enrich with user bio (description) if available
|
||||
let bio = '';
|
||||
try {
|
||||
const info = await fetchUserInfo(login);
|
||||
if (info && info.description) bio = info.description.slice(0, 200);
|
||||
if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Fetched user info for ${login} - bio length: ${bio.length}`);
|
||||
} catch (e) {
|
||||
if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Failed to fetch user info for ${login}:`, e && e.message ? e.message : e);
|
||||
}
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(0x9146FF)
|
||||
.setColor('#6441A5') // Twitch purple
|
||||
.setAuthor({ name: s.user_name, iconURL: s.profile_image_url || undefined, url: s.url })
|
||||
.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)
|
||||
.setThumbnail(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 || '')
|
||||
.setImage(s.thumbnail_url ? s.thumbnail_url.replace('{width}', '640').replace('{height}', '360') + `?t=${Date.now()}` : null)
|
||||
.setDescription(bio || (s.description || '').slice(0, 200))
|
||||
.setFooter({ text: `ehchadservices • Started: ${s.started_at ? new Date(s.started_at).toLocaleString() : 'unknown'}` });
|
||||
|
||||
await channel.send({ embeds: [embed] });
|
||||
console.log(`🔔 Announced live: ${login} - ${(s.title || '').slice(0, 80)}`);
|
||||
// Determine message text (custom overrides default). Provide a plain text prefix if available.
|
||||
let prefixMsg = '';
|
||||
if (liveSettings.customMessage) {
|
||||
prefixMsg = liveSettings.customMessage;
|
||||
} else if (liveSettings.message) {
|
||||
prefixMsg = liveSettings.message;
|
||||
} else {
|
||||
prefixMsg = `🔴 ${s.user_name} is now live!`;
|
||||
}
|
||||
|
||||
// Replace template variables in custom messages
|
||||
prefixMsg = prefixMsg
|
||||
.replace(/\{user\}/g, s.user_name || login)
|
||||
.replace(/\{title\}/g, s.title || 'Untitled Stream')
|
||||
.replace(/\{category\}/g, s.game_name || 'Unknown')
|
||||
.replace(/\{viewers\}/g, String(s.viewer_count || 0));
|
||||
|
||||
if (debugMode) {
|
||||
console.log(`🔍 [DEBUG] TwitchWatcher: Sending announcement for ${login} in ${guildName} to #${channel.name}`);
|
||||
console.log(`🔍 [DEBUG] TwitchWatcher: Message content: "${prefixMsg}"`);
|
||||
}
|
||||
|
||||
// Ensure we always hyperlink the title via embed; prefix is optional add above embed
|
||||
const payload = prefixMsg ? { content: prefixMsg, embeds: [embed] } : { embeds: [embed] };
|
||||
await channel.send(payload);
|
||||
console.log(`🔔 TwitchWatcher: Successfully announced ${login} in ${guildName} - "${(s.title || '').slice(0, 80)}"`);
|
||||
} catch (e) {
|
||||
console.error(`TwitchWatcher: failed to send announcement for ${login}:`, e && e.message ? e.message : e);
|
||||
console.error(`❌ TwitchWatcher: Failed to send announcement for ${login} in ${guildName}:`, 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); }
|
||||
try {
|
||||
if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Attempting fallback message for ${login} in ${guildName}`);
|
||||
await channel.send({ content: msg });
|
||||
console.log(`🔔 TwitchWatcher: Fallback message sent for ${login} in ${guildName}`);
|
||||
} catch (err) {
|
||||
console.error(`❌ TwitchWatcher: Fallback send failed for ${login} in ${guildName}:`, err && err.message ? err.message : err);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error checking guild for live streams:', e && e.message ? e.message : e);
|
||||
console.error(`❌ TwitchWatcher: Error checking guild ${guildName} (${guildId}) 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`);
|
||||
console.log(`🔁 TwitchWatcher started, polling every ${Math.round(pollIntervalMs/1000)}s${debugMode ? ' (DEBUG MODE ENABLED)' : ''}`);
|
||||
|
||||
// Initial check on restart: send messages for currently live users
|
||||
try {
|
||||
const guilds = Array.from(client.guilds.cache.values());
|
||||
if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Initial check for ${guilds.length} guilds`);
|
||||
|
||||
for (const g of guilds) {
|
||||
if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Initial check for guild ${g.name} (${g.id})`);
|
||||
await checkGuild(client, g).catch(err => {
|
||||
console.error(`❌ TwitchWatcher: Initial checkGuild error for ${g.name}:`, err && err.message ? err.message : err);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('❌ TwitchWatcher: Error during initial twitch check:', e && e.message ? e.message : e);
|
||||
}
|
||||
|
||||
while (polling) {
|
||||
try {
|
||||
const guilds = Array.from(client.guilds.cache.values());
|
||||
if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Polling cycle starting for ${guilds.length} guilds`);
|
||||
|
||||
for (const g of guilds) {
|
||||
await checkGuild(client, g).catch(err => { console.error('TwitchWatcher: checkGuild error', err && err.message ? err.message : err); });
|
||||
await checkGuild(client, g).catch(err => {
|
||||
console.error(`❌ TwitchWatcher: checkGuild error for ${g.name}:`, err && err.message ? err.message : err);
|
||||
});
|
||||
}
|
||||
|
||||
if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Polling cycle completed, waiting ${Math.round(pollIntervalMs/1000)}s`);
|
||||
} catch (e) {
|
||||
console.error('Error during twitch poll loop:', e && e.message ? e.message : e);
|
||||
console.error('❌ TwitchWatcher: Error during twitch poll loop:', e && e.message ? e.message : e);
|
||||
}
|
||||
await new Promise(r => setTimeout(r, pollIntervalMs));
|
||||
}
|
||||
|
||||
@@ -2,30 +2,75 @@ import React, { useEffect } from 'react';
|
||||
import { BrowserRouter as Router, Route, Routes, useLocation } from 'react-router-dom';
|
||||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import { UserProvider } from './contexts/UserContext';
|
||||
import { CssBaseline } from '@mui/material';
|
||||
import { BackendProvider, useBackend } from './contexts/BackendContext';
|
||||
import { CssBaseline, Box } from '@mui/material';
|
||||
import Login from './components/Login';
|
||||
import Dashboard from './components/Dashboard';
|
||||
import ServerSettings from './components/ServerSettings';
|
||||
import ServerSettings from './components/server/ServerSettings';
|
||||
import NavBar from './components/NavBar';
|
||||
import HelpPage from './components/HelpPage';
|
||||
import HelpPage from './components/server/HelpPage';
|
||||
import DiscordPage from './components/DiscordPage';
|
||||
import MaintenancePage from './components/common/MaintenancePage';
|
||||
import Footer from './components/common/Footer';
|
||||
|
||||
function AppInner() {
|
||||
const { backendOnline, checking, forceCheck } = useBackend();
|
||||
|
||||
const handleRetry = async () => {
|
||||
await forceCheck();
|
||||
};
|
||||
|
||||
return (
|
||||
<UserProvider>
|
||||
<ThemeProvider>
|
||||
<CssBaseline />
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: '100vh',
|
||||
}}
|
||||
>
|
||||
<Router>
|
||||
<TitleSetter />
|
||||
{!backendOnline ? (
|
||||
<MaintenancePage onRetry={handleRetry} checking={checking} />
|
||||
) : (
|
||||
<>
|
||||
<NavBar />
|
||||
<Box
|
||||
component="main"
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<Routes>
|
||||
<Route path="/" element={<Login />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/server/:guildId" element={<ServerSettings />} />
|
||||
<Route path="/server/:guildId/help" element={<HelpPage />} />
|
||||
<Route path="/discord" element={<DiscordPage />} />
|
||||
</Routes>
|
||||
</Box>
|
||||
<Footer />
|
||||
</>
|
||||
)}
|
||||
</Router>
|
||||
</Box>
|
||||
</ThemeProvider>
|
||||
</UserProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<UserProvider>
|
||||
<ThemeProvider>
|
||||
<CssBaseline />
|
||||
<Router>
|
||||
<TitleSetter />
|
||||
<NavBar />
|
||||
<Routes>
|
||||
<Route path="/" element={<Login />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/server/:guildId" element={<ServerSettings />} />
|
||||
<Route path="/server/:guildId/help" element={<HelpPage />} />
|
||||
<Route path="/discord" element={<DiscordPage />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
<BackendProvider>
|
||||
<AppInner />
|
||||
</BackendProvider>
|
||||
</ThemeProvider>
|
||||
</UserProvider>
|
||||
);
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import React, { useState, useEffect, useContext } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { Grid, Card, CardContent, Typography, Box, IconButton, Snackbar, Alert, Menu, MenuItem, Button } from '@mui/material';
|
||||
import { Grid, Card, CardContent, Typography, Box, IconButton, Snackbar, Alert, Menu, MenuItem, Button, Container } from '@mui/material';
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
import { UserContext } from '../contexts/UserContext';
|
||||
import { useBackend } from '../contexts/BackendContext';
|
||||
import PersonAddIcon from '@mui/icons-material/PersonAdd';
|
||||
import RemoveCircleOutlineIcon from '@mui/icons-material/RemoveCircleOutline';
|
||||
import axios from 'axios';
|
||||
import DashboardIcon from '@mui/icons-material/Dashboard';
|
||||
import WavingHandIcon from '@mui/icons-material/WavingHand';
|
||||
import { get, post } from '../lib/api';
|
||||
|
||||
import ConfirmDialog from './ConfirmDialog';
|
||||
import ConfirmDialog from './common/ConfirmDialog';
|
||||
|
||||
const Dashboard = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { user, setUser } = useContext(UserContext);
|
||||
const { eventTarget } = useBackend();
|
||||
|
||||
const [guilds, setGuilds] = useState([]);
|
||||
const [botStatus, setBotStatus] = useState({});
|
||||
@@ -78,7 +82,7 @@ const Dashboard = () => {
|
||||
const statuses = {};
|
||||
await Promise.all(guilds.map(async (g) => {
|
||||
try {
|
||||
const resp = await axios.get(`${API_BASE}/api/servers/${g.id}/bot-status`);
|
||||
const resp = await get(`/api/servers/${g.id}/bot-status`);
|
||||
statuses[g.id] = resp.data.isBotInServer;
|
||||
} catch (err) {
|
||||
statuses[g.id] = false;
|
||||
@@ -89,6 +93,25 @@ const Dashboard = () => {
|
||||
fetchStatuses();
|
||||
}, [guilds, API_BASE]);
|
||||
|
||||
// Listen for bot status updates
|
||||
useEffect(() => {
|
||||
if (!eventTarget) return;
|
||||
|
||||
const onBotStatusUpdate = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
setBotStatus(prev => ({
|
||||
...prev,
|
||||
[data.guildId]: data.isBotInServer
|
||||
}));
|
||||
};
|
||||
|
||||
eventTarget.addEventListener('botStatusUpdate', onBotStatusUpdate);
|
||||
|
||||
return () => {
|
||||
eventTarget.removeEventListener('botStatusUpdate', onBotStatusUpdate);
|
||||
};
|
||||
}, [eventTarget]);
|
||||
|
||||
// Dashboard no longer loads live settings; that's on the server settings page
|
||||
|
||||
// Live notifications handlers were removed from Dashboard
|
||||
@@ -99,7 +122,7 @@ const Dashboard = () => {
|
||||
|
||||
const handleInviteBot = (e, guild) => {
|
||||
e.stopPropagation();
|
||||
axios.get(`${API_BASE}/api/client-id`).then(resp => {
|
||||
get('/api/client-id').then(resp => {
|
||||
const clientId = resp.data.clientId;
|
||||
if (!clientId) {
|
||||
setSnackbarMessage('No client ID available');
|
||||
@@ -124,7 +147,7 @@ const Dashboard = () => {
|
||||
const handleConfirmLeave = async () => {
|
||||
if (!selectedGuild) return;
|
||||
try {
|
||||
await axios.post(`${API_BASE}/api/servers/${selectedGuild.id}/leave`);
|
||||
await post(`/api/servers/${selectedGuild.id}/leave`);
|
||||
setBotStatus(prev => ({ ...prev, [selectedGuild.id]: false }));
|
||||
setSnackbarMessage('Bot left the server');
|
||||
setSnackbarOpen(true);
|
||||
@@ -139,18 +162,32 @@ const Dashboard = () => {
|
||||
const handleSnackbarClose = () => setSnackbarOpen(false);
|
||||
|
||||
return (
|
||||
<div style={{ padding: 20 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Container maxWidth="lg" sx={{ padding: { xs: 2, sm: 3, md: 5 } }}>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>Dashboard</Typography>
|
||||
{user && <Typography variant="h6" gutterBottom>Welcome, {user.username}</Typography>}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 3 }}>
|
||||
<DashboardIcon sx={{ mr: 1, fontSize: { xs: '2rem', sm: '2.5rem' } }} />
|
||||
<Typography variant={{ xs: 'h4', sm: 'h3' }}>Dashboard</Typography>
|
||||
</Box>
|
||||
{user && <Box sx={{ display: 'flex', alignItems: 'center', mt: { xs: 4, sm: 5 }, mb: { xs: 4, sm: 5 } }}>
|
||||
<WavingHandIcon sx={{ mr: 1, color: 'text.secondary' }} />
|
||||
<Typography
|
||||
variant={{ xs: 'h3', sm: 'h2' }}
|
||||
sx={{
|
||||
fontWeight: 300,
|
||||
color: 'text.secondary'
|
||||
}}
|
||||
>
|
||||
Welcome back, {user.username}
|
||||
</Typography>
|
||||
</Box>}
|
||||
</Box>
|
||||
</Box>
|
||||
<Typography variant="h6" gutterBottom>Your Admin Servers:</Typography>
|
||||
|
||||
<Grid container spacing={3} justifyContent="center">
|
||||
{guilds.map(guild => (
|
||||
<Grid item xs={12} sm={6} md={4} lg={3} key={guild.id} sx={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<Grid item xs={12} sm={6} md={6} lg={4} key={guild.id} sx={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', width: '100%' }}>
|
||||
<Card
|
||||
onClick={() => handleCardClick(guild)}
|
||||
@@ -163,6 +200,11 @@ const Dashboard = () => {
|
||||
transform: 'translateY(-5px)',
|
||||
boxShadow: '0 12px 24px rgba(0,0,0,0.3)',
|
||||
},
|
||||
'&:active': {
|
||||
transform: 'translateY(-2px) scale(0.98)',
|
||||
transition: 'transform 0.1s ease-in-out',
|
||||
boxShadow: '0 8px 16px rgba(0,0,0,0.4)',
|
||||
},
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
@@ -171,30 +213,66 @@ const Dashboard = () => {
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', p: 2 }}>
|
||||
<CardContent sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', p: { xs: 1.5, sm: 2 } }}>
|
||||
<Box
|
||||
component="img"
|
||||
src={guild.icon ? `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png` : 'https://cdn.discordapp.com/embed/avatars/0.png'}
|
||||
sx={{
|
||||
width: 80,
|
||||
height: 80,
|
||||
width: { xs: 60, sm: 80 },
|
||||
height: { xs: 60, sm: 80 },
|
||||
borderRadius: '50%',
|
||||
mb: 2,
|
||||
boxShadow: '0 4px 8px rgba(0,0,0,0.2)',
|
||||
}}
|
||||
/>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, textAlign: 'center', mb: 1 }}>{guild.name}</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1 }}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
textAlign: 'center',
|
||||
mb: 1,
|
||||
fontSize: { xs: '1rem', sm: '1.25rem' },
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
lineHeight: 1.2,
|
||||
minHeight: { xs: '2.4rem', sm: '2.5rem' },
|
||||
}}
|
||||
>
|
||||
{guild.name}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||
{botStatus[guild.id] ? (
|
||||
<Button variant="contained" color="error" size="small" onClick={(e) => handleLeaveBot(e, guild)} startIcon={<RemoveCircleOutlineIcon />}>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
size="small"
|
||||
onClick={(e) => handleLeaveBot(e, guild)}
|
||||
startIcon={<RemoveCircleOutlineIcon />}
|
||||
sx={{ fontSize: { xs: '0.75rem', sm: '0.875rem' } }}
|
||||
>
|
||||
Leave
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="contained" color="success" size="small" onClick={(e) => handleInviteBot(e, guild)} startIcon={<PersonAddIcon />}>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="success"
|
||||
size="small"
|
||||
onClick={(e) => handleInviteBot(e, guild)}
|
||||
startIcon={<PersonAddIcon />}
|
||||
sx={{ fontSize: { xs: '0.75rem', sm: '0.875rem' } }}
|
||||
>
|
||||
Invite
|
||||
</Button>
|
||||
)}
|
||||
<IconButton size="small" onClick={(e) => { e.stopPropagation(); setMenuAnchor(e.currentTarget); setMenuGuild(guild); }} aria-label="server menu">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => { e.stopPropagation(); setMenuAnchor(e.currentTarget); setMenuGuild(guild); }}
|
||||
aria-label="server menu"
|
||||
sx={{ ml: { xs: 0, sm: 1 } }}
|
||||
>
|
||||
<MoreVertIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
@@ -206,26 +284,26 @@ const Dashboard = () => {
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
<Menu anchorEl={menuAnchor} open={!!menuAnchor} onClose={() => { setMenuAnchor(null); setMenuGuild(null); }}>
|
||||
<MenuItem onClick={() => { setMenuAnchor(null); if (menuGuild) window.location.href = `/server/${menuGuild.id}`; }}>Open Server Settings</MenuItem>
|
||||
</Menu>
|
||||
<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 */}
|
||||
{/* Live Notifications dialog removed from Dashboard — available on Server Settings page */}
|
||||
|
||||
<Snackbar open={snackbarOpen} autoHideDuration={6000} onClose={handleSnackbarClose}>
|
||||
<Alert onClose={handleSnackbarClose} severity="info" sx={{ width: '100%' }}>
|
||||
{snackbarMessage}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
<Snackbar open={snackbarOpen} autoHideDuration={6000} onClose={handleSnackbarClose}>
|
||||
<Alert onClose={handleSnackbarClose} severity="info" sx={{ width: '100%' }}>
|
||||
{snackbarMessage}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
|
||||
<ConfirmDialog
|
||||
open={dialogOpen}
|
||||
onClose={() => setDialogOpen(false)}
|
||||
onConfirm={handleConfirmLeave}
|
||||
title="Confirm Leave"
|
||||
message={`Are you sure you want the bot to leave ${selectedGuild?.name}?`}
|
||||
/>
|
||||
</div>
|
||||
<ConfirmDialog
|
||||
open={dialogOpen}
|
||||
onClose={() => setDialogOpen(false)}
|
||||
onConfirm={handleConfirmLeave}
|
||||
title="Confirm Leave"
|
||||
message={`Are you sure you want the bot to leave ${selectedGuild?.name}?`}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ const NavBar = () => {
|
||||
const closeMenu = () => { setAnchorEl(null); setOpen(false); };
|
||||
|
||||
return (
|
||||
<AppBar position="static" color="transparent" elevation={0} sx={{ mb: 2, borderBottom: '1px solid rgba(0,0,0,0.06)' }}>
|
||||
<AppBar position="static" color="default" elevation={1} sx={{ mb: 2, borderBottom: '1px solid rgba(0,0,0,0.12)' }}>
|
||||
<Toolbar sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', px: { xs: 2, sm: 4 } }}>
|
||||
<Box>
|
||||
<IconButton onClick={toggleOpen} aria-label="menu" size="large" sx={{ bgcolor: open ? 'primary.main' : 'transparent', color: open ? 'white' : 'text.primary' }}>
|
||||
@@ -33,8 +33,12 @@ const NavBar = () => {
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
<Typography variant="h6" component="div" sx={{ fontWeight: 800, display: { xs: 'block', sm: 'none' } }} onClick={() => { navigate('/dashboard'); closeMenu(); }}>
|
||||
ECS
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" component="div" sx={{ fontWeight: 800, display: { xs: 'none', sm: 'block' } }} onClick={() => { navigate('/dashboard'); closeMenu(); }}>
|
||||
ECS - EHDCHADSWORTH
|
||||
EhChadServices
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
|
||||
@@ -1,794 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate, useLocation } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
import { Button, Typography, Box, IconButton, Switch, Select, MenuItem, FormControl, FormControlLabel, Radio, RadioGroup, TextField, Accordion, AccordionSummary, AccordionDetails, Snackbar, Alert } from '@mui/material';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
// UserSettings moved to NavBar
|
||||
import ConfirmDialog from './ConfirmDialog';
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
|
||||
// Use a relative API base by default so the frontend talks to the same origin that served it.
|
||||
// In development you can set REACT_APP_API_BASE to a full URL if needed.
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || '';
|
||||
|
||||
const ServerSettings = () => {
|
||||
const { guildId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
// settings state removed (not used) to avoid lint warnings
|
||||
const [isBotInServer, setIsBotInServer] = useState(false);
|
||||
const [clientId, setClientId] = useState(null);
|
||||
const [server, setServer] = useState(null);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [channels, setChannels] = useState([]);
|
||||
const [roles, setRoles] = useState([]);
|
||||
const [autoroleSettings, setAutoroleSettings] = useState({
|
||||
enabled: false,
|
||||
roleId: '',
|
||||
});
|
||||
const [commandsList, setCommandsList] = useState([]);
|
||||
const [invites, setInvites] = useState([]);
|
||||
const [deleting, setDeleting] = useState({});
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [pendingDeleteInvite, setPendingDeleteInvite] = useState(null);
|
||||
const [snackbarOpen, setSnackbarOpen] = useState(false);
|
||||
const [snackbarMessage, setSnackbarMessage] = useState('');
|
||||
// SSE connection status (not currently displayed)
|
||||
const [confirmDeleteTwitch, setConfirmDeleteTwitch] = useState(false);
|
||||
const [pendingTwitchUser, setPendingTwitchUser] = useState(null);
|
||||
const [inviteForm, setInviteForm] = useState({ channelId: '', maxAge: 0, maxUses: 0, temporary: false });
|
||||
const [liveEnabled, setLiveEnabled] = useState(false);
|
||||
const [liveChannelId, setLiveChannelId] = useState('');
|
||||
const [liveTwitchUser, setLiveTwitchUser] = useState('');
|
||||
const [watchedUsers, setWatchedUsers] = useState([]);
|
||||
const [liveStatus, setLiveStatus] = useState({});
|
||||
const [commandsExpanded, setCommandsExpanded] = useState(false);
|
||||
const [welcomeLeaveSettings, setWelcomeLeaveSettings] = useState({
|
||||
welcome: {
|
||||
enabled: false,
|
||||
channel: '',
|
||||
message: 'Welcome to the server, {user}!',
|
||||
customMessage: '',
|
||||
},
|
||||
leave: {
|
||||
enabled: false,
|
||||
channel: '',
|
||||
message: '{user} has left the server.',
|
||||
customMessage: '',
|
||||
},
|
||||
});
|
||||
|
||||
const defaultWelcomeMessages = ["Welcome to the server, {user}!", "Hey {user}, welcome!", "{user} has joined the party!"];
|
||||
const defaultLeaveMessages = ["{user} has left the server.", "Goodbye, {user}.", "We'll miss you, {user}."];
|
||||
|
||||
useEffect(() => {
|
||||
if (location.state && location.state.guild) {
|
||||
setServer(location.state.guild);
|
||||
} else {
|
||||
// Fallback if guild data is not passed in state
|
||||
const storedGuilds = localStorage.getItem('guilds');
|
||||
if (storedGuilds) {
|
||||
const guild = JSON.parse(storedGuilds).find(g => g.id === guildId);
|
||||
setServer(guild);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch settings (not used directly in this component)
|
||||
axios.get(`${API_BASE}/api/servers/${guildId}/settings`).catch(() => {});
|
||||
|
||||
// Check if bot is in server
|
||||
axios.get(`${API_BASE}/api/servers/${guildId}/bot-status`)
|
||||
.then(response => {
|
||||
setIsBotInServer(response.data.isBotInServer);
|
||||
});
|
||||
|
||||
// Fetch client ID
|
||||
axios.get(`${API_BASE}/api/client-id`)
|
||||
.then(response => {
|
||||
setClientId(response.data.clientId);
|
||||
});
|
||||
|
||||
// Fetch channels
|
||||
axios.get(`${API_BASE}/api/servers/${guildId}/channels`)
|
||||
.then(response => {
|
||||
setChannels(response.data);
|
||||
});
|
||||
|
||||
// Fetch welcome/leave settings
|
||||
axios.get(`${API_BASE}/api/servers/${guildId}/welcome-leave-settings`)
|
||||
.then(response => {
|
||||
if (response.data) {
|
||||
setWelcomeLeaveSettings(response.data);
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch roles
|
||||
axios.get(`${API_BASE}/api/servers/${guildId}/roles`)
|
||||
.then(response => {
|
||||
setRoles(response.data);
|
||||
});
|
||||
|
||||
// Fetch autorole settings
|
||||
axios.get(`${API_BASE}/api/servers/${guildId}/autorole-settings`)
|
||||
.then(response => {
|
||||
if (response.data) {
|
||||
setAutoroleSettings(response.data);
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch commands/help list
|
||||
axios.get(`${API_BASE}/api/servers/${guildId}/commands`)
|
||||
.then(response => {
|
||||
setCommandsList(response.data || []);
|
||||
})
|
||||
.catch(() => setCommandsList([]));
|
||||
|
||||
// Fetch invites
|
||||
// Fetch live notifications settings and watched users
|
||||
axios.get(`${API_BASE}/api/servers/${guildId}/live-notifications`).then(resp => {
|
||||
const s = resp.data || { enabled: false, twitchUser: '', channelId: '' };
|
||||
setLiveEnabled(!!s.enabled);
|
||||
setLiveChannelId(s.channelId || '');
|
||||
setLiveTwitchUser(s.twitchUser || '');
|
||||
}).catch(() => {});
|
||||
axios.get(`${API_BASE}/api/servers/${guildId}/twitch-users`).then(resp => setWatchedUsers(resp.data || [])).catch(() => setWatchedUsers([]));
|
||||
axios.get(`${API_BASE}/api/servers/${guildId}/invites`)
|
||||
.then(resp => setInvites(resp.data || []))
|
||||
.catch(() => setInvites([]));
|
||||
|
||||
// Open commands accordion if navigated from Help back button
|
||||
if (location.state && location.state.openCommands) {
|
||||
setCommandsExpanded(true);
|
||||
}
|
||||
|
||||
}, [guildId, location.state]);
|
||||
|
||||
// Subscribe to backend Server-Sent Events for real-time updates
|
||||
useEffect(() => {
|
||||
if (!guildId) return;
|
||||
if (typeof window === 'undefined' || typeof EventSource === 'undefined') return;
|
||||
const url = `${API_BASE}/api/events?guildId=${encodeURIComponent(guildId)}`;
|
||||
let es = null;
|
||||
try {
|
||||
es = new EventSource(url);
|
||||
} catch (err) {
|
||||
console.warn('EventSource not available or failed to connect', err);
|
||||
return;
|
||||
}
|
||||
es.addEventListener('connected', (e) => {
|
||||
setSnackbarMessage('Real-time updates connected');
|
||||
setSnackbarOpen(true);
|
||||
});
|
||||
es.addEventListener('commandToggle', (e) => {
|
||||
try {
|
||||
// refresh commands list to keep authoritative state
|
||||
axios.get(`${API_BASE}/api/servers/${guildId}/commands`).then(resp => setCommandsList(resp.data || [])).catch(() => {});
|
||||
} catch (err) {}
|
||||
});
|
||||
es.addEventListener('twitchUsersUpdate', (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data || '{}');
|
||||
if (data && data.users) setWatchedUsers(data.users || []);
|
||||
// also refresh live status
|
||||
if (data && data.users && data.users.length > 0) {
|
||||
const usersCsv = data.users.join(',');
|
||||
axios.get(`${API_BASE}/api/twitch/streams?users=${encodeURIComponent(usersCsv)}`)
|
||||
.then(resp => {
|
||||
const arr = resp.data || [];
|
||||
const map = {};
|
||||
for (const s of arr) if (s && s.user_login) map[s.user_login.toLowerCase()] = s;
|
||||
setLiveStatus(map);
|
||||
})
|
||||
.catch(() => {});
|
||||
} else {
|
||||
setLiveStatus({});
|
||||
}
|
||||
} catch (err) {}
|
||||
});
|
||||
es.addEventListener('liveNotificationsUpdate', (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data || '{}');
|
||||
if (typeof data.enabled !== 'undefined') setLiveEnabled(!!data.enabled);
|
||||
if (data.channelId) setLiveChannelId(data.channelId || '');
|
||||
if (data.twitchUser) setLiveTwitchUser(data.twitchUser || '');
|
||||
} catch (err) {}
|
||||
});
|
||||
es.onerror = (err) => {
|
||||
setSnackbarMessage('Real-time updates disconnected. Retrying...');
|
||||
setSnackbarOpen(true);
|
||||
// attempt reconnects handled by EventSource automatically
|
||||
};
|
||||
return () => { try { es && es.close(); } catch (e) {} };
|
||||
}, [guildId]);
|
||||
|
||||
const handleCloseSnackbar = () => {
|
||||
setSnackbarOpen(false);
|
||||
};
|
||||
|
||||
// Fetch live status for watched users
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
if (!watchedUsers || watchedUsers.length === 0) {
|
||||
setLiveStatus({});
|
||||
return;
|
||||
}
|
||||
const usersCsv = watchedUsers.join(',');
|
||||
axios.get(`${API_BASE}/api/twitch/streams?users=${encodeURIComponent(usersCsv)}`)
|
||||
.then(resp => {
|
||||
if (!mounted) return;
|
||||
const arr = resp.data || [];
|
||||
const map = {};
|
||||
for (const s of arr) {
|
||||
if (s && s.user_login) map[s.user_login.toLowerCase()] = s;
|
||||
}
|
||||
setLiveStatus(map);
|
||||
})
|
||||
.catch(() => setLiveStatus({}));
|
||||
return () => { mounted = false; };
|
||||
}, [watchedUsers]);
|
||||
|
||||
const handleAutoroleSettingUpdate = (newSettings) => {
|
||||
axios.post(`${API_BASE}/api/servers/${guildId}/autorole-settings`, newSettings)
|
||||
.then(response => {
|
||||
if (response.data.success) {
|
||||
setAutoroleSettings(newSettings);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleAutoroleToggleChange = (event) => {
|
||||
const newSettings = { ...autoroleSettings, enabled: event.target.checked };
|
||||
handleAutoroleSettingUpdate(newSettings);
|
||||
};
|
||||
|
||||
const handleAutoroleRoleChange = (event) => {
|
||||
const newSettings = { ...autoroleSettings, roleId: event.target.value };
|
||||
handleAutoroleSettingUpdate(newSettings);
|
||||
};
|
||||
|
||||
const handleSettingUpdate = (newSettings) => {
|
||||
axios.post(`${API_BASE}/api/servers/${guildId}/welcome-leave-settings`, newSettings)
|
||||
.then(response => {
|
||||
if (response.data.success) {
|
||||
setWelcomeLeaveSettings(newSettings);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const handleToggleChange = (type) => (event) => {
|
||||
const newSettings = { ...welcomeLeaveSettings };
|
||||
newSettings[type].enabled = event.target.checked;
|
||||
handleSettingUpdate(newSettings);
|
||||
};
|
||||
|
||||
const handleChannelChange = (type) => (event) => {
|
||||
const newSettings = { ...welcomeLeaveSettings };
|
||||
newSettings[type].channel = event.target.value;
|
||||
handleSettingUpdate(newSettings);
|
||||
};
|
||||
|
||||
const handleMessageOptionChange = (type) => (event) => {
|
||||
const newSettings = { ...welcomeLeaveSettings };
|
||||
if (event.target.value !== 'custom') {
|
||||
newSettings[type].message = event.target.value;
|
||||
handleSettingUpdate(newSettings);
|
||||
} else {
|
||||
const tempSettings = { ...welcomeLeaveSettings };
|
||||
// Set message to custom message to get the radio button to select custom
|
||||
tempSettings[type].message = tempSettings[type].customMessage;
|
||||
setWelcomeLeaveSettings(tempSettings);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCustomMessageChange = (type) => (event) => {
|
||||
const newSettings = { ...welcomeLeaveSettings };
|
||||
newSettings[type].customMessage = event.target.value;
|
||||
setWelcomeLeaveSettings(newSettings);
|
||||
};
|
||||
|
||||
const handleApplyCustomMessage = (type) => () => {
|
||||
const newSettings = { ...welcomeLeaveSettings };
|
||||
newSettings[type].message = newSettings[type].customMessage;
|
||||
handleSettingUpdate(newSettings);
|
||||
};
|
||||
|
||||
const getMessageValue = (type) => {
|
||||
const currentMessage = welcomeLeaveSettings[type].message;
|
||||
const defaultMessages = type === 'welcome' ? defaultWelcomeMessages : defaultLeaveMessages;
|
||||
if (defaultMessages.includes(currentMessage)) {
|
||||
return currentMessage;
|
||||
}
|
||||
return 'custom';
|
||||
}
|
||||
|
||||
const handleInviteBot = () => {
|
||||
if (!clientId) return;
|
||||
const permissions = 8; // Administrator
|
||||
const url = `https://discord.com/api/oauth2/authorize?client_id=${clientId}&permissions=${permissions}&scope=bot%20applications.commands&guild_id=${guildId}&disable_guild_select=true`;
|
||||
window.open(url, '_blank');
|
||||
};
|
||||
|
||||
const handleLeaveBot = () => {
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmLeave = async () => {
|
||||
try {
|
||||
await axios.post(`${API_BASE}/api/servers/${guildId}/leave`);
|
||||
setIsBotInServer(false);
|
||||
} catch (error) {
|
||||
console.error('Error leaving server:', error);
|
||||
}
|
||||
setDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
navigate('/dashboard');
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px' }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<IconButton onClick={handleBack} sx={{ borderRadius: '50%', boxShadow: '0 8px 16px 0 rgba(0,0,0,0.2)' }}>
|
||||
<ArrowBackIcon />
|
||||
</IconButton>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Typography variant="h4" component="h1" sx={{ margin: 0 }}>
|
||||
{server ? `Server Settings for ${server.name}` : 'Loading...'}
|
||||
</Typography>
|
||||
{isBotInServer ? (
|
||||
<>
|
||||
<Button variant="contained" size="small" color="error" onClick={handleLeaveBot}>
|
||||
Leave Server
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="contained" size="small" onClick={handleInviteBot} disabled={!clientId}>
|
||||
Invite Bot
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
{/* UserSettings moved to NavBar */}
|
||||
</Box>
|
||||
<Accordion sx={{ marginTop: '20px', opacity: isBotInServer ? 1 : 0.5 }} expanded={commandsExpanded} onChange={() => setCommandsExpanded(prev => !prev)}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="h6">Commands</Typography>
|
||||
</AccordionSummary>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', padding: 1 }}>
|
||||
<Button variant="text" onClick={() => navigate(`/server/${guildId}/help`)} disabled={!isBotInServer}>Commands List</Button>
|
||||
</Box>
|
||||
<AccordionDetails>
|
||||
{!isBotInServer && <Typography>Invite the bot to enable commands.</Typography>}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, marginTop: '10px' }}>
|
||||
{/** Render protected commands first in a fixed order **/}
|
||||
{(() => {
|
||||
const protectedOrder = ['help', 'manage-commands'];
|
||||
const protectedCmds = protectedOrder.map(name => commandsList.find(c => c.name === name)).filter(Boolean);
|
||||
const otherCmds = (commandsList || []).filter(c => !protectedOrder.includes(c.name)).sort((a,b) => a.name.localeCompare(b.name, undefined, {sensitivity: 'base'}));
|
||||
return (
|
||||
<>
|
||||
{protectedCmds.map(cmd => (
|
||||
<Box key={cmd.name} sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: 1, border: '1px solid #eee', borderRadius: 1 }}>
|
||||
<Box>
|
||||
<Typography sx={{ fontWeight: 'bold' }}>{cmd.name}</Typography>
|
||||
<Typography variant="body2">{cmd.description}</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<FormControlLabel control={<Switch checked={true} disabled />} label="Locked" />
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
{otherCmds.map(cmd => (
|
||||
<Box key={cmd.name} sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: 1, border: '1px solid #eee', borderRadius: 1 }}>
|
||||
<Box>
|
||||
<Typography sx={{ fontWeight: 'bold' }}>{cmd.name}</Typography>
|
||||
<Typography variant="body2">{cmd.description}</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={cmd.enabled} onChange={async (e) => {
|
||||
const newVal = e.target.checked;
|
||||
// optimistic update
|
||||
setCommandsList(prev => prev.map(c => c.name === cmd.name ? { ...c, enabled: newVal } : c));
|
||||
try {
|
||||
await axios.post(`${API_BASE}/api/servers/${guildId}/commands/${cmd.name}/toggle`, { enabled: newVal });
|
||||
// refresh authoritative state from backend
|
||||
const resp = await axios.get(`${API_BASE}/api/servers/${guildId}/commands`);
|
||||
setCommandsList(resp.data || []);
|
||||
} catch (err) {
|
||||
// revert on error and notify
|
||||
setCommandsList(prev => prev.map(c => c.name === cmd.name ? { ...c, enabled: cmd.enabled } : c));
|
||||
setSnackbarMessage('Failed to update command toggle');
|
||||
setSnackbarOpen(true);
|
||||
}
|
||||
}} disabled={!isBotInServer} />}
|
||||
label={cmd.enabled ? 'Enabled' : 'Disabled'}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
{/* Live Notifications dialog */}
|
||||
{/* header live dialog removed; Live Notifications is managed in its own accordion below */}
|
||||
{/* Invite creation and list */}
|
||||
<Accordion sx={{ marginTop: '20px', opacity: isBotInServer ? 1 : 0.5 }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="h6">Invites</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
{!isBotInServer && <Typography>Invite features require the bot to be in the server.</Typography>}
|
||||
<Box sx={{ display: 'flex', gap: 2, flexDirection: { xs: 'column', sm: 'row' }, marginTop: 1 }}>
|
||||
<Box sx={{ width: { xs: '100%', sm: '40%' } }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>Channel (optional)</Typography>
|
||||
<FormControl fullWidth>
|
||||
<Select value={inviteForm.channelId} onChange={(e) => setInviteForm(f => ({ ...f, channelId: e.target.value }))} displayEmpty>
|
||||
<MenuItem value="">(Any channel)</MenuItem>
|
||||
{channels.map(ch => (<MenuItem key={ch.id} value={ch.id}>{ch.name}</MenuItem>))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
<Box sx={{ width: { xs: '100%', sm: '20%' } }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>Expiry</Typography>
|
||||
<FormControl fullWidth>
|
||||
<Select value={inviteForm.maxAge} onChange={(e) => setInviteForm(f => ({ ...f, maxAge: Number(e.target.value) }))}>
|
||||
<MenuItem value={0}>Never expire</MenuItem>
|
||||
<MenuItem value={3600}>1 hour</MenuItem>
|
||||
<MenuItem value={86400}>1 day</MenuItem>
|
||||
<MenuItem value={604800}>7 days</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
<Box sx={{ width: { xs: '100%', sm: '20%' } }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>Max Uses</Typography>
|
||||
<FormControl fullWidth>
|
||||
<Select value={inviteForm.maxUses} onChange={(e) => setInviteForm(f => ({ ...f, maxUses: Number(e.target.value) }))}>
|
||||
<MenuItem value={0}>Unlimited</MenuItem>
|
||||
<MenuItem value={1}>1</MenuItem>
|
||||
<MenuItem value={5}>5</MenuItem>
|
||||
<MenuItem value={10}>10</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, width: { xs: '100%', sm: '20%' } }}>
|
||||
<Box sx={{ width: '100%' }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>Temporary</Typography>
|
||||
<FormControlLabel control={<Switch checked={inviteForm.temporary} onChange={(e) => setInviteForm(f => ({ ...f, temporary: e.target.checked }))} />} label="" />
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-end' }}>
|
||||
<Button variant="contained" onClick={async () => {
|
||||
try {
|
||||
const resp = await axios.post(`${API_BASE}/api/servers/${guildId}/invites`, inviteForm);
|
||||
if (resp.data && resp.data.success) {
|
||||
setInvites(prev => [...prev, resp.data.invite]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error creating invite:', err);
|
||||
}
|
||||
}} disabled={!isBotInServer}>Create Invite</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ marginTop: 2 }}>
|
||||
{invites.length === 0 && <Typography>No invites created by the bot.</Typography>}
|
||||
{invites.map(inv => (
|
||||
<Box key={inv.code} sx={{ border: '1px solid #eee', borderRadius: 1, padding: 1, marginTop: 1, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Box>
|
||||
<Typography>{inv.url}</Typography>
|
||||
<Typography variant="caption">Created: {new Date(inv.createdAt).toLocaleString()} • Uses: {inv.uses || 0} • MaxUses: {inv.maxUses || 0} • MaxAge(s): {inv.maxAge || 0} • Temporary: {inv.temporary ? 'Yes' : 'No'}</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Button startIcon={<ContentCopyIcon />} onClick={async () => {
|
||||
// robust clipboard copy with fallback
|
||||
try {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
await navigator.clipboard.writeText(inv.url);
|
||||
} else {
|
||||
// fallback for older browsers
|
||||
const input = document.createElement('input');
|
||||
input.value = inv.url;
|
||||
document.body.appendChild(input);
|
||||
input.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(input);
|
||||
}
|
||||
setSnackbarMessage('Copied invite URL to clipboard');
|
||||
setSnackbarOpen(true);
|
||||
} catch (err) {
|
||||
console.error('Clipboard copy failed:', err);
|
||||
setSnackbarMessage('Failed to copy — please copy manually');
|
||||
setSnackbarOpen(true);
|
||||
}
|
||||
}}>Copy</Button>
|
||||
<Button startIcon={<DeleteIcon />} color="error" disabled={!!deleting[inv.code]} onClick={() => {
|
||||
// open confirm dialog for this invite
|
||||
setPendingDeleteInvite(inv);
|
||||
setConfirmOpen(true);
|
||||
}}>Delete</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
{/* Help moved to dedicated Help page */}
|
||||
<Accordion sx={{ marginTop: '20px', opacity: isBotInServer ? 1 : 0.5 }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="h6">Welcome/Leave</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
{!isBotInServer && <Typography>Invite the bot to enable this feature.</Typography>}
|
||||
<Box sx={{ marginTop: '10px' }}>
|
||||
<Typography variant="subtitle1">Welcome Messages</Typography>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={welcomeLeaveSettings.welcome.enabled} onChange={handleToggleChange('welcome')} disabled={!isBotInServer} />}
|
||||
label="Enable Welcome Messages"
|
||||
/>
|
||||
<FormControl fullWidth sx={{ marginTop: '10px' }} disabled={!isBotInServer || !welcomeLeaveSettings.welcome.enabled}>
|
||||
<Select
|
||||
value={welcomeLeaveSettings.welcome.channel}
|
||||
onChange={handleChannelChange('welcome')}
|
||||
displayEmpty
|
||||
>
|
||||
<MenuItem value="" disabled>Select a channel</MenuItem>
|
||||
{channels.map(channel => (
|
||||
<MenuItem key={channel.id} value={channel.id}>{channel.name}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl component="fieldset" sx={{ marginTop: '10px' }} disabled={!isBotInServer || !welcomeLeaveSettings.welcome.enabled}>
|
||||
<RadioGroup
|
||||
value={getMessageValue('welcome')}
|
||||
onChange={handleMessageOptionChange('welcome')}
|
||||
>
|
||||
{defaultWelcomeMessages.map(msg => (
|
||||
<FormControlLabel key={msg} value={msg} control={<Radio />} label={msg} />
|
||||
))}
|
||||
<FormControlLabel value="custom" control={<Radio />} label="Custom" />
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', marginTop: '10px' }} >
|
||||
<TextField
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
placeholder="Your custom message"
|
||||
value={welcomeLeaveSettings.welcome.customMessage}
|
||||
onChange={handleCustomMessageChange('welcome')}
|
||||
disabled={!isBotInServer || !welcomeLeaveSettings.welcome.enabled || getMessageValue('welcome') !== 'custom'}
|
||||
/>
|
||||
<Button onClick={handleApplyCustomMessage('welcome')} disabled={!isBotInServer || !welcomeLeaveSettings.welcome.enabled || getMessageValue('welcome') !== 'custom'}>Apply</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ marginTop: '20px' }}>
|
||||
<Typography variant="subtitle1">Leave Messages</Typography>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={welcomeLeaveSettings.leave.enabled} onChange={handleToggleChange('leave')} disabled={!isBotInServer} />}
|
||||
label="Enable Leave Messages"
|
||||
/>
|
||||
<FormControl fullWidth sx={{ marginTop: '10px' }} disabled={!isBotInServer || !welcomeLeaveSettings.leave.enabled}>
|
||||
<Select
|
||||
value={welcomeLeaveSettings.leave.channel}
|
||||
onChange={handleChannelChange('leave')}
|
||||
displayEmpty
|
||||
>
|
||||
<MenuItem value="" disabled>Select a channel</MenuItem>
|
||||
{channels.map(channel => (
|
||||
<MenuItem key={channel.id} value={channel.id}>{channel.name}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl component="fieldset" sx={{ marginTop: '10px' }} disabled={!isBotInServer || !welcomeLeaveSettings.leave.enabled}>
|
||||
<RadioGroup
|
||||
value={getMessageValue('leave')}
|
||||
onChange={handleMessageOptionChange('leave')}
|
||||
>
|
||||
{defaultLeaveMessages.map(msg => (
|
||||
<FormControlLabel key={msg} value={msg} control={<Radio />} label={msg} />
|
||||
))}
|
||||
<FormControlLabel value="custom" control={<Radio />} label="Custom" />
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', marginTop: '10px' }} >
|
||||
<TextField
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
placeholder="Your custom message"
|
||||
value={welcomeLeaveSettings.leave.customMessage}
|
||||
onChange={handleCustomMessageChange('leave')}
|
||||
disabled={!isBotInServer || !welcomeLeaveSettings.leave.enabled || getMessageValue('leave') !== 'custom'}
|
||||
/>
|
||||
<Button onClick={handleApplyCustomMessage('leave')} disabled={!isBotInServer || !welcomeLeaveSettings.leave.enabled || getMessageValue('leave') !== 'custom'}>Apply</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
{/* Live Notifications Accordion */}
|
||||
<Accordion sx={{ marginTop: '20px', opacity: isBotInServer ? 1 : 0.5 }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="h6">Live Notifications</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
{!isBotInServer && <Typography>Invite the bot to enable this feature.</Typography>}
|
||||
<Box sx={{ marginTop: '10px' }}>
|
||||
<FormControl fullWidth disabled={!isBotInServer}>
|
||||
<Select value={liveChannelId} onChange={(e) => setLiveChannelId(e.target.value)} displayEmpty>
|
||||
<MenuItem value="">(Select channel)</MenuItem>
|
||||
{channels.map(ch => (<MenuItem key={ch.id} value={ch.id}>{ch.name}</MenuItem>))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, mt: 2 }}>
|
||||
<TextField label="Twitch username" value={liveTwitchUser} onChange={(e) => setLiveTwitchUser(e.target.value)} fullWidth disabled={!isBotInServer} />
|
||||
<Button variant="contained" onClick={async () => {
|
||||
if (!liveTwitchUser) return;
|
||||
try {
|
||||
await axios.post(`${API_BASE}/api/servers/${guildId}/twitch-users`, { username: liveTwitchUser });
|
||||
setWatchedUsers(prev => [...prev.filter(u => u !== liveTwitchUser.toLowerCase()), liveTwitchUser.toLowerCase()]);
|
||||
setLiveTwitchUser('');
|
||||
} catch (err) { console.error('Failed to add twitch user', err); }
|
||||
}} disabled={!isBotInServer}>Add</Button>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="subtitle2">Watched Users</Typography>
|
||||
{watchedUsers.length === 0 && <Typography>No users added</Typography>}
|
||||
{watchedUsers.map(u => (
|
||||
<Box key={u} sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mt: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Typography>{u}</Typography>
|
||||
{liveStatus[u] && liveStatus[u].is_live && (
|
||||
<Button size="small" color="error" href={liveStatus[u].url} target="_blank" rel="noopener">Watch Live</Button>
|
||||
)}
|
||||
</Box>
|
||||
<Box>
|
||||
<Button size="small" onClick={() => { setPendingTwitchUser(u); setConfirmDeleteTwitch(true); }}>Delete</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<FormControlLabel control={<Switch checked={liveEnabled} onChange={(e) => setLiveEnabled(e.target.checked)} />} label="Enabled" sx={{ mt: 2 }} />
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 2 }}>
|
||||
<Button variant="contained" onClick={async () => {
|
||||
try {
|
||||
await axios.post(`${API_BASE}/api/servers/${guildId}/live-notifications`, { enabled: liveEnabled, twitchUser: '', channelId: liveChannelId });
|
||||
} catch (err) { console.error('Failed to save live settings', err); }
|
||||
}} disabled={!isBotInServer}>Save</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
<Accordion sx={{ marginTop: '20px', opacity: isBotInServer ? 1 : 0.5 }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="h6">Autorole</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
{!isBotInServer && <Typography>Invite the bot to enable this feature.</Typography>}
|
||||
<Box sx={{ marginTop: '10px' }}>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={autoroleSettings.enabled} onChange={handleAutoroleToggleChange} disabled={!isBotInServer} />}
|
||||
label="Enable Autorole"
|
||||
/>
|
||||
<FormControl fullWidth sx={{ marginTop: '10px' }} disabled={!isBotInServer || !autoroleSettings.enabled}>
|
||||
<Select
|
||||
value={autoroleSettings.roleId}
|
||||
onChange={handleAutoroleRoleChange}
|
||||
displayEmpty
|
||||
>
|
||||
<MenuItem value="" disabled>Select a role</MenuItem>
|
||||
{roles.map(role => (
|
||||
<MenuItem key={role.id} value={role.id}><span style={{ color: role.color }}>{role.name}</span></MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
<Accordion sx={{ marginTop: '20px', opacity: isBotInServer ? 1 : 0.5 }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="h6">Admin Commands</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Typography>Coming soon...</Typography>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
<ConfirmDialog
|
||||
open={dialogOpen}
|
||||
onClose={() => setDialogOpen(false)}
|
||||
onConfirm={handleConfirmLeave}
|
||||
title="Confirm Leave"
|
||||
message={`Are you sure you want the bot to leave ${server?.name}?`}
|
||||
/>
|
||||
{/* Confirm dialog for invite deletion */}
|
||||
<ConfirmDialog
|
||||
open={confirmOpen}
|
||||
onClose={() => { setConfirmOpen(false); setPendingDeleteInvite(null); }}
|
||||
onConfirm={async () => {
|
||||
// perform deletion for pendingDeleteInvite
|
||||
if (!pendingDeleteInvite) {
|
||||
setConfirmOpen(false);
|
||||
return;
|
||||
}
|
||||
const code = pendingDeleteInvite.code;
|
||||
setConfirmOpen(false);
|
||||
setDeleting(prev => ({ ...prev, [code]: true }));
|
||||
try {
|
||||
// fetch token (one retry)
|
||||
let token = null;
|
||||
try {
|
||||
const tokenResp = await axios.get(`${API_BASE}/api/servers/${guildId}/invite-token`);
|
||||
token = tokenResp && tokenResp.data && tokenResp.data.token;
|
||||
} catch (tErr) {
|
||||
try {
|
||||
const tokenResp2 = await axios.get(`${API_BASE}/api/servers/${guildId}/invite-token`);
|
||||
token = tokenResp2 && tokenResp2.data && tokenResp2.data.token;
|
||||
} catch (tErr2) {
|
||||
throw new Error('Failed to obtain delete token from server');
|
||||
}
|
||||
}
|
||||
if (!token) throw new Error('No delete token received from server');
|
||||
await axios.delete(`${API_BASE}/api/servers/${guildId}/invites/${code}`, { headers: { 'x-invite-token': token } });
|
||||
setInvites(prev => prev.filter(i => i.code !== code));
|
||||
setSnackbarMessage('Invite deleted');
|
||||
setSnackbarOpen(true);
|
||||
} catch (err) {
|
||||
console.error('Error deleting invite:', err);
|
||||
const msg = (err && err.message) || (err && err.response && err.response.data && err.response.data.message) || 'Failed to delete invite';
|
||||
setSnackbarMessage(msg);
|
||||
setSnackbarOpen(true);
|
||||
} finally {
|
||||
setDeleting(prev => {
|
||||
const copy = { ...prev };
|
||||
delete copy[pendingDeleteInvite?.code];
|
||||
return copy;
|
||||
});
|
||||
setPendingDeleteInvite(null);
|
||||
}
|
||||
}}
|
||||
title="Delete Invite"
|
||||
message={`Are you sure you want to delete invite ${pendingDeleteInvite ? pendingDeleteInvite.url : ''}?`}
|
||||
/>
|
||||
{/* Confirm dialog for deleting a twitch user from watched list */}
|
||||
<ConfirmDialog
|
||||
open={confirmDeleteTwitch}
|
||||
onClose={() => { setConfirmDeleteTwitch(false); setPendingTwitchUser(null); }}
|
||||
onConfirm={async () => {
|
||||
if (!pendingTwitchUser) { setConfirmDeleteTwitch(false); return; }
|
||||
setConfirmDeleteTwitch(false);
|
||||
try {
|
||||
await axios.delete(`${API_BASE}/api/servers/${guildId}/twitch-users/${encodeURIComponent(pendingTwitchUser)}`);
|
||||
setWatchedUsers(prev => prev.filter(x => x !== pendingTwitchUser));
|
||||
setSnackbarMessage('Twitch user removed');
|
||||
setSnackbarOpen(true);
|
||||
} catch (err) {
|
||||
console.error('Failed to delete twitch user', err);
|
||||
setSnackbarMessage('Failed to delete twitch user');
|
||||
setSnackbarOpen(true);
|
||||
} finally {
|
||||
setPendingTwitchUser(null);
|
||||
}
|
||||
}}
|
||||
title="Delete Twitch User"
|
||||
message={`Are you sure you want to remove ${pendingTwitchUser || ''} from the watch list?`}
|
||||
/>
|
||||
<Snackbar open={snackbarOpen} autoHideDuration={4000} onClose={handleCloseSnackbar}>
|
||||
<Alert onClose={handleCloseSnackbar} severity="info" sx={{ width: '100%' }}>
|
||||
{snackbarMessage}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServerSettings;
|
||||
32
frontend/src/components/common/Footer.js
Normal file
32
frontend/src/components/common/Footer.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
import { Box, Typography } from '@mui/material';
|
||||
|
||||
const Footer = () => {
|
||||
return (
|
||||
<Box
|
||||
component="footer"
|
||||
sx={{
|
||||
py: { xs: 1, sm: 2 },
|
||||
px: { xs: 1, sm: 2 },
|
||||
backgroundColor: (theme) =>
|
||||
theme.palette.mode === 'light'
|
||||
? theme.palette.grey[200]
|
||||
: theme.palette.grey[800],
|
||||
textAlign: 'center',
|
||||
borderTop: (theme) => `1px solid ${theme.palette.divider}`,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
fontSize: { xs: '0.75rem', sm: '0.875rem' },
|
||||
}}
|
||||
>
|
||||
© ehchadservices.com 2025
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
};;
|
||||
|
||||
export default Footer;
|
||||
22
frontend/src/components/common/MaintenancePage.js
Normal file
22
frontend/src/components/common/MaintenancePage.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import { Box, Typography, Button, CircularProgress } from '@mui/material';
|
||||
|
||||
const MaintenancePage = ({ onRetry, checking }) => {
|
||||
return (
|
||||
<Box sx={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: 'background.default', p: 3 }}>
|
||||
<Box sx={{ maxWidth: 640, textAlign: 'center' }}>
|
||||
<Typography variant="h4" sx={{ mb: 2, fontWeight: 'bold' }}>EhChadServices is currently under maintenance</Typography>
|
||||
<Typography sx={{ mb: 2 }}>We're checking the service status and will reload automatically when the backend is available. Please check back in a few moments.</Typography>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: 2, mt: 2 }}>
|
||||
<CircularProgress size={40} />
|
||||
<Typography>{checking ? 'Checking...' : 'Idle'}</Typography>
|
||||
</Box>
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<Button variant="contained" onClick={onRetry}>Retry now</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MaintenancePage;
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
import { get } from '../../lib/api';
|
||||
import { Box, IconButton, Typography } from '@mui/material';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
|
||||
@@ -10,9 +10,7 @@ const HelpPage = () => {
|
||||
const [commands, setCommands] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || '';
|
||||
axios.get(`${API_BASE}/api/servers/${guildId}/commands`)
|
||||
.then(res => {
|
||||
get(`/api/servers/${guildId}/commands`).then(res => {
|
||||
const cmds = res.data || [];
|
||||
// sort: locked commands first (preserve relative order), then others alphabetically
|
||||
const locked = cmds.filter(c => c.locked);
|
||||
@@ -28,10 +26,10 @@ const HelpPage = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: 20 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<IconButton onClick={handleBack}><ArrowBackIcon /></IconButton>
|
||||
<Typography variant="h5">Commands List</Typography>
|
||||
<Box sx={{ padding: { xs: 2, sm: 3, md: 5 } }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: { xs: 1, sm: 2 }, mb: 2 }}>
|
||||
<IconButton onClick={handleBack}><ArrowBackIcon /></IconButton>
|
||||
<Typography variant={{ xs: 'h5', sm: 'h5' }}>Commands List</Typography>
|
||||
</Box>
|
||||
<Box sx={{ marginTop: 2 }}>
|
||||
{commands.length === 0 && <Typography>No commands available.</Typography>}
|
||||
@@ -45,7 +43,7 @@ const HelpPage = () => {
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
196
frontend/src/components/server/ReactionRoles.js
Normal file
196
frontend/src/components/server/ReactionRoles.js
Normal file
@@ -0,0 +1,196 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Box, Button, TextField, Select, MenuItem, FormControl, InputLabel, Accordion, AccordionSummary, AccordionDetails, Typography, IconButton, List, ListItem, ListItemText, Chip } from '@mui/material';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import * as api from '../../lib/api';
|
||||
import { useBackend } from '../../contexts/BackendContext';
|
||||
import ConfirmDialog from '../common/ConfirmDialog';
|
||||
|
||||
export default function ReactionRoles({ guildId, channels, roles = [] }) {
|
||||
const { eventTarget } = useBackend() || {};
|
||||
const [list, setList] = useState([]);
|
||||
const [name, setName] = useState('');
|
||||
const [channelId, setChannelId] = useState('');
|
||||
const [embed, setEmbed] = useState('');
|
||||
const [embedTitle, setEmbedTitle] = useState('');
|
||||
const [embedColor, setEmbedColor] = useState('#2f3136');
|
||||
const [embedThumbnail, setEmbedThumbnail] = useState('');
|
||||
const [embedFields, setEmbedFields] = useState([]);
|
||||
const [buttons, setButtons] = useState([]);
|
||||
const [newBtnLabel, setNewBtnLabel] = useState('');
|
||||
const [newBtnRole, setNewBtnRole] = useState('');
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [pendingDeleteId, setPendingDeleteId] = useState(null);
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
async function load() {
|
||||
const rows = await api.listReactionRoles(guildId) || [];
|
||||
if (!mounted) return;
|
||||
setList(rows);
|
||||
}
|
||||
load();
|
||||
|
||||
const onRRUpdate = (e) => {
|
||||
const d = e.detail || {};
|
||||
if (d.guildId && d.guildId !== guildId) return;
|
||||
// reload
|
||||
api.listReactionRoles(guildId).then(rows => setList(rows || []));
|
||||
};
|
||||
|
||||
eventTarget && eventTarget.addEventListener('reactionRolesUpdate', onRRUpdate);
|
||||
return () => { mounted = false; eventTarget && eventTarget.removeEventListener('reactionRolesUpdate', onRRUpdate); };
|
||||
}, [guildId, eventTarget]);
|
||||
|
||||
const addButton = () => {
|
||||
if (!newBtnLabel || !newBtnRole) return;
|
||||
setButtons(prev => [...prev, { label: newBtnLabel, roleId: newBtnRole }]);
|
||||
setNewBtnLabel(''); setNewBtnRole('');
|
||||
};
|
||||
|
||||
const addEmbedField = () => {
|
||||
setEmbedFields(prev => [...prev, { name: '', value: '' }]);
|
||||
};
|
||||
|
||||
const updateEmbedField = (idx, k, v) => {
|
||||
setEmbedFields(prev => prev.map((f,i) => i===idx ? { ...f, [k]: v } : f));
|
||||
};
|
||||
|
||||
const removeEmbedField = (idx) => {
|
||||
setEmbedFields(prev => prev.filter((_,i)=>i!==idx));
|
||||
};
|
||||
|
||||
const createRR = async () => {
|
||||
if (editingId) return updateRR(); // if editing, update instead
|
||||
if (!channelId || !name || (!embed && !embedTitle) || buttons.length === 0) return alert('channel, name, embed (title or description), and at least one button required');
|
||||
const emb = { title: embedTitle, description: embed, color: embedColor, thumbnail: embedThumbnail, fields: embedFields };
|
||||
const res = await api.createReactionRole(guildId, { channelId, name, embed: emb, buttons });
|
||||
if (res && res.reactionRole) {
|
||||
setList(prev => [res.reactionRole, ...prev]);
|
||||
setName(''); setEmbed(''); setEmbedTitle(''); setEmbedColor('#2f3136'); setEmbedThumbnail(''); setEmbedFields([]); setButtons([]); setChannelId('');
|
||||
} else {
|
||||
alert('Failed to create reaction role');
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = (id) => {
|
||||
setPendingDeleteId(id);
|
||||
setConfirmOpen(true);
|
||||
};
|
||||
|
||||
const deleteRR = async (id) => {
|
||||
const ok = await api.deleteReactionRole(guildId, id);
|
||||
if (ok) setList(prev => prev.filter(r => r.id !== id));
|
||||
setConfirmOpen(false);
|
||||
setPendingDeleteId(null);
|
||||
};
|
||||
|
||||
const startEdit = (rr) => {
|
||||
setEditingId(rr.id);
|
||||
setName(rr.name);
|
||||
setChannelId(rr.channel_id);
|
||||
setEmbed(rr.embed?.description || '');
|
||||
setEmbedTitle(rr.embed?.title || '');
|
||||
setEmbedColor(rr.embed?.color || '#2f3136');
|
||||
setEmbedThumbnail(rr.embed?.thumbnail || '');
|
||||
setEmbedFields(rr.embed?.fields || []);
|
||||
setButtons(rr.buttons || []);
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditingId(null);
|
||||
setName(''); setChannelId(''); setEmbed(''); setEmbedTitle(''); setEmbedColor('#2f3136'); setEmbedThumbnail(''); setEmbedFields([]); setButtons([]);
|
||||
};
|
||||
|
||||
const updateRR = async () => {
|
||||
if (!channelId || !name || (!embed && !embedTitle) || buttons.length === 0) return alert('channel, name, embed (title or description), and at least one button required');
|
||||
const emb = { title: embedTitle, description: embed, color: embedColor, thumbnail: embedThumbnail, fields: embedFields };
|
||||
const res = await api.updateReactionRole(guildId, editingId, { channelId, name, embed: emb, buttons });
|
||||
if (res && res.reactionRole) {
|
||||
setList(prev => prev.map(r => r.id === editingId ? res.reactionRole : r));
|
||||
cancelEdit();
|
||||
} else {
|
||||
alert('Failed to update reaction role');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon/>}>
|
||||
<Typography>Reaction Roles</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<FormControl fullWidth sx={{ mb: 1 }}>
|
||||
<InputLabel id="rr-channel-label">Channel</InputLabel>
|
||||
<Select labelId="rr-channel-label" value={channelId} label="Channel" onChange={e => setChannelId(e.target.value)}>
|
||||
<MenuItem value="">Select channel</MenuItem>
|
||||
{channels.map(c => <MenuItem key={c.id} value={c.id}>{c.name}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField label="Name" fullWidth value={name} onChange={e=>setName(e.target.value)} sx={{ mb:1 }} />
|
||||
<TextField label="Embed (description)" fullWidth multiline rows={3} value={embed} onChange={e=>setEmbed(e.target.value)} sx={{ mb:1 }} />
|
||||
<Box sx={{ display: 'flex', gap: 1, mb:1 }}>
|
||||
<TextField label="Embed Title" value={embedTitle} onChange={e=>setEmbedTitle(e.target.value)} sx={{ flex: 1 }} />
|
||||
<TextField label="Color" value={embedColor} onChange={e=>setEmbedColor(e.target.value)} sx={{ width: 120 }} />
|
||||
</Box>
|
||||
<TextField label="Thumbnail URL" fullWidth value={embedThumbnail} onChange={e=>setEmbedThumbnail(e.target.value)} sx={{ mb:1 }} />
|
||||
<Box sx={{ mb:1 }}>
|
||||
<Typography variant="subtitle2">Fields</Typography>
|
||||
{embedFields.map((f,i)=> (
|
||||
<Box key={i} sx={{ display: 'flex', gap: 1, mb: 1 }}>
|
||||
<TextField placeholder="Name" value={f.name} onChange={e=>updateEmbedField(i, 'name', e.target.value)} sx={{ flex: 1 }} />
|
||||
<TextField placeholder="Value" value={f.value} onChange={e=>updateEmbedField(i, 'value', e.target.value)} sx={{ flex: 2 }} />
|
||||
<IconButton onClick={()=>removeEmbedField(i)}><DeleteIcon/></IconButton>
|
||||
</Box>
|
||||
))}
|
||||
<Button onClick={addEmbedField} size="small">Add Field</Button>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 1, mb:1 }}>
|
||||
<TextField label="Button label" value={newBtnLabel} onChange={e=>setNewBtnLabel(e.target.value)} />
|
||||
<FormControl sx={{ minWidth: 220 }}>
|
||||
<InputLabel id="rr-role-label">Role</InputLabel>
|
||||
<Select labelId="rr-role-label" value={newBtnRole} label="Role" onChange={e=>setNewBtnRole(e.target.value)}>
|
||||
<MenuItem value="">Select role</MenuItem>
|
||||
{roles.map(role => (
|
||||
<MenuItem key={role.id} value={role.id}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Chip size="small" label={role.name} sx={{ bgcolor: role.color || undefined, color: role.color ? '#fff' : undefined }} />
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>{role.permissions || ''}</Typography>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Button variant="outlined" onClick={addButton}>Add Button</Button>
|
||||
</Box>
|
||||
<List>
|
||||
{buttons.map((b,i)=>(
|
||||
<ListItem key={i} secondaryAction={<IconButton edge="end" onClick={()=>setButtons(bs=>bs.filter((_,idx)=>idx!==i))}><DeleteIcon/></IconButton>}>
|
||||
<ListItemText primary={b.label} secondary={roles.find(r=>r.id===b.roleId)?.name || b.roleId} />
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button variant="contained" onClick={createRR}>{editingId ? 'Update Reaction Role' : 'Create Reaction Role'}</Button>
|
||||
{editingId && <Button variant="outlined" onClick={cancelEdit}>Cancel</Button>}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Typography variant="h6">Existing</Typography>
|
||||
{list.map(r => (
|
||||
<Box key={r.id} sx={{ border: '1px solid #ddd', p:1, mb:1 }}>
|
||||
<Typography>{r.name}</Typography>
|
||||
<Typography variant="body2">Channel: {r.channel_id || r.channelId}</Typography>
|
||||
<Typography variant="body2">Message: {r.message_id || r.messageId || 'Not posted'}</Typography>
|
||||
<Button variant="outlined" onClick={async ()=>{ const res = await api.postReactionRoleMessage(guildId, r); if (!res || !res.success) alert('Failed to post message'); }}>Post Message</Button>
|
||||
<Button variant="text" color="error" onClick={()=>confirmDelete(r.id)}>Delete</Button>
|
||||
<Button variant="text" onClick={() => startEdit(r)}>Edit</Button>
|
||||
</Box>
|
||||
))}
|
||||
<ConfirmDialog open={confirmOpen} title="Delete Reaction Role" description="Delete this reaction role configuration? This will remove it from the database." onClose={() => { setConfirmOpen(false); setPendingDeleteId(null); }} onConfirm={() => deleteRR(pendingDeleteId)} />
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
);
|
||||
}
|
||||
1454
frontend/src/components/server/ServerSettings.js
Normal file
1454
frontend/src/components/server/ServerSettings.js
Normal file
File diff suppressed because it is too large
Load Diff
101
frontend/src/contexts/BackendContext.js
Normal file
101
frontend/src/contexts/BackendContext.js
Normal file
@@ -0,0 +1,101 @@
|
||||
import React, { createContext, useContext, useEffect, useRef, useState } from 'react';
|
||||
|
||||
const BackendContext = createContext(null);
|
||||
|
||||
export function useBackend() {
|
||||
return useContext(BackendContext);
|
||||
}
|
||||
|
||||
export function BackendProvider({ children }) {
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || '';
|
||||
const [backendOnline, setBackendOnline] = useState(false);
|
||||
const [checking, setChecking] = useState(true);
|
||||
const esRef = useRef(null);
|
||||
const eventTargetRef = useRef(new EventTarget());
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
const check = async () => {
|
||||
if (!mounted) return;
|
||||
setChecking(true);
|
||||
try {
|
||||
const resp = await fetch(`${API_BASE}/api/servers/health`);
|
||||
if (!mounted) return;
|
||||
setBackendOnline(!!(resp && resp.ok));
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setBackendOnline(false);
|
||||
} finally {
|
||||
if (mounted) setChecking(false);
|
||||
}
|
||||
};
|
||||
check();
|
||||
const iv = setInterval(check, 5000);
|
||||
return () => { mounted = false; clearInterval(iv); };
|
||||
}, [API_BASE]);
|
||||
|
||||
// Single shared EventSource forwarded into a DOM EventTarget
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || typeof EventSource === 'undefined') return;
|
||||
const url = `${API_BASE}/api/events`;
|
||||
let es = null;
|
||||
try {
|
||||
es = new EventSource(url);
|
||||
esRef.current = es;
|
||||
} catch (err) {
|
||||
// silently ignore
|
||||
return;
|
||||
}
|
||||
|
||||
const forward = (type) => (e) => {
|
||||
try {
|
||||
const evt = new CustomEvent(type, { detail: e.data ? JSON.parse(e.data) : null });
|
||||
eventTargetRef.current.dispatchEvent(evt);
|
||||
} catch (err) {
|
||||
// ignore parse errors
|
||||
}
|
||||
};
|
||||
|
||||
es.addEventListener('connected', forward('connected'));
|
||||
es.addEventListener('commandToggle', forward('commandToggle'));
|
||||
es.addEventListener('twitchUsersUpdate', forward('twitchUsersUpdate'));
|
||||
es.addEventListener('liveNotificationsUpdate', forward('liveNotificationsUpdate'));
|
||||
es.addEventListener('adminLogAdded', forward('adminLogAdded'));
|
||||
es.addEventListener('adminLogDeleted', forward('adminLogDeleted'));
|
||||
es.addEventListener('adminLogsCleared', forward('adminLogsCleared'));
|
||||
|
||||
es.onerror = () => {
|
||||
// Let consumers react to backendOnline state changes instead of surfacing connection errors
|
||||
};
|
||||
|
||||
return () => { try { es && es.close(); } catch (e) {} };
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const forceCheck = async () => {
|
||||
const API_BASE2 = process.env.REACT_APP_API_BASE || '';
|
||||
try {
|
||||
setChecking(true);
|
||||
const resp = await fetch(`${API_BASE2}/api/servers/health`);
|
||||
setBackendOnline(!!(resp && resp.ok));
|
||||
} catch (e) {
|
||||
setBackendOnline(false);
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const value = {
|
||||
backendOnline,
|
||||
checking,
|
||||
eventTarget: eventTargetRef.current,
|
||||
forceCheck,
|
||||
};
|
||||
|
||||
return (
|
||||
<BackendContext.Provider value={value}>
|
||||
{children}
|
||||
</BackendContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export default BackendContext;
|
||||
@@ -2,7 +2,7 @@ import React, { createContext, useState, useMemo, useContext, useEffect } from '
|
||||
import { ThemeProvider as MuiThemeProvider } from '@mui/material/styles';
|
||||
import { lightTheme, darkTheme, discordTheme } from '../themes';
|
||||
import { UserContext } from './UserContext';
|
||||
import axios from 'axios';
|
||||
import { post } from '../lib/api';
|
||||
|
||||
export const ThemeContext = createContext();
|
||||
|
||||
@@ -45,7 +45,7 @@ export const ThemeProvider = ({ children }) => {
|
||||
|
||||
const changeTheme = (name) => {
|
||||
if (user) {
|
||||
axios.post(`${process.env.REACT_APP_API_BASE || ''}/api/user/theme`, { userId: user.id, theme: name });
|
||||
post('/api/user/theme', { userId: user.id, theme: name }).catch(() => {});
|
||||
}
|
||||
localStorage.setItem('themeName', name);
|
||||
setThemeName(name);
|
||||
|
||||
@@ -11,3 +11,28 @@ code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
|
||||
/* Global responsive styles */
|
||||
#root {
|
||||
max-width: 100vw;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Ensure content doesn't overflow on ultra-wide screens */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Responsive typography adjustments */
|
||||
@media (max-width: 600px) {
|
||||
body {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
/* Ultra-wide screen adjustments */
|
||||
.MuiContainer-root {
|
||||
max-width: 1200px !important;
|
||||
}
|
||||
}
|
||||
|
||||
49
frontend/src/lib/api.js
Normal file
49
frontend/src/lib/api.js
Normal file
@@ -0,0 +1,49 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || '';
|
||||
|
||||
const client = axios.create({
|
||||
baseURL: API_BASE,
|
||||
// optional: set a short timeout for UI requests
|
||||
timeout: 8000,
|
||||
});
|
||||
|
||||
export async function get(path, config) {
|
||||
return client.get(path, config);
|
||||
}
|
||||
|
||||
export async function post(path, data, config) {
|
||||
return client.post(path, data, config);
|
||||
}
|
||||
|
||||
export async function del(path, config) {
|
||||
return client.delete(path, config);
|
||||
}
|
||||
|
||||
export async function listReactionRoles(guildId) {
|
||||
const res = await client.get(`/api/servers/${guildId}/reaction-roles`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function createReactionRole(guildId, body) {
|
||||
const res = await client.post(`/api/servers/${guildId}/reaction-roles`, body);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function deleteReactionRole(guildId, id) {
|
||||
const res = await client.delete(`/api/servers/${guildId}/reaction-roles/${id}`);
|
||||
return res.data && res.data.success;
|
||||
}
|
||||
|
||||
export async function postReactionRoleMessage(guildId, rr) {
|
||||
// instruct backend to have bot post message by asking bot module via internal call
|
||||
const res = await client.post(`/internal/publish-reaction-role`, { guildId, id: rr.id });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function updateReactionRole(guildId, id, body) {
|
||||
const res = await client.put(`/api/servers/${guildId}/reaction-roles/${id}`, body);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export default client;
|
||||
@@ -3,6 +3,13 @@ import { createTheme } from '@mui/material/styles';
|
||||
export const lightTheme = createTheme({
|
||||
palette: {
|
||||
mode: 'light',
|
||||
background: {
|
||||
default: '#e8e8e8', // More greyish background, less bright white
|
||||
paper: '#ffffff',
|
||||
},
|
||||
primary: {
|
||||
main: '#1565c0', // Slightly darker blue for less brightness
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user