297 lines
9.8 KiB
Plaintext
297 lines
9.8 KiB
Plaintext
// 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)
|
|
}
|
|
|
|
};
|
|
|