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.
This commit is contained in:
@@ -106,6 +106,27 @@ public class YouTubeChatService : IDisposable
|
||||
/// <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);
|
||||
@@ -126,9 +147,16 @@ public class YouTubeChatService : IDisposable
|
||||
{
|
||||
var snippet = item.GetProperty("snippet");
|
||||
var type = snippet.TryGetProperty("type", out var t) && t.ValueKind == JsonValueKind.String
|
||||
? t.GetString()
|
||||
? 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(),
|
||||
@@ -157,9 +185,15 @@ public class YouTubeChatService : IDisposable
|
||||
message.Kind = ChatEventKind.SuperChat;
|
||||
if (snippet.TryGetProperty("superChatDetails", out var scDetails))
|
||||
{
|
||||
if (scDetails.TryGetProperty("displayString", out var display)
|
||||
// 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;
|
||||
|
||||
Reference in New Issue
Block a user