C#:
using System;
using System.Collections.Generic;
using System.Text;
using Newtonsoft.Json;
using Oxide.Core;
using Oxide.Core.Libraries;
using UnityEngine;
using Time = UnityEngine.Time;
namespace Oxide.Plugins
{
[Info("TGOCF7Report", "TheGhostOfChaos", "1.0.0")]
[Description("Forwards in-game F7 reports to a Discord webhook")]
public class TGOCF7Report : RustPlugin
{
#region Fields
private Configuration config;
private readonly Queue<string> _queue = new Queue<string>();
private bool _sending;
private readonly Dictionary<ulong, float> _cooldowns = new Dictionary<ulong, float>();
private const string PermBypass = "tgocf7report.bypass";
#endregion
#region Configuration
private class Configuration
{
[JsonProperty("Discord webhook URL")]
public string WebhookUrl = "Change me to the Discord webhook";
[JsonProperty("Webhook bot name")]
public string BotName = "TGOC Reports";
[JsonProperty("Webhook avatar URL (empty = default)")]
public string AvatarUrl = "";
[JsonProperty("Embed thumbnail URL (empty = none)")]
public string ThumbnailUrl = "";
[JsonProperty("Default embed color (decimal)")]
public int EmbedColor = 15158332;
[JsonProperty("Role ID to ping (empty = no ping)")]
public string MentionRoleId = "";
[JsonProperty("Server name shown in embed")]
public string ServerName = "TGOC | Report forwarding";
[JsonProperty("Show Steam profile links")]
public bool ShowSteamLinks = true;
[JsonProperty("Show reporter position / grid")]
public bool ShowPosition = true;
[JsonProperty("Show reported player online status and position")]
public bool ShowTargetStatus = true;
[JsonProperty("Show copy-paste admin commands")]
public bool ShowAdminCommands = true;
[JsonProperty("Cooldown per player in seconds (0 = disabled)")]
public float Cooldown = 30f;
[JsonProperty("Log reports to server console")]
public bool LogToConsole = true;
[JsonProperty("Log reports to file")]
public bool LogToFile = true;
[JsonProperty("Report type labels")]
public Dictionary<string, string> TypeLabels = new Dictionary<string, string>
{
["cheat"] = "🔴 Cheating",
["abusive"] = "🗯️ Abusive behaviour",
["spam"] = "📢 Spam",
["name"] = "🏷️ Offensive name",
["break_server_rules"] = "📕 Breaking server rules",
["other"] = "❔ Other"
};
[JsonProperty("Report type colors (decimal)")]
public Dictionary<string, int> TypeColors = new Dictionary<string, int>
{
["cheat"] = 15158332,
["abusive"] = 15105570,
["spam"] = 10181046,
["name"] = 3447003,
["break_server_rules"] = 16776960,
["other"] = 9807270
};
}
protected override void LoadDefaultConfig()
{
config = new Configuration();
PrintWarning("New configuration file created. Please fill in your webhook URL!");
}
protected override void LoadConfig()
{
base.LoadConfig();
try
{
config = Config.ReadObject<Configuration>();
if (config == null) throw new Exception("Config is null");
}
catch (Exception ex)
{
PrintError($"Failed to read config ({ex.Message}). Using default values.");
config = new Configuration();
}
SaveConfig();
}
protected override void SaveConfig() => Config.WriteObject(config, true);
#endregion
#region Hooks
private void Init()
{
permission.RegisterPermission(PermBypass, this);
try
{
if (config == null) config = new Configuration();
if (Config != null) SaveConfig();
}
catch (Exception ex)
{
PrintError($"Could not write config: {ex.Message}");
}
}
private void OnServerInitialized()
{
Puts($"Config file: {Config.Filename}");
if (string.IsNullOrEmpty(config.WebhookUrl) || !config.WebhookUrl.StartsWith("http"))
PrintWarning("No valid webhook URL set - reports will not be sent.");
}
private void OnPlayerReported(BasePlayer reporter, string targetName, string targetId,
string subject, string message, string type)
{
if (reporter == null) return;
if (config.Cooldown > 0f && !permission.UserHasPermission(reporter.UserIDString, PermBypass))
{
float last;
if (_cooldowns.TryGetValue(reporter.userID, out last) &&
Time.realtimeSinceStartup - last < config.Cooldown)
{
return;
}
_cooldowns[reporter.userID] = Time.realtimeSinceStartup;
}
if (config.LogToConsole)
Puts($"[F7] {reporter.displayName} ({reporter.UserIDString}) reported {targetName} ({targetId}) | {type} | {subject}");
if (config.LogToFile)
LogToFile("reports", $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {reporter.displayName} ({reporter.UserIDString}) -> {targetName} ({targetId}) | Type: {type} | Subject: {subject} | Message: {message}", this);
SendReport(reporter, targetName, targetId, subject, message, type);
}
private void Unload()
{
_queue.Clear();
_cooldowns.Clear();
}
#endregion
#region Discord
private void SendReport(BasePlayer reporter, string targetName, string targetId,
string subject, string message, string type)
{
if (string.IsNullOrEmpty(config.WebhookUrl) || !config.WebhookUrl.StartsWith("http")) return;
BasePlayer target = FindTarget(targetId);
var fields = new List<Field>
{
new Field("👤 Reporter", PlayerBlock(reporter.displayName, reporter.UserIDString, null), true),
new Field("🎯 Reported", PlayerBlock(targetName, targetId, target), true),
new Field("📋 Reason", TypeLabel(type), true)
};
if (config.ShowPosition)
fields.Add(new Field("📍 Reporter", PositionBlock(reporter), true));
if (config.ShowTargetStatus && target != null)
fields.Add(new Field("📍 Reported", PositionBlock(target), true));
if (config.ShowAdminCommands)
fields.Add(new Field("⚙️ Admin", AdminBlock(reporter, target, targetId), false));
var embed = new Embed
{
Author = new Author { Name = "New F7 Report" },
Title = CleanSubject(subject, type),
Description = Quote(message),
Color = TypeColor(type),
Fields = fields,
Footer = new Footer { Text = $"{config.ServerName} • {ConVar.Server.hostname}" },
Timestamp = DateTime.UtcNow.ToString("o")
};
if (!string.IsNullOrEmpty(config.ThumbnailUrl))
embed.Thumbnail = new Media { Url = config.ThumbnailUrl };
var payload = new WebhookPayload
{
Username = string.IsNullOrEmpty(config.BotName) ? null : config.BotName,
AvatarUrl = string.IsNullOrEmpty(config.AvatarUrl) ? null : config.AvatarUrl,
Content = string.IsNullOrEmpty(config.MentionRoleId) ? null : $"<@&{config.MentionRoleId}>",
Embeds = new List<Embed> { embed }
};
Enqueue(JsonConvert.SerializeObject(payload, Formatting.None,
new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }));
}
#endregion
#region Formatting helpers
private BasePlayer FindTarget(string targetId)
{
if (!config.ShowTargetStatus || string.IsNullOrEmpty(targetId)) return null;
try
{
ulong id;
if (!ulong.TryParse(targetId, out id)) return null;
return BasePlayer.FindByID(id) ?? BasePlayer.FindSleeping(id);
}
catch
{
return null;
}
}
private string PlayerBlock(string name, string id, BasePlayer found)
{
string clean = Escape(string.IsNullOrEmpty(name) ? "Unknown" : name);
var sb = new StringBuilder();
sb.Append(config.ShowSteamLinks
? $"**[{Truncate(clean, 60)}](https://steamcommunity.com/profiles/{id})**"
: $"**{Truncate(clean, 60)}**");
sb.Append($"\n`{id}`");
if (config.ShowTargetStatus && found != null)
sb.Append(found.IsConnected ? "\n🟢 Online" : "\n💤 Sleeping");
return sb.ToString();
}
private string PositionBlock(BasePlayer player)
{
try
{
Vector3 pos = player.transform.position;
return $"Grid **{MapHelper.PositionToString(pos)}**\n`{pos.x:F0} {pos.y:F0} {pos.z:F0}`";
}
catch
{
return "*unknown*";
}
}
private string AdminBlock(BasePlayer reporter, BasePlayer target, string targetId)
{
var sb = new StringBuilder("```\n");
if (target != null)
{
Vector3 t = target.transform.position;
sb.Append($"teleportpos {t.x:F0},{t.y:F0},{t.z:F0}\n");
}
Vector3 r = reporter.transform.position;
sb.Append($"teleportpos {r.x:F0},{r.y:F0},{r.z:F0}\n");
sb.Append($"{targetId}\n```");
return sb.ToString();
}
private string TypeLabel(string type)
{
if (string.IsNullOrEmpty(type)) return "*Unknown*";
string label;
if (config.TypeLabels != null && config.TypeLabels.TryGetValue(type, out label) && !string.IsNullOrEmpty(label))
return label;
string pretty = type.Replace('_', ' ').Trim();
return pretty.Length == 0 ? "*Unknown*" : char.ToUpper(pretty[0]) + pretty.Substring(1);
}
private int TypeColor(string type)
{
int color;
if (config.TypeColors != null && !string.IsNullOrEmpty(type) &&
config.TypeColors.TryGetValue(type, out color))
return color;
return config.EmbedColor;
}
private string CleanSubject(string subject, string type)
{
if (string.IsNullOrEmpty(subject)) return "No subject";
if (!string.IsNullOrEmpty(type))
{
string prefix = $"[{type}]";
if (subject.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
subject = subject.Substring(prefix.Length).Trim();
}
return Truncate(subject, 240);
}
private static string Quote(string text)
{
if (string.IsNullOrEmpty(text)) return "> *No message provided*";
string clean = Truncate(text, 3000).Replace("\r", "");
return "> " + clean.Replace("\n", "\n> ");
}
private static string Escape(string input)
{
if (string.IsNullOrEmpty(input)) return input;
var sb = new StringBuilder(input.Length);
foreach (char c in input)
{
if (c == '*' || c == '_' || c == '~' || c == '`' || c == '|' ||
c == '[' || c == ']' || c == '\\' || c == '>' || c == '#')
sb.Append('\\');
sb.Append(c);
}
return sb.ToString();
}
private static string Truncate(string input, int max)
{
if (string.IsNullOrEmpty(input)) return "-";
return input.Length <= max ? input : input.Substring(0, max - 3) + "...";
}
#endregion
#region Webhook queue
private void Enqueue(string payload)
{
_queue.Enqueue(payload);
if (!_sending) ProcessQueue();
}
private void ProcessQueue()
{
if (_queue.Count == 0)
{
_sending = false;
return;
}
_sending = true;
string payload = _queue.Peek();
var headers = new Dictionary<string, string> { ["Content-Type"] = "application/json" };
webrequest.Enqueue(config.WebhookUrl, payload, (code, response) =>
{
if (code == 200 || code == 204)
{
_queue.Dequeue();
timer.Once(0.5f, ProcessQueue);
return;
}
if (code == 429) // rate limited: retry
{
float retry = 2f;
try
{
var obj = JsonConvert.DeserializeObject<Dictionary<string, object>>(response);
object val;
if (obj != null && obj.TryGetValue("retry_after", out val))
{
retry = Convert.ToSingle(val);
if (retry > 100f) retry /= 1000f;
}
}
catch { }
timer.Once(Mathf.Clamp(retry + 0.25f, 0.5f, 60f), ProcessQueue);
return;
}
PrintWarning($"Discord webhook error (HTTP {code}): {response}");
_queue.Dequeue();
timer.Once(1f, ProcessQueue);
}, this, RequestMethod.POST, headers, 10f);
}
#endregion
#region Test command
[ConsoleCommand("tgocf7report.test")]
private void CmdTest(ConsoleSystem.Arg arg)
{
if (arg.Connection != null && !arg.IsAdmin) return;
var player = arg.Player();
if (player == null)
{
arg.ReplyWith("Run this command in-game (F1) so the plugin has a reporter to work with.");
return;
}
SendReport(player, "Test Player", "76561198000000000",
"[break_server_rules] Test report",
"This is a test from TGOCF7Report.\nSecond line to show multi-line formatting.",
"break_server_rules");
arg.ReplyWith("Test report sent to Discord.");
}
#endregion
#region Payload classes
private class WebhookPayload
{
[JsonProperty("username")] public string Username;
[JsonProperty("avatar_url")] public string AvatarUrl;
[JsonProperty("content")] public string Content;
[JsonProperty("embeds")] public List<Embed> Embeds;
}
private class Embed
{
[JsonProperty("author")] public Author Author;
[JsonProperty("title")] public string Title;
[JsonProperty("description")] public string Description;
[JsonProperty("color")] public int Color;
[JsonProperty("fields")] public List<Field> Fields;
[JsonProperty("thumbnail")] public Media Thumbnail;
[JsonProperty("footer")] public Footer Footer;
[JsonProperty("timestamp")] public string Timestamp;
}
private class Author
{
[JsonProperty("name")] public string Name;
[JsonProperty("icon_url")] public string IconUrl;
}
private class Media
{
[JsonProperty("url")] public string Url;
}
private class Field
{
[JsonProperty("name")] public string Name;
[JsonProperty("value")] public string Value;
[JsonProperty("inline")] public bool Inline;
public Field(string name, string value, bool inline)
{
Name = name;
Value = value;
Inline = inline;
}
}
private class Footer
{
[JsonProperty("text")] public string Text;
}
#endregion
}
}