From 94a2973b907d859f28095eecd55b4dca2bd0e5a6 Mon Sep 17 00:00:00 2001 From: Nick Kosarev Date: Thu, 22 Aug 2024 14:12:30 +0200 Subject: [PATCH] chore: all old server endpoints (#210) * chore: all old server endpoints * chore: removed process env * chore: jwt added --- .env.example | 12 +- apps/website/nuxt.config.ts | 8 + apps/website/src/server/api/auth/me.get.ts | 25 +- .../website/src/server/api/auth/twitch.get.ts | 20 +- .../api/character/[id]/activate/index.post.ts | 48 ++ .../server/api/character/[id]/index.get.ts | 24 + .../src/server/api/character/[id]/post.get.ts | 24 + .../api/character/[id]/post/index.post.ts | 53 ++ .../api/character/[id]/top/index.get.ts | 26 + .../api/character/[id]/unlock/index.post.ts | 97 ++++ .../src/server/api/character/index.get.ts | 22 + .../src/server/api/coupon/index.post.ts | 51 ++ .../src/server/api/coupon/latest.get.ts | 25 + apps/website/src/server/api/game/index.get.ts | 3 + .../src/server/api/game/inventory/[id].get.ts | 25 + .../src/server/api/game/player/[id].get.ts | 18 + .../src/server/api/payment/index.post.ts | 95 +++ .../src/server/api/payment/webhook.post.ts | 143 +++++ .../src/server/api/post/[id]/like.post.ts | 71 +++ .../api/profile/[id]/shoroom/index.post.ts | 124 ++++ .../api/profile/[id]/streamer/index.post.ts | 20 + .../api/profile/[id]/twitchToken/index.get.ts | 18 + .../api/profile/[id]/woodland/index.get.ts | 23 + .../src/server/api/profile/index.get.ts | 9 + .../src/server/api/profile/index.post.ts | 27 + .../api/profile/twitchId/[twitchId].get.ts | 18 + .../api/profile/userName/[userName].get.ts | 27 + apps/website/src/server/api/quest/[id].get.ts | 32 ++ .../website/src/server/api/quest/index.get.ts | 13 + .../api/quest/profileId/[profileId].get.ts | 26 + .../src/server/api/transaction/index.get.ts | 19 + .../website/src/server/api/trophy/[id].get.ts | 30 + .../src/server/api/trophy/index.get.ts | 7 + .../src/server/api/trophy/index.post.ts | 54 ++ .../api/trophy/profileId/[profileId].get.ts | 24 + .../src/server/api/twitch/addon/index.post.ts | 51 ++ .../server/api/twitch/ai-view/index.post.ts | 51 ++ .../src/server/api/twitch/code/index.post.ts | 82 +++ .../src/server/api/twitch/status.get.ts | 14 + .../src/server/api/twitch/token/[id].post.ts | 26 + .../src/server/api/twitch/token/online.get.ts | 17 + .../src/server/api/woodland/[id]/index.get.ts | 21 + .../src/server/api/woodland/index.post.ts | 61 ++ .../api/woodland/player/[id]/wood.post.ts | 41 ++ apps/website/src/server/middleware/auth.ts | 19 + apps/website/src/server/plugins/start.ts | 23 +- apps/website/src/server/utils/date.ts | 4 + apps/website/src/server/utils/quest.ts | 60 ++ apps/website/src/server/utils/repository.ts | 540 ++++++++++++++++++ .../utils/twitch/twitch.addon.controller.ts | 225 ++++++++ .../utils/twitch/twitch.ai.controller.ts | 127 ++++ .../server/utils/twitch/twitch.controller.ts | 121 ++++ .../server/utils/twitch/twitch.provider.ts | 123 ++++ .../src/server/utils/twitch/twitch.service.ts | 154 +++++ .../twitch/twitch.woodland.controller.ts | 170 ++++++ libs/types/src/lib/types.ts | 25 + package.json | 4 + yarn.lock | 121 +++- 58 files changed, 3316 insertions(+), 25 deletions(-) create mode 100644 apps/website/src/server/api/character/[id]/activate/index.post.ts create mode 100644 apps/website/src/server/api/character/[id]/index.get.ts create mode 100644 apps/website/src/server/api/character/[id]/post.get.ts create mode 100644 apps/website/src/server/api/character/[id]/post/index.post.ts create mode 100644 apps/website/src/server/api/character/[id]/top/index.get.ts create mode 100644 apps/website/src/server/api/character/[id]/unlock/index.post.ts create mode 100644 apps/website/src/server/api/character/index.get.ts create mode 100644 apps/website/src/server/api/coupon/index.post.ts create mode 100644 apps/website/src/server/api/coupon/latest.get.ts create mode 100644 apps/website/src/server/api/game/index.get.ts create mode 100644 apps/website/src/server/api/game/inventory/[id].get.ts create mode 100644 apps/website/src/server/api/game/player/[id].get.ts create mode 100644 apps/website/src/server/api/payment/index.post.ts create mode 100644 apps/website/src/server/api/payment/webhook.post.ts create mode 100644 apps/website/src/server/api/post/[id]/like.post.ts create mode 100644 apps/website/src/server/api/profile/[id]/shoroom/index.post.ts create mode 100644 apps/website/src/server/api/profile/[id]/streamer/index.post.ts create mode 100644 apps/website/src/server/api/profile/[id]/twitchToken/index.get.ts create mode 100644 apps/website/src/server/api/profile/[id]/woodland/index.get.ts create mode 100644 apps/website/src/server/api/profile/index.get.ts create mode 100644 apps/website/src/server/api/profile/index.post.ts create mode 100644 apps/website/src/server/api/profile/twitchId/[twitchId].get.ts create mode 100644 apps/website/src/server/api/profile/userName/[userName].get.ts create mode 100644 apps/website/src/server/api/quest/[id].get.ts create mode 100644 apps/website/src/server/api/quest/index.get.ts create mode 100644 apps/website/src/server/api/quest/profileId/[profileId].get.ts create mode 100644 apps/website/src/server/api/transaction/index.get.ts create mode 100644 apps/website/src/server/api/trophy/[id].get.ts create mode 100644 apps/website/src/server/api/trophy/index.get.ts create mode 100644 apps/website/src/server/api/trophy/index.post.ts create mode 100644 apps/website/src/server/api/trophy/profileId/[profileId].get.ts create mode 100644 apps/website/src/server/api/twitch/addon/index.post.ts create mode 100644 apps/website/src/server/api/twitch/ai-view/index.post.ts create mode 100644 apps/website/src/server/api/twitch/code/index.post.ts create mode 100644 apps/website/src/server/api/twitch/status.get.ts create mode 100644 apps/website/src/server/api/twitch/token/[id].post.ts create mode 100644 apps/website/src/server/api/twitch/token/online.get.ts create mode 100644 apps/website/src/server/api/woodland/[id]/index.get.ts create mode 100644 apps/website/src/server/api/woodland/index.post.ts create mode 100644 apps/website/src/server/api/woodland/player/[id]/wood.post.ts create mode 100644 apps/website/src/server/middleware/auth.ts create mode 100644 apps/website/src/server/utils/date.ts create mode 100644 apps/website/src/server/utils/quest.ts create mode 100644 apps/website/src/server/utils/repository.ts create mode 100644 apps/website/src/server/utils/twitch/twitch.addon.controller.ts create mode 100644 apps/website/src/server/utils/twitch/twitch.ai.controller.ts create mode 100644 apps/website/src/server/utils/twitch/twitch.controller.ts create mode 100644 apps/website/src/server/utils/twitch/twitch.provider.ts create mode 100644 apps/website/src/server/utils/twitch/twitch.service.ts create mode 100644 apps/website/src/server/utils/twitch/twitch.woodland.controller.ts diff --git a/.env.example b/.env.example index afffede4..5405a4ab 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,11 @@ -# Go to your Twitch developer console and create a new application +# Go to Twitch developer console NUXT_PUBLIC_TWITCH_CLIENT_ID="" +NUXT_PRIVATE_TWITCH_SECRET_ID="" + +# Twitch streamer data +NUXT_PRIVATE_TWITCH_CHANNEL_NAME="" +NUXT_PRIVATE_TWITCH_CHANNEL_ID="" +NUXT_PRIVATE_TWITCH_OAUTH_CODE="" # Will redirect to from Twitch NUXT_PUBLIC_SIGN_IN_REDIRECT_URL="" @@ -8,8 +14,8 @@ NUXT_PUBLIC_SIGN_IN_REDIRECT_URL="" NUXT_PUBLIC_COOKIE_KEY="" # Our secret... -PRIVATE_JWT_SECRET_KEY="" -PRIVATE_WEBSITE_BEARER="" +NUXT_PRIVATE_JWT_SECRET_KEY="" +NUXT_PRIVATE_WEBSITE_BEARER="" # WebSocket server with event messages PUBLIC_WEBSOCKET_URL="" diff --git a/apps/website/nuxt.config.ts b/apps/website/nuxt.config.ts index 3e535b66..5754b97f 100644 --- a/apps/website/nuxt.config.ts +++ b/apps/website/nuxt.config.ts @@ -24,6 +24,14 @@ export default defineNuxtConfig({ plugins: [nxViteTsPaths()], }, runtimeConfig: { + websiteBearer: '', // NUXT_PRIVATE_WEBSITE_BEARER + jwtSecretKey: '', // NUXT_PRIVATE_JWT_SECRET_KEY + twitchSecretId: '', // NUXT_PRIVATE_TWITCH_SECRET_ID + twitchChannelName: '', // NUXT_PRIVATE_TWITCH_CHANNEL_NAME + twitchChannelId: '', // NUXT_PRIVATE_TWITCH_CHANNEL_ID + twitchOauthCode: '', // NUXT_PRIVATE_TWITCH_OAUTH_CODE + yookassaShopId: '', // NUXT_PRIVATE_YOOKASSA_SHOP_ID + yookassaApiKey: '', // NUXT_PRIVATE_YOOKASSA_API_KEY public: { twitchClientId: '', // NUXT_PUBLIC_TWITCH_CLIENT_ID signInRedirectUrl: '', // NUXT_PUBLIC_SIGN_IN_REDIRECT_URL diff --git a/apps/website/src/server/api/auth/me.get.ts b/apps/website/src/server/api/auth/me.get.ts index 9bc713d6..e02233c6 100644 --- a/apps/website/src/server/api/auth/me.get.ts +++ b/apps/website/src/server/api/auth/me.get.ts @@ -1,17 +1,28 @@ +import jwt, { verify } from 'jsonwebtoken' +import type { WebsiteProfile } from '@chat-game/types' + export default defineEventHandler((event) => { - const { public: publicEnv } = useRuntimeConfig() + const { public: publicEnv, jwtSecretKey } = useRuntimeConfig() - const jwt = getCookie(event, publicEnv.cookieKey) - if (!jwt) { + const token = getCookie(event, publicEnv.cookieKey) + if (!token) { throw createError({ statusCode: 401, statusMessage: 'Unauthorized', }) } - return { - id: '1234', - twitchId: '12345', - userName: 'testuser', + try { + const { profile } = verify(token, jwtSecretKey) as { profile: WebsiteProfile } + return profile + } catch (error) { + if (error instanceof jwt.TokenExpiredError) { + deleteCookie(event, publicEnv.cookieKey, { path: '/' }) + } } + + throw createError({ + statusCode: 401, + statusMessage: 'Unauthorized', + }) }) diff --git a/apps/website/src/server/api/auth/twitch.get.ts b/apps/website/src/server/api/auth/twitch.get.ts index 1069f655..7dd15747 100644 --- a/apps/website/src/server/api/auth/twitch.get.ts +++ b/apps/website/src/server/api/auth/twitch.get.ts @@ -1,5 +1,8 @@ +import { sign } from 'jsonwebtoken' +import type { WebsiteProfile } from '@chat-game/types' + export default defineEventHandler((event) => { - const { public: publicEnv } = useRuntimeConfig() + const { public: publicEnv, jwtSecretKey } = useRuntimeConfig() const query = getQuery(event) if (!query.code) { @@ -11,12 +14,21 @@ export default defineEventHandler((event) => { const code = query.code.toString() - log(JSON.stringify(query)) + log(JSON.stringify(query), code) + + const profile: WebsiteProfile = { + id: '123', + twitchToken: '2134', + twitchId: '1245', + userName: 'tester', + } + + const token = sign({ profile }, jwtSecretKey, { expiresIn: '48h' }) - setCookie(event, publicEnv.cookieKey, code, { + setCookie(event, publicEnv.cookieKey, token, { path: '/', httpOnly: true, }) - sendRedirect(event, '/') + return sendRedirect(event, '/') }) diff --git a/apps/website/src/server/api/character/[id]/activate/index.post.ts b/apps/website/src/server/api/character/[id]/activate/index.post.ts new file mode 100644 index 00000000..9c3a628b --- /dev/null +++ b/apps/website/src/server/api/character/[id]/activate/index.post.ts @@ -0,0 +1,48 @@ +import type { EventHandlerRequest } from 'h3' +import { db } from '@chat-game/prisma-client' + +export default defineEventHandler>(async (event) => { + const id = getRouterParam(event, 'id') + + const body = await readBody(event) + + if (!body.profileId) { + throw createError({ + statusCode: 400, + message: 'You must provide profileId', + }) + } + + const profile = await db.profile.findUnique({ + where: { id: body.profileId }, + include: { + characterEditions: true, + }, + }) + if (!profile) { + throw createError({ + status: 404, + }) + } + + const edition = profile.characterEditions.find((e) => e.characterId === id) + + // Don't have this char + if (!edition) { + throw createError({ + status: 400, + message: 'You do not have this character', + }) + } + + await db.profile.update({ + where: { id: profile.id }, + data: { + activeEditionId: edition.id, + }, + }) + + return { + ok: true, + } +}) diff --git a/apps/website/src/server/api/character/[id]/index.get.ts b/apps/website/src/server/api/character/[id]/index.get.ts new file mode 100644 index 00000000..3ca2115e --- /dev/null +++ b/apps/website/src/server/api/character/[id]/index.get.ts @@ -0,0 +1,24 @@ +import type { EventHandlerRequest } from 'h3' +import type { CharacterWithProfile } from '@chat-game/types' +import { db } from '@chat-game/prisma-client' + +export default defineEventHandler>( + async (event) => { + const id = getRouterParam(event, 'id') + + const character = await db.character.findFirst({ + where: { id }, + include: { + profile: true, + editions: true, + }, + }) + if (!character) { + throw createError({ + status: 404, + }) + } + + return character as CharacterWithProfile + } +) diff --git a/apps/website/src/server/api/character/[id]/post.get.ts b/apps/website/src/server/api/character/[id]/post.get.ts new file mode 100644 index 00000000..1cee08c7 --- /dev/null +++ b/apps/website/src/server/api/character/[id]/post.get.ts @@ -0,0 +1,24 @@ +import type { EventHandlerRequest } from 'h3' +import type { Post } from '@chat-game/types' +import { db } from '@chat-game/prisma-client' + +export default defineEventHandler>(async (event) => { + const characterId = getRouterParam(event, 'id') + + const posts = await db.post.findMany({ + where: { characterId }, + orderBy: { createdAt: 'desc' }, + take: 100, + include: { + profile: true, + likes: true, + }, + }) + if (!posts) { + throw createError({ + status: 404, + }) + } + + return posts as Post[] +}) diff --git a/apps/website/src/server/api/character/[id]/post/index.post.ts b/apps/website/src/server/api/character/[id]/post/index.post.ts new file mode 100644 index 00000000..dbafa4b4 --- /dev/null +++ b/apps/website/src/server/api/character/[id]/post/index.post.ts @@ -0,0 +1,53 @@ +import type { EventHandlerRequest } from 'h3' +import { createId } from '@paralleldrive/cuid2' +import { db } from '@chat-game/prisma-client' + +export default defineEventHandler>(async (event) => { + const characterId = getRouterParam(event, 'id') + + const body = await readBody(event) + + if (!characterId || !body.profileId || !body.text) { + throw createError({ + statusCode: 400, + message: 'You must provide profileId and text', + }) + } + + // Check if not have a coin + const profile = await db.profile.findFirst({ + where: { id: body.profileId, mana: { gte: 5 } }, + }) + if (!profile?.id) { + throw createError({ + status: 400, + message: 'You do not have mana', + }) + } + + // Take payment + await db.profile.update({ + where: { id: profile.id }, + data: { + mana: { decrement: 5 }, + storytellerPoints: { increment: 5 }, + }, + }) + + // sanitize text, max 1500 chars + const text = body.text.trim().substring(0, 1500) + + await db.post.create({ + data: { + id: createId(), + text, + characterId, + profileId: body.profileId, + type: 'NOTE', + }, + }) + + return { + ok: true, + } +}) diff --git a/apps/website/src/server/api/character/[id]/top/index.get.ts b/apps/website/src/server/api/character/[id]/top/index.get.ts new file mode 100644 index 00000000..9cecf605 --- /dev/null +++ b/apps/website/src/server/api/character/[id]/top/index.get.ts @@ -0,0 +1,26 @@ +import type { EventHandlerRequest } from 'h3' +import type { CharacterEditionWithProfile } from '@chat-game/types' +import { db } from '@chat-game/prisma-client' + +export default defineEventHandler>( + async (event) => { + const characterId = getRouterParam(event, 'id') + + const editions = await db.characterEdition.findMany({ + where: { characterId }, + orderBy: { xp: 'desc' }, + take: 10, + include: { + profile: true, + character: true, + }, + }) + if (!editions) { + throw createError({ + status: 404, + }) + } + + return editions as CharacterEditionWithProfile[] + } +) diff --git a/apps/website/src/server/api/character/[id]/unlock/index.post.ts b/apps/website/src/server/api/character/[id]/unlock/index.post.ts new file mode 100644 index 00000000..a2f70e58 --- /dev/null +++ b/apps/website/src/server/api/character/[id]/unlock/index.post.ts @@ -0,0 +1,97 @@ +import type { EventHandlerRequest } from 'h3' +import { createId } from '@paralleldrive/cuid2' +import { db } from '@chat-game/prisma-client' + +export default defineEventHandler>(async (event) => { + const id = getRouterParam(event, 'id') + + const body = await readBody(event) + + if (!body.profileId) { + throw createError({ + statusCode: 400, + message: 'You must provide profileId', + }) + } + + const profile = await db.profile.findUnique({ + where: { id: body.profileId }, + include: { + characterEditions: true, + }, + }) + if (!profile) { + throw createError({ + status: 404, + }) + } + + const character = await db.character.findUnique({ + where: { id, isReady: true, unlockedBy: 'COINS' }, + }) + if (!character) { + throw createError({ + status: 404, + }) + } + + // If already have this char + if (profile.characterEditions.some((edition) => edition.characterId === id)) { + throw createError({ + status: 400, + message: 'You already have this character', + }) + } + + // Don't have enough coins + if (profile.coins < character.price) { + throw createError({ + status: 400, + message: 'You dont have enough coins', + }) + } + + const edition = await db.characterEdition.create({ + data: { + id: createId(), + profileId: profile.id, + characterId: character.id, + }, + }) + + await db.transaction.create({ + data: { + id: createId(), + profileId: profile.id, + entityId: edition.id, + amount: character.price, + type: 'CHARACTER_UNLOCK', + }, + }) + + await db.transaction.create({ + data: { + id: createId(), + profileId: profile.id, + entityId: edition.id, + amount: character.price, + type: 'POINTS_FROM_CHARACTER_UNLOCK', + }, + }) + + await db.profile.update({ + where: { id: profile.id }, + data: { + coins: { + decrement: character.price, + }, + collectorPoints: { + increment: character.price, + }, + }, + }) + + return { + ok: true, + } +}) diff --git a/apps/website/src/server/api/character/index.get.ts b/apps/website/src/server/api/character/index.get.ts new file mode 100644 index 00000000..86911f8d --- /dev/null +++ b/apps/website/src/server/api/character/index.get.ts @@ -0,0 +1,22 @@ +import type { EventHandlerRequest } from 'h3' +import type { CharacterWithEditions } from '@chat-game/types' +import { db } from '@chat-game/prisma-client' + +export default defineEventHandler>( + async () => { + const characters = await db.character.findMany({ + orderBy: { + price: 'asc', + }, + include: { + editions: true, + }, + }) + + if (!characters.length) { + return [] + } + + return characters as CharacterWithEditions[] + } +) diff --git a/apps/website/src/server/api/coupon/index.post.ts b/apps/website/src/server/api/coupon/index.post.ts new file mode 100644 index 00000000..0872c92c --- /dev/null +++ b/apps/website/src/server/api/coupon/index.post.ts @@ -0,0 +1,51 @@ +import type { EventHandlerRequest } from 'h3' +import { createId } from '@paralleldrive/cuid2' +import { db } from '@chat-game/prisma-client' + +export default defineEventHandler>(async (event) => { + const body = await readBody(event) + + if (!body.profileId || !body.type) { + throw createError({ + statusCode: 400, + message: 'You must provide profileId and type', + }) + } + + const profile = await db.profile.findFirst({ + where: { id: body.profileId }, + }) + if (!profile) { + throw createError({ + status: 404, + }) + } + + if (body.type === 'COINS' && profile.coupons > 0) { + await db.profile.update({ + where: { id: profile.id }, + data: { + coins: { + increment: 2, + }, + coupons: { + decrement: 1, + }, + }, + }) + + await db.transaction.create({ + data: { + id: createId(), + profileId: profile.id, + entityId: profile.id, + amount: 2, + type: 'COINS_FROM_COUPON', + }, + }) + } + + return { + ok: true, + } +}) diff --git a/apps/website/src/server/api/coupon/latest.get.ts b/apps/website/src/server/api/coupon/latest.get.ts new file mode 100644 index 00000000..2e5b8170 --- /dev/null +++ b/apps/website/src/server/api/coupon/latest.get.ts @@ -0,0 +1,25 @@ +import type { EventHandlerRequest } from 'h3' +import { db } from '@chat-game/prisma-client' +import type { Coupon, Profile } from '@chat-game/types' + +export type CouponWithProfile = Coupon & { profile: Profile | null } + +export default defineEventHandler>(async () => { + const latestCoupons = (await db.coupon.findMany({ + where: { status: 'TAKEN' }, + orderBy: { createdAt: 'desc' }, + take: 20, + })) as CouponWithProfile[] + + for (const coupon of latestCoupons) { + if (!coupon.profileId) { + continue + } + + coupon.profile = await db.profile.findFirst({ + where: { id: coupon.profileId }, + }) + } + + return latestCoupons +}) diff --git a/apps/website/src/server/api/game/index.get.ts b/apps/website/src/server/api/game/index.get.ts new file mode 100644 index 00000000..f344a067 --- /dev/null +++ b/apps/website/src/server/api/game/index.get.ts @@ -0,0 +1,3 @@ +export default defineEventHandler(() => { + return { status: 'RUNNING' } +}) diff --git a/apps/website/src/server/api/game/inventory/[id].get.ts b/apps/website/src/server/api/game/inventory/[id].get.ts new file mode 100644 index 00000000..f2cf3dc0 --- /dev/null +++ b/apps/website/src/server/api/game/inventory/[id].get.ts @@ -0,0 +1,25 @@ +import type { EventHandlerRequest } from 'h3' +import type { Inventory, InventoryItem } from '@chat-game/types' +import { db } from '@chat-game/prisma-client' + +export default defineEventHandler>(async (event) => { + const id = getRouterParam(event, 'id') + + const inventory = await db.inventory.findUnique({ + where: { id }, + }) + if (!inventory) { + throw createError({ + status: 404, + }) + } + + const items = (await db.inventoryItem.findMany({ + where: { inventoryId: inventory.id }, + })) as InventoryItem[] + + return { + ...inventory, + items, + } +}) diff --git a/apps/website/src/server/api/game/player/[id].get.ts b/apps/website/src/server/api/game/player/[id].get.ts new file mode 100644 index 00000000..2c12f17c --- /dev/null +++ b/apps/website/src/server/api/game/player/[id].get.ts @@ -0,0 +1,18 @@ +import type { EventHandlerRequest } from 'h3' +import type { Player } from '@chat-game/types' +import { db } from '@chat-game/prisma-client' + +export default defineEventHandler>(async (event) => { + const id = getRouterParam(event, 'id') + + const player = await db.player.findUnique({ + where: { id }, + }) + if (!player) { + throw createError({ + status: 404, + }) + } + + return player +}) diff --git a/apps/website/src/server/api/payment/index.post.ts b/apps/website/src/server/api/payment/index.post.ts new file mode 100644 index 00000000..c02b58e9 --- /dev/null +++ b/apps/website/src/server/api/payment/index.post.ts @@ -0,0 +1,95 @@ +import type { EventHandlerRequest } from 'h3' +import { createId } from '@paralleldrive/cuid2' +import type { Payment, PaymentCreateResponse } from '@chat-game/types' +import { db } from '@chat-game/prisma-client' + +export default defineEventHandler>( + async (event) => { + const body = await readBody(event) + + if (!body.profileId || !body.productId) { + throw createError({ + statusCode: 400, + message: 'No data', + }) + } + + const profile = await db.profile.findFirst({ + where: { id: body.profileId }, + }) + if (!profile) { + throw createError({ + status: 404, + }) + } + + const product = await db.product.findFirst({ + where: { id: body.productId }, + }) + if (!product) { + throw createError({ + status: 404, + }) + } + + // Create payment on Provider + const paymentBody = { + amount: { + value: product.price, + currency: 'RUB', + }, + capture: true, + description: `Приобретение "${product.title}" для профиля ID ${profile.id}`, + metadata: { + profileId: profile.id, + productId: product.id, + }, + confirmation: { + type: 'redirect', + return_url: 'https://chatgame.space/ru/shop', + }, + } + + const { yookassaShopId, yookassaApiKey } = useRuntimeConfig() + const credentials = btoa(`${yookassaShopId}:${yookassaApiKey}`) + const res = await fetch(`https://api.yookassa.ru/v3/payments`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Idempotence-Key': createId(), + Authorization: `Basic ${credentials}`, + }, + body: JSON.stringify(paymentBody), + }) + + const paymentOnProvider = await res.json() + if (!paymentOnProvider.id) { + throw createError({ + status: 400, + }) + } + + const redirectUrl = paymentOnProvider.confirmation.confirmation_url + + // Create payment + const payment = await db.payment.create({ + data: { + id: createId(), + externalId: paymentOnProvider.id, + provider: 'YOOKASSA', + profileId: profile.id, + productId: product.id, + status: 'PENDING', + amount: product.price, + }, + }) + + return { + ok: true, + result: { + payment: payment as Payment, + redirectUrl, + }, + } + } +) diff --git a/apps/website/src/server/api/payment/webhook.post.ts b/apps/website/src/server/api/payment/webhook.post.ts new file mode 100644 index 00000000..112d8ebc --- /dev/null +++ b/apps/website/src/server/api/payment/webhook.post.ts @@ -0,0 +1,143 @@ +import type { EventHandlerRequest } from 'h3' +import { createId } from '@paralleldrive/cuid2' +import { db } from '@chat-game/prisma-client' + +export default defineEventHandler>(async (event) => { + const body = await readBody(event) + + if (!body?.event) { + throw createError({ + statusCode: 400, + message: 'No data', + }) + } + + if (body.event === 'payment.succeeded') { + log('[Payment] payment.succeeded', JSON.stringify(body)) + + const payment = await db.payment.findFirst({ + where: { + externalId: body.object.id, + }, + }) + if (!payment) { + throw createError({ + statusCode: 400, + message: 'No payment found', + }) + } + + if (payment.status !== 'PAID') { + if (payment.productId === 'jehj4mxo0g6fp1eopf3jg641') { + await activateProduct1(payment.profileId) + } + if (payment.productId === 'w0895g3t9q75ys2maod0zd1a') { + await activateProduct2(payment.profileId) + } + if (payment.productId === 'nar1acws8c3s4w3cxs6i8qdn') { + await activateProduct3(payment.profileId) + } + if (payment.productId === 'tp5w874gchf6hjfca9vory2r') { + await activateProduct4(payment.profileId) + } + if (payment.productId === 'izh5v4vxztqi55gquts9ukn2') { + await activateProduct5(payment.profileId) + } + + // patron points + await db.profile.update({ + where: { id: payment.profileId }, + data: { + patronPoints: { + increment: payment.amount, + }, + }, + }) + + await db.payment.update({ + where: { id: payment.id }, + data: { + status: 'PAID', + }, + }) + } + } + + return { + ok: true, + } +}) + +function activateProduct1(profileId: string) { + // 10 coins + return db.profile.update({ + where: { id: profileId }, + data: { + coins: { + increment: 10, + }, + }, + }) +} + +function activateProduct2(profileId: string) { + // 50+10 coins + return db.profile.update({ + where: { id: profileId }, + data: { + coins: { + increment: 60, + }, + }, + }) +} + +function activateProduct3(profileId: string) { + // 150+30 coins + return db.profile.update({ + where: { id: profileId }, + data: { + coins: { + increment: 180, + }, + }, + }) +} + +function activateProduct4(profileId: string) { + // 250+80 coins + return db.profile.update({ + where: { id: profileId }, + data: { + coins: { + increment: 330, + }, + }, + }) +} + +async function activateProduct5(profileId: string) { + // 500+150 coins + await db.profile.update({ + where: { id: profileId }, + data: { + coins: { + increment: 650, + }, + }, + }) + + // check if already have char + const char = await db.characterEdition.findFirst({ + where: { profileId, characterId: 'w22vo3qzgfmvgt85ncfg398i' }, + }) + if (!char) { + await db.characterEdition.create({ + data: { + id: createId(), + profileId, + characterId: 'w22vo3qzgfmvgt85ncfg398i', + }, + }) + } +} diff --git a/apps/website/src/server/api/post/[id]/like.post.ts b/apps/website/src/server/api/post/[id]/like.post.ts new file mode 100644 index 00000000..0645229a --- /dev/null +++ b/apps/website/src/server/api/post/[id]/like.post.ts @@ -0,0 +1,71 @@ +import type { EventHandlerRequest } from 'h3' +import { createId } from '@paralleldrive/cuid2' +import { db } from '@chat-game/prisma-client' + +export default defineEventHandler>(async (event) => { + const postId = getRouterParam(event, 'id') + const body = await readBody(event) + + if (!body.profileId) { + throw createError({ + statusCode: 400, + message: 'You must provide profileId', + }) + } + + const post = await db.post.findUnique({ + where: { id: postId }, + }) + if (!post) { + throw createError({ + status: 404, + }) + } + + const profile = await db.profile.findUnique({ + where: { id: body.profileId }, + }) + if (!profile) { + throw createError({ + status: 404, + }) + } + + // Check if already have like + const like = await db.like.findFirst({ + where: { profileId: body.profileId, postId: post.id }, + }) + if (like?.id) { + throw createError({ + status: 400, + message: 'Already liked', + }) + } + + await db.like.create({ + data: { + id: createId(), + profileId: profile.id, + postId: post.id, + }, + }) + + await db.post.update({ + where: { id: post.id }, + data: { + rating: { increment: 1 }, + }, + }) + + // +1 point storyteller to post author + await db.profile.update({ + where: { id: post.profileId }, + data: { + storytellerPoints: { increment: 1 }, + }, + }) + + return { + ok: true, + } +}) diff --git a/apps/website/src/server/api/profile/[id]/shoroom/index.post.ts b/apps/website/src/server/api/profile/[id]/shoroom/index.post.ts new file mode 100644 index 00000000..acd94afe --- /dev/null +++ b/apps/website/src/server/api/profile/[id]/shoroom/index.post.ts @@ -0,0 +1,124 @@ +import type { EventHandlerRequest } from 'h3' +import { createId } from '@paralleldrive/cuid2' +import { db } from '@chat-game/prisma-client' + +export default defineEventHandler>(async (event) => { + const profileId = getRouterParam(event, 'id') + const body = await readBody(event) + + if (!body.characterId) { + throw createError({ + statusCode: 400, + message: 'You must provide data', + }) + } + + const profile = await db.profile.findUnique({ + where: { id: profileId }, + include: { + trophyEditions: true, + }, + }) + if (!profile) { + throw createError({ + status: 404, + }) + } + + const character = await db.character.findUnique({ + where: { id: body.characterId, isReady: true }, + }) + if (!character) { + throw createError({ + status: 404, + }) + } + + // Check, if already have + const editionAlready = await db.characterEdition.findFirst({ + where: { + profileId, + characterId: body.characterId, + }, + }) + if (editionAlready?.id) { + throw createError({ + status: 400, + message: 'You already have this character', + }) + } + + // Give new edition + const edition = await db.characterEdition.create({ + data: { + id: createId(), + profileId: profile.id, + characterId: character.id, + }, + }) + + await db.transaction.create({ + data: { + id: createId(), + profileId: profile.id, + entityId: edition.id, + amount: character.price, + type: 'CHARACTER_UNLOCK', + }, + }) + + await db.transaction.create({ + data: { + id: createId(), + profileId: profile.id, + entityId: edition.id, + amount: character.price, + type: 'POINTS_FROM_CHARACTER_UNLOCK', + }, + }) + + await db.profile.update({ + where: { id: profile.id }, + data: { + collectorPoints: { + increment: character.price, + }, + }, + }) + + // Check trophy + if ( + !profile.trophyEditions.some((progress) => progress.trophyId === 'h09eur7whn4nyjr0bereyb5l') + ) { + await db.trophyEdition.create({ + data: { + id: createId(), + profileId: profile.id, + trophyId: 'h09eur7whn4nyjr0bereyb5l', + }, + }) + + await db.profile.update({ + where: { id: profile.id }, + data: { + trophyHunterPoints: { + increment: 50, + }, + }, + }) + } + + // Donate points + await db.profile.update({ + where: { id: profile.id }, + data: { + patronPoints: { + increment: (character.price / 2) * 10, + }, + }, + }) + + return { + ok: true, + } +}) diff --git a/apps/website/src/server/api/profile/[id]/streamer/index.post.ts b/apps/website/src/server/api/profile/[id]/streamer/index.post.ts new file mode 100644 index 00000000..73c0a1d5 --- /dev/null +++ b/apps/website/src/server/api/profile/[id]/streamer/index.post.ts @@ -0,0 +1,20 @@ +import type { EventHandlerRequest } from 'h3' +import type { StreamerUpdateResponse } from '@chat-game/types' +import { db } from '@chat-game/prisma-client' + +export default defineEventHandler>( + async (event) => { + const profileId = getRouterParam(event, 'id') + + await db.profile.update({ + where: { id: profileId }, + data: { + isStreamer: true, + }, + }) + + return { + ok: true, + } + } +) diff --git a/apps/website/src/server/api/profile/[id]/twitchToken/index.get.ts b/apps/website/src/server/api/profile/[id]/twitchToken/index.get.ts new file mode 100644 index 00000000..4be19dd3 --- /dev/null +++ b/apps/website/src/server/api/profile/[id]/twitchToken/index.get.ts @@ -0,0 +1,18 @@ +import type { EventHandlerRequest } from 'h3' +import type { TwitchToken } from '@chat-game/types' +import { db } from '@chat-game/prisma-client' + +export default defineEventHandler>(async (event) => { + const profileId = getRouterParam(event, 'id') + + const tokens = (await db.twitchToken.findMany({ + where: { profileId }, + })) as TwitchToken[] + if (!tokens) { + throw createError({ + status: 404, + }) + } + + return tokens +}) diff --git a/apps/website/src/server/api/profile/[id]/woodland/index.get.ts b/apps/website/src/server/api/profile/[id]/woodland/index.get.ts new file mode 100644 index 00000000..142f05c5 --- /dev/null +++ b/apps/website/src/server/api/profile/[id]/woodland/index.get.ts @@ -0,0 +1,23 @@ +import type { EventHandlerRequest } from 'h3' +import type { Woodland } from '@chat-game/types' +import { db } from '@chat-game/prisma-client' + +export default defineEventHandler>(async (event) => { + const profileId = getRouterParam(event, 'id') + + const woodlands = (await db.woodland.findMany({ + where: { profileId }, + include: { + players: true, + }, + orderBy: { createdAt: 'desc' }, + take: 50, + })) as Woodland[] + if (!woodlands) { + throw createError({ + status: 404, + }) + } + + return woodlands +}) diff --git a/apps/website/src/server/api/profile/index.get.ts b/apps/website/src/server/api/profile/index.get.ts new file mode 100644 index 00000000..5a70acc2 --- /dev/null +++ b/apps/website/src/server/api/profile/index.get.ts @@ -0,0 +1,9 @@ +import type { EventHandlerRequest } from 'h3' +import type { ProfileInfoResponse } from '@chat-game/types' +import { db } from '@chat-game/prisma-client' + +export default defineEventHandler>(async () => { + const count = await db.profile.count() + + return { count } +}) diff --git a/apps/website/src/server/api/profile/index.post.ts b/apps/website/src/server/api/profile/index.post.ts new file mode 100644 index 00000000..cfad78a9 --- /dev/null +++ b/apps/website/src/server/api/profile/index.post.ts @@ -0,0 +1,27 @@ +import type { EventHandlerRequest } from 'h3' +import type { ProfileCreateResponse } from '@chat-game/types' +import { DBRepository } from '../../utils/repository' + +export default defineEventHandler>( + async (event) => { + const body = await readBody(event) + + if (!body.twitchId || !body.userName) { + throw createError({ + statusCode: 400, + message: 'You must provide twitchId and userName', + }) + } + + const repository = new DBRepository() + const profile = await repository.findOrCreateProfile({ + userId: body.twitchId, + userName: body.userName, + }) + + return { + ok: true, + result: profile, + } + } +) diff --git a/apps/website/src/server/api/profile/twitchId/[twitchId].get.ts b/apps/website/src/server/api/profile/twitchId/[twitchId].get.ts new file mode 100644 index 00000000..d4ecd36c --- /dev/null +++ b/apps/website/src/server/api/profile/twitchId/[twitchId].get.ts @@ -0,0 +1,18 @@ +import type { EventHandlerRequest } from 'h3' +import type { Profile } from '@chat-game/types' +import { db } from '@chat-game/prisma-client' + +export default defineEventHandler>(async (event) => { + const twitchId = getRouterParam(event, 'twitchId') + + const profile = await db.profile.findFirst({ + where: { twitchId }, + }) + if (!profile) { + throw createError({ + status: 404, + }) + } + + return profile +}) diff --git a/apps/website/src/server/api/profile/userName/[userName].get.ts b/apps/website/src/server/api/profile/userName/[userName].get.ts new file mode 100644 index 00000000..bff125b1 --- /dev/null +++ b/apps/website/src/server/api/profile/userName/[userName].get.ts @@ -0,0 +1,27 @@ +import type { EventHandlerRequest } from 'h3' +import type { ProfileWithOwnedCharacters } from '@chat-game/types' +import { db } from '@chat-game/prisma-client' + +export default defineEventHandler>( + async (event) => { + const userName = getRouterParam(event, 'userName') + + const profile = await db.profile.findFirst({ + where: { userName }, + include: { + characterEditions: { + include: { + character: true, + }, + }, + }, + }) + if (!profile) { + throw createError({ + status: 404, + }) + } + + return profile as ProfileWithOwnedCharacters + } +) diff --git a/apps/website/src/server/api/quest/[id].get.ts b/apps/website/src/server/api/quest/[id].get.ts new file mode 100644 index 00000000..053f6823 --- /dev/null +++ b/apps/website/src/server/api/quest/[id].get.ts @@ -0,0 +1,32 @@ +import type { EventHandlerRequest } from 'h3' +import type { QuestWithEditions } from '@chat-game/types' +import { db } from '@chat-game/prisma-client' + +export default defineEventHandler>( + async (event) => { + const id = getRouterParam(event, 'id') + + const quest = (await db.quest.findFirst({ + where: { id }, + include: { + editions: { + where: { status: 'COMPLETED' }, + include: { + profile: true, + }, + orderBy: { completedAt: 'desc' }, + take: 50, + }, + rewards: true, + profile: true, + }, + })) as QuestWithEditions + if (!quest) { + throw createError({ + status: 404, + }) + } + + return quest + } +) diff --git a/apps/website/src/server/api/quest/index.get.ts b/apps/website/src/server/api/quest/index.get.ts new file mode 100644 index 00000000..50c83102 --- /dev/null +++ b/apps/website/src/server/api/quest/index.get.ts @@ -0,0 +1,13 @@ +import type { EventHandlerRequest } from 'h3' +import type { QuestWithRewards } from '@chat-game/types' +import { db } from '@chat-game/prisma-client' + +export default defineEventHandler>(async () => { + const quests = await db.quest.findMany({ + include: { + rewards: true, + }, + }) + + return quests as QuestWithRewards[] +}) diff --git a/apps/website/src/server/api/quest/profileId/[profileId].get.ts b/apps/website/src/server/api/quest/profileId/[profileId].get.ts new file mode 100644 index 00000000..82b9bd4c --- /dev/null +++ b/apps/website/src/server/api/quest/profileId/[profileId].get.ts @@ -0,0 +1,26 @@ +import type { EventHandlerRequest } from 'h3' +import type { QuestWithEditions } from '@chat-game/types' +import { db } from '@chat-game/prisma-client' + +export default defineEventHandler>( + async (event) => { + const profileId = getRouterParam(event, 'profileId') + + const quests = (await db.quest.findMany({ + include: { + editions: { + where: { profileId }, + }, + rewards: true, + profile: true, + }, + })) as QuestWithEditions[] + if (!quests) { + throw createError({ + status: 404, + }) + } + + return quests + } +) diff --git a/apps/website/src/server/api/transaction/index.get.ts b/apps/website/src/server/api/transaction/index.get.ts new file mode 100644 index 00000000..9120e737 --- /dev/null +++ b/apps/website/src/server/api/transaction/index.get.ts @@ -0,0 +1,19 @@ +import type { EventHandlerRequest } from 'h3' +import type { TransactionWithProfile } from '@chat-game/types' +import { db } from '@chat-game/prisma-client' + +export default defineEventHandler>( + async () => { + const transactions = await db.transaction.findMany({ + include: { + profile: true, + }, + orderBy: { + createdAt: 'desc', + }, + take: 50, + }) + + return transactions as TransactionWithProfile[] + } +) diff --git a/apps/website/src/server/api/trophy/[id].get.ts b/apps/website/src/server/api/trophy/[id].get.ts new file mode 100644 index 00000000..4a28a91b --- /dev/null +++ b/apps/website/src/server/api/trophy/[id].get.ts @@ -0,0 +1,30 @@ +import type { EventHandlerRequest } from 'h3' +import type { TrophyWithEditions } from '@chat-game/types' +import { db } from '@chat-game/prisma-client' + +export default defineEventHandler>( + async (event) => { + const id = getRouterParam(event, 'id') + + const trophy = (await db.trophy.findFirst({ + where: { id }, + include: { + editions: { + include: { + profile: true, + }, + orderBy: { createdAt: 'desc' }, + take: 50, + }, + profile: true, + }, + })) as TrophyWithEditions + if (!trophy) { + throw createError({ + status: 404, + }) + } + + return trophy + } +) diff --git a/apps/website/src/server/api/trophy/index.get.ts b/apps/website/src/server/api/trophy/index.get.ts new file mode 100644 index 00000000..d03accda --- /dev/null +++ b/apps/website/src/server/api/trophy/index.get.ts @@ -0,0 +1,7 @@ +import type { EventHandlerRequest } from 'h3' +import type { Trophy } from '@chat-game/types' +import { db } from '@chat-game/prisma-client' + +export default defineEventHandler>(async () => { + return db.trophy.findMany() +}) diff --git a/apps/website/src/server/api/trophy/index.post.ts b/apps/website/src/server/api/trophy/index.post.ts new file mode 100644 index 00000000..46058ea9 --- /dev/null +++ b/apps/website/src/server/api/trophy/index.post.ts @@ -0,0 +1,54 @@ +import type { EventHandlerRequest } from 'h3' +import { createId } from '@paralleldrive/cuid2' +import type { TrophyCreateResponse } from '@chat-game/types' +import { db } from '@chat-game/prisma-client' + +export default defineEventHandler>( + async (event) => { + const body = await readBody(event) + + if (!body.profileId || !body.name || !body.description) { + throw createError({ + statusCode: 400, + message: 'You must provide data', + }) + } + + const profile = await db.profile.findUnique({ + where: { id: body.profileId, mana: { gte: 5 } }, + }) + if (!profile) { + throw createError({ + status: 404, + }) + } + + // Take payment + await db.profile.update({ + where: { id: profile.id }, + data: { + mana: { decrement: 5 }, + }, + }) + + // sanitize, max chars + const name = body.name.trim().substring(0, 35) + const description = body.description.trim().substring(0, 140) + + const trophy = await db.trophy.create({ + data: { + id: createId(), + name, + description, + profileId: body.profileId, + points: 10, + rarity: 0, + }, + }) + + return { + ok: true, + result: trophy, + } + } +) diff --git a/apps/website/src/server/api/trophy/profileId/[profileId].get.ts b/apps/website/src/server/api/trophy/profileId/[profileId].get.ts new file mode 100644 index 00000000..0ce92784 --- /dev/null +++ b/apps/website/src/server/api/trophy/profileId/[profileId].get.ts @@ -0,0 +1,24 @@ +import type { EventHandlerRequest } from 'h3' +import type { TrophyEditionWithTrophy } from '@chat-game/types' +import { db } from '@chat-game/prisma-client' + +export default defineEventHandler>( + async (event) => { + const profileId = getRouterParam(event, 'profileId') + + const progress = (await db.trophyEdition.findMany({ + where: { profileId }, + include: { + trophy: true, + }, + orderBy: { createdAt: 'desc' }, + })) as TrophyEditionWithTrophy[] + if (!progress) { + throw createError({ + status: 404, + }) + } + + return progress + } +) diff --git a/apps/website/src/server/api/twitch/addon/index.post.ts b/apps/website/src/server/api/twitch/addon/index.post.ts new file mode 100644 index 00000000..dcd1d4a4 --- /dev/null +++ b/apps/website/src/server/api/twitch/addon/index.post.ts @@ -0,0 +1,51 @@ +import { createId } from '@paralleldrive/cuid2' +import type { EventHandlerRequest } from 'h3' +import type { TokenCreateResponse, TwitchToken } from '@chat-game/types' +import { db } from '@chat-game/prisma-client' + +export default defineEventHandler>( + async (event) => { + const body = await readBody(event) + + if (!body.profileId) { + throw createError({ + statusCode: 400, + message: 'You must provide profileId', + }) + } + + const profile = await db.profile.findFirst({ + where: { id: body.profileId }, + }) + if (!profile) { + throw createError({ + statusCode: 400, + message: 'Not correct profile', + }) + } + + const addon = await db.twitchToken.findFirst({ + where: { profileId: profile.id, type: 'ADDON' }, + }) + if (addon) { + throw createError({ + statusCode: 400, + message: 'Already have one', + }) + } + + const token = await db.twitchToken.create({ + data: { + id: createId(), + profileId: profile.id, + status: 'ACTIVE', + type: 'ADDON', + }, + }) + + return { + ok: true, + result: token as TwitchToken, + } + } +) diff --git a/apps/website/src/server/api/twitch/ai-view/index.post.ts b/apps/website/src/server/api/twitch/ai-view/index.post.ts new file mode 100644 index 00000000..d1a750bd --- /dev/null +++ b/apps/website/src/server/api/twitch/ai-view/index.post.ts @@ -0,0 +1,51 @@ +import { createId } from '@paralleldrive/cuid2' +import type { EventHandlerRequest } from 'h3' +import type { TokenCreateResponse, TwitchToken } from '@chat-game/types' +import { db } from '@chat-game/prisma-client' + +export default defineEventHandler>( + async (event) => { + const body = await readBody(event) + + if (!body.profileId) { + throw createError({ + statusCode: 400, + message: 'You must provide profileId', + }) + } + + const profile = await db.profile.findFirst({ + where: { id: body.profileId }, + }) + if (!profile) { + throw createError({ + statusCode: 400, + message: 'Not correct profile', + }) + } + + const ai = await db.twitchToken.findFirst({ + where: { profileId: profile.id, type: 'AI_VIEW' }, + }) + if (ai) { + throw createError({ + statusCode: 400, + message: 'Already have one', + }) + } + + const token = await db.twitchToken.create({ + data: { + id: createId(), + profileId: profile.id, + status: 'ACTIVE', + type: 'AI_VIEW', + }, + }) + + return { + ok: true, + result: token as TwitchToken, + } + } +) diff --git a/apps/website/src/server/api/twitch/code/index.post.ts b/apps/website/src/server/api/twitch/code/index.post.ts new file mode 100644 index 00000000..93f59c93 --- /dev/null +++ b/apps/website/src/server/api/twitch/code/index.post.ts @@ -0,0 +1,82 @@ +import { createId } from '@paralleldrive/cuid2' +import type { EventHandlerRequest } from 'h3' +import type { TokenCreateResponse, TwitchAccessTokenResponse, TwitchToken } from '@chat-game/types' +import { db } from '@chat-game/prisma-client' + +export default defineEventHandler>( + async (event) => { + const { public: publicEnv } = useRuntimeConfig() + const body = await readBody(event) + + if (!body.code || !body.profileId) { + throw createError({ + statusCode: 400, + message: 'You must provide code and profileId', + }) + } + + const profile = await db.profile.findFirst({ + where: { id: body.profileId }, + }) + if (!profile) { + throw createError({ + statusCode: 400, + message: 'Not correct profile', + }) + } + + const res = await obtainTwitchAccessToken(body.code, publicEnv.signInRedirectUrl) + + if (!res?.access_token) { + throw createError({ + statusCode: 400, + message: 'Not correct code', + }) + } + + const twitchAccessToken = await db.twitchAccessToken.create({ + data: { + id: createId(), + userId: profile.twitchId, + accessToken: res.access_token, + refreshToken: res.refresh_token, + scope: res.scope, + expiresIn: res.expires_in, + obtainmentTimestamp: new Date().getTime().toString(), + }, + }) + + const token = await db.twitchToken.create({ + data: { + id: createId(), + accessTokenId: twitchAccessToken.id, + profileId: profile.id, + status: 'ACTIVE', + type: 'ADDON', + }, + }) + + return { + ok: true, + result: token as TwitchToken, + } + } +) + +async function obtainTwitchAccessToken(code: string, redirectUrl: string) { + const { public: publicEnv, twitchSecretId } = useRuntimeConfig() + const clientId = publicEnv.twitchClientId + + try { + const response = await fetch( + `https://id.twitch.tv/oauth2/token?client_id=${clientId}&client_secret=${twitchSecretId}&code=${code}&grant_type=authorization_code&redirect_uri=${redirectUrl}`, + { + method: 'POST', + } + ) + return (await response.json()) as TwitchAccessTokenResponse + } catch (err) { + console.error('obtainTwitchAccessToken', err) + return null + } +} diff --git a/apps/website/src/server/api/twitch/status.get.ts b/apps/website/src/server/api/twitch/status.get.ts new file mode 100644 index 00000000..11eb872e --- /dev/null +++ b/apps/website/src/server/api/twitch/status.get.ts @@ -0,0 +1,14 @@ +import type { EventHandlerRequest } from 'h3' +import { twitchAddonController } from '../../utils/twitch/twitch.addon.controller' +import { twitchController } from '../../utils/twitch/twitch.controller' +import { twitchWoodlandController } from '../../utils/twitch/twitch.woodland.controller' +import type { TwitchServiceStatus } from '@chat-game/types' + +export default defineEventHandler(() => { + return [ + { service: 'PUBLIC_ADDON', status: twitchAddonController.status }, + { service: 'WOODLAND', status: twitchWoodlandController.status }, + { service: 'HMBANAN666_TWITCH', status: twitchController.status }, + { service: 'COUPON_GENERATOR', status: twitchController.couponGeneratorStatus }, + ] +}) diff --git a/apps/website/src/server/api/twitch/token/[id].post.ts b/apps/website/src/server/api/twitch/token/[id].post.ts new file mode 100644 index 00000000..bac44a45 --- /dev/null +++ b/apps/website/src/server/api/twitch/token/[id].post.ts @@ -0,0 +1,26 @@ +import type { EventHandlerRequest } from 'h3' +import { db } from '@chat-game/prisma-client' + +export default defineEventHandler>(async (event) => { + const id = getRouterParam(event, 'id') + + const token = await db.twitchToken.findFirst({ + where: { id }, + }) + if (!token) { + throw createError({ + status: 404, + }) + } + + await db.twitchToken.update({ + where: { id }, + data: { + onlineAt: new Date(), + }, + }) + + return { + ok: true, + } +}) diff --git a/apps/website/src/server/api/twitch/token/online.get.ts b/apps/website/src/server/api/twitch/token/online.get.ts new file mode 100644 index 00000000..44997c6c --- /dev/null +++ b/apps/website/src/server/api/twitch/token/online.get.ts @@ -0,0 +1,17 @@ +import type { EventHandlerRequest } from 'h3' +import type { TwitchTokenWithProfile } from '@chat-game/types' +import { getDateMinusMinutes } from '../../../utils/date' +import { db } from '@chat-game/prisma-client' + +export default defineEventHandler>( + async () => { + const gte = getDateMinusMinutes(5) + + return (await db.twitchToken.findMany({ + where: { onlineAt: { gte } }, + include: { + profile: true, + }, + })) as TwitchTokenWithProfile[] + } +) diff --git a/apps/website/src/server/api/woodland/[id]/index.get.ts b/apps/website/src/server/api/woodland/[id]/index.get.ts new file mode 100644 index 00000000..80b96646 --- /dev/null +++ b/apps/website/src/server/api/woodland/[id]/index.get.ts @@ -0,0 +1,21 @@ +import type { EventHandlerRequest } from 'h3' +import type { Woodland } from '@chat-game/types' +import { db } from '@chat-game/prisma-client' + +export default defineEventHandler>(async (event) => { + const id = getRouterParam(event, 'id') + + const woodland = await db.woodland.findFirst({ + where: { id }, + include: { + players: true, + }, + }) + if (!woodland) { + throw createError({ + status: 404, + }) + } + + return woodland as Woodland +}) diff --git a/apps/website/src/server/api/woodland/index.post.ts b/apps/website/src/server/api/woodland/index.post.ts new file mode 100644 index 00000000..aaac30ea --- /dev/null +++ b/apps/website/src/server/api/woodland/index.post.ts @@ -0,0 +1,61 @@ +import { createId } from '@paralleldrive/cuid2' +import type { EventHandlerRequest } from 'h3' +import type { TwitchToken, Woodland, WoodlandCreateResponse } from '@chat-game/types' +import { db } from '@chat-game/prisma-client' + +export default defineEventHandler>( + async (event) => { + const body = await readBody(event) + + if (!body.profileId) { + throw createError({ + statusCode: 400, + message: 'You must provide profileId', + }) + } + + const profile = await db.profile.findFirst({ + where: { id: body.profileId }, + }) + if (!profile) { + throw createError({ + statusCode: 400, + message: 'Not correct profile', + }) + } + + let token = await db.twitchToken.findFirst({ + where: { profileId: profile.id, type: 'WOODLAND' }, + }) + if (!token) { + token = await db.twitchToken.create({ + data: { + id: createId(), + profileId: profile.id, + status: 'ACTIVE', + type: 'WOODLAND', + }, + }) + } + + const woodland = await db.woodland.create({ + data: { + id: createId(), + profileId: profile.id, + tokenId: token.id, + status: 'CREATED', + }, + include: { + players: true, + }, + }) + + return { + ok: true, + result: { + token: token as TwitchToken, + woodland: woodland as Woodland, + }, + } + } +) diff --git a/apps/website/src/server/api/woodland/player/[id]/wood.post.ts b/apps/website/src/server/api/woodland/player/[id]/wood.post.ts new file mode 100644 index 00000000..1c041fa1 --- /dev/null +++ b/apps/website/src/server/api/woodland/player/[id]/wood.post.ts @@ -0,0 +1,41 @@ +import type { EventHandlerRequest } from 'h3' +import { db } from '@chat-game/prisma-client' + +export default defineEventHandler>(async (event) => { + const playerId = getRouterParam(event, 'id') + const body = await readBody(event) + + if (!body?.wood) { + throw createError({ + statusCode: 400, + message: 'You must provide data', + }) + } + + const player = await db.woodlandPlayer.findFirst({ + where: { id: playerId }, + }) + if (!player) { + throw createError({ + statusCode: 400, + message: 'Player not found', + }) + } + + const wood: number = Number(body.wood) + if (wood < 0 || !Number.isInteger(wood)) { + throw createError({ + statusCode: 400, + message: 'Wood must be a number', + }) + } + + await db.woodlandPlayer.update({ + where: { id: player.id }, + data: { + wood, + }, + }) + + return { ok: true } +}) diff --git a/apps/website/src/server/middleware/auth.ts b/apps/website/src/server/middleware/auth.ts new file mode 100644 index 00000000..a598dba9 --- /dev/null +++ b/apps/website/src/server/middleware/auth.ts @@ -0,0 +1,19 @@ +export default defineEventHandler((event) => { + const { websiteBearer } = useRuntimeConfig() + + if (event.method !== 'GET') { + const headers = getHeaders(event) + const token = headers.authorization ?? headers.Authorization + + // Payment webhook dont need auth + if (event.path === '/api/payment/webhook') { + return + } + + if (!token || token !== `Bearer ${websiteBearer}`) { + return createError({ + statusCode: 403, + }) + } + } +}) diff --git a/apps/website/src/server/plugins/start.ts b/apps/website/src/server/plugins/start.ts index 7f99385e..dedeae8f 100644 --- a/apps/website/src/server/plugins/start.ts +++ b/apps/website/src/server/plugins/start.ts @@ -1,9 +1,22 @@ -import { log } from '../utils/logger' +import { twitchController } from '../utils/twitch/twitch.controller' +import { twitchAddonController } from '../utils/twitch/twitch.addon.controller' +import { twitchWoodlandController } from '../utils/twitch/twitch.woodland.controller' +import { twitchProvider } from '../utils/twitch/twitch.provider' export default defineNitroPlugin(() => { - log('Server started!') + void twitchController.serve() + void twitchController.serveStreamOnline() + void twitchAddonController.serve() + void twitchWoodlandController.serve() - setInterval(() => { - //log('Server is OK...') - }, 60000) + setTimeout(checkIfStreamingNow, 5000) }) + +async function checkIfStreamingNow() { + const res = await fetch('https://twitch.tv/hmbanan666') + const code = await res.text() + + if (code.includes('isLiveBroadcast')) { + twitchProvider.isStreaming = true + } +} diff --git a/apps/website/src/server/utils/date.ts b/apps/website/src/server/utils/date.ts new file mode 100644 index 00000000..bb62c04f --- /dev/null +++ b/apps/website/src/server/utils/date.ts @@ -0,0 +1,4 @@ +export function getDateMinusMinutes(minutes: number): Date { + const milliseconds = minutes * 60 * 1000 + return new Date(Date.now() - milliseconds) +} diff --git a/apps/website/src/server/utils/quest.ts b/apps/website/src/server/utils/quest.ts new file mode 100644 index 00000000..6335e83b --- /dev/null +++ b/apps/website/src/server/utils/quest.ts @@ -0,0 +1,60 @@ +import { createId } from '@paralleldrive/cuid2' +import type { QuestReward } from '@chat-game/types' +import { DBRepository } from './repository' + +export class QuestService { + readonly #repository: DBRepository + + constructor() { + this.#repository = new DBRepository() + } + + async updateProgress(profileId: string) { + const profileQuests = await this.#repository.findProfileQuests(profileId) + + for (const quest of profileQuests) { + let progress = quest.editions[0] + if (!progress) { + progress = await this.#repository.createQuestEdition({ + id: createId(), + completedAt: null, + status: 'IN_PROGRESS', + questId: quest.id, + progress: 0, + profileId, + }) + } + } + } + + async completeQuest(id: string, profileId: string) { + const profileQuests = await this.#repository.findProfileQuests(profileId) + const quest = profileQuests.find((q) => q.id === id) + const questEdition = quest?.editions[0] + const questRewards = quest?.rewards as QuestReward[] + + if (!questEdition || questEdition.status === 'COMPLETED') { + return + } + + if (id === 'xu44eon7teobb4a74cd4yvuh') { + // Coupon taken + await this.#repository.updateQuestEdition(questEdition.id, { + completedAt: new Date(), + status: 'COMPLETED', + }) + + await this.#repository.addRangerPoints(profileId, quest.points) + + // Rewards + for (const reward of questRewards) { + if (reward.type === 'COINS') { + await this.#repository.addCoinsToProfileFromQuest(profileId, quest.id, reward.amount) + } + if (reward.type === 'TROPHY' && reward.entityId) { + await this.#repository.addTrophyToProfile(reward.entityId, profileId) + } + } + } + } +} diff --git a/apps/website/src/server/utils/repository.ts b/apps/website/src/server/utils/repository.ts new file mode 100644 index 00000000..cc9f0004 --- /dev/null +++ b/apps/website/src/server/utils/repository.ts @@ -0,0 +1,540 @@ +import { createId } from '@paralleldrive/cuid2' +import type { QuestEdition } from '@prisma/client' +import type { + CharacterEditionWithCharacter, + Coupon, + Profile, + ProfileWithTokens, + TwitchAccessToken, + TwitchToken, +} from '@chat-game/types' +import { db } from '@chat-game/prisma-client' +import { getDateMinusMinutes } from './date' + +export class DBRepository { + createTwitchAccessToken(token: TwitchAccessToken) { + return db.twitchAccessToken.create({ + data: { + ...token, + obtainmentTimestamp: token.obtainmentTimestamp.toString(), + }, + }) + } + + async getTwitchAccessToken(userId: string): Promise { + const token = await db.twitchAccessToken.findFirst({ + where: { userId }, + }) + if (!token) { + return null + } + + return { + ...token, + obtainmentTimestamp: Number(token.obtainmentTimestamp), + } + } + + updateTwitchAccessToken(userId: string, token: Partial) { + return db.twitchAccessToken.updateMany({ + where: { userId }, + data: { + ...token, + obtainmentTimestamp: token.obtainmentTimestamp?.toString(), + }, + }) + } + + async findAllStreamers(type: TwitchToken['type']): Promise { + return (await db.profile.findMany({ + where: { twitchTokens: { some: { status: 'ACTIVE', type } } }, + include: { + twitchTokens: true, + }, + })) as ProfileWithTokens[] + } + + async getTokensCount(type: TwitchToken['type']) { + return db.twitchToken.count({ + where: { status: 'ACTIVE', type }, + }) + } + + findProfile(id: string) { + return db.profile.findUnique({ where: { id } }) + } + + findProfileByTwitchId(twitchId: string): Promise { + return db.profile.findFirst({ where: { twitchId } }) + } + + async findOrCreateProfile({ userId, userName }: { userId: string; userName: string }) { + let profile = await db.profile.findFirst({ + where: { twitchId: userId }, + }) + if (!profile) { + const editionId = createId() + + const newProfile = { + id: createId(), + twitchId: userId, + userName, + activeEditionId: editionId, + mana: 10, + } + + const createdProfile: Profile = await db.profile.create({ + data: newProfile, + }) + + // Twitchy + const edition = await db.characterEdition.create({ + data: { + id: editionId, + profileId: createdProfile.id, + characterId: 'staoqh419yy3k22cbtm9wquc', + }, + }) + + profile = await db.profile.update({ + where: { id: createdProfile.id }, + data: { + activeEditionId: edition.id, + }, + }) + } + + return profile + } + + async findCharacterByEditionId(editionId: string): Promise { + const char = await db.characterEdition.findFirst({ + where: { id: editionId }, + include: { + character: true, + }, + }) + + return char as CharacterEditionWithCharacter + } + + async updateManaOnProfiles() { + const profiles = await db.profile.findMany({ + where: { + mana: { + lt: 10, + }, + }, + }) + + for (const profile of profiles) { + await db.profile.update({ + where: { id: profile.id }, + data: { + mana: { + increment: 2, + }, + }, + }) + } + } + + addXpToCharacterEdition(id: string) { + return db.characterEdition.update({ + where: { id }, + data: { + xp: { + increment: 1, + }, + }, + }) + } + + addLevelToCharacterEdition(id: string) { + return db.characterEdition.update({ + where: { id }, + data: { + level: { + increment: 1, + }, + }, + }) + } + + async addCoinsToProfile(id: string, editionId: string, increment = 1) { + await db.transaction.create({ + data: { + id: createId(), + profileId: id, + entityId: editionId, + amount: increment, + type: 'COIN_FROM_LVL_UP', + }, + }) + + return db.profile.update({ + where: { id }, + data: { + coins: { increment }, + }, + }) + } + + async addCoinsToProfileFromQuest(id: string, questId: string, increment = 1) { + await db.transaction.create({ + data: { + id: createId(), + profileId: id, + entityId: questId, + amount: increment, + type: 'COINS_FROM_QUEST', + }, + }) + + return db.profile.update({ + where: { id }, + data: { + coins: { increment }, + }, + }) + } + + async addTrophyToProfile(id: string, profileId: string) { + const trophyEdition = await db.trophyEdition.findUnique({ + where: { id, profileId }, + include: { trophy: true }, + }) + if (trophyEdition?.id) { + // Already added + return + } + + const newEdition = await db.trophyEdition.create({ + data: { + id: createId(), + profileId, + trophyId: id, + }, + include: { trophy: true }, + }) + + return this.addTrophyHunterPoints(profileId, newEdition.trophy.points) + } + + async addCollectorPoints(id: string, points: number) { + await db.transaction.create({ + data: { + id: createId(), + profileId: id, + entityId: id, + amount: points, + type: 'POINTS_FROM_LEVEL_UP', + }, + }) + + const profile = await db.profile.update({ + where: { id }, + data: { + collectorPoints: { + increment: points, + }, + }, + }) + + await this.recountProfilePoints(profile.id) + + return profile + } + + async recountProfilePoints(id: string) { + const profile = await db.profile.findUnique({ where: { id } }) + if (!profile) { + return + } + + const points = + profile.collectorPoints + + profile.trophyHunterPoints + + profile.storytellerPoints + + profile.patronPoints + if (points !== profile.points) { + await db.profile.update({ + where: { id }, + data: { points }, + }) + } + + if (this.checkIfNeedToLevelUpProfile(points, profile.level)) { + await this.addLevelToProfile(id) + } + } + + addLevelToProfile(id: string) { + return db.profile.update({ + where: { id }, + data: { + level: { increment: 1 }, + }, + }) + } + + async addTrophyHunterPoints(id: string, points: number) { + const profile = await db.profile.update({ + where: { id }, + data: { + trophyHunterPoints: { + increment: points, + }, + }, + }) + + await this.recountProfilePoints(profile.id) + + return profile + } + + async addRangerPoints(id: string, points: number) { + const profile = await db.profile.update({ + where: { id }, + data: { + rangerPoints: { increment: points }, + }, + }) + + await this.recountProfilePoints(profile.id) + + return profile + } + + checkIfNeedToLevelUpProfile(xpNow: number, levelNow: number) { + if (levelNow >= 50) { + return false + } + + const levelProgress = { + 1: 0, + 2: 25, + 3: 50, + 4: 100, + 5: 200, + 6: 400, + 7: 800, + 8: 1600, + 9: 3200, + 10: 6400, + 11: 9600, + 12: 14400, + 13: 21600, + 14: 32400, + 15: 48600, + 16: 72900, + 17: 110000, + 18: 165000, + 19: 247000, + 20: 370000, + 21: 444000, + 22: 532000, + 23: 638000, + 24: 765000, + 25: 918000, + 26: 1100000, + 27: 1320000, + 28: 1580000, + 29: 1890000, + 30: 2260000, + 31: 2480000, + 32: 2720000, + 33: 2990000, + 34: 3200000, + 35: 3500000, + 36: 3800000, + 37: 4100000, + 38: 4500000, + 39: 4900000, + 40: 5300000, + 41: 5800000, + 42: 6300000, + 43: 6900000, + 44: 7500000, + 45: 8200000, + 46: 9000000, + 47: 9900000, + 48: 11000000, + 49: 13000000, + 50: 15000000, + } + + const key = (levelNow + 1) as keyof typeof levelProgress + return xpNow >= levelProgress[key] + } + + async findOrCreatePlayer({ userName, profileId }: { userName: string; profileId: string }) { + let player = await db.player.findFirst({ + where: { profileId }, + }) + if (!player) { + const playerId = createId() + + // Create new inventory + const newInventory = { + id: createId(), + objectId: playerId, + } + const inventory = await db.inventory.create({ + data: newInventory, + }) + + // Create new one + const newPlayer = { + id: playerId, + name: userName, + inventoryId: inventory.id, + profileId, + } + player = await db.player.create({ + data: newPlayer, + }) + } + + return player + } + + async findOrCreateWoodlandPlayer({ + name, + profileId, + woodlandId, + }: { + name: string + profileId: string + woodlandId: string + }) { + let player = await db.woodlandPlayer.findFirst({ + where: { profileId }, + }) + if (!player) { + // Create new one + player = await db.woodlandPlayer.create({ + data: { + id: createId(), + name, + profileId, + woodlandId, + }, + }) + } + + return player + } + + findActiveWoodland(tokenId: string) { + return db.woodland.findFirst({ + where: { tokenId, status: { in: ['CREATED', 'STARTED'] } }, + }) + } + + async getPlaceInTopByCoupons(profileId: string) { + const topProfiles = await db.profile.findMany({ + orderBy: { coupons: 'desc' }, + take: 1000, + }) + + return topProfiles.findIndex((profile) => profile.id === profileId) + 1 + } + + async generateCouponCommand(): Promise { + // Generate 2-digit number + const number = Math.floor(Math.random() * 90 + 10) + + // Check if already have in 24 hours + const gte = getDateMinusMinutes(60 * 24) + + const isAlreadyExist = await db.coupon.findFirst({ + where: { activationCommand: number.toString(), createdAt: { gte } }, + }) + if (isAlreadyExist) { + return this.generateCouponCommand() + } + + return number.toString() + } + + async generateCoupon() { + const activationCommand = await this.generateCouponCommand() + + const newCoupon: Omit = { + id: createId(), + activationCommand, + status: 'CREATED', + } + + return db.coupon.create({ + data: newCoupon, + }) + } + + async activateCouponByCommand( + id: string, + profileId: string + ): Promise<'OK' | 'TAKEN_ALREADY' | 'TIME_LIMIT' | 'NOT_FOUND'> { + // Check, if this profile already take coupon in 10 hours + const gte = getDateMinusMinutes(60 * 10) + + const isAlreadyToday = await db.coupon.findFirst({ + where: { profileId, createdAt: { gte } }, + }) + if (isAlreadyToday) { + return 'TIME_LIMIT' + } + + const coupon = await db.coupon.findFirst({ + where: { activationCommand: id, createdAt: { gte } }, + }) + if (!coupon) { + return 'NOT_FOUND' + } + if (coupon.profileId) { + return 'TAKEN_ALREADY' + } + + await db.coupon.update({ + where: { id: coupon.id }, + data: { + profileId, + status: 'TAKEN', + }, + }) + + await db.profile.update({ + where: { id: profileId }, + data: { + coupons: { increment: 1 }, + }, + }) + + return 'OK' + } + + async findProfileQuests(profileId: string) { + return db.quest.findMany({ + include: { + editions: { + where: { profileId }, + }, + rewards: true, + }, + }) + } + + async createQuestEdition(data: Omit) { + return db.questEdition.create({ + data, + }) + } + + async updateQuestEdition(id: string, data: Pick) { + return db.questEdition.update({ + where: { id }, + data, + }) + } +} diff --git a/apps/website/src/server/utils/twitch/twitch.addon.controller.ts b/apps/website/src/server/utils/twitch/twitch.addon.controller.ts new file mode 100644 index 00000000..dd7b204a --- /dev/null +++ b/apps/website/src/server/utils/twitch/twitch.addon.controller.ts @@ -0,0 +1,225 @@ +import { ChatClient } from '@twurple/chat' +import type { Listener } from '@d-fischer/typed-event-emitter' +import type { ActiveCharacter, ProfileWithTokens } from '@chat-game/types' +import { getDateMinusMinutes } from '../date' +import { QuestService } from '../quest' +import { sendMessage } from '../../api/websocket' +import { DBRepository } from '../repository' + +class TwitchAddonController { + readonly #quest: QuestService + readonly #repository: DBRepository + #client!: ChatClient + #activeListeners: Listener[] = [] + #loggerId!: ReturnType + #checkerId!: ReturnType + #tokensCount: number = 0 + #streamerProfiles: ProfileWithTokens[] = [] + #activeCharacters: ActiveCharacter[] = [] + #activeCharactersUpdater!: ReturnType | null + + constructor() { + this.#quest = new QuestService() + this.#repository = new DBRepository() + } + + get status() { + return this.#activeCharactersUpdater ? 'RUNNING' : 'STOPPED' + } + + async serve() { + if (this.#checkerId) { + return + } + + // Checking new Tokens + this.#checkerId = setInterval(async () => { + const now = await this.#repository.getTokensCount('ADDON') + if (this.#tokensCount !== now) { + void this.#restart() + this.#tokensCount = now + } + }, 60000) + } + + async startCharacters() { + if (this.#activeCharactersUpdater) { + return + } + + this.#activeCharactersUpdater = setInterval(async () => { + for (const c of this.#activeCharacters) { + const checkTime = getDateMinusMinutes(4) + if (c.lastActionAt.getTime() <= checkTime.getTime()) { + // Inactive - remove via splice + this.#activeCharacters.splice(this.#activeCharacters.indexOf(c), 1) + continue + } + + const xp = await this.#repository.addXpToCharacterEdition(c.id) + if (c.level !== this.#getLevelByXp(xp.xp)) { + // Level up! + const newLevel = await this.#repository.addLevelToCharacterEdition(c.id) + c.level = newLevel.level + + await this.#repository.addCollectorPoints(c.profileId, 5) + await this.#repository.addCoinsToProfile(c.profileId, c.id, 1) + + sendMessage( + { + type: 'LEVEL_UP', + data: { playerId: c.playerId, text: `Новый уровень: ${newLevel.level}!` }, + }, + c.token + ) + } + } + }, 30000) + } + + stopCharacters() { + if (this.#activeCharactersUpdater) { + clearInterval(this.#activeCharactersUpdater) + this.#activeCharactersUpdater = null + } + } + + async #initClient() { + if (this.#client) { + clearInterval(this.#loggerId) + + for (const l of this.#activeListeners) { + this.#client.removeListener(l) + } + + this.#activeListeners = [] + this.#activeCharacters = [] + + this.#client.quit() + } + + this.#streamerProfiles = await this.#repository.findAllStreamers('ADDON') + + this.#client = new ChatClient({ + channels: this.#streamerProfiles.map((p) => p.userName), + }) + + this.#client.connect() + + const listener = this.#client.onDisconnect(() => { + log('[Public Addon]', 'Chat Client disconnected. Trying to reconnect...') + return this.#restart() + }) + this.#activeListeners.push(listener) + } + + async #restart() { + await this.#initClient() + + const listener = this.#client.onMessage(async (channel, user, text, msg) => { + const channelId = msg.channelId + const userId = msg.userInfo.userId + const userName = msg.userInfo.userName + + const profile = this.#streamerProfiles.find((p) => p.twitchId === channelId) + const token = profile?.twitchTokens.find((t) => t.status === 'ACTIVE' && t.type === 'ADDON') + if (!token) { + return + } + + log('[Public Addon]', channel, user, text, channelId ?? '', userId, token.id) + + await this.#handleMessage({ + userId, + userName, + text, + token: token.id, + }) + }) + + this.#activeListeners.push(listener) + + this.#loggerId = setInterval(() => { + log( + '[Public Addon]', + this.#client.currentChannels.toString(), + this.#client.isConnected ? 'Connected' : 'Not connected' + ) + }, 60 * 30 * 1000) + + setTimeout(() => { + log( + '[Public Addon]', + `Restarted with ${this.#client.currentChannels.length} channels active and ${ + this.#activeListeners.length + } listeners` + ) + }, 10000) + } + + #getLevelByXp(xp: number, needed = 20): number { + if (xp >= needed) { + needed = needed * 1.05 + return this.#getLevelByXp(xp - needed, needed) + 1 + } + + return 1 + } + + async #handleMessage({ + userName, + userId, + text, + token, + }: { + userName: string + userId: string + text: string + token: string + }) { + const strings = text.split(' ') + + const firstChar = strings[0].charAt(0) + const possibleCommand = strings[0].substring(1) + const otherStrings = strings.toSpliced(0, 1) + + const profile = await this.#repository.findOrCreateProfile({ userId, userName }) + const character = await this.#repository.findCharacterByEditionId(profile.activeEditionId) + const player = await this.#repository.findOrCreatePlayer({ profileId: profile.id, userName }) + + await this.#quest.updateProgress(profile.id) + + if (firstChar === '!' && possibleCommand) { + return sendMessage( + { + type: 'COMMAND', + data: { + command: possibleCommand, + params: otherStrings, + player, + profile, + character, + text, + }, + }, + token + ) + } + + if (!this.#activeCharacters.find((c) => c.id === character.id)) { + // Not allow bots + if (character.id !== 'krzq22sjnbj4crrxzdwvrcym') { + this.#activeCharacters.push({ + ...character, + lastActionAt: new Date(), + token, + playerId: player.id, + }) + } + } + + sendMessage({ type: 'MESSAGE', data: { player, profile, character, text } }, token) + } +} + +export const twitchAddonController = new TwitchAddonController() diff --git a/apps/website/src/server/utils/twitch/twitch.ai.controller.ts b/apps/website/src/server/utils/twitch/twitch.ai.controller.ts new file mode 100644 index 00000000..3e791f8c --- /dev/null +++ b/apps/website/src/server/utils/twitch/twitch.ai.controller.ts @@ -0,0 +1,127 @@ +import { ChatClient } from '@twurple/chat' +import type { Listener } from '@d-fischer/typed-event-emitter' +import type { AuthProvider } from '@twurple/auth' +import { RefreshingAuthProvider } from '@twurple/auth' +import type { ProfileWithTokens } from '@chat-game/types' +import { DBRepository } from '../repository' + +class TwitchAiController { + readonly #userId = '1115655883' // ai_view + readonly #clientId: string + readonly #clientSecret: string + + readonly #repository: DBRepository + #authProvider!: AuthProvider + #client!: ChatClient + #activeListeners: Listener[] = [] + #loggerId!: ReturnType + #checkerId!: ReturnType + #tokensCount: number = 0 + #streamerProfiles: ProfileWithTokens[] = [] + + constructor() { + this.#repository = new DBRepository() + + const { public: publicEnv, twitchSecretId } = useRuntimeConfig() + this.#clientId = publicEnv.twitchClientId + this.#clientSecret = twitchSecretId + + void this.#init() + } + + get status() { + return this.#client?.isConnected ? 'RUNNING' : 'STOPPED' + } + + async serve() { + if (this.#checkerId) { + return + } + + // Checking new Tokens + this.#checkerId = setInterval(async () => { + const now = await this.#repository.getTokensCount('AI_VIEW') + if (this.#tokensCount !== now) { + void this.#restart() + this.#tokensCount = now + } + }, 60000) + } + + async #init() { + await this.#initAuthProvider() + } + + async #initClient() { + if (this.#client) { + clearInterval(this.#loggerId) + + for (const l of this.#activeListeners) { + this.#client.removeListener(l) + } + + this.#activeListeners = [] + + this.#client.quit() + } + + this.#streamerProfiles = await this.#repository.findAllStreamers('AI_VIEW') + + this.#client = new ChatClient({ + channels: this.#streamerProfiles.map((p) => p.userName), + authProvider: this.#authProvider, + }) + + this.#client.connect() + + const listener = this.#client.onDisconnect(() => { + log('[AI]', 'Chat Client disconnected. Trying to reconnect...') + return this.#restart() + }) + this.#activeListeners.push(listener) + } + + async #restart() { + await this.#initClient() + + this.#loggerId = setInterval(() => { + log( + '[AI]', + this.#client.currentChannels.toString(), + this.#client.isConnected ? 'Connected' : 'Not connected' + ) + }, 60 * 30 * 1000) + + setTimeout(() => { + log( + '[AI]', + `Restarted with ${this.#client.currentChannels.length} channels active and ${ + this.#activeListeners.length + } listeners` + ) + }, 10000) + } + + async #initAuthProvider() { + const accessToken = await this.#repository.getTwitchAccessToken(this.#userId) + if (!accessToken) { + log('[AI]', 'No access token found') + return + } + + const authProvider = new RefreshingAuthProvider({ + clientId: this.#clientId, + clientSecret: this.#clientSecret, + }) + + authProvider.onRefresh(async (userId, newTokenData) => { + await this.#repository.updateTwitchAccessToken(userId, newTokenData) + }) + + await authProvider.addUserForToken(accessToken, ['chat']) + + this.#authProvider = authProvider + } +} + +export const twitchAiController = new TwitchAiController() diff --git a/apps/website/src/server/utils/twitch/twitch.controller.ts b/apps/website/src/server/utils/twitch/twitch.controller.ts new file mode 100644 index 00000000..d9ce113b --- /dev/null +++ b/apps/website/src/server/utils/twitch/twitch.controller.ts @@ -0,0 +1,121 @@ +import { Bot } from '@twurple/easy-bot' +import { PubSubClient } from '@twurple/pubsub' +import { ApiClient } from '@twurple/api' +import { EventSubWsListener } from '@twurple/eventsub-ws' +import { TwitchService } from './twitch.service' +import { twitchProvider } from './twitch.provider' +import { DBRepository } from '../repository' + +class TwitchController { + readonly #channel: string + readonly #userId: string + + readonly #service: TwitchService + readonly #repository: DBRepository + + #bot!: Bot + #couponGeneratorId!: ReturnType | null + + constructor() { + const { twitchChannelName, twitchChannelId } = useRuntimeConfig() + this.#channel = twitchChannelName + this.#userId = twitchChannelId + + this.#service = new TwitchService() + this.#repository = new DBRepository() + } + + get status() { + return twitchProvider.isStreaming ? 'RUNNING' : 'STOPPED' + } + + startCouponGenerator() { + if (this.#couponGeneratorId) { + return + } + + this.#couponGeneratorId = setInterval(async () => { + const coupon = await this.#repository.generateCoupon() + + await this.#bot.say( + this.#channel, + `Появился новый Купон! Забирай: пиши команду "!купон ${coupon.activationCommand}". Подробности на https://chatgame.space/ru/coupon :D` + ) + }, 1000 * 60 * 25) + } + + stopCouponGenerator() { + if (this.#couponGeneratorId) { + clearInterval(this.#couponGeneratorId) + this.#couponGeneratorId = null + } + } + + get couponGeneratorStatus() { + return this.#couponGeneratorId ? 'RUNNING' : 'STOPPED' + } + + async serve() { + const authProvider = await twitchProvider.getAuthProvider() + + const pubSubClient = new PubSubClient({ authProvider }) + + pubSubClient.onRedemption(this.#userId, ({ userId, userName, rewardId, message }) => { + this.#service.handleChannelRewardRedemption({ + userId, + userName, + rewardId, + message, + }) + }) + + this.#bot = new Bot({ + authProvider, + channels: [this.#channel], + chatClientOptions: { + requestMembershipEvents: true, + }, + }) + + this.#bot.onRaid(({ userName, userId, viewerCount }) => { + void this.#service.handleRaid({ userName, userId, viewerCount }) + }) + + // Handle only commands with answers + this.#bot.onMessage(async (message) => { + const answer = await this.#service.handleMessage({ + userId: message.userId, + userName: message.userName, + text: message.text, + }) + if (answer?.message) { + await message.reply(answer.message) + } + }) + + setInterval(() => { + void this.#repository.updateManaOnProfiles() + }, 1000 * 60 * 60) + } + + async serveStreamOnline() { + const authProvider = await twitchProvider.getAuthProvider() + + const apiClient = new ApiClient({ authProvider }) + const listener = new EventSubWsListener({ apiClient }) + + listener.start() + + listener.onStreamOnline(this.#userId, (message) => { + log('onStreamOnline', JSON.stringify(message)) + twitchProvider.isStreaming = true + }) + + listener.onStreamOffline(this.#userId, (message) => { + log('onStreamOffline', JSON.stringify(message)) + twitchProvider.isStreaming = false + }) + } +} + +export const twitchController = new TwitchController() diff --git a/apps/website/src/server/utils/twitch/twitch.provider.ts b/apps/website/src/server/utils/twitch/twitch.provider.ts new file mode 100644 index 00000000..db92dbe0 --- /dev/null +++ b/apps/website/src/server/utils/twitch/twitch.provider.ts @@ -0,0 +1,123 @@ +import type { AuthProvider } from '@twurple/auth' +import { RefreshingAuthProvider } from '@twurple/auth' +import { createId } from '@paralleldrive/cuid2' +import type { TwitchAccessTokenResponse } from '@chat-game/types' +import { twitchController } from './twitch.controller' +import { twitchAddonController } from './twitch.addon.controller' +import { DBRepository } from '../repository' + +class TwitchProvider { + #authProvider!: AuthProvider + #isStreaming: boolean = false + readonly #userId: string + readonly #clientId: string + readonly #clientSecret: string + readonly #code: string + readonly #redirectUrl: string + readonly #repository: DBRepository + + constructor() { + this.#repository = new DBRepository() + + const { + public: publicEnv, + twitchChannelId, + twitchSecretId, + twitchOauthCode, + } = useRuntimeConfig() + this.#userId = twitchChannelId + this.#clientSecret = twitchSecretId + this.#code = twitchOauthCode + this.#clientId = publicEnv.twitchClientId + this.#redirectUrl = publicEnv.signInRedirectUrl + + void this.getAuthProvider() + } + + get isStreaming() { + return this.#isStreaming + } + + set isStreaming(value: boolean) { + this.#isStreaming = value + + if (value) { + twitchController.startCouponGenerator() + void twitchAddonController.startCharacters() + } else { + twitchController.stopCouponGenerator() + twitchAddonController.stopCharacters() + } + } + + async getAuthProvider() { + if (this.#authProvider) { + return this.#authProvider + } + + this.#authProvider = await this.#prepareAuthProvider() + if (!this.#authProvider) { + return this.#createNewAccessToken() + } + + return this.#authProvider + } + + async #obtainTwitchAccessToken() { + try { + const response = await fetch( + `https://id.twitch.tv/oauth2/token?client_id=${this.#clientId}&client_secret=${ + this.#clientSecret + }&code=${this.#code}&grant_type=authorization_code&redirect_uri=${this.#redirectUrl}`, + { + method: 'POST', + } + ) + return (await response.json()) as TwitchAccessTokenResponse + } catch (err) { + console.error('obtainTwitchAccessToken', err) + return null + } + } + + async #createNewAccessToken(): Promise { + const res = await this.#obtainTwitchAccessToken() + if (res?.access_token) { + await this.#repository.createTwitchAccessToken({ + id: createId(), + userId: this.#userId, + accessToken: res.access_token, + refreshToken: res.refresh_token, + scope: res.scope, + expiresIn: res.expires_in, + obtainmentTimestamp: new Date().getTime(), + }) + + throw new Error('Saved new access token. Restart server!') + } + + throw new Error('No access token found and no Twitch code. See .env.example') + } + + async #prepareAuthProvider() { + const accessToken = await this.#repository.getTwitchAccessToken(this.#userId) + if (!accessToken) { + throw new Error('No access token') + } + + const authProvider = new RefreshingAuthProvider({ + clientId: this.#clientId, + clientSecret: this.#clientSecret, + }) + + authProvider.onRefresh(async (userId, newTokenData) => { + await this.#repository.updateTwitchAccessToken(userId, newTokenData) + }) + + await authProvider.addUserForToken(accessToken, ['chat', 'user', 'channel', 'moderator']) + + return authProvider + } +} + +export const twitchProvider = new TwitchProvider() diff --git a/apps/website/src/server/utils/twitch/twitch.service.ts b/apps/website/src/server/utils/twitch/twitch.service.ts new file mode 100644 index 00000000..f8af355a --- /dev/null +++ b/apps/website/src/server/utils/twitch/twitch.service.ts @@ -0,0 +1,154 @@ +import { DBRepository } from '../repository' +import { QuestService } from '../quest' + +export const TWITCH_CHANNEL_REWARDS = { + add150ViewerPointsId: 'd8237822-c943-434f-9d7e-87a9f549f4c4', + villainStealFuelId: 'd5956de4-54ff-49e4-afbe-ee4e62718eee', + addNewIdea: '289457e8-18c2-4b68-8564-fc61dd60b2a2', +} + +export class TwitchService { + readonly #quest: QuestService + readonly #repository: DBRepository + + constructor() { + this.#quest = new QuestService() + this.#repository = new DBRepository() + } + + async handleMessage({ + userName, + userId, + text, + }: { + userName: string + userId: string + text: string + }) { + const strings = text.split(' ') + + const firstChar = strings[0].charAt(0) + const possibleCommand = strings[0].substring(1) + const otherStrings = strings.toSpliced(0, 1) + + const profile = await this.#repository.findProfileByTwitchId(userId) + if (!profile) { + return + } + + const player = await this.#repository.findOrCreatePlayer({ profileId: profile.id, userName }) + + if (firstChar === '!' && possibleCommand) { + if (possibleCommand === 'купон' || possibleCommand === 'coupon') { + return this.handleCouponActivation(otherStrings[0], player.profileId) + } + if (possibleCommand === 'инвентарь' || possibleCommand === 'inventory') { + return this.handleInventoryCommand(player.profileId) + } + } + } + + async handleInventoryCommand(profileId: string) { + const profile = await this.#repository.findProfile(profileId) + if (!profile) { + return { + ok: false, + message: null, + } + } + + const positionInTop = await this.#repository.getPlaceInTopByCoupons(profile.id) + + return { + ok: true, + message: `У тебя есть ${profile.coupons} купон(а/ов). Место в рейтинге: ${positionInTop}`, + } + } + + async handleCouponActivation(id: string, profileId: string) { + const status = await this.#repository.activateCouponByCommand(id, profileId) + if (status === 'OK') { + // Quest + await this.#quest.completeQuest('xu44eon7teobb4a74cd4yvuh', profileId) + + return { + ok: true, + message: 'А ты молодец! +1 купон 🎟️', + } + } + if (status === 'TIME_LIMIT') { + return { + ok: false, + message: 'Неа, один уже взят. Новый - на следующем стриме 🍌', + } + } + if (status === 'TAKEN_ALREADY') { + return { + ok: false, + message: 'Тебя опередили 🔥', + } + } + if (status === 'NOT_FOUND') { + return { + ok: false, + message: null, + } + } + } + + async handleRaid({ + userName, + userId, + viewerCount, + }: { + userName: string + userId: string + viewerCount: number + }) { + log('START_RAID', userName, userId, `${viewerCount} viewers`) + // return this.game.handleActionFromChat({ + // action: 'START_RAID', + // userId, + // userName, + // params: [viewerCount.toString()], + // }) + } + + public async handleChannelRewardRedemption({ + userId, + userName, + rewardId, + message, + }: { + userId: string + userName: string + rewardId: string + message: string + }) { + log('The viewer bought a reward using channel points', userId, userName, rewardId, message) + + // const player = await this.game.repository.findOrCreatePlayer( + // userId, + // userName, + // ) + // if (rewardId === TWITCH_CHANNEL_REWARDS.add150ViewerPointsId) { + // await this.game.repository.addPlayerViewerPoints(player.id, 150) + // return + // } + // if (rewardId === TWITCH_CHANNEL_REWARDS.villainStealFuelId) { + // return this.game.handleActionFromChat({ + // action: 'STEAL_FUEL', + // userId, + // userName, + // }) + // } + // if (rewardId === TWITCH_CHANNEL_REWARDS.addNewIdea) { + // return this.game.handleActionFromChat({ + // action: 'CREATE_IDEA', + // userId, + // userName, + // params: [message], + // }) + // } + } +} diff --git a/apps/website/src/server/utils/twitch/twitch.woodland.controller.ts b/apps/website/src/server/utils/twitch/twitch.woodland.controller.ts new file mode 100644 index 00000000..93c70459 --- /dev/null +++ b/apps/website/src/server/utils/twitch/twitch.woodland.controller.ts @@ -0,0 +1,170 @@ +import { ChatClient } from '@twurple/chat' +import type { Listener } from '@d-fischer/typed-event-emitter' +import { sendMessage } from '../../api/websocket' +import type { ProfileWithTokens } from '@chat-game/types' +import { DBRepository } from '../repository' + +class TwitchWoodlandController { + readonly #repository: DBRepository + #client!: ChatClient + #activeListeners: Listener[] = [] + #loggerId!: ReturnType + #checkerId!: ReturnType + #tokensCount: number = 0 + #streamerProfiles: ProfileWithTokens[] = [] + + constructor() { + this.#repository = new DBRepository() + } + + get status() { + return this.#client?.isConnected ? 'RUNNING' : 'STOPPED' + } + + async serve() { + if (this.#checkerId) { + return + } + + // Checking new Tokens + this.#checkerId = setInterval(async () => { + const now = await this.#repository.getTokensCount('WOODLAND') + if (this.#tokensCount !== now) { + void this.#restart() + this.#tokensCount = now + } + }, 30000) + } + + async #initClient() { + if (this.#client) { + clearInterval(this.#loggerId) + + for (const l of this.#activeListeners) { + this.#client.removeListener(l) + } + + this.#activeListeners = [] + + this.#client.quit() + } + + this.#streamerProfiles = await this.#repository.findAllStreamers('WOODLAND') + + this.#client = new ChatClient({ + channels: this.#streamerProfiles.map((p) => p.userName), + }) + + this.#client.connect() + + const listener = this.#client.onDisconnect(() => { + log('[Woodland]', 'Chat Client disconnected. Trying to reconnect...') + return this.#restart() + }) + this.#activeListeners.push(listener) + } + + async #restart() { + await this.#initClient() + + const listener = this.#client.onMessage(async (channel, user, text, msg) => { + const channelId = msg.channelId + const userId = msg.userInfo.userId + const userName = msg.userInfo.userName + + const profile = this.#streamerProfiles.find((p) => p.twitchId === channelId) + const token = profile?.twitchTokens.find( + (t) => t.status === 'ACTIVE' && t.type === 'WOODLAND' + ) + if (!token) { + return + } + + log('[Woodland]', channel, user, text, channelId ?? '', userId, token.id) + + await this.#handleMessage({ + userId, + userName, + text, + token: token.id, + }) + }) + + this.#activeListeners.push(listener) + + this.#loggerId = setInterval(() => { + log( + '[Woodland]', + this.#client.currentChannels.toString(), + this.#client.isConnected ? 'Connected' : 'Not connected' + ) + }, 60 * 30 * 1000) + + setTimeout(() => { + log( + '[Woodland]', + `Restarted with ${this.#client.currentChannels.length} channels active and ${ + this.#activeListeners.length + } listeners` + ) + }, 10000) + } + + async #handleMessage({ + userName, + userId, + text, + token, + }: { + userName: string + userId: string + text: string + token: string + }) { + const strings = text.split(' ') + + const firstChar = strings[0].charAt(0) + const possibleCommand = strings[0].substring(1) + const otherStrings = strings.toSpliced(0, 1) + + const woodland = await this.#repository.findActiveWoodland(token) + if (!woodland) { + return + } + + const profile = await this.#repository.findProfileByTwitchId(userId) + if (!profile) { + return + } + + const character = await this.#repository.findCharacterByEditionId(profile.activeEditionId) + const player = await this.#repository.findOrCreateWoodlandPlayer({ + profileId: profile.id, + name: userName, + woodlandId: woodland.id, + }) + + // await this.#trophy.updateProgress(player.profileId, text) + + if (firstChar === '!' && possibleCommand) { + return sendMessage( + { + type: 'WOODLAND_COMMAND', + data: { + command: possibleCommand, + params: otherStrings, + player, + profile, + character, + text, + }, + }, + token + ) + } + + sendMessage({ type: 'WOODLAND_MESSAGE', data: { player, profile, character, text } }, token) + } +} + +export const twitchWoodlandController = new TwitchWoodlandController() diff --git a/libs/types/src/lib/types.ts b/libs/types/src/lib/types.ts index 283c751f..be0f87ce 100644 --- a/libs/types/src/lib/types.ts +++ b/libs/types/src/lib/types.ts @@ -485,3 +485,28 @@ export interface PaymentCreateResponse { redirectUrl: string } } + +export interface TwitchAccessTokenResponse { + access_token: string + refresh_token: string + scope: string[] + expires_in: number + token_type: 'bearer' +} + +export interface TwitchAccessToken { + id: string + userId: string + accessToken: string + refreshToken: string | null + scope: string[] + expiresIn: number | null + obtainmentTimestamp: number +} + +export interface WebsiteProfile { + id: string + twitchToken: string + twitchId: string + userName: string +} diff --git a/package.json b/package.json index bb0c2ddb..b2d4e449 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,10 @@ "@radix-ui/colors": "^3.0.0", "@twurple/api": "^7.1.0", "@twurple/auth": "^7.1.0", + "@twurple/chat": "^7.1.0", + "@twurple/easy-bot": "^7.1.0", + "@twurple/eventsub-ws": "^7.1.0", + "@twurple/pubsub": "^7.1.0", "howler": "^2.2.4", "javascript-time-ago": "^2.5.10", "jsonwebtoken": "^9.0.2", diff --git a/yarn.lock b/yarn.lock index 1d71b833..a3a0bb9b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1243,6 +1243,19 @@ "@d-fischer/shared-utils" "^3.6.3" tslib "^2.6.2" +"@d-fischer/connection@^9.0.0": + version "9.0.0" + resolved "https://registry.yarnpkg.com/@d-fischer/connection/-/connection-9.0.0.tgz#8e684bdbc08330380de98d9cc3a61329b603d932" + integrity sha512-Mljp/EbaE+eYWfsFXUOk+RfpbHgrWGL/60JkAvjYixw6KREfi5r17XdUiXe54ByAQox6jwgdN2vebdmW1BT+nQ== + dependencies: + "@d-fischer/isomorphic-ws" "^7.0.0" + "@d-fischer/logger" "^4.2.1" + "@d-fischer/shared-utils" "^3.5.0" + "@d-fischer/typed-event-emitter" "^3.3.0" + "@types/ws" "^8.5.4" + tslib "^2.4.1" + ws "^8.11.0" + "@d-fischer/cross-fetch@^5.0.1": version "5.0.5" resolved "https://registry.yarnpkg.com/@d-fischer/cross-fetch/-/cross-fetch-5.0.5.tgz#5905ac512e8e14110de8d4b92c0c4e7da0e6b5d9" @@ -1250,11 +1263,26 @@ dependencies: node-fetch "^2.6.12" +"@d-fischer/deprecate@^2.0.2": + version "2.0.2" + resolved "https://registry.yarnpkg.com/@d-fischer/deprecate/-/deprecate-2.0.2.tgz#d1f0d40acc881edd771cace7992a1070460608c8" + integrity sha512-wlw3HwEanJFJKctwLzhfOM6LKwR70FPfGZGoKOhWBKyOPXk+3a9Cc6S9zhm6tka7xKtpmfxVIReGUwPnMbIaZg== + "@d-fischer/detect-node@^3.0.1": version "3.0.1" resolved "https://registry.yarnpkg.com/@d-fischer/detect-node/-/detect-node-3.0.1.tgz#7b051a86611b0396ba205aabae805b18cc642a78" integrity sha512-0Rf3XwTzuTh8+oPZW9SfxTIiL+26RRJ0BRPwj5oVjZFyFKmsj9RGfN2zuTRjOuA3FCK/jYm06HOhwNK+8Pfv8w== +"@d-fischer/escape-string-regexp@^5.0.0": + version "5.0.0" + resolved "https://registry.yarnpkg.com/@d-fischer/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz#40b1d52529c5674caf510213efc9c982877d01c4" + integrity sha512-7eoxnxcto5eVPW5h1T+ePnVFukmI9f/ZR9nlBLh1t3kyzJDUNor2C+YW9H/Terw3YnbZSDgDYrpCJCHtOtAQHw== + +"@d-fischer/isomorphic-ws@^7.0.0": + version "7.0.2" + resolved "https://registry.yarnpkg.com/@d-fischer/isomorphic-ws/-/isomorphic-ws-7.0.2.tgz#444eeb82c2f79566ec5e055fab1e2e43b578330c" + integrity sha512-xK+qIJUF0ne3dsjq5Y3BviQ4M+gx9dzkN+dPP7abBMje4YRfow+X9jBgeEoTe5e+Q6+8hI9R0b37Okkk8Vf0hQ== + "@d-fischer/logger@^4.2.1", "@d-fischer/logger@^4.2.3": version "4.2.3" resolved "https://registry.yarnpkg.com/@d-fischer/logger/-/logger-4.2.3.tgz#1c8f64a2dd560eb623b00eadffbd033a290fcc38" @@ -1278,14 +1306,14 @@ "@d-fischer/shared-utils" "^3.6.3" tslib "^2.6.2" -"@d-fischer/shared-utils@^3.2.0", "@d-fischer/shared-utils@^3.6.1", "@d-fischer/shared-utils@^3.6.3": +"@d-fischer/shared-utils@^3.2.0", "@d-fischer/shared-utils@^3.5.0", "@d-fischer/shared-utils@^3.6.1", "@d-fischer/shared-utils@^3.6.3": version "3.6.3" resolved "https://registry.yarnpkg.com/@d-fischer/shared-utils/-/shared-utils-3.6.3.tgz#1178633bf5fe715cf6222a51f2b3169132496ba8" integrity sha512-Lz+Qk1WJLVoeREOHPZcIDTHOoxecxMSG2sq+x1xWYCH1exqiMKMMx06pXdy15UzHG7ohvQRNXk2oHqZ9EOl9jQ== dependencies: tslib "^2.4.1" -"@d-fischer/typed-event-emitter@^3.3.1": +"@d-fischer/typed-event-emitter@^3.3.0", "@d-fischer/typed-event-emitter@^3.3.1": version "3.3.3" resolved "https://registry.yarnpkg.com/@d-fischer/typed-event-emitter/-/typed-event-emitter-3.3.3.tgz#d65fcf7756f3503bd56ed41bc916dc181d30597a" integrity sha512-OvSEOa8icfdWDqcRtjSEZtgJTFOFNgTjje7zaL0+nAtu2/kZtRCSK5wUMrI/aXtCH8o0Qz2vA8UqkhWUTARFQQ== @@ -3504,7 +3532,7 @@ "@twurple/common" "7.1.0" tslib "^2.0.3" -"@twurple/api@^7.1.0": +"@twurple/api@7.1.0", "@twurple/api@^7.1.0": version "7.1.0" resolved "https://registry.yarnpkg.com/@twurple/api/-/api-7.1.0.tgz#a46f009c9c681f31e24d94bd6e63d12eec68b77a" integrity sha512-cDVVY+vecMFNEOyp7UobQn4ARydIDf04NZy1YCKIKpJHBuOV/pkTjNGluRZ0nR9/t9hBFfOyHAH4JswRZpZbnw== @@ -3521,7 +3549,7 @@ retry "^0.13.1" tslib "^2.0.3" -"@twurple/auth@^7.1.0": +"@twurple/auth@7.1.0", "@twurple/auth@^7.1.0": version "7.1.0" resolved "https://registry.yarnpkg.com/@twurple/auth/-/auth-7.1.0.tgz#f9287be00cb8675af812a3120692f8408065116e" integrity sha512-OT7XtoXeYA8yLvCKdIZ76x71D/RfxPZQqufpimy5ZSL4+TpxY1CJNFp8YWstC1KEfyGVwyr7ZoV49u95k0JJmw== @@ -3533,6 +3561,21 @@ "@twurple/common" "7.1.0" tslib "^2.0.3" +"@twurple/chat@7.1.0", "@twurple/chat@^7.1.0": + version "7.1.0" + resolved "https://registry.yarnpkg.com/@twurple/chat/-/chat-7.1.0.tgz#f95a6502ecf94bb46b08bbc88fd3bcd34110bc02" + integrity sha512-AzLtq+xqbyYpqPZau5jvX3Dov+C7MW1YTunYZZ5TqyQlEb/leUD6LdwdhXhsVxQUfpVD1FhU1NSlNd7VEhv0Rg== + dependencies: + "@d-fischer/cache-decorators" "^4.0.0" + "@d-fischer/deprecate" "^2.0.2" + "@d-fischer/logger" "^4.2.1" + "@d-fischer/rate-limiter" "^1.0.0" + "@d-fischer/shared-utils" "^3.6.1" + "@d-fischer/typed-event-emitter" "^3.3.0" + "@twurple/common" "7.1.0" + ircv3 "^0.33.0" + tslib "^2.0.3" + "@twurple/common@7.1.0": version "7.1.0" resolved "https://registry.yarnpkg.com/@twurple/common/-/common-7.1.0.tgz#a4d4797355d0d70c275c9f0347c014b5e9f2d275" @@ -3542,6 +3585,59 @@ klona "^2.0.4" tslib "^2.0.3" +"@twurple/easy-bot@^7.1.0": + version "7.1.0" + resolved "https://registry.yarnpkg.com/@twurple/easy-bot/-/easy-bot-7.1.0.tgz#fe11bd80b65328236e14a2c09e2c17f965c95c6f" + integrity sha512-NBuNIAS3aJYzdDMr48kNbc1RdnmKL/abJf5HJgnlE7PP/ndthQYS3S/RaNkR/DIouvbJAmgSsYRj/7UlyZ3lZQ== + dependencies: + "@d-fischer/logger" "^4.2.1" + "@d-fischer/shared-utils" "^3.6.1" + "@d-fischer/typed-event-emitter" "^3.3.0" + "@twurple/api" "7.1.0" + "@twurple/auth" "7.1.0" + "@twurple/chat" "7.1.0" + "@twurple/common" "7.1.0" + tslib "^2.0.3" + +"@twurple/eventsub-base@7.1.0": + version "7.1.0" + resolved "https://registry.yarnpkg.com/@twurple/eventsub-base/-/eventsub-base-7.1.0.tgz#2fedfd0ace0e95209915993ba2443193b2348aa9" + integrity sha512-3FNmSwhf09yWYQwhkc+EjmEngbMLbmMPJvJ4m30X9duuhFqvZcd0XnnRvHWSd4qpiolJb0BPerqP2EGXlGjElA== + dependencies: + "@d-fischer/logger" "^4.2.1" + "@d-fischer/shared-utils" "^3.6.1" + "@d-fischer/typed-event-emitter" "^3.3.0" + "@twurple/api" "7.1.0" + "@twurple/auth" "7.1.0" + "@twurple/common" "7.1.0" + tslib "^2.0.3" + +"@twurple/eventsub-ws@^7.1.0": + version "7.1.0" + resolved "https://registry.yarnpkg.com/@twurple/eventsub-ws/-/eventsub-ws-7.1.0.tgz#fed93415183d136aab0288e9e827a50c470e7962" + integrity sha512-0ZOPAGvStqjBTT2Vjtz6euxgtcb8U4cQ23TOaUssgUYJ6hy34ebmLxt6Ghj/5tJt11sduYAxHRvw+XKTSjGIoA== + dependencies: + "@d-fischer/connection" "^9.0.0" + "@d-fischer/logger" "^4.2.1" + "@d-fischer/shared-utils" "^3.6.1" + "@d-fischer/typed-event-emitter" "^3.3.0" + "@twurple/auth" "7.1.0" + "@twurple/common" "7.1.0" + "@twurple/eventsub-base" "7.1.0" + tslib "^2.0.3" + +"@twurple/pubsub@^7.1.0": + version "7.1.0" + resolved "https://registry.yarnpkg.com/@twurple/pubsub/-/pubsub-7.1.0.tgz#a54ac2e784de69b0841371325f474c105ac1971d" + integrity sha512-2YMiktQbHPiPqCNzdlQ2OOMLVHNiy5tjRImkVBHNjw0tqCsbrCUhmFwgKjiIiDQUTTzOcNdFhDNhlcgy5Mz65A== + dependencies: + "@d-fischer/connection" "^9.0.0" + "@d-fischer/logger" "^4.2.1" + "@d-fischer/shared-utils" "^3.6.1" + "@d-fischer/typed-event-emitter" "^3.3.0" + "@twurple/common" "7.1.0" + tslib "^2.0.3" + "@tybys/wasm-util@^0.9.0": version "0.9.0" resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.9.0.tgz#3e75eb00604c8d6db470bf18c37b7d984a0e3355" @@ -3805,7 +3901,7 @@ resolved "https://registry.yarnpkg.com/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz#f066abfcd1cbe66267cdbbf0de010d8a41b41597" integrity sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow== -"@types/ws@^8.5.10": +"@types/ws@^8.5.10", "@types/ws@^8.5.4": version "8.5.12" resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.5.12.tgz#619475fe98f35ccca2a2f6c137702d85ec247b7e" integrity sha512-3tPRkv1EtkDpzlgyKyI8pGsGZAGPEaXeu0DOj5DI25Ja91bdAYddYHbADRYVrZMRbfW+1l5YwXVDKohDJNQxkQ== @@ -8380,6 +8476,19 @@ ipaddr.js@^2.1.0: resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-2.2.0.tgz#d33fa7bac284f4de7af949638c9d68157c6b92e8" integrity sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA== +ircv3@^0.33.0: + version "0.33.0" + resolved "https://registry.yarnpkg.com/ircv3/-/ircv3-0.33.0.tgz#13b7a308f8604c088579ebf9615bb0ad457a78aa" + integrity sha512-7rK1Aial3LBiFycE8w3MHiBBFb41/2GG2Ll/fR2IJj1vx0pLpn1s+78K+z/I4PZTqCCSp/Sb4QgKMh3NMhx0Kg== + dependencies: + "@d-fischer/connection" "^9.0.0" + "@d-fischer/escape-string-regexp" "^5.0.0" + "@d-fischer/logger" "^4.2.1" + "@d-fischer/shared-utils" "^3.5.0" + "@d-fischer/typed-event-emitter" "^3.3.0" + klona "^2.0.5" + tslib "^2.4.1" + iron-webcrypto@^1.1.1: version "1.2.1" resolved "https://registry.yarnpkg.com/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz#aa60ff2aa10550630f4c0b11fd2442becdb35a6f" @@ -13705,7 +13814,7 @@ ws@8.17.1: resolved "https://registry.yarnpkg.com/ws/-/ws-8.17.1.tgz#9293da530bb548febc95371d90f9c878727d919b" integrity sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ== -ws@^8.13.0, ws@^8.16.0, ws@^8.17.1, ws@^8.18.0: +ws@^8.11.0, ws@^8.13.0, ws@^8.16.0, ws@^8.17.1, ws@^8.18.0: version "8.18.0" resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.0.tgz#0d7505a6eafe2b0e712d232b42279f53bc289bbc" integrity sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==