diff --git a/containers/homer/assets/config.yml b/containers/homer/assets/config.yml index 6f90f8dad1..6663cc415d 100644 --- a/containers/homer/assets/config.yml +++ b/containers/homer/assets/config.yml @@ -134,3 +134,7 @@ services: subtitle: "Mail server" url: "http://127.0.0.1:8025" target: "_blank" + - name: "Redis Insight" + icon: "fas fa-database" + subtitle: "Redis UI" + url: "http://127.0.0.1:5540" \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 3167a0d5ad..1774acce16 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,7 +21,7 @@ services: POSTGRES_DB: ${POSTGRES_DB} redis: - image: redis:alpine + image: redis:7.2-alpine # enables memory overcommit on reboot # echo "vm.overcommit_memory = 1" | sudo tee /etc/sysctl.d/takaro-aio-memory-overcommit.conf # sudo sysctl -w vm.overcommit_memory=1 (enables on-the-fly temporary) @@ -31,6 +31,13 @@ services: ports: - 6379:6379 + redis-insight: + image: redis/redisinsight:2.58 + ports: + - "5540:5540" + volumes: + - ./_data/redis-insight:/data + takaro: build: context: . diff --git a/package-lock.json b/package-lock.json index c6bbe0c9d7..92b94a1146 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,7 +25,7 @@ "ajv": "8.17.1", "axios": "1.7.7", "body-parser": "1.20.3", - "bullmq": "5.13.2", + "bullmq": "5.15.0", "class-transformer": "0.5.1", "class-validator": "0.14.1", "class-validator-jsonschema": "5.0.1", @@ -82,7 +82,7 @@ "@types/ms": "0.7.34", "@types/multer": "1.4.12", "@types/node": "20.16.10", - "@types/react": "18.3.10", + "@types/react": "18.3.11", "@types/react-dom": "18.3.0", "@types/safe-regex": "1.1.6", "@types/sinon": "17.0.3", @@ -16656,9 +16656,9 @@ } }, "node_modules/@openapitools/openapi-generator-cli": { - "version": "2.13.12", - "resolved": "https://registry.npmjs.org/@openapitools/openapi-generator-cli/-/openapi-generator-cli-2.13.12.tgz", - "integrity": "sha512-ieYnbFiSYAEXmmLea+BLh50kMCnUxUoMfElKvFNFPkK8xDCFIzdsa5OVfR/gUKJRuWz/znbEF+zIY3rXlBT+3Q==", + "version": "2.13.13", + "resolved": "https://registry.npmjs.org/@openapitools/openapi-generator-cli/-/openapi-generator-cli-2.13.13.tgz", + "integrity": "sha512-uioqbxB6TfiLoOEE3T8kqTn/ffaRzOwS3ATMQnoMvh2lwADKMT6bDLfE3YO3XTEj+HflXcsLXQGK6PLiqa8Mmw==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", @@ -16674,7 +16674,7 @@ "concurrently": "6.5.1", "console.table": "0.10.0", "fs-extra": "10.1.0", - "glob": "7.2.3", + "glob": "9.3.5", "https-proxy-agent": "7.0.5", "inquirer": "8.2.6", "lodash": "4.17.21", @@ -16686,7 +16686,7 @@ "openapi-generator-cli": "main.js" }, "engines": { - "node": ">=10.0.0" + "node": ">=16" }, "funding": { "type": "opencollective", @@ -16709,6 +16709,16 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/@openapitools/openapi-generator-cli/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, "node_modules/@openapitools/openapi-generator-cli/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -16823,22 +16833,19 @@ } }, "node_modules/@openapitools/openapi-generator-cli/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", + "version": "9.3.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-9.3.5.tgz", + "integrity": "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==", "dev": true, "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" + "minimatch": "^8.0.2", + "minipass": "^4.2.4", + "path-scurry": "^1.6.1" }, "engines": { - "node": "*" + "node": ">=16 || 14 >=14.17" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -16854,6 +16861,32 @@ "node": ">=8" } }, + "node_modules/@openapitools/openapi-generator-cli/node_modules/minimatch": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-8.0.4.tgz", + "integrity": "sha512-W0Wvr9HyFXZRGIDgCicunpQ299OKXs9RgZfaukz4qAW/pJhcpUfupc9c+OObPOFueNy8VSrZgEmDtk6Kh4WzDA==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@openapitools/openapi-generator-cli/node_modules/minipass": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-4.2.8.tgz", + "integrity": "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=8" + } + }, "node_modules/@openapitools/openapi-generator-cli/node_modules/reflect-metadata": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.13.tgz", @@ -24280,9 +24313,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "18.3.10", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.10.tgz", - "integrity": "sha512-02sAAlBnP39JgXwkAq3PeU9DVaaGpZyF3MGcC0MKgQVkZor5IiiDAipVaxQHtDJAmO4GIy/rVBy/LzVj76Cyqg==", + "version": "18.3.11", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.11.tgz", + "integrity": "sha512-r6QZ069rFTjrEYgFdOck1gK7FLVsgJE7tTz0pQBczlBNUhBNk0MQH4UbnFSwjpQLMkLzgqvBBa+qGpLje16eTQ==", "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -26909,9 +26942,9 @@ "license": "MIT" }, "node_modules/bullmq": { - "version": "5.13.2", - "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.13.2.tgz", - "integrity": "sha512-McGE8k3mrCvdUHdU0sHkTKDS1xr4pff+hbEKBY51wk5S6Za0gkuejYA620VQTo3Zz37E/NVWMgumwiXPQ3yZcA==", + "version": "5.15.0", + "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.15.0.tgz", + "integrity": "sha512-h53shVjx8s6wxYGtUfzAfENpSP7N5T0D4PMTvbZncozLjb8yUKhopfpa7PmcpQfq7SSO9dm/OZ9XQuGOCSGNug==", "license": "MIT", "dependencies": { "cron-parser": "^4.6.0", @@ -48848,7 +48881,7 @@ "version": "0.0.11", "license": "ISC", "devDependencies": { - "@openapitools/openapi-generator-cli": "2.13.12" + "@openapitools/openapi-generator-cli": "2.13.13" } }, "packages/lib-auth": { diff --git a/package.json b/package.json index 7486bd979e..be94c54211 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "@types/ms": "0.7.34", "@types/multer": "1.4.12", "@types/node": "20.16.10", - "@types/react": "18.3.10", + "@types/react": "18.3.11", "@types/react-dom": "18.3.0", "@types/safe-regex": "1.1.6", "@types/sinon": "17.0.3", @@ -118,7 +118,7 @@ "ajv": "8.17.1", "axios": "1.7.7", "body-parser": "1.20.3", - "bullmq": "5.13.2", + "bullmq": "5.15.0", "class-transformer": "0.5.1", "class-validator": "0.14.1", "class-validator-jsonschema": "5.0.1", diff --git a/packages/app-api/src/controllers/Shop/Order.ts b/packages/app-api/src/controllers/Shop/Order.ts index ee08bd8b6b..9bdd91fb45 100644 --- a/packages/app-api/src/controllers/Shop/Order.ts +++ b/packages/app-api/src/controllers/Shop/Order.ts @@ -29,6 +29,9 @@ class ShopOrderSearchInputAllowedFilters extends AllowedFilters { listingId: string[]; @IsOptional() @IsUUID(4, { each: true }) + gameServerId: string[]; + @IsOptional() + @IsUUID(4, { each: true }) userId: string[]; @IsOptional() @IsNumber({}, { each: true }) diff --git a/packages/app-api/src/controllers/__tests__/ShopOrder.integration.test.ts b/packages/app-api/src/controllers/__tests__/ShopOrder.integration.test.ts index bda8224b78..d1f7ac855e 100644 --- a/packages/app-api/src/controllers/__tests__/ShopOrder.integration.test.ts +++ b/packages/app-api/src/controllers/__tests__/ShopOrder.integration.test.ts @@ -62,6 +62,10 @@ interface IShopSetup extends SetupGameServerPlayers.ISetupData { client1: Client; user2: UserOutputDTO; client2: Client; + user3: UserOutputDTO; + client3: Client; + user4: UserOutputDTO; + client4: Client; } const shopSetup = async function (this: IntegrationTest): Promise { @@ -74,6 +78,11 @@ const shopSetup = async function (this: IntegrationTest): Promise): Promise): Promise): Promise({ + group, + snapshot: false, + name: 'Can filter shop orders by gameserver ID', + setup: shopSetup, + test: async function () { + /** + * Setup listings on both gameservers + * Create orders for both listings + * Then, filter orders by gameserver ID + * Expect to only get orders for that gameserver + */ + const listingGameserver1 = ( + await this.client.shopListing.shopListingControllerCreate({ + gameServerId: this.setupData.gameServer1.id, + items: [{ itemId: this.setupData.items[0].id, amount: 1 }], + price: 1, + name: 'Test item 1', + }) + ).data.data; + + const listingGameserver2 = ( + await this.client.shopListing.shopListingControllerCreate({ + gameServerId: this.setupData.gameServer2.id, + items: [{ itemId: this.setupData.items[0].id, amount: 1 }], + price: 1, + name: 'Test item 2', + }) + ).data.data; + + await this.setupData.client1.shopOrder.shopOrderControllerCreate({ + listingId: listingGameserver1.id, + amount: 1, + }); + + await this.setupData.client3.shopOrder.shopOrderControllerCreate({ + listingId: listingGameserver2.id, + amount: 1, + }); + + const filteredOrders = await this.client.shopOrder.shopOrderControllerSearch({ + filters: { gameServerId: [this.setupData.gameServer1.id] }, + }); + + expect(filteredOrders.data.data).to.have.length(1); + }, + }), ]; describe(group, function () { diff --git a/packages/app-api/src/db/player.ts b/packages/app-api/src/db/player.ts index cd3dc81799..5816592a62 100644 --- a/packages/app-api/src/db/player.ts +++ b/packages/app-api/src/db/player.ts @@ -299,6 +299,10 @@ export class PlayerRepo extends ITakaroRepo) { const { query } = await this.getModel(); - const result = await new QueryBuilder({ + const qry = new QueryBuilder({ ...filters, }).build(query); + + if (filters.filters?.gameServerId && Array.isArray(filters.filters.gameServerId)) { + qry + .join(ShopListingModel.tableName, `${ShopListingModel.tableName}.id`, `${ShopOrderModel.tableName}.listingId`) + .whereIn(`${ShopListingModel.tableName}.gameServerId`, filters.filters.gameServerId as string[]); + } + + const result = await qry; + return { total: result.total, results: await Promise.all(result.results.map((item) => new ShopOrderOutputDTO(item))), diff --git a/packages/app-api/src/lib/steamApi.ts b/packages/app-api/src/lib/steamApi.ts index 31391c701e..5f3adc1dd2 100644 --- a/packages/app-api/src/lib/steamApi.ts +++ b/packages/app-api/src/lib/steamApi.ts @@ -1,6 +1,8 @@ import axios, { AxiosError, AxiosInstance } from 'axios'; import { config } from '../config.js'; -import { addCounterToAxios, logger } from '@takaro/util'; +import { addCounterToAxios, errors, logger } from '@takaro/util'; +import { Redis } from '@takaro/db'; +import ms from 'ms'; interface IPlayerSummary { steamid: string; @@ -30,16 +32,22 @@ interface IPlayerBans { EconomyBan: string; } +const redisClient = await Redis.getClient('steam'); +const STEAM_RATE_LIMITED_KEY = 'steamApiRateLimit'; +// How many calls per day we are allowed to make +const TOTAL_STEAM_API_CALLS = 100000; + class SteamApi { private apiKey = config.get('steam.apiKey'); private _client: AxiosInstance; private log = logger('steamApi'); + public isRateLimited = false; get client() { if (!this._client) { this._client = axios.create({ baseURL: 'https://api.steampowered.com', - timeout: 1000, + timeout: 10000, }); addCounterToAxios(this._client, { @@ -48,6 +56,23 @@ class SteamApi { }); this._client.interceptors.request.use((config) => { + if (this.isRateLimited) { + this.log.warn('Rate limited by Steam API, skipping request. This will reset after 24 hours'); + const controller = new AbortController(); + controller.abort(); + } + + this.incrCallCounter(); + + return config; + }); + + this._client.interceptors.request.use((config) => { + if (!this.apiKey) { + this.log.warn('Steam API key not set, skipping sync'); + throw new errors.ConfigError('Steam API key not set'); + } + config.params ||= {}; config.params.key = this.apiKey; return config; @@ -77,7 +102,7 @@ class SteamApi { return response; }, - (error: AxiosError) => { + async (error: AxiosError) => { let details = {}; if (error.response?.data) { @@ -85,6 +110,10 @@ class SteamApi { details = JSON.stringify(data.meta); } + if (error.response?.status === 429) { + await this.setRateLimited(); + } + this.log.error('☠️ Request errored', { traceId: error.response?.headers['x-trace-id'], details, @@ -129,6 +158,34 @@ class SteamApi { return response.data.response.player_level; } + + private async setRateLimited() { + this.log.warn('Rate limited by Steam API'); + await redisClient.set(STEAM_RATE_LIMITED_KEY, 'true', { + PX: ms('1day'), + }); + } + + async refreshRateLimitedStatus() { + const res = await redisClient.get(STEAM_RATE_LIMITED_KEY); + this.isRateLimited = res === 'true'; + return this.isRateLimited; + } + + async incrCallCounter() { + await redisClient.incr(this.steamCallsKey); + // Ensure the key gets cleaned after a few days + await redisClient.expire(this.steamCallsKey, ms('7days') / 1000, 'NX'); + } + + async getRemainingCalls() { + const calls = await redisClient.get(this.steamCallsKey); + return TOTAL_STEAM_API_CALLS - parseInt(calls || '0', 10); + } + + get steamCallsKey() { + return `steamApiCallsMade:${new Date().toISOString().split('T')[0]}`; + } } export const steamApi = new SteamApi(); diff --git a/packages/app-api/src/service/PlayerService.ts b/packages/app-api/src/service/PlayerService.ts index 83f423d281..573a8e6a26 100644 --- a/packages/app-api/src/service/PlayerService.ts +++ b/packages/app-api/src/service/PlayerService.ts @@ -351,8 +351,8 @@ export class PlayerService extends TakaroService { - if (!config.get('steam.apiKey')) { - this.log.warn('Steam API key not set, skipping sync'); + if (steamApi.isRateLimited) { + this.log.error('Rate limited, skipping steam sync'); return 0; } const toRefresh = await this.repo.getPlayersToRefreshSteam(); @@ -394,6 +394,38 @@ export class PlayerService extends TakaroService) { if (type === EVENT_TYPES.PLAYER_CONNECTED) { await playerOnGameServerService.update(pog.id, new PlayerOnGameServerUpdateDTO({ online: true })); + if (player.steamId) await playerService.syncSingleSteamAccount(player.steamId); await eventService.create( new EventCreateDTO({ diff --git a/packages/app-api/src/workers/steamSyncWorker.ts b/packages/app-api/src/workers/steamSyncWorker.ts index b76d4ebcf6..48a36bdac8 100644 --- a/packages/app-api/src/workers/steamSyncWorker.ts +++ b/packages/app-api/src/workers/steamSyncWorker.ts @@ -4,6 +4,7 @@ import { Job } from 'bullmq'; import { logger, ctx } from '@takaro/util'; import { DomainService } from '../service/DomainService.js'; import { PlayerService } from '../service/PlayerService.js'; +import { steamApi } from '../lib/steamApi.js'; const log = logger('worker:steamSync'); @@ -32,6 +33,14 @@ export async function processJob(job: Job) { if (job.data.domainId === 'all') { log.info('Processing steamSync job for all domains'); + await steamApi.refreshRateLimitedStatus(); + + const remainingCalls = await steamApi.getRemainingCalls(); + if (remainingCalls < 10000) { + log.warn('Less than 10k calls remaining, skipping job so realtime updates are not affected'); + return; + } + const domainsService = new DomainService(); const domains = await domainsService.find({}); diff --git a/packages/lib-apiclient/package.json b/packages/lib-apiclient/package.json index 4d499f773d..e735df4d74 100644 --- a/packages/lib-apiclient/package.json +++ b/packages/lib-apiclient/package.json @@ -19,6 +19,6 @@ "license": "ISC", "dependencies": {}, "devDependencies": { - "@openapitools/openapi-generator-cli": "2.13.12" + "@openapitools/openapi-generator-cli": "2.13.13" } } diff --git a/packages/lib-apiclient/src/generated/api.ts b/packages/lib-apiclient/src/generated/api.ts index 16a8e4b5ee..192db2eabf 100644 --- a/packages/lib-apiclient/src/generated/api.ts +++ b/packages/lib-apiclient/src/generated/api.ts @@ -4,7 +4,7 @@ * Takaro app-api * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) * - * The version of the OpenAPI document: development - fed5a6efe9e46ce59cd6ec11031f8d8bddd3bece + * The version of the OpenAPI document: development - beba7b82477f27e48a128b4b6028e6fdfa149ec4 * Contact: support@takaro.io * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). @@ -8384,6 +8384,12 @@ export interface ShopOrderSearchInputAllowedFilters { * @memberof ShopOrderSearchInputAllowedFilters */ listingId?: Array; + /** + * + * @type {Array} + * @memberof ShopOrderSearchInputAllowedFilters + */ + gameServerId?: Array; /** * * @type {Array} diff --git a/packages/lib-apiclient/src/generated/base.ts b/packages/lib-apiclient/src/generated/base.ts index 74a72c2f11..d8cc4edeaa 100644 --- a/packages/lib-apiclient/src/generated/base.ts +++ b/packages/lib-apiclient/src/generated/base.ts @@ -4,7 +4,7 @@ * Takaro app-api * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) * - * The version of the OpenAPI document: development - fed5a6efe9e46ce59cd6ec11031f8d8bddd3bece + * The version of the OpenAPI document: development - beba7b82477f27e48a128b4b6028e6fdfa149ec4 * Contact: support@takaro.io * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/packages/lib-apiclient/src/generated/common.ts b/packages/lib-apiclient/src/generated/common.ts index 76935c54a1..d48c577a02 100644 --- a/packages/lib-apiclient/src/generated/common.ts +++ b/packages/lib-apiclient/src/generated/common.ts @@ -4,7 +4,7 @@ * Takaro app-api * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) * - * The version of the OpenAPI document: development - fed5a6efe9e46ce59cd6ec11031f8d8bddd3bece + * The version of the OpenAPI document: development - beba7b82477f27e48a128b4b6028e6fdfa149ec4 * Contact: support@takaro.io * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/packages/lib-apiclient/src/generated/configuration.ts b/packages/lib-apiclient/src/generated/configuration.ts index 047c5402ca..0777f922e9 100644 --- a/packages/lib-apiclient/src/generated/configuration.ts +++ b/packages/lib-apiclient/src/generated/configuration.ts @@ -4,7 +4,7 @@ * Takaro app-api * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) * - * The version of the OpenAPI document: development - fed5a6efe9e46ce59cd6ec11031f8d8bddd3bece + * The version of the OpenAPI document: development - beba7b82477f27e48a128b4b6028e6fdfa149ec4 * Contact: support@takaro.io * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/packages/lib-apiclient/src/generated/index.ts b/packages/lib-apiclient/src/generated/index.ts index 259d0679b1..469841c9c0 100644 --- a/packages/lib-apiclient/src/generated/index.ts +++ b/packages/lib-apiclient/src/generated/index.ts @@ -4,7 +4,7 @@ * Takaro app-api * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) * - * The version of the OpenAPI document: development - fed5a6efe9e46ce59cd6ec11031f8d8bddd3bece + * The version of the OpenAPI document: development - beba7b82477f27e48a128b4b6028e6fdfa149ec4 * Contact: support@takaro.io * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/packages/lib-modules/src/__tests__/economy/shop.integration.test.ts b/packages/lib-modules/src/__tests__/economy/shop.integration.test.ts index 5bcb413db5..a5f82067b2 100644 --- a/packages/lib-modules/src/__tests__/economy/shop.integration.test.ts +++ b/packages/lib-modules/src/__tests__/economy/shop.integration.test.ts @@ -8,17 +8,22 @@ const tests = [ group, snapshot: false, setup: shopSetup, - name: 'Can view the first page of shop items', + name: 'Calling /shop without arguments displays help information', test: async function () { - const events = (await new EventsAwaiter().connect(this.client)).waitForEvents(HookEvents.CHAT_MESSAGE, 2); + const events = (await new EventsAwaiter().connect(this.client)).waitForEvents(HookEvents.CHAT_MESSAGE, 5); await this.client.command.commandControllerTrigger(this.setupData.gameserver.id, { msg: '/shop', playerId: this.setupData.players[0].id, }); - expect(await events).to.have.length(2); - expect((await events)[0].data.meta.msg).to.eq('- Test item - 100 test coin. 1x Stone'); - expect((await events)[1].data.meta.msg).to.eq('- Test item 2 - 33 test coin. 1x Wood'); + expect(await events).to.have.length(5); + expect((await events)[0].data.meta.msg).to.eq( + 'This command allows you to browse the shop and view available items.', + ); + expect((await events)[1].data.meta.msg).to.eq('Usage: /shop [page] [item] [action]'); + expect((await events)[2].data.meta.msg).to.eq('/shop 2 - View the second page of shop items'); + expect((await events)[3].data.meta.msg).to.eq('/shop 1 3 - View details about the third item on the first page'); + expect((await events)[4].data.meta.msg).to.eq('/shop 1 3 buy - Purchase the third item on the first page'); }, }), new IntegrationTest({ @@ -30,7 +35,7 @@ const tests = [ await this.setupData.createListings(this.client, { gameServerId: this.setupData.gameserver.id, amount: 5 }); const events = (await new EventsAwaiter().connect(this.client)).waitForEvents(HookEvents.CHAT_MESSAGE, 5); await this.client.command.commandControllerTrigger(this.setupData.gameserver.id, { - msg: '/shop', + msg: '/shop 1', playerId: this.setupData.players[0].id, }); diff --git a/packages/lib-modules/src/modules/economyUtils/commands/shop.js b/packages/lib-modules/src/modules/economyUtils/commands/shop.js index 90a96f237a..7fb12237cd 100644 --- a/packages/lib-modules/src/modules/economyUtils/commands/shop.js +++ b/packages/lib-modules/src/modules/economyUtils/commands/shop.js @@ -2,8 +2,19 @@ import { takaro, data, TakaroUserError } from '@takaro/helpers'; async function main() { const { arguments: args, player, gameServerId, user } = data; - const { page, item, action } = args; + const prefix = (await takaro.settings.settingsControllerGetOne('commandPrefix', gameServerId)).data.data.value; + + // If command is called without any arguments + const messageWithoutPrefix = data.chatMessage.msg.slice(prefix.length).trim(); + if (!messageWithoutPrefix.includes(' ')) { + await player.pm('This command allows you to browse the shop and view available items.'); + await player.pm(`Usage: ${prefix}shop [page] [item] [action]`); + await player.pm(`${prefix}shop 2 - View the second page of shop items`); + await player.pm(`${prefix}shop 1 3 - View details about the third item on the first page`); + await player.pm(`${prefix}shop 1 3 buy - Purchase the third item on the first page`); + return; + } const shopItems = await takaro.shopListing.shopListingControllerSearch({ limit: 5, diff --git a/packages/lib-queues/src/config.ts b/packages/lib-queues/src/config.ts index 1f55dbb0a7..ce82b0a124 100644 --- a/packages/lib-queues/src/config.ts +++ b/packages/lib-queues/src/config.ts @@ -189,7 +189,7 @@ export const queuesConfigSchema = { interval: { doc: 'The interval to run the steam sync', format: Number, - default: ms('10minutes'), + default: ms('60minutes'), env: 'STEAM_SYNC_QUEUE_INTERVAL', }, }, diff --git a/packages/web-docs/docs/modules.json b/packages/web-docs/docs/modules.json index 856e75a9cd..97945dd1e3 100644 --- a/packages/web-docs/docs/modules.json +++ b/packages/web-docs/docs/modules.json @@ -410,7 +410,7 @@ ] }, { - "function": "import { takaro, data, TakaroUserError } from '@takaro/helpers';\nasync function main() {\n const { arguments: args, player, gameServerId, user } = data;\n const { page, item, action } = args;\n const shopItems = await takaro.shopListing.shopListingControllerSearch({\n limit: 5,\n page: page - 1,\n sortBy: 'name',\n sortDirection: 'asc',\n filters: {\n gameServerId: [gameServerId],\n draft: false,\n },\n });\n if (shopItems.data.data.length === 0) {\n await player.pm('No items found.');\n return;\n }\n const currencyName = (await takaro.settings.settingsControllerGetOne('currencyName', data.gameServerId)).data.data;\n if (!item) {\n // List the shop items\n for (const listing of shopItems.data.data) {\n const items = listing.items.slice(0, 3).map((item) => {\n return `${item.amount}x ${item.item.name}`;\n });\n await player.pm(`- ${listing.name} - ${listing.price} ${currencyName.value}. ${items.join(', ')}`);\n }\n return;\n }\n const selectedItem = shopItems.data.data[item - 1];\n if (!selectedItem)\n throw new TakaroUserError(`Item not found. Please select an item from the list, valid options are 1-${shopItems.data.data.length}.`);\n if (action === 'none') {\n // Display more info about the item\n await player.pm(`Listing ${selectedItem.name} - ${selectedItem.price} ${currencyName.value}`);\n await Promise.all(selectedItem.items.map((item) => {\n const quality = item.quality ? `Quality: ${item.quality}` : '';\n const description = (item.item.description ? `Description: ${item.item.description}` : '').replaceAll('\\\\n', ' ');\n return player.pm(`- ${item.amount}x ${item.item.name}. ${quality} ${description}`);\n }));\n return;\n }\n if (action === 'buy') {\n if (!user)\n throw new TakaroUserError('You must link your account to Takaro to use this command.');\n const orderRes = await takaro.shopOrder.shopOrderControllerCreate({\n amount: 1,\n listingId: selectedItem.id,\n userId: user.id,\n });\n await player.pm(`You have purchased ${selectedItem.name} for ${selectedItem.price} ${currencyName.value}.`);\n await takaro.shopOrder.shopOrderControllerClaim(orderRes.data.data.id);\n return;\n }\n throw new TakaroUserError('Invalid action. Valid actions are \"buy\".');\n}\nawait main();\n//# sourceMappingURL=shop.js.map", + "function": "import { takaro, data, TakaroUserError } from '@takaro/helpers';\nasync function main() {\n const { arguments: args, player, gameServerId, user } = data;\n const { page, item, action } = args;\n const prefix = (await takaro.settings.settingsControllerGetOne('commandPrefix', gameServerId)).data.data.value;\n // If command is called without any arguments\n const messageWithoutPrefix = data.chatMessage.msg.slice(prefix.length).trim();\n if (!messageWithoutPrefix.includes(' ')) {\n await player.pm('This command allows you to browse the shop and view available items.');\n await player.pm(`Usage: ${prefix}shop [page] [item] [action]`);\n await player.pm(`${prefix}shop 2 - View the second page of shop items`);\n await player.pm(`${prefix}shop 1 3 - View details about the third item on the first page`);\n await player.pm(`${prefix}shop 1 3 buy - Purchase the third item on the first page`);\n return;\n }\n const shopItems = await takaro.shopListing.shopListingControllerSearch({\n limit: 5,\n page: page - 1,\n sortBy: 'name',\n sortDirection: 'asc',\n filters: {\n gameServerId: [gameServerId],\n draft: false,\n },\n });\n if (shopItems.data.data.length === 0) {\n await player.pm('No items found.');\n return;\n }\n const currencyName = (await takaro.settings.settingsControllerGetOne('currencyName', data.gameServerId)).data.data;\n if (!item) {\n // List the shop items\n for (const listing of shopItems.data.data) {\n const items = listing.items.slice(0, 3).map((item) => {\n return `${item.amount}x ${item.item.name}`;\n });\n await player.pm(`- ${listing.name} - ${listing.price} ${currencyName.value}. ${items.join(', ')}`);\n }\n return;\n }\n const selectedItem = shopItems.data.data[item - 1];\n if (!selectedItem)\n throw new TakaroUserError(`Item not found. Please select an item from the list, valid options are 1-${shopItems.data.data.length}.`);\n if (action === 'none') {\n // Display more info about the item\n await player.pm(`Listing ${selectedItem.name} - ${selectedItem.price} ${currencyName.value}`);\n await Promise.all(selectedItem.items.map((item) => {\n const quality = item.quality ? `Quality: ${item.quality}` : '';\n const description = (item.item.description ? `Description: ${item.item.description}` : '').replaceAll('\\\\n', ' ');\n return player.pm(`- ${item.amount}x ${item.item.name}. ${quality} ${description}`);\n }));\n return;\n }\n if (action === 'buy') {\n if (!user)\n throw new TakaroUserError('You must link your account to Takaro to use this command.');\n const orderRes = await takaro.shopOrder.shopOrderControllerCreate({\n amount: 1,\n listingId: selectedItem.id,\n userId: user.id,\n });\n await player.pm(`You have purchased ${selectedItem.name} for ${selectedItem.price} ${currencyName.value}.`);\n await takaro.shopOrder.shopOrderControllerClaim(orderRes.data.data.id);\n return;\n }\n throw new TakaroUserError('Invalid action. Valid actions are \"buy\".');\n}\nawait main();\n//# sourceMappingURL=shop.js.map", "name": "shop", "trigger": "shop", "helpText": "Browse the shop and view available items.",