diff --git a/commands/add.js b/commands/add.js new file mode 100644 index 0000000..b1ec647 --- /dev/null +++ b/commands/add.js @@ -0,0 +1,178 @@ +import { + EmbedBuilder, + PermissionsBitField, + SlashCommandBuilder, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, +} from "discord.js"; +import fs from "fs/promises"; +import { Spiget } from "spiget"; +import { + generateAvatarLink, + generateAuthorURL, + generateResourceIconURL, +} from "../util/helpers.js"; + +const spiget = new Spiget("Viper-Network"); +export default { + name: "add", + description: "Richtet einen Listener ein, der Plugin-Updates in einem bestimmten Kanal postet", + aliases: [], + guild: ["all"], + nsfw: false, + user_permissions: [PermissionsBitField.Flags.Administrator], + bot_permissions: [], + args_required: 2, + args_usage: "[ressourcen_id] [kanal] Beispiel: vn!add 72678 #ankündigungen", + cooldown: 5, + + data: new SlashCommandBuilder() + .setName("add") + .setDescription("Richtet einen Listener für Plugin-Updates ein") + .addStringOption((opt) => + opt.setName("ressourcen_id").setDescription("Spiget Ressourcen-ID").setRequired(true) + ) + .addChannelOption((opt) => + opt.setName("kanal").setDescription("Kanal für Update-Benachrichtigungen").setRequired(true) + ), + + async execute(client, ctx, args) { + const resourceArg = ctx.isSlash + ? ctx.interaction.options.getString("ressourcen_id") + : args[0]; + + const channel = ctx.isSlash + ? ctx.interaction.options.getChannel("kanal") + : ctx.mentions?.channels?.first(); + + if (!channel) { + return ctx.reply("Dieser Kanal ist ungültig. Bitte erwähne einen Kanal auf diesem Server."); + } + if (!ctx.guild.channels.cache.has(channel.id)) { + return ctx.reply("Dieser Kanal befindet sich nicht auf diesem Server!"); + } + + let resource; + try { + resource = await spiget.getResource(resourceArg); + } catch (e) { + client.logger.error(e); + return ctx.reply(`Ups! \`${resourceArg}\` ist keine gültige Ressourcen-ID!`); + } + + let author; + try { + author = await resource.getAuthor(); + } catch (e) { + client.logger.error(e); + return ctx.reply(`Ups! Der Autor für Ressource \`${resourceArg}\` konnte nicht gefunden werden.`); + } + + let latestVersion; + try { + const res = await fetch(`https://api.spigotmc.org/legacy/update.php?resource=${resourceArg}`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + latestVersion = (await res.text()).trim(); + } catch (e) { + client.logger.error(e); + return ctx.reply("Die neueste Version konnte nicht von SpigotMC abgerufen werden. Bitte versuche es erneut."); + } + + const guildID = ctx.guild.id; + const filePath = `./serverdata/${guildID}.json`; + + // Check existing data & limits + let saveData = { watchedResources: [] }; + try { + const raw = await fs.readFile(filePath, "utf8"); + saveData = JSON.parse(raw); + + const duplicate = saveData.watchedResources.find((r) => r.resourceID == resource.id); + if (duplicate) { + return ctx.reply(`Diese Ressource wird bereits in <#${duplicate.channelID}> beobachtet.`); + } + + + } catch { + // Datei existiert noch nicht + } + + const authorURL = generateAuthorURL(author.name, author.id); + const authorAvatarURL = generateAvatarLink(author.id); + const resourceIconURL = generateResourceIconURL(resource); + const resourceURL = `https://spigotmc.org/resources/.${resourceArg}/`; + + // Build confirmation embed with buttons + const confirmEmbed = new EmbedBuilder() + .setAuthor({ name: `Autor: ${author.name}`, iconURL: authorAvatarURL, url: authorURL }) + .setColor(ctx.guild.members.me.displayHexColor) + .setTitle(`Plugin beobachten: ${resource.name}`) + .setDescription(`${resource.tag}\n\nSoll dieses Plugin in <#${channel.id}> beobachtet werden?`) + .addFields([ + { name: "Kanal", value: `<#${channel.id}>`, inline: true }, + { name: "Version", value: latestVersion, inline: true }, + { name: "Download", value: resourceURL, inline: false }, + ]) + .setThumbnail(resourceIconURL) + .setTimestamp(); + + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId("add_confirm") + .setLabel("✅ Bestätigen") + .setStyle(ButtonStyle.Success), + new ButtonBuilder() + .setCustomId("add_cancel") + .setLabel("❌ Abbrechen") + .setStyle(ButtonStyle.Danger) + ); + + const confirmMsg = await ctx.reply({ embeds: [confirmEmbed], components: [row], fetchReply: true }); + + // Wait for button click (30 seconds) + let btnInteraction; + try { + btnInteraction = await confirmMsg.awaitMessageComponent({ + filter: (i) => i.user.id === ctx.author.id, + time: 30_000, + }); + } catch { + // Timeout + const timeoutEmbed = EmbedBuilder.from(confirmEmbed) + .setColor("#808080") + .setDescription("⏱️ Zeit abgelaufen – Befehl wurde abgebrochen."); + return confirmMsg.edit({ embeds: [timeoutEmbed], components: [] }); + } + + if (btnInteraction.customId === "add_cancel") { + const cancelEmbed = EmbedBuilder.from(confirmEmbed) + .setColor("#FF0000") + .setDescription("❌ Abgebrochen."); + return btnInteraction.update({ embeds: [cancelEmbed], components: [] }); + } + + // Confirmed – save data + saveData.watchedResources.push({ + resourceID: resource.id, + resourceName: resource.name, + channelID: channel.id, + lastCheckedVersion: latestVersion, + }); + + try { + await fs.mkdir("./serverdata", { recursive: true }); + await fs.writeFile(filePath, JSON.stringify(saveData, null, 2)); + } catch (e) { + client.logger.error(e); + return btnInteraction.update({ content: "Beim Speichern der Beobachtungsdaten ist ein Fehler aufgetreten.", components: [] }); + } + + const successEmbed = EmbedBuilder.from(confirmEmbed) + .setTitle(`✅ Wird jetzt beobachtet: ${resource.name}`) + .setDescription(resource.tag) + .setColor("#00FF00"); + + return btnInteraction.update({ embeds: [successEmbed], components: [] }); + }, +}; \ No newline at end of file diff --git a/commands/addauthor.js b/commands/addauthor.js new file mode 100644 index 0000000..4f8ba1d --- /dev/null +++ b/commands/addauthor.js @@ -0,0 +1,216 @@ +import { + EmbedBuilder, + PermissionsBitField, + SlashCommandBuilder, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, +} from "discord.js"; +import fs from "fs/promises"; + +export default { + name: "addauthor", + description: "Fügt alle Plugins eines SpigotMC-Autors zur Beobachtungsliste hinzu", + aliases: ["addall"], + guild: ["all"], + nsfw: false, + user_permissions: [PermissionsBitField.Flags.Administrator], + bot_permissions: [], + args_required: 2, + args_usage: "[author_id] [#kanal] Beispiel: vn!addauthor 618600 #plugin-updates", + cooldown: 10, + + data: new SlashCommandBuilder() + .setName("addauthor") + .setDescription("Fügt alle Plugins eines SpigotMC-Autors zur Beobachtungsliste hinzu") + .addStringOption((opt) => + opt + .setName("author_id") + .setDescription("SpigotMC Autor-ID (aus der Profil-URL)") + .setRequired(true) + ) + .addChannelOption((opt) => + opt + .setName("kanal") + .setDescription("Kanal für Update-Benachrichtigungen") + .setRequired(true) + ), + + async execute(client, ctx, args) { + const authorID = ctx.isSlash + ? ctx.interaction.options.getString("author_id") + : args[0]; + + const channel = ctx.isSlash + ? ctx.interaction.options.getChannel("kanal") + : ctx.mentions?.channels?.first(); + + if (!channel) { + return ctx.reply("Dieser Kanal ist ungültig. Bitte erwähne einen Kanal auf diesem Server."); + } + if (!ctx.guild.channels.cache.has(channel.id)) { + return ctx.reply("Dieser Kanal befindet sich nicht auf diesem Server!"); + } + + // Fetch author info + let authorName = authorID; + try { + const res = await fetch(`https://api.spiget.org/v2/authors/${authorID}`); + if (res.ok) { + const data = await res.json(); + authorName = data.name ?? authorID; + } + } catch { /* ignore */ } + + // Fetch all resources by author + let resources; + try { + const res = await fetch( + `https://api.spiget.org/v2/authors/${authorID}/resources?size=50&sort=-updateDate` + ); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + resources = await res.json(); + } catch (e) { + client.logger.error(e); + return ctx.reply("Ressourcen konnten nicht von der Spiget-API abgerufen werden. Bitte versuche es erneut."); + } + + if (!Array.isArray(resources) || resources.length === 0) { + return ctx.reply(`Für Autor \`${authorID}\` wurden keine Ressourcen gefunden.`); + } + + const guildID = ctx.guild.id; + const filePath = `./serverdata/${guildID}.json`; + + // Load existing data + let saveData = { watchedResources: [] }; + try { + const raw = await fs.readFile(filePath, "utf8"); + saveData = JSON.parse(raw); + } catch { /* Datei existiert noch nicht */ } + + // Filter out already watched and check limit + const alreadyWatched = resources.filter((r) => + saveData.watchedResources.some((w) => String(w.resourceID) === String(r.id)) + ); + const toAdd = resources.filter((r) => + !saveData.watchedResources.some((w) => String(w.resourceID) === String(r.id)) + ); + + const willAdd = toAdd; + + if (willAdd.length === 0 && alreadyWatched.length > 0) { + return ctx.reply( + `Alle ${alreadyWatched.length} Ressourcen von **${authorName}** werden bereits beobachtet.` + ); + } + + if (willAdd.length === 0) { + return ctx.reply("Es gibt keine neuen Ressourcen zum Hinzufügen."); + } + + // Build confirmation embed + const resourceList = willAdd + .map((r) => `• **${r.name}** (\`${r.id}\`)`) + .join("\n"); + + const warnings = []; + if (alreadyWatched.length > 0) + warnings.push(`⚠️ ${alreadyWatched.length} bereits beobachtet (übersprungen)`); + + const confirmEmbed = new EmbedBuilder() + .setColor(ctx.guild.members.me.displayHexColor) + .setTitle(`📦 Alle Plugins von ${authorName} beobachten`) + .setDescription( + `Folgende **${willAdd.length}** Ressourcen werden in <#${channel.id}> beobachtet:\n\n${resourceList}` + + (warnings.length > 0 ? `\n\n${warnings.join("\n")}` : "") + ) + .setFooter({ text: `Autor-ID: ${authorID} • SpigotMC` }) + .setTimestamp(); + + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId("addauthor_confirm") + .setLabel(`✅ Alle ${willAdd.length} hinzufügen`) + .setStyle(ButtonStyle.Success), + new ButtonBuilder() + .setCustomId("addauthor_cancel") + .setLabel("❌ Abbrechen") + .setStyle(ButtonStyle.Danger) + ); + + const confirmMsg = await ctx.reply({ embeds: [confirmEmbed], components: [row], fetchReply: true }); + + // Wait for button click (30 seconds) + let btnInteraction; + try { + btnInteraction = await confirmMsg.awaitMessageComponent({ + filter: (i) => i.user.id === ctx.author.id, + time: 30_000, + }); + } catch { + const timeoutEmbed = EmbedBuilder.from(confirmEmbed) + .setColor("#808080") + .setDescription("⏱️ Zeit abgelaufen – Befehl wurde abgebrochen."); + return confirmMsg.edit({ embeds: [timeoutEmbed], components: [] }); + } + + if (btnInteraction.customId === "addauthor_cancel") { + const cancelEmbed = EmbedBuilder.from(confirmEmbed) + .setColor("#FF0000") + .setDescription("❌ Abgebrochen."); + return btnInteraction.update({ embeds: [cancelEmbed], components: [] }); + } + + // Defer immediately – version fetching can take longer than 3 seconds + await btnInteraction.deferUpdate(); + + // Fetch latest versions and save + const added = []; + const failed = []; + + for (const resource of willAdd) { + let latestVersion = "unbekannt"; + try { + const res = await fetch(`https://api.spigotmc.org/legacy/update.php?resource=${resource.id}`); + if (res.ok) latestVersion = (await res.text()).trim(); + } catch { /* ignore – speichern ohne Version */ } + + saveData.watchedResources.push({ + resourceID: resource.id, + resourceName: resource.name, + channelID: channel.id, + lastCheckedVersion: latestVersion, + }); + + added.push(`✅ **${resource.name}** (v${latestVersion})`); + + // Rate limiting + await new Promise((r) => setTimeout(r, 300)); + } + + try { + await fs.mkdir("./serverdata", { recursive: true }); + await fs.writeFile(filePath, JSON.stringify(saveData, null, 2)); + } catch (e) { + client.logger.error(e); + return btnInteraction.editReply({ + content: "Beim Speichern der Daten ist ein Fehler aufgetreten.", + components: [], + }); + } + + const successEmbed = new EmbedBuilder() + .setColor("#00FF00") + .setTitle(`✅ ${added.length} Plugins von ${authorName} werden jetzt beobachtet`) + .setDescription(added.join("\n")) + .addFields([ + { name: "📢 Kanal", value: `<#${channel.id}>`, inline: true }, + { name: "📊 Gesamt", value: `${saveData.watchedResources.length} beobachtet`, inline: true }, + ]) + .setFooter({ text: `Autor-ID: ${authorID}` }) + .setTimestamp(); + + return btnInteraction.editReply({ embeds: [successEmbed], components: [] }); + }, +}; \ No newline at end of file diff --git a/commands/changelog.js b/commands/changelog.js new file mode 100644 index 0000000..c2a788d --- /dev/null +++ b/commands/changelog.js @@ -0,0 +1,90 @@ +import { EmbedBuilder, SlashCommandBuilder } from "discord.js"; +import { formatText } from "../util/helpers.js"; + +export default { + name: "changelog", + description: "Zeigt die letzten Update-Beschreibungen eines Plugins an", + aliases: ["cl"], + guild: ["all"], + nsfw: false, + user_permissions: [], + bot_permissions: [], + args_required: 1, + args_usage: "[ressourcen_id] [anzahl] Beispiel: vn!changelog 72678 5", + cooldown: 5, + + data: new SlashCommandBuilder() + .setName("changelog") + .setDescription("Zeigt die letzten Updates eines Plugins") + .addStringOption((opt) => + opt.setName("ressourcen_id").setDescription("Spiget Ressourcen-ID").setRequired(true) + ) + .addIntegerOption((opt) => + opt + .setName("anzahl") + .setDescription("Anzahl der Updates (1–10, Standard: 3)") + .setMinValue(1) + .setMaxValue(10) + .setRequired(false) + ), + + async execute(client, ctx, args) { + const resourceID = ctx.isSlash + ? ctx.interaction.options.getString("ressourcen_id") + : args[0]; + + let count = ctx.isSlash + ? (ctx.interaction.options.getInteger("anzahl") ?? 3) + : (parseInt(args[1], 10) || 3); + + count = Math.min(Math.max(count, 1), 10); + + // Fetch resource name + let resourceName = resourceID; + try { + const resInfo = await fetch(`https://api.spiget.org/v2/resources/${resourceID}`); + if (resInfo.ok) { + const data = await resInfo.json(); + resourceName = data.name ?? resourceID; + } + } catch { /* ignore */ } + + // Fetch updates list + let updates; + try { + const res = await fetch( + `https://api.spiget.org/v2/resources/${resourceID}/updates?size=${count}&sort=-date` + ); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + updates = await res.json(); + } catch (e) { + client.logger.error(e); + return ctx.reply("Update-Verlauf konnte nicht abgerufen werden. Bitte versuche es erneut."); + } + + if (!Array.isArray(updates) || updates.length === 0) { + return ctx.reply(`Für Ressource \`${resourceID}\` wurden keine Updates gefunden.`); + } + + const fields = updates.map((u, i) => { + const date = new Date(u.date * 1000).toLocaleDateString("de-DE"); + let desc = formatText(Buffer.from(u.description, "base64").toString("utf8")); + if (desc.length > 300) desc = desc.substring(0, 297) + "..."; + return { + name: `${i + 1}. ${u.title ?? "Update"} – v${u.likes ?? ""} (${date})`, + value: desc || "Keine Beschreibung.", + inline: false, + }; + }); + + const changelogEmbed = new EmbedBuilder() + .setColor(ctx.guild.members.me.displayHexColor) + .setTitle(`📋 Changelog: ${resourceName}`) + .setDescription(`Die letzten **${updates.length}** Updates:`) + .addFields(fields) + .setFooter({ text: `Ressource ID: ${resourceID}` }) + .setTimestamp(); + + return ctx.reply({ embeds: [changelogEmbed] }); + }, +}; \ No newline at end of file diff --git a/commands/check.js b/commands/check.js new file mode 100644 index 0000000..11f3c6c --- /dev/null +++ b/commands/check.js @@ -0,0 +1,167 @@ +import { EmbedBuilder, SlashCommandBuilder } from "discord.js"; +import fs from "fs"; +import { t, getLang } from "../util/i18n.js"; +import { + generateAvatarLink, + generateAuthorURL, + generateResourceIconURL, +} from "../util/helpers.js"; +import { Spiget } from "spiget"; + +const spiget = new Spiget("Viper-Network"); + +export default { + name: "check", + description: "Prüft ob ein Plugin mit einer Minecraft-Version kompatibel ist", + aliases: [], + guild: ["all"], + nsfw: false, + user_permissions: [], + bot_permissions: [], + args_required: 1, + args_usage: "[ressourcen_id] [mc-version] Beispiel: vn!check 72678 1.21", + cooldown: 5, + + data: new SlashCommandBuilder() + .setName("check") + .setDescription("Checks if a plugin is compatible with a Minecraft version") + .addStringOption((opt) => + opt.setName("ressourcen_id").setDescription("Spiget Ressourcen-ID").setRequired(true) + ) + .addStringOption((opt) => + opt + .setName("mc_version") + .setDescription("Minecraft-Version (z.B. 1.21) – leer = aktuellste prüfen") + .setRequired(false) + ), + + async execute(client, ctx, args) { + const guildID = ctx.guild.id; + const lang = loadLang(guildID); + const resourceID = ctx.isSlash + ? ctx.interaction.options.getString("ressourcen_id") + : args[0]; + const mcVersion = ctx.isSlash + ? (ctx.interaction.options.getString("mc_version") ?? null) + : (args[1] ?? null); + + // Fetch resource details from Spiget + let resource, author; + try { + resource = await spiget.getResource(resourceID); + author = await resource.getAuthor(); + } catch { + return ctx.reply(t(lang, "error.invalidID", { id: resourceID })); + } + + // Fetch full resource data for tested versions + let data; + try { + const res = await fetch(`https://api.spiget.org/v2/resources/${resourceID}`); + if (!res.ok) throw new Error(); + data = await res.json(); + } catch { + return ctx.reply(t(lang, "error.apiDown")); + } + + // testedVersions is a string like "1.8 - 1.21" or array + const testedRaw = data.testedVersions ?? []; + const tested = Array.isArray(testedRaw) + ? testedRaw + : [testedRaw]; + + // Supported versions from tags/description often contains "1.x" + const supportedVersions = tested.length > 0 + ? tested.join(", ") + : "Keine Angabe"; + + // Determine compatibility for requested version + let compatStatus; + let compatColor; + + if (mcVersion) { + const matches = tested.some((v) => { + // Handle ranges like "1.8-1.21" or "1.8 - 1.21" + const rangeMatch = v.match(/(\d+\.\d+)\s*[-–]\s*(\d+\.\d+)/); + if (rangeMatch) { + const [, from, to] = rangeMatch; + return compareVersions(mcVersion, from) >= 0 && + compareVersions(mcVersion, to) <= 0; + } + // Exact match or starts-with + return v.trim().startsWith(mcVersion.trim()); + }); + + if (tested.length === 0) { + compatStatus = t(lang, "check.unknown"); + compatColor = "#FFA500"; + } else if (matches) { + compatStatus = t(lang, "check.compatible"); + compatColor = "#00FF00"; + } else { + compatStatus = t(lang, "check.incompatible"); + compatColor = "#FF0000"; + } + } else { + compatStatus = tested.length > 0 + ? `${t(lang, "check.compatible")} (${supportedVersions})` + : t(lang, "check.unknown"); + compatColor = tested.length > 0 ? "#00FF00" : "#FFA500"; + } + + const authorURL = generateAuthorURL(author.name, author.id); + const authorAvatarURL = generateAvatarLink(author.id); + const resourceIconURL = generateResourceIconURL(resource); + const resourceURL = `https://spigotmc.org/resources/.${resourceID}/`; + + const fields = [ + { + name: t(lang, "check.supportedVersions"), + value: supportedVersions, + inline: false, + }, + ]; + + if (mcVersion) { + fields.unshift({ + name: `Minecraft ${mcVersion}`, + value: compatStatus, + inline: false, + }); + } + + fields.push( + { name: "📦 Version", value: data.version?.id ?? "?", inline: true }, + { name: "⬇️ Download", value: resourceURL, inline: true } + ); + + const embed = new EmbedBuilder() + .setAuthor({ name: t(lang, "general.author", { name: author.name }), iconURL: authorAvatarURL, url: authorURL }) + .setColor(compatColor) + .setTitle(t(lang, "check.title", { name: resource.name })) + .setDescription(resource.tag) + .addFields(fields) + .setThumbnail(resourceIconURL) + .setTimestamp(); + + return ctx.reply({ embeds: [embed] }); + }, +}; + +/** Vergleicht zwei Versions-Strings (z.B. "1.8" und "1.21") */ +function compareVersions(a, b) { + const pa = a.split(".").map(Number); + const pb = b.split(".").map(Number); + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const diff = (pa[i] ?? 0) - (pb[i] ?? 0); + if (diff !== 0) return diff; + } + return 0; +} + +function loadLang(guildID) { + try { + const data = JSON.parse(fs.readFileSync(`./serverdata/${guildID}.json`, "utf8")); + return getLang(data); + } catch { return "de"; } +} \ No newline at end of file diff --git a/commands/compare.js b/commands/compare.js new file mode 100644 index 0000000..afec1f2 --- /dev/null +++ b/commands/compare.js @@ -0,0 +1,130 @@ +import { EmbedBuilder, SlashCommandBuilder } from "discord.js"; +import fs from "fs"; +import { t, getLang } from "../util/i18n.js"; +import { generateResourceIconURL } from "../util/helpers.js"; +import { Spiget } from "spiget"; + +const spiget = new Spiget("Viper-Network"); + +export default { + name: "compare", + description: "Vergleicht zwei Plugins nebeneinander", + aliases: ["cmp"], + guild: ["all"], + nsfw: false, + user_permissions: [], + bot_permissions: [], + args_required: 2, + args_usage: "[id1] [id2] Beispiel: vn!compare 72678 34315", + cooldown: 10, + + data: new SlashCommandBuilder() + .setName("compare") + .setDescription("Compares two plugins side by side") + .addStringOption((opt) => + opt.setName("id1").setDescription("Erste Ressourcen-ID").setRequired(true) + ) + .addStringOption((opt) => + opt.setName("id2").setDescription("Zweite Ressourcen-ID").setRequired(true) + ), + + async execute(client, ctx, args) { + const lang = loadLang(ctx.guild.id); + const id1 = ctx.isSlash ? ctx.interaction.options.getString("id1") : args[0]; + const id2 = ctx.isSlash ? ctx.interaction.options.getString("id2") : args[1]; + + // Fetch both resources in parallel + let [dataA, dataB] = await Promise.all([ + fetchResource(id1), + fetchResource(id2), + ]); + + if (!dataA) return ctx.reply(t(lang, "error.invalidID", { id: id1 })); + if (!dataB) return ctx.reply(t(lang, "error.invalidID", { id: id2 })); + + // Fetch latest versions + const [verA, verB] = await Promise.all([ + fetchVersion(id1), + fetchVersion(id2), + ]); + + dataA.latestVersion = verA; + dataB.latestVersion = verB; + + // Helper: format a stat with winner indicator + const win = (valA, valB, higherIsBetter = true) => { + if (valA === valB) return ["–", "–"]; + const aWins = higherIsBetter ? valA > valB : valA < valB; + return aWins ? ["🏆", ""] : ["", "🏆"]; + }; + + const [dlWinA, dlWinB] = win(dataA.downloads, dataB.downloads); + const [rtWinA, rtWinB] = win(dataA.rating?.average ?? 0, dataB.rating?.average ?? 0); + + const dateA = dataA.updateDate ? new Date(dataA.updateDate * 1000).toLocaleDateString("de-DE") : "?"; + const dateB = dataB.updateDate ? new Date(dataB.updateDate * 1000).toLocaleDateString("de-DE") : "?"; + const [dtWinA, dtWinB] = win(dataA.updateDate ?? 0, dataB.updateDate ?? 0); + + const dlA = (dataA.downloads ?? 0).toLocaleString("de-DE"); + const dlB = (dataB.downloads ?? 0).toLocaleString("de-DE"); + const rtA = dataA.rating?.average?.toFixed(1) ?? "–"; + const rtB = dataB.rating?.average?.toFixed(1) ?? "–"; + + const embed = new EmbedBuilder() + .setColor(ctx.guild.members.me.displayHexColor) + .setTitle(t(lang, "compare.title")) + .addFields([ + // Header row + { name: "📦 Plugin", value: `**${dataA.name}**`, inline: true }, + { name: "\u200b", value: "**vs**", inline: true }, + { name: "\u200b", value: `**${dataB.name}**`, inline: true }, + // Downloads + { name: t(lang, "compare.downloads"), value: `${dlWinA} ${dlA}`, inline: true }, + { name: "\u200b", value: "⬇️", inline: true }, + { name: "\u200b", value: `${dlWinB} ${dlB}`, inline: true }, + // Rating + { name: t(lang, "compare.rating"), value: `${rtWinA} ⭐ ${rtA}`, inline: true }, + { name: "\u200b", value: "⭐", inline: true }, + { name: "\u200b", value: `${rtWinB} ⭐ ${rtB}`, inline: true }, + // Version + { name: t(lang, "compare.version"), value: `v${verA ?? "?"}`, inline: true }, + { name: "\u200b", value: "📦", inline: true }, + { name: "\u200b", value: `v${verB ?? "?"}`, inline: true }, + // Last update + { name: t(lang, "compare.updated"), value: `${dtWinA} ${dateA}`, inline: true }, + { name: "\u200b", value: "🕐", inline: true }, + { name: "\u200b", value: `${dtWinB} ${dateB}`, inline: true }, + // Links + { name: "🔗 Link", value: `[SpigotMC](https://spigotmc.org/resources/.${id1}/)`, inline: true }, + { name: "\u200b", value: "\u200b", inline: true }, + { name: "\u200b", value: `[SpigotMC](https://spigotmc.org/resources/.${id2}/)`, inline: true }, + ]) + .setFooter({ text: `IDs: ${id1} vs ${id2}` }) + .setTimestamp(); + + return ctx.reply({ embeds: [embed] }); + }, +}; + +async function fetchResource(id) { + try { + const res = await fetch(`https://api.spiget.org/v2/resources/${id}`); + if (!res.ok) return null; + return await res.json(); + } catch { return null; } +} + +async function fetchVersion(id) { + try { + const res = await fetch(`https://api.spigotmc.org/legacy/update.php?resource=${id}`); + if (!res.ok) return null; + return (await res.text()).trim(); + } catch { return null; } +} + +function loadLang(guildID) { + try { + const data = JSON.parse(fs.readFileSync(`./serverdata/${guildID}.json`, "utf8")); + return getLang(data); + } catch { return "de"; } +} \ No newline at end of file diff --git a/commands/help.js b/commands/help.js new file mode 100644 index 0000000..25ccd7a --- /dev/null +++ b/commands/help.js @@ -0,0 +1,73 @@ +import { EmbedBuilder, SlashCommandBuilder } from "discord.js"; + +export default { + name: "help", + description: "Alle Befehle des Bots und Informationen zur Nutzung!", + aliases: ["commands", "cmds"], + guild: ["all"], + nsfw: false, + user_permissions: [], + bot_permissions: [], + args_required: 0, + args_usage: "[befehlsname]", + cooldown: 0, + + data: new SlashCommandBuilder() + .setName("help") + .setDescription("Zeigt alle Befehle des Bots an") + .addStringOption((opt) => + opt.setName("befehl").setDescription("Name eines bestimmten Befehls").setRequired(false) + ), + + async execute(client, ctx, args) { + const helpEmbed = new EmbedBuilder(); + const requestedName = ctx.isSlash + ? ctx.interaction.options.getString("befehl") + : args?.[0]; + + if (!requestedName) { + const commandList = `\`${Array.from(client.commands.keys()).join("` `")}\``; + const { me } = ctx.guild.members; + const displayName = me.displayName || client.bot.user.username; + + helpEmbed + .setAuthor({ name: client.config.authorName, url: client.config.authorGithub }) + .setColor(me.displayHexColor) + .setTitle(`${displayName} Hilfe`) + .setDescription(commandList) + .setFooter({ + text: `Nutze \`${client.config.prefix}${this.name} ${this.args_usage}\` für mehr Details!`, + }); + } else { + const requestedCommand = requestedName.replace(client.config.prefix, ""); + const command = + client.commands.get(requestedCommand) || + client.commands.get(client.aliases.get(requestedCommand)); + + if ( + !command || + (!command.guild.includes("all") && !command.guild.includes(ctx.guild.id)) + ) + return ctx.reply(`Ich konnte \`${requestedCommand}\` nicht in der Befehlsliste finden!`); + + helpEmbed + .setTitle(`Befehl: ${command.name}`) + .setDescription(command.description) + .addFields([ + { name: "Nutzung", value: `\`${client.config.prefix}${command.name} ${command.args_usage}\`` }, + { name: "NSFW", value: `${command.nsfw}`, inline: true }, + { name: "Abklingzeit", value: `${command.cooldown} Sek.`, inline: true }, + ]); + + if (command.aliases?.length > 0) + helpEmbed.addFields([{ name: "Aliasse", value: `\`${command.aliases.join("` `")}\`` }]); + + if (command.user_permissions?.length > 0) + helpEmbed.addFields([{ name: "Berechtigungen", value: `\`${command.user_permissions.join("` `")}\`` }]); + + helpEmbed.setFooter({ text: `Nutze \`${client.config.prefix}${this.name}\` um alle Befehle zu sehen!` }); + } + + return ctx.reply({ embeds: [helpEmbed] }); + }, +}; \ No newline at end of file diff --git a/commands/info.js b/commands/info.js new file mode 100644 index 0000000..37a5258 --- /dev/null +++ b/commands/info.js @@ -0,0 +1,68 @@ +import { EmbedBuilder, SlashCommandBuilder } from "discord.js"; + +export default { + name: "info", + description: "Informationen über den Bot", + aliases: ["botinfo"], + guild: ["all"], + nsfw: false, + user_permissions: [], + bot_permissions: [], + args_required: 0, + args_usage: "", + cooldown: 5, + + data: new SlashCommandBuilder() + .setName("info") + .setDescription("Zeigt Informationen über den Bot an"), + + async execute(client, ctx) { + let totalMembers = 0; + client.bot.guilds.cache.forEach((guild) => { totalMembers += guild.memberCount; }); + + const uptime = getUptime(client.bot); + const memUsed = (process.memoryUsage().heapUsed / 1024 / 1024).toFixed(1); + const nodeVer = process.version; + + const infoEmbed = new EmbedBuilder() + .setAuthor({ + name: client.config.authorName, + url: client.config.authorGithub, + iconURL: client.bot.user.displayAvatarURL(), + }) + .setColor(ctx.guild.members.me.displayHexColor) + .setTitle(`ℹ️ ${client.bot.user.username} – Bot-Informationen`) + .setThumbnail(client.bot.user.displayAvatarURL()) + .addFields([ + { name: "🤖 Bot-Name", value: client.config.botName, inline: true }, + { name: "👨‍💻 Entwickler", value: `[${client.config.authorName}](${client.config.authorGithub})`, inline: true }, + { name: "📦 Version", value: client.packageData.version, inline: true }, + { name: "⏱️ Laufzeit", value: uptime, inline: true }, + { name: "🌐 Server", value: `${client.bot.guilds.cache.size}`, inline: true }, + { name: "👥 Nutzer", value: `${totalMembers.toLocaleString("de-DE")}`, inline: true }, + { name: "💬 Kanäle", value: `${client.bot.channels.cache.size}`, inline: true }, + { name: "🔧 Befehle", value: `${client.commands.size}`, inline: true }, + { name: "💾 Speicher", value: `${memUsed} MB`, inline: true }, + { name: "🟢 Node.js", value: nodeVer, inline: true }, + { name: "📚 discord.js", value: `v14`, inline: true }, + { name: "🔗 Einladen", value: `[Bot einladen](https://discord.com/oauth2/authorize?client_id=${client.config.inviteClientID}&scope=bot&permissions=8)`, inline: true }, + ]) + .setFooter({ text: `${client.config.botName} • SpigotMC Plugin Update Tracker` }) + .setTimestamp(); + + return ctx.reply({ embeds: [infoEmbed] }); + }, +}; + +function getUptime(bot) { + let total = bot.uptime / 1000; + const days = Math.floor(total / 86400); total %= 86400; + const hours = Math.floor(total / 3600); total %= 3600; + const minutes = Math.floor(total / 60); + const seconds = Math.floor(total % 60); + + if (days > 0) return `${days}T ${hours}Std ${minutes}Min ${seconds}Sek`; + if (hours > 0) return `${hours}Std ${minutes}Min ${seconds}Sek`; + if (minutes > 0) return `${minutes}Min ${seconds}Sek`; + return `${seconds}Sek`; +} \ No newline at end of file diff --git a/commands/invite.js b/commands/invite.js new file mode 100644 index 0000000..e6fd224 --- /dev/null +++ b/commands/invite.js @@ -0,0 +1,30 @@ +import { EmbedBuilder, SlashCommandBuilder } from "discord.js"; + +export default { + name: "invite", + description: "Der Einladungslink des Bots!", + aliases: [], + guild: ["all"], + nsfw: false, + user_permissions: [], + bot_permissions: [], + args_required: 0, + args_usage: "", + cooldown: 5, + + data: new SlashCommandBuilder() + .setName("invite") + .setDescription("Gibt den Einladungslink des Bots aus"), + + async execute(client, ctx) { + const inviteURL = `https://discord.com/oauth2/authorize?client_id=${client.config.inviteClientID}&scope=bot&permissions=8`; + + const embed = new EmbedBuilder() + .setURL(inviteURL) + .setColor(ctx.guild.members.me.displayHexColor) + .setTitle("🔗 Bot-Einladungslink") + .setDescription("Klicke auf diesen Link, um den Bot zu deinem Discord-Server hinzuzufügen!"); + + return ctx.reply({ embeds: [embed] }); + }, +}; \ No newline at end of file diff --git a/commands/list.js b/commands/list.js new file mode 100644 index 0000000..8ce0d32 --- /dev/null +++ b/commands/list.js @@ -0,0 +1,60 @@ +import { EmbedBuilder, PermissionsBitField, SlashCommandBuilder } from "discord.js"; +import fs from "fs/promises"; + +export default { + name: "list", + description: "Listet alle Plugin-Update-Listener für diesen Server auf", + aliases: [], + guild: ["all"], + nsfw: false, + user_permissions: [PermissionsBitField.Flags.Administrator], + bot_permissions: [], + args_required: 0, + args_usage: "", + cooldown: 5, + + data: new SlashCommandBuilder() + .setName("list") + .setDescription("Listet alle beobachteten Plugins dieses Servers auf"), + + async execute(client, ctx) { + const guildID = ctx.guild.id; + const filePath = `./serverdata/${guildID}.json`; + + let data; + try { + const raw = await fs.readFile(filePath, "utf8"); + data = JSON.parse(raw); + } catch { + return ctx.reply( + `Dieser Server hat keine beobachteten Ressourcen. Nutze \`${client.config.prefix}add\` um eine hinzuzufügen.` + ); + } + + if (!data.watchedResources || data.watchedResources.length === 0) { + return ctx.reply( + `Dieser Server hat keine beobachteten Ressourcen. Nutze \`${client.config.prefix}add\` um eine hinzuzufügen.` + ); + } + + const list = data.watchedResources + .map((r) => { + const name = r.resourceName ? `**${r.resourceName}**` : `ID: \`${r.resourceID}\``; + const version = r.lastCheckedVersion ? ` • v${r.lastCheckedVersion}` : ""; + return `${name}${version}\n└ Kanal: <#${r.channelID}> • ID: \`${r.resourceID}\``; + }) + .join("\n\n"); + + const intervalInfo = data.updateInterval + ? `⏱️ Update-Intervall: ${data.updateInterval} Minute(n)` + : "⏱️ Update-Intervall: 5 Minuten (Standard)"; + + const listEmbed = new EmbedBuilder() + .setColor(ctx.guild.members.me.displayHexColor) + .setTitle(`📋 Beobachtete Plugins (${data.watchedResources.length})`) + .setDescription(list) + .setFooter({ text: intervalInfo }); + + return ctx.reply({ embeds: [listEmbed] }); + }, +}; \ No newline at end of file diff --git a/commands/ping.js b/commands/ping.js new file mode 100644 index 0000000..a86425f --- /dev/null +++ b/commands/ping.js @@ -0,0 +1,42 @@ +import { SlashCommandBuilder } from "discord.js"; +import { requireOwner } from "../util/ownerOnly.js"; +import { t, getLang } from "../util/i18n.js"; +import fsSync from "fs"; + +export default { + name: "ping", + description: "Der Ping des Bots!", + aliases: ["pingpong"], + guild: ["all"], + nsfw: false, + user_permissions: [], + bot_permissions: [], + args_required: 0, + args_usage: "", + cooldown: 5, + + data: new SlashCommandBuilder() + .setName("ping") + .setDescription("Zeigt die aktuelle Latenz des Bots"), + + async execute(client, ctx) { + const lang = loadLang(ctx.guild.id); + if (!await requireOwner(client, ctx, lang)) return; + if (ctx.isSlash) { + await ctx.interaction.reply("Ping?"); + const reply = await ctx.interaction.fetchReply(); + const latency = reply.createdTimestamp - ctx.interaction.createdTimestamp; + return ctx.interaction.editReply(`:ping_pong: Pong! Die Latenz beträgt **${latency}ms**.`); + } + + const m = await ctx.channel.send("Ping?"); + m.edit(`:ping_pong: Pong! Die Latenz beträgt **${m.createdTimestamp - ctx.createdTimestamp}ms**.`); + }, +}; + +function loadLang(guildID) { + try { + const data = JSON.parse(fsSync.readFileSync(`./serverdata/${guildID}.json`, "utf8")); + return getLang(data); + } catch { return "de"; } +} \ No newline at end of file diff --git a/commands/plugin.js b/commands/plugin.js new file mode 100644 index 0000000..24423d9 --- /dev/null +++ b/commands/plugin.js @@ -0,0 +1,81 @@ +import { EmbedBuilder, SlashCommandBuilder } from "discord.js"; +import { Spiget } from "spiget"; +import { + generateAvatarLink, + generateAuthorURL, + generateResourceIconURL, + generateResourceURL, +} from "../util/helpers.js"; + +const spiget = new Spiget("Viper-Network"); + +export default { + name: "plugin", + description: "Ruft ein Plugin anhand seiner Ressourcen-ID ab und gibt Details zurück", + aliases: [], + guild: ["all"], + nsfw: false, + user_permissions: [], + bot_permissions: [], + args_required: 1, + args_usage: "[ressourcen_id]", + cooldown: 5, + + data: new SlashCommandBuilder() + .setName("plugin") + .setDescription("Zeigt Details zu einem Plugin an") + .addStringOption((opt) => + opt.setName("ressourcen_id").setDescription("Spiget Ressourcen-ID").setRequired(true) + ), + + async execute(client, ctx, args) { + const resourceID = ctx.isSlash + ? ctx.interaction.options.getString("ressourcen_id") + : args[0]; + + let resource; + try { + resource = await spiget.getResource(resourceID); + } catch (e) { + client.logger.error(e); + return ctx.reply(`Ups! \`${resourceID}\` ist keine gültige Ressourcen-ID!`); + } + + let author; + try { + author = await resource.getAuthor(); + } catch (e) { + client.logger.error(e); + return ctx.reply(`Ups! Der Autor für Ressource \`${resourceID}\` konnte nicht gefunden werden.`); + } + + let latestVersion; + try { + const res = await fetch(`https://api.spigotmc.org/legacy/update.php?resource=${resourceID}`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + latestVersion = (await res.text()).trim(); + } catch (e) { + client.logger.error(e); + return ctx.reply("Versionsinformationen konnten nicht abgerufen werden. Bitte versuche es erneut."); + } + + const authorURL = generateAuthorURL(author.name, author.id); + const authorAvatarURL = generateAvatarLink(author.id); + const resourceIconURL = generateResourceIconURL(resource); + const resourceURL = generateResourceURL(resourceID); + + const embed = new EmbedBuilder() + .setAuthor({ name: `Autor: ${author.name}`, iconURL: authorAvatarURL, url: authorURL }) + .setColor(ctx.guild.members.me.displayHexColor) + .setTitle(resource.name) + .setDescription(resource.tag) + .addFields([ + { name: "📦 Version", value: latestVersion, inline: true }, + { name: "⬇️ Download", value: resourceURL, inline: true }, + ]) + .setThumbnail(resourceIconURL) + .setTimestamp(); + + return ctx.reply({ embeds: [embed] }); + }, +}; \ No newline at end of file diff --git a/commands/remove.js b/commands/remove.js new file mode 100644 index 0000000..d90be6a --- /dev/null +++ b/commands/remove.js @@ -0,0 +1,63 @@ +import { PermissionsBitField, SlashCommandBuilder } from "discord.js"; +import fs from "fs/promises"; + +export default { + name: "remove", + description: "Stoppt den Listener für Plugin-Updates", + aliases: [], + guild: ["all"], + nsfw: false, + user_permissions: [PermissionsBitField.Flags.Administrator], + bot_permissions: [], + args_required: 1, + args_usage: "[ressourcen_id]", + cooldown: 5, + + data: new SlashCommandBuilder() + .setName("remove") + .setDescription("Entfernt einen Plugin-Update-Listener") + .addStringOption((opt) => + opt.setName("ressourcen_id").setDescription("Spiget Ressourcen-ID").setRequired(true) + ), + + async execute(client, ctx, args) { + const resourceID = ctx.isSlash + ? ctx.interaction.options.getString("ressourcen_id") + : args[0]; + + const guildID = ctx.guild.id; + const filePath = `./serverdata/${guildID}.json`; + + let data; + try { + const raw = await fs.readFile(filePath, "utf8"); + data = JSON.parse(raw); + } catch { + return ctx.reply("Dieser Server hat keine beobachteten Ressourcen!"); + } + + const index = data.watchedResources.findIndex( + (r) => String(r.resourceID) === String(resourceID) + ); + + if (index === -1) { + return ctx.reply(`Fehler: Ressource \`${resourceID}\` wurde auf diesem Server nicht beobachtet.`); + } + + const removedName = data.watchedResources[index].resourceName ?? resourceID; + data.watchedResources.splice(index, 1); + + try { + if (data.watchedResources.length === 0) { + await fs.unlink(filePath); + } else { + await fs.writeFile(filePath, JSON.stringify(data, null, 2)); + } + } catch (e) { + client.logger.error(e); + return ctx.reply("Beim Aktualisieren der Beobachtungsdaten ist ein Fehler aufgetreten."); + } + + return ctx.reply(`✅ **${removedName}** (\`${resourceID}\`) wird nicht mehr beobachtet.`); + }, +}; \ No newline at end of file diff --git a/commands/search.js b/commands/search.js new file mode 100644 index 0000000..dc2e570 --- /dev/null +++ b/commands/search.js @@ -0,0 +1,117 @@ +import { EmbedBuilder, SlashCommandBuilder } from "discord.js"; +import { Spiget } from "spiget"; + +const spiget = new Spiget("Viper-Network"); + +export default { + name: "search", + description: "Durchsucht die Spiget-API nach einer Ressource und gibt die Top-Ergebnisse zurück", + aliases: [], + guild: ["all"], + nsfw: false, + user_permissions: [], + bot_permissions: [], + args_required: 1, + args_usage: `[ressourcenname] Beispiel: vn!search Vault\nTipp: -n [anzahl] für Suchmenge (Standard: 100), -r für Sortierung nach Bewertung\nBeispiel: vn!search a -n 5000 -r`, + cooldown: 3, + + data: new SlashCommandBuilder() + .setName("search") + .setDescription("Sucht nach einem Plugin auf SpigotMC") + .addStringOption((opt) => + opt.setName("name").setDescription("Name des Plugins").setRequired(true) + ) + .addIntegerOption((opt) => + opt.setName("anzahl").setDescription("Suchmenge (Standard: 100)").setRequired(false) + ) + .addStringOption((opt) => + opt + .setName("sortierung") + .setDescription("Sortierung der Ergebnisse") + .setRequired(false) + .addChoices( + { name: "Downloads", value: "downloads" }, + { name: "Bewertung", value: "rating" } + ) + ), + + async execute(client, ctx, args) { + let search, size, sortBy; + + if (ctx.isSlash) { + search = ctx.interaction.options.getString("name"); + size = ctx.interaction.options.getInteger("anzahl") ?? 100; + sortBy = ctx.interaction.options.getString("sortierung") ?? "downloads"; + } else { + size = 100; + sortBy = "downloads"; + + // Parse -n flag + const nIdx = args.indexOf("-n"); + if (nIdx !== -1 && args[nIdx + 1]) { + const parsed = parseInt(args[nIdx + 1], 10); + if (!isNaN(parsed) && parsed > 0) size = parsed; + args.splice(nIdx, 2); + } + + // Parse -r flag (sort by rating) + const rIdx = args.indexOf("-r"); + if (rIdx !== -1) { + sortBy = "rating"; + args.splice(rIdx, 1); + } + + if (args.length === 0) { + return ctx.reply("Bitte gib einen Suchbegriff an!"); + } + search = args.join(" "); + } + + const apiURL = `https://api.spiget.org/v2/search/resources/${encodeURIComponent(search)}?size=${size}`; + + let results; + try { + const res = await fetch(apiURL); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + results = await res.json(); + } catch (e) { + client.logger.error(e); + return ctx.reply("Die Spiget-API konnte nicht erreicht werden. Bitte versuche es später erneut."); + } + + if (!Array.isArray(results) || results.length === 0) { + return ctx.reply(`Keine Ergebnisse für \`${search}\` gefunden.`); + } + + // Sort by downloads or rating + const topResults = results + .sort((a, b) => { + if (sortBy === "rating") { + const ratingA = a.rating?.average ?? 0; + const ratingB = b.rating?.average ?? 0; + return ratingB - ratingA; + } + return b.downloads - a.downloads; + }) + .slice(0, 5); + + const list = topResults + .map(({ id, name, downloads, rating }) => { + const stars = rating?.average ? `⭐ ${rating.average.toFixed(1)}` : "⭐ k.A."; + return `**${name}**\n⬇️ *${downloads.toLocaleString("de-DE")}* ${stars} 🆔 ${id}`; + }) + .join("\n\n"); + + const sortLabel = sortBy === "rating" ? "Bewertung" : "Downloads"; + + const resultEmbed = new EmbedBuilder() + .setColor(ctx.guild.members.me.displayHexColor) + .setTitle(`:mag: Top-Ergebnisse für '${search}'`) + .setDescription(`${list}\n\n⬇️ = Downloads ⭐ = Bewertung 🆔 = Plugin-ID`) + .setFooter({ + text: `Sortiert nach: ${sortLabel} | ${client.config.prefix}plugin [id] für mehr Details`, + }); + + return ctx.reply({ embeds: [resultEmbed] }); + }, +}; \ No newline at end of file diff --git a/commands/setchannel.js b/commands/setchannel.js new file mode 100644 index 0000000..1a44f3e --- /dev/null +++ b/commands/setchannel.js @@ -0,0 +1,77 @@ +import { EmbedBuilder, PermissionsBitField, SlashCommandBuilder } from "discord.js"; +import fs from "fs/promises"; +import { t, getLang } from "../util/i18n.js"; + +export default { + name: "setchannel", + description: "Ändert den Update-Kanal für eine beobachtete Ressource", + aliases: [], + guild: ["all"], + nsfw: false, + user_permissions: [PermissionsBitField.Flags.Administrator], + bot_permissions: [], + args_required: 2, + args_usage: "[ressourcen_id] [#kanal] Beispiel: vn!setchannel 72678 #updates", + cooldown: 5, + + data: new SlashCommandBuilder() + .setName("setchannel") + .setDescription("Changes the update channel for a watched resource") + .addStringOption((opt) => + opt.setName("ressourcen_id").setDescription("Spiget Ressourcen-ID").setRequired(true) + ) + .addChannelOption((opt) => + opt.setName("kanal").setDescription("Neuer Update-Kanal").setRequired(true) + ), + + async execute(client, ctx, args) { + const guildID = ctx.guild.id; + const filePath = `./serverdata/${guildID}.json`; + + let saveData; + try { + const raw = await fs.readFile(filePath, "utf8"); + saveData = JSON.parse(raw); + } catch { + return ctx.reply("Dieser Server hat keine beobachteten Ressourcen."); + } + + const lang = getLang(saveData); + + const resourceID = ctx.isSlash + ? ctx.interaction.options.getString("ressourcen_id") + : args[0]; + + const channel = ctx.isSlash + ? ctx.interaction.options.getChannel("kanal") + : ctx.mentions?.channels?.first(); + + if (!channel) { + return ctx.reply(t(lang, "add.invalidChannel")); + } + + const watched = saveData.watchedResources.find( + (r) => String(r.resourceID) === String(resourceID) + ); + + if (!watched) { + return ctx.reply(t(lang, "setchannel.notWatched", { id: resourceID })); + } + + watched.channelID = channel.id; + + try { + await fs.writeFile(filePath, JSON.stringify(saveData, null, 2)); + } catch (e) { + client.logger.error(e); + return ctx.reply(t(lang, "error.saveFailed")); + } + + const name = watched.resourceName ?? resourceID; + const embed = new EmbedBuilder() + .setColor(ctx.guild.members.me.displayHexColor) + .setDescription(t(lang, "setchannel.success", { name, channelID: channel.id })); + + return ctx.reply({ embeds: [embed] }); + }, +}; \ No newline at end of file diff --git a/commands/setinterval.js b/commands/setinterval.js new file mode 100644 index 0000000..1afa79f --- /dev/null +++ b/commands/setinterval.js @@ -0,0 +1,83 @@ +import { EmbedBuilder, PermissionsBitField, SlashCommandBuilder } from "discord.js"; +import { requireOwner } from "../util/ownerOnly.js"; +import { t, getLang } from "../util/i18n.js"; +import fsSync from "fs"; +import fs from "fs/promises"; + +const MIN_INTERVAL = 1; +const MAX_INTERVAL = 60; + +export default { + name: "setinterval", + description: "Legt das Update-Check-Intervall für diesen Server fest (in Minuten)", + aliases: ["interval"], + guild: ["all"], + nsfw: false, + user_permissions: [PermissionsBitField.Flags.Administrator], + bot_permissions: [], + args_required: 1, + args_usage: `[minuten] Beispiel: vn!setinterval 10 (erlaubt: ${MIN_INTERVAL}–${MAX_INTERVAL})`, + cooldown: 5, + + data: new SlashCommandBuilder() + .setName("setinterval") + .setDescription("Legt das Update-Check-Intervall für diesen Server fest") + .addIntegerOption((opt) => + opt + .setName("minuten") + .setDescription(`Intervall in Minuten (${MIN_INTERVAL}–${MAX_INTERVAL})`) + .setMinValue(MIN_INTERVAL) + .setMaxValue(MAX_INTERVAL) + .setRequired(true) + ), + + async execute(client, ctx, args) { + const lang = loadLang(ctx.guild.id); + if (!await requireOwner(client, ctx, lang)) return; + const minutes = ctx.isSlash + ? ctx.interaction.options.getInteger("minuten") + : parseInt(args[0], 10); + + if (isNaN(minutes) || minutes < MIN_INTERVAL || minutes > MAX_INTERVAL) { + return ctx.reply( + `Ungültiger Wert. Bitte gib eine Zahl zwischen **${MIN_INTERVAL}** und **${MAX_INTERVAL}** Minuten an.` + ); + } + + const guildID = ctx.guild.id; + const filePath = `./serverdata/${guildID}.json`; + + let saveData = { watchedResources: [] }; + try { + const raw = await fs.readFile(filePath, "utf8"); + saveData = JSON.parse(raw); + } catch { + // Datei existiert noch nicht – wird erstellt + } + + saveData.updateInterval = minutes; + + try { + await fs.mkdir("./serverdata", { recursive: true }); + await fs.writeFile(filePath, JSON.stringify(saveData, null, 2)); + } catch (e) { + client.logger.error(e); + return ctx.reply("Beim Speichern der Einstellung ist ein Fehler aufgetreten."); + } + + const embed = new EmbedBuilder() + .setColor(ctx.guild.members.me.displayHexColor) + .setTitle("⏱️ Update-Intervall aktualisiert") + .setDescription(`Das Update-Check-Intervall für diesen Server wurde auf **${minutes} Minute(n)** gesetzt.`) + .setFooter({ text: "Der nächste Check erfolgt nach Ablauf des neuen Intervalls." }); + + return ctx.reply({ embeds: [embed] }); + }, +}; + +function loadLang(guildID) { + try { + const data = JSON.parse(fsSync.readFileSync(`./serverdata/${guildID}.json`, "utf8")); + return getLang(data); + } catch { return "de"; } +} \ No newline at end of file diff --git a/commands/setlang.js b/commands/setlang.js new file mode 100644 index 0000000..13753ab --- /dev/null +++ b/commands/setlang.js @@ -0,0 +1,67 @@ +import { EmbedBuilder, PermissionsBitField, SlashCommandBuilder } from "discord.js"; +import fs from "fs/promises"; +import { t } from "../util/i18n.js"; + +export default { + name: "setlang", + description: "Stellt die Sprache des Bots für diesen Server ein (DE/EN)", + aliases: ["language", "lang"], + guild: ["all"], + nsfw: false, + user_permissions: [PermissionsBitField.Flags.Administrator], + bot_permissions: [], + args_required: 1, + args_usage: "[de|en] Beispiel: vn!setlang en", + cooldown: 5, + + data: new SlashCommandBuilder() + .setName("setlang") + .setDescription("Set the bot language for this server (DE/EN)") + .addStringOption((opt) => + opt + .setName("sprache") + .setDescription("Sprache / Language") + .setRequired(true) + .addChoices( + { name: "🇩🇪 Deutsch", value: "de" }, + { name: "🇬🇧 English", value: "en" } + ) + ), + + async execute(client, ctx, args) { + const input = ctx.isSlash + ? ctx.interaction.options.getString("sprache") + : args[0]?.toLowerCase(); + + if (!["de", "en"].includes(input)) { + return ctx.reply("Ungültige Sprache. Nutze `de` oder `en`."); + } + + const guildID = ctx.guild.id; + const filePath = `./serverdata/${guildID}.json`; + + let saveData = { watchedResources: [] }; + try { + const raw = await fs.readFile(filePath, "utf8"); + saveData = JSON.parse(raw); + } catch { /* Datei noch nicht vorhanden */ } + + saveData.lang = input; + + try { + await fs.mkdir("./serverdata", { recursive: true }); + await fs.writeFile(filePath, JSON.stringify(saveData, null, 2)); + } catch (e) { + client.logger.error(e); + return ctx.reply(t(input, "error.saveFailed")); + } + + const flag = input === "de" ? "🇩🇪" : "🇬🇧"; + const embed = new EmbedBuilder() + .setColor(ctx.guild.members.me.displayHexColor) + .setTitle(t(input, "setlang.title")) + .setDescription(`${flag} ${t(input, "setlang.success")}`); + + return ctx.reply({ embeds: [embed] }); + }, +}; \ No newline at end of file diff --git a/commands/setmention.js b/commands/setmention.js new file mode 100644 index 0000000..8ccc1ce --- /dev/null +++ b/commands/setmention.js @@ -0,0 +1,82 @@ +import { EmbedBuilder, PermissionsBitField, SlashCommandBuilder } from "discord.js"; +import fs from "fs/promises"; +import { t, getLang } from "../util/i18n.js"; + +export default { + name: "setmention", + description: "Legt eine Rolle fest die bei Updates einer Ressource gepingt wird", + aliases: [], + guild: ["all"], + nsfw: false, + user_permissions: [PermissionsBitField.Flags.Administrator], + bot_permissions: [], + args_required: 1, + args_usage: "[ressourcen_id] [@rolle] Beispiel: vn!setmention 72678 @Updates (ohne Rolle = entfernen)", + cooldown: 5, + + data: new SlashCommandBuilder() + .setName("setmention") + .setDescription("Sets a role to ping when a plugin gets an update") + .addStringOption((opt) => + opt.setName("ressourcen_id").setDescription("Spiget Ressourcen-ID").setRequired(true) + ) + .addRoleOption((opt) => + opt.setName("rolle").setDescription("Zu pingende Rolle (weglassen = entfernen)").setRequired(false) + ), + + async execute(client, ctx, args) { + const guildID = ctx.guild.id; + const filePath = `./serverdata/${guildID}.json`; + + let saveData; + try { + const raw = await fs.readFile(filePath, "utf8"); + saveData = JSON.parse(raw); + } catch { + return ctx.reply("Dieser Server hat keine beobachteten Ressourcen."); + } + + const lang = getLang(saveData); + + const resourceID = ctx.isSlash + ? ctx.interaction.options.getString("ressourcen_id") + : args[0]; + + const role = ctx.isSlash + ? ctx.interaction.options.getRole("rolle") + : ctx.mentions?.roles?.first() ?? null; + + const watched = saveData.watchedResources.find( + (r) => String(r.resourceID) === String(resourceID) + ); + + if (!watched) { + return ctx.reply(t(lang, "setmention.notWatched", { id: resourceID })); + } + + const name = watched.resourceName ?? resourceID; + + if (role) { + watched.mentionRoleID = role.id; + } else { + delete watched.mentionRoleID; + } + + try { + await fs.writeFile(filePath, JSON.stringify(saveData, null, 2)); + } catch (e) { + client.logger.error(e); + return ctx.reply(t(lang, "error.saveFailed")); + } + + const msg = role + ? t(lang, "setmention.set", { name, roleID: role.id }) + : t(lang, "setmention.removed", { name }); + + const embed = new EmbedBuilder() + .setColor(ctx.guild.members.me.displayHexColor) + .setDescription(msg); + + return ctx.reply({ embeds: [embed] }); + }, +}; \ No newline at end of file diff --git a/commands/stats.js b/commands/stats.js new file mode 100644 index 0000000..d8a0ebb --- /dev/null +++ b/commands/stats.js @@ -0,0 +1,51 @@ +import { EmbedBuilder, SlashCommandBuilder } from "discord.js"; + +export default { + name: "stats", + description: "Verschiedene Statistiken des Bots!", + aliases: [], + guild: ["all"], + nsfw: false, + user_permissions: [], + bot_permissions: [], + args_required: 0, + args_usage: "", + cooldown: 5, + + data: new SlashCommandBuilder() + .setName("stats") + .setDescription("Zeigt Statistiken des Bots an"), + + async execute(client, ctx) { + let members = 0; + client.bot.guilds.cache.forEach((guild) => { members += guild.memberCount; }); + + const embed = new EmbedBuilder() + .setAuthor({ name: client.config.authorName, url: client.config.authorGithub }) + .setColor(ctx.guild.members.me.displayHexColor) + .setTitle(`📊 ${ctx.guild.members.me.displayName} Statistiken`) + .addFields([ + { name: "📦 Version", value: `${client.packageData.version}`, inline: true }, + { name: "👥 Nutzer", value: `${members.toLocaleString("de-DE")}`, inline: true }, + { name: "💬 Kanäle", value: `${client.bot.channels.cache.size}`, inline: true }, + { name: "🌐 Server", value: `${client.bot.guilds.cache.size}`, inline: true }, + { name: "🔧 Befehle", value: `${client.commands.size}`, inline: true }, + { name: "⏱️ Laufzeit", value: getUptime(client.bot), inline: true }, + ]); + + return ctx.reply({ embeds: [embed] }); + }, +}; + +function getUptime(bot) { + let total = bot.uptime / 1000; + const days = Math.floor(total / 86400); total %= 86400; + const hours = Math.floor(total / 3600); total %= 3600; + const minutes = Math.floor(total / 60); + const seconds = Math.floor(total % 60); + + if (days > 0) return `${days}T ${hours}Std ${minutes}Min ${seconds}Sek`; + if (hours > 0) return `${hours}Std ${minutes}Min ${seconds}Sek`; + if (minutes > 0) return `${minutes}Min ${seconds}Sek`; + return `${seconds}Sek`; +} \ No newline at end of file diff --git a/commands/status.js b/commands/status.js new file mode 100644 index 0000000..60964bf --- /dev/null +++ b/commands/status.js @@ -0,0 +1,105 @@ +import { EmbedBuilder, SlashCommandBuilder } from "discord.js"; +import stats from "../util/stats.js"; +import queue from "../util/queue.js"; +import { requireOwner } from "../util/ownerOnly.js"; +import { t, getLang } from "../util/i18n.js"; +import fs from "fs"; + +export default { + name: "status", + description: "Zeigt den Bot-Status, API-Verfügbarkeit und den nächsten Update-Check", + aliases: ["botstatus"], + guild: ["all"], + nsfw: false, + user_permissions: [], + bot_permissions: [], + args_required: 0, + args_usage: "", + cooldown: 10, + + data: new SlashCommandBuilder() + .setName("status") + .setDescription("Zeigt Bot-Status, API-Verfügbarkeit und Update-Check-Info"), + + async execute(client, ctx) { + const lang = loadLang(ctx.guild.id); + if (!await requireOwner(client, ctx, lang)) return; + // --- Spiget API Health-Check --- + let spigetStatus = "✅ Erreichbar"; + let spigetPing = "–"; + try { + const start = Date.now(); + const res = await fetch("https://api.spiget.org/v2/status", { signal: AbortSignal.timeout(5000) }); + spigetPing = `${Date.now() - start}ms`; + if (!res.ok) spigetStatus = `⚠️ HTTP ${res.status}`; + } catch { + spigetStatus = "❌ Nicht erreichbar"; + } + + // --- SpigotMC API Health-Check (Vault = 34315, existiert garantiert) --- + let spigotStatus = "✅ Erreichbar"; + try { + const res = await fetch("https://api.spigotmc.org/legacy/update.php?resource=34315", { + signal: AbortSignal.timeout(5000), + }); + // 200 mit Versionstext = OK; alles andere = Problem + if (!res.ok) spigotStatus = `⚠️ HTTP ${res.status}`; + } catch { + spigotStatus = "❌ Nicht erreichbar"; + } + + // --- Queue-Status --- + const queueStatus = queue.isRunning + ? `🔄 Aktiv (${queue.size} ausstehend)` + : queue.size > 0 + ? `⏳ Wartend (${queue.size} Jobs)` + : "✅ Leerlauf"; + + // --- Nächster Check --- + const nextRun = queue.nextRunAt + ? `` + : "Unbekannt"; + + // --- Statistiken --- + const s = stats.all(); + const startedAt = s.startedAt + ? `` + : "Unbekannt"; + + // --- Discord Latenz --- + const discordPing = `${client.bot.ws.ping}ms`; + + const embed = new EmbedBuilder() + .setColor(ctx.guild.members.me.displayHexColor) + .setTitle(`📡 ${client.config.botName} – Status`) + .addFields([ + // APIs + { name: "🌐 Spiget API", value: `${spigetStatus} (${spigetPing})`, inline: true }, + { name: "🌐 SpigotMC API", value: spigotStatus, inline: true }, + { name: "💬 Discord Ping", value: discordPing, inline: true }, + // Update-Check + { name: "⚙️ Queue", value: queueStatus, inline: true }, + { name: "⏱️ Nächster Check", value: nextRun, inline: true }, + { name: "🔄 Checks gesamt", value: `${s.checksRun}`, inline: true }, + // Statistiken + { name: "🔔 Updates gefunden", value: `${s.updatesFound}`, inline: true }, + { name: "📤 Updates gepostet", value: `${s.updatesPosted}`, inline: true }, + { name: "⚠️ API-Fehler", value: `${s.apiErrors}`, inline: true }, + // Bot-Info + { name: "🕐 Online seit", value: startedAt, inline: true }, + { name: "🏓 Jobs verarbeitet", value: `${queue.processed}`, inline: true }, + { name: "📩 Owner-DMs", value: `${s.dmsSent}`, inline: true }, + ]) + .setFooter({ text: `${client.config.botName} • Version ${client.packageData.version}` }) + .setTimestamp(); + + return ctx.reply({ embeds: [embed] }); + }, +}; + +function loadLang(guildID) { + try { + const data = JSON.parse(fs.readFileSync(`./serverdata/${guildID}.json`, "utf8")); + return getLang(data); + } catch { return "de"; } +} \ No newline at end of file diff --git a/commands/top.js b/commands/top.js new file mode 100644 index 0000000..a308a8b --- /dev/null +++ b/commands/top.js @@ -0,0 +1,100 @@ +import { EmbedBuilder, SlashCommandBuilder } from "discord.js"; +import fs from "fs/promises"; +import { t, getLang } from "../util/i18n.js"; + +export default { + name: "top", + description: "Zeigt die meistgeladenen/bestbewerteten beobachteten Plugins", + aliases: [], + guild: ["all"], + nsfw: false, + user_permissions: [], + bot_permissions: [], + args_required: 0, + args_usage: "[-r] -r = nach Bewertung sortieren", + cooldown: 10, + + data: new SlashCommandBuilder() + .setName("top") + .setDescription("Shows the top watched plugins by downloads or rating") + .addStringOption((opt) => + opt + .setName("sortierung") + .setDescription("Sortierung") + .setRequired(false) + .addChoices( + { name: "⬇️ Downloads", value: "downloads" }, + { name: "⭐ Bewertung", value: "rating" } + ) + ), + + async execute(client, ctx, args) { + const guildID = ctx.guild.id; + const filePath = `./serverdata/${guildID}.json`; + + let saveData; + try { + const raw = await fs.readFile(filePath, "utf8"); + saveData = JSON.parse(raw); + } catch { + return ctx.reply("Dieser Server hat keine beobachteten Ressourcen."); + } + + const lang = getLang(saveData); + const sortBy = ctx.isSlash + ? (ctx.interaction.options.getString("sortierung") ?? "downloads") + : (args.includes("-r") ? "rating" : "downloads"); + + const watched = saveData.watchedResources; + if (!watched || watched.length === 0) { + return ctx.reply(t(lang, "top.noData")); + } + + // Fetch stats for all watched resources + const results = []; + for (const w of watched) { + try { + const res = await fetch(`https://api.spiget.org/v2/resources/${w.resourceID}`); + if (!res.ok) continue; + const data = await res.json(); + results.push({ + id: w.resourceID, + name: data.name ?? w.resourceName ?? w.resourceID, + downloads: data.downloads ?? 0, + rating: data.rating?.average ?? 0, + version: w.lastCheckedVersion ?? "?", + channelID: w.channelID, + }); + await new Promise((r) => setTimeout(r, 300)); + } catch { /* Plugin überspringen */ } + } + + if (results.length === 0) { + return ctx.reply(t(lang, "error.apiDown")); + } + + // Sort + results.sort((a, b) => + sortBy === "rating" ? b.rating - a.rating : b.downloads - a.downloads + ); + + const sortLabel = t(lang, sortBy === "rating" ? "top.rating" : "top.downloads"); + const medal = ["🥇", "🥈", "🥉"]; + + const list = results.map((r, i) => { + const icon = medal[i] ?? `${i + 1}.`; + const dl = r.downloads.toLocaleString("de-DE"); + const stars = r.rating > 0 ? `⭐ ${r.rating.toFixed(1)}` : "⭐ –"; + return `${icon} **${r.name}** (v${r.version})\n⬇️ ${dl} ${stars} 🆔 \`${r.id}\``; + }).join("\n\n"); + + const embed = new EmbedBuilder() + .setColor(ctx.guild.members.me.displayHexColor) + .setTitle(t(lang, "top.title", { sort: sortLabel })) + .setDescription(list) + .setFooter({ text: `${results.length} Plugins • ${client.config.prefix}plugin [id] für Details` }) + .setTimestamp(); + + return ctx.reply({ embeds: [embed] }); + }, +}; \ No newline at end of file diff --git a/commands/update.js b/commands/update.js new file mode 100644 index 0000000..76302e0 --- /dev/null +++ b/commands/update.js @@ -0,0 +1,143 @@ +import { EmbedBuilder, PermissionsBitField, SlashCommandBuilder } from "discord.js"; +import fs from "fs/promises"; +import { Spiget } from "spiget"; +import { + generateAvatarLink, + generateAuthorURL, + generateResourceIconURL, + getLatestVersion, + getUpdateDescription, +} from "../util/helpers.js"; + +const spiget = new Spiget("Viper-Network"); + +export default { + name: "update", + description: "Führt manuell einen Update-Check für eine bestimmte Ressource durch", + aliases: [], + guild: ["all"], + nsfw: false, + user_permissions: [PermissionsBitField.Flags.Administrator], + bot_permissions: [], + args_required: 1, + args_usage: "[ressourcen_id] Beispiel: vn!update 72678", + cooldown: 10, + + data: new SlashCommandBuilder() + .setName("update") + .setDescription("Manueller Update-Check für eine Ressource") + .addStringOption((opt) => + opt.setName("ressourcen_id").setDescription("Spiget Ressourcen-ID").setRequired(true) + ), + + async execute(client, ctx, args) { + const resourceID = ctx.isSlash + ? ctx.interaction.options.getString("ressourcen_id") + : args[0]; + + const guildID = ctx.guild.id; + const filePath = `./serverdata/${guildID}.json`; + + // Load server data + let saveData; + try { + const raw = await fs.readFile(filePath, "utf8"); + saveData = JSON.parse(raw); + } catch { + return ctx.reply("Dieser Server hat keine beobachteten Ressourcen."); + } + + const watched = saveData.watchedResources.find( + (r) => String(r.resourceID) === String(resourceID) + ); + + if (!watched) { + return ctx.reply( + `Ressource \`${resourceID}\` wird auf diesem Server nicht beobachtet. Füge sie zuerst mit \`${client.config.prefix}add\` hinzu.` + ); + } + + // Fetch resource info + let resource, author; + try { + resource = await spiget.getResource(resourceID); + author = await resource.getAuthor(); + } catch (e) { + client.logger.error(e); + return ctx.reply("Ressourcen-Informationen konnten nicht abgerufen werden."); + } + + let latestVersion; + try { + latestVersion = await getLatestVersion(resourceID); + } catch (e) { + client.logger.error(e); + return ctx.reply("Version konnte nicht abgerufen werden."); + } + + const previousVersion = watched.lastCheckedVersion; + const hasUpdate = previousVersion !== latestVersion; + + const authorURL = generateAuthorURL(author.name, author.id); + const authorAvatarURL = generateAvatarLink(author.id); + const resourceIconURL = generateResourceIconURL(resource); + const resourceURL = `https://spigotmc.org/resources/.${resourceID}/`; + + if (!hasUpdate) { + const upToDateEmbed = new EmbedBuilder() + .setAuthor({ name: `Autor: ${author.name}`, iconURL: authorAvatarURL, url: authorURL }) + .setColor("#00FF00") + .setTitle(`✅ ${resource.name} ist aktuell`) + .setDescription(resource.tag) + .addFields([ + { name: "Version", value: `**${latestVersion}**`, inline: true }, + { name: "Status", value: "Kein Update verfügbar", inline: true }, + ]) + .setThumbnail(resourceIconURL) + .setTimestamp(); + + return ctx.reply({ embeds: [upToDateEmbed] }); + } + + // Update available + let updateDesc; + try { + updateDesc = await getUpdateDescription(resourceID); + } catch { + updateDesc = "Update-Beschreibung konnte nicht abgerufen werden."; + } + + const updateEmbed = new EmbedBuilder() + .setAuthor({ name: `Autor: ${author.name}`, iconURL: authorAvatarURL, url: authorURL }) + .setColor("#FFA500") + .setTitle(`🔔 Update verfügbar: ${resource.name}`) + .setDescription(resource.tag) + .addFields([ + { + name: "📦 Version", + value: `~~${previousVersion}~~ → **${latestVersion}**`, + inline: false, + }, + { name: "📝 Update-Beschreibung", value: updateDesc, inline: false }, + { name: "⬇️ Download", value: resourceURL, inline: false }, + ]) + .setThumbnail(resourceIconURL) + .setTimestamp(); + + // Update saved version + watched.lastCheckedVersion = latestVersion; + try { + await fs.writeFile(filePath, JSON.stringify(saveData, null, 2)); + } catch (e) { + client.logger.error(e); + } + + // Also send to the watched channel if different from current + const watchedChannel = ctx.guild.channels.cache.get(watched.channelID); + if (watchedChannel && watchedChannel.id !== ctx.channel.id) { + watchedChannel.send({ embeds: [updateEmbed] }).catch(client.logger.error); + } + + return ctx.reply({ embeds: [updateEmbed] }); + }, +}; \ No newline at end of file