Add bulk moderation actions for server administrators
Adds a new `/bulkmod` slash command and a corresponding API endpoint to `webServer.js` to handle bulk moderation actions (ban, kick, timeout, warn, remove_timeout) for up to 25 users at once. Also adds a new "Bulk Actions" tab to the dashboard. Replit-Commit-Author: Agent Replit-Commit-Session-Id: aed2e46d-25bb-4b73-81a1-bb9e8437c261 Replit-Commit-Checkpoint-Type: intermediate_checkpoint Replit-Commit-Event-Id: 9c56d07f-e250-4dd9-8108-5c170cbdb44f Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/3bdfff67-975a-46ad-9845-fbb6b4a4c4b5/aed2e46d-25bb-4b73-81a1-bb9e8437c261/INbSSam Replit-Helium-Checkpoint-Created: true
This commit is contained in:
parent
467fd8467f
commit
299db195c6
3 changed files with 799 additions and 0 deletions
401
aethex-bot/commands/bulkmod.js
Normal file
401
aethex-bot/commands/bulkmod.js
Normal file
|
|
@ -0,0 +1,401 @@
|
|||
const { SlashCommandBuilder, EmbedBuilder, PermissionFlagsBits } = require('discord.js');
|
||||
|
||||
module.exports = {
|
||||
data: new SlashCommandBuilder()
|
||||
.setName('bulkmod')
|
||||
.setDescription('Perform moderation actions on multiple users at once')
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers)
|
||||
.addSubcommand(sub =>
|
||||
sub.setName('ban')
|
||||
.setDescription('Ban multiple users at once')
|
||||
.addStringOption(opt =>
|
||||
opt.setName('users')
|
||||
.setDescription('User IDs or mentions separated by spaces')
|
||||
.setRequired(true))
|
||||
.addStringOption(opt =>
|
||||
opt.setName('reason')
|
||||
.setDescription('Reason for ban')
|
||||
.setRequired(false)
|
||||
.setMaxLength(500))
|
||||
.addIntegerOption(opt =>
|
||||
opt.setName('delete_days')
|
||||
.setDescription('Days of messages to delete (0-7)')
|
||||
.setMinValue(0)
|
||||
.setMaxValue(7)))
|
||||
.addSubcommand(sub =>
|
||||
sub.setName('kick')
|
||||
.setDescription('Kick multiple users at once')
|
||||
.addStringOption(opt =>
|
||||
opt.setName('users')
|
||||
.setDescription('User IDs or mentions separated by spaces')
|
||||
.setRequired(true))
|
||||
.addStringOption(opt =>
|
||||
opt.setName('reason')
|
||||
.setDescription('Reason for kick')
|
||||
.setRequired(false)
|
||||
.setMaxLength(500)))
|
||||
.addSubcommand(sub =>
|
||||
sub.setName('timeout')
|
||||
.setDescription('Timeout multiple users at once')
|
||||
.addStringOption(opt =>
|
||||
opt.setName('users')
|
||||
.setDescription('User IDs or mentions separated by spaces')
|
||||
.setRequired(true))
|
||||
.addStringOption(opt =>
|
||||
opt.setName('duration')
|
||||
.setDescription('Timeout duration')
|
||||
.setRequired(true)
|
||||
.addChoices(
|
||||
{ name: '5 minutes', value: '5m' },
|
||||
{ name: '10 minutes', value: '10m' },
|
||||
{ name: '30 minutes', value: '30m' },
|
||||
{ name: '1 hour', value: '1h' },
|
||||
{ name: '6 hours', value: '6h' },
|
||||
{ name: '12 hours', value: '12h' },
|
||||
{ name: '1 day', value: '1d' },
|
||||
{ name: '3 days', value: '3d' },
|
||||
{ name: '1 week', value: '1w' }
|
||||
))
|
||||
.addStringOption(opt =>
|
||||
opt.setName('reason')
|
||||
.setDescription('Reason for timeout')
|
||||
.setRequired(false)
|
||||
.setMaxLength(500)))
|
||||
.addSubcommand(sub =>
|
||||
sub.setName('warn')
|
||||
.setDescription('Warn multiple users at once')
|
||||
.addStringOption(opt =>
|
||||
opt.setName('users')
|
||||
.setDescription('User IDs or mentions separated by spaces')
|
||||
.setRequired(true))
|
||||
.addStringOption(opt =>
|
||||
opt.setName('reason')
|
||||
.setDescription('Reason for warning')
|
||||
.setRequired(true)
|
||||
.setMaxLength(500)))
|
||||
.addSubcommand(sub =>
|
||||
sub.setName('remove_timeout')
|
||||
.setDescription('Remove timeout from multiple users')
|
||||
.addStringOption(opt =>
|
||||
opt.setName('users')
|
||||
.setDescription('User IDs or mentions separated by spaces')
|
||||
.setRequired(true))),
|
||||
|
||||
async execute(interaction, supabase, client) {
|
||||
await interaction.deferReply();
|
||||
|
||||
const subcommand = interaction.options.getSubcommand();
|
||||
const usersInput = interaction.options.getString('users');
|
||||
const reason = interaction.options.getString('reason') || 'No reason provided';
|
||||
const moderator = interaction.user;
|
||||
|
||||
const userIds = parseUserIds(usersInput);
|
||||
|
||||
if (userIds.length === 0) {
|
||||
return interaction.editReply({ content: '❌ No valid user IDs provided.' });
|
||||
}
|
||||
|
||||
if (userIds.length > 25) {
|
||||
return interaction.editReply({ content: '❌ Maximum 25 users per bulk action.' });
|
||||
}
|
||||
|
||||
const results = { success: [], failed: [] };
|
||||
|
||||
switch (subcommand) {
|
||||
case 'ban':
|
||||
await handleBulkBan(interaction, userIds, reason, results, supabase, moderator);
|
||||
break;
|
||||
case 'kick':
|
||||
await handleBulkKick(interaction, userIds, reason, results, supabase, moderator);
|
||||
break;
|
||||
case 'timeout':
|
||||
const duration = interaction.options.getString('duration');
|
||||
await handleBulkTimeout(interaction, userIds, duration, reason, results, supabase, moderator);
|
||||
break;
|
||||
case 'warn':
|
||||
await handleBulkWarn(interaction, userIds, reason, results, supabase, moderator);
|
||||
break;
|
||||
case 'remove_timeout':
|
||||
await handleBulkRemoveTimeout(interaction, userIds, results, supabase, moderator);
|
||||
break;
|
||||
}
|
||||
|
||||
const embed = createResultEmbed(subcommand, results, moderator);
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
},
|
||||
};
|
||||
|
||||
function parseUserIds(input) {
|
||||
const mentionRegex = /<@!?(\d+)>/g;
|
||||
const idRegex = /\b(\d{17,20})\b/g;
|
||||
|
||||
const ids = new Set();
|
||||
|
||||
let match;
|
||||
while ((match = mentionRegex.exec(input)) !== null) {
|
||||
ids.add(match[1]);
|
||||
}
|
||||
while ((match = idRegex.exec(input)) !== null) {
|
||||
ids.add(match[1]);
|
||||
}
|
||||
|
||||
return Array.from(ids);
|
||||
}
|
||||
|
||||
function parseDuration(duration) {
|
||||
const units = { m: 60, h: 3600, d: 86400, w: 604800 };
|
||||
const match = duration.match(/^(\d+)([mhdw])$/);
|
||||
if (!match) return null;
|
||||
return parseInt(match[1]) * units[match[2]] * 1000;
|
||||
}
|
||||
|
||||
async function handleBulkBan(interaction, userIds, reason, results, supabase, moderator) {
|
||||
const deleteDays = interaction.options.getInteger('delete_days') || 0;
|
||||
|
||||
for (const userId of userIds) {
|
||||
try {
|
||||
if (userId === moderator.id) {
|
||||
results.failed.push({ id: userId, reason: 'Cannot ban yourself' });
|
||||
continue;
|
||||
}
|
||||
|
||||
const member = await interaction.guild.members.fetch(userId).catch(() => null);
|
||||
if (member && !member.bannable) {
|
||||
results.failed.push({ id: userId, reason: 'Not bannable' });
|
||||
continue;
|
||||
}
|
||||
|
||||
await interaction.guild.members.ban(userId, {
|
||||
reason: `[Bulk] ${reason} | By ${moderator.tag}`,
|
||||
deleteMessageSeconds: deleteDays * 24 * 60 * 60
|
||||
});
|
||||
|
||||
if (supabase) {
|
||||
const user = await interaction.client.users.fetch(userId).catch(() => null);
|
||||
await supabase.from('mod_actions').insert({
|
||||
guild_id: interaction.guildId,
|
||||
action: 'ban',
|
||||
user_id: userId,
|
||||
user_tag: user?.tag || 'Unknown',
|
||||
moderator_id: moderator.id,
|
||||
moderator_tag: moderator.tag,
|
||||
reason: `[Bulk] ${reason}`,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
results.success.push(userId);
|
||||
} catch (error) {
|
||||
results.failed.push({ id: userId, reason: error.message.substring(0, 50) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBulkKick(interaction, userIds, reason, results, supabase, moderator) {
|
||||
for (const userId of userIds) {
|
||||
try {
|
||||
if (userId === moderator.id) {
|
||||
results.failed.push({ id: userId, reason: 'Cannot kick yourself' });
|
||||
continue;
|
||||
}
|
||||
|
||||
const member = await interaction.guild.members.fetch(userId).catch(() => null);
|
||||
if (!member) {
|
||||
results.failed.push({ id: userId, reason: 'Not in server' });
|
||||
continue;
|
||||
}
|
||||
if (!member.kickable) {
|
||||
results.failed.push({ id: userId, reason: 'Not kickable' });
|
||||
continue;
|
||||
}
|
||||
|
||||
await member.kick(`[Bulk] ${reason} | By ${moderator.tag}`);
|
||||
|
||||
if (supabase) {
|
||||
await supabase.from('mod_actions').insert({
|
||||
guild_id: interaction.guildId,
|
||||
action: 'kick',
|
||||
user_id: userId,
|
||||
user_tag: member.user.tag,
|
||||
moderator_id: moderator.id,
|
||||
moderator_tag: moderator.tag,
|
||||
reason: `[Bulk] ${reason}`,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
results.success.push(userId);
|
||||
} catch (error) {
|
||||
results.failed.push({ id: userId, reason: error.message.substring(0, 50) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBulkTimeout(interaction, userIds, duration, reason, results, supabase, moderator) {
|
||||
const durationMs = parseDuration(duration);
|
||||
|
||||
for (const userId of userIds) {
|
||||
try {
|
||||
if (userId === moderator.id) {
|
||||
results.failed.push({ id: userId, reason: 'Cannot timeout yourself' });
|
||||
continue;
|
||||
}
|
||||
|
||||
const member = await interaction.guild.members.fetch(userId).catch(() => null);
|
||||
if (!member) {
|
||||
results.failed.push({ id: userId, reason: 'Not in server' });
|
||||
continue;
|
||||
}
|
||||
if (!member.moderatable) {
|
||||
results.failed.push({ id: userId, reason: 'Not moderatable' });
|
||||
continue;
|
||||
}
|
||||
|
||||
await member.timeout(durationMs, `[Bulk] ${reason} | By ${moderator.tag}`);
|
||||
|
||||
if (supabase) {
|
||||
await supabase.from('mod_actions').insert({
|
||||
guild_id: interaction.guildId,
|
||||
action: 'timeout',
|
||||
user_id: userId,
|
||||
user_tag: member.user.tag,
|
||||
moderator_id: moderator.id,
|
||||
moderator_tag: moderator.tag,
|
||||
reason: `[Bulk] ${reason}`,
|
||||
duration: duration
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
results.success.push(userId);
|
||||
} catch (error) {
|
||||
results.failed.push({ id: userId, reason: error.message.substring(0, 50) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBulkWarn(interaction, userIds, reason, results, supabase, moderator) {
|
||||
for (const userId of userIds) {
|
||||
try {
|
||||
const member = await interaction.guild.members.fetch(userId).catch(() => null);
|
||||
if (!member) {
|
||||
results.failed.push({ id: userId, reason: 'Not in server' });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (supabase) {
|
||||
await supabase.from('warnings').insert({
|
||||
guild_id: interaction.guildId,
|
||||
user_id: userId,
|
||||
user_tag: member.user.tag,
|
||||
moderator_id: moderator.id,
|
||||
moderator_tag: moderator.tag,
|
||||
reason: `[Bulk] ${reason}`,
|
||||
});
|
||||
|
||||
await supabase.from('mod_actions').insert({
|
||||
guild_id: interaction.guildId,
|
||||
action: 'warn',
|
||||
user_id: userId,
|
||||
user_tag: member.user.tag,
|
||||
moderator_id: moderator.id,
|
||||
moderator_tag: moderator.tag,
|
||||
reason: `[Bulk] ${reason}`,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
try {
|
||||
await member.user.send({
|
||||
embeds: [
|
||||
new EmbedBuilder()
|
||||
.setColor(0xffa500)
|
||||
.setTitle(`Warning from ${interaction.guild.name}`)
|
||||
.setDescription(reason)
|
||||
.setTimestamp()
|
||||
]
|
||||
});
|
||||
} catch {}
|
||||
|
||||
results.success.push(userId);
|
||||
} catch (error) {
|
||||
results.failed.push({ id: userId, reason: error.message.substring(0, 50) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBulkRemoveTimeout(interaction, userIds, results, supabase, moderator) {
|
||||
for (const userId of userIds) {
|
||||
try {
|
||||
const member = await interaction.guild.members.fetch(userId).catch(() => null);
|
||||
if (!member) {
|
||||
results.failed.push({ id: userId, reason: 'Not in server' });
|
||||
continue;
|
||||
}
|
||||
if (!member.isCommunicationDisabled()) {
|
||||
results.failed.push({ id: userId, reason: 'Not timed out' });
|
||||
continue;
|
||||
}
|
||||
|
||||
await member.timeout(null, `Timeout removed by ${moderator.tag}`);
|
||||
|
||||
if (supabase) {
|
||||
await supabase.from('mod_actions').insert({
|
||||
guild_id: interaction.guildId,
|
||||
action: 'untimeout',
|
||||
user_id: userId,
|
||||
user_tag: member.user.tag,
|
||||
moderator_id: moderator.id,
|
||||
moderator_tag: moderator.tag,
|
||||
reason: 'Bulk timeout removal',
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
results.success.push(userId);
|
||||
} catch (error) {
|
||||
results.failed.push({ id: userId, reason: error.message.substring(0, 50) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createResultEmbed(action, results, moderator) {
|
||||
const actionNames = {
|
||||
ban: 'Bulk Ban',
|
||||
kick: 'Bulk Kick',
|
||||
timeout: 'Bulk Timeout',
|
||||
warn: 'Bulk Warning',
|
||||
remove_timeout: 'Bulk Timeout Removal'
|
||||
};
|
||||
|
||||
const colors = {
|
||||
ban: 0xff0000,
|
||||
kick: 0xff6b6b,
|
||||
timeout: 0xffa500,
|
||||
warn: 0xffcc00,
|
||||
remove_timeout: 0x00ff00
|
||||
};
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(`${actionNames[action]} Results`)
|
||||
.setColor(colors[action])
|
||||
.setTimestamp()
|
||||
.setFooter({ text: `Action by ${moderator.tag}` });
|
||||
|
||||
if (results.success.length > 0) {
|
||||
const successList = results.success.map(id => `<@${id}>`).join(', ');
|
||||
embed.addFields({
|
||||
name: `✅ Successful (${results.success.length})`,
|
||||
value: successList.length > 1024 ? `${results.success.length} users affected` : successList
|
||||
});
|
||||
}
|
||||
|
||||
if (results.failed.length > 0) {
|
||||
const failedList = results.failed.map(f => `<@${f.id}>: ${f.reason}`).join('\n');
|
||||
embed.addFields({
|
||||
name: `❌ Failed (${results.failed.length})`,
|
||||
value: failedList.length > 1024 ? `${results.failed.length} users failed` : failedList
|
||||
});
|
||||
}
|
||||
|
||||
if (results.success.length === 0 && results.failed.length === 0) {
|
||||
embed.setDescription('No users were processed.');
|
||||
}
|
||||
|
||||
return embed;
|
||||
}
|
||||
|
|
@ -1870,6 +1870,7 @@
|
|||
<div class="tab active" data-mod-tab="mod-warnings">Warnings</div>
|
||||
<div class="tab" data-mod-tab="mod-bans">Bans</div>
|
||||
<div class="tab" data-mod-tab="mod-activity">Activity Feed</div>
|
||||
<div class="tab" data-mod-tab="mod-bulk">Bulk Actions</div>
|
||||
</div>
|
||||
|
||||
<div id="mod-warnings" class="mod-section">
|
||||
|
|
@ -1929,6 +1930,80 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="mod-bulk" class="mod-section hidden">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Bulk Moderation Actions</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p style="color:var(--text-secondary);margin-bottom:1.5rem">
|
||||
Perform moderation actions on multiple users at once. Enter user IDs separated by spaces, commas, or newlines.
|
||||
</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="bulkAction">Action Type</label>
|
||||
<select id="bulkAction" class="form-input" onchange="updateBulkActionForm()">
|
||||
<option value="ban">Ban Users</option>
|
||||
<option value="kick">Kick Users</option>
|
||||
<option value="timeout">Timeout Users</option>
|
||||
<option value="warn">Warn Users</option>
|
||||
<option value="remove_timeout">Remove Timeout</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="bulkUserIds">User IDs (max 25)</label>
|
||||
<textarea id="bulkUserIds" class="form-input" rows="4" placeholder="123456789012345678 234567890123456789 345678901234567890"></textarea>
|
||||
<small style="color:var(--text-secondary)">Enter one user ID per line, or separate with spaces/commas</small>
|
||||
</div>
|
||||
|
||||
<div id="bulkTimeoutDuration" class="form-group" style="display:none">
|
||||
<label for="bulkDuration">Timeout Duration</label>
|
||||
<select id="bulkDuration" class="form-input">
|
||||
<option value="5m">5 minutes</option>
|
||||
<option value="10m">10 minutes</option>
|
||||
<option value="30m">30 minutes</option>
|
||||
<option value="1h" selected>1 hour</option>
|
||||
<option value="6h">6 hours</option>
|
||||
<option value="12h">12 hours</option>
|
||||
<option value="1d">1 day</option>
|
||||
<option value="3d">3 days</option>
|
||||
<option value="1w">1 week</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="bulkReasonGroup" class="form-group">
|
||||
<label for="bulkReason">Reason</label>
|
||||
<input type="text" id="bulkReason" class="form-input" placeholder="Reason for action..." maxlength="500">
|
||||
</div>
|
||||
|
||||
<div id="bulkDeleteDays" class="form-group" style="display:none">
|
||||
<label for="bulkBanDeleteDays">Delete Message History</label>
|
||||
<select id="bulkBanDeleteDays" class="form-input">
|
||||
<option value="0">Don't delete messages</option>
|
||||
<option value="1">1 day</option>
|
||||
<option value="3">3 days</option>
|
||||
<option value="7">7 days</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:1rem;align-items:center;margin-top:1.5rem">
|
||||
<button class="btn btn-danger" onclick="executeBulkAction()" id="bulkActionBtn">Execute Bulk Ban</button>
|
||||
<span id="bulkActionStatus"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-top:1.5rem">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Results</h3>
|
||||
</div>
|
||||
<div class="card-body" id="bulkActionResults">
|
||||
<div class="empty-state">No bulk actions executed yet</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="page-analytics" class="page hidden">
|
||||
|
|
@ -3437,6 +3512,136 @@
|
|||
}
|
||||
}
|
||||
|
||||
function updateBulkActionForm() {
|
||||
const action = document.getElementById('bulkAction').value;
|
||||
const btn = document.getElementById('bulkActionBtn');
|
||||
const timeoutDiv = document.getElementById('bulkTimeoutDuration');
|
||||
const deleteDaysDiv = document.getElementById('bulkDeleteDays');
|
||||
const reasonGroup = document.getElementById('bulkReasonGroup');
|
||||
|
||||
const actionNames = {
|
||||
ban: 'Execute Bulk Ban',
|
||||
kick: 'Execute Bulk Kick',
|
||||
timeout: 'Execute Bulk Timeout',
|
||||
warn: 'Execute Bulk Warning',
|
||||
remove_timeout: 'Remove Timeouts'
|
||||
};
|
||||
|
||||
btn.textContent = actionNames[action] || 'Execute Action';
|
||||
btn.className = action === 'remove_timeout' ? 'btn btn-primary' : 'btn btn-danger';
|
||||
|
||||
timeoutDiv.style.display = action === 'timeout' ? 'block' : 'none';
|
||||
deleteDaysDiv.style.display = action === 'ban' ? 'block' : 'none';
|
||||
reasonGroup.style.display = action === 'remove_timeout' ? 'none' : 'block';
|
||||
}
|
||||
|
||||
function parseUserIdsFromInput(input) {
|
||||
const ids = input.match(/\d{17,20}/g) || [];
|
||||
return [...new Set(ids)];
|
||||
}
|
||||
|
||||
async function executeBulkAction() {
|
||||
if (!currentGuild) return;
|
||||
|
||||
const action = document.getElementById('bulkAction').value;
|
||||
const userIdsInput = document.getElementById('bulkUserIds').value;
|
||||
const reason = document.getElementById('bulkReason').value || 'No reason provided';
|
||||
const duration = document.getElementById('bulkDuration').value;
|
||||
const deleteDays = document.getElementById('bulkBanDeleteDays').value;
|
||||
|
||||
const userIds = parseUserIdsFromInput(userIdsInput);
|
||||
|
||||
if (userIds.length === 0) {
|
||||
alert('Please enter at least one valid user ID');
|
||||
return;
|
||||
}
|
||||
|
||||
if (userIds.length > 25) {
|
||||
alert('Maximum 25 users per bulk action');
|
||||
return;
|
||||
}
|
||||
|
||||
const actionNames = { ban: 'ban', kick: 'kick', timeout: 'timeout', warn: 'warn', remove_timeout: 'remove timeout from' };
|
||||
if (!confirm(`Are you sure you want to ${actionNames[action]} ${userIds.length} user(s)? This action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById('bulkActionBtn');
|
||||
const status = document.getElementById('bulkActionStatus');
|
||||
btn.disabled = true;
|
||||
status.textContent = 'Processing...';
|
||||
status.style.color = 'var(--text-secondary)';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/guild/' + currentGuild + '/moderation/bulk', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action,
|
||||
userIds,
|
||||
reason,
|
||||
duration,
|
||||
deleteDays: parseInt(deleteDays, 10)
|
||||
})
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || 'Bulk action failed');
|
||||
}
|
||||
|
||||
status.textContent = 'Complete!';
|
||||
status.style.color = 'var(--success)';
|
||||
|
||||
displayBulkResults(action, data);
|
||||
|
||||
document.getElementById('bulkUserIds').value = '';
|
||||
document.getElementById('bulkReason').value = '';
|
||||
|
||||
loadModerationStats();
|
||||
loadActivityFeed();
|
||||
} catch (e) {
|
||||
status.textContent = 'Error: ' + e.message;
|
||||
status.style.color = 'var(--danger)';
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function displayBulkResults(action, data) {
|
||||
const container = document.getElementById('bulkActionResults');
|
||||
const actionNames = { ban: 'Banned', kick: 'Kicked', timeout: 'Timed out', warn: 'Warned', remove_timeout: 'Timeout removed' };
|
||||
|
||||
let html = '';
|
||||
|
||||
if (data.success && data.success.length > 0) {
|
||||
html += '<div style="margin-bottom:1rem">';
|
||||
html += '<div style="color:var(--success);font-weight:600;margin-bottom:0.5rem">Successful (' + data.success.length + ')</div>';
|
||||
html += '<div style="display:flex;flex-wrap:wrap;gap:0.5rem">';
|
||||
data.success.forEach(id => {
|
||||
html += '<span style="background:rgba(34,197,94,0.2);color:var(--success);padding:0.25rem 0.5rem;border-radius:4px;font-size:0.85rem">' + id + '</span>';
|
||||
});
|
||||
html += '</div></div>';
|
||||
}
|
||||
|
||||
if (data.failed && data.failed.length > 0) {
|
||||
html += '<div>';
|
||||
html += '<div style="color:var(--danger);font-weight:600;margin-bottom:0.5rem">Failed (' + data.failed.length + ')</div>';
|
||||
html += '<div style="display:flex;flex-direction:column;gap:0.25rem">';
|
||||
data.failed.forEach(f => {
|
||||
html += '<div style="font-size:0.85rem;color:var(--text-secondary)">' + f.id + ': <span style="color:var(--danger)">' + escapeHtml(f.reason) + '</span></div>';
|
||||
});
|
||||
html += '</div></div>';
|
||||
}
|
||||
|
||||
if (!html) {
|
||||
html = '<div class="empty-state">No users processed</div>';
|
||||
}
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// Analytics Functions
|
||||
function loadAnalyticsData() {
|
||||
loadAnalyticsStats();
|
||||
|
|
|
|||
|
|
@ -1596,6 +1596,199 @@ function createWebServer(discordClient, supabase, options = {}) {
|
|||
}
|
||||
});
|
||||
|
||||
app.post('/api/guild/:guildId/moderation/bulk', async (req, res) => {
|
||||
const userId = req.session.user?.id;
|
||||
if (!userId) return res.status(401).json({ error: 'Not authenticated' });
|
||||
|
||||
const { guildId } = req.params;
|
||||
const userGuild = req.session.user.guilds?.find(g => g.id === guildId);
|
||||
if (!userGuild?.isAdmin) return res.status(403).json({ error: 'No admin access' });
|
||||
|
||||
const { action, userIds, reason, duration, deleteDays } = req.body;
|
||||
|
||||
if (!action || !userIds || !Array.isArray(userIds)) {
|
||||
return res.status(400).json({ error: 'Invalid request' });
|
||||
}
|
||||
|
||||
if (userIds.length > 25) {
|
||||
return res.status(400).json({ error: 'Maximum 25 users per bulk action' });
|
||||
}
|
||||
|
||||
const validActions = ['ban', 'kick', 'timeout', 'warn', 'remove_timeout'];
|
||||
if (!validActions.includes(action)) {
|
||||
return res.status(400).json({ error: 'Invalid action' });
|
||||
}
|
||||
|
||||
const guild = discordClient.guilds.cache.get(guildId);
|
||||
if (!guild) {
|
||||
return res.status(404).json({ error: 'Guild not found' });
|
||||
}
|
||||
|
||||
const moderator = req.session.user;
|
||||
const results = { success: [], failed: [] };
|
||||
|
||||
const durationMap = {
|
||||
'5m': 5 * 60 * 1000,
|
||||
'10m': 10 * 60 * 1000,
|
||||
'30m': 30 * 60 * 1000,
|
||||
'1h': 60 * 60 * 1000,
|
||||
'6h': 6 * 60 * 60 * 1000,
|
||||
'12h': 12 * 60 * 60 * 1000,
|
||||
'1d': 24 * 60 * 60 * 1000,
|
||||
'3d': 3 * 24 * 60 * 60 * 1000,
|
||||
'1w': 7 * 24 * 60 * 60 * 1000
|
||||
};
|
||||
|
||||
for (const targetId of userIds) {
|
||||
try {
|
||||
switch (action) {
|
||||
case 'ban': {
|
||||
await guild.members.ban(targetId, {
|
||||
reason: `[Dashboard Bulk] ${reason || 'No reason'} | By ${moderator.username}`,
|
||||
deleteMessageSeconds: (deleteDays || 0) * 24 * 60 * 60
|
||||
});
|
||||
|
||||
if (supabase) {
|
||||
const user = await discordClient.users.fetch(targetId).catch(() => null);
|
||||
await supabase.from('mod_actions').insert({
|
||||
guild_id: guildId,
|
||||
action: 'ban',
|
||||
user_id: targetId,
|
||||
user_tag: user?.tag || 'Unknown',
|
||||
moderator_id: moderator.id,
|
||||
moderator_tag: moderator.username,
|
||||
reason: `[Dashboard Bulk] ${reason || 'No reason'}`
|
||||
}).catch(() => {});
|
||||
}
|
||||
results.success.push(targetId);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'kick': {
|
||||
const member = await guild.members.fetch(targetId).catch(() => null);
|
||||
if (!member) {
|
||||
results.failed.push({ id: targetId, reason: 'Not in server' });
|
||||
continue;
|
||||
}
|
||||
if (!member.kickable) {
|
||||
results.failed.push({ id: targetId, reason: 'Not kickable' });
|
||||
continue;
|
||||
}
|
||||
|
||||
await member.kick(`[Dashboard Bulk] ${reason || 'No reason'} | By ${moderator.username}`);
|
||||
|
||||
if (supabase) {
|
||||
await supabase.from('mod_actions').insert({
|
||||
guild_id: guildId,
|
||||
action: 'kick',
|
||||
user_id: targetId,
|
||||
user_tag: member.user.tag,
|
||||
moderator_id: moderator.id,
|
||||
moderator_tag: moderator.username,
|
||||
reason: `[Dashboard Bulk] ${reason || 'No reason'}`
|
||||
}).catch(() => {});
|
||||
}
|
||||
results.success.push(targetId);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'timeout': {
|
||||
const member = await guild.members.fetch(targetId).catch(() => null);
|
||||
if (!member) {
|
||||
results.failed.push({ id: targetId, reason: 'Not in server' });
|
||||
continue;
|
||||
}
|
||||
if (!member.moderatable) {
|
||||
results.failed.push({ id: targetId, reason: 'Not moderatable' });
|
||||
continue;
|
||||
}
|
||||
|
||||
const timeoutMs = durationMap[duration] || durationMap['1h'];
|
||||
await member.timeout(timeoutMs, `[Dashboard Bulk] ${reason || 'No reason'} | By ${moderator.username}`);
|
||||
|
||||
if (supabase) {
|
||||
await supabase.from('mod_actions').insert({
|
||||
guild_id: guildId,
|
||||
action: 'timeout',
|
||||
user_id: targetId,
|
||||
user_tag: member.user.tag,
|
||||
moderator_id: moderator.id,
|
||||
moderator_tag: moderator.username,
|
||||
reason: `[Dashboard Bulk] ${reason || 'No reason'}`,
|
||||
duration: duration
|
||||
}).catch(() => {});
|
||||
}
|
||||
results.success.push(targetId);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'warn': {
|
||||
const member = await guild.members.fetch(targetId).catch(() => null);
|
||||
if (!member) {
|
||||
results.failed.push({ id: targetId, reason: 'Not in server' });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (supabase) {
|
||||
await supabase.from('warnings').insert({
|
||||
guild_id: guildId,
|
||||
user_id: targetId,
|
||||
username: member.user.tag,
|
||||
moderator_id: moderator.id,
|
||||
moderator_tag: moderator.username,
|
||||
reason: `[Dashboard Bulk] ${reason || 'No reason'}`
|
||||
});
|
||||
|
||||
await supabase.from('mod_actions').insert({
|
||||
guild_id: guildId,
|
||||
action: 'warn',
|
||||
user_id: targetId,
|
||||
user_tag: member.user.tag,
|
||||
moderator_id: moderator.id,
|
||||
moderator_tag: moderator.username,
|
||||
reason: `[Dashboard Bulk] ${reason || 'No reason'}`
|
||||
}).catch(() => {});
|
||||
}
|
||||
results.success.push(targetId);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'remove_timeout': {
|
||||
const member = await guild.members.fetch(targetId).catch(() => null);
|
||||
if (!member) {
|
||||
results.failed.push({ id: targetId, reason: 'Not in server' });
|
||||
continue;
|
||||
}
|
||||
if (!member.isCommunicationDisabled()) {
|
||||
results.failed.push({ id: targetId, reason: 'Not timed out' });
|
||||
continue;
|
||||
}
|
||||
|
||||
await member.timeout(null, `Timeout removed via Dashboard by ${moderator.username}`);
|
||||
|
||||
if (supabase) {
|
||||
await supabase.from('mod_actions').insert({
|
||||
guild_id: guildId,
|
||||
action: 'untimeout',
|
||||
user_id: targetId,
|
||||
user_tag: member.user.tag,
|
||||
moderator_id: moderator.id,
|
||||
moderator_tag: moderator.username,
|
||||
reason: 'Dashboard bulk timeout removal'
|
||||
}).catch(() => {});
|
||||
}
|
||||
results.success.push(targetId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
results.failed.push({ id: targetId, reason: error.message?.substring(0, 50) || 'Unknown error' });
|
||||
}
|
||||
}
|
||||
|
||||
res.json(results);
|
||||
});
|
||||
|
||||
// Analytics API Endpoints
|
||||
app.get('/api/guild/:guildId/analytics/stats', async (req, res) => {
|
||||
if (!supabase) return res.status(503).json({ error: 'Database not available' });
|
||||
|
|
|
|||
Loading…
Reference in a new issue