namespace Aeshnidae.Leaderboard; /// /// The daily post: the top few of each chosen board, to a webhook. Fire-and-forget /// on a thread-pool task so the refresh timer never waits on Discord; a failure is /// logged and tried again at the next refresh that falls in the posting hour. /// The day of the last successful post is kept in a file beside the dll so a /// restart in the posting hour does not post twice. /// public static class Discord { private static readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(15) }; private static string StampPath => Path.Combine(Mod.ModPath, "lastpost.txt"); private static int _posting; public static string LastResult { get; private set; } = "never posted"; /// Called after every refresh: post if this is the hour and today has not been posted. public static void MaybePostDaily() { var d = Mod.Settings.Discord; if (d.PostHourUtc < 0 || string.IsNullOrWhiteSpace(d.WebhookUrl)) return; var now = DateTime.UtcNow; if (now.Hour != d.PostHourUtc) return; var today = now.ToString("yyyy-MM-dd"); try { if (File.Exists(StampPath) && File.ReadAllText(StampPath).Trim() == today) return; } catch { /* treat as not posted */ } _ = PostAsync(today); } /// /top post: right now, regardless of the hour. public static void PostNow() => _ = PostAsync(null); public static string LastAlertResult { get; private set; } = "never posted"; /// An alert to the staff webhook, when one is set; the log has it either way. public static void PostAlert(string text) { var url = Mod.Settings.History.AlertWebhookUrl; if (string.IsNullOrWhiteSpace(url)) { LastAlertResult = "no alert webhook configured"; return; } _ = Task.Run(async () => { try { var payload = JsonSerializer.Serialize(new { content = text, username = Mod.Settings.Discord.Username }); using var body = new StringContent(payload, Encoding.UTF8, "application/json"); using var response = await _http.PostAsync(url, body); LastAlertResult = response.IsSuccessStatusCode ? $"posted {DateTime.UtcNow:yyyy-MM-dd HH:mm} UTC" : $"HTTP {(int)response.StatusCode} at {DateTime.UtcNow:HH:mm} UTC"; } catch (Exception ex) { LastAlertResult = $"failed: {ex.Message}"; } }); } private static async Task PostAsync(string? stampWhenDone) { if (Interlocked.Exchange(ref _posting, 1) == 1) return; try { var d = Mod.Settings.Discord; if (string.IsNullOrWhiteSpace(d.WebhookUrl)) { LastResult = "no webhook configured"; return; } foreach (var chunk in Chunks(Compose(d.Rows, d.Boards))) { var payload = JsonSerializer.Serialize(new { content = chunk, username = d.Username }); using var body = new StringContent(payload, Encoding.UTF8, "application/json"); using var response = await _http.PostAsync(d.WebhookUrl, body); if (!response.IsSuccessStatusCode) { LastResult = $"HTTP {(int)response.StatusCode} at {DateTime.UtcNow:HH:mm} UTC"; ModManager.Log($"[{Mod.Name}] Discord post failed: {LastResult}", ModManager.LogLevel.Warn); return; } await Task.Delay(600); // webhooks are limited to about five posts per two seconds } LastResult = $"posted {DateTime.UtcNow:yyyy-MM-dd HH:mm} UTC"; if (stampWhenDone is not null) File.WriteAllText(StampPath, stampWhenDone); } catch (Exception ex) { LastResult = $"failed: {ex.Message}"; ModManager.Log($"[{Mod.Name}] Discord post failed: {ex.Message}", ModManager.LogLevel.Warn); } finally { Interlocked.Exchange(ref _posting, 0); } } /// The post: a heading, then each board as a bold title and its rows. public static string Compose(int rows, List boardKeys) { var boards = boardKeys.Count == 0 ? Boards.All.ToList() : boardKeys.Select(Boards.Find).Where(b => b is not null).Select(b => b!).ToList(); var sb = new StringBuilder(); sb.AppendLine($"**Aelrynth leaderboards** - {DateTime.UtcNow:d MMMM yyyy}, {Boards.CharacterCount} characters. `/top` in game for the rest."); foreach (var board in boards) { if (!Boards.Snapshot.TryGetValue(board.Key, out var list) || list.Count == 0) continue; sb.AppendLine(); sb.AppendLine($"**{board.Title}**{(board.SinceInstall ? " (since 16 Sep)" : "")}"); foreach (var row in list.Take(rows)) sb.AppendLine($"{row.Rank}. {row.Name} - {row.Value:N0}{(row.Extra.Length > 0 ? $" ({row.Extra})" : "")}"); } foreach (var key in Mod.Settings.Discord.MoverBoards) { var board = Boards.Find(key); var movers = board is null ? null : History.Find(board.Key, "24h"); if (board is null || movers is null || movers.Rows.Count == 0) continue; sb.AppendLine(); sb.AppendLine($"**{board.Title} - most gained in 24 h**"); foreach (var row in movers.Rows.Take(rows)) sb.AppendLine($"{row.Rank}. {row.Name} +{row.Delta:N0} ({row.From:N0} -> {row.To:N0}){(row.IsNew ? " new" : "")}"); } return sb.ToString().TrimEnd(); } /// Under Discord's 2,000 characters, split on blank lines so a board is never cut in half. private static IEnumerable Chunks(string text) { var current = new StringBuilder(); foreach (var block in text.Split("\n\n")) { if (current.Length > 0 && current.Length + block.Length + 2 > 1900) { yield return current.ToString(); current.Clear(); } if (current.Length > 0) current.Append("\n\n"); current.Append(block); } if (current.Length > 0) yield return current.ToString(); } }