C#:
using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Oxide.Core;
using Oxide.Core.Libraries;
using UnityEngine;
namespace Oxide.Plugins {
[Info("PlayerReports", "Krasnokutskyi", "1.0.0")]
[Description("In-game report system with localized chat messages, config migration, admin replies and one editable Discord webhook message per report.")]
public class PlayerReports : RustPlugin {
private PluginConfig config;
private StoredData storedData;
private const string StatusOpen = "open";
private const string StatusClosed = "closed";
private const string RolePlayer = "player";
private const string RoleAdmin = "admin";
private const string RoleSystem = "system";
private readonly HashSet<string> supportedLanguages = new HashSet<string> {
"en", "ru", "uk"
}
;
#region Config
private class PluginConfig {
[JsonProperty("Server name shown in Discord")]
public string ServerName = "My Server";
[JsonProperty("Chat prefix")]
public string ChatPrefix = "<color=#ffb347>[Reports]</color>";
[JsonProperty("Admin permission")]
public string AdminPermission = "playerreports.admin";
[JsonProperty("Allow player /report to append to existing open report instead of creating a new one")]
public bool AppendToExistingOpenReport = true;
[JsonProperty("Auto-close player reports when no report admins are online")]
public bool AutoCloseReportsWhenNoAdminsOnline = true;
[JsonProperty("Discord ticket channel/link shown to players when no admins are online")]
public string DiscordTicketText = "/discord → open a ticket in Discord";
[JsonProperty("Maximum message length from player/admin")]
public int MaxSingleMessageLength = 700;
[JsonProperty("How many reports to show in /reports")]
public int ReportsPerPage = 10;
[JsonProperty("How many conversation lines to show in game chat")]
public int ChatHistoryLines = 12;
[JsonProperty("Force language for all report messages, empty = player game language/auto (en, ru, uk)")]
public string ForceLanguage = "";
[JsonProperty("Fallback language if player language is unknown (en, ru, uk)")]
public string FallbackLanguage = "en";
[JsonProperty("Discord settings")]
public DiscordConfig Discord = new DiscordConfig();
public static PluginConfig DefaultConfig() {
return new PluginConfig();
}
}
private class DiscordConfig {
[JsonProperty("Enabled")]
public bool Enabled = true;
[JsonProperty("Webhook URL")]
public string WebhookUrl = "";
[JsonProperty("Webhook username")]
public string Username = "Player Reports";
[JsonProperty("Webhook avatar URL")]
public string AvatarUrl = "";
[JsonProperty("Mention text on new report, empty = no mention. Example: <@&ROLE_ID>")]
public string NewReportMention = "";
[JsonProperty("Disable Discord mentions inside report text")]
public bool DisableMentions = true;
[JsonProperty("Max transcript length in Discord embed")]
public int MaxTranscriptLength = 3800;
[JsonProperty("Open report embed color")]
public int OpenColor = 16753920;
[JsonProperty("Closed report embed color")]
public int ClosedColor = 8421504;
}
protected override void LoadDefaultConfig() {
Config.WriteObject(PluginConfig.DefaultConfig(), true);
}
protected override void LoadConfig() {
base.LoadConfig();
try {
config = Config.ReadObject<PluginConfig>();
if (config == null) throw new Exception("Config is null");
bool changed = false;
if (config.Discord == null) {
config.Discord = new DiscordConfig();
changed = true;
}
if (config.FallbackLanguage == null) {
config.FallbackLanguage = "en";
changed = true;
}
if (config.ForceLanguage == null) {
config.ForceLanguage = "";
changed = true;
}
if (config.DiscordTicketText == null) {
config.DiscordTicketText = "/discord → open a ticket in Discord";
changed = true;
}
// Save config after every successful load so new settings are visible in oxide/config/PlayerReports.json.
SaveConfig();
}
catch {
PrintWarning("Config file is corrupted or invalid. Creating default config.");
config = PluginConfig.DefaultConfig();
SaveConfig();
}
}
protected override void SaveConfig() {
Config.WriteObject(config, true);
}
#endregion
#region Localization
protected override void LoadDefaultMessages() {
lang.RegisterMessages(new Dictionary<string, string> {
["AdminOpenCount"]
= "There are currently <color=#ffd479>{0}</color> open reports. Command: <color=#ffd479>/reports</color>", ["PlayerOpenReportOnJoin"] = "You have an open report <color=#ffd479>#{0}</color>. Add a message: <color=#ffd479>/report text</color>. View: <color=#ffd479>/myreport {0}</color>", ["ReportUsage"] = "Use: <color=#ffd479>/report issue description</color>", ["ReportCreated"] = "Report <color=#ffd479>#{0}</color> created. The administration will see it in-game and in Discord.", ["ReportCreatedNoAdmins"] = "Report <color=#ffd479>#{0}</color> was logged, but no admins are online right now. Please open a Discord ticket: <color=#ffd479>{1}</color>", ["ReportAutoClosedNoAdminsLine"] = "Report automatically closed: no admins were online. Player was asked to open a Discord ticket: {0}", ["ReportCreatedNoAdminsV2"] = "Report <color=#ffd479>#{0}</color> has been logged, but there are currently no administrators online. To help us review your issue as quickly as possible, please get the Discord link using <color=#ffd479>/discord</color> and open a ticket in the <color=#ffd479>Support</color> section.", ["ReportAutoClosedNoAdminsLineV2"] = "Report automatically closed: no admins were online. Player was asked to get the Discord link with /discord and open a ticket in the Support section.", ["ReportCreatedNoAdminsV3"] = "Report <color=#ffd479>#{0}</color> has been logged, but there are currently no administrators online on the server. To help us review your issue as quickly as possible, please get the Discord link using <color=#ffd479>/discord</color> and open a ticket in the <color=#ffd479>Support</color> section.", ["ReportAutoClosedNoAdminsLineV3"] = "Report automatically closed: no administrators were online on the server. Player was asked to get the Discord link with /discord and open a ticket in the Support section.", ["ReportMessageAdded"] = "Message added to report <color=#ffd479>#{0}</color>.", ["NoMyReports"] = "You do not have any reports yet.", ["MyReportsHeader"] = "Your latest reports:", ["MyReportLine"] = "#{0} | {1} | lines: {2} | /myreport {0}", ["ReportStatusOpen"] = "open", ["ReportStatusClosed"] = "closed", ["MyReportUsage"] = "Use: <color=#ffd479>/myreport ID</color>", ["ReportNotFound"] = "Report not found.", ["PlayerHelp"] = "Player commands:\n/report text — create a report or add to your open report\n/myreports — your reports\n/myreport ID — view conversation", ["NoAccess"] = "You do not have access to this command.", ["ReportsFilterUsage"] = "Filters: <color=#ffd479>/reports</color>, <color=#ffd479>/reports all</color>, <color=#ffd479>/reports closed</color>", ["NoReportsByFilter"] = "There are no reports for filter <color=#ffd479>{0}</color>.", ["ReportsHeader"] = "Reports ({0}), latest {1}:", ["ReportListLine"] = "#{0} | {1} | {2} [{3}] | {4} | /rview {0}", ["ReportViewUsage"] = "Use: <color=#ffd479>/rview ID</color>", ["ReportReplyUsage"] = "Use: <color=#ffd479>/rreply ID reply text</color>", ["ReportClosedNeedOpen"] = "This report is closed. Open it first: <color=#ffd479>/ropen {0}</color>", ["ReplyAdded"] = "Reply added to report <color=#ffd479>#{0}</color>.", ["AdminReplyToPlayer"] = "Admin reply for report <color=#ffd479>#{0}</color>: {1}", ["ReplyInstruction"] = "To reply, use: <color=#ffd479>/report your text</color>", ["ReportCloseUsage"] = "Use: <color=#ffd479>/rclose ID reason</color>", ["ReportAlreadyClosed"] = "This report is already closed.", ["NoReason"] = "No reason", ["ReportClosedByAdminLine"] = "Report closed by admin {0}. Reason: {1}", ["ReportClosedAdmin"] = "Report <color=#ffd479>#{0}</color> closed.", ["ReportClosedPlayer"] = "Your report <color=#ffd479>#{0}</color> has been closed. Reason: {1}", ["ReportOpenUsage"] = "Use: <color=#ffd479>/ropen ID</color>", ["ReportReopenedLine"] = "Report reopened by admin {0}.", ["ReportReopenedAdmin"] = "Report <color=#ffd479>#{0}</color> reopened.", ["AdminHelp"] = "Admin commands:\n/reports — open reports\n/reports all — all reports\n/rview ID — view conversation\n/rreply ID text or /rr ID text — reply\n/rclose ID reason — close\n/ropen ID — reopen", ["AdminNotifyNewReportTitle"] = "New report", ["AdminNotifyNewMessageTitle"] = "New message in report", ["AdminNotifyLine"] = "<color=#ffd479>{0}</color> #{1} from {2} [{3}]: {4}", ["AdminNotifyCommands"] = "Reply: <color=#ffd479>/rreply {0} text</color> | View: <color=#ffd479>/rview {0}</color>", ["ReportHeader"] = "Report <color=#ffd479>#{0}</color> | {1} | Player: {2} [{3}]", ["ChatHistoryTrimmed"] = "Showing the last {0} lines out of {1}.", ["ReportLine"] = "<color=#ffd479>[#{0}]</color> {1} | {2} | {3}: {4}", ["ReportAdminActions"] = "Reply: <color=#ffd479>/rreply {0} text</color> | Close: <color=#ffd479>/rclose {0} reason</color>", ["RolePlayer"] = "Player", ["RoleAdmin"] = "Admin", ["RoleSystem"] = "System", ["NoText"] = "no text"
}
, this, "en");
lang.RegisterMessages(new Dictionary<string, string> {
["AdminOpenCount"]
= "Сейчас открытых репортов: <color=#ffd479>{0}</color>. Команда: <color=#ffd479>/reports</color>", ["PlayerOpenReportOnJoin"] = "У вас есть открытый репорт <color=#ffd479>#{0}</color>. Добавить сообщение: <color=#ffd479>/report текст</color>. Посмотреть: <color=#ffd479>/myreport {0}</color>", ["ReportUsage"] = "Напишите: <color=#ffd479>/report описание проблемы</color>", ["ReportCreated"] = "Репорт <color=#ffd479>#{0}</color> создан. Администрация увидит его в игре и Discord.", ["ReportCreatedNoAdmins"] = "Репорт <color=#ffd479>#{0}</color> записан, но сейчас нет администраторов онлайн. Пожалуйста, откройте тикет в Discord: <color=#ffd479>{1}</color>", ["ReportAutoClosedNoAdminsLine"] = "Репорт автоматически закрыт: администраторов онлайн не было. Игроку предложено открыть тикет в Discord: {0}", ["ReportCreatedNoAdminsV2"] = "Репорт <color=#ffd479>#{0}</color> записан, но сейчас на сервере нет администраторов онлайн. Чтобы мы смогли рассмотреть вашу проблему как можно быстрее, пожалуйста, получите ссылку на наш Discord командой <color=#ffd479>/discord</color> и откройте тикет в разделе <color=#ffd479>Поддержка</color>.", ["ReportAutoClosedNoAdminsLineV2"] = "Репорт автоматически закрыт: администраторов онлайн не было. Игроку предложено получить ссылку командой /discord и открыть тикет в разделе Поддержка.", ["ReportCreatedNoAdminsV3"] = "Репорт <color=#ffd479>#{0}</color> записан, но сейчас на сервере нет администраторов онлайн. Чтобы мы смогли рассмотреть вашу проблему как можно быстрее, пожалуйста, получите ссылку на наш Discord командой <color=#ffd479>/discord</color> и откройте тикет в разделе <color=#ffd479>Поддержка</color>.", ["ReportAutoClosedNoAdminsLineV3"] = "Репорт автоматически закрыт: на сервере не было администраторов онлайн. Игроку предложено получить ссылку на Discord командой /discord и открыть тикет в разделе Поддержка.", ["ReportMessageAdded"] = "Сообщение добавлено в репорт <color=#ffd479>#{0}</color>.", ["NoMyReports"] = "У вас пока нет репортов.", ["MyReportsHeader"] = "Ваши последние репорты:", ["MyReportLine"] = "#{0} | {1} | строк: {2} | /myreport {0}", ["ReportStatusOpen"] = "открыт", ["ReportStatusClosed"] = "закрыт", ["MyReportUsage"] = "Напишите: <color=#ffd479>/myreport ID</color>", ["ReportNotFound"] = "Репорт не найден.", ["PlayerHelp"] = "Команды игроков:\n/report текст — создать репорт или дописать в открытый\n/myreports — ваши репорты\n/myreport ID — посмотреть переписку", ["NoAccess"] = "У вас нет доступа к этой команде.", ["ReportsFilterUsage"] = "Фильтры: <color=#ffd479>/reports</color>, <color=#ffd479>/reports all</color>, <color=#ffd479>/reports closed</color>", ["NoReportsByFilter"] = "Репортов по фильтру <color=#ffd479>{0}</color> нет.", ["ReportsHeader"] = "Репорты ({0}), последние {1}:", ["ReportListLine"] = "#{0} | {1} | {2} [{3}] | {4} | /rview {0}", ["ReportViewUsage"] = "Напишите: <color=#ffd479>/rview ID</color>", ["ReportReplyUsage"] = "Напишите: <color=#ffd479>/rreply ID текст ответа</color>", ["ReportClosedNeedOpen"] = "Репорт закрыт. Сначала откройте: <color=#ffd479>/ropen {0}</color>", ["ReplyAdded"] = "Ответ добавлен в репорт <color=#ffd479>#{0}</color>.", ["AdminReplyToPlayer"] = "Ответ администратора по репорту <color=#ffd479>#{0}</color>: {1}", ["ReplyInstruction"] = "Чтобы ответить, напишите: <color=#ffd479>/report ваш текст</color>", ["ReportCloseUsage"] = "Напишите: <color=#ffd479>/rclose ID причина</color>", ["ReportAlreadyClosed"] = "Этот репорт уже закрыт.", ["NoReason"] = "Без причины", ["ReportClosedByAdminLine"] = "Репорт закрыт администратором {0}. Причина: {1}", ["ReportClosedAdmin"] = "Репорт <color=#ffd479>#{0}</color> закрыт.", ["ReportClosedPlayer"] = "Ваш репорт <color=#ffd479>#{0}</color> закрыт. Причина: {1}", ["ReportOpenUsage"] = "Напишите: <color=#ffd479>/ropen ID</color>", ["ReportReopenedLine"] = "Репорт снова открыт администратором {0}.", ["ReportReopenedAdmin"] = "Репорт <color=#ffd479>#{0}</color> снова открыт.", ["AdminHelp"] = "Команды админа:\n/reports — открытые репорты\n/reports all — все репорты\n/rview ID — посмотреть переписку\n/rreply ID текст или /rr ID текст — ответить\n/rclose ID причина — закрыть\n/ropen ID — открыть снова", ["AdminNotifyNewReportTitle"] = "Новый репорт", ["AdminNotifyNewMessageTitle"] = "Новое сообщение в репорте", ["AdminNotifyLine"] = "<color=#ffd479>{0}</color> #{1} от {2} [{3}]: {4}", ["AdminNotifyCommands"] = "Ответить: <color=#ffd479>/rreply {0} текст</color> | Смотреть: <color=#ffd479>/rview {0}</color>", ["ReportHeader"] = "Репорт <color=#ffd479>#{0}</color> | {1} | Игрок: {2} [{3}]", ["ChatHistoryTrimmed"] = "Показаны последние {0} строк из {1}.", ["ReportLine"] = "<color=#ffd479>[#{0}]</color> {1} | {2} | {3}: {4}", ["ReportAdminActions"] = "Ответить: <color=#ffd479>/rreply {0} текст</color> | Закрыть: <color=#ffd479>/rclose {0} причина</color>", ["RolePlayer"] = "Игрок", ["RoleAdmin"] = "Админ", ["RoleSystem"] = "Система", ["NoText"] = "без текста"
}
, this, "ru");
lang.RegisterMessages(new Dictionary<string, string> {
["AdminOpenCount"]
= "Зараз відкритих репортів: <color=#ffd479>{0}</color>. Команда: <color=#ffd479>/reports</color>", ["PlayerOpenReportOnJoin"] = "У вас є відкритий репорт <color=#ffd479>#{0}</color>. Додати повідомлення: <color=#ffd479>/report текст</color>. Переглянути: <color=#ffd479>/myreport {0}</color>", ["ReportUsage"] = "Напишіть: <color=#ffd479>/report опис проблеми</color>", ["ReportCreated"] = "Репорт <color=#ffd479>#{0}</color> створено. Адміністрація побачить його в грі та Discord.", ["ReportCreatedNoAdmins"] = "Репорт <color=#ffd479>#{0}</color> записано, але зараз немає адміністраторів онлайн. Будь ласка, відкрийте тікет у Discord: <color=#ffd479>{1}</color>", ["ReportAutoClosedNoAdminsLine"] = "Репорт автоматично закрито: адміністраторів онлайн не було. Гравцю запропоновано відкрити тікет у Discord: {0}", ["ReportCreatedNoAdminsV2"] = "Репорт <color=#ffd479>#{0}</color> записано, але зараз на сервері немає адміністраторів онлайн. Щоб ми могли якнайшвидше розглянути вашу проблему, будь ласка, отримайте посилання на наш Discord командою <color=#ffd479>/discord</color> та відкрийте тікет у розділі <color=#ffd479>Підтримка</color>.", ["ReportAutoClosedNoAdminsLineV2"] = "Репорт автоматично закрито: адміністраторів онлайн не було. Гравцю запропоновано отримати посилання командою /discord і відкрити тікет у розділі Підтримка.", ["ReportCreatedNoAdminsV3"] = "Репорт <color=#ffd479>#{0}</color> записано, але зараз на сервері немає адміністраторів онлайн. Щоб ми могли якнайшвидше розглянути вашу проблему, будь ласка, отримайте посилання на наш Discord командою <color=#ffd479>/discord</color> та відкрийте тікет у розділі <color=#ffd479>Підтримка</color>.", ["ReportAutoClosedNoAdminsLineV3"] = "Репорт автоматично закрито: на сервері не було адміністраторів онлайн. Гравцю запропоновано отримати посилання на Discord командою /discord і відкрити тікет у розділі Підтримка.", ["ReportMessageAdded"] = "Повідомлення додано до репорту <color=#ffd479>#{0}</color>.", ["NoMyReports"] = "У вас поки немає репортів.", ["MyReportsHeader"] = "Ваші останні репорти:", ["MyReportLine"] = "#{0} | {1} | рядків: {2} | /myreport {0}", ["ReportStatusOpen"] = "відкритий", ["ReportStatusClosed"] = "закритий", ["MyReportUsage"] = "Напишіть: <color=#ffd479>/myreport ID</color>", ["ReportNotFound"] = "Репорт не знайдено.", ["PlayerHelp"] = "Команди гравців:\n/report текст — створити репорт або дописати у відкритий\n/myreports — ваші репорти\n/myreport ID — переглянути переписку", ["NoAccess"] = "У вас немає доступу до цієї команди.", ["ReportsFilterUsage"] = "Фільтри: <color=#ffd479>/reports</color>, <color=#ffd479>/reports all</color>, <color=#ffd479>/reports closed</color>", ["NoReportsByFilter"] = "Репортів за фільтром <color=#ffd479>{0}</color> немає.", ["ReportsHeader"] = "Репорти ({0}), останні {1}:", ["ReportListLine"] = "#{0} | {1} | {2} [{3}] | {4} | /rview {0}", ["ReportViewUsage"] = "Напишіть: <color=#ffd479>/rview ID</color>", ["ReportReplyUsage"] = "Напишіть: <color=#ffd479>/rreply ID текст відповіді</color>", ["ReportClosedNeedOpen"] = "Репорт закритий. Спочатку відкрийте: <color=#ffd479>/ropen {0}</color>", ["ReplyAdded"] = "Відповідь додано до репорту <color=#ffd479>#{0}</color>.", ["AdminReplyToPlayer"] = "Відповідь адміністратора по репорту <color=#ffd479>#{0}</color>: {1}", ["ReplyInstruction"] = "Щоб відповісти, напишіть: <color=#ffd479>/report ваш текст</color>", ["ReportCloseUsage"] = "Напишіть: <color=#ffd479>/rclose ID причина</color>", ["ReportAlreadyClosed"] = "Цей репорт уже закритий.", ["NoReason"] = "Без причини", ["ReportClosedByAdminLine"] = "Репорт закрито адміністратором {0}. Причина: {1}", ["ReportClosedAdmin"] = "Репорт <color=#ffd479>#{0}</color> закрито.", ["ReportClosedPlayer"] = "Ваш репорт <color=#ffd479>#{0}</color> закрито. Причина: {1}", ["ReportOpenUsage"] = "Напишіть: <color=#ffd479>/ropen ID</color>", ["ReportReopenedLine"] = "Репорт знову відкрито адміністратором {0}.", ["ReportReopenedAdmin"] = "Репорт <color=#ffd479>#{0}</color> знову відкрито.", ["AdminHelp"] = "Команди адміна:\n/reports — відкриті репорти\n/reports all — усі репорти\n/rview ID — переглянути переписку\n/rreply ID текст або /rr ID текст — відповісти\n/rclose ID причина — закрити\n/ropen ID — відкрити знову", ["AdminNotifyNewReportTitle"] = "Новий репорт", ["AdminNotifyNewMessageTitle"] = "Нове повідомлення в репорті", ["AdminNotifyLine"] = "<color=#ffd479>{0}</color> #{1} від {2} [{3}]: {4}", ["AdminNotifyCommands"] = "Відповісти: <color=#ffd479>/rreply {0} текст</color> | Переглянути: <color=#ffd479>/rview {0}</color>", ["ReportHeader"] = "Репорт <color=#ffd479>#{0}</color> | {1} | Гравець: {2} [{3}]", ["ChatHistoryTrimmed"] = "Показано останні {0} рядків із {1}.", ["ReportLine"] = "<color=#ffd479>[#{0}]</color> {1} | {2} | {3}: {4}", ["ReportAdminActions"] = "Відповісти: <color=#ffd479>/rreply {0} текст</color> | Закрити: <color=#ffd479>/rclose {0} причина</color>", ["RolePlayer"] = "Гравець", ["RoleAdmin"] = "Адмін", ["RoleSystem"] = "Система", ["NoText"] = "без тексту"
}
, this, "uk");
}
private string NormalizeLanguage(string language) {
if (string.IsNullOrEmpty(language)) return "";
language = language.Trim().ToLowerInvariant().Replace('_', '-');
if (language.StartsWith("en")) return "en";
if (language.StartsWith("ru")) return "ru";
if (language.StartsWith("uk") || language.StartsWith("ua")) return "uk";
return language.Length >= 2 ? language.Substring(0, 2) : language;
}
private string GetPlayerLanguage(BasePlayer player) {
string forced = NormalizeLanguage(config.ForceLanguage);
if (supportedLanguages.Contains(forced)) return forced;
string clientLanguage = "";
try {
if (player != null && player.net != null && player.net.connection != null && player.net.connection.info != null) {
clientLanguage = NormalizeLanguage(player.net.connection.info.GetString("global.language", ""));
}
}
catch {
clientLanguage = "";
}
if (supportedLanguages.Contains(clientLanguage)) return clientLanguage;
try {
if (player != null && !string.IsNullOrEmpty(player.UserIDString)) {
string oxideLanguage = NormalizeLanguage(lang.GetLanguage(player.UserIDString));
if (supportedLanguages.Contains(oxideLanguage)) return oxideLanguage;
}
}
catch {
}
string fallback = NormalizeLanguage(config.FallbackLanguage);
return supportedLanguages.Contains(fallback) ? fallback : "en";
}
private string Message(BasePlayer player, string key, params object[] args) {
string language = GetPlayerLanguage(player);
if (player != null && !string.IsNullOrEmpty(player.UserIDString)) {
lang.SetLanguage(language, player.UserIDString);
}
string template = lang.GetMessage(key, this, player == null ? null : player.UserIDString);
if (string.IsNullOrEmpty(template)) template = key;
return args != null && args.Length > 0 ? string.Format(template, args) : template;
}
private void Reply(BasePlayer player, string key, params object[] args) {
if (player == null) return;
player.ChatMessage($"{config.ChatPrefix} {Message(player, key, args)}");
}
#endregion
#region Data
private class StoredData {
public int LastReportId = 0;
public Dictionary<int, ReportInfo> Reports = new Dictionary<int, ReportInfo>();
}
private class ReportInfo {
public int Id;
public ulong PlayerId;
public string PlayerName;
public string Status = StatusOpen;
public long CreatedAt;
public long UpdatedAt;
public string DiscordMessageId = "";
public List<ReportLine> Lines = new List<ReportLine>();
}
private class ReportLine {
public long Time;
public string Role;
public string AuthorName;
public string AuthorId;
public string Message;
}
private void LoadData() {
try {
storedData = Interface.Oxide.DataFileSystem.ReadObject<StoredData>(Name);
}
catch {
storedData = new StoredData();
}
if (storedData == null) storedData = new StoredData();
if (storedData.Reports == null) storedData.Reports = new Dictionary<int, ReportInfo>();
}
private void SaveData() {
Interface.Oxide.DataFileSystem.WriteObject(Name, storedData);
}
#endregion
#region Hooks
private void Init() {
LoadData();
permission.RegisterPermission(config.AdminPermission, this);
Puts($"PlayerReports v1.0.0 loaded. Language config: force='{config.ForceLanguage}', fallback='{config.FallbackLanguage}'. Configuration loaded successfully.");
}
private void Unload() {
SaveData();
}
private void OnServerSave() {
SaveData();
}
private void OnPlayerInit(BasePlayer player) {
if (player == null) return;
timer.Once(3f, () => {
if (player == null || !player.IsConnected) return;
if (IsReportAdmin(player)) {
int openCount = storedData.Reports.Values.Count(r => r.Status == StatusOpen);
if (openCount > 0) {
Reply(player, "AdminOpenCount", openCount);
}
return;
}
ReportInfo playerReport = GetLatestOpenReportByPlayer(player.userID);
if (playerReport != null) {
Reply(player, "PlayerOpenReportOnJoin", playerReport.Id);
}
}
);
}
#endregion
#region Player Commands
[ChatCommand("report")]
private void CmdReport(BasePlayer player, string command, string[] args) {
if (player == null) return;
string message = GetArgs(args);
if (string.IsNullOrEmpty(message)) {
Reply(player, "ReportUsage");
return;
}
message = CleanMessage(message);
ReportInfo report = null;
bool created = false;
if (config.AppendToExistingOpenReport) {
report = GetLatestOpenReportByPlayer(player.userID);
}
if (report == null) {
report = CreateReport(player, message);
created = true;
}
else {
AddLine(report, RolePlayer, player.displayName, player.UserIDString, message);
}
bool noAdminsOnline = config.AutoCloseReportsWhenNoAdminsOnline && !HasOnlineReportAdmin(player.userID);
if (noAdminsOnline && report.Status == StatusOpen) {
report.Status = StatusClosed;
AddLine(report, RoleSystem, "System", "0", Message(player, "ReportAutoClosedNoAdminsLineV3"));
Reply(player, "ReportCreatedNoAdminsV3", report.Id);
}
else {
Reply(player, created ? "ReportCreated" : "ReportMessageAdded", report.Id);
NotifyAdmins(report, created ? "AdminNotifyNewReportTitle" : "AdminNotifyNewMessageTitle");
}
SaveData();
SendOrUpdateDiscord(report, created);
}
[ChatCommand("myreports")]
private void CmdMyReports(BasePlayer player, string command, string[] args) {
if (player == null) return;
List<ReportInfo> reports = storedData.Reports.Values .Where(r => r.PlayerId == player.userID) .OrderByDescending(r => r.UpdatedAt) .Take(8) .ToList();
if (reports.Count == 0) {
Reply(player, "NoMyReports");
return;
}
Reply(player, "MyReportsHeader");
foreach (ReportInfo report in reports) {
player.ChatMessage(Message(player, "MyReportLine", report.Id, GetStatusName(player, report.Status), report.Lines.Count));
}
}
[ChatCommand("myreport")]
private void CmdMyReport(BasePlayer player, string command, string[] args) {
if (player == null) return;
int id;
if (args.Length < 1 || !int.TryParse(args[0], out id)) {
Reply(player, "MyReportUsage");
return;
}
ReportInfo report;
if (!storedData.Reports.TryGetValue(id, out report) || report.PlayerId != player.userID) {
Reply(player, "ReportNotFound");
return;
}
SendReportToChat(player, report, false);
}
[ChatCommand("reporthelp")]
private void CmdReportHelp(BasePlayer player, string command, string[] args) {
if (player == null) return;
Reply(player, "PlayerHelp");
}
[ChatCommand("reportversion")]
private void CmdReportVersion(BasePlayer player, string command, string[] args) {
if (player == null) return;
string detected = GetPlayerLanguage(player);
string client = "";
try {
if (player.net != null && player.net.connection != null && player.net.connection.info != null) {
client = player.net.connection.info.GetString("global.language", "");
}
}
catch {
client = "unknown";
}
player.ChatMessage($"{config.ChatPrefix} PlayerReports v1.0.0 | detected language: <color=#ffd479>{detected}</color> | force: <color=#ffd479>{(string.IsNullOrEmpty(config.ForceLanguage) ? "auto" : config.ForceLanguage)}</color> | fallback: <color=#ffd479>{config.FallbackLanguage}</color>");
}
#endregion
#region Admin Commands
[ChatCommand("reports")]
private void CmdReports(BasePlayer player, string command, string[] args) {
if (!CanUseAdminCommand(player)) return;
string filter = args.Length > 0 ? args[0].ToLower() : StatusOpen;
IEnumerable<ReportInfo> query = storedData.Reports.Values;
if (filter == StatusOpen || filter == "open" || filter == "opened") {
query = query.Where(r => r.Status == StatusOpen);
filter = StatusOpen;
}
else if (filter == StatusClosed || filter == "close" || filter == "closed") {
query = query.Where(r => r.Status == StatusClosed);
filter = StatusClosed;
}
else if (filter == "all" || filter == "все") {
filter = "all";
}
else {
Reply(player, "ReportsFilterUsage");
return;
}
List<ReportInfo> reports = query.OrderByDescending(r => r.UpdatedAt).Take(Math.Max(1, config.ReportsPerPage)).ToList();
if (reports.Count == 0) {
Reply(player, "NoReportsByFilter", filter);
return;
}
Reply(player, "ReportsHeader", filter, reports.Count);
foreach (ReportInfo report in reports) {
string firstText = report.Lines.Count > 0 ? Short(report.Lines[0].Message, 60) : Message(player, "NoText");
player.ChatMessage(Message(player, "ReportListLine", report.Id, GetStatusName(player, report.Status), report.PlayerName, report.PlayerId, firstText));
}
}
[ChatCommand("rview")]
private void CmdReportView(BasePlayer player, string command, string[] args) {
if (!CanUseAdminCommand(player)) return;
int id;
if (args.Length < 1 || !int.TryParse(args[0], out id)) {
Reply(player, "ReportViewUsage");
return;
}
ReportInfo report;
if (!storedData.Reports.TryGetValue(id, out report)) {
Reply(player, "ReportNotFound");
return;
}
SendReportToChat(player, report, true);
}
[ChatCommand("rreply")]
private void CmdReportReply(BasePlayer player, string command, string[] args) {
AdminReplyCommand(player, args);
}
[ChatCommand("rr")]
private void CmdReportReplyShort(BasePlayer player, string command, string[] args) {
AdminReplyCommand(player, args);
}
private void AdminReplyCommand(BasePlayer player, string[] args) {
if (!CanUseAdminCommand(player)) return;
int id;
if (args.Length < 2 || !int.TryParse(args[0], out id)) {
Reply(player, "ReportReplyUsage");
return;
}
ReportInfo report;
if (!storedData.Reports.TryGetValue(id, out report)) {
Reply(player, "ReportNotFound");
return;
}
if (report.Status == StatusClosed) {
Reply(player, "ReportClosedNeedOpen", report.Id);
return;
}
string message = CleanMessage(GetArgs(args, 1));
AddLine(report, RoleAdmin, player.displayName, player.UserIDString, message);
SaveData();
Reply(player, "ReplyAdded", report.Id);
BasePlayer target = BasePlayer.FindByID(report.PlayerId);
if (target != null && target.IsConnected) {
Reply(target, "AdminReplyToPlayer", report.Id, message);
Reply(target, "ReplyInstruction");
}
SendOrUpdateDiscord(report, false);
}
[ChatCommand("rclose")]
private void CmdReportClose(BasePlayer player, string command, string[] args) {
if (!CanUseAdminCommand(player)) return;
int id;
if (args.Length < 1 || !int.TryParse(args[0], out id)) {
Reply(player, "ReportCloseUsage");
return;
}
ReportInfo report;
if (!storedData.Reports.TryGetValue(id, out report)) {
Reply(player, "ReportNotFound");
return;
}
if (report.Status == StatusClosed) {
Reply(player, "ReportAlreadyClosed");
return;
}
string reason = args.Length > 1 ? CleanMessage(GetArgs(args, 1)) : Message(player, "NoReason");
report.Status = StatusClosed;
AddLine(report, RoleSystem, "System", "0", Message(player, "ReportClosedByAdminLine", player.displayName, reason));
SaveData();
Reply(player, "ReportClosedAdmin", report.Id);
BasePlayer target = BasePlayer.FindByID(report.PlayerId);
if (target != null && target.IsConnected) {
Reply(target, "ReportClosedPlayer", report.Id, reason);
}
SendOrUpdateDiscord(report, false);
}
[ChatCommand("ropen")]
private void CmdReportOpen(BasePlayer player, string command, string[] args) {
if (!CanUseAdminCommand(player)) return;
int id;
if (args.Length < 1 || !int.TryParse(args[0], out id)) {
Reply(player, "ReportOpenUsage");
return;
}
ReportInfo report;
if (!storedData.Reports.TryGetValue(id, out report)) {
Reply(player, "ReportNotFound");
return;
}
report.Status = StatusOpen;
AddLine(report, RoleSystem, "System", "0", Message(player, "ReportReopenedLine", player.displayName));
SaveData();
Reply(player, "ReportReopenedAdmin", report.Id);
SendOrUpdateDiscord(report, false);
}
[ChatCommand("rhelp")]
private void CmdAdminHelp(BasePlayer player, string command, string[] args) {
if (!CanUseAdminCommand(player)) return;
Reply(player, "AdminHelp");
}
#endregion
#region Report Core
private ReportInfo CreateReport(BasePlayer player, string message) {
storedData.LastReportId++;
ReportInfo report = new ReportInfo {
Id = storedData.LastReportId, PlayerId = player.userID, PlayerName = player.displayName, Status = StatusOpen, CreatedAt = Now(), UpdatedAt = Now(), DiscordMessageId = "", Lines = new List<ReportLine>()
}
;
AddLine(report, RolePlayer, player.displayName, player.UserIDString, message);
storedData.Reports[report.Id] = report;
return report;
}
private void AddLine(ReportInfo report, string role, string authorName, string authorId, string message) {
if (report.Lines == null) report.Lines = new List<ReportLine>();
report.Lines.Add(new ReportLine {
Time = Now(), Role = role, AuthorName = authorName, AuthorId = authorId, Message = message
}
);
report.UpdatedAt = Now();
}
private ReportInfo GetLatestOpenReportByPlayer(ulong playerId) {
return storedData.Reports.Values .Where(r => r.PlayerId == playerId && r.Status == StatusOpen) .OrderByDescending(r => r.UpdatedAt) .FirstOrDefault();
}
private bool HasOnlineReportAdmin(ulong reportingPlayerId) {
foreach (BasePlayer admin in BasePlayer.activePlayerList) {
if (admin == null || !admin.IsConnected) continue;
if (admin.userID == reportingPlayerId) continue;
if (IsReportAdmin(admin)) return true;
}
return false;
}
private void NotifyAdmins(ReportInfo report, string titleKey) {
foreach (BasePlayer admin in BasePlayer.activePlayerList) {
if (admin == null || !admin.IsConnected || !IsReportAdmin(admin)) continue;
string last = report.Lines.Count > 0 ? report.Lines[report.Lines.Count - 1].Message : "";
playerSafeNotify(admin, "AdminNotifyLine", Message(admin, titleKey), report.Id, report.PlayerName, report.PlayerId, Short(last, 120));
Reply(admin, "AdminNotifyCommands", report.Id);
}
Puts($"{titleKey} #{report.Id} from {report.PlayerName} ({report.PlayerId})");
}
private void playerSafeNotify(BasePlayer player, string key, params object[] args) {
Reply(player, key, args);
}
private void SendReportToChat(BasePlayer target, ReportInfo report, bool adminView) {
Reply(target, "ReportHeader", report.Id, GetStatusName(target, report.Status), report.PlayerName, report.PlayerId);
int take = Math.Max(1, config.ChatHistoryLines);
List<ReportLine> lines = report.Lines.OrderBy(l => l.Time).ToList();
if (lines.Count > take) {
Reply(target, "ChatHistoryTrimmed", take, lines.Count);
lines = lines.Skip(lines.Count - take).ToList();
}
foreach (ReportLine line in lines) {
string roleName = GetRoleName(line.Role, target);
target.ChatMessage(Message(target, "ReportLine", report.Id, FormatTime(line.Time), roleName, line.AuthorName, line.Message));
}
if (adminView && report.Status == StatusOpen) {
Reply(target, "ReportAdminActions", report.Id);
}
}
#endregion
#region Discord
private void SendOrUpdateDiscord(ReportInfo report, bool newReport) {
if (config.Discord == null || !config.Discord.Enabled) return;
if (string.IsNullOrEmpty(config.Discord.WebhookUrl)) return;
string payload = BuildDiscordPayload(report, newReport);
Dictionary<string, string> headers = new Dictionary<string, string> {
{
"Content-Type", "application/json"
}
}
;
if (string.IsNullOrEmpty(report.DiscordMessageId)) {
string url = AddQuery(config.Discord.WebhookUrl, "wait=true");
webrequest.Enqueue(url, payload, (code, response) => {
if (code >= 200 && code <= 299) {
string messageId = ExtractDiscordMessageId(response);
if (!string.IsNullOrEmpty(messageId)) {
ReportInfo stored;
if (storedData.Reports.TryGetValue(report.Id, out stored)) {
stored.DiscordMessageId = messageId;
SaveData();
}
}
}
else {
PrintWarning($"Discord webhook send failed for report #{report.Id}. Code: {code}. Response: {Short(response, 500)}");
}
}
, this, RequestMethod.POST, headers);
}
else {
string editUrl = GetDiscordEditUrl(config.Discord.WebhookUrl, report.DiscordMessageId);
webrequest.Enqueue(editUrl, payload, (code, response) => {
if (code < 200 || code > 299) {
PrintWarning($"Discord webhook edit failed for report #{report.Id}. Code: {code}. Response: {Short(response, 500)}");
}
}
, this, RequestMethod.PATCH, headers);
}
}
private string BuildDiscordPayload(ReportInfo report, bool newReport) {
string statusText = report.Status == StatusOpen ? "OPEN" : "CLOSED";
int color = report.Status == StatusOpen ? config.Discord.OpenColor : config.Discord.ClosedColor;
string transcript = BuildDiscordTranscript(report, Math.Max(500, config.Discord.MaxTranscriptLength));
JObject embed = new JObject {
["title"]
= $"Report #{report.Id} • {statusText}", ["color"] = color, ["description"] = transcript, ["timestamp"] = DateTime.UtcNow.ToString("o"), ["footer"] = new JObject {
["text"]
= "PlayerReports • one Discord message per report"
}
, ["fields"] = new JArray {
new JObject {
["name"]
= "Player", ["value"] = $"{EscapeDiscord(report.PlayerName)}\n`{report.PlayerId}`", ["inline"] = true
}
, new JObject {
["name"]
= "Status", ["value"] = statusText, ["inline"] = true
}
, new JObject {
["name"]
= "Server", ["value"] = EscapeDiscord(config.ServerName), ["inline"] = true
}
, new JObject {
["name"]
= "In-game commands", ["value"] = $"`/rview {report.Id}` • `/rreply {report.Id} text` • `/rclose {report.Id} reason`", ["inline"] = false
}
}
}
;
JObject payload = new JObject {
["username"]
= config.Discord.Username, ["embeds"] = new JArray {
embed
}
}
;
if (!string.IsNullOrEmpty(config.Discord.AvatarUrl)) {
payload["avatar_url"] = config.Discord.AvatarUrl;
}
if (newReport && !string.IsNullOrEmpty(config.Discord.NewReportMention)) {
payload["content"] = config.Discord.NewReportMention;
}
else {
payload["content"] = "";
}
if (config.Discord.DisableMentions) {
if (newReport && !string.IsNullOrEmpty(config.Discord.NewReportMention)) {
payload["allowed_mentions"] = new JObject {
["parse"]
= new JArray {
"users", "roles"
}
}
;
}
else {
payload["allowed_mentions"] = new JObject {
["parse"]
= new JArray()
}
;
}
}
return payload.ToString(Formatting.None);
}
private string BuildDiscordTranscript(ReportInfo report, int maxLength) {
List<string> allLines = new List<string>();
List<ReportLine> lines = report.Lines.OrderBy(l => l.Time).ToList();
foreach (ReportLine line in lines) {
string role = GetRoleName(line.Role, null);
string time = FormatTime(line.Time);
string author = EscapeDiscord(line.AuthorName);
string msg = EscapeDiscord(line.Message);
allLines.Add($"`{time}` **{role} {author}:** {msg}");
}
string result = "";
for (int i = allLines.Count - 1; i >= 0; i--) {
string candidate = string.IsNullOrEmpty(result) ? allLines[i] : allLines[i] + "\n" + result;
if (candidate.Length > maxLength) {
result = "_Older messages were hidden because Discord has message/embed limits._\n" + result;
break;
}
result = candidate;
}
if (string.IsNullOrEmpty(result)) result = "No messages.";
if (result.Length > maxLength) result = result.Substring(result.Length - maxLength);
return result;
}
private string ExtractDiscordMessageId(string response) {
if (string.IsNullOrEmpty(response)) return "";
try {
JObject json = JObject.Parse(response);
JToken token;
if (json.TryGetValue("id", out token)) return token.ToString();
}
catch {
return "";
}
return "";
}
private string AddQuery(string url, string query) {
if (url.Contains("?")) return url + "&" + query;
return url + "?" + query;
}
private string GetDiscordEditUrl(string webhookUrl, string messageId) {
string cleanUrl = webhookUrl.Split('?')[0].TrimEnd('/');
return cleanUrl + "/messages/" + messageId;
}
#endregion
#region Helpers
private bool CanUseAdminCommand(BasePlayer player) {
if (player == null) return false;
if (!IsReportAdmin(player)) {
Reply(player, "NoAccess");
return false;
}
return true;
}
private bool IsReportAdmin(BasePlayer player) {
if (player == null) return false;
if (permission.UserHasPermission(player.UserIDString, config.AdminPermission)) return true;
if (player.net != null && player.net.connection != null && player.net.connection.authLevel >= 1) return true;
return false;
}
private string GetArgs(string[] args, int start = 0) {
// ... [Code preview truncated due to length. Full source in attached file] ...