Update from Git Manager GUI

This commit is contained in:
2026-02-25 18:51:08 +01:00
parent de4341a0a9
commit 1567151fce
21 changed files with 2023 additions and 0 deletions

178
commands/add.js Normal file
View File

@@ -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: [] });
},
};

216
commands/addauthor.js Normal file
View File

@@ -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: [] });
},
};

90
commands/changelog.js Normal file
View File

@@ -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 (110, 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] });
},
};

167
commands/check.js Normal file
View File

@@ -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"; }
}

130
commands/compare.js Normal file
View File

@@ -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"; }
}

73
commands/help.js Normal file
View File

@@ -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] });
},
};

68
commands/info.js Normal file
View File

@@ -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`;
}

30
commands/invite.js Normal file
View File

@@ -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] });
},
};

60
commands/list.js Normal file
View File

@@ -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] });
},
};

42
commands/ping.js Normal file
View File

@@ -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"; }
}

81
commands/plugin.js Normal file
View File

@@ -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] });
},
};

63
commands/remove.js Normal file
View File

@@ -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.`);
},
};

117
commands/search.js Normal file
View File

@@ -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] });
},
};

77
commands/setchannel.js Normal file
View File

@@ -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] });
},
};

83
commands/setinterval.js Normal file
View File

@@ -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"; }
}

67
commands/setlang.js Normal file
View File

@@ -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] });
},
};

82
commands/setmention.js Normal file
View File

@@ -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] });
},
};

51
commands/stats.js Normal file
View File

@@ -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`;
}

105
commands/status.js Normal file
View File

@@ -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
? `<t:${Math.floor(queue.nextRunAt / 1000)}:R>`
: "Unbekannt";
// --- Statistiken ---
const s = stats.all();
const startedAt = s.startedAt
? `<t:${Math.floor(new Date(s.startedAt).getTime() / 1000)}:R>`
: "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"; }
}

100
commands/top.js Normal file
View File

@@ -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] });
},
};

143
commands/update.js Normal file
View File

@@ -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] });
},
};