Compare commits

...

11 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
2ae7202445 Update backend, DB, Commands, Live Reloading 2025-10-09 02:17:33 -04:00
6a78ec6453 live updates and file organization 2025-10-06 14:47:05 -04:00
ca23c0ab8c swapped to a new db locally hosted 2025-10-06 00:25:29 -04:00
097583ca0a updated tab titles. 2025-10-04 10:59:35 -04:00
54cc4ea697 readme update 2025-10-04 10:41:29 -04:00
053ffe51f7 tweaked ui and updated invite command 2025-10-04 10:27:45 -04:00
834e77a93e fixed themes and ui added new features 2025-10-04 08:39:54 -04:00
67 changed files with 11130 additions and 1048 deletions

51
CHANGELOG.md Normal file
View File

@@ -0,0 +1,51 @@
# Changelog
All notable changes to this project are documented below. This file follows a lightweight "Keep a Changelog" style.
## 2025-10-05 — New features & changes
### Added
- Postgres persistence support for the backend (requires `DATABASE_URL`). Backend will auto-create `servers`, `invites`, and `users` tables on startup.
- `backend/pg.js` helper module to manage Postgres connection and CRUD helpers.
- `backend/.env.example` updated to show `DATABASE_URL` with a Tailscale IP example (100.111.50.59).
- Short-lived invite token flow (HMAC) for delete authorization (default, via `INVITE_TOKEN_SECRET`).
- Frontend invite UI fixes (copy/delete handlers, confirmation dialog) and various UI polish updates.
### Changed
- Backend now requires `DATABASE_URL` by default; no automatic fallback to encrypted `db.json` for persistence.
- `INVITE_API_KEY` static option deprecated in favor of short-lived invite tokens; token issuance endpoint remains unauthenticated (recommendation: restrict in production).
### Fixed
- Async/await handler fixes and small bug fixes across backend endpoints.
### Notes
- Existing encrypted `backend/db.json` is retained but no longer used by the running backend. A migration script to import old data into Postgres is planned but not yet implemented.
### Documentation
- README updated with setup steps, Postgres guidance, migration note, and expanded app description.
### Added
- Live Notifications: per-guild Twitch live notification settings (backend endpoints, frontend UI, and bot command to configure). Stored in server settings under `liveNotifications`.
## 2025-10-06 — Improvements & housekeeping
### Changed
- Backend now enforces `DATABASE_URL` as the primary persistence store. If `DATABASE_URL` is unset the server will refuse to start. This ensures all server settings and invites are stored in Postgres consistently.
- Server-Sent Events (SSE) endpoint `/api/events` added to allow the frontend to receive real-time updates (command toggles, twitch users updates, live-notifications updates) without refresh.
- `discord-bot/twitch-watcher.js` logging reduced to avoid per-guild spam; announcements are concise and errors still reported.
- Command deployment logs are aggregated (single summary line) to avoid flooding logs with per-guild messages.
### Added
- `/internal/test-live` dev endpoint (backend) & `announceLive` bot helper to force live announcements for debugging.
### Fixed
- Frontend `ServerSettings.js` now subscribes to SSE and updates commands, watched users and live settings in real-time when backend publishes events.
- Help command updated to produce a neat Embed and support `/help <command>` for detailed usage; help lists read from backend to remain future-proof.
## [Previous] - 2025-10-04
### Added
- Initial full-stack integration (React frontend, Express backend, Discord bot) and file-based encrypted persistence.
- Dashboard and server settings UI components.
- Invite creation, listing and deletion endpoints and UI.
---

162
README.md
View File

