feat: initial Papo bot scaffold

This commit is contained in:
Pascal.P
2025-11-30 11:04:41 +01:00
commit 000481a3b0
12168 changed files with 1584750 additions and 0 deletions

View File

@@ -0,0 +1,49 @@
import { Collection, Message } from 'discord.js';
import { logger } from '../utils/logger.js';
export class AutoModService {
private spamTracker = new Collection<string, { count: number; lastMessage: number }>();
private spamThreshold = 5;
private windowMs = 7000;
constructor(private linkFilterEnabled = true, private antiSpamEnabled = true) {}
public checkMessage(message: Message) {
if (message.author.bot) return;
if (this.linkFilterEnabled && this.containsLink(message.content)) {
message.delete().catch(() => undefined);
message.channel
.send({ content: `${message.author}, Links sind hier nicht erlaubt.` })
.then((m) => setTimeout(() => m.delete().catch(() => undefined), 5000));
logger.info(`Deleted link from ${message.author.tag}`);
return true;
}
if (this.antiSpamEnabled) {
const now = Date.now();
const tracker = this.spamTracker.get(message.author.id) ?? { count: 0, lastMessage: now };
if (now - tracker.lastMessage < this.windowMs) {
tracker.count += 1;
} else {
tracker.count = 1;
}
tracker.lastMessage = now;
this.spamTracker.set(message.author.id, tracker);
if (tracker.count >= this.spamThreshold) {
message.member?.timeout(10 * 60 * 1000, 'Automod: Spam').catch(() => undefined);
message.channel
.send({ content: `${message.author}, bitte langsamer schreiben (Spam-Schutz).` })
.then((m) => setTimeout(() => m.delete().catch(() => undefined), 5000));
logger.warn(`Timed out ${message.author.tag} for spam`);
this.spamTracker.delete(message.author.id);
return true;
}
}
return false;
}
private containsLink(content: string) {
return /(https?:\/\/|discord\.gg|www\.)/i.test(content);
}
}

View File

@@ -0,0 +1,94 @@
import { REST, Routes, Collection, Client, ChatInputCommandInteraction, GatewayIntentBits } from 'discord.js';
import fs from 'fs';
import path from 'path';
import { SlashCommand } from '../utils/types.js';
import { env } from '../config/env.js';
import { logger } from '../utils/logger.js';
export class CommandHandler {
private commands = new Collection<string, SlashCommand>();
constructor(private client: Client) {}
public async loadCommands() {
const commandsPath = path.join(process.cwd(), 'src', 'commands');
const commandFiles = this.getCommandFiles(commandsPath);
for (const file of commandFiles) {
const mod = await import(file);
const command: SlashCommand = mod.default;
if (command?.data && command?.execute) {
this.commands.set(command.data.name, command);
logger.info(`Loaded command ${command.data.name}`);
}
}
}
private getCommandFiles(dir: string): string[] {
const entries = fs.readdirSync(dir, { withFileTypes: true });
const files: string[] = [];
for (const entry of entries) {
const res = path.resolve(dir, entry.name);
if (entry.isDirectory()) {
files.push(...this.getCommandFiles(res));
} else if (entry.isFile() && (entry.name.endsWith('.ts') || entry.name.endsWith('.js'))) {
files.push(res);
}
}
return files;
}
public async registerSlashCommands() {
const rest = new REST({ version: '10' }).setToken(env.token);
const body = this.commands.map((command) => command.data.toJSON());
const guilds = env.guildIds.length ? env.guildIds : [];
if (guilds.length) {
for (const gid of guilds) {
try {
await rest.put(Routes.applicationGuildCommands(env.clientId, gid), { body });
logger.info(`Registered ${body.length} slash commands for guild ${gid}`);
} catch (err) {
logger.error(`Failed to register slash commands for guild ${gid}`, err);
}
}
} else {
try {
await rest.put(Routes.applicationCommands(env.clientId), { body });
logger.info(`Registered ${body.length} global slash commands`);
} catch (err) {
logger.error('Failed to register global slash commands', err);
}
}
}
public async registerGuildCommands(guildId: string) {
const rest = new REST({ version: '10' }).setToken(env.token);
const body = this.commands.map((command) => command.data.toJSON());
try {
await rest.put(Routes.applicationGuildCommands(env.clientId, guildId), { body });
logger.info(`Registered ${body.length} slash commands for guild ${guildId}`);
} catch (err) {
logger.error(`Failed to register commands for guild ${guildId}`, err);
}
}
public async handleInteraction(interaction: ChatInputCommandInteraction) {
const command = this.commands.get(interaction.commandName);
if (!command) return;
if (command.guildOnly && !interaction.inGuild()) {
await interaction.reply({ content: 'Dieser Befehl funktioniert nur auf Servern.', ephemeral: true });
return;
}
try {
await command.execute(interaction, this.client);
} catch (err) {
logger.error(`Command ${interaction.commandName} failed`, err);
if (interaction.deferred || interaction.replied) {
await interaction.followUp({ content: 'Es ist ein Fehler aufgetreten.', ephemeral: true });
} else {
await interaction.reply({ content: 'Es ist ein Fehler aufgetreten.', ephemeral: true });
}
}
}
}

