diff --git a/apps/service-app/src/app.module.ts b/apps/service-app/src/app.module.ts index b5186b4..596c06d 100644 --- a/apps/service-app/src/app.module.ts +++ b/apps/service-app/src/app.module.ts @@ -1,12 +1,13 @@ import { MongodbModule } from '@app/mongodb'; +import { RedisModule } from '@app/redis'; +import { RestModule } from '@app/rest'; import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { AppController } from './app.controller'; import { AppService } from './app.service'; -import { RedisModule } from '@app/redis'; @Module({ - imports: [ConfigModule.forRoot({ isGlobal: true }), MongodbModule, RedisModule], + imports: [ConfigModule.forRoot({ isGlobal: true }), MongodbModule, RedisModule, RestModule], controllers: [AppController], providers: [AppService], }) diff --git a/apps/service-app/src/app.service.ts b/apps/service-app/src/app.service.ts index d046e89..2b89d88 100644 --- a/apps/service-app/src/app.service.ts +++ b/apps/service-app/src/app.service.ts @@ -1,6 +1,7 @@ import { Tokens } from '@app/constants'; import { MongodbService } from '@app/mongodb'; import { RedisClient, RedisService } from '@app/redis'; +import RestHandler from '@app/rest/rest.module'; import { Inject, Injectable } from '@nestjs/common'; import { Db } from 'mongodb'; @@ -9,6 +10,7 @@ export class AppService { constructor( @Inject(Tokens.MONGODB) private readonly db: Db, @Inject(Tokens.REDIS) private readonly redis: RedisClient, + @Inject(Tokens.REST) private readonly restClient: RestHandler, private readonly redisService: RedisService, private readonly mongoService: MongodbService, ) {} diff --git a/apps/service-capital/src/service-capital.controller.spec.ts b/apps/service-capital/src/service-capital.controller.spec.ts deleted file mode 100644 index 7006011..0000000 --- a/apps/service-capital/src/service-capital.controller.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { ServiceCapitalController } from './service-capital.controller'; -import { ServiceCapitalService } from './service-capital.service'; - -describe('ServiceCapitalController', () => { - let serviceCapitalController: ServiceCapitalController; - - beforeEach(async () => { - const app: TestingModule = await Test.createTestingModule({ - controllers: [ServiceCapitalController], - providers: [ServiceCapitalService], - }).compile(); - - serviceCapitalController = app.get(ServiceCapitalController); - }); - - describe('root', () => { - it('should return "Hello World!"', () => { - expect(serviceCapitalController.getHello()).toBe('Hello World!'); - }); - }); -}); diff --git a/apps/service-capital/src/service-capital.controller.ts b/apps/service-capital/src/service-capital.controller.ts index a955dd4..993df85 100644 --- a/apps/service-capital/src/service-capital.controller.ts +++ b/apps/service-capital/src/service-capital.controller.ts @@ -1,9 +1,9 @@ import { Controller, Get } from '@nestjs/common'; -import { ServiceCapitalService } from './service-capital.service'; +import { CapitalService } from './service-capital.service'; @Controller() export class ServiceCapitalController { - constructor(private readonly serviceCapitalService: ServiceCapitalService) {} + constructor(private readonly serviceCapitalService: CapitalService) {} @Get() getHello(): string { diff --git a/apps/service-capital/src/service-capital.module.ts b/apps/service-capital/src/service-capital.module.ts index 37914f0..4544f4a 100644 --- a/apps/service-capital/src/service-capital.module.ts +++ b/apps/service-capital/src/service-capital.module.ts @@ -1,10 +1,14 @@ +import { MongodbModule } from '@app/mongodb'; +import { RedisModule } from '@app/redis'; +import { RestModule } from '@app/rest'; import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; import { ServiceCapitalController } from './service-capital.controller'; -import { ServiceCapitalService } from './service-capital.service'; +import { CapitalService } from './service-capital.service'; @Module({ - imports: [], + imports: [ConfigModule.forRoot({ isGlobal: true }), MongodbModule, RedisModule, RestModule], controllers: [ServiceCapitalController], - providers: [ServiceCapitalService], + providers: [CapitalService], }) export class ServiceCapitalModule {} diff --git a/apps/service-capital/src/service-capital.service.ts b/apps/service-capital/src/service-capital.service.ts index 11fe7a2..fcad7a0 100644 --- a/apps/service-capital/src/service-capital.service.ts +++ b/apps/service-capital/src/service-capital.service.ts @@ -1,7 +1,83 @@ -import { Injectable } from '@nestjs/common'; +import { Collections, RedisKeyPrefixes, Tokens } from '@app/constants'; +import { CapitalRaidSeasonsEntity } from '@app/entities/capital.entity'; +import { MongodbService } from '@app/mongodb'; +import { RedisClient, RedisService, TrackedClanList, getRedisKey } from '@app/redis'; +import RestHandler from '@app/rest/rest.module'; +import { Inject, Injectable } from '@nestjs/common'; +import moment from 'moment'; +import { Collection, Db } from 'mongodb'; @Injectable() -export class ServiceCapitalService { +export class CapitalService { + constructor( + @Inject(Tokens.MONGODB) private readonly db: Db, + @Inject(Tokens.REDIS) private readonly redis: RedisClient, + @Inject(Tokens.REST) private readonly restClient: RestHandler, + private readonly redisService: RedisService, + private readonly mongoService: MongodbService, + @Inject(Collections.CAPITAL_RAID_SEASONS) + private readonly capitalRaidsCollection: Collection, + ) {} + + private readonly cached = new Map(); + + async onModuleInit() { + await this.loadClans(); + } + + async fetchCapitalRaidWeekend(clanTag: string) { + const clan = await this.redisService.getClan(clanTag); + if (!clan) return null; + + const { body, res } = await this.restClient.getCapitalRaidSeasons(clanTag, { limit: 1 }); + if (!res.ok || !body.items.length) return null; + + const season = body.items.at(0)!; + if (!Array.isArray(season.members)) return null; + + const { weekId } = this.getCapitalRaidWeekendTiming(); + const raidWeekId = this.getCurrentWeekId(season.startTime); + + const isCached = await this.redis.get( + getRedisKey(RedisKeyPrefixes.CAPITAL_RAID_WEEK, `${weekId}-${clan.tag}`), + ); + if (!isCached && raidWeekId === weekId) { + // TODO: push reminders + } + } + + async loadClans() { + const clans = await this.redisService.getTrackedClans(); + for (const clan of clans) this.cached.set(clan.tag, clan); + } + + async startPolling() { + for (const clanTag of this.cached.keys()) { + await this.fetchCapitalRaidWeekend(clanTag); + } + } + + public getCapitalRaidWeekendTiming() { + const start = moment(); + const day = start.day(); + const hours = start.hours(); + const isRaidWeekend = + (day === 5 && hours >= 7) || [0, 6].includes(day) || (day === 1 && hours < 7); + if (day < 5 || (day <= 5 && hours < 7)) start.day(-7); + start.day(5); + start.hours(7).minutes(0).seconds(0).milliseconds(0); + return { + startTime: start.toDate(), + endTime: start.clone().add(3, 'days').toDate(), + weekId: start.format('YYYY-MM-DD'), + isRaidWeekend, + }; + } + + private getCurrentWeekId(weekId: string) { + return moment(weekId).toDate().toISOString().substring(0, 10); + } + getHello(): string { return 'Hello World!'; } diff --git a/libs/constants/src/constants.values.ts b/libs/constants/src/constants.values.ts index 610f1a9..2251e2a 100644 --- a/libs/constants/src/constants.values.ts +++ b/libs/constants/src/constants.values.ts @@ -9,8 +9,15 @@ export enum TokenType { ACCESS_TOKEN = 'access_token', } +export enum RedisKeyPrefixes { + CAPITAL_RAID_SEASON = 'CRS', + CAPITAL_RAID_WEEK = 'CR', + CLAN = 'C', + PLAYER = 'P', + LINKED_CLANS = 'LINKED_CLANS', +} + export enum Collections { - // LOG_CHANNELS CLAN_STORES = 'ClanStores', DONATION_LOGS = 'DonationLogs', LAST_SEEN_LOGS = 'LastSeenLogs', @@ -25,10 +32,8 @@ export enum Collections { LEGEND_ATTACKS = 'LegendAttacks', - // FLAGS FLAGS = 'Flags', - // LINKED_DATA LINKED_CLANS = 'LinkedClans', LINKED_PLAYERS = 'LinkedPlayers', LINKED_CHANNELS = 'LinkedChannels', @@ -36,7 +41,6 @@ export enum Collections { REMINDERS = 'Reminders', SCHEDULERS = 'Schedulers', - // LARGE_DATA PATRONS = 'Patrons', SETTINGS = 'Settings', LAST_SEEN = 'LastSeen', @@ -58,7 +62,6 @@ export enum Collections { CAPITAL_RANKS = 'CapitalRanks', CLAN_RANKS = 'ClanRanks', - // BOT_STATS BOT_GROWTH = 'BotGrowth', BOT_USAGE = 'BotUsage', BOT_GUILDS = 'BotGuilds', diff --git a/libs/entities/src/capital.entity.ts b/libs/entities/src/capital.entity.ts new file mode 100644 index 0000000..c1688ae --- /dev/null +++ b/libs/entities/src/capital.entity.ts @@ -0,0 +1,3 @@ +import { APICapitalRaidSeason } from 'clashofclans.js'; + +export interface CapitalRaidSeasonsEntity extends APICapitalRaidSeason {} diff --git a/libs/entities/src/wars.entity.ts b/libs/entities/src/wars.entity.ts index 6cbe089..41c7b87 100644 --- a/libs/entities/src/wars.entity.ts +++ b/libs/entities/src/wars.entity.ts @@ -1,9 +1,3 @@ -export class ClanStoresEntity { - guildId: string; +import { APIClanWar } from 'clashofclans.js'; - name: string; - - tag: string; - - createdAt: Date; -} +export interface ClanWarsEntity extends APIClanWar {} diff --git a/libs/mongodb/src/mongodb.module.ts b/libs/mongodb/src/mongodb.module.ts index c50566b..55b5d02 100644 --- a/libs/mongodb/src/mongodb.module.ts +++ b/libs/mongodb/src/mongodb.module.ts @@ -1,4 +1,4 @@ -import { Tokens } from '@app/constants'; +import { Collections, Tokens } from '@app/constants'; import { Module, Provider } from '@nestjs/common'; import { Db, MongoClient } from 'mongodb'; import { MongodbService } from './mongodb.service'; @@ -11,8 +11,16 @@ const MongodbProvider: Provider = { }, }; +export const collectionProviders: Provider[] = Object.values(Collections).map((collection) => ({ + provide: collection, + useFactory: async (db: Db) => { + return db.collection(collection); + }, + inject: [Tokens.MONGODB], +})); + @Module({ - providers: [MongodbProvider, MongodbService], - exports: [MongodbProvider, MongodbService], + providers: [MongodbProvider, MongodbService, ...collectionProviders], + exports: [MongodbProvider, MongodbService, ...collectionProviders], }) export class MongodbModule {} diff --git a/libs/redis/src/redis.service.ts b/libs/redis/src/redis.service.ts index 9130b6c..43efb3d 100644 --- a/libs/redis/src/redis.service.ts +++ b/libs/redis/src/redis.service.ts @@ -1,8 +1,30 @@ -import { Tokens } from '@app/constants'; +import { RedisKeyPrefixes, Tokens } from '@app/constants'; import { Inject, Injectable } from '@nestjs/common'; +import { APIClan } from 'clashofclans.js'; import { RedisClient } from './redis.module'; +export const getRedisKey = (prefix: RedisKeyPrefixes, key: string): string => { + return `${prefix}:${key}`; +}; + @Injectable() export class RedisService { constructor(@Inject(Tokens.REDIS) private readonly redis: RedisClient) {} + + async getTrackedClans(): Promise { + const result = await this.redis.json.get(getRedisKey(RedisKeyPrefixes.LINKED_CLANS, 'ALL')); + return (result ?? []) as unknown as TrackedClanList[]; + } + + async getClan(clanTag: string): Promise { + const result = await this.redis.json.get(getRedisKey(RedisKeyPrefixes.CLAN, clanTag)); + return result as unknown as APIClan; + } +} + +export interface TrackedClanList { + clan: string; + tag: string; + isPatron: boolean; + guildIds: string[]; } diff --git a/libs/rest/src/rest.module.ts b/libs/rest/src/rest.module.ts index 2f93514..0286497 100644 --- a/libs/rest/src/rest.module.ts +++ b/libs/rest/src/rest.module.ts @@ -1,12 +1,52 @@ import { Tokens } from '@app/constants'; -import { Module, Provider } from '@nestjs/common'; -import { Client } from 'clashofclans.js'; +import { Logger, Module, Provider } from '@nestjs/common'; +import { + QueueThrottler, + RESTManager, + RequestHandler, + RequestOptions, + Result, +} from 'clashofclans.js'; import { RestService } from './rest.service'; +class ReqHandler extends RequestHandler { + private readonly logger = new Logger('ClashApiRest'); + + public async request(path: string, options: RequestOptions = {}): Promise> { + const result = await super.request(path, options); + if ( + !result.res.ok && + // @ts-expect-error --- + !(!result.body?.message && result.res.status === 403) && + !(path.includes('war') && result.res.status === 404) + ) { + this.logger.log(`${result.res.status} ${path}`); + } + return result; + } +} + +export default class RestHandler extends RESTManager { + public constructor(rateLimit: number) { + super(); + this.requestHandler = new ReqHandler({ + cache: false, + rejectIfNotValid: false, + restRequestTimeout: 10_000, + retryLimit: 0, + connections: 50, + pipelining: 10, + baseURL: process.env.CLASH_API_BASE_URL, + keys: process.env.CLASH_API_TOKENS?.split(',') ?? [], + throttler: rateLimit ? new QueueThrottler(rateLimit) : null, + }); + } +} + const RestProvider: Provider = { provide: Tokens.REST, - useFactory: (): Client => { - return new Client(); + useFactory: (): RestHandler => { + return new RestHandler(0); }, }; diff --git a/libs/rest/src/rest.service.ts b/libs/rest/src/rest.service.ts index 89b9ad1..eaf10e8 100644 --- a/libs/rest/src/rest.service.ts +++ b/libs/rest/src/rest.service.ts @@ -1,6 +1,6 @@ import { Tokens } from '@app/constants'; import { Inject, Injectable } from '@nestjs/common'; -import type { Client } from 'clashofclans.js'; +import { Client } from 'clashofclans.js'; @Injectable() export class RestService { diff --git a/package-lock.json b/package-lock.json index 9921d35..a72da34 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,9 +14,11 @@ "@nestjs/config": "^3.1.1", "@nestjs/core": "^10.0.0", "@nestjs/jwt": "^10.1.1", + "@nestjs/microservices": "^10.2.6", "@nestjs/passport": "^10.0.2", "@nestjs/platform-express": "^10.0.0", - "clashofclans.js": "^3.1.4-dev.68bb5bc", + "clashofclans.js": "^3.1.5-dev.6c2a144", + "moment": "^2.29.4", "mongodb": "^6.1.0", "passport-jwt": "^4.0.1", "redis": "^4.6.8", @@ -1646,6 +1648,63 @@ "npm": ">=6" } }, + "node_modules/@nestjs/microservices": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/@nestjs/microservices/-/microservices-10.2.6.tgz", + "integrity": "sha512-Ef5Tv0arRSXmMwzlOvXHZEoOS8QlftIrDVrLkpcR6x5Q3BaKrkGOKBet6w2JbssX4eEGt2nw4dy/TbzN9pQYFw==", + "dependencies": { + "iterare": "1.2.1", + "tslib": "2.6.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@grpc/grpc-js": "*", + "@nestjs/common": "^10.0.0", + "@nestjs/core": "^10.0.0", + "@nestjs/websockets": "^10.0.0", + "amqp-connection-manager": "*", + "amqplib": "*", + "cache-manager": "*", + "ioredis": "*", + "kafkajs": "*", + "mqtt": "*", + "nats": "*", + "reflect-metadata": "^0.1.12", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "@grpc/grpc-js": { + "optional": true + }, + "@nestjs/websockets": { + "optional": true + }, + "amqp-connection-manager": { + "optional": true + }, + "amqplib": { + "optional": true + }, + "cache-manager": { + "optional": true + }, + "ioredis": { + "optional": true + }, + "kafkajs": { + "optional": true + }, + "mqtt": { + "optional": true + }, + "nats": { + "optional": true + } + } + }, "node_modules/@nestjs/passport": { "version": "10.0.2", "resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-10.0.2.tgz", @@ -3346,9 +3405,10 @@ "dev": true }, "node_modules/clashofclans.js": { - "version": "3.1.4-dev.68bb5bc", - "resolved": "https://registry.npmjs.org/clashofclans.js/-/clashofclans.js-3.1.4-dev.68bb5bc.tgz", - "integrity": "sha512-y1ix2NduzdrgloTaLUrg6uDQYOldsWk7anSFPfHbSLET/WqqVRtVzwezP6ZCubbitcI5J4XA21NNmfcC1WsG/A==", + "version": "3.1.5-dev.6c2a144", + "resolved": "https://registry.npmjs.org/clashofclans.js/-/clashofclans.js-3.1.5-dev.6c2a144.tgz", + "integrity": "sha512-3yT5lawMTf+bzmGvlD9p0T/KjQYJANk3Sz0ZPfm5/dq72mNSA5C+XdFy4pEgOQ18JxG8zjaZPJPljGwMzM3I6g==", + "deprecated": "No longer supported", "dependencies": { "undici": "^5.23.0" }, @@ -6514,6 +6574,14 @@ "mkdirp": "bin/cmd.js" } }, + "node_modules/moment": { + "version": "2.29.4", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", + "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==", + "engines": { + "node": "*" + } + }, "node_modules/mongodb": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.1.0.tgz", diff --git a/package.json b/package.json index 7706d2a..ce6a3f5 100644 --- a/package.json +++ b/package.json @@ -25,9 +25,11 @@ "@nestjs/config": "^3.1.1", "@nestjs/core": "^10.0.0", "@nestjs/jwt": "^10.1.1", + "@nestjs/microservices": "^10.2.6", "@nestjs/passport": "^10.0.2", "@nestjs/platform-express": "^10.0.0", - "clashofclans.js": "^3.1.4-dev.68bb5bc", + "clashofclans.js": "^3.1.5-dev.6c2a144", + "moment": "^2.29.4", "mongodb": "^6.1.0", "passport-jwt": "^4.0.1", "redis": "^4.6.8",