namespace Aeshnidae.AdminAudit;
///
/// Optional live feed of the audit trail into a private Discord channel.
///
/// Deliberately a second implementation rather than a call into Aeshnidae.DiscordRelay:
/// mods load into separate collectible assembly contexts, so calling across is awkward
/// and would couple the audit trail's availability to a chat relay's. The duplication
/// is the cheaper of the two costs. If a third consumer ever appears, extract this into
/// a shared source file that both projects <Compile Include>.
///
/// This sink is a convenience, never the record - if Discord is down, the JSONL file is
/// unaffected.
///
internal sealed class DiscordSink : IDisposable
{
private const int DiscordContentLimit = 1900;
private const int MaxPostsPerFlush = 2;
private const int MaxQueued = 500;
private readonly DiscordSettings _settings;
private readonly HttpClient _http;
private readonly ConcurrentQueue _queue = new();
private readonly CancellationTokenSource _cts = new();
private readonly Task _pump;
private long _posted, _dropped;
private volatile bool _stopping, _parked;
private volatile string _lastError = "";
public long Posted => Interlocked.Read(ref _posted);
public long Dropped => Interlocked.Read(ref _dropped);
public string LastError => _lastError;
public bool Parked => _parked;
public int Pending => _queue.Count;
/// The webhook this sink posts to; the routing table may create several.
public string WebhookUrl { get; }
/// Name for status output - the route prefix, or "default".
public string Label { get; }
public DiscordSink(DiscordSettings settings, string webhookUrl, string label = "default")
{
_settings = settings;
WebhookUrl = webhookUrl;
Label = label;
_http = new HttpClient { Timeout = TimeSpan.FromSeconds(15) };
_http.DefaultRequestHeaders.UserAgent.ParseAdd("Aeshnidae-AdminAudit/1.0");
_pump = Task.Run(PumpAsync);
}
public void Post(AuditEvent record)
{
if (_stopping || _parked)
return;
if (record.ReadOnly && !_settings.IncludeReadOnly)
return;
if (_queue.Count >= MaxQueued)
{
Interlocked.Increment(ref _dropped);
return;
}
_queue.Enqueue(Escape(record.ToLine()));
}
private async Task PumpAsync()
{
var interval = TimeSpan.FromSeconds(Math.Clamp(_settings.BatchSeconds, 1, 60));
while (!_stopping)
{
try
{
await Task.Delay(interval, _cts.Token);
}
catch (OperationCanceledException)
{
break;
}
await FlushAsync(_cts.Token);
}
using var final = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await FlushAsync(final.Token);
}
private async Task FlushAsync(CancellationToken ct)
{
if (_queue.IsEmpty || _parked)
return;
var posts = new List();
var sb = new StringBuilder();
while (posts.Count < MaxPostsPerFlush && _queue.TryPeek(out var peeked))
{
var line = peeked.Length > DiscordContentLimit ? peeked[..(DiscordContentLimit - 1)] + "…" : peeked;
if (sb.Length > 0 && sb.Length + 1 + line.Length > DiscordContentLimit)
{
posts.Add(sb.ToString());
sb.Clear();
if (posts.Count == MaxPostsPerFlush)
break;
}
_queue.TryDequeue(out _);
if (sb.Length > 0)
sb.Append('\n');
sb.Append(line);
}
if (sb.Length > 0 && posts.Count < MaxPostsPerFlush)
posts.Add(sb.ToString());
foreach (var content in posts)
{
if (ct.IsCancellationRequested || !await PostAsync(content, ct))
return;
}
}
private async Task PostAsync(string content, CancellationToken ct)
{
var payload = new
{
content,
username = string.IsNullOrWhiteSpace(_settings.Username)
? null
: _settings.Username.Replace("discord", "disc0rd", StringComparison.OrdinalIgnoreCase),
allowed_mentions = new { parse = Array.Empty() },
};
var json = JsonSerializer.Serialize(payload);
for (var attempt = 0; attempt < 2; attempt++)
{
try
{
using var body = new StringContent(json, Encoding.UTF8, "application/json");
using var response = await _http.PostAsync(WebhookUrl, body, ct);
if (response.IsSuccessStatusCode)
{
Interlocked.Increment(ref _posted);
return true;
}
if ((int)response.StatusCode == 429 && attempt == 0)
{
var seconds = response.Headers.RetryAfter?.Delta?.TotalSeconds ?? 3;
await Task.Delay(TimeSpan.FromSeconds(Math.Clamp(seconds, 1, 30)), ct);
continue;
}
_lastError = $"HTTP {(int)response.StatusCode} {response.ReasonPhrase}";
if ((int)response.StatusCode is 401 or 403 or 404)
{
_parked = true;
ModManager.Log($"[{Mod.Name}] audit webhook rejected with HTTP {(int)response.StatusCode}; " +
"the Discord feed is parked until reload. The JSONL trail is unaffected.",
ModManager.LogLevel.Error);
}
return false;
}
catch (OperationCanceledException)
{
return false;
}
catch (Exception ex)
{
_lastError = $"{ex.GetType().Name}: {ex.Message}";
return false;
}
}
return false;
}
/// Stops player- or admin-supplied text reformatting the audit channel.
private static string Escape(string text)
{
var sb = new StringBuilder(text.Length + 8);
foreach (var c in text)
{
if (c is '\r' or '\n' or '\t')
{
sb.Append(' ');
continue;
}
if (char.IsControl(c))
continue;
if (c is '\\' or '*' or '_' or '~' or '`' or '|' or '>' or '#' or '[' or ']')
sb.Append('\\');
sb.Append(c);
}
return sb.ToString();
}
public void Dispose()
{
_stopping = true;
try
{
_cts.Cancel();
_pump.Wait(TimeSpan.FromSeconds(8));
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] audit Discord feed did not stop cleanly: {ex.Message}", ModManager.LogLevel.Warn);
}
finally
{
_http.Dispose();
_cts.Dispose();
}
}
}