first commit

This commit is contained in:
Space-Banane
2026-02-22 14:55:10 +01:00
commit 9235748a47
23 changed files with 2343 additions and 0 deletions

13
src/commands/ping.ts Normal file
View File

@@ -0,0 +1,13 @@
import { Client, SlashCommandBuilder } from "discord.js";
import { registerCommand } from "../lib/commandRegistry";
export default async function (_client: Client) {
registerCommand({
data: new SlashCommandBuilder()
.setName("ping")
.setDescription("Replies with Pong!"),
async execute(interaction) {
await interaction.reply("Pong!");
},
});
}

29
src/config.ts Normal file
View File

@@ -0,0 +1,29 @@
import { ActivityType } from "discord.js";
export const GUILD_ID = '1475098530505953441';
export const CHANNELS = {
RULES: "1475100731991392539",
DEV: "1475110775235543092",
ANNOUNCEMENTS: "1475100626286547004",
UPDATES: "1475101645963661333",
DEV_CHAT: "1475101776561569875",
GENERAL: "1475098531814707343",
DEV_MEMES: "1475101184069992670",
MODERATOR_ONLY: "1475100731991392542",
};
export const ROLES = {
RULES: "1475100051352191047",
};
// Discord modified the way activities work, for now, we'll only use custom ones
export const ROTATE_ACTIVITIES:{content:string;type:ActivityType}[] = [
{content: "Fixing Bugs", type: ActivityType.Custom},
{content: "Adding New Features", type: ActivityType.Custom},
{content: "Improving Performance", type: ActivityType.Custom},
{content: "Listening to Feedback", type: ActivityType.Custom},
]

View File

@@ -0,0 +1,36 @@
import { Client, Events, REST, Routes } from "discord.js";
import { getAllCommands, getCommand } from "../lib/commandRegistry";
import { GUILD_ID } from "../config";
export default async function (client: Client) {
const token = process.env.DISCORD_TOKEN!;
const clientId = client.user!.id; // use the ready client's ID
const rest = new REST().setToken(token);
const commandData = getAllCommands().map((cmd) => cmd.data.toJSON());
await rest.put(Routes.applicationGuildCommands(clientId, GUILD_ID), {
body: commandData,
});
console.log(`Registered ${commandData.length} slash command(s).`);
client.on(Events.InteractionCreate, async (interaction) => {
if (!interaction.isChatInputCommand()) return;
const command = getCommand(interaction.commandName);
if (!command) return;
try {
await command.execute(interaction);
} catch (err) {
console.error(err);
if (interaction.replied || interaction.deferred) {
await interaction.followUp({ content: "An error occurred.", ephemeral: true });
} else {
await interaction.reply({ content: "An error occurred.", ephemeral: true });
}
}
});
}

View File

