bug fixes
This commit is contained in:
103
backend/index.js
103
backend/index.js
@@ -1216,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) => {
|
app.get('/api/servers/:guildId/admin-logs', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { guildId } = req.params;
|
const { guildId } = req.params;
|
||||||
@@ -1537,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, () => {
|
app.listen(port, host, () => {
|
||||||
console.log(`Server is running on ${host}:${port}`);
|
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": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1",
|
"test": "jest --runInBand",
|
||||||
"start": "node index.js",
|
"start": "node index.js",
|
||||||
"dev": "nodemon index.js"
|
"dev": "nodemon index.js"
|
||||||
},
|
},
|
||||||
@@ -22,6 +22,8 @@
|
|||||||
"node-fetch": "^2.6.7"
|
"node-fetch": "^2.6.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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()
|
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
|
// Servers
|
||||||
@@ -132,6 +145,116 @@ async function deleteAllAdminLogs(guildId) {
|
|||||||
await p.query('DELETE FROM admin_logs WHERE guild_id = $1', [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
|
// Users
|
||||||
async function getUserData(discordId) {
|
async function getUserData(discordId) {
|
||||||
const p = initPool();
|
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]);
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
12
checklist.md
12
checklist.md
@@ -103,6 +103,18 @@
|
|||||||
- [x] Offline reconciliation: bot detects and removes invites deleted while offline on startup
|
- [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] 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
|
## Database
|
||||||
- [x] Postgres support via `DATABASE_URL` (backend auto-creates `servers`, `invites`, `users`)
|
- [x] Postgres support via `DATABASE_URL` (backend auto-creates `servers`, `invites`, `users`)
|
||||||
- [x] Legacy encrypted `backend/db.json` retained (migration planned)
|
- [x] Legacy encrypted `backend/db.json` retained (migration planned)
|
||||||
|
|||||||
@@ -87,6 +87,12 @@ async function listInvites(guildId) {
|
|||||||
return json || [];
|
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) {
|
async function addInvite(guildId, invite) {
|
||||||
const path = `/api/servers/${guildId}/invites`;
|
const path = `/api/servers/${guildId}/invites`;
|
||||||
try {
|
try {
|
||||||
@@ -127,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 };
|
module.exports = { getServerSettings, upsertServerSettings, getCommands, toggleCommand, listInvites, addInvite, deleteInvite };
|
||||||
// Twitch users helpers
|
// Twitch users helpers
|
||||||
async function getTwitchUsers(guildId) {
|
async function getTwitchUsers(guildId) {
|
||||||
@@ -262,4 +295,4 @@ async function reconcileInvites(guildId, currentDiscordInvites) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { getServerSettings, upsertServerSettings, getCommands, toggleCommand, listInvites, addInvite, deleteInvite, getTwitchUsers, addTwitchUser, deleteTwitchUser, tryFetchTwitchStreams, _rawGetTwitchStreams, getKickUsers, addKickUser, deleteKickUser, getWelcomeLeaveSettings, getAutoroleSettings, reconcileInvites };
|
module.exports = { getServerSettings, upsertServerSettings, getCommands, toggleCommand, listInvites, addInvite, deleteInvite, listReactionRoles, updateReactionRole, deleteReactionRole, getTwitchUsers, addTwitchUser, deleteTwitchUser, tryFetchTwitchStreams, _rawGetTwitchStreams, getKickUsers, addKickUser, deleteKickUser, getWelcomeLeaveSettings, getAutoroleSettings, reconcileInvites };
|
||||||
|
|||||||
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -12,11 +12,27 @@ for (const file of commandFiles) {
|
|||||||
const command = require(filePath);
|
const command = require(filePath);
|
||||||
if (command.enabled === false || command.dev === true) 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());
|
commands.push(command.builder.toJSON());
|
||||||
} else {
|
} else {
|
||||||
commands.push({ name: command.name, description: command.description });
|
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 rest = new REST({ version: '10' }).setToken(process.env.DISCORD_BOT_TOKEN);
|
||||||
|
|||||||
@@ -42,6 +42,72 @@ module.exports = {
|
|||||||
console.log('✅ Invite reconciliation complete: no stale invites found');
|
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 = [
|
const activities = [
|
||||||
{ name: 'Watch EhChad Live!', type: ActivityType.Streaming, url: 'https://twitch.tv/ehchad' },
|
{ name: 'Watch EhChad Live!', type: ActivityType.Streaming, url: 'https://twitch.tv/ehchad' },
|
||||||
{ name: 'Follow EhChad!', 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;
|
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;
|
if (!interaction.isCommand()) return;
|
||||||
|
|
||||||
const command = client.commands.get(interaction.commandName);
|
const command = client.commands.get(interaction.commandName);
|
||||||
@@ -176,6 +231,50 @@ async function announceLive(guildId, stream) {
|
|||||||
|
|
||||||
module.exports = { login, client, setGuildSettings, getGuildSettingsFromCache, announceLive };
|
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)
|
// Start twitch watcher when client is ready (use 'clientReady' as the event name)
|
||||||
try {
|
try {
|
||||||
const watcher = require('./twitch-watcher');
|
const watcher = require('./twitch-watcher');
|
||||||
|
|||||||
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 ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||||
import DeleteIcon from '@mui/icons-material/Delete';
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
import { UserContext } from '../../contexts/UserContext';
|
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.
|
// 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.
|
// In development you can set REACT_APP_API_BASE to a full URL if needed.
|
||||||
@@ -674,6 +675,10 @@ const ServerSettings = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
</AccordionDetails>
|
</AccordionDetails>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
{/* Reaction Roles Accordion */}
|
||||||
|
<Box sx={{ marginTop: '20px' }}>
|
||||||
|
<ReactionRoles guildId={guildId} channels={channels} roles={roles} />
|
||||||
|
</Box>
|
||||||
{/* Live Notifications dialog */}
|
{/* Live Notifications dialog */}
|
||||||
{/* header live dialog removed; Live Notifications is managed in its own accordion below */}
|
{/* header live dialog removed; Live Notifications is managed in its own accordion below */}
|
||||||
{/* Invite creation and list */}
|
{/* Invite creation and list */}
|
||||||
|
|||||||
@@ -20,4 +20,30 @@ export async function del(path, config) {
|
|||||||
return client.delete(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;
|
export default client;
|
||||||
|
|||||||
Reference in New Issue
Block a user