diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 0957130..2ecc257 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -20,6 +20,6 @@ module.exports = { "rules": { "unused-imports/no-unused-imports": "error", "semi": ["error", "always"], - "quotes": ["error", "double"] + "quotes": ["error", "double", {"avoidEscape": true}] } } diff --git a/src/twitch.ts b/src/twitch.ts index 6f263e4..0448ade 100644 --- a/src/twitch.ts +++ b/src/twitch.ts @@ -127,7 +127,18 @@ export interface Channel { export interface Options { clientId?: string, oauthToken?: string, - userId?: string + userId?: string, + deviceId?: string +} + +interface Integrity { + token: string, + expiration: number, + request_id: string +} + +function parseJwt(token: string): string { + return Buffer.from(token.split(".")[1], "base64").toString(); } /** @@ -138,11 +149,19 @@ export class Client { readonly #clientId: string; readonly #oauthToken?: string; #userId?: string; + readonly #deviceId?: string; + + /** + * https://github.com/mauricew/twitch-graphql-api#integrity + * @private + */ + #integrity?: Integrity; constructor(options?: Options) { this.#clientId = options?.clientId ?? "kimne78kx3ncx6brgo4mv6wki5h1ko"; this.#oauthToken = options?.oauthToken; this.#userId = options?.userId; + this.#deviceId = options?.deviceId; } async autoDetectUserId(): Promise { @@ -195,6 +214,34 @@ export class Client { return response.data; } + async #postIntegrity(): Promise { + logger.debug("post integrity"); + assert(this.#deviceId, "Missing device ID"); + const response = await axios.post("https://gql.twitch.tv/integrity", null, { + headers: { + "Client-Id": this.#clientId, + "Authorization": `OAuth ${this.#oauthToken}`, + "X-Device-Id": this.#deviceId + } + }); + logger.debug("integrity response: " + JSON.stringify(response.data, null, 4)); + const decoded = parseJwt((response.data.token as string).slice(3)); + logger.debug("decoded: " + decoded); + if (decoded.includes('"is_bad_bot":"true"')) { + logger.debug("BAD BOT!"); + } + return response.data; + } + + async #ensureIntegrity() { + logger.debug("EXP: " + this.#integrity?.expiration + " NOW: " + new Date().getTime()); + if (this.#integrity && this.#integrity.expiration > new Date().getTime()) { + logger.debug("integ still valid"); + return; + } + this.#integrity = await this.#postIntegrity(); + } + /** * Send a POST request to the Twitch GQL endpoint. * @param data The data to send to the API. @@ -203,24 +250,22 @@ export class Client { async #post(data: any): Promise { return this.postWrapper(data, { "Content-Type": "text/plain;charset=UTF-8", - "Client-Id": this.#clientId, + "Client-Id": this.#clientId }); } /** * Send a POST request to the Twitch GQL endpoint. The request will include authentication headers. * @param data The data to send to the API. + * @param headers * @private */ - async #postAuthorized(data: any): Promise { + async #postAuthorized(data: any, headers: any = {}): Promise { assert(this.#oauthToken !== undefined, "Missing OAuth token!"); - return this.postWrapper(data, - { - "Content-Type": "text/plain;charset=UTF-8", - "Client-Id": this.#clientId, - "Authorization": `OAuth ${this.#oauthToken}` - } - ); + headers["Content-Type"] = "text/plain;charset=UTF-8"; + headers["Client-Id"] = this.#clientId; + headers["Authorization"] = `OAuth ${this.#oauthToken}`; + return this.postWrapper(data, headers); } async getGameIdFromName(name: string): Promise { @@ -349,6 +394,9 @@ export class Client { } async claimDropReward(dropId: string) { + await this.#ensureIntegrity(); + assert(this.#integrity, "Missing integrity"); + assert(this.#deviceId, "Missing device ID"); return await this.#postAuthorized({ "operationName": "DropsPage_ClaimDropRewards", "variables": { @@ -362,6 +410,9 @@ export class Client { "sha256Hash": "2f884fa187b8fadb2a49db0adc033e636f7b6aaee6e76de1e2bba9a7baf0daf6" } } + }, { + "Client-Integrity": this.#integrity.token, + "X-Device-Id": this.#deviceId }); } diff --git a/src/twitch_drops_bot.ts b/src/twitch_drops_bot.ts index 955213a..7e4ff65 100644 --- a/src/twitch_drops_bot.ts +++ b/src/twitch_drops_bot.ts @@ -350,6 +350,7 @@ export class TwitchDropsBot extends EventEmitter { // Get some data from the cookies let oauthToken: string | undefined = undefined; let channelLogin: string | undefined = undefined; + let deviceId: string | undefined = undefined; for (const cookie of cookies) { switch (cookie["name"]) { case "auth-token": // OAuth token @@ -359,6 +360,10 @@ export class TwitchDropsBot extends EventEmitter { case "persistent": // "channelLogin" Used for "DropCampaignDetails" operation channelLogin = cookie["value"].split("%3A")[0]; break; + + case "unique_id": + deviceId = cookie["value"]; + break; } } @@ -366,9 +371,13 @@ export class TwitchDropsBot extends EventEmitter { throw new Error("Invalid cookies!"); } + if (!deviceId) { + throw new Error("Missing device ID!"); + } + // Seems to be the default hard-coded client ID // Found in sources / static.twitchcdn.net / assets / minimal-cc607a041bc4ae8d6723.js - const client = new Client({oauthToken: oauthToken, userId: channelLogin}); + const client = new Client({oauthToken: oauthToken, userId: channelLogin, deviceId: deviceId}); if (!channelLogin) { await client.autoDetectUserId(); logger.info("auto detected user id"); @@ -488,7 +497,8 @@ export class TwitchDropsBot extends EventEmitter { this.emit("before_drop_campaigns_updated"); }); this.#twitchDropsWatchdog.on("error", (error) => { - logger.debug("Error checking twitch drops: " + error); + logger.error("Error checking twitch drops!"); + logger.debug(error); }); this.#twitchDropsWatchdog.on("update", async (campaigns: DropCampaign[]) => {