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:
@@ -0,0 +1,97 @@
|
||||
using System.Linq;
|
||||
using Xunit;
|
||||
using ytLive.Services;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 2026-11-05 ruling (retires TASK 49's local wordlist): YouTube owns chat moderation, so the
|
||||
/// behaviour we owe it is not a second list — it is <b>not showing what YouTube has removed</b>.
|
||||
/// The feed sends deleted messages as a <c>tombstone</c> placeholder carrying no text, and marks
|
||||
/// non-displayable events with <c>hasDisplayContent: false</c>; both used to become blank rows on
|
||||
/// the overlay (the parser had no filter at all).
|
||||
/// </summary>
|
||||
public class ChatModerationParseTests
|
||||
{
|
||||
[Fact]
|
||||
public void Tombstone_NeverBecomesAChatRow()
|
||||
{
|
||||
const string json = """
|
||||
{
|
||||
"items": [
|
||||
{"id":"d1","snippet":{"type":"tombstone","liveChatId":"c","publishedAt":"2026-11-05T10:00:00.000Z"}},
|
||||
{"id":"m1","snippet":{"type":"textMessageEvent","hasDisplayContent":true,
|
||||
"textMessageDetails":{"messageText":"hello"},"publishedAt":"2026-11-05T10:00:01.000Z"},
|
||||
"authorDetails":{"displayName":"Viewer","channelId":"UC1"}}
|
||||
],
|
||||
"nextPageToken":"n"
|
||||
}
|
||||
""";
|
||||
|
||||
var page = YouTubeChatService.ParsePage(json);
|
||||
|
||||
Assert.Single(page.Messages);
|
||||
Assert.Equal("hello", page.Messages[0].Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MessagesWithoutDisplayContent_AreSkipped()
|
||||
{
|
||||
const string json = """
|
||||
{
|
||||
"items": [
|
||||
{"id":"m1","snippet":{"type":"textMessageEvent","hasDisplayContent":false,
|
||||
"publishedAt":"2026-11-05T10:00:00.000Z"},
|
||||
"authorDetails":{"displayName":"Muted","channelId":"UC1"}},
|
||||
{"id":"m2","snippet":{"type":"textMessageEvent","hasDisplayContent":true,
|
||||
"textMessageDetails":{"messageText":"visible"},"publishedAt":"2026-11-05T10:00:01.000Z"},
|
||||
"authorDetails":{"displayName":"Viewer","channelId":"UC2"}}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
var page = YouTubeChatService.ParsePage(json);
|
||||
|
||||
Assert.Single(page.Messages);
|
||||
Assert.Equal("visible", page.Messages[0].Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChatModeEvents_AreNotChat()
|
||||
{
|
||||
const string json = """
|
||||
{
|
||||
"items": [
|
||||
{"id":"e1","snippet":{"type":"sponsorOnlyModeStartedEvent","hasDisplayContent":false,
|
||||
"publishedAt":"2026-11-05T10:00:00.000Z"}},
|
||||
{"id":"e2","snippet":{"type":"chatEndedEvent","publishedAt":"2026-11-05T10:00:01.000Z"}}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
Assert.Empty(YouTubeChatService.ParsePage(json).Messages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RealChatStillParses_WithAuthorAndSuperChat()
|
||||
{
|
||||
const string json = """
|
||||
{
|
||||
"items": [
|
||||
{"id":"m1","snippet":{"type":"superChatEvent","hasDisplayContent":true,
|
||||
"superChatDetails":{"amountMicros":12340000,"currency":"USD","amountDisplayString":"$12.34",
|
||||
"userComment":"nice stream"},"publishedAt":"2026-11-05T10:00:00.000Z"},
|
||||
"authorDetails":{"displayName":"Funder","channelId":"UC9","isChatSponsor":true}}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
var message = Assert.Single(YouTubeChatService.ParsePage(json).Messages);
|
||||
|
||||
Assert.Equal("Funder", message.AuthorName);
|
||||
Assert.True(message.IsSuperChat);
|
||||
// Constraint that survives the TASK 49 retirement: reward fields are never reinterpreted.
|
||||
Assert.Equal("$12.34", message.SuperChatDisplayString);
|
||||
Assert.Equal("nice stream", message.Message);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user