@@ -0,0 +1,155 @@
import {
Client,
EmbedBuilder,
Events,
GuildMember,
Partials,
TextChannel,
} from "discord.js";
import { CHANNELS, GUILD_ID, ROLES } from "../../config";
const RULES_EMOJI = "✅";
export default async function checkRules(client: Client): Promise<void> {
const guild = await client.guilds.fetch(GUILD_ID);
const channel = (await guild.channels.fetch(
CHANNELS["RULES"],
)) as TextChannel;
if (!channel || !channel.isTextBased()) {
console.error(
"[checkRules] Rules channel not found or is not a text channel.",
);
return;
}
// Look for an existing rules message posted by the bot
const messages = await channel.messages.fetch({ limit: 50 });
const existingMessage = messages.find((m) => m.author.id === client.user!.id);
if (!existingMessage) {
const embed1 = new EmbedBuilder()
.setTitle("📜 Server Rules — Part 1: General Conduct")
.setColor(0x5865f2)
.setDescription(
"Please read and follow all rules to keep this server a safe and welcoming place for everyone.",
)
.addFields(
{
name: "1. Be Respectful",
value:
"Treat all members with respect. Harassment, hate speech, slurs, and discrimination of any kind will not be tolerated.",
},
{
name: "2. No Spam",
value:
"Do not spam messages, emojis, or mentions. Keep conversations relevant to the channel topic.",
},
{
name: "3. No NSFW Content",
value:
"Explicit, graphic, or otherwise inappropriate content is strictly prohibited.",
},
{
name: "4. No Self-Promotion",
value:
"Do not advertise other servers, social media accounts, or services without prior approval from staff.",
},
{
name: "5. Follow Discord ToS",
value:
"All members must comply with [Discord's Terms of Service](https://discord.com/terms) and [Community Guidelines](https://discord.com/guidelines).",
},
)
.setTimestamp();
const embed2 = new EmbedBuilder()
.setTitle("📋 Server Rules — Part 2: Channels & Community")
.setColor(0x57f287)
.addFields(
{
name: "6. Use the Right Channels",
value:
"Keep discussions in their appropriate channels. Off-topic conversations belong in the designated channel.",
},
{
name: "7. No Doxxing",
value:
"Sharing personal or private information of others without their explicit consent is strictly forbidden.",
},
{
name: "8. English in Main Channels",
value:
"Please communicate in English in main channels so all members and staff can participate.",
},
{
name: "9. Listen to Staff",
value:
"Follow the instructions of moderators and admins. If you disagree with a decision, open a support ticket calmly.",
},
{
name: "10. Have Fun!",
value:
"This is a community — be kind, stay positive, and enjoy your time here. 🎉",
},
)
.setFooter({
text: "React with ✅ below to accept the rules and gain access to the server.",
})
.setTimestamp();
const rulesMessage = await channel.send({ embeds: [embed1, embed2] });
await rulesMessage.react(RULES_EMOJI);
console.log("[checkRules] Rules message posted successfully.");
} else {
console.log("[checkRules] Rules message already exists, skipping.");
}
// Grant role when a member reacts with ✅ in the rules channel
client.on(Events.MessageReactionAdd, async (reaction, user) => {
if (user.bot) return;
if (reaction.message.channelId !== CHANNELS.RULES) return;
if (reaction.emoji.name !== RULES_EMOJI) return;
// Resolve partials if needed
try {
if (reaction.partial) await reaction.fetch();
if (user.partial) await user.fetch();
} catch {
return;
}
try {
const member = await guild.members.fetch(user.id);
await member.roles.add(ROLES.RULES);
console.log(`[checkRules] Granted rules role to ${user.tag ?? user.id}`);
} catch (err) {
console.error("[checkRules] Failed to add rules role:", err);
}
});
// Remove role when the reaction is removed
client.on(Events.MessageReactionRemove, async (reaction, user) => {
if (user.bot) return;
if (reaction.message.channelId !== CHANNELS.RULES) return;
if (reaction.emoji.name !== RULES_EMOJI) return;
try {
if (reaction.partial) await reaction.fetch();
if (user.partial) await user.fetch();
} catch (err) {
console.error("[checkRules] Failed to fetch reaction or user:", err);
return;
}
try {
const member = await guild.members.fetch(user.id);
await member.roles.remove(ROLES.RULES);
console.log(
`[checkRules] Removed rules role from ${user.tag ?? user.id}`,
);
} catch (err) {
console.error("[checkRules] Failed to remove rules role:", err);
}
});
}

View File

@@ -0,0 +1,18 @@
import { ActivityType, Client } from "discord.js";
import { ROTATE_ACTIVITIES } from "../../config";
export default async function rotatingActivity(client: Client): Promise<void> {
if (!client.user) {
console.error("[rotatingActivity] Client is not ready yet.");
return;
}
client.user.setActivity("Fixing Bugs", { type: ActivityType.Custom });
let index = 0;
setInterval(() => {
const activity = ROTATE_ACTIVITIES[index];
client.user!.setActivity(activity.content, { type: activity.type });
index = (index + 1) % ROTATE_ACTIVITIES.length;
console.log(`[rotatingActivity] Updated activity to: ${activity.content} (${activity.type})`);
}, 60000); // Rotate every 60 seconds
}

53
src/index.ts Normal file
View File

@@ -0,0 +1,53 @@
import { Client, Events, GatewayIntentBits, Message, Partials } from 'discord.js';
import dotenv from 'dotenv';
import path from 'path';
import loadModulesFromDir from './lib/loadStartupHandlers';
import * as mongoDB from "mongodb";
import { env } from 'process';
import webserver from './webserver';
dotenv.config();
const dbclient: mongoDB.MongoClient = new mongoDB.MongoClient(
env.MONGO_DB!
);
const db: mongoDB.Db = dbclient.db(env.DB_NAME!);
export { db, dbclient };
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.GuildMessageReactions,
GatewayIntentBits.GuildMembers,
GatewayIntentBits.MessageContent,
],
partials: [Partials.Message, Partials.Channel, Partials.Reaction, Partials.User],
});
let readyClient: typeof client | null = null;
client.once(Events.ClientReady, async (rc) => {
readyClient = rc;
console.log(`Logged in as ${rc.user.tag}`);
const dirs = [
path.join(__dirname, 'commands'), // load commands first
path.join(__dirname, 'handlers'),
path.join(__dirname, 'listeners'),
];
for (const dir of dirs) {
await loadModulesFromDir(dir, client);
}
});
// Start webserver in the background
webserver().catch(console.error);
// Exports
export { client, readyClient };
client.login(process.env.DISCORD_TOKEN);

View File

