From c9be6c8ffb869f8459a9a6f2d7244df77f27e663 Mon Sep 17 00:00:00 2001 From: Matt Date: Sun, 17 Sep 2023 13:48:34 -0500 Subject: [PATCH] split steam into its own service --- docker-compose-dev.yml | 5 + docker-compose.yml | 25 +- package.json | 3 +- packages/Dockerfile.steam | 62 + packages/dota/package.json | 3 - packages/dota/src/dota/GSIServer.ts | 18 +- .../src/dota/events/gsi-events/newdata.ts | 63 +- packages/dota/src/dota/lib/getPlayers.ts | 18 +- packages/dota/src/dota/lib/ranks.ts | 15 +- packages/dota/src/steam/ws.ts | 9 + packages/dota/src/twitch/commands/items.ts | 29 +- packages/dota/src/twitch/commands/test.ts | 56 +- packages/dota/src/utils/index.ts | 33 - packages/steam/.eslintrc.cjs | 92 ++ packages/steam/.gitignore | 7 + packages/steam/.nvmrc | 1 + packages/steam/.prettierrc | 11 + packages/steam/.unimportedrc.json | 17 + packages/steam/package.json | 75 ++ packages/steam/src/MongoDBSingleton.ts | 62 + packages/steam/src/getHero.ts | 8 + packages/steam/src/heroes.ts | 704 +++++++++++ packages/steam/src/index.ts | 56 + .../src/steam/index.ts => steam/src/steam.ts} | 1070 +++++++++-------- packages/steam/src/types/index.ts | 526 ++++++++ packages/steam/src/utils/customError.ts | 7 + .../steam/src/utils/getAccountsFromMatch.ts | 58 + .../steam/src/utils/getSpectatorPlayers.ts | 27 + packages/steam/src/utils/logger.ts | 31 + packages/steam/src/utils/retry.ts | 30 + packages/steam/tsconfig.json | 13 + 31 files changed, 2479 insertions(+), 655 deletions(-) create mode 100644 packages/Dockerfile.steam create mode 100644 packages/dota/src/steam/ws.ts create mode 100644 packages/steam/.eslintrc.cjs create mode 100644 packages/steam/.gitignore create mode 100644 packages/steam/.nvmrc create mode 100644 packages/steam/.prettierrc create mode 100644 packages/steam/.unimportedrc.json create mode 100644 packages/steam/package.json create mode 100644 packages/steam/src/MongoDBSingleton.ts create mode 100644 packages/steam/src/getHero.ts create mode 100644 packages/steam/src/heroes.ts create mode 100644 packages/steam/src/index.ts rename packages/{dota/src/steam/index.ts => steam/src/steam.ts} (92%) create mode 100644 packages/steam/src/types/index.ts create mode 100644 packages/steam/src/utils/customError.ts create mode 100644 packages/steam/src/utils/getAccountsFromMatch.ts create mode 100644 packages/steam/src/utils/getSpectatorPlayers.ts create mode 100644 packages/steam/src/utils/logger.ts create mode 100644 packages/steam/src/utils/retry.ts create mode 100644 packages/steam/tsconfig.json diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml index 20f6e4cc..d4e7ddbb 100644 --- a/docker-compose-dev.yml +++ b/docker-compose-dev.yml @@ -4,6 +4,11 @@ services: volumes: - ./packages/twitch/chat/src:/app/packages/twitch/chat/src + steam: + image: "ghcr.io/dotabod/steam:v2.0" + volumes: + - ./packages/steam/src:/app/packages/steam/src + twitch-events: image: "ghcr.io/dotabod/twitch-events:v2.0" volumes: diff --git a/docker-compose.yml b/docker-compose.yml index 53833791..bebd122d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,6 +33,29 @@ services: volumes: - ./packages/twitch/chat/locales:/app/packages/twitch/chat/locales + steam: + image: "ghcr.io/dotabod/steam:v2.0" + platform: linux/amd64 + container_name: steam + restart: on-failure + build: + context: . + dockerfile: ./packages/Dockerfile.steam + args: + - NODE_ENV=${NODE_ENV:-development} + - BUILD_CONTEXT=packages/steam + hostname: steam + ports: + - "5035:5035" + environment: + - MONGO_URL + - STEAM_PASS + - 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" platform: linux/amd64 @@ -97,8 +120,6 @@ services: - NEW_RELIC_LOG=stdout - NEW_RELIC_LICENSE_KEY - NODE_ENV - - STEAM_PASS - - STEAM_USER - STEAM_WEB_API - TWITCH_BOT_PROVIDERID - TWITCH_CLIENT_ID diff --git a/package.json b/package.json index dcb23dbf..36b71ab2 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "packages/settings", "packages/twitch/events", "packages/twitch/chat", - "packages/dota" + "packages/dota", + "packages/steam" ], "scripts": { "up": "yarn upgrade-interactive --latest", diff --git a/packages/Dockerfile.steam b/packages/Dockerfile.steam new file mode 100644 index 00000000..7bf48915 --- /dev/null +++ b/packages/Dockerfile.steam @@ -0,0 +1,62 @@ +# Using a more specific version to ensure reproducibility +FROM node:16-alpine3.17 as base + +# Add Git, Python3, make, g++ in a single RUN command +RUN apk add --no-cache git python3 make g++ \ + && yarn cache clean + +# Set build context and work directories +ARG BUILD_CONTEXT +WORKDIR /app + +# Copy just the relevant package.json and yarn.lock files +COPY package.json yarn.lock* ./ +COPY $BUILD_CONTEXT/package.json ./$BUILD_CONTEXT/ + +# Install dependencies +RUN yarn install --pure-lockfile --non-interactive + +#------------------------- + +FROM base as builder + +# Copy source code and build configurations +COPY tsconfig.json ./ +COPY $BUILD_CONTEXT/tsconfig.json $BUILD_CONTEXT/ +COPY $BUILD_CONTEXT/src $BUILD_CONTEXT/src/ + +WORKDIR /app/ +RUN yarn build + +#------------------------- + +FROM node:16-alpine3.17 as prod + +ARG BUILD_CONTEXT +ARG NODE_ENV + +# Meta-data and labels +LABEL org.opencontainers.image.source="https://github.com/dotabod/backend" \ + org.opencontainers.image.description="Dotabod container: ${BUILD_CONTEXT}" \ + org.opencontainers.image.licenses="AGPL-3.0" + +# Create unprivileged user and switch to it +RUN adduser -D dotadockeruser +USER dotadockeruser + +# Copy relevant build artifacts +WORKDIR /app +COPY --from=builder --chown=dotadockeruser /app/package.json /app/tsconfig.json ./ +COPY --from=builder --chown=dotadockeruser /app/$BUILD_CONTEXT/package.json ./$BUILD_CONTEXT/ +COPY --from=builder --chown=dotadockeruser /app/$BUILD_CONTEXT/dist $BUILD_CONTEXT/dist/ + +# Create required directories +RUN mkdir -p $BUILD_CONTEXT/src/steam/volumes + +# Copy node_modules +COPY --from=base --chown=dotadockeruser /app/node_modules ./node_modules + +# Environment and CMD +WORKDIR /app/$BUILD_CONTEXT +ENV NODE_ENV=$NODE_ENV +CMD yarn docker:$NODE_ENV diff --git a/packages/dota/package.json b/packages/dota/package.json index eda1313b..f978fefd 100644 --- a/packages/dota/package.json +++ b/packages/dota/package.json @@ -31,7 +31,6 @@ "body-parser": "^1.20.2", "chokidar": "^3.5.3", "country-code-emoji": "^2.3.0", - "dota2": "https://github.com/dotabod/node-dota2.git", "dotaconstants": "^7.18.0", "express": "^4.18.2", "lodash.isequal": "^4.5.0", @@ -43,8 +42,6 @@ "retry": "^0.13.1", "socket.io": "^4.7.2", "socket.io-client": "^4.7.2", - "steam": "https://github.com/dotabod/node-steam", - "steam-errors": "^1.0.0", "winston": "^3.10.0" }, "devDependencies": { diff --git a/packages/dota/src/dota/GSIServer.ts b/packages/dota/src/dota/GSIServer.ts index 706b9175..42a8b08a 100644 --- a/packages/dota/src/dota/GSIServer.ts +++ b/packages/dota/src/dota/GSIServer.ts @@ -3,11 +3,10 @@ import http from 'http' import { Server, Socket } from 'socket.io' import getDBUser from '../db/getDBUser.js' -import Dota from '../steam/index.js' import { logger } from '../utils/logger.js' import { newData, processChanges } from './globalEventEmitter.js' import { emitMinimapBlockerStatus } from './GSIHandler.js' -import { gsiHandlers, isDev } from './lib/consts.js' +import { gsiHandlers } from './lib/consts.js' import { validateToken } from './validateToken.js' function handleSocketAuth(socket: Socket, next: (err?: Error) => void) { @@ -47,11 +46,9 @@ async function handleSocketConnection(socket: Socket) { class GSIServer { io: Server - dota: Dota constructor() { logger.info('Starting GSI Server!') - this.dota = Dota.getInstance() const app = express() const httpServer = http.createServer(app) @@ -65,18 +62,7 @@ class GSIServer { app.use(express.json({ limit: '1mb' })) app.use(express.urlencoded({ extended: true, limit: '1mb' })) - const setupPostRoute = () => { - app.post('/', validateToken, processChanges('previously'), processChanges('added'), newData) - } - - if (isDev) { - setupPostRoute() - } else { - this.dota.dota2.on('ready', () => { - logger.info('[SERVER] Connected to dota game coordinator') - setupPostRoute() - }) - } + app.post('/', validateToken, processChanges('previously'), processChanges('added'), newData) app.get('/', (req: Request, res: Response) => { res.status(200).json({ status: 'ok' }) diff --git a/packages/dota/src/dota/events/gsi-events/newdata.ts b/packages/dota/src/dota/events/gsi-events/newdata.ts index 2e6a6f99..75360c17 100644 --- a/packages/dota/src/dota/events/gsi-events/newdata.ts +++ b/packages/dota/src/dota/events/gsi-events/newdata.ts @@ -1,11 +1,11 @@ import { DBSettings, getValueOrDefault } from '@dotabod/settings' import { t } from 'i18next' -import { Packet, SocketClient, validEventTypes } from '../../../types.js' +import { steamSocket } from '../../../steam/ws.js' +import { DelayedGames, Packet, SocketClient, validEventTypes } from '../../../types.js' import { logger } from '../../../utils/logger.js' import { events } from '../../globalEventEmitter.js' import { GSIHandler, redisClient } from '../../GSIHandler.js' -import { server } from '../../index.js' import { checkPassiveMidas } from '../../lib/checkMidas.js' import { checkPassiveTp } from '../../lib/checkPassiveTp.js' import { calculateManaSaved } from '../../lib/checkTreadToggle.js' @@ -35,8 +35,8 @@ function chatterMatchFound(client: SocketClient) { } } -const steamServerLookupMap = new Map() -const steamDelayDataLookupMap = new Map() +const steamServerLookupMap = new Set() +const steamDelayDataLookupMap = new Set() // Runs every gametick async function saveMatchData(client: SocketClient) { @@ -55,7 +55,7 @@ async function saveMatchData(client: SocketClient) { .get(`${matchId}:lobbyType`) .exec() - let [steamServerId] = res + const [steamServerId] = res const [, lobbyType] = res if (steamServerId && lobbyType) return @@ -63,12 +63,19 @@ async function saveMatchData(client: SocketClient) { if (!steamServerId && !lobbyType) { if (steamServerLookupMap.has(matchId)) return - const promise = server.dota.getUserSteamServer(client.steam32Id).catch((e) => { - logger.error('err getUserSteamServer', { e }) - return null + // Wrap the steamSocket.emit in a Promise + const getDelayedDataPromise = new Promise((resolve, reject) => { + steamSocket.emit('getUserSteamServer', client.steam32Id, (err: any, cards: any) => { + if (err) { + reject(err) + } else { + resolve(cards) + } + }) }) - steamServerLookupMap.set(matchId, promise) - steamServerId = await promise + + steamServerLookupMap.add(matchId) + const steamServerId = await getDelayedDataPromise steamServerLookupMap.delete(matchId) // Remove the promise once it's resolved if (!steamServerId) return @@ -78,20 +85,28 @@ async function saveMatchData(client: SocketClient) { if (steamServerId && !lobbyType) { if (steamDelayDataLookupMap.has(matchId)) return - const promise = server.dota - .GetRealTimeStats({ - match_id: matchId!, - refetchCards: true, - steam_server_id: steamServerId.toString(), - token: client.token, - }) - .catch((e) => { - logger.error('err GetRealTimeStats', { e }) - return null - }) - steamDelayDataLookupMap.set(matchId, promise) - const delayedData = await promise - steamDelayDataLookupMap.delete(matchId) // Remove the promise once it's resolved + steamDelayDataLookupMap.add(matchId) + const getDelayedDataPromise = new Promise((resolve, reject) => { + steamSocket.emit( + 'getRealTimeStats', + { + match_id: matchId!, + refetchCards: true, + steam_server_id: steamServerId?.toString(), + token: client.token, + }, + (err: any, data: DelayedGames) => { + if (err) { + reject(err) + } else { + resolve(data) + } + }, + ) + }) + + const delayedData = await getDelayedDataPromise + steamDelayDataLookupMap.delete(matchId) if (!delayedData?.match.lobby_type) return await redisClient.client.set(`${matchId}:lobbyType`, delayedData.match.lobby_type) diff --git a/packages/dota/src/dota/lib/getPlayers.ts b/packages/dota/src/dota/lib/getPlayers.ts index 4bfe70f6..7ddc6891 100644 --- a/packages/dota/src/dota/lib/getPlayers.ts +++ b/packages/dota/src/dota/lib/getPlayers.ts @@ -1,13 +1,11 @@ import { t } from 'i18next' -import Dota from '../../steam/index.js' import MongoDBSingleton from '../../steam/MongoDBSingleton.js' -import { DelayedGames } from '../../types.js' +import { steamSocket } from '../../steam/ws.js' +import { Cards, DelayedGames } from '../../types.js' import CustomError from '../../utils/customError.js' import { getAccountsFromMatch } from './getAccountsFromMatch.js' -const dota = Dota.getInstance() - export async function getPlayers({ locale, currentMatchId, @@ -42,7 +40,17 @@ export async function getPlayers({ searchPlayers: players, }) - const cards = await dota.getCards(accountIds) + const getCardsPromise = new Promise((resolve, reject) => { + steamSocket.emit('getCards', accountIds, (err: any, cards: any) => { + if (err) { + reject(err) + } else { + resolve(cards) + } + }) + }) + + const cards = await getCardsPromise return { gameMode: response ? Number(response.match.game_mode) : undefined, diff --git a/packages/dota/src/dota/lib/ranks.ts b/packages/dota/src/dota/lib/ranks.ts index 27e185a1..db323574 100644 --- a/packages/dota/src/dota/lib/ranks.ts +++ b/packages/dota/src/dota/lib/ranks.ts @@ -1,8 +1,9 @@ import { t } from 'i18next' import RedisClient from '../../db/RedisClient.js' +import { steamSocket } from '../../steam/ws.js' +import { Cards } from '../../types.js' import { logger } from '../../utils/logger.js' -import { server } from '../index.js' import { leaderRanks, ranks } from './consts.js' export function rankTierToMmr(rankTier: string | number) { @@ -56,8 +57,18 @@ export async function lookupLeaderRank( result = medalCache as unknown as LeaderRankData } else { try { + const getCardPromise = new Promise((resolve, reject) => { + steamSocket.emit('getCard', steam32Id, (err: any, card: Cards) => { + if (err) { + reject(err) + } else { + resolve(card) + } + }) + }) + // Fetch the leaderboard rank from the Dota 2 server - const data = await server.dota.getCard(steam32Id) + const data = await getCardPromise const standing: number = data?.leaderboard_rank // If the rank is not available, return default values diff --git a/packages/dota/src/steam/ws.ts b/packages/dota/src/steam/ws.ts new file mode 100644 index 00000000..4c9a1420 --- /dev/null +++ b/packages/dota/src/steam/ws.ts @@ -0,0 +1,9 @@ +import { io } from 'socket.io-client' + +import { logger } from '../utils/logger.js' + +export const steamSocket = io('ws://steam:5035') + +steamSocket.on('connect', () => { + logger.info('We alive on dotabod steam server!') +}) diff --git a/packages/dota/src/twitch/commands/items.ts b/packages/dota/src/twitch/commands/items.ts index d866b4b0..14f2e6db 100644 --- a/packages/dota/src/twitch/commands/items.ts +++ b/packages/dota/src/twitch/commands/items.ts @@ -4,10 +4,10 @@ import DOTA_ITEMS from 'dotaconstants/build/items.json' assert { type: 'json' } import { t } from 'i18next' import RedisClient from '../../db/RedisClient.js' -import { server } from '../../dota/index.js' import { getHeroNameOrColor } from '../../dota/lib/heroes.js' import { isSpectator } from '../../dota/lib/isSpectator.js' -import { Item, Packet } from '../../types.js' +import { steamSocket } from '../../steam/ws.js' +import { DelayedGames, Item, Packet } from '../../types.js' import CustomError from '../../utils/customError.js' import { chatClient } from '../chatClient.js' import commandHandler from '../lib/CommandHandler.js' @@ -81,13 +81,28 @@ async function getItems({ throw new CustomError(t('missingMatchData', { emote: 'PauseChamp', lng: locale })) } - const delayedData = await server.dota.GetRealTimeStats({ - match_id: packet?.map?.matchid ?? '', - forceRefetchAll: true, - steam_server_id: steamServerId, - token, + // Wrap the steamSocket.emit in a Promise + const getDelayedDataPromise = new Promise((resolve, reject) => { + steamSocket.emit( + 'getRealTimeStats', + { + match_id: packet?.map?.matchid ?? '', + forceRefetchAll: true, + steam_server_id: steamServerId, + token, + }, + (err: any, cards: any) => { + if (err) { + reject(err) + } else { + resolve(cards) + } + }, + ) }) + const delayedData = await getDelayedDataPromise + if (!delayedData) { throw new CustomError(t('missingMatchData', { emote: 'PauseChamp', lng: locale })) } diff --git a/packages/dota/src/twitch/commands/test.ts b/packages/dota/src/twitch/commands/test.ts index 098db217..a6777d88 100644 --- a/packages/dota/src/twitch/commands/test.ts +++ b/packages/dota/src/twitch/commands/test.ts @@ -1,9 +1,8 @@ import { t } from 'i18next' -import { server } from '../../dota/index.js' import { gsiHandlers } from '../../dota/lib/consts.js' import { getAccountsFromMatch } from '../../dota/lib/getAccountsFromMatch.js' -import Dota from '../../steam/index.js' +import { steamSocket } from '../../steam/ws.js' import { logger } from '../../utils/logger.js' import { chatClient } from '../chatClient.js' import commandHandler, { MessageType } from '../lib/CommandHandler.js' @@ -28,43 +27,36 @@ commandHandler.registerCommand('test', { const { accountIds } = await getAccountsFromMatch({ gsi: client.gsi, }) - const dota = Dota.getInstance() - await dota.getCards(accountIds, true) + steamSocket.emit('getCards', accountIds, (err: any, response: any) => { + console.log(response) // one response per client + }) chatClient.say(channel, `cards! ${client.gsi?.map?.matchid}`) return } const [steam32Id] = args - if (!client.steam32Id || !steam32Id) { - chatClient.say( - channel, - message.channel.client.multiAccount - ? t('multiAccount', { - lng: message.channel.client.locale, - url: 'dotabod.com/dashboard/features', - }) - : t('unknownSteam', { lng: message.channel.client.locale }), - ) - return - } - - const steamserverid = await server.dota.getUserSteamServer(steam32Id || client.steam32Id) - - if (!steamserverid) { - chatClient.say(channel, t('gameNotFound', { lng: message.channel.client.locale })) - return - } - - logger.info('test command', { - command: 'TEST', - steam32Id: steam32Id || client.steam32Id, - steamserverid, - }) - logger.info( - `https://api.steampowered.com/IDOTA2MatchStats_570/GetRealtimeStats/v1/?key=${process.env - .STEAM_WEB_API!}&server_steam_id=${steamserverid}`, + steamSocket.emit( + 'getUserSteamServer', + steam32Id || client.steam32Id, + (err: any, steamserverid: string) => { + if (!steamserverid) { + chatClient.say(channel, t('gameNotFound', { lng: message.channel.client.locale })) + return + } + + logger.info('test command', { + command: 'TEST', + steam32Id: steam32Id || client.steam32Id, + steamserverid, + }) + + logger.info( + `https://api.steampowered.com/IDOTA2MatchStats_570/GetRealtimeStats/v1/?key=${process.env + .STEAM_WEB_API!}&server_steam_id=${steamserverid}`, + ) + }, ) }, }) diff --git a/packages/dota/src/utils/index.ts b/packages/dota/src/utils/index.ts index e5422985..4e494486 100644 --- a/packages/dota/src/utils/index.ts +++ b/packages/dota/src/utils/index.ts @@ -1,5 +1,3 @@ -import retry from 'retry' - export function steamID64toSteamID32(steamID64: string) { if (!steamID64) return null return Number(steamID64.substr(-16, 16)) - 6561197960265728 @@ -19,34 +17,3 @@ export function fmtMSS(totalSeconds: number) { // ✅ format as MM:SS return `${padTo2Digits(minutes)}:${padTo2Digits(seconds)}` } - -export const wait = (time: number) => new Promise((resolve) => setTimeout(resolve, time || 0)) - -export const retryCustom = async ({ - retries, - fn, - minTimeout, -}: { - retries: number - fn: () => Promise - minTimeout: number -}): Promise => { - const operation = retry.operation({ - retries, - minTimeout, - }) - - return new Promise((resolve, reject) => { - // eslint-disable-next-line @typescript-eslint/no-misused-promises - operation.attempt(async (currentAttempt) => { - try { - const result = await fn() - resolve(result) - } catch (err: any) { - if (!operation.retry(new Error('retrying'))) { - reject(operation.mainError()) - } - } - }) - }) -} diff --git a/packages/steam/.eslintrc.cjs b/packages/steam/.eslintrc.cjs new file mode 100644 index 00000000..4c4f1d2e --- /dev/null +++ b/packages/steam/.eslintrc.cjs @@ -0,0 +1,92 @@ +module.exports = { + root: true, + env: { + browser: false, + es2021: true, + node: true, + }, + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:import/recommended', + 'plugin:import/typescript', + 'plugin:@typescript-eslint/recommended-requiring-type-checking', + 'plugin:@typescript-eslint/strict', + 'plugin:prettier/recommended', + ], + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + tsconfigRootDir: __dirname, + project: './tsconfig.json', + }, + plugins: ['@typescript-eslint', 'import', 'unused-imports', 'prettier'], + rules: { + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unsafe-call': 'off', + '@typescript-eslint/no-unsafe-assignment': 'off', + '@typescript-eslint/no-unsafe-argument': 'off', + '@typescript-eslint/no-unsafe-member-access': 'off', + '@typescript-eslint/no-unused-vars': 'off', // or "@typescript-eslint/no-unused-vars": "off", + 'unused-imports/no-unused-imports': 'error', + 'unused-imports/no-unused-vars': [ + 'warn', + { + vars: 'all', + varsIgnorePattern: '^_', + args: 'after-used', + argsIgnorePattern: '^_', + }, + ], + 'sort-imports': [ + 'error', + { + ignoreCase: false, + ignoreDeclarationSort: true, // don"t want to sort import lines, use eslint-plugin-import instead + ignoreMemberSort: false, + memberSyntaxSortOrder: ['none', 'all', 'multiple', 'single'], + allowSeparatedGroups: true, + }, + ], + 'prefer-destructuring': [ + 'error', + { + array: true, + object: true, + }, + { + enforceForRenamedProperties: false, + }, + ], + 'import/no-unresolved': 'error', + 'import/order': [ + 'error', + { + groups: [ + 'builtin', // Built-in imports (come from NodeJS native) go first + 'external', // <- External imports + 'internal', // <- Absolute imports + ['sibling', 'parent'], // <- Relative imports, the sibling and parent types they can be mingled together + 'index', // <- index imports + 'unknown', // <- unknown + ], + 'newlines-between': 'always', + alphabetize: { + order: 'asc', + caseInsensitive: true, + }, + }, + ], + }, + settings: { + 'import/parsers': { + '@typescript-eslint/parser': ['.ts'], + }, + 'import/resolver': { + typescript: { + project: __dirname + '/tsconfig.json', + }, + }, + }, +} diff --git a/packages/steam/.gitignore b/packages/steam/.gitignore new file mode 100644 index 00000000..03fee256 --- /dev/null +++ b/packages/steam/.gitignore @@ -0,0 +1,7 @@ +node_modules +.env +assets +dist +build +config.js +**/volumes diff --git a/packages/steam/.nvmrc b/packages/steam/.nvmrc new file mode 100644 index 00000000..0c19c7b4 --- /dev/null +++ b/packages/steam/.nvmrc @@ -0,0 +1 @@ +v16.18.1 diff --git a/packages/steam/.prettierrc b/packages/steam/.prettierrc new file mode 100644 index 00000000..9c586f70 --- /dev/null +++ b/packages/steam/.prettierrc @@ -0,0 +1,11 @@ +{ + "arrowParens": "always", + "bracketSpacing": true, + "endOfLine": "auto", + "printWidth": 100, + "semi": false, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "all", + "useTabs": false +} diff --git a/packages/steam/.unimportedrc.json b/packages/steam/.unimportedrc.json new file mode 100644 index 00000000..d231550f --- /dev/null +++ b/packages/steam/.unimportedrc.json @@ -0,0 +1,17 @@ +{ + "ignorePatterns": [ + "**/node_modules/**", + "**/*.tests.{js,jsx,ts,tsx}", + "**/*.test.{js,jsx,ts,tsx}", + "**/*.spec.{js,jsx,ts,tsx}", + "**/tests/**", + "**/__tests__/**", + "**/*.d.ts" + ], + "ignoreUnimported": [], + "ignoreUnused": [], + "ignoreUnresolved": [], + "pathTransforms": { + "(\\..+)\\.js$": "$1.ts" + } +} diff --git a/packages/steam/package.json b/packages/steam/package.json new file mode 100644 index 00000000..96aa8744 --- /dev/null +++ b/packages/steam/package.json @@ -0,0 +1,75 @@ +{ + "name": "@dotabod/steam", + "description": "Forward twitch chat to dotabod.", + "version": "1.0.0", + "license": "GPL-3.0-or-later", + "author": "Geczy", + "main": "src/index.ts", + "exports": "./dist/index.js", + "type": "module", + "packageManager": "yarn@1.22.19", + "private": true, + "scripts": { + "build": "tsc --build --verbose", + "docker:production": "node --trace-warnings ./dist/index.js", + "docker:development": "nodemon -L --ext ts --exec 'node --no-warnings -r ts-node/register --loader ts-node/esm src/index.ts'", + "host:development": "nodemon -L --ext ts ./src/index.ts" + }, + "devDependencies": { + "@types/node": "^20.5.7", + "@typescript-eslint/eslint-plugin": "^6.5.0", + "@typescript-eslint/parser": "^6.5.0", + "eslint": "^8.48.0", + "@node-steam/id": "^1.2.0", + "@supabase/supabase-js": "^2.33.1", + "@twurple/eventsub-base": "^6.0.9", + "@types/long": "^5.0.0", + "@types/lru-cache": "^7.10.10", + "axios": "1.2.0-alpha.1", + "axios-retry": "^3.3.1", + "body-parser": "^1.20.2", + "chokidar": "^3.5.3", + "country-code-emoji": "^2.3.0", + "dota2": "https://github.com/dotabod/node-dota2.git", + "dotaconstants": "^7.18.0", + "express": "^4.18.2", + "lodash.isequal": "^4.5.0", + "lru-cache": "^10.0.1", + "mongodb": "^6.0.0", + "newrelic": "^11.0.0", + "node-gyp": "^9.4.0", + "redis": "^4.6.8", + "retry": "^0.13.1", + "socket.io": "^4.7.2", + "socket.io-client": "^4.7.2", + "steam": "https://github.com/dotabod/node-steam", + "steam-errors": "^1.0.0", + "winston": "^3.10.0" + }, + "dependencies": { + "node-gyp": "^9.4.0", + "@faker-js/faker": "^8.0.2", + "@jest/globals": "^29.6.4", + "@testing-library/dom": "^9.3.1", + "@types/body-parser": "^1.19.2", + "@types/express": "^4.17.17", + "@types/jest": "^29.5.4", + "@types/lodash.isequal": "^4.5.6", + "@types/memoizee": "^0.4.8", + "@types/newrelic": "^9.14.0", + "@types/node": "^20.5.7", + "@types/retry": "^0.12.2", + "@types/steam": "^0.0.29", + "@typescript-eslint/eslint-plugin": "^6.5.0", + "@typescript-eslint/parser": "^6.5.0", + "eslint": "^8.48.0", + "eslint-plugin-prettier": "^5.0.0", + "eslint-plugin-unused-imports": "^3.0.0", + "jest": "^29.6.4", + "lint-staged": "^14.0.1", + "nodemon": "^3.0.1", + "prettier": "^3.0.3", + "ts-jest": "^29.1.1", + "typescript": "^5.2.2" + } +} diff --git a/packages/steam/src/MongoDBSingleton.ts b/packages/steam/src/MongoDBSingleton.ts new file mode 100644 index 00000000..9b7a7db5 --- /dev/null +++ b/packages/steam/src/MongoDBSingleton.ts @@ -0,0 +1,62 @@ +import { Db, MongoClient } from 'mongodb' +import retry from 'retry' + +import { logger } from './utils/logger.js' + +class MongoDBSingleton { + clientPromise: Promise | null = null + mongoClient: MongoClient | null = null // Store the MongoClient object + + async connect(): Promise { + // If the client promise is already resolved, return it + if (this.clientPromise) { + return this.clientPromise + } + + // Create a new promise that will be resolved with the MongoDB client + this.clientPromise = new Promise((resolve, reject) => { + // Set up the retry operation + const operation = retry.operation({ + retries: 5, // Number of retries + factor: 3, // Exponential backoff factor + minTimeout: 1 * 1000, // Minimum retry timeout (1 second) + maxTimeout: 60 * 1000, // Maximum retry timeout (60 seconds) + }) + + // Attempt to connect to MongoDB with the retry operation + // eslint-disable-next-line @typescript-eslint/no-misused-promises + operation.attempt(async (currentAttempt) => { + try { + // Connect to MongoDB + const mongoURL = process.env.MONGO_URL + if (!mongoURL) throw new Error('MONGO_URL not set') + const client = await MongoClient.connect(mongoURL) + this.mongoClient = client // Store the MongoClient object + + // Resolve the promise with the client + resolve(client.db()) + } catch (error: any) { + logger.info('Retrying mongo connection', { currentAttempt }) + // If the retry operation has been exhausted, reject the promise with the error + if (operation.retry(error)) { + return + } + reject(error) + } + }) + }) + + return this.clientPromise + } + + async close(): Promise { + // for now, don't close, because we call mongo so often i think it will + // cause more problems than it solves + // if (this.mongoClient) { + // await this.mongoClient.close() + // } + return Promise.resolve() + } +} + +export default new MongoDBSingleton() diff --git a/packages/steam/src/getHero.ts b/packages/steam/src/getHero.ts new file mode 100644 index 00000000..ca8a5202 --- /dev/null +++ b/packages/steam/src/getHero.ts @@ -0,0 +1,8 @@ +import heroes from './heroes.js' + +export type HeroNames = keyof typeof heroes + +export default function handleGetHero(name?: HeroNames) { + if (!name || typeof name !== 'string' || name.length < 3) return null + return heroes[name] +} diff --git a/packages/steam/src/heroes.ts b/packages/steam/src/heroes.ts new file mode 100644 index 00000000..80a85152 --- /dev/null +++ b/packages/steam/src/heroes.ts @@ -0,0 +1,704 @@ +import { t } from 'i18next' + +const heroes = { + npc_dota_hero_antimage: { + id: 1, + localized_name: 'Anti-Mage', + alias: ['am'], + }, + npc_dota_hero_axe: { + id: 2, + localized_name: 'Axe', + alias: [], + }, + npc_dota_hero_bane: { + id: 3, + localized_name: 'Bane', + alias: [], + }, + npc_dota_hero_bloodseeker: { + id: 4, + localized_name: 'Bloodseeker', + alias: ['bs'], + }, + npc_dota_hero_crystal_maiden: { + id: 5, + localized_name: 'Crystal Maiden', + alias: ['cm', 'rylai'], + }, + npc_dota_hero_drow_ranger: { + id: 6, + localized_name: 'Drow Ranger', + alias: ['drow'], + }, + npc_dota_hero_earthshaker: { + id: 7, + localized_name: 'Earthshaker', + alias: ['es', 'shaker'], + }, + npc_dota_hero_juggernaut: { + id: 8, + localized_name: 'Juggernaut', + alias: ['jug'], + }, + npc_dota_hero_mirana: { + id: 9, + localized_name: 'Mirana', + alias: ['potm'], + }, + npc_dota_hero_morphling: { + id: 10, + localized_name: 'Morphling', + alias: ['morph'], + }, + npc_dota_hero_nevermore: { + id: 11, + localized_name: 'Shadow Fiend', + alias: ['sf'], + }, + npc_dota_hero_phantom_lancer: { + id: 12, + localized_name: 'Phantom Lancer', + alias: ['pl'], + }, + npc_dota_hero_puck: { + id: 13, + localized_name: 'Puck', + alias: [], + }, + npc_dota_hero_pudge: { + id: 14, + localized_name: 'Pudge', + alias: [], + }, + npc_dota_hero_razor: { + id: 15, + localized_name: 'Razor', + alias: [], + }, + npc_dota_hero_sand_king: { + id: 16, + localized_name: 'Sand King', + alias: ['sk'], + }, + npc_dota_hero_storm_spirit: { + id: 17, + localized_name: 'Storm Spirit', + alias: ['storm'], + }, + npc_dota_hero_sven: { + id: 18, + localized_name: 'Sven', + alias: [], + }, + npc_dota_hero_tiny: { + id: 19, + localized_name: 'Tiny', + alias: [], + }, + npc_dota_hero_vengefulspirit: { + id: 20, + localized_name: 'Vengeful Spirit', + alias: ['venge', 'vs'], + }, + npc_dota_hero_windrunner: { + id: 21, + localized_name: 'Windranger', + alias: ['wr'], + }, + npc_dota_hero_zuus: { + id: 22, + localized_name: 'Zeus', + alias: [], + }, + npc_dota_hero_kunkka: { + id: 23, + localized_name: 'Kunkka', + alias: [], + }, + npc_dota_hero_lina: { + id: 25, + localized_name: 'Lina', + alias: [], + }, + npc_dota_hero_lion: { + id: 26, + localized_name: 'Lion', + alias: [], + }, + npc_dota_hero_shadow_shaman: { + id: 27, + localized_name: 'Shadow Shaman', + alias: ['ss', 'shaman', 'rhasta'], + }, + npc_dota_hero_slardar: { + id: 28, + localized_name: 'Slardar', + alias: [], + }, + npc_dota_hero_tidehunter: { + id: 29, + localized_name: 'Tidehunter', + alias: ['tide'], + }, + npc_dota_hero_witch_doctor: { + id: 30, + localized_name: 'Witch Doctor', + alias: ['wd', 'doc'], + }, + npc_dota_hero_lich: { + id: 31, + localized_name: 'Lich', + alias: [], + }, + npc_dota_hero_riki: { + id: 32, + localized_name: 'Riki', + alias: [], + }, + npc_dota_hero_enigma: { + id: 33, + localized_name: 'Enigma', + alias: ['nigma'], + }, + npc_dota_hero_tinker: { + id: 34, + localized_name: 'Tinker', + alias: ['tink'], + }, + npc_dota_hero_sniper: { + id: 35, + localized_name: 'Sniper', + alias: [], + }, + npc_dota_hero_necrolyte: { + id: 36, + localized_name: 'Necrophos', + alias: ['necro', 'necrolyte'], + }, + npc_dota_hero_warlock: { + id: 37, + localized_name: 'Warlock', + alias: ['wl'], + }, + npc_dota_hero_beastmaster: { + id: 38, + localized_name: 'Beastmaster', + alias: ['bm', 'beast'], + }, + npc_dota_hero_queenofpain: { + id: 39, + localized_name: 'Queen of Pain', + alias: ['qop'], + }, + npc_dota_hero_venomancer: { + id: 40, + localized_name: 'Venomancer', + alias: ['veno'], + }, + npc_dota_hero_faceless_void: { + id: 41, + localized_name: 'Faceless Void', + alias: ['fv', 'faceless', 'void'], + }, + npc_dota_hero_skeleton_king: { + id: 42, + localized_name: 'Wraith King', + alias: ['wk'], + }, + npc_dota_hero_death_prophet: { + id: 43, + localized_name: 'Death Prophet', + alias: ['dp'], + }, + npc_dota_hero_phantom_assassin: { + id: 44, + localized_name: 'Phantom Assassin', + alias: ['pa'], + }, + npc_dota_hero_pugna: { + id: 45, + localized_name: 'Pugna', + alias: [], + }, + npc_dota_hero_templar_assassin: { + id: 46, + localized_name: 'Templar Assassin', + alias: ['ta', 'templar', 'lanaya'], + }, + npc_dota_hero_viper: { + id: 47, + localized_name: 'Viper', + alias: [], + }, + npc_dota_hero_luna: { + id: 48, + localized_name: 'Luna', + alias: [], + }, + npc_dota_hero_dragon_knight: { + id: 49, + localized_name: 'Dragon Knight', + alias: ['dk'], + }, + npc_dota_hero_dazzle: { + id: 50, + localized_name: 'Dazzle', + alias: [], + }, + npc_dota_hero_rattletrap: { + id: 51, + localized_name: 'Clockwerk', + alias: ['cw', 'clock'], + }, + npc_dota_hero_leshrac: { + id: 52, + localized_name: 'Leshrac', + alias: ['lesh'], + }, + npc_dota_hero_furion: { + id: 53, + localized_name: "Nature's Prophet", + alias: ['np', 'natures', 'prophet', 'furion'], + }, + npc_dota_hero_life_stealer: { + id: 54, + localized_name: 'Lifestealer', + alias: ['ls', 'naix'], + }, + npc_dota_hero_dark_seer: { + id: 55, + localized_name: 'Dark Seer', + alias: ['ds'], + }, + npc_dota_hero_clinkz: { + id: 56, + localized_name: 'Clinkz', + alias: [], + }, + npc_dota_hero_omniknight: { + id: 57, + localized_name: 'Omniknight', + alias: ['omni'], + }, + npc_dota_hero_enchantress: { + id: 58, + localized_name: 'Enchantress', + alias: ['ench'], + }, + npc_dota_hero_huskar: { + id: 59, + localized_name: 'Huskar', + alias: ['husk'], + }, + npc_dota_hero_night_stalker: { + id: 60, + localized_name: 'Night Stalker', + alias: ['ns'], + }, + npc_dota_hero_broodmother: { + id: 61, + localized_name: 'Broodmother', + alias: ['brood', 'bm'], + }, + npc_dota_hero_bounty_hunter: { + id: 62, + localized_name: 'Bounty Hunter', + alias: ['bh', 'bounty'], + }, + npc_dota_hero_weaver: { + id: 63, + localized_name: 'Weaver', + alias: [], + }, + npc_dota_hero_jakiro: { + id: 64, + localized_name: 'Jakiro', + alias: ['jak'], + }, + npc_dota_hero_batrider: { + id: 65, + localized_name: 'Batrider', + alias: ['bat'], + }, + npc_dota_hero_chen: { + id: 66, + localized_name: 'Chen', + alias: [], + }, + npc_dota_hero_spectre: { + id: 67, + localized_name: 'Spectre', + alias: ['spec'], + }, + npc_dota_hero_ancient_apparition: { + id: 68, + localized_name: 'Ancient Apparition', + alias: ['aa'], + }, + npc_dota_hero_doom_bringer: { + id: 69, + localized_name: 'Doom', + alias: [], + }, + npc_dota_hero_ursa: { + id: 70, + localized_name: 'Ursa', + alias: [], + }, + npc_dota_hero_spirit_breaker: { + id: 71, + localized_name: 'Spirit Breaker', + alias: ['sb', 'bara'], + }, + npc_dota_hero_gyrocopter: { + id: 72, + localized_name: 'Gyrocopter', + alias: ['gyro'], + }, + npc_dota_hero_alchemist: { + id: 73, + localized_name: 'Alchemist', + alias: ['alch'], + }, + npc_dota_hero_invoker: { + id: 74, + localized_name: 'Invoker', + alias: ['invo', 'voker'], + }, + npc_dota_hero_silencer: { + id: 75, + localized_name: 'Silencer', + alias: ['nortrom'], + }, + npc_dota_hero_obsidian_destroyer: { + id: 76, + localized_name: 'Outworld Destroyer', + alias: ['od'], + }, + npc_dota_hero_lycan: { + id: 77, + localized_name: 'Lycan', + alias: [], + }, + npc_dota_hero_brewmaster: { + id: 78, + localized_name: 'Brewmaster', + alias: ['brew', 'panda'], + }, + npc_dota_hero_shadow_demon: { + id: 79, + localized_name: 'Shadow Demon', + alias: ['sd'], + }, + npc_dota_hero_lone_druid: { + id: 80, + localized_name: 'Lone Druid', + alias: ['ld', 'lone'], + }, + npc_dota_hero_chaos_knight: { + id: 81, + localized_name: 'Chaos Knight', + alias: ['ck', 'chaos'], + }, + npc_dota_hero_meepo: { + id: 82, + localized_name: 'Meepo', + alias: [], + }, + npc_dota_hero_treant: { + id: 83, + localized_name: 'Treant Protector', + alias: ['treant', 'tree'], + }, + npc_dota_hero_ogre_magi: { + id: 84, + localized_name: 'Ogre Magi', + alias: ['ogre'], + }, + npc_dota_hero_undying: { + id: 85, + localized_name: 'Undying', + alias: ['undy', 'und'], + }, + npc_dota_hero_rubick: { + id: 86, + localized_name: 'Rubick', + alias: ['rub', 'rubi', 'rubik'], + }, + npc_dota_hero_disruptor: { + id: 87, + localized_name: 'Disruptor', + alias: ['dis', 'thrall'], + }, + npc_dota_hero_nyx_assassin: { + id: 88, + localized_name: 'Nyx Assassin', + alias: ['nyx'], + }, + npc_dota_hero_naga_siren: { + id: 89, + localized_name: 'Naga Siren', + alias: ['naga', 'siren'], + }, + npc_dota_hero_keeper_of_the_light: { + id: 90, + localized_name: 'Keeper of the Light', + alias: ['kotl', 'keeper', 'ezalor'], + }, + npc_dota_hero_wisp: { + id: 91, + localized_name: 'Io', + alias: ['wisp'], + }, + npc_dota_hero_visage: { + id: 92, + localized_name: 'Visage', + alias: [], + }, + npc_dota_hero_slark: { + id: 93, + localized_name: 'Slark', + alias: [], + }, + npc_dota_hero_medusa: { + id: 94, + localized_name: 'Medusa', + alias: ['dusa', 'dussy'], + }, + npc_dota_hero_troll_warlord: { + id: 95, + localized_name: 'Troll Warlord', + alias: ['troll'], + }, + npc_dota_hero_centaur: { + id: 96, + localized_name: 'Centaur Warrunner', + alias: ['cent', 'centaur'], + }, + npc_dota_hero_magnataur: { + id: 97, + localized_name: 'Magnus', + alias: ['mag'], + }, + npc_dota_hero_shredder: { + id: 98, + localized_name: 'Timbersaw', + alias: ['timber'], + }, + npc_dota_hero_bristleback: { + id: 99, + localized_name: 'Bristleback', + alias: ['bb', 'bristle'], + }, + npc_dota_hero_tusk: { + id: 100, + localized_name: 'Tusk', + alias: ['tuskarr'], + }, + npc_dota_hero_skywrath_mage: { + id: 101, + localized_name: 'Skywrath Mage', + alias: ['sky', 'skywrath'], + }, + npc_dota_hero_abaddon: { + id: 102, + localized_name: 'Abaddon', + alias: ['aba', 'abba'], + }, + npc_dota_hero_elder_titan: { + id: 103, + localized_name: 'Elder Titan', + alias: ['et', 'elder'], + }, + npc_dota_hero_legion_commander: { + id: 104, + localized_name: 'Legion Commander', + alias: ['lc', 'legion'], + }, + npc_dota_hero_techies: { + id: 105, + localized_name: 'Techies', + alias: ['tech'], + }, + npc_dota_hero_ember_spirit: { + id: 106, + localized_name: 'Ember Spirit', + alias: ['ember'], + }, + npc_dota_hero_earth_spirit: { + id: 107, + localized_name: 'Earth Spirit', + alias: ['earth', 'es'], + }, + npc_dota_hero_abyssal_underlord: { + id: 108, + localized_name: 'Underlord', + alias: ['ul', 'under', 'pitlord'], + }, + npc_dota_hero_terrorblade: { + id: 109, + localized_name: 'Terrorblade', + alias: ['tb', 'terror'], + }, + npc_dota_hero_phoenix: { + id: 110, + localized_name: 'Phoenix', + alias: [], + }, + npc_dota_hero_oracle: { + id: 111, + localized_name: 'Oracle', + alias: [], + }, + npc_dota_hero_winter_wyvern: { + id: 112, + localized_name: 'Winter Wyvern', + alias: ['ww', 'winter', 'wyvern'], + }, + npc_dota_hero_arc_warden: { + id: 113, + localized_name: 'Arc Warden', + alias: ['arc'], + }, + npc_dota_hero_monkey_king: { + id: 114, + localized_name: 'Monkey King', + alias: ['mk', 'monkey'], + }, + npc_dota_hero_dark_willow: { + id: 119, + localized_name: 'Dark Willow', + alias: ['dw', 'willow'], + }, + npc_dota_hero_pangolier: { + id: 120, + localized_name: 'Pangolier', + alias: ['pango'], + }, + npc_dota_hero_grimstroke: { + id: 121, + localized_name: 'Grimstroke', + alias: ['grim'], + }, + npc_dota_hero_hoodwink: { + id: 123, + localized_name: 'Hoodwink', + alias: ['hw', 'hoodwinkle'], + }, + npc_dota_hero_void_spirit: { + id: 126, + localized_name: 'Void Spirit', + alias: ['vs'], + }, + npc_dota_hero_snapfire: { + id: 128, + localized_name: 'Snapfire', + alias: ['snap', 'mortimer', 'grandma', 'granny'], + }, + npc_dota_hero_mars: { + id: 129, + localized_name: 'Mars', + alias: [], + }, + npc_dota_hero_dawnbreaker: { + id: 135, + localized_name: 'Dawnbreaker', + alias: ['db', 'dawn'], + }, + npc_dota_hero_marci: { + id: 136, + localized_name: 'Marci', + alias: [], + }, + npc_dota_hero_primal_beast: { + id: 137, + localized_name: 'Primal Beast', + alias: ['pb', 'primal'], + }, + npc_dota_hero_muerta: { + id: 138, + localized_name: 'Muerta', + alias: [], + }, +} + +export const translatedColor = (color: string, lng: string) => { + if (lng === 'en') return color + + const props = { lng } + switch (color) { + case 'Blue': + return t('colors.blue', props) + case 'Teal': + return t('colors.teal', props) + case 'Purple': + return t('colors.purple', props) + case 'Yellow': + return t('colors.yellow', props) + case 'Orange': + return t('colors.orange', props) + case 'Pink': + return t('colors.pink', props) + case 'Olive': + return t('colors.olive', props) + case 'Cyan': + return t('colors.cyan', props) + case 'Green': + return t('colors.green', props) + case 'Brown': + return t('colors.brown', props) + default: + return color + } +} + +export const heroColors = 'Blue,Teal,Purple,Yellow,Orange,Pink,Olive,Cyan,Green,Brown'.split(',') +export function getHeroNameOrColor(id: number, index?: number) { + if (!id && typeof index === 'number') return heroColors[index] + + const name = getHeroById(id)?.localized_name + if (!name && typeof index === 'number') { + return heroColors[index] + } + + return name ?? 'Unknown' +} + +export function getHeroById(id: number) { + if (!id) return null + + return Object.values(heroes).find((h) => h.id === id) +} + +export function getHeroByName(name: string) { + if (!name) return null + + // only keep a-z in name + name = name + .replace(/[^a-z]/gi, '') + .toLowerCase() + .trim() + + const hero = Object.values(heroes).find((h) => { + const inName = h.localized_name + // replace all spaces with nothing, and only keep a-z + .replace(/[^a-z]/gi, '') + .toLowerCase() + .trim() + + // check for alias + const hasAlias = h.alias.some( + (alias) => + alias + .replace(/[^a-z]/gi, '') + .toLowerCase() + .trim() === name, + ) + return inName.includes(name) || hasAlias + }) + + return hero +} + +export default heroes diff --git a/packages/steam/src/index.ts b/packages/steam/src/index.ts new file mode 100644 index 00000000..10cfe6a6 --- /dev/null +++ b/packages/steam/src/index.ts @@ -0,0 +1,56 @@ +import { Server } from 'socket.io' + +import Dota from './steam.js' +import { logger } from './utils/logger.js' + +let hasDotabodSocket = false +let isConnectedToSteam = false + +const io = new Server(5035) +const dota = Dota.getInstance() + +dota.dota2.on('ready', () => { + logger.info('[SERVER] Connected to dota game coordinator') + isConnectedToSteam = true +}) + +io.on('connection', (socket) => { + // dota node app just connected + // make it join our room + console.log('Found a connection!') + try { + void socket.join('steam') + } catch (e) { + console.log('Could not join steam socket') + return + } + + hasDotabodSocket = true + + socket.on('disconnect', () => { + console.log('We lost the server! Respond to all messages with "server offline"') + hasDotabodSocket = false + }) + + socket.on('getCards', async function (accountIds: number[]) { + if (!isConnectedToSteam) return + return await dota.getCards(accountIds) + }) + + socket.on('getCard', async function (accountId: number) { + if (!isConnectedToSteam) return + return await dota.getCard(accountId) + }) + + socket.on('getUserSteamServer', async function (steam32Id: number) { + if (!isConnectedToSteam) return + return await dota.getUserSteamServer(steam32Id) + }) + + socket.on('getRealTimeStats', async function (data: any) { + if (!isConnectedToSteam) return + return await dota.GetRealTimeStats(data) + }) +}) + +export default io diff --git a/packages/dota/src/steam/index.ts b/packages/steam/src/steam.ts similarity index 92% rename from packages/dota/src/steam/index.ts rename to packages/steam/src/steam.ts index d2bdcb99..511bd6c7 100644 --- a/packages/dota/src/steam/index.ts +++ b/packages/steam/src/steam.ts @@ -1,530 +1,540 @@ -import axios from 'axios' -import crypto from 'crypto' -// @ts-expect-error ??? -import Dota2 from 'dota2' -import fs from 'fs' -import { Long } from 'mongodb' -import retry from 'retry' -import Steam from 'steam' -// @ts-expect-error ??? -import steamErrors from 'steam-errors' - -import { events } from '../dota/globalEventEmitter.js' -import { isDev } from '../dota/lib/consts.js' -import { getAccountsFromMatch } from '../dota/lib/getAccountsFromMatch.js' -import { Cards, DelayedGames, GCMatchData } from '../types.js' -import CustomError from '../utils/customError.js' -import { retryCustom } from '../utils/index.js' -import { logger } from '../utils/logger.js' -import MongoDBSingleton from './MongoDBSingleton.js' - -// Fetches data from MongoDB -const fetchDataFromMongo = async (match_id: string) => { - const mongo = MongoDBSingleton - const db = await mongo.connect() - - try { - return await db.collection('delayedGames').findOne({ 'match.match_id': match_id }) - } finally { - await mongo.close() - } -} - -// Constructs the API URL -const getApiUrl = (steam_server_id: string) => { - return `https://api.steampowered.com/IDOTA2MatchStats_570/GetRealtimeStats/v1/?key=${process.env - .STEAM_WEB_API!}&server_steam_id=${steam_server_id}` -} - -// Saves the match to MongoDB and fetches new medals if needed -const saveMatch = async ({ - match_id, - game, - refetchCards = false, -}: { - match_id: string - game: DelayedGames - refetchCards?: boolean -}) => { - const mongo = MongoDBSingleton - const db = await mongo.connect() - - try { - await db - .collection('delayedGames') - .updateOne({ 'match.match_id': match_id }, { $set: game }, { upsert: true }) - - if (refetchCards) { - const { accountIds } = await getAccountsFromMatch({ - searchMatchId: game.match.match_id, - }) - await dota.getCards(accountIds, true) - } - } finally { - await mongo.close() - } -} - -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 = - hasTeams && - Array.isArray(game.teams[0].players) && - Array.isArray(game.teams[1].players) && - game.teams[0].players.length === 5 && - game.teams[1].players.length === 5 - - // Dev should be able to test in a lobby with bot matches - const hasAccountIds = isDev - ? hasPlayers // dev local lobby just needs the players array - : hasPlayers && - game.teams[0].players.every((player) => player.accountid) && - game.teams[1].players.every((player) => player.accountid) - const hasHeroes = - hasPlayers && - game.teams[0].players.every((player) => player.heroid) && - game.teams[1].players.every((player) => player.heroid) - return { hasAccountIds, hasPlayers, hasHeroes } -} - -class Dota { - private static instance: Dota - - private steamClient - - private steamUser - - public dota2 - - constructor() { - this.steamClient = new Steam.SteamClient() - // @ts-expect-error no types exist - this.steamUser = new Steam.SteamUser(this.steamClient) - this.dota2 = new Dota2.Dota2Client(this.steamClient, false, false) - - const details = this.getUserDetails() - - this.loadServerList() - this.loadSentry(details) - - this.setupClientEventHandlers(details) - this.setupUserEventHandlers() - this.setupDotaEventHandlers() - - // @ts-expect-error no types exist - this.steamClient.connect() - } - - getUserDetails() { - return { - account_name: process.env.STEAM_USER!, - password: process.env.STEAM_PASS!, - } - } - - loadServerList() { - const serverPath = './src/steam/volumes/servers.json' - if (fs.existsSync(serverPath)) { - try { - Steam.servers = JSON.parse(fs.readFileSync(serverPath).toString()) - } catch (e) { - // Ignore - } - } - } - - loadSentry(details: steamUserDetails) { - const sentryPath = './src/steam/volumes/sentry' - if (fs.existsSync(sentryPath)) { - const sentry = fs.readFileSync(sentryPath) - if (sentry.length) details.sha_sentryfile = sentry - } - } - - setupClientEventHandlers(details: steamUserDetails) { - this.steamClient.on('connected', () => { - this.steamUser.logOn(details) - }) - this.steamClient.on('logOnResponse', this.handleLogOnResponse.bind(this)) - this.steamClient.on('loggedOff', this.handleLoggedOff.bind(this)) - this.steamClient.on('error', this.handleClientError.bind(this)) - this.steamClient.on('servers', this.handleServerUpdate.bind(this)) - } - - handleLogOnResponse(logonResp: any) { - // @ts-expect-error no types exist - if (logonResp.eresult == Steam.EResult.OK) { - logger.info('[STEAM] Logged on.') - this.dota2.launch() - } else { - this.logSteamError(logonResp.eresult) - } - } - - handleLoggedOff(eresult: any) { - // @ts-expect-error no types exist - if (this.isProduction()) this.steamClient.connect() - logger.info('[STEAM] Logged off from Steam.', { eresult }) - this.logSteamError(eresult) - } - - handleClientError(error: any) { - logger.info('[STEAM] steam error', { error }) - if (!this.isProduction()) { - this.exit().catch((e) => logger.error('err steam error', { e })) - } - // @ts-expect-error no types exist - if (this.isProduction()) this.steamClient.connect() - } - - handleServerUpdate(servers: any) { - fs.writeFileSync('./src/steam/volumes/servers.json', JSON.stringify(servers)) - } - - setupUserEventHandlers() { - this.steamUser.on('updateMachineAuth', this.handleMachineAuth.bind(this)) - } - - // @ts-expect-error no types exist - handleMachineAuth(sentry, callback) { - const hashedSentry = crypto.createHash('sha1').update(sentry.bytes).digest() - fs.writeFileSync('./src/steam/volumes/sentry', hashedSentry) - logger.info('[STEAM] sentryfile saved') - callback({ sha_file: hashedSentry }) - } - - setupDotaEventHandlers() { - this.dota2.on('hellotimeout', this.handleHelloTimeout.bind(this)) - this.dota2.on('unready', () => logger.info('[STEAM] disconnected from dota game coordinator')) - } - - handleHelloTimeout() { - this.dota2.exit() - setTimeout(() => { - // @ts-expect-error no types exist - if (this.steamClient.loggedOn) this.dota2.launch() - }, 30000) - logger.info('[STEAM] hello time out!') - } - - // @ts-expect-error no types exist - logSteamError(eresult) { - try { - // @ts-expect-error no types exist - steamErrors(eresult, (err, errorObject) => { - logger.info('[STEAM]', { errorObject, err }) - }) - } catch (e) { - // Ignore - } - } - - isProduction() { - return process.env.NODE_ENV === 'production' - } - - public getUserSteamServer = (steam32Id: number | string): Promise => { - const steam_id = this.dota2.ToSteamID(Number(steam32Id)) - - // Set up the retry operation - const operation = retry.operation({ - retries: 35, // Number of retries - factor: 1, // Exponential backoff factor - minTimeout: 1 * 1000, // Minimum retry timeout (1 second) - maxTimeout: 60 * 1000, // Maximum retry timeout (60 seconds) - }) - - return new Promise((resolve, reject) => { - operation.attempt(() => { - this.dota2.spectateFriendGame({ steam_id }, (response: any, err: any) => { - const theID = response?.server_steamid?.toString() - - const shouldRetry = !theID ? new Error('No ID yet, will keep trying.') : undefined - if (operation.retry(shouldRetry)) return - - if (theID) resolve(theID) - else reject('No spectator match found') - }) - }) - }) - } - - public GetRealTimeStats = async ({ - match_id, - refetchCards = false, - steam_server_id, - token, - forceRefetchAll = false, - }: { - forceRefetchAll?: boolean - match_id: string - refetchCards?: boolean - steam_server_id: string - token: string - }): Promise => { - let waitForHeros = forceRefetchAll || false - - if (!steam_server_id) { - throw new Error('Match not found') - } - - const currentData = await fetchDataFromMongo(match_id) - const { hasAccountIds, hasHeroes } = hasSteamData(currentData) - - // can early exit if we have all the data we need - if (currentData && hasHeroes && hasAccountIds && !forceRefetchAll) { - return currentData - } - - const operation = retry.operation({ - retries: 35, - factor: 1.1, - minTimeout: 1 * 5000, - }) - - return new Promise((resolve, reject) => { - // eslint-disable-next-line @typescript-eslint/no-misused-promises - operation.attempt(async (currentAttempt) => { - let game: DelayedGames - try { - game = (await axios(getApiUrl(steam_server_id)))?.data - } catch (e) { - return operation.retry(new Error('Match not found')) - } - const { hasAccountIds, hasHeroes } = hasSteamData(game) - - // needs account ids - const retryAttempt = !hasAccountIds || !game ? new Error() : undefined - if (operation.retry(retryAttempt)) return - - // needs hero data - const retryAttempt2 = waitForHeros && !hasHeroes ? new Error() : undefined - if (operation.retry(retryAttempt2)) return - - // 2-minute delay gives "0" match id, so we use the gsi match id instead - game.match.match_id = match_id - game.match.server_steam_id = steam_server_id - const gamePlusMore = { ...game, createdAt: new Date() } - - if (hasHeroes) { - await saveMatch({ match_id, game: gamePlusMore }) - if (!forceRefetchAll) events.emit('saveHeroesForMatchId', { matchId: match_id }, token) - return resolve(gamePlusMore) - } - - if (!waitForHeros) { - await saveMatch({ match_id, game: gamePlusMore, refetchCards }) - waitForHeros = true - operation.retry(new Error()) - } - - return resolve(gamePlusMore) - }) - }) - } - - // @DEPRECATED - public getGcMatchData( - matchId: number | string, - cb: (err: number | null, body: GCMatchData | null) => void, - ) { - const operation = retry.operation({ - retries: 8, - factor: 2, - minTimeout: 2 * 1000, - }) - - 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({ - retries: 10, - fn: () => this.getCard(accountId), - minTimeout: 1000, - }).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() - logger.info('[STEAM] Manually closed dota') - // @ts-expect-error disconnect is there - this.steamClient.disconnect() - logger.info('[STEAM] Manually closed steam') - this.steamClient.removeAllListeners() - this.dota2.removeAllListeners() - logger.info('[STEAM] Removed all listeners from dota and steam') - resolve(true) - }) - } -} - -export default Dota - -const dota = Dota.getInstance() - -process - .on('SIGTERM', () => { - logger.info('[STEAM] Received SIGTERM') - - Promise.all([dota.exit()]) - .then(() => process.exit(0)) - .catch((e) => { - logger.info('[STEAM]', e) - }) - }) - .on('SIGINT', () => { - logger.info('[STEAM] Received SIGINT') - - Promise.all([dota.exit()]) - .then(() => process.exit(0)) - .catch((e) => { - logger.info('[STEAM]', e) - }) - }) - .on('uncaughtException', (e) => logger.error('uncaughtException', e)) +import crypto from 'crypto' +import fs from 'fs' + +import axios from 'axios' +import Dota2 from 'dota2' +import { Long } from 'mongodb' +import retry from 'retry' +import Steam from 'steam' +import steamErrors from 'steam-errors' + +import MongoDBSingleton from './MongoDBSingleton.js' +import { Cards, DelayedGames, GCMatchData } from './types/index.js' +import CustomError from './utils/customError.js' +import { getAccountsFromMatch } from './utils/getAccountsFromMatch.js' +import { logger } from './utils/logger.js' +import { retryCustom } from './utils/retry.js' + +import io from './index.js' + +const isDev = process.env.NODE_ENV === 'development' + +// Fetches data from MongoDB +const fetchDataFromMongo = async (match_id: string) => { + const mongo = MongoDBSingleton + const db = await mongo.connect() + + try { + return await db.collection('delayedGames').findOne({ 'match.match_id': match_id }) + } finally { + await mongo.close() + } +} + +// Constructs the API URL +const getApiUrl = (steam_server_id: string) => { + if (!process.env.STEAM_WEB_API) throw new CustomError('STEAM_WEB_API not set') + + return `https://api.steampowered.com/IDOTA2MatchStats_570/GetRealtimeStats/v1/?key=${process.env.STEAM_WEB_API}&server_steam_id=${steam_server_id}` +} + +// Saves the match to MongoDB and fetches new medals if needed +const saveMatch = async ({ + match_id, + game, + refetchCards = false, +}: { + match_id: string + game: DelayedGames + refetchCards?: boolean +}) => { + const mongo = MongoDBSingleton + const db = await mongo.connect() + + try { + await db + .collection('delayedGames') + .updateOne({ 'match.match_id': match_id }, { $set: game }, { upsert: true }) + + if (refetchCards) { + const { accountIds } = await getAccountsFromMatch({ + searchMatchId: game.match.match_id, + }) + const dota = Dota.getInstance() + await dota.getCards(accountIds, true) + } + } finally { + await mongo.close() + } +} + +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 = + hasTeams && + Array.isArray(game.teams[0].players) && + Array.isArray(game.teams[1].players) && + game.teams[0].players.length === 5 && + game.teams[1].players.length === 5 + + // Dev should be able to test in a lobby with bot matches + const hasAccountIds = isDev + ? hasPlayers // dev local lobby just needs the players array + : hasPlayers && + game.teams[0].players.every((player) => player.accountid) && + game.teams[1].players.every((player) => player.accountid) + const hasHeroes = + hasPlayers && + game.teams[0].players.every((player) => player.heroid) && + game.teams[1].players.every((player) => player.heroid) + return { hasAccountIds, hasPlayers, hasHeroes } +} + +class Dota { + private static instance: Dota + + private steamClient + + private steamUser + + public dota2 + + constructor() { + this.steamClient = new Steam.SteamClient() + // @ts-expect-error no types exist + this.steamUser = new Steam.SteamUser(this.steamClient) + this.dota2 = new Dota2.Dota2Client(this.steamClient, false, false) + + const details = this.getUserDetails() + + this.loadServerList() + this.loadSentry(details) + + this.setupClientEventHandlers(details) + this.setupUserEventHandlers() + this.setupDotaEventHandlers() + + // @ts-expect-error no types exist + this.steamClient.connect() + } + + getUserDetails() { + if (!process.env.STEAM_USER || !process.env.STEAM_PASS) { + throw new Error('STEAM_USER or STEAM_PASS not set') + } + + return { + account_name: process.env.STEAM_USER, + password: process.env.STEAM_PASS, + } + } + + loadServerList() { + const serverPath = './src/steam/volumes/servers.json' + if (fs.existsSync(serverPath)) { + try { + Steam.servers = JSON.parse(fs.readFileSync(serverPath).toString()) + } catch (e) { + // Ignore + } + } + } + + loadSentry(details: steamUserDetails) { + const sentryPath = './src/steam/volumes/sentry' + if (fs.existsSync(sentryPath)) { + const sentry = fs.readFileSync(sentryPath) + if (sentry.length) details.sha_sentryfile = sentry + } + } + + setupClientEventHandlers(details: steamUserDetails) { + this.steamClient.on('connected', () => { + this.steamUser.logOn(details) + }) + this.steamClient.on('logOnResponse', this.handleLogOnResponse.bind(this)) + this.steamClient.on('loggedOff', this.handleLoggedOff.bind(this)) + this.steamClient.on('error', this.handleClientError.bind(this)) + this.steamClient.on('servers', this.handleServerUpdate.bind(this)) + } + + handleLogOnResponse(logonResp: any) { + // @ts-expect-error no types exist + if (logonResp.eresult == Steam.EResult.OK) { + logger.info('[STEAM] Logged on.') + this.dota2.launch() + } else { + this.logSteamError(logonResp.eresult) + } + } + + handleLoggedOff(eresult: any) { + // @ts-expect-error no types exist + if (this.isProduction()) this.steamClient.connect() + logger.info('[STEAM] Logged off from Steam.', { eresult }) + this.logSteamError(eresult) + } + + handleClientError(error: any) { + logger.info('[STEAM] steam error', { error }) + if (!this.isProduction()) { + this.exit().catch((e) => logger.error('err steam error', { e })) + } + // @ts-expect-error no types exist + if (this.isProduction()) this.steamClient.connect() + } + + handleServerUpdate(servers: any) { + fs.writeFileSync('./src/steam/volumes/servers.json', JSON.stringify(servers)) + } + + setupUserEventHandlers() { + this.steamUser.on('updateMachineAuth', this.handleMachineAuth.bind(this)) + } + + // @ts-expect-error no types exist + handleMachineAuth(sentry, callback) { + const hashedSentry = crypto.createHash('sha1').update(sentry.bytes).digest() + fs.writeFileSync('./src/steam/volumes/sentry', hashedSentry) + logger.info('[STEAM] sentryfile saved') + callback({ sha_file: hashedSentry }) + } + + setupDotaEventHandlers() { + this.dota2.on('hellotimeout', this.handleHelloTimeout.bind(this)) + this.dota2.on('unready', () => logger.info('[STEAM] disconnected from dota game coordinator')) + } + + handleHelloTimeout() { + this.dota2.exit() + setTimeout(() => { + // @ts-expect-error no types exist + if (this.steamClient.loggedOn) this.dota2.launch() + }, 30000) + logger.info('[STEAM] hello time out!') + } + + // @ts-expect-error no types exist + logSteamError(eresult) { + try { + // @ts-expect-error no types exist + steamErrors(eresult, (err, errorObject) => { + logger.info('[STEAM]', { errorObject, err }) + }) + } catch (e) { + // Ignore + } + } + + isProduction() { + return process.env.NODE_ENV === 'production' + } + + public getUserSteamServer = (steam32Id: number | string): Promise => { + const steam_id = this.dota2.ToSteamID(Number(steam32Id)) + + // Set up the retry operation + const operation = retry.operation({ + retries: 35, // Number of retries + factor: 1, // Exponential backoff factor + minTimeout: 1 * 1000, // Minimum retry timeout (1 second) + maxTimeout: 60 * 1000, // Maximum retry timeout (60 seconds) + }) + + return new Promise((resolve, reject) => { + operation.attempt(() => { + this.dota2.spectateFriendGame({ steam_id }, (response: any, err: any) => { + const theID = response?.server_steamid?.toString() + + const shouldRetry = !theID ? new Error('No ID yet, will keep trying.') : undefined + if (operation.retry(shouldRetry)) return + + if (theID) resolve(theID) + else reject('No spectator match found') + }) + }) + }) + } + + public GetRealTimeStats = async ({ + match_id, + refetchCards = false, + steam_server_id, + token, + forceRefetchAll = false, + }: { + forceRefetchAll?: boolean + match_id: string + refetchCards?: boolean + steam_server_id: string + token: string + }): Promise => { + let waitForHeros = forceRefetchAll || false + + if (!steam_server_id) { + throw new Error('Match not found') + } + + const currentData = await fetchDataFromMongo(match_id) + const { hasAccountIds, hasHeroes } = hasSteamData(currentData) + + // can early exit if we have all the data we need + if (currentData && hasHeroes && hasAccountIds && !forceRefetchAll) { + return currentData + } + + const operation = retry.operation({ + retries: 35, + factor: 1.1, + minTimeout: 1 * 5000, + }) + + return new Promise((resolve, reject) => { + // eslint-disable-next-line @typescript-eslint/no-misused-promises + operation.attempt(async (currentAttempt) => { + let game: DelayedGames + try { + game = (await axios(getApiUrl(steam_server_id)))?.data + } catch (e) { + return operation.retry(new Error('Match not found')) + } + const { hasAccountIds, hasHeroes } = hasSteamData(game) + + // needs account ids + const retryAttempt = !hasAccountIds || !game ? new Error() : undefined + if (operation.retry(retryAttempt)) return + + // needs hero data + const retryAttempt2 = waitForHeros && !hasHeroes ? new Error() : undefined + if (operation.retry(retryAttempt2)) return + + // 2-minute delay gives "0" match id, so we use the gsi match id instead + game.match.match_id = match_id + game.match.server_steam_id = steam_server_id + const gamePlusMore = { ...game, createdAt: new Date() } + + if (hasHeroes) { + await saveMatch({ match_id, game: gamePlusMore }) + if (!forceRefetchAll) { + // forward the msg to dota node app + io.to('steam').emit('saveHeroesForMatchId', { matchId: match_id }, token) + } + return resolve(gamePlusMore) + } + + if (!waitForHeros) { + await saveMatch({ match_id, game: gamePlusMore, refetchCards }) + waitForHeros = true + operation.retry(new Error()) + } + + return resolve(gamePlusMore) + }) + }) + } + + // @DEPRECATED + public getGcMatchData( + matchId: number | string, + cb: (err: number | null, body: GCMatchData | null) => void, + ) { + const operation = retry.operation({ + retries: 8, + factor: 2, + minTimeout: 2 * 1000, + }) + + 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({ + retries: 10, + fn: () => this.getCard(accountId), + minTimeout: 1000, + }).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() + logger.info('[STEAM] Manually closed dota') + // @ts-expect-error disconnect is there + this.steamClient.disconnect() + logger.info('[STEAM] Manually closed steam') + this.steamClient.removeAllListeners() + this.dota2.removeAllListeners() + logger.info('[STEAM] Removed all listeners from dota and steam') + resolve(true) + }) + } +} + +export default Dota + +process + .on('SIGTERM', () => { + logger.info('[STEAM] Received SIGTERM') + + const dota = Dota.getInstance() + Promise.all([dota.exit()]) + .then(() => process.exit(0)) + .catch((e) => { + logger.info('[STEAM]', e) + }) + }) + .on('SIGINT', () => { + logger.info('[STEAM] Received SIGINT') + + const dota = Dota.getInstance() + Promise.all([dota.exit()]) + .then(() => process.exit(0)) + .catch((e) => { + logger.info('[STEAM]', e) + }) + }) + .on('uncaughtException', (e) => logger.error('uncaughtException', e)) diff --git a/packages/steam/src/types/index.ts b/packages/steam/src/types/index.ts new file mode 100644 index 00000000..f5a253fe --- /dev/null +++ b/packages/steam/src/types/index.ts @@ -0,0 +1,526 @@ +import { HeroNames } from '../getHero.js' + +export interface SocketClient { + name: string + token: string + stream_online: boolean + stream_start_date: Date | null + beta_tester: boolean + locale: string + multiAccount?: number + steam32Id: number | null // currently connected steam id + mmr: number // currently connected mmr + gsi?: Packet + Account: { + refresh_token: string + access_token: string + expires_at: number | null + scope: string | null + obtainment_timestamp: Date | null + expires_in: number | null + providerAccountId: string + } | null + SteamAccount: { + mmr: number + leaderboard_rank: number | null + name: string | null + steam32Id: number + }[] + settings: { + key: string + value: any + }[] +} +interface Provider { + name: string // "Dota 2" + appid: number // 570 for Dota 2 + version: number // 5.11.2022 it was version 47 + timestamp: number // Unix epoch time stamp in seconds of datapoint +} + +export interface MapData { + name: string // e.g. 'start' (for standard games), 'last_hit_trainer', etc. + matchid: string // "6845526874"; + game_time: number // 34; + clock_time: number // -28; + daytime: boolean // false; + nightstalker_night: boolean // false; + radiant_score: number // 0; + dire_score: number // 0; + game_state: string // "DOTA_GAMERULES_STATE_WAIT_FOR_PLAYERS_TO_LOAD"; + paused: boolean // false; + win_team: string // "none"; + customgamename: string // ""; + ward_purchase_cooldown: number // 0 +} + +/* +Example player from spec mode + steamid: '76561198157081101', + accountid: '196815373', + name: 'fat', + activity: 'playing', + kills: 0, + deaths: 2, + assists: 1, + last_hits: 6, + denies: 2, + kill_streak: 0, + commands_issued: 1319, + kill_list: {}, + team_name: 'radiant', + gold: 83, + gold_reliable: 83, + gold_unreliable: 0, + gold_from_hero_kills: 60, + gold_from_creep_kills: 0, + gold_from_income: 936, + gold_from_shared: 60, + gpm: 138, + xpm: 170, + net_worth: 1248, + hero_damage: 1705, + tower_damage: 0, + wards_purchased: 11, + wards_placed: 6, + wards_destroyed: 1, + runes_activated: 0, + camps_stacked: 0, + support_gold_spent: 200, + consumable_gold_spent: 985, + item_gold_spent: 950, + gold_lost_to_death: 26, + gold_spent_on_buybacks: 0 +*/ + +export interface Wearables { + wearable0: number + wearable1: number + wearable2: number + wearable3: number + wearable4: number + wearable5: number + wearable6: number + wearable7: number + wearable8: number + wearable9: number + wearable10: number + wearable11: number +} + +export interface Entity { + xpos: number + ypos: number + image: string + team: number + yaw: number + unitname: string + visionrange: number + name?: string + eventduration?: number + yposP?: string // parser + xposP?: string // parser + teamP?: string // parser +} + +export type Minimap = Record + +export interface Player { + team2?: { player0: Player; player1: Player; player2: Player; player3: Player; player4: Player } + team3?: { player5: Player; player6: Player; player7: Player; player8: Player; player9: Player } + id?: number // hero id + steamid: string // "76561198352664103", + accountid: string // "392398375", + name: string // "Valhalla", + activity: string // "playing", + kills: number // 0, + deaths: number //0, + assists: number // 0, + last_hits: number // 0, + denies: number // 0, + kill_streak: number // 0, + commands_issued: number // 0, + kill_list: Record + team_name: 'spectator' | 'radiant' | 'dire' + gold: number // 600, + gold_reliable: number // 0, + gold_unreliable: number //600, + gold_from_hero_kills: number // 0, + gold_from_creep_kills: number // 0, + gold_from_income: number // 0, + gold_from_shared: number // 0, + gpm: number // 0, + xpm: number // 0 + + // Additional fields in spectating mode + net_worth: number // 23815; + hero_damage: number // 28438; + tower_damage: number // 5924; + wards_purchased: number // 8; + wards_placed: number // 7; + wards_destroyed: number // 0; + runes_activated: number // 8; + camps_stacked: number // 2; + support_gold_spent: number // 350; + consumable_gold_spent: number //1720; + item_gold_spent: number // 17800; + gold_lost_to_death: number //1476; + gold_spent_on_buybacks: number //0; +} + +export interface Hero { + team2?: { player0: Hero; player1: Hero; player2: Hero; player3: Hero; player4: Hero } + team3?: { player5: Hero; player6: Hero; player7: Hero; player8: Hero; player9: Hero } + id: number // -1 if hero not yet set + name?: HeroNames // e.g. 'npc_dota_hero_antimage' once set + xpos?: number // -5422, + ypos?: number // -4771, + level?: number // 27, + xp?: number // 43424, + alive?: boolean // true, + respawn_seconds?: number // 0, + buyback_cost?: number // 2655, + buyback_cooldown?: number // 0, + health?: number // 2450, + max_health?: number // 2450, + health_percent?: number // 100, + mana?: number // 932, + max_mana?: number // 1107, + mana_percent?: number // From 0 to 100, e.g. 84, + silenced?: boolean // false, + stunned?: boolean // false, + disarmed?: boolean // false, + magicimmune?: boolean // false, + hexed?: boolean // false, + muted?: boolean // false, + break?: boolean // false, + aghanims_scepter?: boolean // false, + aghanims_shard?: boolean // false, + smoked?: boolean // false, + has_debuff?: boolean // false, + selected_unit?: boolean // true; // Only available as spectator + talent_1?: boolean // false, + talent_2?: boolean // true, + talent_3?: boolean // true, + talent_4?: boolean // false, + talent_5?: boolean // false, + talent_6?: boolean // true, + talent_7?: boolean // false, + talent_8?: boolean // true +} + +export interface Abilities { + // set once the game starts, i.e. game_state is set to "DOTA_GAMERULES_STATE_PRE_GAME" + ability0?: Ability + ability1?: Ability + ability2?: Ability + ability3?: Ability + ability4?: Ability + ability5?: Ability + ability6?: Ability + ability7?: Ability + ability8?: Ability + ability9?: Ability + ability10?: Ability + ability11?: Ability + ability12?: Ability + ability13?: Ability + ability14?: Ability + ability15?: Ability + ability16?: Ability + ability17?: Ability + ability18?: Ability + ability19?: Ability +} +export interface Ability { + name: string // e.g. "antimage_mana_break" or "seasonal_ti11_balloon" + level: number // e.g. 1, + can_cast: boolean // e.g. false, + passive: boolean // e.g. false, + ability_active: boolean // e.g. true, + cooldown: number // e.g. 0 + ultimate: boolean // e.g. false, + charges: number // e.g. 0, + max_charges: number // e.g. 0, + charge_cooldown: number // e.g. 0 +} + +export interface Items { + team2?: { player0: Items; player1: Items; player2: Items; player3: Items; player4: Items } + team3?: { player5: Items; player6: Items; player7: Items; player8: Items; player9: Items } + + // set once the game starts, i.e. game_state is set to "DOTA_GAMERULES_STATE_PRE_GAME" + slot0?: Item + slot1?: Item + slot2?: Item + slot3?: Item + slot4?: Item + slot5?: Item + slot6?: Item + slot7?: Item + slot8?: Item + stash0?: Item + stash1?: Item + stash2?: Item + stash3?: Item + stash4?: Item + stash5?: Item + teleport0?: Item + neutral0?: Item +} +export interface Item { + name: string // e.g. item_power_treads or "empty" + purchaser?: number // 5, + can_cast?: boolean // e.g. true, + cooldown?: number // e.g. 0, + item_level?: number // e.g. 1,2,3,4 = 9,8,7,6 seconds for bkb **new 3/12/2023** + passive: boolean // e.g. true for item_paladin_sword + charges?: number // e.g. 2 +} +interface Buildings { + dota_badguys_tower1_top: Building + dota_badguys_tower2_top: Building + dota_badguys_tower3_top: Building + dota_badguys_tower1_mid: Building + dota_badguys_tower2_mid: Building + dota_badguys_tower3_mid: Building + dota_badguys_tower1_bot: Building + dota_badguys_tower2_bot: Building + dota_badguys_tower3_bot: Building + dota_badguys_tower4_top: Building + dota_badguys_tower4_bot: Building + bad_rax_melee_top: Building + bad_rax_range_top: Building + bad_rax_melee_mid: Building + bad_rax_range_mid: Building + bad_rax_melee_bot: Building + bad_rax_range_bot: Building + dota_badguys_fort: Building +} +interface Building { + health: number // e.g. 1800 + max_health: number // e.g. 1800 +} +interface Draft { + // Undefined in player games, but provided in watching replays + activeteam: number // 2 (radiant) or 3 (dire) + pick: boolean // true, + activeteam_time_remaining: number // e.g. 25, + radiant_bonus_time: number // e.g. 130, + dire_bonus_time: number // e.g. 130, + team2: TeamDraft + team3: TeamDraft +} +interface TeamDraft { + pick0_id: number // e.g., 0, + pick0_class: string // e.g., '', + pick1_id: number // e.g., 0, + pick1_class: string // e.g., '', + pick2_id: number // e.g., 0, + pick2_class: string // e.g., '', + pick3_id: number // e.g., 0, + pick3_class: string // e.g., '', + pick4_id: number // e.g., 0, + pick4_class: string // e.g., '', + ban0_id: number // e.g., 69, + ban0_class: string // e.g., 'doom_bringer', + ban1_id: number // e.g., 61, + ban1_class: string // e.g., 'broodmother', + ban2_id: number // e.g., 0, + ban2_class: string // e.g., '', + ban3_id: number // e.g., 0, + ban3_class: string // e.g., '', + ban4_id: number // e.g., 0, + ban4_class: string // e.g., '', + ban5_id: number // e.g., 0, + ban5_class: string // e.g., '', + ban6_id: number // e.g., 0, + ban6_class: string // e.g., '' +} + +export enum DotaEventTypes { + RoshanKilled = 'roshan_killed', + AegisPickedUp = 'aegis_picked_up', + AegisDenied = 'aegis_denied', + Tip = 'tip', + BountyPickup = 'bounty_rune_pickup', + CourierKilled = 'courier_killed', // spectator only +} + +export const validEventTypes = new Set(Object.values(DotaEventTypes)) + +export interface DotaEvent { + game_time: number // 810, + event_type: DotaEventTypes + + // Event 'tip' + sender_player_id: number // 7, + receiver_player_id: number // 3, + tip_amount: number // 50 + + // Event 'courier_killed' + courier_team: string // 'dire', + killer_player_id: number // 1, + owning_player_id: number // 5 + + // Event 'bounty_rune_pickup' + player_id: number // 9, + team: string // 'dire', + bounty_value: number // 45, + team_gold: number // 225 + + // Event 'roshan_killed' + killed_by_team: 'dire' + //killer_player_id: 7; + + // Event 'aegis_picked_up' + //player_id: 7; + snatched: false + + // Event 'aegis_denied' + //player_id: 7; +} +/** + * + * Dump elements that are not matching data structure.... + * + */ + +export interface Packet { + provider: Provider + map?: MapData + player?: Player + minimap?: Minimap + hero?: Hero + abilities?: Abilities + items?: Items + buildings?: { + // only providing information for own towers in a game, of for all towers if a spectator + radiant?: Buildings + dire?: Buildings + } + draft?: Draft + events?: DotaEvent[] + previously?: Omit & { map: MapData | boolean } + added?: Omit // it has the same structure as above, and has a value "true" +} + +export interface GCMatchData { + result: number + match?: Match + vote: number +} + +export interface Match { + duration: number + starttime: number + players: Player[] + match_id: ID + tower_status: number[] + barracks_status: number[] + cluster: number + first_blood_time: number + replay_salt: number + server_ip: null + server_port: null + lobby_type: number + human_players: number + average_skill: null + game_balance: null + radiant_team_id: null + dire_team_id: null + leagueid: number + radiant_team_name: null + dire_team_name: null + radiant_team_logo: null + dire_team_logo: null + radiant_team_logo_url: null + dire_team_logo_url: null + radiant_team_complete: null + dire_team_complete: null + positive_votes: number + negative_votes: number + game_mode: number + picks_bans: any[] + match_seq_num: null + replay_state: number + radiant_guild_id: null + dire_guild_id: null + radiant_team_tag: null + dire_team_tag: null + series_id: number + series_type: number + broadcaster_channels: any[] + engine: number + custom_game_data: null + match_flags: number + private_metadata_key: null + radiant_team_score: number + dire_team_score: number + match_outcome: number + tournament_id: null + tournament_round: null + pre_game_duration: number + mvp_account_id: any[] + coaches: any[] + level: string + timestamp: string +} + +export interface ID { + low: number + high: number + unsigned: boolean +} + +export interface HeroDamage { + pre_reduction: number + post_reduction: number + damage_type: number +} + +export interface PermanentBuff { + permanent_buff: number + stack_count: number + grant_time: number +} + +export interface NotablePlayer { + heroId: number + account_id: number + position: number + heroName: string + name: string + image?: string + country_code: string +} + +export interface Medals { + id: string + name: string + rank_tier: number +} + +export interface DelayedGames { + _id: string + match: { + server_steam_id: string + match_id: string + game_mode: number + lobby_type: number + } + teams: { + players: { + items: number[] + heroid: number + accountid: string + }[] + }[] +} +export interface Cards { + lifetime_games: number + account_id: number + leaderboard_rank: number + rank_tier: number + createdAt: Date +} diff --git a/packages/steam/src/utils/customError.ts b/packages/steam/src/utils/customError.ts new file mode 100644 index 00000000..d6f9e73a --- /dev/null +++ b/packages/steam/src/utils/customError.ts @@ -0,0 +1,7 @@ +export default class CustomError extends Error { + constructor(...args: any[]) { + super(...args) + Error.captureStackTrace(this, CustomError) + this.name = 'CustomError' + } +} diff --git a/packages/steam/src/utils/getAccountsFromMatch.ts b/packages/steam/src/utils/getAccountsFromMatch.ts new file mode 100644 index 00000000..37b11f30 --- /dev/null +++ b/packages/steam/src/utils/getAccountsFromMatch.ts @@ -0,0 +1,58 @@ +import { getSpectatorPlayers } from './getSpectatorPlayers.js' +import MongoDBSingleton from '../MongoDBSingleton.js' +import { DelayedGames, Packet } from '../types/index.js' + +export async function getAccountsFromMatch({ + gsi, + searchMatchId, + searchPlayers, +}: { + gsi?: Packet + searchMatchId?: string + searchPlayers?: { + heroid: number + accountid: number + }[] +} = {}) { + const players = searchPlayers?.length ? searchPlayers : getSpectatorPlayers(gsi) + + // spectator account ids + if (Array.isArray(players) && players.length) { + return { + matchPlayers: players, + accountIds: players.map((player) => player.accountid), + } + } + + const matchId = searchMatchId || gsi?.map?.matchid + + const mongo = MongoDBSingleton + const db = await mongo.connect() + + try { + const response = await db + .collection('delayedGames') + .findOne({ 'match.match_id': matchId }) + + const matchPlayers = + Array.isArray(response?.teams) && response?.teams.length === 2 + ? [ + ...response.teams[0].players.map((a) => ({ + heroid: a.heroid, + accountid: Number(a.accountid), + })), + ...response.teams[1].players.map((a) => ({ + heroid: a.heroid, + accountid: Number(a.accountid), + })), + ] + : ([] as { heroid: number; accountid: number }[]) + + return { + matchPlayers, + accountIds: matchPlayers.map((player) => player.accountid), + } + } finally { + await mongo.close() + } +} diff --git a/packages/steam/src/utils/getSpectatorPlayers.ts b/packages/steam/src/utils/getSpectatorPlayers.ts new file mode 100644 index 00000000..5d8d708c --- /dev/null +++ b/packages/steam/src/utils/getSpectatorPlayers.ts @@ -0,0 +1,27 @@ +import { Packet } from '../types/index.js' + +export function getSpectatorPlayers(gsi?: Packet) { + let matchPlayers: { heroid: number; accountid: number; selected: boolean }[] = [] + if (gsi?.hero?.team2 && gsi.hero.team3) { + matchPlayers = [ + ...Object.keys(gsi.hero.team2).map((playerIdx: any) => ({ + // @ts-expect-error asdf + heroid: gsi.hero?.team2?.[playerIdx].id, + // @ts-expect-error asdf + accountid: Number(gsi.player?.team2?.[playerIdx].accountid), + // @ts-expect-error asdf + selected: !!gsi.hero?.team2?.[playerIdx].selected_unit, + })), + ...Object.keys(gsi.hero.team3).map((playerIdx: any) => ({ + // @ts-expect-error asdf + heroid: gsi.hero?.team3?.[playerIdx].id, + // @ts-expect-error asdf + accountid: Number(gsi.player?.team3?.[playerIdx].accountid), + // @ts-expect-error asdf + selected: !!gsi.hero?.team3?.[playerIdx].selected_unit, + })), + ] + } + + return matchPlayers +} diff --git a/packages/steam/src/utils/logger.ts b/packages/steam/src/utils/logger.ts new file mode 100644 index 00000000..a185f219 --- /dev/null +++ b/packages/steam/src/utils/logger.ts @@ -0,0 +1,31 @@ +import { createLogger, format, transports } from 'winston' +const { combine, printf, errors, json, timestamp } = format + +const isDev = process.env.NODE_ENV === 'development' + +const handleErrors = format((info) => { + if (info instanceof Error) { + return Object.assign({}, info, { stack: info.stack }) + } + if (info.e instanceof Error) { + return Object.assign({}, info, { 'e.stack': info.e.stack }) + } + return info +}) + +const prodFormats = combine(handleErrors(), errors({ stack: true }), json()) + +const devFormats = combine( + handleErrors(), + errors({ stack: true }), + json(), + timestamp(), + printf(({ message, level, timestamp, ...rest }) => { + return `[${timestamp}] ${level}: ${message} ${JSON.stringify(rest, null, 2)}` + }), +) + +export const logger = createLogger({ + format: isDev ? devFormats : prodFormats, + transports: [new transports.Console()], +}) diff --git a/packages/steam/src/utils/retry.ts b/packages/steam/src/utils/retry.ts new file mode 100644 index 00000000..0dbe1e89 --- /dev/null +++ b/packages/steam/src/utils/retry.ts @@ -0,0 +1,30 @@ +import retry from 'retry' + +export const retryCustom = async ({ + retries, + fn, + minTimeout, +}: { + retries: number + fn: () => Promise + minTimeout: number +}): Promise => { + const operation = retry.operation({ + retries, + minTimeout, + }) + + return new Promise((resolve, reject) => { + // eslint-disable-next-line @typescript-eslint/no-misused-promises + operation.attempt(async (currentAttempt) => { + try { + const result = await fn() + resolve(result) + } catch (err: any) { + if (!operation.retry(new Error('retrying'))) { + reject(operation.mainError()) + } + } + }) + }) +} diff --git a/packages/steam/tsconfig.json b/packages/steam/tsconfig.json new file mode 100644 index 00000000..f624bf63 --- /dev/null +++ b/packages/steam/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + /* Basic Options */ + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "composite": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "extends": "../../tsconfig.json" +}