Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 17 additions & 5 deletions src/handlers/honeypotHandler.js
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -10,7 +11,7 @@ const HONEYPOT_CHANNEL_ID = process.env.HONEYPOT_CHANNEL_ID;
// across restarts without persisting state.
const HONEYPOT_MARKER = "<!-- trackerbot-honeypot-message -->";

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
Expand Down Expand Up @@ -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);
});
}

Expand Down
80 changes: 73 additions & 7 deletions test/honeypot.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -29,18 +42,71 @@ 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");
assert.equal(onEvents.clientReady, undefined, "ClientReady must not use .on");

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;
});
Loading