@@ -1 +1,161 @@
# ECS FULL STACK
# ECS Full Stack
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.
## Features
- **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
## Quick Start
### Prerequisites
- Node.js 18+
- PostgreSQL database
- Discord application with bot user
### Setup
1. **Clone and install dependencies:**
```bash
git clone <repository-url>
cd ECS-FullStack
npm install # Run in both frontend/ and backend/ directories
```
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
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;
```
4. **Environment Configuration:**
**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
```
**frontend/.env:**
```env
REACT_APP_API_BASE=http://localhost:3002
```
5. **Start the application:**
```bash
# Backend (includes Discord bot)
cd backend && npm start
# Frontend (separate terminal)
cd frontend && npm start
```
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
## Project Structure
```
ECS-FullStack/
├── frontend/ # React dashboard
├── backend/ # Express API + Discord bot
├── discord-bot/ # Bot wrapper
├── checklist.md # Feature tracking
└── README.md
```
## API Endpoints
### 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
### Invites
- `GET /api/servers/:guildId/invites` - List invites
- `POST /api/servers/:guildId/invites` - Create invite
- `DELETE /api/servers/:guildId/invites/:code` - Delete invite
### Moderation
- `POST /api/servers/:guildId/moderate` - Ban/kick/timeout users
- `GET /api/servers/:guildId/admin-logs` - View moderation logs
### 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
```
### Building for Production
```bash
cd frontend && npm run build
cd backend && npm run build # If applicable
```
## Troubleshooting
### 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
### Logs
- Backend logs Discord bot status and API requests
- Frontend console shows API calls and errors
- Check browser Network tab for failed requests
## Contributing
1. Fork the repository
2. Create feature branch
3. Make changes with tests
4. Submit pull request
## License
MIT License - see LICENSE file for details.
---
**Updated**: October 9, 2025

82
backend/.env.example Normal file
View File

@@ -0,0 +1,82 @@
# Example backend/.env for ECS-FullStack
# Copy this file to backend/.env and fill values before running the backend
# Postgres connection (required)
# Example formats:
# postgres://user:password@host:5432/dbname
# postgresql://user:password@localhost:5432/dbname
DATABASE_URL=postgres://postgres:password@127.0.0.1:5432/ecs_fullstack
# Discord OAuth / Bot
DISCORD_CLIENT_ID=your_discord_client_id
DISCORD_CLIENT_SECRET=your_discord_client_secret
DISCORD_BOT_TOKEN=your_discord_bot_token
# Frontend base URL (where the frontend is served). Used for OAuth redirect and dashboard links.
FRONTEND_BASE=https://discordbot.YOURDOMAIN.com
# Host/port to bind the backend server (bind to 0.0.0.0 or your Tailscale IP as needed)
HOST=0.0.0.0
PORT=3002
# CORS origin - set to your frontend origin for tighter security (or '*' to allow all)
# Example: https://discordbot.YOURDOMAIN.com
CORS_ORIGIN=http://127.0.0.1:3001
# Twitch API (for the watcher and proxy)
TWITCH_CLIENT_ID=your_twitch_client_id
TWITCH_CLIENT_SECRET=your_twitch_client_secret
TWITCH_POLL_INTERVAL_MS=5000
# Optional bot push receiver settings - allows backend to notify a remote bot process
# BOT_PUSH_PORT if you run the bot on another host and want the backend to push settings
BOT_PUSH_PORT=
BOT_PUSH_URL=
BOT_SECRET=
# Optional logging level: debug | info | warn | error
LOG_LEVEL=info
# Example backend .env
# Set the host/interface to bind to (for Tailscale use your 100.x.y.z address)
HOST=0.0.0.0
PORT=3002
# Optional: fully-qualified base URLs
BACKEND_BASE=http://100.x.y.z:3002
FRONTEND_BASE=http://100.x.y.z:3000
# CORS origin (frontend origin) - set to frontend base for tighter security
CORS_ORIGIN=http://100.x.y.z:3000
# Postgres connection (replace user, password, host, port, and database name)
# Example for your Tailscale IP 100.111.50.59:
DATABASE_URL=postgres://dbuser:dbpass@100.111.50.59:5432/ecs_db
# Invite token secret (short-lived HMAC tokens for invite delete protection)
INVITE_TOKEN_SECRET=replace-with-a-long-random-secret
# Discord credentials
DISCORD_CLIENT_ID=your_client_id
DISCORD_CLIENT_SECRET=your_client_secret
DISCORD_BOT_TOKEN=your_bot_token
# Encryption key for backend db.json (only needed if you plan to decrypt/migrate old data)
ENCRYPTION_KEY=pick-a-long-random-string
# --- Twitch API (optional; required for Live Notifications)
# Register an application at https://dev.twitch.tv to obtain these
TWITCH_CLIENT_ID=your_twitch_client_id
TWITCH_CLIENT_SECRET=your_twitch_client_secret
# Poll interval in milliseconds for the bot watcher (default = 30000 = 30s)
TWITCH_POLL_INTERVAL_MS=30000
# --- Bot push (optional) - used when backend and bot run on different hosts
# If the bot runs on a separate host/process, set BOT_PUSH_URL to the public
# URL the bot exposes for receiving settings pushes (backend will POST there)
# Example: BOT_PUSH_URL=http://bot-host:4002
BOT_PUSH_URL=
# Shared secret used to secure backend -> bot pushes. Must match BOT_SECRET in the bot env.
BOT_SECRET=replace-with-a-long-random-secret
# When BOT_PUSH_PORT is set, the bot starts a small HTTP endpoint to accept pushes
# (only needed if bot runs separately and you want immediate pushes).
BOT_PUSH_PORT=4002

File diff suppressed because it is too large Load Diff

4
backend/jest.config.js Normal file
View File

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

3891
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"
},
@@ -16,9 +16,14 @@
"cors": "^2.8.5",
"crypto-js": "^4.2.0",
"dotenv": "^16.4.5",
"express": "^4.19.2"
"express": "^4.19.2",
"pg": "^8.11.0",
"pg-format": "^1.0.4",
"node-fetch": "^2.6.7"
},
"devDependencies": {
"nodemon": "^3.1.3"
"nodemon": "^3.1.3",
"jest": "^29.6.1",
"supertest": "^6.3.3"
}
}

272
backend/pg.js Normal file
View File

@@ -0,0 +1,272 @@
const { Pool } = require('pg');
const format = require('pg-format');
let pool;
function initPool() {
if (pool) return pool;
const connectionString = process.env.DATABASE_URL;
if (!connectionString) throw new Error('DATABASE_URL is not set');
pool = new Pool({ connectionString });
return pool;
}
async function ensureSchema() {
const p = initPool();
// basic tables: servers (settings), invites
await p.query(`
CREATE TABLE IF NOT EXISTS servers (
guild_id TEXT PRIMARY KEY,
settings JSONB DEFAULT '{}'
);
`);
await p.query(`
CREATE TABLE IF NOT EXISTS invites (
code TEXT PRIMARY KEY,
guild_id TEXT NOT NULL,
url TEXT,
channel_id TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
max_uses INTEGER DEFAULT 0,
max_age INTEGER DEFAULT 0,
temporary BOOLEAN DEFAULT false
);
`);
await p.query(`
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
discord_id TEXT UNIQUE,
data JSONB DEFAULT '{}'
);
`);
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
async function getServerSettings(guildId) {
const p = initPool();
const res = await p.query('SELECT settings FROM servers WHERE guild_id = $1', [guildId]);
if (res.rowCount === 0) return null;
return res.rows[0].settings || {};
}
async function upsertServerSettings(guildId, settings) {
const p = initPool();
await p.query(`INSERT INTO servers(guild_id, settings) VALUES($1, $2)
ON CONFLICT (guild_id) DO UPDATE SET settings = $2`, [guildId, settings]);
}
// Invites
async function listInvites(guildId) {
const p = initPool();
const res = await p.query('SELECT code, url, channel_id, created_at, max_uses, max_age, temporary FROM invites WHERE guild_id = $1 ORDER BY created_at DESC', [guildId]);
return res.rows;
}
async function addInvite(inv) {
const p = initPool();
const q = `INSERT INTO invites(code, guild_id, url, channel_id, created_at, max_uses, max_age, temporary) VALUES($1,$2,$3,$4,$5,$6,$7,$8)
ON CONFLICT (code) DO UPDATE SET url = EXCLUDED.url, channel_id = EXCLUDED.channel_id, max_uses = EXCLUDED.max_uses, max_age = EXCLUDED.max_age, temporary = EXCLUDED.temporary, created_at = EXCLUDED.created_at`;
await p.query(q, [inv.code, inv.guildId, inv.url, inv.channelId, inv.createdAt ? new Date(inv.createdAt) : new Date(), inv.maxUses || 0, inv.maxAge || 0, inv.temporary || false]);
}
async function deleteInvite(guildId, code) {
const p = initPool();
await p.query('DELETE FROM invites WHERE guild_id = $1 AND code = $2', [guildId, code]);
}
// 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();
const res = await p.query('SELECT data FROM users WHERE discord_id = $1', [discordId]);
if (res.rowCount === 0) return null;
return res.rows[0].data || {};
}
async function upsertUserData(discordId, data) {
const p = initPool();
await p.query(`INSERT INTO users(discord_id, data) VALUES($1, $2) ON CONFLICT (discord_id) DO UPDATE SET data = $2`, [discordId, data]);
}
module.exports = { initPool, ensureSchema, getServerSettings, upsertServerSettings, listInvites, addInvite, deleteInvite, getUserData, upsertUserData, addAdminLog, getAdminLogs, getAdminLogsByAction, deleteAdminLog, deleteAllAdminLogs, listReactionRoles, getReactionRole, createReactionRole, updateReactionRole, deleteReactionRole };

View File

@@ -0,0 +1,12 @@
const { Pool } = require('pg');
(async function(){
try{
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const res = await pool.query('SELECT guild_id FROM servers LIMIT 10');
console.log('servers:', JSON.stringify(res.rows, null, 2));
await pool.end();
}catch(err){
console.error('ERR', err && err.message ? err.message : err);
process.exit(1);
}
})();

View File

@@ -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,136 +1,184 @@
# Project Checklist
# Project Checklist (tidy & current)
## Backend
- [x] Create backend directory
- [x] Initialize Node.js project
- [x] Install backend dependencies
- [x] Create a basic Express server
- [x] Set up Discord OAuth2
- [x] Create API endpoint to get user's servers
- [x] Store user theme preference on the backend
- [x] Create API endpoint to make bot leave a server
- [x] Encrypt user information in `db.json`.
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.
## Frontend
- [x] Create login page
- [x] Create dashboard page
- [x] Connect frontend to backend
- [x] Improve frontend UI with a component library
- [x] Implement server-specific settings pages
- [x] Persist user data on the frontend
- [x] Add logout functionality
- [x] Add more styling and animations to the UI
- [x] Remember scroll position
- [x] Fix issue with fetching server names
- [x] Implement theme switching (light, dark, Discord grey)
- [x] Create a user settings menu
- [x] Set Discord grey as the default theme
- [x] Refine user settings menu UI
- [x] Further refine user settings menu UI
- [x] Add server icons to dashboard cards
- [x] Ensure responsive and uniform card sizing
- [x] Fine-tune card sizing
- [x] Further fine-tune card UI
- [x] Add Commands section to server settings page
- [x] Refine 'Back to Dashboard' button
- [x] Remove 'Open Help Page' button from individual command controls (moved to dedicated Commands List page)
- [x] Rename Help nav/button to 'Commands List' and update page title
- [x] Restructure Commands list UI to show per-command toggles and cleaner layout
- [x] Ensure frontend persists selections and doesn't overwrite other settings
- [x] Improve NavBar layout and styling for clarity and compactness
- [x] Implement single-hamburger NavBar (hamburger toggles to X; buttons hidden when collapsed)
- [x] Commands List button added above Commands accordion in Server Settings
- [x] Place 'Invite' button beside the server title on dashboard/server cards
- Acceptance criteria: the invite button appears horizontally adjacent to the server title (to the right), remains visible and usable on tablet and desktop layouts, is keyboard-focusable, and has an accessible aria-label (e.g. "Invite bot to SERVER_NAME").
- [x] Show the server name in a rounded "bubble" and render it bold
- Acceptance criteria: server name is inside a rounded container (padding + border-radius), the text is bold, background provides sufficient contrast, and the bubble adapts to long names (truncation or wrapping) to avoid layout breakage.
- [x] Update Dashboard component to use bubble title + invite-button layout
- Acceptance criteria: visual matches design spec above; small-screen fallback stacks invite button under the title or shows a compact icon; no regressions to other card elements.
- [x] Add pre-invite check to dashboard invite button
- Acceptance criteria: Clicking invite button checks if bot is already in the server. If so, show a dismissible message (e.g., a snackbar or modal) saying "Bot is already in this server." If not, proceed with the invite.
- [x] Center invite button on Server Settings page
- Acceptance criteria: On the server-specific settings page, the "Invite Bot" button (or the "Bot is already in this server" text) should be vertically and horizontally aligned with the main server name title for a cleaner look.
- [x] Fix incorrect invite link on dashboard cards
- Acceptance criteria: The invite link generated for the dashboard cards should correctly redirect to the Discord OAuth2 authorization page with the proper client ID, permissions, and scope, matching the functionality of the invite button in the server settings panel.
- [x] ~~by default hide or gray out bot options in each server dashboard panel if the bot is not in the server. Only allow to edit the features if the bot is in the discord server.~~ (User changed their mind)
- [x] Allow dashboard cards to be clickable even if the bot is not in the server. Inside the server settings, all commands and categories should be greyed out and not touchable. Only allow the invite button to be clicked within.
- [x] Add a button to the server cards that allows the user to make the bot leave the server. The button should only be visible if the bot is in the server.
- [x] In the server settings, replace the text "The bot is already in this server" with a "Leave" button.
- [x] Add a confirmation dialog to the "Leave" buttons on both the dashboard cards and the server settings page.
- [x] Redesign the login page to be more bubbly, centered, and eye-catching, with bigger text and responsive design.
- [x] Make server settings panels collapsible for a cleaner mobile UI.
## 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
## Discord Bot
- [x] Create a basic Discord bot
- [x] Add a feature with both slash and web commands
- [x] Implement bot invitation functionality
- [x] Reorganize bot file structure
- [x] Implement command handler
- [x] Implement event handler
- [x] Set bot status on ready event
- [x] Automatically register slash commands on server join.
- [x] On startup, automatically register all slash commands and remove any obsolete commands.
- [x] Add a mechanism to enable or disable commands from being registered and displayed.
- [x] In `ready.js`, set the bot's activity to change every 3 seconds with the following streaming activities: "Watch EhChad Live!", "Follow EhChad!", "/help", and "EhChadServices", all pointing to `https://twitch.tv/ehchad`.
## 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 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
## Features
- [x] **Welcome/Leave Messages**
- [x] Add "Welcome/Leave" section to server settings.
- [x] **Welcome Messages:**
- [x] Add toggle to enable/disable welcome messages.
- [x] Add dropdown to select a channel for welcome messages.
- [x] Add 3 default welcome message options.
- [x] Add a custom welcome message option with a text input and apply button.
- [x] **Leave Messages:**
- [x] Add toggle to enable/disable leave messages.
- [x] Add dropdown to select a channel for leave messages.
- [x] Add 3 default leave message options.
- [x] Add a custom leave message option with a text input and apply button.
- [x] **Bot Integration:**
- [x] Connect frontend settings to the backend.
- [x] Implement bot logic to send welcome/leave messages based on server settings.
- [x] Fix: Leave messages now fetch channel reliably, ensure bot has permissions (ViewChannel/SendMessages), and use mention-friendly user formatting. Added debug logging.
- [x] Fix: Removed verbose console logging of incoming settings and messages in backend and bot (no sensitive or noisy payloads logged).
- [x] **Slash Command Integration:**
- [x] ~~Create a `/config-welcome` slash command.~~
- [x] ~~Add a subcommand to `set-channel` for welcome messages.~~
- [x] ~~Add a subcommand to `set-message` with options for default and custom messages.~~
- [x] ~~Add a subcommand to `disable` welcome messages.~~
- [x] ~~Create a `/config-leave` slash command.~~
- [x] ~~Add a subcommand to `set-channel` for leave messages.~~
- [x] ~~Add a subcommand to `set-message` with options for default and custom messages.~~
- [x] ~~Add a subcommand to `disable` leave messages.~~
- [x] ~~Create a `/view-config` slash command to display the current welcome and leave channels.~~
- [x] Refactor `/config-welcome` to `/setup-welcome` with interactive setup for channel and message.
- [x] Refactor `/config-leave` to `/setup-leave` with interactive setup for channel and message.
- [x] Rename `/view-config` to `/view-welcome-leave`.
- [x] Ensure settings updated via slash commands are reflected on the frontend.
- [x] Ensure settings updated via the frontend are reflected in the bot's behavior.
- [x] **New:** Interactive setup should prompt for channel, then for message (default or custom, matching frontend options).
- [x] Persist the selected message option (default or custom) for welcome and leave messages.
- [x] Added `/view-autorole` slash command to report autorole status and selected role.
- [x] Added `/manage-commands` admin slash command to list and toggle commands per-server (persists toggles to backend DB).
- [x] Refactor: `/manage-commands` now renders a single message with toggle buttons reflecting each command's current state and updates in-place.
- [x] Ensure `/manage-commands` lists all loaded commands (including non-slash/simple commands like `ping`) and will include future commands automatically.
- [x] Ensure the `/help` command is locked (protected) and cannot be disabled via `/manage-commands`.
- [x] Add a Help tab in the frontend Server Settings that lists all bot commands and their descriptions per-server.
- [x] Move Help to a dedicated page within the server dashboard and add a top NavBar (collapsible) with Dashboard, Discord!, Contact, and Help (when on a server) buttons. Ensure Help page has a back arrow to return to the Commands section.
- [x] Move Help to a dedicated page within the server dashboard and add a top NavBar (collapsible) with Dashboard, Discord!, and Commands List (when on a server) buttons. Ensure Help page has a back arrow to return to the Commands section.
- [x] Remove Contact page from the frontend and App routes (no longer needed).
- [x] Redesign NavBar for cleaner layout and prettier appearance.
- [x] Redesign NavBar for cleaner layout and prettier appearance. (Title updated to 'ECS - EHDCHADSWORTH')
- [x] Added `/help` slash command that lists commands and their descriptions and shows per-server enable/disable status.
- [x] **Autorole**
- [x] Add "Autorole" section to server settings.
- [x] **Backend:**
- [x] Create API endpoint to get/set autorole settings.
- [x] Create API endpoint to fetch server roles.
- [x] **Bot Integration:**
- [x] Create a `/setup-autorole` slash command to enable/disable and select a role.
- [x] Update `guildMemberAdd` event to assign the selected role on join.
- [x] **Frontend:**
- [x] Add toggle to enable/disable autorole.
- [x] Add dropdown to select a role for autorole.
- [x] Ensure settings updated via slash commands are reflected on the frontend.
- [x] Ensure settings updated via the frontend are reflected in the bot's behavior.
- [x] Fix: Autorole dropdown excludes @everyone and shows only roles the bot can manage. Assignment is validated at join.
## Discord Bot
- [x] discord.js integration (events and commands)
- [x] Slash commands: `/create-invite`, `/list-invites`, `/manage-commands`, `/help`
- [x] Bot used by backend to fetch live guild data and manage invites
- [x] Bot reads/writes per-guild command toggles via backend/Postgres
- [x] Backend immediately notifies bot of toggle changes (pushes updated settings to bot cache) so frontend toggles take effect instantly
- [x] New slash command: `/setup-live` to enable/disable Twitch live notifications for the server (preserves other settings)
- [x] Frontend: Confirm dialog and working Delete action for Twitch watched users in Live Notifications
- [x] Live Notifications: bot posts message to configured channel with stream title and link when a watched Twitch user goes live
- [x] Live Notifications: bot posts rich embed to channel when a watched Twitch user goes live (thumbnail, clickable title, bio/description, category/game, viewers, footer with "ehchadservices" and start datetime)
- [x] Live Notifications polling frequency set to 5 seconds (configurable via `TWITCH_POLL_INTERVAL_MS`)
- [x] On bot restart, sends messages for currently live watched users; then sends for new streams once per session
- [x] Twitch Watcher Debug Logging: comprehensive debug mode added (enable with `TWITCH_WATCHER_DEBUG=true`) to track guild checks, settings retrieval, stream fetching, channel permissions, and message sending for troubleshooting live notification issues
- [x] Twitch API Functions Export Fix: added missing `tryFetchTwitchStreams` and `_rawGetTwitchStreams` to api.js module exports to resolve "is not a function" errors
- [x] Twitch Streams Array Safety: added `Array.isArray()` checks in twitch-watcher.js to prevent "filter is not a function" errors when API returns unexpected data types
- [x] Twitch Commands Postgres Integration: updated all Discord bot Twitch commands (`/add-twitchuser`, `/remove-twitchuser`) to use api.js functions for consistent Postgres backend communication
- [x] Twitch Message Template Variables: added support for `{user}`, `{title}`, `{category}`, and `{viewers}` template variables in custom live notification messages for dynamic content insertion
- [x] Frontend JSX Syntax Fix: fixed React Fragment wrapping for admin logs map to resolve build compilation errors
- [x] Frontend: show "Watch Live" button next to watched user when they are live (links to Twitch)
- [x] Bi-directional sync: backend POST/DELETE for twitch-users now also pushes new settings to bot process (when `BOT_PUSH_URL` configured)
- [x] Bot adds/removes users via backend endpoints ensuring single source of truth (Postgres)
- [x] Live notifications toggle on site enables/disables watching and publishes SSE for real-time updates
- [x] /manage-commands command has enable/disable buttons that sync with frontend via backend API and SSE for live updating
- [x] All Twitch-related commands (add, remove, list) and frontend actions communicate with backend and Postgres database
- [x] Welcome/Leave messages: bot sends configured messages to channels when users join/leave
- [x] Welcome messages with {user} and {server} placeholders
- [x] Leave messages with {user} placeholder
- [x] Autorole assignment on member join
- [x] All settings managed through Server Settings UI
- [x] Event handlers properly integrated with API settings
- [x] Kick live notifications bot integration (temporarily disabled)
- [x] New slash commands: `/add-kickuser`, `/remove-kickuser`, `/list-kickusers` (commands exist but watcher disabled)
- [x] Kick API polling and notification posting (watcher removed, API endpoints remain)
- [x] Per-server Kick user management via backend API (endpoints functional)
- [x] Frontend tabs: separate Twitch and Kick tabs in Live Notifications accordion (Kick tab disabled)
- [x] Bot watcher temporarily disabled in index.js startup
- [x] Dev command filtering: commands marked with `dev: true` are hidden from UI, help, and Discord registration
- [x] Admin Moderation Commands: `/kick`, `/ban`, `/timeout` with proper permission checks and role hierarchy validation
- [x] Commands accept user mentions or user IDs as input to allow targeting any user (not limited by Discord's user selection filtering)
- [x] Frontend integration: web interface moderation actions with permission validation
- [x] Moderation actions are logged to postgres database with reasons and automatically posted to configured admin logs channel
- [x] Admin logs properly capture and display the moderator who performed the action (both from bot slash commands and frontend)
- [x] Admin Logs System: event logging for moderation actions
- [x] New slash command: `/setup-adminlogs` to configure logging channel and per-command enable/disable
- [x] Bot posts detailed moderation logs to configured channel showing: command used, target user, moderator, date/time, reason (required min 3 words), duration, end date
- [x] Backend API endpoints for admin logs configuration and retrieval
- [x] Frontend UI for admin logs configuration in Server Settings
- [x] Database schema for storing moderation action logs
- [x] Require reason field (minimum 3 words) for all moderation commands
- [x] Admin logs are unique to each guild and stored in postgres database
- [x] Frontend delete buttons for individual logs and delete all logs with confirm dialogs
- [x] Live updates between bot and frontend using SSE events for real-time log synchronization
- [x] Admin logs properly display the username who called the command and the user they called it on for both bot slash commands and frontend moderation actions
- [x] Bot command username logging fixed: uses correct Discord user properties (username/global_name instead of deprecated tag)
- [x] Bot event handlers: added guildCreate and guildDelete events to publish SSE notifications for live dashboard updates
- [x] Invite synchronization: real-time sync between Discord server events and frontend
- [x] Discord event handlers for inviteCreate and inviteDelete events
- [x] Only bot-created invites are tracked and synchronized
- [x] Frontend SSE event listeners for inviteCreated and inviteDeleted events
- [x] Backend API updated to store existing invites from Discord events
- [x] Invite deletions from Discord server are immediately reflected in frontend
- [x] Offline reconciliation: bot detects and removes invites deleted while offline on startup
- [x] Automatic cleanup of stale invites from database and frontend when bot comes back online
- [x] Reaction Roles: configurable reaction-role messages with buttons
- [x] Backend table `reaction_roles` and CRUD endpoints
- [x] Frontend accordion UI to create/edit/delete reaction role configurations (channel, named buttons, role picker, embed)
- [x] Live SSE updates when reaction roles are created/updated/deleted
- [x] Bot posts embedded message with buttons and toggles roles on button press
- [x] Replacement of confirm() with app `ConfirmDialog` and role picker dropdown in UI
- [x] Initial and periodic reconciliation: bot removes DB entries when the message or channel is missing
- [x] Backend: tolerate JSON string payloads for `embed` and `buttons` when creating/updating reaction roles (auto-parse before inserting JSONB)
- [x] Slash command `/post-reaction-role <id>` for admins to post a reaction role message from Discord
- [x] Frontend edit functionality for existing reaction roles
- [x] Button ID stability: customId uses roleId instead of array index for robustness
## Database
- [x] Postgres support via `DATABASE_URL` (backend auto-creates `servers`, `invites`, `users`)
- [x] Legacy encrypted `backend/db.json` retained (migration planned)
- [x] Kick.com live notifications: backend API, frontend UI, bot integration
- Database schema: kickUsers table with userId, username, guildId
- API endpoints: GET/POST/DELETE /api/servers/:guildId/kick-users
- Bot commands: /add-kickuser, /remove-kickuser, /list-kickusers
- Frontend tabs: separate Twitch and Kick tabs in Live Notifications accordion
- Kick API integration: polling for live status, stream metadata, web scraping fallback for 403 errors
- Per-server configuration: all settings scoped by guildId
- [x] Schema: live notification settings stored in server settings (via `liveNotifications` JSON)
- Fields: `enabled`, `channelId`, `users[]`, `kickUsers[]`, `message`, `customMessage` (custom overrides default if non-empty)
- Users list preserved when updating other live notification settings (fixed: kickUsers now also preserved)
- [x] Admin Logs Database Schema: new table for storing moderation action logs
- Fields: id, guildId, action (kick/ban/timeout), targetUserId, targetUsername, moderatorUserId, moderatorUsername, reason, duration, endDate, timestamp
## Security & Behavior
- [x] Invite DELETE requires short-lived HMAC token (`x-invite-token`)
- [x] Frontend confirmation dialog for invite deletion
- [ ] Harden invite-token issuance (require OAuth + admin check)
- [ ] Template variables for messages (planned): support `{user}`, `{title}`, `{category}`, `{viewers}` replacement in `message` / `customMessage`
- [x] Moderation Command Requirements: require reason field (minimum 3 words) for all moderation commands (`/kick`, `/ban`, `/timeout`)
- [x] ServerSettings back button: fixed to navigate to dashboard instead of browser history to prevent accidental accordion opening
## Docs & Deployment
- [x] README and CHANGELOG updated with setup steps and Postgres guidance
- Core env vars: `DATABASE_URL`, `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET`, `DISCORD_BOT_TOKEN`, `INVITE_TOKEN_SECRET`, `ENCRYPTION_KEY`, `HOST`, `PORT`, `CORS_ORIGIN`
- Frontend: set `REACT_APP_API_BASE` to backend URL before build
- Tailscale: bind backend to your tailnet IP (100.x.y.z) and set `DATABASE_URL` to a Postgres reachable over the tailnet
Notes:
- `backend/.env.example` and `frontend/.env.example` are templates — copy to `.env` and fill values.
- Postgres / pgAdmin: create DB & user, set `DATABASE_URL`; backend auto-creates tables on startup.
UI tweaks applied:
- Server cards: uniform sizes, image cropping, name clamping
- Mobile spacing and typography adjustments
- Dashboard action buttons repositioned (Invite/Leave under title)
- Live Notifications: collapsible accordion with tabbed interface for Twitch and Kick tabs (Kick tab disabled)
- [x] All accordions in ServerSettings: consistently grayed out (opacity 0.5) when bot is not in server
- [x] Footer component: added global footer showing "© ehchadservices.com 2025" on all pages
- [x] Dashboard live reloading: real-time updates when bot joins/leaves servers via SSE events
- [x] Responsive design: mobile-friendly layout with adaptive padding, typography, and component sizing
- [x] Ultra-wide screen support: max-width constraints and overflow prevention
- [x] Sticky footer: footer positioned at bottom of viewport regardless of content height
- [x] Navbar branding: title shows "ECS" on mobile, "EhChadServices" on desktop
- [x] Dashboard welcome text: updated to "Welcome back, {username}" with even larger typography (h3/h2 variants) and increased spacing; title also enlarged (h4/h3) for better proportion and explicit margin-bottom for clear line separation
- [x] Browser tab now shows `ECS - <Page Name>` (e.g., 'ECS - Dashboard')
- [x] Dashboard duplicate title fixed; user settings (avatar/themes) restored via NavBar
- [x] Maintenance page
- Frontend displays a maintenance page with a loading indicator when the backend is offline; it polls the backend and reloads UI immediately when the backend is available.
- [x] Global backend health & SSE
- [x] Added `BackendContext` to centralize health polling and a single shared EventSource
- [x] Pages (including `ServerSettings`) use the shared event bus for live updates so the whole site receives changes in real-time
- [ ] Frontend file re-organization
- [ ] Verify guild-scoped SSE payloads include guildId and frontend filters events by guild (in-progress)
- [ ] Add debug SSE publish endpoint to help validate real-time flows (done, guarded by DEBUG_SSE)
- [x] Created `frontend/src/lib/api.js` and refactored some modules to use it
- [x] Created `frontend/src/components/common` and `frontend/src/components/server`
- [x] Moved `ConfirmDialog` and `MaintenancePage` to `components/common`
- [x] Moved `ServerSettings` and `HelpPage` to `components/server`
- [x] Fixed ESLint warnings: removed unused imports and handlers, added proper dependency management
- [x] Fixed compilation errors: added missing MUI imports and Snackbar component
- [x] Navbar visibility: enhanced with solid background and stronger border for better visibility across all themes

298
discord-bot/api.js Normal file
View File

@@ -0,0 +1,298 @@
const fetch = require('node-fetch');
// Resolve backend candidates (env or common local addresses). We'll try them in order
// for each request so the bot can still reach the backend even when it binds to
// a specific non-loopback IP.
const envBase = process.env.BACKEND_BASE ? process.env.BACKEND_BASE.replace(/\/$/, '') : null;
const host = process.env.BACKEND_HOST || process.env.HOST || '127.0.0.1';
const port = process.env.BACKEND_PORT || process.env.PORT || '3002';
const CANDIDATES = [envBase, `http://${host}:${port}`, `http://localhost:${port}`, `http://127.0.0.1:${port}`].filter(Boolean);
async function tryFetch(url, opts = {}) {
// Try each candidate base until one responds successfully
for (const base of CANDIDATES) {
const target = `${base.replace(/\/$/, '')}${url}`;
try {
const res = await fetch(target, opts);
if (res && (res.ok || res.status === 204)) {
return res;
}
// if this candidate returned a non-ok status, log and continue trying others
console.error(`Candidate ${base} returned ${res.status} ${res.statusText} for ${target}`);
} catch (e) {
// network error for this candidate; try next
// console.debug(`Candidate ${base} failed:`, e && e.message ? e.message : e);
}
}
// none of the candidates succeeded
return null;
}
async function safeFetchJsonPath(path, opts = {}) {
const res = await tryFetch(path, opts);
if (!res) return null;
try {
return await res.json();
} catch (e) {
console.error('Failed to parse JSON from backend response:', e && e.message ? e.message : e);
return null;
}
}
async function getServerSettings(guildId) {
const path = `/api/servers/${guildId}/settings`;
const json = await safeFetchJsonPath(path);
return json || {};
}
async function upsertServerSettings(guildId, settings) {
const path = `/api/servers/${guildId}/settings`;
try {
const res = await tryFetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings),
});
return res && res.ok;
} catch (e) {
console.error(`Failed to upsert settings for ${guildId}:`, e && e.message ? e.message : e);
return false;
}
}
async function getCommands(guildId) {
const path = `/api/servers/${guildId}/commands`;
const json = await safeFetchJsonPath(path);
return json || [];
}
async function toggleCommand(guildId, cmdName, enabled) {
const path = `/api/servers/${guildId}/commands/${cmdName}/toggle`;
try {
const res = await tryFetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled }),
});
return res && res.ok;
} catch (e) {
console.error(`Failed to toggle command ${cmdName} for ${guildId}:`, e && e.message ? e.message : e);
return false;
}
}
async function listInvites(guildId) {
const path = `/api/servers/${guildId}/invites`;
const json = await safeFetchJsonPath(path);
return json || [];
}
async function 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(body),
});
return res && res.ok;
} catch (e) {
console.error(`Failed to add invite for ${guildId}:`, e && e.message ? e.message : e);
return false;
}
}
async function deleteInvite(guildId, code) {
const path = `/api/servers/${guildId}/invites/${code}`;
try {
const res = await tryFetch(path, { method: 'DELETE' });
return res && res.ok;
} catch (e) {
console.error(`Failed to delete invite ${code} for ${guildId}:`, e && e.message ? e.message : e);
return false;
}
}
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) {
const path = `/api/servers/${guildId}/twitch-users`;
const json = await safeFetchJsonPath(path);
return json || [];
}
async function addTwitchUser(guildId, username) {
const path = `/api/servers/${guildId}/twitch-users`;
try {
const res = await tryFetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username }),
});
return res && res.ok;
} catch (e) {
console.error(`Failed to add twitch user ${username} for ${guildId}:`, e && e.message ? e.message : e);
return false;
}
}
async function deleteTwitchUser(guildId, username) {
const path = `/api/servers/${guildId}/twitch-users/${encodeURIComponent(username)}`;
try {
const res = await tryFetch(path, { method: 'DELETE' });
return res && res.ok;
} catch (e) {
console.error(`Failed to delete twitch user ${username} for ${guildId}:`, e && e.message ? e.message : e);
return false;
}
}
// Fetch stream status via backend proxy endpoint /api/twitch/streams?users=a,b,c
async function tryFetchTwitchStreams(usersCsv) {
const path = `/api/twitch/streams?users=${encodeURIComponent(usersCsv || '')}`;
const json = await safeFetchJsonPath(path);
return json || [];
}
// Raw direct call helper (not used in most environments) — kept for legacy watcher
async function _rawGetTwitchStreams(usersCsv) {
// Try direct backend candidate first
const path = `/api/twitch/streams?users=${encodeURIComponent(usersCsv || '')}`;
const res = await tryFetch(path);
if (!res) return [];
try { return await res.json(); } catch (e) { return []; }
}
// Kick users helpers
async function getKickUsers(guildId) {
const path = `/api/servers/${guildId}/kick-users`;
const json = await safeFetchJsonPath(path);
return json || [];
}
async function addKickUser(guildId, username) {
const path = `/api/servers/${guildId}/kick-users`;
try {
const res = await tryFetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username }),
});
return res && res.ok;
} catch (e) {
console.error(`Failed to add kick user ${username} for ${guildId}:`, e && e.message ? e.message : e);
return false;
}
}
async function deleteKickUser(guildId, username) {
const path = `/api/servers/${guildId}/kick-users/${encodeURIComponent(username)}`;
try {
const res = await tryFetch(path, { method: 'DELETE' });
return res && res.ok;
} catch (e) {
console.error(`Failed to delete kick user ${username} for ${guildId}:`, e && e.message ? e.message : e);
return false;
}
}
async function getWelcomeLeaveSettings(guildId) {
const path = `/api/servers/${guildId}/welcome-leave-settings`;
const json = await safeFetchJsonPath(path);
return json || { welcome: { enabled: false }, leave: { enabled: false } };
}
async function getAutoroleSettings(guildId) {
const path = `/api/servers/${guildId}/autorole-settings`;
const json = await safeFetchJsonPath(path);
return json || { enabled: false, roleId: '' };
}
async function reconcileInvites(guildId, currentDiscordInvites) {
try {
// Get invites from database
const dbInvites = await listInvites(guildId) || [];
// Find invites in database that no longer exist in Discord
const discordInviteCodes = new Set(currentDiscordInvites.map(inv => inv.code));
const deletedInvites = dbInvites.filter(dbInv => !discordInviteCodes.has(dbInv.code));
// Delete each invite that no longer exists
for (const invite of deletedInvites) {
console.log(`🗑️ Reconciling deleted invite ${invite.code} for guild ${guildId}`);
await deleteInvite(guildId, invite.code);
// Publish SSE event for frontend update
try {
await tryFetch('/api/events/publish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
event: 'inviteDeleted',
data: { code: invite.code, guildId }
})
});
} catch (sseErr) {
console.error('Failed to publish SSE event for reconciled invite deletion:', sseErr);
}
}
if (deletedInvites.length > 0) {
console.log(`✅ Reconciled ${deletedInvites.length} deleted invites for guild ${guildId}`);
}
return deletedInvites.length;
} catch (e) {
console.error(`Failed to reconcile invites for guild ${guildId}:`, e && e.message ? e.message : e);
return 0;
}
}
module.exports = { getServerSettings, upsertServerSettings, getCommands, toggleCommand, listInvites, addInvite, deleteInvite, listReactionRoles, updateReactionRole, deleteReactionRole, getTwitchUsers, addTwitchUser, deleteTwitchUser, tryFetchTwitchStreams, _rawGetTwitchStreams, getKickUsers, addKickUser, deleteKickUser, getWelcomeLeaveSettings, getAutoroleSettings, reconcileInvites };