View File

@@ -0,0 +1,36 @@
import fs from 'fs';
import path from 'path';
import { Client } from 'discord.js';
import { EventHandler } from '../utils/types.js';
import { logger } from '../utils/logger.js';
export class EventHandlerService {
constructor(private client: Client) {}
public async loadEvents() {
const eventsPath = path.join(process.cwd(), 'src', 'events');
const files = this.walk(eventsPath);
for (const file of files) {
const mod = await import(file);
const event: EventHandler = mod.default;
if (!event?.name || !event.execute) continue;
if (event.once) {
this.client.once(event.name, (...args) => event.execute(...args));
} else {
this.client.on(event.name, (...args) => event.execute(...args));
}
logger.info(`Bound event ${event.name}`);
}
}
private walk(dir: string): string[] {
const entries = fs.readdirSync(dir, { withFileTypes: true });
const files: string[] = [];
for (const entry of entries) {
const res = path.resolve(dir, entry.name);
if (entry.isDirectory()) files.push(...this.walk(res));
else if (entry.isFile() && (entry.name.endsWith('.ts') || entry.name.endsWith('.js'))) files.push(res);
}
return files;
}
}

View File

@@ -0,0 +1,17 @@
import { ForumRoleSync, ForumTicketLink, ForumUser } from '../utils/types.js';
export class ForumService {
async linkDiscordToForum(discordId: string, forumUserId: string): Promise<ForumUser> {
return { discordId, forumUserId };
}
async syncForumRoles(): Promise<ForumRoleSync[]> {
// Placeholder: integrate with Forum API
return [];
}
async exportTicketToForum(ticketId: string): Promise<ForumTicketLink> {
// Placeholder: integrate with Forum API
return { ticketId };
}
}

View File

@@ -0,0 +1,39 @@
import { Collection, Message } from 'discord.js';
import { logger } from '../utils/logger.js';
import { settings } from '../config/state.js';
interface LevelData {
xp: number;
level: number;
}
export class LevelService {
private data = new Collection<string, LevelData>();
private cooldown = new Set<string>();
handleMessage(message: Message) {
if (!message.guild || message.author.bot) return;
const guildConfig = settings.get(message.guild.id);
if (!guildConfig?.levelingEnabled) return;
const key = `${message.guild.id}:${message.author.id}`;
if (this.cooldown.has(key)) return;
this.cooldown.add(key);
setTimeout(() => this.cooldown.delete(key), 60_000);
const entry = this.data.get(key) ?? { xp: 0, level: 0 };
entry.xp += 10;
const nextLevel = Math.floor(0.2 * Math.sqrt(entry.xp));
if (nextLevel > entry.level) {
entry.level = nextLevel;
message.channel.send({ content: `${message.author} hat Level ${entry.level} erreicht!` }).catch(() => undefined);
logger.info(`Level up: ${message.author.tag} -> ${entry.level}`);
}
this.data.set(key, entry);
}
getLevel(userId: string, guildId: string) {
const key = `${guildId}:${userId}`;
return this.data.get(key) ?? { xp: 0, level: 0 };
}
}

