Compare commits
4 Commits
2ae7202445
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 61ab1e1d9e | |||
| 8236c1e0e7 | |||
| 900ce85e2c | |||
| ff10bb3183 |
407
README.md
407
README.md
@@ -1,320 +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)
|
||||
|
||||
### Twitch Live Notification Settings (Detailed)
|
||||
|
||||
Endpoint: `GET/POST /api/servers/:guildId/live-notifications`
|
||||
|
||||
Shape returned by GET:
|
||||
```json
|
||||
{
|
||||
"enabled": true,
|
||||
"channelId": "123456789012345678",
|
||||
"twitchUser": "deprecated-single-user-field",
|
||||
"message": "🔴 {user} is now live!",
|
||||
"customMessage": "Custom promo text with link etc"
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `twitchUser` is a legacy single-user field retained for backward compatibility. The active watched users list lives under `/api/servers/:guildId/twitch-users`.
|
||||
- `message` (default message) and `customMessage` (override) are persisted. If `customMessage` is non-empty it is used when announcing a live stream; otherwise `message` is used. If both are empty the bot falls back to `🔴 {user} is now live!`.
|
||||
- Update by POSTing the same shape (omit fields you don't change is okay; unspecified become empty unless preserved on server).
|
||||
|
||||
### Discord Bot Twitch Embed Layout
|
||||
|
||||
When a watched streamer goes live the bot posts a standardized embed. The layout is fixed to keep consistency:
|
||||
|
||||
Embed fields:
|
||||
1. Title: Stream title (hyperlinked to Twitch URL) or fallback "{user} is live".
|
||||
2. Author: Twitch display name with avatar and link.
|
||||
3. Thumbnail: Stream thumbnail (or profile image fallback).
|
||||
4. Fields:
|
||||
- Category: Game / category name (or "Unknown").
|
||||
- Viewers: Current viewer count.
|
||||
5. Description: Twitch user bio (if available via Helix `users` endpoint) else truncated stream description (200 chars).
|
||||
6. Footer: `ehchadservices • Started: <localized start time>`.
|
||||
|
||||
Pre-Embed Message (optional):
|
||||
- If `customMessage` is set it is posted as normal message content above the embed.
|
||||
- Else if `message` is set it is posted above the embed.
|
||||
- Else no prefix content is posted (embed alone).
|
||||
|
||||
Variables:
|
||||
- `{user}` in messages will not be auto-replaced server-side yet; include the username manually if desired. (Can add template replacement in a future iteration.)
|
||||
|
||||
### Watched Users
|
||||
|
||||
- Add/remove watched Twitch usernames via `POST /api/servers/:guildId/twitch-users` and `DELETE /api/servers/:guildId/twitch-users/:username`.
|
||||
- Frontend polls `/api/twitch/streams` every ~15s to refresh live status and renders a "Watch Live" button per user.
|
||||
- The watcher announces a stream only once per live session; when a user goes offline the session marker clears so a future live event re-announces.
|
||||
|
||||
### SSE Event Types Relevant to Twitch
|
||||
|
||||
- `twitchUsersUpdate`: `{ users: ["user1", "user2"], guildId: "..." }`
|
||||
- `liveNotificationsUpdate`: `{ enabled, channelId, twitchUser, message, customMessage, guildId }`
|
||||
|
||||
Consume these to live-update UI without refresh (the `BackendContext` exposes an `eventTarget`).
|
||||
|
||||
### Customizing Messages
|
||||
|
||||
- In the dashboard under Live Notifications you can set both a Default Message and a Custom Message.
|
||||
- Clear Custom to fall back to Default.
|
||||
- Save persists to backend and pushes an SSE `liveNotificationsUpdate`.
|
||||
|
||||
### Future Improvements
|
||||
|
||||
- Template variable replacement: support `{user}`, `{title}`, `{category}`, `{viewers}` inside message strings.
|
||||
- Per-user custom messages (different prefix for each watched streamer).
|
||||
- Embed image improvements (dynamic preview resolution trimming for Twitch thumbnails).
|
||||
|
||||
Notes about Postgres requirement
|
||||
- The backend now assumes Postgres persistence (via `DATABASE_URL`). If `DATABASE_URL` is not set the server will exit and complain. This change makes server settings authoritative and persistent across restarts.
|
||||
|
||||
Logs and verbosity
|
||||
- The bot and watcher log messages have been reduced to avoid per-guild spam. You will see concise messages like "🔁 TwitchWatcher started" and "✅ ECS - Full Stack Bot Online!" rather than one-line-per-guild spam.
|
||||
|
||||
Troubleshooting
|
||||
- If you see mixed-content errors in the browser when using a TLS domain with the CRA dev server, configure Nginx to proxy websockets and set CRA `WDS_SOCKET_*` env vars (see docs/nginx-proxy-manager.md)
|
||||
**Updated**: October 9, 2025
|
||||
|
||||
519
backend/index.js
519
backend/index.js
@@ -636,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' });
|
||||
@@ -654,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);
|
||||
@@ -662,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 {
|
||||
@@ -1013,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' });
|
||||
|
||||
@@ -1053,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' });
|
||||
}
|
||||
});
|
||||
@@ -1098,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();
|
||||
@@ -1127,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"
|
||||
},
|
||||
@@ -22,6 +22,8 @@
|
||||
"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 };
|
||||
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
84
checklist.md
84
checklist.md
@@ -1,27 +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)
|
||||
- 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)
|
||||
@@ -35,6 +51,12 @@
|
||||
- [x] Live Notifications: bot posts rich embed to channel when a watched Twitch user goes live (thumbnail, clickable title, bio/description, category/game, viewers, footer with "ehchadservices" and start datetime)
|
||||
- [x] Live Notifications polling frequency set to 5 seconds (configurable via `TWITCH_POLL_INTERVAL_MS`)
|
||||
- [x] On bot restart, sends messages for currently live watched users; then sends for new streams once per session
|
||||
- [x] 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)
|
||||
@@ -54,6 +76,44 @@
|
||||
- [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`)
|
||||
@@ -68,12 +128,16 @@
|
||||
- [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
|
||||
@@ -90,6 +154,16 @@
|
||||
- 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
|
||||
@@ -106,5 +180,5 @@
|
||||
- [x] Moved `ConfirmDialog` and `MaintenancePage` to `components/common`
|
||||
- [x] Moved `ServerSettings` and `HelpPage` to `components/server`
|
||||
- [x] Fixed ESLint warnings: removed unused imports and handlers, added proper dependency management
|
||||
- [ ] Remove legacy top-level duplicate files (archival recommended)
|
||||
|
||||
- [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) {
|
||||
@@ -208,4 +255,44 @@ async function getAutoroleSettings(guildId) {
|
||||
return json || { enabled: false, roleId: '' };
|
||||
}
|
||||
|
||||
module.exports = { getServerSettings, upsertServerSettings, getCommands, toggleCommand, listInvites, addInvite, deleteInvite, getTwitchUsers, addTwitchUser, deleteTwitchUser, getKickUsers, addKickUser, deleteKickUser, getWelcomeLeaveSettings, getAutoroleSettings };
|
||||
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 };
|
||||
|
||||
@@ -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,20 +16,14 @@ 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 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);
|
||||
}
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
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 });
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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,18 +16,14 @@ 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 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);
|
||||
}
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -12,11 +12,27 @@ for (const file of commandFiles) {
|
||||
const command = require(filePath);
|
||||
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);
|
||||
}
|
||||
},
|
||||
};
|
||||
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' },
|
||||
|
||||
@@ -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);
|
||||
@@ -176,6 +231,50 @@ 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');
|
||||
|
||||
@@ -60,41 +60,86 @@ async function fetchUserInfo(login) {
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -103,16 +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} is not a text channel (type: ${channel.type})`);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -121,40 +178,51 @@ 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 (standardized layout)
|
||||
try {
|
||||
// Announce without per-guild log spam
|
||||
const { EmbedBuilder } = require('discord.js');
|
||||
// Attempt to enrich with user bio (description) if available
|
||||
let bio = '';
|
||||
try {
|
||||
const info = await fetchUserInfo(login);
|
||||
if (info && info.description) bio = info.description.slice(0, 200);
|
||||
} catch (_) {}
|
||||
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 }
|
||||
)
|
||||
.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'}` });
|
||||
|
||||
@@ -167,43 +235,75 @@ async function checkGuild(client, guild) {
|
||||
} 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(`🔔 Announced live: ${login} - ${(s.title || '').slice(0, 80)}`);
|
||||
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) {
|
||||
await checkGuild(client, g).catch(err => { console.error('TwitchWatcher: initial checkGuild error', err && err.message ? err.message : err); });
|
||||
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('Error during initial twitch check:', e && e.message ? e.message : 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));
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { BrowserRouter as Router, Route, Routes, useLocation } from 'react-route
|
||||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import { UserProvider } from './contexts/UserContext';
|
||||
import { BackendProvider, useBackend } from './contexts/BackendContext';
|
||||
import { CssBaseline } from '@mui/material';
|
||||
import { CssBaseline, Box } from '@mui/material';
|
||||
import Login from './components/Login';
|
||||
import Dashboard from './components/Dashboard';
|
||||
import ServerSettings from './components/server/ServerSettings';
|
||||
@@ -11,6 +11,7 @@ import NavBar from './components/NavBar';
|
||||
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();
|
||||
@@ -23,23 +24,41 @@ function AppInner() {
|
||||
<UserProvider>
|
||||
<ThemeProvider>
|
||||
<CssBaseline />
|
||||
<Router>
|
||||
<TitleSetter />
|
||||
{!backendOnline ? (
|
||||
<MaintenancePage onRetry={handleRetry} checking={checking} />
|
||||
) : (
|
||||
<>
|
||||
<NavBar />
|
||||
<Routes>
|
||||
<Route path="/" element={<Login />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/server/:guildId" element={<ServerSettings />} />
|
||||
<Route path="/server/:guildId/help" element={<HelpPage />} />
|
||||
<Route path="/discord" element={<DiscordPage />} />
|
||||
</Routes>
|
||||
</>
|
||||
)}
|
||||
</Router>
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
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 DashboardIcon from '@mui/icons-material/Dashboard';
|
||||
import WavingHandIcon from '@mui/icons-material/WavingHand';
|
||||
import { get, post } from '../lib/api';
|
||||
|
||||
import ConfirmDialog from './common/ConfirmDialog';
|
||||
@@ -13,6 +16,7 @@ const Dashboard = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { user, setUser } = useContext(UserContext);
|
||||
const { eventTarget } = useBackend();
|
||||
|
||||
const [guilds, setGuilds] = useState([]);
|
||||
const [botStatus, setBotStatus] = useState({});
|
||||
@@ -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
|
||||
@@ -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 }}>
|
||||
|
||||
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;
|
||||
@@ -26,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>}
|
||||
@@ -43,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>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useContext } from 'react';
|
||||
import { useBackend } from '../../contexts/BackendContext';
|
||||
import { useParams, useNavigate, useLocation } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
import { Button, Typography, Box, IconButton, Switch, Select, MenuItem, FormControl, FormControlLabel, Radio, RadioGroup, TextField, Accordion, AccordionSummary, AccordionDetails, Tabs, Tab, Snackbar, Alert } from '@mui/material';
|
||||
import { Button, Typography, Box, IconButton, Switch, Select, MenuItem, FormControl, FormControlLabel, Radio, RadioGroup, TextField, Accordion, AccordionSummary, AccordionDetails, Tabs, Tab, InputLabel, Snackbar, Alert, Autocomplete } from '@mui/material';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
// UserSettings moved to NavBar
|
||||
import ConfirmDialog from '../common/ConfirmDialog';
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import { UserContext } from '../../contexts/UserContext';
|
||||
import ReactionRoles from './ReactionRoles';
|
||||
|
||||
// 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.
|
||||
@@ -18,6 +20,7 @@ const ServerSettings = () => {
|
||||
const { guildId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { user } = useContext(UserContext);
|
||||
|
||||
// settings state removed (not used) to avoid lint warnings
|
||||
const [isBotInServer, setIsBotInServer] = useState(false);
|
||||
@@ -35,8 +38,6 @@ const ServerSettings = () => {
|
||||
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);
|
||||
@@ -44,18 +45,6 @@ const ServerSettings = () => {
|
||||
const [pendingKickUser, setPendingKickUser] = useState(null);
|
||||
const [commandsExpanded, setCommandsExpanded] = useState(false);
|
||||
const [liveExpanded, setLiveExpanded] = useState(false);
|
||||
const [inviteForm, setInviteForm] = useState({ channelId: '', maxAge: 0, maxUses: 0, temporary: false });
|
||||
const [liveEnabled, setLiveEnabled] = useState(false);
|
||||
const [liveChannelId, setLiveChannelId] = useState('');
|
||||
const [liveTwitchUser, setLiveTwitchUser] = useState('');
|
||||
const [liveMessage, setLiveMessage] = useState('');
|
||||
const [liveCustomMessage, setLiveCustomMessage] = useState('');
|
||||
const [watchedUsers, setWatchedUsers] = useState([]);
|
||||
const [liveStatus, setLiveStatus] = useState({});
|
||||
const [liveTabValue, setLiveTabValue] = useState(0);
|
||||
const [kickUsers, setKickUsers] = useState([]);
|
||||
const [kickStatus, setKickStatus] = useState({});
|
||||
const [kickUser, setKickUser] = useState('');
|
||||
const [welcomeLeaveSettings, setWelcomeLeaveSettings] = useState({
|
||||
welcome: {
|
||||
enabled: false,
|
||||
@@ -70,6 +59,39 @@ const ServerSettings = () => {
|
||||
customMessage: '',
|
||||
},
|
||||
});
|
||||
const [adminLogsSettings, setAdminLogsSettings] = useState({
|
||||
enabled: false,
|
||||
channelId: '',
|
||||
commands: { kick: true, ban: true, timeout: true }
|
||||
});
|
||||
const [adminLogs, setAdminLogs] = useState([]);
|
||||
const [selectedChannelId, setSelectedChannelId] = useState('');
|
||||
const [moderationTarget, setModerationTarget] = useState('');
|
||||
const [moderationReason, setModerationReason] = useState('');
|
||||
const [timeoutDuration, setTimeoutDuration] = useState('');
|
||||
const [serverMembers, setServerMembers] = useState([]);
|
||||
|
||||
// Add back missing state variables that are still used in the code
|
||||
const [snackbarMessage, setSnackbarMessage] = useState('');
|
||||
const [snackbarOpen, setSnackbarOpen] = useState(false);
|
||||
const [inviteForm, setInviteForm] = useState({
|
||||
maxUses: '',
|
||||
expiresAt: '',
|
||||
channelId: ''
|
||||
});
|
||||
const [liveTabValue, setLiveTabValue] = useState(0);
|
||||
const [liveEnabled, setLiveEnabled] = useState(false);
|
||||
const [liveChannelId, setLiveChannelId] = useState('');
|
||||
const [liveTwitchUser, setLiveTwitchUser] = useState('');
|
||||
const [liveMessage, setLiveMessage] = useState('Twitch user {user} is now live!');
|
||||
const [liveCustomMessage, setLiveCustomMessage] = useState('');
|
||||
const [watchedUsers, setWatchedUsers] = useState([]);
|
||||
const [kickUsers, setKickUsers] = useState([]);
|
||||
const [liveStatus, setLiveStatus] = useState({});
|
||||
|
||||
// Confirm dialog states for admin logs
|
||||
const [deleteLogDialog, setDeleteLogDialog] = useState({ open: false, logId: null, logAction: '' });
|
||||
const [deleteAllLogsDialog, setDeleteAllLogsDialog] = useState(false);
|
||||
|
||||
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}."];
|
||||
@@ -103,13 +125,13 @@ const ServerSettings = () => {
|
||||
}).catch(() => {
|
||||
// ignore when offline
|
||||
});
|
||||
|
||||
// Fetch bot status
|
||||
axios.get(`${API_BASE}/api/servers/${guildId}/bot-status`).then(response => {
|
||||
setIsBotInServer(response.data.isBotInServer);
|
||||
}).catch(() => setIsBotInServer(false));
|
||||
|
||||
// Fetch channels
|
||||
axios.get(`${API_BASE}/api/servers/${guildId}/channels`).then(response => {
|
||||
setChannels(response.data);
|
||||
}).catch(() => {
|
||||
setChannels([]);
|
||||
});
|
||||
// Fetch channels - moved to separate useEffect to depend on bot status
|
||||
|
||||
// Fetch welcome/leave settings
|
||||
axios.get(`${API_BASE}/api/servers/${guildId}/welcome-leave-settings`).then(response => {
|
||||
@@ -144,18 +166,46 @@ const ServerSettings = () => {
|
||||
setLiveCustomMessage(s.customMessage || '');
|
||||
}).catch(() => {});
|
||||
|
||||
// Fetch admin logs settings
|
||||
axios.get(`${API_BASE}/api/servers/${guildId}/admin-logs-settings`).then(response => {
|
||||
if (response.data) {
|
||||
setAdminLogsSettings(response.data);
|
||||
setSelectedChannelId(response.data.channelId || '');
|
||||
}
|
||||
}).catch(() => {
|
||||
// ignore
|
||||
});
|
||||
|
||||
// Fetch admin logs
|
||||
axios.get(`${API_BASE}/api/servers/${guildId}/admin-logs`).then(response => {
|
||||
setAdminLogs(response.data || []);
|
||||
}).catch(() => setAdminLogs([]));
|
||||
|
||||
axios.get(`${API_BASE}/api/servers/${guildId}/twitch-users`).then(resp => setWatchedUsers(resp.data || [])).catch(() => setWatchedUsers([]));
|
||||
|
||||
axios.get(`${API_BASE}/api/servers/${guildId}/kick-users`).then(resp => setKickUsers(resp.data || [])).catch(() => setKickUsers([]));
|
||||
|
||||
axios.get(`${API_BASE}/api/servers/${guildId}/invites`).then(resp => setInvites(resp.data || [])).catch(() => setInvites([]));
|
||||
|
||||
// Fetch server members for moderation
|
||||
axios.get(`${API_BASE}/api/servers/${guildId}/members`).then(resp => setServerMembers(resp.data || [])).catch(() => setServerMembers([]));
|
||||
|
||||
// Open commands accordion if navigated from Help back button
|
||||
if (location.state && location.state.openCommands) {
|
||||
setCommandsExpanded(true);
|
||||
}
|
||||
|
||||
}, [guildId, location.state]);
|
||||
}, [guildId, location.state, isBotInServer]);
|
||||
|
||||
// Fetch channels only when bot is in server
|
||||
useEffect(() => {
|
||||
if (!guildId || !isBotInServer) return;
|
||||
axios.get(`${API_BASE}/api/servers/${guildId}/channels`).then(response => {
|
||||
setChannels(response.data);
|
||||
}).catch(() => {
|
||||
setChannels([]);
|
||||
});
|
||||
}, [guildId, isBotInServer]);
|
||||
|
||||
// Listen to backend events for live notifications and twitch user updates
|
||||
const { eventTarget } = useBackend();
|
||||
@@ -198,10 +248,53 @@ const ServerSettings = () => {
|
||||
axios.get(`${API_BASE}/api/servers/${guildId}/commands`).then(resp => setCommandsList(resp.data || [])).catch(() => {});
|
||||
};
|
||||
|
||||
const onAdminLogAdded = (e) => {
|
||||
const data = e.detail || {};
|
||||
if (!data) return;
|
||||
if (data.guildId && data.guildId !== guildId) return;
|
||||
// Add the new log to the beginning of the list
|
||||
setAdminLogs(prev => [data.log, ...prev.slice(0, 49)]); // Keep only latest 50
|
||||
};
|
||||
|
||||
const onAdminLogDeleted = (e) => {
|
||||
const data = e.detail || {};
|
||||
if (!data) return;
|
||||
if (data.guildId && data.guildId !== guildId) return;
|
||||
setAdminLogs(prev => prev.filter(log => log.id !== data.logId));
|
||||
};
|
||||
|
||||
const onAdminLogsCleared = (e) => {
|
||||
const data = e.detail || {};
|
||||
if (!data) return;
|
||||
if (data.guildId && data.guildId !== guildId) return;
|
||||
setAdminLogs([]);
|
||||
};
|
||||
|
||||
const onInviteCreated = (e) => {
|
||||
const data = e.detail || {};
|
||||
if (!data) return;
|
||||
if (data.guildId && data.guildId !== guildId) return;
|
||||
// Add the new invite to the list
|
||||
setInvites(prev => [...prev, data]);
|
||||
};
|
||||
|
||||
const onInviteDeleted = (e) => {
|
||||
const data = e.detail || {};
|
||||
if (!data) return;
|
||||
if (data.guildId && data.guildId !== guildId) return;
|
||||
// Remove the deleted invite from the list
|
||||
setInvites(prev => prev.filter(invite => invite.code !== data.code));
|
||||
};
|
||||
|
||||
eventTarget.addEventListener('twitchUsersUpdate', onTwitchUsers);
|
||||
eventTarget.addEventListener('kickUsersUpdate', onKickUsers);
|
||||
eventTarget.addEventListener('liveNotificationsUpdate', onLiveNotifications);
|
||||
eventTarget.addEventListener('commandToggle', onCommandToggle);
|
||||
eventTarget.addEventListener('adminLogAdded', onAdminLogAdded);
|
||||
eventTarget.addEventListener('adminLogDeleted', onAdminLogDeleted);
|
||||
eventTarget.addEventListener('adminLogsCleared', onAdminLogsCleared);
|
||||
eventTarget.addEventListener('inviteCreated', onInviteCreated);
|
||||
eventTarget.addEventListener('inviteDeleted', onInviteDeleted);
|
||||
|
||||
return () => {
|
||||
try {
|
||||
@@ -209,12 +302,17 @@ const ServerSettings = () => {
|
||||
eventTarget.removeEventListener('kickUsersUpdate', onKickUsers);
|
||||
eventTarget.removeEventListener('liveNotificationsUpdate', onLiveNotifications);
|
||||
eventTarget.removeEventListener('commandToggle', onCommandToggle);
|
||||
eventTarget.removeEventListener('adminLogAdded', onAdminLogAdded);
|
||||
eventTarget.removeEventListener('adminLogDeleted', onAdminLogDeleted);
|
||||
eventTarget.removeEventListener('adminLogsCleared', onAdminLogsCleared);
|
||||
eventTarget.removeEventListener('inviteCreated', onInviteCreated);
|
||||
eventTarget.removeEventListener('inviteDeleted', onInviteDeleted);
|
||||
} catch (err) {}
|
||||
};
|
||||
}, [eventTarget, guildId]);
|
||||
|
||||
const handleBack = () => {
|
||||
navigate(-1);
|
||||
navigate('/dashboard');
|
||||
};
|
||||
|
||||
const handleToggleLive = async (e) => {
|
||||
@@ -229,10 +327,6 @@ const ServerSettings = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseSnackbar = () => {
|
||||
setSnackbarOpen(false);
|
||||
};
|
||||
|
||||
const handleAutoroleSettingUpdate = (newSettings) => {
|
||||
axios.post(`${API_BASE}/api/servers/${guildId}/autorole-settings`, newSettings)
|
||||
.then(response => {
|
||||
@@ -330,6 +424,100 @@ const ServerSettings = () => {
|
||||
setDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleAdminLogsSettingChange = async (key, value) => {
|
||||
const newSettings = { ...adminLogsSettings, [key]: value };
|
||||
setAdminLogsSettings(newSettings);
|
||||
|
||||
try {
|
||||
await axios.post(`${API_BASE}/api/servers/${guildId}/admin-logs-settings`, newSettings);
|
||||
} catch (error) {
|
||||
setSnackbarMessage('Failed to update admin logs settings.');
|
||||
setSnackbarOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdminLogsCommandChange = async (command, enabled) => {
|
||||
const newSettings = {
|
||||
...adminLogsSettings,
|
||||
commands: { ...adminLogsSettings.commands, [command]: enabled }
|
||||
};
|
||||
setAdminLogsSettings(newSettings);
|
||||
|
||||
try {
|
||||
await axios.post(`${API_BASE}/api/servers/${guildId}/admin-logs-settings`, newSettings);
|
||||
} catch (error) {
|
||||
setSnackbarMessage('Failed to update admin logs settings.');
|
||||
setSnackbarOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteLog = async (logId) => {
|
||||
try {
|
||||
await axios.delete(`${API_BASE}/api/servers/${guildId}/admin-logs/${logId}`);
|
||||
setAdminLogs(prev => prev.filter(log => log.id !== logId));
|
||||
setSnackbarMessage('Log deleted successfully.');
|
||||
setSnackbarOpen(true);
|
||||
} catch (error) {
|
||||
setSnackbarMessage('Failed to delete log.');
|
||||
setSnackbarOpen(true);
|
||||
}
|
||||
setDeleteLogDialog({ open: false, logId: null, logAction: '' });
|
||||
};
|
||||
|
||||
const handleDeleteAllLogs = async () => {
|
||||
try {
|
||||
await axios.delete(`${API_BASE}/api/servers/${guildId}/admin-logs`);
|
||||
setAdminLogs([]);
|
||||
setSnackbarMessage('All logs deleted successfully.');
|
||||
setSnackbarOpen(true);
|
||||
} catch (error) {
|
||||
setSnackbarMessage('Failed to delete all logs.');
|
||||
setSnackbarOpen(true);
|
||||
}
|
||||
setDeleteAllLogsDialog(false);
|
||||
};
|
||||
|
||||
const handleModerationAction = async (action) => {
|
||||
// Validate reason has at least 3 words
|
||||
const reasonWords = moderationReason.trim().split(/\s+/);
|
||||
if (reasonWords.length < 3) {
|
||||
setSnackbarMessage('Reason must be at least 3 words long.');
|
||||
setSnackbarOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
action,
|
||||
target: moderationTarget.trim(),
|
||||
reason: moderationReason.trim(),
|
||||
moderator: {
|
||||
id: user?.id,
|
||||
username: user?.username,
|
||||
global_name: user?.global_name,
|
||||
discriminator: user?.discriminator
|
||||
}
|
||||
};
|
||||
|
||||
if (action === 'timeout') {
|
||||
payload.duration = parseInt(timeoutDuration);
|
||||
}
|
||||
|
||||
await axios.post(`${API_BASE}/api/servers/${guildId}/moderate`, payload);
|
||||
|
||||
setSnackbarMessage(`${action.charAt(0).toUpperCase() + action.slice(1)} action completed successfully!`);
|
||||
setSnackbarOpen(true);
|
||||
setModerationTarget('');
|
||||
setModerationReason('');
|
||||
if (action === 'timeout') {
|
||||
setTimeoutDuration('');
|
||||
}
|
||||
} catch (error) {
|
||||
setSnackbarMessage(`Failed to ${action} user.`);
|
||||
setSnackbarOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
// Poll Twitch live status for watched users (simple interval). Avoid spamming when list empty or feature disabled.
|
||||
useEffect(() => {
|
||||
let timer = null;
|
||||
@@ -368,7 +556,7 @@ const ServerSettings = () => {
|
||||
const login = (s.user_login || '').toLowerCase();
|
||||
map[login] = { is_live: s.is_live, url: s.url, viewer_count: s.viewer_count };
|
||||
}
|
||||
setKickStatus(map);
|
||||
// kickStatus not used since Kick functionality is disabled
|
||||
} catch (e) {
|
||||
// network errors ignored
|
||||
}
|
||||
@@ -379,13 +567,31 @@ const ServerSettings = () => {
|
||||
}, [kickUsers]);
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px' }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Box sx={{ padding: { xs: 2, sm: 3, md: 5 } }}>
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
gap: 1,
|
||||
mb: 2
|
||||
}}>
|
||||
<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 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: { xs: 1, sm: 2 }, flexWrap: 'wrap' }}>
|
||||
<Typography
|
||||
variant={{ xs: 'h5', sm: 'h4' }}
|
||||
component="h1"
|
||||
sx={{
|
||||
margin: 0,
|
||||
textAlign: { xs: 'center', sm: 'left' },
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis'
|
||||
}}
|
||||
>
|
||||
{server ? `Server Settings for ${server.name}` : 'Loading...'}
|
||||
</Typography>
|
||||
{isBotInServer ? (
|
||||
@@ -418,7 +624,8 @@ const ServerSettings = () => {
|
||||
{(() => {
|
||||
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'}));
|
||||
const adminCommands = ['kick', 'ban', 'timeout'];
|
||||
const otherCmds = (commandsList || []).filter(c => !protectedOrder.includes(c.name) && !adminCommands.includes(c.name)).sort((a,b) => a.name.localeCompare(b.name, undefined, {sensitivity: 'base'}));
|
||||
return (
|
||||
<>
|
||||
{protectedCmds.map(cmd => (
|
||||
@@ -468,6 +675,10 @@ const ServerSettings = () => {
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
{/* Reaction Roles Accordion */}
|
||||
<Box sx={{ marginTop: '20px' }}>
|
||||
<ReactionRoles guildId={guildId} channels={channels} roles={roles} />
|
||||
</Box>
|
||||
{/* Live Notifications dialog */}
|
||||
{/* header live dialog removed; Live Notifications is managed in its own accordion below */}
|
||||
{/* Invite creation and list */}
|
||||
@@ -483,7 +694,9 @@ const ServerSettings = () => {
|
||||
<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>))}
|
||||
{channels.map((channel) => (
|
||||
<MenuItem key={channel.id} value={channel.id}>{channel.name}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
@@ -592,7 +805,7 @@ const ServerSettings = () => {
|
||||
displayEmpty
|
||||
>
|
||||
<MenuItem value="" disabled>Select a channel</MenuItem>
|
||||
{channels.map(channel => (
|
||||
{channels.map((channel) => (
|
||||
<MenuItem key={channel.id} value={channel.id}>{channel.name}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
@@ -633,7 +846,7 @@ const ServerSettings = () => {
|
||||
displayEmpty
|
||||
>
|
||||
<MenuItem value="" disabled>Select a channel</MenuItem>
|
||||
{channels.map(channel => (
|
||||
{channels.map((channel) => (
|
||||
<MenuItem key={channel.id} value={channel.id}>{channel.name}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
@@ -664,7 +877,7 @@ const ServerSettings = () => {
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
{/* Live Notifications Accordion */}
|
||||
<Accordion expanded={liveExpanded} onChange={() => setLiveExpanded(prev => !prev)} sx={{ marginTop: '20px' }}>
|
||||
<Accordion expanded={liveExpanded} onChange={() => setLiveExpanded(prev => !prev)} sx={{ marginTop: '20px', opacity: isBotInServer ? 1 : 0.5 }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="h6">Live Notifications</Typography>
|
||||
</AccordionSummary>
|
||||
@@ -834,7 +1047,284 @@ const ServerSettings = () => {
|
||||
<Typography variant="h6">Admin Commands</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Typography>Coming soon...</Typography>
|
||||
{!isBotInServer && <Typography sx={{ mb: 2 }}>Invite the bot to enable admin commands.</Typography>}
|
||||
<Accordion sx={{ marginTop: '10px' }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="subtitle1">Moderation Commands</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: 1, border: '1px solid #eee', borderRadius: 1 }}>
|
||||
<Box>
|
||||
<Typography sx={{ fontWeight: 'bold' }}>/kick</Typography>
|
||||
<Typography variant="body2">Kick a user from the server</Typography>
|
||||
<Typography variant="caption" color="text.secondary">Requires: Kick Members permission</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<FormControlLabel control={<Switch checked={true} disabled />} label="Enabled" />
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: 1, border: '1px solid #eee', borderRadius: 1 }}>
|
||||
<Box>
|
||||
<Typography sx={{ fontWeight: 'bold' }}>/ban</Typography>
|
||||
<Typography variant="body2">Ban a user from the server</Typography>
|
||||
<Typography variant="caption" color="text.secondary">Requires: Ban Members permission</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<FormControlLabel control={<Switch checked={true} disabled />} label="Enabled" />
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: 1, border: '1px solid #eee', borderRadius: 1 }}>
|
||||
<Box>
|
||||
<Typography sx={{ fontWeight: 'bold' }}>/timeout</Typography>
|
||||
<Typography variant="body2">Timeout a user in the server</Typography>
|
||||
<Typography variant="caption" color="text.secondary">Requires: Moderate Members permission</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<FormControlLabel control={<Switch checked={true} disabled />} label="Enabled" />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
<Accordion sx={{ marginTop: '20px', opacity: isBotInServer ? 1 : 0.5 }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="h6">Admin Logs</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
{!isBotInServer && <Typography sx={{ mb: 2 }}>Invite the bot to enable admin logs.</Typography>}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={adminLogsSettings?.enabled || false}
|
||||
onChange={(e) => handleAdminLogsSettingChange('enabled', e.target.checked)}
|
||||
disabled={!isBotInServer}
|
||||
/>
|
||||
}
|
||||
label="Enable Admin Logging"
|
||||
/>
|
||||
|
||||
<FormControl fullWidth sx={{ mt: 2 }}>
|
||||
<InputLabel>Log Channel</InputLabel>
|
||||
<Select
|
||||
value={selectedChannelId}
|
||||
onChange={(e) => {
|
||||
setSelectedChannelId(e.target.value);
|
||||
handleAdminLogsSettingChange('channelId', e.target.value);
|
||||
}}
|
||||
disabled={!isBotInServer || !adminLogsSettings?.enabled}
|
||||
label="Log Channel"
|
||||
>
|
||||
{channels.filter(channel => channel.type === 0).map(channel => (
|
||||
<MenuItem key={channel.id} value={channel.id}>
|
||||
# {channel.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<Typography variant="subtitle1" sx={{ mt: 2, mb: 1 }}>Log Commands:</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, pl: 2 }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={adminLogsSettings?.commands?.kick !== false}
|
||||
onChange={(e) => handleAdminLogsCommandChange('kick', e.target.checked)}
|
||||
disabled={!isBotInServer || !adminLogsSettings?.enabled}
|
||||
/>
|
||||
}
|
||||
label="Log Kick Actions"
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={adminLogsSettings?.commands?.ban !== false}
|
||||
onChange={(e) => handleAdminLogsCommandChange('ban', e.target.checked)}
|
||||
disabled={!isBotInServer || !adminLogsSettings?.enabled}
|
||||
/>
|
||||
}
|
||||
label="Log Ban Actions"
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={adminLogsSettings?.commands?.timeout !== false}
|
||||
onChange={(e) => handleAdminLogsCommandChange('timeout', e.target.checked)}
|
||||
disabled={!isBotInServer || !adminLogsSettings?.enabled}
|
||||
/>
|
||||
}
|
||||
label="Log Timeout Actions"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mt: 2, mb: 1 }}>
|
||||
<Typography variant="subtitle1">Recent Logs:</Typography>
|
||||
{adminLogs.length > 0 && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
size="small"
|
||||
onClick={() => setDeleteAllLogsDialog(true)}
|
||||
startIcon={<DeleteIcon />}
|
||||
>
|
||||
Delete All Logs
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ maxHeight: 300, overflowY: 'auto', border: '1px solid #ddd', borderRadius: 1, p: 1 }}>
|
||||
{adminLogs.length === 0 ? (
|
||||
<Typography>No logs available.</Typography>
|
||||
) : (
|
||||
<React.Fragment>
|
||||
{adminLogs.map((log) => (
|
||||
<Box key={log.id} sx={{ p: 1, border: '1px solid #eee', mb: 1, borderRadius: 1, bgcolor: 'background.paper' }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>
|
||||
{log.action.toUpperCase()} - {log.targetUsername || 'Unknown User'} by {log.moderatorUsername || 'Unknown User'}
|
||||
</Typography>
|
||||
<Typography variant="body2">Reason: {log.reason}</Typography>
|
||||
{log.duration && <Typography variant="body2">Duration: {log.duration}</Typography>}
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{new Date(log.timestamp).toLocaleString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => setDeleteLogDialog({ open: true, logId: log.id, logAction: log.action })}
|
||||
sx={{ ml: 1 }}
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</React.Fragment>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
<Accordion sx={{ marginTop: '20px', opacity: isBotInServer ? 1 : 0.5 }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="h6">Moderation Actions</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
{!isBotInServer && <Typography sx={{ mb: 2 }}>Invite the bot to enable moderation actions.</Typography>}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Perform moderation actions directly from the web interface. You must have the appropriate Discord permissions.
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Autocomplete
|
||||
options={serverMembers}
|
||||
getOptionLabel={(option) => {
|
||||
if (typeof option === 'string') return option;
|
||||
return option.globalName || option.username || option.displayName || option.id;
|
||||
}}
|
||||
value={serverMembers.find(member => member.id === moderationTarget) || null}
|
||||
onChange={(event, newValue) => {
|
||||
if (newValue) {
|
||||
setModerationTarget(newValue.id);
|
||||
} else {
|
||||
setModerationTarget('');
|
||||
}
|
||||
}}
|
||||
onInputChange={(event, newInputValue) => {
|
||||
// Allow manual input of user IDs
|
||||
if (event && event.type === 'change') {
|
||||
setModerationTarget(newInputValue);
|
||||
}
|
||||
}}
|
||||
inputValue={moderationTarget}
|
||||
freeSolo
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="User"
|
||||
placeholder="Select user or enter user ID"
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
renderOption={(props, option) => (
|
||||
<Box component="li" {...props}>
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>
|
||||
{option.globalName || option.username}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
@{option.username} • ID: {option.id}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
filterOptions={(options, { inputValue }) => {
|
||||
if (!inputValue) return options.slice(0, 50); // Limit to 50 for performance
|
||||
|
||||
const filtered = options.filter(option =>
|
||||
option.username.toLowerCase().includes(inputValue.toLowerCase()) ||
|
||||
(option.globalName && option.globalName.toLowerCase().includes(inputValue.toLowerCase())) ||
|
||||
option.displayName.toLowerCase().includes(inputValue.toLowerCase()) ||
|
||||
option.id.includes(inputValue)
|
||||
);
|
||||
|
||||
return filtered.slice(0, 50); // Limit results
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Reason (minimum 3 words)"
|
||||
value={moderationReason}
|
||||
onChange={(e) => setModerationReason(e.target.value)}
|
||||
placeholder="Enter reason for moderation action"
|
||||
fullWidth
|
||||
multiline
|
||||
rows={2}
|
||||
/>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="warning"
|
||||
onClick={() => handleModerationAction('kick')}
|
||||
disabled={!isBotInServer || !moderationTarget.trim() || !moderationReason.trim()}
|
||||
>
|
||||
Kick User
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
onClick={() => handleModerationAction('ban')}
|
||||
disabled={!isBotInServer || !moderationTarget.trim() || !moderationReason.trim()}
|
||||
>
|
||||
Ban User
|
||||
</Button>
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||
<TextField
|
||||
label="Duration (minutes)"
|
||||
type="number"
|
||||
value={timeoutDuration}
|
||||
onChange={(e) => setTimeoutDuration(e.target.value)}
|
||||
sx={{ width: 150 }}
|
||||
inputProps={{ min: 1, max: 40320 }}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
onClick={() => handleModerationAction('timeout')}
|
||||
disabled={!isBotInServer || !moderationTarget.trim() || !moderationReason.trim() || !timeoutDuration}
|
||||
>
|
||||
Timeout User
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
<ConfirmDialog
|
||||
@@ -936,7 +1426,28 @@ const ServerSettings = () => {
|
||||
title="Delete Kick User"
|
||||
message={`Are you sure you want to remove ${pendingKickUser || ''} from the watch list?`}
|
||||
/>
|
||||
</div>
|
||||
{/* Confirm dialog for deleting individual admin log */}
|
||||
<ConfirmDialog
|
||||
open={deleteLogDialog.open}
|
||||
onClose={() => setDeleteLogDialog({ open: false, logId: null, logAction: '' })}
|
||||
onConfirm={() => handleDeleteLog(deleteLogDialog.logId)}
|
||||
title="Delete Admin Log"
|
||||
message={`Are you sure you want to delete this ${deleteLogDialog.logAction} log? This action cannot be undone.`}
|
||||
/>
|
||||
{/* Confirm dialog for deleting all admin logs */}
|
||||
<ConfirmDialog
|
||||
open={deleteAllLogsDialog}
|
||||
onClose={() => setDeleteAllLogsDialog(false)}
|
||||
onConfirm={handleDeleteAllLogs}
|
||||
title="Delete All Admin Logs"
|
||||
message="Are you sure you want to delete all admin logs? This action cannot be undone."
|
||||
/>
|
||||
<Snackbar open={snackbarOpen} autoHideDuration={6000} onClose={() => setSnackbarOpen(false)}>
|
||||
<Alert onClose={() => setSnackbarOpen(false)} severity="info" sx={{ width: '100%' }}>
|
||||
{snackbarMessage}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -60,6 +60,9 @@ export function BackendProvider({ children }) {
|
||||
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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,4 +20,30 @@ 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