Compare commits

..

4 Commits

Author SHA1 Message Date
61ab1e1d9e bug fixes 2025-10-10 18:51:23 -04:00
8236c1e0e7 Fixed Invite Accordion 2025-10-10 05:12:54 -04:00
900ce85e2c Fixed Twitch Live notis 2025-10-09 19:24:02 -04:00
ff10bb3183 Moderation Update 2025-10-09 06:13:48 -04:00
35 changed files with 6885 additions and 444 deletions

407
README.md
View File

@@ -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

View File

@@ -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
View File

@@ -0,0 +1,4 @@
module.exports = {
testEnvironment: 'node',
testTimeout: 20000,
};

3689
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}
}

View File

@@ -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 };

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

View File

@@ -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

View File

@@ -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 };

View File

@@ -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
View File

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

View File

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

View File

@@ -0,0 +1,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 });
}
}
};

View File

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

View File

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

View File

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

View File

@@ -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;

View File

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

View File

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

View File

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

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

View File

@@ -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' },

View File

@@ -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');

View File

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

View File

@@ -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>
);

View File

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

View File

@@ -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 }}>

View File

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

View File

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

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

View File

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

View File

@@ -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

View File

@@ -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;
}
}

View File

@@ -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;

View File

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