From d674eef5300c0a14c80d77573e65e7bbde2793bb Mon Sep 17 00:00:00 2001 From: LuanRT Date: Thu, 14 Oct 2021 16:54:02 -0300 Subject: [PATCH] feat: add support for livechats --- lib/Livechat.js | 152 ++++++++++++++++++++++++++++++++++++++++++++++++ lib/Utils.js | 24 ++++++-- 2 files changed, 172 insertions(+), 4 deletions(-) create mode 100644 lib/Livechat.js diff --git a/lib/Livechat.js b/lib/Livechat.js new file mode 100644 index 00000000..f2dd158e --- /dev/null +++ b/lib/Livechat.js @@ -0,0 +1,152 @@ +'use strict'; + +const Axios = require('axios'); +const Utils = require('./Utils'); +const Constants = require('./Constants'); +const EventEmitter = require('events'); +const Uuid = require("uuid"); + +class Livechat extends EventEmitter { + constructor(session, token, channel_id, video_id) { + super(session); + this.ctoken = token; + this.session = session; + this.video_id = video_id; + this.channel_id = channel_id; + + this.message_queue = []; + this.id_cache = []; + + this.poll_intervals_ms = 0; + this.running = true; + + this.poll(); + } + + async sendMessage(text) { + let data = { + context: this.session.context, + params: Utils.encodeChannelIdWithVideoId(this.channel_id, this.video_id), + clientMessageId: `INntLiB${Uuid.v4()}`, + richMessage: { + textSegments: [{ text }] + } + }; + + const response = await Axios.post(`${Constants.urls.YT_BASE_URL}/youtubei/v1/live_chat/send_message${this.session.logged_in && this.session.cookie.length < 1 ? '' : `?key=${this.session.key}`}`, JSON.stringify(data), Constants.innertube_request_opts({ session: this.session, data, id: this.video_id, desktop: true })).catch((error) => error); + if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.response.data.error.message }; + + const deleteMessage = async () => { + /* + * The first request is made to get the chat options and the delete command endpoint, + * these options contain the required params to delete a message (a string composed of clientId, the channelId of the channel you're watching, your public channelId and the id of the message you sent). + * All put together with some binary data and then base64ed twice (yes, twice lm*o top notch security). + **/ + const item_menu_res = await Axios.post(`${Constants.urls.YT_BASE_URL}/youtubei/v1/live_chat/get_item_context_menu?params=${response.data.actions[0].addChatItemAction.item.liveChatTextMessageRenderer.contextMenuEndpoint.liveChatItemContextMenuEndpoint.params}&pbj=1${this.session.logged_in && this.session.cookie.length < 1 ? '' : `&key=${this.session.key}`}`, JSON.stringify({ context: this.session.context }), Constants.innertube_request_opts({ session: this.session, id: this.video_id, desktop: true })).catch((error) => error); + if (item_menu_res instanceof Error) return { success: false, status_code: item_menu_res.response.status, message: item_menu_res.response.data.error.message }; + const chat_item_menu = item_menu_res.data.liveChatItemContextMenuSupportedRenderers.menuRenderer.items[0]; + + const delete_message_reqbody = { + context: this.session.context, + params: chat_item_menu.menuServiceItemRenderer.serviceEndpoint.moderateLiveChatEndpoint.params + }; + + const delete_message_cmd = await Axios.post(`${Constants.urls.YT_BASE_URL}${chat_item_menu.menuServiceItemRenderer.serviceEndpoint.commandMetadata.webCommandMetadata.apiUrl}${this.session.logged_in && this.session.cookie.length < 1 ? '' : `&key=${this.session.key}`}`, JSON.stringify(delete_message_reqbody), Constants.innertube_request_opts({ session: this.session, delete_message_reqbody, id: this.video_id, desktop: true })).catch((error) => error); + if (delete_message_cmd instanceof Error) return { success: false, status_code: delete_message_cmd.response.status, message: delete_message_cmd.response.data.error.message }; + return { success: true, status_code: response.status }; + }; + + return { + success: true, + status_code: response.status, + deleteMessage: () => deleteMessage(), + message_data: { + text: response.data.actions[0].addChatItemAction.item.liveChatTextMessageRenderer.message.runs.map((item) => item.text).join(' '), + author: { + name: response.data.actions[0].addChatItemAction.item.liveChatTextMessageRenderer.authorName && response.data.actions[0].addChatItemAction.item.liveChatTextMessageRenderer.authorName.simpleText || 'N/', + channel_id: response.data.actions[0].addChatItemAction.item.liveChatTextMessageRenderer.authorExternalChannelId, + profile_picture: response.data.actions[0].addChatItemAction.item.liveChatTextMessageRenderer.authorPhoto.thumbnails + }, + timestamp: response.data.actions[0].addChatItemAction.item.liveChatTextMessageRenderer.timestampUsec, + id: response.data.actions[0].addChatItemAction.item.liveChatTextMessageRenderer.id + } + }; + } + + enqueueActionGroup(group) { + group.forEach((action) => { + if (!action.addChatItemAction) return; + const message_content = action.addChatItemAction.item.liveChatTextMessageRenderer; + if (!message_content) return; + + const message = { + text: message_content.message.runs.map((item) => item.text).join(' '), + author: { + name: message_content.authorName && message_content.authorName.simpleText || 'N/', + channel_id: message_content.authorExternalChannelId, + profile_picture: message_content.authorPhoto.thumbnails + }, + timestamp: message_content.timestampUsec, + id: message_content.id + }; + + this.message_queue.push(message); + }); + } + + async poll() { + if (!this.running) return; + + let data; + + data = { context: this.session.context, continuation: this.ctoken }; + const livechat = await Axios.post(`${Constants.urls.YT_BASE_URL}/youtubei/v1/live_chat/get_live_chat${this.session.logged_in && this.session.cookie.length < 1 ? '' : `?key=${this.session.key}`}`, JSON.stringify(data), Constants.innertube_request_opts({ session: this.session, data, desktop: true })); + if (livechat instanceof Error) throw new Error(`Error polling livechat: ${livechat.message}`); + + const continuation_contents = livechat.data.continuationContents; + const action_group = continuation_contents.liveChatContinuation.actions; + this.enqueueActionGroup(action_group); + + // Why don't we just emit the message directly? Well, enqueueing the messages is necessary so they are not emitted in a “messy” way, funny enough that's exactly how YouTube does it in its livechat js player. + this.message_queue.forEach((message, index) => { + if (this.id_cache.includes(message.id)) return; + setTimeout(() => this.emit('chat-update', message), message.timestamp / 1000 - new Date().getTime()); + this.id_cache.push(message.id); + }); + + + data = { context: this.session.context, videoId: this.video_id }; + if (this.metadata_ctoken) data.continuation = this.metadata_ctoken; + + const updated_metadata = await Axios.post(`${Constants.urls.YT_BASE_URL}/youtubei/v1/updated_metadata${this.session.logged_in && this.session.cookie.length < 1 ? '' : `?key=${this.session.key}`}`, JSON.stringify(data), Constants.innertube_request_opts({ session: this.session, data, desktop: true })); + if (updated_metadata instanceof Error) throw new Error(`Error polling updated metadata: ${updated_metadata.message}`); + this.metadata_ctoken = updated_metadata.data.continuation.timedContinuationData.continuation; + + const metadata = updated_metadata.data.actions; + this.emit('update-metadata', { + likes: metadata[1].updateToggleButtonTextAction.defaultText.simpleText, + dislikes: metadata[2].updateToggleButtonTextAction.defaultText.simpleText, + view_count: { + simple_text: metadata[0].updateViewershipAction.viewCount.videoViewCountRenderer.viewCount.simpleText, + short_view_count: metadata[0].updateViewershipAction.viewCount.videoViewCountRenderer.extraShortViewCount.simpleText + } + }); + + // Amount of time we should wait to poll the chat again. + if (continuation_contents.liveChatContinuation.continuations[0].timedContinuationData) { + this.poll_intervals_ms = continuation_contents.liveChatContinuation.continuations[0].timedContinuationData.timeoutMs; + } else { + this.poll_intervals_ms = 4000; + } + + await this.poll(); + this.livechat_poller = setTimeout(() => this.poll(), this.poll_intervals_ms); + } + + stop() { + this.running = false; + clearTimeout(this.livechat_poller); + } +} + +module.exports = Livechat; \ No newline at end of file diff --git a/lib/Utils.js b/lib/Utils.js index 2c023662..cb5b4e59 100644 --- a/lib/Utils.js +++ b/lib/Utils.js @@ -3,6 +3,10 @@ const Crypto = require('crypto'); const UserAgent = require('user-agents'); +function wait (milliseconds) { + return new Promise((resolve, reject) => setTimeout(() => resolve(), milliseconds)); +} + function getRandomUserAgent(type) { switch (type) { case 'mobile': @@ -39,6 +43,18 @@ function createFunction(input, raw_code) { // I hate this return new Function(input, raw_code); } +function encodeChannelIdWithVideoId(channel_id, video_id) { + const buff_start = ` +)*' +`; + const buff_middle = ` `; + const buff_end = ``; + + // Yes, we also have to base64 these twice lol + let encodedIds = Buffer.from([ buff_start, channel_id, buff_middle, video_id, buff_end ].join('')).toString('base64'); + return `${Buffer.from(encodedIds).toString('base64').slice(0, -4)}JTNE`; +} + function encodeChannelId(id, notification_pref) { const buff_start = ` `; @@ -47,13 +63,13 @@ function encodeChannelId(id, notification_pref) { ``, // none ``, // personalized ]; - - let buff = Buffer.from(`${buff_start}${id}${buff_end[notification_pref]}`); - return encodeURIComponent(`${buff.toString('base64')}GAAgBA==`); + + let encodedId = Buffer.from([buff_start, id, buff_end[notification_pref]].join('')).toString('base64'); + return encodeURIComponent(`${encodedId}GAAgBA==`); } function encodeVideoId(id) { return encodeURIComponent(`${Buffer.from(` ` + id + `*`).toString('base64').slice(0, -1)}BQBw==`); } -module.exports = { getRandomUserAgent, generateSidAuth, getStringBetweenStrings, createFunction, encodeVideoId, encodeChannelId }; \ No newline at end of file +module.exports = { wait, getRandomUserAgent, generateSidAuth, getStringBetweenStrings, createFunction, encodeChannelIdWithVideoId, encodeVideoId, encodeChannelId }; \ No newline at end of file