1
0
Fork 0

clean up types, use prefix matching, add readline mode

This commit is contained in:
Sean Sube 2022-12-15 17:05:03 -06:00
parent 39bc30f52a
commit c7775bf6f5
24 changed files with 408 additions and 127 deletions

7
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,7 @@
{
"cSpell.words": [
"datetime",
"rcon",
"unixepoch"
]
}

15
Containerfile Normal file
View File

@ -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" ]

View File

@ -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"

View File

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

View File

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

6
src/bot/index.ts Normal file
View File

@ -0,0 +1,6 @@
import { ParsedArgs } from '../args.js';
export interface Bot {
login(args: ParsedArgs): Promise<boolean>;
end(): Promise<void>;
}

61
src/bot/readline.ts Normal file
View File

@ -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();
},
};
}

View File

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

12
src/command/admin/rcon.ts Normal file
View File

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

View File

@ -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}`);
},
}

View File

@ -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}`);
},
}

View File

@ -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)}`);
},
};

View File

@ -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)}`);
},
};

17
src/command/help.ts Normal file
View File

@ -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}`);
},
};

View File

@ -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,
];

View File

@ -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');
},
}
};

View File

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

View File

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

View File

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

View File

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

View File

56
src/utils/command.ts Normal file
View File

@ -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];
}

39
src/utils/rcon.ts Normal file
View File

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

49
src/utils/table.ts Normal file
View File

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