From 3e39d3f5ca2d28aa07d4b702aa08b216e0ead2fc Mon Sep 17 00:00:00 2001 From: FlaminSarge Date: Tue, 12 May 2026 23:22:13 -0700 Subject: [PATCH] Handle End Poll button, do not allow poll owner to close the poll banner so they can always end it --- src/components/PollResults.svelte | 39 +++++++++----- src/ts/chat-actions.ts | 21 +++++++- src/ts/chat-constants.ts | 4 ++ src/ts/chat-parser.ts | 26 ++++++++-- src/ts/messaging.ts | 84 ++++++++++++++++++++++++++++--- src/ts/typings/chat.d.ts | 8 ++- src/ts/typings/ytc.d.ts | 12 +++-- 7 files changed, 164 insertions(+), 30 deletions(-) diff --git a/src/components/PollResults.svelte b/src/components/PollResults.svelte index a25a3c6e..cd22f3e5 100644 --- a/src/components/PollResults.svelte +++ b/src/components/PollResults.svelte @@ -5,8 +5,10 @@ import Icon from 'smelte/src/components/Icon'; import { Theme } from '../ts/chat-constants'; import { createEventDispatcher } from 'svelte'; - import { showProfileIcons } from '../ts/storage'; + import { port, showProfileIcons } from '../ts/storage'; import ProgressLinear from 'smelte/src/components/ProgressLinear'; + import { endPoll } from '../ts/chat-actions'; + import Button from 'smelte/src/components/Button'; export let poll: Ytc.ParsedPoll; @@ -59,18 +61,20 @@ {/if} {/each} -
- - { dismissed = true; }} - > - close - - Dismiss - -
+ {#if !poll.item.action} +
+ + { dismissed = true; }} + > + close + + Dismiss + +
+ {/if} {#if !shorten && !dismissed}
@@ -85,6 +89,15 @@
{/each} + {#if poll.item.action} +
+ +
+ {/if} {/if} {/if} diff --git a/src/ts/chat-actions.ts b/src/ts/chat-actions.ts index c11b3ae8..65d6c300 100644 --- a/src/ts/chat-actions.ts +++ b/src/ts/chat-actions.ts @@ -1,5 +1,5 @@ import { writable } from 'svelte/store'; -import { ChatReportUserOptions, ChatUserActions } from './chat-constants'; +import { ChatReportUserOptions, ChatUserActions, ChatPollActions } from './chat-constants'; import { reportDialog } from './storage'; import type { Chat } from './typings/chat'; @@ -29,3 +29,22 @@ export function useBanHammer( }); } } + +/** + * Ends a poll that is currently active in the live chat + * @param poll The ParsedPoll object containing information about the poll to end + * @param port The port to communicate with the background script + */ +export function endPoll( + poll: Ytc.ParsedPoll, + port: Chat.Port | null +): void { + if (!port) return; + + // Use a dedicated executePollAction message type for poll operations + port?.postMessage({ + type: 'executePollAction', + poll, + action: ChatPollActions.END_POLL + }); +} diff --git a/src/ts/chat-constants.ts b/src/ts/chat-constants.ts index 1fac499e..41bf03c5 100644 --- a/src/ts/chat-constants.ts +++ b/src/ts/chat-constants.ts @@ -31,6 +31,10 @@ export enum ChatUserActions { DELETE_MESSAGE = 'DELETE_MESSAGE', } +export enum ChatPollActions { + END_POLL = 'END_POLL', +} + export enum ChatReportUserOptions { UNWANTED_SPAM = 'UNWANTED_SPAM', PORN_OR_SEX = 'PORN_OR_SEX', diff --git a/src/ts/chat-parser.ts b/src/ts/chat-parser.ts index 920cfa6d..35e577f3 100644 --- a/src/ts/chat-parser.ts +++ b/src/ts/chat-parser.ts @@ -118,10 +118,16 @@ const parseRedirectBanner = (renderer: Ytc.AddChatItem, actionId: string, showti src: fixUrl(baseRenderer.authorPhoto?.thumbnails[0].url ?? ''), alt: 'Redirect profile icon' }; - const url = baseRenderer.inlineActionButton?.buttonRenderer.command.urlEndpoint?.url || - (baseRenderer.inlineActionButton?.buttonRenderer.command.watchEndpoint?.videoId - ? '/watch?v=' + baseRenderer.inlineActionButton?.buttonRenderer.command.watchEndpoint?.videoId + const buttonRenderer = baseRenderer.inlineActionButton?.buttonRenderer; + const url = buttonRenderer?.command.urlEndpoint?.url || + (buttonRenderer?.command.watchEndpoint?.videoId + ? '/watch?v=' + buttonRenderer?.command.watchEndpoint?.videoId : ''); + const buttonRendererText = buttonRenderer?.text; + const buttonText = (buttonRendererText && ( + ('runs' in buttonRendererText && parseMessageRuns(buttonRendererText.runs)) || + ('simpleText' in buttonRendererText && [{ type: 'text', text: buttonRendererText.simpleText }] as Ytc.ParsedTextRun[]) + )) || []; const item: Ytc.ParsedRedirect = { type: 'redirect', actionId, @@ -130,7 +136,7 @@ const parseRedirectBanner = (renderer: Ytc.AddChatItem, actionId: string, showti profileIcon, action: { url: fixUrl(url), - text: parseMessageRuns(baseRenderer.inlineActionButton?.buttonRenderer.text?.runs) + text: buttonText } }, showtime, @@ -271,6 +277,15 @@ const parsePollRenderer = (baseRenderer: Ytc.PollRenderer): Ytc.ParsedPoll | und src: fixUrl(baseRenderer.header.pollHeaderRenderer.thumbnail?.thumbnails[0].url ?? ''), alt: 'Poll profile icon' }; + // only allow action if all the relevant fields are present for it + const buttonRenderer = baseRenderer.button?.buttonRenderer; + const actionButton = buttonRenderer?.command?.commandMetadata?.webCommandMetadata?.apiUrl && + buttonRenderer?.text && 'simpleText' in buttonRenderer?.text && + buttonRenderer?.command?.liveChatActionEndpoint?.params && { + api: buttonRenderer.command.commandMetadata.webCommandMetadata.apiUrl, + text: buttonRenderer.text.simpleText, + params: buttonRenderer.command.liveChatActionEndpoint.params + } || undefined; // TODO implement 'selected' field? YT doesn't use it in results. return { type: 'poll', @@ -286,7 +301,8 @@ const parsePollRenderer = (baseRenderer: Ytc.PollRenderer): Ytc.ParsedPoll | und ratio: choice.voteRatio, percentage: choice.votePercentage?.simpleText }; - }) + }), + action: actionButton } }; }; diff --git a/src/ts/messaging.ts b/src/ts/messaging.ts index 67fcec22..c253122e 100644 --- a/src/ts/messaging.ts +++ b/src/ts/messaging.ts @@ -1,6 +1,6 @@ import type { Unsubscriber } from './queue'; import { ytcQueue } from './queue'; -import { chatReportUserOptions, ChatUserActions, ChatReportUserOptions } from '../ts/chat-constants'; +import { chatReportUserOptions, ChatUserActions, ChatReportUserOptions, ChatPollActions } from '../ts/chat-constants'; import type { Chat } from './typings/chat'; import sha1 from 'sha-1'; @@ -180,6 +180,38 @@ const sendLtlMessage = (message: Chat.LtlMessage): void => { ); }; +function getCookie(name: string): string { + const value = `; ${document.cookie}`; + const parts = value.split(`; ${name}=`); + if (parts.length === 2) return (parts.pop() ?? '').split(';').shift() ?? ''; + return ''; +} + +function parseServiceEndpoint(baseContext: any, serviceEndpoint: any, prop: string): { params: string, context: any } { + const { clickTrackingParams, [prop]: { params } } = serviceEndpoint; + const clonedContext = JSON.parse(JSON.stringify(baseContext)); + clonedContext.clickTracking = { + clickTrackingParams + }; + return { + params, + context: clonedContext + }; +} + +const fetcher = async (...args: any[]): Promise => { + return await new Promise((resolve) => { + const encoded = JSON.stringify(args); + window.addEventListener('proxyFetchResponse', (e) => { + const response = JSON.parse((e as CustomEvent).detail); + resolve(response); + }); + window.dispatchEvent(new CustomEvent('proxyFetchRequest', { + detail: encoded + })); + }); +}; + const executeChatAction = async ( message: Ytc.ParsedMessage, ytcfg: YtCfg, @@ -229,12 +261,7 @@ const executeChatAction = async ( const contextMenuUrl = `${currentDomain}/youtubei/v1/live_chat/get_item_context_menu?params=` + `${encodeURIComponent(message.params)}&pbj=1&key=${apiKey}&prettyPrint=false`; const baseContext = ytcfg.data_.INNERTUBE_CONTEXT; - function getCookie(name: string): string { - const value = `; ${document.cookie}`; - const parts = value.split(`; ${name}=`); - if (parts.length === 2) return (parts.pop() ?? '').split(';').shift() ?? ''; - return ''; - } + const time = Math.floor(Date.now() / 1000); const sapisid = getCookie('__Secure-3PAPISID') || getCookie('SAPISID'); const auth = sapisid ? `SAPISIDHASH ${time}_${sha1(`${time} ${sapisid} ${currentDomain}`)}` : null; @@ -423,6 +450,46 @@ const executeChatAction = async ( ); }; +const executePollAction = async ( + poll: Ytc.ParsedPoll, + ytcfg: YtCfg, + action: ChatPollActions, +): Promise => { + try { + const apiKey = ytcfg.data_.INNERTUBE_API_KEY; + const baseContext = ytcfg.data_.INNERTUBE_CONTEXT; + + const time = Math.floor(Date.now() / 1000); + const SAPISID = getCookie('__Secure-3PAPISID'); + const sha = sha1(`${time} ${SAPISID} ${currentDomain}`); + const auth = `SAPISIDHASH ${time}_${sha}`; + const heads = { + headers: { + 'Content-Type': 'application/json', + Accept: '*/*', + Authorization: auth + }, + method: 'POST' + }; + + if (action === ChatPollActions.END_POLL) { + const params = poll.item.action?.params || ''; + const url = poll.item.action?.api || '/youtubei/v1/live_chat/live_chat_action'; + + // Call YouTube API to end the poll + await fetcher(`${currentDomain}${url}?key=${apiKey}&prettyPrint=false`, { + ...heads, + body: JSON.stringify({ + params, + context: baseContext + }) + }); + } + } catch (e) { + console.debug('Error executing poll action', e); + } +} + export const initInterceptor = ( source: Chat.InterceptorSource, ytcfg: YtCfg, @@ -462,6 +529,9 @@ export const initInterceptor = ( case 'executeChatAction': executeChatAction(message.message, ytcfg, message.action, message.reportOption).catch(console.error); break; + case 'executePollAction': + executePollAction(message.poll, ytcfg, message.action).catch(console.error); + break; case 'ping': port.postMessage({ type: 'ping' }); break; diff --git a/src/ts/typings/chat.d.ts b/src/ts/typings/chat.d.ts index df62583e..162574f4 100644 --- a/src/ts/typings/chat.d.ts +++ b/src/ts/typings/chat.d.ts @@ -145,10 +145,16 @@ declare namespace Chat { reportOption?: ChatReportUserOptions; } + interface executePollActionMsg { + type: 'executePollAction'; + poll: Ytc.ParsedPoll; + action: ChatPollActions; + } + type BackgroundMessage = RegisterInterceptorMsg | RegisterClientMsg | processJsonMsg | setInitialDataMsg | updatePlayerProgressMsg | setThemeMsg | getThemeMsg | - RegisterYtcInterceptorMsg | sendLtlMessageMsg | executeChatActionMsg | chatUserActionResponse | Ping; + RegisterYtcInterceptorMsg | sendLtlMessageMsg | executeChatActionMsg | executePollActionMsg | chatUserActionResponse | Ping; type Port = Omit & { postMessage: (message: BackgroundMessage | BackgroundResponse) => void; diff --git a/src/ts/typings/ytc.d.ts b/src/ts/typings/ytc.d.ts index be0de96b..e7afc4b2 100644 --- a/src/ts/typings/ytc.d.ts +++ b/src/ts/typings/ytc.d.ts @@ -268,7 +268,7 @@ declare namespace Ytc { icon?: string; accessibility?: AccessibilityObj; isDisabled?: boolean; - text?: RunsObj; // | SimpleTextObj; + text?: RunsObj | SimpleTextObj; command: { commandMetadata?: { webCommandMetadata?: { @@ -319,7 +319,9 @@ declare namespace Ytc { }; choices: PollChoice[]; displayVoteResults?: boolean; - button?: ButtonRenderer; + button?: { + buttonRenderer: ButtonRenderer; + } } interface PollChoice { @@ -519,8 +521,12 @@ declare namespace Ytc { ratio?: number; percentage?: string; }>; + action?: { + api: string; + params: string; + text: string; + }; }; - // TODO add 'action' for ending poll button } interface ParsedRemoveBanner {