import fetch from "node-fetch" import { NotificationData, processEmail } from "./workflows.js" import qs from 'qs' import { IPlatformNotificationResponse, IVtuberResponse, IStreamResponse } from 'next' import { getImage } from '../vtuber.js' import { fpSlugify } from '../utils.js' import { getProminentColor } from '../image.js' import { uploadFile } from '../s3.js' import { addMinutes, subMinutes } from 'date-fns' export type ChargeResult = { status: string; errorMessage?: string; }; if (!process.env.SCOUT_STRAPI_API_KEY) throw new Error('SCOUT_STRAPI_API_KEY is missing from env'); if (!process.env.STRAPI_URL) throw new Error('STRAPI_URL is missing from env'); if (!process.env.CDN_BUCKET_URL) throw new Error('CDN_BUCKET_URL is missing from env'); if (!process.env.SCOUT_NITTER_URL) throw new Error('SCOUT_NITTER_URL is missing from env'); if (!process.env.SCOUT_NITTER_ACCESS_KEY) throw new Error('SCOUT_NITTER_ACCESS_KEY is missing from env'); /** * find or create vtuber in Strapi */ export async function upsertVtuber({ platform, userId, url, channel }: NotificationData): Promise { let vtuberId console.log('>> # Step 1, upsertVtuber') // # Step 1. // First we find or create the vtuber // The vtuber may already be in the db, so we look for that record. All we need is the Vtuber ID. // If the vtuber is not in the db, we create the vtuber record. // GET /api/:pluralApiId?filters[field][operator]=value const findVtubersFilters = (() => { if (platform === 'chaturbate') { return { chaturbate: { $eq: url } } } else if (platform === 'fansly') { if (!userId) throw new Error('Fansly userId was undefined, but it is required.') return { fanslyId: { $eq: userId } } } })() console.log('>>>>> the following is findVtubersFilters.') console.log(findVtubersFilters) const findVtubersQueryString = qs.stringify({ filters: findVtubersFilters }, { encode: false }) console.log(`>>>>> platform=${platform}, url=${url}, userId=${userId}`) console.log('>> findVtuber') const findVtuberRes = await fetch(`${process.env.STRAPI_URL}/api/vtubers?${findVtubersQueryString}`, { method: 'GET', headers: { 'content-type': 'application/json' } }) const findVtuberJson = await findVtuberRes.json() as IVtuberResponse console.log('>> here is the vtuber json') console.log(findVtuberJson) if (findVtuberJson?.data && findVtuberJson.data.length > 0) { console.log('>> a vtuber was FOUND') if (findVtuberJson.data.length > 1) throw new Error('There was more than one vtuber match. There must only be one.') vtuberId = findVtuberJson.data[0].id console.log('here is the findVtuberJson (as follows)') console.log(findVtuberJson) console.log(`the matching vtuber has ID=${vtuberId} (${findVtuberJson.data[0].attributes.displayName})`) } if (!vtuberId) { console.log('>> vtuberId was not found so we create') /** * We are creating a vtuber record. * We need a few things. * * image URL * * themeColor * * To get an image, we have to do a few things. * * [x] download image from platform * * [x] get themeColor from image * * [x] upload image to b2 * * [x] get B2 cdn link to image * * To get themeColor, we need the image locally where we can then run */ // download image from platform // vtuber.getImage expects a vtuber object, which we don't have yet, so we create a dummy one const dummyVtuber = { attributes: { slug: fpSlugify(channel), fanslyId: (platform === 'fansly') ? userId : null } } const imageFile = await getImage(dummyVtuber) // get themeColor from image const themeColor = await getProminentColor(imageFile) // upload image to b2 const b2FileData = await uploadFile(imageFile) // get b2 cdn link to image const imageCdnLink = `${process.env.CDN_BUCKET_URL}/${b2FileData.Key}` console.log(`>>> createVtuberRes here we go 3-2-1, POST!`) const createVtuberRes = await fetch(`${process.env.STRAPI_URL}/api/vtubers`, { method: 'POST', headers: { 'authorization': `Bearer ${process.env.SCOUT_STRAPI_API_KEY}`, 'content-type': 'application/json' }, body: JSON.stringify({ data: { displayName: channel, fansly: (platform === 'fansly') ? url : null, fanslyId: (platform === 'fansly') ? userId : null, chaturbate: (platform === 'chaturbate') ? url : null, slug: fpSlugify(channel), description1: ' ', image: imageCdnLink, themeColor: themeColor || '#dde1ec' } }) }) const createVtuberJson = await createVtuberRes.json() as IVtuberResponse console.log('>> createVtuberJson as follows') console.log(JSON.stringify(createVtuberJson, null, 2)) if (createVtuberJson.data) { vtuberId = createVtuberJson.data.id console.log(`>>> vtuber created with id=${vtuberId}`) } } if (!vtuberId) throw new Error(`upsertVtuber failed to produce a vtuberId! This should not happen under normal circumstances.`); return vtuberId } export async function upsertPlatformNotification({ source, date, platform, vtuberId }: { source: string, date: string, platform: string, vtuberId: number }): Promise { if (!source) throw new Error(`upsertPlatformNotification requires source arg, but it was undefined`); if (!date) throw new Error(`upsertPlatformNotification requires date arg, but it was undefined`); if (!platform) throw new Error(`upsertPlatformNotification requires platform arg, but it was undefined`); if (!vtuberId) throw new Error(`upsertPlatformNotification requires vtuberId arg, but it was undefined`); let pNotifId // # Step 2. // Next we create the platform-notification record. // This probably doesn't already exist, so we don't check for a pre-existing platform-notification. const pNotifPayload = { data: { source: source, date: date, date2: date, platform: platform, vtuber: vtuberId, } } console.log('pNotifPayload as follows') console.log(pNotifPayload) const pNotifCreateRes = await fetch(`${process.env.STRAPI_URL}/api/platform-notifications`, { method: 'POST', headers: { 'authorization': `Bearer ${process.env.SCOUT_STRAPI_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify(pNotifPayload) }) const pNotifData = await pNotifCreateRes.json() as IPlatformNotificationResponse if (pNotifData.error) { console.error('>> we failed to create platform-notification, there was an error in the response') console.error(JSON.stringify(pNotifData.error, null, 2)) throw new Error(pNotifData.error) } console.log(`>> pNotifData (json response) is as follows`) console.log(pNotifData) if (!pNotifData.data?.id) throw new Error('failed to created pNotifData! The response was missing an id'); pNotifId = pNotifData.data.id if (!pNotifId) throw new Error('failed to get Platform Notification ID'); return pNotifId } export async function upsertStream({ date, vtuberId, platform, pNotifId }: { date: string, vtuberId: number, platform: string, pNotifId: number }): Promise { if (!date) throw new Error(`upsertStream requires date in the arg object, but it was undefined`); if (!vtuberId) throw new Error(`upsertStream requires vtuberId in the arg object, but it was undefined`); if (!platform) throw new Error(`upsertStream requires platform in the arg object, but it was undefined`); if (!pNotifId) throw new Error(`upsertStream requires pNotifId in the arg object, but it was undefined`); let streamId // # Step 3. // Finally we find or create the stream record // The stream may already be in the db (the streamer is multi-platform streaming), so we look for that record. // This gets a bit tricky. How do we determine one stream from another? // For now, the rule is 30 minutes of separation. // Anything <=30m is interpreted as the same stream. Anything >30m is interpreted as a different stream. // If the stream is not in the db, we create the stream record const dateSinceRange = subMinutes(new Date(date), 30) const dateUntilRange = addMinutes(new Date(date), 30) console.log(`Find a stream within + or - 30 mins of the notif date=${new Date(date).toISOString()}. dateSinceRange=${dateSinceRange.toISOString()}, dateUntilRange=${dateUntilRange.toISOString()}`) const findStreamQueryString = qs.stringify({ populate: 'platform-notifications', filters: { date: { $gte: dateSinceRange, $lte: dateUntilRange }, vtuber: { id: { '$eq': vtuberId } } } }, { encode: false }) console.log('>> findStream') const findStreamRes = await fetch(`${process.env.STRAPI_URL}/api/streams?${findStreamQueryString}`, { method: 'GET', headers: { 'authorization': `Bearer ${process.env.SCOUT_STRAPI_API_KEY}`, 'Content-Type': 'application/json' } }) const findStreamData = await findStreamRes.json() as IStreamResponse if (findStreamData?.data && findStreamData.data.length > 0) { console.log('>> we found a findStreamData json. (there is an existing stream for this e-mail/notification)') console.log(JSON.stringify(findStreamData, null, 2)) streamId = findStreamData.data[0].id // Before we're done here, we need to do something extra. We need to populate isChaturbateStream and/or isFanslyStream. // We know which of these booleans to set based on the stream's related platformNotifications // We go through each pNotif and look at it's platform let isFanslyStream = false let isChaturbateStream = false if (findStreamData.data[0].attributes.platformNotifications) { for (const pn of findStreamData.data[0].attributes.platformNotifications) { if (pn.platform === 'fansly') { isFanslyStream = true } else if (pn.platform === 'chaturbate') { isChaturbateStream = true } } } console.log(`>>> updating stream ${streamId}. isFanslyStream=${isFanslyStream}, isChaturbateStream=${isChaturbateStream}`) const updateStreamRes = await fetch(`${process.env.STRAPI_URL}/api/streams/${streamId}`, { method: 'PUT', headers: { 'authorization': `Bearer ${process.env.SCOUT_STRAPI_API_KEY}`, 'content-type': 'application/json' }, body: JSON.stringify({ data: { isFanslyStream: isFanslyStream, isChaturbateStream: isChaturbateStream, platformNotifications: [ pNotifId ] } }) }) const updateStreamJson = await updateStreamRes.json() as IStreamResponse if (updateStreamJson?.error) throw new Error(JSON.stringify(updateStreamJson, null, 2)); console.log(`>> assuming a successful update to the stream record. response as follows.`) console.log(JSON.stringify(updateStreamJson, null, 2)) } if (!streamId) { console.log('>> did not find a streamId, so we go ahead and create a stream record in the db.') const createStreamPayload = { data: { isFanslyStream: (platform === 'fansly') ? true : false, isChaturbateStream: (platform === 'chaturbate') ? true : false, archiveStatus: 'missing', date: date, date2: date, date_str: date, vtuber: vtuberId, platformNotifications: [ pNotifId ] } } console.log('>> createStreamPayload as follows') console.log(createStreamPayload) const createStreamRes = await fetch(`${process.env.STRAPI_URL}/api/streams`, { method: 'POST', headers: { 'authorization': `Bearer ${process.env.SCOUT_STRAPI_API_KEY}`, "Content-Type": "application/json" }, body: JSON.stringify(createStreamPayload) }) const createStreamJson = await createStreamRes.json() as IStreamResponse console.log('>> we got the createStreamJson') console.log(createStreamJson) if (createStreamJson.error) { console.error(JSON.stringify(createStreamJson.error, null, 2)) throw new Error('Failed to create stream in DB due to an error. (see above)') } streamId = createStreamJson.id } if (!streamId) throw new Error('failed to get streamId') return streamId } export async function chargeUser( userId: string, itemId: string, quantity: number, ): Promise { // TODO send request to the payments service that looks up the user's saved // payment info and the cost of the item and attempts to charge their payment // method. console.log(`Charging user ${userId} for ${quantity} of item ${itemId}`); try { const response = await fetch("http://httpbin.org/get?status=success"); const body: any = await response.json(); return { status: body.args.status }; } catch (e: any) { return { status: "failure", errorMessage: e.message }; } } export async function checkAndDecrementInventory( itemId: string, quantity: number, ): Promise { // TODO a database request that—in a single operation or transaction—checks // whether there are `quantity` items remaining, and if so, decreases the // total. Something like: // const result = await db.collection('items').updateOne( // { _id: itemId, numAvailable: { $gte: quantity } }, // { $inc: { numAvailable: -quantity } } // ) // return result.modifiedCount === 1 console.log(`Reserving ${quantity} of item ${itemId}`); return true; } export async function incrementInventory( itemId: string, quantity: number, ): Promise { // TODO increment inventory: // const result = await db.collection('items').updateOne( // { _id: itemId }, // { $inc: { numAvailable: quantity } } // ) // return result.modifiedCount === 1 console.log(`Incrementing ${itemId} inventory by ${quantity}`); return true; }