clean up types, use prefix matching, add readline mode
This commit is contained in:
parent
39bc30f52a
commit
c7775bf6f5
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"cSpell.words": [
|
||||
"datetime",
|
||||
"rcon",
|
||||
"unixepoch"
|
||||
]
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
FROM docker.artifacts.apextoaster.com/library/node:18
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# dependencies first, to invalidate other layers when version changes
|
||||
COPY package.json /app/package.json
|
||||
COPY yarn.lock /app/yarn.lock
|
||||
|
||||
RUN yarn install --production
|
||||
|
||||
# copy build output
|
||||
COPY out/src/ /app/out/src/
|
||||
|
||||
ENTRYPOINT [ "node", "/app/out/src/index.js" ]
|
||||
|
14
package.json
14
package.json
|
@ -7,10 +7,14 @@
|
|||
"license": "MIT",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"discord.js": "^14.7.1"
|
||||
"@apextoaster/js-utils": "^0.5.0",
|
||||
"bunyan": "^1.8.15",
|
||||
"discord.js": "^14.7.1",
|
||||
"noicejs": "^5.0.0-3",
|
||||
"rcon-client": "^4.2.3",
|
||||
"yargs": "^17.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@apextoaster/js-utils": "^0.5.0",
|
||||
"@istanbuljs/nyc-config-typescript": "^1.0.2",
|
||||
"@mochajs/multi-reporter": "^1.1.0",
|
||||
"@types/bunyan": "^1.8.8",
|
||||
|
@ -18,7 +22,6 @@
|
|||
"@types/mocha": "^10.0.1",
|
||||
"@types/sinon-chai": "^3.2.9",
|
||||
"@types/yargs": "^17.0.17",
|
||||
"bunyan": "^1.8.15",
|
||||
"c8": "^7.12.0",
|
||||
"chai": "^4.3.7",
|
||||
"chai-as-promised": "^7.1.1",
|
||||
|
@ -34,14 +37,11 @@
|
|||
"mocha": "^10.2.0",
|
||||
"mocha-foam": "^0.1.10",
|
||||
"mocha-junit-reporter": "^2.2.0",
|
||||
"noicejs": "^5.0.0-3",
|
||||
"rcon-client": "^4.2.3",
|
||||
"sinon": "^15.0.0",
|
||||
"sinon-chai": "^3.7.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"tslib": "^2.4.1",
|
||||
"typescript": "^4.9.4",
|
||||
"yargs": "^17.6.2"
|
||||
"typescript": "^4.9.4"
|
||||
},
|
||||
"nyc": {
|
||||
"extends": "@istanbuljs/nyc-config-typescript"
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import yargs from 'yargs';
|
||||
|
||||
export interface ParsedArgs {
|
||||
commandPrefix: string;
|
||||
discordToken: string;
|
||||
rconHost: string;
|
||||
rconPassword: string;
|
||||
|
@ -8,10 +9,16 @@ export interface ParsedArgs {
|
|||
}
|
||||
|
||||
export const APP_NAME = 'conan-discord';
|
||||
export const ENV_NAME = 'CONAN_DISCORD';
|
||||
|
||||
export async function parseArgs(argv: Array<string>): Promise<ParsedArgs> {
|
||||
const parser = yargs(argv).usage(`Usage: ${APP_NAME} [options]`)
|
||||
.options({
|
||||
commandPrefix: {
|
||||
default: '!',
|
||||
desc: 'command prefix marker',
|
||||
type: 'string',
|
||||
},
|
||||
discordToken: {
|
||||
demandOption: true,
|
||||
desc: 'discord bot token',
|
||||
|
@ -33,6 +40,7 @@ export async function parseArgs(argv: Array<string>): Promise<ParsedArgs> {
|
|||
type: 'number',
|
||||
},
|
||||
})
|
||||
.env(ENV_NAME)
|
||||
.help()
|
||||
.exitProcess(false);
|
||||
|
||||
|
|
|
@ -4,18 +4,14 @@ import { Client, Events, GatewayIntentBits, Message, Partials } from 'discord.js
|
|||
|
||||
import { ParsedArgs } from '../args.js';
|
||||
import { Command, CommandContext } from '../command/index.js';
|
||||
import { RconClient } from '../conan/rcon.js';
|
||||
import { matchCommands } from '../utils/command.js';
|
||||
import { RconClient } from '../utils/rcon.js';
|
||||
|
||||
export interface DiscordClient {
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
export async function discordConnect(args: ParsedArgs, logger: Logger, rcon: RconClient, commands: ReadonlyArray<Command>): Promise<DiscordClient> {
|
||||
const context: CommandContext = {
|
||||
args,
|
||||
rcon,
|
||||
};
|
||||
|
||||
const client = new Client({
|
||||
intents: [
|
||||
GatewayIntentBits.DirectMessages,
|
||||
|
@ -30,28 +26,40 @@ export async function discordConnect(args: ParsedArgs, logger: Logger, rcon: Rco
|
|||
],
|
||||
});
|
||||
|
||||
client.once(Events.ClientReady, c => {
|
||||
logger.info({user: c.user.tag}, 'logged in and ready');
|
||||
client.once(Events.ClientReady, (client) => {
|
||||
logger.info({
|
||||
user: client.user.tag,
|
||||
}, 'logged in and ready');
|
||||
});
|
||||
|
||||
client.on(Events.MessageCreate, (msg: Message) => {
|
||||
const name = `${msg.author.username}#${msg.author.discriminator}`;
|
||||
logger.debug({user: name, text: msg.content}, 'message created');
|
||||
logger.debug({
|
||||
text: msg.content,
|
||||
user: name,
|
||||
}, 'message created');
|
||||
|
||||
if (name === mustExist(client.user).tag) {
|
||||
logger.debug('own message, ignoring');
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof msg.content === 'string') {
|
||||
for (const cmd of commands) {
|
||||
if (msg.content === cmd.data.name) {
|
||||
logger.debug({command: cmd.data.name}, 'message matched command');
|
||||
cmd.execute(msg, context).catch((err) => {
|
||||
logger.error(err, 'error executing command');
|
||||
});
|
||||
}
|
||||
}
|
||||
const matchingCommands = matchCommands(msg.content, commands, args.commandPrefix);
|
||||
for (const cmd of matchingCommands) {
|
||||
logger.debug({
|
||||
command: cmd.name,
|
||||
}, 'message matched command');
|
||||
|
||||
const context: CommandContext = {
|
||||
args,
|
||||
cmd,
|
||||
logger,
|
||||
rcon,
|
||||
};
|
||||
|
||||
cmd.run(msg, context).catch((err) => {
|
||||
logger.error(err, 'error executing command');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
import { ParsedArgs } from '../args.js';
|
||||
|
||||
export interface Bot {
|
||||
login(args: ParsedArgs): Promise<boolean>;
|
||||
end(): Promise<void>;
|
||||
}
|
|
@ -0,0 +1,61 @@
|
|||
import Logger from 'bunyan';
|
||||
import { stdin as input, stdout as output } from 'node:process';
|
||||
import * as readline from 'node:readline/promises';
|
||||
|
||||
import { ParsedArgs } from '../args.js';
|
||||
import { Command, CommandContext, Message } from '../command/index.js';
|
||||
import { matchCommands } from '../utils/command.js';
|
||||
import { RconClient } from '../utils/rcon.js';
|
||||
|
||||
export interface ReadlineClient {
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
export async function readlineConnect(args: ParsedArgs, logger: Logger, rcon: RconClient, commands: ReadonlyArray<Command>): Promise<ReadlineClient> {
|
||||
const rl = readline.createInterface({ input, output });
|
||||
rl.setPrompt('> ');
|
||||
|
||||
rl.on('line', (line: string) => {
|
||||
logger.debug({ line }, 'message received');
|
||||
|
||||
const matchingCommands = matchCommands(line, commands, args.commandPrefix);
|
||||
for (const cmd of matchingCommands) {
|
||||
logger.debug({
|
||||
command: cmd.name,
|
||||
}, 'message matched command');
|
||||
|
||||
const context: CommandContext = {
|
||||
args,
|
||||
cmd,
|
||||
logger,
|
||||
rcon,
|
||||
};
|
||||
|
||||
const message: Message = {
|
||||
author: {
|
||||
discriminator: '0000',
|
||||
username: 'local',
|
||||
},
|
||||
content: line,
|
||||
async reply(reply: string) {
|
||||
logger.info({ reply }, 'reply from command');
|
||||
return this;
|
||||
},
|
||||
};
|
||||
|
||||
cmd.run(message, context).catch((err) => {
|
||||
logger.error(err, 'error executing command');
|
||||
}).then(() => {
|
||||
rl.prompt();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
rl.prompt();
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
rl.close();
|
||||
},
|
||||
};
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
import { removeCommand } from '../../utils/command.js';
|
||||
import { Command, CommandContext, Message } from '../index.js';
|
||||
|
||||
export const rconSQL: Command = {
|
||||
name: 'rcon-sql',
|
||||
desc: 'SQL over rcon command',
|
||||
async run(msg: Message, context: CommandContext) {
|
||||
const query = removeCommand(msg.content, context.cmd, context.args.commandPrefix);
|
||||
const result = await context.rcon.send(`sql ${query}`);
|
||||
await msg.reply(result);
|
||||
},
|
||||
};
|
|
@ -0,0 +1,12 @@
|
|||
import { removeCommand } from '../../utils/command.js';
|
||||
import { Command, CommandContext, Message } from '../index.js';
|
||||
|
||||
export const rcon: Command = {
|
||||
name: 'rcon',
|
||||
desc: 'generic rcon command',
|
||||
async run(msg: Message, context: CommandContext) {
|
||||
const query = removeCommand(msg.content, context.cmd, context.args.commandPrefix);
|
||||
const result = await context.rcon.send(query);
|
||||
await msg.reply(result);
|
||||
},
|
||||
};
|
|
@ -1,13 +0,0 @@
|
|||
import { Message, SlashCommandBuilder } from 'discord.js';
|
||||
|
||||
import { Command, CommandContext } from './index.js';
|
||||
|
||||
export const conanOnline: Command = {
|
||||
data: new SlashCommandBuilder()
|
||||
.setName('conan-online')
|
||||
.setDescription('number of players online'),
|
||||
async execute(msg: Message, context: CommandContext) {
|
||||
const players = await context.rcon.send('listplayers');
|
||||
await msg.reply(`players: ${players}`);
|
||||
},
|
||||
}
|
|
@ -1,18 +0,0 @@
|
|||
import { Message, SlashCommandBuilder } from 'discord.js';
|
||||
|
||||
import { Command, CommandContext } from './index.js';
|
||||
|
||||
export const conanPlayers: Command = {
|
||||
data: new SlashCommandBuilder()
|
||||
.setName('conan-players')
|
||||
.setDescription('number of players online'),
|
||||
async execute(msg: Message, context: CommandContext) {
|
||||
const players = await context.rcon.send(`sql \
|
||||
SELECT char_name, level, guilds.name, lastTimeOnline \
|
||||
FROM characters \
|
||||
INNER JOIN guilds ON characters.guild = guilds.guildId \
|
||||
ORDER BY lastTimeOnline DESC \
|
||||
LIMIT 10`);
|
||||
await msg.reply(`players: ${players}`);
|
||||
},
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
import { codeBlock } from 'discord.js';
|
||||
|
||||
import { Command, CommandContext, Message } from '../index.js';
|
||||
|
||||
export const conanOnline: Command = {
|
||||
name: 'conan-online',
|
||||
desc: 'list players who are currently online',
|
||||
async run(msg: Message, context: CommandContext) {
|
||||
const players = await context.rcon.send('listplayers');
|
||||
await msg.reply(`online players: ${codeBlock(players)}`);
|
||||
},
|
||||
};
|
|
@ -0,0 +1,44 @@
|
|||
import { codeBlock } from 'discord.js';
|
||||
|
||||
import { parseBody } from '../../utils/command.js';
|
||||
import { parseTable } from '../../utils/table.js';
|
||||
import { Command, CommandContext, Message } from '../index.js';
|
||||
|
||||
export interface ConanPlayersArgs {
|
||||
count: number;
|
||||
}
|
||||
|
||||
export const conanPlayers: Command = {
|
||||
name: 'conan-players',
|
||||
desc: 'show the most recently online players',
|
||||
async run(msg: Message, context: CommandContext) {
|
||||
const [_body, args] = await parseBody<ConanPlayersArgs>(msg.content, context.cmd, context.args.commandPrefix, {
|
||||
count: {
|
||||
default: 3,
|
||||
type: 'number',
|
||||
},
|
||||
});
|
||||
const count = Math.min(args.count, 10);
|
||||
|
||||
context.logger.debug({
|
||||
count,
|
||||
}, 'getting recent players');
|
||||
|
||||
const players = await context.rcon.send(`sql \
|
||||
SELECT \
|
||||
char_name AS name, \
|
||||
level, \
|
||||
guilds.name AS guild, \
|
||||
datetime(lastTimeOnline, 'unixepoch') AS last_seen \
|
||||
FROM characters \
|
||||
INNER JOIN guilds ON characters.guild = guilds.guildId \
|
||||
ORDER BY lastTimeOnline DESC \
|
||||
LIMIT ${count}`);
|
||||
|
||||
context.logger.debug({
|
||||
players: parseTable(players),
|
||||
}, 'players table');
|
||||
|
||||
await msg.reply(`recent players: ${codeBlock(players)}`);
|
||||
},
|
||||
};
|
|
@ -0,0 +1,17 @@
|
|||
import { codeBlock } from 'discord.js';
|
||||
|
||||
import { matchName } from '../utils/command.js';
|
||||
import { Command, CommandContext, COMMANDS, Message } from './index.js';
|
||||
|
||||
export const help: Command = {
|
||||
name: 'help',
|
||||
desc: 'list the available commands',
|
||||
async run(msg: Message, context: CommandContext) {
|
||||
const helps = COMMANDS.map((cmd) => {
|
||||
const name = matchName(cmd, context.args.commandPrefix);
|
||||
return `${name}: ${cmd.desc}`;
|
||||
});
|
||||
const body = codeBlock(helps.join('\n'));
|
||||
await msg.reply(`commands: ${body}`);
|
||||
},
|
||||
};
|
|
@ -1,27 +1,41 @@
|
|||
import { Message, SlashCommandBuilder } from 'discord.js';
|
||||
import { ParsedArgs } from '../args.js';
|
||||
import { RconClient } from '../conan/rcon.js';
|
||||
import Logger from 'bunyan';
|
||||
|
||||
import { conanOnline } from './conan-online.js';
|
||||
import { conanPlayers } from './conan-players.js';
|
||||
import { ParsedArgs } from '../args.js';
|
||||
import { RconClient } from '../utils/rcon.js';
|
||||
import { rconSQL } from './admin/rcon-sql.js';
|
||||
import { rcon } from './admin/rcon.js';
|
||||
import { conanOnline } from './conan/online.js';
|
||||
import { conanPlayers } from './conan/players.js';
|
||||
import { help } from './help.js';
|
||||
import { ping } from './ping.js';
|
||||
import { rconSQL } from './rcon-sql.js';
|
||||
import { rcon } from './rcon.js';
|
||||
|
||||
export interface Message {
|
||||
author: {
|
||||
discriminator: string;
|
||||
username: string;
|
||||
};
|
||||
content: string;
|
||||
reply(msg: string): Promise<Message>;
|
||||
}
|
||||
|
||||
export interface Command {
|
||||
data: SlashCommandBuilder;
|
||||
execute: (msg: Message, context: CommandContext) => Promise<void>;
|
||||
desc: string;
|
||||
name: string;
|
||||
run: (msg: Message, context: CommandContext) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface CommandContext {
|
||||
args: ParsedArgs;
|
||||
cmd: Command;
|
||||
logger: Logger;
|
||||
rcon: RconClient;
|
||||
}
|
||||
|
||||
export const COMMANDS: Array<Command> = [
|
||||
conanOnline,
|
||||
conanPlayers,
|
||||
help,
|
||||
ping,
|
||||
rcon,
|
||||
rconSQL,
|
||||
];
|
||||
// rcon,
|
||||
// rconSQL,
|
||||
];
|
||||
|
|
|
@ -1,11 +1,9 @@
|
|||
import { Message, SlashCommandBuilder } from 'discord.js';
|
||||
import { Command } from './index.js';
|
||||
import { Command, Message } from './index.js';
|
||||
|
||||
export const ping: Command = {
|
||||
data: new SlashCommandBuilder()
|
||||
.setName('ping')
|
||||
.setDescription('health check'),
|
||||
async execute(msg: Message) {
|
||||
name: 'ping',
|
||||
desc: 'health check',
|
||||
async run(msg: Message) {
|
||||
await msg.reply('pong');
|
||||
},
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,13 +0,0 @@
|
|||
import { Message, SlashCommandBuilder } from 'discord.js';
|
||||
|
||||
import { Command, CommandContext } from './index.js';
|
||||
|
||||
export const rconSQL: Command = {
|
||||
data: new SlashCommandBuilder()
|
||||
.setName('rcon-sql')
|
||||
.setDescription('SQL over rcon command'),
|
||||
async execute(msg: Message, context: CommandContext) {
|
||||
const result = await context.rcon.send(`sql ${msg.content}`);
|
||||
await msg.reply(result);
|
||||
},
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
import { Message, SlashCommandBuilder } from 'discord.js';
|
||||
|
||||
import { Command, CommandContext } from './index.js';
|
||||
|
||||
export const rcon: Command = {
|
||||
data: new SlashCommandBuilder()
|
||||
.setName('rcon')
|
||||
.setDescription('generic rcon command'),
|
||||
async execute(msg: Message, context: CommandContext) {
|
||||
const result = await context.rcon.send(msg.content);
|
||||
await msg.reply(result);
|
||||
},
|
||||
}
|
|
@ -1,24 +0,0 @@
|
|||
import Logger from 'bunyan';
|
||||
import { Rcon } from 'rcon-client';
|
||||
|
||||
import { ParsedArgs } from '../args.js';
|
||||
|
||||
export interface RconClient {
|
||||
end(): void;
|
||||
send(cmd: string): Promise<string>;
|
||||
}
|
||||
|
||||
export async function rconConnect(args: ParsedArgs, logger: Logger): Promise<RconClient> {
|
||||
const client = new Rcon({
|
||||
host: args.rconHost,
|
||||
port: args.rconPort,
|
||||
password: args.rconPassword,
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
client.on('error', (err: any) => {
|
||||
logger.warn({ err }, 'rcon client error');
|
||||
});
|
||||
|
||||
return client.connect();
|
||||
}
|
|
@ -3,8 +3,9 @@ import { createLogger, DEBUG } from 'bunyan';
|
|||
|
||||
import { APP_NAME, parseArgs } from './args.js';
|
||||
import { discordConnect } from './bot/discord.js';
|
||||
import { readlineConnect } from './bot/readline.js';
|
||||
import { COMMANDS } from './command/index.js';
|
||||
import { rconConnect } from './conan/rcon.js';
|
||||
import { rconConnect } from './utils/rcon.js';
|
||||
|
||||
export enum ExitCode {
|
||||
SUCCESS = 0,
|
||||
|
@ -21,15 +22,18 @@ export async function main(argv: Array<string>): Promise<ExitCode> {
|
|||
logger.info({app: APP_NAME, args}, 'starting bot');
|
||||
|
||||
for (const cmd of COMMANDS) {
|
||||
logger.debug('command debug', cmd.data.toJSON());
|
||||
logger.debug({ cmd }, 'command debug');
|
||||
}
|
||||
|
||||
const rcon = await rconConnect(args, logger);
|
||||
const readline = await readlineConnect(args, logger, rcon, COMMANDS);
|
||||
const discord = await discordConnect(args, logger, rcon, COMMANDS);
|
||||
|
||||
await signal(SIGNAL_STOP);
|
||||
|
||||
discord.destroy();
|
||||
readline.destroy();
|
||||
rcon.end();
|
||||
|
||||
return ExitCode.SUCCESS;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,56 @@
|
|||
import yargs from 'yargs';
|
||||
|
||||
import { Command } from '../command/index.js';
|
||||
|
||||
/**
|
||||
* Wrapper for append with a default prefix.
|
||||
*/
|
||||
export function matchName(command: Command, prefix: string): string {
|
||||
return `${prefix}${command.name}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match command names against a message body.
|
||||
*/
|
||||
export function matchCommands(msg: string, commands: ReadonlyArray<Command>, prefix: string): ReadonlyArray<Command> {
|
||||
const [head, ..._rest] = msg.split(' ');
|
||||
|
||||
return commands.filter((cmd) => {
|
||||
const name = matchName(cmd, prefix);
|
||||
return name === head;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and remove the command name from a message body.
|
||||
*/
|
||||
export function removeCommand(msg: string, command: Command, prefix: string): string {
|
||||
const name = matchName(command, prefix);
|
||||
const [head, ...rest] = msg.split(' ');
|
||||
|
||||
if (head === name) {
|
||||
return rest.join(' ');
|
||||
} else {
|
||||
return msg;
|
||||
}
|
||||
}
|
||||
|
||||
export type StringsOnly<T> = T extends string ? T : never;
|
||||
export type BodyOptions<T> = { [key in StringsOnly<keyof T>]: yargs.Options };
|
||||
|
||||
/**
|
||||
* Remove the command name from a message, parse any CLI args, and return them with the remainder of the message body.
|
||||
*/
|
||||
export async function parseBody<
|
||||
ArgT,
|
||||
OptionT extends BodyOptions<ArgT> = BodyOptions<ArgT>
|
||||
>(msg: string, command: Command, prefix: string, options: OptionT): Promise<[string, ArgT]> {
|
||||
const body = removeCommand(msg, command, prefix);
|
||||
const parser = yargs()
|
||||
.exitProcess(false)
|
||||
.options<OptionT>(options)
|
||||
.help();
|
||||
const args = await parser.parse(body) as any; // TODO: hax, the compiler says there is no overlap
|
||||
|
||||
return [body, args];
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
import Logger from 'bunyan';
|
||||
import { Rcon } from 'rcon-client';
|
||||
import rconPacket from 'rcon-client/lib/packet.js';
|
||||
|
||||
import { ParsedArgs } from '../args.js';
|
||||
|
||||
export interface RconClient {
|
||||
end(): void;
|
||||
send(cmd: string): Promise<string>;
|
||||
}
|
||||
|
||||
export async function rconConnect(args: ParsedArgs, logger: Logger): Promise<RconClient> {
|
||||
const client = new Rcon({
|
||||
host: args.rconHost,
|
||||
port: args.rconPort,
|
||||
password: args.rconPassword,
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
// monkey patch for https://github.com/janispritzkau/rcon-client/issues/21
|
||||
function handlePacket(this: any, data: any) {
|
||||
const packet = rconPacket.decodePacket(data);
|
||||
const id = this.authenticated ? (packet.id + 1) : this.requestId - 1;
|
||||
logger.debug({ id, packet }, 'handle packet');
|
||||
const handler = this.callbacks.get(id);
|
||||
if (handler) {
|
||||
handler(packet);
|
||||
this.callbacks.delete(id);
|
||||
}
|
||||
};
|
||||
|
||||
Reflect.set(client, 'handlePacket', handlePacket);
|
||||
|
||||
client.on('error', (err: any) => {
|
||||
logger.warn({ err }, 'rcon client error');
|
||||
});
|
||||
|
||||
return client.connect();
|
||||
}
|
|
@ -0,0 +1,49 @@
|
|||
export function parseTable(input: string): Array<Array<string>> {
|
||||
// split lines
|
||||
const lines = input.split('\n');
|
||||
|
||||
// split columns
|
||||
const [headers, ...fields] = lines.map(split)
|
||||
|
||||
// get index field
|
||||
for (const cells of fields) {
|
||||
const match = cells[0].match(/^#(\d+) +(.+)$/);
|
||||
if (match) {
|
||||
const parts = Array.from(match);
|
||||
cells.shift();
|
||||
cells.unshift(parts[1], parts[2]);
|
||||
}
|
||||
}
|
||||
|
||||
// sort by index
|
||||
const sorted = fields.sort((a, b) => {
|
||||
if (a[0] <= b[0]) {
|
||||
return -1;
|
||||
} else {
|
||||
return +1;
|
||||
}
|
||||
});
|
||||
|
||||
// build dataset
|
||||
const sortedFields = sorted.map((row) => {
|
||||
const [_index, ...fields] = row;
|
||||
return fields;
|
||||
});
|
||||
|
||||
return [
|
||||
headers,
|
||||
...sortedFields,
|
||||
];
|
||||
}
|
||||
|
||||
function trim(cell: string) {
|
||||
return cell.trim();
|
||||
}
|
||||
|
||||
function len(cell: string) {
|
||||
return cell.length > 0;
|
||||
}
|
||||
|
||||
function split(line: string) {
|
||||
return line.split('|').map(trim).filter(len);
|
||||
}
|
Loading…
Reference in New Issue