feat: initial Papo bot scaffold
This commit is contained in:
30
src/commands/admin/ban.ts
Normal file
30
src/commands/admin/ban.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, ChatInputCommandInteraction } from 'discord.js';
|
||||
import { SlashCommand } from '../../utils/types.js';
|
||||
import { context } from '../../config/context.js';
|
||||
|
||||
const command: SlashCommand = {
|
||||
guildOnly: true,
|
||||
data: new SlashCommandBuilder()
|
||||
.setName('ban')
|
||||
.setDescription('Bannt einen Nutzer.')
|
||||
.addUserOption((opt) => opt.setName('user').setDescription('Nutzer').setRequired(true))
|
||||
.addStringOption((opt) => opt.setName('reason').setDescription('Grund'))
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.BanMembers),
|
||||
async execute(interaction: ChatInputCommandInteraction) {
|
||||
const user = interaction.options.getUser('user', true);
|
||||
const reason = interaction.options.getString('reason') ?? 'Kein Grund angegeben';
|
||||
if (!interaction.guild) return;
|
||||
|
||||
const member = await interaction.guild.members.fetch(user.id).catch(() => null);
|
||||
if (!member) {
|
||||
await interaction.reply({ content: 'Mitglied nicht gefunden.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
await member.ban({ reason }).catch(() => null);
|
||||
await interaction.reply({ content: `${user.tag} wurde gebannt. Grund: ${reason}` });
|
||||
context.logging.logAction(user, 'Ban', reason);
|
||||
}
|
||||
};
|
||||
|
||||
export default command;
|
||||
22
src/commands/admin/clear.ts
Normal file
22
src/commands/admin/clear.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, ChatInputCommandInteraction } from 'discord.js';
|
||||
import { SlashCommand } from '../../utils/types.js';
|
||||
|
||||
const command: SlashCommand = {
|
||||
guildOnly: true,
|
||||
data: new SlashCommandBuilder()
|
||||
.setName('clear')
|
||||
.setDescription('Löscht Nachrichten (max 100).')
|
||||
.addIntegerOption((opt) => opt.setName('amount').setDescription('Anzahl').setRequired(true))
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages),
|
||||
async execute(interaction: ChatInputCommandInteraction) {
|
||||
const amount = interaction.options.getInteger('amount', true);
|
||||
if (!interaction.channel || amount < 1 || amount > 100) {
|
||||
await interaction.reply({ content: 'Anzahl muss zwischen 1 und 100 liegen.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
const messages = await interaction.channel.bulkDelete(amount, true);
|
||||
await interaction.reply({ content: `Gelöschte Nachrichten: ${messages.size}`, ephemeral: true });
|
||||
}
|
||||
};
|
||||
|
||||
export default command;
|
||||
28
src/commands/admin/kick.ts
Normal file
28
src/commands/admin/kick.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, ChatInputCommandInteraction } from 'discord.js';
|
||||
import { SlashCommand } from '../../utils/types.js';
|
||||
import { context } from '../../config/context.js';
|
||||
|
||||
const command: SlashCommand = {
|
||||
guildOnly: true,
|
||||
data: new SlashCommandBuilder()
|
||||
.setName('kick')
|
||||
.setDescription('Kickt einen Nutzer vom Server.')
|
||||
.addUserOption((opt) => opt.setName('user').setDescription('Nutzer').setRequired(true))
|
||||
.addStringOption((opt) => opt.setName('reason').setDescription('Grund'))
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.KickMembers),
|
||||
async execute(interaction: ChatInputCommandInteraction) {
|
||||
if (!interaction.guild) return;
|
||||
const user = interaction.options.getUser('user', true);
|
||||
const reason = interaction.options.getString('reason') ?? 'Kein Grund angegeben';
|
||||
const member = await interaction.guild.members.fetch(user.id).catch(() => null);
|
||||
if (!member) {
|
||||
await interaction.reply({ content: 'Mitglied nicht gefunden.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
await member.kick(reason);
|
||||
await interaction.reply({ content: `${user.tag} wurde gekickt.` });
|
||||
context.logging.logAction(user, 'Kick', reason);
|
||||
}
|
||||
};
|
||||
|
||||
export default command;
|
||||
30
src/commands/admin/mute.ts
Normal file
30
src/commands/admin/mute.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, ChatInputCommandInteraction } from 'discord.js';
|
||||
import { SlashCommand } from '../../utils/types.js';
|
||||
import { context } from '../../config/context.js';
|
||||
|
||||
const command: SlashCommand = {
|
||||
guildOnly: true,
|
||||
data: new SlashCommandBuilder()
|
||||
.setName('mute')
|
||||
.setDescription('Stummschalten per Timeout (Minuten).')
|
||||
.addUserOption((opt) => opt.setName('user').setDescription('Nutzer').setRequired(true))
|
||||
.addIntegerOption((opt) => opt.setName('minutes').setDescription('Dauer').setRequired(true))
|
||||
.addStringOption((opt) => opt.setName('reason').setDescription('Grund'))
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers),
|
||||
async execute(interaction: ChatInputCommandInteraction) {
|
||||
if (!interaction.guild) return;
|
||||
const user = interaction.options.getUser('user', true);
|
||||
const minutes = interaction.options.getInteger('minutes', true);
|
||||
const reason = interaction.options.getString('reason') ?? 'Kein Grund angegeben';
|
||||
const member = await interaction.guild.members.fetch(user.id).catch(() => null);
|
||||
if (!member) {
|
||||
await interaction.reply({ content: 'Mitglied nicht gefunden.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
await member.timeout(minutes * 60 * 1000, reason).catch(() => null);
|
||||
await interaction.reply({ content: `${user.tag} wurde für ${minutes} Minuten gemutet.` });
|
||||
context.logging.logAction(user, 'Mute', reason);
|
||||
}
|
||||
};
|
||||
|
||||
export default command;
|
||||
36
src/commands/admin/tempban.ts
Normal file
36
src/commands/admin/tempban.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, ChatInputCommandInteraction } from 'discord.js';
|
||||
import { SlashCommand } from '../../utils/types.js';
|
||||
import { context } from '../../config/context.js';
|
||||
|
||||
const command: SlashCommand = {
|
||||
guildOnly: true,
|
||||
data: new SlashCommandBuilder()
|
||||
.setName('tempban')
|
||||
.setDescription('Bannt einen Nutzer temporär (Angabe in Minuten).')
|
||||
.addUserOption((opt) => opt.setName('user').setDescription('Nutzer').setRequired(true))
|
||||
.addIntegerOption((opt) => opt.setName('minutes').setDescription('Dauer').setRequired(true))
|
||||
.addStringOption((opt) => opt.setName('reason').setDescription('Grund'))
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.BanMembers),
|
||||
async execute(interaction: ChatInputCommandInteraction) {
|
||||
if (!interaction.guild) return;
|
||||
const user = interaction.options.getUser('user', true);
|
||||
const minutes = interaction.options.getInteger('minutes', true);
|
||||
const reason = interaction.options.getString('reason') ?? 'Kein Grund angegeben';
|
||||
|
||||
const member = await interaction.guild.members.fetch(user.id).catch(() => null);
|
||||
if (!member) {
|
||||
await interaction.reply({ content: 'Mitglied nicht gefunden.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
await member.ban({ reason: `${reason} | ${minutes} Minuten` });
|
||||
await interaction.reply({ content: `${user.tag} wurde für ${minutes} Minuten gebannt.` });
|
||||
context.logging.logAction(user, 'Tempban', reason);
|
||||
|
||||
setTimeout(async () => {
|
||||
await interaction.guild?.members.unban(user.id, 'Tempban abgelaufen').catch(() => null);
|
||||
}, minutes * 60 * 1000);
|
||||
}
|
||||
};
|
||||
|
||||
export default command;
|
||||
30
src/commands/admin/timeout.ts
Normal file
30
src/commands/admin/timeout.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, ChatInputCommandInteraction } from 'discord.js';
|
||||
import { SlashCommand } from '../../utils/types.js';
|
||||
import { context } from '../../config/context.js';
|
||||
|
||||
const command: SlashCommand = {
|
||||
guildOnly: true,
|
||||
data: new SlashCommandBuilder()
|
||||
.setName('timeout')
|
||||
.setDescription('Timeout in Minuten setzen.')
|
||||
.addUserOption((opt) => opt.setName('user').setDescription('Nutzer').setRequired(true))
|
||||
.addIntegerOption((opt) => opt.setName('minutes').setDescription('Dauer').setRequired(true))
|
||||
.addStringOption((opt) => opt.setName('reason').setDescription('Grund'))
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers),
|
||||
async execute(interaction: ChatInputCommandInteraction) {
|
||||
if (!interaction.guild) return;
|
||||
const user = interaction.options.getUser('user', true);
|
||||
const minutes = interaction.options.getInteger('minutes', true);
|
||||
const reason = interaction.options.getString('reason') ?? 'Kein Grund angegeben';
|
||||
const member = await interaction.guild.members.fetch(user.id).catch(() => null);
|
||||
if (!member) {
|
||||
await interaction.reply({ content: 'Mitglied nicht gefunden.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
await member.timeout(minutes * 60 * 1000, reason).catch(() => null);
|
||||
await interaction.reply({ content: `${user.tag} wurde für ${minutes} Minuten in Timeout gesetzt.` });
|
||||
context.logging.logAction(user, 'Timeout', reason);
|
||||
}
|
||||
};
|
||||
|
||||
export default command;
|
||||
26
src/commands/admin/unmute.ts
Normal file
26
src/commands/admin/unmute.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, ChatInputCommandInteraction } from 'discord.js';
|
||||
import { SlashCommand } from '../../utils/types.js';
|
||||
import { context } from '../../config/context.js';
|
||||
|
||||
const command: SlashCommand = {
|
||||
guildOnly: true,
|
||||
data: new SlashCommandBuilder()
|
||||
.setName('unmute')
|
||||
.setDescription('Hebt einen Timeout auf.')
|
||||
.addUserOption((opt) => opt.setName('user').setDescription('Nutzer').setRequired(true))
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers),
|
||||
async execute(interaction: ChatInputCommandInteraction) {
|
||||
if (!interaction.guild) return;
|
||||
const user = interaction.options.getUser('user', true);
|
||||
const member = await interaction.guild.members.fetch(user.id).catch(() => null);
|
||||
if (!member) {
|
||||
await interaction.reply({ content: 'Mitglied nicht gefunden.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
await member.timeout(null).catch(() => null);
|
||||
await interaction.reply({ content: `${user.tag} ist nun entmuted.` });
|
||||
context.logging.logAction(user, 'Unmute');
|
||||
}
|
||||
};
|
||||
|
||||
export default command;
|
||||
30
src/commands/music/loop.ts
Normal file
30
src/commands/music/loop.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { SlashCommandBuilder, ChatInputCommandInteraction } from 'discord.js';
|
||||
import { SlashCommand } from '../../utils/types.js';
|
||||
import { context } from '../../config/context.js';
|
||||
import { LoopMode } from '../../services/musicService.js';
|
||||
|
||||
const command: SlashCommand = {
|
||||
guildOnly: true,
|
||||
data: new SlashCommandBuilder()
|
||||
.setName('loop')
|
||||
.setDescription('Loop-Modus setzen (off/song/queue).')
|
||||
.addStringOption((opt) =>
|
||||
opt
|
||||
.setName('mode')
|
||||
.setDescription('Loop Modus')
|
||||
.addChoices(
|
||||
{ name: 'aus', value: 'off' },
|
||||
{ name: 'song', value: 'song' },
|
||||
{ name: 'queue', value: 'queue' }
|
||||
)
|
||||
.setRequired(true)
|
||||
),
|
||||
async execute(interaction: ChatInputCommandInteraction) {
|
||||
if (!interaction.guildId) return;
|
||||
const mode = interaction.options.getString('mode', true) as LoopMode;
|
||||
context.music.setLoop(interaction.guildId, mode);
|
||||
await interaction.reply({ content: `Loop-Modus: ${mode}` });
|
||||
}
|
||||
};
|
||||
|
||||
export default command;
|
||||
15
src/commands/music/pause.ts
Normal file
15
src/commands/music/pause.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { SlashCommandBuilder, ChatInputCommandInteraction } from 'discord.js';
|
||||
import { SlashCommand } from '../../utils/types.js';
|
||||
import { context } from '../../config/context.js';
|
||||
|
||||
const command: SlashCommand = {
|
||||
guildOnly: true,
|
||||
data: new SlashCommandBuilder().setName('pause').setDescription('Pausiert die Wiedergabe.'),
|
||||
async execute(interaction: ChatInputCommandInteraction) {
|
||||
if (!interaction.guildId) return;
|
||||
context.music.pause(interaction.guildId);
|
||||
await interaction.reply({ content: 'Pausiert.' });
|
||||
}
|
||||
};
|
||||
|
||||
export default command;
|
||||
17
src/commands/music/play.ts
Normal file
17
src/commands/music/play.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { SlashCommandBuilder, ChatInputCommandInteraction } from 'discord.js';
|
||||
import { SlashCommand } from '../../utils/types.js';
|
||||
import { context } from '../../config/context.js';
|
||||
|
||||
const command: SlashCommand = {
|
||||
guildOnly: true,
|
||||
data: new SlashCommandBuilder()
|
||||
.setName('play')
|
||||
.setDescription('Spielt einen Song oder fügt ihn zur Queue hinzu.')
|
||||
.addStringOption((opt) => opt.setName('query').setDescription('Link oder Suchbegriff').setRequired(true)),
|
||||
async execute(interaction: ChatInputCommandInteraction) {
|
||||
const query = interaction.options.getString('query', true);
|
||||
await context.music.play(interaction, query);
|
||||
}
|
||||
};
|
||||
|
||||
export default command;
|
||||
23
src/commands/music/queue.ts
Normal file
23
src/commands/music/queue.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { SlashCommandBuilder, ChatInputCommandInteraction, EmbedBuilder } from 'discord.js';
|
||||
import { SlashCommand } from '../../utils/types.js';
|
||||
import { context } from '../../config/context.js';
|
||||
|
||||
const command: SlashCommand = {
|
||||
guildOnly: true,
|
||||
data: new SlashCommandBuilder().setName('queue').setDescription('Zeigt die aktuelle Queue.'),
|
||||
async execute(interaction: ChatInputCommandInteraction) {
|
||||
if (!interaction.guildId) return;
|
||||
const info = context.music.getQueueInfo(interaction.guildId);
|
||||
if (!info) {
|
||||
await interaction.reply({ content: 'Keine Queue vorhanden.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle('Aktuelle Queue')
|
||||
.addFields({ name: 'Jetzt', value: info.nowPlaying ? info.nowPlaying.title : 'Nichts' })
|
||||
.setDescription(info.upcoming.map((s, i) => `${i + 1}. ${s.title} — ${s.requester}`).join('\n') || 'Keine weiteren Songs');
|
||||
await interaction.reply({ embeds: [embed] });
|
||||
}
|
||||
};
|
||||
|
||||
export default command;
|
||||
15
src/commands/music/resume.ts
Normal file
15
src/commands/music/resume.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { SlashCommandBuilder, ChatInputCommandInteraction } from 'discord.js';
|
||||
import { SlashCommand } from '../../utils/types.js';
|
||||
import { context } from '../../config/context.js';
|
||||
|
||||
const command: SlashCommand = {
|
||||
guildOnly: true,
|
||||
data: new SlashCommandBuilder().setName('resume').setDescription('Setzt die Wiedergabe fort.'),
|
||||
async execute(interaction: ChatInputCommandInteraction) {
|
||||
if (!interaction.guildId) return;
|
||||
context.music.resume(interaction.guildId);
|
||||
await interaction.reply({ content: 'Weiter gehts.' });
|
||||
}
|
||||
};
|
||||
|
||||
export default command;
|
||||
15
src/commands/music/skip.ts
Normal file
15
src/commands/music/skip.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { SlashCommandBuilder, ChatInputCommandInteraction } from 'discord.js';
|
||||
import { SlashCommand } from '../../utils/types.js';
|
||||
import { context } from '../../config/context.js';
|
||||
|
||||
const command: SlashCommand = {
|
||||
guildOnly: true,
|
||||
data: new SlashCommandBuilder().setName('skip').setDescription('Überspringt den aktuellen Song.'),
|
||||
async execute(interaction: ChatInputCommandInteraction) {
|
||||
if (!interaction.guildId) return;
|
||||
context.music.skip(interaction.guildId);
|
||||
await interaction.reply({ content: 'Übersprungen.' });
|
||||
}
|
||||
};
|
||||
|
||||
export default command;
|
||||
15
src/commands/music/stop.ts
Normal file
15
src/commands/music/stop.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { SlashCommandBuilder, ChatInputCommandInteraction } from 'discord.js';
|
||||
import { SlashCommand } from '../../utils/types.js';
|
||||
import { context } from '../../config/context.js';
|
||||
|
||||
const command: SlashCommand = {
|
||||
guildOnly: true,
|
||||
data: new SlashCommandBuilder().setName('stop').setDescription('Stoppt die Wiedergabe und leert die Queue.'),
|
||||
async execute(interaction: ChatInputCommandInteraction) {
|
||||
if (!interaction.guildId) return;
|
||||
context.music.stop(interaction.guildId);
|
||||
await interaction.reply({ content: 'Musik gestoppt und Queue geleert.' });
|
||||
}
|
||||
};
|
||||
|
||||
export default command;
|
||||
18
src/commands/tickets/claim.ts
Normal file
18
src/commands/tickets/claim.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { SlashCommandBuilder, ChatInputCommandInteraction } from 'discord.js';
|
||||
import { SlashCommand } from '../../utils/types.js';
|
||||
import { context } from '../../config/context.js';
|
||||
|
||||
const command: SlashCommand = {
|
||||
guildOnly: true,
|
||||
data: new SlashCommandBuilder().setName('claim').setDescription('Übernimmt das aktuelle Ticket.'),
|
||||
async execute(interaction: ChatInputCommandInteraction) {
|
||||
const ok = await context.tickets.claimTicket(interaction);
|
||||
if (!ok) {
|
||||
await interaction.reply({ content: 'Kein Ticket in diesem Kanal gefunden.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
await interaction.reply({ content: 'Ticket übernommen.' });
|
||||
}
|
||||
};
|
||||
|
||||
export default command;
|
||||
22
src/commands/tickets/close.ts
Normal file
22
src/commands/tickets/close.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { SlashCommandBuilder, ChatInputCommandInteraction } from 'discord.js';
|
||||
import { SlashCommand } from '../../utils/types.js';
|
||||
import { context } from '../../config/context.js';
|
||||
|
||||
const command: SlashCommand = {
|
||||
guildOnly: true,
|
||||
data: new SlashCommandBuilder()
|
||||
.setName('close')
|
||||
.setDescription('Schließt das aktuelle Ticket.')
|
||||
.addStringOption((opt) => opt.setName('reason').setDescription('Grund')),
|
||||
async execute(interaction: ChatInputCommandInteraction) {
|
||||
const reason = interaction.options.getString('reason') ?? undefined;
|
||||
const ok = await context.tickets.closeTicket(interaction, reason);
|
||||
if (!ok) {
|
||||
await interaction.reply({ content: 'Kein Ticket in diesem Kanal gefunden.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
await interaction.reply({ content: 'Ticket geschlossen.' });
|
||||
}
|
||||
};
|
||||
|
||||
export default command;
|
||||
26
src/commands/tickets/panel.ts
Normal file
26
src/commands/tickets/panel.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { SlashCommandBuilder, ChatInputCommandInteraction, PermissionFlagsBits, ChannelType, TextChannel } from 'discord.js';
|
||||
import { SlashCommand } from '../../utils/types.js';
|
||||
import { context } from '../../config/context.js';
|
||||
|
||||
const command: SlashCommand = {
|
||||
guildOnly: true,
|
||||
data: new SlashCommandBuilder()
|
||||
.setName('ticketpanel')
|
||||
.setDescription('Sendet ein Ticket-Panel in einen Kanal.')
|
||||
.addChannelOption((opt) =>
|
||||
opt.setName('channel').setDescription('Zielkanal').addChannelTypes(ChannelType.GuildText)
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels),
|
||||
async execute(interaction: ChatInputCommandInteraction) {
|
||||
const target = (interaction.options.getChannel('channel') as TextChannel | null) ?? interaction.channel;
|
||||
if (!target || !target.isTextBased()) {
|
||||
await interaction.reply({ content: 'Bitte wähle einen Textkanal.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
const { embed, buttons } = context.tickets.buildPanelEmbed();
|
||||
await target.send({ embeds: [embed], components: [buttons] });
|
||||
await interaction.reply({ content: `Ticket-Panel gesendet in ${target}`, ephemeral: true });
|
||||
}
|
||||
};
|
||||
|
||||
export default command;
|
||||
34
src/commands/tickets/priority.ts
Normal file
34
src/commands/tickets/priority.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { SlashCommandBuilder, ChatInputCommandInteraction } from 'discord.js';
|
||||
import { SlashCommand } from '../../utils/types.js';
|
||||
import { prisma } from '../../database/index.js';
|
||||
|
||||
const command: SlashCommand = {
|
||||
guildOnly: true,
|
||||
data: new SlashCommandBuilder()
|
||||
.setName('ticketpriority')
|
||||
.setDescription('Setzt die Priorität des aktuellen Tickets.')
|
||||
.addStringOption((opt) =>
|
||||
opt
|
||||
.setName('level')
|
||||
.setDescription('Priorität')
|
||||
.addChoices(
|
||||
{ name: 'Low', value: 'low' },
|
||||
{ name: 'Normal', value: 'normal' },
|
||||
{ name: 'High', value: 'high' }
|
||||
)
|
||||
.setRequired(true)
|
||||
),
|
||||
async execute(interaction: ChatInputCommandInteraction) {
|
||||
const channelId = interaction.channelId;
|
||||
const ticket = await prisma.ticket.findFirst({ where: { channelId } });
|
||||
if (!ticket) {
|
||||
await interaction.reply({ content: 'Kein Ticket in diesem Kanal.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
const level = interaction.options.getString('level', true) as 'low' | 'normal' | 'high';
|
||||
await prisma.ticket.update({ where: { id: ticket.id }, data: { priority: level } });
|
||||
await interaction.reply({ content: `Ticket-Priorität gesetzt auf **${level}**.` });
|
||||
}
|
||||
};
|
||||
|
||||
export default command;
|
||||
33
src/commands/tickets/status.ts
Normal file
33
src/commands/tickets/status.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { SlashCommandBuilder, ChatInputCommandInteraction } from 'discord.js';
|
||||
import { SlashCommand } from '../../utils/types.js';
|
||||
import { prisma } from '../../database/index.js';
|
||||
|
||||
const command: SlashCommand = {
|
||||
guildOnly: true,
|
||||
data: new SlashCommandBuilder()
|
||||
.setName('ticketstatus')
|
||||
.setDescription('Ändert den Status des aktuellen Tickets.')
|
||||
.addStringOption((opt) =>
|
||||
opt
|
||||
.setName('status')
|
||||
.setDescription('Neuer Status')
|
||||
.addChoices(
|
||||
{ name: 'Offen', value: 'open' },
|
||||
{ name: 'In Bearbeitung', value: 'in-progress' },
|
||||
{ name: 'Geschlossen', value: 'closed' }
|
||||
)
|
||||
.setRequired(true)
|
||||
),
|
||||
async execute(interaction: ChatInputCommandInteraction) {
|
||||
const status = interaction.options.getString('status', true) as 'open' | 'in-progress' | 'closed';
|
||||
const ticket = await prisma.ticket.findFirst({ where: { channelId: interaction.channelId } });
|
||||
if (!ticket) {
|
||||
await interaction.reply({ content: 'Kein Ticket in diesem Kanal.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
await prisma.ticket.update({ where: { id: ticket.id }, data: { status } });
|
||||
await interaction.reply({ content: `Ticket-Status geändert zu **${status}**.` });
|
||||
}
|
||||
};
|
||||
|
||||
export default command;
|
||||
22
src/commands/tickets/ticket.ts
Normal file
22
src/commands/tickets/ticket.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { SlashCommandBuilder, ChatInputCommandInteraction } from 'discord.js';
|
||||
import { SlashCommand } from '../../utils/types.js';
|
||||
import { context } from '../../config/context.js';
|
||||
|
||||
const command: SlashCommand = {
|
||||
guildOnly: true,
|
||||
data: new SlashCommandBuilder().setName('ticket').setDescription('Erstellt ein persönliches Ticket.'),
|
||||
async execute(interaction: ChatInputCommandInteraction) {
|
||||
const ticket = await context.tickets.createTicket(interaction);
|
||||
if (!ticket) {
|
||||
if (!interaction.replied) {
|
||||
await interaction.reply({ content: 'Ticket konnte nicht erstellt werden.', ephemeral: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!interaction.replied) {
|
||||
await interaction.reply({ content: 'Ticket erstellt! Schau in deinem neuen Kanal nach.', ephemeral: true });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default command;
|
||||
25
src/commands/tickets/transcript.ts
Normal file
25
src/commands/tickets/transcript.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { SlashCommandBuilder, ChatInputCommandInteraction } from 'discord.js';
|
||||
import { SlashCommand } from '../../utils/types.js';
|
||||
import { context } from '../../config/context.js';
|
||||
import { prisma } from '../../database/index.js';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
const command: SlashCommand = {
|
||||
guildOnly: true,
|
||||
data: new SlashCommandBuilder().setName('transcript').setDescription('Exportiert das Transcript dieses Tickets.'),
|
||||
async execute(interaction: ChatInputCommandInteraction) {
|
||||
const channelId = interaction.channelId;
|
||||
const ticket = await prisma.ticket.findFirst({ where: { channelId } });
|
||||
if (!ticket) {
|
||||
await interaction.reply({ content: 'Kein Ticket in diesem Kanal.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = await context.tickets.exportTranscript(interaction.channel!, ticket.id);
|
||||
const fileName = path.basename(filePath);
|
||||
await interaction.reply({ content: `Transcript exportiert: ${fileName}`, files: [fs.createReadStream(filePath)] });
|
||||
}
|
||||
};
|
||||
|
||||
export default command;
|
||||
36
src/commands/utility/configure.ts
Normal file
36
src/commands/utility/configure.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { SlashCommandBuilder, ChatInputCommandInteraction, PermissionFlagsBits, ChannelType } from 'discord.js';
|
||||
import { SlashCommand } from '../../utils/types.js';
|
||||
import { settings } from '../../config/state.js';
|
||||
import { context } from '../../config/context.js';
|
||||
|
||||
const command: SlashCommand = {
|
||||
guildOnly: true,
|
||||
data: new SlashCommandBuilder()
|
||||
.setName('configure')
|
||||
.setDescription('Setzt Basis-Einstellungen des Bots (gildenspezifisch).')
|
||||
.addChannelOption((opt) => opt.setName('welcome_channel').setDescription('Kanal für Willkommensnachrichten').addChannelTypes(ChannelType.GuildText))
|
||||
.addChannelOption((opt) => opt.setName('log_channel').setDescription('Kanal für Logs').addChannelTypes(ChannelType.GuildText))
|
||||
.addBooleanOption((opt) => opt.setName('automod').setDescription('Automod an/aus'))
|
||||
.addBooleanOption((opt) => opt.setName('leveling').setDescription('Level-System an/aus'))
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild),
|
||||
async execute(interaction: ChatInputCommandInteraction) {
|
||||
if (!interaction.guildId) return;
|
||||
const guildSetting = settings.get(interaction.guildId) ?? {};
|
||||
const welcome = interaction.options.getChannel('welcome_channel');
|
||||
const logChannel = interaction.options.getChannel('log_channel');
|
||||
const automod = interaction.options.getBoolean('automod');
|
||||
const leveling = interaction.options.getBoolean('leveling');
|
||||
|
||||
if (welcome) guildSetting.welcomeChannelId = welcome.id;
|
||||
if (logChannel) guildSetting.logChannelId = logChannel.id;
|
||||
if (automod !== null) guildSetting.automodEnabled = automod;
|
||||
if (leveling !== null) guildSetting.levelingEnabled = leveling;
|
||||
|
||||
settings.set(interaction.guildId, guildSetting);
|
||||
context.logging = new (context.logging.constructor as any)(guildSetting.logChannelId);
|
||||
|
||||
await interaction.reply({ content: 'Einstellungen gespeichert.', ephemeral: true });
|
||||
}
|
||||
};
|
||||
|
||||
export default command;
|
||||
20
src/commands/utility/help.ts
Normal file
20
src/commands/utility/help.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { SlashCommandBuilder, ChatInputCommandInteraction, EmbedBuilder } from 'discord.js';
|
||||
import { SlashCommand } from '../../utils/types.js';
|
||||
|
||||
const command: SlashCommand = {
|
||||
data: new SlashCommandBuilder().setName('help').setDescription('Zeigt Befehle und Module.'),
|
||||
async execute(interaction: ChatInputCommandInteraction) {
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle('Papo Hilfe')
|
||||
.setDescription('Multi-Guild ready | Admin, Tickets, Musik, Automod, Dashboard')
|
||||
.addFields(
|
||||
{ name: 'Admin', value: '/ban /kick /mute /timeout /clear', inline: false },
|
||||
{ name: 'Tickets', value: '/ticket /ticketpanel /ticketpriority /ticketstatus /transcript', inline: false },
|
||||
{ name: 'Musik', value: '/play /pause /resume /skip /stop /queue /loop', inline: false },
|
||||
{ name: 'Utility', value: '/ping /configure /serverinfo /rank', inline: false }
|
||||
);
|
||||
await interaction.reply({ embeds: [embed], ephemeral: true });
|
||||
}
|
||||
};
|
||||
|
||||
export default command;
|
||||
11
src/commands/utility/ping.ts
Normal file
11
src/commands/utility/ping.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { SlashCommandBuilder, ChatInputCommandInteraction } from 'discord.js';
|
||||
import { SlashCommand } from '../../utils/types.js';
|
||||
|
||||
const command: SlashCommand = {
|
||||
data: new SlashCommandBuilder().setName('ping').setDescription('Antwortet mit Pong!'),
|
||||
async execute(interaction: ChatInputCommandInteraction) {
|
||||
await interaction.reply({ content: 'Pong!' });
|
||||
}
|
||||
};
|
||||
|
||||
export default command;
|
||||
19
src/commands/utility/rank.ts
Normal file
19
src/commands/utility/rank.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { SlashCommandBuilder, ChatInputCommandInteraction } from 'discord.js';
|
||||
import { SlashCommand } from '../../utils/types.js';
|
||||
import { context } from '../../config/context.js';
|
||||
|
||||
const command: SlashCommand = {
|
||||
guildOnly: true,
|
||||
data: new SlashCommandBuilder()
|
||||
.setName('rank')
|
||||
.setDescription('Zeigt deinen XP-Status an.')
|
||||
.addUserOption((opt) => opt.setName('user').setDescription('Nutzer (optional)')),
|
||||
async execute(interaction: ChatInputCommandInteraction) {
|
||||
if (!interaction.guildId) return;
|
||||
const user = interaction.options.getUser('user') ?? interaction.user;
|
||||
const level = context.leveling.getLevel(user.id, interaction.guildId);
|
||||
await interaction.reply({ content: `${user.tag}: Level ${level.level}, XP ${level.xp}` });
|
||||
}
|
||||
};
|
||||
|
||||
export default command;
|
||||
21
src/commands/utility/serverinfo.ts
Normal file
21
src/commands/utility/serverinfo.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { SlashCommandBuilder, ChatInputCommandInteraction, EmbedBuilder } from 'discord.js';
|
||||
import { SlashCommand } from '../../utils/types.js';
|
||||
|
||||
const command: SlashCommand = {
|
||||
guildOnly: true,
|
||||
data: new SlashCommandBuilder().setName('serverinfo').setDescription('Zeigt Infos zum aktuellen Server.'),
|
||||
async execute(interaction: ChatInputCommandInteraction) {
|
||||
const guild = interaction.guild!;
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(guild.name)
|
||||
.setThumbnail(guild.iconURL() ?? null)
|
||||
.addFields(
|
||||
{ name: 'Mitglieder', value: `${guild.memberCount}`, inline: true },
|
||||
{ name: 'Erstellt', value: `<t:${Math.floor(guild.createdTimestamp / 1000)}:R>`, inline: true },
|
||||
{ name: 'ID', value: guild.id, inline: true }
|
||||
);
|
||||
await interaction.reply({ embeds: [embed] });
|
||||
}
|
||||
};
|
||||
|
||||
export default command;
|
||||
15
src/config/context.ts
Normal file
15
src/config/context.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { CommandHandler } from '../services/commandHandler.js';
|
||||
import { AutoModService } from '../services/automodService.js';
|
||||
import { LoggingService } from '../services/loggingService.js';
|
||||
import { MusicService } from '../services/musicService.js';
|
||||
import { TicketService } from '../services/ticketService.js';
|
||||
import { LevelService } from '../services/levelService.js';
|
||||
|
||||
export const context = {
|
||||
commandHandler: null as CommandHandler | null,
|
||||
automod: new AutoModService(true, true),
|
||||
logging: new LoggingService(),
|
||||
music: new MusicService(),
|
||||
tickets: new TicketService(),
|
||||
leveling: new LevelService()
|
||||
};
|
||||
25
src/config/env.ts
Normal file
25
src/config/env.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
|
||||
dotenv.config({ path: path.resolve(process.cwd(), '.env') });
|
||||
|
||||
const required = ['DISCORD_TOKEN', 'DISCORD_CLIENT_ID', 'DISCORD_GUILD_ID', 'DATABASE_URL'] as const;
|
||||
|
||||
required.forEach((key) => {
|
||||
if (!process.env[key]) {
|
||||
console.warn(`[config] Missing environment variable ${key}`);
|
||||
}
|
||||
});
|
||||
|
||||
export const env = {
|
||||
token: process.env.DISCORD_TOKEN ?? '',
|
||||
clientId: process.env.DISCORD_CLIENT_ID ?? '',
|
||||
guildId: process.env.DISCORD_GUILD_ID ?? '',
|
||||
guildIds: (process.env.DISCORD_GUILD_IDS ?? process.env.DISCORD_GUILD_ID ?? '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
databaseUrl: process.env.DATABASE_URL ?? '',
|
||||
port: Number(process.env.PORT ?? 3000),
|
||||
sessionSecret: process.env.SESSION_SECRET ?? 'papo_dev_secret'
|
||||
};
|
||||
8
src/config/state.ts
Normal file
8
src/config/state.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export interface GuildSettings {
|
||||
welcomeChannelId?: string;
|
||||
logChannelId?: string;
|
||||
automodEnabled?: boolean;
|
||||
levelingEnabled?: boolean;
|
||||
}
|
||||
|
||||
export const settings = new Map<string, GuildSettings>();
|
||||
2
src/database/index.ts
Normal file
2
src/database/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
export const prisma = new PrismaClient();
|
||||
32
src/database/schema.prisma
Normal file
32
src/database/schema.prisma
Normal file
@@ -0,0 +1,32 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model Ticket {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
channelId String
|
||||
guildId String
|
||||
topic String?
|
||||
priority String @default("normal")
|
||||
status String
|
||||
claimedBy String?
|
||||
transcript String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Level {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
guildId String
|
||||
xp Int @default(0)
|
||||
level Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
12
src/events/guildBanAdd.ts
Normal file
12
src/events/guildBanAdd.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { GuildBan } from 'discord.js';
|
||||
import { EventHandler } from '../utils/types.js';
|
||||
import { context } from '../config/context.js';
|
||||
|
||||
const event: EventHandler = {
|
||||
name: 'guildBanAdd',
|
||||
execute(ban: GuildBan) {
|
||||
context.logging.logAction(ban.user, 'Ban');
|
||||
}
|
||||
};
|
||||
|
||||
export default event;
|
||||
16
src/events/guildCreate.ts
Normal file
16
src/events/guildCreate.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Guild } from 'discord.js';
|
||||
import { EventHandler } from '../utils/types.js';
|
||||
import { context } from '../config/context.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
const event: EventHandler = {
|
||||
name: 'guildCreate',
|
||||
async execute(guild: Guild) {
|
||||
logger.info(`Joined new guild: ${guild.name} (${guild.id})`);
|
||||
if (context.commandHandler) {
|
||||
await context.commandHandler.registerGuildCommands(guild.id);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default event;
|
||||
20
src/events/guildMemberAdd.ts
Normal file
20
src/events/guildMemberAdd.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { GuildMember } from 'discord.js';
|
||||
import { EventHandler } from '../utils/types.js';
|
||||
import { context } from '../config/context.js';
|
||||
import { settings } from '../config/state.js';
|
||||
|
||||
const event: EventHandler = {
|
||||
name: 'guildMemberAdd',
|
||||
execute(member: GuildMember) {
|
||||
const guildConfig = settings.get(member.guild.id);
|
||||
if (guildConfig?.welcomeChannelId) {
|
||||
const channel = member.guild.channels.cache.get(guildConfig.welcomeChannelId);
|
||||
if (channel && channel.isTextBased()) {
|
||||
channel.send({ content: `Willkommen ${member}!` }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
context.logging.logMemberJoin(member);
|
||||
}
|
||||
};
|
||||
|
||||
export default event;
|
||||
12
src/events/guildMemberRemove.ts
Normal file
12
src/events/guildMemberRemove.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { GuildMember } from 'discord.js';
|
||||
import { EventHandler } from '../utils/types.js';
|
||||
import { context } from '../config/context.js';
|
||||
|
||||
const event: EventHandler = {
|
||||
name: 'guildMemberRemove',
|
||||
execute(member: GuildMember) {
|
||||
context.logging.logMemberLeave(member);
|
||||
}
|
||||
};
|
||||
|
||||
export default event;
|
||||
21
src/events/interactionCreate.ts
Normal file
21
src/events/interactionCreate.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Interaction } from 'discord.js';
|
||||
import { EventHandler } from '../utils/types.js';
|
||||
import { context } from '../config/context.js';
|
||||
|
||||
const event: EventHandler = {
|
||||
name: 'interactionCreate',
|
||||
async execute(interaction: Interaction) {
|
||||
if (interaction.isChatInputCommand()) {
|
||||
const handler = context.commandHandler;
|
||||
if (!handler) return;
|
||||
await handler.handleInteraction(interaction);
|
||||
return;
|
||||
}
|
||||
|
||||
if (interaction.isButton()) {
|
||||
await context.tickets.handleButton(interaction);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default event;
|
||||
13
src/events/messageCreate.ts
Normal file
13
src/events/messageCreate.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Message } from 'discord.js';
|
||||
import { EventHandler } from '../utils/types.js';
|
||||
import { context } from '../config/context.js';
|
||||
|
||||
const event: EventHandler = {
|
||||
name: 'messageCreate',
|
||||
async execute(message: Message) {
|
||||
context.automod.checkMessage(message);
|
||||
context.leveling.handleMessage(message);
|
||||
}
|
||||
};
|
||||
|
||||
export default event;
|
||||
12
src/events/messageDelete.ts
Normal file
12
src/events/messageDelete.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Message } from 'discord.js';
|
||||
import { EventHandler } from '../utils/types.js';
|
||||
import { context } from '../config/context.js';
|
||||
|
||||
const event: EventHandler = {
|
||||
name: 'messageDelete',
|
||||
execute(message: Message) {
|
||||
context.logging.logMessageDelete(message);
|
||||
}
|
||||
};
|
||||
|
||||
export default event;
|
||||
13
src/events/messageUpdate.ts
Normal file
13
src/events/messageUpdate.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Message } from 'discord.js';
|
||||
import { EventHandler } from '../utils/types.js';
|
||||
import { context } from '../config/context.js';
|
||||
|
||||
const event: EventHandler = {
|
||||
name: 'messageUpdate',
|
||||
execute(oldMessage: Message | null, newMessage: Message) {
|
||||
if (!oldMessage) return;
|
||||
context.logging.logMessageEdit(oldMessage, newMessage);
|
||||
}
|
||||
};
|
||||
|
||||
export default event;
|
||||
16
src/events/ready.ts
Normal file
16
src/events/ready.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Client } from 'discord.js';
|
||||
import { EventHandler } from '../utils/types.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { env } from '../config/env.js';
|
||||
|
||||
const event: EventHandler = {
|
||||
name: 'ready',
|
||||
once: true,
|
||||
execute(client: Client) {
|
||||
logger.info(`Bot eingeloggt als ${client.user?.tag}`);
|
||||
client.user?.setPresence({ activities: [{ name: 'Papo | /help', type: 0 }], status: 'online' });
|
||||
logger.info(`Ready on ${client.guilds.cache.size} Guilds`);
|
||||
}
|
||||
};
|
||||
|
||||
export default event;
|
||||
36
src/index.ts
Normal file
36
src/index.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Client, GatewayIntentBits, Partials } from 'discord.js';
|
||||
import { env } from './config/env.js';
|
||||
import { CommandHandler } from './services/commandHandler.js';
|
||||
import { EventHandlerService } from './services/eventHandler.js';
|
||||
import { logger } from './utils/logger.js';
|
||||
import { context } from './config/context.js';
|
||||
import { createWebServer } from './web/server.js';
|
||||
|
||||
async function bootstrap() {
|
||||
const client = new Client({
|
||||
intents: [
|
||||
GatewayIntentBits.Guilds,
|
||||
GatewayIntentBits.GuildMembers,
|
||||
GatewayIntentBits.GuildMessages,
|
||||
GatewayIntentBits.MessageContent,
|
||||
GatewayIntentBits.GuildVoiceStates
|
||||
],
|
||||
partials: [Partials.Channel, Partials.GuildMember, Partials.Message]
|
||||
});
|
||||
|
||||
const commandHandler = new CommandHandler(client);
|
||||
context.commandHandler = commandHandler;
|
||||
|
||||
await commandHandler.loadCommands();
|
||||
await commandHandler.registerSlashCommands();
|
||||
|
||||
const eventHandler = new EventHandlerService(client);
|
||||
await eventHandler.loadEvents();
|
||||
|
||||
client.login(env.token);
|
||||
|
||||
const app = createWebServer();
|
||||
app.listen(env.port, () => logger.info(`Webserver läuft auf Port ${env.port}`));
|
||||
}
|
||||
|
||||
bootstrap().catch((err) => logger.error('Bootstrap failed', err));
|
||||
49
src/services/automodService.ts
Normal file
49
src/services/automodService.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
94
src/services/commandHandler.ts
Normal file
94
src/services/commandHandler.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
36
src/services/eventHandler.ts
Normal file
36
src/services/eventHandler.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
17
src/services/forumService.ts
Normal file
17
src/services/forumService.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
39
src/services/levelService.ts
Normal file
39
src/services/levelService.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
78
src/services/loggingService.ts
Normal file
78
src/services/loggingService.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
155
src/services/musicService.ts
Normal file
155
src/services/musicService.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
213
src/services/ticketService.ts
Normal file
213
src/services/ticketService.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
5
src/utils/logger.ts
Normal file
5
src/utils/logger.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const logger = {
|
||||
info: (msg: string) => console.log(`[INFO] ${msg}`),
|
||||
warn: (msg: string) => console.warn(`[WARN] ${msg}`),
|
||||
error: (msg: string, err?: unknown) => console.error(`[ERROR] ${msg}`, err)
|
||||
};
|
||||
43
src/utils/types.ts
Normal file
43
src/utils/types.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { SlashCommandBuilder, ChatInputCommandInteraction, PermissionResolvable, Client } from 'discord.js';
|
||||
|
||||
export interface SlashCommand {
|
||||
data: SlashCommandBuilder;
|
||||
execute: (interaction: ChatInputCommandInteraction, client: Client) => Promise<void>;
|
||||
cooldown?: number;
|
||||
requiredPermissions?: PermissionResolvable[];
|
||||
guildOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface EventHandler {
|
||||
name: string;
|
||||
once?: boolean;
|
||||
execute: (...args: any[]) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export interface TicketRecord {
|
||||
id: string;
|
||||
userId: string;
|
||||
channelId: string;
|
||||
guildId: string;
|
||||
topic?: string;
|
||||
priority?: 'low' | 'normal' | 'high';
|
||||
status: 'open' | 'in-progress' | 'closed';
|
||||
claimedBy?: string;
|
||||
transcript?: string;
|
||||
}
|
||||
|
||||
export interface ForumUser {
|
||||
discordId: string;
|
||||
forumUserId: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
export interface ForumRoleSync {
|
||||
discordRoleId: string;
|
||||
forumRoleId: string;
|
||||
}
|
||||
|
||||
export interface ForumTicketLink {
|
||||
ticketId: string;
|
||||
forumThreadId?: string;
|
||||
}
|
||||
47
src/web/routes/api.ts
Normal file
47
src/web/routes/api.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Router } from 'express';
|
||||
import { prisma } from '../../database/index.js';
|
||||
import { settings } from '../../config/state.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/overview', async (_req, res) => {
|
||||
try {
|
||||
const [open, inProgress, closed] = await Promise.all([
|
||||
prisma.ticket.count({ where: { status: 'open' } }),
|
||||
prisma.ticket.count({ where: { status: 'in-progress' } }),
|
||||
prisma.ticket.count({ where: { status: 'closed' } })
|
||||
]);
|
||||
res.json({ tickets: { open, inProgress, closed } });
|
||||
} catch (err) {
|
||||
res.json({ tickets: { open: 0, inProgress: 0, closed: 0 }, error: 'DB unavailable' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/tickets', async (_req, res) => {
|
||||
try {
|
||||
const tickets = await prisma.ticket.findMany({ orderBy: { createdAt: 'desc' }, take: 20 });
|
||||
res.json({ tickets });
|
||||
} catch (err) {
|
||||
res.json({ tickets: [], error: 'DB unavailable' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/settings', (_req, res) => {
|
||||
res.json({ guilds: Array.from(settings.entries()) });
|
||||
});
|
||||
|
||||
router.post('/settings', (req, res) => {
|
||||
const { guildId, welcomeChannelId, logChannelId, automodEnabled, levelingEnabled } = req.body;
|
||||
if (!guildId) return res.status(400).json({ error: 'guildId required' });
|
||||
const current = settings.get(guildId) ?? {};
|
||||
settings.set(guildId, {
|
||||
...current,
|
||||
welcomeChannelId: welcomeChannelId ?? current.welcomeChannelId,
|
||||
logChannelId: logChannelId ?? current.logChannelId,
|
||||
automodEnabled: automodEnabled ?? current.automodEnabled,
|
||||
levelingEnabled: levelingEnabled ?? current.levelingEnabled
|
||||
});
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
export default router;
|
||||
21
src/web/routes/auth.ts
Normal file
21
src/web/routes/auth.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Router } from 'express';
|
||||
import { env } from '../../config/env.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/discord', (_req, res) => {
|
||||
const redirect = encodeURIComponent('http://localhost:' + env.port + '/auth/callback');
|
||||
const url =
|
||||
`https://discord.com/api/oauth2/authorize?client_id=${env.clientId}` +
|
||||
`&redirect_uri=${redirect}&response_type=code&scope=identify%20guilds`;
|
||||
res.redirect(url);
|
||||
});
|
||||
|
||||
router.get('/callback', (req, res) => {
|
||||
const code = req.query.code;
|
||||
if (!code) return res.status(400).send('No code provided');
|
||||
// TODO: exchange code via Discord OAuth2 token endpoint
|
||||
res.send('OAuth2 Callback erhalten (Stub).');
|
||||
});
|
||||
|
||||
export default router;
|
||||
110
src/web/routes/dashboard.ts
Normal file
110
src/web/routes/dashboard.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { Router } from 'express';
|
||||
import { settings } from '../../config/state.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', (_req, res) => {
|
||||
res.send(`
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Papo Dashboard</title>
|
||||
<style>
|
||||
:root { --bg:#0f172a; --card:#111827; --text:#e5e7eb; --accent:#22c55e; --muted:#9ca3af; }
|
||||
body { margin:0; font-family: 'Inter', system-ui, -apple-system, sans-serif; background:radial-gradient(circle at 10% 20%, #1f2937 0, transparent 25%), radial-gradient(circle at 80% 0, #0ea5e9 0, transparent 25%), var(--bg); color:var(--text); }
|
||||
header { padding:24px 32px; display:flex; justify-content:space-between; align-items:center; }
|
||||
h1 { margin:0; font-size:24px; letter-spacing:0.5px; }
|
||||
main { padding:0 32px 48px; display:grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap:16px; }
|
||||
.card { background:var(--card); border:1px solid rgba(255,255,255,0.05); border-radius:14px; padding:16px 18px; box-shadow:0 10px 30px rgba(0,0,0,0.25); }
|
||||
.stat { font-size:32px; font-weight:700; margin:0; }
|
||||
.label { color:var(--muted); margin:4px 0 0 0; }
|
||||
.ticket-list { max-height:320px; overflow:auto; }
|
||||
.ticket { padding:10px; border-radius:10px; background:rgba(255,255,255,0.02); margin-bottom:8px; border:1px solid rgba(255,255,255,0.04); }
|
||||
.pill { display:inline-block; padding:4px 8px; border-radius:999px; font-size:12px; font-weight:600; }
|
||||
.pill.open { background:rgba(34,197,94,0.15); color:#22c55e; }
|
||||
.pill.in-progress { background:rgba(234,179,8,0.15); color:#eab308; }
|
||||
.pill.closed { background:rgba(239,68,68,0.15); color:#ef4444; }
|
||||
form { display:flex; flex-direction:column; gap:8px; margin-top:8px; }
|
||||
input, select { padding:10px 12px; border-radius:10px; border:1px solid rgba(255,255,255,0.08); background:rgba(255,255,255,0.05); color:var(--text); }
|
||||
button { padding:10px 12px; border-radius:10px; border:none; background:linear-gradient(135deg, #10b981, #22c55e); color:white; font-weight:700; cursor:pointer; }
|
||||
button:hover { filter:brightness(1.05); }
|
||||
.muted { color:var(--muted); font-size:13px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Papo Control</h1>
|
||||
<span class="muted">Live Overview & Tickets</span>
|
||||
</header>
|
||||
<main>
|
||||
<section class="card">
|
||||
<p class="label">Tickets offen</p>
|
||||
<p class="stat" id="openCount">-</p>
|
||||
<p class="label">in-progress / geschlossen: <span id="ipCount">-</span> / <span id="closedCount">-</span></p>
|
||||
</section>
|
||||
<section class="card ticket-list">
|
||||
<p class="label">Neueste Tickets</p>
|
||||
<div id="ticketList"></div>
|
||||
</section>
|
||||
<section class="card">
|
||||
<p class="label">Einstellungen speichern</p>
|
||||
<form id="settingsForm">
|
||||
<input name="guildId" placeholder="Guild ID" required />
|
||||
<input name="welcomeChannelId" placeholder="Welcome Channel ID" />
|
||||
<input name="logChannelId" placeholder="Log Channel ID" />
|
||||
<select name="automodEnabled">
|
||||
<option value="">Automod unverändert</option>
|
||||
<option value="true">Automod an</option>
|
||||
<option value="false">Automod aus</option>
|
||||
</select>
|
||||
<select name="levelingEnabled">
|
||||
<option value="">Leveling unverändert</option>
|
||||
<option value="true">Leveling an</option>
|
||||
<option value="false">Leveling aus</option>
|
||||
</select>
|
||||
<button type="submit">Speichern</button>
|
||||
<p class="muted" id="saveStatus"></p>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
<script>
|
||||
async function loadOverview() {
|
||||
const res = await fetch('/api/overview'); const data = await res.json();
|
||||
document.getElementById('openCount').textContent = data.tickets?.open ?? '-';
|
||||
document.getElementById('ipCount').textContent = data.tickets?.inProgress ?? '-';
|
||||
document.getElementById('closedCount').textContent = data.tickets?.closed ?? '-';
|
||||
}
|
||||
async function loadTickets() {
|
||||
const res = await fetch('/api/tickets'); const data = await res.json();
|
||||
const box = document.getElementById('ticketList'); box.innerHTML = '';
|
||||
(data.tickets || []).forEach(t => {
|
||||
const div = document.createElement('div'); div.className = 'ticket';
|
||||
div.innerHTML = \`<div><strong>\${t.topic || 'Ticket'}</strong> — \${t.userId}</div>
|
||||
<div class="muted">#\${t.channelId} • Priorität: \${t.priority || 'normal'}</div>
|
||||
<div class="pill \${(t.status||'open').replace(/\\\\s/g,'-')}">\${t.status}</div>\`;
|
||||
box.appendChild(div);
|
||||
});
|
||||
}
|
||||
document.getElementById('settingsForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const form = new FormData(e.currentTarget);
|
||||
const payload = Object.fromEntries(form.entries());
|
||||
['automodEnabled','levelingEnabled'].forEach(k => { if (payload[k]==='') delete payload[k]; });
|
||||
const res = await fetch('/api/settings', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) });
|
||||
document.getElementById('saveStatus').textContent = res.ok ? 'Gespeichert' : 'Fehler';
|
||||
});
|
||||
loadOverview(); loadTickets();
|
||||
setInterval(loadOverview, 10000); setInterval(loadTickets, 12000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
});
|
||||
|
||||
router.get('/settings', (_req, res) => {
|
||||
res.json({ settings: Array.from(settings.entries()) });
|
||||
});
|
||||
|
||||
export default router;
|
||||
32
src/web/server.ts
Normal file
32
src/web/server.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import express from 'express';
|
||||
import session from 'express-session';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import path from 'path';
|
||||
import authRouter from './routes/auth.js';
|
||||
import dashboardRouter from './routes/dashboard.js';
|
||||
import apiRouter from './routes/api.js';
|
||||
import { env } from '../config/env.js';
|
||||
|
||||
export function createWebServer() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use(
|
||||
session({
|
||||
secret: env.sessionSecret,
|
||||
resave: false,
|
||||
saveUninitialized: false
|
||||
})
|
||||
);
|
||||
|
||||
app.use('/auth', authRouter);
|
||||
app.use('/dashboard', dashboardRouter);
|
||||
app.use('/api', apiRouter);
|
||||
|
||||
app.get('/', (_req, res) => {
|
||||
res.send(`<h1>Papo Dashboard</h1><p><a href="/dashboard">Zum Dashboard</a></p>`);
|
||||
});
|
||||
|
||||
app.use('/static', express.static(path.join(process.cwd(), 'static')));
|
||||
return app;
|
||||
}
|
||||
Reference in New Issue
Block a user