Skip to content

Commit

Permalink
V1.1 Enable handling of multiple servers
Browse files Browse the repository at this point in the history
  • Loading branch information
bl3rune committed Sep 9, 2022
1 parent 9accfcc commit cbeb5c2
Show file tree
Hide file tree
Showing 9 changed files with 154 additions and 105 deletions.
13 changes: 6 additions & 7 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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!'
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!'
35 changes: 25 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
5 changes: 2 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
18 changes: 4 additions & 14 deletions src/app.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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);
})
}

Expand Down
14 changes: 14 additions & 0 deletions src/models/game-url.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
12 changes: 12 additions & 0 deletions src/models/server-response.ts
Original file line number Diff line number Diff line change
@@ -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
}

}
95 changes: 61 additions & 34 deletions src/services/discord-publisher.ts
Original file line number Diff line number Diff line change
@@ -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<string>;
private serverAvailable = false;
private serverUp: Map<Type,boolean>;

constructor() {
var client = new Client({intents: [GatewayIntentBits.Guilds]});
Expand All @@ -18,6 +19,7 @@ private serverAvailable = false;

this.ready = client.login(process.env.DISCORD_TOKEN || '');
this.client = client;
this.serverUp = new Map<Type,boolean>();
}

public async isReady(): Promise<string> {
Expand All @@ -28,58 +30,83 @@ private serverAvailable = false;
this.client.destroy();
}

async publish(status: QueryResult | undefined): Promise<void> {
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<void> {
await this.client.user?.setPresence({
status: 'idle',
activities: [{
type: ActivityType.Watching,
name: 'the server do nothing'
}],
});
}

async publish(results: ServerResponse[] | undefined): Promise<void> {

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);
}
}
}

}

}
38 changes: 25 additions & 13 deletions src/services/gamedig-query-provider.ts
Original file line number Diff line number Diff line change
@@ -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<QueryResult> {
return await query({
type: this.game,
host: this.host,
port: this.port,
});
protected async retrieve(): Promise<ServerResponse[]> {
let results = new Array<ServerResponse>();
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;
}
}
29 changes: 5 additions & 24 deletions src/services/polling-provider.ts
Original file line number Diff line number Diff line change
@@ -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<QueryResult | undefined>();
private resultSubject = new Subject<ServerResponse[] | undefined>();

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<QueryResult | undefined> {
provide(): Observable<ServerResponse[] | undefined> {
return this.resultSubject.asObservable();
}

protected abstract retrieve(): Promise<QueryResult>;
protected abstract retrieve(): Promise<ServerResponse[]>;
}

0 comments on commit cbeb5c2

Please sign in to comment.