View File

@@ -0,0 +1,78 @@
import { TextChannel, Guild, Message, GuildMember, User, EmbedBuilder } from 'discord.js';
import { logger } from '../utils/logger.js';
export class LoggingService {
constructor(private logChannelId?: string) {}
private getChannel(guild: Guild): TextChannel | null {
if (!this.logChannelId) return null;
const channel = guild.channels.cache.get(this.logChannelId);
if (!channel || channel.type !== 0) return null;
return channel as TextChannel;
}
logMemberJoin(member: GuildMember) {
const channel = this.getChannel(member.guild);
if (!channel) return;
const embed = new EmbedBuilder()
.setTitle('Member joined')
.setDescription(`${member.user.tag} ist dem Server beigetreten.`)
.setColor(0x00ff99)
.setTimestamp();
channel.send({ embeds: [embed] }).catch((err) => logger.error('Failed to log join', err));
}
logMemberLeave(member: GuildMember) {
const channel = this.getChannel(member.guild);
if (!channel) return;
const embed = new EmbedBuilder()
.setTitle('Member left')
.setDescription(`${member.user.tag} hat den Server verlassen.`)
.setColor(0xff9900)
.setTimestamp();
channel.send({ embeds: [embed] }).catch((err) => logger.error('Failed to log leave', err));
}
logMessageDelete(message: Message) {
if (!message.guild) return;
const channel = this.getChannel(message.guild);
if (!channel) return;
const embed = new EmbedBuilder()
.setTitle('Nachricht gelöscht')
.setDescription(`Von: ${message.author?.tag ?? 'Unbekannt'}`)
.addFields({ name: 'Inhalt', value: message.content || '(leer)' })
.setColor(0xff0000)
.setTimestamp();
channel.send({ embeds: [embed] }).catch((err) => logger.error('Failed to log message delete', err));
}
logMessageEdit(oldMessage: Message, newMessage: Message) {
if (!oldMessage.guild) return;
const channel = this.getChannel(oldMessage.guild);
if (!channel) return;
const embed = new EmbedBuilder()
.setTitle('Nachricht bearbeitet')
.setDescription(`Von: ${oldMessage.author?.tag ?? 'Unbekannt'}`)
.addFields(
{ name: 'Alt', value: oldMessage.content || '(leer)' },
{ name: 'Neu', value: newMessage.content || '(leer)' }
)
.setColor(0xffff00)
.setTimestamp();
channel.send({ embeds: [embed] }).catch((err) => logger.error('Failed to log message edit', err));
}
logAction(user: User, action: string, reason?: string) {
const guild = user instanceof GuildMember ? user.guild : null;
if (!guild) return;
const channel = this.getChannel(guild);
if (!channel) return;
const embed = new EmbedBuilder()
.setTitle('Moderation')
.setDescription(`${user.tag} -> ${action}`)
.addFields({ name: 'Grund', value: reason || 'Nicht angegeben' })
.setColor(0x7289da)
.setTimestamp();
channel.send({ embeds: [embed] }).catch((err) => logger.error('Failed to log action', err));
}
}

View File

