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.
98 lines
3.5 KiB
C#
98 lines
3.5 KiB
C#
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);
|
|
}
|
|
}
|