diff --git a/src/handlers/honeypotHandler.js b/src/handlers/honeypotHandler.js index 3c0c6c7..482d112 100644 --- a/src/handlers/honeypotHandler.js +++ b/src/handlers/honeypotHandler.js @@ -1,4 +1,5 @@ import { Events } from "discord.js"; +import { hasAdminRole } from "../config/constants.js"; // Honeypot channel: a hidden channel that real users should never post in. // Bots/spam accounts that find it and send a message are banned on sight. @@ -10,7 +11,7 @@ const HONEYPOT_CHANNEL_ID = process.env.HONEYPOT_CHANNEL_ID; // across restarts without persisting state. const HONEYPOT_MARKER = ""; -const HONEYPOT_WARNING = `${HONEYPOT_MARKER}\n**⚠️ Honeypot channel** — do not post here.`; +const HONEYPOT_WARNING = `${HONEYPOT_MARKER}\n# ⛔️⚠️ DO NOT SEND MESSAGES HERE, YOU WILL BE BANNED INSTANTLY ⚠️⛔️`; const SIXTEEN_HOURS_MS = 16 * 60 * 60 * 1000; const CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // hourly @@ -52,12 +53,23 @@ export function setupHoneypot(client) { if (message.author.bot) return; try { - await message.delete(); + // Fetch the member when Discord did not hydrate it in the event so the + // admin exemption cannot be bypassed by a missing message.member value. + const member = message.member ?? await message.guild.members.fetch(message.author.id); + if (hasAdminRole(member.roles.cache)) return; + + try { + await message.delete(); + } catch (err) { + console.error("Failed to delete honeypot trigger message:", err); + } + + await punishAuthor(message); } catch (err) { - console.error("Failed to delete honeypot trigger message:", err); + // Fail safe: if member resolution or role inspection fails, do not risk + // punishing an administrator. + console.error("Failed to handle honeypot message:", err); } - - await punishAuthor(message); }); } diff --git a/test/honeypot.test.js b/test/honeypot.test.js index 3f3c84f..b003d1a 100644 --- a/test/honeypot.test.js +++ b/test/honeypot.test.js @@ -9,6 +9,19 @@ async function importHoneypot() { return import(url); } +function createFakeClient() { + const onEvents = {}; + const onceEvents = {}; + return { + onEvents, + onceEvents, + client: { + on: (evt, fn) => { onEvents[evt] = fn; }, + once: (evt, fn) => { onceEvents[evt] = fn; } + } + }; +} + test("setupHoneypot is a no-op when HONEYPOT_CHANNEL_ID is unset", async () => { delete process.env.HONEYPOT_CHANNEL_ID; const { setupHoneypot } = await importHoneypot(); @@ -29,14 +42,9 @@ test("setupHoneypot registers listeners when enabled", async () => { process.env.HONEYPOT_CHANNEL_ID = "123"; const { setupHoneypot } = await importHoneypot(); - const onEvents = {}; - const onceEvents = {}; - const fakeClient = { - on: (evt, fn) => { onEvents[evt] = fn; }, - once: (evt, fn) => { onceEvents[evt] = fn; } - }; + const { client, onEvents, onceEvents } = createFakeClient(); - setupHoneypot(fakeClient); + setupHoneypot(client); assert.ok(onEvents.messageCreate, "MessageCreate listener should be registered via .on"); // ClientReady must be registered via .once (not .on) to avoid timer leaks on reconnect. assert.ok(onceEvents.clientReady, "ClientReady handler should be registered via .once"); @@ -44,3 +52,61 @@ test("setupHoneypot registers listeners when enabled", async () => { delete process.env.HONEYPOT_CHANNEL_ID; }); + +test("honeypot resolves a missing member before enforcing the admin exemption", async () => { + process.env.HONEYPOT_CHANNEL_ID = "123"; + const { adminRoles } = await import("../src/config/constants.js"); + adminRoles.add("admin"); + const { setupHoneypot } = await importHoneypot(); + const { client, onEvents } = createFakeClient(); + setupHoneypot(client); + + let deleted = false; + let banned = false; + const adminMember = { roles: { cache: [{ id: "admin" }] } }; + await onEvents.messageCreate({ + channelId: "123", + author: { id: "user", bot: false }, + member: null, + guild: { + members: { fetch: async () => adminMember }, + bans: { create: async () => { banned = true; } } + }, + delete: async () => { deleted = true; } + }); + + assert.equal(deleted, false, "admin message should not be deleted"); + assert.equal(banned, false, "admin should not be banned"); + adminRoles.delete("admin"); + delete process.env.HONEYPOT_CHANNEL_ID; +}); + +test("honeypot fails safe when a missing member cannot be resolved", async () => { + process.env.HONEYPOT_CHANNEL_ID = "123"; + const { setupHoneypot } = await importHoneypot(); + const { client, onEvents } = createFakeClient(); + setupHoneypot(client); + + let deleted = false; + let banned = false; + const originalConsoleError = console.error; + console.error = () => {}; + try { + await onEvents.messageCreate({ + channelId: "123", + author: { id: "user", bot: false }, + member: null, + guild: { + members: { fetch: async () => { throw new Error("unavailable"); } }, + bans: { create: async () => { banned = true; } } + }, + delete: async () => { deleted = true; } + }); + } finally { + console.error = originalConsoleError; + } + + assert.equal(deleted, false, "message should remain when roles cannot be checked"); + assert.equal(banned, false, "author should not be punished when roles cannot be checked"); + delete process.env.HONEYPOT_CHANNEL_ID; +});