View File

@@ -0,0 +1,43 @@
const { SlashCommandBuilder, PermissionsBitField } = require('discord.js');
const fetch = require('node-fetch');
module.exports = {
name: 'add-kickuser',
description: 'Admin: add a Kick username to watch for this server (DISABLED)',
enabled: false,
dev: true,
builder: new SlashCommandBuilder()
.setName('add-kickuser')
.setDescription('Add a Kick username to watch for live notifications')
.addStringOption(opt => opt.setName('username').setDescription('Kick username').setRequired(true)),
async execute(interaction) {
if (!interaction.member.permissions.has(PermissionsBitField.Flags.Administrator)) {
await interaction.reply({ content: 'You must be an administrator to use this command.', flags: 64 });
return;
}
const username = interaction.options.getString('username').toLowerCase().trim();
try {
const backendBase = process.env.BACKEND_BASE || `http://${process.env.HOST || '127.0.0.1'}:${process.env.PORT || 3002}`;
const resp = await fetch(`${backendBase}/api/servers/${interaction.guildId}/kick-users`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username })
});
if (resp.ok) {
await interaction.reply({ content: `Added ${username} to Kick watch list.`, flags: 64 });
// Refresh cached settings from backend so watcher sees new user immediately
try {
const settingsResp = await fetch(`${backendBase}/api/servers/${interaction.guildId}/settings`);
if (settingsResp.ok) {
const json = await settingsResp.json();
const bot = require('..');
if (bot && bot.setGuildSettings) bot.setGuildSettings(interaction.guildId, json);
}
} catch (_) {}
} else {
await interaction.reply({ content: 'Failed to add user via backend.', flags: 64 });
}
} catch (e) {
console.error('Error adding kick user:', e);
await interaction.reply({ content: 'Internal error adding kick user.', flags: 64 });
}
}
};

View File

