Skip to content

Commit 0bb76da

Browse files
authored
Adds a raid announcement and reward for those that join the raid and enter a specific message (#1215)
* Adds a raid announcement and reward for those that join the raid and enter a specific message * Code review changes
1 parent 107115c commit 0bb76da

18 files changed

Lines changed: 1001 additions & 10 deletions

File tree

PenguinTwitchBot.Test/Bot/Commands/Misc/RaidTrackerTests.cs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ public async Task GetHistory_ShouldGetHistory()
3535
var queryable = new List<RaidHistoryEntry> { new RaidHistoryEntry() }.AsQueryable();
3636
dbContext.RaidHistory.GetAllAsync().Returns(queryable);
3737

38-
var raidTracker = new RaidTracker(Substitute.For<ILogger<RaidTracker>>(), scopeFactory, Substitute.For<ITwitchService>(), Substitute.For<IServiceBackbone>(), dispatcherSubstitute, Substitute.For<ICommandHandler>());
38+
var raidTracker = new RaidTracker(Substitute.For<ILogger<RaidTracker>>(), scopeFactory, Substitute.For<ITwitchService>(), Substitute.For<IServiceBackbone>(), dispatcherSubstitute, Substitute.For<ICommandHandler>(), Substitute.For<PenguinTwitchBot.Services.IRaidRewardService>());
3939
//Act
4040
var result = await raidTracker.GetHistory();
4141

@@ -65,7 +65,7 @@ public async Task Raid_InvalidUser_ShouldThrow()
6565
var twitchService = Substitute.For<ITwitchService>();
6666
twitchService.GetUserByName("").ReturnsNull();
6767

68-
var raidTracker = new RaidTracker(Substitute.For<ILogger<RaidTracker>>(), scopeFactory, twitchService, Substitute.For<IServiceBackbone>(), dispatcherSubstitute, Substitute.For<ICommandHandler>());
68+
var raidTracker = new RaidTracker(Substitute.For<ILogger<RaidTracker>>(), scopeFactory, twitchService, Substitute.For<IServiceBackbone>(), dispatcherSubstitute, Substitute.For<ICommandHandler>(), Substitute.For<PenguinTwitchBot.Services.IRaidRewardService>());
6969
//Act
7070

7171

@@ -96,7 +96,7 @@ public async Task Raid_IsOffline_ShouldThrow()
9696
twitchService.GetUserByName("").Returns(new User(Id: "", Login: "", DisplayName: "", Description: "", CreatedAt: default));
9797
twitchService.IsStreamOnline(Arg.Any<string>()).Returns(false);
9898

99-
var raidTracker = new RaidTracker(Substitute.For<ILogger<RaidTracker>>(), scopeFactory, twitchService, Substitute.For<IServiceBackbone>(), dispatcherSubstitute, Substitute.For<ICommandHandler>());
99+
var raidTracker = new RaidTracker(Substitute.For<ILogger<RaidTracker>>(), scopeFactory, twitchService, Substitute.For<IServiceBackbone>(), dispatcherSubstitute, Substitute.For<ICommandHandler>(), Substitute.For<PenguinTwitchBot.Services.IRaidRewardService>());
100100
//Act
101101

102102

@@ -127,7 +127,7 @@ public async Task Raid_ShouldSucceed()
127127
twitchService.GetUserByName("").Returns(new User(Id: "", Login: "", DisplayName: "", Description: "", CreatedAt: default));
128128
twitchService.IsStreamOnline(Arg.Any<string>()).Returns(true);
129129

130-
var raidTracker = new RaidTracker(Substitute.For<ILogger<RaidTracker>>(), scopeFactory, twitchService, serviceBackbone, dispatcherSubstitute, Substitute.For<ICommandHandler>());
130+
var raidTracker = new RaidTracker(Substitute.For<ILogger<RaidTracker>>(), scopeFactory, twitchService, serviceBackbone, dispatcherSubstitute, Substitute.For<ICommandHandler>(), Substitute.For<PenguinTwitchBot.Services.IRaidRewardService>());
131131
//Act
132132
await raidTracker.Raid("");
133133

@@ -156,7 +156,7 @@ public async Task OnIncomingRaid_NoneExisting_ShouldSucceed()
156156
dbContext.RaidHistory.Find(x => true).ReturnsForAnyArgs(queryable);
157157

158158
twitchService.GetUserId(Arg.Any<string>()).Returns("");
159-
var raidTracker = new RaidTracker(Substitute.For<ILogger<RaidTracker>>(), scopeFactory, twitchService, serviceBackbone, dispatcherSubstitute, Substitute.For<ICommandHandler>());
159+
var raidTracker = new RaidTracker(Substitute.For<ILogger<RaidTracker>>(), scopeFactory, twitchService, serviceBackbone, dispatcherSubstitute, Substitute.For<ICommandHandler>(), Substitute.For<PenguinTwitchBot.Services.IRaidRewardService>());
160160

161161
//Act
162162
await raidTracker.OnIncomingRaid(new PenguinTwitchBot.Bot.Events.RaidEventArgs());
@@ -192,7 +192,7 @@ public async Task UpdateOnlineStatus_ShouldUpdateStatuses()
192192
serviceBackbone.IsOnline = true;
193193

194194
twitchService.AreStreamsOnline(Arg.Any<List<string>>()).ReturnsForAnyArgs([new()]);
195-
var raidTracker = new RaidTracker(Substitute.For<ILogger<RaidTracker>>(), scopeFactory, twitchService, serviceBackbone, dispatcherSubstitute, Substitute.For<ICommandHandler>());
195+
var raidTracker = new RaidTracker(Substitute.For<ILogger<RaidTracker>>(), scopeFactory, twitchService, serviceBackbone, dispatcherSubstitute, Substitute.For<ICommandHandler>(), Substitute.For<PenguinTwitchBot.Services.IRaidRewardService>());
196196

197197
//Act
198198
await raidTracker.UpdateOnlineStatus();
@@ -228,7 +228,7 @@ public async Task OneCommand_Raid_ShouldSucceed()
228228

229229
commandHandler.GetCommandDefaultName("raid").Returns("raid");
230230

231-
var raidTracker = new RaidTracker(Substitute.For<ILogger<RaidTracker>>(), scopeFactory, twitchService, serviceBackbone, dispatcherSubstitute, commandHandler);
231+
var raidTracker = new RaidTracker(Substitute.For<ILogger<RaidTracker>>(), scopeFactory, twitchService, serviceBackbone, dispatcherSubstitute, commandHandler, Substitute.For<PenguinTwitchBot.Services.IRaidRewardService>());
232232
//Act
233233
await raidTracker.OnCommand(null, new PenguinTwitchBot.Bot.Events.Chat.CommandEventArgs
234234
{
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
using Microsoft.Extensions.Configuration;
2+
using Microsoft.Extensions.Logging;
3+
using NSubstitute;
4+
using PenguinTwitchBot.Bot.Commands.Features;
5+
using PenguinTwitchBot.Bot.Core;
6+
using PenguinTwitchBot.Bot.Core.Points;
7+
using PenguinTwitchBot.Bot.Events;
8+
using PenguinTwitchBot.Bot.TwitchServices;
9+
using PenguinTwitchBot.Database.Bot.Models.Points;
10+
using PenguinTwitchBot.Services;
11+
using PenguinTwitchBot.TwitchApi.EventSub;
12+
using PenguinTwitchBot.TwitchApi.EventSub.EventArgs.Channel;
13+
using PenguinTwitchBot.TwitchApi.EventSub.Models.Chat;
14+
using PenguinTwitchBot.TwitchApi.EventSub.SubscriptionTypes.Channel;
15+
using PenguinTwitchBot.TwitchApi.EventSub.Websockets;
16+
using PenguinTwitchBot.TwitchApi.Helix;
17+
using PenguinTwitchBot.TwitchApi.Models.EventSub;
18+
using Xunit;
19+
20+
namespace PenguinTwitchBot.Test.Services
21+
{
22+
public class RaidRewardServiceTests
23+
{
24+
private sealed class ConcreteEventSubMetadata : EventSubMetadata { }
25+
26+
private readonly IServiceBackbone _serviceBackbone = Substitute.For<IServiceBackbone>();
27+
private readonly IEventSubWebsocketClient _eventSubClient = Substitute.For<IEventSubWebsocketClient>();
28+
private readonly IViewerFeature _viewerFeature = Substitute.For<IViewerFeature>();
29+
private readonly IPointsSystem _pointsSystem = Substitute.For<IPointsSystem>();
30+
private readonly ITwitchService _twitchService = Substitute.For<ITwitchService>();
31+
private readonly IRaidRewardSettingsService _settings = Substitute.For<IRaidRewardSettingsService>();
32+
private readonly IModerationClient _moderationClient = Substitute.For<IModerationClient>();
33+
private readonly IConfiguration _configuration;
34+
private readonly RaidRewardService _service;
35+
36+
public RaidRewardServiceTests()
37+
{
38+
_configuration = new ConfigurationBuilder()
39+
.AddInMemoryCollection(new Dictionary<string, string?>
40+
{
41+
{ "twitchClientId", "client-id" },
42+
{ "twitchAccessToken", "token" }
43+
})
44+
.Build();
45+
46+
_eventSubClient.SessionId.Returns("session-1");
47+
_twitchService.GetBroadcasterUserId().Returns("broadcaster-1");
48+
_moderationClient.CreateEventSubSubscriptionDetailedAsync(
49+
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
50+
Arg.Any<Dictionary<string, string>>(), Arg.Any<EventSubTransportMethod>(), Arg.Any<string>())
51+
.Returns(new CreateEventSubSubscriptionResult(true, "sub-1", null));
52+
53+
_service = new RaidRewardService(
54+
Substitute.For<ILogger<RaidRewardService>>(),
55+
_serviceBackbone, _eventSubClient, _viewerFeature, _pointsSystem,
56+
_twitchService, _settings, _moderationClient, _configuration, TimeProvider.System);
57+
}
58+
59+
private static RaidRewardConfig DefaultConfig() => new(
60+
Enabled: true,
61+
PointTypeId: 5,
62+
PointsToAward: 100,
63+
TimeWindowMinutes: 5,
64+
Message: "penguin raid",
65+
SubscriberMessage: "penguin sub raid",
66+
AnnouncementTemplate: "tpl",
67+
PostAnnouncement: true);
68+
69+
private async Task StartAndOpenRaidWindowAsync(RaidRewardConfig config, List<string>? current = null, List<string>? active = null)
70+
{
71+
_settings.GetConfigAsync().Returns(config);
72+
_pointsSystem.GetPointTypeById(config.PointTypeId).Returns(new PointType { Id = config.PointTypeId, Name = "Points" });
73+
_viewerFeature.GetCurrentViewers().Returns(current ?? new List<string> { "viewer1" });
74+
_viewerFeature.GetActiveViewers().Returns(active ?? new List<string>());
75+
76+
await _service.StartAsync(CancellationToken.None);
77+
78+
// Invoke the outgoing raid handler directly.
79+
await _service.OnOutgoingRaid(_serviceBackbone, new OutgoingRaidEventArgs { TargetUserId = "target-1", TargetDisplayName = "Target", TargetUserName = "target", NumberOfViewers = 10 });
80+
await Task.Delay(10);
81+
}
82+
83+
private Task SendChatMessageAsync(string login, string userId, string text, string broadcasterId = "target-1")
84+
{
85+
var args = new ChannelChatMessageEventArgs
86+
{
87+
Metadata = new ConcreteEventSubMetadata { MessageId = Guid.NewGuid().ToString(), MessageType = "notification", MessageTimestamp = DateTime.UtcNow },
88+
Event = new ChannelChatMessage
89+
{
90+
ChatterUserId = userId,
91+
ChatterUserLogin = login,
92+
ChatterUserName = login,
93+
BroadcasterUserId = broadcasterId,
94+
Message = new ChatMessage { Text = text }
95+
}
96+
};
97+
return _service.OnChannelChatMessage(_eventSubClient, args);
98+
}
99+
100+
[Fact]
101+
public async Task SharedChatGuestChannelMessage_NotAwarded()
102+
{
103+
// A message from a DIFFERENT channel in a shared-chat session (different
104+
// broadcaster_user_id) must not count even if the chatter is eligible.
105+
await StartAndOpenRaidWindowAsync(DefaultConfig());
106+
await SendChatMessageAsync("viewer1", "uid-1", "penguin raid", broadcasterId: "57135261");
107+
await Task.Delay(10);
108+
109+
await _pointsSystem.DidNotReceive().AddPointsByUserId(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<long>());
110+
}
111+
112+
[Fact]
113+
public async Task EligibleViewer_SendsMessage_AwardedOnce()
114+
{
115+
await StartAndOpenRaidWindowAsync(DefaultConfig());
116+
await SendChatMessageAsync("viewer1", "uid-1", "PENGUIN RAID hype!");
117+
await Task.Delay(10);
118+
119+
await _pointsSystem.Received(1).AddPointsByUserId("uid-1", 5, 100);
120+
}
121+
122+
[Fact]
123+
public async Task DuplicateMessage_NotAwardedTwice()
124+
{
125+
await StartAndOpenRaidWindowAsync(DefaultConfig());
126+
await SendChatMessageAsync("viewer1", "uid-1", "penguin raid");
127+
await SendChatMessageAsync("viewer1", "uid-1", "penguin raid again");
128+
await Task.Delay(10);
129+
130+
await _pointsSystem.Received(1).AddPointsByUserId("uid-1", 5, 100);
131+
}
132+
133+
[Fact]
134+
public async Task IneligibleViewer_NotAwarded()
135+
{
136+
await StartAndOpenRaidWindowAsync(DefaultConfig());
137+
await SendChatMessageAsync("outsider", "uid-9", "penguin raid");
138+
await Task.Delay(10);
139+
140+
await _pointsSystem.DidNotReceive().AddPointsByUserId(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<long>());
141+
}
142+
143+
[Fact]
144+
public async Task MessageWithoutPhrase_NotAwarded()
145+
{
146+
await StartAndOpenRaidWindowAsync(DefaultConfig());
147+
await SendChatMessageAsync("viewer1", "uid-1", "hello there");
148+
await Task.Delay(10);
149+
150+
await _pointsSystem.DidNotReceive().AddPointsByUserId(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<long>());
151+
}
152+
153+
[Fact]
154+
public async Task NonSub_UsingSubPhrase_NotAwarded()
155+
{
156+
await StartAndOpenRaidWindowAsync(DefaultConfig());
157+
_viewerFeature.IsSubscriber("viewer1").Returns(false);
158+
await SendChatMessageAsync("viewer1", "uid-1", "penguin sub raid");
159+
await Task.Delay(10);
160+
161+
await _pointsSystem.DidNotReceive().AddPointsByUserId(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<long>());
162+
}
163+
164+
[Fact]
165+
public async Task Sub_UsingSubPhrase_Awarded()
166+
{
167+
await StartAndOpenRaidWindowAsync(DefaultConfig());
168+
_viewerFeature.IsSubscriber("viewer1").Returns(true);
169+
await SendChatMessageAsync("viewer1", "uid-1", "penguin sub raid!!");
170+
await Task.Delay(10);
171+
172+
await _pointsSystem.Received(1).AddPointsByUserId("uid-1", 5, 100);
173+
}
174+
175+
[Fact]
176+
public async Task AwardFails_ReservationRolledBack_RetryAwards()
177+
{
178+
await StartAndOpenRaidWindowAsync(DefaultConfig());
179+
180+
// First attempt: point write throws -> reservation must roll back.
181+
_pointsSystem.AddPointsByUserId(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<long>())
182+
.Returns<long>(_ => throw new Exception("db down"));
183+
await SendChatMessageAsync("viewer1", "uid-1", "penguin raid");
184+
await Task.Delay(10);
185+
186+
// Second attempt after recovery -> should be awarded.
187+
_pointsSystem.AddPointsByUserId(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<long>())
188+
.Returns(1000L);
189+
await SendChatMessageAsync("viewer1", "uid-1", "penguin raid again");
190+
await Task.Delay(10);
191+
192+
await _pointsSystem.Received(2).AddPointsByUserId("uid-1", 5, 100);
193+
}
194+
195+
[Fact]
196+
public async Task AnnouncementThrows_RaidNotBlocked()
197+
{
198+
// Settings access throws; AnnounceRaidInitiatedAsync must swallow it (not propagate to RaidTracker).
199+
_settings.GetConfigAsync().Returns<RaidRewardConfig>(_ => throw new Exception("settings unavailable"));
200+
await _service.StartAsync(CancellationToken.None);
201+
202+
// Should not throw.
203+
await _service.AnnounceRaidInitiatedAsync("Target");
204+
}
205+
206+
[Fact]
207+
public async Task DisabledFeature_NoSubscriptionCreated()
208+
{
209+
var config = DefaultConfig() with { Enabled = false };
210+
_settings.GetConfigAsync().Returns(config);
211+
await _service.StartAsync(CancellationToken.None);
212+
213+
await _service.OnOutgoingRaid(_serviceBackbone, new OutgoingRaidEventArgs { TargetUserId = "t", TargetDisplayName = "T", TargetUserName = "t" });
214+
await Task.Delay(10);
215+
216+
await _moderationClient.DidNotReceive().CreateEventSubSubscriptionDetailedAsync(
217+
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
218+
Arg.Any<Dictionary<string, string>>(), Arg.Any<EventSubTransportMethod>(), Arg.Any<string>());
219+
}
220+
}
221+
}

PenguinTwitchBot.TwitchApi/Helix/IModerationClient.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,6 @@ public interface IModerationClient
1111
Task BanUserAsync(string clientId, string? accessToken, string broadcasterId, string moderatorId, BanUserRequest request);
1212
Task DeleteChatMessagesAsync(string clientId, string? accessToken, string broadcasterId, string moderatorId, string? messageId);
1313
Task<EventSubSubscriptionResult> CreateEventSubSubscriptionAsync(string clientId, string? accessToken, string type, string version, Dictionary<string, string> condition, EventSubTransportMethod transportMethod, string transportSessionId);
14+
Task<CreateEventSubSubscriptionResult> CreateEventSubSubscriptionDetailedAsync(string clientId, string? accessToken, string type, string version, Dictionary<string, string> condition, EventSubTransportMethod transportMethod, string transportSessionId);
15+
Task DeleteEventSubSubscriptionAsync(string clientId, string? accessToken, string subscriptionId);
1416
}

PenguinTwitchBot.TwitchApi/Helix/IModerationTransport.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,6 @@ public interface IModerationTransport
1111
Task BanUserAsync(string clientId, string? accessToken, string broadcasterId, string moderatorId, BanUserRequest request);
1212
Task DeleteChatMessagesAsync(string clientId, string? accessToken, string broadcasterId, string moderatorId, string? messageId);
1313
Task<EventSubSubscriptionResult> CreateEventSubSubscriptionAsync(string clientId, string? accessToken, string type, string version, Dictionary<string, string> condition, EventSubTransportMethod transportMethod, string transportSessionId);
14+
Task<CreateEventSubSubscriptionResult> CreateEventSubSubscriptionDetailedAsync(string clientId, string? accessToken, string type, string version, Dictionary<string, string> condition, EventSubTransportMethod transportMethod, string transportSessionId);
15+
Task DeleteEventSubSubscriptionAsync(string clientId, string? accessToken, string subscriptionId);
1416
}

PenguinTwitchBot.TwitchApi/Helix/ModerationClient.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,15 @@ public async Task<EventSubSubscriptionResult> CreateEventSubSubscriptionAsync(st
3737
() => transport.CreateEventSubSubscriptionAsync(clientId, accessToken, type, version, condition, transportMethod, transportSessionId),
3838
"create eventsub subscription");
3939
}
40+
41+
public async Task<CreateEventSubSubscriptionResult> CreateEventSubSubscriptionDetailedAsync(string clientId, string? accessToken, string type, string version, Dictionary<string, string> condition, Models.EventSub.EventSubTransportMethod transportMethod, string transportSessionId)
42+
{
43+
// Do not retry with backoff here; the caller needs the immediate, raw Twitch error for diagnosis.
44+
return await transport.CreateEventSubSubscriptionDetailedAsync(clientId, accessToken, type, version, condition, transportMethod, transportSessionId);
45+
}
46+
47+
public async Task DeleteEventSubSubscriptionAsync(string clientId, string? accessToken, string subscriptionId)
48+
{
49+
await ExecuteWithRetryAsync(() => transport.DeleteEventSubSubscriptionAsync(clientId, accessToken, subscriptionId), "delete eventsub subscription");
50+
}
4051
}

0 commit comments

Comments
 (0)