@@ -0,0 +1,155 @@
import { AudioPlayer, AudioPlayerStatus, AudioResource, VoiceConnection, createAudioPlayer, createAudioResource, entersState, joinVoiceChannel, VoiceConnectionStatus, getVoiceConnection } from '@discordjs/voice';
import { ChatInputCommandInteraction, GuildMember, TextChannel } from 'discord.js';
import play from 'play-dl';
import { logger } from '../utils/logger.js';
export type LoopMode = 'off' | 'song' | 'queue';
interface QueueItem {
title: string;
url: string;
requester: string;
}
interface QueueState {
connection: VoiceConnection;
player: AudioPlayer;
channel: TextChannel;
loop: LoopMode;
queue: QueueItem[];
current?: QueueItem;
}
export class MusicService {
private queues = new Map<string, QueueState>();
private getQueue(guildId: string) {
return this.queues.get(guildId);
}
private ensureConnection(interaction: ChatInputCommandInteraction): VoiceConnection | null {
const member = interaction.member as GuildMember;
const voice = member?.voice?.channel;
if (!voice || !interaction.guildId) return null;
const existing = getVoiceConnection(interaction.guildId);
if (existing) return existing;
const connection = joinVoiceChannel({
channelId: voice.id,
guildId: interaction.guildId,
adapterCreator: interaction.guild!.voiceAdapterCreator
});
return connection;
}
public async play(interaction: ChatInputCommandInteraction, query: string) {
if (!interaction.guildId) return;
const connection = this.ensureConnection(interaction);
if (!connection) {
await interaction.reply({ content: 'Du musst in einem Voice-Channel sein.', ephemeral: true });
return;
}
const search = await play.search(query, { source: { youtube: 'video' }, limit: 1 });
if (!search.length) {
await interaction.reply({ content: 'Nichts gefunden.', ephemeral: true });
return;
}
const info = search[0];
const queueItem: QueueItem = { title: info.title ?? 'Unbekannt', url: info.url, requester: interaction.user.tag };
const queue = this.getQueue(interaction.guildId);
if (!queue) {
const player = createAudioPlayer();
const state: QueueState = {
connection,
player,
channel: interaction.channel as TextChannel,
loop: 'off',
queue: [queueItem],
current: undefined
};
this.registerPlayer(state, interaction.guildId);
this.queues.set(interaction.guildId, state);
await interaction.reply({ content: `Spiele: **${queueItem.title}**` });
this.processQueue(interaction.guildId);
} else {
queue.queue.push(queueItem);
await interaction.reply({ content: `Zur Queue hinzugefügt: **${queueItem.title}**` });
}
}
public skip(guildId: string) {
const queue = this.getQueue(guildId);
queue?.player.stop(true);
}
public stop(guildId: string) {
const queue = this.getQueue(guildId);
if (!queue) return;
queue.queue = [];
queue.player.stop(true);
queue.connection.destroy();
this.queues.delete(guildId);
}
public pause(guildId: string) {
this.getQueue(guildId)?.player.pause(true);
}
public resume(guildId: string) {
this.getQueue(guildId)?.player.unpause();
}
public setLoop(guildId: string, mode: LoopMode) {
const queue = this.getQueue(guildId);
if (!queue) return;
queue.loop = mode;
}
public getQueueInfo(guildId: string) {
const queue = this.getQueue(guildId);
return queue
? {
nowPlaying: queue.current,
upcoming: queue.queue
}
: null;
}
private async processQueue(guildId: string) {
const queue = this.getQueue(guildId);
if (!queue || queue.player.state.status === AudioPlayerStatus.Playing) return;
let next = queue.queue.shift();
if (!next && queue.loop === 'queue' && queue.current) {
next = queue.current;
}
if (!next) {
queue.channel.send({ content: 'Queue beendet.' }).catch(() => undefined);
return;
}
queue.current = next;
const stream = await play.stream(next.url);
const resource: AudioResource = createAudioResource(stream.stream, {
inputType: stream.type
});
queue.player.play(resource);
queue.connection.subscribe(queue.player);
}
private registerPlayer(queue: QueueState, guildId: string) {
queue.player.on(AudioPlayerStatus.Idle, () => {
if (queue.loop === 'song' && queue.current) {
queue.queue.unshift(queue.current);
}
this.processQueue(guildId);
});
queue.connection.on(VoiceConnectionStatus.Disconnected, () => {
this.queues.delete(guildId);
});
queue.player.on('error', (err) => logger.error('Audio player error', err));
}
}

View File

