From d34c95749ff4a48d4e617507955534f9c34d8488 Mon Sep 17 00:00:00 2001 From: Kento Nishi Date: Wed, 20 May 2026 12:21:05 -0400 Subject: [PATCH 1/6] document mod actions --- docs/YOUTUBE_ACTIONS.md | 59 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 56 insertions(+), 3 deletions(-) diff --git a/docs/YOUTUBE_ACTIONS.md b/docs/YOUTUBE_ACTIONS.md index 685e4dac..d09a2aab 100644 --- a/docs/YOUTUBE_ACTIONS.md +++ b/docs/YOUTUBE_ACTIONS.md @@ -1,6 +1,6 @@ # YouTube Actions (Dev Notes) -This repo implements YouTube "chat actions" (block, report, delete/retract, and future mod actions) by calling Innertube endpoints based on data from the message + its context menu. +This repo implements YouTube "chat actions" (block, report, delete/retract, and mod actions) by calling Innertube endpoints based on data from the message + its context menu. This doc exists so we do not re-learn the same YouTube quirks every time. @@ -58,7 +58,7 @@ Instead: Examples: -- block: `moderateLiveChatEndpoint` (and friends) +- block/hide/delete/timeout/unhide: `moderateLiveChatEndpoint` (but choose by menu icon/action, not by endpoint type alone) - report: `getReportFormEndpoint` (flow can be multi-step) - delete/retract: look for the delete/retract endpoint in the same way @@ -68,6 +68,60 @@ If an endpoint is missing, log enough context to diagnose: - which ones we did not - which message/menu payload we used to ask for the menu +## Mod Action Learnings + +The captured mod-action HAR (`artifacts/build/trying-mod-actions.har`) is enough to reconstruct the exact native YouTube flows. All mutation requests start from a message's `contextMenuEndpoint.liveChatItemContextMenuEndpoint.params`, then call `live_chat/get_item_context_menu`, then execute the endpoint attached to the selected menu item or nested option. + +Do not resolve mod actions by grabbing the first `moderateLiveChatEndpoint`. In moderator menus, delete, timeout, hide, and unhide all use `moderateLiveChatEndpoint`. The action identity comes from the menu item icon, and for dialog-backed actions, from the selected nested option. + +Known menu/action mapping: + +- `KEEP` / `Pin message`: top-level `liveChatActionEndpoint` -> `live_chat/live_chat_action` +- `KEEP` / `Replace pinned message`: top-level `liveChatActionEndpoint` -> `live_chat/live_chat_action` +- `DELETE` / `Remove`: top-level `moderateLiveChatEndpoint` -> `live_chat/moderate` +- `HOURGLASS` / `Put user in timeout`: nested option `submitEndpoint.moderateLiveChatEndpoint` -> `live_chat/moderate` +- `REMOVE_CIRCLE` / `Hide user on this channel`: top-level `moderateLiveChatEndpoint` -> `live_chat/moderate` +- `ADD_CIRCLE` / `Unhide user on this channel`: top-level `moderateLiveChatEndpoint` -> `live_chat/moderate` +- `ADD_MODERATOR` / `Add as moderator`: nested option `submitEndpoint.manageLiveChatUserEndpoint` -> `live_chat/manage_user` +- `REMOVE_MODERATOR` / `Remove as managing moderator`: top-level `manageLiveChatUserEndpoint` -> `live_chat/manage_user` +- `REMOVE_MODERATOR` / `Remove as standard moderator`: top-level `manageLiveChatUserEndpoint` -> `live_chat/manage_user` +- `FLAG` / `Report`: top-level `getReportFormEndpoint` -> `flag/get_form`, then `flag/flag` +- `WATCH_HISTORY` / `Channel Activity`: `showEngagementPanelEndpoint`; this opens YouTube's engagement panel and is not a moderation mutation request + +Nested timeout options captured from the native dialog: + +- `10 seconds` +- `1 minute` +- `5 minutes` +- `10 minutes` +- `30 minutes` +- `24 hours` + +Nested add-moderator options captured from the native dialog: + +- `Managing moderator` +- `Standard moderator` + +Exact captured demo sequence: + +1. Menu entry `187`: selected `KEEP` / `Pin message`; POST entry `196` to `live_chat/live_chat_action`; response showed `Message pinned`, `Undo`, and `addBannerToLiveChatCommand`. +2. Menu entry `227`: selected `DELETE` / `Remove`; POST entry `229` to `live_chat/moderate`; response had `markChatItemAsDeletedAction` with `[message retracted]`. +3. Menu entry `437`: selected `KEEP` / `Replace pinned message`; POST entry `443` to `live_chat/live_chat_action`; response showed `Message pinned`, `Undo`, and a pinned banner update. +4. Menu entry `453`: selected `DELETE` / `Remove`; POST entry `460` to `live_chat/moderate`; response had `markChatItemAsDeletedAction` with `Message deleted by @livetl-vtuberclipsch.8354.`. +5. Menu entry `466`: selected `HOURGLASS` / `Put user in timeout`, nested option `1 minute`; POST entry `478` to `live_chat/moderate`; response toast said `@KentoNishi has been timed out for 1 minute`. +6. Menu entry `531`: selected `ADD_MODERATOR` / `Add as moderator`, nested option `Managing moderator`; POST entry `543` to `live_chat/manage_user`; response toast said `@KentoNishi is now a managing moderator for your channel`. +7. Menu entry `545`: selected `REMOVE_MODERATOR` / `Remove as managing moderator`; POST entry `551` to `live_chat/manage_user`; response toast said `@KentoNishi is no longer a managing moderator for your channel`. +8. Menu entry `554`: selected `ADD_MODERATOR` / `Add as moderator`, nested option `Standard moderator`; POST entry `561` to `live_chat/manage_user`; response toast said `@KentoNishi is now a standard moderator for your channel`. +9. Menu entry `564`: selected `REMOVE_MODERATOR` / `Remove as standard moderator`; POST entry `568` to `live_chat/manage_user`; response toast said `@KentoNishi is no longer a standard moderator for your channel`. +10. Menu entry `578`: selected `REMOVE_CIRCLE` / `Hide user on this channel`; POST entry `584` to `live_chat/moderate`; response toast said `This user's messages will be hidden` and included an `Undo` button. +11. Menu entry `590`: selected `ADD_CIRCLE` / `Unhide user on this channel`; POST entry `594` to `live_chat/moderate`; response was an empty success. +12. Menu entry `597`: selected `REMOVE_CIRCLE` / `Hide user on this channel`; POST entry `602` to `live_chat/moderate`; response toast said `This user's messages will be hidden` and included an `Undo` button. +13. Response entry `602`: clicked the hide toast's `Undo` button; POST entry `605` to `live_chat/moderate`; response was an empty success. + +The hide/unhide flow therefore has two proven unhide sources: the context menu's `ADD_CIRCLE` item and the `Undo` button endpoint returned by a successful hide response. For HyperChat's message action menu, use the context-menu `ADD_CIRCLE` path. If HyperChat later renders native-style action toasts, the response-button endpoint is also valid. + +The mod-action HAR contains `FLAG` / `Report` menu items, but it does not contain an executed report submission. Use the existing report flow for report execution unless a new report-specific HAR says otherwise. + ## Keep Requests Correlated If you proxy Innertube calls through a background/service worker, keep request/response events correlated by request id. @@ -98,4 +152,3 @@ If you fake success, users will trust the UI less than the native UI. - Wrong Innertube client name/version (YouTube serves different schemas) - SAPISIDHASH removed or computed for the wrong origin - Context menu parsing tied to item index instead of endpoint types - From 8fb277857a5e4665fe25dce3831995530418137f Mon Sep 17 00:00:00 2001 From: Kento Nishi Date: Wed, 20 May 2026 12:34:20 -0400 Subject: [PATCH 2/6] register mod action plan --- docs/YOUTUBE_ACTIONS.md | 49 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/docs/YOUTUBE_ACTIONS.md b/docs/YOUTUBE_ACTIONS.md index d09a2aab..e8405186 100644 --- a/docs/YOUTUBE_ACTIONS.md +++ b/docs/YOUTUBE_ACTIONS.md @@ -122,6 +122,55 @@ The hide/unhide flow therefore has two proven unhide sources: the context menu's The mod-action HAR contains `FLAG` / `Report` menu items, but it does not contain an executed report submission. Use the existing report flow for report execution unless a new report-specific HAR says otherwise. +## Mod Action Implementation Plan + +Everything currently implemented for block, report, delete/retract, message parsing, queueing, and MV2 background forwarding works and must not regress. Implement mod actions by preserving the existing architecture and changing only the pieces required to select and execute the correct YouTube endpoints. + +Constraints: + +- Do not rewrite the common menu component. +- Do not make the message menu dynamically fetch native YouTube menu items on open. +- Do not replace the background/interceptor message flow. +- Do not change deletion/retraction UI state handling except where endpoint selection must become more precise. +- Do not ingest arbitrary action response bodies into the queue in the first mod-action pass. + +Implementation shape: + +1. Keep the static HyperChat message menu. +2. Add explicit action constants/menu entries for the supported mod actions. +3. Use the existing report-dialog pattern for actions that need a choice: + - timeout duration: `10 seconds`, `1 minute`, `5 minutes`, `10 minutes`, `30 minutes`, `24 hours` + - add moderator role: `Managing moderator`, `Standard moderator` +4. Keep `useBanHammer`, `executeChatAction`, `chatUserActionResponse`, and MV2 background forwarding structurally intact. +5. Inside the action executor, keep the existing `get_item_context_menu` request, headers, SAPISIDHASH, and proxy fetch flow. +6. Replace fragile endpoint selection with icon-aware resolution: + - `DELETE_MESSAGE`: `DELETE` + `moderateLiveChatEndpoint` + - `PIN_MESSAGE`: `KEEP` + `liveChatActionEndpoint` + - `HIDE_USER`: `REMOVE_CIRCLE` + `moderateLiveChatEndpoint` + - `UNHIDE_USER`: `ADD_CIRCLE` + `moderateLiveChatEndpoint` + - `TIMEOUT_USER`: `HOURGLASS` + selected nested option's `moderateLiveChatEndpoint` + - `ADD_MODERATOR`: `ADD_MODERATOR` + selected nested option's `manageLiveChatUserEndpoint` + - `REMOVE_MODERATOR`: `REMOVE_MODERATOR` + `manageLiveChatUserEndpoint` + - `REPORT_USER`: existing report form flow + - `BLOCK`: only a real `BLOCK` menu item; do not fall back to the first `moderateLiveChatEndpoint` +7. Keep local success side effects narrow: + - `DELETE_MESSAGE`: keep the current local deleted-message replacement. + - `BLOCK`: keep current removal of that author's visible messages. + - `HIDE_USER`: may remove that author's visible messages, matching the user-visible effect of hiding. + - pin, timeout, add moderator, remove moderator, unhide, and report: show success/failure only. +8. Implement on MV2 first, then merge forward: + - HyperChat `mv2` + - HyperChat `main` + - HyperChat `mv3-ltl` + +Regression guardrails: + +- Existing delete/retract behavior must continue to work for self messages, own streams, other streams, and moderator deletes. +- Existing report behavior must keep the same dialog and request flow. +- Existing block behavior must not accidentally execute delete/hide/timeout just because those share `moderateLiveChatEndpoint`. +- Existing queue/parser deletion handling must remain the source of truth for YouTube-originated delete updates. +- If a static HyperChat action is unavailable in YouTube's context menu, fail gracefully through `chatUserActionResponse` instead of guessing another endpoint. + ## Keep Requests Correlated If you proxy Innertube calls through a background/service worker, keep request/response events correlated by request id. From 9f536938f357fe6b4656573eb82b033a18d5e395 Mon Sep 17 00:00:00 2001 From: Kento Nishi Date: Wed, 20 May 2026 12:37:41 -0400 Subject: [PATCH 3/6] add mod actions --- docs/YOUTUBE_ACTIONS.md | 3 +- src/components/Hyperchat.svelte | 7 + src/components/Message.svelte | 5 +- src/components/ReportBanDialog.svelte | 19 +++ src/scripts/chat-interceptor.ts | 203 +++++++++++++++++++------- src/ts/chat-actions.ts | 37 ++++- src/ts/chat-constants.ts | 74 ++++++++++ src/ts/storage.ts | 7 + src/ts/typings/chat.d.ts | 1 + 9 files changed, 296 insertions(+), 60 deletions(-) diff --git a/docs/YOUTUBE_ACTIONS.md b/docs/YOUTUBE_ACTIONS.md index e8405186..1a7ea4e9 100644 --- a/docs/YOUTUBE_ACTIONS.md +++ b/docs/YOUTUBE_ACTIONS.md @@ -156,8 +156,9 @@ Implementation shape: 7. Keep local success side effects narrow: - `DELETE_MESSAGE`: keep the current local deleted-message replacement. - `BLOCK`: keep current removal of that author's visible messages. + - `REPORT_USER`: keep current removal of that author's visible messages. - `HIDE_USER`: may remove that author's visible messages, matching the user-visible effect of hiding. - - pin, timeout, add moderator, remove moderator, unhide, and report: show success/failure only. + - pin, timeout, add moderator, remove moderator, and unhide: show success/failure only. 8. Implement on MV2 first, then merge forward: - HyperChat `mv2` - HyperChat `main` diff --git a/src/components/Hyperchat.svelte b/src/components/Hyperchat.svelte index bc13bd15..da41f677 100644 --- a/src/components/Hyperchat.svelte +++ b/src/components/Hyperchat.svelte @@ -303,6 +303,12 @@ else document.body.classList.remove('bg-ytdark-50'); }; + const removesAuthorMessages = (action: ChatUserActions): boolean => { + return action === ChatUserActions.BLOCK || + action === ChatUserActions.REPORT_USER || + action === ChatUserActions.HIDE_USER; + }; + const onPortMessage = (response: Chat.BackgroundResponse) => { if (responseIsAction(response)) { onChatAction(response); @@ -334,6 +340,7 @@ }); break; } + if (!removesAuthorMessages(response.action)) break; messageActions = messageActions.filter( (a) => { if (isWelcome(a)) return true; diff --git a/src/components/Message.svelte b/src/components/Message.svelte index 61452897..d13a9e3f 100644 --- a/src/components/Message.svelte +++ b/src/components/Message.svelte @@ -74,7 +74,10 @@ if (isSelf) { return d.value === ChatUserActions.DELETE_MESSAGE && message.params != null; } - return d.value !== ChatUserActions.DELETE_MESSAGE; + if (message.params == null) { + return d.value === ChatUserActions.BLOCK || d.value === ChatUserActions.REPORT_USER; + } + return true; }); $: menuItems = visibleActions.map((d) => ({ icon: d.icon, diff --git a/src/components/ReportBanDialog.svelte b/src/components/ReportBanDialog.svelte index 4aea12be..844a9437 100644 --- a/src/components/ReportBanDialog.svelte +++ b/src/components/ReportBanDialog.svelte @@ -3,6 +3,7 @@ import type { ChatReportUserOptions } from '../ts/chat-constants'; import { reportDialog, + chatActionOptionDialog, alertDialog } from '../ts/storage'; import Dialog from './common/Dialog.svelte'; @@ -10,6 +11,7 @@ import RadioGroupStore from './common/RadioGroupStore.svelte'; import Button from 'smelte/src/components/Button'; $: optionStore = $reportDialog?.optionStore as Writable; + $: actionOptionStore = $chatActionOptionDialog?.optionStore as Writable; @@ -29,6 +31,23 @@ + + {$chatActionOptionDialog?.title} +
+ +
+
+ +
+
+ {$alertDialog?.title}
diff --git a/src/scripts/chat-interceptor.ts b/src/scripts/chat-interceptor.ts index bc414fb6..c5d283b7 100644 --- a/src/scripts/chat-interceptor.ts +++ b/src/scripts/chat-interceptor.ts @@ -237,25 +237,41 @@ const chatLoaded = async (): Promise => { hints: iconTypes }); } - function findServiceEndpoint(root: any, prop: string): any | null { + type EndpointProp = 'moderateLiveChatEndpoint' | 'getReportFormEndpoint' | + 'liveChatActionEndpoint' | 'manageLiveChatUserEndpoint'; + function getText(text: any): string { + if (typeof text?.simpleText === 'string') return text.simpleText; + if (Array.isArray(text?.runs)) { + return text.runs.map((r: any) => r?.text).filter(Boolean).join(''); + } + return ''; + } + function walkObjects(root: any, visitor: (current: any) => void): void { const queue = [root]; const visited = new Set(); while (queue.length > 0) { const current = queue.shift(); if (current == null || typeof current !== 'object' || visited.has(current)) continue; visited.add(current); - if (typeof current?.[prop]?.params === 'string') { - return current; - } + visitor(current); for (const value of Object.values(current)) { if (value != null && typeof value === 'object') { queue.push(value); } } } - return null; } - function parseServiceEndpoint(serviceEndpoint: any, prop: string): { params: string, context: any } { + function findServiceEndpoint(root: any, prop: EndpointProp): any | null { + let found: any | null = null; + walkObjects(root, (current) => { + if (found != null) return; + if (typeof current?.[prop]?.params === 'string') { + found = current; + } + }); + return found; + } + function parseServiceEndpoint(serviceEndpoint: any, prop: EndpointProp): { params: string, context: any } { if (typeof serviceEndpoint?.[prop]?.params !== 'string') { throw new Error(`Missing service endpoint params for ${prop}`); } @@ -271,78 +287,104 @@ const chatLoaded = async (): Promise => { context: clonedContext }; } - function findDeleteMessageEndpoint(root: any): any | null { - const queue = [root]; - const visited = new Set(); - const candidates: Array<{ iconType?: string, label?: string, endpoint: any }> = []; - while (queue.length > 0) { - const current = queue.shift(); - if (current == null || typeof current !== 'object' || visited.has(current)) continue; - visited.add(current); + function findMenuEndpoint( + root: any, + iconType: string, + prop: EndpointProp, + labelMatches: Array<(label: string) => boolean> = [] + ): any | null { + const candidates: Array<{ iconType?: string, label: string, endpoint: any }> = []; + walkObjects(root, (current) => { const menu = current?.menuServiceItemRenderer; - const iconType = menu?.icon?.iconType; + if (menu == null) return; const endpoint = menu?.serviceEndpoint; - const label = ( - Array.isArray(menu?.text?.runs) - ? menu.text.runs.map((r: any) => r?.text).filter(Boolean).join('') - : menu?.text?.simpleText - ) as string | undefined; - // Prefer stable identifiers (DELETE icon + moderate endpoint) over localized label text. - if (typeof endpoint?.moderateLiveChatEndpoint?.params === 'string') { - candidates.push({ iconType, label, endpoint }); - } - for (const value of Object.values(current)) { - if (value != null && typeof value === 'object') { - queue.push(value); - } + if (typeof endpoint?.[prop]?.params === 'string') { + candidates.push({ + iconType: menu?.icon?.iconType, + label: getText(menu?.text), + endpoint + }); } - } + }); for (const c of candidates) { - if (c.iconType === 'DELETE') return c.endpoint; + if (c.iconType === iconType) return c.endpoint; } for (const c of candidates) { - const l = (c.label ?? '').toLowerCase(); - if (l.includes('remove') || l.includes('delete') || l.includes('retract') || l.includes('unsend')) { + const label = c.label.toLowerCase(); + if (labelMatches.some((matcher) => matcher(label))) { return c.endpoint; } } - if (candidates.length === 1) return candidates[0].endpoint; return null; } - if (msg.action === ChatUserActions.BLOCK) { - const serviceEndpoint = findServiceEndpoint(res, 'moderateLiveChatEndpoint'); - if (serviceEndpoint == null) { - throw new Error('Could not find moderate endpoint in context menu'); + function findNestedOptionEndpoint( + root: any, + iconType: string, + optionLabel: string | undefined, + prop: EndpointProp + ): any | null { + if (optionLabel == null) { + throw new Error(`Missing option label for ${iconType}`); } - const { params, context } = parseServiceEndpoint(serviceEndpoint, 'moderateLiveChatEndpoint'); - const moderationResponse = await fetcher(`${currentDomain}/youtubei/v1/live_chat/moderate?key=${apiKey}&prettyPrint=false`, { + let found: any | null = null; + const normalizedOptionLabel = optionLabel.toLowerCase(); + walkObjects(root, (current) => { + if (found != null) return; + const menu = current?.menuServiceItemRenderer; + if (menu?.icon?.iconType !== iconType) return; + walkObjects(menu, (menuNode) => { + if (found != null) return; + const option = menuNode?.optionSelectableItemRenderer; + const endpoint = option?.submitEndpoint; + if (typeof endpoint?.[prop]?.params !== 'string') return; + if (getText(option?.text).toLowerCase() === normalizedOptionLabel) { + found = endpoint; + } + }); + }); + return found; + } + async function postEndpoint( + serviceEndpoint: any, + prop: EndpointProp, + apiPath: string + ): Promise { + const { params, context } = parseServiceEndpoint(serviceEndpoint, prop); + const actionResponse = await fetcher(`${currentDomain}/youtubei/v1/${apiPath}?key=${apiKey}&prettyPrint=false`, { ...heads, body: JSON.stringify({ params, context }) }); - if (moderationResponse?.error != null || moderationResponse?.success === false) { - throw new Error('Moderation request failed'); + if (actionResponse?.error != null || actionResponse?.success === false) { + throw new Error(`${apiPath} request failed`); + } + return actionResponse; + } + if (msg.action === ChatUserActions.BLOCK) { + const serviceEndpoint = findMenuEndpoint(res, 'BLOCK', 'moderateLiveChatEndpoint', [ + (label) => label.includes('block') + ]); + if (serviceEndpoint == null) { + throw new Error('Could not find block endpoint in context menu'); } + await postEndpoint(serviceEndpoint, 'moderateLiveChatEndpoint', 'live_chat/moderate'); } else if (msg.action === ChatUserActions.DELETE_MESSAGE) { - const serviceEndpoint = findDeleteMessageEndpoint(res); + const serviceEndpoint = findMenuEndpoint(res, 'DELETE', 'moderateLiveChatEndpoint', [ + (label) => label.includes('remove') || label.includes('delete') || + label.includes('retract') || label.includes('unsend') + ]); if (serviceEndpoint == null) { throw new Error('Could not find delete endpoint in context menu'); } - const { params, context } = parseServiceEndpoint(serviceEndpoint, 'moderateLiveChatEndpoint'); if (debugAction) { + const { params } = parseServiceEndpoint(serviceEndpoint, 'moderateLiveChatEndpoint'); console.debug('[hc] delete: moderate', { paramsPrefix: params.slice(0, 24) }); } - const moderationResponse = await fetcher(`${currentDomain}/youtubei/v1/live_chat/moderate?key=${apiKey}&prettyPrint=false`, { - ...heads, - body: JSON.stringify({ - params, - context - }) - }); + const moderationResponse = await postEndpoint(serviceEndpoint, 'moderateLiveChatEndpoint', 'live_chat/moderate'); if (debugAction) { console.debug('[hc] delete: moderate response', { keys: moderationResponse != null && typeof moderationResponse === 'object' @@ -352,12 +394,65 @@ const chatLoaded = async (): Promise => { success: moderationResponse?.success }); } - if (moderationResponse?.error != null || moderationResponse?.success === false) { - throw new Error('Moderation request failed'); + } else if (msg.action === ChatUserActions.PIN_MESSAGE) { + const serviceEndpoint = findMenuEndpoint(res, 'KEEP', 'liveChatActionEndpoint', [ + (label) => label.includes('pin') + ]); + if (serviceEndpoint == null) { + throw new Error('Could not find pin endpoint in context menu'); + } + await postEndpoint(serviceEndpoint, 'liveChatActionEndpoint', 'live_chat/live_chat_action'); + } else if (msg.action === ChatUserActions.TIMEOUT_USER) { + const serviceEndpoint = findNestedOptionEndpoint( + res, + 'HOURGLASS', + msg.actionOption, + 'moderateLiveChatEndpoint' + ); + if (serviceEndpoint == null) { + throw new Error('Could not find timeout endpoint in context menu'); + } + await postEndpoint(serviceEndpoint, 'moderateLiveChatEndpoint', 'live_chat/moderate'); + } else if (msg.action === ChatUserActions.HIDE_USER) { + const serviceEndpoint = findMenuEndpoint(res, 'REMOVE_CIRCLE', 'moderateLiveChatEndpoint', [ + (label) => label.includes('hide user') + ]); + if (serviceEndpoint == null) { + throw new Error('Could not find hide endpoint in context menu'); + } + await postEndpoint(serviceEndpoint, 'moderateLiveChatEndpoint', 'live_chat/moderate'); + } else if (msg.action === ChatUserActions.UNHIDE_USER) { + const serviceEndpoint = findMenuEndpoint(res, 'ADD_CIRCLE', 'moderateLiveChatEndpoint', [ + (label) => label.includes('unhide user') + ]); + if (serviceEndpoint == null) { + throw new Error('Could not find unhide endpoint in context menu'); + } + await postEndpoint(serviceEndpoint, 'moderateLiveChatEndpoint', 'live_chat/moderate'); + } else if (msg.action === ChatUserActions.ADD_MODERATOR) { + const serviceEndpoint = findNestedOptionEndpoint( + res, + 'ADD_MODERATOR', + msg.actionOption, + 'manageLiveChatUserEndpoint' + ); + if (serviceEndpoint == null) { + throw new Error('Could not find add moderator endpoint in context menu'); + } + await postEndpoint(serviceEndpoint, 'manageLiveChatUserEndpoint', 'live_chat/manage_user'); + } else if (msg.action === ChatUserActions.REMOVE_MODERATOR) { + const serviceEndpoint = findMenuEndpoint(res, 'REMOVE_MODERATOR', 'manageLiveChatUserEndpoint', [ + (label) => label.includes('remove') && label.includes('moderator') + ]); + if (serviceEndpoint == null) { + throw new Error('Could not find remove moderator endpoint in context menu'); } + await postEndpoint(serviceEndpoint, 'manageLiveChatUserEndpoint', 'live_chat/manage_user'); } else if (msg.action === ChatUserActions.REPORT_USER) { const apiKey = ytcfg.data_.INNERTUBE_API_KEY; - const serviceEndpoint = findServiceEndpoint(res, 'getReportFormEndpoint'); + const serviceEndpoint = findMenuEndpoint(res, 'FLAG', 'getReportFormEndpoint', [ + (label) => label.includes('report') + ]) ?? findServiceEndpoint(res, 'getReportFormEndpoint'); if (serviceEndpoint == null) { throw new Error('Could not find report endpoint in context menu'); } @@ -398,6 +493,8 @@ const chatLoaded = async (): Promise => { if (flagResponse?.error != null || flagResponse?.success === false) { throw new Error('Report request failed'); } + } else { + throw new Error(`Unknown chat action: ${msg.action as string}`); } } catch (e) { console.debug('Error executing chat action', e); diff --git a/src/ts/chat-actions.ts b/src/ts/chat-actions.ts index df0c1c3d..589882f9 100644 --- a/src/ts/chat-actions.ts +++ b/src/ts/chat-actions.ts @@ -1,19 +1,26 @@ import { writable } from 'svelte/store'; -import { ChatReportUserOptions, ChatUserActions } from './chat-constants'; -import { reportDialog } from './storage'; +import { + ChatReportUserOptions, + ChatUserActions, + chatModeratorRoleOptions, + chatTimeoutOptions +} from './chat-constants'; +import { chatActionOptionDialog, reportDialog } from './storage'; export function useBanHammer( message: Ytc.ParsedMessage, action: ChatUserActions, port: Chat.Port | null ): void { - if (action === ChatUserActions.BLOCK || action === ChatUserActions.DELETE_MESSAGE) { + const executeAction = (actionOption?: string): void => { port?.postMessage({ type: 'executeChatAction', message, - action + action, + actionOption }); - } else if (action === ChatUserActions.REPORT_USER) { + }; + if (action === ChatUserActions.REPORT_USER) { const store = writable(null as null | ChatReportUserOptions); reportDialog.set({ callback: (selection) => { @@ -26,6 +33,26 @@ export function useBanHammer( }, optionStore: store }); + } else if (action === ChatUserActions.TIMEOUT_USER) { + const store = writable(null as null | string); + chatActionOptionDialog.set({ + title: 'Put User In Timeout', + confirmText: 'Timeout', + items: chatTimeoutOptions, + callback: executeAction, + optionStore: store + }); + } else if (action === ChatUserActions.ADD_MODERATOR) { + const store = writable(null as null | string); + chatActionOptionDialog.set({ + title: 'Add Moderator', + confirmText: 'Add', + items: chatModeratorRoleOptions, + callback: executeAction, + optionStore: store + }); + } else { + executeAction(); } } diff --git a/src/ts/chat-constants.ts b/src/ts/chat-constants.ts index e3c28136..b4bb36a5 100644 --- a/src/ts/chat-constants.ts +++ b/src/ts/chat-constants.ts @@ -50,6 +50,12 @@ export enum ChatUserActions { BLOCK = 'BLOCK', REPORT_USER = 'REPORT_USER', DELETE_MESSAGE = 'DELETE_MESSAGE', + PIN_MESSAGE = 'PIN_MESSAGE', + TIMEOUT_USER = 'TIMEOUT_USER', + HIDE_USER = 'HIDE_USER', + UNHIDE_USER = 'UNHIDE_USER', + ADD_MODERATOR = 'ADD_MODERATOR', + REMOVE_MODERATOR = 'REMOVE_MODERATOR', } export enum ChatReportUserOptions { @@ -74,6 +80,20 @@ export const chatReportUserOptions = [ { value: ChatReportUserOptions.MISINFORMATION, label: 'Misinformation' } ]; +export const chatTimeoutOptions = [ + { value: '10 seconds', label: '10 seconds' }, + { value: '1 minute', label: '1 minute' }, + { value: '5 minutes', label: '5 minutes' }, + { value: '10 minutes', label: '10 minutes' }, + { value: '30 minutes', label: '30 minutes' }, + { value: '24 hours', label: '24 hours' } +]; + +export const chatModeratorRoleOptions = [ + { value: 'Managing moderator', label: 'Managing moderator' }, + { value: 'Standard moderator', label: 'Standard moderator' } +]; + export const chatUserActionsItems = [ { value: ChatUserActions.BLOCK, @@ -101,6 +121,60 @@ export const chatUserActionsItems = [ success: 'Your message has been deleted.', error: 'There was an error deleting your message. Please try again later.' } + }, + { + value: ChatUserActions.PIN_MESSAGE, + text: 'Pin message', + icon: 'push_pin', + messages: { + success: 'The message has been pinned.', + error: 'There was an error pinning the message. Please try again later.' + } + }, + { + value: ChatUserActions.TIMEOUT_USER, + text: 'Put user in timeout', + icon: 'hourglass_empty', + messages: { + success: 'The user has been timed out.', + error: 'There was an error timing out the user. Please try again later.' + } + }, + { + value: ChatUserActions.HIDE_USER, + text: 'Hide user', + icon: 'remove_circle', + messages: { + success: 'The user has been hidden from this channel.', + error: 'There was an error hiding the user. Please try again later.' + } + }, + { + value: ChatUserActions.UNHIDE_USER, + text: 'Unhide user', + icon: 'add_circle', + messages: { + success: 'The user has been unhidden from this channel.', + error: 'There was an error unhiding the user. Please try again later.' + } + }, + { + value: ChatUserActions.ADD_MODERATOR, + text: 'Add moderator', + icon: 'person_add', + messages: { + success: 'The user has been added as a moderator.', + error: 'There was an error adding the moderator. Please try again later.' + } + }, + { + value: ChatUserActions.REMOVE_MODERATOR, + text: 'Remove moderator', + icon: 'person_remove', + messages: { + success: 'The moderator has been removed.', + error: 'There was an error removing the moderator. Please try again later.' + } } ]; diff --git a/src/ts/storage.ts b/src/ts/storage.ts index 1c27ed03..2013d86a 100644 --- a/src/ts/storage.ts +++ b/src/ts/storage.ts @@ -68,6 +68,13 @@ export const reportDialog = writable(null as null | { callback: (selection: ChatReportUserOptions) => void; optionStore: Writable; }); +export const chatActionOptionDialog = writable(null as null | { + title: string; + confirmText: string; + items: Array<{ value: string, label: string }>; + callback: (selection: string) => void; + optionStore: Writable; +}); export const alertDialog = writable(null as null | { title: string; message: string; diff --git a/src/ts/typings/chat.d.ts b/src/ts/typings/chat.d.ts index 1d40cbc8..a4cb9596 100644 --- a/src/ts/typings/chat.d.ts +++ b/src/ts/typings/chat.d.ts @@ -157,6 +157,7 @@ declare namespace Chat { message: Ytc.ParsedMessage; action: ChatUserActions; reportOption?: ChatReportUserOptions; + actionOption?: string; } type BackgroundMessage = From 17ef4913f426019cde9f491cf1fef15a6c410661 Mon Sep 17 00:00:00 2001 From: Kento Nishi Date: Sat, 30 May 2026 17:21:56 -0400 Subject: [PATCH 4/6] document action request shape --- docs/YOUTUBE_ACTIONS.md | 66 +++++++++++++++++++++++++++++++++-------- 1 file changed, 54 insertions(+), 12 deletions(-) diff --git a/docs/YOUTUBE_ACTIONS.md b/docs/YOUTUBE_ACTIONS.md index 1a7ea4e9..2a00ec74 100644 --- a/docs/YOUTUBE_ACTIONS.md +++ b/docs/YOUTUBE_ACTIONS.md @@ -26,6 +26,7 @@ YouTube actions almost always depend on these fields. If you drop any of them, y - Account identity: `x-goog-authuser` must match the active YouTube account (multi-login breaks without it) - Visitor identity: `x-goog-visitor-id` - Client identity: `x-youtube-client-name` and `x-youtube-client-version` +- Delegated channel identity: `x-goog-pageid` from `DELEGATED_SESSION_ID` when present If you are unsure where a value comes from, stop and find it in: @@ -33,18 +34,17 @@ If you are unsure where a value comes from, stop and find it in: - the context menu response tree - the message renderer tree that created the menu -## Auth: SAPISIDHASH Still Matters +## Auth And Identity Headers Some actions require a valid `Authorization: SAPISIDHASH ...` header (computed from cookies). -Do not remove SAPISIDHASH support just because a specific action seems to work without it on your machine. It can break on: +Do not remove SAPISIDHASH support just because a specific action seems to work without it on your machine. Deletion/retraction experiments included a successful HyperChat request with SAPISIDHASH, so the implementation keeps the computed auth path. -- different accounts -- different regions -- different browsers -- multi-login sessions +Do not claim SAPISIDHASH is required for every action either. The native mod-action HAR did not send `Authorization` on `get_item_context_menu`, `live_chat/moderate`, `live_chat/manage_user`, or `live_chat/live_chat_action`. -Treat "native works" as the ground truth: if native sends SAPISIDHASH for that call, we should too. +Treat "native works" as the ground truth: if native sends SAPISIDHASH for that call, we should too. If native does not send it, the HAR only proves the identity envelope around the request, not that auth is required. + +For mod actions from a delegated channel, `x-goog-pageid` is part of that identity envelope. In `trying-mod-actions.har`, every relevant native request had `x-goog-pageid`; deletion/retraction captures did not. Keep this distinction visible when carrying code between MV2 and MV3. ## Endpoint Discovery: Never Hardcode Indices @@ -60,7 +60,7 @@ Examples: - block/hide/delete/timeout/unhide: `moderateLiveChatEndpoint` (but choose by menu icon/action, not by endpoint type alone) - report: `getReportFormEndpoint` (flow can be multi-step) -- delete/retract: look for the delete/retract endpoint in the same way +- delete/retract: `moderateLiveChatEndpoint` from the delete/retract menu item If an endpoint is missing, log enough context to diagnose: @@ -68,6 +68,38 @@ If an endpoint is missing, log enough context to diagnose: - which ones we did not - which message/menu payload we used to ask for the menu +## Delete / Retract Flow + +Delete/retract is not a separate obvious top-level API. It is a context-menu action: + +1. Use the message's `contextMenuEndpoint.liveChatItemContextMenuEndpoint.params`. +2. Call `live_chat/get_item_context_menu` with those params and the same Innertube identity headers YouTube uses. +3. Search the response tree for `menuServiceItemRenderer.serviceEndpoint.moderateLiveChatEndpoint`. +4. Prefer candidates whose icon is `DELETE`. +5. Fall back to label text only after endpoint and icon checks, because labels are localized and less stable. +6. POST the selected endpoint params to `live_chat/moderate`. + +For streamer/mod deletes, do not infer capability from author id alone. YouTube exposes the delete affordance on each message via `inlineActionButtons`; parse that into `canDelete` and use it with the message's context-menu params. + +For self retraction, use the same endpoint discovery path. It may still be a `moderateLiveChatEndpoint`; the important distinction is the menu item YouTube returned for that message/account state, not a different hardcoded endpoint. + +## Context Shape Matters + +The deletion/retraction HARs proved that "success: true" is not enough. A sloppy request can return a successful response with the wrong chat action. + +For `live_chat/get_item_context_menu`, native requests used: + +- `context.adSignalsInfo` +- `context.client` +- `context.request` +- `context.user` + +They did not include `context.clickTracking`. + +For the actual mutation request (`live_chat/moderate`, `live_chat/manage_user`, or `live_chat/live_chat_action`), native requests used the same context plus endpoint-specific `context.clickTracking.clickTrackingParams` from the selected returned endpoint. + +Do not carry stale click tracking from the base context into the context-menu request. Do not invent click tracking for the mutation request. Copy it from the selected endpoint. + ## Mod Action Learnings The captured mod-action HAR (`artifacts/build/trying-mod-actions.har`) is enough to reconstruct the exact native YouTube flows. All mutation requests start from a message's `contextMenuEndpoint.liveChatItemContextMenuEndpoint.params`, then call `live_chat/get_item_context_menu`, then execute the endpoint attached to the selected menu item or nested option. @@ -142,7 +174,11 @@ Implementation shape: - timeout duration: `10 seconds`, `1 minute`, `5 minutes`, `10 minutes`, `30 minutes`, `24 hours` - add moderator role: `Managing moderator`, `Standard moderator` 4. Keep `useBanHammer`, `executeChatAction`, `chatUserActionResponse`, and MV2 background forwarding structurally intact. -5. Inside the action executor, keep the existing `get_item_context_menu` request, headers, SAPISIDHASH, and proxy fetch flow. +5. Inside the action executor, keep the existing `get_item_context_menu` request and proxy fetch flow, but make the request shape match the HAR: + - context-menu request: no `clickTracking` + - mutation request: endpoint-specific `clickTrackingParams` + - identity headers: active account, visitor, client name/version, origin, and delegated `x-goog-pageid` when present + - SAPISIDHASH support remains available, but the mod-action HAR does not prove it is required 6. Replace fragile endpoint selection with icon-aware resolution: - `DELETE_MESSAGE`: `DELETE` + `moderateLiveChatEndpoint` - `PIN_MESSAGE`: `KEEP` + `liveChatActionEndpoint` @@ -184,10 +220,14 @@ When we apply an action locally, do it only when YouTube confirms success. For delete/retract: -- on success: remove the message from display (and tolerate YouTube later echoing a "retracted" update) +- on local request success: mark the message as pending deleted, but keep the original runs available locally +- on `markChatItemAsDeletedAction`: replace with YouTube's deleted-state message +- on `removeChatItemAction`: treat the target message as pending deleted if YouTube only tells us to remove the item +- on `markChatItemsByAuthorAsDeletedAction`: apply the deleted-state message to messages from that author +- if YouTube provides `showOriginalContentMessage`, keep it as a view-original toggle for moderator contexts - on failure: keep it visible and surface an error -If you fake success, users will trust the UI less than the native UI. +Do not mutate the parsed message text in place. Keep the original parsed runs and choose the displayed runs at the render edge. Otherwise pending state, "view deleted message", and later YouTube confirmation can clobber each other. ## HAR + DevTools Tips (So You Do Not Lose The Payload) @@ -198,7 +238,9 @@ If you fake success, users will trust the UI less than the native UI. ## Where This Usually Breaks - Missing `x-goog-authuser` (multi-account sessions) +- Missing `x-goog-pageid` when acting as a delegated channel - Dropped `clickTrackingParams` / message `params` +- Carrying `clickTracking` into `get_item_context_menu` - Wrong Innertube client name/version (YouTube serves different schemas) -- SAPISIDHASH removed or computed for the wrong origin +- SAPISIDHASH removed where native/working HC requests require it, or computed for the wrong origin - Context menu parsing tied to item index instead of endpoint types From da60bec3adae4242e21fba3b259f3ee247f3ce0f Mon Sep 17 00:00:00 2001 From: Kento Nishi Date: Sat, 30 May 2026 17:26:23 -0400 Subject: [PATCH 5/6] match action request context --- src/scripts/chat-interceptor.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/scripts/chat-interceptor.ts b/src/scripts/chat-interceptor.ts index ccfc9a16..d89ae72e 100644 --- a/src/scripts/chat-interceptor.ts +++ b/src/scripts/chat-interceptor.ts @@ -212,7 +212,13 @@ const chatLoaded = async (): Promise => { // ytcfg.context.client.visitorData in subtle ways and cause YT to treat the request as logged out. // Instead, let the page-side proxy merge the latest headers from real YT requests. const heads = buildInnertubeHeaders(); - const contextMenuContext = JSON.parse(JSON.stringify(baseContext)); + const cloneBaseContext = (): any => JSON.parse(JSON.stringify(baseContext)); + const buildContextMenuContext = (): any => { + const context = cloneBaseContext(); + delete context.clickTracking; + return context; + }; + const contextMenuContext = buildContextMenuContext(); if (debugAction) { console.debug('[hc] delete: get_item_context_menu', { url: contextMenuUrl, @@ -278,7 +284,8 @@ const chatLoaded = async (): Promise => { throw new Error(`Missing service endpoint params for ${prop}`); } const { clickTrackingParams, [prop]: { params } } = serviceEndpoint; - const clonedContext = JSON.parse(JSON.stringify(baseContext)); + const clonedContext = cloneBaseContext(); + delete clonedContext.clickTracking; if (clickTrackingParams != null) { clonedContext.clickTracking = { clickTrackingParams From 84020e61003e1612b61dd4741dd192590d04a48c Mon Sep 17 00:00:00 2001 From: Kento Nishi Date: Sat, 30 May 2026 17:34:28 -0400 Subject: [PATCH 6/6] note block report guardrail --- docs/YOUTUBE_ACTIONS.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/YOUTUBE_ACTIONS.md b/docs/YOUTUBE_ACTIONS.md index 2a00ec74..031478df 100644 --- a/docs/YOUTUBE_ACTIONS.md +++ b/docs/YOUTUBE_ACTIONS.md @@ -154,6 +154,8 @@ The hide/unhide flow therefore has two proven unhide sources: the context menu's The mod-action HAR contains `FLAG` / `Report` menu items, but it does not contain an executed report submission. Use the existing report flow for report execution unless a new report-specific HAR says otherwise. +Block and report predate the full mod-action work and should be treated as existing behavior, not new HAR-derived features. The HAR lessons still apply to their shared context-menu request shape and endpoint selection, but do not remove existing block/report UI affordances just because some messages lack context-menu params. That path may fail through the executor today, but hiding it would be a behavior change without a proven replacement. + ## Mod Action Implementation Plan Everything currently implemented for block, report, delete/retract, message parsing, queueing, and MV2 background forwarding works and must not regress. Implement mod actions by preserving the existing architecture and changing only the pieces required to select and execute the correct YouTube endpoints. @@ -205,6 +207,7 @@ Regression guardrails: - Existing delete/retract behavior must continue to work for self messages, own streams, other streams, and moderator deletes. - Existing report behavior must keep the same dialog and request flow. - Existing block behavior must not accidentally execute delete/hide/timeout just because those share `moderateLiveChatEndpoint`. +- Existing block/report visibility rules must not be tightened as part of mod actions. - Existing queue/parser deletion handling must remain the source of truth for YouTube-originated delete updates. - If a static HyperChat action is unavailable in YouTube's context menu, fail gracefully through `chatUserActionResponse` instead of guessing another endpoint.