@@ -0,0 +1,36 @@
const { SlashCommandBuilder, PermissionsBitField } = require('discord.js');
const api = require('../api');
module.exports = {
name: 'add-twitchuser',
description: 'Admin: add a Twitch username to watch for this server',
enabled: true,
builder: new SlashCommandBuilder()
.setName('add-twitchuser')
.setDescription('Add a Twitch username to watch for live notifications')
.addStringOption(opt => opt.setName('username').setDescription('Twitch username').setRequired(true)),
async execute(interaction) {
if (!interaction.member.permissions.has(PermissionsBitField.Flags.Administrator)) {
await interaction.reply({ content: 'You must be an administrator to use this command.', flags: 64 });
return;
}
const username = interaction.options.getString('username').toLowerCase().trim();
try {
const success = await api.addTwitchUser(interaction.guildId, username);
if (success) {
await interaction.reply({ content: `Added ${username} to watch list.`, flags: 64 });
// Refresh cached settings from backend so watcher sees new user immediately
try {
const settings = await api.getServerSettings(interaction.guildId);
const bot = require('..');
if (bot && bot.setGuildSettings) bot.setGuildSettings(interaction.guildId, settings);
} catch (_) {}
} else {
await interaction.reply({ content: 'Failed to add user via backend.', flags: 64 });
}
} catch (e) {
console.error('Error adding twitch user:', e);
await interaction.reply({ content: 'Internal error adding twitch user.', 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,53 @@
const { SlashCommandBuilder, PermissionFlagsBits } = require('discord.js');
const { readDb, writeDb } = require('../../backend/db');
module.exports = {
name: 'create-invite',
description: 'Create a Discord invite with options (channel optional, maxAge seconds, maxUses, temporary).',
enabled: true,
builder: new SlashCommandBuilder()
.setName('create-invite')
.setDescription('Create a Discord invite with options (channel optional, maxAge seconds, maxUses, temporary).')
.addChannelOption(opt => opt.setName('channel').setDescription('Channel to create invite in').setRequired(false))
.addIntegerOption(opt => opt.setName('maxage').setDescription('Duration in seconds (0 means never expire)').setRequired(false))
.addIntegerOption(opt => opt.setName('maxuses').setDescription('Number of uses allowed (0 means unlimited)').setRequired(false))
.addBooleanOption(opt => opt.setName('temporary').setDescription('Temporary membership?').setRequired(false))
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
async execute(interaction) {
try {
const channel = interaction.options.getChannel('channel');
const maxAge = interaction.options.getInteger('maxage') || 0;
const maxUses = interaction.options.getInteger('maxuses') || 0;
const temporary = interaction.options.getBoolean('temporary') || false;
const targetChannel = channel || interaction.guild.channels.cache.find(c => c.type === 0);
if (!targetChannel) {
await interaction.reply({ content: 'No valid channel found to create an invite.', ephemeral: true });
return;
}
const invite = await targetChannel.createInvite({ maxAge, maxUses, temporary, unique: true });
const api = require('../api');
const item = {
code: invite.code,
url: invite.url,
channel_id: targetChannel.id,
created_at: new Date().toISOString(),
max_uses: invite.maxUses || maxUses || 0,
max_age: invite.maxAge || maxAge || 0,
temporary: !!invite.temporary,
};
try {
await api.addInvite(interaction.guildId, { channelId: targetChannel.id, maxAge, maxUses, temporary });
} catch (e) {
console.error('Error saving invite to backend:', e);
}
await interaction.reply({ content: `Invite created: ${invite.url}`, ephemeral: true });
} catch (error) {
console.error('Error in create-invite:', error);
await interaction.reply({ content: 'Failed to create invite.', ephemeral: true });
}
},
};

View File

@@ -1,4 +1,4 @@
const { SlashCommandBuilder } = require('discord.js');
const { SlashCommandBuilder, EmbedBuilder } = require('discord.js');
module.exports = {
name: 'help',
@@ -6,20 +6,62 @@ module.exports = {
enabled: true,
builder: new SlashCommandBuilder()
.setName('help')
.setDescription('List available bot commands and what they do.'),
.setDescription('List available bot commands and what they do.')
.addStringOption(opt => opt.setName('command').setDescription('Get detailed help for a specific command').setRequired(false)),
async execute(interaction) {
const commands = Array.from(interaction.client.commands.values()).filter(cmd => !!cmd.builder);
let text = '**Available Commands:**\n\n';
const db = require('../../backend/db').readDb();
const guildSettings = db[interaction.guildId] || {};
const toggles = guildSettings.commandToggles || {};
const protectedCommands = ['manage-commands', 'help'];
try {
const api = require('../api');
// fetch authoritative commands list for this guild
const commands = await api.getCommands(interaction.guildId) || [];
for (const cmd of commands) {
const isEnabled = protectedCommands.includes(cmd.name) ? true : (toggles[cmd.name] !== false && cmd.enabled !== false);
text += `/${cmd.name}${cmd.description || 'No description.'}${isEnabled ? 'Enabled' : 'Disabled'}${protectedCommands.includes(cmd.name) ? ' (locked)' : ''}\n`;
const target = interaction.options.getString('command');
if (target) {
const found = commands.find(c => c.name.toLowerCase() === target.toLowerCase());
if (!found) {
return await interaction.reply({ content: `No command named "/${target}" found.`, flags: 64 });
}
const embed = new EmbedBuilder()
.setTitle(`/${found.name}${found.locked ? 'Locked' : (found.enabled ? 'Enabled' : 'Disabled')}`)
.setDescription(found.description || 'No description available.')
.setColor(found.enabled ? 0x22c55e : 0xe11d48)
.addFields(
{ name: 'Usage', value: `/${found.name} ${(found.usage || '').trim() || ''}` },
{ name: 'Status', value: found.locked ? 'Locked (cannot be toggled)' : (found.enabled ? 'Enabled' : 'Disabled'), inline: true },
{ name: 'Has Slash Builder', value: found.hasSlashBuilder ? 'Yes' : 'No', inline: true }
)
.setFooter({ text: 'Use /help <command> to view detailed info about a command.' });
return await interaction.reply({ embeds: [embed], flags: 64 });
}
// Build a neat embed listing commands grouped by status
const embed = new EmbedBuilder()
.setTitle('Available Commands')
.setDescription('Use `/help <command>` to get detailed info on a specific command.')
.setColor(0x5865f2);
// Sort commands: enabled first, then disabled, locked last
const sorted = commands.slice().sort((a, b) => {
const ka = a.locked ? 2 : (a.enabled ? 0 : 1);
const kb = b.locked ? 2 : (b.enabled ? 0 : 1);
if (ka !== kb) return ka - kb;
return a.name.localeCompare(b.name);
});
// Build a concise field list (max 25 fields in Discord embed)
const fields = [];
for (const cmd of sorted) {
const status = cmd.locked ? '🔒 Locked' : (cmd.enabled ? '✅ Enabled' : '⛔ Disabled');
fields.push({ name: `/${cmd.name}`, value: `${cmd.description || 'No description.'}\n${status}`, inline: false });
if (fields.length >= 24) break;
}
if (fields.length > 0) embed.addFields(fields);
else embed.setDescription('No commands available.');
return await interaction.reply({ embeds: [embed], flags: 64 });
} catch (e) {
console.error('Error in help command:', e && e.message ? e.message : e);
return await interaction.reply({ content: 'Failed to retrieve commands. Try again later.', flags: 64 });
}
await interaction.reply({ content: text, 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,41 @@
const { SlashCommandBuilder, PermissionFlagsBits, ActionRowBuilder, ButtonBuilder, ButtonStyle } = require('discord.js');
const api = require('../api');
module.exports = {
name: 'list-invites',
description: 'List invites created by the bot for this guild',
enabled: true,
builder: new SlashCommandBuilder()
.setName('list-invites')
.setDescription('List invites created by the bot for this guild')
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
async execute(interaction) {
try {
const invites = await api.listInvites(interaction.guildId) || [];
if (!invites.length) {
await interaction.reply({ content: 'No invites created by the bot in this server.', ephemeral: true });
return;
}
// Build a message with invite details and action buttons
for (const inv of invites) {
const created = inv.createdAt || 'Unknown';
const uses = inv.uses || inv.maxUses || 0;
const temporary = inv.temporary ? 'Yes' : 'No';
const content = `Invite: ${inv.url}\nCreated: ${created}\nUses: ${uses}\nMax Uses: ${inv.maxUses || 0}\nMax Age (s): ${inv.maxAge || 0}\nTemporary: ${temporary}`;
const row = new ActionRowBuilder()
.addComponents(
new ButtonBuilder().setLabel('Copy Invite').setStyle(ButtonStyle.Secondary).setCustomId(`copy_inv_${inv.code}`),
new ButtonBuilder().setLabel('Delete Invite').setStyle(ButtonStyle.Danger).setCustomId(`delete_inv_${inv.code}`),
);
await interaction.reply({ content, components: [row], ephemeral: true });
}
} catch (error) {
console.error('Error in list-invites:', error);
await interaction.reply({ content: 'Failed to list invites.', ephemeral: true });
}
},
};

View File

@@ -0,0 +1,24 @@
const { SlashCommandBuilder } = require('discord.js');
const api = require('../api');
module.exports = {
name: 'list-kickusers',
description: 'List watched Kick usernames for this server (DISABLED).',
enabled: false,
dev: true,
builder: new SlashCommandBuilder().setName('list-kickusers').setDescription('List watched Kick usernames for this server'),
async execute(interaction) {
try {
const users = await api.getKickUsers(interaction.guildId) || [];
if (!users || users.length === 0) {
await interaction.reply({ content: 'No Kick users are being watched for this server.', ephemeral: true });
return;
}
const list = users.map(u => `${u}`).join('\n');
await interaction.reply({ content: `Watched Kick users:\n${list}`, ephemeral: true });
} catch (e) {
console.error('Error listing kick users:', e);
await interaction.reply({ content: 'Failed to retrieve watched users.', ephemeral: true });
}
},
};

View File

@@ -0,0 +1,23 @@
const { SlashCommandBuilder } = require('discord.js');
const api = require('../api');
module.exports = {
name: 'list-twitchusers',
description: 'List watched Twitch usernames for this server (Live Notifications).',
enabled: true,
builder: new SlashCommandBuilder().setName('list-twitchusers').setDescription('List watched Twitch usernames for this server'),
async execute(interaction) {
try {
const users = await api.getTwitchUsers(interaction.guildId) || [];
if (!users || users.length === 0) {
await interaction.reply({ content: 'No Twitch users are being watched for this server.', ephemeral: true });
return;
}
const list = users.map(u => `${u}`).join('\n');
await interaction.reply({ content: `Watched Twitch users:\n${list}`, ephemeral: true });
} catch (e) {
console.error('Error listing twitch users:', e);
await interaction.reply({ content: 'Failed to retrieve watched users.', ephemeral: true });
}
},
};

View File

@@ -1,5 +1,5 @@
const { SlashCommandBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, PermissionsBitField } = require('discord.js');
const { readDb, writeDb } = require('../../backend/db.js');
const api = require('../api');
module.exports = {
name: 'manage-commands',
@@ -15,14 +15,12 @@ module.exports = {
return;
}
const db = readDb();
if (!db[interaction.guildId]) db[interaction.guildId] = {};
if (!db[interaction.guildId].commandToggles) db[interaction.guildId].commandToggles = {};
const toggles = db[interaction.guildId].commandToggles;
const existingSettings = (await api.getServerSettings(interaction.guildId)) || {};
if (!existingSettings.commandToggles) existingSettings.commandToggles = {};
let toggles = existingSettings.commandToggles;
// Include all loaded commands so simple command modules (no SlashCommandBuilder) like
// `ping` are also listed. Filter for objects with a name for safety.
const commands = Array.from(interaction.client.commands.values()).filter(cmd => cmd && cmd.name);
const commands = Array.from(interaction.client.commands.values()).filter(cmd => cmd && cmd.name && !cmd.dev);
// Build button components (max 5 rows, 5 buttons per row)
const actionRows = [];
@@ -67,9 +65,19 @@ module.exports = {
collector.on('collect', async i => {
const cmdName = i.customId.replace('toggle_cmd_', '');
toggles[cmdName] = !(toggles[cmdName] !== false);
writeDb(db);
const newVal = !(toggles[cmdName] !== false);
// persist via backend API
try {
await api.toggleCommand(interaction.guildId, cmdName, newVal);
// fetch authoritative list to rebuild buttons
const fresh = await api.getCommands(interaction.guildId);
toggles = {};
for (const c of fresh) {
toggles[c.name] = c.enabled;
}
} catch (e) {
console.error('Error persisting command toggle:', e);
}
// rebuild buttons to reflect new state
const updatedRows = [];
let r = new ActionRowBuilder();

View File

@@ -1,17 +1,10 @@
const { readDb } = require('../../backend/db.js');
// ping uses backend settings via API
module.exports = {
name: 'ping',
description: 'Replies with Pong!',
enabled: true,
execute(interaction) {
const db = readDb();
const settings = db[interaction.guildId] || { pingCommand: false };
if (settings.pingCommand) {
interaction.reply('Pong!');
} else {
interaction.reply('The ping command is disabled on this server.');
}
async execute(interaction) {
await interaction.reply('Pong!');
},
};

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

@@ -0,0 +1,41 @@
const { SlashCommandBuilder, PermissionsBitField } = require('discord.js');
const fetch = require('node-fetch');
module.exports = {
name: 'remove-kickuser',
description: 'Admin: remove a Kick username from this server watch list',
enabled: false,
dev: true,
builder: new SlashCommandBuilder()
.setName('remove-kickuser')
.setDescription('Remove a Kick username from the watch list')
.addStringOption(opt => opt.setName('username').setDescription('Kick username to remove').setRequired(true)),
async execute(interaction) {
if (!interaction.member.permissions.has(PermissionsBitField.Flags.Administrator)) {
await interaction.reply({ content: 'You must be an administrator to use this command.', flags: 64 });
return;
}
const username = interaction.options.getString('username').toLowerCase().trim();
try {
const backendBase = process.env.BACKEND_BASE || `http://${process.env.HOST || '127.0.0.1'}:${process.env.PORT || 3002}`;
const resp = await fetch(`${backendBase}/api/servers/${interaction.guildId}/kick-users/${encodeURIComponent(username)}`, { method: 'DELETE' });
if (resp.ok) {
await interaction.reply({ content: `Removed ${username} from Kick watch list.`, flags: 64 });
// Refresh cached settings from backend
try {
const settingsResp = await fetch(`${backendBase}/api/servers/${interaction.guildId}/settings`);
if (settingsResp.ok) {
const json = await settingsResp.json();
const bot = require('..');
if (bot && bot.setGuildSettings) bot.setGuildSettings(interaction.guildId, json);
}
} catch (_) {}
} else {
await interaction.reply({ content: 'Failed to remove user via backend.', flags: 64 });
}
} catch (e) {
console.error('Error removing kick user:', e);
await interaction.reply({ content: 'Internal error removing kick user.', flags: 64 });
}
}
};

View File

@@ -0,0 +1,36 @@
const { SlashCommandBuilder, PermissionsBitField } = require('discord.js');
const api = require('../api');
module.exports = {
name: 'remove-twitchuser',
description: 'Admin: remove a Twitch username from this server watch list',
enabled: true,
builder: new SlashCommandBuilder()
.setName('remove-twitchuser')
.setDescription('Remove a Twitch username from the watch list')
.addStringOption(opt => opt.setName('username').setDescription('Twitch username to remove').setRequired(true)),
async execute(interaction) {
if (!interaction.member.permissions.has(PermissionsBitField.Flags.Administrator)) {
await interaction.reply({ content: 'You must be an administrator to use this command.', flags: 64 });
return;
}
const username = interaction.options.getString('username').toLowerCase().trim();
try {
const success = await api.deleteTwitchUser(interaction.guildId, username);
if (success) {
await interaction.reply({ content: `Removed ${username} from watch list.`, flags: 64 });
// Refresh cached settings from backend
try {
const settings = await api.getServerSettings(interaction.guildId);
const bot = require('..');
if (bot && bot.setGuildSettings) bot.setGuildSettings(interaction.guildId, settings);
} catch (_) {}
} else {
await interaction.reply({ content: 'Failed to remove user via backend.', flags: 64 });
}
} catch (e) {
console.error('Error removing twitch user:', e);
await interaction.reply({ content: 'Internal error removing twitch user.', 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

@@ -9,13 +9,8 @@ module.exports = {
.setName('setup-autorole')
.setDescription('Interactively set up the autorole for this server.'),
async execute(interaction) {
const db = readDb();
const guildId = interaction.guildId;
if (!db[guildId]) {
db[guildId] = {};
}
const roleSelect = new RoleSelectMenuBuilder()
.setCustomId('autorole_role_select')
.setPlaceholder('Select the role to assign on join.');
@@ -45,11 +40,20 @@ module.exports = {
return;
}
db[guildId].autorole = {
enabled: true,
roleId: roleId,
};
writeDb(db);
// persist to backend
try {
const api = require('../api');
const existing = await api.getServerSettings(guildId) || {};
existing.autorole = { enabled: true, roleId };
await api.upsertServerSettings(guildId, existing);
} catch (e) {
console.error('Error persisting autorole to backend, falling back to local:', e);
const { readDb, writeDb } = require('../../backend/db.js');
const db = readDb();
if (!db[guildId]) db[guildId] = {};
db[guildId].autorole = { enabled: true, roleId };
writeDb(db);
}
await roleConfirmation.update({
content: `Autorole setup complete! New members will be assigned the **${role.name}** role.`,

View File

@@ -1,4 +1,5 @@
const { SlashCommandBuilder, ActionRowBuilder, ChannelSelectMenuBuilder, StringSelectMenuBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, ComponentType } = require('discord.js');
const api = require('../api');
const { readDb, writeDb } = require('../../backend/db.js');
const defaultLeaveMessages = ["{user} has left the server.", "Goodbye, {user}.", "We'll miss you, {user}."];
@@ -38,8 +39,19 @@ module.exports = {
});
const channelId = channelConfirmation.values[0];
db[guildId].leaveChannel = channelId;
db[guildId].leaveEnabled = true;
try {
const existing = (await api.getServerSettings(guildId)) || {};
existing.leaveEnabled = true;
existing.leaveChannel = channelId;
await api.upsertServerSettings(guildId, existing);
} catch (e) {
console.error('Error persisting leave settings to backend, falling back to local:', e);
const db = readDb();
if (!db[guildId]) db[guildId] = {};
db[guildId].leaveChannel = channelId;
db[guildId].leaveEnabled = true;
writeDb(db);
}
const messageOptions = defaultLeaveMessages.map(msg => ({
label: msg.length > 100 ? msg.substring(0, 97) + '...' : msg,
@@ -92,8 +104,17 @@ module.exports = {
});
const customMessage = modalSubmit.fields.getTextInputValue('custom_message_input');
db[guildId].leaveMessage = customMessage;
writeDb(db);
try {
const existing = (await api.getServerSettings(guildId)) || {};
existing.leaveMessage = customMessage;
await api.upsertServerSettings(guildId, existing);
} catch (e) {
console.error('Error persisting leave message to backend, falling back to local:', e);
const db = readDb();
if (!db[guildId]) db[guildId] = {};
db[guildId].leaveMessage = customMessage;
writeDb(db);
}
await modalSubmit.reply({
content: `Leave message setup complete! Channel: <#${channelId}>, Message: "${customMessage}"`,
@@ -101,8 +122,17 @@ module.exports = {
});
} else {
db[guildId].leaveMessage = selectedMessage;
writeDb(db);
try {
const existing = (await api.getServerSettings(guildId)) || {};
existing.leaveMessage = selectedMessage;
await api.upsertServerSettings(guildId, existing);
} catch (e) {
console.error('Error persisting leave message to backend, falling back to local:', e);
const db = readDb();
if (!db[guildId]) db[guildId] = {};
db[guildId].leaveMessage = selectedMessage;
writeDb(db);
}
await messageConfirmation.update({
content: `Leave message setup complete! Channel: <#${channelId}>, Message: "${selectedMessage}"`,
components: [],

View File

@@ -0,0 +1,33 @@
const { SlashCommandBuilder, PermissionsBitField } = require('discord.js');
const api = require('../api');
module.exports = {
name: 'setup-live',
description: 'Admin: enable or disable Twitch live notifications for this server',
enabled: true,
builder: new SlashCommandBuilder()
.setName('setup-live')
.setDescription('Enable or disable Twitch live notifications for this server')
.addBooleanOption(opt => opt.setName('enabled').setDescription('Enable/disable notifications').setRequired(true)),
async execute(interaction) {
if (!interaction.member.permissions.has(PermissionsBitField.Flags.Administrator)) {
await interaction.reply({ content: 'You must be a server administrator to configure live notifications.', flags: 64 });
return;
}
const enabled = interaction.options.getBoolean('enabled');
try {
const api = require('../api');
const existing = (await api.getServerSettings(interaction.guildId)) || {};
const currentLn = existing.liveNotifications || {};
existing.liveNotifications = { ...currentLn, enabled: !!enabled };
await api.upsertServerSettings(interaction.guildId, existing);
await interaction.reply({ content: `Live notifications ${enabled ? 'enabled' : 'disabled'} for this server.`, flags: 64 });
} catch (e) {
console.error('Error saving live notifications to backend:', e);
await interaction.reply({ content: 'Failed to update live notifications.', flags: 64 });
}
}
};

View File

@@ -1,4 +1,5 @@
const { SlashCommandBuilder, ActionRowBuilder, ChannelSelectMenuBuilder, StringSelectMenuBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, ComponentType } = require('discord.js');
const api = require('../api');
const { readDb, writeDb } = require('../../backend/db.js');
const defaultWelcomeMessages = ["Welcome to the server, {user}!", "Hey {user}, welcome!", "{user} has joined the party!"];
@@ -38,8 +39,20 @@ module.exports = {
});
const channelId = channelConfirmation.values[0];
db[guildId].welcomeChannel = channelId;
db[guildId].welcomeEnabled = true;
// persist via backend
try {
const existing = (await api.getServerSettings(guildId)) || {};
existing.welcomeEnabled = true;
existing.welcomeChannel = channelId;
await api.upsertServerSettings(guildId, existing);
} catch (e) {
console.error('Error persisting welcome settings to backend, falling back to local:', e);
const db = readDb();
if (!db[guildId]) db[guildId] = {};
db[guildId].welcomeChannel = channelId;
db[guildId].welcomeEnabled = true;
writeDb(db);
}
const messageOptions = defaultWelcomeMessages.map(msg => ({
label: msg.length > 100 ? msg.substring(0, 97) + '...' : msg,
@@ -92,8 +105,17 @@ module.exports = {
});
const customMessage = modalSubmit.fields.getTextInputValue('custom_message_input');
db[guildId].welcomeMessage = customMessage;
writeDb(db);
try {
const existing = (await api.getServerSettings(guildId)) || {};
existing.welcomeMessage = customMessage;
await api.upsertServerSettings(guildId, existing);
} catch (e) {
console.error('Error persisting welcome message to backend, falling back to local:', e);
const db = readDb();
if (!db[guildId]) db[guildId] = {};
db[guildId].welcomeMessage = customMessage;
writeDb(db);
}
await modalSubmit.reply({
content: `Welcome message setup complete! Channel: <#${channelId}>, Message: "${customMessage}"`,
@@ -101,8 +123,17 @@ module.exports = {
});
} else {
db[guildId].welcomeMessage = selectedMessage;
writeDb(db);
try {
const existing = (await api.getServerSettings(guildId)) || {};
existing.welcomeMessage = selectedMessage;
await api.upsertServerSettings(guildId, existing);
} catch (e) {
console.error('Error persisting welcome message to backend, falling back to local:', e);
const db = readDb();
if (!db[guildId]) db[guildId] = {};
db[guildId].welcomeMessage = selectedMessage;
writeDb(db);
}
await messageConfirmation.update({
content: `Welcome message setup complete! Channel: <#${channelId}>, Message: "${selectedMessage}"`,
components: [],

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

@@ -1,5 +1,5 @@
const { SlashCommandBuilder } = require('discord.js');
const { readDb } = require('../../backend/db.js');
const api = require('../api');
module.exports = {
name: 'view-autorole',
@@ -9,10 +9,9 @@ module.exports = {
.setName('view-autorole')
.setDescription('View the current autorole configuration for this server.'),
async execute(interaction) {
const db = readDb();
const guildId = interaction.guildId;
const settings = db[guildId] || {};
const autorole = settings.autorole || { enabled: false, roleId: '' };
const guildId = interaction.guildId;
const settings = (await api.getServerSettings(guildId)) || {};
const autorole = settings.autorole || { enabled: false, roleId: '' };
if (!autorole.enabled) {
await interaction.reply({ content: 'Autorole is currently disabled for this server.', flags: 64 });

View File

@@ -1,5 +1,5 @@
const { SlashCommandBuilder } = require('discord.js');
const { readDb } = require('../../backend/db.js');
const api = require('../api');
module.exports = {
name: 'view-welcome-leave',
@@ -9,9 +9,8 @@ module.exports = {
.setName('view-welcome-leave')
.setDescription('View the current welcome and leave message configuration.'),
async execute(interaction) {
const db = readDb();
const guildId = interaction.guildId;
const settings = db[guildId] || {};
const guildId = interaction.guildId;
const settings = (await api.getServerSettings(guildId)) || {};
const welcomeChannel = settings.welcomeChannel ? `<#${settings.welcomeChannel}>` : 'Not set';
const welcomeMessage = settings.welcomeMessage || 'Not set';

View File

@@ -10,30 +10,67 @@ const commandFiles = fs.readdirSync(commandsPath).filter(file => file.endsWith('
for (const file of commandFiles) {
const filePath = path.join(commandsPath, file);
const command = require(filePath);
if (command.enabled === false) continue;
if (command.enabled === false || command.dev === true) continue;
if (command.builder) {
if (command.builder) {
try {
// Some command modules export builder as a function (builder => builder...) or as an instance
if (typeof command.builder === 'function') {
// create a temporary SlashCommandBuilder by requiring it from discord.js
const { SlashCommandBuilder } = require('discord.js');
const built = command.builder(new SlashCommandBuilder());
if (built && typeof built.toJSON === 'function') commands.push(built.toJSON());
else commands.push({ name: command.name, description: command.description });
} else if (command.builder && typeof command.builder.toJSON === 'function') {
commands.push(command.builder.toJSON());
} else {
} else {
commands.push({ name: command.name, description: command.description });
}
} catch (e) {
console.warn(`Failed to build command ${command.name}:`, e && e.message ? e.message : e);
commands.push({ name: command.name, description: command.description });
}
} else {
commands.push({ name: command.name, description: command.description });
}
}
const rest = new REST({ version: '10' }).setToken(process.env.DISCORD_BOT_TOKEN);
const deployCommands = async (guildId) => {
try {
console.log(`Started refreshing application (/) commands for guild ${guildId}.`);
// Minimal logging: indicate a refresh is happening (no per-guild spam)
console.log('🔁 Refreshing application commands...');
await rest.put(
Routes.applicationGuildCommands(process.env.DISCORD_CLIENT_ID, guildId),
{ body: commands },
);
console.log(`Successfully reloaded application (/) commands for guild ${guildId}.`);
console.log(`✅ Reloaded application commands (${commands.length} commands)`);
} catch (error) {
console.error(error);
console.error('Failed to deploy commands:', error && error.message ? error.message : error);
}
};
// 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

@@ -1,27 +1,30 @@
const { Events } = require('discord.js');
const { readDb } = require('../../backend/db.js');
module.exports = {
name: Events.GuildMemberAdd,
async execute(member) {
try {
const db = readDb();
const settings = db[member.guild.id];
const api = require('../api');
// Get the welcome/leave settings from the API
const welcomeLeaveSettings = await api.getWelcomeLeaveSettings(member.guild.id) || { welcome: { enabled: false } };
const welcome = welcomeLeaveSettings.welcome;
if (settings && settings.welcomeEnabled && settings.welcomeChannel) {
const channel = member.guild.channels.cache.get(settings.welcomeChannel);
if (welcome && welcome.enabled && welcome.channel) {
const channel = member.guild.channels.cache.get(welcome.channel);
if (channel) {
try {
const message = (settings.welcomeMessage || 'Welcome {user} to {server}!').replace('{user}', member.user.toString()).replace('{server}', member.guild.name);
const message = (welcome.message || 'Welcome {user} to {server}!').replace('{user}', member.user.toString()).replace('{server}', member.guild.name);
await channel.send(message);
} catch (error) {
console.error(`Could not send welcome message to channel ${settings.welcomeChannel} in guild ${member.guild.id}:`, error);
console.error(`Could not send welcome message to channel ${welcome.channel} in guild ${member.guild.id}:`, error);
}
}
}
if (settings && settings.autorole && settings.autorole.enabled && settings.autorole.roleId) {
const role = member.guild.roles.cache.get(settings.autorole.roleId);
// Handle autorole
const autoroleSettings = await api.getAutoroleSettings(member.guild.id) || { enabled: false };
if (autoroleSettings && autoroleSettings.enabled && autoroleSettings.roleId) {
const role = member.guild.roles.cache.get(autoroleSettings.roleId);
if (role) {
try {
// Re-check that role is assignable
@@ -39,5 +42,5 @@ module.exports = {
} catch (error) {
console.error(`Error in guildMemberAdd event for guild ${member.guild.id}:`, error);
}
},
}
};

View File

@@ -1,43 +1,27 @@
const { Events } = require('discord.js');
const { readDb } = require('../../backend/db.js');
module.exports = {
name: Events.GuildMemberRemove,
async execute(member) {
try {
const db = readDb();
const settings = db[member.guild.id];
const api = require('../api');
// Get the welcome/leave settings from the API
const welcomeLeaveSettings = await api.getWelcomeLeaveSettings(member.guild.id) || { leave: { enabled: false } };
const leave = welcomeLeaveSettings.leave;
if (settings && settings.leaveEnabled && settings.leaveChannel) {
let channel = member.guild.channels.cache.get(settings.leaveChannel);
if (!channel) {
if (leave && leave.enabled && leave.channel) {
const channel = member.guild.channels.cache.get(leave.channel);
if (channel) {
try {
channel = await member.guild.channels.fetch(settings.leaveChannel);
} catch (err) {
return;
}
}
if (channel && channel.isTextBased && channel.isTextBased()) {
try {
const me = member.guild.members.me;
const perms = channel.permissionsFor(me);
if (!perms || !perms.has('ViewChannel') || !perms.has('SendMessages')) {
return;
}
const userMention = member.user ? (member.user.toString ? member.user.toString() : member.user.tag) : 'A user';
const message = (settings.leaveMessage || '{user} has left the server.').replace('{user}', userMention).replace('{server}', member.guild.name);
const message = (leave.message || '{user} has left the server.').replace('{user}', member.user.toString());
await channel.send(message);
} catch (error) {
console.error(`Could not send leave message to channel ${settings.leaveChannel} in guild ${member.guild.id}:`, error);
console.error(`Could not send leave message to channel ${leave.channel} in guild ${member.guild.id}:`, error);
}
} else {
return;
}
}
} catch (error) {
console.error(`Error in guildMemberRemove event for guild ${member.guild.id}:`, 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,17 +1,113 @@
const { ActivityType } = require('discord.js');
const deployCommands = require('../deploy-commands');
const api = require('../api');
module.exports = {
name: 'clientReady',
once: true,
async execute(client) {
console.log('ECS - Full Stack Bot Online!');
const guilds = client.guilds.cache.map(guild => guild.id);
for (const guildId of guilds) {
await deployCommands(guildId);
const guildIds = client.guilds.cache.map(guild => guild.id);
if (guildIds.length > 0) {
// Deploy commands for all guilds in parallel, but only log a single summary
try {
await Promise.all(guildIds.map(id => deployCommands(id)));
console.log(`🔁 Refreshed application commands for ${guildIds.length} guild(s)`);
} catch (e) {
console.error('Error refreshing application commands:', e && e.message ? e.message : e);
}
}
// 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' },
@@ -26,5 +122,8 @@ module.exports = {
client.user.setActivity(activity.name, { type: activity.type, url: activity.url });
activityIndex = (activityIndex + 1) % activities.length;
}, 3000);
// Signal that startup is complete
console.log('✅ ECS - Full Stack Bot Online!');
},
};

View File

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

View File

@@ -2,12 +2,25 @@ const { Client, GatewayIntentBits, Collection } = require('discord.js');
const fs = require('fs');
const path = require('path');
const deployCommands = require('./deploy-commands');
const { readDb } = require('../backend/db');
// legacy local db is available as a fallback in some commands via require('../../backend/db')
const api = require('./api');
const client = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers] });
client.commands = new Collection();
// In-memory cache of server settings to allow backend to push updates and make toggles immediate
const guildSettingsCache = new Map();
function setGuildSettings(guildId, settings) {
if (!guildId) return;
guildSettingsCache.set(guildId, settings || {});
}
function getGuildSettingsFromCache(guildId) {
return guildSettingsCache.get(guildId) || null;
}
const commandHandler = require('./handlers/command-handler');
const eventHandler = require('./handlers/event-handler');
@@ -15,17 +28,130 @@ commandHandler(client);
eventHandler(client);
client.on('interactionCreate', async interaction => {
// Handle button/component interactions for invites
if (interaction.isButton && interaction.isButton()) {
const id = interaction.customId || '';
if (id.startsWith('copy_inv_')) {
const code = id.replace('copy_inv_', '');
try {
const invites = await api.listInvites(interaction.guildId);
const inv = (invites || []).find(i => i.code === code);
if (inv) {
await interaction.reply({ content: `Invite: ${inv.url}`, ephemeral: true });
} else {
await interaction.reply({ content: 'Invite not found.', ephemeral: true });
}
} catch (e) {
console.error('Error fetching invites via API:', e);
await interaction.reply({ content: 'Invite not found.', ephemeral: true });
}
} else if (id.startsWith('delete_inv_')) {
const code = id.replace('delete_inv_', '');
// permission check: admin only
const member = interaction.member;
if (!member.permissions.has('Administrator')) {
await interaction.reply({ content: 'You must be an administrator to delete invites.', ephemeral: true });
return;
}
try {
// call backend delete endpoint via helper
const ok = await api.deleteInvite(interaction.guildId, code);
if (ok) {
await interaction.reply({ content: 'Invite deleted.', ephemeral: true });
} else {
await interaction.reply({ content: 'Failed to delete invite via API.', ephemeral: true });
}
} catch (e) {
console.error('Error deleting invite via API:', e);
await interaction.reply({ content: 'Failed to delete invite.', ephemeral: true });
}
}
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);
if (!command) return;
// Check per-guild toggles
// Check per-guild toggles via Postgres (directly) for lower latency and reliability
try {
const db = readDb();
const guildSettings = db[interaction.guildId] || {};
const toggles = guildSettings.commandToggles || {};
// authoritative path: always try the backend HTTP API first so separate processes stay in sync
let guildSettings = await api.getServerSettings(interaction.guildId) || {};
// if API didn't return anything useful, try in-memory cache then direct DB as fallbacks
if (!guildSettings || Object.keys(guildSettings).length === 0) {
guildSettings = getGuildSettingsFromCache(interaction.guildId) || {};
if (!guildSettings || Object.keys(guildSettings).length === 0) {
try {
const pg = require('../backend/pg');
guildSettings = (await pg.getServerSettings(interaction.guildId)) || {};
} catch (pgErr) {
// leave guildSettings empty
}
}
}
// Normalize legacy flags into commandToggles for backward compatibility
const toggles = { ...(guildSettings.commandToggles || {}) };
// Example legacy flag mapping: pingCommand -> commandToggles.ping
if (typeof guildSettings.pingCommand !== 'undefined') {
toggles.ping = !!guildSettings.pingCommand;
}
const protectedCommands = ['manage-commands', 'help'];
// If command is protected, always allow
@@ -61,4 +187,147 @@ const login = () => {
client.login(process.env.DISCORD_BOT_TOKEN);
}
module.exports = { login, client };
// Allow backend to trigger a live announcement for debugging
async function announceLive(guildId, stream) {
try {
const guild = await client.guilds.fetch(guildId).catch(() => null);
if (!guild) return { success: false, message: 'Guild not found' };
const settings = await (require('../backend/pg')).getServerSettings(guildId).catch(() => null) || await (require('./api')).getServerSettings(guildId).catch(() => ({}));
const liveSettings = (settings && settings.liveNotifications) || {};
if (!liveSettings.enabled) return { success: false, message: 'Live notifications disabled' };
const channelId = liveSettings.channelId;
if (!channelId) return { success: false, message: 'No channel configured' };
const channel = await guild.channels.fetch(channelId).catch(() => null);
if (!channel) return { success: false, message: 'Channel not found' };
const { EmbedBuilder } = require('discord.js');
const embed = new EmbedBuilder()
.setColor(0x9146FF)
.setTitle(stream.title || `${stream.user_name} is live`)
.setURL(stream.url)
.setAuthor({ name: stream.user_name, iconURL: stream.profile_image_url || undefined, url: stream.url })
.setThumbnail(stream.thumbnail_url || stream.profile_image_url || undefined)
.addFields(
{ name: 'Category', value: stream.game_name || 'Unknown', inline: true },
{ name: 'Viewers', value: String(stream.viewer_count || 0), inline: true }
)
.setDescription((stream.description || '').slice(0, 200))
.setFooter({ text: `ehchadservices • Started: ${stream.started_at ? new Date(stream.started_at).toLocaleString() : 'unknown'}` });
let prefixMsg = '';
if (liveSettings.customMessage) {
prefixMsg = liveSettings.customMessage;
} else if (liveSettings.message) {
prefixMsg = liveSettings.message;
} else {
prefixMsg = `🔴 ${stream.user_name} is now live!`;
}
const payload = prefixMsg ? { content: prefixMsg, embeds: [embed] } : { embeds: [embed] };
await channel.send(payload);
return { success: true };
} catch (e) {
console.error('announceLive failed:', e && e.message ? e.message : e);
return { success: false, message: e && e.message ? e.message : 'unknown error' };
}
}
module.exports = { login, client, setGuildSettings, getGuildSettingsFromCache, announceLive };
async function postReactionRoleMessage(guildId, reactionRole) {
try {
const guild = client.guilds.cache.get(guildId);
if (!guild) return { success: false, message: 'Guild not found' };
const channel = await guild.channels.fetch(reactionRole.channel_id || reactionRole.channelId).catch(() => null);
if (!channel) return { success: false, message: 'Channel not found' };
// Build buttons
const { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } = require('discord.js');
const row = new ActionRowBuilder();
const buttons = reactionRole.buttons || [];
for (let i = 0; i < buttons.length; i++) {
const b = buttons[i];
const customId = `rr_${reactionRole.id}_${b.roleId}`;
const btn = new ButtonBuilder().setCustomId(customId).setLabel(b.label || b.name || `Button ${i+1}`).setStyle(ButtonStyle.Primary);
row.addComponents(btn);
}
const embedData = reactionRole.embed || reactionRole.embed || {};
const embed = new EmbedBuilder();
if (embedData.title) embed.setTitle(embedData.title);
if (embedData.description) embed.setDescription(embedData.description);
if (embedData.color) embed.setColor(embedData.color);
if (embedData.thumbnail) embed.setThumbnail(embedData.thumbnail);
if (embedData.fields && Array.isArray(embedData.fields)) {
for (const f of embedData.fields) {
if (f.name && f.value) embed.addFields({ name: f.name, value: f.value, inline: false });
}
}
const sent = await channel.send({ embeds: [embed], components: [row] });
// update backend with message id
try {
const api = require('./api');
await api.updateReactionRole(guildId, reactionRole.id, { messageId: sent.id });
} catch (e) {
console.error('Failed to update reaction role message id in backend:', e);
}
return { success: true, messageId: sent.id };
} catch (e) {
console.error('postReactionRoleMessage failed:', e && e.message ? e.message : e);
return { success: false, message: e && e.message ? e.message : 'unknown error' };
}
}
module.exports.postReactionRoleMessage = postReactionRoleMessage;
// Start twitch watcher when client is ready (use 'clientReady' as the event name)
try {
const watcher = require('./twitch-watcher');
// discord.js uses 'clientReady' event
client.once('clientReady', () => {
// start polling in background
watcher.poll(client).catch(err => console.error('Twitch watcher failed to start:', err));
});
process.on('exit', () => { watcher.stop(); });
process.on('SIGINT', () => { watcher.stop(); process.exit(); });
} catch (e) {
// ignore if watcher not available
}
try {
const kickWatcher = require('./kick-watcher');
client.once('clientReady', () => {
// TEMPORARILY DISABLED: Kick watcher removed for now
// kickWatcher.poll(client).catch(err => console.error('Kick watcher failed to start:', err));
console.log('Kick watcher: temporarily disabled');
});
// process.on('exit', () => { kickWatcher.stop(); });
// process.on('SIGINT', () => { kickWatcher.stop(); process.exit(); });
} catch (e) {
// ignore if kick watcher not available
}
// --- Optional push receiver (so backend can notify a remote bot process) ---
try {
const express = require('express');
const bodyParser = require('body-parser');
const botPort = process.env.BOT_PUSH_PORT || process.env.BOT_PORT || null;
const botSecret = process.env.BOT_SECRET || null;
if (botPort) {
const app = express();
app.use(bodyParser.json());
app.post('/internal/set-settings', (req, res) => {
try {
if (botSecret) {
const provided = req.headers['x-bot-secret'];
if (!provided || provided !== botSecret) return res.status(401).json({ success: false, message: 'Unauthorized' });
}
const { guildId, settings } = req.body || {};
if (!guildId) return res.status(400).json({ success: false, message: 'Missing guildId' });
setGuildSettings(guildId, settings || {});
return res.json({ success: true });
} catch (e) {
console.error('Error in bot push receiver:', e);
return res.status(500).json({ success: false });
}
});
app.listen(botPort, () => console.log(`Bot push receiver listening on port ${botPort}`));
}
} catch (e) {
// ignore if express isn't available in this environment
}

294
discord-bot/kick-watcher.js Normal file
View File

@@ -0,0 +1,294 @@
const api = require('./api');
const fetch = require('node-fetch');
// Kick API helpers (web scraping since no public API)
let polling = false;
const pollIntervalMs = Number(process.env.KICK_POLL_INTERVAL_MS || 15000); // 15s default (slower than Twitch)
// Keep track of which streams we've already announced per guild:user -> { started_at }
const announced = new Map(); // key: `${guildId}:${user}` -> { started_at }
// Simple web scraping to check if a Kick user is live
async function checkKickUserLive(username) {
try {
// First try the API endpoint
const apiUrl = `https://kick.com/api/v1/channels/${encodeURIComponent(username)}`;
const apiController = new AbortController();
const apiTimeoutId = setTimeout(() => apiController.abort(), 5000);
const apiResponse = await fetch(apiUrl, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
'Accept': 'application/json',
'Referer': 'https://kick.com/'
},
signal: apiController.signal
});
clearTimeout(apiTimeoutId);
if (apiResponse.ok) {
const data = await apiResponse.json();
if (data && data.livestream && data.livestream.is_live) {
return {
is_live: true,
user_login: username,
user_name: data.user?.username || username,
title: data.livestream.session_title || `${username} is live`,
viewer_count: data.livestream.viewer_count || 0,
started_at: data.livestream.start_time,
url: `https://kick.com/${username}`,
thumbnail_url: data.livestream.thumbnail?.url || null,
category: data.category?.name || 'Unknown',
description: data.user?.bio || ''
};
}
return { is_live: false, user_login: username };
}
// If API fails with 403, try web scraping as fallback
if (apiResponse.status === 403) {
console.log(`API blocked for ${username}, trying web scraping fallback...`);
const pageUrl = `https://kick.com/${encodeURIComponent(username)}`;
const pageController = new AbortController();
const pageTimeoutId = setTimeout(() => pageController.abort(), 5000);
const pageResponse = await fetch(pageUrl, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.5',
'Accept-Encoding': 'gzip, deflate, br',
'DNT': '1',
'Connection': 'keep-alive',
'Upgrade-Insecure-Requests': '1',
'Sec-Fetch-Dest': 'document',
'Sec-Fetch-Mode': 'navigate',
'Sec-Fetch-Site': 'none',
'Cache-Control': 'max-age=0'
},
signal: pageController.signal
});
clearTimeout(pageTimeoutId);
if (pageResponse.ok) {
const html = await pageResponse.text();
// Check for live stream indicators in the HTML
const isLive = html.includes('"is_live":true') || html.includes('"is_live": true') ||
html.includes('data-is-live="true"') || html.includes('isLive:true');
if (isLive) {
// Try to extract stream info from HTML
let title = `${username} is live`;
let viewerCount = 0;
let category = 'Unknown';
// Extract title
const titleMatch = html.match(/"session_title"\s*:\s*"([^"]+)"/) || html.match(/"title"\s*:\s*"([^"]+)"/);
if (titleMatch) {
title = titleMatch[1].replace(/\\"/g, '"');
}
// Extract viewer count
const viewerMatch = html.match(/"viewer_count"\s*:\s*(\d+)/);
if (viewerMatch) {
viewerCount = parseInt(viewerMatch[1]);
}
// Extract category
const categoryMatch = html.match(/"category"\s*:\s*{\s*"name"\s*:\s*"([^"]+)"/);
if (categoryMatch) {
category = categoryMatch[1];
}
return {
is_live: true,
user_login: username,
user_name: username,
title: title,
viewer_count: viewerCount,
started_at: new Date().toISOString(),
url: `https://kick.com/${username}`,
thumbnail_url: null,
category: category,
description: ''
};
}
}
}
return { is_live: false, user_login: username };
} catch (e) {
if (e.name === 'AbortError') {
console.error(`Timeout checking Kick user ${username}`);
} else {
console.error(`Failed to check Kick user ${username}:`, e && e.message ? e.message : e);
}
return { is_live: false, user_login: username };
}
}
// Check all Kick users for a guild
async function checkKickStreamsForGuild(guildId, usernames) {
const results = [];
for (const username of usernames) {
try {
const stream = await checkKickUserLive(username);
if (stream.is_live) {
results.push(stream);
}
} catch (e) {
console.error(`Error checking Kick user ${username}:`, e && e.message ? e.message : e);
}
// Small delay between requests to be respectful
await new Promise(r => setTimeout(r, 500));
}
return results;
}
async function checkGuild(client, guild) {
try {
// Get settings for this guild
const settings = await api.getServerSettings(guild.id) || {};
const liveSettings = settings.liveNotifications || {};
if (!liveSettings.enabled) return;
const channelId = liveSettings.channelId;
const users = (liveSettings.kickUsers || []).map(u => u.toLowerCase()).filter(Boolean);
if (!channelId || users.length === 0) return;
// Check which users are live
const live = await checkKickStreamsForGuild(guild.id, users);
if (!live || live.length === 0) {
// No live streams: ensure any announced keys for these users are cleared so they can be re-announced later
for (const u of users) {
const key = `${guild.id}:${u}`;
if (announced.has(key)) {
announced.delete(key);
}
}
return;
}
// Fetch channel using client to ensure we can reach it
let channel = null;
try {
channel = await client.channels.fetch(channelId);
if (channel.type !== 0) { // 0 is text channel
console.error(`KickWatcher: channel ${channelId} is not a text channel (type: ${channel.type})`);
channel = null;
}
} catch (e) {
console.error(`KickWatcher: failed to fetch channel ${channelId}:`, e && e.message ? e.message : e);
channel = null;
}
if (!channel) {
return;
}
// Build a map of live logins for quick lookup
const liveLogins = new Set(live.map(s => (s.user_login || '').toLowerCase()));
// Clear announced entries for users that are no longer live
for (const u of users) {
const key = `${guild.id}:${u}`;
if (!liveLogins.has(u) && announced.has(key)) {
announced.delete(key);
}
}
// Announce each live once per live session
for (const s of live) {
const login = (s.user_login || '').toLowerCase();
const key = `${guild.id}:${login}`;
if (announced.has(key)) continue; // already announced for this live session
// Mark announced for this session
announced.set(key, { started_at: s.started_at || new Date().toISOString() });
// Build and send embed (similar to Twitch but with Kick branding)
try {
const { EmbedBuilder } = require('discord.js');
const embed = new EmbedBuilder()
.setColor(0x53FC18) // Kick green color
.setTitle(s.title || `${s.user_name} is live`)
.setURL(s.url)
.setAuthor({ name: s.user_name, iconURL: s.thumbnail_url || undefined, url: s.url })
.setThumbnail(s.thumbnail_url || undefined)
.addFields(
{ name: 'Category', value: s.category || 'Unknown', inline: true },
{ name: 'Viewers', value: String(s.viewer_count || 0), inline: true }
)
.setDescription((s.description || '').slice(0, 200))
.setFooter({ text: `ehchadservices • Started: ${s.started_at ? new Date(s.started_at).toLocaleString() : 'unknown'}` });
// Determine message text (custom overrides default)
let prefixMsg = '';
if (liveSettings.customMessage) {
prefixMsg = liveSettings.customMessage;
} else if (liveSettings.message) {
prefixMsg = liveSettings.message;
} else {
prefixMsg = `🟢 ${s.user_name} is now live on Kick!`;
}
const payload = prefixMsg ? { content: prefixMsg, embeds: [embed] } : { embeds: [embed] };
await channel.send(payload);
console.log(`🔔 Announced Kick live: ${login} - ${(s.title || '').slice(0, 80)}`);
} catch (e) {
console.error(`KickWatcher: failed to send announcement for ${login}:`, e && e.message ? e.message : e);
// Fallback to simple message
const msg = `🟢 ${s.user_name} is live on Kick: **${s.title}**\nWatch: ${s.url}`;
try {
await channel.send({ content: msg });
console.log('KickWatcher: fallback message sent');
} catch (err) {
console.error('KickWatcher: fallback send failed:', err && err.message ? err.message : err);
}
}
}
} catch (e) {
console.error('Error checking guild for Kick live streams:', e && e.message ? e.message : e);
}
}
async function poll(client) {
if (polling) return;
polling = true;
console.log(`🔁 KickWatcher started, polling every ${Math.round(pollIntervalMs/1000)}s`);
// Initial check on restart: send messages for currently live users
try {
const guilds = Array.from(client.guilds.cache.values());
for (const g of guilds) {
await checkGuild(client, g).catch(err => {
console.error('KickWatcher: initial checkGuild error', err && err.message ? err.message : err);
});
}
} catch (e) {
console.error('Error during initial Kick check:', e && e.message ? e.message : e);
}
while (polling) {
try {
const guilds = Array.from(client.guilds.cache.values());
for (const g of guilds) {
await checkGuild(client, g).catch(err => {
console.error('KickWatcher: checkGuild error', err && err.message ? err.message : err);
});
}
} catch (e) {
console.error('Error during Kick poll loop:', e && e.message ? e.message : e);
}
await new Promise(r => setTimeout(r, pollIntervalMs));
}
}
function stop() { polling = false; }
module.exports = { poll, stop };

View File

@@ -11,7 +11,8 @@
"dependencies": {
"crypto-js": "^4.2.0",
"discord.js": "^14.22.1",
"dotenv": "^16.4.5"
"dotenv": "^16.4.5",
"node-fetch": "^2.6.7"
}
},
"node_modules/@discordjs/builders": {
@@ -314,6 +315,32 @@
"integrity": "sha512-ThQLOhN86ZkJ7qemtVRGYM+gRgR8GEXNli9H/PMvpnZsE44Xfh3wx9kGJaldg314v85m+bFW6WBMaVHJc/c3zA==",
"license": "MIT"
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/ts-mixer": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz",
@@ -341,6 +368,22 @@
"integrity": "sha512-Ov2Rr9Sx+fRgagJ5AX0qvItZG/JKKoBRAVITs1zk7IqZGTJUwgUr7qoYBpWwakpWilTZFM98rG/AFRocu10iIQ==",
"license": "MIT"
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",

View File

@@ -13,5 +13,6 @@
"crypto-js": "^4.2.0",
"discord.js": "^14.22.1",
"dotenv": "^16.4.5"
,"node-fetch": "^2.6.7"
}
}

View File

@@ -0,0 +1,314 @@
const api = require('./api');
const fetch = require('node-fetch');
// Twitch API credentials (optional). If provided, we'll enrich embeds with user bio.
const twitchClientId = process.env.TWITCH_CLIENT_ID || null;
const twitchClientSecret = process.env.TWITCH_CLIENT_SECRET || null;
let twitchAppToken = null; // cached app access token
let twitchTokenExpires = 0;
// Cache of user login -> { description, profile_image_url, fetchedAt }
const userInfoCache = new Map();
async function getAppToken() {
if (!twitchClientId || !twitchClientSecret) return null;
const now = Date.now();
if (twitchAppToken && now < twitchTokenExpires - 60000) { // refresh 1 min early
return twitchAppToken;
}
try {
const res = await fetch(`https://id.twitch.tv/oauth2/token?client_id=${twitchClientId}&client_secret=${twitchClientSecret}&grant_type=client_credentials`, { method: 'POST' });
const json = await res.json();
twitchAppToken = json.access_token;
twitchTokenExpires = now + (json.expires_in * 1000);
return twitchAppToken;
} catch (e) {
console.error('Failed to fetch Twitch app token:', e && e.message ? e.message : e);
return null;
}
}
async function fetchUserInfo(login) {
if (!login) return null;
const lower = login.toLowerCase();
const cached = userInfoCache.get(lower);
const now = Date.now();
if (cached && now - cached.fetchedAt < 1000 * 60 * 30) { // 30 min cache
return cached;
}
const token = await getAppToken();
if (!token) return null;
try {
const res = await fetch(`https://api.twitch.tv/helix/users?login=${encodeURIComponent(lower)}`, {
headers: {
'Client-ID': twitchClientId,
'Authorization': `Bearer ${token}`
}
});
const json = await res.json();
const data = (json.data && json.data[0]) || null;
if (data) {
const info = { description: data.description || '', profile_image_url: data.profile_image_url || '', fetchedAt: now };
userInfoCache.set(lower, info);
return info;
}
} catch (e) {
console.error('Failed to fetch Twitch user info for', lower, e && e.message ? e.message : e);
}
return null;
}
let polling = false;
const pollIntervalMs = Number(process.env.TWITCH_POLL_INTERVAL_MS || 5000); // 5s default
const debugMode = false; // Debug logging disabled
// Keep track of which streams we've already announced per guild:user -> { started_at }
const announced = new Map(); // key: `${guildId}:${user}` -> { started_at }
async function checkGuild(client, guild) {
const guildId = guild.id;
const guildName = guild.name;
try {
if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Checking guild ${guildName} (${guildId})`);
const settings = await api.getServerSettings(guildId) || {};
const liveSettings = settings.liveNotifications || {};
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 (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 && 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 = (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 = `${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;
}
// fetch channel using client to ensure we can reach it
let channel = null;
try {
channel = await client.channels.fetch(channelId);
if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Successfully fetched channel ${channel.name} (${channelId}) in ${guildName}`);
if (channel.type !== 0) { // 0 is text channel
console.error(`❌ TwitchWatcher: Channel ${channelId} in ${guildName} is not a text channel (type: ${channel.type})`);
channel = null;
} else {
// Check if bot has permission to send messages
const permissions = channel.permissionsFor(client.user);
if (!permissions || !permissions.has('SendMessages')) {
console.error(`❌ TwitchWatcher: Bot lacks SendMessages permission in channel ${channel.name} (${channelId}) for ${guildName}`);
channel = null;
} else if (debugMode) {
console.log(`🔍 [DEBUG] TwitchWatcher: Bot has SendMessages permission in ${channel.name}`);
}
}
} catch (e) {
console.error(`❌ TwitchWatcher: Failed to fetch channel ${channelId} 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;
}
// Build a map of live logins for quick lookup
const liveLogins = new Set(live.map(s => (s.user_login || '').toLowerCase()));
// Clear announced entries for users that are no longer live
for (const u of users) {
const key = `${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 = `${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 {
const { EmbedBuilder } = require('discord.js');
// Attempt to enrich with user bio (description) if available
let bio = '';
try {
const info = await fetchUserInfo(login);
if (info && info.description) bio = info.description.slice(0, 200);
if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Fetched user info for ${login} - bio length: ${bio.length}`);
} catch (e) {
if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Failed to fetch user info for ${login}:`, e && e.message ? e.message : e);
}
const embed = new EmbedBuilder()
.setColor('#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)
.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'}` });
// Determine message text (custom overrides default). Provide a plain text prefix if available.
let prefixMsg = '';
if (liveSettings.customMessage) {
prefixMsg = liveSettings.customMessage;
} else if (liveSettings.message) {
prefixMsg = liveSettings.message;
} else {
prefixMsg = `🔴 ${s.user_name} is now live!`;
}
// Replace template variables in custom messages
prefixMsg = prefixMsg
.replace(/\{user\}/g, s.user_name || login)
.replace(/\{title\}/g, s.title || 'Untitled Stream')
.replace(/\{category\}/g, s.game_name || 'Unknown')
.replace(/\{viewers\}/g, String(s.viewer_count || 0));
if (debugMode) {
console.log(`🔍 [DEBUG] TwitchWatcher: Sending announcement for ${login} in ${guildName} to #${channel.name}`);
console.log(`🔍 [DEBUG] TwitchWatcher: Message content: "${prefixMsg}"`);
}
// Ensure we always hyperlink the title via embed; prefix is optional add above embed
const payload = prefixMsg ? { content: prefixMsg, embeds: [embed] } : { embeds: [embed] };
await channel.send(payload);
console.log(`🔔 TwitchWatcher: Successfully announced ${login} in ${guildName} - "${(s.title || '').slice(0, 80)}"`);
} catch (e) {
console.error(`❌ TwitchWatcher: Failed to send announcement for ${login} in ${guildName}:`, e && e.message ? e.message : e);
// fallback
const msg = `🔴 ${s.user_name} is live: **${s.title}**\nWatch: ${s.url}`;
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(`❌ 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${debugMode ? ' (DEBUG MODE ENABLED)' : ''}`);
// Initial check on restart: send messages for currently live users
try {
const guilds = Array.from(client.guilds.cache.values());
if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Initial check for ${guilds.length} guilds`);
for (const g of guilds) {
if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Initial check for guild ${g.name} (${g.id})`);
await checkGuild(client, g).catch(err => {
console.error(`❌ TwitchWatcher: Initial checkGuild error for ${g.name}:`, err && err.message ? err.message : err);
});
}
} catch (e) {
console.error('❌ TwitchWatcher: Error during initial twitch check:', e && e.message ? e.message : e);
}
while (polling) {
try {
const guilds = Array.from(client.guilds.cache.values());
if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Polling cycle starting for ${guilds.length} guilds`);
for (const g of guilds) {
await checkGuild(client, g).catch(err => {
console.error(`❌ TwitchWatcher: checkGuild error 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('❌ TwitchWatcher: Error during twitch poll loop:', e && e.message ? e.message : e);
}
await new Promise(r => setTimeout(r, pollIntervalMs));
}
}
function stop() { polling = false; }
module.exports = { poll, stop };

View File

@@ -1,68 +0,0 @@
# Error Log
## ENOENT: no such file or directory, open 'F:\Projects\Github\ECS-Discordweb\discord-bot\events\...ackenddb.json'
**Context:** This error occurs in the `guildMemberAdd` and `guildMemberRemove` event handlers in the Discord bot when trying to read the `db.json` file.
**Initial Fix Attempts:**
1. **`path.join` with relative path:** Initially, the path was constructed using `path.join(__dirname, '..\..\backend\db.json')`. This was incorrect as it didn't go up enough directories.
2. **`path.join` with corrected relative path:** The path was corrected to `path.join(__dirname, '..\..\..\backend\db.json')`. This seemed logically correct, but still resulted in the same error, with a strange `......` in the error path.
3. **`path.resolve`:** The path construction was changed to use `path.resolve(__dirname, '..\..\..\backend\db.json')` to ensure an absolute path was resolved. This also failed with the same error.
**Root Cause Analysis:**
The exact cause of the path resolution failure is unclear, but it seems to be related to how `__dirname` and relative paths are handled when the bot module is required by the backend server. The inconsistent working directory might be the source of the problem.
**Solution:**
To provide a more robust and centralized solution, the database path will be managed within the `backend/db.js` file.
1. The `dbPath` will be exported from `backend/db.js`.
2. The `guildMemberAdd.js` and `guildMemberRemove.js` event handlers will import the `dbPath` directly from `backend/db.js`, eliminating the need for path resolution in the event handlers themselves.
## SyntaxError: Unexpected token 'U', "U2FsdGVkX1"... is not valid JSON
**Context:** This error occurs in the `guildMemberAdd` and `guildMemberRemove` event handlers when trying to parse the content of `db.json`.
**Root Cause Analysis:**
The `db.json` file is encrypted, and the event handlers were reading the file content directly and trying to parse it as JSON. The encrypted string starts with "U2FsdGVkX1", which is not a valid JSON token, causing the `JSON.parse` to fail.
**Solution:**
The `backend/db.js` module already provides a `readDb` function that handles reading the file and decrypting its content. The event handlers must be updated to use this function instead of reading the file directly. This ensures that the encrypted data is correctly decrypted before being parsed as JSON.
## Outdated Discord.js Practices
**Context:** The `discord-bot` is using a slightly outdated version of `discord.js` (`^14.15.3` instead of the latest `^14.22.1`). Additionally, the `package.json` includes `@discordjs/rest` and `discord-api-types` as explicit dependencies, which are now bundled with `discord.js` v14 and can cause conflicts.
**Solution:**
1. Update the `discord.js` dependency to the latest stable version (`^14.22.1`).
2. Remove the `@discordjs/rest` and `discord-api-types` dependencies from `package.json`.
3. Run `npm install` to apply the changes and ensure all packages are up-to-date.
## Error: Cannot find module 'discord-api-types/v9'
**Context:** After removing the `discord-api-types` package, the application fails to start due to a missing module error in `discord-bot/deploy-commands.js`.
**Root Cause Analysis:**
The `deploy-commands.js` file was still referencing `discord-api-types/v9` for API-related enums or types. Since this package is no longer an explicit dependency (as it is bundled with `discord.js` v14), the import fails.
**Solution:**
The `deploy-commands.js` file needs to be updated to import the necessary modules, such as `Routes`, directly from `discord.js` instead of the now-removed `discord-api-types` package.
## DiscordAPIError[10004]: Unknown Guild
**Context:** The backend throws an "Unknown Guild" error when the frontend tries to fetch the channels for a server that the bot is not a member of.
**Root Cause Analysis:**
The `/api/servers/:guildId/channels` endpoint was attempting to fetch guild information regardless of whether the bot was actually in that guild. This caused a Discord API error when the guild was not found.
**Solution:**
The endpoint will be updated to first check if the guild exists in the bot's cache using `bot.client.guilds.cache.get(guildId)`. If the guild is not found, it will return an empty array, preventing the API error.

16
frontend/.env.example Normal file
View File

@@ -0,0 +1,16 @@
# Example frontend/.env for developing behind an HTTPS domain and Nginx Proxy Manager
# Bind the dev server to the machine/Tailscale IP you want (or omit to use localhost)
HOST=0.0.0.0
PORT=3001
# Production API base (use https when serving through nginx with TLS)
REACT_APP_API_BASE=https://discordbot.YOURDOMAIN.com
# Force CRA dev client to use secure websocket to the domain
WDS_SOCKET_PROTOCOL=wss
WDS_SOCKET_HOST=discordbot.YOURDOMAIN.com
WDS_SOCKET_PORT=443
WDS_SOCKET_PATH=/ws
# If CRA uses sockjs-node, set WDS_SOCKET_PATH accordingly:
# WDS_SOCKET_PATH=/sockjs-node

View File

@@ -1,33 +1,100 @@
import React from 'react';
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom';
import React, { useEffect } from 'react';
import { BrowserRouter as Router, Route, Routes, useLocation } from 'react-router-dom';
import { ThemeProvider } from './contexts/ThemeContext';
import { UserProvider } from './contexts/UserContext';
import { CssBaseline } from '@mui/material';
import { BackendProvider, useBackend } from './contexts/BackendContext';
import { CssBaseline, Box } from '@mui/material';
import Login from './components/Login';
import Dashboard from './components/Dashboard';
import ServerSettings from './components/ServerSettings';
import ServerSettings from './components/server/ServerSettings';
import NavBar from './components/NavBar';
import HelpPage from './components/HelpPage';
import HelpPage from './components/server/HelpPage';
import DiscordPage from './components/DiscordPage';
import MaintenancePage from './components/common/MaintenancePage';
import Footer from './components/common/Footer';
function AppInner() {
const { backendOnline, checking, forceCheck } = useBackend();
const handleRetry = async () => {
await forceCheck();
};
return (
<UserProvider>
<ThemeProvider>
<CssBaseline />
<Box
sx={{
display: 'flex',
flexDirection: 'column',
minHeight: '100vh',
}}
>
<Router>
<TitleSetter />
{!backendOnline ? (
<MaintenancePage onRetry={handleRetry} checking={checking} />
) : (
<>
<NavBar />
<Box
component="main"
sx={{
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
}}
>
<Routes>
<Route path="/" element={<Login />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/server/:guildId" element={<ServerSettings />} />
<Route path="/server/:guildId/help" element={<HelpPage />} />
<Route path="/discord" element={<DiscordPage />} />
</Routes>
</Box>
<Footer />
</>
)}
</Router>
</Box>
</ThemeProvider>
</UserProvider>
);
}
function App() {
return (
<UserProvider>
<ThemeProvider>
<CssBaseline />
<Router>
<NavBar />
<Routes>
<Route path="/" element={<Login />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/server/:guildId" element={<ServerSettings />} />
<Route path="/server/:guildId/help" element={<HelpPage />} />
<Route path="/discord" element={<DiscordPage />} />
</Routes>
</Router>
<BackendProvider>
<AppInner />
</BackendProvider>
</ThemeProvider>
</UserProvider>
);
}
export default App;
// small helper component to set the browser tab title based on current route
function TitleSetter() {
const location = useLocation();
useEffect(() => {
// derive a friendly page name from the path
const path = location.pathname || '/';
let page = 'App';
if (path === '/' || path === '/login') page = 'Login';
else if (path.startsWith('/dashboard')) page = 'Dashboard';
else if (path.startsWith('/server/') && path.endsWith('/help')) page = 'Server Help';
else if (path.startsWith('/server/')) page = 'Server Settings';
else if (path.startsWith('/discord')) page = 'Discord';
else page = path.replace('/', '').split('/').map(p => p.charAt(0).toUpperCase() + p.slice(1)).join(' - ');
document.title = `ECS - ${page}`;
}, [location]);
return null;
}

View File

@@ -1,90 +1,141 @@
import React, { useState, useEffect, useLayoutEffect, useContext } from 'react';
import { useNavigate } from 'react-router-dom';
import { Grid, Card, CardContent, Typography, Box, CardMedia, IconButton, Snackbar, Alert } from '@mui/material';
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, Container } from '@mui/material';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import { UserContext } from '../contexts/UserContext';
import { useBackend } from '../contexts/BackendContext';
import PersonAddIcon from '@mui/icons-material/PersonAdd';
import RemoveCircleOutlineIcon from '@mui/icons-material/RemoveCircleOutline';
import axios from 'axios';
import DashboardIcon from '@mui/icons-material/Dashboard';
import WavingHandIcon from '@mui/icons-material/WavingHand';
import { get, post } from '../lib/api';
import ConfirmDialog from './ConfirmDialog';
import ConfirmDialog from './common/ConfirmDialog';
const Dashboard = () => {
const navigate = useNavigate();
const location = useLocation();
const { user, setUser } = useContext(UserContext);
const { eventTarget } = useBackend();
const [guilds, setGuilds] = useState([]);
const [botStatus, setBotStatus] = useState({});
const [snackbarOpen, setSnackbarOpen] = useState(false);
const [snackbarMessage, setSnackbarMessage] = useState('');
const [dialogOpen, setDialogOpen] = useState(false);
const [selectedGuild, setSelectedGuild] = useState(null);
const navigate = useNavigate();
const [menuAnchor, setMenuAnchor] = useState(null);
const [menuGuild, setMenuGuild] = useState(null);
// Live notifications are managed on the Server Settings page; keep dashboard lightweight
const API_BASE = process.env.REACT_APP_API_BASE || '';
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const userParam = urlParams.get('user');
const guildsParam = urlParams.get('guilds');
if (userParam && guildsParam) {
const parsedUser = JSON.parse(decodeURIComponent(userParam));
const parsedGuilds = JSON.parse(decodeURIComponent(guildsParam));
localStorage.setItem('user', JSON.stringify(parsedUser));
localStorage.setItem('guilds', JSON.stringify(parsedGuilds));
setUser(parsedUser);
setGuilds(parsedGuilds);
// Clean the URL
window.history.replaceState({}, document.title, "/dashboard");
} else {
const storedUser = localStorage.getItem('user');
const storedGuilds = localStorage.getItem('guilds');
if (storedUser && storedGuilds) {
setUser(JSON.parse(storedUser));
setGuilds(JSON.parse(storedGuilds));
const params = new URLSearchParams(location.search);
const userParam = params.get('user');
if (userParam) {
try {
const parsedUser = JSON.parse(decodeURIComponent(userParam));
setUser(parsedUser);
localStorage.setItem('user', JSON.stringify(parsedUser));
} catch (err) {
console.error("Failed to parse user from URL", err);
}
}
}, [setUser]);
const guildsParam = params.get('guilds');
if (guildsParam) {
try {
const parsedGuilds = JSON.parse(decodeURIComponent(guildsParam));
setGuilds(parsedGuilds || []);
localStorage.setItem('guilds', JSON.stringify(parsedGuilds || []));
} catch (err) {
// ignore
}
} else {
const storedGuilds = localStorage.getItem('guilds');
if (storedGuilds) {
try {
setGuilds(JSON.parse(storedGuilds));
} catch (err) {
setGuilds([]);
}
}
}
}, [location.search, setUser]);
// Protect this route: if no user in context or localStorage, redirect to login
useEffect(() => {
if (!user) {
const stored = localStorage.getItem('user');
if (!stored) {
navigate('/');
} else {
try { setUser(JSON.parse(stored)); } catch (e) { navigate('/'); }
}
}
}, [user, navigate, setUser]);
useEffect(() => {
const fetchBotStatus = async () => {
const statusPromises = guilds.map(async (guild) => {
if (!guilds || guilds.length === 0) return;
const fetchStatuses = async () => {
const statuses = {};
await Promise.all(guilds.map(async (g) => {
try {
const response = await axios.get(`http://localhost:3002/api/servers/${guild.id}/bot-status`);
return { guildId: guild.id, isBotInServer: response.data.isBotInServer };
} catch (error) {
console.error(`Error fetching bot status for guild ${guild.id}:`, error);
return { guildId: guild.id, isBotInServer: false };
const resp = await get(`/api/servers/${g.id}/bot-status`);
statuses[g.id] = resp.data.isBotInServer;
} catch (err) {
statuses[g.id] = false;
}
});
const results = await Promise.all(statusPromises);
const newBotStatus = results.reduce((acc, curr) => {
acc[curr.guildId] = curr.isBotInServer;
return acc;
}, {});
setBotStatus(newBotStatus);
}));
setBotStatus(statuses);
};
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
}));
};
if (guilds.length > 0) {
fetchBotStatus();
}
}, [guilds]);
eventTarget.addEventListener('botStatusUpdate', onBotStatusUpdate);
useLayoutEffect(() => {
const scrollPosition = sessionStorage.getItem('scrollPosition');
if (scrollPosition) {
window.scrollTo(0, parseInt(scrollPosition));
sessionStorage.removeItem('scrollPosition');
}
}, []);
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
const handleCardClick = (guild) => {
sessionStorage.setItem('scrollPosition', window.scrollY);
navigate(`/server/${guild.id}`, { state: { guild } });
};
const handleInviteBot = async (e, guild) => {
const handleInviteBot = (e, guild) => {
e.stopPropagation();
const clientId = '1423377662055026840'; // Hardcoded client ID from user request
const permissions = 8; // Administrator
const inviteUrl = `https://discord.com/api/oauth2/authorize?client_id=${clientId}&permissions=${permissions}&scope=bot%20applications.commands&guild_id=${guild.id}&disable_guild_select=true`;
window.open(inviteUrl, '_blank', 'noopener,noreferrer');
get('/api/client-id').then(resp => {
const clientId = resp.data.clientId;
if (!clientId) {
setSnackbarMessage('No client ID available');
setSnackbarOpen(true);
return;
}
const permissions = 8; // admin
const url = `https://discord.com/api/oauth2/authorize?client_id=${clientId}&permissions=${permissions}&scope=bot%20applications.commands&guild_id=${guild.id}&disable_guild_select=true`;
window.open(url, '_blank');
}).catch(() => {
setSnackbarMessage('Failed to fetch client id');
setSnackbarOpen(true);
});
};
const handleLeaveBot = (e, guild) => {
@@ -96,122 +147,163 @@ const Dashboard = () => {
const handleConfirmLeave = async () => {
if (!selectedGuild) return;
try {
await axios.post(`http://localhost:3002/api/servers/${selectedGuild.id}/leave`);
setBotStatus(prevStatus => ({ ...prevStatus, [selectedGuild.id]: false }));
setSnackbarMessage('Bot has left the server.');
await post(`/api/servers/${selectedGuild.id}/leave`);
setBotStatus(prev => ({ ...prev, [selectedGuild.id]: false }));
setSnackbarMessage('Bot left the server');
setSnackbarOpen(true);
} catch (error) {
console.error('Error leaving server:', error);
setSnackbarMessage('Failed to make the bot leave the server.');
} catch (err) {
setSnackbarMessage('Failed to leave server');
setSnackbarOpen(true);
}
setDialogOpen(false);
setSelectedGuild(null);
};
const handleSnackbarClose = (event, reason) => {
if (reason === 'clickaway') {
return;
}
setSnackbarOpen(false);
};
const handleSnackbarClose = () => setSnackbarOpen(false);
return (
<div style={{ padding: '20px' }}>
{/* UserSettings moved to NavBar */}
<Typography variant="h4" gutterBottom>
Dashboard
</Typography>
{user && (
<Typography variant="h5" gutterBottom>
Welcome, {user.username}
</Typography>
)}
<Typography variant="h6" gutterBottom>
Your Admin Servers:
</Typography>
<Grid container spacing={3}>
{guilds.map(guild => (
<Grid item xs={12} sm={6} md={4} lg={3} key={guild.id}>
<Card
onClick={() => handleCardClick(guild)}
<Container maxWidth="lg" sx={{ padding: { xs: 2, sm: 3, md: 5 } }}>
<Box sx={{ mb: 2 }}>
<Box>
<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={{
cursor: 'pointer',
borderRadius: '20px',
boxShadow: '0 8px 16px 0 rgba(0,0,0,0.2)',
transition: 'transform 0.3s',
height: '250px',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
'&:hover': {
transform: 'scale(1.05)'
}
fontWeight: 300,
color: 'text.secondary'
}}
>
<CardMedia
component="img"
sx={{ height: '60%', objectFit: 'cover' }}
image={guild.icon ? `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png` : 'https://cdn.discordapp.com/embed/avatars/0.png'}
alt={guild.name}
/>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexDirection: { xs: 'column', sm: 'row' } }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, width: '100%', justifyContent: { xs: 'center', sm: 'flex-start' } }}>
<Box
title={guild.name}
sx={{
px: 2,
py: 0.5,
borderRadius: '999px',
fontWeight: 'bold',
bgcolor: 'rgba(0,0,0,0.06)',
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
textAlign: { xs: 'center', sm: 'left' }
}}
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={6} lg={4} key={guild.id} sx={{ display: 'flex', justifyContent: 'center' }}>
<Box sx={{ display: 'flex', justifyContent: 'center', width: '100%' }}>
<Card
onClick={() => handleCardClick(guild)}
sx={{
cursor: 'pointer',
borderRadius: '16px',
boxShadow: '0 8px 16px rgba(0,0,0,0.2)',
transition: 'transform 0.2s ease-in-out, box-shadow 0.2s',
'&:hover': {
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',
flexDirection: 'column',
justifyContent: 'space-between',
overflow: 'hidden',
}}
>
<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: { 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,
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 />}
sx={{ fontSize: { xs: '0.75rem', sm: '0.875rem' } }}
>
Leave
</Button>
) : (
<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"
sx={{ ml: { xs: 0, sm: 1 } }}
>
{guild.name}
</Box>
<MoreVertIcon />
</IconButton>
</Box>
{botStatus[guild.id] ? (
<IconButton
aria-label={`Make bot leave ${guild.name}`}
size="small"
onClick={(e) => handleLeaveBot(e, guild)}
>
<RemoveCircleOutlineIcon />
</IconButton>
) : (
<IconButton
aria-label={`Invite bot to ${guild.name}`}
size="small"
onClick={(e) => handleInviteBot(e, guild)}
>
<PersonAddIcon />
</IconButton>
)}
</Box>
</CardContent>
</Card>
</CardContent>
</Card>
</Box>
</Grid>
))}
</Grid>
<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>
<Menu anchorEl={menuAnchor} open={!!menuAnchor} onClose={() => { setMenuAnchor(null); setMenuGuild(null); }}>
<MenuItem onClick={() => { setMenuAnchor(null); if (menuGuild) window.location.href = `/server/${menuGuild.id}`; }}>Open Server Settings</MenuItem>
</Menu>
{/* Live Notifications dialog removed from Dashboard — available on Server Settings page */}
<Snackbar open={snackbarOpen} autoHideDuration={6000} onClose={handleSnackbarClose}>
<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}?`}
/>
</Container>
);
};

View File

@@ -13,7 +13,8 @@ const Login = () => {
}, [navigate]);
const handleLogin = () => {
window.location.href = 'http://localhost:3002/auth/discord';
const API_BASE = process.env.REACT_APP_API_BASE || '';
window.location.href = `${API_BASE}/auth/discord`;
};
return (

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 }} onClick={() => { navigate('/dashboard'); closeMenu(); }}>
ECS - EHDCHADSWORTH
<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(); }}>
EhChadServices
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>

View File

@@ -1,427 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate, useLocation } from 'react-router-dom';
import axios from 'axios';
import { Button, Typography, Box, IconButton, Switch, Select, MenuItem, FormControl, FormControlLabel, Radio, RadioGroup, TextField, Accordion, AccordionSummary, AccordionDetails } from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
// UserSettings moved to NavBar
import ConfirmDialog from './ConfirmDialog';
const ServerSettings = () => {
const { guildId } = useParams();
const navigate = useNavigate();
const location = useLocation();
const [settings, setSettings] = useState({ pingCommand: false });
const [isBotInServer, setIsBotInServer] = useState(false);
const [clientId, setClientId] = useState(null);
const [server, setServer] = useState(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [channels, setChannels] = useState([]);
const [roles, setRoles] = useState([]);
const [autoroleSettings, setAutoroleSettings] = useState({
enabled: false,
roleId: '',
});
const [commandsList, setCommandsList] = useState([]);
const [commandsExpanded, setCommandsExpanded] = useState(false);
const [welcomeLeaveSettings, setWelcomeLeaveSettings] = useState({
welcome: {
enabled: false,
channel: '',
message: 'Welcome to the server, {user}!',
customMessage: '',
},
leave: {
enabled: false,
channel: '',
message: '{user} has left the server.',
customMessage: '',
},
});
const defaultWelcomeMessages = ["Welcome to the server, {user}!", "Hey {user}, welcome!", "{user} has joined the party!"];
const defaultLeaveMessages = ["{user} has left the server.", "Goodbye, {user}.", "We'll miss you, {user}."];
useEffect(() => {
if (location.state && location.state.guild) {
setServer(location.state.guild);
} else {
// Fallback if guild data is not passed in state
const storedGuilds = localStorage.getItem('guilds');
if (storedGuilds) {
const guild = JSON.parse(storedGuilds).find(g => g.id === guildId);
setServer(guild);
}
}
// Fetch settings
axios.get(`http://localhost:3002/api/servers/${guildId}/settings`)
.then(response => {
setSettings(response.data);
});
// Check if bot is in server
axios.get(`http://localhost:3002/api/servers/${guildId}/bot-status`)
.then(response => {
setIsBotInServer(response.data.isBotInServer);
});
// Fetch client ID
axios.get('http://localhost:3002/api/client-id')
.then(response => {
setClientId(response.data.clientId);
});
// Fetch channels
axios.get(`http://localhost:3002/api/servers/${guildId}/channels`)
.then(response => {
setChannels(response.data);
});
// Fetch welcome/leave settings
axios.get(`http://localhost:3002/api/servers/${guildId}/welcome-leave-settings`)
.then(response => {
if (response.data) {
setWelcomeLeaveSettings(response.data);
}
});
// Fetch roles
axios.get(`http://localhost:3002/api/servers/${guildId}/roles`)
.then(response => {
setRoles(response.data);
});
// Fetch autorole settings
axios.get(`http://localhost:3002/api/servers/${guildId}/autorole-settings`)
.then(response => {
if (response.data) {
setAutoroleSettings(response.data);
}
});
// Fetch commands/help list
axios.get(`http://localhost:3002/api/servers/${guildId}/commands`)
.then(response => {
setCommandsList(response.data || []);
})
.catch(() => setCommandsList([]));
// Open commands accordion if navigated from Help back button
if (location.state && location.state.openCommands) {
setCommandsExpanded(true);
}
}, [guildId, location.state]);
const handleAutoroleSettingUpdate = (newSettings) => {
axios.post(`http://localhost:3002/api/servers/${guildId}/autorole-settings`, newSettings)
.then(response => {
if (response.data.success) {
setAutoroleSettings(newSettings);
}
});
};
const handleAutoroleToggleChange = (event) => {
const newSettings = { ...autoroleSettings, enabled: event.target.checked };
handleAutoroleSettingUpdate(newSettings);
};
const handleAutoroleRoleChange = (event) => {
const newSettings = { ...autoroleSettings, roleId: event.target.value };
handleAutoroleSettingUpdate(newSettings);
};
const handleSettingUpdate = (newSettings) => {
axios.post(`http://localhost:3002/api/servers/${guildId}/welcome-leave-settings`, newSettings)
.then(response => {
if (response.data.success) {
setWelcomeLeaveSettings(newSettings);
}
});
}
const handleToggleChange = (type) => (event) => {
const newSettings = { ...welcomeLeaveSettings };
newSettings[type].enabled = event.target.checked;
handleSettingUpdate(newSettings);
};
const handleChannelChange = (type) => (event) => {
const newSettings = { ...welcomeLeaveSettings };
newSettings[type].channel = event.target.value;
handleSettingUpdate(newSettings);
};
const handleMessageOptionChange = (type) => (event) => {
const newSettings = { ...welcomeLeaveSettings };
if (event.target.value !== 'custom') {
newSettings[type].message = event.target.value;
handleSettingUpdate(newSettings);
} else {
const tempSettings = { ...welcomeLeaveSettings };
// Set message to custom message to get the radio button to select custom
tempSettings[type].message = tempSettings[type].customMessage;
setWelcomeLeaveSettings(tempSettings);
}
};
const handleCustomMessageChange = (type) => (event) => {
const newSettings = { ...welcomeLeaveSettings };
newSettings[type].customMessage = event.target.value;
setWelcomeLeaveSettings(newSettings);
};
const handleApplyCustomMessage = (type) => () => {
const newSettings = { ...welcomeLeaveSettings };
newSettings[type].message = newSettings[type].customMessage;
handleSettingUpdate(newSettings);
};
const getMessageValue = (type) => {
const currentMessage = welcomeLeaveSettings[type].message;
const defaultMessages = type === 'welcome' ? defaultWelcomeMessages : defaultLeaveMessages;
if (defaultMessages.includes(currentMessage)) {
return currentMessage;
}
return 'custom';
}
const togglePingCommand = () => {
const newSettings = { ...settings, pingCommand: !settings.pingCommand };
axios.post(`http://localhost:3002/api/servers/${guildId}/settings`, newSettings)
.then(response => {
if (response.data.success) {
setSettings(newSettings);
}
});
};
const handleInviteBot = () => {
if (!clientId) return;
const permissions = 8; // Administrator
const url = `https://discord.com/api/oauth2/authorize?client_id=${clientId}&permissions=${permissions}&scope=bot%20applications.commands&guild_id=${guildId}&disable_guild_select=true`;
window.open(url, '_blank');
};
const handleLeaveBot = () => {
setDialogOpen(true);
};
const handleConfirmLeave = async () => {
try {
await axios.post(`http://localhost:3002/api/servers/${guildId}/leave`);
setIsBotInServer(false);
} catch (error) {
console.error('Error leaving server:', error);
}
setDialogOpen(false);
};
const handleBack = () => {
navigate('/dashboard');
}
return (
<div style={{ padding: '20px' }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<IconButton onClick={handleBack} sx={{ borderRadius: '50%', boxShadow: '0 8px 16px 0 rgba(0,0,0,0.2)' }}>
<ArrowBackIcon />
</IconButton>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Typography variant="h4" component="h1" sx={{ margin: 0 }}>
{server ? `Server Settings for ${server.name}` : 'Loading...'}
</Typography>
{isBotInServer ? (
<Button variant="contained" size="small" color="error" onClick={handleLeaveBot}>
Leave Server
</Button>
) : (
<Button variant="contained" size="small" onClick={handleInviteBot} disabled={!clientId}>
Invite Bot
</Button>
)}
</Box>
{/* UserSettings moved to NavBar */}
</Box>
<Accordion sx={{ marginTop: '20px', opacity: isBotInServer ? 1 : 0.5 }} expanded={commandsExpanded} onChange={() => setCommandsExpanded(prev => !prev)}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="h6">Commands</Typography>
</AccordionSummary>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', padding: 1 }}>
<Button variant="text" onClick={() => navigate(`/server/${guildId}/help`)} disabled={!isBotInServer}>Commands List</Button>
</Box>
<AccordionDetails>
{!isBotInServer && <Typography>Invite the bot to enable commands.</Typography>}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, marginTop: '10px' }}>
{commandsList.map(cmd => (
<Box key={cmd.name} sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: 1, border: '1px solid #eee', borderRadius: 1 }}>
<Box>
<Typography sx={{ fontWeight: 'bold' }}>{cmd.name}</Typography>
<Typography variant="body2">{cmd.description}</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{['help', 'manage-commands'].includes(cmd.name) ? (
<FormControlLabel
control={<Switch checked={true} disabled />}
label="Locked"
/>
) : (
<FormControlLabel
control={<Switch checked={cmd.enabled} onChange={async (e) => {
const newVal = e.target.checked;
// optimistic update
setCommandsList(prev => prev.map(c => c.name === cmd.name ? { ...c, enabled: newVal } : c));
try {
await axios.post(`http://localhost:3002/api/servers/${guildId}/commands/${cmd.name}/toggle`, { enabled: newVal });
} catch (err) {
// revert on error
setCommandsList(prev => prev.map(c => c.name === cmd.name ? { ...c, enabled: cmd.enabled } : c));
}
}} disabled={!isBotInServer} label={cmd.enabled ? 'Enabled' : 'Disabled'} />}
/>
)}
</Box>
</Box>
))}
</Box>
</AccordionDetails>
</Accordion>
{/* Help moved to dedicated Help page */}
<Accordion sx={{ marginTop: '20px', opacity: isBotInServer ? 1 : 0.5 }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="h6">Welcome/Leave</Typography>
</AccordionSummary>
<AccordionDetails>
{!isBotInServer && <Typography>Invite the bot to enable this feature.</Typography>}
<Box sx={{ marginTop: '10px' }}>
<Typography variant="subtitle1">Welcome Messages</Typography>
<FormControlLabel
control={<Switch checked={welcomeLeaveSettings.welcome.enabled} onChange={handleToggleChange('welcome')} disabled={!isBotInServer} />}
label="Enable Welcome Messages"
/>
<FormControl fullWidth sx={{ marginTop: '10px' }} disabled={!isBotInServer || !welcomeLeaveSettings.welcome.enabled}>
<Select
value={welcomeLeaveSettings.welcome.channel}
onChange={handleChannelChange('welcome')}
displayEmpty
>
<MenuItem value="" disabled>Select a channel</MenuItem>
{channels.map(channel => (
<MenuItem key={channel.id} value={channel.id}>{channel.name}</MenuItem>
))}
</Select>
</FormControl>
<FormControl component="fieldset" sx={{ marginTop: '10px' }} disabled={!isBotInServer || !welcomeLeaveSettings.welcome.enabled}>
<RadioGroup
value={getMessageValue('welcome')}
onChange={handleMessageOptionChange('welcome')}
>
{defaultWelcomeMessages.map(msg => (
<FormControlLabel key={msg} value={msg} control={<Radio />} label={msg} />
))}
<FormControlLabel value="custom" control={<Radio />} label="Custom" />
</RadioGroup>
</FormControl>
<Box sx={{ display: 'flex', alignItems: 'center', marginTop: '10px' }} >
<TextField
fullWidth
variant="outlined"
placeholder="Your custom message"
value={welcomeLeaveSettings.welcome.customMessage}
onChange={handleCustomMessageChange('welcome')}
disabled={!isBotInServer || !welcomeLeaveSettings.welcome.enabled || getMessageValue('welcome') !== 'custom'}
/>
<Button onClick={handleApplyCustomMessage('welcome')} disabled={!isBotInServer || !welcomeLeaveSettings.welcome.enabled || getMessageValue('welcome') !== 'custom'}>Apply</Button>
</Box>
</Box>
<Box sx={{ marginTop: '20px' }}>
<Typography variant="subtitle1">Leave Messages</Typography>
<FormControlLabel
control={<Switch checked={welcomeLeaveSettings.leave.enabled} onChange={handleToggleChange('leave')} disabled={!isBotInServer} />}
label="Enable Leave Messages"
/>
<FormControl fullWidth sx={{ marginTop: '10px' }} disabled={!isBotInServer || !welcomeLeaveSettings.leave.enabled}>
<Select
value={welcomeLeaveSettings.leave.channel}
onChange={handleChannelChange('leave')}
displayEmpty
>
<MenuItem value="" disabled>Select a channel</MenuItem>
{channels.map(channel => (
<MenuItem key={channel.id} value={channel.id}>{channel.name}</MenuItem>
))}
</Select>
</FormControl>
<FormControl component="fieldset" sx={{ marginTop: '10px' }} disabled={!isBotInServer || !welcomeLeaveSettings.leave.enabled}>
<RadioGroup
value={getMessageValue('leave')}
onChange={handleMessageOptionChange('leave')}
>
{defaultLeaveMessages.map(msg => (
<FormControlLabel key={msg} value={msg} control={<Radio />} label={msg} />
))}
<FormControlLabel value="custom" control={<Radio />} label="Custom" />
</RadioGroup>
</FormControl>
<Box sx={{ display: 'flex', alignItems: 'center', marginTop: '10px' }} >
<TextField
fullWidth
variant="outlined"
placeholder="Your custom message"
value={welcomeLeaveSettings.leave.customMessage}
onChange={handleCustomMessageChange('leave')}
disabled={!isBotInServer || !welcomeLeaveSettings.leave.enabled || getMessageValue('leave') !== 'custom'}
/>
<Button onClick={handleApplyCustomMessage('leave')} disabled={!isBotInServer || !welcomeLeaveSettings.leave.enabled || getMessageValue('leave') !== 'custom'}>Apply</Button>
</Box>
</Box>
</AccordionDetails>
</Accordion>
<Accordion sx={{ marginTop: '20px', opacity: isBotInServer ? 1 : 0.5 }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="h6">Autorole</Typography>
</AccordionSummary>
<AccordionDetails>
{!isBotInServer && <Typography>Invite the bot to enable this feature.</Typography>}
<Box sx={{ marginTop: '10px' }}>
<FormControlLabel
control={<Switch checked={autoroleSettings.enabled} onChange={handleAutoroleToggleChange} disabled={!isBotInServer} />}
label="Enable Autorole"
/>
<FormControl fullWidth sx={{ marginTop: '10px' }} disabled={!isBotInServer || !autoroleSettings.enabled}>
<Select
value={autoroleSettings.roleId}
onChange={handleAutoroleRoleChange}
displayEmpty
>
<MenuItem value="" disabled>Select a role</MenuItem>
{roles.map(role => (
<MenuItem key={role.id} value={role.id}><span style={{ color: role.color }}>{role.name}</span></MenuItem>
))}
</Select>
</FormControl>
</Box>
</AccordionDetails>
</Accordion>
<Accordion sx={{ marginTop: '20px', opacity: isBotInServer ? 1 : 0.5 }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="h6">Admin Commands</Typography>
</AccordionSummary>
<AccordionDetails>
<Typography>Coming soon...</Typography>
</AccordionDetails>
</Accordion>
<ConfirmDialog
open={dialogOpen}
onClose={() => setDialogOpen(false)}
onConfirm={handleConfirmLeave}
title="Confirm Leave"
message={`Are you sure you want the bot to leave ${server?.name}?`}
/>
</div>
);
};
export default ServerSettings;

View File

@@ -1,4 +1,4 @@
import React, { useState, useContext } from 'react';
import React, { useState, useContext, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Avatar, Menu, MenuItem, Button, Typography } from '@mui/material';
import { UserContext } from '../contexts/UserContext';
@@ -11,6 +11,17 @@ const UserSettings = () => {
const [anchorEl, setAnchorEl] = useState(null);
const [themeMenuAnchorEl, setThemeMenuAnchorEl] = useState(null);
useEffect(() => {
const storedUser = localStorage.getItem('user');
if (storedUser) {
try {
setUser(JSON.parse(storedUser));
} catch (error) {
console.error("Failed to parse user from localStorage", error);
}
}
}, [setUser]);
const handleMenu = (event) => {
setAnchorEl(event.currentTarget);
};

View File

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

View File

@@ -0,0 +1,22 @@
import React from 'react';
import { Box, Typography, Button, CircularProgress } from '@mui/material';
const MaintenancePage = ({ onRetry, checking }) => {
return (
<Box sx={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: 'background.default', p: 3 }}>
<Box sx={{ maxWidth: 640, textAlign: 'center' }}>
<Typography variant="h4" sx={{ mb: 2, fontWeight: 'bold' }}>EhChadServices is currently under maintenance</Typography>
<Typography sx={{ mb: 2 }}>We're checking the service status and will reload automatically when the backend is available. Please check back in a few moments.</Typography>
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: 2, mt: 2 }}>
<CircularProgress size={40} />
<Typography>{checking ? 'Checking...' : 'Idle'}</Typography>
</Box>
<Box sx={{ mt: 3 }}>
<Button variant="contained" onClick={onRetry}>Retry now</Button>
</Box>
</Box>
</Box>
);
};
export default MaintenancePage;

View File

@@ -1,18 +1,22 @@
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate, useLocation } from 'react-router-dom';
import axios from 'axios';
import { useParams, useNavigate } from 'react-router-dom';
import { get } from '../../lib/api';
import { Box, IconButton, Typography } from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
const HelpPage = () => {
const { guildId } = useParams();
const navigate = useNavigate();
const location = useLocation();
const [commands, setCommands] = useState([]);
useEffect(() => {
axios.get(`http://localhost:3002/api/servers/${guildId}/commands`)
.then(res => setCommands(res.data || []))
get(`/api/servers/${guildId}/commands`).then(res => {
const cmds = res.data || [];
// sort: locked commands first (preserve relative order), then others alphabetically
const locked = cmds.filter(c => c.locked);
const others = cmds.filter(c => !c.locked).sort((a, b) => a.name.localeCompare(b.name));
setCommands([...locked, ...others]);
})
.catch(() => setCommands([]));
}, [guildId]);
@@ -22,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>}
@@ -39,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>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,101 @@
import React, { createContext, useContext, useEffect, useRef, useState } from 'react';
const BackendContext = createContext(null);
export function useBackend() {
return useContext(BackendContext);
}
export function BackendProvider({ children }) {
const API_BASE = process.env.REACT_APP_API_BASE || '';
const [backendOnline, setBackendOnline] = useState(false);
const [checking, setChecking] = useState(true);
const esRef = useRef(null);
const eventTargetRef = useRef(new EventTarget());
useEffect(() => {
let mounted = true;
const check = async () => {
if (!mounted) return;
setChecking(true);
try {
const resp = await fetch(`${API_BASE}/api/servers/health`);
if (!mounted) return;
setBackendOnline(!!(resp && resp.ok));
} catch (e) {
if (!mounted) return;
setBackendOnline(false);
} finally {
if (mounted) setChecking(false);
}
};
check();
const iv = setInterval(check, 5000);
return () => { mounted = false; clearInterval(iv); };
}, [API_BASE]);
// Single shared EventSource forwarded into a DOM EventTarget
useEffect(() => {
if (typeof window === 'undefined' || typeof EventSource === 'undefined') return;
const url = `${API_BASE}/api/events`;
let es = null;
try {
es = new EventSource(url);
esRef.current = es;
} catch (err) {
// silently ignore
return;
}
const forward = (type) => (e) => {
try {
const evt = new CustomEvent(type, { detail: e.data ? JSON.parse(e.data) : null });
eventTargetRef.current.dispatchEvent(evt);
} catch (err) {
// ignore parse errors
}
};
es.addEventListener('connected', forward('connected'));
es.addEventListener('commandToggle', forward('commandToggle'));
es.addEventListener('twitchUsersUpdate', forward('twitchUsersUpdate'));
es.addEventListener('liveNotificationsUpdate', forward('liveNotificationsUpdate'));
es.addEventListener('adminLogAdded', forward('adminLogAdded'));
es.addEventListener('adminLogDeleted', forward('adminLogDeleted'));
es.addEventListener('adminLogsCleared', forward('adminLogsCleared'));
es.onerror = () => {
// Let consumers react to backendOnline state changes instead of surfacing connection errors
};
return () => { try { es && es.close(); } catch (e) {} };
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const forceCheck = async () => {
const API_BASE2 = process.env.REACT_APP_API_BASE || '';
try {
setChecking(true);
const resp = await fetch(`${API_BASE2}/api/servers/health`);
setBackendOnline(!!(resp && resp.ok));
} catch (e) {
setBackendOnline(false);
} finally {
setChecking(false);
}
};
const value = {
backendOnline,
checking,
eventTarget: eventTargetRef.current,
forceCheck,
};
return (
<BackendContext.Provider value={value}>
{children}
</BackendContext.Provider>
);
}
export default BackendContext;

View File

@@ -2,7 +2,7 @@ import React, { createContext, useState, useMemo, useContext, useEffect } from '
import { ThemeProvider as MuiThemeProvider } from '@mui/material/styles';
import { lightTheme, darkTheme, discordTheme } from '../themes';
import { UserContext } from './UserContext';
import axios from 'axios';
import { post } from '../lib/api';
export const ThemeContext = createContext();
@@ -11,16 +11,25 @@ export const ThemeProvider = ({ children }) => {
const [themeName, setThemeName] = useState(localStorage.getItem('themeName') || 'discord');
useEffect(() => {
// Prefer an explicit user selection (stored in localStorage) over defaults or server values.
// Behavior:
// - If localStorage has a themeName, use that (user's explicit choice always wins).
// - Else if the authenticated user has a server-side preference, adopt that and persist it locally.
// - Else (first visit, no local choice and no server preference) use default 'discord'.
const storedTheme = localStorage.getItem('themeName');
if (storedTheme) {
setThemeName(storedTheme);
return;
}
if (user && user.theme) {
setThemeName(user.theme);
} else {
const storedTheme = localStorage.getItem('themeName');
if (storedTheme) {
setThemeName(storedTheme);
} else {
setThemeName('discord');
}
localStorage.setItem('themeName', user.theme);
return;
}
// First-time visitor: fall back to default
setThemeName('discord');
}, [user]);
const theme = useMemo(() => {
@@ -36,7 +45,7 @@ export const ThemeProvider = ({ children }) => {
const changeTheme = (name) => {
if (user) {
axios.post('http://localhost:3002/api/user/theme', { userId: user.id, theme: name });
post('/api/user/theme', { userId: user.id, theme: name }).catch(() => {});
}
localStorage.setItem('themeName', name);
setThemeName(name);

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

49
frontend/src/lib/api.js Normal file
View File

@@ -0,0 +1,49 @@
import axios from 'axios';
const API_BASE = process.env.REACT_APP_API_BASE || '';
const client = axios.create({
baseURL: API_BASE,
// optional: set a short timeout for UI requests
timeout: 8000,
});
export async function get(path, config) {
return client.get(path, config);
}
export async function post(path, data, config) {
return client.post(path, data, config);
}
export async function del(path, config) {
return client.delete(path, config);
}
export async function listReactionRoles(guildId) {
const res = await client.get(`/api/servers/${guildId}/reaction-roles`);
return res.data;
}
export async function createReactionRole(guildId, body) {
const res = await client.post(`/api/servers/${guildId}/reaction-roles`, body);
return res.data;
}
export async function deleteReactionRole(guildId, id) {
const res = await client.delete(`/api/servers/${guildId}/reaction-roles/${id}`);
return res.data && res.data.success;
}
export async function postReactionRoleMessage(guildId, rr) {
// instruct backend to have bot post message by asking bot module via internal call
const res = await client.post(`/internal/publish-reaction-role`, { guildId, id: rr.id });
return res.data;
}
export async function updateReactionRole(guildId, id, body) {
const res = await client.put(`/api/servers/${guildId}/reaction-roles/${id}`, body);
return res.data;
}
export default client;

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