diff --git a/.env.example b/.env.example index 0ff4355..8be45a3 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,9 @@ -# Gamedig Query provider specific -GAME=minecraft -HOST=mysite.com || 0.0.0.0 -GAME_QUERY_PORT=25565 +# Gamedig specific +GAME_URLS='minecraft:mysite.com:25565,przomboid:mysite.com' -# Discord settings (required) +# Discord settings DISCORD_TOKEN= DISCORD_CHANNEL=000000000000 -SERVER_UP_MESSAGE='The minecraft server is available!' -SERVER_DOWN_MESSAGE='The minecraft server is no longer available!' \ No newline at end of file +UP.przomboid=':zombie: The Project Zomboid server is available!' +UP.minecraft=':pick: The Minecraft server is available!' +DOWN.minecraft=':pick: The Minecraft server is no longer available!' \ No newline at end of file diff --git a/README.md b/README.md index fee3b03..8ab6988 100644 --- a/README.md +++ b/README.md @@ -11,23 +11,38 @@ There are basically two ways to run and configure this discord bot: * as a docker container * as a plain nodejs app + #### Run as a plain nodejs app * Build the project: `npm ci` * Start the bot: `npm start` * Configure the bot with the necessary configuration + +### GameUrl Format + +This format holds 3 key pieces of information: +- One of the supported game types. See the [list of supported games](https://www.npmjs.com/package/gamedig#user-content-games-list) for the Game Type ID of your game. +- The hostname / IP address of the game server you want to query. +- (Optional) The query port configured for the game server. + +This information is formatted in the following structure with the port being optional : `game:host:port` +Here are some examples below: +- Omitting port number `przomboid:mysite.com` +- With port number `minecraft:mysite.com:25565` +- Using ip instead of hostname `csgo:23.4.140.70` +- Multiple defined in `GAME_URLS` config `przomboid:mysite.com,minecraft:mysite.com` + ### Configure the bot -You can create an `.env` file in the root directory of the project and set the options there (see the `.env.example` file for an example). +When running as a docker container provide the following as Docker environment variables. +When running as a nodejs app you can create an `.env` file in the root directory of the project and set the options there (see the `.env.example` file for an example). You need to set the following configuration options. -| Required | Configuration option | Description | Value | -| -------- | -------------------------- | ----------- | ------ | -| TRUE | `GAME` | One of the supported game types. See the [list of supported games](https://www.npmjs.com/package/gamedig#user-content-games-list) for the Game Type ID of your game. | `string` | -| TRUE | `HOST` | The hostname / IP address of the game server you want to query. | `string` | -| FALSE | `PORT` | The gamedig query port configured for the game server. | `number` | -| TRUE | `DISCORD_TOKEN` | The bot token of your discord app, obtained from https://discord.com/developers/applications -> (Select your application) -> Bot -> Token | `string` | -| FALSE | `DISCORD_CHANNEL` | The channel id of your discord chat to send server availability to | `string` | -| FALSE | `SERVER_UP_MESSAGE` | Message to be sent on server startup (DISCORD_CHANNEL must be provided) | `string` | -| FALSE | `SERVER_DOWN_MESSAGE` | Message to be sent on server startup (DISCORD_CHANNEL must be provided) | `string` | +| Required | Configuration option | Description | Value | +| -------- | ----------------------- | ----------- | ------ | +| TRUE | `GAME_URLS` | Comma seperated list of GameUrl format entries [see GameUrl format section](#gameurl-format) | `string` | +| TRUE | `DISCORD_TOKEN` | The bot token of your discord app from https://discord.com/developers/applications -> (Select your application) -> Bot -> Token | `string` | +| FALSE | `DISCORD_CHANNEL` | The channel id of your discord chat to send server availability to | `string` | +| FALSE | `UP.####` | Message to be sent on server available for game type #### (DISCORD_CHANNEL must be provided) | `string` | +| FALSE | `DOWN.####` | Message to be sent on server unavailable for game type #### (DISCORD_CHANNEL must be provided) | `string` | diff --git a/package.json b/package.json index 0a5b62e..0afe816 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,11 @@ { "name": "gamedig-discord-bot", - "version": "1.0.0", + "version": "1.1.0", "main": "dist/index.js", "scripts": { "build": "gulp", "prestart": "npm run build", - "start": "node .", - "publish": "docker build -t gamedig-discord-bot . && docker image tag gamedig-discord-bot bl3rune/gamedig-discord-bot:latest && docker image push bl3rune/gamedig-discord-bot:latest" + "start": "node ." }, "devDependencies": { "@types/gamedig": "^3.0.2", diff --git a/src/app.ts b/src/app.ts index 7862519..2c76705 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,5 +1,4 @@ import {GamedigQueryProvider} from './services/gamedig-query-provider'; -import {QueryResult} from 'gamedig'; import {DiscordPublisher} from './services/discord-publisher'; import {Subscription} from 'rxjs'; @@ -14,23 +13,14 @@ export class App { } public async isReady() { - - if (!process.env.GAME) { - throw new Error('GAME needs to be set from list!'); - } - if (!process.env.HOST) { - throw new Error('HOST needs to be set!'); - } - if (!process.env.DISCORD_TOKEN) { - throw new Error('DISCORD_TOKEN needs to be set!'); - } - + if (!process.env.GAME_URLS) throw new Error('GAME_URLS needs to be set!'); + if (!process.env.DISCORD_TOKEN) throw new Error('DISCORD_TOKEN needs to be set!'); await this.publisher.isReady(); } public async start() { - this.updateSubscription = this.provider.provide().subscribe(async (status: QueryResult | undefined) => { - await this.publisher.publish(status); + this.updateSubscription = this.provider.provide().subscribe(async (response) => { + await this.publisher.publish(response); }) } diff --git a/src/models/game-url.ts b/src/models/game-url.ts new file mode 100644 index 0000000..bff24a0 --- /dev/null +++ b/src/models/game-url.ts @@ -0,0 +1,14 @@ +import { Type } from "gamedig"; + +export class GameUrl { + + game: Type; + host: string; + port: number | undefined; + + constructor(game: Type, host: string, port?: number) { + this.game = game; + this.host = host; + this.port = port; + } +} \ No newline at end of file diff --git a/src/models/server-response.ts b/src/models/server-response.ts new file mode 100644 index 0000000..dada8f5 --- /dev/null +++ b/src/models/server-response.ts @@ -0,0 +1,12 @@ +import {QueryResult, Type} from 'gamedig'; + +export class ServerResponse { + game: Type; + result: QueryResult | undefined; + + constructor(game: Type, result?: QueryResult){ + this.game = game; + this.result = result + } + +} \ No newline at end of file diff --git a/src/services/discord-publisher.ts b/src/services/discord-publisher.ts index 3f0d125..593df84 100644 --- a/src/services/discord-publisher.ts +++ b/src/services/discord-publisher.ts @@ -1,10 +1,11 @@ -import {ActivityType, Client, GatewayIntentBits, TextBasedChannel} from 'discord.js'; -import { QueryResult } from 'gamedig'; +import {ActivityType, ActivitiesOptions, Client, GatewayIntentBits, TextBasedChannel} from 'discord.js'; +import { Type } from 'gamedig'; +import { ServerResponse } from '../models/server-response'; export class DiscordPublisher { private client: Client; private ready: Promise; -private serverAvailable = false; +private serverUp: Map; constructor() { var client = new Client({intents: [GatewayIntentBits.Guilds]}); @@ -18,6 +19,7 @@ private serverAvailable = false; this.ready = client.login(process.env.DISCORD_TOKEN || ''); this.client = client; + this.serverUp = new Map(); } public async isReady(): Promise { @@ -28,58 +30,83 @@ private serverAvailable = false; this.client.destroy(); } - async publish(status: QueryResult | undefined): Promise { - if (status === undefined) { - if (this.serverAvailable) { - this.announce(false); - - await this.client.user?.setPresence({ - status: 'idle', - activities: [{ - type: ActivityType.Watching, - name: 'the server do nothing' - }], - }); + async idlePresence(): Promise { + await this.client.user?.setPresence({ + status: 'idle', + activities: [{ + type: ActivityType.Watching, + name: 'the server do nothing' + }], + }); + } + + async publish(results: ServerResponse[] | undefined): Promise { + + if (!results || results.length == 0) { + this.serverUp.forEach((up, game) => { + if (up) { + this.announce(game, false); + } + up = false + }); + return this.idlePresence(); + } + + let activities = ''; + + for(let s of results) { + let status = s.result; + + if (!status) { + if (this.serverUp.get(s.game)) { + this.announce(s.game, false); + } + this.serverUp.set(s.game,false); + continue; } - this.serverAvailable = false; - } else { - let name = ''; + let raw = status.raw as any; - if (raw) { - name = name + raw['game'] + ' - '; - } else { - name = name + status.name + ' - '; + let name = `${raw['game'] || status.name.replace(/[^a-zA-Z0-9 -]/g, '') || s.game} ${status.players.length}/${status.maxplayers} (${status.ping}ms) ${status.password ? '🔒': ''}`; + if (activities != '') { + activities = activities + ' ///// '; } - name = name + status.players.length + '/' + status.maxplayers + ' '; - name = name + '(' + status.ping + 'ms)'; - - if (!this.serverAvailable) { - console.log('SERVER INFO :::: ' + name); - this.announce(true); + activities = activities + name; + if (!this.serverUp.get(s.game)) { + console.log(`${s.game} : ${name}`); + this.announce(s.game, true); } + this.serverUp.set(s.game,true); + } + + if (activities === '') { + return this.idlePresence(); + } else { await this.client.user?.setPresence({ status: 'online', activities: [{ type: ActivityType.Playing, - name: name + name: activities }] }); - this.serverAvailable = true; } } - async announce(serverUp: boolean) { + async announce(game: Type, serverUp: boolean) { if (process.env.DISCORD_CHANNEL) { const channel = await this.client.channels.fetch(process.env.DISCORD_CHANNEL); if (channel?.isTextBased()) { const textChat = channel as TextBasedChannel; + let message = ''; if (serverUp) { - textChat.send(process.env.SERVER_UP_MESSAGE || 'The server has started!'); + message = process.env['UP' + '.' + game] || ''; } else { - textChat.send(process.env.SERVER_DOWN_MESSAGE || 'The server has stopped!'); + message = process.env['DOWN' + '.' + game] || ''; + } + if (message !== '') { + textChat.send(message); } } } - } + } diff --git a/src/services/gamedig-query-provider.ts b/src/services/gamedig-query-provider.ts index 100bb05..3ecb016 100644 --- a/src/services/gamedig-query-provider.ts +++ b/src/services/gamedig-query-provider.ts @@ -1,24 +1,36 @@ import {PollingProvider} from './polling-provider'; -import {query, QueryResult, Type} from 'gamedig'; +import {query, Type} from 'gamedig'; +import { GameUrl } from '../models/game-url'; +import { ServerResponse } from '../models/server-response'; export class GamedigQueryProvider extends PollingProvider { - private game: Type; - private host: string; - private port: number | undefined; + private gameUrls: GameUrl[]; constructor() { super(); - this.game = process.env.GAME as Type; - this.host = process.env.HOST || ''; - this.port = process.env.PORT ? parseInt(process.env.PORT || '0') : undefined; + let rawGameUrls = (process.env.GAME_URLS || '').split(','); + this.gameUrls = rawGameUrls.filter((raw) => raw.split(':').length > 1).map(g => { + let rawUrl = g.split(':'); + return { + game: rawUrl[0] as Type, + host: rawUrl[1], + port: rawUrl[2] ? parseInt(rawUrl[2]) : undefined + } as GameUrl; + }); } - protected async retrieve(): Promise { - return await query({ - type: this.game, - host: this.host, - port: this.port, - }); + protected async retrieve(): Promise { + let results = new Array(); + for (let g of this.gameUrls) { + results.push(new ServerResponse(g.game, + await query({ + type: g.game, + host: g.host, + port: g.port, + }).catch((e) => undefined) + )); + } + return results; } } diff --git a/src/services/polling-provider.ts b/src/services/polling-provider.ts index c2330a7..9fe3782 100644 --- a/src/services/polling-provider.ts +++ b/src/services/polling-provider.ts @@ -1,39 +1,20 @@ -import {asyncScheduler, delayWhen, from, Observable, retryWhen, Subject, tap, timer} from 'rxjs'; +import {from, Observable, Subject, timer} from 'rxjs'; import {switchMap} from 'rxjs/operators'; -import { QueryResult } from 'gamedig'; +import { ServerResponse } from '../models/server-response'; export abstract class PollingProvider { - private try = 1; private interval: number = 10000; - private resultSubject = new Subject(); + private resultSubject = new Subject(); protected constructor() { timer(0, this.interval).pipe( switchMap(() => from(this.retrieve())), - retryWhen((errors => { - return errors.pipe( - tap(val => { - this.resultSubject.next(undefined); - if (this.try > 15) { - throw new Error('PollingProvider became unhealty, exiting... Check the configuration and make sure the game server is online.'); - } - console.log('PollingProvider errored, retrying in ' + (this.try + 1) * 2 + ' seconds for max 15 tries (' + this.try + '. try). Error:', val.message); - this.try++; - }), - delayWhen(() => { - return timer(this.try * 2 * 1000, asyncScheduler); - }), - ); - })), - tap(() => { - this.try = 1; - }), ).subscribe((val) => this.resultSubject.next(val)); } - provide(): Observable { + provide(): Observable { return this.resultSubject.asObservable(); } - protected abstract retrieve(): Promise; + protected abstract retrieve(): Promise; }