@@ -0,0 +1,20 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
export interface Command {
data: SlashCommandBuilder;
execute: (interaction: ChatInputCommandInteraction) => Promise<void>;
}
const commands = new Map<string, Command>();
export function registerCommand(command: Command) {
commands.set(command.data.name, command);
}
export function getCommand(name: string): Command | undefined {
return commands.get(name);
}
export function getAllCommands(): Command[] {
return Array.from(commands.values());
}

View File

@@ -0,0 +1,32 @@
import { Client } from "discord.js";
import fs from "fs";
import path from "path";
export default async function loadModulesFromDir(dir: string, client: Client): Promise<void> {
if (!fs.existsSync(dir)) {
console.warn(`[loadModules] Directory not found: ${dir}`);
return;
}
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
await loadModulesFromDir(fullPath, client); // recurse
} else if (entry.isFile() && /\.(ts|js)$/.test(entry.name)) {
try {
const mod = await import(fullPath);
if (typeof mod.default === "function") {
await mod.default(client);
console.log(`[loadModules] Loaded: ${fullPath}`);
} else {
console.warn(`[loadModules] No default export function in: ${entry.name}`);
}
} catch (err) {
console.error(`[loadModules] Failed to load ${entry.name}:`, err);
}
}
}
}

View File

@@ -0,0 +1,36 @@
import { Client, Events, GuildMember } from "discord.js";
import { db } from "../..";
// Adds a User to the database as they just joined the server. This is used for tracking purposes and to store user data in the future.
export default async function addUserToDB(client: Client): Promise<void> {
client.on(Events.GuildMemberAdd, async (member) => {
console.log(`[addUserToDB] [${member.user.tag}] Joined the server`);
await db.collection("users").insertOne({
userId: member.user.id,
authorTag: member.user.tag,
content: member.user.username,
guildId: member.guild.id,
timestamp: new Date(member.joinedTimestamp!),
});
});
}
// Silenced becuase its very noisy on every message
export async function addUserToDBManually(member: GuildMember): Promise<void> {
// console.log(`[addUserToDBManually] [${member.user.tag}] Adding user to DB manually`);
const existingUser = await db.collection("users").findOne({ userId: member.user.id });
if (existingUser) {
// console.log(`[addUserToDBManually] [${member.user.tag}] User already exists in DB, skipping.`);
return;
}
await db.collection("users").insertOne({
userId: member.user.id,
authorTag: member.user.tag,
content: member.user.username,
guildId: member.guild.id,
timestamp: new Date(member.joinedTimestamp!),
});
}

View File

@@ -0,0 +1,14 @@
import { Client, Events, GuildMember } from "discord.js";
import { db } from "../..";
// Removes a User from the database as they just left the server.
export default async function removeUserFromDB(client: Client): Promise<void> {
client.on(Events.GuildMemberRemove, async (member) => {
console.log(`[removeUserFromDB] [${member.user.tag}] Left the server`);
await db.collection("users").deleteOne({
userId: member.user.id,
guildId: member.guild.id,
});
});
}

View File

@@ -0,0 +1,20 @@
import { Client, Events, Message } from "discord.js";
export default async function helloWorld(client: Client): Promise<void> {
client.on(Events.MessageCreate, (message: Message) => {
// Ignore messages from bots
if (message.author.bot) return;
if (message.content.toLowerCase().replace("!", "") === "hello world") {
message.reply({
embeds: [
{
title: "Hello, World!",
description: "Hello from the SHSF Team!",
color: 0x00ff00,
},
],
});
}
});
}

View File

@@ -0,0 +1,26 @@
import { Client, Events, Message } from "discord.js";
import { db } from "../..";
import { addUserToDBManually } from "../GuildMemberAdd/addUser";
export default async function logMessage(client: Client): Promise<void> {
client.on(Events.MessageCreate, async (message: Message) => {
if (!message.guildId) return; // Only log messages from guilds (ignore DMs)
if (!message.member) return; // Ignore messages without member info (should be rare)
// Log ALL message inlcuding bots
console.log(`[logMessage] [${message.author.tag}] Sent a message (${message.content.length} chars)`);
await db.collection("messages").insertOne({
messageId: message.id,
authorId: message.author.id,
authorTag: message.author.tag,
content: message.content,
channelId: message.channelId,
guildId: message.guildId,
timestamp: new Date(message.createdTimestamp),
});
// Does this user exist in our database? If not, add them (this is for users who were in the server before the bot was added)
await addUserToDBManually(message.member);
});
}

0
src/types.ts Normal file
View File

96
src/web/gitCommit.ts Normal file
View File

