diff --git a/docker-compose.yml b/docker-compose.yml index d223b44f..ce896db9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,7 +5,7 @@ services: restart: on-failure twitch-chat: - image: "ghcr.io/dotabod/twitch-chat:v2.0" + image: "ghcr.io/dotabod/twitch-chat:v2.1" platform: linux/amd64 container_name: twitch-chat restart: on-failure @@ -31,7 +31,7 @@ services: - ./packages/twitch/chat/locales:/app/packages/twitch/chat/locales steam: - image: "ghcr.io/dotabod/steam:v2.0" + image: "ghcr.io/dotabod/steam:v2.1" platform: linux/amd64 container_name: steam restart: on-failure @@ -48,11 +48,9 @@ services: - STEAM_USER - STEAM_WEB_API - NODE_ENV - volumes: - - ./packages/steam/locales:/app/packages/steam/locales twitch-events: - image: "ghcr.io/dotabod/twitch-events:v2.0" + image: "ghcr.io/dotabod/twitch-events:v2.1" platform: linux/amd64 container_name: twitch-events restart: on-failure @@ -78,7 +76,7 @@ services: - TWITCH_EVENTSUB_SECRET dota: - image: "ghcr.io/dotabod/dota:v2.0" + image: "ghcr.io/dotabod/dota:v2.1" platform: linux/amd64 container_name: dota restart: on-failure @@ -121,7 +119,7 @@ services: nginx: container_name: nginx restart: on-failure - image: "ghcr.io/dotabod/nginx:v2.0" + image: "ghcr.io/dotabod/nginx:v2.1" platform: linux/amd64 volumes: - ./services/nginx/default.conf:/etc/nginx/templates/default.conf.template diff --git a/packages/dota/src/dota/events/gsi-events/newdata.ts b/packages/dota/src/dota/events/gsi-events/newdata.ts index 75360c17..fbed1976 100644 --- a/packages/dota/src/dota/events/gsi-events/newdata.ts +++ b/packages/dota/src/dota/events/gsi-events/newdata.ts @@ -63,7 +63,6 @@ async function saveMatchData(client: SocketClient) { if (!steamServerId && !lobbyType) { if (steamServerLookupMap.has(matchId)) return - // Wrap the steamSocket.emit in a Promise const getDelayedDataPromise = new Promise((resolve, reject) => { steamSocket.emit('getUserSteamServer', client.steam32Id, (err: any, cards: any) => { if (err) { diff --git a/packages/dota/src/dota/lib/getPlayers.ts b/packages/dota/src/dota/lib/getPlayers.ts index 7ddc6891..4ab68e9a 100644 --- a/packages/dota/src/dota/lib/getPlayers.ts +++ b/packages/dota/src/dota/lib/getPlayers.ts @@ -41,7 +41,7 @@ export async function getPlayers({ }) const getCardsPromise = new Promise((resolve, reject) => { - steamSocket.emit('getCards', accountIds, (err: any, cards: any) => { + steamSocket.emit('getCards', accountIds, false, (err: any, cards: any) => { if (err) { reject(err) } else { diff --git a/packages/dota/src/steam/ws.ts b/packages/dota/src/steam/ws.ts index 4c9a1420..858eb3ec 100644 --- a/packages/dota/src/steam/ws.ts +++ b/packages/dota/src/steam/ws.ts @@ -5,5 +5,5 @@ import { logger } from '../utils/logger.js' export const steamSocket = io('ws://steam:5035') steamSocket.on('connect', () => { - logger.info('We alive on dotabod steam server!') + logger.info('We alive on steamSocket steam server!') }) diff --git a/packages/dota/src/twitch/commands/items.ts b/packages/dota/src/twitch/commands/items.ts index 14f2e6db..bd5e0e3a 100644 --- a/packages/dota/src/twitch/commands/items.ts +++ b/packages/dota/src/twitch/commands/items.ts @@ -49,7 +49,7 @@ async function getItems({ locale: string command: string }) { - const { hero, items, playerIdx, player } = await profileLink({ + const { hero, items, playerIdx } = await profileLink({ command, packet, locale, @@ -81,7 +81,6 @@ async function getItems({ throw new CustomError(t('missingMatchData', { emote: 'PauseChamp', lng: locale })) } - // Wrap the steamSocket.emit in a Promise const getDelayedDataPromise = new Promise((resolve, reject) => { steamSocket.emit( 'getRealTimeStats', diff --git a/packages/dota/src/twitch/commands/test.ts b/packages/dota/src/twitch/commands/test.ts index 4e7066e3..3448495a 100644 --- a/packages/dota/src/twitch/commands/test.ts +++ b/packages/dota/src/twitch/commands/test.ts @@ -43,7 +43,6 @@ commandHandler.registerCommand('test', { steamServerId, }) - // Wrap the steamSocket.emit in a Promise const getDelayedDataPromise = new Promise((resolve, reject) => { steamSocket.emit( 'getRealTimeStats', @@ -91,7 +90,7 @@ commandHandler.registerCommand('test', { const { accountIds } = await getAccountsFromMatch({ gsi: client.gsi, }) - steamSocket.emit('getCards', accountIds, (err: any, response: any) => { + steamSocket.emit('getCards', accountIds, false, (err: any, response: any) => { console.log(response, err) // one response per client }) diff --git a/packages/steam/src/index.ts b/packages/steam/src/index.ts index a9fe301d..0a178e0e 100644 --- a/packages/steam/src/index.ts +++ b/packages/steam/src/index.ts @@ -37,14 +37,17 @@ io.on('connection', (socket) => { hasDotabodSocket = false }) - socket.on('getCards', async function (accountIds: number[], callback: callback) { - if (!isConnectedToSteam) return - try { - callback(null, await dota.getCards(accountIds)) - } catch (e: any) { - callback(e.message, null) - } - }) + socket.on( + 'getCards', + async function (accountIds: number[], refetchCards: boolean, callback: callback) { + if (!isConnectedToSteam) return + try { + callback(null, await dota.getCards(accountIds, refetchCards)) + } catch (e: any) { + callback(e.message, null) + } + }, + ) socket.on('getCard', async function (accountId: number, callback: callback) { if (!isConnectedToSteam) return diff --git a/packages/steam/src/steam.ts b/packages/steam/src/steam.ts index e8918db9..b711ab97 100644 --- a/packages/steam/src/steam.ts +++ b/packages/steam/src/steam.ts @@ -11,7 +11,7 @@ import Steam from 'steam' import steamErrors from 'steam-errors' import MongoDBSingleton from './MongoDBSingleton.js' -import { Cards, DelayedGames, GCMatchData } from './types/index.js' +import { Cards, DelayedGames } from './types/index.js' import CustomError from './utils/customError.js' import { getAccountsFromMatch } from './utils/getAccountsFromMatch.js' import { logger } from './utils/logger.js' @@ -19,8 +19,53 @@ import { retryCustom } from './utils/retry.js' import io from './index.js' +interface steamUserDetails { + account_name: string + password: string + sha_sentryfile?: Buffer +} + +interface CacheEntry { + timestamp: number + card: Cards +} + +const MAX_CACHE_SIZE = 5000 +const CACHE_TTL = 10 * 60 * 1000 // 10 minutes + const isDev = process.env.NODE_ENV === 'development' +function onGCSpectateFriendGameResponse(message: any, callback: any) { + const response: { server_steamid: Long; watch_live_result: number } = + Dota2.schema.CMsgSpectateFriendGameResponse.decode(message) + if (callback !== undefined) { + callback(response) + } +} + +Dota2.Dota2Client.prototype.spectateFriendGame = function ( + friend: { steam_id: number; live: boolean }, + callback: any, +) { + callback = callback || null + if (!this._gcReady) { + logger.info("[STEAM] GC not ready, please listen for the 'ready' event.") + return null + } + // CMsgSpectateFriendGame + const payload = new Dota2.schema.CMsgSpectateFriendGame(friend) + this.sendToGC( + Dota2.schema.EDOTAGCMsg.k_EMsgGCSpectateFriendGame, + payload, + onGCSpectateFriendGameResponse, + callback, + ) +} + +const handlers = Dota2.Dota2Client.prototype._handlers +handlers[Dota2.schema.EDOTAGCMsg.k_EMsgGCSpectateFriendGameResponse] = + onGCSpectateFriendGameResponse + // Fetches data from MongoDB const fetchDataFromMongo = async (match_id: string) => { const mongo = MongoDBSingleton @@ -70,43 +115,6 @@ const saveMatch = async ({ } } -function onGCSpectateFriendGameResponse(message: any, callback: any) { - const response: { server_steamid: Long; watch_live_result: number } = - Dota2.schema.CMsgSpectateFriendGameResponse.decode(message) - if (callback !== undefined) { - callback(response) - } -} - -Dota2.Dota2Client.prototype.spectateFriendGame = function ( - friend: { steam_id: number; live: boolean }, - callback: any, -) { - callback = callback || null - if (!this._gcReady) { - logger.info("[STEAM] GC not ready, please listen for the 'ready' event.") - return null - } - // CMsgSpectateFriendGame - const payload = new Dota2.schema.CMsgSpectateFriendGame(friend) - this.sendToGC( - Dota2.schema.EDOTAGCMsg.k_EMsgGCSpectateFriendGame, - payload, - onGCSpectateFriendGameResponse, - callback, - ) -} - -const handlers = Dota2.Dota2Client.prototype._handlers -handlers[Dota2.schema.EDOTAGCMsg.k_EMsgGCSpectateFriendGameResponse] = - onGCSpectateFriendGameResponse - -interface steamUserDetails { - account_name: string - password: string - sha_sentryfile?: Buffer -} - function hasSteamData(game?: DelayedGames | null) { const hasTeams = Array.isArray(game?.teams) && game?.teams.length === 2 const hasPlayers = @@ -131,6 +139,7 @@ function hasSteamData(game?: DelayedGames | null) { class Dota { private static instance: Dota + private cache: Map = new Map() private steamClient @@ -297,6 +306,157 @@ class Dota { }) } + fetchAndUpdateCard = async (accountId: number) => { + let fetchedCard = { + rank_tier: -10, + leaderboard_rank: 0, + } + + if (accountId) { + fetchedCard = await retryCustom(() => this.getCard(accountId)).catch(() => fetchedCard) + } + + const card = { + ...fetchedCard, + account_id: accountId, + createdAt: new Date(), + rank_tier: fetchedCard?.rank_tier ?? 0, + leaderboard_rank: fetchedCard?.leaderboard_rank ?? 0, + } as Cards + + if (!accountId) return card + + if (fetchedCard?.rank_tier !== -10) { + const mongo = MongoDBSingleton + const db = await mongo.connect() + + try { + await db + .collection('cards') + .updateOne({ account_id: accountId }, { $set: card }, { upsert: true }) + } finally { + await mongo.close() + } + } + + return card + } + + private async fetchProfileCard(account: number): Promise { + return new Promise((resolve, reject) => { + // @ts-expect-error no types exist for `loggedOn` + if (!this.dota2._gcReady || !this.steamClient.loggedOn) + reject(new CustomError('Error getting medal')) + else { + this.dota2.requestProfileCard(account, (err: any, card: Cards) => { + if (err) reject(err) + resolve(card) + }) + } + }) + } + + promiseTimeout = (promise: Promise, ms: number, reason: string): Promise => + new Promise((resolve, reject) => { + let timeoutCleared = false + const timeoutId = setTimeout(() => { + timeoutCleared = true + reject(new CustomError(reason)) + }, ms) + promise + .then((result) => { + if (!timeoutCleared) { + clearTimeout(timeoutId) + resolve(result) + } + }) + .catch((err) => { + if (!timeoutCleared) { + clearTimeout(timeoutId) + reject(err) + } + }) + }) + + public async getCard(account: number): Promise { + const now = Date.now() + const cacheEntry = this.cache.get(account) + + if (cacheEntry && now - cacheEntry.timestamp < CACHE_TTL) { + return cacheEntry.card + } + + // If not cached or cache is stale, fetch the profile card + const card = await this.promiseTimeout( + this.fetchProfileCard(account), + 1000, + 'Error getting medal', + ) + + this.evictOldCacheEntries() // Evict entries based on time + this.cache.set(account, { + timestamp: now, + card: card, + }) + + this.evictExtraCacheEntries() // Evict extra entries if cache size exceeds MAX_CACHE_SIZE + + return card + } + + private evictOldCacheEntries() { + const now = Date.now() + for (const [key, value] of this.cache.entries()) { + if (now - value.timestamp > CACHE_TTL) { + this.cache.delete(key) + } + } + } + + private evictExtraCacheEntries() { + while (this.cache.size > MAX_CACHE_SIZE) { + const oldestKey = [...this.cache.entries()].reduce( + (oldest, [key, entry]) => { + if (!oldest) return key + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return entry.timestamp < this.cache.get(oldest)!.timestamp ? key : oldest + }, + null as number | null, + ) + + if (oldestKey !== null) { + this.cache.delete(oldestKey) + } + } + } + + public async getCards(accounts: number[], refetchCards = false): Promise { + const mongo = MongoDBSingleton + const db = await mongo.connect() + + try { + const cardsFromDb = await db + .collection('cards') + .find({ account_id: { $in: accounts.filter((a) => !!a) } }) + .sort({ createdAt: -1 }) + .toArray() + + const cardsMap = new Map(cardsFromDb.map((card) => [card.account_id, card])) + + const promises = accounts.map(async (accountId) => { + const existingCard = cardsMap.get(accountId) + if (refetchCards || !existingCard || typeof existingCard.rank_tier !== 'number') { + return this.fetchAndUpdateCard(accountId) + } + return existingCard + }) + + return Promise.all(promises) + } finally { + await mongo.close() + } + } + public GetRealTimeStats = async ({ match_id, refetchCards = false, @@ -375,131 +535,11 @@ class Dota { }) } - // @DEPRECATED - public getGcMatchData( - matchId: number | string, - cb: (err: number | null, body: GCMatchData | null) => void, - ) { - const operation = retry.operation({ - retries: 8, - factor: 1, - minTimeout: 2000, - }) - - operation.attempt((currentAttempt: number) => { - logger.info('[STEAM] requesting match', { matchId, currentAttempt }) - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return this.dota2.requestMatchDetails( - Number(matchId), - (err: number | null, body: GCMatchData | null) => { - err && logger.error(err) - if (operation.retry(err ? new Error('Match not found') : undefined)) return - - let arr: Error | undefined - if (body?.match?.players) { - body.match.players = body.match.players.map((p: any) => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return { - ...p, - party_size: body.match?.players.filter( - (matchPlayer: any) => matchPlayer.party_id?.low === p.party_id?.low, - ).length, - } - }) - - logger.info('[STEAM] received match', { matchId }) - } else { - arr = new Error('Match not found') - } - - if (operation.retry(arr)) return - - cb(err, body) - }, - ) - }) - } - public static getInstance(): Dota { if (!Dota.instance) Dota.instance = new Dota() return Dota.instance } - fetchAndUpdateCard = async (accountId: number) => { - const fetchedCard = accountId - ? await retryCustom(() => this.getCard(accountId)).catch(() => ({ - rank_tier: -10, - leaderboard_rank: 0, - })) - : undefined - - const card = { - ...fetchedCard, - account_id: accountId, - createdAt: new Date(), - rank_tier: fetchedCard?.rank_tier ?? 0, - leaderboard_rank: fetchedCard?.leaderboard_rank ?? 0, - } as Cards - - if (!accountId) return card - - if (fetchedCard?.rank_tier !== -10) { - const mongo = MongoDBSingleton - const db = await mongo.connect() - - try { - await db - .collection('cards') - .updateOne({ account_id: accountId }, { $set: card }, { upsert: true }) - } finally { - await mongo.close() - } - } - - return card - } - - public async getCards(accounts: number[], refetchCards = false): Promise { - const mongo = MongoDBSingleton - const db = await mongo.connect() - - try { - const cardsFromDb = await db - .collection('cards') - .find({ account_id: { $in: accounts.filter((a) => !!a) } }) - .sort({ createdAt: -1 }) - .toArray() - - const cardsMap = new Map(cardsFromDb.map((card) => [card.account_id, card])) - - const promises = accounts.map(async (accountId) => { - const existingCard = cardsMap.get(accountId) - if (refetchCards || !existingCard || typeof existingCard.rank_tier !== 'number') { - return this.fetchAndUpdateCard(accountId) - } - return existingCard - }) - - return Promise.all(promises) - } finally { - await mongo.close() - } - } - - public async getCard(account: number): Promise { - // @ts-expect-error no types exist for `loggedOn` - if (!this.dota2._gcReady || !this.steamClient.loggedOn) { - throw new CustomError('Error getting medal') - } - - return new Promise((resolve, reject) => { - this.dota2.requestProfileCard(account, (err: any, card: Cards) => { - if (err) reject(err) - else resolve(card) - }) - }) - } - public exit(): Promise { return new Promise((resolve) => { this.dota2.exit()