Skip to content

Commit

Permalink
feat: add steam info to players (#777)
Browse files Browse the repository at this point in the history
* feat: add steam info to players

* fix: make steam api key optional

* fix: some tests :)

* more test-fixing
  • Loading branch information
niekcandaele authored Dec 23, 2023
1 parent 144f124 commit 9e6ec3d
Show file tree
Hide file tree
Showing 14 changed files with 577 additions and 97 deletions.
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ DISCORD_BOT_TOKEN=

MAILHOG_URL="http://127.0.0.1:8025"

STEAM_API_KEY=""

#################################
# AWS
#################################
Expand Down
26 changes: 26 additions & 0 deletions packages/app-api/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { queuesConfigSchema, IQueuesConfig } from '@takaro/queues';
import { IDbConfig, configSchema as dbConfigSchema } from '@takaro/db';
import { IAuthConfig, authConfigSchema } from '@takaro/auth';
import { errors } from '@takaro/util';
import ms from 'ms';

enum CLUSTER_MODE {
SINGLE = 'single',
Expand Down Expand Up @@ -33,6 +34,11 @@ interface IHttpConfig extends IBaseConfig {
url: string;
startWorkers: boolean;
};
steam: {
apiKey: string;
refreshOlderThanMs: number;
refreshBatchSize: number;
};
}

const configSchema = {
Expand Down Expand Up @@ -142,6 +148,26 @@ const configSchema = {
env: 'START_WORKERS',
},
},
steam: {
apiKey: {
doc: 'The Steam API key',
format: String,
default: '',
env: 'STEAM_API_KEY',
},
refreshOlderThanMs: {
doc: 'Players whose Steam data is older than this will be refreshed',
format: Number,
default: ms('1day'),
env: 'STEAM_REFRESH_OLDER_THAN_MS',
},
refreshBatchSize: {
doc: 'The amount of players to refresh per time the sync runs',
format: Number,
default: 100,
env: 'STEAM_REFRESH_BATCH_SIZE',
},
},
};

