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:
2026-11-05 08:39:46 -08:00
parent 7bb1207fb2
commit a814bcd502
6 changed files with 171 additions and 4 deletions
+5
View File
@@ -14,6 +14,11 @@
the same settings block (one alert box per layout). the same settings block (one alert box per layout).
- Tests: `ChromaKeyDetectorTests` + `ChromaKeyApplyTests` (pipe asks ffmpeg to key; offer → accept → - Tests: `ChromaKeyDetectorTests` + `ChromaKeyApplyTests` (pipe asks ffmpeg to key; offer → accept →
keyed session feeds the resolver; survives save; auto-apply; alert clip keyed). App build 0 warnings. keyed session feeds the resolver; survives save; auto-apply; alert clip keyed). App build 0 warnings.
- **TASK 49 retired (creator ruling):** YouTube owns chat moderation — blocked words and hold-for-review
run before the API. What we owed it was not a wordlist but not painting what YouTube removed: the chat
parser now skips `tombstone` (a deleted message's text is never sent) and `hasDisplayContent:false`.
Same commit fixed a live bug: Super Chat read `displayString` instead of `amountDisplayString`, so every
amount fell back to a recomposed string.
## Next action ## Next action
+36 -2
View File
@@ -106,6 +106,27 @@ public class YouTubeChatService : IDisposable
/// <summary>One liveChat/messages page → typed chat messages + pagination + /// <summary>One liveChat/messages page → typed chat messages + pagination +
/// the server's poll cadence. Internal + separated from the HTTP so the six /// the server's poll cadence. Internal + separated from the HTTP so the six
/// event parses are deterministically testable without any network.</summary> /// 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) internal static ChatPage ParsePage(string json)
{ {
var data = JsonSerializer.Deserialize<JsonElement>(json); var data = JsonSerializer.Deserialize<JsonElement>(json);
@@ -126,9 +147,16 @@ public class YouTubeChatService : IDisposable
{ {
var snippet = item.GetProperty("snippet"); var snippet = item.GetProperty("snippet");
var type = snippet.TryGetProperty("type", out var t) && t.ValueKind == JsonValueKind.String var type = snippet.TryGetProperty("type", out var t) && t.ValueKind == JsonValueKind.String
? t.GetString() ? t.GetString() ?? "textMessageEvent"
: "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 var message = new ChatMessage
{ {
Id = item.TryGetProperty("id", out var idProp) ? idProp.GetString() ?? Guid.NewGuid().ToString() : Guid.NewGuid().ToString(), 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; message.Kind = ChatEventKind.SuperChat;
if (snippet.TryGetProperty("superChatDetails", out var scDetails)) 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) && display.ValueKind == JsonValueKind.String)
message.SuperChatDisplayString = display.GetString(); 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) if (scDetails.TryGetProperty("amountMicros", out var amt)
&& amt.ValueKind == JsonValueKind.Number) && amt.ValueKind == JsonValueKind.Number)
message.SuperChatAmount = amt.GetDouble() / 1_000_000.0; message.SuperChatAmount = amt.GetDouble() / 1_000_000.0;
+1 -1
View File
@@ -55,7 +55,7 @@ blocker; the smallest slice is 32·4 (fragmented MP4 on the record block).
| 37 | ☐ | Defaults vs current layout split (build-id); enables one-click revert to defaults | [`task-37`](TASKS/task-37-defaults-current-split.md) | | 37 | ☐ | Defaults vs current layout split (build-id); enables one-click revert to defaults | [`task-37`](TASKS/task-37-defaults-current-split.md) |
| — | ✅ | **Preview undo (Ctrl+Z / Ctrl+Shift+Z)** — done 2026-11-01: `Models/PreviewHistory` + `MainViewModel.Preview`, one step per gesture, gated on `EditingActive`, cleared on layout load. Property edits not yet in the stack | [`ui-surface`](docs/ai/ui-surface.md) | | — | ✅ | **Preview undo (Ctrl+Z / Ctrl+Shift+Z)** — done 2026-11-01: `Models/PreviewHistory` + `MainViewModel.Preview`, one step per gesture, gated on `EditingActive`, cleared on layout load. Property edits not yet in the stack | [`ui-surface`](docs/ai/ui-surface.md) |
| 39·2 | ☐ | YPP slice 2: Analytics scopes, watch-hours/Shorts velocity + ETA | [`task-39`](TASKS/task-39-ypp-journey-tracker.md) | | 39·2 | ☐ | YPP slice 2: Analytics scopes, watch-hours/Shorts velocity + ETA | [`task-39`](TASKS/task-39-ypp-journey-tracker.md) |
| 49 | ☐ | Chat profanity filter: local, opt-in, **non-persistent**, user-supplied word list | [`task-49`](TASKS/task-49-chat-profanity-filter.md) | | 49 | ❌ | Chat profanity filter — **retired 2026-11-05**: YouTube owns moderation (blocked words + hold for review run before the API). Replaced by not rendering YouTube's moderation markers (tombstones / no-display events) | [`task-49`](TASKS/task-49-chat-profanity-filter.md) |
| 50 | ☐ | One-button publish of the local recording (resumable upload) | [`task-50`](TASKS/task-50-publish-recording.md) | | 50 | ☐ | One-button publish of the local recording (resumable upload) | [`task-50`](TASKS/task-50-publish-recording.md) |
| 54 | 🔶 | Freemium: EULA + privacy shipped; free Steam Demo and paid app use one codebase; paid app ID and Steam Demo setup still pending | [`task-54`](TASKS/task-54-marcom-free-merch.md) | | 54 | 🔶 | Freemium: EULA + privacy shipped; free Steam Demo and paid app use one codebase; paid app ID and Steam Demo setup still pending | [`task-54`](TASKS/task-54-marcom-free-merch.md) |
| — | 🔶 | **Steam ownership** — `BIsSubscribedApp(paidAppId)` + Debug fake + app-wide `IsAppOwned`; paid app ID awaits Steamworks | [`commercial`](docs/ai/commercial.md) | | — | 🔶 | **Steam ownership** — `BIsSubscribedApp(paidAppId)` + Debug fake + app-wide `IsAppOwned`; paid app ID awaits Steamworks | [`commercial`](docs/ai/commercial.md) |
+24 -1
View File
@@ -4,7 +4,30 @@
> **Research: [`TASKS/research-store-certification.md`](research-store-certification.md) §12.** > **Research: [`TASKS/research-store-certification.md`](research-store-certification.md) §12.**
> Carved out 2026-09-27; shares a scope lock with nothing else. > Carved out 2026-09-27; shares a scope lock with nothing else.
**Status:** ☐ Queued — not blocked (does not depend on the route decision) **Status:** ❌ **Retired 2026-11-05** — YouTube owns chat moderation (creator ruling). What replaced it:
`Services/YouTubeChatService.cs` no longer turns YouTube's own moderation markers into chat rows
(`tombstone`, `hasDisplayContent:false`, chat-mode events), with tests in
`ytLive.Tests/ChatModerationParseTests.cs`. The reasoning and citations are below and in
`docs/ai/decisions.md`; the body of this file is kept so the option is not re-derived from scratch.
### Why retired
- YouTube's **blocked words** filter ("block live chat messages that contain or closely match certain
words") and **hold for review** both run upstream of the API: blocked/held messages are not returned
by `liveChatMessages.list` (community evidence: held messages never appear —
<https://stackoverflow.com/questions/74511524/>). Ours would be a second list, a second set of false
positives and a second support surface for a switch the creator already owns.
- Deleted messages arrive as a `tombstone` — "signifies that a message used to exist … it has since
been deleted … only liveChatId, type and publishedAt are present"
(<https://developers.google.com/youtube/data/live/reference/rest/v3/liveChatMessages>). The parser had
no filter, so a deletion rendered as a **blank chat row**. That is the actual defect, and it is fixed.
- Deletion events (`messageDeletedEvent`/`messageRetractedEvent`/`userBannedEvent`) are documented in
the resource but have not been delivered to clients for years
(<https://stackoverflow.com/questions/44346893/>), so no client-side moderation machinery can be built
on them. The parser skips them if they ever do arrive.
The constraints below are therefore **moot for us and binding on YouTube** — except constraint 4, which is
why the Super Chat parse path still asserts that reward fields are never reinterpreted.
**Goal:** let the creator decide what reaches the projected chat box, without the app ever **Goal:** let the creator decide what reaches the projected chat box, without the app ever
becoming a moderator that stores what people said. becoming a moderator that stores what people said.
+8
View File
@@ -209,6 +209,14 @@ Do not build these; each was explicitly ruled out.
never forced. The original objection was webcam background removal (ML model, lighting, support never forced. The original objection was webcam background removal (ML model, lighting, support
weight); none of it applies to a clip the creator supplied. The webcam still gets the shipped ONNX weight); none of it applies to a clip the creator supplied. The webcam still gets the shipped ONNX
path, and NVIDIA-only effects stay out. path, and NVIDIA-only effects stay out.
- **Chat profanity filtering is YouTube's job (2026-11-05, creator ruling):** TASK 49's local wordlist is
retired. YouTube's blocked-words filter and hold-for-review both run upstream of `liveChatMessages.list`
(held messages are never returned: <https://stackoverflow.com/questions/74511524/>), so a local list would
duplicate a switch the creator already owns. The rule that replaces it: **never render what YouTube has
removed** — a deleted message arrives as a `tombstone` with no text
(<https://developers.google.com/youtube/data/live/reference/rest/v3/liveChatMessages>) and is skipped, as are
`hasDisplayContent:false` messages and chat-mode events. Deletion events are documented but not delivered
(<https://stackoverflow.com/questions/44346893/>), so no client-side moderation machinery is built on them.
- Virtual camera output — a different product and a device-driver swamp; our output is one - Virtual camera output — a different product and a device-driver swamp; our output is one
YouTube stream. YouTube stream.
- Replay buffer, stream clipping, multi-destination restreaming, an in-app bug reporter, and an - Replay buffer, stream clipping, multi-destination restreaming, an in-app bug reporter, and an
+97
View File
@@ -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);
}
}