-
Notifications
You must be signed in to change notification settings - Fork 1
Fix raid detection and use Announcements instead #1216
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -58,6 +58,8 @@ | |
| private readonly object _windowLock = new(); | ||
| private RaidWindow? _activeWindow; | ||
| private Timer? _expiryTimer; | ||
| private Timer? _preRaidReminderTimer; | ||
| private int _preRaidReminderGeneration; | ||
|
|
||
| public RaidRewardService( | ||
| ILogger<RaidRewardService> logger, | ||
|
|
@@ -95,6 +97,7 @@ | |
| { | ||
| _serviceBackbone.OutgoingRaidEvent -= OnOutgoingRaid; | ||
| _eventSubClient.ChannelChatMessage -= OnChannelChatMessage; | ||
| CancelPreRaidReminder(); | ||
| await CloseActiveWindowAsync(); | ||
| } | ||
|
|
||
|
|
@@ -107,23 +110,91 @@ | |
| try | ||
| { | ||
| var config = await _settings.GetConfigAsync(); | ||
| if (!config.Enabled || !config.PostAnnouncement) | ||
| if (!config.Enabled || !config.PostPreRaidAnnouncement) | ||
| return; | ||
| if (string.IsNullOrWhiteSpace(config.Message)) | ||
| return; | ||
|
|
||
| var pointTypeName = await GetPointTypeNameAsync(config.PointTypeId); | ||
| var message = BuildAnnouncement(config, targetDisplayName, pointTypeName); | ||
| await _serviceBackbone.SendChatMessage(message); | ||
| await PostAnnouncementAsync(targetDisplayName, config, "pre-raid"); | ||
| StartPreRaidReminder(targetDisplayName); | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| // Never let announcement failures (settings, point lookup, chat dispatch) | ||
| // propagate to RaidTracker.Raid and prevent the raid from starting. | ||
| _logger.LogError(ex, "Raid reward: failed to post announcement for {Target}", targetDisplayName); | ||
| _logger.LogError(ex, "Raid reward: failed to post pre-raid announcement for {Target}", targetDisplayName); | ||
| } | ||
| } | ||
|
|
||
| private void StartPreRaidReminder(string targetDisplayName) | ||
| { | ||
| CancelPreRaidReminder(); | ||
| var generation = Interlocked.Increment(ref _preRaidReminderGeneration); | ||
| _preRaidReminderTimer = new Timer(_ => _ = SendPreRaidReminderAsync(targetDisplayName, generation), null, TimeSpan.FromSeconds(30), Timeout.InfiniteTimeSpan); | ||
|
Check warning on line 133 in PenguinTwitchBot/Services/RaidRewardService.cs
|
||
| } | ||
|
|
||
| private void CancelPreRaidReminder() | ||
| { | ||
| Interlocked.Increment(ref _preRaidReminderGeneration); | ||
| _preRaidReminderTimer?.Dispose(); | ||
| _preRaidReminderTimer = null; | ||
| } | ||
|
|
||
| private bool IsCurrentReminder(int generation) | ||
| => generation == _preRaidReminderGeneration; | ||
|
|
||
| private void CancelPreRaidReminderIfCurrent(int generation) | ||
| { | ||
| if (!IsCurrentReminder(generation)) return; | ||
| var timer = _preRaidReminderTimer; | ||
| _preRaidReminderTimer = null; | ||
| timer?.Dispose(); | ||
| } | ||
|
|
||
| internal async Task SendPreRaidReminderAsync(string targetDisplayName, int generation) | ||
| { | ||
| try | ||
| { | ||
| // Skip if the raid already fired (a window is open) or a newer raid superseded this reminder. | ||
| lock (_windowLock) | ||
| { | ||
| if (_activeWindow != null || !IsCurrentReminder(generation)) | ||
| { | ||
| CancelPreRaidReminderIfCurrent(generation); | ||
| return; | ||
| } | ||
| } | ||
|
|
||
| var config = await _settings.GetConfigAsync(); | ||
| if (!IsCurrentReminder(generation)) return; | ||
|
|
||
| if (!config.Enabled || !config.PostPreRaidAnnouncement || string.IsNullOrWhiteSpace(config.Message)) | ||
| { | ||
| CancelPreRaidReminderIfCurrent(generation); | ||
| return; | ||
| } | ||
|
|
||
| if (!IsCurrentReminder(generation)) return; | ||
| await PostAnnouncementAsync(targetDisplayName, config, "pre-raid reminder"); | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| _logger.LogError(ex, "Raid reward: failed to post pre-raid reminder for {Target}", targetDisplayName); | ||
| } | ||
| finally | ||
| { | ||
| CancelPreRaidReminderIfCurrent(generation); | ||
| } | ||
| } | ||
|
|
||
| private async Task PostAnnouncementAsync(string targetDisplayName, RaidRewardConfig config, string kind) | ||
| { | ||
| var pointTypeName = await GetPointTypeNameAsync(config.PointTypeId); | ||
| var message = BuildAnnouncement(config, targetDisplayName, pointTypeName); | ||
| await _twitchService.Announcement(message); | ||
| _logger.LogInformation("Raid reward: posted {Kind} announcement for {Target}", kind, targetDisplayName); | ||
| } | ||
|
|
||
| internal async Task OnOutgoingRaid(object? sender, OutgoingRaidEventArgs e) | ||
| { | ||
| try | ||
|
|
@@ -149,6 +220,9 @@ | |
| Config = config | ||
| }; | ||
|
|
||
| // The raid fired; no need for the pre-raid reminder anymore. | ||
| CancelPreRaidReminder(); | ||
|
|
||
| // Close any prior window (deleting its chat subscription) before swapping in the new one. | ||
| await CloseActiveWindowAsync(); | ||
|
|
||
|
|
@@ -174,6 +248,18 @@ | |
|
|
||
| _logger.LogInformation("Raid reward window opened for {Target} until {Expiry} with {Count} eligible viewers", | ||
| e.TargetDisplayName, window.ExpiresAtUtc, eligible.Count); | ||
|
|
||
| if (config.PostAnnouncement) | ||
| { | ||
| try | ||
| { | ||
| await PostAnnouncementAsync(e.TargetDisplayName, config, "raid-start"); | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| _logger.LogError(ex, "Raid reward: failed to post raid-start announcement for {Target}", e.TargetDisplayName); | ||
| } | ||
| } | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
|
|
@@ -242,41 +328,96 @@ | |
| return; | ||
|
|
||
| var evt = e.Event; | ||
| var text = evt.Message.Text ?? string.Empty; | ||
|
|
||
| _logger.LogInformation("Raid reward chat received (window for {Target}): Chatter={Chatter} ({ChatterId}) BroadcasterId={BId} TargetId={TId} Text='{Text}'", | ||
| window.TargetDisplayName, evt.ChatterUserLogin, evt.ChatterUserId, evt.BroadcasterUserId, window.TargetUserId, text); | ||
|
Comment on lines
+333
to
+334
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win Do not log every chat event at Information. This log runs before the channel and eligibility filters. A raid into a busy channel can create an unbounded number of Information records and include each message body in the log payload. Move per-message diagnostics to Debug or Trace. Keep routine Information logs limited to lifecycle and award events. 🤖 Prompt for AI Agents |
||
|
|
||
| // Only count messages that actually occurred in the raided channel. Verified: | ||
| // broadcaster_user_id is the channel we joined/raided; in a shared-chat session, | ||
| // messages from OTHER (guest) channels report a different broadcaster_user_id, | ||
| // while source_broadcaster_user_id stays null for direct messages. So filtering | ||
| // on broadcaster_user_id == the raided target correctly scopes to that channel. | ||
| if (!string.Equals(evt.BroadcasterUserId, window.TargetUserId, StringComparison.OrdinalIgnoreCase)) | ||
| { | ||
| _logger.LogDebug("Raid reward chat ignored: BroadcasterUserId '{BId}' does not match TargetUserId '{TId}' for {Target}", | ||
| evt.BroadcasterUserId, window.TargetUserId, window.TargetDisplayName); | ||
| return; | ||
| } | ||
|
|
||
| var username = UsernameNormalizer.Normalize(evt.ChatterUserLogin); | ||
|
|
||
| if (string.IsNullOrWhiteSpace(username) || !window.EligibleUsernames.Contains(username)) | ||
| { | ||
| _logger.LogInformation("Raid reward chat from {Chatter} in {Target}: '{Text}' -> SKIPPED (chatter '{ChatterNormalized}' not in pre-raid chatter list of {Count} viewers)", | ||
| evt.ChatterUserLogin, window.TargetDisplayName, text, username, window.EligibleUsernames.Count); | ||
| return; | ||
| } | ||
|
|
||
| // Match: message contains the configured phrase (case-insensitive). Subs may | ||
| // also use the optional subscriber phrase. | ||
| var text = evt.Message.Text ?? string.Empty; | ||
| // Match: message contains the configured phrase (case-insensitive & punctuation-insensitive). | ||
| // Subs may also use the optional subscriber phrase. | ||
| var isSub = await _viewerFeature.IsSubscriber(username); | ||
| var matched = ContainsPhrase(text, window.Config.Message); | ||
| if (!matched && isSub && !string.IsNullOrWhiteSpace(window.Config.SubscriberMessage)) | ||
| matched = ContainsPhrase(text, window.Config.SubscriberMessage); | ||
|
|
||
| if (!matched) | ||
| { | ||
| _logger.LogInformation("Raid reward chat from {Chatter} in {Target}: '{Text}' -> NO MATCH for phrase '{Message}' (subPhrase='{SubMessage}', isSub={IsSub})", | ||
| evt.ChatterUserLogin, window.TargetDisplayName, text, window.Config.Message, window.Config.SubscriberMessage ?? "", isSub); | ||
| return; | ||
| } | ||
|
|
||
| // Award once per raid event. Reserve atomically for concurrency; roll back on failure. | ||
| if (!window.AwardedUsernames.Add(username)) | ||
| { | ||
| _logger.LogInformation("Raid reward chat from {Chatter} in {Target}: '{Text}' -> ALREADY AWARDED in this raid", | ||
| evt.ChatterUserLogin, window.TargetDisplayName, text); | ||
| return; | ||
| } | ||
|
|
||
| _logger.LogInformation("Raid reward chat from {Chatter} in {Target}: '{Text}' -> MATCHED! Awarding points...", | ||
| evt.ChatterUserLogin, window.TargetDisplayName, text); | ||
|
|
||
| var awarded = await AwardAsync(window, username, evt.ChatterUserId, evt.ChatterUserName); | ||
| if (!awarded) | ||
| window.AwardedUsernames.Remove(username); | ||
| } | ||
|
|
||
| private static bool ContainsPhrase(string text, string phrase) | ||
| => text.Contains(phrase, StringComparison.OrdinalIgnoreCase); | ||
| { | ||
| if (string.IsNullOrWhiteSpace(phrase) || string.IsNullOrWhiteSpace(text)) | ||
| return false; | ||
|
|
||
| // 1. Direct case-insensitive substring match | ||
| if (text.Contains(phrase, StringComparison.OrdinalIgnoreCase)) | ||
| return true; | ||
|
|
||
| // 2. Normalized match (strip non-alphanumeric punctuation and collapse extra spaces) | ||
| var normText = NormalizeForMatching(text); | ||
| var normPhrase = NormalizeForMatching(phrase); | ||
|
|
||
| if (string.IsNullOrWhiteSpace(normPhrase)) | ||
| return false; | ||
|
|
||
| return normText.Contains(normPhrase, StringComparison.OrdinalIgnoreCase); | ||
| } | ||
|
|
||
| private static string NormalizeForMatching(string input) | ||
| { | ||
| if (string.IsNullOrWhiteSpace(input)) | ||
| return string.Empty; | ||
|
|
||
| var sb = new System.Text.StringBuilder(input.Length); | ||
| foreach (var c in input) | ||
| { | ||
| if (char.IsLetterOrDigit(c) || c == '_') | ||
| sb.Append(c); | ||
| else | ||
| sb.Append(' '); | ||
| } | ||
| return System.Text.RegularExpressions.Regex.Replace(sb.ToString(), @"\s+", " ").Trim().ToLowerInvariant(); | ||
| } | ||
|
|
||
| private async Task<bool> AwardAsync(RaidWindow window, string username, string chatterUserId, string chatterDisplayName) | ||
| { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🔎 Supported by static analysis
🤖 get_repo_knowledge executed:
get_repo_knowledge Psychoboy/PenguinTwitchBot /tmp/coderabbit-repo-knowledge/psychoboy-penguintwitchbot-1309ceb8/architectureLength of output: 34956
🏁 Script executed:
Repository: Psychoboy/PenguinTwitchBot
Length of output: 17285
🏁 Script executed:
Repository: Psychoboy/PenguinTwitchBot
Length of output: 50383
🏁 Script executed:
Repository: Psychoboy/PenguinTwitchBot
Length of output: 34585
Bind each reminder callback to its own raid.
SendPreRaidReminderAsyncchecks_activeWindowbefore awaiting settings. A raid can start during that await, so the callback can still post a reminder afterward. Its unconditionalCancelPreRaidReminder()can then dispose a newer timer stored in_preRaidReminderTimer.Use a per-reminder cancellation token or generation value. Re-check it after each await. Cancel only the timer owned by the callback. Add an interleaving test that pauses settings retrieval.
🤖 Prompt for AI Agents