Files
LlamaCasty/Services/YouTubeChatService.cs
T
gramps a814bcd502 fix(chat): stop painting what YouTube removed, and retire the local word filter
TASK 49 is retired (creator ruling): YouTube owns chat moderation. Its blocked-words filter and
hold-for-review run upstream of `liveChatMessages.list` — held messages are never returned
(https://stackoverflow.com/questions/74511524/) — so a second local list would duplicate a switch
the creator already owns, with a second set of false positives and a second support surface.

What we actually owed the platform, and had wrong:

- The parser added every item to the overlay. A deleted message arrives as a `tombstone` carrying
  no text at all ("it has since been deleted … only liveChatId, type and publishedAt are present" —
  https://developers.google.com/youtube/data/live/reference/rest/v3/liveChatMessages), so every
  deletion rendered as a BLANK chat row. `hasDisplayContent:false` messages and chat-mode events
  did the same.
- Deletion events (`messageDeletedEvent`/`messageRetractedEvent`/`userBannedEvent`) are documented
  but have not been delivered to clients for years (https://stackoverflow.com/questions/44346893/),
  so nothing is built on them; they are skipped if they ever arrive.
- Found while testing: Super Chat read `displayString`, but YouTube's field is `amountDisplayString`
  — every amount silently fell back to a recomposed string, which mangles non-dollar locales. The
  model's own comment said this was the reason the field exists.

Test: `ChatModerationParseTests` — tombstone, no-display-content, chat-mode events all produce no
row; a real Super Chat still parses its pre-rendered amount (constraint 4: reward fields are never
reinterpreted).

Docs: TASK 49 marked retired with the citations, the rule recorded in decisions.md, HANDOFF updated.
verify.sh: 550 passed, 0 failed, 0 warnings.
2026-11-05 08:39:46 -08:00

304 lines
14 KiB
C#

using System.Net.Http;
using System.Text.Json;
using ytLive.Models;
namespace ytLive.Services;
/// <summary>
/// Polls YouTube Live Chat API for messages.
/// YouTube doesn't have WebSocket for chat — polling is the only option. The
/// cadence follows the server's <c>pollingIntervalMillis</c> (the streamList
/// connection semantics: reconnect with nextPageToken on that cadence, never
/// faster than the refresh rate — YouTube rejects requests sent too quickly).
/// The chat feed IS YouTube's event feed: Super Chats, stickers, memberships,
/// gifts and milestones arrive here as typed messages (see <see cref="ChatEventKind"/>).
/// </summary>
public class YouTubeChatService : IDisposable
{
private readonly YouTubeAuthService _auth;
private readonly HttpClient _http;
private Timer? _pollTimer;
private string? _nextPageToken;
private string? _liveChatId;
private bool _isRunning;
private int _intervalMs = 2000;
public event Action<ChatMessage>? MessageReceived;
private static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNameCaseInsensitive = true
};
public YouTubeChatService(YouTubeAuthService auth, HttpClient? http = null)
{
_auth = auth;
_http = http ?? new HttpClient();
}
public void Start(string liveChatId, int pollIntervalMs = 2000)
{
_liveChatId = liveChatId;
_isRunning = true;
_nextPageToken = null;
_intervalMs = Math.Clamp(pollIntervalMs, 1000, 6000);
_pollTimer = new Timer(async _ => await Poll(), null, Timeout.Infinite, Timeout.Infinite);
_pollTimer.Change(0, Timeout.Infinite);
}
public void Stop()
{
_isRunning = false;
_pollTimer?.Dispose();
_pollTimer = null;
}
/// <summary>TASK 41: injects a synthetic message through the exact seam the real
/// poll uses (MessageReceived), so a simulated member / SuperChat / subscriber
/// event renders through the whole overlay pipeline. YouTube's insert API only
/// creates text messages — these events are local-only by design and never
/// reach YouTube (marked <see cref="ChatMessage.IsSimulated"/>).</summary>
public void InjectSimulatedMessage(ChatMessage message)
=> MessageReceived?.Invoke(message);
private async Task Poll()
{
try
{
if (!_isRunning || _liveChatId == null || _auth.CurrentChannel == null) return;
if (_auth.CurrentChannel.TokenExpiry <= DateTime.UtcNow.AddMinutes(5))
await _auth.RefreshToken();
var url = $"https://www.googleapis.com/youtube/v3/liveChat/messages?liveChatId={_liveChatId}&part=snippet,authorDetails&maxResults=2000";
if (_nextPageToken != null)
url += $"&pageToken={_nextPageToken}";
_http.DefaultRequestHeaders.Authorization = new("Bearer", _auth.CurrentChannel.AccessToken);
var response = await _http.GetAsync(url);
if (!response.IsSuccessStatusCode) return;
var json = await response.Content.ReadAsStringAsync();
var page = ParsePage(json);
if (page.NextPageToken != null)
_nextPageToken = page.NextPageToken;
if (page.PollIntervalMs > 0 && page.PollIntervalMs != _intervalMs)
_intervalMs = page.PollIntervalMs;
foreach (var message in page.Messages)
MessageReceived?.Invoke(message);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Chat poll error: {ex.Message}");
}
finally
{
// Re-arm the one-shot poll on the server's cadence. Guarded by
// _isRunning so a concurrent Stop() (dispose + null) can't race a
// Change() back onto the disposed timer.
if (_isRunning)
_pollTimer?.Change(_intervalMs, Timeout.Infinite);
}
}
/// <summary>One liveChat/messages page → typed chat messages + pagination +
/// the server's poll cadence. Internal + separated from the HTTP so the six
/// event parses are deterministically testable without any network.</summary>
/// <summary>True for the types that carry something a viewer should read. `snippet.hasDisplayContent`
/// is authoritative when present; tombstones and chat-mode events are never shown (they exist to
/// mark a deletion or a mode change, and carry no message).</summary>
internal static bool HasDisplayableContent(JsonElement snippet, string type)
{
switch (type)
{
case "tombstone":
case "chatEndedEvent":
case "sponsorOnlyModeStartedEvent":
case "sponsorOnlyModeEndedEvent":
case "messageDeletedEvent":
case "messageRetractedEvent":
case "userBannedEvent":
return false;
}
return !snippet.TryGetProperty("hasDisplayContent", out var has)
|| has.ValueKind != JsonValueKind.False;
}
internal static ChatPage ParsePage(string json)
{
var data = JsonSerializer.Deserialize<JsonElement>(json);
var messages = new List<ChatMessage>();
var nextPageToken = data.TryGetProperty("nextPageToken", out var token)
? token.GetString()
: null;
var intervalMs = data.TryGetProperty("pollingIntervalMillis", out var interval)
&& interval.ValueKind == JsonValueKind.Number
? interval.GetInt32()
: 0;
if (!data.TryGetProperty("items", out var items)) return new ChatPage(messages, nextPageToken, intervalMs);
foreach (var item in items.EnumerateArray())
{
var snippet = item.GetProperty("snippet");
var type = snippet.TryGetProperty("type", out var t) && t.ValueKind == JsonValueKind.String
? t.GetString() ?? "textMessageEvent"
: "textMessageEvent";
// YouTube owns moderation: blocked words, held-for-review messages and deleted
// messages are filtered before the feed ever reaches us (2026-11-05 ruling —
// TASK 49's local wordlist retired). What DOES arrive are event markers rather
// than chat: a `tombstone` (a deleted message's placeholder, carrying no text at
// all) and the silent chat-mode events. They get no row on the overlay.
if (!HasDisplayableContent(snippet, type)) continue;
var message = new ChatMessage
{
Id = item.TryGetProperty("id", out var idProp) ? idProp.GetString() ?? Guid.NewGuid().ToString() : Guid.NewGuid().ToString(),
Timestamp = DateTime.UtcNow,
};
if (item.TryGetProperty("authorDetails", out var author))
{
message.AuthorName = author.TryGetProperty("displayName", out var dn) ? dn.GetString() ?? string.Empty : string.Empty;
message.AuthorChannelId = author.TryGetProperty("channelId", out var cid) ? cid.GetString() ?? string.Empty : string.Empty;
message.AuthorImageUrl = author.TryGetProperty("profileImageUrl", out var im) ? im.GetString() ?? string.Empty : string.Empty;
message.IsMember = author.TryGetProperty("isChatSponsor", out var sp) ? sp.GetBoolean() : false;
message.MembershipLevel = author.TryGetProperty("badgeText", out var badge)
? badge.GetString()
: null;
}
if (snippet.TryGetProperty("publishedAt", out var published)
&& DateTime.TryParse(published.GetString(), out var publishedAt))
message.Timestamp = publishedAt;
switch (type)
{
case "superChatEvent":
message.IsSuperChat = true;
message.Kind = ChatEventKind.SuperChat;
if (snippet.TryGetProperty("superChatDetails", out var scDetails))
{
// YouTube names the pre-rendered amount `amountDisplayString`; the old
// `displayString` lookup never matched, so every Super Chat fell back to a
// recomposed amount and mangled non-dollar locales (fixed 2026-11-05).
if (scDetails.TryGetProperty("amountDisplayString", out var display)
&& display.ValueKind == JsonValueKind.String)
message.SuperChatDisplayString = display.GetString();
else if (scDetails.TryGetProperty("displayString", out var legacyDisplay)
&& legacyDisplay.ValueKind == JsonValueKind.String)
message.SuperChatDisplayString = legacyDisplay.GetString();
if (scDetails.TryGetProperty("amountMicros", out var amt)
&& amt.ValueKind == JsonValueKind.Number)
message.SuperChatAmount = amt.GetDouble() / 1_000_000.0;
message.SuperChatCurrency = scDetails.TryGetProperty("currency", out var cur)
? cur.GetString()
: null;
message.Message = scDetails.TryGetProperty("userComment", out var comment)
? comment.GetString() ?? string.Empty
: string.Empty;
}
break;
case "superStickerEvent":
message.Kind = ChatEventKind.SuperSticker;
if (snippet.TryGetProperty("superStickerDetails", out var stickerDetails))
{
message.SuperStickerDescription = stickerDetails.TryGetProperty("stickerDescription", out var desc)
? desc.GetString()
: stickerDetails.TryGetProperty("description", out var descAlt)
? descAlt.GetString()
: null;
if (stickerDetails.TryGetProperty("displayString", out var display)
&& display.ValueKind == JsonValueKind.String)
message.SuperChatDisplayString = display.GetString();
message.SuperChatCurrency = stickerDetails.TryGetProperty("currency", out var cur)
? cur.GetString()
: null;
message.Message = stickerDetails.TryGetProperty("userComment", out var comment)
? comment.GetString() ?? string.Empty
: string.Empty;
}
break;
case "newSponsorEvent":
message.Kind = ChatEventKind.NewMember;
message.IsMember = true;
message.Message = $"🎉 {message.AuthorName} became a member!";
if (snippet.TryGetProperty("membershipDetails", out var memDetails))
{
message.MembershipLevel = memDetails.TryGetProperty("memberLevelName", out var level)
? level.GetString()
: message.MembershipLevel;
}
break;
case "membershipGiftingEvent":
message.Kind = ChatEventKind.MemberGift;
if (snippet.TryGetProperty("membershipGiftingDetails", out var giftingDetails)
&& giftingDetails.TryGetProperty("giftNewMembersCount", out var count)
&& count.ValueKind == JsonValueKind.Number)
message.GiftNewMembersCount = count.GetInt32();
message.Message = message.GiftNewMembersCount > 0
? $"🎁 {message.AuthorName} gifted {message.GiftNewMembersCount} memberships!"
: $"🎁 {message.AuthorName} gifted memberships!";
break;
case "giftMembershipReceivedEvent":
message.Kind = ChatEventKind.GiftReceived;
if (snippet.TryGetProperty("giftMembershipReceivedDetails", out var giftReceived)
&& giftReceived.TryGetProperty("gifterName", out var gifter))
message.GifterName = gifter.GetString();
message.Message = message.GifterName != null
? $"{message.GifterName} gifted you a membership! 🎉"
: $"{message.AuthorName} received a membership gift! 🎉";
break;
case "memberMilestoneChatEvent":
message.Kind = ChatEventKind.MemberMilestone;
message.IsMember = true;
message.Message = snippet.TryGetProperty("textMessageDetails", out var msd)
? msd.TryGetProperty("messageText", out var mst)
? mst.GetString() ?? string.Empty
: string.Empty
: string.Empty;
if (message.Message.Length > 0)
{
var match = System.Text.RegularExpressions.Regex.Match(message.Message, @"(\d+)\s*month", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
if (match.Success && int.TryParse(match.Groups[1].Value, out var months))
message.MilestoneMonths = months;
}
break;
default: // textMessageEvent and anything else — anonymous chat rows only.
message.Message = snippet.TryGetProperty("textMessageDetails", out var textDetails)
? textDetails.TryGetProperty("messageText", out var text)
? text.GetString() ?? string.Empty
: string.Empty
: string.Empty;
break;
}
messages.Add(message);
}
return new ChatPage(messages, nextPageToken, intervalMs);
}
public void Dispose()
{
Stop();
_http.Dispose();
}
}
/// <summary>Decoded liveChat/messages response: the messages + where to resume +
/// how long to wait before reconnecting.</summary>
internal sealed record ChatPage(List<ChatMessage> Messages, string? NextPageToken, int PollIntervalMs);