diff --git a/src/FloppyBot.Aux.TwitchAlerts.Core/DiSetup.cs b/src/FloppyBot.Aux.TwitchAlerts.Core/DiSetup.cs index 43fff6c8..e88fedb6 100644 --- a/src/FloppyBot.Aux.TwitchAlerts.Core/DiSetup.cs +++ b/src/FloppyBot.Aux.TwitchAlerts.Core/DiSetup.cs @@ -1,4 +1,5 @@ using FloppyBot.Aux.TwitchAlerts.Core.Entities; +using FloppyBot.Aux.TwitchAlerts.Core.Entities.Storage; using Microsoft.Extensions.DependencyInjection; namespace FloppyBot.Aux.TwitchAlerts.Core; @@ -14,6 +15,6 @@ public static IServiceCollection AddTwitchAlertService(this IServiceCollection s { return services .AddTransient() - .AddAutoMapper(typeof(TwitchAlertListener)); + .AddAutoMapper(typeof(TwitchAlertStorageProfile)); } } diff --git a/src/FloppyBot.Aux.TwitchAlerts.Core/Entities/Storage/TwitchAlertSettingsEo.cs b/src/FloppyBot.Aux.TwitchAlerts.Core/Entities/Storage/TwitchAlertSettingsEo.cs index 13c3b03a..9471bbb9 100644 --- a/src/FloppyBot.Aux.TwitchAlerts.Core/Entities/Storage/TwitchAlertSettingsEo.cs +++ b/src/FloppyBot.Aux.TwitchAlerts.Core/Entities/Storage/TwitchAlertSettingsEo.cs @@ -8,7 +8,8 @@ public record TwitchAlertSettingsEo( TwitchAlertMessageEo[] SubMessages, TwitchAlertMessageEo[] ReSubMessages, TwitchAlertMessageEo[] GiftSubMessages, - TwitchAlertMessageEo[] GiftSubCommunityMessages + TwitchAlertMessageEo[] GiftSubCommunityMessages, + TwitchAlertMessageEo[] RaidAlertMessages ) : IEntity { public TwitchAlertSettingsEo WithId(string newId) diff --git a/src/FloppyBot.Aux.TwitchAlerts.Core/Entities/Storage/TwitchAlertStorageProfile.cs b/src/FloppyBot.Aux.TwitchAlerts.Core/Entities/Storage/TwitchAlertStorageProfile.cs index 2a847e05..10a33f3e 100644 --- a/src/FloppyBot.Aux.TwitchAlerts.Core/Entities/Storage/TwitchAlertStorageProfile.cs +++ b/src/FloppyBot.Aux.TwitchAlerts.Core/Entities/Storage/TwitchAlertStorageProfile.cs @@ -23,6 +23,10 @@ public TwitchAlertStorageProfile() dto.GiftSubCommunityMessage.Select(msg => ctx.Mapper.Map(msg) ) + .ToArray(), + dto.RaidAlertMessage.Select(msg => + ctx.Mapper.Map(msg) + ) .ToArray() ) ); @@ -50,6 +54,11 @@ public TwitchAlertStorageProfile() ctx.Mapper.Map(msg) ) .ToImmutableList(), + RaidAlertMessage = eo + .RaidAlertMessages.Select(msg => + ctx.Mapper.Map(msg) + ) + .ToImmutableList(), } ); CreateMap(); diff --git a/src/FloppyBot.Aux.TwitchAlerts.Core/Entities/TwitchAlertSettings.cs b/src/FloppyBot.Aux.TwitchAlerts.Core/Entities/TwitchAlertSettings.cs index ae78d391..dbb3a3b3 100644 --- a/src/FloppyBot.Aux.TwitchAlerts.Core/Entities/TwitchAlertSettings.cs +++ b/src/FloppyBot.Aux.TwitchAlerts.Core/Entities/TwitchAlertSettings.cs @@ -15,6 +15,8 @@ public record TwitchAlertSettings : IEntity ImmutableList.Empty; private readonly IImmutableList _giftSubCommunityMessages = ImmutableList.Empty; + private readonly IImmutableList _raidAlertMessage = + ImmutableList.Empty; public string Id { get; init; } @@ -44,6 +46,12 @@ public IImmutableList GiftSubCommunityMessage init => _giftSubCommunityMessages = value.WithValueSemantics(); } + public IImmutableList RaidAlertMessage + { + get => _raidAlertMessage; + init => _raidAlertMessage = value.WithValueSemantics(); + } + public TwitchAlertSettings WithId(string newId) { return this with { Id = newId }; diff --git a/src/FloppyBot.Aux.TwitchAlerts.Core/TwitchAlertListener.cs b/src/FloppyBot.Aux.TwitchAlerts.Core/TwitchAlertListener.cs index 54b90b9c..4fef7f06 100644 --- a/src/FloppyBot.Aux.TwitchAlerts.Core/TwitchAlertListener.cs +++ b/src/FloppyBot.Aux.TwitchAlerts.Core/TwitchAlertListener.cs @@ -1,4 +1,5 @@ -using System.Collections.Immutable; +using System.Collections.Frozen; +using System.Collections.Immutable; using System.Text.Json; using FloppyBot.Aux.TwitchAlerts.Core.Entities; using FloppyBot.Base.Configuration; @@ -15,13 +16,13 @@ namespace FloppyBot.Aux.TwitchAlerts.Core; public class TwitchAlertListener : IDisposable { - private static readonly ISet AllowedEvents = new[] + private static readonly FrozenSet AllowedEvents = new[] { TwitchEventTypes.SUBSCRIPTION, TwitchEventTypes.RE_SUBSCRIPTION, TwitchEventTypes.SUBSCRIPTION_GIFT, TwitchEventTypes.SUBSCRIPTION_GIFT_COMMUNITY, - }.ToHashSet(); + }.ToFrozenSet(); private static readonly IImmutableDictionary< TwitchSubscriptionPlanTier, @@ -52,6 +53,7 @@ private static readonly IImmutableDictionary< { TwitchEventTypes.RE_SUBSCRIPTION, s => s.ReSubMessage }, { TwitchEventTypes.SUBSCRIPTION_GIFT, s => s.GiftSubMessage }, { TwitchEventTypes.SUBSCRIPTION_GIFT_COMMUNITY, s => s.GiftSubCommunityMessage }, + { TwitchEventTypes.RAID, s => s.RaidAlertMessage }, }.ToImmutableDictionary(); private static string? DetermineTemplate( @@ -84,6 +86,7 @@ TwitchReSubscriptionReceivedEvent reSubscriptionReceivedEvent TwitchSubscriptionGiftEvent subGiftEvent => subGiftEvent.SubscriptionPlanTier.Tier, TwitchSubscriptionCommunityGiftEvent subCommunityGiftEvent => subCommunityGiftEvent.SubscriptionPlanTier.Tier, + TwitchRaidEvent _ => TwitchSubscriptionPlanTier.Unknown, _ => throw new ArgumentOutOfRangeException(nameof(twitchEvent)), }; } @@ -100,6 +103,7 @@ TwitchSubscriptionCommunityGiftEvent subCommunityGiftEvent => JsonSerializer.Deserialize(content), TwitchEventTypes.SUBSCRIPTION_GIFT_COMMUNITY => JsonSerializer.Deserialize(content), + TwitchEventTypes.RAID => JsonSerializer.Deserialize(content), _ => throw new ArgumentOutOfRangeException(nameof(type)), }; } @@ -166,15 +170,14 @@ private void OnMessageReceived(ChatMessage chatMessage) #endif var twitchEvent = ParseTwitchEvent(chatMessage.EventName, chatMessage.Content); - if (twitchEvent == null) + if (twitchEvent is null) { _logger.LogWarning("Failed to parse chat message content"); return; } var alertMessage = GetFormattedMessage(chatMessage, twitchEvent); - - if (alertMessage == null) + if (alertMessage is null) { return; } @@ -193,7 +196,7 @@ private void OnMessageReceived(ChatMessage chatMessage) { var channelId = chatMessage.Identifier.GetChannel(); var alertSettings = _alertService.GetAlertSettings(channelId); - if (alertSettings == null) + if (alertSettings is null) { _logger.LogDebug("No alert settings found for channel {Channel}, skipping", channelId); return null; @@ -202,7 +205,7 @@ private void OnMessageReceived(ChatMessage chatMessage) var subTier = GetTier(twitchEvent); var template = DetermineTemplate(twitchEvent, alertSettings, subTier); - if (template == null) + if (template is null) { _logger.LogDebug( "No template found for event {EventName} and tier {Tier}, skipping", diff --git a/src/FloppyBot.Base.Extensions/StringExtensions.cs b/src/FloppyBot.Base.Extensions/StringExtensions.cs index 94ec5e8b..caffc7d9 100644 --- a/src/FloppyBot.Base.Extensions/StringExtensions.cs +++ b/src/FloppyBot.Base.Extensions/StringExtensions.cs @@ -11,4 +11,9 @@ public static string Capitalize(this string s) _ => $"{char.ToUpperInvariant(s[0])}{s[1..]}", }; } + + public static int ParseInt(this string s, int defaultValue = 0) + { + return int.TryParse(s, out var result) ? result : defaultValue; + } } diff --git a/src/FloppyBot.Base.Storage.MongoDb/MongoDbRepositoryFactory.cs b/src/FloppyBot.Base.Storage.MongoDb/MongoDbRepositoryFactory.cs index ab42541b..a477968b 100644 --- a/src/FloppyBot.Base.Storage.MongoDb/MongoDbRepositoryFactory.cs +++ b/src/FloppyBot.Base.Storage.MongoDb/MongoDbRepositoryFactory.cs @@ -1,15 +1,22 @@ using FloppyBot.Base.Storage.Utils; +using Microsoft.Extensions.Logging; +using MongoDB.Bson; using MongoDB.Driver; namespace FloppyBot.Base.Storage.MongoDb; public class MongoDbRepositoryFactory : IRepositoryFactory { + private readonly ILogger _logger; private readonly IMongoDatabase _database; - public MongoDbRepositoryFactory(IMongoDatabase database) + public MongoDbRepositoryFactory( + IMongoDatabase database, + ILogger logger + ) { _database = database; + _logger = logger; } public IRepository GetRepository() @@ -19,6 +26,20 @@ public IRepository GetRepository() public IRepository GetRepository(string collectionName) where T : class, IEntity { + var collectionExists = _database + .ListCollections( + new ListCollectionsOptions + { + Filter = Builders.Filter.Eq(f => f["name"], collectionName), + } + ) + .Any(); + if (!collectionExists) + { + _logger.LogDebug("Creating collection {CollectionName}", collectionName); + _database.CreateCollection(collectionName); + } + return new MongoDbRepository(_database.GetCollection(collectionName)); } } diff --git a/src/FloppyBot.Chat.Twitch.Events/TwitchEventTypes.cs b/src/FloppyBot.Chat.Twitch.Events/TwitchEventTypes.cs index 0dde3b1b..760b4119 100644 --- a/src/FloppyBot.Chat.Twitch.Events/TwitchEventTypes.cs +++ b/src/FloppyBot.Chat.Twitch.Events/TwitchEventTypes.cs @@ -6,4 +6,5 @@ public static class TwitchEventTypes public const string RE_SUBSCRIPTION = "Twitch.ReSubscription"; public const string SUBSCRIPTION_GIFT = "Twitch.SubscriptionGift"; public const string SUBSCRIPTION_GIFT_COMMUNITY = "Twitch.SubscriptionGiftCommunity"; + public const string RAID = "Twitch.Raid"; } diff --git a/src/FloppyBot.Chat.Twitch.Events/TwitchRaidEvent.cs b/src/FloppyBot.Chat.Twitch.Events/TwitchRaidEvent.cs new file mode 100644 index 00000000..40b04730 --- /dev/null +++ b/src/FloppyBot.Chat.Twitch.Events/TwitchRaidEvent.cs @@ -0,0 +1,10 @@ +namespace FloppyBot.Chat.Twitch.Events; + +public record TwitchRaidEvent( + string ChannelName, + string ChannelDisplayName, + int ViewerCount, + StreamTeam? StreamTeam +) : TwitchEvent(TwitchEventTypes.RAID); + +public record StreamTeam(string Name, string DisplayName); diff --git a/src/FloppyBot.Chat.Twitch.Tests/TwitchChatInterfaceTests.cs b/src/FloppyBot.Chat.Twitch.Tests/TwitchChatInterfaceTests.cs index 248a8671..a5c651ae 100644 --- a/src/FloppyBot.Chat.Twitch.Tests/TwitchChatInterfaceTests.cs +++ b/src/FloppyBot.Chat.Twitch.Tests/TwitchChatInterfaceTests.cs @@ -6,6 +6,7 @@ using FloppyBot.Base.Testing; using FloppyBot.Chat.Entities; using FloppyBot.Chat.Entities.Identifiers; +using FloppyBot.Chat.Twitch.Api; using FloppyBot.Chat.Twitch.Config; using FloppyBot.Chat.Twitch.Events; using FloppyBot.Chat.Twitch.Monitor; @@ -22,15 +23,12 @@ namespace FloppyBot.Chat.Twitch.Tests; [TestClass] public class TwitchChatInterfaceTests { - private readonly ITwitchClient _client; - private readonly ITwitchChannelOnlineMonitor _onlineMonitor; - private TwitchConfiguration _configuration; + private readonly ITwitchClient _client = A.Fake(); + private readonly ITwitchChannelOnlineMonitor _onlineMonitor = + A.Fake(); - public TwitchChatInterfaceTests() - { - _client = A.Fake(); - _onlineMonitor = A.Fake(); - _configuration = new TwitchConfiguration( + private TwitchConfiguration _configuration = + new( "atwitchbot", "sometoken", "atwitchchannel", @@ -40,7 +38,6 @@ public TwitchChatInterfaceTests() 0, false ); - } [TestMethod] public void BroadcasterHasAdminRights() @@ -234,7 +231,8 @@ private TwitchChatInterface CreateInterface(TwitchConfiguration? configuration = LoggingUtils.GetLogger(), _client, _configuration, - _onlineMonitor + _onlineMonitor, + A.Fake() ); } diff --git a/src/FloppyBot.Chat.Twitch/Api/Dtos/StreamTeam.cs b/src/FloppyBot.Chat.Twitch/Api/Dtos/StreamTeam.cs new file mode 100644 index 00000000..bcf0dcac --- /dev/null +++ b/src/FloppyBot.Chat.Twitch/Api/Dtos/StreamTeam.cs @@ -0,0 +1,3 @@ +namespace FloppyBot.Chat.Twitch.Api.Dtos; + +public record StreamTeam(string Id, string Name, string DisplayName); diff --git a/src/FloppyBot.Chat.Twitch/Api/ITwitchApiService.cs b/src/FloppyBot.Chat.Twitch/Api/ITwitchApiService.cs new file mode 100644 index 00000000..74f56dd5 --- /dev/null +++ b/src/FloppyBot.Chat.Twitch/Api/ITwitchApiService.cs @@ -0,0 +1,54 @@ +using FloppyBot.Chat.Twitch.Api.Dtos; +using TwitchLib.Api.Helix.Models.Teams; +using TwitchLib.Api.Interfaces; + +namespace FloppyBot.Chat.Twitch.Api; + +public interface ITwitchApiService +{ + IEnumerable GetStreamTeamsOfChannel(string channel); + string? GetBroadcasterId(string channel); +} + +public class TwitchApiService : ITwitchApiService +{ + private readonly ITwitchAPI _twitchApi; + + public TwitchApiService(ITwitchAPI twitchApi) + { + _twitchApi = twitchApi; + } + + public IEnumerable GetStreamTeamsOfChannel(string channel) + { + return DoGetStreamTeamName(channel).GetAwaiter().GetResult(); + } + + public string? GetBroadcasterId(string channel) + { + return DoGetBroadcasterId(channel).GetAwaiter().GetResult(); + } + + private static StreamTeam ConvertToStreamTeam(Team team) + { + return new StreamTeam(team.Id, team.TeamName, team.TeamDisplayName); + } + + private async Task> DoGetStreamTeamName(string channel) + { + var broadcasterId = await DoGetBroadcasterId(channel); + if (broadcasterId == null) + { + return Enumerable.Empty(); + } + + var teams = await _twitchApi.Helix.Teams.GetTeamsAsync(broadcasterId); + return teams.Teams.Select(ConvertToStreamTeam); + } + + private async Task DoGetBroadcasterId(string channel) + { + var program = await _twitchApi.Helix.Users.GetUsersAsync(logins: [channel]); + return program.Users.Select(u => u.Id).FirstOrDefault(); + } +} diff --git a/src/FloppyBot.Chat.Twitch/Config/Registration.cs b/src/FloppyBot.Chat.Twitch/Config/Registration.cs index 83467ec2..e7bf27b6 100644 --- a/src/FloppyBot.Chat.Twitch/Config/Registration.cs +++ b/src/FloppyBot.Chat.Twitch/Config/Registration.cs @@ -1,3 +1,4 @@ +using FloppyBot.Chat.Twitch.Api; using FloppyBot.Chat.Twitch.Monitor; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -60,6 +61,7 @@ public static IServiceCollection AddTwitchChatInterface(this IServiceCollection return new LiveStreamMonitorService(api, config.MonitorInterval); }) .AddSingleton() + .AddSingleton() // - Chat Interface .AddSingleton(); } diff --git a/src/FloppyBot.Chat.Twitch/FloppyBot.Chat.Twitch.csproj b/src/FloppyBot.Chat.Twitch/FloppyBot.Chat.Twitch.csproj index 5ba219af..20421452 100644 --- a/src/FloppyBot.Chat.Twitch/FloppyBot.Chat.Twitch.csproj +++ b/src/FloppyBot.Chat.Twitch/FloppyBot.Chat.Twitch.csproj @@ -11,6 +11,7 @@ + diff --git a/src/FloppyBot.Chat.Twitch/TwitchChatInterface.cs b/src/FloppyBot.Chat.Twitch/TwitchChatInterface.cs index ac61be94..d229086a 100644 --- a/src/FloppyBot.Chat.Twitch/TwitchChatInterface.cs +++ b/src/FloppyBot.Chat.Twitch/TwitchChatInterface.cs @@ -1,6 +1,8 @@ using System.Text.Json; +using FloppyBot.Base.Extensions; using FloppyBot.Chat.Entities; using FloppyBot.Chat.Entities.Identifiers; +using FloppyBot.Chat.Twitch.Api; using FloppyBot.Chat.Twitch.Config; using FloppyBot.Chat.Twitch.Events; using FloppyBot.Chat.Twitch.Extensions; @@ -23,6 +25,7 @@ public class TwitchChatInterface : IChatInterface private readonly ILogger _logger; private readonly ITwitchChannelOnlineMonitor _onlineMonitor; + private readonly ITwitchApiService _twitchApiService; public TwitchChatInterface( ILogger logger, @@ -30,12 +33,15 @@ public TwitchChatInterface( ILogger clientLogger, ITwitchClient client, TwitchConfiguration configuration, - ITwitchChannelOnlineMonitor onlineMonitor + ITwitchChannelOnlineMonitor onlineMonitor, + ITwitchApiService twitchApiService ) { _logger = logger; _clientLogger = clientLogger; _configuration = configuration; + _twitchApiService = twitchApiService; + _onlineMonitor = onlineMonitor; _onlineMonitor.OnlineStatusChanged += OnlineMonitor_OnlineStatusChanged; @@ -53,6 +59,7 @@ ITwitchChannelOnlineMonitor onlineMonitor _client.OnReSubscriber += Client_OnReSubscriber; _client.OnGiftedSubscription += Client_OnGiftedSubscription; _client.OnCommunitySubscription += Client_OnCommunitySubscription; + _client.OnRaidNotification += Client_OnRaidNotification; } public string Name => _channelIdentifier; @@ -358,6 +365,57 @@ private void Client_OnCommunitySubscription(object? sender, OnCommunitySubscript ); } + private void Client_OnRaidNotification(object? sender, OnRaidNotificationArgs e) + { + _logger.LogTrace( + "Raid inbound from {TwitchUser}@{TwitchChannel}: {TwitchRaidViewerCount} viewers", + e.RaidNotification.DisplayName, + e.Channel, + e.RaidNotification.MsgParamViewerCount + ); + + var eventArgs = new TwitchRaidEvent( + e.RaidNotification.MsgParamLogin, + e.RaidNotification.MsgParamDisplayName, + e.RaidNotification.MsgParamViewerCount.ParseInt(), + TryExtensions.TryOr( + () => + { + var response = _twitchApiService.GetStreamTeamsOfChannel( + e.RaidNotification.MsgParamLogin + ); + return response + .Select(t => new StreamTeam(t.Name, t.DisplayName)) + .FirstOrDefault(); + }, + (ex) => + { + _logger.LogError( + ex, + "Failed to retrieve stream team information for {RaidChannelName}, returning null as default", + e.RaidNotification.MsgParamLogin + ); + return null; + } + ) + ); + MessageReceived?.Invoke( + this, + new ChatMessage( + NewChatMessageIdentifier(e.RaidNotification.Id), + TwitchEntityExtensions.ConvertToChatUser( + e.RaidNotification.MsgParamLogin, + e.RaidNotification.MsgParamDisplayName, + DeterminePrivilegeLevel(false, false, false) + ), + TwitchEventTypes.RAID, + JsonSerializer.Serialize(eventArgs), + null, + SupportedFeatures + ) + ); + } + private void Client_OnReconnected(object? sender, OnReconnectedEventArgs e) { _logger.LogInformation("Reconnected"); @@ -368,7 +426,7 @@ private void OnlineMonitor_OnlineStatusChanged( TwitchChannelOnlineStatusChangedEventArgs e ) { - _logger.LogInformation( + _logger.LogTrace( "Channel online status changed to {IsChannelOnline}: {TwitchStream}", e.IsOnline, e.Stream diff --git a/src/FloppyBot.Commands.Aux.Twitch.Tests/ShoutoutCommandTests.cs b/src/FloppyBot.Commands.Aux.Twitch.Tests/ShoutoutCommandTests.cs index 83e89b85..19a7f41f 100644 --- a/src/FloppyBot.Commands.Aux.Twitch.Tests/ShoutoutCommandTests.cs +++ b/src/FloppyBot.Commands.Aux.Twitch.Tests/ShoutoutCommandTests.cs @@ -31,12 +31,46 @@ public ShoutoutCommandTests() new TwitchUserLookupResult("somestreamer", "SomeStreamer", "Cool Game") ) ); + A.CallTo( + () => + twitchApiService.LookupTeam( + A.That.Matches(s => s == "somestreamer" || s == "someteamuser") + ) + ) + .ReturnsLazily( + (string accountName) => + Task.FromResult( + new TwitchStreamTeamResult( + accountName, + "1234567890", + "coolteam", + "The Really Cool Stream Team" + ) + ) + ); A.CallTo(() => _shoutoutMessageSettingService.GetSettings("Twitch/someuser")) .ReturnsLazily( () => new ShoutoutMessageSetting( "Twitch/someuser", - "Check out {DisplayName} at {Link}! They last played {LastGame}!" + "Check out {DisplayName} at {Link}! They last played {LastGame}!", + null + ) + ); + A.CallTo( + () => + _shoutoutMessageSettingService.GetSettings( + A.That.Matches(s => + s == "Twitch/someteamuser" || s == "Twitch/somestreamer" + ) + ) + ) + .ReturnsLazily( + () => + new ShoutoutMessageSetting( + "Twitch/someteamuser", + "Check out {DisplayName} at {Link}! They last played {LastGame}!", + "Check out my team mate {DisplayName} at {Link}! They last played {LastGame}! Also go checkout {TeamName} at {TeamLink}!" ) ); } @@ -51,6 +85,16 @@ public async Task RepliesWithShoutoutMessage() ); } + [TestMethod] + public async Task RepliesWithTeamShoutoutMessage() + { + var reply = await _shoutoutCommand.Shoutout("Twitch/someteamuser", "somestreamer"); + Assert.AreEqual( + "Check out my team mate SomeStreamer at https://twitch.tv/somestreamer! They last played Cool Game! Also go checkout The Really Cool Stream Team at https://www.twitch.tv/team/coolteam!", + reply + ); + } + [TestMethod] public async Task DoNotReplyIfNoMessageIsConfigured() { @@ -79,6 +123,24 @@ public void ConfigureCreatesDatabaseRecord() .MustHaveHappenedOnceExactly(); } + [TestMethod] + public void ConfigureTeamShoutoutSavesToDatabase() + { + var reply = _shoutoutCommand.SetTeamShoutout("Twitch/someuser", "My new team template"); + + Assert.AreEqual(ShoutoutCommand.REPLY_SAVE, reply); + A.CallTo( + () => + _shoutoutMessageSettingService.SetShoutoutMessage( + A.That.Matches(setting => + setting.Id == "Twitch/someuser" + && setting.TeamMessage == "My new team template" + ) + ) + ) + .MustHaveHappenedOnceExactly(); + } + [TestMethod] public void ClearIssuesDeleteCommand() { diff --git a/src/FloppyBot.Commands.Aux.Twitch/Api/ITwitchApiService.cs b/src/FloppyBot.Commands.Aux.Twitch/Api/ITwitchApiService.cs index 6b77e6a8..5b40d448 100644 --- a/src/FloppyBot.Commands.Aux.Twitch/Api/ITwitchApiService.cs +++ b/src/FloppyBot.Commands.Aux.Twitch/Api/ITwitchApiService.cs @@ -3,4 +3,5 @@ public interface ITwitchApiService { Task LookupUser(string userId); + Task LookupTeam(string userId); } diff --git a/src/FloppyBot.Commands.Aux.Twitch/Api/TwitchApiService.cs b/src/FloppyBot.Commands.Aux.Twitch/Api/TwitchApiService.cs index ebe81c04..f782daf6 100644 --- a/src/FloppyBot.Commands.Aux.Twitch/Api/TwitchApiService.cs +++ b/src/FloppyBot.Commands.Aux.Twitch/Api/TwitchApiService.cs @@ -1,36 +1,102 @@ -using TwitchLib.Api.Helix.Models.Users.GetUsers; +using Microsoft.Extensions.Caching.Memory; +using TwitchLib.Api.Helix.Models.Channels.GetChannelInformation; +using TwitchLib.Api.Helix.Models.Teams; +using TwitchLib.Api.Helix.Models.Users.GetUsers; using TwitchLib.Api.Interfaces; namespace FloppyBot.Commands.Aux.Twitch.Api; public class TwitchApiService : ITwitchApiService { + private const string API_ERROR = "[API ERROR]"; + + private static readonly TimeSpan CacheDuration = TimeSpan.FromDays(14); + private static readonly TimeSpan CacheDurationShort = TimeSpan.FromMinutes(30); + private readonly ITwitchAPI _twitchApi; + private readonly IMemoryCache _memoryCache; - public TwitchApiService(ITwitchAPI twitchApi) + public TwitchApiService(ITwitchAPI twitchApi, IMemoryCache memoryCache) { _twitchApi = twitchApi; + _memoryCache = memoryCache; } public async Task LookupUser(string userId) { - GetUsersResponse? userResponse = _twitchApi - .Helix.Users.GetUsersAsync(logins: new List { userId }) - .Result; + var userResponse = await GetUsersAsync(userId); + if (userResponse is null || userResponse.Users.Length == 0) + { + return null; + } + + var user = userResponse.Users.First(); + var channelResponse = await GetChannelInformationAsync(user.Id); + + return new TwitchUserLookupResult( + user.Login, + user.DisplayName, + channelResponse?.Data.FirstOrDefault()?.GameName ?? API_ERROR + ); + } + + public async Task LookupTeam(string userId) + { + var userResponse = await GetUsersAsync(userId); + if (userResponse is null || userResponse.Users.Length == 0) + { + return null; + } - if (userResponse.Users.Any()) + var user = userResponse.Users.First(); + var channelTeams = await GetChannelTeamsAsync(user.Id); + if (channelTeams is null || channelTeams.ChannelTeams.Length == 0) { - var user = userResponse.Users.First(); - var channelResponse = await _twitchApi.Helix.Channels.GetChannelInformationAsync( - user.Id - ); - return new TwitchUserLookupResult( - user.Login, - user.DisplayName, - channelResponse.Data.FirstOrDefault()?.GameName ?? "[API ERROR]" - ); + return null; } - return null; + var team = channelTeams.ChannelTeams.First(); + return new TwitchStreamTeamResult( + user.DisplayName, + team.Id, + team.TeamName, + team.TeamDisplayName + ); + } + + private Task GetUsersAsync(string loginName) + { + return _memoryCache.GetOrCreateAsync( + $"twitch-user-{loginName}", + async entry => + { + entry.AbsoluteExpirationRelativeToNow = CacheDuration; + return await _twitchApi.Helix.Users.GetUsersAsync(logins: [loginName]); + } + ); + } + + private Task GetChannelInformationAsync(string userId) + { + return _memoryCache.GetOrCreateAsync( + $"twitch-channel-{userId}", + async entry => + { + entry.AbsoluteExpirationRelativeToNow = CacheDurationShort; + return await _twitchApi.Helix.Channels.GetChannelInformationAsync(userId); + } + ); + } + + private Task GetChannelTeamsAsync(string userId) + { + return _memoryCache.GetOrCreateAsync( + $"twitch-teams-{userId}", + async entry => + { + entry.AbsoluteExpirationRelativeToNow = CacheDuration; + return await _twitchApi.Helix.Teams.GetChannelTeamsAsync(userId); + } + ); } } diff --git a/src/FloppyBot.Commands.Aux.Twitch/Api/TwitchStreamTeamResult.cs b/src/FloppyBot.Commands.Aux.Twitch/Api/TwitchStreamTeamResult.cs new file mode 100644 index 00000000..ffc6f671 --- /dev/null +++ b/src/FloppyBot.Commands.Aux.Twitch/Api/TwitchStreamTeamResult.cs @@ -0,0 +1,17 @@ +namespace FloppyBot.Commands.Aux.Twitch.Api; + +public record TwitchStreamTeamResult( + string AccountName, + string? TeamId, + string? TeamSlug, + string? TeamName +) +{ + public string? Link => + !string.IsNullOrWhiteSpace(TeamSlug) ? $"https://www.twitch.tv/team/{TeamSlug}" : null; + + public bool IsSameTeam(TwitchStreamTeamResult team) + { + return TeamId == team.TeamId; + } +} diff --git a/src/FloppyBot.Commands.Aux.Twitch/FloppyBot.Commands.Aux.Twitch.csproj b/src/FloppyBot.Commands.Aux.Twitch/FloppyBot.Commands.Aux.Twitch.csproj index e167ca86..038d0d4f 100644 --- a/src/FloppyBot.Commands.Aux.Twitch/FloppyBot.Commands.Aux.Twitch.csproj +++ b/src/FloppyBot.Commands.Aux.Twitch/FloppyBot.Commands.Aux.Twitch.csproj @@ -13,6 +13,7 @@ + \ No newline at end of file diff --git a/src/FloppyBot.Commands.Aux.Twitch/Helpers/MessageTemplateParams.cs b/src/FloppyBot.Commands.Aux.Twitch/Helpers/MessageTemplateParams.cs new file mode 100644 index 00000000..355cfa11 --- /dev/null +++ b/src/FloppyBot.Commands.Aux.Twitch/Helpers/MessageTemplateParams.cs @@ -0,0 +1,32 @@ +using FloppyBot.Commands.Aux.Twitch.Api; + +namespace FloppyBot.Commands.Aux.Twitch.Helpers; + +public record MessageTemplateParams( + string AccountName, + string DisplayName, + string? LastGame, + string? TeamId, + string? TeamSlug, + string? TeamName +) +{ + public string Link => $"https://twitch.tv/{AccountName}"; + public string? TeamLink => + !string.IsNullOrWhiteSpace(TeamSlug) ? $"https://www.twitch.tv/team/{TeamSlug}" : null; + + public static MessageTemplateParams FromLookups( + TwitchUserLookupResult userLookup, + TwitchStreamTeamResult? teamLookup + ) + { + return new MessageTemplateParams( + userLookup.AccountName, + userLookup.DisplayName, + userLookup.LastGame, + teamLookup?.TeamId, + teamLookup?.TeamSlug, + teamLookup?.TeamName + ); + } +} diff --git a/src/FloppyBot.Commands.Aux.Twitch/ShoutoutCommand.cs b/src/FloppyBot.Commands.Aux.Twitch/ShoutoutCommand.cs index 97745272..e985dd81 100644 --- a/src/FloppyBot.Commands.Aux.Twitch/ShoutoutCommand.cs +++ b/src/FloppyBot.Commands.Aux.Twitch/ShoutoutCommand.cs @@ -2,9 +2,12 @@ using FloppyBot.Base.Cron; using FloppyBot.Base.TextFormatting; using FloppyBot.Chat.Entities; +using FloppyBot.Chat.Entities.Identifiers; using FloppyBot.Commands.Aux.Twitch.Api; using FloppyBot.Commands.Aux.Twitch.Config; +using FloppyBot.Commands.Aux.Twitch.Helpers; using FloppyBot.Commands.Aux.Twitch.Storage; +using FloppyBot.Commands.Aux.Twitch.Storage.Entities; using FloppyBot.Commands.Core.Attributes; using FloppyBot.Commands.Core.Attributes.Args; using FloppyBot.Commands.Core.Attributes.Dependencies; @@ -102,15 +105,44 @@ public static void SetupTimerMessageDbDependencies(IServiceCollection services) return null; } - var query = await _twitchApiService.LookupUser(channel); - if (query == null) + var userLookup = await _twitchApiService.LookupUser(channel); + if (userLookup == null) { return null; } + string messageTemplate = setting.Message; + TwitchStreamTeamResult? raiderTeamLookup = null; + + if (!string.IsNullOrWhiteSpace(setting.TeamMessage)) + { + var userTeamLookup = await _twitchApiService.LookupTeam( + sourceChannel.ParseAsChannelId().Channel + ); + raiderTeamLookup = await _twitchApiService.LookupTeam(channel); + + if ( + userTeamLookup is not null + && raiderTeamLookup is not null + && userTeamLookup.IsSameTeam(raiderTeamLookup) + ) + { + messageTemplate = setting.TeamMessage; + } + } + else + { + _logger.LogTrace( + "No team message configured for {Channel}, skipping lookup", + sourceChannel + ); + } + try { - return setting.Message.Format(query); + return messageTemplate.Format( + MessageTemplateParams.FromLookups(userLookup, raiderTeamLookup) + ); } catch (FormattingException ex) { @@ -123,14 +155,14 @@ public static void SetupTimerMessageDbDependencies(IServiceCollection services) } } - [Command("setshoutout")] + [Command("setshoutout", "setso")] [CommandDescription( "Sets the shoutout template for the requesting channel. " + "The following placeholders are supported, when surrounded by {}: " - + $"{nameof(TwitchUserLookupResult.AccountName)}, " - + $"{nameof(TwitchUserLookupResult.DisplayName)}, " - + $"{nameof(TwitchUserLookupResult.LastGame)}, " - + $"{nameof(TwitchUserLookupResult.Link)}" + + $"{nameof(MessageTemplateParams.AccountName)}, " + + $"{nameof(MessageTemplateParams.DisplayName)}, " + + $"{nameof(MessageTemplateParams.LastGame)}, " + + $"{nameof(MessageTemplateParams.Link)}" )] [CommandSyntax( "", @@ -143,7 +175,38 @@ public string SetShoutout([SourceChannel] string sourceChannel, [AllArguments] s return REPLY_SAVE; } - [Command("clearshoutout")] + [Command("setteamshoutout", "setteamso")] + [CommandDescription( + "Sets the shoutout template for the requesting channel when the raiding channel is part of the same team. " + + "The following placeholders are supported, when surrounded by {}: " + + $"{nameof(MessageTemplateParams.AccountName)}, " + + $"{nameof(MessageTemplateParams.DisplayName)}, " + + $"{nameof(MessageTemplateParams.LastGame)}, " + + $"{nameof(MessageTemplateParams.Link)}, " + + $"{nameof(MessageTemplateParams.TeamId)}, " + + $"{nameof(MessageTemplateParams.TeamSlug)}, " + + $"{nameof(MessageTemplateParams.TeamName)}, " + + $"{nameof(MessageTemplateParams.TeamLink)}" + )] + public string? SetTeamShoutout( + [SourceChannel] string sourceChannel, + [AllArguments] string template + ) + { + var settings = + _shoutoutMessageSettingService.GetSettings(sourceChannel) + ?? new ShoutoutMessageSetting(sourceChannel, template, null); + + _shoutoutMessageSettingService.SetShoutoutMessage( + settings with + { + TeamMessage = template, + } + ); + return REPLY_SAVE; + } + + [Command("clearshoutout", "clearso")] [CommandDescription( "Clears the channels shoutout message, effectively disabling the shoutout command" )] diff --git a/src/FloppyBot.Commands.Aux.Twitch/Storage/Entities/ShoutoutMessageSetting.cs b/src/FloppyBot.Commands.Aux.Twitch/Storage/Entities/ShoutoutMessageSetting.cs index a2e3efac..030b9fab 100644 --- a/src/FloppyBot.Commands.Aux.Twitch/Storage/Entities/ShoutoutMessageSetting.cs +++ b/src/FloppyBot.Commands.Aux.Twitch/Storage/Entities/ShoutoutMessageSetting.cs @@ -4,7 +4,8 @@ namespace FloppyBot.Commands.Aux.Twitch.Storage.Entities; [CollectionName("ShoutoutMessageSettings")] -public record ShoutoutMessageSetting(string Id, string Message) : IEntity +public record ShoutoutMessageSetting(string Id, string Message, string? TeamMessage) + : IEntity { public ShoutoutMessageSetting WithId(string newId) { diff --git a/src/FloppyBot.Commands.Aux.Twitch/Storage/IShoutoutMessageSettingService.cs b/src/FloppyBot.Commands.Aux.Twitch/Storage/IShoutoutMessageSettingService.cs index 379cf834..aab73139 100644 --- a/src/FloppyBot.Commands.Aux.Twitch/Storage/IShoutoutMessageSettingService.cs +++ b/src/FloppyBot.Commands.Aux.Twitch/Storage/IShoutoutMessageSettingService.cs @@ -6,5 +6,6 @@ public interface IShoutoutMessageSettingService { ShoutoutMessageSetting? GetSettings(string channelId); void SetShoutoutMessage(string channelId, string message); + void SetShoutoutMessage(ShoutoutMessageSetting setting); void ClearSettings(string channelId); } diff --git a/src/FloppyBot.Commands.Aux.Twitch/Storage/ShoutoutMessageSettingService.cs b/src/FloppyBot.Commands.Aux.Twitch/Storage/ShoutoutMessageSettingService.cs index e6f653f5..f5029274 100644 --- a/src/FloppyBot.Commands.Aux.Twitch/Storage/ShoutoutMessageSettingService.cs +++ b/src/FloppyBot.Commands.Aux.Twitch/Storage/ShoutoutMessageSettingService.cs @@ -26,7 +26,20 @@ public void SetShoutoutMessage(string channelId, string message) } else { - _repository.Insert(new ShoutoutMessageSetting(channelId, message)); + _repository.Insert(new ShoutoutMessageSetting(channelId, message, null)); + } + } + + public void SetShoutoutMessage(ShoutoutMessageSetting setting) + { + var existingSetting = GetSettings(setting.Id); + if (existingSetting != null) + { + _repository.Update(setting); + } + else + { + _repository.Insert(setting); } } diff --git a/src/FloppyBot.Commands.Parser.Agent/FloppyBot.Commands.Parser.Agent.csproj b/src/FloppyBot.Commands.Parser.Agent/FloppyBot.Commands.Parser.Agent.csproj index 612e42e4..b904e9be 100644 --- a/src/FloppyBot.Commands.Parser.Agent/FloppyBot.Commands.Parser.Agent.csproj +++ b/src/FloppyBot.Commands.Parser.Agent/FloppyBot.Commands.Parser.Agent.csproj @@ -17,6 +17,7 @@ + \ No newline at end of file diff --git a/src/FloppyBot.Commands.Parser.Agent/Program.cs b/src/FloppyBot.Commands.Parser.Agent/Program.cs index d65c93b3..bcdd58e1 100644 --- a/src/FloppyBot.Commands.Parser.Agent/Program.cs +++ b/src/FloppyBot.Commands.Parser.Agent/Program.cs @@ -25,6 +25,7 @@ config.GetSection("CommandPrefixes").Get() ?? new[] { "?" }; return new CommandParser(prefixes); }) + .AddMemoryCache() .AddHostedService(); }) .Build(); diff --git a/src/FloppyBot.IntTest/ServiceInterfaceTest.cs b/src/FloppyBot.IntTest/ServiceInterfaceTest.cs index 7e281422..09cbea06 100644 --- a/src/FloppyBot.IntTest/ServiceInterfaceTest.cs +++ b/src/FloppyBot.IntTest/ServiceInterfaceTest.cs @@ -24,7 +24,8 @@ public async Task TestMongoDatabase() await _testContainerFixture.Startup(); var repositoryFactory = new MongoDbRepositoryFactory( - _testContainerFixture.GetMongoDatabase() + _testContainerFixture.GetMongoDatabase(), + A.Fake>() ); // arrange diff --git a/src/FloppyBot.WebApi.Agent/Program.cs b/src/FloppyBot.WebApi.Agent/Program.cs index 8ab14f36..6a5c6a6b 100644 --- a/src/FloppyBot.WebApi.Agent/Program.cs +++ b/src/FloppyBot.WebApi.Agent/Program.cs @@ -149,7 +149,8 @@ await context.Response.WriteAsync( .AddKillSwitchTrigger() .AddKillSwitch() .AddV1Compatibility() - .AddSingleton(); + .AddSingleton() + .AddMemoryCache(); // *** CONFIGURE ************************************************************************ var app = builder.Build(); diff --git a/src/FloppyBot.WebApi.V1Compatibility/Controllers/v1/Config/ShoutoutConfigController.cs b/src/FloppyBot.WebApi.V1Compatibility/Controllers/v1/Config/ShoutoutConfigController.cs index 58feb319..e8dcad2a 100644 --- a/src/FloppyBot.WebApi.V1Compatibility/Controllers/v1/Config/ShoutoutConfigController.cs +++ b/src/FloppyBot.WebApi.V1Compatibility/Controllers/v1/Config/ShoutoutConfigController.cs @@ -39,7 +39,7 @@ public ShoutoutMessageConfig[] GetAllConfigs() .Where(channelId => channelId.StartsWith("Twitch/")) .Select(channelId => _shoutoutMessageSettingService.GetSettings(channelId) - ?? new ShoutoutMessageSetting(channelId, string.Empty) + ?? new ShoutoutMessageSetting(channelId, string.Empty, null) ) .Select(settings => _mapper.Map(settings)) .ToArray(); diff --git a/src/FloppyBot.WebApi.V1Compatibility/Mapping/V1CompatibilityProfile.cs b/src/FloppyBot.WebApi.V1Compatibility/Mapping/V1CompatibilityProfile.cs index 0b3b9d0a..228a1469 100644 --- a/src/FloppyBot.WebApi.V1Compatibility/Mapping/V1CompatibilityProfile.cs +++ b/src/FloppyBot.WebApi.V1Compatibility/Mapping/V1CompatibilityProfile.cs @@ -136,7 +136,7 @@ private void MapUser() private void MapShoutoutMessage() { CreateMap() - .ConstructUsing(c => new ShoutoutMessageSetting(c.Id, c.Message)); + .ConstructUsing(c => new ShoutoutMessageSetting(c.Id, c.Message, null)); CreateMap() .ConstructUsing(c => new ShoutoutMessageConfig(c.Id, c.Message)); } diff --git a/src/FloppyBot.WebApi.V2/Controllers/ShoutoutCommandConfigController.cs b/src/FloppyBot.WebApi.V2/Controllers/ShoutoutCommandConfigController.cs index 3cd5da3a..00d5421a 100644 --- a/src/FloppyBot.WebApi.V2/Controllers/ShoutoutCommandConfigController.cs +++ b/src/FloppyBot.WebApi.V2/Controllers/ShoutoutCommandConfigController.cs @@ -49,7 +49,7 @@ [FromBody] ShoutoutCommandConfig config ) { var channelId = EnsureChannelAccess(messageInterface, channel); - _shoutoutMessageSettingService.SetShoutoutMessage(channelId, config.Message); + _shoutoutMessageSettingService.SetShoutoutMessage(config.ToEntity(channelId)); return NoContent(); } } diff --git a/src/FloppyBot.WebApi.V2/Dtos/ShoutoutCommandConfig.cs b/src/FloppyBot.WebApi.V2/Dtos/ShoutoutCommandConfig.cs index 9db6ca3c..dcb25280 100644 --- a/src/FloppyBot.WebApi.V2/Dtos/ShoutoutCommandConfig.cs +++ b/src/FloppyBot.WebApi.V2/Dtos/ShoutoutCommandConfig.cs @@ -2,12 +2,17 @@ namespace FloppyBot.WebApi.V2.Dtos; -public record ShoutoutCommandConfig(string Message) +public record ShoutoutCommandConfig(string Message, string? TeamMessage) { - public static readonly ShoutoutCommandConfig Empty = new(string.Empty); + public static readonly ShoutoutCommandConfig Empty = new(string.Empty, null); public static ShoutoutCommandConfig FromEntity(ShoutoutMessageSetting entity) { - return new ShoutoutCommandConfig(entity.Message); + return new ShoutoutCommandConfig(entity.Message, entity.TeamMessage); + } + + public ShoutoutMessageSetting ToEntity(string channelId) + { + return new ShoutoutMessageSetting(channelId, Message, TeamMessage); } }