From dc0994045335903f5733a787fe5f8427670acad4 Mon Sep 17 00:00:00 2001 From: Geczy <1036968+Geczy@users.noreply.github.com> Date: Mon, 7 Sep 2026 03:04:51 +0000 Subject: [PATCH] refactor: continue Oxlint backlog cleanup --- packages/dota/src/db/get-db-user.ts | 2 +- packages/dota/src/db/redis-client.ts | 4 +- packages/dota/src/db/watcher.ts | 2 +- .../events/gsi-events/event.chat_message.ts | 30 +- .../events/gsi-events/event.roshan_killed.ts | 2 +- .../src/dota/events/gsi-events/newdata.ts | 4 +- .../dota/src/dota/events/minimap/parser.ts | 2 +- packages/dota/src/dota/get-stream-delay.ts | 2 +- packages/dota/src/dota/gsi-handler.ts | 80 +- packages/dota/src/dota/index.ts | 2 +- .../dota/src/dota/lib/announce-features.ts | 4 +- .../dota/src/dota/lib/capture-cosmetics.ts | 2 +- packages/dota/src/dota/lib/check-midas.ts | 2 +- packages/dota/src/dota/lib/get-players.ts | 2 +- packages/dota/src/dota/lib/heroes.ts | 20 +- .../__tests__/match-data-service.test.ts | 2 +- packages/dota/src/index.ts | 6 +- .../steam/__tests__/player-summaries.test.ts | 4 +- packages/dota/src/steam/medals.ts | 4 +- packages/dota/src/steam/realtime-stats.ts | 2 +- packages/dota/src/steam/smurfs.ts | 2 +- packages/dota/src/twitch/chat-client.ts | 2 +- packages/shared-utils/src/db/supabase.ts | 8 +- .../shared-utils/src/disableReason/service.ts | 4 +- packages/shared-utils/src/heartbeat.ts | 4 +- packages/steam/src/index.ts | 6 +- packages/steam/src/socket-server.ts | 4 +- packages/steam/src/steam.ts | 10 +- .../twitch-chat/src/__tests__/shared-mocks.ts | 2 +- packages/twitch-chat/src/event-sub-socket.ts | 2 +- packages/twitch-chat/src/index.ts | 6 +- scripts/quality/oxlint-baseline.json | 3554 +++-------------- 32 files changed, 781 insertions(+), 3001 deletions(-) diff --git a/packages/dota/src/db/get-db-user.ts b/packages/dota/src/db/get-db-user.ts index 56d4e560..6301793d 100644 --- a/packages/dota/src/db/get-db-user.ts +++ b/packages/dota/src/db/get-db-user.ts @@ -185,7 +185,7 @@ export default async function getDBUser({ let subscription: SocketClient['subscription'] | undefined if (Array.isArray(user.subscriptions) && user.subscriptions.length > 0) { const activeSubscription = - user.subscriptions.find((sub: SubscriptionRow) => isSubscriptionActive(sub)) || + user.subscriptions.find((sub: SubscriptionRow) => isSubscriptionActive(sub)) ?? user.subscriptions[0] subscription = { ...activeSubscription, diff --git a/packages/dota/src/db/redis-client.ts b/packages/dota/src/db/redis-client.ts index 22109fcf..0e58a72b 100644 --- a/packages/dota/src/db/redis-client.ts +++ b/packages/dota/src/db/redis-client.ts @@ -47,9 +47,7 @@ class RedisClient { } public static getInstance(): RedisClient { - if (RedisClient.instance === undefined) { - RedisClient.instance = new RedisClient() - } + RedisClient.instance ??= new RedisClient() return RedisClient.instance } } diff --git a/packages/dota/src/db/watcher.ts b/packages/dota/src/db/watcher.ts index 53a8a69b..bdfce901 100644 --- a/packages/dota/src/db/watcher.ts +++ b/packages/dota/src/db/watcher.ts @@ -457,7 +457,7 @@ class SetupSupabase { giftQuantity: giftQuantityNum, }) } - } else if (giftQuantityRaw != null) { + } else if (giftQuantityRaw !== null && giftQuantityRaw !== undefined) { // Log only if it was provided but invalid logger.warn('Gift quantity is invalid or not positive', { giftId: newObj.id, diff --git a/packages/dota/src/dota/events/gsi-events/event.chat_message.ts b/packages/dota/src/dota/events/gsi-events/event.chat_message.ts index c6b33d32..231b6e29 100644 --- a/packages/dota/src/dota/events/gsi-events/event.chat_message.ts +++ b/packages/dota/src/dota/events/gsi-events/event.chat_message.ts @@ -129,7 +129,7 @@ const isLikelyEnglish = function isLikelyEnglish(message: string): boolean { /\b(the|and|or|but|in|on|at|to|for|of|with|by|an|a|is|are|was|were|be|been|being|have|has|had|do|does|did|will|would|could|should|may|might|must|can|shall|this|that|these|those|here|there|where|when|why|how|what|who|which|all|some|any|every|most|many|much|few|little|no|not|yes|ok|okay|hi|hello|hey|bye|good|bad|big|small|long|short|hot|cold|new|old|high|low|right|wrong|true|false|first|last|next|now|then|soon|later|before|after|up|down|in|out|on|off|over|under|above|below|left|right|front|back|inside|outside|open|close|full|empty|fast|slow|easy|hard|quick|quickly|slowly|carefully|well|badly|better|best|worse|worst|more|most|less|least|many|much|few|little|some|any|every|all|no|none|nothing|something|anything|everything|everyone|someone|anyone|noone)\b/giu const wordCount = message.split(/\s+/u).length - const englishWordMatches = (message.match(englishWords) || []).length + const englishWordMatches = (message.match(englishWords) ?? []).length const englishRatio = wordCount > 0 ? englishWordMatches / wordCount : 0 const hasNonLatinChars = detectNonLatinCharacters(message) @@ -404,20 +404,18 @@ eventHandler.registerEvent(`event:${DotaEventTypes.ChatMessage}`, { }) // Set timeout if not already set - if (!buffer.timeout) { - buffer.timeout = setTimeout(async () => { - const currentBuffer = translationBuffers.get(clientKey) - if (currentBuffer) { - await processTranslationBuffer( - currentBuffer.messages, - dotaClient, - translateInChat, - translateOnOverlay, - typedLanguage - ) - translationBuffers.delete(clientKey) - } - }, TRANSLATION_DEBOUNCE_TIME) - } + buffer.timeout ??= setTimeout(async () => { + const currentBuffer = translationBuffers.get(clientKey) + if (currentBuffer) { + await processTranslationBuffer( + currentBuffer.messages, + dotaClient, + translateInChat, + translateOnOverlay, + typedLanguage + ) + translationBuffers.delete(clientKey) + } + }, TRANSLATION_DEBOUNCE_TIME) }, }) diff --git a/packages/dota/src/dota/events/gsi-events/event.roshan_killed.ts b/packages/dota/src/dota/events/gsi-events/event.roshan_killed.ts index a1842867..8559b453 100644 --- a/packages/dota/src/dota/events/gsi-events/event.roshan_killed.ts +++ b/packages/dota/src/dota/events/gsi-events/event.roshan_killed.ts @@ -49,7 +49,7 @@ eventHandler.registerEvent(`event:${DotaEventTypes.RoshanKilled}`, { // TODO: move this to a redis handler const redisJson = await redisClient.getJson(`${dotaClient.getToken()}:roshan`) - const count = redisJson ? Number(redisJson.count) : 0 + const count = redisJson ? redisJson.count : 0 const res = { count: count + 1, maxDate, diff --git a/packages/dota/src/dota/events/gsi-events/newdata.ts b/packages/dota/src/dota/events/gsi-events/newdata.ts index 566244b9..67e7eacb 100644 --- a/packages/dota/src/dota/events/gsi-events/newdata.ts +++ b/packages/dota/src/dota/events/gsi-events/newdata.ts @@ -352,7 +352,7 @@ const saveMatchData = async function saveMatchData(client: SocketClient) { { match_id: matchId, refetchCards: true, - steam_server_id: currentSteamServerId.toString(), + steam_server_id: currentSteamServerId, token: client.token, }, (err: unknown, data: DelayedGames) => { @@ -383,7 +383,7 @@ const saveMatchData = async function saveMatchData(client: SocketClient) { // Update cache with complete data matchDataCache.set(cacheKey, { lobbyType: String(delayedData.match.lobby_type), - steamServerId: currentSteamServerId.toString(), + steamServerId: currentSteamServerId, timestamp: Date.now(), }) } diff --git a/packages/dota/src/dota/events/minimap/parser.ts b/packages/dota/src/dota/events/minimap/parser.ts index 5de996b5..549209bb 100644 --- a/packages/dota/src/dota/events/minimap/parser.ts +++ b/packages/dota/src/dota/events/minimap/parser.ts @@ -127,7 +127,7 @@ class MinimapParser { // Simplify Coordinates if (entity.xpos !== undefined) { if (entity.xpos >= 0) { - entity.xpos = Number(entity.xpos) + Number(this.xLength) + entity.xpos += this.xLength } else { entity.xpos = this.xLength - Math.abs(entity.xpos) } diff --git a/packages/dota/src/dota/get-stream-delay.ts b/packages/dota/src/dota/get-stream-delay.ts index 53d0b38a..1dadbc39 100644 --- a/packages/dota/src/dota/get-stream-delay.ts +++ b/packages/dota/src/dota/get-stream-delay.ts @@ -8,5 +8,5 @@ export const getStreamDelay = function getStreamDelay( settings: SocketClient['settings'], subscription?: SubscriptionRow ) { - return Number(getValueOrDefault(DBSettings.streamDelay, settings, subscription)) + GLOBAL_DELAY + return getValueOrDefault(DBSettings.streamDelay, settings, subscription) + GLOBAL_DELAY } diff --git a/packages/dota/src/dota/gsi-handler.ts b/packages/dota/src/dota/gsi-handler.ts index 9619a3a2..8e0894f3 100644 --- a/packages/dota/src/dota/gsi-handler.ts +++ b/packages/dota/src/dota/gsi-handler.ts @@ -251,7 +251,7 @@ class GSIHandler implements GSIHandlerType { private captureInGameSnapshot() { const matchId = this.client.gsi?.map?.matchid - if (matchId == null || matchId.length === 0 || matchId === '0') { + if (matchId === null || matchId === undefined || matchId.length === 0 || matchId === '0') { return } @@ -398,7 +398,7 @@ class GSIHandler implements GSIHandlerType { if (!this.client.stream_online) { return } - if (matchId == null || matchId.length === 0 || matchId === '0') { + if (matchId === null || matchId === undefined || matchId.length === 0 || matchId === '0') { return } @@ -450,7 +450,12 @@ class GSIHandler implements GSIHandlerType { // so add to their list of steam accounts async updateSteam32Id() { const steamId = this.client.gsi?.player?.steamid - if (this.creatingSteamAccount || steamId == null || steamId.length === 0) { + if ( + this.creatingSteamAccount || + steamId === null || + steamId === undefined || + steamId.length === 0 + ) { return } @@ -460,7 +465,7 @@ class GSIHandler implements GSIHandlerType { try { steam32Id = steamID64toSteamID32(steamId) - if (steam32Id == null || steam32Id === 0) { + if (steam32Id === null || steam32Id === undefined || steam32Id === 0) { this.creatingSteamAccount = false return } @@ -508,7 +513,7 @@ class GSIHandler implements GSIHandlerType { return } - if (res?.id != null && res.id.length > 0) { + if (res?.id !== null && res?.id !== undefined && res.id.length > 0) { await this.handleExistingAccount(res, steam32Id) } else { const created = await this.createNewSteamAccount(mmr, steam32Id) @@ -520,7 +525,7 @@ class GSIHandler implements GSIHandlerType { this.creatingSteamAccount = false } catch (error) { - if (steam32Id != null && steam32Id !== 0) { + if (steam32Id !== null && steam32Id !== undefined && steam32Id !== 0) { this.client.multiAccount = steam32Id this.multiAccountRevalidatedAt = Date.now() } @@ -556,7 +561,9 @@ class GSIHandler implements GSIHandlerType { leaderboard_rank: null, mmr: res.mmr, name: - this.client.gsi?.player?.name != null && this.client.gsi.player.name.length > 0 + this.client.gsi?.player?.name !== null && + this.client.gsi?.player?.name !== undefined && + this.client.gsi.player.name.length > 0 ? this.client.gsi.player.name : null, steam32Id, @@ -580,7 +587,8 @@ class GSIHandler implements GSIHandlerType { logger.info('[STEAM32ID] Adding steam32Id', { name: this.client.name }) const playerName = this.client.gsi?.player?.name - const name = playerName != null && playerName.length > 0 ? playerName : null + const name = + playerName !== null && playerName !== undefined && playerName.length > 0 ? playerName : null const { error } = await supabase.from('steam_accounts').insert({ mmr, name, @@ -709,7 +717,7 @@ class GSIHandler implements GSIHandlerType { // We at least want the hero name so it can go in the twitch bet title const heroName = client.gsi.hero?.name - if (heroName == null || heroName.length === 0) { + if (heroName === null || heroName === undefined || heroName.length === 0) { // console.log(`if (!client.gsi.hero?.name || !client.gsi.hero.name.length) {`) return } @@ -765,7 +773,8 @@ class GSIHandler implements GSIHandlerType { .is('won', null) .single() if ( - predictionResponse.data?.predictionId != null && + predictionResponse.data?.predictionId !== null && + predictionResponse.data?.predictionId !== undefined && predictionResponse.data.predictionId.length > 0 ) { await refundTwitchBet(this.getChannelId(), predictionResponse.data.predictionId) @@ -843,7 +852,7 @@ class GSIHandler implements GSIHandlerType { } // Check if this bet for this match id already exists, dont continue if it does - if (bet?.[0]?.id != null && bet[0].id.length > 0) { + if (bet?.[0]?.id !== null && bet?.[0]?.id !== undefined && bet[0].id.length > 0) { logger.info('[BETS] Found a bet in the database', { id: bet?.[0]?.id }) this.openingBets = false return @@ -1042,7 +1051,7 @@ class GSIHandler implements GSIHandlerType { ? this.client.gsi?.player?.team_name : null)) - if (this.openingBets || matchId == null || matchId.length === 0) { + if (this.openingBets || matchId === null || matchId === undefined || matchId.length === 0) { logger.debug('[BETS] Not closing bets', { endingBets: this.endingBets, name: this.client.name, @@ -1050,7 +1059,7 @@ class GSIHandler implements GSIHandlerType { playingMatchId: matchId, }) - if (matchId == null || matchId.length === 0) { + if (matchId === null || matchId === undefined || matchId.length === 0) { await this.resetClientState() } return @@ -1109,9 +1118,14 @@ class GSIHandler implements GSIHandlerType { // Pretty rare case, 26 times in 7 days. Usually when they test Dotabod in a custom lobby // Custom lobbies create a match ID but don't report any stats if ( - (this.client.gsi?.map?.dire_score == null || this.client.gsi.map.dire_score === 0) && - (this.client.gsi?.map?.radiant_score == null || this.client.gsi.map.radiant_score === 0) && - this.client.gsi?.map?.matchid != null && + (this.client.gsi?.map?.dire_score === null || + this.client.gsi?.map?.dire_score === undefined || + this.client.gsi.map.dire_score === 0) && + (this.client.gsi?.map?.radiant_score === null || + this.client.gsi?.map?.radiant_score === undefined || + this.client.gsi.map.radiant_score === 0) && + this.client.gsi?.map?.matchid !== null && + this.client.gsi?.map?.matchid !== undefined && this.client.gsi.map.matchid.length > 0 ) { logger.info('This is likely a no stats recorded match', { @@ -1137,14 +1151,15 @@ class GSIHandler implements GSIHandlerType { .is('won', null) .single() if ( - predictionResponse.data?.predictionId != null && + predictionResponse.data?.predictionId !== null && + predictionResponse.data?.predictionId !== undefined && predictionResponse.data.predictionId.length > 0 ) { const oldBetId = await refundTwitchBet( this.getChannelId(), predictionResponse.data.predictionId ) - if (oldBetId != null && oldBetId.length > 0) { + if (oldBetId !== null && oldBetId !== undefined && oldBetId.length > 0) { await supabase .from('matches') .update({ predictionId: null, updated_at: new Date().toISOString() }) @@ -1168,13 +1183,13 @@ class GSIHandler implements GSIHandlerType { // Use the lobby type from Redis if it exists (including 0) // Otherwise default to ranked - const localLobbyType = playingLobbyType === null ? LOBBY_TYPE_RANKED : playingLobbyType + const localLobbyType = playingLobbyType ?? LOBBY_TYPE_RANKED const isParty = getValueOrDefault(DBSettings.onlyParty, this.client.settings) await this.updateMMR({ // 22 is game mode for normal game non turbo - gameMode: playingGameMode === null ? 22 : playingGameMode, + gameMode: playingGameMode ?? 22, heroName, heroSlot, increase: won, @@ -1205,7 +1220,8 @@ class GSIHandler implements GSIHandlerType { ) if ( - treadToggleData?.treadToggles != null && + treadToggleData?.treadToggles !== null && + treadToggleData?.treadToggles !== undefined && treadToggleData.treadToggles > 0 && this.client.stream_online ) { @@ -1266,7 +1282,12 @@ class GSIHandler implements GSIHandlerType { say(this.client, message, { chattersKey: 'matchOutcome', delay: false }) - if (!betsEnabled || predictionId == null || predictionId.length === 0) { + if ( + !betsEnabled || + predictionId === null || + predictionId === undefined || + predictionId.length === 0 + ) { logger.debug('Bets are not enabled or no prediction was opened, stopping here', { name: this.client.name, }) @@ -1334,7 +1355,7 @@ class GSIHandler implements GSIHandlerType { .eq('userId', this.client.token) .single() - if (error !== null || matchData == null) { + if (error !== null || matchData === null || matchData === undefined) { logger.info('[BETS] Match already closed or not found, skipping early DC winner check', { error: error?.message, matchId, @@ -1376,7 +1397,9 @@ class GSIHandler implements GSIHandlerType { await supabase .from('matches') .update({ - ...(snapshotMatch.hero_name != null && snapshotMatch.hero_name.length > 0 + ...(snapshotMatch.hero_name !== null && + snapshotMatch.hero_name !== undefined && + snapshotMatch.hero_name.length > 0 ? { hero_name: snapshotMatch.hero_name } : {}), dire_score: snapshotMatch.dire_score, @@ -1454,7 +1477,7 @@ class GSIHandler implements GSIHandlerType { .eq('userId', this.client.token) .single() - if (matchNotEnded == null || error !== null) { + if (matchNotEnded === null || matchNotEnded === undefined || error !== null) { logger.info('[BETS] Match already ended, skipping early DC winner check', { matchId, name: this.client.name, @@ -1511,7 +1534,7 @@ class GSIHandler implements GSIHandlerType { 'getMatchMinimalDetails', { match_id: Number(matchId) }, (err: unknown, response: MatchMinimalDetailsResponse) => { - if (err != null) { + if (err !== null && err !== undefined) { reject(err) } else { resolve(response) @@ -1586,14 +1609,15 @@ class GSIHandler implements GSIHandlerType { .is('won', null) .single() if ( - predictionResponse.data?.predictionId != null && + predictionResponse.data?.predictionId !== null && + predictionResponse.data?.predictionId !== undefined && predictionResponse.data.predictionId.length > 0 ) { const oldBetId = await refundTwitchBet( this.getChannelId(), predictionResponse.data.predictionId ) - if (oldBetId != null && oldBetId.length > 0) { + if (oldBetId !== null && oldBetId !== undefined && oldBetId.length > 0) { await supabase .from('matches') .update({ predictionId: null, updated_at: new Date().toISOString() }) diff --git a/packages/dota/src/dota/index.ts b/packages/dota/src/dota/index.ts index 160d21d0..e5dd5dfb 100644 --- a/packages/dota/src/dota/index.ts +++ b/packages/dota/src/dota/index.ts @@ -29,7 +29,7 @@ const setupTranslations = async () => { preload: readdirSync(join('./locales')).filter((fileName: string) => { const joinedPath = join(join('./locales'), fileName) const isDirectory = lstatSync(joinedPath).isDirectory() - return !!isDirectory + return isDirectory }), returnEmptyString: false, returnNull: false, diff --git a/packages/dota/src/dota/lib/announce-features.ts b/packages/dota/src/dota/lib/announce-features.ts index e7604b30..ecc37570 100644 --- a/packages/dota/src/dota/lib/announce-features.ts +++ b/packages/dota/src/dota/lib/announce-features.ts @@ -130,7 +130,7 @@ export const dispatchFeatureAnnouncements = async function dispatchFeatureAnnoun } const guardKey = `${client.token}:featureAnnouncedMatch` - if ((await redisClient.client.get(guardKey)) === String(matchId)) { + if ((await redisClient.client.get(guardKey)) === matchId) { return } @@ -142,7 +142,7 @@ export const dispatchFeatureAnnouncements = async function dispatchFeatureAnnoun continue } if (await announceFeatureOnce(client, feature)) { - await redisClient.client.set(guardKey, String(matchId)) + await redisClient.client.set(guardKey, matchId) return } } diff --git a/packages/dota/src/dota/lib/capture-cosmetics.ts b/packages/dota/src/dota/lib/capture-cosmetics.ts index a9023d37..73ac781a 100644 --- a/packages/dota/src/dota/lib/capture-cosmetics.ts +++ b/packages/dota/src/dota/lib/capture-cosmetics.ts @@ -30,7 +30,7 @@ export const captureCosmetics = async function captureCosmetics( heroId, heroName: getHeroNameOrColor(heroId), items: items as unknown as Json, - matchId: String(matchId), + matchId, updated_at: new Date().toISOString(), userId: client.token, }, diff --git a/packages/dota/src/dota/lib/check-midas.ts b/packages/dota/src/dota/lib/check-midas.ts index 92124520..deb14f6e 100644 --- a/packages/dota/src/dota/lib/check-midas.ts +++ b/packages/dota/src/dota/lib/check-midas.ts @@ -46,7 +46,7 @@ const checkMidasIterator = async function checkMidasIterator(client: SocketClien // Get passive midas data from Redis const passiveMidasData = (await redisClient.getJson( `${token}:passiveMidas` - )) || { + )) ?? { firstNoticedPassive: 0, told: 0, } diff --git a/packages/dota/src/dota/lib/get-players.ts b/packages/dota/src/dota/lib/get-players.ts index 5cd7b2b5..fdd02f9d 100644 --- a/packages/dota/src/dota/lib/get-players.ts +++ b/packages/dota/src/dota/lib/get-players.ts @@ -82,7 +82,7 @@ export const getPlayers = async function getPlayers({ accountIds, average_mmr: response?.average_mmr, cards, - gameMode: response !== null ? Number(response.match.game_mode) : undefined, + gameMode: response !== null ? response.match.game_mode : undefined, matchPlayers, } } finally { diff --git a/packages/dota/src/dota/lib/heroes.ts b/packages/dota/src/dota/lib/heroes.ts index 8f30b2cf..48c8f448 100644 --- a/packages/dota/src/dota/lib/heroes.ts +++ b/packages/dota/src/dota/lib/heroes.ts @@ -96,17 +96,15 @@ export const getHeroByName = function getHeroByName( }) // then hero name - if (!hero) { - hero = lookInHeroes.find((h) => { - const inName = h.localized_name - // replace all spaces with nothing, and only keep a-z - .replaceAll(/[^a-z]/giu, '') - .toLowerCase() - .trim() - - return inName.includes(localName) - }) - } + hero ??= lookInHeroes.find((h) => { + const inName = h.localized_name + // replace all spaces with nothing, and only keep a-z + .replaceAll(/[^a-z]/giu, '') + .toLowerCase() + .trim() + + return inName.includes(localName) + }) return hero } diff --git a/packages/dota/src/dota/lib/matchData/__tests__/match-data-service.test.ts b/packages/dota/src/dota/lib/matchData/__tests__/match-data-service.test.ts index aa11e50c..28b54f97 100644 --- a/packages/dota/src/dota/lib/matchData/__tests__/match-data-service.test.ts +++ b/packages/dota/src/dota/lib/matchData/__tests__/match-data-service.test.ts @@ -143,7 +143,7 @@ interface ClientOverrides { } const makeClient = function makeClient(o: ClientOverrides = {}): SocketClient { - const matchid = o.matchid === undefined ? '8800000001' : o.matchid + const matchid = o.matchid ?? '8800000001' const ownAccountId = o.ownAccountId ?? '111' const baseGsi = createPacketStub( matchid diff --git a/packages/dota/src/index.ts b/packages/dota/src/index.ts index d0a22754..60c58549 100644 --- a/packages/dota/src/index.ts +++ b/packages/dota/src/index.ts @@ -1,11 +1,11 @@ -process.on('SIGTERM', () => process.exit(0)) -process.on('SIGINT', () => process.exit(0)) - import { checkSupabaseHealth, startHeartbeat } from '@dotabod/shared-utils' import { redisClient } from './db/redis-instance' import { steamSocket } from './steam/ws' +process.on('SIGTERM', () => process.exit(0)) +process.on('SIGINT', () => process.exit(0)) + const initServer = function initServer() { Promise.all([import('./dota/index'), import('./twitch/index')]) .then(() => { diff --git a/packages/dota/src/steam/__tests__/player-summaries.test.ts b/packages/dota/src/steam/__tests__/player-summaries.test.ts index b5e49349..6d925ab2 100644 --- a/packages/dota/src/steam/__tests__/player-summaries.test.ts +++ b/packages/dota/src/steam/__tests__/player-summaries.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it, vi } from 'vitest' +import { getSteamPlayerSummaries } from '../player-summaries.ts' + const { emit } = vi.hoisted(() => ({ emit: vi.fn( ( @@ -31,8 +33,6 @@ const { emit } = vi.hoisted(() => ({ vi.mock('../ws.ts', () => ({ steamSocket: { emit } })) -import { getSteamPlayerSummaries } from '../player-summaries.ts' - describe(getSteamPlayerSummaries, () => { it('maps Steam-service RPC results by account ID', async () => { await expect(getSteamPlayerSummaries([123, 456])).resolves.toStrictEqual( diff --git a/packages/dota/src/steam/medals.ts b/packages/dota/src/steam/medals.ts index 9e72e4d2..fe1af570 100644 --- a/packages/dota/src/steam/medals.ts +++ b/packages/dota/src/steam/medals.ts @@ -50,7 +50,7 @@ export const gameMedals = async function gameMedals( }) // sort according to medal order - const sortedMedals = Object.keys(medalsToPlayers).sort((a, b) => { + const sortedMedals = Object.keys(medalsToPlayers).toSorted((a, b) => { if (a === 'Uncalibrated') { return -1 } @@ -80,7 +80,7 @@ export const gameMedals = async function gameMedals( } if (a.startsWith('#') || b.startsWith('#')) { - return Number.parseInt(b.slice(1), 10) - Number.parseInt(a.slice(1), 10) + return Math.trunc(Number(b.slice(1))) - Math.trunc(Number(a.slice(1))) } return 0 diff --git a/packages/dota/src/steam/realtime-stats.ts b/packages/dota/src/steam/realtime-stats.ts index 6f2486f1..0498c05d 100644 --- a/packages/dota/src/steam/realtime-stats.ts +++ b/packages/dota/src/steam/realtime-stats.ts @@ -92,7 +92,7 @@ export const findRealtimePlayer = function findRealtimePlayer( if (accountId !== undefined && accountId !== 0 && Number.isFinite(accountId)) { const accountPlayer = game.teams .flatMap((team) => team.players) - .find((player) => Number(player.accountid) === accountId) + .find((player) => player.accountid === accountId) if (accountPlayer !== undefined) { return accountPlayer } diff --git a/packages/dota/src/steam/smurfs.ts b/packages/dota/src/steam/smurfs.ts index 554c0364..5ae566c3 100644 --- a/packages/dota/src/steam/smurfs.ts +++ b/packages/dota/src/steam/smurfs.ts @@ -19,7 +19,7 @@ export const smurfs = async function smurfs( }) }) const results = result - .sort((a, b) => (a.lifetime_games ?? 0) - (b.lifetime_games ?? 0)) + .toSorted((a, b) => (a.lifetime_games ?? 0) - (b.lifetime_games ?? 0)) .map((m) => typeof m.lifetime_games === 'number' && m.lifetime_games > 0 ? `${m.heroName}: ${m.lifetime_games.toLocaleString()}` diff --git a/packages/dota/src/twitch/chat-client.ts b/packages/dota/src/twitch/chat-client.ts index db73f3ed..883f0190 100644 --- a/packages/dota/src/twitch/chat-client.ts +++ b/packages/dota/src/twitch/chat-client.ts @@ -40,7 +40,7 @@ const processQueue = async () => { const sendWhisper = (channel: string, text: string) => { const MAX_WHISPER_LENGTH = 10_000 - const chunks = text.match(new RegExp(`.{1,${MAX_WHISPER_LENGTH}}`, 'ug')) || [] + const chunks = text.match(new RegExp(`.{1,${MAX_WHISPER_LENGTH}}`, 'ug')) ?? [] chunks.forEach((chunk) => { twitchChat.emit('whisper', channel, chunk) diff --git a/packages/shared-utils/src/db/supabase.ts b/packages/shared-utils/src/db/supabase.ts index e34179ac..abcd4b70 100644 --- a/packages/shared-utils/src/db/supabase.ts +++ b/packages/shared-utils/src/db/supabase.ts @@ -26,11 +26,9 @@ type SupabaseClient = ReturnType> let supabaseInstance: SupabaseClient | null = null export const getSupabaseClient = (): SupabaseClient => { - if (supabaseInstance === null) { - supabaseInstance = createClient(supabaseUrl, supabaseKey, { - auth: { persistSession: false }, - }) - } + supabaseInstance ??= createClient(supabaseUrl, supabaseKey, { + auth: { persistSession: false }, + }) return supabaseInstance } diff --git a/packages/shared-utils/src/disableReason/service.ts b/packages/shared-utils/src/disableReason/service.ts index ff6d107e..92ce0d1e 100644 --- a/packages/shared-utils/src/disableReason/service.ts +++ b/packages/shared-utils/src/disableReason/service.ts @@ -11,7 +11,7 @@ export const recordDisableNotification = async function recordDisableNotificatio try { await supabase.from('disable_notifications').insert({ created_at: new Date().toISOString(), - metadata: metadata || {}, + metadata: metadata ?? {}, reason, setting_key: settingKey, user_id: userId, @@ -73,7 +73,7 @@ export const trackDisableReason = async function trackDisableReason( { auto_disabled_at: now.toISOString(), auto_disabled_by: 'system', - disable_metadata: metadata || {}, + disable_metadata: metadata ?? {}, disable_reason: reason, key: settingKey, updated_at: now.toISOString(), diff --git a/packages/shared-utils/src/heartbeat.ts b/packages/shared-utils/src/heartbeat.ts index e9894c7e..8149d448 100644 --- a/packages/shared-utils/src/heartbeat.ts +++ b/packages/shared-utils/src/heartbeat.ts @@ -38,9 +38,7 @@ export const startHeartbeat = function startHeartbeat(opts: HeartbeatOptions = { downSince = null } else { const now = Date.now() - if (downSince === null) { - downSince = now - } + downSince ??= now report = now - downSince < debounceMs } diff --git a/packages/steam/src/index.ts b/packages/steam/src/index.ts index 3ff4fe90..9133e7b5 100644 --- a/packages/steam/src/index.ts +++ b/packages/steam/src/index.ts @@ -1,6 +1,3 @@ -process.on('SIGTERM', () => process.exit(0)) -process.on('SIGINT', () => process.exit(0)) - import { startHeartbeat } from '@dotabod/shared-utils' import type { Socket } from 'socket.io' @@ -10,6 +7,9 @@ import Dota, { GetRealTimeStats } from './steam' import type { MatchMinimalDetailsResponse } from './types/match-minimal-details' import { logger } from './utils/logger' +process.on('SIGTERM', () => process.exit(0)) +process.on('SIGINT', () => process.exit(0)) + let _hasDotabodSocket = false let isConnectedToSteam = false diff --git a/packages/steam/src/socket-server.ts b/packages/steam/src/socket-server.ts index a597dea7..fe28a050 100644 --- a/packages/steam/src/socket-server.ts +++ b/packages/steam/src/socket-server.ts @@ -7,8 +7,6 @@ export const createSocketServer = function createSocketServer(port = 5035): Serv let _socketIoServer: Server | undefined export const getSocketIoServer = function getSocketIoServer(): Server { - if (!_socketIoServer) { - _socketIoServer = createSocketServer() - } + _socketIoServer ??= createSocketServer() return _socketIoServer } diff --git a/packages/steam/src/steam.ts b/packages/steam/src/steam.ts index d76223d8..4743d836 100644 --- a/packages/steam/src/steam.ts +++ b/packages/steam/src/steam.ts @@ -289,10 +289,8 @@ class Dota { } void this.getGames() - if (!this.interval) { - // Get latest games every 30 seconds - this.interval = setInterval(this.checkAccounts, 30_000) - } + // Get latest games every 30 seconds + this.interval ??= setInterval(this.checkAccounts, 30_000) } // Writer #2 of the `delayedGames` collection: polls the GC's public @@ -883,9 +881,7 @@ class Dota { }) public static getInstance(): Dota { - if (Dota.instance === undefined) { - Dota.instance = new Dota() - } + Dota.instance ??= new Dota() return Dota.instance } diff --git a/packages/twitch-chat/src/__tests__/shared-mocks.ts b/packages/twitch-chat/src/__tests__/shared-mocks.ts index 9a974cd2..1ab92627 100644 --- a/packages/twitch-chat/src/__tests__/shared-mocks.ts +++ b/packages/twitch-chat/src/__tests__/shared-mocks.ts @@ -238,7 +238,7 @@ interface FakeWebSocketEvent { wasClean?: boolean } -vi.doMock('ws', () => ({ default: FakeWebSocket })) +vi.doMock('ws', () => ({ WebSocket: FakeWebSocket, default: FakeWebSocket })) // Route fetch through state so each test controls the HTTP response. globalThis.fetch = vi.fn(async (input, options) => { diff --git a/packages/twitch-chat/src/event-sub-socket.ts b/packages/twitch-chat/src/event-sub-socket.ts index 948b9307..1420b84f 100644 --- a/packages/twitch-chat/src/event-sub-socket.ts +++ b/packages/twitch-chat/src/event-sub-socket.ts @@ -1,7 +1,7 @@ import { EventEmitter } from 'node:events' import { logger } from '@dotabod/shared-utils' -import WebSocket from 'ws' +import { WebSocket } from 'ws' // Cap the reconnect backoff factor. The generic branch used to grow this // unbounded; combined with leaked sockets that pushed it into the hundreds diff --git a/packages/twitch-chat/src/index.ts b/packages/twitch-chat/src/index.ts index 5f38f835..a49ebda6 100644 --- a/packages/twitch-chat/src/index.ts +++ b/packages/twitch-chat/src/index.ts @@ -1,6 +1,3 @@ -process.on('SIGTERM', () => process.exit(0)) -process.on('SIGINT', () => process.exit(0)) - import { lstatSync, readdirSync } from 'node:fs' import { join } from 'node:path' @@ -24,6 +21,9 @@ import { isEventsubConnected } from './event-sub-socket' import { sendTwitchChatMessage } from './handle-chat' import { io, setupSocketServer } from './utils/socket-manager' +process.on('SIGTERM', () => process.exit(0)) +process.on('SIGINT', () => process.exit(0)) + const isNonEmptyText = function isNonEmptyText(value: string | null | undefined): value is string { return value !== null && value !== undefined && value.length > 0 } diff --git a/scripts/quality/oxlint-baseline.json b/scripts/quality/oxlint-baseline.json index 0b0f4ab8..db313d14 100644 --- a/scripts/quality/oxlint-baseline.json +++ b/scripts/quality/oxlint-baseline.json @@ -1,25 +1,5 @@ { "diagnostics": { - "0047b2abf9138f1cd960288d8a8da673b08615e3ef45ed549f07d4cffcfe690b": { - "count": 1, - "diagnostic": { - "code": "eslint(no-eq-null)", - "file": "packages/dota/src/db/watcher.ts", - "labels": [ - { - "context": [ - "}", - "} else if (giftQuantityRaw != null) {", - "// Log only if it was provided but invalid" - ], - "message": "", - "span": "giftQuantityRaw != null" - } - ], - "message": "Do not use `null` comparisons without type-checking operators.", - "severity": "error" - } - }, "005a8a0b21e6abb8a053cd90002b1c19e723241c023b80cded9a20acc832511e": { "count": 1, "diagnostic": { @@ -326,6 +306,26 @@ "severity": "error" } }, + "0274d5f90506c4140c5b3ebceca4bf70f096ce2795372363739838e57d987c1b": { + "count": 1, + "diagnostic": { + "code": "unicorn(no-negated-condition)", + "file": "packages/dota/src/dota/lib/get-players.ts", + "labels": [ + { + "context": [ + "cards,", + "gameMode: response !== null ? response.match.game_mode : undefined,", + "matchPlayers," + ], + "message": "", + "span": "response !== null" + } + ], + "message": "Unexpected negated condition.", + "severity": "error" + } + }, "0295660a4a79f44a546488f3f17d3c2cc75a65a32f20f222001df437564f0a86": { "count": 1, "diagnostic": { @@ -467,26 +467,6 @@ "severity": "error" } }, - "0372eada0f98be92ee4d6d793e2ed7fb429d1713fe338c1bf2884fd26b515229": { - "count": 1, - "diagnostic": { - "code": "import(first)", - "file": "packages/steam/src/index.ts", - "labels": [ - { - "context": [ - "", - "import { startHeartbeat } from '@dotabod/shared-utils'", - "import type { Socket } from 'socket.io'" - ], - "message": "", - "span": "import { startHeartbeat } from '@dotabod/shared-utils'" - } - ], - "message": "Import statements must come first", - "severity": "error" - } - }, "03780272181a4d0df71726fafc8e612c0c00cf1cf2b7b7f6977a635dcbfdb816": { "count": 1, "diagnostic": { @@ -1016,26 +996,6 @@ "severity": "error" } }, - "062ce2863d4a7aee32a3f37f22379d4ca6819e51c813c4d4bcec725bc8b87926": { - "count": 1, - "diagnostic": { - "code": "import(first)", - "file": "packages/twitch-chat/src/index.ts", - "labels": [ - { - "context": [ - "", - "import { ensureEventSubInitialized } from './conduit-setup'", - "import { clearDisableCache, DISABLE_CACHE_EXPIRY, disableUserCache } from './disable-cache'" - ], - "message": "", - "span": "import { ensureEventSubInitialized } from './conduit-setup'" - } - ], - "message": "Import statements must come first", - "severity": "error" - } - }, "063bf0633f09909486f4b572e8a1a3360bb63dff17f857495ee843b19c12ab42": { "count": 1, "diagnostic": { @@ -1373,26 +1333,6 @@ "severity": "error" } }, - "087ab359d7ec47f408942b9ffbc83f3e2424feb7091846cca2889cf7e081a1b3": { - "count": 1, - "diagnostic": { - "code": "eslint(complexity)", - "file": "packages/dota/src/dota/gsi-handler.ts", - "labels": [ - { - "context": [ - "// so add to their list of steam accounts", - "async updateSteam32Id() {", - "const steamId = this.client.gsi?.player?.steamid" - ], - "message": "", - "span": "() {\n const steamId = this.client.gsi?.player?.steamid\n if (this.creatingSteamAccount || steamId == null || steamId.length === 0) {\n return\n }\n\n // Set a flag to prevent concurrent calls\n this.creatingSteamAccount = true\n let steam32Id: number | null | undefined\n\n try {\n steam32Id = steamID64toSteamID32(steamId)\n if (steam32Id == null || steam32Id === 0) {\n this.creatingSteamAccount = false\n return\n }\n\n // User already has a steam32Id and its saved to the `steam_accounts` table\n const foundAct = this.client.SteamAccount.find((act) => act.steam32Id === steam32Id)\n if (foundAct) {\n // Logged into a new steam account on the same twitch channel\n Object.assign(this.client, {\n mmr: foundAct.mmr,\n multiAccount: undefined,\n steam32Id,\n })\n this.multiAccountRevalidatedAt = undefined\n this.emitBadgeUpdate()\n return\n }\n\n const isMultiAccount = this.client.multiAccount === steam32Id\n if (\n isMultiAccount &&\n this.multiAccountRevalidatedAt !== undefined &&\n Date.now() - this.multiAccountRevalidatedAt < MULTI_ACCOUNT_REVALIDATION_COOLDOWN_MS\n ) {\n return\n }\n\n // Continue to create this act in db\n // Default to the mmr from `users` table for this brand new steam account\n // this.getMmr() should return mmr from `user` table on new accounts without steam acts\n const mmr = this.client.SteamAccount.length ? 0 : this.getMmr()\n\n this.creatingSteamAccount = true\n const { data: res, error } = await supabase\n .from('steam_accounts')\n .select('id, userId, mmr, connectedUserIds')\n .eq('steam32Id', steam32Id)\n .maybeSingle()\n\n if (error) {\n if (isMultiAccount) {\n this.multiAccountRevalidatedAt = Date.now()\n }\n logger.error('Error in updateSteam32Id', { error, name: this.client.name })\n return\n }\n\n if (res?.id != null && res.id.length > 0) {\n await this.handleExistingAccount(res, steam32Id)\n } else {\n const created = await this.createNewSteamAccount(mmr, steam32Id)\n if (!created) {\n this.client.multiAccount = steam32Id\n this.multiAccountRevalidatedAt = Date.now()\n }\n }\n\n this.creatingSteamAccount = false\n } catch (error) {\n if (steam32Id != null && steam32Id !== 0) {\n this.client.multiAccount = steam32Id\n this.multiAccountRevalidatedAt = Date.now()\n }\n logger.error('Error in updateSteam32Id', { error, name: this.client.name })\n } finally {\n // Ensure flag is reset even if an error occurs\n this.creatingSteamAccount = false\n }\n }" - } - ], - "message": "async method `updateSteam32Id` has a complexity of 22. Maximum allowed is 20.", - "severity": "error" - } - }, "08a662ed943a3ecca15212fc801cdb033d5d517f303d397362092687da00beb2": { "count": 1, "diagnostic": { @@ -1621,26 +1561,6 @@ "severity": "error" } }, - "09b3bcf699cc66cfa0b7b6dff8fc5b1fba1cf8589b36fd49b7959847b67b70f3": { - "count": 1, - "diagnostic": { - "code": "eslint(no-eq-null)", - "file": "packages/dota/src/dota/gsi-handler.ts", - "labels": [ - { - "context": [ - "(this.client.gsi?.map?.dire_score == null || this.client.gsi.map.dire_score === 0) &&", - "(this.client.gsi?.map?.radiant_score == null || this.client.gsi.map.radiant_score === 0) &&", - "this.client.gsi?.map?.matchid != null &&" - ], - "message": "", - "span": "this.client.gsi?.map?.radiant_score == null" - } - ], - "message": "Do not use `null` comparisons without type-checking operators.", - "severity": "error" - } - }, "09d4ac1885013fd10313246e69e87a786848ae6c5daff3a49e3a937773b29b82": { "count": 1, "diagnostic": { @@ -1761,26 +1681,6 @@ "severity": "error" } }, - "0b2800bc35d5797fecd6b49e7f749b7c5d130893b24377ce158690b469bc4249": { - "count": 1, - "diagnostic": { - "code": "import(first)", - "file": "packages/twitch-chat/src/index.ts", - "labels": [ - { - "context": [ - "import type { DisableReasonMetadata } from '@dotabod/shared-utils'", - "import { use } from 'i18next'", - "import FsBackend from 'i18next-fs-backend'" - ], - "message": "", - "span": "import { use } from 'i18next'" - } - ], - "message": "Import statements must come first", - "severity": "error" - } - }, "0b308e62e2eafe1b5aa8fb4e9aa987c88c2fb6a75aa9f34014afe47de01b56b5": { "count": 1, "diagnostic": { @@ -1918,26 +1818,6 @@ "severity": "error" } }, - "0c16fe6fb321f7ac72edf830c35175c2a1cb47f0b51f6d2a02f128b1207f3ae1": { - "count": 1, - "diagnostic": { - "code": "eslint(eqeqeq)", - "file": "packages/dota/src/dota/gsi-handler.ts", - "labels": [ - { - "context": [ - "", - "if (res?.id != null && res.id.length > 0) {", - "await this.handleExistingAccount(res, steam32Id)" - ], - "message": "", - "span": "!=" - } - ], - "message": "Expected !== and instead saw !=", - "severity": "error" - } - }, "0c200a57a24d136d4c4bde90fd417fa2642373c8765526076d3f56f8516144e3": { "count": 2, "diagnostic": { @@ -2030,26 +1910,6 @@ "severity": "error" } }, - "0cf7b5005c197dce4f18fa025ec824efd228696ac5f0c750cf738c8c044b86c7": { - "count": 1, - "diagnostic": { - "code": "typescript(prefer-nullish-coalescing)", - "file": "packages/dota/src/dota/gsi-handler.ts", - "labels": [ - { - "context": [ - "// 22 is game mode for normal game non turbo", - "gameMode: playingGameMode === null ? 22 : playingGameMode,", - "heroName," - ], - "message": "", - "span": "playingGameMode === null ? 22 : playingGameMode" - } - ], - "message": "Prefer using nullish coalescing operator (`??`) instead of a ternary expression, as it is simpler to read.", - "severity": "error" - } - }, "0cfd28cfb67bac6725b01b7d2ea1acb480ebd3a21c67355983dc5073e954edd6": { "count": 1, "diagnostic": { @@ -2383,26 +2243,6 @@ "severity": "error" } }, - "0e66b873817753c4e16fd1d907da06fccd80b05383f05923d9be1b1223e260e9": { - "count": 1, - "diagnostic": { - "code": "typescript(prefer-nullish-coalescing)", - "file": "packages/dota/src/dota/lib/matchData/__tests__/match-data-service.test.ts", - "labels": [ - { - "context": [ - "const makeClient = function makeClient(o: ClientOverrides = {}): SocketClient {", - "const matchid = o.matchid === undefined ? '8800000001' : o.matchid", - "const ownAccountId = o.ownAccountId ?? '111'" - ], - "message": "", - "span": "o.matchid === undefined ? '8800000001' : o.matchid" - } - ], - "message": "Prefer using nullish coalescing operator (`??`) instead of a ternary expression, as it is simpler to read.", - "severity": "error" - } - }, "0e79fe34a49d6f7a9f9f60b3f1bb29eba11aeacaec5003e96a41f386e580f218": { "count": 1, "diagnostic": { @@ -2760,46 +2600,6 @@ "severity": "error" } }, - "0ff2f69e8eb6953d4f2232420353ec29aa0089f6dbe9ce11640fb306b8a39b80": { - "count": 1, - "diagnostic": { - "code": "eslint(eqeqeq)", - "file": "packages/dota/src/dota/gsi-handler.ts", - "labels": [ - { - "context": [ - "if (", - "(this.client.gsi?.map?.dire_score == null || this.client.gsi.map.dire_score === 0) &&", - "(this.client.gsi?.map?.radiant_score == null || this.client.gsi.map.radiant_score === 0) &&" - ], - "message": "", - "span": "==" - } - ], - "message": "Expected === and instead saw ==", - "severity": "error" - } - }, - "1007b85b828cd911952e2226f14d62bc70ebd736675ad73118267816f1e32f2e": { - "count": 1, - "diagnostic": { - "code": "unicorn(no-negated-condition)", - "file": "packages/dota/src/dota/lib/get-players.ts", - "labels": [ - { - "context": [ - "cards,", - "gameMode: response !== null ? Number(response.match.game_mode) : undefined,", - "matchPlayers," - ], - "message": "", - "span": "response !== null" - } - ], - "message": "Unexpected negated condition.", - "severity": "error" - } - }, "100e7e49792e65c6b6c0689700f2bf7ac5a90a961eec38acbb67ac23d1f55fc6": { "count": 1, "diagnostic": { @@ -2936,22 +2736,6 @@ "severity": "error" } }, - "10d16f2d012153100dfbcaf0bb849cf3baf93d0a8fbf31260f8a2000df2bc2f5": { - "count": 2, - "diagnostic": { - "code": "eslint(eqeqeq)", - "file": "packages/dota/src/dota/gsi-handler.ts", - "labels": [ - { - "context": [")", "if (oldBetId != null && oldBetId.length > 0) {", "await supabase"], - "message": "", - "span": "!=" - } - ], - "message": "Expected !== and instead saw !=", - "severity": "error" - } - }, "10e373c4283dfe05dad8574a6871fd896b87a8240d20daab9dad2b05ae69af1a": { "count": 1, "diagnostic": { @@ -3208,26 +2992,6 @@ "severity": "error" } }, - "1263c6f46b5fcf74ddd2f9cd37098389f0bb33c700fa76ee400cdc2e1198e524": { - "count": 1, - "diagnostic": { - "code": "eslint(complexity)", - "file": "packages/dota/src/dota/gsi-handler.ts", - "labels": [ - { - "context": [ - "", - "async closeBets(winningTeam: Team | null = null, gcData?: MatchClosingDetailsResponse) {", - "if (this.endingBets) {" - ], - "message": "", - "span": "(winningTeam: Team | null = null, gcData?: MatchClosingDetailsResponse) {\n if (this.endingBets) {\n return\n }\n this.endingBets = true\n\n try {\n const match = gcData?.matches?.[0]\n const longMatchId = match?.match_id\n ? (() => {\n const id = new Long(match.match_id.low, match.match_id.high).toString()\n const numId = Number(id)\n return !Number.isNaN(numId) && numId > 1 ? id : undefined\n })()\n : undefined\n const matchId = (await redisClient.client.get(`${this.client.token}:matchId`)) ?? longMatchId\n const player = match?.players?.find(\n (player) => player.account_id === Number(this.client.gsi?.player?.accountid)\n )\n const gcTeam =\n player?.team_number === DotaGcTeam.DOTA_GC_TEAM_GOOD_GUYS\n ? 'radiant'\n : player?.team_number === DotaGcTeam.DOTA_GC_TEAM_BAD_GUYS\n ? 'dire'\n : null\n const myTeam: Team | null =\n typeof player?.team_number === 'number'\n ? (gcTeam ?? null)\n : (parseTeam(await redisClient.client.get(`${this.client.token}:playingTeam`)) ??\n (this.client.gsi?.player?.team_name === 'radiant' ||\n this.client.gsi?.player?.team_name === 'dire'\n ? this.client.gsi?.player?.team_name\n : null))\n\n if (this.openingBets || matchId == null || matchId.length === 0) {\n logger.debug('[BETS] Not closing bets', {\n endingBets: this.endingBets,\n name: this.client.name,\n openingBets: this.openingBets,\n playingMatchId: matchId,\n })\n\n if (matchId == null || matchId.length === 0) {\n await this.resetClientState()\n }\n return\n }\n\n const betsEnabled = getValueOrDefault(\n DBSettings.bets,\n this.client.settings,\n this.client.subscription\n )\n const heroSlot =\n player?.player_slot ?? (await getRedisNumberValue(`${this.client.token}:playingHeroSlot`))\n const heroName =\n getHeroById(player?.hero_id)?.key ??\n (await redisClient.client.get(`${this.client.token}:playingHero`))\n\n // An early without waiting for ancient to blow up\n // We have to check every few seconds with an api to see if the match is over\n if (!winningTeam) {\n void this.checkEarlyDCWinner(matchId)\n return\n }\n\n const localWinner = winningTeam\n const scores = buildClosingScores({\n gcMatch: match,\n gcPlayer: player,\n gsi: this.client.gsi,\n })\n const won = myTeam === localWinner\n logger.info('[BETS] end bets won data', {\n channel: this.client.name,\n localWinner,\n myTeam,\n playingMatchId: matchId,\n won,\n })\n\n // Both or one undefined\n if (!myTeam) {\n // Very rare case, but it can happen. Once every 7 days\n logger.error('[BETS] trying to end bets but did not find localWinner or myTeam', {\n channel: this.client.name,\n matchId,\n })\n return\n }\n\n logger.debug('[BETS] Running end bets to award mmr and close predictions', {\n matchId,\n name: this.client.name,\n })\n\n const channel = this.client.name\n\n // Pretty rare case, 26 times in 7 days. Usually when they test Dotabod in a custom lobby\n // Custom lobbies create a match ID but don't report any stats\n if (\n (this.client.gsi?.map?.dire_score == null || this.client.gsi.map.dire_score === 0) &&\n (this.client.gsi?.map?.radiant_score == null || this.client.gsi.map.radiant_score === 0) &&\n this.client.gsi?.map?.matchid != null &&\n this.client.gsi.map.matchid.length > 0\n ) {\n logger.info('This is likely a no stats recorded match', {\n matchId,\n name: this.client.name,\n })\n\n if (this.client.stream_online) {\n say(\n this.client,\n t('bets.notScored', {\n emote: 'D:',\n key: DBSettings.tellChatBets,\n lng: this.client.locale,\n matchId,\n })\n )\n const predictionResponse = await supabase\n .from('matches')\n .select('predictionId')\n .eq('matchId', matchId)\n .eq('userId', this.client.token)\n .is('won', null)\n .single()\n if (\n predictionResponse.data?.predictionId != null &&\n predictionResponse.data.predictionId.length > 0\n ) {\n const oldBetId = await refundTwitchBet(\n this.getChannelId(),\n predictionResponse.data.predictionId\n )\n if (oldBetId != null && oldBetId.length > 0) {\n await supabase\n .from('matches')\n .update({ predictionId: null, updated_at: new Date().toISOString() })\n .eq('predictionId', oldBetId)\n }\n }\n }\n // No-stats match can never be resolved with !won/!lost; don't nag for it.\n await this.suppressUnresolvedReminder(matchId)\n await this.resetClientState()\n return\n }\n\n // 0 is a correct lobby type meaning unranked\n // https://github.com/dotabod/backend/issues/373#issuecomment-2366822786\n // Default to ranked if we don't have valid data\n const playingLobbyType = await getRedisNumberValue(\n `${matchId}:${this.client.token}:lobbyType`\n )\n const playingGameMode = await getRedisNumberValue(`${matchId}:${this.client.token}:gameMode`)\n\n // Use the lobby type from Redis if it exists (including 0)\n // Otherwise default to ranked\n const localLobbyType = playingLobbyType === null ? LOBBY_TYPE_RANKED : playingLobbyType\n\n const isParty = getValueOrDefault(DBSettings.onlyParty, this.client.settings)\n\n await this.updateMMR({\n // 22 is game mode for normal game non turbo\n gameMode: playingGameMode === null ? 22 : playingGameMode,\n heroName,\n heroSlot,\n increase: won,\n isParty,\n lobbyType: localLobbyType,\n matchId,\n myTeam,\n scores,\n })\n\n const response = await getRankDetail(this.getMmr(), this.getSteam32())\n if (\n this.client.steam32Id !== null &&\n this.client.steam32Id !== 0 &&\n response !== null &&\n 'standing' in response\n ) {\n await supabase\n .from('steam_accounts')\n .update({ leaderboard_rank: response.standing, updated_at: new Date().toISOString() })\n .eq('steam32Id', this.client.steam32Id)\n }\n\n const TreadToggleData = this.treadsData\n const toggleHandler = async () => {\n const treadToggleData = await redisClient.getJson(\n `${this.client.token}:treadtoggle`\n )\n\n if (\n treadToggleData?.treadToggles != null &&\n treadToggleData.treadToggles > 0 &&\n this.client.stream_online\n ) {\n say(\n this.client,\n t('treadToggle', {\n count: treadToggleData.treadToggles,\n lng: this.client.locale,\n manaCount: treadToggleData.manaSaved,\n matchId,\n })\n )\n }\n }\n\n try {\n void toggleHandler()\n } catch (error) {\n logger.error('err toggleHandler', { error })\n }\n\n let predictionId: string | null = null\n if (betsEnabled) {\n try {\n const predictionResponse = await supabase\n .from('matches')\n .select('predictionId')\n .eq('matchId', matchId.toString())\n .eq('userId', this.client.token)\n .single()\n\n if (\n !predictionResponse.error &&\n typeof predictionResponse.data?.predictionId === 'string' &&\n predictionResponse.data.predictionId.length > 0\n ) {\n predictionId = predictionResponse.data.predictionId\n } else {\n logger.info('[BETS] Skipping Twitch closure because predictionId is unavailable', {\n channel,\n error: predictionResponse.error?.message,\n matchId,\n })\n }\n } catch (error) {\n logger.info('[BETS] Skipping Twitch closure because predictionId is unreadable', {\n channel,\n error: error instanceof Error ? error.message : error,\n matchId,\n })\n }\n }\n\n delayedQueue.addTask(getStreamDelay(this.client.settings, this.client.subscription), () => {\n const message = won\n ? t('bets.won', { emote: 'Happi', lng: this.client.locale })\n : t('bets.lost', { emote: 'Happi', lng: this.client.locale })\n\n say(this.client, message, { chattersKey: 'matchOutcome', delay: false })\n\n if (!betsEnabled || predictionId == null || predictionId.length === 0) {\n logger.debug('Bets are not enabled or no prediction was opened, stopping here', {\n name: this.client.name,\n })\n this.resetClientState().catch(() => {\n //\n })\n return\n }\n\n closeTwitchBet(\n won,\n this.getChannelId(),\n matchId,\n this.client.settings,\n this.client.subscription\n )\n .then(() => {\n logger.info('[BETS] end bets', {\n didWin: won,\n event: 'end_bets',\n matchId,\n name: this.client.name,\n player_team: myTeam,\n winning_team: localWinner,\n })\n })\n .catch((error: unknown) => {\n logger.error('[BETS] Error closing twitch bet', {\n channel,\n e: error instanceof Error ? error.message : error,\n matchId,\n })\n })\n .finally(() => {\n this.resetClientState().catch((error) => {\n logger.error('Error resetting client state', { error })\n })\n })\n })\n } catch (error) {\n logger.error('Error closing bets', { error, name: this.client.name })\n } finally {\n this.endingBets = false\n }\n }" - } - ], - "message": "async method `closeBets` has a complexity of 72. Maximum allowed is 20.", - "severity": "error" - } - }, "1275df764d31693fa7b363c457a65be86fe89e6bbaba05dc86e58dd6ca584b4a": { "count": 1, "diagnostic": { @@ -3308,26 +3072,6 @@ "severity": "error" } }, - "13540147b98f0466b3f01a4801836d32e663efa4f67fcf7bfcbd86c01f198220": { - "count": 1, - "diagnostic": { - "code": "typescript(prefer-nullish-coalescing)", - "file": "packages/shared-utils/src/disableReason/service.ts", - "labels": [ - { - "context": [ - "created_at: new Date().toISOString(),", - "metadata: metadata || {},", - "reason," - ], - "message": "", - "span": "||" - } - ], - "message": "Prefer using nullish coalescing operator (`??`) instead of a logical or (`||`), as it is a safer operator.", - "severity": "error" - } - }, "1363234ac0a88aeab4ba02290c41eef07721785554240af06008aa4479eb512a": { "count": 1, "diagnostic": { @@ -3524,6 +3268,26 @@ "severity": "error" } }, + "15485b6bad6b5d872966f5c78bdb2534c54f83965e108c992a2f026eff523228": { + "count": 1, + "diagnostic": { + "code": "sonarjs(cognitive-complexity)", + "file": "packages/dota/src/db/watcher.ts", + "labels": [ + { + "context": [ + "{ event: 'INSERT', schema: 'public', table: 'gift_subscriptions' },", + "async (payload: { new: Tables<'gift_subscriptions'> }) => {", + "const newObj = payload.new" + ], + "message": "", + "span": "=>" + } + ], + "message": "Refactor this function to reduce its Cognitive Complexity from 29 to the 20 allowed.", + "severity": "error" + } + }, "15532e51a9a1a421ff5869c51512f4f7d3598f8f85d4d9251b855dbb0bfc4f2e": { "count": 1, "diagnostic": { @@ -3580,26 +3344,6 @@ "severity": "error" } }, - "15c99c58d151cf31317117cea78d3ca0f688f1fafc0c76f56b4202e60da0a6ca": { - "count": 1, - "diagnostic": { - "code": "unicorn(no-array-sort)", - "file": "packages/dota/src/steam/medals.ts", - "labels": [ - { - "context": [ - "// sort according to medal order", - "const sortedMedals = Object.keys(medalsToPlayers).sort((a, b) => {", - "if (a === 'Uncalibrated') {" - ], - "message": "", - "span": "sort" - } - ], - "message": "Use `Array#toSorted()` instead of `Array#sort()`.", - "severity": "error" - } - }, "1697f42015e1ac8d075c3e8446b9ebcb9c72e269bed5e1aa41bf08be427eac6d": { "count": 1, "diagnostic": { @@ -3656,26 +3400,6 @@ "severity": "error" } }, - "16d5ba73fbd25c193ff0505a8ce22a71631411ac70d89181c092e6d67a3f2b6f": { - "count": 1, - "diagnostic": { - "code": "typescript(prefer-nullish-coalescing)", - "file": "packages/dota/src/db/redis-client.ts", - "labels": [ - { - "context": [ - "public static getInstance(): RedisClient {", - "if (RedisClient.instance === undefined) {", - "RedisClient.instance = new RedisClient()" - ], - "message": "", - "span": "if (RedisClient.instance === undefined) {\n RedisClient.instance = new RedisClient()\n }" - } - ], - "message": "Prefer using nullish coalescing operator (`??=`) instead of an assignment expression, as it is simpler to read.", - "severity": "error" - } - }, "16d779540ce4dd4435173c3f3abcc1046fc15ebf5ae1c8da702c525bb99746cf": { "count": 1, "diagnostic": { @@ -3825,26 +3549,6 @@ "severity": "error" } }, - "17b5d60978f668a88f18c2ba085bd0be22cedc304d52edc4ad243f95518f866d": { - "count": 1, - "diagnostic": { - "code": "eslint(no-eq-null)", - "file": "packages/dota/src/dota/gsi-handler.ts", - "labels": [ - { - "context": [ - "const matchId = this.client.gsi?.map?.matchid", - "if (matchId == null || matchId.length === 0 || matchId === '0') {", - "return" - ], - "message": "", - "span": "matchId == null" - } - ], - "message": "Do not use `null` comparisons without type-checking operators.", - "severity": "error" - } - }, "1816646a2281bad2f4ce9abad42d5637551a1ece9279919957c5e812619b5c74": { "count": 1, "diagnostic": { @@ -4077,26 +3781,6 @@ "severity": "error" } }, - "19cece4054b379d260bed93ac27d59e42a78c12c7d931b6435fcb156b26a8209": { - "count": 1, - "diagnostic": { - "code": "eslint(complexity)", - "file": "packages/dota/src/dota/gsi-handler.ts", - "labels": [ - { - "context": [ - "// 4 Then, tell twitch to close bets based on win result", - "async openBets(client: SocketClient) {", - "if (this.openingBets) {" - ], - "message": "", - "span": "(client: SocketClient) {\n if (this.openingBets) {\n // console.log('still opening')\n return\n }\n\n // Why open if not playing?\n if (client.gsi?.player?.activity !== 'playing') {\n // console.log(`if (client.gsi?.player?.activity !== 'playing') {`)\n return\n }\n\n // Why open if won?\n if (client.gsi.map?.win_team !== 'none') {\n // console.log(`if (client.gsi.map?.win_team !== 'none') {`)\n return\n }\n\n // We at least want the hero name so it can go in the twitch bet title\n const heroName = client.gsi.hero?.name\n if (heroName == null || heroName.length === 0) {\n // console.log(`if (!client.gsi.hero?.name || !client.gsi.hero.name.length) {`)\n return\n }\n\n // It's not a live game, so we don't want to open bets nor save it to DB\n if (!client.gsi.map?.matchid || client.gsi.map?.matchid === '0') {\n // console.log(`if (!client.gsi.map.matchid || client.gsi.map.matchid === '0') {`)\n return\n }\n\n // Snapshot validated matchid + hero name now; openTheBet runs after the\n // stream delay and `client.gsi` can be cleared by then (e.g. draft abandon\n // + requeue triggers resetClientState). Without the snapshot, openTheBet\n // would insert a `matches` row with an empty matchId and open a Twitch\n // prediction titled \"Will we win with \".\n const validatedMatchId = client.gsi.map.matchid\n const validatedHeroName = heroName\n // team_name is set on player at this point because activity === 'playing'\n // (checked above). Capture it for the same reason as matchId/heroName so\n // the matches row records the team the streamer was actually on.\n const validatedMyTeam = client.gsi.player?.team_name ?? ''\n\n const matchId = (await redisClient.client.get(`${client.token}:matchId`)) ?? undefined\n\n if (matchId !== undefined && matchId.length > 0 && matchId !== validatedMatchId) {\n // Check if there's a pending manual resolution for the old match\n const pendingResolution = await redisClient.client.get(\n `${client.token}:pendingManualResolution`\n )\n if (pendingResolution !== null && pendingResolution.length > 0) {\n try {\n const { matchId: pendingMatchId } = JSON.parse(pendingResolution)\n\n // If the pending match is the old one, refund it and notify\n if (pendingMatchId === matchId) {\n logger.info('[BETS] Expiring pending manual resolution - new match joined', {\n name: client.name,\n newMatchId: client.gsi.map.matchid,\n oldMatchId: matchId,\n })\n\n const betsEnabled = getValueOrDefault(\n DBSettings.bets,\n client.settings,\n client.subscription\n )\n if (betsEnabled) {\n const predictionResponse = await supabase\n .from('matches')\n .select('predictionId')\n .eq('matchId', matchId.toString())\n .eq('userId', client.token)\n .is('won', null)\n .single()\n if (\n predictionResponse.data?.predictionId != null &&\n predictionResponse.data.predictionId.length > 0\n ) {\n await refundTwitchBet(this.getChannelId(), predictionResponse.data.predictionId)\n\n const tellChatBets = getValueOrDefault(\n DBSettings.tellChatBets,\n client.settings,\n client.subscription\n )\n if (tellChatBets && client.stream_online) {\n say(\n client,\n t('bets.manualResolutionExpired', {\n emote: 'FeelsBadMan',\n lng: client.locale,\n })\n )\n }\n }\n }\n }\n } catch (error) {\n logger.error('[BETS] Error handling pending manual resolution expiration', { error })\n }\n }\n\n // We have the wrong matchid, reset vars and start over\n logger.info('[BETS] openBets resetClientState because stuck on old match id', {\n gsiMatchId: client.gsi.map.matchid,\n name: client.name,\n playingMatchId: matchId,\n steam32Id: client.steam32Id,\n steamFromGSI: client.gsi.player?.steamid,\n token: client.token,\n })\n await this.resetClientState()\n return\n }\n\n // The bet was already made\n if (Number(matchId) >= 0) {\n return\n }\n\n logger.info('[BETS] Begin opening bets', {\n hero: heroName,\n matchId: client.gsi.map.matchid,\n name: client.name,\n playingMatchId: matchId,\n })\n\n this.openingBets = true\n\n const { data: bet } = await supabase\n .from('matches')\n .select('matchId, myTeam, id')\n .eq('matchId', client.gsi.map.matchid)\n .eq('userId', client.token)\n .is('won', null)\n\n try {\n // Saving to redis so we don't have to query the db again\n await redisClient.client.set(`${client.token}:matchId`, client.gsi.map.matchid)\n\n const playingTeam = bet?.[0]?.myTeam ?? client.gsi?.player?.team_name ?? ''\n await redisClient.client.set(`${client.token}:playingTeam`, playingTeam)\n await redisClient.client.set(`${client.token}:playingHero`, client.gsi.hero?.name ?? '')\n } catch (error) {\n logger.error('Error while saving data to Redis:', {\n client: client.name,\n error,\n matchId: client.gsi.map.matchid,\n token: client.token,\n })\n }\n\n // Check if this bet for this match id already exists, dont continue if it does\n if (bet?.[0]?.id != null && bet[0].id.length > 0) {\n logger.info('[BETS] Found a bet in the database', { id: bet?.[0]?.id })\n this.openingBets = false\n return\n }\n\n if (!client.stream_online) {\n logger.info('[BETS] Not opening bets bc stream is offline for', {\n name: client.name,\n })\n this.openingBets = false\n return\n }\n\n if (!client.token) {\n this.openingBets = false\n return\n }\n\n this.openTheBetTaskId = delayedQueue.addTask(\n getStreamDelay(client.settings, client.subscription),\n async () => {\n await this.openTheBet(validatedMatchId, validatedHeroName, validatedMyTeam)\n }\n )\n\n // .catch((e: any) => {\n // logger.error(`[BETS] Could not add bet to channel`, {\n // channel: client.name,\n // e: e?.message || e,\n // })\n // this.openingBets = false\n // })\n\n // .catch((e: any) => {\n // logger.error('[BETS] Error opening bet', {\n // matchId: client?.gsi?.map?.matchid || '',\n // channel,\n // e: e?.message || e,\n // })\n // if ((e?.message || e).includes('error')) {\n // this.openingBets = false\n // }\n // })\n }" - } - ], - "message": "async method `openBets` has a complexity of 49. Maximum allowed is 20.", - "severity": "error" - } - }, "19de3904e686a01525c47e7d1db6deac8e73414a71ccc0e13f4e21a58df55697": { "count": 1, "diagnostic": { @@ -4425,6 +4109,26 @@ "severity": "error" } }, + "1b741cdb833ad11f8ff9412de2ea8fd7198b9b323b1be154a6c0540894cb5a16": { + "count": 1, + "diagnostic": { + "code": "anti-slop(no-unknown-parameters)", + "file": "packages/dota/src/dota/gsi-handler.ts", + "labels": [ + { + "context": [ + "{ match_id: Number(matchId) },", + "(err: unknown, response: MatchMinimalDetailsResponse) => {", + "if (err !== null && err !== undefined) {" + ], + "message": "", + "span": "unknown" + } + ], + "message": "Parameter `err` leaves input unparsed. Accept a named domain type; run the expected schema or parser at the I/O boundary before calling this function.", + "severity": "error" + } + }, "1b823ae54855a4c508f1b1e121c1f9c38d7fb764cefe7335bb9d8e1c6f506785": { "count": 1, "diagnostic": { @@ -4529,26 +4233,6 @@ "severity": "error" } }, - "1ceaaa0e655642c9b174125e492dff07c3de16b552c039a9e6693f396bf2b7e8": { - "count": 1, - "diagnostic": { - "code": "anti-slop(no-unknown-parameters)", - "file": "packages/dota/src/dota/gsi-handler.ts", - "labels": [ - { - "context": [ - "{ match_id: Number(matchId) },", - "(err: unknown, response: MatchMinimalDetailsResponse) => {", - "if (err != null) {" - ], - "message": "", - "span": "unknown" - } - ], - "message": "Parameter `err` leaves input unparsed. Accept a named domain type; run the expected schema or parser at the I/O boundary before calling this function.", - "severity": "error" - } - }, "1d131f7f36855358c202529c3d6faa218040d45acb20e31a8043885005cd54e7": { "count": 1, "diagnostic": { @@ -5202,6 +4886,26 @@ "severity": "error" } }, + "21e33c2ef93a8c8c5be98582c78d622cea9569a576c55e1d65e260cc4a071044": { + "count": 1, + "diagnostic": { + "code": "eslint(complexity)", + "file": "packages/dota/src/dota/events/gsi-events/newdata.ts", + "labels": [ + { + "context": [ + "// Runs every gametick", + "const saveMatchData = async function saveMatchData(client: SocketClient) {", + "// This now waits for the bet to complete before checking match data" + ], + "message": "", + "span": "async function saveMatchData(client: SocketClient) {\n // This now waits for the bet to complete before checking match data\n // Since match data is delayed it will run far fewer than before, when checking actual match id of an ingame match\n // the matchid is saved when the hero is selected\n const matchId = await redisClient.client.get(`${client.token}:matchId`)\n if (\n matchId === null ||\n matchId.length === 0 ||\n Number(matchId) === 0 ||\n Number.isNaN(Number(matchId))\n ) {\n return\n }\n\n if (client.steam32Id === null || client.steam32Id === 0) {\n return\n }\n\n // Check for account sharing before proceeding with match data processing\n const accountSharingDetected = await checkAccountSharing(client, matchId)\n if (accountSharingDetected) {\n // If account sharing is detected, stop processing for this client\n return\n }\n\n const cacheKey = `${matchId}:${client.token}`\n\n // Check in-memory cache first\n const cachedData = matchDataCache.get(cacheKey)\n if (cachedData) {\n // If cache is still valid, use cached data and return\n if (Date.now() - cachedData.timestamp < CACHE_EXPIRATION) {\n if (\n cachedData.steamServerId !== null &&\n cachedData.steamServerId.length > 0 &&\n cachedData.lobbyType !== null\n ) {\n return\n }\n } else {\n // If cache expired, remove it\n matchDataCache.delete(cacheKey)\n }\n }\n\n // Implement debounce logic\n const debounceKey = client.token\n const now = Date.now()\n const debounceData = saveMatchDataDebounceMap.get(debounceKey)\n\n // If this client's function is already in progress or ran recently, skip this execution\n if (debounceData) {\n if (debounceData.inProgress || now - debounceData.lastExecuted < DEBOUNCE_INTERVAL) {\n return\n }\n }\n\n // Mark this execution as in progress\n saveMatchDataDebounceMap.set(debounceKey, { inProgress: true, lastExecuted: now })\n\n try {\n // did we already come here before?\n const res = await redisClient.client\n .multi()\n .get(`${matchId}:${client.token}:steamServerId`)\n .get(`${matchId}:${client.token}:lobbyType`)\n .exec()\n\n const [steamServerId] = res\n const [, lobbyType] = res\n\n // Update cache with Redis data\n matchDataCache.set(cacheKey, {\n lobbyType: lobbyType === null || lobbyType === '' ? null : String(lobbyType),\n steamServerId: steamServerId === null || steamServerId === '' ? null : String(steamServerId),\n timestamp: now,\n })\n\n if (steamServerId !== null && steamServerId !== '' && lobbyType !== null) {\n return\n }\n\n // PRESERVED — gated, not dead. This block is the sole writer of the redis steamServerId key\n // that the ordinary-pub `!items`/`!stats`/`!winprobability` fallback later reads. SourceTV\n // commands instead use the server_steam_id already present in delayedGames. This lookup stays\n // gated pending bot-friend management at scale; see memory `keep-spectate-friend-path`.\n if (\n (steamServerId === null || steamServerId === '') &&\n lobbyType === null &&\n !is8500Plus(client) &&\n ENABLE_SPECTATE_FRIEND_GAME\n ) {\n // Fix: Check if we're already looking up this match to prevent race conditions\n if (steamServerLookupMap.has(matchId)) {\n return\n }\n\n // Add to lookup map before starting the async operation\n steamServerLookupMap.add(matchId)\n\n try {\n const getDelayedDataPromise = new Promise((resolve, reject) => {\n const timeoutId = setTimeout(() => {\n reject(new CustomError(t('matchData8500', { emote: 'PoroSad', lng: client.locale })))\n // 10 second timeout\n }, 10_000)\n\n steamSocket.emit(\n 'getUserSteamServer',\n client.steam32Id,\n (err: unknown, cards: string) => {\n clearTimeout(timeoutId)\n if (err !== null && err !== undefined) {\n reject(err)\n } else {\n resolve(cards)\n }\n }\n )\n })\n\n const steamServerId = await getDelayedDataPromise\n\n if (steamServerId.length > 0) {\n await redisClient.client.set(\n `${matchId}:${client.token}:steamServerId`,\n steamServerId.toString()\n )\n\n // Update cache\n matchDataCache.set(cacheKey, {\n lobbyType: null,\n steamServerId: steamServerId.toString(),\n timestamp: Date.now(),\n })\n }\n } catch {\n // Do nothing, we don't want to log this error\n // logger.error('Error getting steam server data', { error, matchId })\n } finally {\n // Always remove from the map, even if there was an error\n steamServerLookupMap.delete(matchId)\n }\n }\n\n // Re-check steamServerId from cache first, then Redis if needed\n let currentSteamServerId = matchDataCache.get(cacheKey)?.steamServerId ?? null\n if (currentSteamServerId === null || currentSteamServerId.length === 0) {\n currentSteamServerId = await redisClient.client.get(\n `${matchId}:${client.token}:steamServerId`\n )\n\n // Update cache if we found it in Redis\n if (currentSteamServerId !== null && currentSteamServerId.length > 0) {\n const currentCache = matchDataCache.get(cacheKey) ?? {\n lobbyType: null,\n steamServerId: null,\n timestamp: now,\n }\n matchDataCache.set(cacheKey, {\n ...currentCache,\n steamServerId: currentSteamServerId,\n timestamp: now,\n })\n }\n }\n\n if (\n currentSteamServerId !== null &&\n currentSteamServerId.length > 0 &&\n lobbyType === null &&\n !is8500Plus(client)\n ) {\n // Fix: Check if we're already looking up this match to prevent race conditions\n if (steamDelayDataLookupMap.has(matchId)) {\n return\n }\n\n steamDelayDataLookupMap.add(matchId)\n\n try {\n const getDelayedDataPromise = new Promise((resolve, reject) => {\n const timeoutId = setTimeout(() => {\n reject(new CustomError(t('matchData8500', { emote: 'PoroSad', lng: client.locale })))\n // 10 second timeout\n }, 10_000)\n\n steamSocket.emit(\n 'getRealTimeStats',\n {\n match_id: matchId,\n refetchCards: true,\n steam_server_id: currentSteamServerId,\n token: client.token,\n },\n (err: unknown, data: DelayedGames) => {\n clearTimeout(timeoutId)\n if (err !== null && err !== undefined) {\n reject(err)\n } else {\n resolve(data)\n }\n }\n )\n })\n\n const delayedData = await getDelayedDataPromise\n\n if (delayedData.match.lobby_type !== undefined) {\n await Promise.all([\n redisClient.client.set(\n `${matchId}:${client.token}:lobbyType`,\n delayedData.match.lobby_type\n ),\n redisClient.client.set(\n `${matchId}:${client.token}:gameMode`,\n delayedData.match.game_mode\n ),\n ])\n\n // Update cache with complete data\n matchDataCache.set(cacheKey, {\n lobbyType: String(delayedData.match.lobby_type),\n steamServerId: currentSteamServerId,\n timestamp: Date.now(),\n })\n }\n } catch (error) {\n if (!(error instanceof CustomError)) {\n logger.error('Error getting delayed match data', { error, matchId })\n }\n } finally {\n // Always remove from the map, even if there was an error\n steamDelayDataLookupMap.delete(matchId)\n }\n }\n } finally {\n // Update the debounce map to mark execution as complete\n const currentDebounce = saveMatchDataDebounceMap.get(debounceKey)\n if (currentDebounce) {\n saveMatchDataDebounceMap.set(debounceKey, { ...currentDebounce, inProgress: false })\n\n // Set up an automatic cleanup for the debounce map entry after 5 minutes of inactivity\n setTimeout(() => {\n const entry = saveMatchDataDebounceMap.get(debounceKey)\n if (entry && Date.now() - entry.lastExecuted > 300_000) {\n // 5 minutes\n saveMatchDataDebounceMap.delete(debounceKey)\n }\n // 5 minutes\n }, 300_000)\n }\n }\n}" + } + ], + "message": "async function `saveMatchData` has a complexity of 47. Maximum allowed is 20.", + "severity": "error" + } + }, "21eaf1ef45ee66a7e06ed909418e3f800689e829ef4ada409fa8db08b922712e": { "count": 1, "diagnostic": { @@ -5323,26 +5027,6 @@ "severity": "error" } }, - "22b5c2d2d7248e923254aaf2b4c1140fd92f26aa700d2753e0147b17ef05b3ab": { - "count": 1, - "diagnostic": { - "code": "eslint(eqeqeq)", - "file": "packages/dota/src/dota/gsi-handler.ts", - "labels": [ - { - "context": [ - "", - "if (error !== null || matchData == null) {", - "logger.info('[BETS] Match already closed or not found, skipping early DC winner check', {" - ], - "message": "", - "span": "==" - } - ], - "message": "Expected === and instead saw ==", - "severity": "error" - } - }, "22e1147a69f91919972e30660e8181fc4fdea153fe3ee3463b1d7238ac9d34bd": { "count": 1, "diagnostic": { @@ -5372,26 +5056,6 @@ "severity": "error" } }, - "233e954b111f41cd6f72af7052a2853379b7eeaefa498607c6749bd9d32773c6": { - "count": 1, - "diagnostic": { - "code": "eslint(eqeqeq)", - "file": "packages/dota/src/dota/gsi-handler.ts", - "labels": [ - { - "context": [ - "if (", - "treadToggleData?.treadToggles != null &&", - "treadToggleData.treadToggles > 0 &&" - ], - "message": "", - "span": "!=" - } - ], - "message": "Expected !== and instead saw !=", - "severity": "error" - } - }, "2355a69547cf4038dc6eea8d738e7f777c6c9571334476a0ba519551014c8855": { "count": 1, "diagnostic": { @@ -5432,26 +5096,6 @@ "severity": "error" } }, - "23b976935c96f3c59bcbc257e6abe172fb4fc1c035518c661e00140a78c8197a": { - "count": 1, - "diagnostic": { - "code": "import(first)", - "file": "packages/steam/src/index.ts", - "labels": [ - { - "context": [ - "import type { MatchMinimalDetailsResponse } from './types/match-minimal-details'", - "import { logger } from './utils/logger'", - "" - ], - "message": "", - "span": "import { logger } from './utils/logger'" - } - ], - "message": "Import statements must come first", - "severity": "error" - } - }, "23ba4c5bbbb71ca3d444f5956f7cc47dd56d22ec4ce9501637ea50875f9a44f6": { "count": 1, "diagnostic": { @@ -5704,6 +5348,26 @@ "severity": "error" } }, + "25a7b6000c4818f50b8ce8ad7228aea27fac1af8df15641840ebd67ec1503ad0": { + "count": 1, + "diagnostic": { + "code": "typescript(no-misused-promises)", + "file": "packages/dota/src/db/watcher.ts", + "labels": [ + { + "context": [ + "{ event: 'INSERT', schema: 'public', table: 'gift_subscriptions' },", + "async (payload: { new: Tables<'gift_subscriptions'> }) => {", + "const newObj = payload.new" + ], + "message": "", + "span": "async (payload: { new: Tables<'gift_subscriptions'> }) => {\n const newObj = payload.new\n // Fetch the subscription details to get the userId\n const { data: subscriptionData, error: subError } = await supabase\n .from('subscriptions')\n .select('userId')\n .eq('id', newObj.subscriptionId)\n .eq('isGift', true)\n // Use maybeSingle to handle potential null result gracefully\n .maybeSingle()\n\n if (subError || !subscriptionData) {\n logger.error('Error fetching subscription or subscription not found for gift', {\n error: subError,\n giftId: newObj.id,\n subscriptionId: newObj.subscriptionId,\n })\n return\n }\n\n const client = findUser(subscriptionData.userId)\n\n // Only proceed if the client is found and currently considered online\n if (client === null || client.stream_online !== true) {\n logger.info('Gift notification skipped: Client not found or not online', {\n found: client !== null,\n online: client?.stream_online,\n userId: subscriptionData.userId,\n })\n return\n }\n\n try {\n // Calculate duration string\n let durationString = ''\n const giftQuantityRaw = newObj.giftQuantity\n\n // Check if giftQuantityRaw is a valid number representation (string or number) and positive\n const giftQuantityNum = Number(giftQuantityRaw)\n const isValidQuantity = !Number.isNaN(giftQuantityNum) && giftQuantityNum > 0\n\n if (isValidQuantity) {\n const { giftType } = newObj\n\n if (giftType) {\n if (giftType === 'monthly') {\n durationString =\n giftQuantityNum === 1 ? '(1 month)' : `(${giftQuantityNum} months)`\n } else if (giftType === 'annual') {\n durationString = giftQuantityNum === 1 ? '(1 year)' : `(${giftQuantityNum} years)`\n } else if (giftType === 'lifetime') {\n durationString = '(Lifetime)'\n }\n // Add more gift types here if necessary\n } else {\n logger.warn('Gift type missing, cannot determine duration string', {\n giftId: newObj.id,\n giftQuantity: giftQuantityNum,\n })\n }\n } else if (giftQuantityRaw !== null && giftQuantityRaw !== undefined) {\n // Log only if it was provided but invalid\n logger.warn('Gift quantity is invalid or not positive', {\n giftId: newObj.id,\n giftQuantity: giftQuantityRaw,\n })\n }\n // If quantity is null/undefined, we just don't add a duration string silently.\n\n // Construct the base message using translation keys\n const baseMessage = newObj.senderName\n ? t('giftSub', {\n lng: client.locale,\n senderName: newObj.senderName,\n })\n : t('giftSubAnonymous', {\n lng: client.locale,\n })\n\n // Prepare optional details parts\n const detailsParts: string[] = []\n if (durationString) {\n detailsParts.push(durationString)\n }\n if (isNonEmptyString(newObj.giftMessage)) {\n // Ensure message is trimmed and quoted\n const trimmedMessage = String(newObj.giftMessage).trim()\n if (trimmedMessage.length > 0) {\n detailsParts.push(`\"${trimmedMessage}\"`)\n }\n }\n\n // Combine base message and details with proper spacing\n let fullMessage = baseMessage\n if (detailsParts.length > 0) {\n fullMessage += ` ${detailsParts.join(' ')}`\n }\n\n // Send notification message to chat\n // Add logging\n logger.info(`Sending gift notification: ${fullMessage}`)\n chatClient.say(client.name, fullMessage)\n } catch (error) {\n logger.error('Error constructing or sending gift notification to chat', {\n error,\n giftId: newObj.id,\n userId: client.token,\n })\n }\n }" + } + ], + "message": "Promise returned in function argument where a void return was expected.", + "severity": "error" + } + }, "26049b8ca182eb46b13e23295c0d5d7003b2a5ede86f5aa671dcf4da086d0bbe": { "count": 1, "diagnostic": { @@ -5936,26 +5600,6 @@ "severity": "error" } }, - "2829879649f74ac8bca85de9557ce14b1851220678bbe51bebf32dbe8e04457d": { - "count": 1, - "diagnostic": { - "code": "eslint(complexity)", - "file": "packages/dota/src/db/watcher.ts", - "labels": [ - { - "context": [ - "{ event: 'INSERT', schema: 'public', table: 'gift_subscriptions' },", - "async (payload: { new: Tables<'gift_subscriptions'> }) => {", - "const newObj = payload.new" - ], - "message": "", - "span": "async (payload: { new: Tables<'gift_subscriptions'> }) => {\n const newObj = payload.new\n // Fetch the subscription details to get the userId\n const { data: subscriptionData, error: subError } = await supabase\n .from('subscriptions')\n .select('userId')\n .eq('id', newObj.subscriptionId)\n .eq('isGift', true)\n // Use maybeSingle to handle potential null result gracefully\n .maybeSingle()\n\n if (subError || !subscriptionData) {\n logger.error('Error fetching subscription or subscription not found for gift', {\n error: subError,\n giftId: newObj.id,\n subscriptionId: newObj.subscriptionId,\n })\n return\n }\n\n const client = findUser(subscriptionData.userId)\n\n // Only proceed if the client is found and currently considered online\n if (client === null || client.stream_online !== true) {\n logger.info('Gift notification skipped: Client not found or not online', {\n found: client !== null,\n online: client?.stream_online,\n userId: subscriptionData.userId,\n })\n return\n }\n\n try {\n // Calculate duration string\n let durationString = ''\n const giftQuantityRaw = newObj.giftQuantity\n\n // Check if giftQuantityRaw is a valid number representation (string or number) and positive\n const giftQuantityNum = Number(giftQuantityRaw)\n const isValidQuantity = !Number.isNaN(giftQuantityNum) && giftQuantityNum > 0\n\n if (isValidQuantity) {\n const { giftType } = newObj\n\n if (giftType) {\n if (giftType === 'monthly') {\n durationString =\n giftQuantityNum === 1 ? '(1 month)' : `(${giftQuantityNum} months)`\n } else if (giftType === 'annual') {\n durationString = giftQuantityNum === 1 ? '(1 year)' : `(${giftQuantityNum} years)`\n } else if (giftType === 'lifetime') {\n durationString = '(Lifetime)'\n }\n // Add more gift types here if necessary\n } else {\n logger.warn('Gift type missing, cannot determine duration string', {\n giftId: newObj.id,\n giftQuantity: giftQuantityNum,\n })\n }\n } else if (giftQuantityRaw != null) {\n // Log only if it was provided but invalid\n logger.warn('Gift quantity is invalid or not positive', {\n giftId: newObj.id,\n giftQuantity: giftQuantityRaw,\n })\n }\n // If quantity is null/undefined, we just don't add a duration string silently.\n\n // Construct the base message using translation keys\n const baseMessage = newObj.senderName\n ? t('giftSub', {\n lng: client.locale,\n senderName: newObj.senderName,\n })\n : t('giftSubAnonymous', {\n lng: client.locale,\n })\n\n // Prepare optional details parts\n const detailsParts: string[] = []\n if (durationString) {\n detailsParts.push(durationString)\n }\n if (isNonEmptyString(newObj.giftMessage)) {\n // Ensure message is trimmed and quoted\n const trimmedMessage = String(newObj.giftMessage).trim()\n if (trimmedMessage.length > 0) {\n detailsParts.push(`\"${trimmedMessage}\"`)\n }\n }\n\n // Combine base message and details with proper spacing\n let fullMessage = baseMessage\n if (detailsParts.length > 0) {\n fullMessage += ` ${detailsParts.join(' ')}`\n }\n\n // Send notification message to chat\n // Add logging\n logger.info(`Sending gift notification: ${fullMessage}`)\n chatClient.say(client.name, fullMessage)\n } catch (error) {\n logger.error('Error constructing or sending gift notification to chat', {\n error,\n giftId: newObj.id,\n userId: client.token,\n })\n }\n }" - } - ], - "message": "async function has a complexity of 21. Maximum allowed is 20.", - "severity": "error" - } - }, "28317b204ac340a82c9459f327ffeb70629f1bce6c1093e48d3b0abe06f199cd": { "count": 1, "diagnostic": { @@ -6074,6 +5718,22 @@ "severity": "error" } }, + "28a948e15c36aaeadf1d6993291ccc1247cacdcbb2c5266193268f8e2768ae76": { + "count": 1, + "diagnostic": { + "code": "typescript(prefer-promise-reject-errors)", + "file": "packages/dota/src/dota/gsi-handler.ts", + "labels": [ + { + "context": ["if (err !== null && err !== undefined) {", "reject(err)", "} else {"], + "message": "", + "span": "reject(err)" + } + ], + "message": "Expected the Promise rejection reason to be an Error.", + "severity": "error" + } + }, "28abb76ca5093b587fb5ce3e0088026a441d7a5ebaa98424ccf82a23c7f0f1bd": { "count": 1, "diagnostic": { @@ -6174,46 +5834,6 @@ "severity": "error" } }, - "28f4d1918bd4bf25cc3fcbf504e3780394f590712cf29dc3bee1f3d824b03794": { - "count": 1, - "diagnostic": { - "code": "eslint(eqeqeq)", - "file": "packages/dota/src/dota/gsi-handler.ts", - "labels": [ - { - "context": [ - "}", - "if (matchId == null || matchId.length === 0 || matchId === '0') {", - "return" - ], - "message": "", - "span": "==" - } - ], - "message": "Expected === and instead saw ==", - "severity": "error" - } - }, - "2934aeddf50ab3d3aa80398c8159e45b78fb28446faf5c83275b0ca2c834049e": { - "count": 1, - "diagnostic": { - "code": "eslint(eqeqeq)", - "file": "packages/dota/src/dota/gsi-handler.ts", - "labels": [ - { - "context": [ - "steam32Id = steamID64toSteamID32(steamId)", - "if (steam32Id == null || steam32Id === 0) {", - "this.creatingSteamAccount = false" - ], - "message": "", - "span": "==" - } - ], - "message": "Expected === and instead saw ==", - "severity": "error" - } - }, "294ddca717988e16f934679435e8540afcc6459b53da9b4e7e2cdf28a1141d52": { "count": 1, "diagnostic": { @@ -6439,6 +6059,26 @@ "severity": "error" } }, + "2a2174273268aa3d28c6e5ad070de00195f6f71fb57f2ea4ec794ee66d536db7": { + "count": 1, + "diagnostic": { + "code": "promise(avoid-new)", + "file": "packages/dota/src/dota/gsi-handler.ts", + "labels": [ + { + "context": [ + "// Request match data from Steam socket", + "const getMatchDetailsPromise = new Promise(", + "(resolve, reject) => {" + ], + "message": "", + "span": "new Promise(\n (resolve, reject) => {\n steamSocket.emit(\n 'getMatchMinimalDetails',\n { match_id: Number(matchId) },\n (err: unknown, response: MatchMinimalDetailsResponse) => {\n if (err !== null && err !== undefined) {\n reject(err)\n } else {\n resolve(response)\n }\n }\n )\n }\n )" + } + ], + "message": "Avoid creating new promises", + "severity": "error" + } + }, "2a50eb28f84a666391d2579a35b416987159260a51b905b79214ca6b60e24738": { "count": 1, "diagnostic": { @@ -6479,26 +6119,6 @@ "severity": "error" } }, - "2a87801e04abe496620c1e8f5906d9b7746a614c66202ed04c6efdb56cff9bf1": { - "count": 1, - "diagnostic": { - "code": "typescript(prefer-nullish-coalescing)", - "file": "packages/steam/src/steam.ts", - "labels": [ - { - "context": [ - "public static getInstance(): Dota {", - "if (Dota.instance === undefined) {", - "Dota.instance = new Dota()" - ], - "message": "", - "span": "if (Dota.instance === undefined) {\n Dota.instance = new Dota()\n }" - } - ], - "message": "Prefer using nullish coalescing operator (`??=`) instead of an assignment expression, as it is simpler to read.", - "severity": "error" - } - }, "2a87e22e9f944fb7411a2346dc1280a622d58126875e52fae661039d9b81e27b": { "count": 1, "diagnostic": { @@ -6829,26 +6449,6 @@ "severity": "error" } }, - "2d32cbb217a6c37bc5171f54cd0447ded485bb333276049eba4b598f46d3628a": { - "count": 1, - "diagnostic": { - "code": "eslint(eqeqeq)", - "file": "packages/dota/src/dota/gsi-handler.ts", - "labels": [ - { - "context": [ - ".update({", - "...(snapshotMatch.hero_name != null && snapshotMatch.hero_name.length > 0", - "? { hero_name: snapshotMatch.hero_name }" - ], - "message": "", - "span": "!=" - } - ], - "message": "Expected !== and instead saw !=", - "severity": "error" - } - }, "2d457561477eb018c528e432b72a925017f2e8d286e50dca7baed9dc12c2fde2": { "count": 1, "diagnostic": { @@ -6949,26 +6549,6 @@ "severity": "error" } }, - "2deeb6da416c04a57b7702c54f317af2e6c4383c15e59cd03da42417821fbeb1": { - "count": 1, - "diagnostic": { - "code": "import(first)", - "file": "packages/dota/src/index.ts", - "labels": [ - { - "context": [ - "", - "import { checkSupabaseHealth, startHeartbeat } from '@dotabod/shared-utils'", - "" - ], - "message": "", - "span": "import { checkSupabaseHealth, startHeartbeat } from '@dotabod/shared-utils'" - } - ], - "message": "Import statements must come first", - "severity": "error" - } - }, "2df18fb3ab6ee99b401aa11ee9eb7b8862c454b5e1e51a29ebea7b539479f34d": { "count": 1, "diagnostic": { @@ -7049,22 +6629,6 @@ "severity": "error" } }, - "2e9f379f1205fd504fb73f611582b62fe0319269b80b91910ac7794aeb9de180": { - "count": 1, - "diagnostic": { - "code": "typescript(prefer-nullish-coalescing)", - "file": "packages/steam/src/steam.ts", - "labels": [ - { - "context": ["", "if (!this.interval) {", "// Get latest games every 30 seconds"], - "message": "", - "span": "if (!this.interval) {\n // Get latest games every 30 seconds\n this.interval = setInterval(this.checkAccounts, 30_000)\n }" - } - ], - "message": "Prefer using nullish coalescing operator (`??=`) instead of an assignment expression, as it is simpler to read.", - "severity": "error" - } - }, "2ec95a88ca0de420c138d9f5e7a311dda23c4ab76bd7090d93571e51d23d21e6": { "count": 1, "diagnostic": { @@ -7290,22 +6854,6 @@ "severity": "error" } }, - "305fc63088a8de1c205c3e99cbfa9a1be3045392e40acd4a10bf5426e3d353af": { - "count": 1, - "diagnostic": { - "code": "promise(no-multiple-resolved)", - "file": "packages/steam/src/steam.ts", - "labels": [ - { - "context": ["}", "resolve(data)", "})"], - "message": "", - "span": "resolve(data)" - } - ], - "message": "Promise should not be resolved multiple times. Promise is potentially resolved on line 623.", - "severity": "error" - } - }, "30856b65ce599206caa4de7a4f95dea7039d23f4d1dec271aa68db5d05ab275d": { "count": 1, "diagnostic": { @@ -7527,26 +7075,6 @@ "severity": "error" } }, - "3211adb8596f8f45313e8819afedb24c386c647c8cd441554e81e6fa960735d4": { - "count": 1, - "diagnostic": { - "code": "eslint(no-eq-null)", - "file": "packages/dota/src/dota/gsi-handler.ts", - "labels": [ - { - "context": [ - "", - "if (matchNotEnded == null || error !== null) {", - "logger.info('[BETS] Match already ended, skipping early DC winner check', {" - ], - "message": "", - "span": "matchNotEnded == null" - } - ], - "message": "Do not use `null` comparisons without type-checking operators.", - "severity": "error" - } - }, "3212c1eedc4c2e32db09edae90e2b3c24e385b865993fb66e8a22a9134c09646": { "count": 1, "diagnostic": { @@ -7627,26 +7155,6 @@ "severity": "error" } }, - "3260e234c3a2f859dbd1e67d897d94dcbe749ec46ee93f7ffb63482790f16f32": { - "count": 1, - "diagnostic": { - "code": "import(first)", - "file": "packages/twitch-chat/src/index.ts", - "labels": [ - { - "context": [ - "import { lstatSync, readdirSync } from 'node:fs'", - "import { join } from 'node:path'", - "" - ], - "message": "", - "span": "import { join } from 'node:path'" - } - ], - "message": "Import statements must come first", - "severity": "error" - } - }, "3273a69a0c59f1caa50fddda61240b1069a1ef5322a5e94c102babcb665d6e67": { "count": 1, "diagnostic": { @@ -8620,26 +8128,6 @@ "severity": "error" } }, - "39b145bd68e98d32dcc8c433e96fdb22b8d30cdd622e11303d40c23c0513f7c6": { - "count": 1, - "diagnostic": { - "code": "eslint(no-eq-null)", - "file": "packages/dota/src/dota/gsi-handler.ts", - "labels": [ - { - "context": [ - "", - "if (this.openingBets || matchId == null || matchId.length === 0) {", - "logger.debug('[BETS] Not closing bets', {" - ], - "message": "", - "span": "matchId == null" - } - ], - "message": "Do not use `null` comparisons without type-checking operators.", - "severity": "error" - } - }, "39b5fc533490fc6906a64a2052d24ad071b35160810aeddbdb18e9691e9d743e": { "count": 1, "diagnostic": { @@ -8720,35 +8208,6 @@ "severity": "error" } }, - "3a44698d457a68790f629003b315f0d96630a69b3f01d2c1eb8fe338d943852b": { - "count": 1, - "diagnostic": { - "code": "typescript(no-unnecessary-type-conversion)", - "file": "packages/dota/src/dota/events/minimap/parser.ts", - "labels": [ - { - "context": [ - "if (entity.xpos >= 0) {", - "entity.xpos = Number(entity.xpos) + Number(this.xLength)", - "} else {" - ], - "message": "", - "span": "this.xLength" - }, - { - "context": [ - "if (entity.xpos >= 0) {", - "entity.xpos = Number(entity.xpos) + Number(this.xLength)", - "} else {" - ], - "message": "", - "span": "Number" - } - ], - "message": "This type conversion does not change the type or value of the expression.", - "severity": "error" - } - }, "3a4c3ae351fbe530bef6a2895985e5e5d7452cb4b4d83a9016fb144a1fef58fd": { "count": 1, "diagnostic": { @@ -9468,6 +8927,26 @@ "severity": "error" } }, + "3e3298bf1248e101ae9870fd5ced558435e0e78e845c261e5a14176f401c4180": { + "count": 1, + "diagnostic": { + "code": "anti-slop(no-conditional-empty-object-spread)", + "file": "packages/dota/src/dota/gsi-handler.ts", + "labels": [ + { + "context": [ + ".update({", + "...(snapshotMatch.hero_name !== null &&", + "snapshotMatch.hero_name !== undefined &&" + ], + "message": "", + "span": "...(snapshotMatch.hero_name !== null &&\n snapshotMatch.hero_name !== undefined &&\n snapshotMatch.hero_name.length > 0\n ? { hero_name: snapshotMatch.hero_name }\n : {})" + } + ], + "message": "This conditional spread hides property omission behind an empty object. Build the object in separate statements and add the property only when present.", + "severity": "error" + } + }, "3e39cf530eb26e1fca4ac69d1acf9410009db73ed2e4ed0ad8df509d688f1d1f": { "count": 1, "diagnostic": { @@ -9504,26 +8983,6 @@ "severity": "error" } }, - "3eac31874731b7e846ab70de6d4fe90ab814526f0ff7b6178055f8ef48c95177": { - "count": 1, - "diagnostic": { - "code": "eslint(no-eq-null)", - "file": "packages/dota/src/dota/gsi-handler.ts", - "labels": [ - { - "context": [ - "const steamId = this.client.gsi?.player?.steamid", - "if (this.creatingSteamAccount || steamId == null || steamId.length === 0) {", - "return" - ], - "message": "", - "span": "steamId == null" - } - ], - "message": "Do not use `null` comparisons without type-checking operators.", - "severity": "error" - } - }, "3edbdd8f106814ecfbe27e97790a8e8ce1af344532e3e28e179a2d92be55757b": { "count": 1, "diagnostic": { @@ -9560,6 +9019,26 @@ "severity": "error" } }, + "3ee295d9ea8758d4d3e04e1fc3eab67ae7d2ffdd264055ea8724d70e86b5c820": { + "count": 1, + "diagnostic": { + "code": "anti-slop(no-module-mocking)", + "file": "packages/twitch-chat/src/__tests__/shared-mocks.ts", + "labels": [ + { + "context": [ + "", + "vi.doMock('ws', () => ({ WebSocket: FakeWebSocket, default: FakeWebSocket }))", + "" + ], + "message": "", + "span": "vi.doMock('ws', () => ({ WebSocket: FakeWebSocket, default: FakeWebSocket }))" + } + ], + "message": "Replace module mocking with dependency injection through a real interface, service layer, or faithful test implementation.", + "severity": "error" + } + }, "3eebd05b523592281a02b61270f806bcd1209636a68f665e867a678e82997060": { "count": 1, "diagnostic": { @@ -10195,6 +9674,22 @@ "severity": "error" } }, + "42559fa0e9648e27c26da1a87cf4b898054a847d15486aaa125064715a631afe": { + "count": 1, + "diagnostic": { + "code": "promise(no-multiple-resolved)", + "file": "packages/steam/src/steam.ts", + "labels": [ + { + "context": ["}", "resolve(data)", "})"], + "message": "", + "span": "resolve(data)" + } + ], + "message": "Promise should not be resolved multiple times. Promise is potentially resolved on line 621.", + "severity": "error" + } + }, "426e55e1fa3182100768f2a96f9758e5738bb08744a6e5a6a8c4750954c73627": { "count": 1, "diagnostic": { @@ -10593,26 +10088,6 @@ "severity": "error" } }, - "45713b944a8167753ce96142055ae5002619e25cbf5a55f9b170ba9c34ee0ad6": { - "count": 1, - "diagnostic": { - "code": "eslint(no-negated-condition)", - "file": "packages/dota/src/dota/gsi-handler.ts", - "labels": [ - { - "context": [ - "(err: unknown, response: MatchMinimalDetailsResponse) => {", - "if (err != null) {", - "reject(err)" - ], - "message": "", - "span": "err != null" - } - ], - "message": "Unexpected negated condition.", - "severity": "error" - } - }, "459f8514d82692ee0d2711e18aedcfdead0b939903cdb54c1be474f3de905e64": { "count": 1, "diagnostic": { @@ -11037,26 +10512,6 @@ "severity": "error" } }, - "482d5be96e82acace4e1451ab6a074cd1acb76f5de7349b0de56bb2e6cb66adf": { - "count": 1, - "diagnostic": { - "code": "promise(avoid-new)", - "file": "packages/dota/src/dota/gsi-handler.ts", - "labels": [ - { - "context": [ - "// Request match data from Steam socket", - "const getMatchDetailsPromise = new Promise(", - "(resolve, reject) => {" - ], - "message": "", - "span": "new Promise(\n (resolve, reject) => {\n steamSocket.emit(\n 'getMatchMinimalDetails',\n { match_id: Number(matchId) },\n (err: unknown, response: MatchMinimalDetailsResponse) => {\n if (err != null) {\n reject(err)\n } else {\n resolve(response)\n }\n }\n )\n }\n )" - } - ], - "message": "Avoid creating new promises", - "severity": "error" - } - }, "484b20ebc1aab57e86d44ebfaaeb6c8afd7f90c5b44c31050c76566f5c128f5a": { "count": 1, "diagnostic": { @@ -11133,26 +10588,6 @@ "severity": "error" } }, - "48aac01041ce28dcb783e0fb96bf84342654625697ac586345d95f7e7919d456": { - "count": 1, - "diagnostic": { - "code": "eslint(eqeqeq)", - "file": "packages/dota/src/dota/gsi-handler.ts", - "labels": [ - { - "context": [ - "(err: unknown, response: MatchMinimalDetailsResponse) => {", - "if (err != null) {", - "reject(err)" - ], - "message": "", - "span": "!=" - } - ], - "message": "Expected !== and instead saw !=", - "severity": "error" - } - }, "48b8b396265140139742f69db97ac0321cb95370cfc7a2ce1cec1ade9edbc375": { "count": 1, "diagnostic": { @@ -11189,22 +10624,6 @@ "severity": "error" } }, - "494304c1de8006b77bbb0332150f4397108bc819b80bd38996415e71ec8514d5": { - "count": 1, - "diagnostic": { - "code": "typescript(prefer-nullish-coalescing)", - "file": "packages/dota/src/dota/lib/check-midas.ts", - "labels": [ - { - "context": ["`${token}:passiveMidas`", ")) || {", "firstNoticedPassive: 0,"], - "message": "", - "span": "||" - } - ], - "message": "Prefer using nullish coalescing operator (`??`) instead of a logical or (`||`), as it is a safer operator.", - "severity": "error" - } - }, "495d761e5d090835bc4e7c0c22255c059182765a5d1e130956eca6527caadf53": { "count": 1, "diagnostic": { @@ -11241,26 +10660,6 @@ "severity": "error" } }, - "496f3e22bd92bd12a50d6aad6faee80428c4fba70bf8903e8d1aa6184841af88": { - "count": 1, - "diagnostic": { - "code": "eslint(eqeqeq)", - "file": "packages/dota/src/dota/gsi-handler.ts", - "labels": [ - { - "context": [ - "", - "if (matchNotEnded == null || error !== null) {", - "logger.info('[BETS] Match already ended, skipping early DC winner check', {" - ], - "message": "", - "span": "==" - } - ], - "message": "Expected === and instead saw ==", - "severity": "error" - } - }, "49703ddef4a6f0a6d2458ed27edf0d23698b6d1285a78f8d441a28977d830b67": { "count": 1, "diagnostic": { @@ -11393,52 +10792,43 @@ "severity": "error" } }, - "4a43e1ba6335831869a22ac6d34f4125433ceb47515b01cc1f12a47312b0a937": { + "4a373c1a8a5b9ac15b3fa64e0daf54137a89b270e7bf9363151b1242c7e5beb1": { "count": 1, "diagnostic": { - "code": "sonarjs(max-union-size)", - "file": "packages/steam/src/types/index.ts", + "code": "anti-slop(no-chained-type-assertions)", + "file": "packages/dota/src/dota/lib/capture-cosmetics.ts", "labels": [ { "context": [ - "export type Team2PlayerId = `player${0 | 1 | 2 | 3 | 4}`", - "export type Team3PlayerId = `player${5 | 6 | 7 | 8 | 9}`", - "" + "heroName: getHeroNameOrColor(heroId),", + "items: items as unknown as Json,", + "matchId," ], "message": "", - "span": "5 | 6 | 7 | 8 | 9" + "span": "items as unknown as Json" } ], - "message": "Refactor this union type to have less than 3 elements.", + "message": "This assertion chain discards type evidence. Keep the original precise type, or parse untrusted input at its boundary before narrowing it.", "severity": "error" } }, - "4a63f9fa7bc93c8540621c22da041e6b7b6a6cf768c113fbdbb43fe8693b7aec": { + "4a43e1ba6335831869a22ac6d34f4125433ceb47515b01cc1f12a47312b0a937": { "count": 1, "diagnostic": { - "code": "typescript(no-unnecessary-type-conversion)", - "file": "packages/dota/src/dota/lib/announce-features.ts", + "code": "sonarjs(max-union-size)", + "file": "packages/steam/src/types/index.ts", "labels": [ { "context": [ - "if (await announceFeatureOnce(client, feature)) {", - "await redisClient.client.set(guardKey, String(matchId))", - "return" - ], - "message": "", - "span": "matchId" - }, - { - "context": [ - "if (await announceFeatureOnce(client, feature)) {", - "await redisClient.client.set(guardKey, String(matchId))", - "return" + "export type Team2PlayerId = `player${0 | 1 | 2 | 3 | 4}`", + "export type Team3PlayerId = `player${5 | 6 | 7 | 8 | 9}`", + "" ], "message": "", - "span": "String" + "span": "5 | 6 | 7 | 8 | 9" } ], - "message": "This type conversion does not change the type or value of the expression.", + "message": "Refactor this union type to have less than 3 elements.", "severity": "error" } }, @@ -11662,26 +11052,6 @@ "severity": "error" } }, - "4bf0647e11acfb937f4dfdf44dd6a7a8de3afac8bcf3e828f2827024104dd647": { - "count": 1, - "diagnostic": { - "code": "import(first)", - "file": "packages/twitch-chat/src/index.ts", - "labels": [ - { - "context": [ - "import { clearDisableCache, DISABLE_CACHE_EXPIRY, disableUserCache } from './disable-cache'", - "import { isEventsubConnected } from './event-sub-socket'", - "import { sendTwitchChatMessage } from './handle-chat'" - ], - "message": "", - "span": "import { isEventsubConnected } from './event-sub-socket'" - } - ], - "message": "Import statements must come first", - "severity": "error" - } - }, "4c47d5068082e12a8cb21642037de9aa314490e63c026cd80d9425c26a7b05c4": { "count": 1, "diagnostic": { @@ -11874,26 +11244,6 @@ "severity": "error" } }, - "4d797731aa1791f76d9cfa3c0a66d6eb04ec1b75c75ca6d2cdcb9aa3047e2116": { - "count": 1, - "diagnostic": { - "code": "import(first)", - "file": "packages/twitch-chat/src/index.ts", - "labels": [ - { - "context": [ - "import { use } from 'i18next'", - "import FsBackend from 'i18next-fs-backend'", - "import type { FsBackendOptions } from 'i18next-fs-backend'" - ], - "message": "", - "span": "import FsBackend from 'i18next-fs-backend'" - } - ], - "message": "Import statements must come first", - "severity": "error" - } - }, "4d915b4074afe26bb52caa50189689e415315be9cb8d94302275f5eca106fc40": { "count": 1, "diagnostic": { @@ -12010,6 +11360,26 @@ "severity": "error" } }, + "4e68835b3ed2ed25754c379bc8dcb747e1692b84dd16b2b3cf040127f8e74a9c": { + "count": 1, + "diagnostic": { + "code": "sonarjs(expression-complexity)", + "file": "packages/dota/src/dota/gsi-handler.ts", + "labels": [ + { + "context": [ + "if (", + "(this.client.gsi?.map?.dire_score === null ||", + "this.client.gsi?.map?.dire_score === undefined ||" + ], + "message": "", + "span": "(this.client.gsi?.map?.dire_score === null ||\n this.client.gsi?.map?.dire_score === undefined ||\n this.client.gsi.map.dire_score === 0) &&\n (this.client.gsi?.map?.radiant_score === null ||\n this.client.gsi?.map?.radiant_score === undefined ||\n this.client.gsi.map.radiant_score === 0) &&\n this.client.gsi?.map?.matchid !== null &&\n this.client.gsi?.map?.matchid !== undefined &&\n this.client.gsi.map.matchid.length > 0" + } + ], + "message": "Reduce the number of conditional operators (8) used in the expression (maximum allowed 3).", + "severity": "error" + } + }, "4e8bf88c84a3fe629f6e0ae6087c70bb9df95bef372847bf4cd58de4cfb6b565": { "count": 1, "diagnostic": { @@ -12266,6 +11636,26 @@ "severity": "error" } }, + "5044c09ba99ddb5e63c65f6b2366295d45c92001b5bac0a88edfa3b4ff6d6478": { + "count": 1, + "diagnostic": { + "code": "anti-slop(no-unknown-returns)", + "file": "packages/dota/src/dota/gsi-server-types.ts", + "labels": [ + { + "context": [ + "interface SocketBroadcastTarget {", + "emit: (event: string, ...args: unknown[]) => unknown", + "}" + ], + "message": "", + "span": "unknown" + } + ], + "message": "This function exposes `unknown` to its caller. Parse the value at its boundary and return a named domain type.", + "severity": "error" + } + }, "5057cc81c39099eaec48cfa9f2cb432c633d67523adbeb65152da59032a2930b": { "count": 1, "diagnostic": { @@ -12371,55 +11761,6 @@ "severity": "error" } }, - "5101a3367ae455452cd466593faecb9ae1083a6c7444fd5a56a36dcd194d6e01": { - "count": 1, - "diagnostic": { - "code": "eslint(no-eq-null)", - "file": "packages/dota/src/dota/gsi-handler.ts", - "labels": [ - { - "context": [ - "}", - "if (matchId == null || matchId.length === 0 || matchId === '0') {", - "return" - ], - "message": "", - "span": "matchId == null" - } - ], - "message": "Do not use `null` comparisons without type-checking operators.", - "severity": "error" - } - }, - "510bd98c7fc287da960137e7ec91f9ac4d3f09c2a3cfe83a484b40aa02b0883b": { - "count": 1, - "diagnostic": { - "code": "typescript(no-unnecessary-type-conversion)", - "file": "packages/dota/src/dota/lib/announce-features.ts", - "labels": [ - { - "context": [ - "const guardKey = `${client.token}:featureAnnouncedMatch`", - "if ((await redisClient.client.get(guardKey)) === String(matchId)) {", - "return" - ], - "message": "", - "span": "matchId" - }, - { - "context": [ - "const guardKey = `${client.token}:featureAnnouncedMatch`", - "if ((await redisClient.client.get(guardKey)) === String(matchId)) {", - "return" - ], - "message": "", - "span": "String" - } - ], - "message": "This type conversion does not change the type or value of the expression.", - "severity": "error" - } - }, "5111788c367c1b0b593abf77ba046950a4b464ecc410ebecd32e5a53129eee2e": { "count": 1, "diagnostic": { @@ -12549,26 +11890,6 @@ "severity": "error" } }, - "51cfa45318bad5f5f2f7675bfcbb5996fa13170e374349278cc9093f33abdaeb": { - "count": 1, - "diagnostic": { - "code": "eslint(no-eq-null)", - "file": "packages/dota/src/dota/gsi-handler.ts", - "labels": [ - { - "context": [ - "(err: unknown, response: MatchMinimalDetailsResponse) => {", - "if (err != null) {", - "reject(err)" - ], - "message": "", - "span": "err != null" - } - ], - "message": "Do not use `null` comparisons without type-checking operators.", - "severity": "error" - } - }, "520bf02706599552d8a1ca394bd633906e007635164361e7d03a1cbf018de614": { "count": 1, "diagnostic": { @@ -13313,26 +12634,6 @@ "severity": "error" } }, - "572b22f5b01ddc9d25f7930e4c14d2feceaa4116df1adc3a536a116e6633fe39": { - "count": 1, - "diagnostic": { - "code": "eslint(eqeqeq)", - "file": "packages/dota/src/dota/gsi-handler.ts", - "labels": [ - { - "context": [ - "", - "if (this.openingBets || matchId == null || matchId.length === 0) {", - "logger.debug('[BETS] Not closing bets', {" - ], - "message": "", - "span": "==" - } - ], - "message": "Expected === and instead saw ==", - "severity": "error" - } - }, "57477bfa1b0355a1fbde9f866655a90d702283f860fed299c7e5845df7f57790": { "count": 1, "diagnostic": { @@ -13518,26 +12819,6 @@ "severity": "error" } }, - "58830d264aa24db8759fc67a015c4e3c2fc4682307de3eb94c320b197270ce19": { - "count": 1, - "diagnostic": { - "code": "typescript(prefer-nullish-coalescing)", - "file": "packages/dota/src/twitch/chat-client.ts", - "labels": [ - { - "context": [ - "const MAX_WHISPER_LENGTH = 10_000", - "const chunks = text.match(new RegExp(`.{1,${MAX_WHISPER_LENGTH}}`, 'ug')) || []", - "" - ], - "message": "", - "span": "||" - } - ], - "message": "Prefer using nullish coalescing operator (`??`) instead of a logical or (`||`), as it is a safer operator.", - "severity": "error" - } - }, "588e46f218768a339e490da49cb9a8c333b9df97c543a58fe94ee316a594e8dc": { "count": 1, "diagnostic": { @@ -14085,26 +13366,6 @@ "severity": "error" } }, - "5b555e98881befbe1b57459a18faa6c397c0fb1690833fb66358422200dcde92": { - "count": 1, - "diagnostic": { - "code": "typescript(no-misused-promises)", - "file": "packages/dota/src/db/watcher.ts", - "labels": [ - { - "context": [ - "{ event: 'INSERT', schema: 'public', table: 'gift_subscriptions' },", - "async (payload: { new: Tables<'gift_subscriptions'> }) => {", - "const newObj = payload.new" - ], - "message": "", - "span": "async (payload: { new: Tables<'gift_subscriptions'> }) => {\n const newObj = payload.new\n // Fetch the subscription details to get the userId\n const { data: subscriptionData, error: subError } = await supabase\n .from('subscriptions')\n .select('userId')\n .eq('id', newObj.subscriptionId)\n .eq('isGift', true)\n // Use maybeSingle to handle potential null result gracefully\n .maybeSingle()\n\n if (subError || !subscriptionData) {\n logger.error('Error fetching subscription or subscription not found for gift', {\n error: subError,\n giftId: newObj.id,\n subscriptionId: newObj.subscriptionId,\n })\n return\n }\n\n const client = findUser(subscriptionData.userId)\n\n // Only proceed if the client is found and currently considered online\n if (client === null || client.stream_online !== true) {\n logger.info('Gift notification skipped: Client not found or not online', {\n found: client !== null,\n online: client?.stream_online,\n userId: subscriptionData.userId,\n })\n return\n }\n\n try {\n // Calculate duration string\n let durationString = ''\n const giftQuantityRaw = newObj.giftQuantity\n\n // Check if giftQuantityRaw is a valid number representation (string or number) and positive\n const giftQuantityNum = Number(giftQuantityRaw)\n const isValidQuantity = !Number.isNaN(giftQuantityNum) && giftQuantityNum > 0\n\n if (isValidQuantity) {\n const { giftType } = newObj\n\n if (giftType) {\n if (giftType === 'monthly') {\n durationString =\n giftQuantityNum === 1 ? '(1 month)' : `(${giftQuantityNum} months)`\n } else if (giftType === 'annual') {\n durationString = giftQuantityNum === 1 ? '(1 year)' : `(${giftQuantityNum} years)`\n } else if (giftType === 'lifetime') {\n durationString = '(Lifetime)'\n }\n // Add more gift types here if necessary\n } else {\n logger.warn('Gift type missing, cannot determine duration string', {\n giftId: newObj.id,\n giftQuantity: giftQuantityNum,\n })\n }\n } else if (giftQuantityRaw != null) {\n // Log only if it was provided but invalid\n logger.warn('Gift quantity is invalid or not positive', {\n giftId: newObj.id,\n giftQuantity: giftQuantityRaw,\n })\n }\n // If quantity is null/undefined, we just don't add a duration string silently.\n\n // Construct the base message using translation keys\n const baseMessage = newObj.senderName\n ? t('giftSub', {\n lng: client.locale,\n senderName: newObj.senderName,\n })\n : t('giftSubAnonymous', {\n lng: client.locale,\n })\n\n // Prepare optional details parts\n const detailsParts: string[] = []\n if (durationString) {\n detailsParts.push(durationString)\n }\n if (isNonEmptyString(newObj.giftMessage)) {\n // Ensure message is trimmed and quoted\n const trimmedMessage = String(newObj.giftMessage).trim()\n if (trimmedMessage.length > 0) {\n detailsParts.push(`\"${trimmedMessage}\"`)\n }\n }\n\n // Combine base message and details with proper spacing\n let fullMessage = baseMessage\n if (detailsParts.length > 0) {\n fullMessage += ` ${detailsParts.join(' ')}`\n }\n\n // Send notification message to chat\n // Add logging\n logger.info(`Sending gift notification: ${fullMessage}`)\n chatClient.say(client.name, fullMessage)\n } catch (error) {\n logger.error('Error constructing or sending gift notification to chat', {\n error,\n giftId: newObj.id,\n userId: client.token,\n })\n }\n }" - } - ], - "message": "Promise returned in function argument where a void return was expected.", - "severity": "error" - } - }, "5babee82fc3453d9934346647cd0179df0978964915e538bca4d2bcea1efb7a1": { "count": 2, "diagnostic": { @@ -14321,46 +13582,6 @@ "severity": "error" } }, - "5cf99ea6402bb5fd5560e005ee780d354d661b9db71138d957f597d95a8b3d74": { - "count": 1, - "diagnostic": { - "code": "promise(avoid-new)", - "file": "packages/dota/src/dota/events/gsi-events/newdata.ts", - "labels": [ - { - "context": [ - "try {", - "const getDelayedDataPromise = new Promise((resolve, reject) => {", - "const timeoutId = setTimeout(() => {" - ], - "message": "", - "span": "new Promise((resolve, reject) => {\n const timeoutId = setTimeout(() => {\n reject(new CustomError(t('matchData8500', { emote: 'PoroSad', lng: client.locale })))\n // 10 second timeout\n }, 10_000)\n\n steamSocket.emit(\n 'getRealTimeStats',\n {\n match_id: matchId,\n refetchCards: true,\n steam_server_id: currentSteamServerId.toString(),\n token: client.token,\n },\n (err: unknown, data: DelayedGames) => {\n clearTimeout(timeoutId)\n if (err !== null && err !== undefined) {\n reject(err)\n } else {\n resolve(data)\n }\n }\n )\n })" - } - ], - "message": "Avoid creating new promises", - "severity": "error" - } - }, - "5d63053f68bf27871e4a4251631d0566ed49f1d62416be332128f57944d592cd": { - "count": 1, - "diagnostic": { - "code": "promise(prefer-await-to-callbacks)", - "file": "packages/dota/src/dota/gsi-handler.ts", - "labels": [ - { - "context": [ - "{ match_id: Number(matchId) },", - "(err: unknown, response: MatchMinimalDetailsResponse) => {", - "if (err != null) {" - ], - "message": "", - "span": "(err: unknown, response: MatchMinimalDetailsResponse) => {\n if (err != null) {\n reject(err)\n } else {\n resolve(response)\n }\n }" - } - ], - "message": "Prefer `async`/`await` to the callback pattern", - "severity": "error" - } - }, "5d6644e58ec75cbb98206819780a7512b5eda604a4fe7832be2d2323727f279f": { "count": 2, "diagnostic": { @@ -14613,35 +13834,6 @@ "severity": "error" } }, - "5ec1f30dc3b7518078610bf7128bec56b85006983e15cf83118181783b897ef4": { - "count": 1, - "diagnostic": { - "code": "typescript(no-unnecessary-type-conversion)", - "file": "packages/dota/src/dota/events/minimap/parser.ts", - "labels": [ - { - "context": [ - "if (entity.xpos >= 0) {", - "entity.xpos = Number(entity.xpos) + Number(this.xLength)", - "} else {" - ], - "message": "", - "span": "entity.xpos" - }, - { - "context": [ - "if (entity.xpos >= 0) {", - "entity.xpos = Number(entity.xpos) + Number(this.xLength)", - "} else {" - ], - "message": "", - "span": "Number" - } - ], - "message": "This type conversion does not change the type or value of the expression.", - "severity": "error" - } - }, "5ef22dee3e76d0cc75adc74ecaf0ab64039042312d4a0b65a8226964aa97d6c5": { "count": 1, "diagnostic": { @@ -14934,6 +14126,26 @@ "severity": "error" } }, + "611446e970cc0a4909cdd35f2025986746db7c95a89b3066338aca7448bb41c1": { + "count": 1, + "diagnostic": { + "code": "eslint(no-negated-condition)", + "file": "packages/dota/src/dota/lib/get-players.ts", + "labels": [ + { + "context": [ + "cards,", + "gameMode: response !== null ? response.match.game_mode : undefined,", + "matchPlayers," + ], + "message": "", + "span": "response !== null" + } + ], + "message": "Unexpected negated condition.", + "severity": "error" + } + }, "6116c63d73c9e0cdf5a66d7240f1a6c535214b136911bb8b3bbb488353741b53": { "count": 1, "diagnostic": { @@ -15775,26 +14987,6 @@ "severity": "error" } }, - "66e5aaea218632a185d709f8c045753d7c02555d8367fa0117a547851e38864c": { - "count": 1, - "diagnostic": { - "code": "typescript(prefer-nullish-coalescing)", - "file": "packages/dota/src/dota/events/gsi-events/event.chat_message.ts", - "labels": [ - { - "context": [ - "const wordCount = message.split(/\\s+/u).length", - "const englishWordMatches = (message.match(englishWords) || []).length", - "const englishRatio = wordCount > 0 ? englishWordMatches / wordCount : 0" - ], - "message": "", - "span": "||" - } - ], - "message": "Prefer using nullish coalescing operator (`??`) instead of a logical or (`||`), as it is a safer operator.", - "severity": "error" - } - }, "673cba3f5f529621c96c8c7c09ff7e55d62f5564e8701f26b058aca04e2b556c": { "count": 1, "diagnostic": { @@ -16163,26 +15355,6 @@ "severity": "error" } }, - "6abadabc7f34a0f6804f4b2dc1d16fd5f31d30fab5afc6b91b26dede96459e52": { - "count": 1, - "diagnostic": { - "code": "eslint(complexity)", - "file": "packages/dota/src/dota/events/gsi-events/event.chat_message.ts", - "labels": [ - { - "context": [ - "eventHandler.registerEvent(`event:${DotaEventTypes.ChatMessage}`, {", - "handler: async (dotaClient, event: ChatMessageEvent) => {", - "if (!dotaClient.client.stream_online) {" - ], - "message": "", - "span": "async (dotaClient, event: ChatMessageEvent) => {\n if (!dotaClient.client.stream_online) {\n return\n }\n if (!isPlayingMatch(dotaClient.client.gsi)) {\n return\n }\n\n const message = await moderateText(event.message?.trim())\n if (message === null || message === undefined || message.length === 0 || message === '***') {\n return\n }\n\n // Check for chatting behavior\n if (!disableChatterMessage && dotaClient.client.gsi?.player?.player_slot === event.player_id) {\n // Check global chatter access\n const {\n chattingSpamEmote: { enabled: chattingEmoteEnabled },\n } = getValueOrDefault(\n DBSettings.chatters,\n dotaClient.client.settings,\n dotaClient.client.subscription,\n 'chattingSpamEmote'\n )\n\n if (chattingEmoteEnabled) {\n const wordCount = message.split(/\\s+/u).length\n const chattingSeverity = shouldTriggerChattingAlert(\n dotaClient.client.name,\n event.player_id,\n wordCount\n )\n if (chattingSeverity > 0) {\n sendChattingAlert(dotaClient, event.player_id, chattingSeverity)\n }\n }\n }\n\n // Translation logic with debouncing\n if (disableTranslation || authKey.length === 0) {\n return\n }\n\n const translateInChat = getValueOrDefault(\n DBSettings.autoTranslate,\n dotaClient.client.settings,\n dotaClient.client.subscription\n )\n\n const translateOnOverlay = getValueOrDefault(\n DBSettings.translateOnOverlay,\n dotaClient.client.settings,\n dotaClient.client.subscription\n )\n\n if (!translateInChat && !translateOnOverlay) {\n return\n }\n\n // Check global chatter access\n const toLanguage = getValueOrDefault(\n DBSettings.translationLanguage,\n dotaClient.client.settings,\n dotaClient.client.subscription\n )\n\n // Validate and convert language code to DeepL-supported format\n const deeplLanguage = getDeepLLanguage(toLanguage)\n if (deeplLanguage === null || deeplLanguage.length === 0) {\n // Language not supported by DeepL, skip translation to avoid API errors\n return\n }\n const typedLanguage = deeplLanguage as deepl.TargetLanguageCode\n\n const clientKey = dotaClient.client.name\n let buffer = translationBuffers.get(clientKey)\n if (!buffer) {\n buffer = { messages: [], timeout: null }\n translationBuffers.set(clientKey, buffer)\n }\n\n // Get hero name\n const roster = await new MatchDataService(dotaClient.client).resolveRoster()\n const { players } = roster\n let playerIdIndex = players.findIndex((p) => p.slot === event.player_id)\n const foundInMatchPlayers = playerIdIndex !== -1\n if (!foundInMatchPlayers) {\n playerIdIndex = event.player_id\n }\n const heroName = getHeroNameOrColor(players[playerIdIndex]?.heroId ?? 0, playerIdIndex)\n const displayHeroName = resolveTranslatedHeroName({\n foundInMatchPlayers,\n heroName,\n isHighMmr: is8500Plus(dotaClient.client),\n locale: dotaClient.client.locale,\n playerId: event.player_id,\n })\n const speakerLabel = formatTranslatedSpeakerLabel(\n displayHeroName,\n event.player_id,\n dotaClient.client.locale\n )\n\n // Add to buffer\n buffer.messages.push({\n message,\n playerId: event.player_id,\n speakerLabel,\n timestamp: Date.now(),\n })\n\n // Set timeout if not already set\n if (!buffer.timeout) {\n buffer.timeout = setTimeout(async () => {\n const currentBuffer = translationBuffers.get(clientKey)\n if (currentBuffer) {\n await processTranslationBuffer(\n currentBuffer.messages,\n dotaClient,\n translateInChat,\n translateOnOverlay,\n typedLanguage\n )\n translationBuffers.delete(clientKey)\n }\n }, TRANSLATION_DEBOUNCE_TIME)\n }\n }" - } - ], - "message": "async function `handler` has a complexity of 25. Maximum allowed is 20.", - "severity": "error" - } - }, "6b3cf47c3fc9120514e2292d647ed3596ab025e1e5d5f9f8cc30b0bb68be3eb6": { "count": 1, "diagnostic": { @@ -16239,26 +15411,6 @@ "severity": "error" } }, - "6b66936dc1f2dd20347e0a69cc0be9b7939a0e76831cf61427a538e7021b895f": { - "count": 1, - "diagnostic": { - "code": "unicorn(prefer-number-coercion)", - "file": "packages/dota/src/steam/medals.ts", - "labels": [ - { - "context": [ - "if (a.startsWith('#') || b.startsWith('#')) {", - "return Number.parseInt(b.slice(1), 10) - Number.parseInt(a.slice(1), 10)", - "}" - ], - "message": "", - "span": "Number.parseInt(b.slice(1), 10)" - } - ], - "message": "Prefer `Math.trunc(Number(b.slice(1)))`.", - "severity": "error" - } - }, "6b950f8ad52dc4534cdf0b1f1ef6dfd115723428739015d7572a081f7525c6a1": { "count": 1, "diagnostic": { @@ -16451,22 +15603,6 @@ "severity": "error" } }, - "6d2c90e0332f1948e7103cffa41fb8da396696c72570f52ec3352bbaf34f38e9": { - "count": 1, - "diagnostic": { - "code": "vitest(prefer-import-in-mock)", - "file": "packages/twitch-chat/src/__tests__/shared-mocks.ts", - "labels": [ - { - "context": ["", "vi.doMock('ws', () => ({ default: FakeWebSocket }))", ""], - "message": "", - "span": "'ws', () => ({ default: FakeWebSocket })" - } - ], - "message": "Mocked modules must be dynamic imported.", - "severity": "error" - } - }, "6d4b793cea1e17046f4fdf506dbb906e416d569daae9083e225844f571990e7b": { "count": 1, "diagnostic": { @@ -16930,26 +16066,6 @@ "severity": "error" } }, - "70ed631c99c9261ec3a0539bfddcfdbb2a26a7c430f98131306a579f7ef4adc9": { - "count": 1, - "diagnostic": { - "code": "sonarjs(cognitive-complexity)", - "file": "packages/dota/src/dota/gsi-handler.ts", - "labels": [ - { - "context": [ - "", - "async closeBets(winningTeam: Team | null = null, gcData?: MatchClosingDetailsResponse) {", - "if (this.endingBets) {" - ], - "message": "", - "span": "closeBets" - } - ], - "message": "Refactor this function to reduce its Cognitive Complexity from 43 to the 20 allowed.", - "severity": "error" - } - }, "71c75f0799c0a05fd39715b1691f1c72500abe1d7a3ad2e21f223228cd74de4e": { "count": 2, "diagnostic": { @@ -16990,46 +16106,6 @@ "severity": "error" } }, - "72473c13b7d71fb04f12f368c14432f5ff26ae849b3a69b3ea2e5fbc54dd5513": { - "count": 1, - "diagnostic": { - "code": "eslint(no-eq-null)", - "file": "packages/dota/src/dota/gsi-handler.ts", - "labels": [ - { - "context": [ - "", - "if (error !== null || matchData == null) {", - "logger.info('[BETS] Match already closed or not found, skipping early DC winner check', {" - ], - "message": "", - "span": "matchData == null" - } - ], - "message": "Do not use `null` comparisons without type-checking operators.", - "severity": "error" - } - }, - "726ccf572597686e1376846316b9902cf650527939036bca768a2a22c1f2b81a": { - "count": 1, - "diagnostic": { - "code": "unicorn(no-negated-condition)", - "file": "packages/dota/src/dota/gsi-handler.ts", - "labels": [ - { - "context": [ - "(err: unknown, response: MatchMinimalDetailsResponse) => {", - "if (err != null) {", - "reject(err)" - ], - "message": "", - "span": "err != null" - } - ], - "message": "Unexpected negated condition.", - "severity": "error" - } - }, "72987f93edf0e3a87cc512a50d058bf8595f12917a1fb2689bc11e2ec7833b57": { "count": 1, "diagnostic": { @@ -17115,22 +16191,6 @@ "severity": "error" } }, - "72ee66c3bebf5ea2f1ff3305d59ae8d393cc61ff26cafb3da9a59170ca63e95a": { - "count": 1, - "diagnostic": { - "code": "typescript(prefer-nullish-coalescing)", - "file": "packages/shared-utils/src/heartbeat.ts", - "labels": [ - { - "context": ["const now = Date.now()", "if (downSince === null) {", "downSince = now"], - "message": "", - "span": "if (downSince === null) {\n downSince = now\n }" - } - ], - "message": "Prefer using nullish coalescing operator (`??=`) instead of an assignment expression, as it is simpler to read.", - "severity": "error" - } - }, "7311406b850d2f5ec6fe575d590f0caf59b1299c0490f15225f21b5630332489": { "count": 1, "diagnostic": { @@ -17379,26 +16439,6 @@ "severity": "error" } }, - "7547a8e1083e868192e44c1bed3855c9cc324a8f93022a500b19fb9c2877182f": { - "count": 1, - "diagnostic": { - "code": "eslint(no-eq-null)", - "file": "packages/dota/src/dota/gsi-handler.ts", - "labels": [ - { - "context": [ - "} catch (error) {", - "if (steam32Id != null && steam32Id !== 0) {", - "this.client.multiAccount = steam32Id" - ], - "message": "", - "span": "steam32Id != null" - } - ], - "message": "Do not use `null` comparisons without type-checking operators.", - "severity": "error" - } - }, "757298ef12ab9e59d466e5e4f42a85e0e419d8827fc980e3b1f0c5e4f246c149": { "count": 1, "diagnostic": { @@ -17929,22 +16969,6 @@ "severity": "error" } }, - "79b334e7291a00f5bdbed0ae7f7c0c325a76c3094cb27822e69bb532cc41ddba": { - "count": 1, - "diagnostic": { - "code": "promise(no-multiple-resolved)", - "file": "packages/steam/src/steam.ts", - "labels": [ - { - "context": ["}", "resolve(card)", "})"], - "message": "", - "span": "resolve(card)" - } - ], - "message": "Promise should not be resolved multiple times. Promise is potentially resolved on line 753.", - "severity": "error" - } - }, "79febad418662979e39ead4294d2d1e436257862cbc510f2a6bae57799fbd541": { "count": 1, "diagnostic": { @@ -18001,46 +17025,6 @@ "severity": "error" } }, - "7a23eebb87676cb4f8fb6331c055ee5981771e1ddb67bc7bea4d7f7613f68bc4": { - "count": 1, - "diagnostic": { - "code": "eslint(eqeqeq)", - "file": "packages/dota/src/dota/gsi-handler.ts", - "labels": [ - { - "context": [ - "", - "if (matchId == null || matchId.length === 0) {", - "await this.resetClientState()" - ], - "message": "", - "span": "==" - } - ], - "message": "Expected === and instead saw ==", - "severity": "error" - } - }, - "7a3b526a395d751a8c9bb40f897e1abeba73c38dc78fa024c84b0030c04a8ca2": { - "count": 1, - "diagnostic": { - "code": "typescript(prefer-nullish-coalescing)", - "file": "packages/dota/src/dota/events/gsi-events/event.chat_message.ts", - "labels": [ - { - "context": [ - "// Set timeout if not already set", - "if (!buffer.timeout) {", - "buffer.timeout = setTimeout(async () => {" - ], - "message": "", - "span": "if (!buffer.timeout) {\n buffer.timeout = setTimeout(async () => {\n const currentBuffer = translationBuffers.get(clientKey)\n if (currentBuffer) {\n await processTranslationBuffer(\n currentBuffer.messages,\n dotaClient,\n translateInChat,\n translateOnOverlay,\n typedLanguage\n )\n translationBuffers.delete(clientKey)\n }\n }, TRANSLATION_DEBOUNCE_TIME)\n }" - } - ], - "message": "Prefer using nullish coalescing operator (`??=`) instead of an assignment expression, as it is simpler to read.", - "severity": "error" - } - }, "7a42048e3be9d244d761669e6cc6e44e327966ca378333ee20a4d4738c646408": { "count": 1, "diagnostic": { @@ -18081,26 +17065,6 @@ "severity": "error" } }, - "7ac7fb6b5c92d659a0a8dc6076c7861a61b219a9bd6dc1d134b4d7e3830b7ba8": { - "count": 1, - "diagnostic": { - "code": "import(first)", - "file": "packages/twitch-chat/src/index.ts", - "labels": [ - { - "context": [ - "} from '@dotabod/shared-utils'", - "import type { DisableReasonMetadata } from '@dotabod/shared-utils'", - "import { use } from 'i18next'" - ], - "message": "", - "span": "import type { DisableReasonMetadata } from '@dotabod/shared-utils'" - } - ], - "message": "Import statements must come first", - "severity": "error" - } - }, "7aebe00c16485360fd255620ce606535d2c701b59fc322d65004a0063b605043": { "count": 1, "diagnostic": { @@ -18257,26 +17221,6 @@ "severity": "error" } }, - "7b76b0a612e57c3f9c47f131078a21d199b985e187bfb285b933f43a0798f6e5": { - "count": 1, - "diagnostic": { - "code": "import(first)", - "file": "packages/dota/src/index.ts", - "labels": [ - { - "context": [ - "", - "import { redisClient } from './db/redis-instance'", - "import { steamSocket } from './steam/ws'" - ], - "message": "", - "span": "import { redisClient } from './db/redis-instance'" - } - ], - "message": "Import statements must come first", - "severity": "error" - } - }, "7b97df5f1576e41399b3f50c6bd2dc966d143d8125a0fd734eaa3c790d3adfed": { "count": 1, "diagnostic": { @@ -18409,26 +17353,6 @@ "severity": "error" } }, - "7c90f12c67ddef873147048e7ae0cdfacc30d47678da08d1acd8f0655cb75a21": { - "count": 1, - "diagnostic": { - "code": "eslint(no-eq-null)", - "file": "packages/dota/src/dota/gsi-handler.ts", - "labels": [ - { - "context": [ - "", - "if (matchId == null || matchId.length === 0) {", - "await this.resetClientState()" - ], - "message": "", - "span": "matchId == null" - } - ], - "message": "Do not use `null` comparisons without type-checking operators.", - "severity": "error" - } - }, "7ca61158ace44e23d552abc01cc4d8584a5ec86cbc29cb156dd1e153bbcb4412": { "count": 1, "diagnostic": { @@ -18581,26 +17505,6 @@ "severity": "error" } }, - "7d91e9b230d8caadf508358c9561dc738c1312002a73cebe27b0429e6b16a1e2": { - "count": 1, - "diagnostic": { - "code": "eslint(eqeqeq)", - "file": "packages/dota/src/dota/gsi-handler.ts", - "labels": [ - { - "context": [ - "name:", - "this.client.gsi?.player?.name != null && this.client.gsi.player.name.length > 0", - "? this.client.gsi.player.name" - ], - "message": "", - "span": "!=" - } - ], - "message": "Expected !== and instead saw !=", - "severity": "error" - } - }, "7dc3f474fca5825317616f86308b84cbf4d43b04ae40b6985bb09f32ff4e08b4": { "count": 1, "diagnostic": { @@ -18930,22 +17834,6 @@ "severity": "error" } }, - "804de89798b5af3e5ea70bab0c9b8c82dd61f283b06f70278231e718625201f9": { - "count": 2, - "diagnostic": { - "code": "eslint(no-eq-null)", - "file": "packages/dota/src/dota/gsi-handler.ts", - "labels": [ - { - "context": [")", "if (oldBetId != null && oldBetId.length > 0) {", "await supabase"], - "message": "", - "span": "oldBetId != null" - } - ], - "message": "Do not use `null` comparisons without type-checking operators.", - "severity": "error" - } - }, "80b171cd8d4fc8e4f8679aae6dac6aec561ce51b546c5a07102bd25259131371": { "count": 2, "diagnostic": { @@ -19026,26 +17914,6 @@ "severity": "error" } }, - "812c5ef99ffd879fc33e84afec6754d1f676d4e965abaea35125eb5d480866da": { - "count": 1, - "diagnostic": { - "code": "typescript(strict-void-return)", - "file": "packages/dota/src/db/watcher.ts", - "labels": [ - { - "context": [ - "{ event: 'INSERT', schema: 'public', table: 'gift_subscriptions' },", - "async (payload: { new: Tables<'gift_subscriptions'> }) => {", - "const newObj = payload.new" - ], - "message": "", - "span": "async (payload: { new: Tables<'gift_subscriptions'> }) => {\n const newObj = payload.new\n // Fetch the subscription details to get the userId\n const { data: subscriptionData, error: subError } = await supabase\n .from('subscriptions')\n .select('userId')\n .eq('id', newObj.subscriptionId)\n .eq('isGift', true)\n // Use maybeSingle to handle potential null result gracefully\n .maybeSingle()\n\n if (subError || !subscriptionData) {\n logger.error('Error fetching subscription or subscription not found for gift', {\n error: subError,\n giftId: newObj.id,\n subscriptionId: newObj.subscriptionId,\n })\n return\n }\n\n const client = findUser(subscriptionData.userId)\n\n // Only proceed if the client is found and currently considered online\n if (client === null || client.stream_online !== true) {\n logger.info('Gift notification skipped: Client not found or not online', {\n found: client !== null,\n online: client?.stream_online,\n userId: subscriptionData.userId,\n })\n return\n }\n\n try {\n // Calculate duration string\n let durationString = ''\n const giftQuantityRaw = newObj.giftQuantity\n\n // Check if giftQuantityRaw is a valid number representation (string or number) and positive\n const giftQuantityNum = Number(giftQuantityRaw)\n const isValidQuantity = !Number.isNaN(giftQuantityNum) && giftQuantityNum > 0\n\n if (isValidQuantity) {\n const { giftType } = newObj\n\n if (giftType) {\n if (giftType === 'monthly') {\n durationString =\n giftQuantityNum === 1 ? '(1 month)' : `(${giftQuantityNum} months)`\n } else if (giftType === 'annual') {\n durationString = giftQuantityNum === 1 ? '(1 year)' : `(${giftQuantityNum} years)`\n } else if (giftType === 'lifetime') {\n durationString = '(Lifetime)'\n }\n // Add more gift types here if necessary\n } else {\n logger.warn('Gift type missing, cannot determine duration string', {\n giftId: newObj.id,\n giftQuantity: giftQuantityNum,\n })\n }\n } else if (giftQuantityRaw != null) {\n // Log only if it was provided but invalid\n logger.warn('Gift quantity is invalid or not positive', {\n giftId: newObj.id,\n giftQuantity: giftQuantityRaw,\n })\n }\n // If quantity is null/undefined, we just don't add a duration string silently.\n\n // Construct the base message using translation keys\n const baseMessage = newObj.senderName\n ? t('giftSub', {\n lng: client.locale,\n senderName: newObj.senderName,\n })\n : t('giftSubAnonymous', {\n lng: client.locale,\n })\n\n // Prepare optional details parts\n const detailsParts: string[] = []\n if (durationString) {\n detailsParts.push(durationString)\n }\n if (isNonEmptyString(newObj.giftMessage)) {\n // Ensure message is trimmed and quoted\n const trimmedMessage = String(newObj.giftMessage).trim()\n if (trimmedMessage.length > 0) {\n detailsParts.push(`\"${trimmedMessage}\"`)\n }\n }\n\n // Combine base message and details with proper spacing\n let fullMessage = baseMessage\n if (detailsParts.length > 0) {\n fullMessage += ` ${detailsParts.join(' ')}`\n }\n\n // Send notification message to chat\n // Add logging\n logger.info(`Sending gift notification: ${fullMessage}`)\n chatClient.say(client.name, fullMessage)\n } catch (error) {\n logger.error('Error constructing or sending gift notification to chat', {\n error,\n giftId: newObj.id,\n userId: client.token,\n })\n }\n }" - } - ], - "message": "Async function used in a context where a void function is expected.", - "severity": "error" - } - }, "812cbcd42f8350b2bfa6c13d8d87c1b78eb9f60afdcff4b854d39c2adde0094a": { "count": 1, "diagnostic": { @@ -19675,26 +18543,6 @@ "severity": "error" } }, - "85dda8cbe3f3859e8691b2fec1df3fa967349a25f9f613d28da4acf016a46d3d": { - "count": 1, - "diagnostic": { - "code": "eslint(eqeqeq)", - "file": "packages/dota/src/dota/gsi-handler.ts", - "labels": [ - { - "context": [ - "", - "if (!betsEnabled || predictionId == null || predictionId.length === 0) {", - "logger.debug('Bets are not enabled or no prediction was opened, stopping here', {" - ], - "message": "", - "span": "==" - } - ], - "message": "Expected === and instead saw ==", - "severity": "error" - } - }, "85e74a68fae6af0b8c0be9b0d42499ccccef9f6e186eb21640bc88bf26685777": { "count": 1, "diagnostic": { @@ -19839,26 +18687,6 @@ "severity": "error" } }, - "871f4883213dc38b72d1402b50fa541c21d35cf2f1ebe84f13f45fb52ce241d4": { - "count": 1, - "diagnostic": { - "code": "eslint(no-eq-null)", - "file": "packages/dota/src/dota/gsi-handler.ts", - "labels": [ - { - "context": [ - ".update({", - "...(snapshotMatch.hero_name != null && snapshotMatch.hero_name.length > 0", - "? { hero_name: snapshotMatch.hero_name }" - ], - "message": "", - "span": "snapshotMatch.hero_name != null" - } - ], - "message": "Do not use `null` comparisons without type-checking operators.", - "severity": "error" - } - }, "871fb49a64024fe0d71b91bb2ca73d324f5896c495a97cc7edf21d6027d6b3ad": { "count": 1, "diagnostic": { @@ -20116,26 +18944,6 @@ "severity": "error" } }, - "890516f6113452cd05bc8c4444a8d6c2d049ad827b711d6f909d8d3a57135b91": { - "count": 1, - "diagnostic": { - "code": "import(first)", - "file": "packages/dota/src/index.ts", - "labels": [ - { - "context": [ - "import { redisClient } from './db/redis-instance'", - "import { steamSocket } from './steam/ws'", - "" - ], - "message": "", - "span": "import { steamSocket } from './steam/ws'" - } - ], - "message": "Import statements must come first", - "severity": "error" - } - }, "89119275a56edbece1d76127e16aaa1af4f8a64c26a23c00ba8b293f04a3c359": { "count": 1, "diagnostic": { @@ -20216,35 +19024,6 @@ "severity": "error" } }, - "89bba499fd7a7bdbf1c92d7afa591ee68ab688aa42f12f7f54ede367f18d4e4f": { - "count": 1, - "diagnostic": { - "code": "typescript(no-unnecessary-type-conversion)", - "file": "packages/dota/src/dota/index.ts", - "labels": [ - { - "context": [ - "const isDirectory = lstatSync(joinedPath).isDirectory()", - "return !!isDirectory", - "})," - ], - "message": "", - "span": "isDirectory" - }, - { - "context": [ - "const isDirectory = lstatSync(joinedPath).isDirectory()", - "return !!isDirectory", - "})," - ], - "message": "", - "span": "!!" - } - ], - "message": "This type conversion does not change the type or value of the expression.", - "severity": "error" - } - }, "8a1dc7bf542c8a6aa819a2a5e566bbe9251f1ffc9dc9fc732a6a88497e1537b7": { "count": 1, "diagnostic": { @@ -20714,26 +19493,6 @@ "severity": "error" } }, - "8d14763da9eb3133e7011ce5b6f14070e438b0e55fe63c0cc22bdaddc5131aef": { - "count": 1, - "diagnostic": { - "code": "eslint(no-eq-null)", - "file": "packages/dota/src/dota/gsi-handler.ts", - "labels": [ - { - "context": [ - "// Check if this bet for this match id already exists, dont continue if it does", - "if (bet?.[0]?.id != null && bet[0].id.length > 0) {", - "logger.info('[BETS] Found a bet in the database', { id: bet?.[0]?.id })" - ], - "message": "", - "span": "bet?.[0]?.id != null" - } - ], - "message": "Do not use `null` comparisons without type-checking operators.", - "severity": "error" - } - }, "8d1cb09da5d891d29833e132e3deacd38f6844c54ed788cb32b71ae75c1f0b8a": { "count": 1, "diagnostic": { @@ -21059,35 +19818,6 @@ "severity": "error" } }, - "8e3b150981f43a2c7d83c3fbb0ce4a00aed2d337b6245b9f4ae58aa68fcfe235": { - "count": 1, - "diagnostic": { - "code": "typescript(no-unnecessary-type-conversion)", - "file": "packages/dota/src/dota/events/gsi-events/newdata.ts", - "labels": [ - { - "context": [ - "refetchCards: true,", - "steam_server_id: currentSteamServerId.toString(),", - "token: client.token," - ], - "message": "", - "span": "currentSteamServerId" - }, - { - "context": [ - "refetchCards: true,", - "steam_server_id: currentSteamServerId.toString(),", - "token: client.token," - ], - "message": "", - "span": "toString()" - } - ], - "message": "This type conversion does not change the type or value of the expression.", - "severity": "error" - } - }, "8e53be8774498d7b0123e8eeb2d70b3d2f7d12127123758b9861f13d745bf1b8": { "count": 1, "diagnostic": { @@ -21240,26 +19970,6 @@ "severity": "error" } }, - "8f6c21d13da39f6015fd26e1c4cf8715f5d4654a01a2e5a8c89e8909264eb55b": { - "count": 1, - "diagnostic": { - "code": "import(first)", - "file": "packages/twitch-chat/src/index.ts", - "labels": [ - { - "context": [ - "import { sendTwitchChatMessage } from './handle-chat'", - "import { io, setupSocketServer } from './utils/socket-manager'", - "" - ], - "message": "", - "span": "import { io, setupSocketServer } from './utils/socket-manager'" - } - ], - "message": "Import statements must come first", - "severity": "error" - } - }, "8f6cec7e5f5aa29999cdf86c088e1a4fb3f2e001643eaa1dce1d7eeca8b7e4e3": { "count": 1, "diagnostic": { @@ -21280,22 +19990,6 @@ "severity": "error" } }, - "8f82f674e2527d05ef356d25fa9f6ea7467a6d1f649f9e47ed0f0ae3708907c7": { - "count": 1, - "diagnostic": { - "code": "typescript(prefer-promise-reject-errors)", - "file": "packages/dota/src/dota/gsi-handler.ts", - "labels": [ - { - "context": ["if (err != null) {", "reject(err)", "} else {"], - "message": "", - "span": "reject(err)" - } - ], - "message": "Expected the Promise rejection reason to be an Error.", - "severity": "error" - } - }, "8f9c0629df895db0ecd8606d37203f970c13fb4860c611ec8ea0a6daaffe7689": { "count": 1, "diagnostic": { @@ -21372,6 +20066,26 @@ "severity": "error" } }, + "9052be4c8afc10c551dd8d70bdd361454a152964bd8f5d68bbf70fab45f135ac": { + "count": 1, + "diagnostic": { + "code": "vitest(prefer-import-in-mock)", + "file": "packages/twitch-chat/src/__tests__/shared-mocks.ts", + "labels": [ + { + "context": [ + "", + "vi.doMock('ws', () => ({ WebSocket: FakeWebSocket, default: FakeWebSocket }))", + "" + ], + "message": "", + "span": "'ws', () => ({ WebSocket: FakeWebSocket, default: FakeWebSocket })" + } + ], + "message": "Mocked modules must be dynamic imported.", + "severity": "error" + } + }, "90574c50362f17c198cf8b9785da73c7e15706924ba5c357de85a66f736cd083": { "count": 1, "diagnostic": { @@ -21492,26 +20206,6 @@ "severity": "error" } }, - "90e91c217047294a7521366018d8b2035906de349c437edf6ffc031e89ea5820": { - "count": 1, - "diagnostic": { - "code": "anti-slop(no-conditional-empty-object-spread)", - "file": "packages/dota/src/dota/gsi-handler.ts", - "labels": [ - { - "context": [ - ".update({", - "...(snapshotMatch.hero_name != null && snapshotMatch.hero_name.length > 0", - "? { hero_name: snapshotMatch.hero_name }" - ], - "message": "", - "span": "...(snapshotMatch.hero_name != null && snapshotMatch.hero_name.length > 0\n ? { hero_name: snapshotMatch.hero_name }\n : {})" - } - ], - "message": "This conditional spread hides property omission behind an empty object. Build the object in separate statements and add the property only when present.", - "severity": "error" - } - }, "90ec271589ff1cddfd695bb558b913b5139c777589b910e33239548a3a039c05": { "count": 1, "diagnostic": { @@ -21648,26 +20342,6 @@ "severity": "error" } }, - "91e2ca42d5f04e3153c9cc9a0cf3bed4f589858570a9294d22a5565a1b01a04c": { - "count": 1, - "diagnostic": { - "code": "typescript(prefer-nullish-coalescing)", - "file": "packages/dota/src/db/get-db-user.ts", - "labels": [ - { - "context": [ - "const activeSubscription =", - "user.subscriptions.find((sub: SubscriptionRow) => isSubscriptionActive(sub)) ||", - "user.subscriptions[0]" - ], - "message": "", - "span": "||" - } - ], - "message": "Prefer using nullish coalescing operator (`??`) instead of a logical or (`||`), as it is a safer operator.", - "severity": "error" - } - }, "91e73f8b35d0a6463619ce4c28b5e019f3852e62578a40f54eaa63dc80ce00f7": { "count": 1, "diagnostic": { @@ -21972,35 +20646,6 @@ "severity": "error" } }, - "948bb763e462d291416b8aca6830203fdc9502115512a8751975241119a4a03c": { - "count": 1, - "diagnostic": { - "code": "typescript(no-unnecessary-type-conversion)", - "file": "packages/dota/src/dota/get-stream-delay.ts", - "labels": [ - { - "context": [ - ") {", - "return Number(getValueOrDefault(DBSettings.streamDelay, settings, subscription)) + GLOBAL_DELAY", - "}" - ], - "message": "", - "span": "getValueOrDefault(DBSettings.streamDelay, settings, subscription)" - }, - { - "context": [ - ") {", - "return Number(getValueOrDefault(DBSettings.streamDelay, settings, subscription)) + GLOBAL_DELAY", - "}" - ], - "message": "", - "span": "Number" - } - ], - "message": "This type conversion does not change the type or value of the expression.", - "severity": "error" - } - }, "948d5eef0629e6e0d74396ca7eaaf49a52a5721cbd2e8a5b17c1615173cae89f": { "count": 1, "diagnostic": { @@ -22297,46 +20942,6 @@ "severity": "error" } }, - "9685f6392c79ab8590ee1504278de941309e2727b15cb9dc435466e0375fe12a": { - "count": 1, - "diagnostic": { - "code": "typescript(strict-void-return)", - "file": "packages/dota/src/dota/events/gsi-events/event.chat_message.ts", - "labels": [ - { - "context": [ - "if (!buffer.timeout) {", - "buffer.timeout = setTimeout(async () => {", - "const currentBuffer = translationBuffers.get(clientKey)" - ], - "message": "", - "span": "async () => {\n const currentBuffer = translationBuffers.get(clientKey)\n if (currentBuffer) {\n await processTranslationBuffer(\n currentBuffer.messages,\n dotaClient,\n translateInChat,\n translateOnOverlay,\n typedLanguage\n )\n translationBuffers.delete(clientKey)\n }\n }" - } - ], - "message": "Async function used in a context where a void function is expected.", - "severity": "error" - } - }, - "969006eab172e79740df09ab52b05eedc2f8e698f6c31d0cacb396fe5fa8a638": { - "count": 1, - "diagnostic": { - "code": "import(first)", - "file": "packages/steam/src/index.ts", - "labels": [ - { - "context": [ - "import { initSpectatorProtobuff } from './init-spectator-protobuff'", - "import { getSocketIoServer } from './socket-server'", - "import Dota, { GetRealTimeStats } from './steam'" - ], - "message": "", - "span": "import { getSocketIoServer } from './socket-server'" - } - ], - "message": "Import statements must come first", - "severity": "error" - } - }, "969d3ca9b9311251a549896b57270830e70a123c5f1c9557f158f44683ccae1a": { "count": 1, "diagnostic": { @@ -22453,6 +21058,46 @@ "severity": "error" } }, + "976b0f4a35421952a1343713147c45b20c77f559b698d980aa32a3780421be43": { + "count": 1, + "diagnostic": { + "code": "eslint(complexity)", + "file": "packages/dota/src/dota/gsi-handler.ts", + "labels": [ + { + "context": [ + "// 4 Then, tell twitch to close bets based on win result", + "async openBets(client: SocketClient) {", + "if (this.openingBets) {" + ], + "message": "", + "span": "(client: SocketClient) {\n if (this.openingBets) {\n // console.log('still opening')\n return\n }\n\n // Why open if not playing?\n if (client.gsi?.player?.activity !== 'playing') {\n // console.log(`if (client.gsi?.player?.activity !== 'playing') {`)\n return\n }\n\n // Why open if won?\n if (client.gsi.map?.win_team !== 'none') {\n // console.log(`if (client.gsi.map?.win_team !== 'none') {`)\n return\n }\n\n // We at least want the hero name so it can go in the twitch bet title\n const heroName = client.gsi.hero?.name\n if (heroName === null || heroName === undefined || heroName.length === 0) {\n // console.log(`if (!client.gsi.hero?.name || !client.gsi.hero.name.length) {`)\n return\n }\n\n // It's not a live game, so we don't want to open bets nor save it to DB\n if (!client.gsi.map?.matchid || client.gsi.map?.matchid === '0') {\n // console.log(`if (!client.gsi.map.matchid || client.gsi.map.matchid === '0') {`)\n return\n }\n\n // Snapshot validated matchid + hero name now; openTheBet runs after the\n // stream delay and `client.gsi` can be cleared by then (e.g. draft abandon\n // + requeue triggers resetClientState). Without the snapshot, openTheBet\n // would insert a `matches` row with an empty matchId and open a Twitch\n // prediction titled \"Will we win with \".\n const validatedMatchId = client.gsi.map.matchid\n const validatedHeroName = heroName\n // team_name is set on player at this point because activity === 'playing'\n // (checked above). Capture it for the same reason as matchId/heroName so\n // the matches row records the team the streamer was actually on.\n const validatedMyTeam = client.gsi.player?.team_name ?? ''\n\n const matchId = (await redisClient.client.get(`${client.token}:matchId`)) ?? undefined\n\n if (matchId !== undefined && matchId.length > 0 && matchId !== validatedMatchId) {\n // Check if there's a pending manual resolution for the old match\n const pendingResolution = await redisClient.client.get(\n `${client.token}:pendingManualResolution`\n )\n if (pendingResolution !== null && pendingResolution.length > 0) {\n try {\n const { matchId: pendingMatchId } = JSON.parse(pendingResolution)\n\n // If the pending match is the old one, refund it and notify\n if (pendingMatchId === matchId) {\n logger.info('[BETS] Expiring pending manual resolution - new match joined', {\n name: client.name,\n newMatchId: client.gsi.map.matchid,\n oldMatchId: matchId,\n })\n\n const betsEnabled = getValueOrDefault(\n DBSettings.bets,\n client.settings,\n client.subscription\n )\n if (betsEnabled) {\n const predictionResponse = await supabase\n .from('matches')\n .select('predictionId')\n .eq('matchId', matchId.toString())\n .eq('userId', client.token)\n .is('won', null)\n .single()\n if (\n predictionResponse.data?.predictionId !== null &&\n predictionResponse.data?.predictionId !== undefined &&\n predictionResponse.data.predictionId.length > 0\n ) {\n await refundTwitchBet(this.getChannelId(), predictionResponse.data.predictionId)\n\n const tellChatBets = getValueOrDefault(\n DBSettings.tellChatBets,\n client.settings,\n client.subscription\n )\n if (tellChatBets && client.stream_online) {\n say(\n client,\n t('bets.manualResolutionExpired', {\n emote: 'FeelsBadMan',\n lng: client.locale,\n })\n )\n }\n }\n }\n }\n } catch (error) {\n logger.error('[BETS] Error handling pending manual resolution expiration', { error })\n }\n }\n\n // We have the wrong matchid, reset vars and start over\n logger.info('[BETS] openBets resetClientState because stuck on old match id', {\n gsiMatchId: client.gsi.map.matchid,\n name: client.name,\n playingMatchId: matchId,\n steam32Id: client.steam32Id,\n steamFromGSI: client.gsi.player?.steamid,\n token: client.token,\n })\n await this.resetClientState()\n return\n }\n\n // The bet was already made\n if (Number(matchId) >= 0) {\n return\n }\n\n logger.info('[BETS] Begin opening bets', {\n hero: heroName,\n matchId: client.gsi.map.matchid,\n name: client.name,\n playingMatchId: matchId,\n })\n\n this.openingBets = true\n\n const { data: bet } = await supabase\n .from('matches')\n .select('matchId, myTeam, id')\n .eq('matchId', client.gsi.map.matchid)\n .eq('userId', client.token)\n .is('won', null)\n\n try {\n // Saving to redis so we don't have to query the db again\n await redisClient.client.set(`${client.token}:matchId`, client.gsi.map.matchid)\n\n const playingTeam = bet?.[0]?.myTeam ?? client.gsi?.player?.team_name ?? ''\n await redisClient.client.set(`${client.token}:playingTeam`, playingTeam)\n await redisClient.client.set(`${client.token}:playingHero`, client.gsi.hero?.name ?? '')\n } catch (error) {\n logger.error('Error while saving data to Redis:', {\n client: client.name,\n error,\n matchId: client.gsi.map.matchid,\n token: client.token,\n })\n }\n\n // Check if this bet for this match id already exists, dont continue if it does\n if (bet?.[0]?.id !== null && bet?.[0]?.id !== undefined && bet[0].id.length > 0) {\n logger.info('[BETS] Found a bet in the database', { id: bet?.[0]?.id })\n this.openingBets = false\n return\n }\n\n if (!client.stream_online) {\n logger.info('[BETS] Not opening bets bc stream is offline for', {\n name: client.name,\n })\n this.openingBets = false\n return\n }\n\n if (!client.token) {\n this.openingBets = false\n return\n }\n\n this.openTheBetTaskId = delayedQueue.addTask(\n getStreamDelay(client.settings, client.subscription),\n async () => {\n await this.openTheBet(validatedMatchId, validatedHeroName, validatedMyTeam)\n }\n )\n\n // .catch((e: any) => {\n // logger.error(`[BETS] Could not add bet to channel`, {\n // channel: client.name,\n // e: e?.message || e,\n // })\n // this.openingBets = false\n // })\n\n // .catch((e: any) => {\n // logger.error('[BETS] Error opening bet', {\n // matchId: client?.gsi?.map?.matchid || '',\n // channel,\n // e: e?.message || e,\n // })\n // if ((e?.message || e).includes('error')) {\n // this.openingBets = false\n // }\n // })\n }" + } + ], + "message": "async method `openBets` has a complexity of 55. Maximum allowed is 20.", + "severity": "error" + } + }, + "978b89a55a4a5b89a2261fba6c9f127d6f3f949283f61829a48dfd6ec63de00e": { + "count": 1, + "diagnostic": { + "code": "anti-slop(require-safety-comment-for-type-assertion)", + "file": "packages/dota/src/dota/lib/capture-cosmetics.ts", + "labels": [ + { + "context": [ + "heroName: getHeroNameOrColor(heroId),", + "items: items as unknown as Json,", + "matchId," + ], + "message": "", + "span": "items as unknown" + } + ], + "message": "This type assertion has no `SAFETY:` justification. State the checked invariant immediately before the assertion or its containing statement.", + "severity": "error" + } + }, "97a07b6521595c10b6a01956bb062216f22f3c4cf6bb8369d28e820e4bcab33c": { "count": 1, "diagnostic": { @@ -22569,6 +21214,26 @@ "severity": "error" } }, + "983d131140140323c9d1fc04db3525d3435bedc1ba588f3cd5513a465c24551e": { + "count": 1, + "diagnostic": { + "code": "anti-slop(require-safety-comment-for-type-assertion)", + "file": "packages/dota/src/dota/lib/capture-cosmetics.ts", + "labels": [ + { + "context": [ + "heroName: getHeroNameOrColor(heroId),", + "items: items as unknown as Json,", + "matchId," + ], + "message": "", + "span": "items as unknown as Json" + } + ], + "message": "This type assertion has no `SAFETY:` justification. State the checked invariant immediately before the assertion or its containing statement.", + "severity": "error" + } + }, "98561c21238435f56fbdc8c1deec2032b5b2352a1271225b8e317ed320afbd4a": { "count": 1, "diagnostic": { @@ -22761,35 +21426,6 @@ "severity": "error" } }, - "9a0ed13e9ca039735e9e43a2ed679311ea906bd106fc120d7507b16029cc2710": { - "count": 1, - "diagnostic": { - "code": "typescript(no-unnecessary-type-conversion)", - "file": "packages/dota/src/dota/events/gsi-events/event.roshan_killed.ts", - "labels": [ - { - "context": [ - "const redisJson = await redisClient.getJson(`${dotaClient.getToken()}:roshan`)", - "const count = redisJson ? Number(redisJson.count) : 0", - "const res = {" - ], - "message": "", - "span": "redisJson.count" - }, - { - "context": [ - "const redisJson = await redisClient.getJson(`${dotaClient.getToken()}:roshan`)", - "const count = redisJson ? Number(redisJson.count) : 0", - "const res = {" - ], - "message": "", - "span": "Number" - } - ], - "message": "This type conversion does not change the type or value of the expression.", - "severity": "error" - } - }, "9a1ae9f6776e24e2fefbfc9b68c336d83e746dab670064f077e526c03bd7672b": { "count": 1, "diagnostic": { @@ -23367,26 +22003,6 @@ "severity": "error" } }, - "9ec3f6870fbfaf8f570e516acac94b7afa0782bf51329820cefc1b9d514b616e": { - "count": 1, - "diagnostic": { - "code": "typescript(prefer-nullish-coalescing)", - "file": "packages/shared-utils/src/disableReason/service.ts", - "labels": [ - { - "context": [ - "auto_disabled_by: 'system',", - "disable_metadata: metadata || {},", - "disable_reason: reason," - ], - "message": "", - "span": "||" - } - ], - "message": "Prefer using nullish coalescing operator (`??`) instead of a logical or (`||`), as it is a safer operator.", - "severity": "error" - } - }, "9eddd721dd7c5bb57de83e003fa5d780af23c8e0b051a78273c712edfe9f6304": { "count": 1, "diagnostic": { @@ -23407,42 +22023,6 @@ "severity": "error" } }, - "9efb2fb41887288ec5716dccc45845cefe0ea952215b02f6ccab1cb32761da31": { - "count": 1, - "diagnostic": { - "code": "import(first)", - "file": "packages/dota/src/steam/__tests__/player-summaries.test.ts", - "labels": [ - { - "context": ["", "import { getSteamPlayerSummaries } from '../player-summaries.ts'", ""], - "message": "", - "span": "import { getSteamPlayerSummaries } from '../player-summaries.ts'" - } - ], - "message": "Import statements must come first", - "severity": "error" - } - }, - "9f0678fd84c2926ebe5bd22971dbaa39edf618bf9b4787950ba818907a8ca68b": { - "count": 1, - "diagnostic": { - "code": "eslint(eqeqeq)", - "file": "packages/dota/src/dota/gsi-handler.ts", - "labels": [ - { - "context": [ - "const steamId = this.client.gsi?.player?.steamid", - "if (this.creatingSteamAccount || steamId == null || steamId.length === 0) {", - "return" - ], - "message": "", - "span": "==" - } - ], - "message": "Expected === and instead saw ==", - "severity": "error" - } - }, "9f0cb9a0bee3942699b2663f287dc019e4284e4d6f0822c161f361a60048d81b": { "count": 1, "diagnostic": { @@ -23639,6 +22219,26 @@ "severity": "error" } }, + "a024520d6cf4911b341376fa0ff2f63b21e01330c5a57f53022745a1b5a73a2d": { + "count": 1, + "diagnostic": { + "code": "eslint(complexity)", + "file": "packages/dota/src/db/watcher.ts", + "labels": [ + { + "context": [ + "{ event: 'INSERT', schema: 'public', table: 'gift_subscriptions' },", + "async (payload: { new: Tables<'gift_subscriptions'> }) => {", + "const newObj = payload.new" + ], + "message": "", + "span": "async (payload: { new: Tables<'gift_subscriptions'> }) => {\n const newObj = payload.new\n // Fetch the subscription details to get the userId\n const { data: subscriptionData, error: subError } = await supabase\n .from('subscriptions')\n .select('userId')\n .eq('id', newObj.subscriptionId)\n .eq('isGift', true)\n // Use maybeSingle to handle potential null result gracefully\n .maybeSingle()\n\n if (subError || !subscriptionData) {\n logger.error('Error fetching subscription or subscription not found for gift', {\n error: subError,\n giftId: newObj.id,\n subscriptionId: newObj.subscriptionId,\n })\n return\n }\n\n const client = findUser(subscriptionData.userId)\n\n // Only proceed if the client is found and currently considered online\n if (client === null || client.stream_online !== true) {\n logger.info('Gift notification skipped: Client not found or not online', {\n found: client !== null,\n online: client?.stream_online,\n userId: subscriptionData.userId,\n })\n return\n }\n\n try {\n // Calculate duration string\n let durationString = ''\n const giftQuantityRaw = newObj.giftQuantity\n\n // Check if giftQuantityRaw is a valid number representation (string or number) and positive\n const giftQuantityNum = Number(giftQuantityRaw)\n const isValidQuantity = !Number.isNaN(giftQuantityNum) && giftQuantityNum > 0\n\n if (isValidQuantity) {\n const { giftType } = newObj\n\n if (giftType) {\n if (giftType === 'monthly') {\n durationString =\n giftQuantityNum === 1 ? '(1 month)' : `(${giftQuantityNum} months)`\n } else if (giftType === 'annual') {\n durationString = giftQuantityNum === 1 ? '(1 year)' : `(${giftQuantityNum} years)`\n } else if (giftType === 'lifetime') {\n durationString = '(Lifetime)'\n }\n // Add more gift types here if necessary\n } else {\n logger.warn('Gift type missing, cannot determine duration string', {\n giftId: newObj.id,\n giftQuantity: giftQuantityNum,\n })\n }\n } else if (giftQuantityRaw !== null && giftQuantityRaw !== undefined) {\n // Log only if it was provided but invalid\n logger.warn('Gift quantity is invalid or not positive', {\n giftId: newObj.id,\n giftQuantity: giftQuantityRaw,\n })\n }\n // If quantity is null/undefined, we just don't add a duration string silently.\n\n // Construct the base message using translation keys\n const baseMessage = newObj.senderName\n ? t('giftSub', {\n lng: client.locale,\n senderName: newObj.senderName,\n })\n : t('giftSubAnonymous', {\n lng: client.locale,\n })\n\n // Prepare optional details parts\n const detailsParts: string[] = []\n if (durationString) {\n detailsParts.push(durationString)\n }\n if (isNonEmptyString(newObj.giftMessage)) {\n // Ensure message is trimmed and quoted\n const trimmedMessage = String(newObj.giftMessage).trim()\n if (trimmedMessage.length > 0) {\n detailsParts.push(`\"${trimmedMessage}\"`)\n }\n }\n\n // Combine base message and details with proper spacing\n let fullMessage = baseMessage\n if (detailsParts.length > 0) {\n fullMessage += ` ${detailsParts.join(' ')}`\n }\n\n // Send notification message to chat\n // Add logging\n logger.info(`Sending gift notification: ${fullMessage}`)\n chatClient.say(client.name, fullMessage)\n } catch (error) {\n logger.error('Error constructing or sending gift notification to chat', {\n error,\n giftId: newObj.id,\n userId: client.token,\n })\n }\n }" + } + ], + "message": "async function has a complexity of 22. Maximum allowed is 20.", + "severity": "error" + } + }, "a03361b674712046efdabdb9652d6a27c378debc468275bebedef05e82b92d15": { "count": 1, "diagnostic": { @@ -23787,26 +22387,6 @@ "severity": "error" } }, - "a1b00aca69fb27301b2015e6410a9f317cde0ccb184212417982d8f46b435f44": { - "count": 1, - "diagnostic": { - "code": "eslint(no-eq-null)", - "file": "packages/dota/src/dota/gsi-handler.ts", - "labels": [ - { - "context": [ - "steam32Id = steamID64toSteamID32(steamId)", - "if (steam32Id == null || steam32Id === 0) {", - "this.creatingSteamAccount = false" - ], - "message": "", - "span": "steam32Id == null" - } - ], - "message": "Do not use `null` comparisons without type-checking operators.", - "severity": "error" - } - }, "a1cb3df806690a7883e1d2ad787d8cf3954a3f48ce3ee21bbbd20e2f2a569ec5": { "count": 2, "diagnostic": { @@ -24282,26 +22862,6 @@ "severity": "error" } }, - "a356920723c64683102a4defba0e0b622a9cd21d9210ac7dee617bc150e4b677": { - "count": 1, - "diagnostic": { - "code": "import(first)", - "file": "packages/steam/src/index.ts", - "labels": [ - { - "context": [ - "import { startHeartbeat } from '@dotabod/shared-utils'", - "import type { Socket } from 'socket.io'", - "" - ], - "message": "", - "span": "import type { Socket } from 'socket.io'" - } - ], - "message": "Import statements must come first", - "severity": "error" - } - }, "a37007c2d7c2c5d548ab6acb9a43adb0533788c2fae6c2663f6e2038e19388e8": { "count": 1, "diagnostic": { @@ -24414,6 +22974,26 @@ "severity": "error" } }, + "a44a1eb715c690264c060416cdf7b7b22bddff42ab4c3869c13d52e0841b92d6": { + "count": 1, + "diagnostic": { + "code": "eslint(complexity)", + "file": "packages/dota/src/dota/gsi-handler.ts", + "labels": [ + { + "context": [ + "// so add to their list of steam accounts", + "async updateSteam32Id() {", + "const steamId = this.client.gsi?.player?.steamid" + ], + "message": "", + "span": "() {\n const steamId = this.client.gsi?.player?.steamid\n if (\n this.creatingSteamAccount ||\n steamId === null ||\n steamId === undefined ||\n steamId.length === 0\n ) {\n return\n }\n\n // Set a flag to prevent concurrent calls\n this.creatingSteamAccount = true\n let steam32Id: number | null | undefined\n\n try {\n steam32Id = steamID64toSteamID32(steamId)\n if (steam32Id === null || steam32Id === undefined || steam32Id === 0) {\n this.creatingSteamAccount = false\n return\n }\n\n // User already has a steam32Id and its saved to the `steam_accounts` table\n const foundAct = this.client.SteamAccount.find((act) => act.steam32Id === steam32Id)\n if (foundAct) {\n // Logged into a new steam account on the same twitch channel\n Object.assign(this.client, {\n mmr: foundAct.mmr,\n multiAccount: undefined,\n steam32Id,\n })\n this.multiAccountRevalidatedAt = undefined\n this.emitBadgeUpdate()\n return\n }\n\n const isMultiAccount = this.client.multiAccount === steam32Id\n if (\n isMultiAccount &&\n this.multiAccountRevalidatedAt !== undefined &&\n Date.now() - this.multiAccountRevalidatedAt < MULTI_ACCOUNT_REVALIDATION_COOLDOWN_MS\n ) {\n return\n }\n\n // Continue to create this act in db\n // Default to the mmr from `users` table for this brand new steam account\n // this.getMmr() should return mmr from `user` table on new accounts without steam acts\n const mmr = this.client.SteamAccount.length ? 0 : this.getMmr()\n\n this.creatingSteamAccount = true\n const { data: res, error } = await supabase\n .from('steam_accounts')\n .select('id, userId, mmr, connectedUserIds')\n .eq('steam32Id', steam32Id)\n .maybeSingle()\n\n if (error) {\n if (isMultiAccount) {\n this.multiAccountRevalidatedAt = Date.now()\n }\n logger.error('Error in updateSteam32Id', { error, name: this.client.name })\n return\n }\n\n if (res?.id !== null && res?.id !== undefined && res.id.length > 0) {\n await this.handleExistingAccount(res, steam32Id)\n } else {\n const created = await this.createNewSteamAccount(mmr, steam32Id)\n if (!created) {\n this.client.multiAccount = steam32Id\n this.multiAccountRevalidatedAt = Date.now()\n }\n }\n\n this.creatingSteamAccount = false\n } catch (error) {\n if (steam32Id !== null && steam32Id !== undefined && steam32Id !== 0) {\n this.client.multiAccount = steam32Id\n this.multiAccountRevalidatedAt = Date.now()\n }\n logger.error('Error in updateSteam32Id', { error, name: this.client.name })\n } finally {\n // Ensure flag is reset even if an error occurs\n this.creatingSteamAccount = false\n }\n }" + } + ], + "message": "async method `updateSteam32Id` has a complexity of 27. Maximum allowed is 20.", + "severity": "error" + } + }, "a4697b12e3a5e9864c284bb4cd12536f035da31ed5c2d0fdfbb42293fe6c40b7": { "count": 1, "diagnostic": { @@ -24506,26 +23086,6 @@ "severity": "error" } }, - "a4b9dd89f2222a5605006327ef9533ae144fa57cd6c19898979efbd560fd7e62": { - "count": 1, - "diagnostic": { - "code": "eslint(complexity)", - "file": "packages/dota/src/dota/gsi-handler.ts", - "labels": [ - { - "context": [ - "", - "private async checkEarlyDCWinner(matchId: string | number) {", - "// Prevent multiple concurrent early DC winner checks" - ], - "message": "", - "span": "(matchId: string | number) {\n // Prevent multiple concurrent early DC winner checks\n if (this.checkingEarlyDCWinner) {\n logger.info('[BETS] Already checking early DC winner, skipping duplicate call', {\n matchId,\n name: this.client.name,\n })\n return\n }\n\n this.checkingEarlyDCWinner = true\n\n // Check if the bet for this match is already closed in the database\n const { data: matchData, error } = await supabase\n .from('matches')\n .select('won')\n .is('won', null)\n .eq('matchId', matchId.toString())\n .eq('userId', this.client.token)\n .single()\n\n if (error !== null || matchData == null) {\n logger.info('[BETS] Match already closed or not found, skipping early DC winner check', {\n error: error?.message,\n matchId,\n name: this.client.name,\n })\n this.checkingEarlyDCWinner = false\n return\n }\n\n logger.info('[BETS] Streamer exited the match before it ended with a winner', {\n endingBets: this.endingBets,\n matchId,\n name: this.client.name,\n openingBets: this.openingBets,\n })\n\n // Persist a snapshot so unresolved-match messages can show hero / KDA /\n // score / length. The live packet that triggered the DC has usually shed\n // these values (hero/player empty, scores back to 0), so the merge prefers\n // the cached last-in-game snapshot for this match.\n const cached =\n this.lastInGameSnapshot?.matchId === matchId.toString() ? this.lastInGameSnapshot : null\n const snapshotMatch = buildUnresolvedSnapshot({\n cached,\n gsi: this.client.gsi,\n matchId: matchId.toString(),\n now: new Date(),\n })\n // Only write while still unresolved and only if no snapshot exists yet, so a\n // concurrent resolution can't be clobbered and re-entry can't reset updated_at\n // (the reminder's 10-minute anchor). hero_name is set at bet-open and again\n // on hero swap — only overwrite it when we actually have one.\n const kdaForDb = {\n assists: snapshotMatch.kda?.assists ?? null,\n deaths: snapshotMatch.kda?.deaths ?? null,\n duration: snapshotMatch.kda?.duration ?? null,\n kills: snapshotMatch.kda?.kills ?? null,\n }\n await supabase\n .from('matches')\n .update({\n ...(snapshotMatch.hero_name != null && snapshotMatch.hero_name.length > 0\n ? { hero_name: snapshotMatch.hero_name }\n : {}),\n dire_score: snapshotMatch.dire_score,\n kda: kdaForDb,\n radiant_score: snapshotMatch.radiant_score,\n updated_at: snapshotMatch.updated_at,\n })\n .match({ matchId: matchId.toString(), userId: this.client.token })\n .is('won', null)\n .is('kda', null)\n\n // Check if player is high MMR (8500+)\n const isHighMmr = is8500Plus(this.client)\n\n if (isHighMmr) {\n // For high MMR players, skip automatic retries and prompt for manual resolution\n logger.info('[BETS] High MMR player detected, prompting for manual resolution', {\n matchId,\n mmr: this.getMmr(),\n name: this.client.name,\n })\n\n // Set pending manual resolution flag in Redis\n await redisClient.client.set(\n `${this.client.token}:pendingManualResolution`,\n JSON.stringify({ matchId, timestamp: Date.now() })\n )\n\n // Send chat message to notify mods\n const tellChatBets = getValueOrDefault(\n DBSettings.tellChatBets,\n this.client.settings,\n this.client.subscription\n )\n if (tellChatBets && this.client.stream_online) {\n say(\n this.client,\n t('bets.manualResolution', {\n details: formatUnresolvedMatch(snapshotMatch),\n emote: 'PauseChamp',\n lng: this.client.locale,\n matchId,\n })\n )\n }\n\n this.checkingEarlyDCWinner = false\n return\n }\n\n // Set up retry parameters for lower MMR players\n // Try up to 5 times\n const MAX_RETRIES = 5\n // 30 seconds between retries (total 2.5 minutes)\n const RETRY_DELAY = 30_000\n let retryCount = 0\n\n const attemptFetchMatchData = async (): Promise => {\n // Check if they rejoined the match they disconnected from\n if (this.client.gsi?.map?.matchid === matchId) {\n logger.info('[BETS] Streamer rejoined the match, skipping early DC winner check', {\n matchId,\n name: this.client.name,\n })\n return\n }\n\n // Check if the bet for this match is already closed in the database\n const { data: matchNotEnded, error } = await supabase\n .from('matches')\n .select('won')\n // Null means there is a winner of this match\n .is('won', null)\n .eq('matchId', matchId.toString())\n .eq('userId', this.client.token)\n .single()\n\n if (matchNotEnded == null || error !== null) {\n logger.info('[BETS] Match already ended, skipping early DC winner check', {\n matchId,\n name: this.client.name,\n })\n return\n }\n\n if (retryCount >= MAX_RETRIES) {\n // Handle exhausting all retries - prompt for manual resolution instead of refunding\n if (this.client.stream_online) {\n logger.info(\n 'Exceeded maximum retries for early DC match check, prompting for manual resolution',\n {\n matchId,\n name: this.client.name,\n }\n )\n\n // Set pending manual resolution flag in Redis\n await redisClient.client.set(\n `${this.client.token}:pendingManualResolution`,\n JSON.stringify({ matchId, timestamp: Date.now() })\n )\n\n // Send chat message to notify mods\n const tellChatBets = getValueOrDefault(\n DBSettings.tellChatBets,\n this.client.settings,\n this.client.subscription\n )\n if (tellChatBets) {\n say(\n this.client,\n t('bets.manualResolution', {\n details: formatUnresolvedMatch(snapshotMatch),\n emote: 'PauseChamp',\n lng: this.client.locale,\n matchId,\n })\n )\n }\n }\n\n // Reset the flag since we've exhausted retries\n this.checkingEarlyDCWinner = false\n return\n }\n\n try {\n // Request match data from Steam socket\n const getMatchDetailsPromise = new Promise(\n (resolve, reject) => {\n steamSocket.emit(\n 'getMatchMinimalDetails',\n { match_id: Number(matchId) },\n (err: unknown, response: MatchMinimalDetailsResponse) => {\n if (err != null) {\n reject(err)\n } else {\n resolve(response)\n }\n }\n )\n }\n )\n\n const response = await getMatchDetailsPromise\n const matchData = response?.matches?.[0]\n\n // Check if we got a valid response with match outcome\n if (\n matchData !== undefined &&\n typeof matchData.match_outcome === 'number' &&\n [\n EMatchOutcome.k_EMatchOutcome_RadVictory,\n EMatchOutcome.k_EMatchOutcome_DireVictory,\n ].includes(matchData.match_outcome)\n ) {\n logger.info('Successfully retrieved match result for early DC', {\n matchId,\n matchOutcome: matchData.match_outcome,\n name: this.client.name,\n })\n\n // Determine winner based on match outcome\n // k_EMatchOutcome_RadVictory = 2, k_EMatchOutcome_DireVictory = 3\n const winningTeam =\n matchData.match_outcome === EMatchOutcome.k_EMatchOutcome_RadVictory\n ? 'radiant'\n : 'dire'\n\n // Reset flag before calling closeBets to prevent duplicate calls from closeBets\n this.checkingEarlyDCWinner = false\n await this.closeBets(winningTeam, response)\n } else if (\n matchData !== undefined &&\n typeof matchData.match_outcome === 'number' &&\n matchData.match_outcome > EMatchOutcome.k_EMatchOutcome_DireVictory\n ) {\n // Not scored\n logger.info('Match not scored, skipping early DC winner check', {\n matchId,\n matchOutcome: matchData.match_outcome,\n name: this.client.name,\n })\n\n // Reset flag before calling closeBets to prevent duplicate calls from closeBets\n this.checkingEarlyDCWinner = false\n logger.info('This is likely a no stats recorded match', {\n matchId,\n name: this.client.name,\n })\n\n if (this.client.stream_online) {\n say(\n this.client,\n t('bets.notScored', {\n emote: 'D:',\n key: DBSettings.tellChatBets,\n lng: this.client.locale,\n matchId,\n })\n )\n const predictionResponse = await supabase\n .from('matches')\n .select('predictionId')\n .eq('matchId', matchId.toString())\n .eq('userId', this.client.token)\n .is('won', null)\n .single()\n if (\n predictionResponse.data?.predictionId != null &&\n predictionResponse.data.predictionId.length > 0\n ) {\n const oldBetId = await refundTwitchBet(\n this.getChannelId(),\n predictionResponse.data.predictionId\n )\n if (oldBetId != null && oldBetId.length > 0) {\n await supabase\n .from('matches')\n .update({ predictionId: null, updated_at: new Date().toISOString() })\n .eq('predictionId', oldBetId)\n }\n }\n }\n // No-stats match can never be resolved with !won/!lost; don't nag for it.\n await this.suppressUnresolvedReminder(matchId)\n await this.resetClientState()\n return\n } else {\n // Invalid response, retry after delay\n retryCount += 1\n logger.info('Invalid match data response, scheduling retry', {\n matchId,\n maxRetries: MAX_RETRIES,\n name: this.client.name,\n response,\n retryCount,\n })\n\n setTimeout(attemptFetchMatchData, RETRY_DELAY)\n }\n } catch (error) {\n // Error occurred, retry after delay\n retryCount += 1\n logger.error('Error in early DC match check, scheduling retry', {\n error,\n matchId,\n maxRetries: MAX_RETRIES,\n name: this.client.name,\n retryCount,\n })\n\n setTimeout(attemptFetchMatchData, RETRY_DELAY)\n }\n }\n\n try {\n // Start the first attempt\n await attemptFetchMatchData()\n } catch (error) {\n // If any uncaught error occurs, reset the flag\n logger.error('Uncaught error in checkEarlyDCWinner', {\n error,\n matchId,\n name: this.client.name,\n })\n this.checkingEarlyDCWinner = false\n }\n }" - } - ], - "message": "private async method `checkEarlyDCWinner` has a complexity of 21. Maximum allowed is 20.", - "severity": "error" - } - }, "a4ca8336a418bc343ae312c006f373df253bb2d4c7da451ae763c738bfc85ccf": { "count": 1, "diagnostic": { @@ -24642,26 +23202,6 @@ "severity": "error" } }, - "a5b3c07e9a54218e6bcf95ce151ea59e7b2af7f88703c47b6b20879bfd8a0622": { - "count": 1, - "diagnostic": { - "code": "unicorn(prefer-number-coercion)", - "file": "packages/dota/src/steam/medals.ts", - "labels": [ - { - "context": [ - "if (a.startsWith('#') || b.startsWith('#')) {", - "return Number.parseInt(b.slice(1), 10) - Number.parseInt(a.slice(1), 10)", - "}" - ], - "message": "", - "span": "Number.parseInt(a.slice(1), 10)" - } - ], - "message": "Prefer `Math.trunc(Number(a.slice(1)))`.", - "severity": "error" - } - }, "a5d0177c445f544a727b25e5d707863bfabef21337540e4be37bed9b91163aa9": { "count": 1, "diagnostic": { @@ -24818,23 +23358,19 @@ "severity": "error" } }, - "a6bb796ff03f8dd55e031e024efa1ec7596e79bd8e2f1b5fd74fbb401183e20b": { + "a6a91a4406036b62f929ceea6209a03f01a825d171e4f26398e933a73f20bdcd": { "count": 1, "diagnostic": { - "code": "anti-slop(require-safety-comment-for-type-assertion)", - "file": "packages/dota/src/dota/lib/capture-cosmetics.ts", + "code": "eslint(complexity)", + "file": "packages/dota/src/db/get-db-user.ts", "labels": [ { - "context": [ - "heroName: getHeroNameOrColor(heroId),", - "items: items as unknown as Json,", - "matchId: String(matchId)," - ], + "context": ["", "export default async function getDBUser({", "token,"], "message": "", - "span": "items as unknown" + "span": "async function getDBUser({\n token,\n twitchId: providerAccountId,\n ip: _ip,\n}: {\n token?: string\n twitchId?: string\n ip?: string\n} = {}): Promise<{\n reason: string\n result: SocketClient | null | undefined\n}> {\n const lookupToken = token ?? providerAccountId ?? ''\n\n if (invalidTokens.has(lookupToken)) {\n return { reason: 'Token is in invalidTokens set', result: null }\n }\n\n let client = findUser(token) ?? findUserByTwitchId(providerAccountId)\n if (client) {\n lookingupToken.delete(lookupToken)\n return { reason: 'Client found by token or twitchId', result: client }\n }\n\n if (lookingupToken.has(lookupToken)) {\n return { reason: 'Token is currently being looked up', result: null }\n }\n\n lookingupToken.set(lookupToken, true)\n\n if (!lookupToken) {\n invalidTokens.add(lookupToken)\n lookingupToken.delete(lookupToken)\n return { reason: 'No lookup token provided', result: null }\n }\n\n let userId = token === undefined || token.length === 0 ? null : token\n if (providerAccountId !== undefined && providerAccountId.length > 0) {\n const { data, error } = await supabase\n .from('accounts')\n .select('userId')\n .eq('provider', 'twitch')\n .eq('providerAccountId', providerAccountId)\n .single()\n userId = data?.userId ?? null\n\n if (error) {\n if (error.code === 'PGRST116') {\n // Genuine \"0 rows\" (DB enforces uniqueness on provider+providerAccountId,\n // so >1 rows can't surface as PGRST116 here). Safe to persist for 24h.\n invalidTokens.add(lookupToken)\n } else {\n // Transient DB error — log for observability but only cache in-memory\n // so recovery on next deploy doesn't require waiting out the 24h TTL.\n logger.error('[USER] accounts lookup failed', { error, lookupToken, providerAccountId })\n invalidTokens.addEphemeral(lookupToken)\n }\n lookingupToken.delete(lookupToken)\n return {\n reason: `Error looking up userId by providerAccountId: ${error.message}`,\n result: null,\n }\n }\n }\n\n if (userId === null || userId.length === 0) {\n invalidTokens.add(lookupToken)\n lookingupToken.delete(lookupToken)\n return { reason: 'No userId found', result: null }\n }\n\n // Fetch user by `twitchId` and `token`\n const { data: user, error: userError } = await supabase\n .from('users')\n .select(\n `\n id,\n name,\n mmr,\n steam32Id,\n stream_online,\n stream_start_date,\n beta_tester,\n locale,\n banned_at,\n subscriptions (\n id,\n tier,\n status,\n isGift\n ),\n Account:accounts (\n refresh_token,\n scope,\n expires_at,\n requires_refresh,\n expires_in,\n obtainment_timestamp,\n access_token,\n providerAccountId\n ),\n SteamAccount:steam_accounts (\n mmr,\n connectedUserIds,\n steam32Id,\n name,\n leaderboard_rank\n ),\n settings (\n key,\n value\n )\n `\n )\n .eq('id', userId)\n .single()\n\n // Handle errors\n if (userError) {\n if (userError.code === 'PGRST116') {\n // Genuine \"0 rows\" — user was deleted (users.id is the primary key so\n // >1 rows can't surface as PGRST116). Safe to persist for 24h.\n invalidTokens.add(lookupToken)\n } else {\n // Transient DB error — log for observability but only cache in-memory.\n logger.error('[USER] users lookup failed', { error: userError, lookupToken })\n invalidTokens.addEphemeral(lookupToken)\n }\n lookingupToken.delete(lookupToken)\n return { reason: `Error fetching user from supabase: ${userError.message}`, result: null }\n }\n\n if (!user?.id) {\n logger.info('Invalid token', { token: lookupToken })\n invalidTokens.add(lookupToken)\n lookingupToken.delete(lookupToken)\n return { reason: 'No user or user.id found', result: null }\n }\n\n // Hard gate: banned user. Persist in invalidTokens so subsequent GSI POSTs\n // short-circuit at the top of getDBUser without re-hitting the DB. The\n // dota watcher's UPDATE:users handler adds to invalidTokens on the\n // null→set banned_at transition so a live ban is effective immediately.\n if (user.banned_at !== null && user.banned_at !== undefined && user.banned_at.length > 0) {\n invalidTokens.add(lookupToken)\n lookingupToken.delete(lookupToken)\n return { reason: 'User is banned', result: null }\n }\n\n // If they require a refresh, don't cache them\n const Account = Array.isArray(user?.Account) ? user.Account[0] : user.Account\n if (Account?.requires_refresh === true) {\n invalidTokens.add(lookupToken)\n lookingupToken.delete(lookupToken)\n return { reason: 'Account requires refresh', result: null }\n }\n\n client = findUser(user.id)\n if (client) {\n lookingupToken.delete(lookupToken)\n return { reason: 'Client found by user.id', result: client }\n }\n\n if (Account === null || Account === undefined) {\n logger.info('Invalid token missing Account??', { token: lookupToken })\n invalidTokens.add(lookupToken)\n lookingupToken.delete(lookupToken)\n return { reason: 'No Account found', result: undefined }\n }\n let subscription: SocketClient['subscription'] | undefined\n if (Array.isArray(user.subscriptions) && user.subscriptions.length > 0) {\n const activeSubscription =\n user.subscriptions.find((sub: SubscriptionRow) => isSubscriptionActive(sub)) ??\n user.subscriptions[0]\n subscription = {\n ...activeSubscription,\n }\n }\n\n const userInfo = {\n ...user,\n Account: {\n ...Account,\n obtainment_timestamp:\n Account.obtainment_timestamp === null ||\n Account.obtainment_timestamp === undefined ||\n Account.obtainment_timestamp === ''\n ? null\n : new Date(Account.obtainment_timestamp),\n requires_refresh: Account.requires_refresh ?? false,\n },\n mmr: user.mmr || user.SteamAccount[0]?.mmr || 0,\n steam32Id:\n user.steam32Id === null || user.steam32Id === undefined || user.steam32Id === 0\n ? (user.SteamAccount[0]?.steam32Id ?? 0)\n : user.steam32Id,\n stream_start_date:\n user.stream_start_date === null ||\n user.stream_start_date === undefined ||\n user.stream_start_date.length === 0\n ? null\n : new Date(user.stream_start_date),\n subscription,\n token: user.id,\n }\n\n const gsiHandler = gsiHandlers.get(userInfo.id) ?? createGSIHandler(userInfo)\n gsiHandlers.set(userInfo.id, gsiHandler)\n\n twitchIdToToken.set(Account.providerAccountId, userInfo.id)\n twitchNameToToken.set(userInfo.name.toLowerCase(), userInfo.id)\n lookingupToken.delete(lookupToken)\n invalidTokens.delete(userInfo.id)\n\n return { reason: 'User successfully retrieved', result: userInfo }\n}" } ], - "message": "This type assertion has no `SAFETY:` justification. State the checked invariant immediately before the assertion or its containing statement.", + "message": "async function `getDBUser` has a complexity of 52. Maximum allowed is 20.", "severity": "error" } }, @@ -25139,26 +23675,6 @@ "severity": "error" } }, - "a834705d8496123421bf0aa726a6958d4bf800781e311b78a447fa4cbdd3f1eb": { - "count": 1, - "diagnostic": { - "code": "import(first)", - "file": "packages/twitch-chat/src/index.ts", - "labels": [ - { - "context": [ - "import { isEventsubConnected } from './event-sub-socket'", - "import { sendTwitchChatMessage } from './handle-chat'", - "import { io, setupSocketServer } from './utils/socket-manager'" - ], - "message": "", - "span": "import { sendTwitchChatMessage } from './handle-chat'" - } - ], - "message": "Import statements must come first", - "severity": "error" - } - }, "a8382d1c40dd3c1f22d40f33c5b9cfa540d9bc92d784b8fc862320d8870e1664": { "count": 1, "diagnostic": { @@ -25259,55 +23775,6 @@ "severity": "error" } }, - "a8d891418d46b989f41eebd5326285f8cca3748d2a4d5201044713b32a46d635": { - "count": 1, - "diagnostic": { - "code": "import(first)", - "file": "packages/twitch-chat/src/index.ts", - "labels": [ - { - "context": [ - "import { ensureEventSubInitialized } from './conduit-setup'", - "import { clearDisableCache, DISABLE_CACHE_EXPIRY, disableUserCache } from './disable-cache'", - "import { isEventsubConnected } from './event-sub-socket'" - ], - "message": "", - "span": "import { clearDisableCache, DISABLE_CACHE_EXPIRY, disableUserCache } from './disable-cache'" - } - ], - "message": "Import statements must come first", - "severity": "error" - } - }, - "a8ea90da752239c2628bf8adb0530a2511d8c6ad281cb8916671d31022b0fba2": { - "count": 1, - "diagnostic": { - "code": "typescript(no-unnecessary-type-conversion)", - "file": "packages/dota/src/dota/events/gsi-events/newdata.ts", - "labels": [ - { - "context": [ - "lobbyType: String(delayedData.match.lobby_type),", - "steamServerId: currentSteamServerId.toString(),", - "timestamp: Date.now()," - ], - "message": "", - "span": "currentSteamServerId" - }, - { - "context": [ - "lobbyType: String(delayedData.match.lobby_type),", - "steamServerId: currentSteamServerId.toString(),", - "timestamp: Date.now()," - ], - "message": "", - "span": "toString()" - } - ], - "message": "This type conversion does not change the type or value of the expression.", - "severity": "error" - } - }, "a91c4b0e3398f632cbad09f316326713ab4966382474a09445f9bee49e6486d9": { "count": 1, "diagnostic": { @@ -25388,6 +23855,26 @@ "severity": "error" } }, + "a9d4d15f13d6695480868ef7b15e21ebd3d459a38a2131dc96516795abacd01a": { + "count": 1, + "diagnostic": { + "code": "eslint(complexity)", + "file": "packages/twitch-chat/src/handle-chat.ts", + "labels": [ + { + "context": [ + "", + "export const sendTwitchChatMessage = async function sendTwitchChatMessage(", + "params: SendChatMessageParams" + ], + "message": "", + "span": "async function sendTwitchChatMessage(\n params: SendChatMessageParams\n): Promise {\n const message = fitTwitchChatMessage(params.message)\n const { replyParentMessageId, requestBody } = normalizeSendChatRequest(params, message)\n\n // Check if this broadcaster is currently being disabled to prevent race condition\n if (isBroadcasterBeingDisabled(params.broadcaster_id)) {\n logger.info('[DISABLE_CACHE] Skipping chat message for broadcaster being disabled', {\n broadcaster_id: params.broadcaster_id,\n message: params.message,\n })\n\n return {\n data: [\n {\n drop_reason: {\n code: 'user_being_disabled',\n message:\n 'User is currently being disabled, skipping chat message to prevent race condition',\n },\n is_sent: false,\n message_id: '',\n },\n ],\n }\n }\n\n // Check for duplicate replies within the dedupe window. The parent message is the only proof\n // that two sends came from the same command event; unthreaded messages must not be collapsed\n // merely because their text matches.\n const dedupeKey =\n replyParentMessageId !== undefined && replyParentMessageId.length > 0\n ? `${params.broadcaster_id}:${replyParentMessageId}:${params.message}`\n : undefined\n const now = Date.now()\n const lastSent = dedupeKey === undefined ? undefined : messageDedupeCache.get(dedupeKey)\n\n if (lastSent !== undefined && lastSent !== 0 && now - lastSent < DEDUPE_WINDOW_MS) {\n logger.info('[DEDUPE] Dropping duplicate chat message', {\n broadcaster_id: params.broadcaster_id,\n last_sent_ms_ago: now - lastSent,\n message: params.message,\n })\n\n return {\n data: [\n {\n drop_reason: {\n code: 'duplicate_message',\n message: `Duplicate message dropped (sent ${now - lastSent}ms ago): ${params.message}`,\n },\n is_sent: false,\n message_id: '',\n },\n ],\n }\n }\n\n // Record this message in the cache\n if (dedupeKey !== undefined) {\n messageDedupeCache.set(dedupeKey, now)\n }\n\n const url = 'https://api.twitch.tv/helix/chat/messages'\n // Only the bot can send messages\n // Or a user with \"user:bot\" scope\n const headers = await getTwitchHeaders(params.sender_id)\n const options = {\n body: JSON.stringify(requestBody),\n headers: { ...headers, 'Content-Type': 'application/json' },\n method: 'POST',\n }\n\n try {\n const response = await fetch(url, options)\n\n if (!response.ok) {\n let errorMessage = `Failed to send chat message: ${response.status} ${response.statusText}`\n let dropReasonCode = 'send_error'\n\n // Handle rate limiting specifically\n if (response.status === 429) {\n dropReasonCode = 'rate_limited'\n errorMessage = `Rate limited: ${response.status} ${response.statusText}`\n }\n\n // Try to read the response body for more details\n try {\n const errorBody = await response.text()\n if (errorBody) {\n errorMessage += ` - ${errorBody}`\n }\n } catch {\n // If we can't read the body, continue with the basic error\n }\n\n return {\n data: [\n {\n drop_reason: {\n code: dropReasonCode,\n message: errorMessage,\n },\n is_sent: false,\n message_id: '',\n },\n ],\n }\n }\n\n const result = (await response.json()) as TwitchChatMessageResponse\n if (result.data?.[0]?.drop_reason?.code !== 'msg_duplicate') {\n return result\n }\n\n const distinctMessage = makeDistinctTwitchChatMessage(message)\n logger.info('[DEDUPE] Retrying Twitch duplicate response with disambiguated text', {\n broadcaster_id: params.broadcaster_id,\n message: params.message,\n })\n\n const retryResponse = await fetch(url, {\n ...options,\n body: JSON.stringify({ ...requestBody, message: distinctMessage }),\n })\n if (!retryResponse.ok) {\n return {\n data: [\n {\n drop_reason: {\n code: retryResponse.status === 429 ? 'rate_limited' : 'send_error',\n message: `Failed to send disambiguated chat message: ${retryResponse.status} ${retryResponse.statusText}`,\n },\n is_sent: false,\n message_id: '',\n },\n ],\n }\n }\n\n return retryResponse.json() as Promise\n } catch (error) {\n // If it's not an HTTP error we already handled, log and return a formatted error\n logger.error('Error sending chat message', { broadcaster_id: params.broadcaster_id, error })\n\n return {\n data: [\n {\n drop_reason: {\n code: 'send_error',\n message: error instanceof Error ? error.message : 'Unknown error',\n },\n is_sent: false,\n message_id: '',\n },\n ],\n }\n }\n}" + } + ], + "message": "async function `sendTwitchChatMessage` has a complexity of 21. Maximum allowed is 20.", + "severity": "error" + } + }, "a9eee0eb53a983a9cc82ccdc3ab3cc86b4afbb691d1f67df229412311e4e0537": { "count": 1, "diagnostic": { @@ -25636,26 +24123,6 @@ "severity": "error" } }, - "abafcf684458fc23f3ee3fddd8bfbeb4fa8a3c0fefa5df1f79938e7f930937de": { - "count": 1, - "diagnostic": { - "code": "eslint(complexity)", - "file": "packages/dota/src/dota/events/gsi-events/newdata.ts", - "labels": [ - { - "context": [ - "// Runs every gametick", - "const saveMatchData = async function saveMatchData(client: SocketClient) {", - "// This now waits for the bet to complete before checking match data" - ], - "message": "", - "span": "async function saveMatchData(client: SocketClient) {\n // This now waits for the bet to complete before checking match data\n // Since match data is delayed it will run far fewer than before, when checking actual match id of an ingame match\n // the matchid is saved when the hero is selected\n const matchId = await redisClient.client.get(`${client.token}:matchId`)\n if (\n matchId === null ||\n matchId.length === 0 ||\n Number(matchId) === 0 ||\n Number.isNaN(Number(matchId))\n ) {\n return\n }\n\n if (client.steam32Id === null || client.steam32Id === 0) {\n return\n }\n\n // Check for account sharing before proceeding with match data processing\n const accountSharingDetected = await checkAccountSharing(client, matchId)\n if (accountSharingDetected) {\n // If account sharing is detected, stop processing for this client\n return\n }\n\n const cacheKey = `${matchId}:${client.token}`\n\n // Check in-memory cache first\n const cachedData = matchDataCache.get(cacheKey)\n if (cachedData) {\n // If cache is still valid, use cached data and return\n if (Date.now() - cachedData.timestamp < CACHE_EXPIRATION) {\n if (\n cachedData.steamServerId !== null &&\n cachedData.steamServerId.length > 0 &&\n cachedData.lobbyType !== null\n ) {\n return\n }\n } else {\n // If cache expired, remove it\n matchDataCache.delete(cacheKey)\n }\n }\n\n // Implement debounce logic\n const debounceKey = client.token\n const now = Date.now()\n const debounceData = saveMatchDataDebounceMap.get(debounceKey)\n\n // If this client's function is already in progress or ran recently, skip this execution\n if (debounceData) {\n if (debounceData.inProgress || now - debounceData.lastExecuted < DEBOUNCE_INTERVAL) {\n return\n }\n }\n\n // Mark this execution as in progress\n saveMatchDataDebounceMap.set(debounceKey, { inProgress: true, lastExecuted: now })\n\n try {\n // did we already come here before?\n const res = await redisClient.client\n .multi()\n .get(`${matchId}:${client.token}:steamServerId`)\n .get(`${matchId}:${client.token}:lobbyType`)\n .exec()\n\n const [steamServerId] = res\n const [, lobbyType] = res\n\n // Update cache with Redis data\n matchDataCache.set(cacheKey, {\n lobbyType: lobbyType === null || lobbyType === '' ? null : String(lobbyType),\n steamServerId: steamServerId === null || steamServerId === '' ? null : String(steamServerId),\n timestamp: now,\n })\n\n if (steamServerId !== null && steamServerId !== '' && lobbyType !== null) {\n return\n }\n\n // PRESERVED — gated, not dead. This block is the sole writer of the redis steamServerId key\n // that the ordinary-pub `!items`/`!stats`/`!winprobability` fallback later reads. SourceTV\n // commands instead use the server_steam_id already present in delayedGames. This lookup stays\n // gated pending bot-friend management at scale; see memory `keep-spectate-friend-path`.\n if (\n (steamServerId === null || steamServerId === '') &&\n lobbyType === null &&\n !is8500Plus(client) &&\n ENABLE_SPECTATE_FRIEND_GAME\n ) {\n // Fix: Check if we're already looking up this match to prevent race conditions\n if (steamServerLookupMap.has(matchId)) {\n return\n }\n\n // Add to lookup map before starting the async operation\n steamServerLookupMap.add(matchId)\n\n try {\n const getDelayedDataPromise = new Promise((resolve, reject) => {\n const timeoutId = setTimeout(() => {\n reject(new CustomError(t('matchData8500', { emote: 'PoroSad', lng: client.locale })))\n // 10 second timeout\n }, 10_000)\n\n steamSocket.emit(\n 'getUserSteamServer',\n client.steam32Id,\n (err: unknown, cards: string) => {\n clearTimeout(timeoutId)\n if (err !== null && err !== undefined) {\n reject(err)\n } else {\n resolve(cards)\n }\n }\n )\n })\n\n const steamServerId = await getDelayedDataPromise\n\n if (steamServerId.length > 0) {\n await redisClient.client.set(\n `${matchId}:${client.token}:steamServerId`,\n steamServerId.toString()\n )\n\n // Update cache\n matchDataCache.set(cacheKey, {\n lobbyType: null,\n steamServerId: steamServerId.toString(),\n timestamp: Date.now(),\n })\n }\n } catch {\n // Do nothing, we don't want to log this error\n // logger.error('Error getting steam server data', { error, matchId })\n } finally {\n // Always remove from the map, even if there was an error\n steamServerLookupMap.delete(matchId)\n }\n }\n\n // Re-check steamServerId from cache first, then Redis if needed\n let currentSteamServerId = matchDataCache.get(cacheKey)?.steamServerId ?? null\n if (currentSteamServerId === null || currentSteamServerId.length === 0) {\n currentSteamServerId = await redisClient.client.get(\n `${matchId}:${client.token}:steamServerId`\n )\n\n // Update cache if we found it in Redis\n if (currentSteamServerId !== null && currentSteamServerId.length > 0) {\n const currentCache = matchDataCache.get(cacheKey) ?? {\n lobbyType: null,\n steamServerId: null,\n timestamp: now,\n }\n matchDataCache.set(cacheKey, {\n ...currentCache,\n steamServerId: currentSteamServerId,\n timestamp: now,\n })\n }\n }\n\n if (\n currentSteamServerId !== null &&\n currentSteamServerId.length > 0 &&\n lobbyType === null &&\n !is8500Plus(client)\n ) {\n // Fix: Check if we're already looking up this match to prevent race conditions\n if (steamDelayDataLookupMap.has(matchId)) {\n return\n }\n\n steamDelayDataLookupMap.add(matchId)\n\n try {\n const getDelayedDataPromise = new Promise((resolve, reject) => {\n const timeoutId = setTimeout(() => {\n reject(new CustomError(t('matchData8500', { emote: 'PoroSad', lng: client.locale })))\n // 10 second timeout\n }, 10_000)\n\n steamSocket.emit(\n 'getRealTimeStats',\n {\n match_id: matchId,\n refetchCards: true,\n steam_server_id: currentSteamServerId.toString(),\n token: client.token,\n },\n (err: unknown, data: DelayedGames) => {\n clearTimeout(timeoutId)\n if (err !== null && err !== undefined) {\n reject(err)\n } else {\n resolve(data)\n }\n }\n )\n })\n\n const delayedData = await getDelayedDataPromise\n\n if (delayedData.match.lobby_type !== undefined) {\n await Promise.all([\n redisClient.client.set(\n `${matchId}:${client.token}:lobbyType`,\n delayedData.match.lobby_type\n ),\n redisClient.client.set(\n `${matchId}:${client.token}:gameMode`,\n delayedData.match.game_mode\n ),\n ])\n\n // Update cache with complete data\n matchDataCache.set(cacheKey, {\n lobbyType: String(delayedData.match.lobby_type),\n steamServerId: currentSteamServerId.toString(),\n timestamp: Date.now(),\n })\n }\n } catch (error) {\n if (!(error instanceof CustomError)) {\n logger.error('Error getting delayed match data', { error, matchId })\n }\n } finally {\n // Always remove from the map, even if there was an error\n steamDelayDataLookupMap.delete(matchId)\n }\n }\n } finally {\n // Update the debounce map to mark execution as complete\n const currentDebounce = saveMatchDataDebounceMap.get(debounceKey)\n if (currentDebounce) {\n saveMatchDataDebounceMap.set(debounceKey, { ...currentDebounce, inProgress: false })\n\n // Set up an automatic cleanup for the debounce map entry after 5 minutes of inactivity\n setTimeout(() => {\n const entry = saveMatchDataDebounceMap.get(debounceKey)\n if (entry && Date.now() - entry.lastExecuted > 300_000) {\n // 5 minutes\n saveMatchDataDebounceMap.delete(debounceKey)\n }\n // 5 minutes\n }, 300_000)\n }\n }\n}" - } - ], - "message": "async function `saveMatchData` has a complexity of 47. Maximum allowed is 20.", - "severity": "error" - } - }, "abb276ac9fcfa9bbf200d1a1d177a7eef805ac9230aefc30f536042c8b9accb3": { "count": 1, "diagnostic": { @@ -25676,26 +24143,6 @@ "severity": "error" } }, - "abd1ddcb05d3246bba8902b591949ddf2becb0279d0df187f68d0f49ba986517": { - "count": 1, - "diagnostic": { - "code": "eslint(eqeqeq)", - "file": "packages/dota/src/dota/gsi-handler.ts", - "labels": [ - { - "context": [ - "(this.client.gsi?.map?.radiant_score == null || this.client.gsi.map.radiant_score === 0) &&", - "this.client.gsi?.map?.matchid != null &&", - "this.client.gsi.map.matchid.length > 0" - ], - "message": "", - "span": "!=" - } - ], - "message": "Expected !== and instead saw !=", - "severity": "error" - } - }, "ac0edc4196bc6efbbb2f65a562c48eff5f48249876849024a03265c045bdc848": { "count": 1, "diagnostic": { @@ -25752,26 +24199,6 @@ "severity": "error" } }, - "acb4e791082ef35b0dbf957ee52acb23c775a19a65757d1d2e209a9d8610549a": { - "count": 1, - "diagnostic": { - "code": "eslint(no-negated-condition)", - "file": "packages/dota/src/dota/lib/get-players.ts", - "labels": [ - { - "context": [ - "cards,", - "gameMode: response !== null ? Number(response.match.game_mode) : undefined,", - "matchPlayers," - ], - "message": "", - "span": "response !== null" - } - ], - "message": "Unexpected negated condition.", - "severity": "error" - } - }, "acb822d0298f3f3eddeb972f2f02589e127615855717ea1cb2a366eb3d8e2a4f": { "count": 1, "diagnostic": { @@ -25980,26 +24407,6 @@ "severity": "error" } }, - "ae4e5d7d5589a574e15964ed36fb3da580fa7498eccbd33c6ef74efc4a1bf7ac": { - "count": 1, - "diagnostic": { - "code": "eslint(no-eq-null)", - "file": "packages/dota/src/dota/gsi-handler.ts", - "labels": [ - { - "context": [ - "const heroName = client.gsi.hero?.name", - "if (heroName == null || heroName.length === 0) {", - "// console.log(`if (!client.gsi.hero?.name || !client.gsi.hero.name.length) {`)" - ], - "message": "", - "span": "heroName == null" - } - ], - "message": "Do not use `null` comparisons without type-checking operators.", - "severity": "error" - } - }, "ae7a9175feff3fe75ae97eb02c75cc754718cd0261c045c2aef01194faaf2351": { "count": 1, "diagnostic": { @@ -26116,26 +24523,6 @@ "severity": "error" } }, - "aef48b048da463646c50aabf9b98c21b8e232e5f92a3259ff95ce2db68280904": { - "count": 1, - "diagnostic": { - "code": "eslint(no-eq-null)", - "file": "packages/dota/src/dota/gsi-handler.ts", - "labels": [ - { - "context": [ - "if (", - "(this.client.gsi?.map?.dire_score == null || this.client.gsi.map.dire_score === 0) &&", - "(this.client.gsi?.map?.radiant_score == null || this.client.gsi.map.radiant_score === 0) &&" - ], - "message": "", - "span": "this.client.gsi?.map?.dire_score == null" - } - ], - "message": "Do not use `null` comparisons without type-checking operators.", - "severity": "error" - } - }, "af08737e0286161873943d670b531284798732029fa6cffc694f4ac7e27038a7": { "count": 1, "diagnostic": { @@ -26540,26 +24927,6 @@ "severity": "error" } }, - "b13d31a872916190ab255b60ecb6a87147d635913f544355c5db80a266a035e9": { - "count": 1, - "diagnostic": { - "code": "unicorn(prefer-ternary)", - "file": "packages/dota/src/dota/events/minimap/parser.ts", - "labels": [ - { - "context": [ - "if (entity.xpos !== undefined) {", - "if (entity.xpos >= 0) {", - "entity.xpos = Number(entity.xpos) + Number(this.xLength)" - ], - "message": "", - "span": "if (entity.xpos >= 0) {\n entity.xpos = Number(entity.xpos) + Number(this.xLength)\n } else {\n entity.xpos = this.xLength - Math.abs(entity.xpos)\n }" - } - ], - "message": "Prefer ternary expressions over simple `if-else` statements.", - "severity": "error" - } - }, "b169ac46a559b38fbbf56346b4dfcccd6c178d45320864c74cfb03fc93948ed6": { "count": 1, "diagnostic": { @@ -26917,26 +25284,6 @@ "severity": "error" } }, - "b3e13973599876b3efbc21228197e98dc527b3dee0575f824142c1e0ba8b167d": { - "count": 3, - "diagnostic": { - "code": "eslint(eqeqeq)", - "file": "packages/dota/src/dota/gsi-handler.ts", - "labels": [ - { - "context": [ - "if (", - "predictionResponse.data?.predictionId != null &&", - "predictionResponse.data.predictionId.length > 0" - ], - "message": "", - "span": "!=" - } - ], - "message": "Expected !== and instead saw !=", - "severity": "error" - } - }, "b3e13c8d40ee9e59af337257a0af56586fb816dd4a3847a2ba61af9cc17acc41": { "count": 1, "diagnostic": { @@ -26953,26 +25300,6 @@ "severity": "error" } }, - "b3e2d4ad60ba141b234f77428b83f25ef005e7cd56c7699ec1f78f698acbcc86": { - "count": 1, - "diagnostic": { - "code": "eslint(eqeqeq)", - "file": "packages/dota/src/dota/gsi-handler.ts", - "labels": [ - { - "context": [ - "} catch (error) {", - "if (steam32Id != null && steam32Id !== 0) {", - "this.client.multiAccount = steam32Id" - ], - "message": "", - "span": "!=" - } - ], - "message": "Expected !== and instead saw !=", - "severity": "error" - } - }, "b3fb3a5542ff723d85260076a3bfe44789012587e44fdc1563b6547d22d49769": { "count": 1, "diagnostic": { @@ -27109,26 +25436,6 @@ "severity": "error" } }, - "b54d616e396ffb2d572e909bd045089d00cd4550508a9433031e0ed94fec3690": { - "count": 1, - "diagnostic": { - "code": "sonarjs(expression-complexity)", - "file": "packages/dota/src/dota/gsi-handler.ts", - "labels": [ - { - "context": [ - "if (", - "(this.client.gsi?.map?.dire_score == null || this.client.gsi.map.dire_score === 0) &&", - "(this.client.gsi?.map?.radiant_score == null || this.client.gsi.map.radiant_score === 0) &&" - ], - "message": "", - "span": "(this.client.gsi?.map?.dire_score == null || this.client.gsi.map.dire_score === 0) &&\n (this.client.gsi?.map?.radiant_score == null || this.client.gsi.map.radiant_score === 0) &&\n this.client.gsi?.map?.matchid != null &&\n this.client.gsi.map.matchid.length > 0" - } - ], - "message": "Reduce the number of conditional operators (5) used in the expression (maximum allowed 3).", - "severity": "error" - } - }, "b56bea810dc2ec77ef52b7d2b0f9a5bc7a8c87876673f800cbf0f72569e43945": { "count": 1, "diagnostic": { @@ -27201,26 +25508,6 @@ "severity": "error" } }, - "b62fd29c1393329e1aa263a8a1914d001954b2cf1ca0d453a7009b07247568e9": { - "count": 1, - "diagnostic": { - "code": "eslint(eqeqeq)", - "file": "packages/dota/src/dota/gsi-handler.ts", - "labels": [ - { - "context": [ - "const matchId = this.client.gsi?.map?.matchid", - "if (matchId == null || matchId.length === 0 || matchId === '0') {", - "return" - ], - "message": "", - "span": "==" - } - ], - "message": "Expected === and instead saw ==", - "severity": "error" - } - }, "b63a122ad8c0c5a5ca02cf34e61257dc29bdcbe03f6c43b9520e26d6d8e488af": { "count": 1, "diagnostic": { @@ -27241,26 +25528,6 @@ "severity": "error" } }, - "b6461926654d744cbef91ddcfb12894024b25decb10754a4c63dae5ffd3aff73": { - "count": 1, - "diagnostic": { - "code": "anti-slop(no-unknown-returns)", - "file": "packages/dota/src/dota/gsi-server-types.ts", - "labels": [ - { - "context": [ - "export interface SocketBroadcastTarget {", - "emit: (event: string, ...args: unknown[]) => unknown", - "}" - ], - "message": "", - "span": "unknown" - } - ], - "message": "This function exposes `unknown` to its caller. Parse the value at its boundary and return a named domain type.", - "severity": "error" - } - }, "b6a0c62d99be83a8fb8e3458dca0e9412cec14687f1f994d00377de14206bb7e": { "count": 1, "diagnostic": { @@ -27518,59 +25785,39 @@ "severity": "error" } }, - "b8b76601da2106c87cb2f3fb1c3752d7a8291264a1a4aa39fb6c126633646d85": { + "b8baf7c6accaffc741dbb15d6d5a7ccedbbe68da459a95e3b9bbbe6658030f2e": { "count": 1, "diagnostic": { - "code": "eslint(eqeqeq)", - "file": "packages/dota/src/dota/gsi-handler.ts", + "code": "anti-slop(no-module-mocking)", + "file": "packages/shared-utils/tests/setup-mocks.ts", "labels": [ { - "context": [ - "const heroName = client.gsi.hero?.name", - "if (heroName == null || heroName.length === 0) {", - "// console.log(`if (!client.gsi.hero?.name || !client.gsi.hero.name.length) {`)" - ], + "context": ["", "vi.doMock('../src/db/supabase', () => ({", "default: supabaseMock,"], "message": "", - "span": "==" + "span": "vi.doMock('../src/db/supabase', () => ({\n default: supabaseMock,\n getSupabaseClient: () => supabaseMock,\n supabase: supabaseMock,\n}))" } ], - "message": "Expected === and instead saw ==", + "message": "Replace module mocking with dependency injection through a real interface, service layer, or faithful test implementation.", "severity": "error" } }, - "b8b9bbabedf7db0ccf8e1ba3c6ac76970d9f1e4370fb7e3c148fcadaab0a848b": { + "b8f77351f741578657ffa7f5af1f476eb3172d7feac71e4a95ca73f8d50025dc": { "count": 1, "diagnostic": { - "code": "eslint(eqeqeq)", + "code": "eslint(complexity)", "file": "packages/dota/src/dota/gsi-handler.ts", "labels": [ { "context": [ - "(this.client.gsi?.map?.dire_score == null || this.client.gsi.map.dire_score === 0) &&", - "(this.client.gsi?.map?.radiant_score == null || this.client.gsi.map.radiant_score === 0) &&", - "this.client.gsi?.map?.matchid != null &&" + "", + "async closeBets(winningTeam: Team | null = null, gcData?: MatchClosingDetailsResponse) {", + "if (this.endingBets) {" ], "message": "", - "span": "==" + "span": "(winningTeam: Team | null = null, gcData?: MatchClosingDetailsResponse) {\n if (this.endingBets) {\n return\n }\n this.endingBets = true\n\n try {\n const match = gcData?.matches?.[0]\n const longMatchId = match?.match_id\n ? (() => {\n const id = new Long(match.match_id.low, match.match_id.high).toString()\n const numId = Number(id)\n return !Number.isNaN(numId) && numId > 1 ? id : undefined\n })()\n : undefined\n const matchId = (await redisClient.client.get(`${this.client.token}:matchId`)) ?? longMatchId\n const player = match?.players?.find(\n (player) => player.account_id === Number(this.client.gsi?.player?.accountid)\n )\n const gcTeam =\n player?.team_number === DotaGcTeam.DOTA_GC_TEAM_GOOD_GUYS\n ? 'radiant'\n : player?.team_number === DotaGcTeam.DOTA_GC_TEAM_BAD_GUYS\n ? 'dire'\n : null\n const myTeam: Team | null =\n typeof player?.team_number === 'number'\n ? (gcTeam ?? null)\n : (parseTeam(await redisClient.client.get(`${this.client.token}:playingTeam`)) ??\n (this.client.gsi?.player?.team_name === 'radiant' ||\n this.client.gsi?.player?.team_name === 'dire'\n ? this.client.gsi?.player?.team_name\n : null))\n\n if (this.openingBets || matchId === null || matchId === undefined || matchId.length === 0) {\n logger.debug('[BETS] Not closing bets', {\n endingBets: this.endingBets,\n name: this.client.name,\n openingBets: this.openingBets,\n playingMatchId: matchId,\n })\n\n if (matchId === null || matchId === undefined || matchId.length === 0) {\n await this.resetClientState()\n }\n return\n }\n\n const betsEnabled = getValueOrDefault(\n DBSettings.bets,\n this.client.settings,\n this.client.subscription\n )\n const heroSlot =\n player?.player_slot ?? (await getRedisNumberValue(`${this.client.token}:playingHeroSlot`))\n const heroName =\n getHeroById(player?.hero_id)?.key ??\n (await redisClient.client.get(`${this.client.token}:playingHero`))\n\n // An early without waiting for ancient to blow up\n // We have to check every few seconds with an api to see if the match is over\n if (!winningTeam) {\n void this.checkEarlyDCWinner(matchId)\n return\n }\n\n const localWinner = winningTeam\n const scores = buildClosingScores({\n gcMatch: match,\n gcPlayer: player,\n gsi: this.client.gsi,\n })\n const won = myTeam === localWinner\n logger.info('[BETS] end bets won data', {\n channel: this.client.name,\n localWinner,\n myTeam,\n playingMatchId: matchId,\n won,\n })\n\n // Both or one undefined\n if (!myTeam) {\n // Very rare case, but it can happen. Once every 7 days\n logger.error('[BETS] trying to end bets but did not find localWinner or myTeam', {\n channel: this.client.name,\n matchId,\n })\n return\n }\n\n logger.debug('[BETS] Running end bets to award mmr and close predictions', {\n matchId,\n name: this.client.name,\n })\n\n const channel = this.client.name\n\n // Pretty rare case, 26 times in 7 days. Usually when they test Dotabod in a custom lobby\n // Custom lobbies create a match ID but don't report any stats\n if (\n (this.client.gsi?.map?.dire_score === null ||\n this.client.gsi?.map?.dire_score === undefined ||\n this.client.gsi.map.dire_score === 0) &&\n (this.client.gsi?.map?.radiant_score === null ||\n this.client.gsi?.map?.radiant_score === undefined ||\n this.client.gsi.map.radiant_score === 0) &&\n this.client.gsi?.map?.matchid !== null &&\n this.client.gsi?.map?.matchid !== undefined &&\n this.client.gsi.map.matchid.length > 0\n ) {\n logger.info('This is likely a no stats recorded match', {\n matchId,\n name: this.client.name,\n })\n\n if (this.client.stream_online) {\n say(\n this.client,\n t('bets.notScored', {\n emote: 'D:',\n key: DBSettings.tellChatBets,\n lng: this.client.locale,\n matchId,\n })\n )\n const predictionResponse = await supabase\n .from('matches')\n .select('predictionId')\n .eq('matchId', matchId)\n .eq('userId', this.client.token)\n .is('won', null)\n .single()\n if (\n predictionResponse.data?.predictionId !== null &&\n predictionResponse.data?.predictionId !== undefined &&\n predictionResponse.data.predictionId.length > 0\n ) {\n const oldBetId = await refundTwitchBet(\n this.getChannelId(),\n predictionResponse.data.predictionId\n )\n if (oldBetId !== null && oldBetId !== undefined && oldBetId.length > 0) {\n await supabase\n .from('matches')\n .update({ predictionId: null, updated_at: new Date().toISOString() })\n .eq('predictionId', oldBetId)\n }\n }\n }\n // No-stats match can never be resolved with !won/!lost; don't nag for it.\n await this.suppressUnresolvedReminder(matchId)\n await this.resetClientState()\n return\n }\n\n // 0 is a correct lobby type meaning unranked\n // https://github.com/dotabod/backend/issues/373#issuecomment-2366822786\n // Default to ranked if we don't have valid data\n const playingLobbyType = await getRedisNumberValue(\n `${matchId}:${this.client.token}:lobbyType`\n )\n const playingGameMode = await getRedisNumberValue(`${matchId}:${this.client.token}:gameMode`)\n\n // Use the lobby type from Redis if it exists (including 0)\n // Otherwise default to ranked\n const localLobbyType = playingLobbyType ?? LOBBY_TYPE_RANKED\n\n const isParty = getValueOrDefault(DBSettings.onlyParty, this.client.settings)\n\n await this.updateMMR({\n // 22 is game mode for normal game non turbo\n gameMode: playingGameMode ?? 22,\n heroName,\n heroSlot,\n increase: won,\n isParty,\n lobbyType: localLobbyType,\n matchId,\n myTeam,\n scores,\n })\n\n const response = await getRankDetail(this.getMmr(), this.getSteam32())\n if (\n this.client.steam32Id !== null &&\n this.client.steam32Id !== 0 &&\n response !== null &&\n 'standing' in response\n ) {\n await supabase\n .from('steam_accounts')\n .update({ leaderboard_rank: response.standing, updated_at: new Date().toISOString() })\n .eq('steam32Id', this.client.steam32Id)\n }\n\n const TreadToggleData = this.treadsData\n const toggleHandler = async () => {\n const treadToggleData = await redisClient.getJson(\n `${this.client.token}:treadtoggle`\n )\n\n if (\n treadToggleData?.treadToggles !== null &&\n treadToggleData?.treadToggles !== undefined &&\n treadToggleData.treadToggles > 0 &&\n this.client.stream_online\n ) {\n say(\n this.client,\n t('treadToggle', {\n count: treadToggleData.treadToggles,\n lng: this.client.locale,\n manaCount: treadToggleData.manaSaved,\n matchId,\n })\n )\n }\n }\n\n try {\n void toggleHandler()\n } catch (error) {\n logger.error('err toggleHandler', { error })\n }\n\n let predictionId: string | null = null\n if (betsEnabled) {\n try {\n const predictionResponse = await supabase\n .from('matches')\n .select('predictionId')\n .eq('matchId', matchId.toString())\n .eq('userId', this.client.token)\n .single()\n\n if (\n !predictionResponse.error &&\n typeof predictionResponse.data?.predictionId === 'string' &&\n predictionResponse.data.predictionId.length > 0\n ) {\n predictionId = predictionResponse.data.predictionId\n } else {\n logger.info('[BETS] Skipping Twitch closure because predictionId is unavailable', {\n channel,\n error: predictionResponse.error?.message,\n matchId,\n })\n }\n } catch (error) {\n logger.info('[BETS] Skipping Twitch closure because predictionId is unreadable', {\n channel,\n error: error instanceof Error ? error.message : error,\n matchId,\n })\n }\n }\n\n delayedQueue.addTask(getStreamDelay(this.client.settings, this.client.subscription), () => {\n const message = won\n ? t('bets.won', { emote: 'Happi', lng: this.client.locale })\n : t('bets.lost', { emote: 'Happi', lng: this.client.locale })\n\n say(this.client, message, { chattersKey: 'matchOutcome', delay: false })\n\n if (\n !betsEnabled ||\n predictionId === null ||\n predictionId === undefined ||\n predictionId.length === 0\n ) {\n logger.debug('Bets are not enabled or no prediction was opened, stopping here', {\n name: this.client.name,\n })\n this.resetClientState().catch(() => {\n //\n })\n return\n }\n\n closeTwitchBet(\n won,\n this.getChannelId(),\n matchId,\n this.client.settings,\n this.client.subscription\n )\n .then(() => {\n logger.info('[BETS] end bets', {\n didWin: won,\n event: 'end_bets',\n matchId,\n name: this.client.name,\n player_team: myTeam,\n winning_team: localWinner,\n })\n })\n .catch((error: unknown) => {\n logger.error('[BETS] Error closing twitch bet', {\n channel,\n e: error instanceof Error ? error.message : error,\n matchId,\n })\n })\n .finally(() => {\n this.resetClientState().catch((error) => {\n logger.error('Error resetting client state', { error })\n })\n })\n })\n } catch (error) {\n logger.error('Error closing bets', { error, name: this.client.name })\n } finally {\n this.endingBets = false\n }\n }" } ], - "message": "Expected === and instead saw ==", - "severity": "error" - } - }, - "b8baf7c6accaffc741dbb15d6d5a7ccedbbe68da459a95e3b9bbbe6658030f2e": { - "count": 1, - "diagnostic": { - "code": "anti-slop(no-module-mocking)", - "file": "packages/shared-utils/tests/setup-mocks.ts", - "labels": [ - { - "context": ["", "vi.doMock('../src/db/supabase', () => ({", "default: supabaseMock,"], - "message": "", - "span": "vi.doMock('../src/db/supabase', () => ({\n default: supabaseMock,\n getSupabaseClient: () => supabaseMock,\n supabase: supabaseMock,\n}))" - } - ], - "message": "Replace module mocking with dependency injection through a real interface, service layer, or faithful test implementation.", + "message": "async method `closeBets` has a complexity of 86. Maximum allowed is 20.", "severity": "error" } }, @@ -27968,26 +26215,6 @@ "severity": "error" } }, - "bc26fdbc1d00abe0b693870b2b7b1903731b46e199a79ffe4745ed4652f5b0bc": { - "count": 1, - "diagnostic": { - "code": "eslint(complexity)", - "file": "packages/dota/src/dota/gsi-handler.ts", - "labels": [ - { - "context": [ - "", - "const attemptFetchMatchData = async (): Promise => {", - "// Check if they rejoined the match they disconnected from" - ], - "message": "", - "span": "async (): Promise => {\n // Check if they rejoined the match they disconnected from\n if (this.client.gsi?.map?.matchid === matchId) {\n logger.info('[BETS] Streamer rejoined the match, skipping early DC winner check', {\n matchId,\n name: this.client.name,\n })\n return\n }\n\n // Check if the bet for this match is already closed in the database\n const { data: matchNotEnded, error } = await supabase\n .from('matches')\n .select('won')\n // Null means there is a winner of this match\n .is('won', null)\n .eq('matchId', matchId.toString())\n .eq('userId', this.client.token)\n .single()\n\n if (matchNotEnded == null || error !== null) {\n logger.info('[BETS] Match already ended, skipping early DC winner check', {\n matchId,\n name: this.client.name,\n })\n return\n }\n\n if (retryCount >= MAX_RETRIES) {\n // Handle exhausting all retries - prompt for manual resolution instead of refunding\n if (this.client.stream_online) {\n logger.info(\n 'Exceeded maximum retries for early DC match check, prompting for manual resolution',\n {\n matchId,\n name: this.client.name,\n }\n )\n\n // Set pending manual resolution flag in Redis\n await redisClient.client.set(\n `${this.client.token}:pendingManualResolution`,\n JSON.stringify({ matchId, timestamp: Date.now() })\n )\n\n // Send chat message to notify mods\n const tellChatBets = getValueOrDefault(\n DBSettings.tellChatBets,\n this.client.settings,\n this.client.subscription\n )\n if (tellChatBets) {\n say(\n this.client,\n t('bets.manualResolution', {\n details: formatUnresolvedMatch(snapshotMatch),\n emote: 'PauseChamp',\n lng: this.client.locale,\n matchId,\n })\n )\n }\n }\n\n // Reset the flag since we've exhausted retries\n this.checkingEarlyDCWinner = false\n return\n }\n\n try {\n // Request match data from Steam socket\n const getMatchDetailsPromise = new Promise(\n (resolve, reject) => {\n steamSocket.emit(\n 'getMatchMinimalDetails',\n { match_id: Number(matchId) },\n (err: unknown, response: MatchMinimalDetailsResponse) => {\n if (err != null) {\n reject(err)\n } else {\n resolve(response)\n }\n }\n )\n }\n )\n\n const response = await getMatchDetailsPromise\n const matchData = response?.matches?.[0]\n\n // Check if we got a valid response with match outcome\n if (\n matchData !== undefined &&\n typeof matchData.match_outcome === 'number' &&\n [\n EMatchOutcome.k_EMatchOutcome_RadVictory,\n EMatchOutcome.k_EMatchOutcome_DireVictory,\n ].includes(matchData.match_outcome)\n ) {\n logger.info('Successfully retrieved match result for early DC', {\n matchId,\n matchOutcome: matchData.match_outcome,\n name: this.client.name,\n })\n\n // Determine winner based on match outcome\n // k_EMatchOutcome_RadVictory = 2, k_EMatchOutcome_DireVictory = 3\n const winningTeam =\n matchData.match_outcome === EMatchOutcome.k_EMatchOutcome_RadVictory\n ? 'radiant'\n : 'dire'\n\n // Reset flag before calling closeBets to prevent duplicate calls from closeBets\n this.checkingEarlyDCWinner = false\n await this.closeBets(winningTeam, response)\n } else if (\n matchData !== undefined &&\n typeof matchData.match_outcome === 'number' &&\n matchData.match_outcome > EMatchOutcome.k_EMatchOutcome_DireVictory\n ) {\n // Not scored\n logger.info('Match not scored, skipping early DC winner check', {\n matchId,\n matchOutcome: matchData.match_outcome,\n name: this.client.name,\n })\n\n // Reset flag before calling closeBets to prevent duplicate calls from closeBets\n this.checkingEarlyDCWinner = false\n logger.info('This is likely a no stats recorded match', {\n matchId,\n name: this.client.name,\n })\n\n if (this.client.stream_online) {\n say(\n this.client,\n t('bets.notScored', {\n emote: 'D:',\n key: DBSettings.tellChatBets,\n lng: this.client.locale,\n matchId,\n })\n )\n const predictionResponse = await supabase\n .from('matches')\n .select('predictionId')\n .eq('matchId', matchId.toString())\n .eq('userId', this.client.token)\n .is('won', null)\n .single()\n if (\n predictionResponse.data?.predictionId != null &&\n predictionResponse.data.predictionId.length > 0\n ) {\n const oldBetId = await refundTwitchBet(\n this.getChannelId(),\n predictionResponse.data.predictionId\n )\n if (oldBetId != null && oldBetId.length > 0) {\n await supabase\n .from('matches')\n .update({ predictionId: null, updated_at: new Date().toISOString() })\n .eq('predictionId', oldBetId)\n }\n }\n }\n // No-stats match can never be resolved with !won/!lost; don't nag for it.\n await this.suppressUnresolvedReminder(matchId)\n await this.resetClientState()\n return\n } else {\n // Invalid response, retry after delay\n retryCount += 1\n logger.info('Invalid match data response, scheduling retry', {\n matchId,\n maxRetries: MAX_RETRIES,\n name: this.client.name,\n response,\n retryCount,\n })\n\n setTimeout(attemptFetchMatchData, RETRY_DELAY)\n }\n } catch (error) {\n // Error occurred, retry after delay\n retryCount += 1\n logger.error('Error in early DC match check, scheduling retry', {\n error,\n matchId,\n maxRetries: MAX_RETRIES,\n name: this.client.name,\n retryCount,\n })\n\n setTimeout(attemptFetchMatchData, RETRY_DELAY)\n }\n }" - } - ], - "message": "async function has a complexity of 25. Maximum allowed is 20.", - "severity": "error" - } - }, "bc8dd4abdb1c985f677a0170f34e4526b7c76209b313d10aae2720b3d512d84e": { "count": 1, "diagnostic": { @@ -28120,43 +26347,43 @@ "severity": "error" } }, - "be2346f20c871de4c8db417bda4290a77b50d30c4372acfbcd0ac83548ed8d8c": { + "bdfa191397c8630f5867960dc2e78fcb967dbb5c9f501e99b8d3354b23470bad": { "count": 1, "diagnostic": { - "code": "unicorn(no-object-as-default-parameter)", - "file": "packages/dota/src/twitch/lib/resolve-match.ts", + "code": "eslint(no-await-in-loop)", + "file": "packages/dota/src/dota/lib/announce-features.ts", "labels": [ { "context": [ - "streamStartDate: Date | null,", - "opts: { limit: number; excludeMatchId?: string } = { limit: 5 }", - "): Promise {" + "if (await announceFeatureOnce(client, feature)) {", + "await redisClient.client.set(guardKey, matchId)", + "return" ], "message": "", - "span": "{ limit: 5 }" + "span": "await" } ], - "message": "Do not use an object literal as default for parameter `opts`.", + "message": "Unexpected `await` inside a loop.", "severity": "error" } }, - "bea3acf97156e194833a463fe890173cfc92c4b2d198340a2fc55391a082942d": { + "be2346f20c871de4c8db417bda4290a77b50d30c4372acfbcd0ac83548ed8d8c": { "count": 1, "diagnostic": { - "code": "eslint(eqeqeq)", - "file": "packages/dota/src/dota/gsi-handler.ts", + "code": "unicorn(no-object-as-default-parameter)", + "file": "packages/dota/src/twitch/lib/resolve-match.ts", "labels": [ { "context": [ - "const playerName = this.client.gsi?.player?.name", - "const name = playerName != null && playerName.length > 0 ? playerName : null", - "const { error } = await supabase.from('steam_accounts').insert({" + "streamStartDate: Date | null,", + "opts: { limit: number; excludeMatchId?: string } = { limit: 5 }", + "): Promise {" ], "message": "", - "span": "!=" + "span": "{ limit: 5 }" } ], - "message": "Expected !== and instead saw !=", + "message": "Do not use an object literal as default for parameter `opts`.", "severity": "error" } }, @@ -28592,6 +26819,22 @@ "severity": "error" } }, + "c1376fc2e63d2f46ed0bf4d5e93d904e7c28cda426d0f2cfe69b95f99237fdc1": { + "count": 1, + "diagnostic": { + "code": "promise(no-multiple-resolved)", + "file": "packages/steam/src/steam.ts", + "labels": [ + { + "context": ["}", "resolve(data)", "}"], + "message": "", + "span": "resolve(data)" + } + ], + "message": "Promise should not be resolved multiple times. Promise is potentially resolved on line 875.", + "severity": "error" + } + }, "c14126100713d389d2e5537ef4b297f11132a2f400ccbfe684499f304bd4c914": { "count": 1, "diagnostic": { @@ -28708,6 +26951,26 @@ "severity": "error" } }, + "c1bad03f797692597d7878191d40f48444a1ea9d83ec315e2a8bf211fb2822f6": { + "count": 1, + "diagnostic": { + "code": "typescript(strict-void-return)", + "file": "packages/dota/src/dota/events/gsi-events/event.chat_message.ts", + "labels": [ + { + "context": [ + "// Set timeout if not already set", + "buffer.timeout ??= setTimeout(async () => {", + "const currentBuffer = translationBuffers.get(clientKey)" + ], + "message": "", + "span": "async () => {\n const currentBuffer = translationBuffers.get(clientKey)\n if (currentBuffer) {\n await processTranslationBuffer(\n currentBuffer.messages,\n dotaClient,\n translateInChat,\n translateOnOverlay,\n typedLanguage\n )\n translationBuffers.delete(clientKey)\n }\n }" + } + ], + "message": "Async function used in a context where a void function is expected.", + "severity": "error" + } + }, "c1be837883a23dba092b7a5ec8a950faa4b17e83424be98156f4813ffdb7c760": { "count": 1, "diagnostic": { @@ -28796,26 +27059,6 @@ "severity": "error" } }, - "c2750bbaa9a765b58474344ce52b3496801ab2dde355b2151abaad56c20b2b85": { - "count": 1, - "diagnostic": { - "code": "typescript(no-misused-promises)", - "file": "packages/dota/src/dota/events/gsi-events/event.chat_message.ts", - "labels": [ - { - "context": [ - "if (!buffer.timeout) {", - "buffer.timeout = setTimeout(async () => {", - "const currentBuffer = translationBuffers.get(clientKey)" - ], - "message": "", - "span": "async () => {\n const currentBuffer = translationBuffers.get(clientKey)\n if (currentBuffer) {\n await processTranslationBuffer(\n currentBuffer.messages,\n dotaClient,\n translateInChat,\n translateOnOverlay,\n typedLanguage\n )\n translationBuffers.delete(clientKey)\n }\n }" - } - ], - "message": "Promise returned in function argument where a void return was expected.", - "severity": "error" - } - }, "c2959e4c15c364a0c7f61dc0daf0278b9dd47c5295fdcf7d70326f0b56895d0c": { "count": 1, "diagnostic": { @@ -28872,26 +27115,6 @@ "severity": "error" } }, - "c2f8d37007434619669a730936e70ce160c2e6fc82df960d4c120250d8d7afce": { - "count": 1, - "diagnostic": { - "code": "eslint(eqeqeq)", - "file": "packages/dota/src/dota/gsi-handler.ts", - "labels": [ - { - "context": [ - "// Check if this bet for this match id already exists, dont continue if it does", - "if (bet?.[0]?.id != null && bet[0].id.length > 0) {", - "logger.info('[BETS] Found a bet in the database', { id: bet?.[0]?.id })" - ], - "message": "", - "span": "!=" - } - ], - "message": "Expected !== and instead saw !=", - "severity": "error" - } - }, "c3304ee86852eff0094dc948baa502c07446a12b74841595dfb1546cb59d86cf": { "count": 1, "diagnostic": { @@ -29452,6 +27675,26 @@ "severity": "error" } }, + "c760b92d18c76b13fb900da386ba8fcf8e0e87766c30d2981bbd9be7e21b883f": { + "count": 1, + "diagnostic": { + "code": "eslint(complexity)", + "file": "packages/dota/src/dota/gsi-handler.ts", + "labels": [ + { + "context": [ + "", + "private async checkEarlyDCWinner(matchId: string | number) {", + "// Prevent multiple concurrent early DC winner checks" + ], + "message": "", + "span": "(matchId: string | number) {\n // Prevent multiple concurrent early DC winner checks\n if (this.checkingEarlyDCWinner) {\n logger.info('[BETS] Already checking early DC winner, skipping duplicate call', {\n matchId,\n name: this.client.name,\n })\n return\n }\n\n this.checkingEarlyDCWinner = true\n\n // Check if the bet for this match is already closed in the database\n const { data: matchData, error } = await supabase\n .from('matches')\n .select('won')\n .is('won', null)\n .eq('matchId', matchId.toString())\n .eq('userId', this.client.token)\n .single()\n\n if (error !== null || matchData === null || matchData === undefined) {\n logger.info('[BETS] Match already closed or not found, skipping early DC winner check', {\n error: error?.message,\n matchId,\n name: this.client.name,\n })\n this.checkingEarlyDCWinner = false\n return\n }\n\n logger.info('[BETS] Streamer exited the match before it ended with a winner', {\n endingBets: this.endingBets,\n matchId,\n name: this.client.name,\n openingBets: this.openingBets,\n })\n\n // Persist a snapshot so unresolved-match messages can show hero / KDA /\n // score / length. The live packet that triggered the DC has usually shed\n // these values (hero/player empty, scores back to 0), so the merge prefers\n // the cached last-in-game snapshot for this match.\n const cached =\n this.lastInGameSnapshot?.matchId === matchId.toString() ? this.lastInGameSnapshot : null\n const snapshotMatch = buildUnresolvedSnapshot({\n cached,\n gsi: this.client.gsi,\n matchId: matchId.toString(),\n now: new Date(),\n })\n // Only write while still unresolved and only if no snapshot exists yet, so a\n // concurrent resolution can't be clobbered and re-entry can't reset updated_at\n // (the reminder's 10-minute anchor). hero_name is set at bet-open and again\n // on hero swap — only overwrite it when we actually have one.\n const kdaForDb = {\n assists: snapshotMatch.kda?.assists ?? null,\n deaths: snapshotMatch.kda?.deaths ?? null,\n duration: snapshotMatch.kda?.duration ?? null,\n kills: snapshotMatch.kda?.kills ?? null,\n }\n await supabase\n .from('matches')\n .update({\n ...(snapshotMatch.hero_name !== null &&\n snapshotMatch.hero_name !== undefined &&\n snapshotMatch.hero_name.length > 0\n ? { hero_name: snapshotMatch.hero_name }\n : {}),\n dire_score: snapshotMatch.dire_score,\n kda: kdaForDb,\n radiant_score: snapshotMatch.radiant_score,\n updated_at: snapshotMatch.updated_at,\n })\n .match({ matchId: matchId.toString(), userId: this.client.token })\n .is('won', null)\n .is('kda', null)\n\n // Check if player is high MMR (8500+)\n const isHighMmr = is8500Plus(this.client)\n\n if (isHighMmr) {\n // For high MMR players, skip automatic retries and prompt for manual resolution\n logger.info('[BETS] High MMR player detected, prompting for manual resolution', {\n matchId,\n mmr: this.getMmr(),\n name: this.client.name,\n })\n\n // Set pending manual resolution flag in Redis\n await redisClient.client.set(\n `${this.client.token}:pendingManualResolution`,\n JSON.stringify({ matchId, timestamp: Date.now() })\n )\n\n // Send chat message to notify mods\n const tellChatBets = getValueOrDefault(\n DBSettings.tellChatBets,\n this.client.settings,\n this.client.subscription\n )\n if (tellChatBets && this.client.stream_online) {\n say(\n this.client,\n t('bets.manualResolution', {\n details: formatUnresolvedMatch(snapshotMatch),\n emote: 'PauseChamp',\n lng: this.client.locale,\n matchId,\n })\n )\n }\n\n this.checkingEarlyDCWinner = false\n return\n }\n\n // Set up retry parameters for lower MMR players\n // Try up to 5 times\n const MAX_RETRIES = 5\n // 30 seconds between retries (total 2.5 minutes)\n const RETRY_DELAY = 30_000\n let retryCount = 0\n\n const attemptFetchMatchData = async (): Promise => {\n // Check if they rejoined the match they disconnected from\n if (this.client.gsi?.map?.matchid === matchId) {\n logger.info('[BETS] Streamer rejoined the match, skipping early DC winner check', {\n matchId,\n name: this.client.name,\n })\n return\n }\n\n // Check if the bet for this match is already closed in the database\n const { data: matchNotEnded, error } = await supabase\n .from('matches')\n .select('won')\n // Null means there is a winner of this match\n .is('won', null)\n .eq('matchId', matchId.toString())\n .eq('userId', this.client.token)\n .single()\n\n if (matchNotEnded === null || matchNotEnded === undefined || error !== null) {\n logger.info('[BETS] Match already ended, skipping early DC winner check', {\n matchId,\n name: this.client.name,\n })\n return\n }\n\n if (retryCount >= MAX_RETRIES) {\n // Handle exhausting all retries - prompt for manual resolution instead of refunding\n if (this.client.stream_online) {\n logger.info(\n 'Exceeded maximum retries for early DC match check, prompting for manual resolution',\n {\n matchId,\n name: this.client.name,\n }\n )\n\n // Set pending manual resolution flag in Redis\n await redisClient.client.set(\n `${this.client.token}:pendingManualResolution`,\n JSON.stringify({ matchId, timestamp: Date.now() })\n )\n\n // Send chat message to notify mods\n const tellChatBets = getValueOrDefault(\n DBSettings.tellChatBets,\n this.client.settings,\n this.client.subscription\n )\n if (tellChatBets) {\n say(\n this.client,\n t('bets.manualResolution', {\n details: formatUnresolvedMatch(snapshotMatch),\n emote: 'PauseChamp',\n lng: this.client.locale,\n matchId,\n })\n )\n }\n }\n\n // Reset the flag since we've exhausted retries\n this.checkingEarlyDCWinner = false\n return\n }\n\n try {\n // Request match data from Steam socket\n const getMatchDetailsPromise = new Promise(\n (resolve, reject) => {\n steamSocket.emit(\n 'getMatchMinimalDetails',\n { match_id: Number(matchId) },\n (err: unknown, response: MatchMinimalDetailsResponse) => {\n if (err !== null && err !== undefined) {\n reject(err)\n } else {\n resolve(response)\n }\n }\n )\n }\n )\n\n const response = await getMatchDetailsPromise\n const matchData = response?.matches?.[0]\n\n // Check if we got a valid response with match outcome\n if (\n matchData !== undefined &&\n typeof matchData.match_outcome === 'number' &&\n [\n EMatchOutcome.k_EMatchOutcome_RadVictory,\n EMatchOutcome.k_EMatchOutcome_DireVictory,\n ].includes(matchData.match_outcome)\n ) {\n logger.info('Successfully retrieved match result for early DC', {\n matchId,\n matchOutcome: matchData.match_outcome,\n name: this.client.name,\n })\n\n // Determine winner based on match outcome\n // k_EMatchOutcome_RadVictory = 2, k_EMatchOutcome_DireVictory = 3\n const winningTeam =\n matchData.match_outcome === EMatchOutcome.k_EMatchOutcome_RadVictory\n ? 'radiant'\n : 'dire'\n\n // Reset flag before calling closeBets to prevent duplicate calls from closeBets\n this.checkingEarlyDCWinner = false\n await this.closeBets(winningTeam, response)\n } else if (\n matchData !== undefined &&\n typeof matchData.match_outcome === 'number' &&\n matchData.match_outcome > EMatchOutcome.k_EMatchOutcome_DireVictory\n ) {\n // Not scored\n logger.info('Match not scored, skipping early DC winner check', {\n matchId,\n matchOutcome: matchData.match_outcome,\n name: this.client.name,\n })\n\n // Reset flag before calling closeBets to prevent duplicate calls from closeBets\n this.checkingEarlyDCWinner = false\n logger.info('This is likely a no stats recorded match', {\n matchId,\n name: this.client.name,\n })\n\n if (this.client.stream_online) {\n say(\n this.client,\n t('bets.notScored', {\n emote: 'D:',\n key: DBSettings.tellChatBets,\n lng: this.client.locale,\n matchId,\n })\n )\n const predictionResponse = await supabase\n .from('matches')\n .select('predictionId')\n .eq('matchId', matchId.toString())\n .eq('userId', this.client.token)\n .is('won', null)\n .single()\n if (\n predictionResponse.data?.predictionId !== null &&\n predictionResponse.data?.predictionId !== undefined &&\n predictionResponse.data.predictionId.length > 0\n ) {\n const oldBetId = await refundTwitchBet(\n this.getChannelId(),\n predictionResponse.data.predictionId\n )\n if (oldBetId !== null && oldBetId !== undefined && oldBetId.length > 0) {\n await supabase\n .from('matches')\n .update({ predictionId: null, updated_at: new Date().toISOString() })\n .eq('predictionId', oldBetId)\n }\n }\n }\n // No-stats match can never be resolved with !won/!lost; don't nag for it.\n await this.suppressUnresolvedReminder(matchId)\n await this.resetClientState()\n return\n } else {\n // Invalid response, retry after delay\n retryCount += 1\n logger.info('Invalid match data response, scheduling retry', {\n matchId,\n maxRetries: MAX_RETRIES,\n name: this.client.name,\n response,\n retryCount,\n })\n\n setTimeout(attemptFetchMatchData, RETRY_DELAY)\n }\n } catch (error) {\n // Error occurred, retry after delay\n retryCount += 1\n logger.error('Error in early DC match check, scheduling retry', {\n error,\n matchId,\n maxRetries: MAX_RETRIES,\n name: this.client.name,\n retryCount,\n })\n\n setTimeout(attemptFetchMatchData, RETRY_DELAY)\n }\n }\n\n try {\n // Start the first attempt\n await attemptFetchMatchData()\n } catch (error) {\n // If any uncaught error occurs, reset the flag\n logger.error('Uncaught error in checkEarlyDCWinner', {\n error,\n matchId,\n name: this.client.name,\n })\n this.checkingEarlyDCWinner = false\n }\n }" + } + ], + "message": "private async method `checkEarlyDCWinner` has a complexity of 23. Maximum allowed is 20.", + "severity": "error" + } + }, "c7733173c1c6f9e18bd97ad6d7a282f874ffc11311d66414a0616a8b4ae333fd": { "count": 1, "diagnostic": { @@ -29484,22 +27727,6 @@ "severity": "error" } }, - "c7b2a60c42043328bc0802ba3567df73d889bd6fb5087294b65cb97e5d49305a": { - "count": 1, - "diagnostic": { - "code": "anti-slop(no-module-mocking)", - "file": "packages/twitch-chat/src/__tests__/shared-mocks.ts", - "labels": [ - { - "context": ["", "vi.doMock('ws', () => ({ default: FakeWebSocket }))", ""], - "message": "", - "span": "vi.doMock('ws', () => ({ default: FakeWebSocket }))" - } - ], - "message": "Replace module mocking with dependency injection through a real interface, service layer, or faithful test implementation.", - "severity": "error" - } - }, "c7db123f47d0fede44d85a56b8f12ecdcb556af6826a8c33f478053678556cb5": { "count": 1, "diagnostic": { @@ -29541,6 +27768,26 @@ "severity": "error" } }, + "c83e1e144fc1eb6bdddbfe0d2978b23a5f09477f75289b1612028f3fd00cfa2e": { + "count": 1, + "diagnostic": { + "code": "promise(avoid-new)", + "file": "packages/dota/src/dota/events/gsi-events/newdata.ts", + "labels": [ + { + "context": [ + "try {", + "const getDelayedDataPromise = new Promise((resolve, reject) => {", + "const timeoutId = setTimeout(() => {" + ], + "message": "", + "span": "new Promise((resolve, reject) => {\n const timeoutId = setTimeout(() => {\n reject(new CustomError(t('matchData8500', { emote: 'PoroSad', lng: client.locale })))\n // 10 second timeout\n }, 10_000)\n\n steamSocket.emit(\n 'getRealTimeStats',\n {\n match_id: matchId,\n refetchCards: true,\n steam_server_id: currentSteamServerId,\n token: client.token,\n },\n (err: unknown, data: DelayedGames) => {\n clearTimeout(timeoutId)\n if (err !== null && err !== undefined) {\n reject(err)\n } else {\n resolve(data)\n }\n }\n )\n })" + } + ], + "message": "Avoid creating new promises", + "severity": "error" + } + }, "c875c9bfa8180c0b0bf844048919aefaad616d1e1dbad7bb38233296b1421186": { "count": 1, "diagnostic": { @@ -29794,26 +28041,6 @@ "severity": "error" } }, - "c9c6b0f0bb3fb84a1a9b98d6d0313feaf304b94caa45902a21d146aff3f07a67": { - "count": 1, - "diagnostic": { - "code": "eslint(no-eq-null)", - "file": "packages/dota/src/dota/gsi-handler.ts", - "labels": [ - { - "context": [ - "const playerName = this.client.gsi?.player?.name", - "const name = playerName != null && playerName.length > 0 ? playerName : null", - "const { error } = await supabase.from('steam_accounts').insert({" - ], - "message": "", - "span": "playerName != null" - } - ], - "message": "Do not use `null` comparisons without type-checking operators.", - "severity": "error" - } - }, "c9c92e4a81370131e9bc67ddc6b14118718216f5f32e5c47f653c0fb5cc893f0": { "count": 1, "diagnostic": { @@ -30035,6 +28262,26 @@ "severity": "error" } }, + "caa742ad15539b5fe40326bb7f2af1bdbb0ed1bd9f4fdefa4bdb3d31022aa464": { + "count": 1, + "diagnostic": { + "code": "typescript(strict-void-return)", + "file": "packages/dota/src/db/watcher.ts", + "labels": [ + { + "context": [ + "{ event: 'INSERT', schema: 'public', table: 'gift_subscriptions' },", + "async (payload: { new: Tables<'gift_subscriptions'> }) => {", + "const newObj = payload.new" + ], + "message": "", + "span": "async (payload: { new: Tables<'gift_subscriptions'> }) => {\n const newObj = payload.new\n // Fetch the subscription details to get the userId\n const { data: subscriptionData, error: subError } = await supabase\n .from('subscriptions')\n .select('userId')\n .eq('id', newObj.subscriptionId)\n .eq('isGift', true)\n // Use maybeSingle to handle potential null result gracefully\n .maybeSingle()\n\n if (subError || !subscriptionData) {\n logger.error('Error fetching subscription or subscription not found for gift', {\n error: subError,\n giftId: newObj.id,\n subscriptionId: newObj.subscriptionId,\n })\n return\n }\n\n const client = findUser(subscriptionData.userId)\n\n // Only proceed if the client is found and currently considered online\n if (client === null || client.stream_online !== true) {\n logger.info('Gift notification skipped: Client not found or not online', {\n found: client !== null,\n online: client?.stream_online,\n userId: subscriptionData.userId,\n })\n return\n }\n\n try {\n // Calculate duration string\n let durationString = ''\n const giftQuantityRaw = newObj.giftQuantity\n\n // Check if giftQuantityRaw is a valid number representation (string or number) and positive\n const giftQuantityNum = Number(giftQuantityRaw)\n const isValidQuantity = !Number.isNaN(giftQuantityNum) && giftQuantityNum > 0\n\n if (isValidQuantity) {\n const { giftType } = newObj\n\n if (giftType) {\n if (giftType === 'monthly') {\n durationString =\n giftQuantityNum === 1 ? '(1 month)' : `(${giftQuantityNum} months)`\n } else if (giftType === 'annual') {\n durationString = giftQuantityNum === 1 ? '(1 year)' : `(${giftQuantityNum} years)`\n } else if (giftType === 'lifetime') {\n durationString = '(Lifetime)'\n }\n // Add more gift types here if necessary\n } else {\n logger.warn('Gift type missing, cannot determine duration string', {\n giftId: newObj.id,\n giftQuantity: giftQuantityNum,\n })\n }\n } else if (giftQuantityRaw !== null && giftQuantityRaw !== undefined) {\n // Log only if it was provided but invalid\n logger.warn('Gift quantity is invalid or not positive', {\n giftId: newObj.id,\n giftQuantity: giftQuantityRaw,\n })\n }\n // If quantity is null/undefined, we just don't add a duration string silently.\n\n // Construct the base message using translation keys\n const baseMessage = newObj.senderName\n ? t('giftSub', {\n lng: client.locale,\n senderName: newObj.senderName,\n })\n : t('giftSubAnonymous', {\n lng: client.locale,\n })\n\n // Prepare optional details parts\n const detailsParts: string[] = []\n if (durationString) {\n detailsParts.push(durationString)\n }\n if (isNonEmptyString(newObj.giftMessage)) {\n // Ensure message is trimmed and quoted\n const trimmedMessage = String(newObj.giftMessage).trim()\n if (trimmedMessage.length > 0) {\n detailsParts.push(`\"${trimmedMessage}\"`)\n }\n }\n\n // Combine base message and details with proper spacing\n let fullMessage = baseMessage\n if (detailsParts.length > 0) {\n fullMessage += ` ${detailsParts.join(' ')}`\n }\n\n // Send notification message to chat\n // Add logging\n logger.info(`Sending gift notification: ${fullMessage}`)\n chatClient.say(client.name, fullMessage)\n } catch (error) {\n logger.error('Error constructing or sending gift notification to chat', {\n error,\n giftId: newObj.id,\n userId: client.token,\n })\n }\n }" + } + ], + "message": "Async function used in a context where a void function is expected.", + "severity": "error" + } + }, "cac723126f31472bdd7e39eafd95456e7665e705f415fa4f9e59535a1d7dd92b": { "count": 1, "diagnostic": { @@ -30175,46 +28422,6 @@ "severity": "error" } }, - "cb6145842bf0d7e56020c637d5b283b179d519444019835babeb3209d469746c": { - "count": 1, - "diagnostic": { - "code": "sonarjs(cognitive-complexity)", - "file": "packages/dota/src/db/watcher.ts", - "labels": [ - { - "context": [ - "{ event: 'INSERT', schema: 'public', table: 'gift_subscriptions' },", - "async (payload: { new: Tables<'gift_subscriptions'> }) => {", - "const newObj = payload.new" - ], - "message": "", - "span": "=>" - } - ], - "message": "Refactor this function to reduce its Cognitive Complexity from 28 to the 20 allowed.", - "severity": "error" - } - }, - "cb9311ff18d11624e29a3f1f85341bbdb9a2503a8a6944681d917277b97ce58f": { - "count": 1, - "diagnostic": { - "code": "eslint(complexity)", - "file": "packages/twitch-chat/src/handle-chat.ts", - "labels": [ - { - "context": [ - "", - "export const sendTwitchChatMessage = async function sendTwitchChatMessage(", - "params: SendChatMessageParams" - ], - "message": "", - "span": "async function sendTwitchChatMessage(\n params: SendChatMessageParams\n): Promise {\n const message = fitTwitchChatMessage(params.message)\n\n // Check if this broadcaster is currently being disabled to prevent race condition\n if (isBroadcasterBeingDisabled(params.broadcaster_id)) {\n logger.info('[DISABLE_CACHE] Skipping chat message for broadcaster being disabled', {\n broadcaster_id: params.broadcaster_id,\n message: params.message,\n })\n\n return {\n data: [\n {\n drop_reason: {\n code: 'user_being_disabled',\n message:\n 'User is currently being disabled, skipping chat message to prevent race condition',\n },\n is_sent: false,\n message_id: '',\n },\n ],\n }\n }\n\n // Check for duplicate replies within the dedupe window. The parent message is the only proof\n // that two sends came from the same command event; unthreaded messages must not be collapsed\n // merely because their text matches.\n const dedupeKey =\n params.reply_parent_message_id !== undefined && params.reply_parent_message_id.length > 0\n ? `${params.broadcaster_id}:${params.reply_parent_message_id}:${params.message}`\n : undefined\n const now = Date.now()\n const lastSent = dedupeKey === undefined ? undefined : messageDedupeCache.get(dedupeKey)\n\n if (lastSent !== undefined && lastSent !== 0 && now - lastSent < DEDUPE_WINDOW_MS) {\n logger.info('[DEDUPE] Dropping duplicate chat message', {\n broadcaster_id: params.broadcaster_id,\n last_sent_ms_ago: now - lastSent,\n message: params.message,\n })\n\n return {\n data: [\n {\n drop_reason: {\n code: 'duplicate_message',\n message: `Duplicate message dropped (sent ${now - lastSent}ms ago): ${params.message}`,\n },\n is_sent: false,\n message_id: '',\n },\n ],\n }\n }\n\n // Record this message in the cache\n if (dedupeKey !== undefined) {\n messageDedupeCache.set(dedupeKey, now)\n }\n\n const url = 'https://api.twitch.tv/helix/chat/messages'\n // Only the bot can send messages\n // Or a user with \"user:bot\" scope\n const headers = await getTwitchHeaders(params.sender_id)\n const options = {\n body: JSON.stringify({ ...params, message }),\n headers: { ...headers, 'Content-Type': 'application/json' },\n method: 'POST',\n }\n\n try {\n const response = await fetch(url, options)\n\n if (!response.ok) {\n let errorMessage = `Failed to send chat message: ${response.status} ${response.statusText}`\n let dropReasonCode = 'send_error'\n\n // Handle rate limiting specifically\n if (response.status === 429) {\n dropReasonCode = 'rate_limited'\n errorMessage = `Rate limited: ${response.status} ${response.statusText}`\n }\n\n // Try to read the response body for more details\n try {\n const errorBody = await response.text()\n if (errorBody) {\n errorMessage += ` - ${errorBody}`\n }\n } catch {\n // If we can't read the body, continue with the basic error\n }\n\n return {\n data: [\n {\n drop_reason: {\n code: dropReasonCode,\n message: errorMessage,\n },\n is_sent: false,\n message_id: '',\n },\n ],\n }\n }\n\n const result = (await response.json()) as TwitchChatMessageResponse\n if (result.data?.[0]?.drop_reason?.code !== 'msg_duplicate') {\n return result\n }\n\n const distinctMessage = makeDistinctTwitchChatMessage(message)\n logger.info('[DEDUPE] Retrying Twitch duplicate response with disambiguated text', {\n broadcaster_id: params.broadcaster_id,\n message: params.message,\n })\n\n const retryResponse = await fetch(url, {\n ...options,\n body: JSON.stringify({ ...params, message: distinctMessage }),\n })\n if (!retryResponse.ok) {\n return {\n data: [\n {\n drop_reason: {\n code: retryResponse.status === 429 ? 'rate_limited' : 'send_error',\n message: `Failed to send disambiguated chat message: ${retryResponse.status} ${retryResponse.statusText}`,\n },\n is_sent: false,\n message_id: '',\n },\n ],\n }\n }\n\n return retryResponse.json() as Promise\n } catch (error) {\n // If it's not an HTTP error we already handled, log and return a formatted error\n logger.error('Error sending chat message', { broadcaster_id: params.broadcaster_id, error })\n\n return {\n data: [\n {\n drop_reason: {\n code: 'send_error',\n message: error instanceof Error ? error.message : 'Unknown error',\n },\n is_sent: false,\n message_id: '',\n },\n ],\n }\n }\n}" - } - ], - "message": "async function `sendTwitchChatMessage` has a complexity of 21. Maximum allowed is 20.", - "severity": "error" - } - }, "cb9c2e795badf292b61b6c75249b1a659093b4a465137efcccdc1f217e5f1fb0": { "count": 1, "diagnostic": { @@ -30287,22 +28494,6 @@ "severity": "error" } }, - "cbcfa64ce3dafc435025d6f10cc631415b1766c9e158371ce40645252f0e8011": { - "count": 1, - "diagnostic": { - "code": "promise(no-multiple-resolved)", - "file": "packages/steam/src/steam.ts", - "labels": [ - { - "context": ["}", "resolve(data)", "}"], - "message": "", - "span": "resolve(data)" - } - ], - "message": "Promise should not be resolved multiple times. Promise is potentially resolved on line 877.", - "severity": "error" - } - }, "cbf5e55a6a11dec79f47e34e2ca1a9be983e6549d94f5371a82eae68c4e6f4ed": { "count": 2, "diagnostic": { @@ -30323,26 +28514,6 @@ "severity": "error" } }, - "cc4d9f0e86827813470aed25e9916f4f59cd9b39ba2d87e5ba5d342a23515a68": { - "count": 1, - "diagnostic": { - "code": "eslint(no-await-in-loop)", - "file": "packages/dota/src/dota/lib/announce-features.ts", - "labels": [ - { - "context": [ - "}", - "if (await announceFeatureOnce(client, feature)) {", - "await redisClient.client.set(guardKey, String(matchId))" - ], - "message": "", - "span": "await" - } - ], - "message": "Unexpected `await` inside a loop.", - "severity": "error" - } - }, "cc51c352bdc38e4098a81c2815821fd7b247b76cdf7252116691684c5af6947d": { "count": 1, "diagnostic": { @@ -30403,26 +28574,6 @@ "severity": "error" } }, - "cca324f9beeb60814d5e1732e966ede097331e495660b5e07bddf932209a5176": { - "count": 1, - "diagnostic": { - "code": "eslint(eqeqeq)", - "file": "packages/dota/src/db/watcher.ts", - "labels": [ - { - "context": [ - "}", - "} else if (giftQuantityRaw != null) {", - "// Log only if it was provided but invalid" - ], - "message": "", - "span": "!=" - } - ], - "message": "Expected !== and instead saw !=", - "severity": "error" - } - }, "ccb0b8f53019b554f5a86c56f2f39f580e57ae2f4711e871c87fb950332f1a04": { "count": 1, "diagnostic": { @@ -30439,26 +28590,6 @@ "severity": "error" } }, - "ccd95e770dc8dcbd2c42a597858328b324c5f03766ad193b4f5250f661d3970a": { - "count": 1, - "diagnostic": { - "code": "typescript(prefer-nullish-coalescing)", - "file": "packages/steam/src/socket-server.ts", - "labels": [ - { - "context": [ - "export const getSocketIoServer = function getSocketIoServer(): Server {", - "if (!_socketIoServer) {", - "_socketIoServer = createSocketServer()" - ], - "message": "", - "span": "if (!_socketIoServer) {\n _socketIoServer = createSocketServer()\n }" - } - ], - "message": "Prefer using nullish coalescing operator (`??=`) instead of an assignment expression, as it is simpler to read.", - "severity": "error" - } - }, "ccdab7b287703f5ca6da1800f14e1d1b6bfdfb73beddd5996c71da09d571be1d": { "count": 1, "diagnostic": { @@ -30875,26 +29006,6 @@ "severity": "error" } }, - "cf4c49e65e4f68a76f34635bc044424f3f78cd156249e47213f0881d2609f78c": { - "count": 1, - "diagnostic": { - "code": "eslint(no-eq-null)", - "file": "packages/dota/src/dota/gsi-handler.ts", - "labels": [ - { - "context": [ - "", - "if (res?.id != null && res.id.length > 0) {", - "await this.handleExistingAccount(res, steam32Id)" - ], - "message": "", - "span": "res?.id != null" - } - ], - "message": "Do not use `null` comparisons without type-checking operators.", - "severity": "error" - } - }, "cfd59271c3f9bdeca1ace51a7974bb62112f6a674edae8abea6a8bcb3aa809d8": { "count": 1, "diagnostic": { @@ -31071,6 +29182,26 @@ "severity": "error" } }, + "d09ab79152e3648bd3480637556983b58292928440780a20e86cd378ea7af6af": { + "count": 1, + "diagnostic": { + "code": "sonarjs(cognitive-complexity)", + "file": "packages/dota/src/dota/gsi-handler.ts", + "labels": [ + { + "context": [ + "", + "async closeBets(winningTeam: Team | null = null, gcData?: MatchClosingDetailsResponse) {", + "if (this.endingBets) {" + ], + "message": "", + "span": "closeBets" + } + ], + "message": "Refactor this function to reduce its Cognitive Complexity from 41 to the 20 allowed.", + "severity": "error" + } + }, "d106b0e7fc1670373396c33abdd7c4b85217f8f021ce30eefdd4e985a573751e": { "count": 1, "diagnostic": { @@ -31711,35 +29842,6 @@ "severity": "error" } }, - "d491bd7d8b9b56953f1d2c06cdfd2448f7f53d2c911bb33fe78cef6de61deeb6": { - "count": 1, - "diagnostic": { - "code": "typescript(no-unnecessary-type-conversion)", - "file": "packages/dota/src/steam/realtime-stats.ts", - "labels": [ - { - "context": [ - ".flatMap((team) => team.players)", - ".find((player) => Number(player.accountid) === accountId)", - "if (accountPlayer !== undefined) {" - ], - "message": "", - "span": "player.accountid" - }, - { - "context": [ - ".flatMap((team) => team.players)", - ".find((player) => Number(player.accountid) === accountId)", - "if (accountPlayer !== undefined) {" - ], - "message": "", - "span": "Number" - } - ], - "message": "This type conversion does not change the type or value of the expression.", - "severity": "error" - } - }, "d4e2979fa15629e837457fdb67cb91aef34e1968b5d9593f72d5cfb1f249c52b": { "count": 1, "diagnostic": { @@ -32021,35 +30123,6 @@ "severity": "error" } }, - "d600c62993c8f5737352131ec1eab846a770a657a54f8bcc98221f89989c8e7e": { - "count": 1, - "diagnostic": { - "code": "typescript(no-unnecessary-type-conversion)", - "file": "packages/dota/src/dota/lib/get-players.ts", - "labels": [ - { - "context": [ - "cards,", - "gameMode: response !== null ? Number(response.match.game_mode) : undefined,", - "matchPlayers," - ], - "message": "", - "span": "response.match.game_mode" - }, - { - "context": [ - "cards,", - "gameMode: response !== null ? Number(response.match.game_mode) : undefined,", - "matchPlayers," - ], - "message": "", - "span": "Number" - } - ], - "message": "This type conversion does not change the type or value of the expression.", - "severity": "error" - } - }, "d608e2335e9976564969f2ece45fa2238964cf2dc212536c8e73cfc240d93125": { "count": 1, "diagnostic": { @@ -32696,32 +30769,23 @@ "severity": "error" } }, - "d9df8d8336cedbe1f94592f2d0011794407753bcd305081367aea4afa8ff95ca": { + "d9f6762ef76f0f3bfd366c4ce25a374437b9549a462abdd3330813fae2f65540": { "count": 1, "diagnostic": { - "code": "typescript(no-unnecessary-type-conversion)", - "file": "packages/dota/src/dota/lib/capture-cosmetics.ts", + "code": "promise(prefer-await-to-callbacks)", + "file": "packages/dota/src/dota/gsi-handler.ts", "labels": [ { "context": [ - "items: items as unknown as Json,", - "matchId: String(matchId),", - "updated_at: new Date().toISOString()," - ], - "message": "", - "span": "matchId" - }, - { - "context": [ - "items: items as unknown as Json,", - "matchId: String(matchId),", - "updated_at: new Date().toISOString()," + "{ match_id: Number(matchId) },", + "(err: unknown, response: MatchMinimalDetailsResponse) => {", + "if (err !== null && err !== undefined) {" ], "message": "", - "span": "String" + "span": "(err: unknown, response: MatchMinimalDetailsResponse) => {\n if (err !== null && err !== undefined) {\n reject(err)\n } else {\n resolve(response)\n }\n }" } ], - "message": "This type conversion does not change the type or value of the expression.", + "message": "Prefer `async`/`await` to the callback pattern", "severity": "error" } }, @@ -32765,26 +30829,6 @@ "severity": "error" } }, - "da50de5553c6a43cdfdcf3240245381013bfa11f630fbe140987a3c96cc2168b": { - "count": 1, - "diagnostic": { - "code": "typescript(no-unsafe-type-assertion)", - "file": "packages/dota/src/dota/lib/capture-cosmetics.ts", - "labels": [ - { - "context": [ - "heroName: getHeroNameOrColor(heroId),", - "items: items as unknown as Json,", - "matchId: String(matchId)," - ], - "message": "", - "span": "items as unknown as Json" - } - ], - "message": "Unsafe type assertion: type 'Json' is more narrow than the original type.", - "severity": "error" - } - }, "da6fbe4f016f50c66f06cef36ed0df012b3af90888f80a13d0638779f3446520": { "count": 1, "diagnostic": { @@ -32825,43 +30869,43 @@ "severity": "error" } }, - "daa31a0eda740e2dab1cc040f25a09ad20d4b7c5fcfc325ce21f7eb91fc3639e": { + "dadd31575cc480c4bc513540ce7521011ce0d67a326bcbdfc9f40eadff1b65cc": { "count": 1, "diagnostic": { - "code": "import(first)", - "file": "packages/steam/src/index.ts", + "code": "anti-slop(no-unknown-parameters)", + "file": "packages/shared-utils/tests/setup-mocks.ts", "labels": [ { "context": [ - "", - "import { initSpectatorProtobuff } from './init-spectator-protobuff'", - "import { getSocketIoServer } from './socket-server'" + "},", + "is: (col: string, val: unknown) => {", + "filters.push({ col, method: 'is', val })" ], "message": "", - "span": "import { initSpectatorProtobuff } from './init-spectator-protobuff'" + "span": "unknown" } ], - "message": "Import statements must come first", + "message": "Parameter `val` leaves input unparsed. Accept a named domain type; run the expected schema or parser at the I/O boundary before calling this function.", "severity": "error" } }, - "dadd31575cc480c4bc513540ce7521011ce0d67a326bcbdfc9f40eadff1b65cc": { + "db1472ba1d89c5014616f21dd34538af556a7eb1cbf7b89f04c245c27a678cfe": { "count": 1, "diagnostic": { - "code": "anti-slop(no-unknown-parameters)", - "file": "packages/shared-utils/tests/setup-mocks.ts", + "code": "eslint(complexity)", + "file": "packages/dota/src/dota/events/gsi-events/event.chat_message.ts", "labels": [ { "context": [ - "},", - "is: (col: string, val: unknown) => {", - "filters.push({ col, method: 'is', val })" + "eventHandler.registerEvent(`event:${DotaEventTypes.ChatMessage}`, {", + "handler: async (dotaClient, event: ChatMessageEvent) => {", + "if (!dotaClient.client.stream_online) {" ], "message": "", - "span": "unknown" + "span": "async (dotaClient, event: ChatMessageEvent) => {\n if (!dotaClient.client.stream_online) {\n return\n }\n if (!isPlayingMatch(dotaClient.client.gsi)) {\n return\n }\n\n const message = await moderateText(event.message?.trim())\n if (message === null || message === undefined || message.length === 0 || message === '***') {\n return\n }\n\n // Check for chatting behavior\n if (!disableChatterMessage && dotaClient.client.gsi?.player?.player_slot === event.player_id) {\n // Check global chatter access\n const {\n chattingSpamEmote: { enabled: chattingEmoteEnabled },\n } = getValueOrDefault(\n DBSettings.chatters,\n dotaClient.client.settings,\n dotaClient.client.subscription,\n 'chattingSpamEmote'\n )\n\n if (chattingEmoteEnabled) {\n const wordCount = message.split(/\\s+/u).length\n const chattingSeverity = shouldTriggerChattingAlert(\n dotaClient.client.name,\n event.player_id,\n wordCount\n )\n if (chattingSeverity > 0) {\n sendChattingAlert(dotaClient, event.player_id, chattingSeverity)\n }\n }\n }\n\n // Translation logic with debouncing\n if (disableTranslation || authKey.length === 0) {\n return\n }\n\n const translateInChat = getValueOrDefault(\n DBSettings.autoTranslate,\n dotaClient.client.settings,\n dotaClient.client.subscription\n )\n\n const translateOnOverlay = getValueOrDefault(\n DBSettings.translateOnOverlay,\n dotaClient.client.settings,\n dotaClient.client.subscription\n )\n\n if (!translateInChat && !translateOnOverlay) {\n return\n }\n\n // Check global chatter access\n const toLanguage = getValueOrDefault(\n DBSettings.translationLanguage,\n dotaClient.client.settings,\n dotaClient.client.subscription\n )\n\n // Validate and convert language code to DeepL-supported format\n const deeplLanguage = getDeepLLanguage(toLanguage)\n if (deeplLanguage === null || deeplLanguage.length === 0) {\n // Language not supported by DeepL, skip translation to avoid API errors\n return\n }\n const typedLanguage = deeplLanguage as deepl.TargetLanguageCode\n\n const clientKey = dotaClient.client.name\n let buffer = translationBuffers.get(clientKey)\n if (!buffer) {\n buffer = { messages: [], timeout: null }\n translationBuffers.set(clientKey, buffer)\n }\n\n // Get hero name\n const roster = await new MatchDataService(dotaClient.client).resolveRoster()\n const { players } = roster\n let playerIdIndex = players.findIndex((p) => p.slot === event.player_id)\n const foundInMatchPlayers = playerIdIndex !== -1\n if (!foundInMatchPlayers) {\n playerIdIndex = event.player_id\n }\n const heroName = getHeroNameOrColor(players[playerIdIndex]?.heroId ?? 0, playerIdIndex)\n const displayHeroName = resolveTranslatedHeroName({\n foundInMatchPlayers,\n heroName,\n isHighMmr: is8500Plus(dotaClient.client),\n locale: dotaClient.client.locale,\n playerId: event.player_id,\n })\n const speakerLabel = formatTranslatedSpeakerLabel(\n displayHeroName,\n event.player_id,\n dotaClient.client.locale\n )\n\n // Add to buffer\n buffer.messages.push({\n message,\n playerId: event.player_id,\n speakerLabel,\n timestamp: Date.now(),\n })\n\n // Set timeout if not already set\n buffer.timeout ??= setTimeout(async () => {\n const currentBuffer = translationBuffers.get(clientKey)\n if (currentBuffer) {\n await processTranslationBuffer(\n currentBuffer.messages,\n dotaClient,\n translateInChat,\n translateOnOverlay,\n typedLanguage\n )\n translationBuffers.delete(clientKey)\n }\n }, TRANSLATION_DEBOUNCE_TIME)\n }" } ], - "message": "Parameter `val` leaves input unparsed. Accept a named domain type; run the expected schema or parser at the I/O boundary before calling this function.", + "message": "async function `handler` has a complexity of 25. Maximum allowed is 20.", "severity": "error" } }, @@ -33251,26 +31295,6 @@ "severity": "error" } }, - "de2f804e91f97d6555ff1ca0e5d8504b590a2fb08261079679a6e0e29a39ff41": { - "count": 1, - "diagnostic": { - "code": "import(first)", - "file": "packages/steam/src/index.ts", - "labels": [ - { - "context": [ - "import Dota, { GetRealTimeStats } from './steam'", - "import type { MatchMinimalDetailsResponse } from './types/match-minimal-details'", - "import { logger } from './utils/logger'" - ], - "message": "", - "span": "import type { MatchMinimalDetailsResponse } from './types/match-minimal-details'" - } - ], - "message": "Import statements must come first", - "severity": "error" - } - }, "de3922f7e359b52be6686367499d401e0c2b78be14b67d77504a86a87ff65249": { "count": 1, "diagnostic": { @@ -33291,26 +31315,6 @@ "severity": "error" } }, - "dea509bde02c51bd5ce22f88abd67caa6f03728bd24a953d74e8d492c136f6a4": { - "count": 1, - "diagnostic": { - "code": "eslint(no-eq-null)", - "file": "packages/dota/src/dota/gsi-handler.ts", - "labels": [ - { - "context": [ - "", - "if (!betsEnabled || predictionId == null || predictionId.length === 0) {", - "logger.debug('Bets are not enabled or no prediction was opened, stopping here', {" - ], - "message": "", - "span": "predictionId == null" - } - ], - "message": "Do not use `null` comparisons without type-checking operators.", - "severity": "error" - } - }, "deb4b20464c0712abaafa6d85e63bf9dceeda122864b9fffbecec78f85693b4b": { "count": 1, "diagnostic": { @@ -33495,26 +31499,6 @@ "severity": "error" } }, - "e0629ef01639fd7ebf26617efbd5a9c72e9e1f0c14c678cdef7fa75406e495f9": { - "count": 1, - "diagnostic": { - "code": "eslint(no-eq-null)", - "file": "packages/dota/src/dota/gsi-handler.ts", - "labels": [ - { - "context": [ - "(this.client.gsi?.map?.radiant_score == null || this.client.gsi.map.radiant_score === 0) &&", - "this.client.gsi?.map?.matchid != null &&", - "this.client.gsi.map.matchid.length > 0" - ], - "message": "", - "span": "this.client.gsi?.map?.matchid != null" - } - ], - "message": "Do not use `null` comparisons without type-checking operators.", - "severity": "error" - } - }, "e06835707fb08f11c0d94188d682cb573d684eaffce6d6a55b773b1af98f86da": { "count": 1, "diagnostic": { @@ -33643,26 +31627,6 @@ "severity": "error" } }, - "e162bfba156e35e45c764085f177fc83ccd49ee2ea08819fb5dc3d345d7078c3": { - "count": 1, - "diagnostic": { - "code": "import(first)", - "file": "packages/twitch-chat/src/index.ts", - "labels": [ - { - "context": [ - "", - "import { lstatSync, readdirSync } from 'node:fs'", - "import { join } from 'node:path'" - ], - "message": "", - "span": "import { lstatSync, readdirSync } from 'node:fs'" - } - ], - "message": "Import statements must come first", - "severity": "error" - } - }, "e176be8f99a6b5f76085118ca9010db43763e94685c9d6d95ba4253592238742": { "count": 1, "diagnostic": { @@ -33763,6 +31727,26 @@ "severity": "error" } }, + "e1e8bcf57c38cdea3fb5e2f199449bb3b1c6340fda5f543f04d7844a8954b436": { + "count": 1, + "diagnostic": { + "code": "eslint(complexity)", + "file": "packages/dota/src/dota/gsi-handler.ts", + "labels": [ + { + "context": [ + "", + "const attemptFetchMatchData = async (): Promise => {", + "// Check if they rejoined the match they disconnected from" + ], + "message": "", + "span": "async (): Promise => {\n // Check if they rejoined the match they disconnected from\n if (this.client.gsi?.map?.matchid === matchId) {\n logger.info('[BETS] Streamer rejoined the match, skipping early DC winner check', {\n matchId,\n name: this.client.name,\n })\n return\n }\n\n // Check if the bet for this match is already closed in the database\n const { data: matchNotEnded, error } = await supabase\n .from('matches')\n .select('won')\n // Null means there is a winner of this match\n .is('won', null)\n .eq('matchId', matchId.toString())\n .eq('userId', this.client.token)\n .single()\n\n if (matchNotEnded === null || matchNotEnded === undefined || error !== null) {\n logger.info('[BETS] Match already ended, skipping early DC winner check', {\n matchId,\n name: this.client.name,\n })\n return\n }\n\n if (retryCount >= MAX_RETRIES) {\n // Handle exhausting all retries - prompt for manual resolution instead of refunding\n if (this.client.stream_online) {\n logger.info(\n 'Exceeded maximum retries for early DC match check, prompting for manual resolution',\n {\n matchId,\n name: this.client.name,\n }\n )\n\n // Set pending manual resolution flag in Redis\n await redisClient.client.set(\n `${this.client.token}:pendingManualResolution`,\n JSON.stringify({ matchId, timestamp: Date.now() })\n )\n\n // Send chat message to notify mods\n const tellChatBets = getValueOrDefault(\n DBSettings.tellChatBets,\n this.client.settings,\n this.client.subscription\n )\n if (tellChatBets) {\n say(\n this.client,\n t('bets.manualResolution', {\n details: formatUnresolvedMatch(snapshotMatch),\n emote: 'PauseChamp',\n lng: this.client.locale,\n matchId,\n })\n )\n }\n }\n\n // Reset the flag since we've exhausted retries\n this.checkingEarlyDCWinner = false\n return\n }\n\n try {\n // Request match data from Steam socket\n const getMatchDetailsPromise = new Promise(\n (resolve, reject) => {\n steamSocket.emit(\n 'getMatchMinimalDetails',\n { match_id: Number(matchId) },\n (err: unknown, response: MatchMinimalDetailsResponse) => {\n if (err !== null && err !== undefined) {\n reject(err)\n } else {\n resolve(response)\n }\n }\n )\n }\n )\n\n const response = await getMatchDetailsPromise\n const matchData = response?.matches?.[0]\n\n // Check if we got a valid response with match outcome\n if (\n matchData !== undefined &&\n typeof matchData.match_outcome === 'number' &&\n [\n EMatchOutcome.k_EMatchOutcome_RadVictory,\n EMatchOutcome.k_EMatchOutcome_DireVictory,\n ].includes(matchData.match_outcome)\n ) {\n logger.info('Successfully retrieved match result for early DC', {\n matchId,\n matchOutcome: matchData.match_outcome,\n name: this.client.name,\n })\n\n // Determine winner based on match outcome\n // k_EMatchOutcome_RadVictory = 2, k_EMatchOutcome_DireVictory = 3\n const winningTeam =\n matchData.match_outcome === EMatchOutcome.k_EMatchOutcome_RadVictory\n ? 'radiant'\n : 'dire'\n\n // Reset flag before calling closeBets to prevent duplicate calls from closeBets\n this.checkingEarlyDCWinner = false\n await this.closeBets(winningTeam, response)\n } else if (\n matchData !== undefined &&\n typeof matchData.match_outcome === 'number' &&\n matchData.match_outcome > EMatchOutcome.k_EMatchOutcome_DireVictory\n ) {\n // Not scored\n logger.info('Match not scored, skipping early DC winner check', {\n matchId,\n matchOutcome: matchData.match_outcome,\n name: this.client.name,\n })\n\n // Reset flag before calling closeBets to prevent duplicate calls from closeBets\n this.checkingEarlyDCWinner = false\n logger.info('This is likely a no stats recorded match', {\n matchId,\n name: this.client.name,\n })\n\n if (this.client.stream_online) {\n say(\n this.client,\n t('bets.notScored', {\n emote: 'D:',\n key: DBSettings.tellChatBets,\n lng: this.client.locale,\n matchId,\n })\n )\n const predictionResponse = await supabase\n .from('matches')\n .select('predictionId')\n .eq('matchId', matchId.toString())\n .eq('userId', this.client.token)\n .is('won', null)\n .single()\n if (\n predictionResponse.data?.predictionId !== null &&\n predictionResponse.data?.predictionId !== undefined &&\n predictionResponse.data.predictionId.length > 0\n ) {\n const oldBetId = await refundTwitchBet(\n this.getChannelId(),\n predictionResponse.data.predictionId\n )\n if (oldBetId !== null && oldBetId !== undefined && oldBetId.length > 0) {\n await supabase\n .from('matches')\n .update({ predictionId: null, updated_at: new Date().toISOString() })\n .eq('predictionId', oldBetId)\n }\n }\n }\n // No-stats match can never be resolved with !won/!lost; don't nag for it.\n await this.suppressUnresolvedReminder(matchId)\n await this.resetClientState()\n return\n } else {\n // Invalid response, retry after delay\n retryCount += 1\n logger.info('Invalid match data response, scheduling retry', {\n matchId,\n maxRetries: MAX_RETRIES,\n name: this.client.name,\n response,\n retryCount,\n })\n\n setTimeout(attemptFetchMatchData, RETRY_DELAY)\n }\n } catch (error) {\n // Error occurred, retry after delay\n retryCount += 1\n logger.error('Error in early DC match check, scheduling retry', {\n error,\n matchId,\n maxRetries: MAX_RETRIES,\n name: this.client.name,\n retryCount,\n })\n\n setTimeout(attemptFetchMatchData, RETRY_DELAY)\n }\n }" + } + ], + "message": "async function has a complexity of 29. Maximum allowed is 20.", + "severity": "error" + } + }, "e201f182ba44171b1d9508b21c4a55270b252ba9e5df421283e3686343a9ba63": { "count": 1, "diagnostic": { @@ -33891,26 +31875,6 @@ "severity": "error" } }, - "e2675a84ec00335a9ee4d3c4c3664b1a4c2d3042774e39aea787a867a3bdc220": { - "count": 1, - "diagnostic": { - "code": "eslint(no-eq-null)", - "file": "packages/dota/src/dota/gsi-handler.ts", - "labels": [ - { - "context": [ - "if (", - "treadToggleData?.treadToggles != null &&", - "treadToggleData.treadToggles > 0 &&" - ], - "message": "", - "span": "treadToggleData?.treadToggles != null" - } - ], - "message": "Do not use `null` comparisons without type-checking operators.", - "severity": "error" - } - }, "e2a1d125719571d42e60f90a5319e7774a34d8e29c2d8710122e579b9711471a": { "count": 1, "diagnostic": { @@ -34007,6 +31971,22 @@ "severity": "error" } }, + "e31fae80ead62c97400187c27dd5da37ef21a743aea2c8aea20c561de9048868": { + "count": 1, + "diagnostic": { + "code": "promise(no-multiple-resolved)", + "file": "packages/steam/src/steam.ts", + "labels": [ + { + "context": ["}", "resolve(card)", "})"], + "message": "", + "span": "resolve(card)" + } + ], + "message": "Promise should not be resolved multiple times. Promise is potentially resolved on line 751.", + "severity": "error" + } + }, "e3255aee8dcc57d8292b3f55c585eb325ab3b587079be2c384c7cff2290f98d5": { "count": 1, "diagnostic": { @@ -34802,26 +32782,6 @@ "severity": "error" } }, - "e7d50508ae00f702ee02852c8189d8549e530543d1ce64a00fa697de5608e970": { - "count": 1, - "diagnostic": { - "code": "unicorn(no-array-sort)", - "file": "packages/dota/src/steam/smurfs.ts", - "labels": [ - { - "context": [ - "const results = result", - ".sort((a, b) => (a.lifetime_games ?? 0) - (b.lifetime_games ?? 0))", - ".map((m) =>" - ], - "message": "", - "span": "sort" - } - ], - "message": "Use `Array#toSorted()` instead of `Array#sort()`.", - "severity": "error" - } - }, "e7d6eb5a67b5ce245659a5bb503ab1be22a00b06ef9958a0c1016c01546cc462": { "count": 1, "diagnostic": { @@ -35495,26 +33455,6 @@ "severity": "error" } }, - "ec5fd62fc9f3503bbdfc0611a9ec4d2a3e04b4369afb4b3707823bebaf5203e6": { - "count": 1, - "diagnostic": { - "code": "eslint(no-await-in-loop)", - "file": "packages/dota/src/dota/lib/announce-features.ts", - "labels": [ - { - "context": [ - "if (await announceFeatureOnce(client, feature)) {", - "await redisClient.client.set(guardKey, String(matchId))", - "return" - ], - "message": "", - "span": "await" - } - ], - "message": "Unexpected `await` inside a loop.", - "severity": "error" - } - }, "ec75ded860a5f590614e0e11af0b044da763ede2c4297807a637854475894970": { "count": 1, "diagnostic": { @@ -35531,26 +33471,6 @@ "severity": "error" } }, - "ec937343ba19c989ddadb24daaabb5909093ed71d84e4654c67dc31da2dd0b07": { - "count": 1, - "diagnostic": { - "code": "anti-slop(require-safety-comment-for-type-assertion)", - "file": "packages/dota/src/dota/lib/capture-cosmetics.ts", - "labels": [ - { - "context": [ - "heroName: getHeroNameOrColor(heroId),", - "items: items as unknown as Json,", - "matchId: String(matchId)," - ], - "message": "", - "span": "items as unknown as Json" - } - ], - "message": "This type assertion has no `SAFETY:` justification. State the checked invariant immediately before the assertion or its containing statement.", - "severity": "error" - } - }, "ecbb614bfc9d4d9e9ae972cd1d06fc456a052ed11862d7ac7c6d465f6c4afc4d": { "count": 1, "diagnostic": { @@ -35759,26 +33679,6 @@ "severity": "error" } }, - "ede659e878d31888d56dad9fd1c96bd01fb9fbdfa04ce1404a024765831fe3db": { - "count": 1, - "diagnostic": { - "code": "typescript(prefer-nullish-coalescing)", - "file": "packages/dota/src/dota/gsi-handler.ts", - "labels": [ - { - "context": [ - "// Otherwise default to ranked", - "const localLobbyType = playingLobbyType === null ? LOBBY_TYPE_RANKED : playingLobbyType", - "" - ], - "message": "", - "span": "playingLobbyType === null ? LOBBY_TYPE_RANKED : playingLobbyType" - } - ], - "message": "Prefer using nullish coalescing operator (`??`) instead of a ternary expression, as it is simpler to read.", - "severity": "error" - } - }, "ee6cdd938bb280d0bfa11c751dc28b4887c0b8ea9ab22568aa744c0154c539b0": { "count": 1, "diagnostic": { @@ -36695,26 +34595,6 @@ "severity": "error" } }, - "f49e8249320271457ef94fddc4fb8209f552cf42b1170efc3c77db5bd2f7fa59": { - "count": 1, - "diagnostic": { - "code": "typescript(prefer-nullish-coalescing)", - "file": "packages/shared-utils/src/db/supabase.ts", - "labels": [ - { - "context": [ - "export const getSupabaseClient = (): SupabaseClient => {", - "if (supabaseInstance === null) {", - "supabaseInstance = createClient(supabaseUrl, supabaseKey, {" - ], - "message": "", - "span": "if (supabaseInstance === null) {\n supabaseInstance = createClient(supabaseUrl, supabaseKey, {\n auth: { persistSession: false },\n })\n }" - } - ], - "message": "Prefer using nullish coalescing operator (`??=`) instead of an assignment expression, as it is simpler to read.", - "severity": "error" - } - }, "f4e3ba4263435e7cb5267e0316333776cd1ac422b9335dc9d5e718ec278fd846": { "count": 1, "diagnostic": { @@ -36935,26 +34815,6 @@ "severity": "error" } }, - "f699851f4e1facf9917c1728626535a02057358eb355b09848464eab588a6a98": { - "count": 1, - "diagnostic": { - "code": "eslint(no-eq-null)", - "file": "packages/dota/src/dota/gsi-handler.ts", - "labels": [ - { - "context": [ - "name:", - "this.client.gsi?.player?.name != null && this.client.gsi.player.name.length > 0", - "? this.client.gsi.player.name" - ], - "message": "", - "span": "this.client.gsi?.player?.name != null" - } - ], - "message": "Do not use `null` comparisons without type-checking operators.", - "severity": "error" - } - }, "f6bccdf3cdcb6354031698a5c088734a159ca29f269f90ae4f839b59298b4208": { "count": 1, "diagnostic": { @@ -37071,6 +34931,26 @@ "severity": "error" } }, + "f76d7cccbb4cb0d4fdc0316ae89fd7a0bca09aed1efd19049fc5c658ac2d653b": { + "count": 1, + "diagnostic": { + "code": "eslint(no-await-in-loop)", + "file": "packages/dota/src/dota/lib/announce-features.ts", + "labels": [ + { + "context": [ + "}", + "if (await announceFeatureOnce(client, feature)) {", + "await redisClient.client.set(guardKey, matchId)" + ], + "message": "", + "span": "await" + } + ], + "message": "Unexpected `await` inside a loop.", + "severity": "error" + } + }, "f76db347225a893cc8a8992f732461ded1cb106dd8707bf4cf778fa8bcb9464a": { "count": 1, "diagnostic": { @@ -37123,22 +35003,6 @@ "severity": "error" } }, - "f80a0f80b82f78d91902eab40cc4d9a65dd3acb16bf6245a1cf13116d1997bb2": { - "count": 1, - "diagnostic": { - "code": "eslint(complexity)", - "file": "packages/dota/src/db/get-db-user.ts", - "labels": [ - { - "context": ["", "export default async function getDBUser({", "token,"], - "message": "", - "span": "async function getDBUser({\n token,\n twitchId: providerAccountId,\n ip: _ip,\n}: {\n token?: string\n twitchId?: string\n ip?: string\n} = {}): Promise<{\n reason: string\n result: SocketClient | null | undefined\n}> {\n const lookupToken = token ?? providerAccountId ?? ''\n\n if (invalidTokens.has(lookupToken)) {\n return { reason: 'Token is in invalidTokens set', result: null }\n }\n\n let client = findUser(token) ?? findUserByTwitchId(providerAccountId)\n if (client) {\n lookingupToken.delete(lookupToken)\n return { reason: 'Client found by token or twitchId', result: client }\n }\n\n if (lookingupToken.has(lookupToken)) {\n return { reason: 'Token is currently being looked up', result: null }\n }\n\n lookingupToken.set(lookupToken, true)\n\n if (!lookupToken) {\n invalidTokens.add(lookupToken)\n lookingupToken.delete(lookupToken)\n return { reason: 'No lookup token provided', result: null }\n }\n\n let userId = token === undefined || token.length === 0 ? null : token\n if (providerAccountId !== undefined && providerAccountId.length > 0) {\n const { data, error } = await supabase\n .from('accounts')\n .select('userId')\n .eq('provider', 'twitch')\n .eq('providerAccountId', providerAccountId)\n .single()\n userId = data?.userId ?? null\n\n if (error) {\n if (error.code === 'PGRST116') {\n // Genuine \"0 rows\" (DB enforces uniqueness on provider+providerAccountId,\n // so >1 rows can't surface as PGRST116 here). Safe to persist for 24h.\n invalidTokens.add(lookupToken)\n } else {\n // Transient DB error — log for observability but only cache in-memory\n // so recovery on next deploy doesn't require waiting out the 24h TTL.\n logger.error('[USER] accounts lookup failed', { error, lookupToken, providerAccountId })\n invalidTokens.addEphemeral(lookupToken)\n }\n lookingupToken.delete(lookupToken)\n return {\n reason: `Error looking up userId by providerAccountId: ${error.message}`,\n result: null,\n }\n }\n }\n\n if (userId === null || userId.length === 0) {\n invalidTokens.add(lookupToken)\n lookingupToken.delete(lookupToken)\n return { reason: 'No userId found', result: null }\n }\n\n // Fetch user by `twitchId` and `token`\n const { data: user, error: userError } = await supabase\n .from('users')\n .select(\n `\n id,\n name,\n mmr,\n steam32Id,\n stream_online,\n stream_start_date,\n beta_tester,\n locale,\n banned_at,\n subscriptions (\n id,\n tier,\n status,\n isGift\n ),\n Account:accounts (\n refresh_token,\n scope,\n expires_at,\n requires_refresh,\n expires_in,\n obtainment_timestamp,\n access_token,\n providerAccountId\n ),\n SteamAccount:steam_accounts (\n mmr,\n connectedUserIds,\n steam32Id,\n name,\n leaderboard_rank\n ),\n settings (\n key,\n value\n )\n `\n )\n .eq('id', userId)\n .single()\n\n // Handle errors\n if (userError) {\n if (userError.code === 'PGRST116') {\n // Genuine \"0 rows\" — user was deleted (users.id is the primary key so\n // >1 rows can't surface as PGRST116). Safe to persist for 24h.\n invalidTokens.add(lookupToken)\n } else {\n // Transient DB error — log for observability but only cache in-memory.\n logger.error('[USER] users lookup failed', { error: userError, lookupToken })\n invalidTokens.addEphemeral(lookupToken)\n }\n lookingupToken.delete(lookupToken)\n return { reason: `Error fetching user from supabase: ${userError.message}`, result: null }\n }\n\n if (!user?.id) {\n logger.info('Invalid token', { token: lookupToken })\n invalidTokens.add(lookupToken)\n lookingupToken.delete(lookupToken)\n return { reason: 'No user or user.id found', result: null }\n }\n\n // Hard gate: banned user. Persist in invalidTokens so subsequent GSI POSTs\n // short-circuit at the top of getDBUser without re-hitting the DB. The\n // dota watcher's UPDATE:users handler adds to invalidTokens on the\n // null→set banned_at transition so a live ban is effective immediately.\n if (user.banned_at !== null && user.banned_at !== undefined && user.banned_at.length > 0) {\n invalidTokens.add(lookupToken)\n lookingupToken.delete(lookupToken)\n return { reason: 'User is banned', result: null }\n }\n\n // If they require a refresh, don't cache them\n const Account = Array.isArray(user?.Account) ? user.Account[0] : user.Account\n if (Account?.requires_refresh === true) {\n invalidTokens.add(lookupToken)\n lookingupToken.delete(lookupToken)\n return { reason: 'Account requires refresh', result: null }\n }\n\n client = findUser(user.id)\n if (client) {\n lookingupToken.delete(lookupToken)\n return { reason: 'Client found by user.id', result: client }\n }\n\n if (Account === null || Account === undefined) {\n logger.info('Invalid token missing Account??', { token: lookupToken })\n invalidTokens.add(lookupToken)\n lookingupToken.delete(lookupToken)\n return { reason: 'No Account found', result: undefined }\n }\n let subscription: SocketClient['subscription'] | undefined\n if (Array.isArray(user.subscriptions) && user.subscriptions.length > 0) {\n const activeSubscription =\n user.subscriptions.find((sub: SubscriptionRow) => isSubscriptionActive(sub)) ||\n user.subscriptions[0]\n subscription = {\n ...activeSubscription,\n }\n }\n\n const userInfo = {\n ...user,\n Account: {\n ...Account,\n obtainment_timestamp:\n Account.obtainment_timestamp === null ||\n Account.obtainment_timestamp === undefined ||\n Account.obtainment_timestamp === ''\n ? null\n : new Date(Account.obtainment_timestamp),\n requires_refresh: Account.requires_refresh ?? false,\n },\n mmr: user.mmr || user.SteamAccount[0]?.mmr || 0,\n steam32Id:\n user.steam32Id === null || user.steam32Id === undefined || user.steam32Id === 0\n ? (user.SteamAccount[0]?.steam32Id ?? 0)\n : user.steam32Id,\n stream_start_date:\n user.stream_start_date === null ||\n user.stream_start_date === undefined ||\n user.stream_start_date.length === 0\n ? null\n : new Date(user.stream_start_date),\n subscription,\n token: user.id,\n }\n\n const gsiHandler = gsiHandlers.get(userInfo.id) ?? createGSIHandler(userInfo)\n gsiHandlers.set(userInfo.id, gsiHandler)\n\n twitchIdToToken.set(Account.providerAccountId, userInfo.id)\n twitchNameToToken.set(userInfo.name.toLowerCase(), userInfo.id)\n lookingupToken.delete(lookupToken)\n invalidTokens.delete(userInfo.id)\n\n return { reason: 'User successfully retrieved', result: userInfo }\n}" - } - ], - "message": "async function `getDBUser` has a complexity of 52. Maximum allowed is 20.", - "severity": "error" - } - }, "f8a1ce254024d43f1ebdc07e89481eeebc763139dc4c5f2dec37cd644ba67df3": { "count": 1, "diagnostic": { @@ -37383,26 +35247,6 @@ "severity": "error" } }, - "f9d71a7099c78fdba3432f53167af97a20948549ea2b2ccc3b9b67035d0cd696": { - "count": 1, - "diagnostic": { - "code": "import(first)", - "file": "packages/steam/src/index.ts", - "labels": [ - { - "context": [ - "import { getSocketIoServer } from './socket-server'", - "import Dota, { GetRealTimeStats } from './steam'", - "import type { MatchMinimalDetailsResponse } from './types/match-minimal-details'" - ], - "message": "", - "span": "import Dota, { GetRealTimeStats } from './steam'" - } - ], - "message": "Import statements must come first", - "severity": "error" - } - }, "f9ff6832edf3010009651833bb2da7bc5fd275c28f6cabd955c2557197fc42ec": { "count": 1, "diagnostic": { @@ -37604,42 +35448,6 @@ "severity": "error" } }, - "fb003035e8838ad34a58cec1861bddc989cc2bbddec7b32e09192b5137fe08be": { - "count": 1, - "diagnostic": { - "code": "typescript(prefer-nullish-coalescing)", - "file": "packages/dota/src/dota/lib/heroes.ts", - "labels": [ - { - "context": ["// then hero name", "if (!hero) {", "hero = lookInHeroes.find((h) => {"], - "message": "", - "span": "if (!hero) {\n hero = lookInHeroes.find((h) => {\n const inName = h.localized_name\n // replace all spaces with nothing, and only keep a-z\n .replaceAll(/[^a-z]/giu, '')\n .toLowerCase()\n .trim()\n\n return inName.includes(localName)\n })\n }" - } - ], - "message": "Prefer using nullish coalescing operator (`??=`) instead of an assignment expression, as it is simpler to read.", - "severity": "error" - } - }, - "fb2faee7fb2256296d1ad7a449dfb7cad4d74f2428ee4509b9cf85853754aab8": { - "count": 1, - "diagnostic": { - "code": "anti-slop(no-chained-type-assertions)", - "file": "packages/dota/src/dota/lib/capture-cosmetics.ts", - "labels": [ - { - "context": [ - "heroName: getHeroNameOrColor(heroId),", - "items: items as unknown as Json,", - "matchId: String(matchId)," - ], - "message": "", - "span": "items as unknown as Json" - } - ], - "message": "This assertion chain discards type evidence. Keep the original precise type, or parse untrusted input at its boundary before narrowing it.", - "severity": "error" - } - }, "fb61e973dd7885f39a0edc2706c12f2a547e129104b708e65d348d0dd7b5eaea": { "count": 1, "diagnostic": { @@ -37785,43 +35593,43 @@ "severity": "error" } }, - "fc25e8fc7768c40d995df1889e7e4c22df6b3253fa35b78f29f4bb2f09cbb30b": { + "fc3b2d0ebaa9b759aa105f72e21e7d9a57bfd62b672596679b5155f896987184": { "count": 1, "diagnostic": { - "code": "import(first)", - "file": "packages/twitch-chat/src/index.ts", + "code": "anti-slop(no-unknown-parameters)", + "file": "packages/dota/src/twitch/lib/__tests__/simple-commands.integration.test.ts", "labels": [ { "context": [ - "import FsBackend from 'i18next-fs-backend'", - "import type { FsBackendOptions } from 'i18next-fs-backend'", - "" + "", + "const mockLastFm = function mockLastFm(payload: unknown) {", + "globalThis.fetch = vi.fn(" ], "message": "", - "span": "import type { FsBackendOptions } from 'i18next-fs-backend'" + "span": "unknown" } ], - "message": "Import statements must come first", + "message": "Parameter `payload` leaves input unparsed. Accept a named domain type; run the expected schema or parser at the I/O boundary before calling this function.", "severity": "error" } }, - "fc3b2d0ebaa9b759aa105f72e21e7d9a57bfd62b672596679b5155f896987184": { + "fc4bd8ef401f4a1f073f8af93c20bc2ce98b5de7a522e0b7956bbe1044411488": { "count": 1, "diagnostic": { - "code": "anti-slop(no-unknown-parameters)", - "file": "packages/dota/src/twitch/lib/__tests__/simple-commands.integration.test.ts", + "code": "typescript(no-misused-promises)", + "file": "packages/dota/src/dota/events/gsi-events/event.chat_message.ts", "labels": [ { "context": [ - "", - "const mockLastFm = function mockLastFm(payload: unknown) {", - "globalThis.fetch = vi.fn(" + "// Set timeout if not already set", + "buffer.timeout ??= setTimeout(async () => {", + "const currentBuffer = translationBuffers.get(clientKey)" ], "message": "", - "span": "unknown" + "span": "async () => {\n const currentBuffer = translationBuffers.get(clientKey)\n if (currentBuffer) {\n await processTranslationBuffer(\n currentBuffer.messages,\n dotaClient,\n translateInChat,\n translateOnOverlay,\n typedLanguage\n )\n translationBuffers.delete(clientKey)\n }\n }" } ], - "message": "Parameter `payload` leaves input unparsed. Accept a named domain type; run the expected schema or parser at the I/O boundary before calling this function.", + "message": "Promise returned in function argument where a void return was expected.", "severity": "error" } }, @@ -38085,23 +35893,23 @@ "severity": "error" } }, - "fd75911f43b36495df87db4a5aa6e7b8e4880da385a6fd3d8e466169dd9cea71": { + "fda313072a73d890e5cd1944d5a509bdaa70761ed179598fb14a43077f87c22c": { "count": 1, "diagnostic": { - "code": "import(no-named-as-default)", - "file": "packages/twitch-chat/src/event-sub-socket.ts", + "code": "typescript(no-unsafe-type-assertion)", + "file": "packages/dota/src/dota/lib/capture-cosmetics.ts", "labels": [ { "context": [ - "import { logger } from '@dotabod/shared-utils'", - "import WebSocket from 'ws'", - "" + "heroName: getHeroNameOrColor(heroId),", + "items: items as unknown as Json,", + "matchId," ], "message": "", - "span": "WebSocket" + "span": "items as unknown as Json" } ], - "message": "Module \"ws\" has named export \"WebSocket\"", + "message": "Unsafe type assertion: type 'Json' is more narrow than the original type.", "severity": "error" } }, @@ -38181,26 +35989,6 @@ "severity": "error" } }, - "fe0d913687750a880c07cc0a10803a1572a97963b46be2418d73c6ee6b3a7aa1": { - "count": 3, - "diagnostic": { - "code": "eslint(no-eq-null)", - "file": "packages/dota/src/dota/gsi-handler.ts", - "labels": [ - { - "context": [ - "if (", - "predictionResponse.data?.predictionId != null &&", - "predictionResponse.data.predictionId.length > 0" - ], - "message": "", - "span": "predictionResponse.data?.predictionId != null" - } - ], - "message": "Do not use `null` comparisons without type-checking operators.", - "severity": "error" - } - }, "fe0e79eeabf6bafc9bd708fb5ea22dbc3686b2fa6e84ac738a4435194a611e66": { "count": 1, "diagnostic": { @@ -38468,22 +36256,6 @@ "message": "async function `resolveMatchRetroactively` has a complexity of 46. Maximum allowed is 20.", "severity": "error" } - }, - "fff8b7ea29281995d3dd473e617b818b8e30a4594eee98c8bb3987c0d9579453": { - "count": 1, - "diagnostic": { - "code": "import(first)", - "file": "packages/twitch-chat/src/index.ts", - "labels": [ - { - "context": ["", "import {", "checkBotStatus,"], - "message": "", - "span": "import {\n checkBotStatus,\n checkSupabaseHealth,\n commandDisable,\n getTwitchAPI,\n logger,\n startHeartbeat,\n supabase,\n} from '@dotabod/shared-utils'" - } - ], - "message": "Import statements must come first", - "severity": "error" - } } }, "policy": {