Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 61ab1e1d9e | |||
| 8236c1e0e7 | |||
| 900ce85e2c |
136
backend/index.js
136
backend/index.js
@@ -1049,7 +1049,36 @@ app.get('/api/servers/:guildId/invites', async (req, res) => {
|
||||
app.post('/api/servers/:guildId/invites', async (req, res) => {
|
||||
try {
|
||||
const { guildId } = req.params;
|
||||
const { channelId, maxAge, maxUses, temporary } = req.body || {};
|
||||
const { code, url, channelId, maxAge, maxUses, temporary, createdAt } = req.body || {};
|
||||
|
||||
// If code is provided, this is an existing invite to store (from Discord events)
|
||||
if (code) {
|
||||
const item = {
|
||||
code,
|
||||
url: url || `https://discord.gg/${code}`,
|
||||
channelId: channelId || '',
|
||||
createdAt: createdAt || new Date().toISOString(),
|
||||
maxUses: maxUses || 0,
|
||||
maxAge: maxAge || 0,
|
||||
temporary: !!temporary,
|
||||
};
|
||||
|
||||
await pgClient.addInvite({
|
||||
code: item.code,
|
||||
guildId,
|
||||
url: item.url,
|
||||
channelId: item.channelId,
|
||||
createdAt: item.createdAt,
|
||||
maxUses: item.maxUses,
|
||||
maxAge: item.maxAge,
|
||||
temporary: item.temporary
|
||||
});
|
||||
|
||||
res.json({ success: true, invite: item });
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, create a new invite
|
||||
const guild = bot.client.guilds.cache.get(guildId);
|
||||
if (!guild) return res.status(404).json({ success: false, message: 'Guild not found' });
|
||||
|
||||
@@ -1089,7 +1118,7 @@ app.post('/api/servers/:guildId/invites', async (req, res) => {
|
||||
|
||||
res.json({ success: true, invite: item });
|
||||
} catch (error) {
|
||||
console.error('Error creating invite:', error);
|
||||
console.error('Error creating/storing invite:', error);
|
||||
res.status(500).json({ success: false, message: 'Internal Server Error' });
|
||||
}
|
||||
});
|
||||
@@ -1187,6 +1216,75 @@ app.post('/api/servers/:guildId/admin-logs-settings', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// REACTION ROLES: CRUD
|
||||
app.get('/api/servers/:guildId/reaction-roles', async (req, res) => {
|
||||
try {
|
||||
const { guildId } = req.params;
|
||||
const rows = await pgClient.listReactionRoles(guildId);
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
console.error('Error listing reaction roles:', err);
|
||||
res.status(500).json({ success: false, message: 'Internal Server Error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/servers/:guildId/reaction-roles', async (req, res) => {
|
||||
try {
|
||||
const { guildId } = req.params;
|
||||
const { channelId, name, embed, buttons, messageId } = req.body || {};
|
||||
if (!channelId || !name || !embed || !Array.isArray(buttons) || buttons.length === 0) {
|
||||
return res.status(400).json({ success: false, message: 'channelId, name, embed, and at least one button are required' });
|
||||
}
|
||||
const created = await pgClient.createReactionRole({ guildId, channelId, name, embed, buttons, messageId });
|
||||
// publish SSE
|
||||
publishEvent(guildId, 'reactionRolesUpdate', { action: 'create', reactionRole: created });
|
||||
res.json({ success: true, reactionRole: created });
|
||||
} catch (err) {
|
||||
console.error('Error creating reaction role:', err && err.message ? err.message : err);
|
||||
// If the pg helper threw a validation error, return 400 with message
|
||||
if (err && err.message && err.message.startsWith('Invalid reaction role payload')) {
|
||||
return res.status(400).json({ success: false, message: err.message });
|
||||
}
|
||||
res.status(500).json({ success: false, message: 'Internal Server Error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/servers/:guildId/reaction-roles/:id', async (req, res) => {
|
||||
try {
|
||||
const { guildId, id } = req.params;
|
||||
const updates = req.body || {};
|
||||
const existing = await pgClient.getReactionRole(id);
|
||||
if (!existing || existing.guild_id !== guildId) return res.status(404).json({ success: false, message: 'Not found' });
|
||||
const mapped = {
|
||||
channel_id: updates.channelId || existing.channel_id,
|
||||
message_id: typeof updates.messageId !== 'undefined' ? updates.messageId : existing.message_id,
|
||||
name: typeof updates.name !== 'undefined' ? updates.name : existing.name,
|
||||
embed: typeof updates.embed !== 'undefined' ? updates.embed : existing.embed,
|
||||
buttons: typeof updates.buttons !== 'undefined' ? updates.buttons : existing.buttons
|
||||
};
|
||||
const updated = await pgClient.updateReactionRole(id, mapped);
|
||||
publishEvent(guildId, 'reactionRolesUpdate', { action: 'update', reactionRole: updated });
|
||||
res.json({ success: true, reactionRole: updated });
|
||||
} catch (err) {
|
||||
console.error('Error updating reaction role:', err);
|
||||
res.status(500).json({ success: false, message: 'Internal Server Error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/servers/:guildId/reaction-roles/:id', async (req, res) => {
|
||||
try {
|
||||
const { guildId, id } = req.params;
|
||||
const existing = await pgClient.getReactionRole(id);
|
||||
if (!existing || existing.guild_id !== guildId) return res.status(404).json({ success: false, message: 'Not found' });
|
||||
await pgClient.deleteReactionRole(id);
|
||||
publishEvent(guildId, 'reactionRolesUpdate', { action: 'delete', id });
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error('Error deleting reaction role:', err);
|
||||
res.status(500).json({ success: false, message: 'Internal Server Error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/servers/:guildId/admin-logs', async (req, res) => {
|
||||
try {
|
||||
const { guildId } = req.params;
|
||||
@@ -1508,6 +1606,40 @@ app.post('/internal/test-live', express.json(), async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Internal: ask bot to publish a reaction role message for a reaction role ID
|
||||
app.post('/internal/publish-reaction-role', express.json(), async (req, res) => {
|
||||
try {
|
||||
// If BOT_SECRET is configured, require the request to include it in the header
|
||||
const requiredSecret = process.env.BOT_SECRET;
|
||||
if (requiredSecret) {
|
||||
const provided = (req.get('x-bot-secret') || req.get('X-Bot-Secret') || '').toString();
|
||||
if (!provided || provided !== requiredSecret) {
|
||||
console.warn('/internal/publish-reaction-role: missing or invalid x-bot-secret header');
|
||||
return res.status(401).json({ success: false, message: 'Unauthorized' });
|
||||
}
|
||||
}
|
||||
|
||||
const { guildId, id } = req.body || {};
|
||||
if (!guildId || !id) return res.status(400).json({ success: false, message: 'guildId and id required' });
|
||||
const rr = await pgClient.getReactionRole(id);
|
||||
if (!rr || rr.guild_id !== guildId) return res.status(404).json({ success: false, message: 'Not found' });
|
||||
const result = await bot.postReactionRoleMessage(guildId, rr);
|
||||
if (result && result.success) {
|
||||
// update db already attempted by bot; publish SSE update
|
||||
publishEvent(guildId, 'reactionRolesUpdate', { action: 'posted', id, messageId: result.messageId });
|
||||
} else {
|
||||
// If the channel or message cannot be created because it no longer exists, remove the DB entry
|
||||
if (result && result.message && result.message.toLowerCase && (result.message.includes('Channel not found') || result.message.includes('Guild not found'))) {
|
||||
try { await pgClient.deleteReactionRole(id); publishEvent(guildId, 'reactionRolesUpdate', { action: 'delete', id }); } catch(e){}
|
||||
}
|
||||
}
|
||||
res.json(result);
|
||||
} catch (e) {
|
||||
console.error('Error in /internal/publish-reaction-role:', e);
|
||||
res.status(500).json({ success: false, message: 'Internal error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.listen(port, host, () => {
|
||||
console.log(`Server is running on ${host}:${port}`);
|
||||
});
|
||||
|
||||
4
backend/jest.config.js
Normal file
4
backend/jest.config.js
Normal file
@@ -0,0 +1,4 @@
|
||||
module.exports = {
|
||||
testEnvironment: 'node',
|
||||
testTimeout: 20000,
|
||||
};
|
||||
3689
backend/package-lock.json
generated
3689
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"test": "jest --runInBand",
|
||||
"start": "node index.js",
|
||||
"dev": "nodemon index.js"
|
||||
},
|
||||
@@ -22,6 +22,8 @@
|
||||
"node-fetch": "^2.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.3"
|
||||
"nodemon": "^3.1.3",
|
||||
"jest": "^29.6.1",
|
||||
"supertest": "^6.3.3"
|
||||
}
|
||||
}
|
||||
|
||||
126
backend/pg.js
126
backend/pg.js
@@ -57,6 +57,19 @@ async function ensureSchema() {
|
||||
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
|
||||
@@ -132,6 +145,116 @@ async function deleteAllAdminLogs(guildId) {
|
||||
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();
|
||||
@@ -145,4 +268,5 @@ async function upsertUserData(discordId, data) {
|
||||
await p.query(`INSERT INTO users(discord_id, data) VALUES($1, $2) ON CONFLICT (discord_id) DO UPDATE SET data = $2`, [discordId, data]);
|
||||
}
|
||||
|
||||
module.exports = { initPool, ensureSchema, getServerSettings, upsertServerSettings, listInvites, addInvite, deleteInvite, getUserData, upsertUserData, addAdminLog, getAdminLogs, getAdminLogsByAction, deleteAdminLog, deleteAllAdminLogs };
|
||||
module.exports = { initPool, ensureSchema, getServerSettings, upsertServerSettings, listInvites, addInvite, deleteInvite, getUserData, upsertUserData, addAdminLog, getAdminLogs, getAdminLogsByAction, deleteAdminLog, deleteAllAdminLogs, listReactionRoles, getReactionRole, createReactionRole, updateReactionRole, deleteReactionRole };
|
||||
|
||||
|
||||
62
backend/tests/pg.reactionroles.test.js
Normal file
62
backend/tests/pg.reactionroles.test.js
Normal file
@@ -0,0 +1,62 @@
|
||||
const pg = require('../pg');
|
||||
|
||||
// These tests are optional: they run only if TEST_DATABASE_URL is set in env.
|
||||
// They are intentionally lightweight and will skip when not configured.
|
||||
|
||||
const TEST_DB = process.env.TEST_DATABASE_URL;
|
||||
|
||||
describe('pg reaction_roles helpers (integration)', () => {
|
||||
if (!TEST_DB) {
|
||||
test('skipped - no TEST_DATABASE_URL', () => {
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.DATABASE_URL = TEST_DB;
|
||||
await pg.initPool();
|
||||
await pg.ensureSchema();
|
||||
});
|
||||
|
||||
let created;
|
||||
|
||||
test('createReactionRole -> returns created record', async () => {
|
||||
const rr = {
|
||||
guildId: 'test-guild',
|
||||
channelId: 'test-channel',
|
||||
name: 'Test RR',
|
||||
embed: { title: 'Hello' },
|
||||
buttons: [{ label: 'One', roleId: 'role1' }]
|
||||
};
|
||||
created = await pg.createReactionRole(rr);
|
||||
expect(created).toBeTruthy();
|
||||
expect(created.id).toBeGreaterThan(0);
|
||||
expect(created.guild_id).toBe('test-guild');
|
||||
});
|
||||
|
||||
test('listReactionRoles -> includes created', async () => {
|
||||
const list = await pg.listReactionRoles('test-guild');
|
||||
expect(Array.isArray(list)).toBe(true);
|
||||
const found = list.find(r => r.id === created.id);
|
||||
expect(found).toBeTruthy();
|
||||
});
|
||||
|
||||
test('getReactionRole -> returns record by id', async () => {
|
||||
const got = await pg.getReactionRole(created.id);
|
||||
expect(got).toBeTruthy();
|
||||
expect(got.id).toBe(created.id);
|
||||
});
|
||||
|
||||
test('updateReactionRole -> updates and returns', async () => {
|
||||
const updated = await pg.updateReactionRole(created.id, { name: 'Updated' });
|
||||
expect(updated).toBeTruthy();
|
||||
expect(updated.name).toBe('Updated');
|
||||
});
|
||||
|
||||
test('deleteReactionRole -> removes record', async () => {
|
||||
await pg.deleteReactionRole(created.id);
|
||||
const after = await pg.getReactionRole(created.id);
|
||||
expect(after).toBeNull();
|
||||
});
|
||||
});
|
||||
30
checklist.md
30
checklist.md
@@ -1,4 +1,4 @@
|
||||
# Project Checklist (tidy & current)
|
||||
# Project Checklist (tidy & current)
|
||||
|
||||
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
|
||||
@@ -51,6 +51,12 @@
|
||||
- [x] Live Notifications: bot posts rich embed to channel when a watched Twitch user goes live (thumbnail, clickable title, bio/description, category/game, viewers, footer with "ehchadservices" and start datetime)
|
||||
- [x] Live Notifications polling frequency set to 5 seconds (configurable via `TWITCH_POLL_INTERVAL_MS`)
|
||||
- [x] On bot restart, sends messages for currently live watched users; then sends for new streams once per session
|
||||
- [x] Twitch Watcher Debug Logging: comprehensive debug mode added (enable with `TWITCH_WATCHER_DEBUG=true`) to track guild checks, settings retrieval, stream fetching, channel permissions, and message sending for troubleshooting live notification issues
|
||||
- [x] Twitch API Functions Export Fix: added missing `tryFetchTwitchStreams` and `_rawGetTwitchStreams` to api.js module exports to resolve "is not a function" errors
|
||||
- [x] Twitch Streams Array Safety: added `Array.isArray()` checks in twitch-watcher.js to prevent "filter is not a function" errors when API returns unexpected data types
|
||||
- [x] Twitch Commands Postgres Integration: updated all Discord bot Twitch commands (`/add-twitchuser`, `/remove-twitchuser`) to use api.js functions for consistent Postgres backend communication
|
||||
- [x] Twitch Message Template Variables: added support for `{user}`, `{title}`, `{category}`, and `{viewers}` template variables in custom live notification messages for dynamic content insertion
|
||||
- [x] Frontend JSX Syntax Fix: fixed React Fragment wrapping for admin logs map to resolve build compilation errors
|
||||
- [x] Frontend: show "Watch Live" button next to watched user when they are live (links to Twitch)
|
||||
- [x] Bi-directional sync: backend POST/DELETE for twitch-users now also pushes new settings to bot process (when `BOT_PUSH_URL` configured)
|
||||
- [x] Bot adds/removes users via backend endpoints ensuring single source of truth (Postgres)
|
||||
@@ -86,7 +92,28 @@
|
||||
- [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`)
|
||||
@@ -155,4 +182,3 @@
|
||||
- [x] Fixed ESLint warnings: removed unused imports and handlers, added proper dependency management
|
||||
- [x] Fixed compilation errors: added missing MUI imports and Snackbar component
|
||||
- [x] Navbar visibility: enhanced with solid background and stronger border for better visibility across all themes
|
||||
|
||||
@@ -87,13 +87,33 @@ async function listInvites(guildId) {
|
||||
return json || [];
|
||||
}
|
||||
|
||||
async function listReactionRoles(guildId) {
|
||||
const path = `/api/servers/${guildId}/reaction-roles`;
|
||||
const json = await safeFetchJsonPath(path);
|
||||
return json || [];
|
||||
}
|
||||
|
||||
async function addInvite(guildId, invite) {
|
||||
const path = `/api/servers/${guildId}/invites`;
|
||||
try {
|
||||
// If invite is an object with code property, it's already created - send full data
|
||||
// If it's just channelId/maxAge/etc, it's for creation
|
||||
const isExistingInvite = invite && typeof invite === 'object' && invite.code;
|
||||
|
||||
const body = isExistingInvite ? {
|
||||
code: invite.code,
|
||||
url: invite.url,
|
||||
channelId: invite.channelId,
|
||||
maxUses: invite.maxUses,
|
||||
maxAge: invite.maxAge,
|
||||
temporary: invite.temporary,
|
||||
createdAt: invite.createdAt
|
||||
} : invite;
|
||||
|
||||
const res = await tryFetch(path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(invite),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return res && res.ok;
|
||||
} catch (e) {
|
||||
@@ -113,6 +133,33 @@ async function deleteInvite(guildId, code) {
|
||||
}
|
||||
}
|
||||
|
||||
async function updateReactionRole(guildId, id, updates) {
|
||||
const path = `/api/servers/${guildId}/reaction-roles/${id}`;
|
||||
try {
|
||||
const res = await tryFetch(path, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
if (!res) return null;
|
||||
try { return await res.json(); } catch (e) { return null; }
|
||||
} catch (e) {
|
||||
console.error(`Failed to update reaction role ${id} for ${guildId}:`, e && e.message ? e.message : e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteReactionRole(guildId, id) {
|
||||
const path = `/api/servers/${guildId}/reaction-roles/${id}`;
|
||||
try {
|
||||
const res = await tryFetch(path, { method: 'DELETE' });
|
||||
return res && res.ok;
|
||||
} catch (e) {
|
||||
console.error(`Failed to delete reaction role ${id} for ${guildId}:`, e && e.message ? e.message : e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getServerSettings, upsertServerSettings, getCommands, toggleCommand, listInvites, addInvite, deleteInvite };
|
||||
// Twitch users helpers
|
||||
async function getTwitchUsers(guildId) {
|
||||
@@ -208,4 +255,44 @@ async function getAutoroleSettings(guildId) {
|
||||
return json || { enabled: false, roleId: '' };
|
||||
}
|
||||
|
||||
module.exports = { getServerSettings, upsertServerSettings, getCommands, toggleCommand, listInvites, addInvite, deleteInvite, getTwitchUsers, addTwitchUser, deleteTwitchUser, getKickUsers, addKickUser, deleteKickUser, getWelcomeLeaveSettings, getAutoroleSettings };
|
||||
async function reconcileInvites(guildId, currentDiscordInvites) {
|
||||
try {
|
||||
// Get invites from database
|
||||
const dbInvites = await listInvites(guildId) || [];
|
||||
|
||||
// Find invites in database that no longer exist in Discord
|
||||
const discordInviteCodes = new Set(currentDiscordInvites.map(inv => inv.code));
|
||||
const deletedInvites = dbInvites.filter(dbInv => !discordInviteCodes.has(dbInv.code));
|
||||
|
||||
// Delete each invite that no longer exists
|
||||
for (const invite of deletedInvites) {
|
||||
console.log(`🗑️ Reconciling deleted invite ${invite.code} for guild ${guildId}`);
|
||||
await deleteInvite(guildId, invite.code);
|
||||
|
||||
// Publish SSE event for frontend update
|
||||
try {
|
||||
await tryFetch('/api/events/publish', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
event: 'inviteDeleted',
|
||||
data: { code: invite.code, guildId }
|
||||
})
|
||||
});
|
||||
} catch (sseErr) {
|
||||
console.error('Failed to publish SSE event for reconciled invite deletion:', sseErr);
|
||||
}
|
||||
}
|
||||
|
||||
if (deletedInvites.length > 0) {
|
||||
console.log(`✅ Reconciled ${deletedInvites.length} deleted invites for guild ${guildId}`);
|
||||
}
|
||||
|
||||
return deletedInvites.length;
|
||||
} catch (e) {
|
||||
console.error(`Failed to reconcile invites for guild ${guildId}:`, e && e.message ? e.message : e);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getServerSettings, upsertServerSettings, getCommands, toggleCommand, listInvites, addInvite, deleteInvite, listReactionRoles, updateReactionRole, deleteReactionRole, getTwitchUsers, addTwitchUser, deleteTwitchUser, tryFetchTwitchStreams, _rawGetTwitchStreams, getKickUsers, addKickUser, deleteKickUser, getWelcomeLeaveSettings, getAutoroleSettings, reconcileInvites };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const { SlashCommandBuilder, PermissionsBitField } = require('discord.js');
|
||||
const fetch = require('node-fetch');
|
||||
const api = require('../api');
|
||||
|
||||
module.exports = {
|
||||
name: 'add-twitchuser',
|
||||
@@ -16,20 +16,14 @@ module.exports = {
|
||||
}
|
||||
const username = interaction.options.getString('username').toLowerCase().trim();
|
||||
try {
|
||||
const backendBase = process.env.BACKEND_BASE || `http://${process.env.HOST || '127.0.0.1'}:${process.env.PORT || 3002}`;
|
||||
const resp = await fetch(`${backendBase}/api/servers/${interaction.guildId}/twitch-users`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username })
|
||||
});
|
||||
if (resp.ok) {
|
||||
const success = await api.addTwitchUser(interaction.guildId, username);
|
||||
if (success) {
|
||||
await interaction.reply({ content: `Added ${username} to watch list.`, flags: 64 });
|
||||
// Refresh cached settings from backend so watcher sees new user immediately
|
||||
try {
|
||||
const settingsResp = await fetch(`${backendBase}/api/servers/${interaction.guildId}/settings`);
|
||||
if (settingsResp.ok) {
|
||||
const json = await settingsResp.json();
|
||||
const settings = await api.getServerSettings(interaction.guildId);
|
||||
const bot = require('..');
|
||||
if (bot && bot.setGuildSettings) bot.setGuildSettings(interaction.guildId, json);
|
||||
}
|
||||
if (bot && bot.setGuildSettings) bot.setGuildSettings(interaction.guildId, settings);
|
||||
} catch (_) {}
|
||||
} else {
|
||||
await interaction.reply({ content: 'Failed to add user via backend.', flags: 64 });
|
||||
|
||||
21
discord-bot/commands/post-reaction-role.js
Normal file
21
discord-bot/commands/post-reaction-role.js
Normal file
@@ -0,0 +1,21 @@
|
||||
module.exports = {
|
||||
name: 'post-reaction-role',
|
||||
description: 'Post a reaction role message for the given reaction role ID',
|
||||
builder: (builder) => builder.setName('post-reaction-role').setDescription('Post a reaction role message').addIntegerOption(opt => opt.setName('id').setDescription('Reaction role ID').setRequired(true)),
|
||||
async execute(interaction) {
|
||||
const id = interaction.options.getInteger('id');
|
||||
try {
|
||||
const api = require('../api');
|
||||
const rrList = await api.listReactionRoles(interaction.guildId) || [];
|
||||
const rr = rrList.find(r => Number(r.id) === Number(id));
|
||||
if (!rr) return interaction.reply({ content: 'Reaction role not found', ephemeral: true });
|
||||
const bot = require('../index');
|
||||
const result = await bot.postReactionRoleMessage(interaction.guildId, rr);
|
||||
if (result && result.success) return interaction.reply({ content: 'Posted reaction role message', ephemeral: true });
|
||||
return interaction.reply({ content: 'Failed to post message', ephemeral: true });
|
||||
} catch (e) {
|
||||
console.error('post-reaction-role command error:', e);
|
||||
return interaction.reply({ content: 'Internal error', ephemeral: true });
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
const { SlashCommandBuilder, PermissionsBitField } = require('discord.js');
|
||||
const fetch = require('node-fetch');
|
||||
const api = require('../api');
|
||||
|
||||
module.exports = {
|
||||
name: 'remove-twitchuser',
|
||||
@@ -16,18 +16,14 @@ module.exports = {
|
||||
}
|
||||
const username = interaction.options.getString('username').toLowerCase().trim();
|
||||
try {
|
||||
const backendBase = process.env.BACKEND_BASE || `http://${process.env.HOST || '127.0.0.1'}:${process.env.PORT || 3002}`;
|
||||
const resp = await fetch(`${backendBase}/api/servers/${interaction.guildId}/twitch-users/${encodeURIComponent(username)}`, { method: 'DELETE' });
|
||||
if (resp.ok) {
|
||||
const success = await api.deleteTwitchUser(interaction.guildId, username);
|
||||
if (success) {
|
||||
await interaction.reply({ content: `Removed ${username} from watch list.`, flags: 64 });
|
||||
// Refresh cached settings from backend
|
||||
try {
|
||||
const settingsResp = await fetch(`${backendBase}/api/servers/${interaction.guildId}/settings`);
|
||||
if (settingsResp.ok) {
|
||||
const json = await settingsResp.json();
|
||||
const settings = await api.getServerSettings(interaction.guildId);
|
||||
const bot = require('..');
|
||||
if (bot && bot.setGuildSettings) bot.setGuildSettings(interaction.guildId, json);
|
||||
}
|
||||
if (bot && bot.setGuildSettings) bot.setGuildSettings(interaction.guildId, settings);
|
||||
} catch (_) {}
|
||||
} else {
|
||||
await interaction.reply({ content: 'Failed to remove user via backend.', flags: 64 });
|
||||
|
||||
@@ -13,10 +13,26 @@ for (const file of commandFiles) {
|
||||
if (command.enabled === false || command.dev === true) continue;
|
||||
|
||||
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 {
|
||||
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);
|
||||
|
||||
49
discord-bot/events/inviteCreate.js
Normal file
49
discord-bot/events/inviteCreate.js
Normal file
@@ -0,0 +1,49 @@
|
||||
const api = require('../api');
|
||||
|
||||
module.exports = {
|
||||
name: 'inviteCreate',
|
||||
async execute(invite) {
|
||||
try {
|
||||
// Only track invites created by the bot or in channels the bot can access
|
||||
const guildId = invite.guild.id;
|
||||
|
||||
// Check if this invite was created by our bot
|
||||
const isBotCreated = invite.inviter && invite.inviter.id === invite.client.user.id;
|
||||
|
||||
if (isBotCreated) {
|
||||
// Add to database if created by bot
|
||||
const inviteData = {
|
||||
code: invite.code,
|
||||
guildId: guildId,
|
||||
url: invite.url,
|
||||
channelId: invite.channel.id,
|
||||
createdAt: invite.createdAt ? invite.createdAt.toISOString() : new Date().toISOString(),
|
||||
maxUses: invite.maxUses || 0,
|
||||
maxAge: invite.maxAge || 0,
|
||||
temporary: invite.temporary || false
|
||||
};
|
||||
|
||||
// Use the API to add the invite to database
|
||||
await api.addInvite(inviteData);
|
||||
|
||||
// Publish SSE event for real-time frontend updates
|
||||
const bot = require('..');
|
||||
if (bot && bot.publishEvent) {
|
||||
bot.publishEvent(guildId, 'inviteCreated', {
|
||||
code: invite.code,
|
||||
url: invite.url,
|
||||
channelId: invite.channel.id,
|
||||
maxUses: invite.maxUses || 0,
|
||||
maxAge: invite.maxAge || 0,
|
||||
temporary: invite.temporary || false,
|
||||
createdAt: invite.createdAt ? invite.createdAt.toISOString() : new Date().toISOString()
|
||||
});
|
||||
}
|
||||
}
|
||||
// Note: We don't automatically add invites created by other users to avoid spam
|
||||
// Only bot-created invites are tracked for the web interface
|
||||
} catch (error) {
|
||||
console.error('Error handling inviteCreate:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
24
discord-bot/events/inviteDelete.js
Normal file
24
discord-bot/events/inviteDelete.js
Normal file
@@ -0,0 +1,24 @@
|
||||
const api = require('../api');
|
||||
|
||||
module.exports = {
|
||||
name: 'inviteDelete',
|
||||
async execute(invite) {
|
||||
try {
|
||||
const guildId = invite.guild.id;
|
||||
const code = invite.code;
|
||||
|
||||
// Remove from database
|
||||
await api.deleteInvite(guildId, code);
|
||||
|
||||
// Publish SSE event for real-time frontend updates
|
||||
const bot = require('..');
|
||||
if (bot && bot.publishEvent) {
|
||||
bot.publishEvent(guildId, 'inviteDeleted', {
|
||||
code: code
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error handling inviteDelete:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
const { ActivityType } = require('discord.js');
|
||||
const deployCommands = require('../deploy-commands');
|
||||
const api = require('../api');
|
||||
|
||||
module.exports = {
|
||||
name: 'clientReady',
|
||||
@@ -16,6 +17,97 @@ module.exports = {
|
||||
}
|
||||
}
|
||||
|
||||
// Reconcile invites for all guilds to detect invites deleted while bot was offline
|
||||
console.log('🔄 Reconciling invites for offline changes...');
|
||||
let totalReconciled = 0;
|
||||
for (const guildId of guildIds) {
|
||||
try {
|
||||
const guild = client.guilds.cache.get(guildId);
|
||||
if (!guild) continue;
|
||||
|
||||
// Fetch current invites from Discord
|
||||
const discordInvites = await guild.invites.fetch();
|
||||
const currentInvites = Array.from(discordInvites.values());
|
||||
|
||||
// Reconcile with database
|
||||
const reconciled = await api.reconcileInvites(guildId, currentInvites);
|
||||
totalReconciled += reconciled;
|
||||
} catch (e) {
|
||||
console.error(`Failed to reconcile invites for guild ${guildId}:`, e && e.message ? e.message : e);
|
||||
}
|
||||
}
|
||||
if (totalReconciled > 0) {
|
||||
console.log(`✅ Invite reconciliation complete: removed ${totalReconciled} stale invites`);
|
||||
} else {
|
||||
console.log('✅ Invite reconciliation complete: no stale invites found');
|
||||
}
|
||||
|
||||
// Reconcile reaction roles: ensure stored message IDs still exist, remove stale configs
|
||||
console.log('🔄 Reconciling reaction roles (initial check)...');
|
||||
try {
|
||||
for (const guildId of guildIds) {
|
||||
try {
|
||||
const rrList = await api.listReactionRoles(guildId) || [];
|
||||
for (const rr of rrList) {
|
||||
if (!rr.message_id) continue; // not posted yet
|
||||
try {
|
||||
const guild = client.guilds.cache.get(guildId);
|
||||
if (!guild) continue;
|
||||
const channel = await guild.channels.fetch(rr.channel_id || rr.channelId).catch(() => null);
|
||||
if (!channel) {
|
||||
// channel missing -> delete RR
|
||||
await api.deleteReactionRole(guildId, rr.id);
|
||||
continue;
|
||||
}
|
||||
const msg = await channel.messages.fetch(rr.message_id).catch(() => null);
|
||||
if (!msg) {
|
||||
// message missing -> delete RR
|
||||
await api.deleteReactionRole(guildId, rr.id);
|
||||
continue;
|
||||
}
|
||||
} catch (inner) {
|
||||
// ignore per-item errors
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore guild-level errors
|
||||
}
|
||||
}
|
||||
console.log('✅ Reaction role initial reconciliation complete');
|
||||
} catch (e) {
|
||||
console.error('Failed reaction role reconciliation:', e && e.message ? e.message : e);
|
||||
}
|
||||
|
||||
// Periodic reconciliation every 10 minutes
|
||||
setInterval(async () => {
|
||||
try {
|
||||
for (const guildId of client.guilds.cache.map(g => g.id)) {
|
||||
const rrList = await api.listReactionRoles(guildId) || [];
|
||||
for (const rr of rrList) {
|
||||
if (!rr.message_id) continue;
|
||||
try {
|
||||
const guild = client.guilds.cache.get(guildId);
|
||||
if (!guild) continue;
|
||||
const channel = await guild.channels.fetch(rr.channel_id || rr.channelId).catch(() => null);
|
||||
if (!channel) {
|
||||
await api.deleteReactionRole(guildId, rr.id);
|
||||
continue;
|
||||
}
|
||||
const msg = await channel.messages.fetch(rr.message_id).catch(() => null);
|
||||
if (!msg) {
|
||||
await api.deleteReactionRole(guildId, rr.id);
|
||||
continue;
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}, 10 * 60 * 1000);
|
||||
|
||||
const activities = [
|
||||
{ name: 'Watch EhChad Live!', type: ActivityType.Streaming, url: 'https://twitch.tv/ehchad' },
|
||||
{ name: 'Follow EhChad!', type: ActivityType.Streaming, url: 'https://twitch.tv/ehchad' },
|
||||
|
||||
@@ -69,6 +69,61 @@ client.on('interactionCreate', async interaction => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reaction role button handling
|
||||
if (interaction.isButton && interaction.customId && interaction.customId.startsWith('rr_')) {
|
||||
// customId format: rr_<reactionRoleId>_<roleId>
|
||||
const parts = interaction.customId.split('_');
|
||||
if (parts.length >= 3) {
|
||||
const rrId = parts[1];
|
||||
const roleId = parts[2];
|
||||
try {
|
||||
const rr = await api.safeFetchJsonPath(`/api/servers/${interaction.guildId}/reaction-roles`);
|
||||
// rr is array; find by id
|
||||
const found = (rr || []).find(r => String(r.id) === String(rrId));
|
||||
if (!found) {
|
||||
await interaction.reply({ content: 'Reaction role configuration not found.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
const button = (found.buttons || []).find(b => String(b.roleId) === String(roleId));
|
||||
if (!button) {
|
||||
await interaction.reply({ content: 'Button config not found.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
const roleId = button.roleId || button.role_id || button.role;
|
||||
const member = interaction.member;
|
||||
if (!member) return;
|
||||
// Validate role hierarchy: bot must be higher than role, and member must be lower than role
|
||||
const guild = interaction.guild;
|
||||
const role = guild.roles.cache.get(roleId) || null;
|
||||
if (!role) { await interaction.reply({ content: 'Configured role no longer exists.', ephemeral: true }); return; }
|
||||
const botMember = await guild.members.fetchMe();
|
||||
const botHighest = botMember.roles.highest;
|
||||
const targetPosition = role.position || 0;
|
||||
if (botHighest.position <= targetPosition) {
|
||||
await interaction.reply({ content: 'Cannot assign role: bot lacks sufficient role hierarchy (move bot role higher).', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
const memberHighest = member.roles.highest;
|
||||
if (memberHighest.position >= targetPosition) {
|
||||
await interaction.reply({ content: 'Cannot assign role: your highest role is higher or equal to the role to be assigned.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
const hasRole = member.roles.cache.has(roleId);
|
||||
if (hasRole) {
|
||||
await member.roles.remove(roleId, `Reaction role button toggled by user ${interaction.user.id}`);
|
||||
await interaction.reply({ content: `Removed role ${role.name}.`, ephemeral: true });
|
||||
} else {
|
||||
await member.roles.add(roleId, `Reaction role button toggled by user ${interaction.user.id}`);
|
||||
await interaction.reply({ content: `Assigned role ${role.name}.`, ephemeral: true });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error handling reaction role button:', e);
|
||||
try { await interaction.reply({ content: 'Failed to process reaction role.', ephemeral: true }); } catch(e){}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!interaction.isCommand()) return;
|
||||
|
||||
const command = client.commands.get(interaction.commandName);
|
||||
@@ -176,6 +231,50 @@ async function announceLive(guildId, stream) {
|
||||
|
||||
module.exports = { login, client, setGuildSettings, getGuildSettingsFromCache, announceLive };
|
||||
|
||||
async function postReactionRoleMessage(guildId, reactionRole) {
|
||||
try {
|
||||
const guild = client.guilds.cache.get(guildId);
|
||||
if (!guild) return { success: false, message: 'Guild not found' };
|
||||
const channel = await guild.channels.fetch(reactionRole.channel_id || reactionRole.channelId).catch(() => null);
|
||||
if (!channel) return { success: false, message: 'Channel not found' };
|
||||
// Build buttons
|
||||
const { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } = require('discord.js');
|
||||
const row = new ActionRowBuilder();
|
||||
const buttons = reactionRole.buttons || [];
|
||||
for (let i = 0; i < buttons.length; i++) {
|
||||
const b = buttons[i];
|
||||
const customId = `rr_${reactionRole.id}_${b.roleId}`;
|
||||
const btn = new ButtonBuilder().setCustomId(customId).setLabel(b.label || b.name || `Button ${i+1}`).setStyle(ButtonStyle.Primary);
|
||||
row.addComponents(btn);
|
||||
}
|
||||
const embedData = reactionRole.embed || reactionRole.embed || {};
|
||||
const embed = new EmbedBuilder();
|
||||
if (embedData.title) embed.setTitle(embedData.title);
|
||||
if (embedData.description) embed.setDescription(embedData.description);
|
||||
if (embedData.color) embed.setColor(embedData.color);
|
||||
if (embedData.thumbnail) embed.setThumbnail(embedData.thumbnail);
|
||||
if (embedData.fields && Array.isArray(embedData.fields)) {
|
||||
for (const f of embedData.fields) {
|
||||
if (f.name && f.value) embed.addFields({ name: f.name, value: f.value, inline: false });
|
||||
}
|
||||
}
|
||||
const sent = await channel.send({ embeds: [embed], components: [row] });
|
||||
// update backend with message id
|
||||
try {
|
||||
const api = require('./api');
|
||||
await api.updateReactionRole(guildId, reactionRole.id, { messageId: sent.id });
|
||||
} catch (e) {
|
||||
console.error('Failed to update reaction role message id in backend:', e);
|
||||
}
|
||||
return { success: true, messageId: sent.id };
|
||||
} catch (e) {
|
||||
console.error('postReactionRoleMessage failed:', e && e.message ? e.message : e);
|
||||
return { success: false, message: e && e.message ? e.message : 'unknown error' };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports.postReactionRoleMessage = postReactionRoleMessage;
|
||||
|
||||
// Start twitch watcher when client is ready (use 'clientReady' as the event name)
|
||||
try {
|
||||
const watcher = require('./twitch-watcher');
|
||||
|
||||
@@ -60,41 +60,86 @@ async function fetchUserInfo(login) {
|
||||
|
||||
let polling = false;
|
||||
const pollIntervalMs = Number(process.env.TWITCH_POLL_INTERVAL_MS || 5000); // 5s default
|
||||
const debugMode = false; // Debug logging disabled
|
||||
|
||||
// Keep track of which streams we've already announced per guild:user -> { started_at }
|
||||
const announced = new Map(); // key: `${guildId}:${user}` -> { started_at }
|
||||
|
||||
async function checkGuild(client, guild) {
|
||||
const guildId = guild.id;
|
||||
const guildName = guild.name;
|
||||
|
||||
try {
|
||||
// Intentionally quiet: per-guild checking logs are suppressed to avoid spam
|
||||
const settings = await api.getServerSettings(guild.id) || {};
|
||||
if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Checking guild ${guildName} (${guildId})`);
|
||||
|
||||
const settings = await api.getServerSettings(guildId) || {};
|
||||
const liveSettings = settings.liveNotifications || {};
|
||||
if (!liveSettings.enabled) return;
|
||||
|
||||
if (debugMode) {
|
||||
console.log(`🔍 [DEBUG] TwitchWatcher: Guild ${guildName} settings:`, {
|
||||
enabled: liveSettings.enabled,
|
||||
channelId: liveSettings.channelId,
|
||||
usersCount: (liveSettings.users || []).length,
|
||||
hasCustomMessage: !!liveSettings.customMessage,
|
||||
hasDefaultMessage: !!liveSettings.message
|
||||
});
|
||||
}
|
||||
|
||||
if (!liveSettings.enabled) {
|
||||
if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Live notifications disabled for ${guildName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const channelId = liveSettings.channelId;
|
||||
const users = (liveSettings.users || []).map(u => u.toLowerCase()).filter(Boolean);
|
||||
if (!channelId || users.length === 0) return;
|
||||
|
||||
if (debugMode) {
|
||||
console.log(`🔍 [DEBUG] TwitchWatcher: Guild ${guildName} - Channel: ${channelId}, Users: [${users.join(', ')}]`);
|
||||
}
|
||||
|
||||
if (!channelId || users.length === 0) {
|
||||
if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Skipping ${guildName} - ${!channelId ? 'No channel configured' : 'No users configured'}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// ask backend for current live streams
|
||||
const query = users.join(',');
|
||||
if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Fetching streams for query: ${query}`);
|
||||
|
||||
const streams = await api._rawGetTwitchStreams ? api._rawGetTwitchStreams(query) : null;
|
||||
// If the helper isn't available, try backend proxy
|
||||
let live = [];
|
||||
if (streams) live = streams.filter(s => s.is_live);
|
||||
else {
|
||||
if (streams && Array.isArray(streams)) {
|
||||
live = streams.filter(s => s.is_live);
|
||||
if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Found ${live.length} live streams via _rawGetTwitchStreams`);
|
||||
} else {
|
||||
if (debugMode && streams) {
|
||||
console.log(`🔍 [DEBUG] TwitchWatcher: _rawGetTwitchStreams returned non-array:`, typeof streams, streams);
|
||||
}
|
||||
try {
|
||||
const resp = await api.tryFetchTwitchStreams(query);
|
||||
live = (resp || []).filter(s => s.is_live);
|
||||
live = (Array.isArray(resp) ? resp : []).filter(s => s.is_live);
|
||||
if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Found ${live.length} live streams via tryFetchTwitchStreams`);
|
||||
} catch (e) {
|
||||
console.error(`❌ TwitchWatcher: Failed to fetch streams for ${guildName}:`, e && e.message ? e.message : e);
|
||||
live = [];
|
||||
}
|
||||
}
|
||||
|
||||
if (debugMode && live.length > 0) {
|
||||
console.log(`🔍 [DEBUG] TwitchWatcher: Live streams:`, live.map(s => `${s.user_login} (${s.viewer_count} viewers)`));
|
||||
}
|
||||
|
||||
if (!live || live.length === 0) {
|
||||
// No live streams: ensure any announced keys for these users are cleared so they can be re-announced later
|
||||
for (const u of users) {
|
||||
const key = `${guild.id}:${u}`;
|
||||
const key = `${guildId}:${u}`;
|
||||
if (announced.has(key)) {
|
||||
announced.delete(key);
|
||||
if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Cleared announcement for ${u} in ${guildName} (no longer live)`);
|
||||
}
|
||||
}
|
||||
if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: No live streams found for ${guildName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -103,16 +148,28 @@ async function checkGuild(client, guild) {
|
||||
let channel = null;
|
||||
try {
|
||||
channel = await client.channels.fetch(channelId);
|
||||
if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Successfully fetched channel ${channel.name} (${channelId}) in ${guildName}`);
|
||||
|
||||
if (channel.type !== 0) { // 0 is text channel
|
||||
console.error(`TwitchWatcher: channel ${channelId} is not a text channel (type: ${channel.type})`);
|
||||
console.error(`❌ TwitchWatcher: Channel ${channelId} in ${guildName} is not a text channel (type: ${channel.type})`);
|
||||
channel = null;
|
||||
} else {
|
||||
// Check if bot has permission to send messages
|
||||
const permissions = channel.permissionsFor(client.user);
|
||||
if (!permissions || !permissions.has('SendMessages')) {
|
||||
console.error(`❌ TwitchWatcher: Bot lacks SendMessages permission in channel ${channel.name} (${channelId}) for ${guildName}`);
|
||||
channel = null;
|
||||
} else if (debugMode) {
|
||||
console.log(`🔍 [DEBUG] TwitchWatcher: Bot has SendMessages permission in ${channel.name}`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`TwitchWatcher: failed to fetch channel ${channelId}:`, e && e.message ? e.message : e);
|
||||
console.error(`❌ TwitchWatcher: Failed to fetch channel ${channelId} for ${guildName}:`, e && e.message ? e.message : e);
|
||||
channel = null;
|
||||
}
|
||||
if (!channel) {
|
||||
// Channel not found or inaccessible; skip
|
||||
if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Skipping announcements for ${guildName} - channel unavailable`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -121,40 +178,51 @@ async function checkGuild(client, guild) {
|
||||
|
||||
// Clear announced entries for users that are no longer live
|
||||
for (const u of users) {
|
||||
const key = `${guild.id}:${u}`;
|
||||
const key = `${guildId}:${u}`;
|
||||
if (!liveLogins.has(u) && announced.has(key)) {
|
||||
announced.delete(key);
|
||||
if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Cleared announcement for ${u} in ${guildName} (stream ended)`);
|
||||
}
|
||||
}
|
||||
|
||||
// Announce each live once per live session
|
||||
for (const s of live) {
|
||||
const login = (s.user_login || '').toLowerCase();
|
||||
const key = `${guild.id}:${login}`;
|
||||
if (announced.has(key)) continue; // already announced for this live session
|
||||
const key = `${guildId}:${login}`;
|
||||
if (announced.has(key)) {
|
||||
if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Skipping ${login} in ${guildName} - already announced`);
|
||||
continue; // already announced for this live session
|
||||
}
|
||||
|
||||
if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Preparing announcement for ${login} in ${guildName}`);
|
||||
|
||||
// mark announced for this session
|
||||
announced.set(key, { started_at: s.started_at || new Date().toISOString() });
|
||||
|
||||
// Build and send embed (standardized layout)
|
||||
try {
|
||||
// Announce without per-guild log spam
|
||||
const { EmbedBuilder } = require('discord.js');
|
||||
// Attempt to enrich with user bio (description) if available
|
||||
let bio = '';
|
||||
try {
|
||||
const info = await fetchUserInfo(login);
|
||||
if (info && info.description) bio = info.description.slice(0, 200);
|
||||
} catch (_) {}
|
||||
if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Fetched user info for ${login} - bio length: ${bio.length}`);
|
||||
} catch (e) {
|
||||
if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Failed to fetch user info for ${login}:`, e && e.message ? e.message : e);
|
||||
}
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(0x9146FF)
|
||||
.setColor('#6441A5') // Twitch purple
|
||||
.setAuthor({ name: s.user_name, iconURL: s.profile_image_url || undefined, url: s.url })
|
||||
.setTitle(s.title || `${s.user_name} is live`)
|
||||
.setURL(s.url)
|
||||
.setAuthor({ name: s.user_name, iconURL: s.profile_image_url || undefined, url: s.url })
|
||||
.setThumbnail(s.thumbnail_url || s.profile_image_url || undefined)
|
||||
.setThumbnail(s.profile_image_url || undefined)
|
||||
.addFields(
|
||||
{ name: 'Category', value: s.game_name || 'Unknown', inline: true },
|
||||
{ name: 'Viewers', value: String(s.viewer_count || 0), inline: true }
|
||||
)
|
||||
.setImage(s.thumbnail_url ? s.thumbnail_url.replace('{width}', '640').replace('{height}', '360') + `?t=${Date.now()}` : null)
|
||||
.setDescription(bio || (s.description || '').slice(0, 200))
|
||||
.setFooter({ text: `ehchadservices • Started: ${s.started_at ? new Date(s.started_at).toLocaleString() : 'unknown'}` });
|
||||
|
||||
@@ -167,43 +235,75 @@ async function checkGuild(client, guild) {
|
||||
} else {
|
||||
prefixMsg = `🔴 ${s.user_name} is now live!`;
|
||||
}
|
||||
|
||||
// Replace template variables in custom messages
|
||||
prefixMsg = prefixMsg
|
||||
.replace(/\{user\}/g, s.user_name || login)
|
||||
.replace(/\{title\}/g, s.title || 'Untitled Stream')
|
||||
.replace(/\{category\}/g, s.game_name || 'Unknown')
|
||||
.replace(/\{viewers\}/g, String(s.viewer_count || 0));
|
||||
|
||||
if (debugMode) {
|
||||
console.log(`🔍 [DEBUG] TwitchWatcher: Sending announcement for ${login} in ${guildName} to #${channel.name}`);
|
||||
console.log(`🔍 [DEBUG] TwitchWatcher: Message content: "${prefixMsg}"`);
|
||||
}
|
||||
|
||||
// Ensure we always hyperlink the title via embed; prefix is optional add above embed
|
||||
const payload = prefixMsg ? { content: prefixMsg, embeds: [embed] } : { embeds: [embed] };
|
||||
await channel.send(payload);
|
||||
console.log(`🔔 Announced live: ${login} - ${(s.title || '').slice(0, 80)}`);
|
||||
console.log(`🔔 TwitchWatcher: Successfully announced ${login} in ${guildName} - "${(s.title || '').slice(0, 80)}"`);
|
||||
} catch (e) {
|
||||
console.error(`TwitchWatcher: failed to send announcement for ${login}:`, e && e.message ? e.message : e);
|
||||
console.error(`❌ TwitchWatcher: Failed to send announcement for ${login} in ${guildName}:`, e && e.message ? e.message : e);
|
||||
// fallback
|
||||
const msg = `🔴 ${s.user_name} is live: **${s.title}**\nWatch: ${s.url}`;
|
||||
try { await channel.send({ content: msg }); console.log('TwitchWatcher: fallback message sent'); } catch (err) { console.error('TwitchWatcher: fallback send failed:', err && err.message ? err.message : err); }
|
||||
try {
|
||||
if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Attempting fallback message for ${login} in ${guildName}`);
|
||||
await channel.send({ content: msg });
|
||||
console.log(`🔔 TwitchWatcher: Fallback message sent for ${login} in ${guildName}`);
|
||||
} catch (err) {
|
||||
console.error(`❌ TwitchWatcher: Fallback send failed for ${login} in ${guildName}:`, err && err.message ? err.message : err);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error checking guild for live streams:', e && e.message ? e.message : e);
|
||||
console.error(`❌ TwitchWatcher: Error checking guild ${guildName} (${guildId}) for live streams:`, e && e.message ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
async function poll(client) {
|
||||
if (polling) return;
|
||||
polling = true;
|
||||
console.log(`🔁 TwitchWatcher started, polling every ${Math.round(pollIntervalMs/1000)}s`);
|
||||
console.log(`🔁 TwitchWatcher started, polling every ${Math.round(pollIntervalMs/1000)}s${debugMode ? ' (DEBUG MODE ENABLED)' : ''}`);
|
||||
|
||||
// Initial check on restart: send messages for currently live users
|
||||
try {
|
||||
const guilds = Array.from(client.guilds.cache.values());
|
||||
if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Initial check for ${guilds.length} guilds`);
|
||||
|
||||
for (const g of guilds) {
|
||||
await checkGuild(client, g).catch(err => { console.error('TwitchWatcher: initial checkGuild error', err && err.message ? err.message : err); });
|
||||
if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Initial check for guild ${g.name} (${g.id})`);
|
||||
await checkGuild(client, g).catch(err => {
|
||||
console.error(`❌ TwitchWatcher: Initial checkGuild error for ${g.name}:`, err && err.message ? err.message : err);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error during initial twitch check:', e && e.message ? e.message : e);
|
||||
console.error('❌ TwitchWatcher: Error during initial twitch check:', e && e.message ? e.message : e);
|
||||
}
|
||||
|
||||
while (polling) {
|
||||
try {
|
||||
const guilds = Array.from(client.guilds.cache.values());
|
||||
if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Polling cycle starting for ${guilds.length} guilds`);
|
||||
|
||||
for (const g of guilds) {
|
||||
await checkGuild(client, g).catch(err => { console.error('TwitchWatcher: checkGuild error', err && err.message ? err.message : err); });
|
||||
await checkGuild(client, g).catch(err => {
|
||||
console.error(`❌ TwitchWatcher: checkGuild error for ${g.name}:`, err && err.message ? err.message : err);
|
||||
});
|
||||
}
|
||||
|
||||
if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Polling cycle completed, waiting ${Math.round(pollIntervalMs/1000)}s`);
|
||||
} catch (e) {
|
||||
console.error('Error during twitch poll loop:', e && e.message ? e.message : e);
|
||||
console.error('❌ TwitchWatcher: Error during twitch poll loop:', e && e.message ? e.message : e);
|
||||
}
|
||||
await new Promise(r => setTimeout(r, pollIntervalMs));
|
||||
}
|
||||
|
||||
149
frontend/package-lock.json
generated
149
frontend/package-lock.json
generated
@@ -3580,9 +3580,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rushstack/eslint-patch": {
|
||||
"version": "1.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.14.0.tgz",
|
||||
"integrity": "sha512-WJFej426qe4RWOm9MMtP4V3CV4AucXolQty+GRgAWLgQXmpCuwzs7hEpxxhSc/znXUSxum9d/P/32MW0FlAAlA==",
|
||||
"version": "1.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.12.0.tgz",
|
||||
"integrity": "sha512-5EwMtOqvJMMa3HbmxLlF74e+3/HhwBTMcvt3nqVJgGCozO6hzIPOBlwm8mGVNR9SN2IJpxSnlxczyDjcn7qIyw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@sinclair/typebox": {
|
||||
@@ -4080,9 +4080,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/express-serve-static-core": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.0.tgz",
|
||||
"integrity": "sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==",
|
||||
"version": "5.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.7.tgz",
|
||||
"integrity": "sha512-R+33OsgWw7rOhD1emjU7dzCDHucJrgJXMA5PYCzJxVil0dsyx5iBEPHqpPfiKNJQb7lZ1vxwoLR4Z87bBUpeGQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
@@ -4092,9 +4092,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/express/node_modules/@types/express-serve-static-core": {
|
||||
"version": "4.19.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.7.tgz",
|
||||
"integrity": "sha512-FvPtiIf1LfhzsaIXhv/PHan/2FeQBbtBDtfX2QfvPxdUelMDEckK08SM6nqo1MIZY3RUlfA+HV8+hFUSio78qg==",
|
||||
"version": "4.19.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz",
|
||||
"integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
@@ -4176,12 +4176,12 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz",
|
||||
"integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==",
|
||||
"version": "24.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.6.2.tgz",
|
||||
"integrity": "sha512-d2L25Y4j+W3ZlNAeMKcy7yDsK425ibcAOO2t7aPTz6gNMH0z2GThtwENCDc0d/Pw9wgyRqE5Px1wkV7naz8ang==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
"undici-types": "~7.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node-forge": {
|
||||
@@ -4270,11 +4270,12 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/send": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.0.tgz",
|
||||
"integrity": "sha512-zBF6vZJn1IaMpg3xUF25VK3gd3l8zwE0ZLRX7dsQyQi+jp4E8mMDJNGDYnYse+bQhYwWERTxVwHpi3dMOq7RKQ==",
|
||||
"version": "0.17.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz",
|
||||
"integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/mime": "^1",
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
@@ -4288,24 +4289,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/serve-static": {
|
||||
"version": "1.15.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.9.tgz",
|
||||
"integrity": "sha512-dOTIuqpWLyl3BBXU3maNQsS4A3zuuoYRNIvYSxxhebPfXg2mzWQEPne/nlJ37yOse6uGgR386uTpdsx4D0QZWA==",
|
||||
"version": "1.15.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.8.tgz",
|
||||
"integrity": "sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/http-errors": "*",
|
||||
"@types/node": "*",
|
||||
"@types/send": "<1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/serve-static/node_modules/@types/send": {
|
||||
"version": "0.17.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz",
|
||||
"integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/mime": "^1",
|
||||
"@types/node": "*"
|
||||
"@types/send": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/sockjs": {
|
||||
@@ -5339,9 +5330,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/axe-core": {
|
||||
"version": "4.11.0",
|
||||
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.0.tgz",
|
||||
"integrity": "sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ==",
|
||||
"version": "4.10.3",
|
||||
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.10.3.tgz",
|
||||
"integrity": "sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg==",
|
||||
"license": "MPL-2.0",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
@@ -5644,9 +5635,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.8.18",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.18.tgz",
|
||||
"integrity": "sha512-UYmTpOBwgPScZpS4A+YbapwWuBwasxvO/2IOHArSsAhL/+ZdmATBXTex3t+l2hXwLVYK382ibr/nKoY9GKe86w==",
|
||||
"version": "2.8.10",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.10.tgz",
|
||||
"integrity": "sha512-uLfgBi+7IBNay8ECBO2mVMGZAc1VgZWEChxm4lv+TobGdG82LnXMjuNGo/BSSZZL4UmkWhxEHP2f5ziLNwGWMA==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"baseline-browser-mapping": "dist/cli.js"
|
||||
@@ -5965,9 +5956,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001751",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz",
|
||||
"integrity": "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==",
|
||||
"version": "1.0.30001746",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001746.tgz",
|
||||
"integrity": "sha512-eA7Ys/DGw+pnkWWSE/id29f2IcPHVoE8wxtvE5JdvD2V28VTDPy1yEeo11Guz0sJ4ZeGRcm3uaTcAqK1LXaphA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -6227,9 +6218,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/collect-v8-coverage": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz",
|
||||
"integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz",
|
||||
"integrity": "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
@@ -6407,9 +6398,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/core-js": {
|
||||
"version": "3.46.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.46.0.tgz",
|
||||
"integrity": "sha512-vDMm9B0xnqqZ8uSBpZ8sNtRtOdmfShrvT6h2TuQGLs0Is+cR0DYbj/KWP6ALVNbWPpqA/qPLoOuppJN07humpA==",
|
||||
"version": "3.45.1",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.45.1.tgz",
|
||||
"integrity": "sha512-L4NPsJlCfZsPeXukyzHFlg/i7IIVwHSItR0wg0FLNqYClJ4MQYTYLbC7EkjKYRLZF2iof2MUgN0EGy7MdQFChg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -6418,12 +6409,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/core-js-compat": {
|
||||
"version": "3.46.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.46.0.tgz",
|
||||
"integrity": "sha512-p9hObIIEENxSV8xIu+V68JjSeARg6UVMG5mR+JEUguG3sI6MsiS1njz2jHmyJDvA+8jX/sytkBHup6kxhM9law==",
|
||||
"version": "3.45.1",
|
||||
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.45.1.tgz",
|
||||
"integrity": "sha512-tqTt5T4PzsMIZ430XGviK4vzYSoeNJ6CXODi6c/voxOT6IZqBht5/EKaSNnYiEjjRYxjVz7DQIsOsY0XNi8PIA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"browserslist": "^4.26.3"
|
||||
"browserslist": "^4.25.3"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
@@ -6431,9 +6422,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/core-js-pure": {
|
||||
"version": "3.46.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.46.0.tgz",
|
||||
"integrity": "sha512-NMCW30bHNofuhwLhYPt66OLOKTMbOhgTTatKVbaQC3KRHpTCiRIBYvtshr+NBYSnBxwAFhjW/RfJ0XbIjS16rw==",
|
||||
"version": "3.45.1",
|
||||
"resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.45.1.tgz",
|
||||
"integrity": "sha512-OHnWFKgTUshEU8MK+lOs1H8kC8GkTi9Z1tvNkxrCcw9wl3MJIO7q2ld77wjWn4/xuGrVu2X+nME1iIIPBSdyEQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -7353,9 +7344,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.237",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.237.tgz",
|
||||
"integrity": "sha512-icUt1NvfhGLar5lSWH3tHNzablaA5js3HVHacQimfP8ViEBOQv+L7DKEuHdbTZ0SKCO1ogTJTIL1Gwk9S6Qvcg==",
|
||||
"version": "1.5.229",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.229.tgz",
|
||||
"integrity": "sha512-cwhDcZKGcT/rEthLRJ9eBlMDkh1sorgsuk+6dpsehV0g9CABsIqBxU4rLRjG+d/U6pYU1s37A4lSKrVc5lSQYg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/emittery": {
|
||||
@@ -11657,16 +11648,12 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/loader-runner": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz",
|
||||
"integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==",
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz",
|
||||
"integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.11.5"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/loader-utils": {
|
||||
@@ -12114,9 +12101,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.26",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.26.tgz",
|
||||
"integrity": "sha512-S2M9YimhSjBSvYnlr5/+umAnPHE++ODwt5e2Ij6FoX45HA/s4vHdkDx1eax2pAPeAOqu4s9b7ppahsyEFdVqQA==",
|
||||
"version": "2.0.21",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.21.tgz",
|
||||
"integrity": "sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/normalize-path": {
|
||||
@@ -15210,9 +15197,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.3",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
||||
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
|
||||
"version": "7.7.2",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
|
||||
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
@@ -16964,9 +16951,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"version": "4.9.5",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
|
||||
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
@@ -16974,7 +16961,7 @@
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
"node": ">=4.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unbox-primitive": {
|
||||
@@ -17002,9 +16989,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||
"version": "7.13.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.13.0.tgz",
|
||||
"integrity": "sha512-Ov2Rr9Sx+fRgagJ5AX0qvItZG/JKKoBRAVITs1zk7IqZGTJUwgUr7qoYBpWwakpWilTZFM98rG/AFRocu10iIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unicode-canonical-property-names-ecmascript": {
|
||||
@@ -17285,9 +17272,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/webpack": {
|
||||
"version": "5.102.1",
|
||||
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.102.1.tgz",
|
||||
"integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==",
|
||||
"version": "5.102.0",
|
||||
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.102.0.tgz",
|
||||
"integrity": "sha512-hUtqAR3ZLVEYDEABdBioQCIqSoguHbFn1K7WlPPWSuXmx0031BD73PSE35jKyftdSh4YLDoQNgK4pqBt5Q82MA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/eslint-scope": "^3.7.7",
|
||||
@@ -17298,7 +17285,7 @@
|
||||
"@webassemblyjs/wasm-parser": "^1.14.1",
|
||||
"acorn": "^8.15.0",
|
||||
"acorn-import-phases": "^1.0.3",
|
||||
"browserslist": "^4.26.3",
|
||||
"browserslist": "^4.24.5",
|
||||
"chrome-trace-event": "^1.0.2",
|
||||
"enhanced-resolve": "^5.17.3",
|
||||
"es-module-lexer": "^1.2.1",
|
||||
@@ -17310,8 +17297,8 @@
|
||||
"loader-runner": "^4.2.0",
|
||||
"mime-types": "^2.1.27",
|
||||
"neo-async": "^2.6.2",
|
||||
"schema-utils": "^4.3.3",
|
||||
"tapable": "^2.3.0",
|
||||
"schema-utils": "^4.3.2",
|
||||
"tapable": "^2.2.3",
|
||||
"terser-webpack-plugin": "^5.3.11",
|
||||
"watchpack": "^2.4.4",
|
||||
"webpack-sources": "^3.3.3"
|
||||
|
||||
196
frontend/src/components/server/ReactionRoles.js
Normal file
196
frontend/src/components/server/ReactionRoles.js
Normal file
@@ -0,0 +1,196 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Box, Button, TextField, Select, MenuItem, FormControl, InputLabel, Accordion, AccordionSummary, AccordionDetails, Typography, IconButton, List, ListItem, ListItemText, Chip } from '@mui/material';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import * as api from '../../lib/api';
|
||||
import { useBackend } from '../../contexts/BackendContext';
|
||||
import ConfirmDialog from '../common/ConfirmDialog';
|
||||
|
||||
export default function ReactionRoles({ guildId, channels, roles = [] }) {
|
||||
const { eventTarget } = useBackend() || {};
|
||||
const [list, setList] = useState([]);
|
||||
const [name, setName] = useState('');
|
||||
const [channelId, setChannelId] = useState('');
|
||||
const [embed, setEmbed] = useState('');
|
||||
const [embedTitle, setEmbedTitle] = useState('');
|
||||
const [embedColor, setEmbedColor] = useState('#2f3136');
|
||||
const [embedThumbnail, setEmbedThumbnail] = useState('');
|
||||
const [embedFields, setEmbedFields] = useState([]);
|
||||
const [buttons, setButtons] = useState([]);
|
||||
const [newBtnLabel, setNewBtnLabel] = useState('');
|
||||
const [newBtnRole, setNewBtnRole] = useState('');
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [pendingDeleteId, setPendingDeleteId] = useState(null);
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
async function load() {
|
||||
const rows = await api.listReactionRoles(guildId) || [];
|
||||
if (!mounted) return;
|
||||
setList(rows);
|
||||
}
|
||||
load();
|
||||
|
||||
const onRRUpdate = (e) => {
|
||||
const d = e.detail || {};
|
||||
if (d.guildId && d.guildId !== guildId) return;
|
||||
// reload
|
||||
api.listReactionRoles(guildId).then(rows => setList(rows || []));
|
||||
};
|
||||
|
||||
eventTarget && eventTarget.addEventListener('reactionRolesUpdate', onRRUpdate);
|
||||
return () => { mounted = false; eventTarget && eventTarget.removeEventListener('reactionRolesUpdate', onRRUpdate); };
|
||||
}, [guildId, eventTarget]);
|
||||
|
||||
const addButton = () => {
|
||||
if (!newBtnLabel || !newBtnRole) return;
|
||||
setButtons(prev => [...prev, { label: newBtnLabel, roleId: newBtnRole }]);
|
||||
setNewBtnLabel(''); setNewBtnRole('');
|
||||
};
|
||||
|
||||
const addEmbedField = () => {
|
||||
setEmbedFields(prev => [...prev, { name: '', value: '' }]);
|
||||
};
|
||||
|
||||
const updateEmbedField = (idx, k, v) => {
|
||||
setEmbedFields(prev => prev.map((f,i) => i===idx ? { ...f, [k]: v } : f));
|
||||
};
|
||||
|
||||
const removeEmbedField = (idx) => {
|
||||
setEmbedFields(prev => prev.filter((_,i)=>i!==idx));
|
||||
};
|
||||
|
||||
const createRR = async () => {
|
||||
if (editingId) return updateRR(); // if editing, update instead
|
||||
if (!channelId || !name || (!embed && !embedTitle) || buttons.length === 0) return alert('channel, name, embed (title or description), and at least one button required');
|
||||
const emb = { title: embedTitle, description: embed, color: embedColor, thumbnail: embedThumbnail, fields: embedFields };
|
||||
const res = await api.createReactionRole(guildId, { channelId, name, embed: emb, buttons });
|
||||
if (res && res.reactionRole) {
|
||||
setList(prev => [res.reactionRole, ...prev]);
|
||||
setName(''); setEmbed(''); setEmbedTitle(''); setEmbedColor('#2f3136'); setEmbedThumbnail(''); setEmbedFields([]); setButtons([]); setChannelId('');
|
||||
} else {
|
||||
alert('Failed to create reaction role');
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = (id) => {
|
||||
setPendingDeleteId(id);
|
||||
setConfirmOpen(true);
|
||||
};
|
||||
|
||||
const deleteRR = async (id) => {
|
||||
const ok = await api.deleteReactionRole(guildId, id);
|
||||
if (ok) setList(prev => prev.filter(r => r.id !== id));
|
||||
setConfirmOpen(false);
|
||||
setPendingDeleteId(null);
|
||||
};
|
||||
|
||||
const startEdit = (rr) => {
|
||||
setEditingId(rr.id);
|
||||
setName(rr.name);
|
||||
setChannelId(rr.channel_id);
|
||||
setEmbed(rr.embed?.description || '');
|
||||
setEmbedTitle(rr.embed?.title || '');
|
||||
setEmbedColor(rr.embed?.color || '#2f3136');
|
||||
setEmbedThumbnail(rr.embed?.thumbnail || '');
|
||||
setEmbedFields(rr.embed?.fields || []);
|
||||
setButtons(rr.buttons || []);
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditingId(null);
|
||||
setName(''); setChannelId(''); setEmbed(''); setEmbedTitle(''); setEmbedColor('#2f3136'); setEmbedThumbnail(''); setEmbedFields([]); setButtons([]);
|
||||
};
|
||||
|
||||
const updateRR = async () => {
|
||||
if (!channelId || !name || (!embed && !embedTitle) || buttons.length === 0) return alert('channel, name, embed (title or description), and at least one button required');
|
||||
const emb = { title: embedTitle, description: embed, color: embedColor, thumbnail: embedThumbnail, fields: embedFields };
|
||||
const res = await api.updateReactionRole(guildId, editingId, { channelId, name, embed: emb, buttons });
|
||||
if (res && res.reactionRole) {
|
||||
setList(prev => prev.map(r => r.id === editingId ? res.reactionRole : r));
|
||||
cancelEdit();
|
||||
} else {
|
||||
alert('Failed to update reaction role');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon/>}>
|
||||
<Typography>Reaction Roles</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<FormControl fullWidth sx={{ mb: 1 }}>
|
||||
<InputLabel id="rr-channel-label">Channel</InputLabel>
|
||||
<Select labelId="rr-channel-label" value={channelId} label="Channel" onChange={e => setChannelId(e.target.value)}>
|
||||
<MenuItem value="">Select channel</MenuItem>
|
||||
{channels.map(c => <MenuItem key={c.id} value={c.id}>{c.name}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField label="Name" fullWidth value={name} onChange={e=>setName(e.target.value)} sx={{ mb:1 }} />
|
||||
<TextField label="Embed (description)" fullWidth multiline rows={3} value={embed} onChange={e=>setEmbed(e.target.value)} sx={{ mb:1 }} />
|
||||
<Box sx={{ display: 'flex', gap: 1, mb:1 }}>
|
||||
<TextField label="Embed Title" value={embedTitle} onChange={e=>setEmbedTitle(e.target.value)} sx={{ flex: 1 }} />
|
||||
<TextField label="Color" value={embedColor} onChange={e=>setEmbedColor(e.target.value)} sx={{ width: 120 }} />
|
||||
</Box>
|
||||
<TextField label="Thumbnail URL" fullWidth value={embedThumbnail} onChange={e=>setEmbedThumbnail(e.target.value)} sx={{ mb:1 }} />
|
||||
<Box sx={{ mb:1 }}>
|
||||
<Typography variant="subtitle2">Fields</Typography>
|
||||
{embedFields.map((f,i)=> (
|
||||
<Box key={i} sx={{ display: 'flex', gap: 1, mb: 1 }}>
|
||||
<TextField placeholder="Name" value={f.name} onChange={e=>updateEmbedField(i, 'name', e.target.value)} sx={{ flex: 1 }} />
|
||||
<TextField placeholder="Value" value={f.value} onChange={e=>updateEmbedField(i, 'value', e.target.value)} sx={{ flex: 2 }} />
|
||||
<IconButton onClick={()=>removeEmbedField(i)}><DeleteIcon/></IconButton>
|
||||
</Box>
|
||||
))}
|
||||
<Button onClick={addEmbedField} size="small">Add Field</Button>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 1, mb:1 }}>
|
||||
<TextField label="Button label" value={newBtnLabel} onChange={e=>setNewBtnLabel(e.target.value)} />
|
||||
<FormControl sx={{ minWidth: 220 }}>
|
||||
<InputLabel id="rr-role-label">Role</InputLabel>
|
||||
<Select labelId="rr-role-label" value={newBtnRole} label="Role" onChange={e=>setNewBtnRole(e.target.value)}>
|
||||
<MenuItem value="">Select role</MenuItem>
|
||||
{roles.map(role => (
|
||||
<MenuItem key={role.id} value={role.id}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Chip size="small" label={role.name} sx={{ bgcolor: role.color || undefined, color: role.color ? '#fff' : undefined }} />
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>{role.permissions || ''}</Typography>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Button variant="outlined" onClick={addButton}>Add Button</Button>
|
||||
</Box>
|
||||
<List>
|
||||
{buttons.map((b,i)=>(
|
||||
<ListItem key={i} secondaryAction={<IconButton edge="end" onClick={()=>setButtons(bs=>bs.filter((_,idx)=>idx!==i))}><DeleteIcon/></IconButton>}>
|
||||
<ListItemText primary={b.label} secondary={roles.find(r=>r.id===b.roleId)?.name || b.roleId} />
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button variant="contained" onClick={createRR}>{editingId ? 'Update Reaction Role' : 'Create Reaction Role'}</Button>
|
||||
{editingId && <Button variant="outlined" onClick={cancelEdit}>Cancel</Button>}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Typography variant="h6">Existing</Typography>
|
||||
{list.map(r => (
|
||||
<Box key={r.id} sx={{ border: '1px solid #ddd', p:1, mb:1 }}>
|
||||
<Typography>{r.name}</Typography>
|
||||
<Typography variant="body2">Channel: {r.channel_id || r.channelId}</Typography>
|
||||
<Typography variant="body2">Message: {r.message_id || r.messageId || 'Not posted'}</Typography>
|
||||
<Button variant="outlined" onClick={async ()=>{ const res = await api.postReactionRoleMessage(guildId, r); if (!res || !res.success) alert('Failed to post message'); }}>Post Message</Button>
|
||||
<Button variant="text" color="error" onClick={()=>confirmDelete(r.id)}>Delete</Button>
|
||||
<Button variant="text" onClick={() => startEdit(r)}>Edit</Button>
|
||||
</Box>
|
||||
))}
|
||||
<ConfirmDialog open={confirmOpen} title="Delete Reaction Role" description="Delete this reaction role configuration? This will remove it from the database." onClose={() => { setConfirmOpen(false); setPendingDeleteId(null); }} onConfirm={() => deleteRR(pendingDeleteId)} />
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import ConfirmDialog from '../common/ConfirmDialog';
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import { UserContext } from '../../contexts/UserContext';
|
||||
import ReactionRoles from './ReactionRoles';
|
||||
|
||||
// Use a relative API base by default so the frontend talks to the same origin that served it.
|
||||
// In development you can set REACT_APP_API_BASE to a full URL if needed.
|
||||
@@ -269,6 +270,22 @@ const ServerSettings = () => {
|
||||
setAdminLogs([]);
|
||||
};
|
||||
|
||||
const onInviteCreated = (e) => {
|
||||
const data = e.detail || {};
|
||||
if (!data) return;
|
||||
if (data.guildId && data.guildId !== guildId) return;
|
||||
// Add the new invite to the list
|
||||
setInvites(prev => [...prev, data]);
|
||||
};
|
||||
|
||||
const onInviteDeleted = (e) => {
|
||||
const data = e.detail || {};
|
||||
if (!data) return;
|
||||
if (data.guildId && data.guildId !== guildId) return;
|
||||
// Remove the deleted invite from the list
|
||||
setInvites(prev => prev.filter(invite => invite.code !== data.code));
|
||||
};
|
||||
|
||||
eventTarget.addEventListener('twitchUsersUpdate', onTwitchUsers);
|
||||
eventTarget.addEventListener('kickUsersUpdate', onKickUsers);
|
||||
eventTarget.addEventListener('liveNotificationsUpdate', onLiveNotifications);
|
||||
@@ -276,6 +293,8 @@ const ServerSettings = () => {
|
||||
eventTarget.addEventListener('adminLogAdded', onAdminLogAdded);
|
||||
eventTarget.addEventListener('adminLogDeleted', onAdminLogDeleted);
|
||||
eventTarget.addEventListener('adminLogsCleared', onAdminLogsCleared);
|
||||
eventTarget.addEventListener('inviteCreated', onInviteCreated);
|
||||
eventTarget.addEventListener('inviteDeleted', onInviteDeleted);
|
||||
|
||||
return () => {
|
||||
try {
|
||||
@@ -286,6 +305,8 @@ const ServerSettings = () => {
|
||||
eventTarget.removeEventListener('adminLogAdded', onAdminLogAdded);
|
||||
eventTarget.removeEventListener('adminLogDeleted', onAdminLogDeleted);
|
||||
eventTarget.removeEventListener('adminLogsCleared', onAdminLogsCleared);
|
||||
eventTarget.removeEventListener('inviteCreated', onInviteCreated);
|
||||
eventTarget.removeEventListener('inviteDeleted', onInviteDeleted);
|
||||
} catch (err) {}
|
||||
};
|
||||
}, [eventTarget, guildId]);
|
||||
@@ -654,6 +675,10 @@ const ServerSettings = () => {
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
{/* Reaction Roles Accordion */}
|
||||
<Box sx={{ marginTop: '20px' }}>
|
||||
<ReactionRoles guildId={guildId} channels={channels} roles={roles} />
|
||||
</Box>
|
||||
{/* Live Notifications dialog */}
|
||||
{/* header live dialog removed; Live Notifications is managed in its own accordion below */}
|
||||
{/* Invite creation and list */}
|
||||
@@ -669,7 +694,9 @@ const ServerSettings = () => {
|
||||
<FormControl fullWidth>
|
||||
<Select value={inviteForm.channelId} onChange={(e) => setInviteForm(f => ({ ...f, channelId: e.target.value }))} displayEmpty>
|
||||
<MenuItem value="">(Any channel)</MenuItem>
|
||||
{channels.map(ch => (<MenuItem key={ch.id} value={ch.id}>{ch.name}</MenuItem>))}
|
||||
{channels.map((channel) => (
|
||||
<MenuItem key={channel.id} value={channel.id}>{channel.name}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
@@ -778,7 +805,7 @@ const ServerSettings = () => {
|
||||
displayEmpty
|
||||
>
|
||||
<MenuItem value="" disabled>Select a channel</MenuItem>
|
||||
{channels.map(channel => (
|
||||
{channels.map((channel) => (
|
||||
<MenuItem key={channel.id} value={channel.id}>{channel.name}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
@@ -819,7 +846,7 @@ const ServerSettings = () => {
|
||||
displayEmpty
|
||||
>
|
||||
<MenuItem value="" disabled>Select a channel</MenuItem>
|
||||
{channels.map(channel => (
|
||||
{channels.map((channel) => (
|
||||
<MenuItem key={channel.id} value={channel.id}>{channel.name}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
@@ -1151,7 +1178,8 @@ const ServerSettings = () => {
|
||||
{adminLogs.length === 0 ? (
|
||||
<Typography>No logs available.</Typography>
|
||||
) : (
|
||||
adminLogs.map(log => (
|
||||
<React.Fragment>
|
||||
{adminLogs.map((log) => (
|
||||
<Box key={log.id} sx={{ p: 1, border: '1px solid #eee', mb: 1, borderRadius: 1, bgcolor: 'background.paper' }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
@@ -1174,7 +1202,8 @@ const ServerSettings = () => {
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
))
|
||||
))}
|
||||
</React.Fragment>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -20,4 +20,30 @@ export async function del(path, config) {
|
||||
return client.delete(path, config);
|
||||
}
|
||||
|
||||
export async function listReactionRoles(guildId) {
|
||||
const res = await client.get(`/api/servers/${guildId}/reaction-roles`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function createReactionRole(guildId, body) {
|
||||
const res = await client.post(`/api/servers/${guildId}/reaction-roles`, body);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function deleteReactionRole(guildId, id) {
|
||||
const res = await client.delete(`/api/servers/${guildId}/reaction-roles/${id}`);
|
||||
return res.data && res.data.success;
|
||||
}
|
||||
|
||||
export async function postReactionRoleMessage(guildId, rr) {
|
||||
// instruct backend to have bot post message by asking bot module via internal call
|
||||
const res = await client.post(`/internal/publish-reaction-role`, { guildId, id: rr.id });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function updateReactionRole(guildId, id, body) {
|
||||
const res = await client.put(`/api/servers/${guildId}/reaction-roles/${id}`, body);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export default client;
|
||||
|
||||
6
package-lock.json
generated
6
package-lock.json
generated
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"name": "ECS-FullStack",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
Reference in New Issue
Block a user