feat: initial prototype

This commit is contained in:
2025-09-03 19:01:12 -07:00
parent 1a576160be
commit 7e0e54c06b
16 changed files with 5332 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
node_modules
prod
+17
View File
@@ -0,0 +1,17 @@
import { SlashCommandBuilder, ChannelType } from "discord.js";
const about = new SlashCommandBuilder()
.setName("about")
.setDescription("Get information about the bot.");
const honeypot = new SlashCommandBuilder()
.setName("honeypot")
.setDescription("Configure your honeypot channel.")
.addChannelOption(
option => option.setName("channel").setDescription("Your honeypot channel.").setRequired(true).addChannelTypes(ChannelType.GuildText)
);
console.log(JSON.stringify([
about.toJSON(),
honeypot.toJSON()
]));
+5
View File
@@ -0,0 +1,5 @@
import NaomisConfig from "@nhcarrigan/eslint-config";
export default [
...NaomisConfig
];
+31
View File
@@ -0,0 +1,31 @@
{
"name": "umbrelle",
"version": "1.0.0",
"description": "",
"main": "index.js",
"type": "module",
"scripts": {
"build": "prisma generate && tsc",
"lint": "eslint src --max-warnings 0",
"start": "op run --env-file=prod.env -- node prod/index.js",
"test": "echo \"Error: no test specified\" && exit 0"
},
"keywords": [],
"author": "",
"license": "ISC",
"packageManager": "pnpm@10.15.0",
"devDependencies": {
"@nhcarrigan/eslint-config": "5.2.0",
"@nhcarrigan/typescript-config": "4.0.0",
"@types/node": "24.3.0",
"eslint": "9.34.0",
"prisma": "6.15.0",
"typescript": "5.9.2"
},
"dependencies": {
"@nhcarrigan/logger": "1.0.0",
"@prisma/client": "6.15.0",
"discord.js": "14.22.1",
"fastify": "5.5.0"
}
}
+4912
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mongodb"
url = env("MONGO_URI")
}
model Honeypots {
id String @id @default(auto()) @map("_id") @db.ObjectId
serverId String @unique
channelId String @unique
}
+3
View File
@@ -0,0 +1,3 @@
LOG_TOKEN="op://Environment Variables - Naomi/Alert Server/api_auth"
BOT_TOKEN="op://Environment Variables - Naomi/Umbrelle/bot token"
MONGO_URI="op://Environment Variables - Naomi/Umbrelle/mongo"
+62
View File
@@ -0,0 +1,62 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import {
TextDisplayBuilder,
SeparatorBuilder,
SeparatorSpacingSize,
ContainerBuilder,
ButtonBuilder,
ButtonStyle,
ActionRowBuilder,
} from "discord.js";
export const about = [
new ContainerBuilder().
addTextDisplayComponents(
new TextDisplayBuilder().setContent("# About Umbrelle"),
).
addTextDisplayComponents(
new TextDisplayBuilder().setContent(
// eslint-disable-next-line stylistic/max-len -- Big boi string.
"Hi there~! I am Umbrelle, a bot that allows you to configure a honeypot channel to catch compromised accounts.",
),
).
addSeparatorComponents(
new SeparatorBuilder().
setSpacing(SeparatorSpacingSize.Small).
setDivider(true),
).
addTextDisplayComponents(
new TextDisplayBuilder().setContent("## What can I do?"),
).
addTextDisplayComponents(
new TextDisplayBuilder().setContent(
// eslint-disable-next-line stylistic/max-len -- Big boi string.
"To get started, you (or a server admin, if you are not one) needs to run `/honeypot` to set the channel up. From there, sit back and relax and let me do the work! Anyone who sends a message in that channel will be 'soft-banned'; that is, I will ban them to clean up their message history and immediately unban them so they may return to the community when they recover their account.",
),
).
addSeparatorComponents(
new SeparatorBuilder().
setSpacing(SeparatorSpacingSize.Small).
setDivider(true),
).
addTextDisplayComponents(
new TextDisplayBuilder().setContent("## What if I need help?"),
).
addTextDisplayComponents(
new TextDisplayBuilder().setContent(
// eslint-disable-next-line stylistic/max-len -- Big boi string.
"My deepest apologies if I have made a mistake! Please reach out to us in our Discord server or on the forum, and we will do our best to assist you.",
),
),
new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder().
setStyle(ButtonStyle.Link).
setLabel("Discord Server").
setURL("https://chat.nhcarrigan.com"),
),
];
+59
View File
@@ -0,0 +1,59 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { MessageFlags, type Interaction, ChannelType } from "discord.js";
import { about } from "../components/about.js";
import type { Umbrelle } from "../interfaces/umbrelle.js";
/**
* Handles interactions. Rejects anything but chat commands,
* runs logic based on command name.
* @param umbrelle - Umbrelle's instance.
* @param interaction - The interaction payload from Discord.
*/
export const handleInteraction = async(
umbrelle: Umbrelle,
interaction: Interaction,
): Promise<void> => {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild()) {
return;
}
await interaction.deferReply({
flags: [ MessageFlags.Ephemeral ],
});
const { commandName, options, guild } = interaction;
if (commandName === "about") {
await interaction.editReply({
components: about,
flags: [ MessageFlags.IsComponentsV2 ],
});
return;
}
if (commandName === "honeypot") {
const channel
= options.getChannel("channel", true, [ ChannelType.GuildText ]);
await umbrelle.db.honeypots.upsert({
create: {
channelId: channel.id,
serverId: guild.id,
},
update: {
channelId: channel.id,
},
where: {
serverId: guild.id,
},
});
umbrelle.cache.set(guild.id, channel.id);
await interaction.editReply({
content: `Okay! When people post in <#${channel.id}> I will soft-ban them.`,
});
return;
}
await interaction.editReply({
content: `Sorry, but I do not know what to do with a ${commandName} command.`,
});
};
+48
View File
@@ -0,0 +1,48 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type { Umbrelle } from "../interfaces/umbrelle.js";
import type { Message } from "discord.js";
/**
* Fetches the configured honeypot channels from the database
* and attaches them to Umbrelle's cache.
* @param umbrelle - Umbrelle's instance.
* @param message - The message payload from Discord.
*/
export const handleMessage = async(
umbrelle: Umbrelle,
message: Message,
): Promise<void> => {
if (!message.inGuild()) {
return;
}
const { author, member, guild, channel } = message;
const cached = umbrelle.cache.get(guild.id);
if (cached !== channel.id) {
return;
}
const resolvedMember = member
?? guild.members.cache.get(author.id)
?? await guild.members.fetch(author.id).catch(() => {
return null;
});
if (!resolvedMember) {
return;
}
await author.send({
content: `Your account appears to have been compromised. As a security measure, you have been removed from the ${guild.name} community.
You are not banned, however, and may rejoin at any time after you recover your account.`,
});
await resolvedMember.ban({
deleteMessageSeconds: 24 * 60 * 60 * 1000,
reason: "Soft ban for a compromised account.",
});
await guild.bans.remove(resolvedMember.id,
"Soft ban for a compromised account.");
};
+28
View File
@@ -0,0 +1,28 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { logger } from "../utils/logger.js";
import type { Umbrelle } from "../interfaces/umbrelle.js";
/**
* Fetches the configured honeypot channels from the database
* and attaches them to Umbrelle's cache.
* @param umbrelle - Umbrelle's instance.
*/
export const handleReady = async(umbrelle: Umbrelle): Promise<void> => {
await logger.log(
"debug",
`Client authenticated as ${umbrelle.discord.user?.username ?? "unknown user"}`,
);
const records = await umbrelle.db.honeypots.findMany();
for (const record of records) {
umbrelle.cache.set(record.serverId, record.channelId);
}
await logger.log(
"debug",
`Cached ${records.length.toString()} honeypot channels.`,
);
};
+36
View File
@@ -0,0 +1,36 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { PrismaClient } from "@prisma/client";
import { Client, GatewayIntentBits, Events } from "discord.js";
import { handleInteraction } from "./events/handleInteraction.js";
import { handleMessage } from "./events/handleMessage.js";
import { handleReady } from "./events/handleReady.js";
import { instantiateServer } from "./server/serve.js";
import type { Umbrelle } from "./interfaces/umbrelle.js";
const umbrelle: Umbrelle = {
cache: new Map<string, string>(),
db: new PrismaClient(),
discord: new Client({
intents: [ GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages ],
}),
};
umbrelle.discord.once(Events.ClientReady, () => {
void handleReady(umbrelle);
});
umbrelle.discord.on(Events.MessageCreate, (message) => {
void handleMessage(umbrelle, message);
});
umbrelle.discord.on(Events.InteractionCreate, (interaction) => {
void handleInteraction(umbrelle, interaction);
});
await umbrelle.discord.login(process.env.BOT_TOKEN);
instantiateServer();
+14
View File
@@ -0,0 +1,14 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type { PrismaClient } from "@prisma/client";
import type { Client } from "discord.js";
export interface Umbrelle {
db: PrismaClient;
discord: Client;
cache: Map<string, string>;
}
+79
View File
@@ -0,0 +1,79 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import fastify from "fastify";
import { logger } from "../utils/logger.js";
const html = `<!DOCTYPE html>
<html>
<head>
<title>Umbrelle</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="Discord bot that allows you to configure a honeypot channel where accounts will automatically be banned for posting. Great to catch compromised accounts." />
<script src="https://cdn.nhcarrigan.com/headers/index.js" async defer></script>
</head>
<body>
<main>
<h1>Pavelle</h1>
<img src="https://cdn.nhcarrigan.com/new-avatars/umbrelle-full.png" width="250" alt="Umbrelle" />
<section>
<p>Discord bot that allows you to configure a honeypot channel where accounts will automatically be banned for posting. Great to catch compromised accounts.</p>
<a href="https://discord.com/oauth2/authorize?client_id=1412945347134881862" class="social-button discord-button" style="display: inline-block; background-color: #5865F2; color: white; padding: 10px 20px; text-decoration: none; border-radius: 4px; margin: 5px;">
<i class="fab fa-discord"></i> Add to Discord
</a>
</section>
<section>
<h2>Links</h2>
<p>
<a href="https://git.nhcarrigan.com/nhcarrigan/umbrelle">
<i class="fa-solid fa-code"></i> Source Code
</a>
</p>
<p>
<a href="https://docs.nhcarrigan.com/">
<i class="fa-solid fa-book"></i> Documentation
</a>
</p>
<p>
<a href="https://chat.nhcarrigan.com">
<i class="fa-solid fa-circle-info"></i> Support
</a>
</p>
</section>
</main>
</body>
</html>`;
/**
* Starts up a web server for health monitoring.
*/
export const instantiateServer = (): void => {
try {
const server = fastify({
logger: false,
});
server.get("/", (_request, response) => {
response.header("Content-Type", "text/html");
response.send(html);
});
server.listen({ port: 6088 }, (error) => {
if (error) {
void logger.error("instantiate server", error);
return;
}
void logger.log("debug", "Server listening on port 6088.");
});
} catch (error) {
if (error instanceof Error) {
void logger.error("instantiate server", error);
return;
}
void logger.error("instantiate server", new Error(String(error)));
}
};
+12
View File
@@ -0,0 +1,12 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { Logger } from "@nhcarrigan/logger";
export const logger = new Logger(
"Umbrelle",
process.env.LOG_TOKEN ?? "",
);
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "@nhcarrigan/typescript-config",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./prod"
}
}