diff --git a/src/index.tsx b/src/index.tsx index 8560f09..81c6c81 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -205,11 +205,12 @@ function startProgressBarMode(bot: TwitchDropsBot, config: Config) { if (campaign) { result += `${ansiEscape("36m")}${campaign.game.name ?? campaign.game.displayName}${ansiEscape("39m")} | ${ansiEscape("35m")}${campaign.name}${ansiEscape("39m")}\n`; } else { - result += '\n' + result += ansiEscape("2K") + '\n' } result += `${getDropBenefitNames(drop)} ${BarFormat((drop.self.currentMinutesWatched ?? 0) / drop.requiredMinutesWatched, options)} ${drop.self.currentMinutesWatched ?? 0} / ${drop.requiredMinutesWatched} minutes` + ansiEscape('0K') + '\n'; } else { - result += `- No Drops Active -\n\n`; + result += ansiEscape("2K") + `- No Drops Active -\n`; + result += ansiEscape("2K") +" \n"; } if (isFirstOutput) { diff --git a/src/twitch.ts b/src/twitch.ts index 4147bfb..c5a3626 100644 --- a/src/twitch.ts +++ b/src/twitch.ts @@ -325,7 +325,10 @@ export class Client { return stream !== null && stream !== undefined; } - async getStream(broadcasterId: string): Promise { + async getStream(broadcasterId: string): Promise<{ + id: string, + viewersCount: number + } | null> { const data = await this.#post({ "operationName": "ChannelShell", "variables": { @@ -453,8 +456,8 @@ export function getDropBenefitNames(drop: TimeBasedDrop): string { return result; } -export function getStreamUrl(stream: Stream) { - return "https://www.twitch.tv/" + stream.broadcaster.login; +export function getStreamUrl(username: string) { + return "https://www.twitch.tv/" + username; } /** diff --git a/src/twitch_drops_bot.ts b/src/twitch_drops_bot.ts index b053253..a29887e 100644 --- a/src/twitch_drops_bot.ts +++ b/src/twitch_drops_bot.ts @@ -18,9 +18,9 @@ import CommunityPointsComponent from "./components/community_points.js"; import WebSocketListener from "./web_socket_listener.js"; import {TwitchDropsWatchdog} from "./watchdog.js"; import {StreamPage} from "./pages/stream.js"; -import utils, {TimedSet} from './utils.js'; +import utils, {TimedSet, waitForResponseWithOperationName} from './utils.js'; import logger from "./logger.js"; -import {Client, TimeBasedDrop, DropCampaign, StreamTag, getInventoryDrop, Tag, Inventory, isDropCompleted, getStreamUrl, Stream} from "./twitch.js"; +import {Client, TimeBasedDrop, DropCampaign, StreamTag, getInventoryDrop, Tag, Inventory, isDropCompleted, getStreamUrl} from "./twitch.js"; import {NoStreamsError, NoProgressError, HighPriorityError, StreamLoadFailedError, StreamDownError} from "./errors.js"; type Class = { new(...args: any[]): T }; @@ -701,7 +701,7 @@ export class TwitchDropsBot extends EventEmitter { // Check if the first active stream has Drops available. If it doesn't, then we most likely completed // this campaign already, so we shouldn't start watching the stream. - if (!(await this.#hasDropsAvailable(getStreamUrl(streams[0]), dropCampaignId))) { + if (!(await this.#hasDropsAvailable(getStreamUrl(streams[0].broadcaster.login), dropCampaignId))) { continue; } @@ -749,24 +749,24 @@ export class TwitchDropsBot extends EventEmitter { return null; } - async #getNextPreferredBroadcasterStream(): Promise { + async #getNextPreferredBroadcasterStreamUrl(): Promise { // Check if any of the preferred broadcasters are online for (const broadcasterId of this.#broadcasterIds) { const stream = await this.#twitchClient.getStream(broadcasterId); if (stream) { - const streamUrl = getStreamUrl(stream); + const streamUrl = getStreamUrl(broadcasterId); if (this.#streamUrlTemporaryBlacklist.has(streamUrl)) { continue; } - return stream; + return streamUrl; } } return null; } - async #getNextIdleStream(): Promise { + async #getNextIdleStreamUrl(): Promise { // Check if any of the preferred broadcasters are online - const streamUrl = await this.#getNextPreferredBroadcasterStream(); + const streamUrl = await this.#getNextPreferredBroadcasterStreamUrl(); if (streamUrl !== null) { return streamUrl; } @@ -788,11 +788,11 @@ export class TwitchDropsBot extends EventEmitter { } const streams = await this.#twitchClient.getActiveStreams(campaign.game.displayName); if (streams.length > 0) { - const streamUrl = getStreamUrl(streams[0]); + const streamUrl = getStreamUrl(streams[0].broadcaster.login); if (this.#streamUrlTemporaryBlacklist.has(streamUrl)) { continue; } - return streams[0]; + return streamUrl; } } @@ -839,19 +839,18 @@ export class TwitchDropsBot extends EventEmitter { logger.info("No drop campaigns active, watching a stream instead."); // Choose a stream to watch - let stream: Stream | null = null; + let streamUrl: string | null = null; try { - stream = await this.#getNextIdleStream(); + streamUrl = await this.#getNextIdleStreamUrl(); } catch (error) { logger.error("Error getting next idle stream!"); logger.debug(error); } - if (stream === null) { + if (streamUrl === null) { logger.info("No idle streams available! sleeping for a bit..."); await this.waitForDropCampaignUpdateOrTimeout(this.sleepTimeMilliseconds); continue; } - const streamUrl = getStreamUrl(stream); logger.info("stream: " + streamUrl) const dropProgressComponent = new DropProgressComponent({requireProgress: false, exitOnClaim: false}); @@ -868,15 +867,15 @@ export class TwitchDropsBot extends EventEmitter { this.#pendingHighPriority = true; } else { // Check if a more preferred broadcaster is online - let preferredBroadcasterStream: Stream | null = null; + let preferredBroadcasterStreamUrl: string | null = null; try { - preferredBroadcasterStream = await this.#getNextPreferredBroadcasterStream(); + preferredBroadcasterStreamUrl = await this.#getNextPreferredBroadcasterStreamUrl(); } catch (error) { logger.error("Failed to get next preferred broadcaster stream url"); logger.debug(error); } - if (preferredBroadcasterStream !== null) { - if (getStreamUrl(preferredBroadcasterStream) !== streamUrl) { + if (preferredBroadcasterStreamUrl !== null) { + if (preferredBroadcasterStreamUrl !== streamUrl) { this.#pendingHighPriority = true; } } @@ -888,7 +887,7 @@ export class TwitchDropsBot extends EventEmitter { // Watch stream try { - await this.#watchStreamWrapper(stream, components); + await this.#watchStreamWrapper(streamUrl, components); } catch (error) { await this.#page.goto("about:blank"); } finally { @@ -1064,7 +1063,7 @@ export class TwitchDropsBot extends EventEmitter { continue; } - return stream; + return streamUrl; } // Get a list of active streams that have drops enabled @@ -1073,7 +1072,7 @@ export class TwitchDropsBot extends EventEmitter { // Filter out streams that failed too many times streams = streams.filter(stream => { - return !this.#streamUrlTemporaryBlacklist.has(getStreamUrl(stream)); + return !this.#streamUrlTemporaryBlacklist.has(getStreamUrl(stream.broadcaster.login)); }); logger.info('Found ' + streams.length + ' good streams'); @@ -1081,18 +1080,17 @@ export class TwitchDropsBot extends EventEmitter { return null; } - return streams[0]; + return getStreamUrl(streams[0].broadcaster.login); } - const stream = await getStreamToWatch(); - if (stream === null) { + const streamUrl = await getStreamToWatch(); + logger.debug("want to watch: " + streamUrl); + if (streamUrl === null) { throw new NoStreamsError(); } - const streamUrl = getStreamUrl(stream); - if (!(await this.#hasDropsAvailable(streamUrl, dropCampaignId))) { - logger.warn("This stream has no available Drops. This is likely because you already completed this campaign"); + logger.debug("This stream has no available Drops. This is likely because you already completed this campaign"); return; } @@ -1106,7 +1104,7 @@ export class TwitchDropsBot extends EventEmitter { // Watch first stream logger.info('Watching stream: ' + streamUrl); try { - await this.#watchStreamWrapper(stream, components); + await this.#watchStreamWrapper(streamUrl, components); //todo: update campaign info when we claim a drop } catch (error) { if (error instanceof NoProgressError) { @@ -1185,7 +1183,7 @@ export class TwitchDropsBot extends EventEmitter { } } - async #watchStreamWrapper(stream: Stream, components: Component[]) { + async #watchStreamWrapper(streamUrl: string, components: Component[]) { const getComponent = (c: Class): T | null => { for (const component of components) { @@ -1207,12 +1205,10 @@ export class TwitchDropsBot extends EventEmitter { }); } - const streamUrl = getStreamUrl(stream); - const startWatchTime = new Date().getTime(); try { this.emit("start_watching_stream", streamUrl); - await this.#watchStream(stream, components); + await this.#watchStream(streamUrl, components); } catch (error) { if (error instanceof HighPriorityError) { // Ignore @@ -1253,20 +1249,22 @@ export class TwitchDropsBot extends EventEmitter { } } - async #watchStream(stream: Stream, components: Component[]) { - - const streamUrl = getStreamUrl(stream); + async #watchStream(streamUrl: string, components: Component[]): Promise { // Create a "Chrome Devtools Protocol" session to listen to websocket events const webSocketListener = new WebSocketListener(); + const channelShellOperationResult = waitForResponseWithOperationName(this.#page, "ChannelShell"); + + let channelId: string | null = null; + // Set up web socket listener webSocketListener.on('stream-down', message => { this.#isStreamDown = true; }); webSocketListener.on("points-earned", data => { // Ignore these events if they are from a different stream than the one we are currently watching. This can happen if a user is watching multiple streams on one account. - if (stream.broadcaster.id !== data["channel_id"]) { + if (channelId === null || channelId !== data["channel_id"]) { return; } this.emit("community_points_earned", data); @@ -1295,6 +1293,8 @@ export class TwitchDropsBot extends EventEmitter { // Go to the stream URL await this.#page.goto(streamUrl); + channelId = (await channelShellOperationResult.data())["userOrError"]["channel"]["id"]; + // Wait for the page to load completely (hopefully). This checks the video player container for any DOM changes and waits until there haven't been any changes for a few seconds. logger.info('Waiting for page to load...'); const element = (await this.#page.$x('//div[@data-a-player-state]'))[0] diff --git a/src/utils.ts b/src/utils.ts index b67f57f..f868fe7 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,7 +1,7 @@ import fs from "node:fs" import path from "node:path"; -import {Page} from "puppeteer"; +import {HTTPResponse, Page} from "puppeteer"; import {parse} from "csv-parse/sync"; import {stringify} from "csv-stringify/sync"; import axios from "axios"; @@ -156,6 +156,42 @@ export function updateGames(campaigns: DropCampaign[], sourcePath: string = "./g logger.info('Games list updated'); } +/** + * Create a promise to wait for a response with the given operation name. After calling Page.goto(), await the returned data() function to get the result. + * @param page + * @param operationName + */ +export function waitForResponseWithOperationName(page: Page, operationName: string) { + const result: { + operationIndex: number, + promise: Promise | null, + data: any + } = { + operationIndex: -1, + promise: null, + data: async function () { + const response = await this.promise; + return (await response?.json())[this.operationIndex]["data"]; + } + } + result.promise = page.waitForResponse((response: HTTPResponse) => { + if (response.url().startsWith("https://gql.twitch.tv/gql")) { + const postData = response.request().postData(); + if (postData) { + const data = JSON.parse(postData); + for (let i = 0; i < data.length; ++i) { + if ( data[i]["operationName"] === operationName) { + result.operationIndex = i; + return true; + } + } + } + } + return false; + }); + return result; +} + export default { saveScreenshotAndHtml };