namespace Aeshnidae.DiscordRelay; /// /// The bridge itself: an in-memory queue per Discord destination, drained by one /// background task that batches whatever accumulated into a single webhook POST. /// /// Two constraints shape this. /// /// First, is called from the network thread that is in the /// middle of handling a chat packet, so it must never block, allocate unboundedly, or /// throw. It formats a string and enqueues it; that is all. /// /// Second, Discord rate-limits webhooks at roughly 5 requests per 2 seconds each. A /// POST per chat line would trip that the moment Trade got busy, so lines are /// coalesced: one post per channel per , carrying /// everything said in that window. Chat arrives in Discord a second or two late and /// grouped, which reads better anyway. /// internal sealed class ChatRelay : IDisposable { /// Discord's hard cap on a message is 2000 characters; leave room for the join. private const int DiscordContentLimit = 1900; /// Posts allowed per channel per tick, so a flood cannot become a burst of requests. private const int MaxPostsPerFlush = 3; private readonly Settings _settings; private readonly HttpClient _http; private readonly CancellationTokenSource _cts = new(); private readonly Task _pump; private readonly ConcurrentDictionary> _queues = new(StringComparer.OrdinalIgnoreCase); /// /// Channels whose webhook Discord has rejected outright (deleted, or a bad token). /// Retrying those just burns a request every tick forever, so they are parked until /// the next /discordrelay-reload. /// private readonly ConcurrentDictionary _broken = new(StringComparer.OrdinalIgnoreCase); private long _relayed, _dropped, _posts, _failures; private volatile bool _stopping; private volatile string _lastError = ""; private DateTime _lastPostUtc; public long Relayed => Interlocked.Read(ref _relayed); public long Dropped => Interlocked.Read(ref _dropped); public long Posts => Interlocked.Read(ref _posts); public long Failures => Interlocked.Read(ref _failures); public string LastError => _lastError; public DateTime LastPostUtc => _lastPostUtc; public ChatRelay(Settings settings) { _settings = settings; _http = new HttpClient { Timeout = TimeSpan.FromSeconds(15) }; _http.DefaultRequestHeaders.UserAgent.ParseAdd("Aeshnidae-DiscordRelay/1.0"); _pump = Task.Run(PumpAsync); } public int QueueDepth(string channelKey) => _queues.TryGetValue(channelKey, out var q) ? q.Count : 0; public string? BrokenReason(string channelKey) => _broken.TryGetValue(channelKey, out var reason) ? reason : null; /// /// Called from the chat patch, on ACE's network thread. Cheap and total: every /// rejection is a silent return, and nothing here can throw into the caller. /// public void SubmitChat(ChatType chatType, string? name, string? message) { if (!_settings.Enabled || _stopping) return; if (string.IsNullOrWhiteSpace(message) || _settings.IsIgnored(name)) return; var key = Settings.Key(chatType); if (_settings.For(chatType) is null || _broken.ContainsKey(key)) return; var line = _settings.LineFormat .Replace("{name}", Escape(name ?? "someone")) .Replace("{message}", Escape(Truncate(message!, Math.Max(1, _settings.MaxMessageLength)))) .Replace("{channel}", key); Submit(key, line); if (_settings.LogRelayed) ModManager.Log($"[{Mod.Name}] {key}: {name}: {message}"); } /// Queue an already-formatted line for a channel. Used by /discordrelay-test too. public void Submit(string channelKey, string line) { var queue = _queues.GetOrAdd(channelKey, _ => new ConcurrentQueue()); // Discord being down must not turn into an ever-growing queue on a live server. if (queue.Count >= Math.Max(1, _settings.MaxQueuedPerChannel)) { Interlocked.Increment(ref _dropped); return; } queue.Enqueue(line); Interlocked.Increment(ref _relayed); } private async Task PumpAsync() { var interval = TimeSpan.FromSeconds(Math.Clamp(_settings.BatchSeconds, 0.5, 60)); while (!_stopping) { try { await Task.Delay(interval, _cts.Token); } catch (OperationCanceledException) { break; } await FlushAsync(_cts.Token); } // One last drain on the way out, on its own budget: the token above is already // cancelled by now and would abort the post before it left the building. using var final = new CancellationTokenSource(TimeSpan.FromSeconds(5)); await FlushAsync(final.Token); } private async Task FlushAsync(CancellationToken ct) { foreach (var (key, queue) in _queues) { if (queue.IsEmpty || _broken.ContainsKey(key)) continue; if (!_settings.Channels.TryGetValue(key, out var channel) || channel is null || !channel.Enabled || string.IsNullOrWhiteSpace(channel.WebhookUrl)) continue; foreach (var content in TakeBatches(queue)) { if (ct.IsCancellationRequested) return; if (!await PostAsync(key, channel, content, ct)) break; // channel is unhappy; leave the rest for the next tick } } } /// /// Pulls up to messages' worth of lines off a queue, /// packing them to just under Discord's length cap. Anything left stays queued. /// private static List TakeBatches(ConcurrentQueue queue) { var posts = new List(); var sb = new StringBuilder(); while (posts.Count < MaxPostsPerFlush && queue.TryPeek(out var peeked)) { var line = Truncate(peeked, DiscordContentLimit); 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()); return posts; } private async Task PostAsync(string key, ChannelSettings channel, string content, CancellationToken ct) { var payload = new WebhookPayload { Content = content, Username = string.IsNullOrWhiteSpace(channel.Username) ? null : SanitizeUsername(channel.Username), }; var json = JsonSerializer.Serialize(payload, PayloadOptions); for (var attempt = 0; attempt < 2; attempt++) { try { using var body = new StringContent(json, Encoding.UTF8, "application/json"); using var response = await _http.PostAsync(channel.WebhookUrl, body, ct); if (response.IsSuccessStatusCode) { Interlocked.Increment(ref _posts); _lastPostUtc = DateTime.UtcNow; return true; } // Rate limited: Discord tells us how long to wait. One retry, then give up // on this tick - the next flush carries whatever is still queued. if ((int)response.StatusCode == 429 && attempt == 0) { await Task.Delay(RetryAfter(response), ct); continue; } Interlocked.Increment(ref _failures); _lastError = $"{key}: HTTP {(int)response.StatusCode} {response.ReasonPhrase}"; // 401/403/404 mean the webhook itself is wrong or gone. Retrying every // couple of seconds until someone notices helps nobody. if ((int)response.StatusCode is 401 or 403 or 404) { _broken[key] = $"HTTP {(int)response.StatusCode} - webhook rejected; fix the URL, then /discordrelay-reload"; ModManager.Log($"[{Mod.Name}] {key} webhook rejected with HTTP {(int)response.StatusCode}; " + "relay for that channel is parked until reload.", ModManager.LogLevel.Error); } return false; } catch (OperationCanceledException) { return false; } catch (Exception ex) { Interlocked.Increment(ref _failures); _lastError = $"{key}: {ex.GetType().Name}: {ex.Message}"; return false; } } return false; } /// How long Discord asked us to wait, clamped to something sane. private static TimeSpan RetryAfter(HttpResponseMessage response) { var retryAfter = response.Headers.RetryAfter; var seconds = retryAfter?.Delta?.TotalSeconds ?? (retryAfter?.Date is { } date ? (date - DateTimeOffset.UtcNow).TotalSeconds : 2); return TimeSpan.FromSeconds(Math.Clamp(seconds, 1, 30)); } /// /// Neutralises Discord markdown in player-supplied text, so a name full of asterisks /// cannot reformat the channel, and flattens anything that would break the one /// line-per-message layout. Pings are handled separately, by allowed_mentions. /// 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(); } /// /// Cuts to characters including the ellipsis, without /// splitting a surrogate pair - AC chat is UTF-16 off the wire, and half of an /// emoji is not valid text for a JSON payload. /// private static string Truncate(string text, int max) { if (text.Length <= max) return text; var cut = Math.Max(0, max - 1); if (cut > 0 && char.IsHighSurrogate(text[cut - 1])) cut--; return text[..cut] + "…"; } /// Discord refuses webhook usernames containing "discord", and caps them at 80. private static string SanitizeUsername(string name) => Truncate(name.Replace("discord", "disc0rd", StringComparison.OrdinalIgnoreCase), 80); public void Dispose() { _stopping = true; try { _cts.Cancel(); // Bounded: this runs on the shutdown / mod-disable path, and the pump's final // flush has its own 5s budget on top of the HttpClient timeout. _pump.Wait(TimeSpan.FromSeconds(10)); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] relay did not stop cleanly: {ex.Message}", ModManager.LogLevel.Warn); } finally { _http.Dispose(); _cts.Dispose(); } } private static readonly JsonSerializerOptions PayloadOptions = new() { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, }; private sealed class WebhookPayload { [JsonPropertyName("content")] public string Content { get; set; } = ""; [JsonPropertyName("username")] public string? Username { get; set; } /// /// The important one. An empty parse list tells Discord to render @everyone, /// @here and role mentions as plain text, so nothing a player types in Trade can /// ping the whole server. /// [JsonPropertyName("allowed_mentions")] public AllowedMentions AllowedMentions { get; set; } = new(); } private sealed class AllowedMentions { [JsonPropertyName("parse")] public string[] Parse { get; set; } = Array.Empty(); } }