@@ -0,0 +1,213 @@
import {
ActionRowBuilder,
ButtonBuilder,
ButtonInteraction,
ButtonStyle,
CategoryChannelResolvable,
ChannelType,
ChatInputCommandInteraction,
EmbedBuilder,
Guild,
GuildMember,
PermissionsBitField,
TextChannel
} from 'discord.js';
import { PrismaClient } from '@prisma/client';
import fs from 'fs';
import path from 'path';
import { TicketRecord } from '../utils/types.js';
import { logger } from '../utils/logger.js';
const prisma = new PrismaClient();
export class TicketService {
private categoryName = 'Tickets';
constructor(private transcriptRoot = './transcripts') {}
public async createTicket(interaction: ChatInputCommandInteraction): Promise<TicketRecord | null> {
if (!interaction.guildId || !interaction.guild) return null;
const existing = await prisma.ticket.findFirst({
where: { userId: interaction.user.id, guildId: interaction.guild.id, status: { not: 'closed' } }
});
if (existing) {
await interaction.reply({ content: 'Du hast bereits ein offenes Ticket.', ephemeral: true });
return null;
}
return this.openTicket(interaction.guild, interaction.member as GuildMember, 'support');
}
public async handleButton(interaction: ButtonInteraction) {
if (!interaction.guild) return;
if (interaction.customId.startsWith('ticket:create:')) {
const topic = interaction.customId.split(':')[2] || 'allgemein';
const existing = await prisma.ticket.findFirst({
where: { userId: interaction.user.id, guildId: interaction.guild.id, status: { not: 'closed' } }
});
if (existing) {
await interaction.reply({ content: 'Du hast bereits ein offenes Ticket. Bitte schließe es zuerst.', ephemeral: true });
return;
}
const record = await this.openTicket(interaction.guild, interaction.member as GuildMember, topic);
if (record) {
await interaction.reply({ content: 'Ticket erstellt! Schau im neuen Kanal nach.', ephemeral: true });
} else {
await interaction.reply({ content: 'Ticket konnte nicht erstellt werden.', ephemeral: true });
}
return;
}
if (interaction.customId === 'ticket:claim') {
const ticket = await this.getTicketByChannel(interaction);
if (!ticket) {
await interaction.reply({ content: 'Kein Ticket gefunden.', ephemeral: true });
return;
}
await prisma.ticket.update({ where: { id: ticket.id }, data: { claimedBy: interaction.user.id, status: 'in-progress' } });
await interaction.reply({ content: `${interaction.user} hat das Ticket übernommen.` });
return;
}
if (interaction.customId === 'ticket:close') {
const ok = await this.closeTicketButton(interaction);
if (!ok) await interaction.reply({ content: 'Ticket konnte nicht geschlossen werden.', ephemeral: true });
return;
}
if (interaction.customId === 'ticket:transcript') {
const ticket = await this.getTicketByChannel(interaction);
if (!ticket) {
await interaction.reply({ content: 'Kein Ticket gefunden.', ephemeral: true });
return;
}
const transcriptPath = await this.exportTranscript(interaction.channel as TextChannel, ticket.id);
await interaction.reply({ content: `Transcript erstellt: ${transcriptPath}`, ephemeral: true });
}
}
public async claimTicket(interaction: ChatInputCommandInteraction) {
const channel = interaction.channel as TextChannel;
const ticket = await prisma.ticket.findFirst({ where: { channelId: channel.id } });
if (!ticket) return false;
await prisma.ticket.update({ where: { id: ticket.id }, data: { claimedBy: interaction.user.id, status: 'in-progress' } });
await channel.send({ content: `${interaction.user} hat das Ticket übernommen.` });
return true;
}
public async closeTicket(interaction: ChatInputCommandInteraction, reason?: string) {
const channel = interaction.channel as TextChannel;
const ticket = await prisma.ticket.findFirst({ where: { channelId: channel.id } });
if (!ticket) return false;
const transcriptPath = await this.exportTranscript(channel, ticket.id);
await prisma.ticket.update({ where: { id: ticket.id }, data: { status: 'closed', transcript: transcriptPath } });
await channel.send({ content: `Ticket geschlossen. Grund: ${reason ?? 'Kein Grund angegeben'}` });
return true;
}
public buildPanelEmbed() {
const embed = new EmbedBuilder()
.setTitle('Ticket Support')
.setDescription('Klicke auf eine Kategorie, um ein Ticket zu eröffnen.')
.setColor(0x5865f2)
.addFields(
{ name: 'Support', value: 'Allgemeine Fragen oder Hilfe' },
{ name: 'Report', value: 'Melde Regelverstöße' },
{ name: 'Team', value: 'Bewerbungen oder interne Themen' }
);
const buttons = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder().setCustomId('ticket:create:support').setLabel('Support').setStyle(ButtonStyle.Primary),
new ButtonBuilder().setCustomId('ticket:create:report').setLabel('Report').setStyle(ButtonStyle.Danger),
new ButtonBuilder().setCustomId('ticket:create:team').setLabel('Team').setStyle(ButtonStyle.Secondary)
);
return { embed, buttons };
}
private async ensureCategory(guild: Guild): Promise<CategoryChannelResolvable> {
let category = guild.channels.cache.find(
(c) => c.type === ChannelType.GuildCategory && c.name.toLowerCase().includes(this.categoryName.toLowerCase())
);
if (!category) {
category = await guild.channels.create({ name: this.categoryName, type: ChannelType.GuildCategory });
}
return category as CategoryChannelResolvable;
}
public async exportTranscript(channel: TextChannel, ticketId: string) {
const messages = await channel.messages.fetch({ limit: 100 });
const lines = messages
.sort((a, b) => a.createdTimestamp - b.createdTimestamp)
.map((m) => `[${new Date(m.createdTimestamp).toISOString()}] ${m.author.tag}: ${m.content}`)
.join('\n');
const dir = path.resolve(this.transcriptRoot);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const file = path.join(dir, `${ticketId}.txt`);
fs.writeFileSync(file, lines, 'utf8');
logger.info(`Transcript geschrieben: ${file}`);
return file;
}
private async openTicket(guild: Guild, member: GuildMember, topic: string): Promise<TicketRecord | null> {
const category = await this.ensureCategory(guild);
const channel = await guild.channels.create({
name: `ticket-${member.user.username}`.toLowerCase(),
type: ChannelType.GuildText,
parent: category.id,
permissionOverwrites: [
{
id: guild.id,
deny: [PermissionsBitField.Flags.ViewChannel]
},
{
id: member.id,
allow: [PermissionsBitField.Flags.ViewChannel, PermissionsBitField.Flags.SendMessages]
}
]
});
const record = await prisma.ticket.create({
data: {
userId: member.id,
channelId: channel.id,
guildId: guild.id,
topic,
priority: 'normal',
status: 'open'
}
});
const embed = new EmbedBuilder()
.setTitle(`Ticket: ${topic}`)
.setDescription('Ein Teammitglied wird sich gleich kümmern. Nutze `/claim`, um den Fall zu übernehmen.')
.setColor(0x7289da);
const controls = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder().setCustomId('ticket:claim').setLabel('Claim').setStyle(ButtonStyle.Primary),
new ButtonBuilder().setCustomId('ticket:close').setLabel('Schließen').setStyle(ButtonStyle.Danger),
new ButtonBuilder().setCustomId('ticket:transcript').setLabel('Transcript').setStyle(ButtonStyle.Secondary)
);
await channel.send({ content: `${member}`, embeds: [embed], components: [controls] });
return record as TicketRecord;
}
private async getTicketByChannel(interaction: ButtonInteraction) {
const channel = interaction.channel as TextChannel | null;
if (!channel) return null;
return prisma.ticket.findFirst({ where: { channelId: channel.id } });
}
private async closeTicketButton(interaction: ButtonInteraction) {
const channel = interaction.channel as TextChannel | null;
if (!channel) return false;
const ticket = await prisma.ticket.findFirst({ where: { channelId: channel.id } });
if (!ticket) return false;
const transcriptPath = await this.exportTranscript(channel, ticket.id);
await prisma.ticket.update({ where: { id: ticket.id }, data: { status: 'closed', transcript: transcriptPath } });
await channel.send({ content: `Ticket von ${interaction.user} geschlossen.` });
return true;
}
}