Update from Git Manager GUI
This commit is contained in:
75
util/helpers.js
Normal file
75
util/helpers.js
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Shared utility functions for PluginBot
|
||||
* Centralised here to avoid duplication across commands
|
||||
*/
|
||||
|
||||
export function generateAvatarLink(authorID) {
|
||||
const idStr = authorID.toString();
|
||||
const splitPoint = Math.ceil(idStr.length / 2);
|
||||
const firstHalf = idStr.substring(0, splitPoint);
|
||||
return `https://www.spigotmc.org/data/avatars/l/${firstHalf}/${authorID}.jpg`;
|
||||
}
|
||||
|
||||
export function generateAuthorURL(authorName, authorID) {
|
||||
return `https://www.spigotmc.org/members/${authorName}.${authorID}/`;
|
||||
}
|
||||
|
||||
export function generateResourceIconURL(resource) {
|
||||
return resource.icon
|
||||
.fullUrl()
|
||||
.replace("orgdata", "org/data")
|
||||
.replace("https://spigotmc.org", "https://www.spigotmc.org");
|
||||
// www must be present – embeds don't render without it
|
||||
}
|
||||
|
||||
export function generateResourceURL(resourceID) {
|
||||
return `https://spigotmc.org/resources/.${resourceID}/`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts basic SpigotMC HTML to Discord Markdown.
|
||||
* BUG FIX: </i> was previously mapped to "**" (bold) instead of "*" (italic)
|
||||
*/
|
||||
export function formatText(description) {
|
||||
return description
|
||||
.replace(/<b>/gi, "**")
|
||||
.replace(/<\/b>/gi, "**")
|
||||
.replace(/<i>/gi, "*")
|
||||
.replace(/<\/i>/gi, "*") // ← was "**" before (bug)
|
||||
.replace(/<ul>/gi, "")
|
||||
.replace(/<\/ul>/gi, "")
|
||||
.replace(/<li>/gi, "• ")
|
||||
.replace(/<\/li>/gi, "\n")
|
||||
.replace(/<br\s*\/?>/gi, "\n")
|
||||
.replace(/<[^>]+>/g, ""); // strip any remaining HTML tags
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the latest version name for a Spiget resource.
|
||||
* Uses native fetch instead of the legacy xmlhttprequest package.
|
||||
*/
|
||||
export async function getLatestVersion(resourceID) {
|
||||
const res = await fetch(
|
||||
`https://api.spiget.org/v2/resources/${resourceID}/versions/latest`
|
||||
);
|
||||
if (!res.ok) throw new Error(`Spiget version request failed: HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
return data.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the latest update description for a Spiget resource.
|
||||
* Returns a Discord-safe string (max 1024 chars).
|
||||
*/
|
||||
export async function getUpdateDescription(resourceID) {
|
||||
const res = await fetch(
|
||||
`https://api.spiget.org/v2/resources/${resourceID}/updates/latest`
|
||||
);
|
||||
if (!res.ok) throw new Error(`Spiget update request failed: HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
const decoded = Buffer.from(data.description, "base64").toString("utf8");
|
||||
const formatted = formatText(decoded);
|
||||
return formatted.length > 1024
|
||||
? "Description is greater than 1024 characters – visit the resource page for details."
|
||||
: formatted;
|
||||
}
|
||||
228
util/i18n.js
Normal file
228
util/i18n.js
Normal file
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* util/i18n.js
|
||||
* Übersetzungen für DE und EN.
|
||||
* Nutzung: t(lang, "key") oder t(lang, "key", { var: "wert" })
|
||||
*/
|
||||
|
||||
const strings = {
|
||||
de: {
|
||||
// Allgemein
|
||||
"error.noArgs": "Du hast nicht genug Argumente angegeben!",
|
||||
"error.noPermission": "Du hast keine Berechtigung diesen Befehl auszuführen!",
|
||||
"error.onlyOwner": "Dieser Befehl kann nur vom Bot-Besitzer genutzt werden!",
|
||||
"error.cooldown": "Bitte warte {seconds} Sekunde(n) bevor du diesen Befehl erneut verwendest!",
|
||||
"error.nsfw": "Dieser Befehl ist als NSFW markiert. Bitte verwende ihn in einem NSFW-Kanal!",
|
||||
"error.notFound": "Ich konnte `{name}` nicht in der Befehlsliste finden!",
|
||||
"error.invalidID": "Ups! `{id}` ist keine gültige Ressourcen-ID!",
|
||||
"error.noAuthor": "Ups! Der Autor für Ressource `{id}` konnte nicht gefunden werden.",
|
||||
"error.apiDown": "Die API konnte nicht erreicht werden. Bitte versuche es später erneut.",
|
||||
"error.saveFailed": "Beim Speichern der Daten ist ein Fehler aufgetreten.",
|
||||
"error.timeout": "⏱️ Zeit abgelaufen – Befehl wurde abgebrochen.",
|
||||
"general.confirm": "✅ Bestätigen",
|
||||
"general.cancel": "❌ Abbrechen",
|
||||
"general.cancelled": "❌ Abgebrochen.",
|
||||
"general.author": "Autor: {name}",
|
||||
// add
|
||||
"add.alreadyWatched": "Diese Ressource wird bereits in <#{channelID}> beobachtet.",
|
||||
"add.invalidChannel": "Dieser Kanal ist ungültig. Bitte erwähne einen Kanal auf diesem Server.",
|
||||
"add.notInGuild": "Dieser Kanal befindet sich nicht auf diesem Server!",
|
||||
"add.versionFail": "Die neueste Version konnte nicht von SpigotMC abgerufen werden. Bitte versuche es erneut.",
|
||||
"add.confirmTitle": "Plugin beobachten: {name}",
|
||||
"add.confirmDesc": "{tag}\n\nSoll dieses Plugin in <#{channelID}> beobachtet werden?",
|
||||
"add.successTitle": "✅ Wird jetzt beobachtet: {name}",
|
||||
"add.buttonConfirm": "✅ Bestätigen",
|
||||
"add.channel": "Kanal",
|
||||
"add.version": "Version",
|
||||
"add.download": "Download",
|
||||
// remove
|
||||
"remove.noResources": "Dieser Server hat keine beobachteten Ressourcen!",
|
||||
"remove.notFound": "Fehler: Ressource `{id}` wurde auf diesem Server nicht beobachtet.",
|
||||
"remove.success": "✅ **{name}** (`{id}`) wird nicht mehr beobachtet.",
|
||||
"remove.saveFail": "Beim Aktualisieren der Beobachtungsdaten ist ein Fehler aufgetreten.",
|
||||
// list
|
||||
"list.noResources": "Dieser Server hat keine beobachteten Ressourcen. Nutze `{prefix}add` um eine hinzuzufügen.",
|
||||
"list.title": "📋 Beobachtete Plugins ({count})",
|
||||
"list.interval": "⏱️ Update-Intervall: {min} Minute(n)",
|
||||
"list.intervalDefault": "⏱️ Update-Intervall: 5 Minuten (Standard)",
|
||||
"list.channel": "Kanal",
|
||||
// setinterval
|
||||
"setinterval.invalid": "Ungültiger Wert. Bitte gib eine Zahl zwischen {min} und {max} Minuten an.",
|
||||
"setinterval.success": "Das Update-Intervall für diesen Server wurde auf **{min} Minute(n)** gesetzt.",
|
||||
"setinterval.title": "⏱️ Update-Intervall aktualisiert",
|
||||
"setinterval.footer": "Der nächste Check erfolgt nach Ablauf des neuen Intervalls.",
|
||||
// setchannel
|
||||
"setchannel.notWatched": "Ressource `{id}` wird auf diesem Server nicht beobachtet.",
|
||||
"setchannel.success": "✅ Update-Kanal für **{name}** wurde auf <#{channelID}> geändert.",
|
||||
// setmention
|
||||
"setmention.set": "✅ Bei Updates für **{name}** wird jetzt <@&{roleID}> gepingt.",
|
||||
"setmention.removed": "✅ Mention für **{name}** wurde entfernt.",
|
||||
"setmention.notWatched": "Ressource `{id}` wird auf diesem Server nicht beobachtet.",
|
||||
// setlang
|
||||
"setlang.success": "✅ Sprache wurde auf **Deutsch** gesetzt.",
|
||||
"setlang.title": "🌐 Sprache geändert",
|
||||
// status
|
||||
"status.title": "📡 {botName} – Status",
|
||||
"status.spiget": "🌐 Spiget API",
|
||||
"status.spigot": "🌐 SpigotMC API",
|
||||
"status.discord": "💬 Discord Ping",
|
||||
"status.queue": "⚙️ Queue",
|
||||
"status.nextCheck": "⏱️ Nächster Check",
|
||||
"status.checksTotal": "🔄 Checks gesamt",
|
||||
"status.updatesFound": "🔔 Updates gefunden",
|
||||
"status.updatesPosted": "📤 Updates gepostet",
|
||||
"status.apiErrors": "⚠️ API-Fehler",
|
||||
"status.onlineSince": "🕐 Online seit",
|
||||
"status.jobsDone": "🏓 Jobs verarbeitet",
|
||||
"status.ownerDMs": "📩 Owner-DMs",
|
||||
"status.ok": "✅ Erreichbar",
|
||||
"status.unreachable": "❌ Nicht erreichbar",
|
||||
"status.queueActive": "🔄 Aktiv ({count} ausstehend)",
|
||||
"status.queueWaiting": "⏳ Wartend ({count} Jobs)",
|
||||
"status.queueIdle": "✅ Leerlauf",
|
||||
// top
|
||||
"top.title": "🏆 Top Plugins – {sort}",
|
||||
"top.downloads": "Downloads",
|
||||
"top.rating": "Bewertung",
|
||||
"top.noData": "Keine beobachteten Ressourcen gefunden.",
|
||||
// check
|
||||
"check.title": "🔍 Kompatibilität: {name}",
|
||||
"check.compatible": "✅ Kompatibel",
|
||||
"check.incompatible": "❌ Nicht kompatibel",
|
||||
"check.unknown": "❓ Unbekannt",
|
||||
"check.testedVersions": "Getestete Versionen",
|
||||
"check.supportedVersions": "Unterstützte Versionen",
|
||||
// compare
|
||||
"compare.title": "⚖️ Vergleich",
|
||||
"compare.downloads": "⬇️ Downloads",
|
||||
"compare.rating": "⭐ Bewertung",
|
||||
"compare.version": "📦 Version",
|
||||
"compare.updated": "🕐 Letztes Update",
|
||||
"compare.winner": "🏆 Besser",
|
||||
// ping
|
||||
"ping.measuring": "Ping?",
|
||||
"ping.result": ":ping_pong: Pong! Die Latenz beträgt **{ms}ms**.",
|
||||
},
|
||||
|
||||
en: {
|
||||
// General
|
||||
"error.noArgs": "You didn't provide enough arguments!",
|
||||
"error.noPermission": "You don't have permission to run that command!",
|
||||
"error.onlyOwner": "This command can only be used by the bot owner!",
|
||||
"error.cooldown": "Please wait {seconds} second(s) before using that command again!",
|
||||
"error.nsfw": "This command is marked NSFW. Please run it in an NSFW channel!",
|
||||
"error.notFound": "I couldn't find `{name}` in the command list!",
|
||||
"error.invalidID": "Oops! `{id}` is not a valid resource ID!",
|
||||
"error.noAuthor": "Oops! I couldn't find the author for resource `{id}`.",
|
||||
"error.apiDown": "Could not reach the API. Please try again later.",
|
||||
"error.saveFailed": "An error occurred while saving the data.",
|
||||
"error.timeout": "⏱️ Timed out – command was cancelled.",
|
||||
"general.confirm": "✅ Confirm",
|
||||
"general.cancel": "❌ Cancel",
|
||||
"general.cancelled": "❌ Cancelled.",
|
||||
"general.author": "Author: {name}",
|
||||
// add
|
||||
"add.alreadyWatched": "That resource is already being watched in <#{channelID}>.",
|
||||
"add.invalidChannel": "That channel is invalid. Please mention a channel in this server.",
|
||||
"add.notInGuild": "That channel is not in this server!",
|
||||
"add.versionFail": "Could not fetch the latest version from SpigotMC. Please try again.",
|
||||
"add.confirmTitle": "Watch plugin: {name}",
|
||||
"add.confirmDesc": "{tag}\n\nShould this plugin be watched in <#{channelID}>?",
|
||||
"add.successTitle": "✅ Now watching: {name}",
|
||||
"add.buttonConfirm": "✅ Confirm",
|
||||
"add.channel": "Channel",
|
||||
"add.version": "Version",
|
||||
"add.download": "Download",
|
||||
// remove
|
||||
"remove.noResources": "This server has no watched resources!",
|
||||
"remove.notFound": "Error: Resource `{id}` was not being watched in this server.",
|
||||
"remove.success": "✅ **{name}** (`{id}`) is no longer being watched.",
|
||||
"remove.saveFail": "An error occurred while updating the watch data.",
|
||||
// list
|
||||
"list.noResources": "This server has no watched resources. Use `{prefix}add` to add one.",
|
||||
"list.title": "📋 Watched Plugins ({count})",
|
||||
"list.interval": "⏱️ Update interval: {min} minute(s)",
|
||||
"list.intervalDefault": "⏱️ Update interval: 5 minutes (default)",
|
||||
"list.channel": "Channel",
|
||||
// setinterval
|
||||
"setinterval.invalid": "Invalid value. Please provide a number between {min} and {max} minutes.",
|
||||
"setinterval.success": "The update interval for this server has been set to **{min} minute(s)**.",
|
||||
"setinterval.title": "⏱️ Update interval updated",
|
||||
"setinterval.footer": "The next check will run after the new interval has elapsed.",
|
||||
// setchannel
|
||||
"setchannel.notWatched": "Resource `{id}` is not being watched in this server.",
|
||||
"setchannel.success": "✅ Update channel for **{name}** changed to <#{channelID}>.",
|
||||
// setmention
|
||||
"setmention.set": "✅ <@&{roleID}> will now be pinged for updates to **{name}**.",
|
||||
"setmention.removed": "✅ Mention for **{name}** has been removed.",
|
||||
"setmention.notWatched": "Resource `{id}` is not being watched in this server.",
|
||||
// setlang
|
||||
"setlang.success": "✅ Language has been set to **English**.",
|
||||
"setlang.title": "🌐 Language changed",
|
||||
// status
|
||||
"status.title": "📡 {botName} – Status",
|
||||
"status.spiget": "🌐 Spiget API",
|
||||
"status.spigot": "🌐 SpigotMC API",
|
||||
"status.discord": "💬 Discord Ping",
|
||||
"status.queue": "⚙️ Queue",
|
||||
"status.nextCheck": "⏱️ Next Check",
|
||||
"status.checksTotal": "🔄 Total Checks",
|
||||
"status.updatesFound": "🔔 Updates Found",
|
||||
"status.updatesPosted": "📤 Updates Posted",
|
||||
"status.apiErrors": "⚠️ API Errors",
|
||||
"status.onlineSince": "🕐 Online Since",
|
||||
"status.jobsDone": "🏓 Jobs Processed",
|
||||
"status.ownerDMs": "📩 Owner DMs",
|
||||
"status.ok": "✅ Reachable",
|
||||
"status.unreachable": "❌ Unreachable",
|
||||
"status.queueActive": "🔄 Active ({count} pending)",
|
||||
"status.queueWaiting": "⏳ Waiting ({count} jobs)",
|
||||
"status.queueIdle": "✅ Idle",
|
||||
// top
|
||||
"top.title": "🏆 Top Plugins – {sort}",
|
||||
"top.downloads": "Downloads",
|
||||
"top.rating": "Rating",
|
||||
"top.noData": "No watched resources found.",
|
||||
// check
|
||||
"check.title": "🔍 Compatibility: {name}",
|
||||
"check.compatible": "✅ Compatible",
|
||||
"check.incompatible": "❌ Not compatible",
|
||||
"check.unknown": "❓ Unknown",
|
||||
"check.testedVersions": "Tested Versions",
|
||||
"check.supportedVersions": "Supported Versions",
|
||||
// compare
|
||||
"compare.title": "⚖️ Comparison",
|
||||
"compare.downloads": "⬇️ Downloads",
|
||||
"compare.rating": "⭐ Rating",
|
||||
"compare.version": "📦 Version",
|
||||
"compare.updated": "🕐 Last Updated",
|
||||
"compare.winner": "🏆 Better",
|
||||
// ping
|
||||
"ping.measuring": "Ping?",
|
||||
"ping.result": ":ping_pong: Pong! Latency is **{ms}ms**.",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Gibt den übersetzten String zurück.
|
||||
* Variablen werden mit {key} ersetzt.
|
||||
* @param {string} lang "de" | "en"
|
||||
* @param {string} key
|
||||
* @param {Object} [vars]
|
||||
*/
|
||||
export function t(lang, key, vars = {}) {
|
||||
const dict = strings[lang] ?? strings.de;
|
||||
let str = dict[key] ?? strings.de[key] ?? key;
|
||||
|
||||
for (const [k, v] of Object.entries(vars)) {
|
||||
str = str.replaceAll(`{${k}}`, v);
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt die gespeicherte Serversprache zurück (Standard: "de").
|
||||
* @param {Object} jsonData Serverdaten aus serverdata/
|
||||
*/
|
||||
export function getLang(jsonData) {
|
||||
return jsonData?.lang ?? "de";
|
||||
}
|
||||
22
util/logger.js
Normal file
22
util/logger.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import { createLogger, transports, format } from "winston";
|
||||
|
||||
const logFormat = format.printf(({ level, message, timestamp, stack }) => {
|
||||
return `${timestamp} - ${level} - ${stack || message}`;
|
||||
});
|
||||
|
||||
const logger = createLogger({
|
||||
format: format.combine(
|
||||
format.colorize(),
|
||||
format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }), // ← was "SS" (wrong), now "ss"
|
||||
format.errors({ stack: true }),
|
||||
logFormat
|
||||
),
|
||||
transports: [new transports.Console()],
|
||||
});
|
||||
|
||||
export default {
|
||||
debug: logger.debug.bind(logger),
|
||||
info: logger.info.bind(logger),
|
||||
warn: logger.warn.bind(logger),
|
||||
error: logger.error.bind(logger),
|
||||
};
|
||||
26
util/ownerOnly.js
Normal file
26
util/ownerOnly.js
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* util/ownerOnly.js
|
||||
* Hilfsfunktion um Owner-only Befehle zu schützen.
|
||||
*/
|
||||
|
||||
import { t } from "./i18n.js";
|
||||
|
||||
/**
|
||||
* Gibt true zurück wenn der Nutzer der Bot-Owner ist.
|
||||
* @param {Object} client
|
||||
* @param {Object} ctx message oder slash ctx
|
||||
* @param {string} lang
|
||||
*/
|
||||
export function isOwner(client, ctx) {
|
||||
const userID = ctx.isSlash ? ctx.interaction.user.id : ctx.author.id;
|
||||
return userID === client.config.ownerID;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob Owner – wenn nicht, antwortet mit Fehlermeldung und gibt false zurück.
|
||||
*/
|
||||
export async function requireOwner(client, ctx, lang = "de") {
|
||||
if (isOwner(client, ctx)) return true;
|
||||
await ctx.reply({ content: t(lang, "error.onlyOwner"), ephemeral: true });
|
||||
return false;
|
||||
}
|
||||
58
util/queue.js
Normal file
58
util/queue.js
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* util/queue.js
|
||||
* Verarbeitet Update-Checks als geordnete Warteschlange –
|
||||
* verhindert parallele API-Anfragen und Rate-Limits.
|
||||
*/
|
||||
|
||||
export class UpdateQueue {
|
||||
constructor() {
|
||||
this._queue = [];
|
||||
this._running = false;
|
||||
this.processed = 0; // Gesamt-Jobs seit Start
|
||||
this.nextRunAt = null; // Zeitstempel des nächsten geplanten Runs
|
||||
}
|
||||
|
||||
/** Anzahl der wartenden Jobs */
|
||||
get size() {
|
||||
return this._queue.length;
|
||||
}
|
||||
|
||||
/** Ist die Queue gerade aktiv? */
|
||||
get isRunning() {
|
||||
return this._running;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fügt einen asynchronen Job zur Queue hinzu.
|
||||
* @param {() => Promise<void>} job
|
||||
*/
|
||||
enqueue(job) {
|
||||
this._queue.push(job);
|
||||
this._run();
|
||||
}
|
||||
|
||||
/** Interne Abarbeitungsschleife – läuft bis Queue leer ist */
|
||||
async _run() {
|
||||
if (this._running) return;
|
||||
this._running = true;
|
||||
|
||||
while (this._queue.length > 0) {
|
||||
const job = this._queue.shift();
|
||||
try {
|
||||
await job();
|
||||
} catch (e) {
|
||||
console.error("[Queue] Job-Fehler:", e.message);
|
||||
}
|
||||
this.processed++;
|
||||
|
||||
// 500ms Pause zwischen Jobs
|
||||
if (this._queue.length > 0) {
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
}
|
||||
|
||||
this._running = false;
|
||||
}
|
||||
}
|
||||
|
||||
export default new UpdateQueue();
|
||||
71
util/stats.js
Normal file
71
util/stats.js
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* util/stats.js
|
||||
* Verfolgt Bot-Statistiken persistent in ./data/stats.json
|
||||
*/
|
||||
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
const STATS_FILE = "./data/stats.json";
|
||||
|
||||
const defaults = {
|
||||
updatesFound: 0, // Erkannte Updates gesamt
|
||||
updatesPosted: 0, // Erfolgreich gesendete Update-Embeds
|
||||
checksRun: 0, // Durchgeführte Update-Checks
|
||||
apiErrors: 0, // Spiget-API Fehler
|
||||
dmsSent: 0, // DMs an den Owner gesendet
|
||||
startedAt: null,
|
||||
};
|
||||
|
||||
function load() {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(STATS_FILE, "utf8"));
|
||||
} catch {
|
||||
return { ...defaults };
|
||||
}
|
||||
}
|
||||
|
||||
function save(data) {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(STATS_FILE), { recursive: true });
|
||||
fs.writeFileSync(STATS_FILE, JSON.stringify(data, null, 2));
|
||||
} catch (e) {
|
||||
console.error("[Stats] Fehler beim Speichern:", e.message);
|
||||
}
|
||||
}
|
||||
|
||||
class Stats {
|
||||
constructor() {
|
||||
this._data = load();
|
||||
if (!this._data.startedAt) {
|
||||
this._data.startedAt = new Date().toISOString();
|
||||
save(this._data);
|
||||
}
|
||||
}
|
||||
|
||||
/** Erhöht einen Zähler um 1 und speichert sofort */
|
||||
increment(key, by = 1) {
|
||||
if (key in this._data) {
|
||||
this._data[key] += by;
|
||||
save(this._data);
|
||||
}
|
||||
}
|
||||
|
||||
/** Gibt den aktuellen Wert eines Zählers zurück */
|
||||
get(key) {
|
||||
return this._data[key] ?? 0;
|
||||
}
|
||||
|
||||
/** Gibt alle Statistiken zurück */
|
||||
all() {
|
||||
return { ...this._data };
|
||||
}
|
||||
|
||||
/** Setzt die Startzeit auf jetzt */
|
||||
markStart() {
|
||||
this._data.startedAt = new Date().toISOString();
|
||||
save(this._data);
|
||||
}
|
||||
}
|
||||
|
||||
export default new Stats();
|
||||
Reference in New Issue
Block a user