From 2fc32fe84c4070ef636928baa122e728d6154cc8 Mon Sep 17 00:00:00 2001 From: felk Date: Fri, 20 Dec 2024 02:33:33 +0100 Subject: [PATCH] add global and TPP emotes back to donations via cached lookup --- TPP.Core/Chat/TwitchEventSubChat.cs | 6 +- TPP.Core/Configuration/ConnectionConfig.cs | 1 + TPP.Core/DonationHandler.cs | 5 +- TPP.Core/Modes/ModeBase.cs | 32 +++++-- TPP.Core/Overlay/Events/Common/EmoteInfo.cs | 23 ----- TPP.Core/Overlay/Events/NewDonationEvent.cs | 2 +- TPP.Core/Overlay/Events/SubscriptionEvents.cs | 1 - TPP.Core/TwitchApi.cs | 6 ++ TPP.Core/TwitchEmotesLookup.cs | 83 +++++++++++++++++++ TPP.Model/EmoteInfo.cs | 26 ++++++ 10 files changed, 147 insertions(+), 38 deletions(-) delete mode 100644 TPP.Core/Overlay/Events/Common/EmoteInfo.cs create mode 100644 TPP.Core/TwitchEmotesLookup.cs create mode 100644 TPP.Model/EmoteInfo.cs diff --git a/TPP.Core/Chat/TwitchEventSubChat.cs b/TPP.Core/Chat/TwitchEventSubChat.cs index f8795f63..8fca4470 100644 --- a/TPP.Core/Chat/TwitchEventSubChat.cs +++ b/TPP.Core/Chat/TwitchEventSubChat.cs @@ -13,8 +13,8 @@ using TPP.Common.Utils; using TPP.Core.Overlay; using TPP.Core.Overlay.Events; -using TPP.Core.Overlay.Events.Common; using TPP.Core.Utils; +using TPP.Model; using TPP.Persistence; using TPP.Twitch.EventSub; using TPP.Twitch.EventSub.Notifications; @@ -508,7 +508,7 @@ private async Task ChannelSubscribeReceived(ChannelSubscribe channelSubscribe) await _overlayConnection.Send(new NewSubscriber { User = subscriptionInfo.Subscriber, - Emotes = subscriptionInfo.Emotes.Select(EmoteInfo.FromOccurence).ToImmutableList(), + Emotes = subscriptionInfo.Emotes.Select(e => EmoteInfo.FromIdAndCode(e.Id, e.Code)).ToImmutableList(), SubMessage = subscriptionInfo.Message, ShareSub = true, }, CancellationToken.None); @@ -546,7 +546,7 @@ private async Task ChannelSubscriptionMessageReceived(ChannelSubscriptionMessage await _overlayConnection.Send(new NewSubscriber { User = subscriptionInfo.Subscriber, - Emotes = subscriptionInfo.Emotes.Select(EmoteInfo.FromOccurence).ToImmutableList(), + Emotes = subscriptionInfo.Emotes.Select(e => EmoteInfo.FromIdAndCode(e.Id, e.Code)).ToImmutableList(), SubMessage = subscriptionInfo.Message, ShareSub = true, }, CancellationToken.None); diff --git a/TPP.Core/Configuration/ConnectionConfig.cs b/TPP.Core/Configuration/ConnectionConfig.cs index c831e8b0..e57acde7 100644 --- a/TPP.Core/Configuration/ConnectionConfig.cs +++ b/TPP.Core/Configuration/ConnectionConfig.cs @@ -69,6 +69,7 @@ public enum SuppressionType { Whisper, Message, Command } public CaseInsensitiveImmutableHashSet SuppressionOverrides { get; init; } = new([]); public Duration? GetChattersInterval { get; init; } = Duration.FromMinutes(5); + public Duration? GetEmotesInterval { get; init; } = Duration.FromHours(1); } public sealed class Simulation : ConnectionConfig diff --git a/TPP.Core/DonationHandler.cs b/TPP.Core/DonationHandler.cs index 7c27357d..7034b447 100644 --- a/TPP.Core/DonationHandler.cs +++ b/TPP.Core/DonationHandler.cs @@ -24,6 +24,7 @@ public class DonationHandler( IMessageSender messageSender, OverlayConnection overlayConnection, IChattersSnapshotsRepo chattersSnapshotsRepo, + TwitchEmotesLookup? twitchEmotesLookup, int centsPerToken, int donorBadgeCents) { @@ -88,9 +89,7 @@ await donationRepo.InsertDonation( await RandomlyDistributeTokens(donation.CreatedAt, donation.Id, donation.Username, tokens.Total()); await overlayConnection.Send(new NewDonationEvent { - // We used to look up emotes using the internal Emote Service, but this small feature (emotes in donations) - // was the only thing remaining using the Emote Service, so it's not worth it. - Emotes = [], + Emotes = twitchEmotesLookup?.FindEmotesInText(donation.Message ?? "") ?? [], RecordDonations = new Dictionary> { [cents] = recordBreaks.Select(recordBreak => recordBreak.Name).ToList() diff --git a/TPP.Core/Modes/ModeBase.cs b/TPP.Core/Modes/ModeBase.cs index 1d760e0f..816e544f 100644 --- a/TPP.Core/Modes/ModeBase.cs +++ b/TPP.Core/Modes/ModeBase.cs @@ -40,6 +40,7 @@ public sealed class ModeBase : IWithLifecycle, ICommandHandler private readonly IClock _clock; private readonly ProcessMessage _processMessage; private readonly ChattersWorker? _chattersWorker; + private readonly TwitchEmotesLookup? _emotesWorker; private readonly DonationsWorker? _donationsWorker; /// Processes a message that wasn't already processed by the mode base, @@ -141,20 +142,35 @@ public ModeBase( } } + // chatters worker List chatsWithChattersWorker = baseConfig.Chat.Connections .OfType() .Where(con => con.GetChattersInterval != null) .ToList(); - ConnectionConfig.Twitch? primaryChat = chatsWithChattersWorker.FirstOrDefault(); + ConnectionConfig.Twitch? primaryChattersChat = chatsWithChattersWorker.FirstOrDefault(); if (chatsWithChattersWorker.Count > 1) _logger.LogWarning("More than one twitch chat have GetChattersInterval configured: {ChatNames}. " + "Using only the first one ('{ChosenChat}') for the chatters worker", - string.Join(", ", chatsWithChattersWorker.Select(c => c.Name)), primaryChat?.Name); - _chattersWorker = primaryChat == null + string.Join(", ", chatsWithChattersWorker.Select(c => c.Name)), primaryChattersChat?.Name); + _chattersWorker = primaryChattersChat == null ? null - : new ChattersWorker(loggerFactory, clock, - ((TwitchChat)_chats[primaryChat.Name]).TwitchApi, repos.ChattersSnapshotsRepo, primaryChat, - repos.UserRepo); + : new ChattersWorker(loggerFactory, clock, ((TwitchChat)_chats[primaryChattersChat.Name]).TwitchApi, + repos.ChattersSnapshotsRepo, primaryChattersChat, repos.UserRepo); + + // emotes lookup worker + List chatsWithEmotesWorker = baseConfig.Chat.Connections + .OfType() + .Where(con => con.GetEmotesInterval != null) + .ToList(); + ConnectionConfig.Twitch? primaryEmotesChat = chatsWithEmotesWorker.FirstOrDefault(); + if (chatsWithEmotesWorker.Count > 1) + _logger.LogWarning("More than one twitch chat have GetEmotesInterval configured: {ChatNames}. " + + "Only retrieving global and that channel's ('{ChosenChat}') sub emotes.", + string.Join(", ", chatsWithEmotesWorker.Select(c => c.Name)), primaryEmotesChat?.Name); + _emotesWorker = primaryEmotesChat == null + ? null + : new TwitchEmotesLookup(loggerFactory, ((TwitchChat)_chats[primaryEmotesChat.Name]).TwitchApi, + primaryEmotesChat); StreamlabsConfig streamlabsConfig = baseConfig.StreamlabsConfig; if (streamlabsConfig.Enabled) @@ -170,7 +186,7 @@ public ModeBase( _logger.LogWarning("Multiple chats configured, using {Chat} for donation token whispers", chatName); DonationHandler donationHandler = new(loggerFactory.CreateLogger(), repos.DonationRepo, repos.UserRepo, repos.TokensBank, chat, overlayConnection, - repos.ChattersSnapshotsRepo, + repos.ChattersSnapshotsRepo, _emotesWorker, centsPerToken: baseConfig.CentsPerToken, donorBadgeCents: baseConfig.DonorBadgeCents); StreamlabsClient streamlabsClient = new(loggerFactory.CreateLogger(), streamlabsConfig.AccessToken); @@ -275,6 +291,8 @@ public async Task Start(CancellationToken cancellationToken) tasks.Add(_sendOutQueuedMessagesWorker.Start(cancellationToken)); if (_chattersWorker != null) tasks.Add(_chattersWorker.Start(cancellationToken)); + if (_emotesWorker != null) + tasks.Add(_emotesWorker.Start(cancellationToken)); if (_donationsWorker != null) tasks.Add(_donationsWorker.Start(cancellationToken)); await TaskUtils.WhenAllFastExit(tasks); diff --git a/TPP.Core/Overlay/Events/Common/EmoteInfo.cs b/TPP.Core/Overlay/Events/Common/EmoteInfo.cs deleted file mode 100644 index 64ff52df..00000000 --- a/TPP.Core/Overlay/Events/Common/EmoteInfo.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System.Runtime.Serialization; - -namespace TPP.Core.Overlay.Events.Common; - -[DataContract] -public struct EmoteInfo -{ - [DataMember(Name = "id")] public string Id { get; set; } - [DataMember(Name = "code")] public string Code { get; set; } - [DataMember(Name = "x1")] public string X1 { get; set; } - [DataMember(Name = "x2")] public string X2 { get; set; } - [DataMember(Name = "x3")] public string X3 { get; set; } - - public static EmoteInfo FromOccurence(EmoteOccurrence emote) => new() - { - Code = emote.Code, - Id = emote.Id, - // see https://dev.twitch.tv/docs/irc/tags#privmsg-twitch-tags - X1 = $"http://static-cdn.jtvnw.net/emoticons/v1/{emote.Id}/1.0", - X2 = $"http://static-cdn.jtvnw.net/emoticons/v1/{emote.Id}/2.0", - X3 = $"http://static-cdn.jtvnw.net/emoticons/v1/{emote.Id}/3.0", - }; -} diff --git a/TPP.Core/Overlay/Events/NewDonationEvent.cs b/TPP.Core/Overlay/Events/NewDonationEvent.cs index 047f5643..f55c40ff 100644 --- a/TPP.Core/Overlay/Events/NewDonationEvent.cs +++ b/TPP.Core/Overlay/Events/NewDonationEvent.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; using System.Runtime.Serialization; -using TPP.Core.Overlay.Events.Common; +using TPP.Model; namespace TPP.Core.Overlay.Events; diff --git a/TPP.Core/Overlay/Events/SubscriptionEvents.cs b/TPP.Core/Overlay/Events/SubscriptionEvents.cs index aa8eaac0..85ba61cc 100644 --- a/TPP.Core/Overlay/Events/SubscriptionEvents.cs +++ b/TPP.Core/Overlay/Events/SubscriptionEvents.cs @@ -1,6 +1,5 @@ using System.Collections.Immutable; using System.Runtime.Serialization; -using TPP.Core.Overlay.Events.Common; using TPP.Model; namespace TPP.Core.Overlay.Events diff --git a/TPP.Core/TwitchApi.cs b/TPP.Core/TwitchApi.cs index 9569c537..9d4d7d83 100644 --- a/TPP.Core/TwitchApi.cs +++ b/TPP.Core/TwitchApi.cs @@ -11,6 +11,8 @@ using TwitchLib.Api.Core.Enums; using TwitchLib.Api.Core.Exceptions; using TwitchLib.Api.Helix.Models.Chat.ChatSettings; +using TwitchLib.Api.Helix.Models.Chat.Emotes.GetChannelEmotes; +using TwitchLib.Api.Helix.Models.Chat.Emotes.GetGlobalEmotes; using TwitchLib.Api.Helix.Models.Chat.GetChatters; using TwitchLib.Api.Helix.Models.EventSub; using TwitchLib.Api.Helix.Models.Moderation.BanUser; @@ -96,6 +98,10 @@ public Task SendChatMessage(string broadcasterId, string senderUserId, string me replyParentMessageId: replyParentMessageId)); public Task SendWhisperAsync(string fromUserId, string toUserId, string message, bool newRecipient) => RetryingBot(api => api.Helix.Whispers.SendWhisperAsync(fromUserId, toUserId, message, newRecipient)); + public Task GetGlobalEmotes() => + RetryingBot(api => api.Helix.Chat.GetGlobalEmotesAsync()); + public Task GetChannelEmotes(string broadcasterId) => + RetryingBot(api => api.Helix.Chat.GetChannelEmotesAsync(broadcasterId)); // Users public Task GetUsersAsync(List ids) => diff --git a/TPP.Core/TwitchEmotesLookup.cs b/TPP.Core/TwitchEmotesLookup.cs new file mode 100644 index 00000000..577d87bf --- /dev/null +++ b/TPP.Core/TwitchEmotesLookup.cs @@ -0,0 +1,83 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using TPP.Core.Configuration; +using TPP.Model; +using TwitchLib.Api.Helix.Models.Chat.Emotes; + +namespace TPP.Core; + +public class TwitchEmotesLookup( + ILoggerFactory loggerFactory, + TwitchApi twitchApi, + ConnectionConfig.Twitch chatConfig +) : IWithLifecycle +{ + private readonly ILogger _logger = loggerFactory.CreateLogger(); + private readonly TimeSpan _refreshInterval = chatConfig.GetEmotesInterval!.Value.ToTimeSpan(); + private Dictionary _knownEmotesByCode = []; + private Regex _emoteCodesRegex = new("Kappa"); + + public List FindEmotesInText(string text) + { + MatchCollection matches = _emoteCodesRegex.Matches(text); + return matches.Select(match => _knownEmotesByCode[match.Value]).ToList(); + } + + private async Task RenewEmotes() + { + Stopwatch stopwatch = new(); + stopwatch.Start(); + GlobalEmote[] globalEmotes = (await twitchApi.GetGlobalEmotes()).GlobalEmotes; + ChannelEmote[] channelEmotes = (await twitchApi.GetChannelEmotes(chatConfig.ChannelId)).ChannelEmotes; + stopwatch.Stop(); + _logger.LogDebug("Retrieved {NumGlobalEmotes} global and {NumChannelEmotes} channel emotes in {ElapsedMs}ms", + globalEmotes.Length, channelEmotes.Length, stopwatch.ElapsedMilliseconds); + + IEnumerable globalEmoteInfos = globalEmotes.Select(e => new EmoteInfo + { + Code = e.Name, Id = e.Id, + X1 = e.Images.Url1X, X2 = e.Images.Url2X, X3 = e.Images.Url4X + }); + IEnumerable channelEmoteInfos = channelEmotes.Select(e => new EmoteInfo + { + Code = e.Name, Id = e.Id, + X1 = e.Images.Url1X, X2 = e.Images.Url2X, X3 = e.Images.Url4X + }); + + // Don't use ToDictionary, which forbids duplicate keys, because emote codes are not unique. + // E.g. ':D' can either be ID 3 or 555555560, but they look identical anyway. + Dictionary knownEmotesByCode = []; + foreach (EmoteInfo emoteInfo in globalEmoteInfos) + knownEmotesByCode[emoteInfo.Code] = emoteInfo; + foreach (EmoteInfo emoteInfo in channelEmoteInfos) + knownEmotesByCode[emoteInfo.Code] = emoteInfo; + _knownEmotesByCode = knownEmotesByCode; + _emoteCodesRegex = new Regex(string.Join('|', _knownEmotesByCode.Keys.Select(Regex.Escape))); + _logger.LogDebug("New emotes list: {Emotes}", string.Join(", ", _knownEmotesByCode.Keys)); + } + + public async Task Start(CancellationToken cancellationToken) + { + // don't wait at startup, refresh right away + while (!cancellationToken.IsCancellationRequested) + { + try + { + await RenewEmotes(); + } + catch (Exception e) + { + _logger.LogError(e, "Failed renewing emotes"); + } + + try { await Task.Delay(_refreshInterval, cancellationToken); } + catch (OperationCanceledException) { break; } + } + } +} diff --git a/TPP.Model/EmoteInfo.cs b/TPP.Model/EmoteInfo.cs new file mode 100644 index 00000000..1ec19f70 --- /dev/null +++ b/TPP.Model/EmoteInfo.cs @@ -0,0 +1,26 @@ +using System.Runtime.Serialization; + +namespace TPP.Model; + +[DataContract] +public struct EmoteInfo +{ + [DataMember(Name = "id")] public string Id { get; set; } + [DataMember(Name = "code")] public string Code { get; set; } + [DataMember(Name = "x1")] public string X1 { get; set; } + [DataMember(Name = "x2")] public string X2 { get; set; } + [DataMember(Name = "x3")] public string X3 { get; set; } + + public static EmoteInfo FromIdAndCode(string id, string code) => new() + { + Code = code, + Id = id, + // see https://dev.twitch.tv/docs/irc/tags#privmsg-twitch-tags + X1 = $"https://static-cdn.jtvnw.net/emoticons/v2/{id}/static/light/1.0", + X2 = $"https://static-cdn.jtvnw.net/emoticons/v2/{id}/static/light/2.0", + X3 = $"https://static-cdn.jtvnw.net/emoticons/v2/{id}/static/light/3.0", + }; + + public override string ToString() => + $"Emote({nameof(Id)}: {Id}, {nameof(Code)}: {Code})"; +}