diff --git a/src/commands/Moderation/honeypot.js b/src/commands/Moderation/honeypot.js new file mode 100644 index 0000000000..5a4cbb4535 --- /dev/null +++ b/src/commands/Moderation/honeypot.js @@ -0,0 +1,166 @@ +import { SlashCommandBuilder, MessageFlags, PermissionFlagsBits } from 'discord.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { handleInteractionError, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; +import { createEmbed } from '../../utils/embeds.js'; +import { logger } from '../../utils/logger.js'; +import { getHoneypotConfig, setHoneypotConfig, buildHoneypotWarningEmbed, buildHoneypotComponents, refreshHoneypotMessage } from '../../services/honeypotService.js'; + +export default { + category: 'Moderation', + + data: new SlashCommandBuilder() + .setName('honeypot') + .setDescription('Manage the honeypot channel that auto-bans/kicks anyone who sends a message in it') + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) + + .addSubcommand((sub) => + sub + .setName('set') + .setDescription('Set the honeypot channel and post the warning embed') + .addChannelOption((opt) => + opt.setName('channel').setDescription('The channel to use as a honeypot').setRequired(true), + ), + ) + + .addSubcommand((sub) => + sub + .setName('method') + .setDescription('Toggle between ban and kick for honeypot punishment') + .addStringOption((opt) => + opt + .setName('action') + .setDescription('Punishment to apply when triggered') + .setRequired(true) + .addChoices( + { name: 'Ban (default)', value: 'ban' }, + { name: 'Kick', value: 'kick' }, + ), + ), + ) + + .addSubcommand((sub) => + sub.setName('disable').setDescription('Disable the honeypot without deleting the channel'), + ) + + .addSubcommand((sub) => + sub.setName('status').setDescription('Show the current honeypot configuration'), + ), + + async execute(interaction, config, client) { + try { + await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); + + const sub = interaction.options.getSubcommand(); + const guild = interaction.guild; + const guildId = guild.id; + + if (sub === 'set') { + const channel = interaction.options.getChannel('channel'); + + if (!channel.isTextBased()) { + throw new TitanBotError('Invalid channel type', ErrorTypes.VALIDATION, 'The honeypot channel must be a text channel.'); + } + + const botMember = guild.members.me; + const perms = channel.permissionsFor(botMember); + if (!perms?.has('ViewChannel') || !perms?.has('ManageMessages')) { + throw new TitanBotError('Missing permissions', ErrorTypes.PERMISSION, `I need **View Channel** and **Manage Messages** permissions in ${channel}.`); + } + + const existing = await getHoneypotConfig(guildId); + const banCount = existing.banCount || 0; + const method = existing.method || 'ban'; + + let messageId = null; + try { + const msg = await channel.send({ + embeds: [buildHoneypotWarningEmbed(method)], + components: [buildHoneypotComponents(banCount, method)], + }); + messageId = msg.id; + } catch (err) { + logger.warn(`Honeypot: could not post warning embed in ${channel.id}:`, err); + } + + await setHoneypotConfig(guildId, { channelId: channel.id, enabled: true, banCount, method, messageId }); + + await InteractionHelper.safeEditReply(interaction, { + embeds: [ + createEmbed({ + title: 'Honeypot Set', + description: + `${channel} is now the honeypot channel.\n\n` + + `Anyone who sends a message there will be **instantly ${method === 'kick' ? 'kicked' : 'banned'}**.\n\n` + + `The warning embed has been posted in the channel.`, + color: 'success', + }), + ], + }); + + } else if (sub === 'method') { + const owners = (process.env.OWNER_IDS || '').split(',').map(id => id.trim()).filter(Boolean); + if (!owners.includes(interaction.user.id)) { + throw new TitanBotError('Not bot owner', ErrorTypes.PERMISSION, 'Only the bot owner can change the honeypot method.'); + } + + const action = interaction.options.getString('action'); + const current = await getHoneypotConfig(guildId); + + if (!current.channelId || !current.enabled) { + throw new TitanBotError('Not configured', ErrorTypes.VALIDATION, 'Set up a honeypot channel first with `/honeypot set`.'); + } + + await setHoneypotConfig(guildId, { ...current, method: action }); + await refreshHoneypotMessage(client, guildId); + + await InteractionHelper.safeEditReply(interaction, { + embeds: [ + createEmbed({ + title: 'Honeypot Method Updated', + description: `The honeypot will now **${action}** users who send messages in the protected channel.\n\nThe warning embed has been updated.`, + color: action === 'kick' ? 'warning' : 'error', + }), + ], + }); + + } else if (sub === 'disable') { + const current = await getHoneypotConfig(guildId); + if (!current.channelId || !current.enabled) { + throw new TitanBotError('Not configured', ErrorTypes.VALIDATION, 'The honeypot is not currently enabled on this server.'); + } + + await setHoneypotConfig(guildId, { ...current, enabled: false }); + + await InteractionHelper.safeEditReply(interaction, { + embeds: [ + createEmbed({ + title: 'Honeypot Disabled', + description: 'The honeypot has been disabled. The channel still exists but messages will no longer trigger any action.', + color: 'warning', + }), + ], + }); + + } else if (sub === 'status') { + const current = await getHoneypotConfig(guildId); + const method = current.method || 'ban'; + + await InteractionHelper.safeEditReply(interaction, { + embeds: [ + createEmbed({ + title: 'Honeypot Status', + description: null, + }).addFields( + { name: 'Enabled', value: current.enabled ? 'Yes' : 'No', inline: true }, + { name: 'Channel', value: current.channelId ? `<#${current.channelId}>` : 'Not set', inline: true }, + { name: 'Method', value: method.charAt(0).toUpperCase() + method.slice(1), inline: true }, + { name: 'Total Actions', value: `${current.banCount || 0}`, inline: true }, + ), + ], + }); + } + } catch (error) { + await handleInteractionError(interaction, error, { command: 'honeypot' }); + } + }, +}; diff --git a/src/services/honeypotService.js b/src/services/honeypotService.js new file mode 100644 index 0000000000..5d17024b6f --- /dev/null +++ b/src/services/honeypotService.js @@ -0,0 +1,109 @@ +import { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'; +import { getFromDb, setInDb } from '../utils/database/wrapper.js'; +import { ModerationService } from './moderationService.js'; +import { logger } from '../utils/logger.js'; + +const getHoneypotKey = (guildId) => `guild:${guildId}:honeypot`; +const HONEYPOT_IMAGE = 'https://www.dropbox.com/scl/fi/ilr20lv5u3mlv7ebr1cbz/honey-pot.png?rlkey=9txrk7gvymb5vpi60q4rde697&st=juj7ew12&dl=1'; + +export async function getHoneypotConfig(guildId) { + try { + const data = await getFromDb(getHoneypotKey(guildId), null); + return data || { channelId: null, enabled: false, banCount: 0, method: 'ban' }; + } catch (err) { + logger.error(`Failed to get honeypot config for ${guildId}:`, err); + return { channelId: null, enabled: false, banCount: 0, method: 'ban' }; + } +} + +export async function setHoneypotConfig(guildId, config) { + try { + await setInDb(getHoneypotKey(guildId), config); + return true; + } catch (err) { + logger.error(`Failed to set honeypot config for ${guildId}:`, err); + return false; + } +} + +export function buildHoneypotWarningEmbed(method = 'ban') { + return new EmbedBuilder() + .setColor(0xFF0000) + .setTitle('DO NOT SEND MESSAGES IN THIS CHANNEL') + .setDescription( + 'This channel is used to catch spam bots and scammers.\n' + + `Any messages sent here will result in a **${method}**.`, + ) + .setThumbnail(HONEYPOT_IMAGE); +} + +export function buildHoneypotComponents(banCount = 0, method = 'ban') { + const label = method === 'kick' ? `🍯 Kicks: ${banCount}` : `🍯 Bans: ${banCount}`; + return new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('honeypot_action_count') + .setLabel(label) + .setStyle(ButtonStyle.Danger) + .setDisabled(true), + ); +} + +export async function refreshHoneypotMessage(client, guildId) { + try { + const config = await getHoneypotConfig(guildId); + if (!config.channelId || !config.messageId) return; + + const channel = client.channels.cache.get(config.channelId); + if (!channel) return; + + const msg = await channel.messages.fetch(config.messageId).catch(() => null); + if (!msg) return; + + await msg.edit({ + embeds: [buildHoneypotWarningEmbed(config.method || 'ban')], + components: [buildHoneypotComponents(config.banCount, config.method || 'ban')], + }); + } catch (err) { + logger.error(`Honeypot: failed to refresh warning message in guild ${guildId}:`, err); + } +} + +export async function handleHoneypotMessage(message, client) { + const { guild, author } = message; + + await message.delete().catch(() => null); + + const botMember = guild.members.me; + const config = await getHoneypotConfig(guild.id); + const method = config.method || 'ban'; + + try { + if (method === 'kick') { + const member = await guild.members.fetch(author.id).catch(() => null); + if (member) { + await ModerationService.kickUser({ + guild, + member, + moderator: botMember, + reason: 'Honeypot triggered — sent a message in a protected channel.', + }); + } + logger.info(`Honeypot: kicked ${author.tag} (${author.id}) in guild ${guild.id}`); + } else { + await ModerationService.banUser({ + guild, + user: author, + moderator: botMember, + reason: 'Honeypot triggered — sent a message in a protected channel.', + deleteDays: 1, + }); + logger.info(`Honeypot: banned ${author.tag} (${author.id}) in guild ${guild.id}`); + } + + const newCount = (config.banCount || 0) + 1; + await setHoneypotConfig(guild.id, { ...config, banCount: newCount }); + await refreshHoneypotMessage(client, guild.id); + } catch (err) { + logger.error(`Honeypot: failed to ${method} ${author.tag} in guild ${guild.id}:`, err); + } +} diff --git a/src/services/music/playerHandler.js b/src/services/music/playerHandler.js index a58947a81c..9219750213 100644 --- a/src/services/music/playerHandler.js +++ b/src/services/music/playerHandler.js @@ -117,6 +117,11 @@ export function setupPlayerHandler(client) { client.riffy.on('queueEnd', async (player) => { try { const guildData = getGuildMusicData(player.guildId); + + if (guildData.loop && guildData.loop !== 'none') { + return; + } + clearUpdateInterval(guildData); if (guildData.autoplay) {