using System.Collections.Concurrent; using System.Numerics; using System.Text.Json; using System.Text.Json.Serialization; using Dalamud.Bindings.ImGui; using Dalamud.Interface.Textures; using Dalamud.Interface.Textures.TextureWraps; using Dalamud.Utility; using SixLabors.ImageSharp; using SixLabors.ImageSharp.PixelFormats; namespace HellionChat; public static class EmoteCache { private static readonly string[] NotWorking = [ ":tf:", "(ditto)", "c!", "h!", "l!", "M&Mjc", "LUL3D", "p!", "POLICE2", "r!", "Pussy", "s!", "v!", "w!", "x0r6ztGiggle", "z!", "xar2EDM", "iron95Pls", "Clap2", "AlienPls3", "Life", "peepoPogClimbingTreeHard4House", "monkaGIGAftRobertDowneyJr", "DogLookingSussyAndCold", "DICKS", ]; private static readonly HttpClient Client = new(); private const string BetterTTV = "https://api.betterttv.net/3"; private const string GlobalEmotes = $"{BetterTTV}/cached/emotes/global"; private const string Top100Emotes = "{0}/emotes/shared/top?before={1}&limit=100"; private const string EmotePath = "https://cdn.betterttv.net/emote/{0}/3x"; [Serializable] private struct Top100() { [JsonPropertyName("emote")] public Emote Emote { get; set; } [JsonPropertyName("id")] public required string Id { get; set; } } [Serializable] public struct Emote() { [JsonPropertyName("id")] public required string Id { get; set; } [JsonPropertyName("code")] public required string Code { get; set; } [JsonPropertyName("imageType")] public required string ImageType { get; set; } } public enum LoadingState { Unloaded, Loading, Done, } // All fields below are uninitialised while State != Done. public static LoadingState State = LoadingState.Unloaded; private static readonly Dictionary Cache = new(); private static readonly Dictionary EmoteImages = new(); public static string[] SortedCodeArray = []; // Cancelled on Dispose to stop in-flight downloads; replaced on re-enable. private static CancellationTokenSource Cts = new(); internal static CancellationToken Token => Cts.Token; // Tracks in-flight loads so Dispose can drain them before teardown. private static readonly ConcurrentBag PendingLoads = new(); internal static void TrackLoad(Task loadTask, string emoteCode) { PendingLoads.Add( loadTask.ContinueWith( t => { if (t.IsFaulted) Plugin.LogProxy.Error( t.Exception!, $"EmoteCache load failed for {emoteCode}" ); }, TaskScheduler.Default ) ); } public static async Task LoadData() { if (State is not LoadingState.Unloaded) return; // Reset CTS if Dispose was called and the plugin is being re-enabled. if (Cts.IsCancellationRequested) Cts = new CancellationTokenSource(); State = LoadingState.Loading; var ct = Cts.Token; try { var global = await Client.GetAsync(GlobalEmotes, ct); if (!global.IsSuccessStatusCode) { // Nothing usable at all -- let the catch below reset to Unloaded // so a later trigger can retry. throw new HttpRequestException( $"BetterTTV global emotes returned {(int)global.StatusCode}." ); } var globalList = await global.Content.ReadAsStringAsync(ct); foreach (var emote in JsonSerializer.Deserialize(globalList)!) if (!string.IsNullOrEmpty(emote.Code) && !NotWorking.Contains(emote.Code)) Cache.TryAdd(emote.Code, emote); var lastId = string.Empty; for (var i = 0; i < 15; i++) { var top = await Client.GetAsync(Top100Emotes.Format(BetterTTV, lastId), ct); // The shared-emote endpoint went behind authentication and now // answers 403 with a JSON object. Deserializing that as a list // threw on every single start, and took the global emotes -- which // still work -- down with it. The global set is the useful half // anyway, so a failure here stops paging instead of the load. if (!top.IsSuccessStatusCode) { Plugin.LogProxy.Warning( $"BetterTTV shared emotes unavailable ({(int)top.StatusCode}); " + "continuing with global emotes only." ); break; } var topList = await top.Content.ReadAsStringAsync(ct); var jsonList = JsonSerializer.Deserialize>(topList); if (jsonList is not { Count: > 0 }) break; // BetterTTV occasionally returns entries with a null Code; // skip them so a single bad row doesn't break the whole cache. foreach (var emote in jsonList) if ( !string.IsNullOrEmpty(emote.Emote.Code) && !NotWorking.Contains(emote.Emote.Code) ) Cache.TryAdd(emote.Emote.Code, emote.Emote); lastId = jsonList[^1].Id; } SortedCodeArray = Cache.Keys.Order().ToArray(); State = LoadingState.Done; } catch (OperationCanceledException) { // Plugin disposed mid-load; State stays on Loading so re-enable can retry. } catch (Exception ex) { // Reset to Unloaded so a later trigger can retry without a plugin reload. State = LoadingState.Unloaded; Plugin.LogProxy.Error(ex, "BetterTTV cache wasn't initialized"); } } public static void Dispose() { Cts.Cancel(); // 5s upper bound; anything still running gets abandoned. try { Task.WaitAll(PendingLoads.ToArray(), TimeSpan.FromSeconds(5)); } catch (AggregateException) { // Faults already logged in TrackLoad. } while (PendingLoads.TryTake(out _)) { } foreach (var emote in EmoteImages.Values) emote.InnerDispose(); } internal static bool Exists(string code) { return State is LoadingState.Done && SortedCodeArray.Contains(code); } internal static EmoteBase? GetEmote(string code) { if (State is not LoadingState.Done) return null; if (!Cache.TryGetValue(code, out var emoteDetail)) return null; if (EmoteImages.TryGetValue(emoteDetail.Id, out var emote)) return emote; try { if (emoteDetail.ImageType == "gif") { var animatedEmote = new ImGuiGif().Prepare(emoteDetail); EmoteImages.Add(emoteDetail.Id, animatedEmote); return animatedEmote; } var staticEmote = new ImGuiEmote().Prepare(emoteDetail); EmoteImages.Add(emoteDetail.Id, staticEmote); return staticEmote; } catch { Plugin.LogProxy.Error("Failed to convert"); return null; } } public abstract class EmoteBase { public bool Failed; public bool IsLoaded; public byte[] RawData = []; protected IDalamudTextureWrap? Texture; public virtual void Draw(Vector2 size) { ImGui.Image(Texture!.Handle, size); } internal async Task LoadAsync(Emote emote, CancellationToken ct) { // Path-traversal guard: resolve and verify the candidate path stays // inside the cache directory before reading or writing. var dir = Path.GetFullPath( Path.Join(Plugin.Interface.ConfigDirectory.FullName, "EmoteCacheV1") ); Directory.CreateDirectory(dir); var dirPrefix = dir.EndsWith(Path.DirectorySeparatorChar) ? dir : dir + Path.DirectorySeparatorChar; var filePath = Path.GetFullPath(Path.Join(dir, $"{emote.Id}.{emote.ImageType}")); if (!filePath.StartsWith(dirPrefix, StringComparison.Ordinal)) throw new InvalidOperationException( $"Emote path escapes cache directory: id={emote.Id}, type={emote.ImageType}" ); if (File.Exists(filePath)) { RawData = await File.ReadAllBytesAsync(filePath, ct); } else { var content = await Client.GetAsync(EmotePath.Format(emote.Id), ct); RawData = await content.Content.ReadAsByteArrayAsync(ct); await using var stream = new FileStream( filePath, FileMode.Create, FileAccess.Write, FileShare.Read ); await stream.WriteAsync(RawData, ct); } return RawData; } public abstract void InnerDispose(); } public sealed class ImGuiEmote : EmoteBase { public ImGuiEmote Prepare(Emote emote) { var ct = EmoteCache.Token; // Task.Run keeps the sync prefix off the ImGui render thread. EmoteCache.TrackLoad(Task.Run(() => LoadAsyncTracked(emote, ct), ct), emote.Code); return this; } private async Task LoadAsyncTracked(Emote emote, CancellationToken ct) { try { var image = await LoadAsync(emote, ct); if (image.Length <= 0) return; ct.ThrowIfCancellationRequested(); Texture = await Plugin.TextureProvider.CreateFromImageAsync( image, cancellationToken: ct ); IsLoaded = true; } catch (OperationCanceledException) { } catch (Exception ex) { Failed = true; Plugin.LogProxy.Error(ex, $"Unable to load {emote.Code} with id {emote.Id}"); } } public override void InnerDispose() { Texture?.Dispose(); } } public sealed class ImGuiGif : EmoteBase { private List<(IDalamudTextureWrap Texture, float Delay)> Frames = []; private float FrameTimer; private int CurrentFrame; private ulong GlobalFrameCount; public override void Draw(Vector2 size) { if (Frames.Count == 0) return; if (CurrentFrame >= Frames.Count) { CurrentFrame = 0; FrameTimer = -1f; } var frame = Frames[CurrentFrame]; if (FrameTimer <= 0.0f) FrameTimer = frame.Delay; ImGui.Image(frame.Texture.Handle, size); if (GlobalFrameCount == Plugin.Interface.UiBuilder.FrameCount) return; GlobalFrameCount = Plugin.Interface.UiBuilder.FrameCount; FrameTimer -= ImGui.GetIO().DeltaTime; if (FrameTimer <= 0f) CurrentFrame++; } public override void InnerDispose() { Frames.ForEach(f => f.Texture.Dispose()); Frames.Clear(); } public ImGuiGif Prepare(Emote emote) { var ct = EmoteCache.Token; EmoteCache.TrackLoad(Task.Run(() => LoadAsyncTracked(emote, ct), ct), emote.Code); return this; } private async Task LoadAsyncTracked(Emote emote, CancellationToken ct) { try { var image = await LoadAsync(emote, ct); if (image.Length <= 0) return; using var ms = new MemoryStream(image); using var img = Image.Load(ms); if (img.Frames.Count == 0) return; var frames = new List<(IDalamudTextureWrap Tex, float Delay)>(); foreach (var frame in img.Frames) { ct.ThrowIfCancellationRequested(); var delay = frame.Metadata.GetGifMetadata().FrameDelay / 100f; // Match browser behaviour: anything under 20ms rounds up to 100ms. if (delay < 0.02f) delay = 0.1f; var buffer = new byte[4 * frame.Width * frame.Height]; frame.CopyPixelDataTo(buffer); var tex = await Plugin.TextureProvider.CreateFromRawAsync( RawImageSpecification.Rgba32(frame.Width, frame.Height), buffer, cancellationToken: ct ); frames.Add((tex, delay)); } Frames = frames; IsLoaded = true; } catch (OperationCanceledException) { // Plugin disposed mid-load; release any partial frames. foreach (var f in Frames) f.Texture.Dispose(); Frames = []; } catch (Exception ex) { Failed = true; Plugin.LogProxy.Error(ex, $"Unable to load {emote.Code} with id {emote.Id}"); } } } }