// src/tasks/consolidate_twitch_channel_rewards.ts import type { Task, Helpers } from "graphile-worker"; import { PrismaClient, User, type Pick } from '../../generated/prisma' import { withAccelerate } from "@prisma/extension-accelerate" import { env } from "../config/env"; import { constants } from "../config/constants"; import { getRateLimiter } from "../utils/rateLimiter"; const prisma = new PrismaClient().$extends(withAccelerate()) const cprPath = env.TWITCH_MOCK ? constants.twitch.dev.paths.channelPointRewards : constants.twitch.prod.paths.channelPointRewards interface Payload { userId: number; } export interface TwitchChannelPointReward { id: string; broadcaster_id: string; broadcaster_login: string; broadcaster_name: string; image: string | null; background_color: string; is_enabled: boolean; cost: number; title: string; prompt: string; is_user_input_required: boolean; max_per_stream_setting: { is_enabled: boolean; max_per_stream: number; }; max_per_user_per_stream_setting: { is_enabled: boolean; max_per_user_per_stream: number; }; global_cooldown_setting: { is_enabled: boolean; global_cooldown_seconds: number; }; is_paused: boolean; is_in_stock: boolean; default_image: { url_1x: string; url_2x: string; url_4x: string; }; should_redemptions_skip_request_queue: boolean; redemptions_redeemed_current_stream: number | null; cooldown_expires_at: string | null; } function getAuthToken(user: User) { const authToken = env.TWITCH_MOCK ? env.TWITCH_MOCK_USER_ACCESS_TOKEN : user.twitchToken?.accessToken return authToken } function assertPayload(payload: any): asserts payload is Payload { if (typeof payload !== "object" || !payload) throw new Error("invalid payload"); if (typeof payload.userId !== "number") throw new Error("invalid payload.userId"); } async function getTwitchChannelPointRewards(user: User) { if (!user) throw new Error(`getTwitchChannelPointRewards called with falsy user`); if (!user.twitchToken) throw new Error(`user.twitchToken is not existing, when it needs to.`); const authToken = getAuthToken(user) const limiter = getRateLimiter() await limiter.consume('twitch', 1) // Create the custom Channel Point Reward on Twitch. // @see https://dev.twitch.tv/docs/api/reference/#create-custom-rewards // POST https://api.twitch.tv/helix/channel_points/custom_rewards const query = new URLSearchParams({ broadcaster_id: user.twitchId }) const res = await fetch(`${env.TWITCH_API_ORIGIN}${cprPath}?${query}`, { headers: { 'Authorization': `Bearer ${authToken}`, 'Client-Id': env.TWITCH_CLIENT_ID } }) if (!res.ok) { console.error(`failed to get a custom channel point rewards for user id=${user.id}`) console.error(res.statusText) throw new Error(res.statusText); } const data = await res.json() return data } async function postTwitchChannelPointRewards(user: User, pick: Pick) { const authToken = getAuthToken(user) const limiter = getRateLimiter() await limiter.consume('twitch', 1) // Create the custom Channel Point Reward on Twitch. // @see https://dev.twitch.tv/docs/api/reference/#create-custom-rewards // POST https://api.twitch.tv/helix/channel_points/custom_rewards const query = new URLSearchParams({ broadcaster_id: user.twitchId }) const res = await fetch(`${env.TWITCH_API_ORIGIN}${cprPath}?${query}`, { method: 'POST', headers: { 'Authorization': `Bearer ${authToken}`, 'Client-Id': env.TWITCH_CLIENT_ID }, body: JSON.stringify({ cost: user.redeemCost, title: pick.waifu.name }) }) if (!res.ok) { console.error(`failed to create a custom channel point reward for userId=${user.id}`) console.error(res.statusText) throw new Error(res.statusText); } // Associate the twitch channel point reward with our Pick const data = await res.json() const twitchChannelPointRewardId = data.data.at(0).id await prisma.pick.update({ where: { id: pick.id }, data: { twitchChannelPointRewardId } }) } // * filter rewards which we previously created const isWaifusChannelPointReward = (reward: TwitchChannelPointReward, picks: Pick[]) => { return picks.some((pick) => pick.twitchChannelPointRewardId === reward.id) } // * filter rewards which should no longer be displayed // * delete // * filter rewards which have the wrong redeemCost // * update so they have the correct redeemCost const isOutOfDateReward = ( reward: TwitchChannelPointReward, picks: Pick[], waifuChoicePoolSize: number ): boolean => { const currentPicks = picks.slice(0, waifuChoicePoolSize); console.log('currentPicks as follows') console.log(currentPicks) return !currentPicks.some(pick => pick.twitchChannelPointRewardId === reward.id); }; const isWrongRedeemCost = (reward: TwitchChannelPointReward, redeemCost: number) => reward.cost !== redeemCost // @see https://dev.twitch.tv/docs/api/reference/#delete-custom-reward // DELETE https://api.twitch.tv/helix/channel_points/custom_rewards async function deleteTwitchChannelPointReward(user: User, reward: TwitchChannelPointReward) { const limiter = getRateLimiter() await limiter.consume('twitch', 1) const authToken = getAuthToken(user) const query = new URLSearchParams({ broadcaster_id: user.twitchId, id: reward.id }) const res = await fetch(`${env.TWITCH_API_ORIGIN}${cprPath}?${query}`, { method: 'DELETE', headers: { 'Authorization': `Bearer ${authToken}`, 'Client-Id': env.TWITCH_CLIENT_ID } }) if (!res.ok) { throw new Error(`Failed to delete twitch channel point reward.id=${reward.id} for user.id=${user.id} (user.twitchId=${user.twitchId}) `); } } // @see https://dev.twitch.tv/docs/api/reference/#update-custom-reward async function updateTwitchChannelPointReward(user: User, reward: TwitchChannelPointReward) { const limiter = getRateLimiter() await limiter.consume('twitch', 1) const authToken = getAuthToken(user) const query = new URLSearchParams({ broadcaster_id: user.twitchId, id: reward.id }) const res = await fetch(`${env.TWITCH_API_ORIGIN}${cprPath}?${query}`, { method: 'PATCH', headers: { 'Authorization': `Bearer ${authToken}`, 'Client-Id': env.TWITCH_CLIENT_ID }, body: JSON.stringify({ cost: user.redeemCost }) }) if (!res.ok) { throw new Error(`Failed to update twitch channel point reward.id=${reward.id} with redeemCost=${user.redeemCost} for user.id=${user.id} (user.twitchId=${user.twitchId}) `); } } /** * * consolidate_twitch_channel_rewards * * This script is meant to run via crontab. * It finds Users with Picks that lack a twitchChannelPointRewardId, then * * * @param payload * @param helpers */ export default async function consolidate_twitch_channel_rewards(payload: any, helpers: Helpers) { assertPayload(payload); const { userId } = payload; // helpers.logger.info(`Hello, ${name}`); const user = await prisma.user.findFirstOrThrow({ where: { id: userId }, include: { twitchToken: true } }) // * get the current number of picks const picks = await prisma.pick.findMany({ where: { userId }, take: constants.twitch.maxChannelPointRewards, orderBy: { createdAt: 'desc' } }) // * get the user's configured redeemCost const redeemCost = user.redeemCost const twitchId = user.twitchId // * get the user's currently configured twitch channel point rewards const twitchChannelPointRewards = await getTwitchChannelPointRewards(user) const tcpr = twitchChannelPointRewards.data.map((cpr) => ({ id: cpr.id, cost: cpr.cost, is_in_stock: cpr.is_in_stock, title: cpr.title })) // * identify the actions we need to do to get the channel point rewards up-to-date const twitchRewards = await getTwitchChannelPointRewards(user); const twitchRewardsData = twitchRewards.data; console.log(`User ${userId} has ${picks.length} picks. And ${twitchRewardsData.length} twitch rewards. waifuChoicePoolSize=${user.waifuChoicePoolSize}, maxOnScreenWaifus=${user.maxOnScreenWaifus}`) const currentPicks = picks.slice(0, user.waifuChoicePoolSize); const outOfDate = twitchRewardsData.filter((reward: TwitchChannelPointReward) => picks.some(p => p.twitchChannelPointRewardId === reward.id) && !currentPicks.some(p => p.twitchChannelPointRewardId === reward.id) ); console.log(`outOfDate as follows`) console.log(outOfDate) const costMismatched = twitchRewardsData .filter((r: TwitchChannelPointReward) => isWrongRedeemCost(r, user.redeemCost)); helpers.logger.info(`There are ${outOfDate.length} out of date Channel Point Rewards. outOfDate=${JSON.stringify(outOfDate.map((ood) => ({ title: ood.title, cost: ood.cost, id: ood.id })))}`) helpers.logger.info(`costMismatched=${JSON.stringify(costMismatched)}`) // * make the REST request(s) to get the twitch channel point rewards up-to-date for (const reward of outOfDate) { console.log(`deleting reward.id=${reward.id} with reward.title=${reward.title}`) await deleteTwitchChannelPointReward(user, reward) } for (const reward of costMismatched) { console.log(`updating reward.id=${reward.id} with reward.title=${reward.title}`) await updateTwitchChannelPointReward(user, reward) } };