export const config = new Config<IHttpConfig & IQueuesConfig & IDbConfig & Pick<IAuthConfig, 'kratos' | 'hydra'>>([
Expand Down
10 changes: 9 additions & 1 deletion packages/app-api/src/controllers/PlayerController.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { IsISO8601, IsOptional, IsString, IsUUID, ValidateNested } from 'class-validator';
import { IsBoolean, IsISO8601, IsOptional, IsString, IsUUID, ValidateNested } from 'class-validator';
import { ITakaroQuery } from '@takaro/db';
import { APIOutput, apiResponse } from '@takaro/http';
import { PlayerOutputDTO, PlayerOutputWithRolesDTO, PlayerService } from '../service/PlayerService.js';
Expand Down Expand Up @@ -50,6 +50,14 @@ class PlayerSearchInputAllowedFilters {
@IsOptional()
@IsString({ each: true })
xboxLiveId!: string[];

@IsOptional()
@IsBoolean({ each: true })
steamCommunityBanned!: boolean[];

@IsOptional()
@IsBoolean({ each: true })
steamVacBanned!: boolean[];
}

class PlayerSearchInputDTO extends ITakaroQuery<PlayerSearchInputAllowedFilters> {
Expand Down
54 changes: 54 additions & 0 deletions packages/app-api/src/db/player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,18 @@ import {
} from '../service/PlayerService.js';
import { ROLE_TABLE_NAME, RoleModel } from './role.js';
import { PLAYER_ON_GAMESERVER_TABLE_NAME, PlayerOnGameServerModel } from './playerOnGameserver.js';
import { config } from '../config.js';

export interface ISteamData {
steamId: string;
steamAvatar: string;
steamAccountCreated: number;
steamCommunityBanned: boolean;
steamEconomyBan: string;
steamVacBanned: boolean;
steamsDaysSinceLastBan: number;
steamNumberOfVACBans: number;
}

export const PLAYER_TABLE_NAME = 'players';
const ROLE_ON_PLAYER_TABLE_NAME = 'roleOnPlayer';
Expand Down Expand Up @@ -44,6 +56,15 @@ export class PlayerModel extends TakaroModel {
xboxLiveId?: string;
epicOnlineServicesId?: string;

steamLastFetch: Date;
steamAvatar: string;
steamAccountCreated: Date;
steamCommunityBanned: boolean;
steamEconomyBan: string;
steamVacBanned: boolean;
steamsDaysSinceLastBan: number;
steamNumberOfVACBans: number;

static get relationMappings() {
return {
gameServers: {
Expand Down Expand Up @@ -184,4 +205,37 @@ export class PlayerRepo extends ITakaroRepo<PlayerModel, PlayerOutputDTO, Player

await roleOnPlayerModel.query().delete().where(whereObj);
}

async getPlayersToRefreshSteam(): Promise<string[]> {
const { query } = await this.getModel();

const refreshOlderThanDate = new Date(Date.now() - config.get('steam.refreshOlderThanMs')).toISOString();

const players = await query
.select('steamId')
.where('steamId', 'is not', null)
.andWhere(function () {
this.where('steamLastFetch', '<', refreshOlderThanDate).orWhere('steamLastFetch', 'is', null);
})
.orderBy('steamLastFetch', 'asc')
.limit(config.get('steam.refreshBatchSize'));

return players.filter((item) => item.steamId).map((item) => item.steamId) as string[];
}

async setSteamData(data: (ISteamData | undefined)[]) {
const { query } = await this.getModel();
await Promise.all(
data.map((item) => {
if (!item) return;
return query
.update({
...item,
steamAccountCreated: new Date(item.steamAccountCreated * 1000),
steamLastFetch: new Date(),
})
.where('steamId', item.steamId);
})
);
}
}
121 changes: 121 additions & 0 deletions packages/app-api/src/lib/steamApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { AxiosError, AxiosInstance } from 'axios';
import axios from 'axios';
import { config } from '../config.js';
import { logger } from '@takaro/util';

interface IPlayerSummary {
steamid: string;
communityvisibilitystate: number;
profilestate: number;
personaname: string;
commentpermission: number;
profileurl: string;
avatar: string;
avatarmedium: string;
avatarfull: string;
avatarhash: string;
lastlogoff: number;
personastate: number;
primaryclanid: string;
timecreated: number;
personastateflags: number;
}

interface IPlayerBans {
SteamId: string;
CommunityBanned: boolean;
VACBanned: boolean;
NumberOfVACBans: number;
DaysSinceLastBan: number;
NumberOfGameBans: number;
EconomyBan: string;
}

class SteamApi {
private apiKey = config.get('steam.apiKey');
private _client: AxiosInstance;
private log = logger('steamApi');

get client() {
if (!this._client) {
this._client = axios.create({
baseURL: 'https://api.steampowered.com',
timeout: 1000,
});

this._client.interceptors.request.use((config) => {
config.params = config.params || {};
config.params.key = this.apiKey;
return config;
});

axios.interceptors.request.use((request) => {
this.log.info(`➡️ ${request.method?.toUpperCase()} ${request.url}`, {
method: request.method,
url: request.url,
});
return request;
});

axios.interceptors.response.use(
(response) => {
this.log.info(
`⬅️ ${response.request.method?.toUpperCase()} ${response.request.path} ${response.status} ${
response.statusText
}`,
{
status: response.status,
statusText: response.statusText,
method: response.request.method,
url: response.request.url,
}
);

return response;
},
(error: AxiosError) => {
let details = {};

if (error.response?.data) {
const data = error.response.data as Record<string, unknown>;
details = JSON.stringify(data.meta);
}

this.log.error('☠️ Request errored', {
traceId: error.response?.headers['x-trace-id'],
details,
status: error.response?.status,
statusText: error.response?.statusText,
method: error.config?.method,
url: error.config?.url,
response: error.response?.data,
});
return Promise.reject(error);
}
);
}
return this._client;
}

async getPlayerSummaries(steamIds: string[]): Promise<IPlayerSummary[]> {
const response = await this.client.get('/ISteamUser/GetPlayerSummaries/v2', {
params: {
steamids: steamIds.join(','),
},
});

return response.data.response.players;
}

async getPlayerBans(steamIds: string[]): Promise<IPlayerBans[]> {
const response = await this.client.get('/ISteamUser/GetPlayerBans/v1', {
params: {
steamids: steamIds.join(','),
},
});

return response.data.players;
}
}

export const steamApi = new SteamApi();
69 changes: 67 additions & 2 deletions packages/app-api/src/service/PlayerService.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { TakaroService } from './Base.js';

import { PlayerModel, PlayerRepo } from '../db/player.js';
import { IsOptional, IsString, ValidateNested } from 'class-validator';
import { ISteamData, PlayerModel, PlayerRepo } from '../db/player.js';
import { IsBoolean, IsISO8601, IsNumber, IsOptional, IsString, ValidateNested } from 'class-validator';
import { TakaroDTO, TakaroModelDTO, traceableClass } from '@takaro/util';
import { ITakaroQuery } from '@takaro/db';
import { PaginatedOutput } from '../db/base.js';
Expand All @@ -11,6 +11,8 @@ import { IPlayerReferenceDTO } from '@takaro/gameserver';
import { Type } from 'class-transformer';
import { PlayerRoleAssignmentOutputDTO, RoleService } from './RoleService.js';
import { EVENT_TYPES, EventCreateDTO, EventService } from './EventService.js';
import { steamApi } from '../lib/steamApi.js';
import { config } from '../config.js';

export class PlayerOutputDTO extends TakaroModelDTO<PlayerOutputDTO> {
@IsString()
Expand All @@ -26,6 +28,34 @@ export class PlayerOutputDTO extends TakaroModelDTO<PlayerOutputDTO> {
@IsOptional()
epicOnlineServicesId?: string;

@IsString()
@IsOptional()
steamAvatar?: string;

@IsISO8601()
@IsOptional()
steamAccountCreated?: Date;

@IsBoolean()
@IsOptional()
steamCommunityBanned?: boolean;

@IsString()
@IsOptional()
steamEconomyBan?: string;

@IsBoolean()
@IsOptional()
steamVacBanned?: boolean;

@IsNumber()
@IsOptional()
steamsDaysSinceLastBan?: number;

@IsNumber()
@IsOptional()
steamNumberOfVACBans?: number;

@IsOptional()
@ValidateNested({ each: true })
@Type(() => PlayerOnGameserverOutputDTO)
Expand Down Expand Up @@ -195,4 +225,39 @@ export class PlayerService extends TakaroService<PlayerModel, PlayerOutputDTO, P
})
);
}

async handleSteamSync() {
if (!config.get('steam.apiKey')) return;
const toRefresh = await this.repo.getPlayersToRefreshSteam();

if (!toRefresh.length) return;

const [summaries, bans] = await Promise.all([
steamApi.getPlayerSummaries(toRefresh),
steamApi.getPlayerBans(toRefresh),
]);

const fullData: (ISteamData | undefined)[] = toRefresh.map((steamId) => {
const summary = summaries.find((item) => item.steamid === steamId);
const ban = bans.find((item) => item.SteamId === steamId);

if (!summary || !ban) {
this.log.warn('Steam data missing', { steamId, summary, ban });
return;
}

return {
steamId,
steamAvatar: summary.avatarfull,
steamAccountCreated: summary.timecreated,
steamCommunityBanned: ban.CommunityBanned,
steamEconomyBan: ban.EconomyBan,
steamVacBanned: ban.VACBanned,
steamsDaysSinceLastBan: ban.DaysSinceLastBan,
steamNumberOfVACBans: ban.NumberOfVACBans,
};
});

await this.repo.setSteamData(fullData);
}
}
Loading

0 comments on commit 9e6ec3d

Please sign in to comment.