namespace Aeshnidae.AdminAudit;
///
/// The command an admin is executing on this thread, right now.
///
/// ACE runs a command handler synchronously on the thread that received the packet, so
/// anything a handler does - conjuring an item, granting XP - happens between the
/// bracketing patches on GameActionTalk.Handle. Recording that frame lets an
/// outcome hook say *which* command caused it, and, more usefully, lets hooks on
/// methods that gameplay also uses fire only when a command is actually running.
///
/// Player.TryCreateInInventoryWithNetworking is the reason this exists: 29 call
/// sites, most of them ordinary gameplay. Gated on an active frame it means exactly
/// "an admin conjured this", with no gameplay noise at all.
///
internal sealed class CommandFrame
{
public string Actor = "";
public string? Account;
public AccessLevel Access;
public string Command = "";
public string Detail = "";
public string Source = "ingame";
public bool ReadOnly;
}
internal static class CommandContext
{
[ThreadStatic]
private static CommandFrame? _current;
public static CommandFrame? Current => _current;
public static void Set(CommandFrame frame) => _current = frame;
public static void Clear() => _current = null;
}
///
/// Front door for every audit record. Applies the access-level filter once, in one
/// place, then fans out to the sinks.
///
internal sealed class Auditor : IDisposable
{
private readonly Settings _settings;
public AuditLog Log { get; }
/// The default sink - what unrouted events go to. Null when Discord is off.
public DiscordSink? Discord { get; }
/// Every sink, default first. One per distinct webhook; routes sharing a webhook share a sink.
public IReadOnlyList Sinks => _sinks;
private readonly List _sinks = new();
private readonly List<(string Prefix, DiscordSink Sink)> _routes = new();
private long _recorded, _filtered;
public long Recorded => Interlocked.Read(ref _recorded);
public long Filtered => Interlocked.Read(ref _filtered);
public Auditor(Settings settings, string modPath)
{
_settings = settings;
Log = new AuditLog(settings.ResolveDirectory(modPath), settings.Log);
if (settings.Discord.Enabled && !string.IsNullOrWhiteSpace(settings.Discord.WebhookUrl))
{
Discord = new DiscordSink(settings.Discord, settings.Discord.WebhookUrl);
_sinks.Add(Discord);
foreach (var (prefix, url) in settings.Discord.Routes ?? new())
{
if (string.IsNullOrWhiteSpace(prefix) || string.IsNullOrWhiteSpace(url))
continue;
// A route pointed at the default webhook, or at one another route already
// uses, reuses that sink - one pump per webhook keeps the rate limiting honest.
var sink = _sinks.FirstOrDefault(x => string.Equals(x.WebhookUrl, url, StringComparison.Ordinal));
if (sink is null)
{
sink = new DiscordSink(settings.Discord, url, prefix);
_sinks.Add(sink);
}
_routes.Add((prefix, sink));
}
}
}
/// Longest matching prefix wins; nothing matching falls through to the default.
private DiscordSink? SinkFor(AuditEvent record)
{
if (_routes.Count == 0 || string.IsNullOrEmpty(record.Detail))
return Discord;
DiscordSink? best = null;
var bestLen = -1;
foreach (var (prefix, sink) in _routes)
{
if (prefix.Length > bestLen && record.Detail.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
best = sink;
bestLen = prefix.Length;
}
}
return best ?? Discord;
}
///
/// Records an event. Total by contract: callers are Harmony patches on hot server
/// paths, so this swallows everything rather than letting an audit failure surface
/// as a gameplay bug.
///
public void Record(AuditEvent record)
{
try
{
if (!_settings.Enabled)
return;
Interlocked.Increment(ref _recorded);
Log.Write(record);
SinkFor(record)?.Post(record);
if (_settings.EchoToServerLog)
ModManager.Log($"[AUDIT] {record.ToLine()}");
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] failed to record an audit event: {ex.Message}", ModManager.LogLevel.Error);
}
}
/// Should this actor be audited at all? Counts what it turns away, so /adminaudit can show it.
public bool ShouldAudit(AccessLevel access, string? name)
{
if (_settings.ShouldAudit(access, name))
return true;
Interlocked.Increment(ref _filtered);
return false;
}
/// Fills in the who-and-where fields that every record shares.
public static AuditEvent For(Player? player, AuditKind kind, string action)
{
var record = new AuditEvent
{
Kind = kind.ToString(),
Action = action,
Actor = player?.Name ?? "CONSOLE",
Account = player?.Account?.AccountName,
Access = (player?.Session?.AccessLevel ?? AccessLevel.Player).ToString(),
Source = player is null ? "console" : "ingame",
};
try
{
record.Location = player?.Location?.ToLOCString();
}
catch
{
// A player mid-teleport can have a location that will not render. Not worth
// losing the record over.
}
// Attribute the event to the command that caused it, when there was one.
if (CommandContext.Current is { } frame)
{
record.Via = frame.Command;
record.ReadOnly = frame.ReadOnly;
}
return record;
}
public void Dispose()
{
foreach (var sink in _sinks) sink.Dispose();
Log.Dispose();
}
}