@@ -0,0 +1,96 @@
import { Express, Request, Response } from 'express';
import { EmbedBuilder, TextChannel } from 'discord.js';
import { CHANNELS } from '../config';
import { client, db } from '../index';
const configured_channel = CHANNELS.UPDATES;
export default async function gitCommitPOST(app: Express) {
app.post('/git-commit', async (req: Request, res: Response) => {
try {
const event = req.headers['x-github-event'] as string;
// Acknowledge ping events
if (event === 'ping') {
console.log('[WEB-gitCommit] Received GitHub ping event');
return res.status(200).json({ success: true, message: 'pong' });
}
if (event !== 'push') {
return res.status(200).json({ success: true, message: `Event '${event}' ignored` });
}
const body = req.body;
const repo = body.repository;
const pusher = body.pusher;
const commits: any[] = body.commits ?? [];
const headCommit = body.head_commit;
const ref: string = body.ref ?? '';
const branch = ref.replace('refs/heads/', '');
const compareUrl: string = body.compare ?? '';
const forced: boolean = body.forced ?? false;
if (!repo || !headCommit) {
return res.status(400).json({ error: 'Invalid push payload' });
}
// Build commit list (max X)
const SHOW_MAX = 5;
const commitLines = commits.slice(0, SHOW_MAX).map((c: any) => {
const shortId = c.id.substring(0, 7);
const msg = c.message.split('\n')[0].substring(0, 64);
return `[\`${shortId}\`](${c.url}) ${msg}...`;
});
if (commits.length > SHOW_MAX) {
commitLines.push(`...and ${commits.length - SHOW_MAX} more`);
}
const embed = new EmbedBuilder()
.setColor(forced ? 0xff4444 : 0x2ea44f)
.setTitle(`${forced ? '⚠️ Force Push' : '📦 New Push'} to \`${branch}\``)
.setURL(compareUrl)
.setAuthor({
name: pusher.name,
iconURL: `https://github.com/${pusher.name}.png`,
url: `https://github.com/${pusher.name}`,
})
.addFields(
{ name: '🌿 Branch', value: `\`${branch}\``, inline: true },
{ name: `📝 Commits (${commits.length})`, value: commitLines.join('\n') || '_No commits_' },
)
.setFooter({ text: `Delivery: ${req.headers['x-github-delivery'] ?? 'unknown'}` })
.setTimestamp(headCommit.timestamp ? new Date(headCommit.timestamp) : new Date());
const channel = await client.channels.fetch(configured_channel) as TextChannel | null;
if (!channel || !channel.isTextBased()) {
console.error('[WEB-gitCommit] Configured channel not found or not text-based');
return res.status(500).json({ error: 'Discord channel unavailable' });
}
const message = await channel.send({ embeds: [embed] });
console.log(`[WEB-gitCommit] Push event sent to configured channel (${commits.length} commits on ${branch})`);
// Reactions for engagement
await message.react('👍');
await message.react('🔥');
await message.react('🤯');
// Add to DB
await db.collection('git_commits').insertOne({
repository: repo.full_name,
pusher: pusher.name,
branch,
commitCount: commits.length,
compareUrl,
forced,
timestamp: new Date(),
});
return res.status(200).json({ success: true });
} catch (error) {
console.error('[WEB-gitCommit] Error handling git commit:', error);
return res.status(500).json({ success: false, error: 'Internal Server Error' });
}
});
}

11
src/web/index.ts Normal file
View File

@@ -0,0 +1,11 @@
import { Express, Request, Response } from "express";
export default async function gitCommitPOST(app: Express) {
app.get("/", (req: Request, res: Response) => {
res.status(200).json({ message: "Hello, world!" });
});
app.post("/", (req: Request, res: Response) => {
res.status(200).json({ message: "Hello, world!" });
});
}

43
src/webserver.ts Normal file
View File

@@ -0,0 +1,43 @@
import express from 'express';
import fs from 'fs';
import path from 'path';
export default async function webserver() {
const app = express();
const PORT = process.env.PORT || 3000;
app.use(express.json());
const webDir = path.join(__dirname, 'web');
if (fs.existsSync(webDir)) {
const files = fs.readdirSync(webDir).filter(f => f.endsWith('.ts') || f.endsWith('.js'));
for (const file of files) {
const mod = await import(path.join(webDir, file));
const handler = mod.default ?? mod;
if (typeof handler === 'function') {
await handler(app);
console.log(`[WEB] Loaded web route: ${file}`);
}
}
}
app.listen(PORT, () => {
console.log(`[WEB] Web server is running on port ${PORT}`);
});
const IgnoredPaths = ['/favicon.ico', '/robots.txt', "/", "/hello"];
const KnownPaths = ["/git-commit", "/"];
// log all incoming requests
app.use((req, res, next) => {
if (IgnoredPaths.includes(req.url)) {
return next();
}
if (!KnownPaths.includes(req.url)) {
console.warn(`[WEB] Unknown Route request: ${req.method} ${req.url} {${req.ip}}`);
} else {
console.log(`[WEB] Trusted Route request: ${req.method} ${req.url} {${req.ip}}`);
}
next();
});
}