Files
HellionChat/HellionChat/EmoteCache.cs
T
JonKazama-Hellion 18d43f9afa fix(emotes): survive BetterTTV's shared-emote endpoint going private
/emotes/shared/top now answers 403 with {"message":"unauthorized"}. Nothing
checked the status code, so that object was handed to a List<Top100>
deserializer, which threw on the very first character on every plugin start.

The throw escaped the whole loader, so the 65 global emotes -- fetched
successfully one call earlier -- were discarded along with it, and State went
back to Unloaded, arming the same failure for the next trigger.

A failed page now stops paging and keeps what the global endpoint returned. The
global call itself still throws when it fails, because without it there is
nothing to keep.

Two smaller hazards on the same path: an empty page would have made Last() throw
rather than end the loop, and a null deserialization result was dereferenced
outright. Both end the loop now.

Logged as a warning, not an error. This is a third-party endpoint changing its
access policy, not a fault in the plugin, and it does not need a stack trace in
the log at every launch.
2026-08-18 17:01:10 +02:00

446 lines
14 KiB
C#

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<string, Emote> Cache = new();
private static readonly Dictionary<string, EmoteBase> 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<Task> 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<Emote[]>(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<List<Top100>>(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<byte[]> 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<Rgba32>(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}");
}
}
}
}