namespace Aeshnidae.AdminAudit; /// The live Discord feed. Optional - the JSONL file is the system of record. public class DiscordSettings { public bool Enabled { get; set; } = false; /// /// Webhook for a *private* audit channel. Treat as a credential; it lives only in /// the deployed mod folder, which Mods\.gitignore excludes. /// public string WebhookUrl { get; set; } = ""; public string Username { get; set; } = "Aelrynth audit"; /// /// Send events whose detail starts with a prefix to a different webhook. The /// audit channel gets busy, and FairPlay's notices - which arrive here via /// PlayerManager.BroadcastToAuditChannel - are a different audience from staff /// actions. Anything unmatched goes to . /// /// "Routes": { "[FairPlay]": "https://discord.com/api/webhooks/..." } /// public Dictionary Routes { get; set; } = new(StringComparer.OrdinalIgnoreCase); /// Post the look-but-don't-touch commands too. Off by default; they are still written to file. public bool IncludeReadOnly { get; set; } = false; /// Seconds of events coalesced into one post. Same rate-limit reasoning as the chat relay. public double BatchSeconds { get; set; } = 3.0; } public class LogSettings { /// Empty means <mod folder>\audit. public string Directory { get; set; } = ""; /// Days of audit files to keep. 0 keeps everything - which is a defensible choice for an audit trail. public int RetentionDays { get; set; } = 0; /// /// How long a record may sit in memory before it is on disk. Short by design: a /// crash must not take the trail with it. /// public double FlushSeconds { get; set; } = 1.0; } public class Settings { public const string FileName = "Settings.json"; public bool Enabled { get; set; } = true; /// /// Lowest access level worth recording. "Advocate" means everything above a plain /// player. Set to "Player" to audit absolutely everyone, which is mostly useful /// when chasing a specific incident. /// public string MinimumAccessLevel { get; set; } = nameof(AccessLevel.Advocate); public LogSettings Log { get; set; } = new(); public DiscordSettings Discord { get; set; } = new(); /// /// Commands that only read state. Recorded, but marked "readonly": true so /// the Discord feed can skip them and a grep can filter them out. Extend freely - /// getting this list wrong costs noise, never coverage. /// public string[] ReadOnlyCommands { get; set; } = { "who", "listmods", "mod", "adminaudit", "serverstatus", "serverperformance", "gamecast", "telemetry", "whereami", "loc", "getpos", "listnearby", "showprops", "propertydump", "queryplayer", "finger", "lb", "landblockinfo", "commandhelp", "help", "acecommands", "version", "modlist", "discordrelay", }; /// /// Journal commands that need no more than Player access, when a privileged account /// runs them. /// /// Off by default, because those are not uses of privilege - they are things any /// player could do, and an admin who plays the game normally, or runs a client /// plugin that polls something on a timer, buries the trail in them. /// /// Nothing substantive is lost: the actions that matter are captured by their own /// hooks regardless of this setting. A bank move is recorded by the BankService /// hook, an XP transfer by the Transfer hook, a conjured item by the inventory hook. /// What is dropped is only the echo of the command itself. /// /// Refused commands are always journalled, whatever this is set to - a player /// reaching for a command they do not have is the point of the whole exercise. /// public bool JournalPlayerLevelCommands { get; set; } = false; /// /// Player-access commands to journal anyway, when /// is off. /// /// Empty by default, deliberately. The obvious entries would be the value-moving /// commands - bank and xp - but naming commands here is the wrong handle for them: /// "bank" and its alias "b" are the same action, so listing one and not the other /// makes the trail depend on which alias someone typed. Their effects are recorded /// by the BankService and Transfer hooks anyway, with the amount, the target and /// whether it succeeded - strictly better evidence than an echo of the command line. /// public string[] AlwaysJournalCommands { get; set; } = Array.Empty(); /// /// Also record actions by these characters when they are below /// . For watching a specific account. /// public string[] AlwaysAudit { get; set; } = Array.Empty(); /// Try to hook Aeshnidae.Bank, Aeshnidae.XpCurrency and Aeshnidae.InstancesNoDat. public bool AuditSiblingMods { get; set; } = true; /// Also echo every record to the server log. Off by default - the JSONL file is better in every way. public bool EchoToServerLog { get; set; } = false; private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true, ReadCommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true, }; /// parsed, falling back to Advocate. public AccessLevel Threshold => Enum.TryParse(MinimumAccessLevel, true, out var level) ? level : AccessLevel.Advocate; public bool IsReadOnlyCommand(string? command) => !string.IsNullOrEmpty(command) && ReadOnlyCommands.Any(c => string.Equals(c, command, StringComparison.OrdinalIgnoreCase)); /// /// Should this command appear in the journal? is the /// access the command itself demands, not the actor's. /// public bool ShouldJournalCommand(string? command, AccessLevel required, bool denied) { if (denied || required > AccessLevel.Player || JournalPlayerLevelCommands) return true; return !string.IsNullOrEmpty(command) && AlwaysJournalCommands.Any(c => string.Equals(c, command, StringComparison.OrdinalIgnoreCase)); } public bool IsAlwaysAudited(string? name) => !string.IsNullOrEmpty(name) && AlwaysAudit.Any(n => string.Equals(n, name, StringComparison.OrdinalIgnoreCase)); /// Is this actor worth recording? public bool ShouldAudit(AccessLevel access, string? name) => access >= Threshold || IsAlwaysAudited(name); public string ResolveDirectory(string modPath) => string.IsNullOrWhiteSpace(Log?.Directory) ? Path.Combine(modPath, "audit") : Log!.Directory; public static Settings Load(string modPath) { var path = Path.Combine(modPath, FileName); try { if (File.Exists(path)) { var loaded = JsonSerializer.Deserialize(File.ReadAllText(path), JsonOptions) ?? new Settings(); loaded.Log ??= new LogSettings(); loaded.Discord ??= new DiscordSettings(); loaded.ReadOnlyCommands ??= Array.Empty(); loaded.AlwaysAudit ??= Array.Empty(); loaded.AlwaysJournalCommands ??= Array.Empty(); return loaded; } var defaults = new Settings(); defaults.Save(modPath); ModManager.Log($"[{Mod.Name}] wrote default settings to {path}"); return defaults; } catch (Exception ex) { // Defaults audit MORE than a broken config would. Failing open is the right // direction for an audit trail. ModManager.Log($"[{Mod.Name}] could not read {path}, using defaults: {ex.Message}", ModManager.LogLevel.Warn); return new Settings(); } } public void Save(string modPath) { try { File.WriteAllText(Path.Combine(modPath, FileName), JsonSerializer.Serialize(this, JsonOptions)); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] could not save settings: {ex.Message}", ModManager.LogLevel.Error); } } }