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();
}
}