diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/Config.ts | 42 | ||||
-rw-r--r-- | src/Discord.ts | 180 | ||||
-rw-r--r-- | src/MinecraftHandler.ts | 209 | ||||
-rw-r--r-- | src/Rcon.ts | 124 | ||||
-rw-r--r-- | src/Shulker.ts | 52 | ||||
-rw-r--r-- | src/index.ts | 8 |
6 files changed, 615 insertions, 0 deletions
diff --git a/src/Config.ts b/src/Config.ts new file mode 100644 index 0000000..ce59ed2 --- /dev/null +++ b/src/Config.ts @@ -0,0 +1,42 @@ +export interface Config { + PORT: number + + USE_WEBHOOKS: boolean + WEBHOOK_URL: string + DISCORD_TOKEN: string + DISCORD_CHANNEL_ID: string + DISCORD_CHANNEL_NAME: string + DISCORD_MESSAGE_TEMPLATE: string + + MINECRAFT_SERVER_RCON_IP: string + MINECRAFT_SERVER_RCON_PORT: number + MINECRAFT_SERVER_RCON_PASSWORD: string + MINECRAFT_TELLRAW_TEMPLATE: string + + IS_LOCAL_FILE: boolean + LOCAL_FILE_PATH: string + + PATH_TO_MINECRAFT_SERVER_INSTALL?: string + YOUR_URL?: string + + SHOW_INIT_MESSAGE: boolean + + ALLOW_USER_MENTIONS: boolean + ALLOW_HERE_EVERYONE_MENTIONS: boolean + ALLOW_SLASH_COMMANDS: boolean + SLASH_COMMAND_ROLES: string[] + + WEBHOOK: string + REGEX_SERVER_PREFIX: string + REGEX_MATCH_CHAT_MC: string + REGEX_IGNORED_CHAT: string + DEBUG: boolean + + SERVER_NAME: string + SERVER_IMAGE: string + SHOW_PLAYER_CONN_STAT: boolean + SHOW_PLAYER_ADVANCEMENT: boolean + SHOW_PLAYER_DEATH: boolean + SHOW_PLAYER_ME: boolean + DEATH_KEY_WORDS: string[] +} diff --git a/src/Discord.ts b/src/Discord.ts new file mode 100644 index 0000000..bc15fac --- /dev/null +++ b/src/Discord.ts @@ -0,0 +1,180 @@ +import {Client, Message, Snowflake, TextChannel} from 'discord.js' + +import emojiStrip from 'emoji-strip' +import axios from 'axios' + +import { Config } from './Config' + +import Rcon from './Rcon' + +class Discord { + config: Config + client: Client + + channel: Snowflake + + constructor (config: Config, onReady?: () => void) { + this.config = config + + this.client = new Client() + if (onReady) this.client.once('ready', () => onReady()) + this.client.on('message', (message: Message) => this.onMessage(message)) + + this.channel = config.DISCORD_CHANNEL_ID || '' + } + + public async init () { + try { + await this.client.login(this.config.DISCORD_TOKEN) + if (this.config.DISCORD_CHANNEL_NAME && !this.config.DISCORD_CHANNEL_ID) + this.getChannelIdFromName(this.config.DISCORD_CHANNEL_NAME) + } catch (e) { + console.log('[ERROR] Could not authenticate with Discord: ' + e) + if (this.config.DEBUG) console.error(e) + } + } + + private getChannelIdFromName (name: string) { + // remove the # if there is one + if (name.startsWith('#')) name = name.substring(1, name.length) + // @ts-ignore + const channel: TextChannel = this.client.channels.find((c: TextChannel) => c.type === 'text' && c.name === name && !c.deleted) + if (channel) { + this.channel = channel.id + console.log(`[INFO] Found channel #${channel.name} (id: ${channel.id}) in the server "${channel.guild.name}"`) + } else { + console.log(`[INFO] Could not find channel ${name}! Check that the name is correct or use the ID of the channel instead (DISCORD_CHANNEL_ID)!`) + process.exit(1) + } + } + + private async onMessage (message: Message) { + // no channel, done + if (!this.channel) return + // don't want to check other channels + if (message.channel.id !== this.channel || message.channel.type !== 'text') return + // if using webhooks, ignore this! + if (this.config.USE_WEBHOOKS && message.webhookID) return + // if the same user as the bot, ignore + if (message.author.id === this.client.user.id) return + // ignore any attachments + if (message.attachments.array().length) return + + const rcon = new Rcon(this.config.MINECRAFT_SERVER_RCON_IP, this.config.MINECRAFT_SERVER_RCON_PORT, this.config.DEBUG) + try { + await rcon.auth(this.config.MINECRAFT_SERVER_RCON_PASSWORD) + } catch (e) { + console.log('[ERROR] Could not auth with the server!') + if (this.config.DEBUG) console.error(e) + } + + let command = '' + if (this.config.ALLOW_SLASH_COMMANDS && this.config.SLASH_COMMAND_ROLES && message.cleanContent.startsWith('/')) { + const author = message.member + if (author.roles.find(r => this.config.SLASH_COMMAND_ROLES.includes(r.name))) { + // send the raw command, can be dangerous... + command = message.cleanContent + } else { + console.log('[INFO] User attempted a slash command without a role') + } + } else { + command = `/tellraw @a ${this.makeMinecraftTellraw(message)}` + } + + if (command) { + await rcon.command(command).catch((e) => { + console.log('[ERROR] Could not send command!') + if (this.config.DEBUG) console.error(e) + }) + } + rcon.close() + } + + private makeMinecraftTellraw(message: Message): string { + const username = emojiStrip(message.author.username) + const discriminator = message.author.discriminator + const text = emojiStrip(message.cleanContent) + // hastily use JSON to encode the strings + const variables = JSON.parse(JSON.stringify({ username, discriminator, text })) + + return this.config.MINECRAFT_TELLRAW_TEMPLATE + .replace('%username%', variables.username) + .replace('%discriminator%', variables.discriminator) + .replace('%message%', variables.text) + } + + private replaceDiscordMentions(message: string): string { + const possibleMentions = message.match(/@(\S+)/gim) + if (possibleMentions) { + for (let mention of possibleMentions) { + const mentionParts = mention.split('#') + let username = mentionParts[0].replace('@', '') + if (mentionParts.length > 1) { + if (this.config.ALLOW_USER_MENTIONS) { + const user = this.client.users.find(user => user.username === username && user.discriminator === mentionParts[1]) + if (user) { + message = message.replace(mention, '<@' + user.id + '>') + } + } + } + + if (['here', 'everyone'].includes(username)) { + // remove these large pings + if (!this.config.ALLOW_HERE_EVERYONE_MENTIONS) { + message = message + .replace('@everyone', '@ everyone') + .replace('@here', '@ here') + } + } + } + } + return message + } + + private makeDiscordWebhook (username: string, message: string) { + message = this.replaceDiscordMentions(message) + + let avatarURL + if (username === this.config.SERVER_NAME + ' - Server') { // use avatar for the server + avatarURL = this.config.SERVER_IMAGE || 'https://minotar.net/helm/Steve/256.png' + } else { // use avatar for player + avatarURL = `https://minotar.net/helm/${username}/256.png` + } + + return { + username: username, + content: message, + 'avatar_url': avatarURL, + } + } + + private makeDiscordMessage(username: string, message: string) { + message = this.replaceDiscordMentions(message) + + return this.config.DISCORD_MESSAGE_TEMPLATE + .replace('%username%', username) + .replace('%message%', message) + } + + public async sendMessage (username: string, message: string) { + if (this.config.USE_WEBHOOKS) { + const webhook = this.makeDiscordWebhook(username, message) + try { + await axios.post(this.config.WEBHOOK_URL, webhook, { headers: { 'Content-Type': 'application/json' } }) + } catch (e) { + console.log('[ERROR] Could not send Discord message through WebHook!') + if (this.config.DEBUG) console.log(e) + } + } else { + // find the channel + const channel = this.client.channels.find((ch) => ch.id === this.config.DISCORD_CHANNEL_ID && ch.type === 'text') as TextChannel + if (channel) { + await channel.send(this.makeDiscordMessage(username, message)) + } else { + console.log(`[ERROR] Could not find channel with ID ${this.config.DISCORD_CHANNEL_ID}!`) + } + } + } +} + +export default Discord diff --git a/src/MinecraftHandler.ts b/src/MinecraftHandler.ts new file mode 100644 index 0000000..0e4e5d7 --- /dev/null +++ b/src/MinecraftHandler.ts @@ -0,0 +1,209 @@ +import fs from 'fs' +import path from 'path' +import { Tail } from 'tail' +import express from 'express' + +import { Config } from './Config' + +export type LogLine = { + username: string + message: string +} | null + +type Callback = (data: LogLine) => void + +class MinecraftHandler { + config: Config + + app: express.Application + tail: Tail + + constructor(config: Config) { + this.config = config + } + + private static fixMinecraftUsername (username: string) { + return username.replace(/(ยง[A-Z-a-z0-9])/g, '') + } + + private parseLogLine (data: string): LogLine { + const ignored = new RegExp(this.config.REGEX_IGNORED_CHAT) + + if (ignored.test(data) || data.includes('Rcon connection')) { + if (this.config.DEBUG) console.log('[DEBUG] Line ignored') + return null + } + + if (this.config.DEBUG) console.log('[DEBUG] Received ' + data) + + const logLineDataRegex = new RegExp( + `${(this.config.REGEX_SERVER_PREFIX || "\\[Server thread/INFO\\]:")} (.*)` + ) + + // get the part after the log prefix, so all the actual data is here + const logLineData = data.match(logLineDataRegex) + + if (!logLineDataRegex.test(data) || !logLineData) { + if (this.config.DEBUG) { + console.log('[DEBUG] Regex could not match the string:') + console.log('Received: "' + data + '", Regex matches lines that start with: "' + this.config.REGEX_SERVER_PREFIX + '"') + } + return null + } + + const logLine = logLineData[1] + + // the username used for server messages + const serverUsername = `${this.config.SERVER_NAME} - Server` + + if (logLine.startsWith('<')) { + if (this.config.DEBUG){ + console.log('[DEBUG]: A player sent a chat message') + } + + const re = new RegExp(this.config.REGEX_MATCH_CHAT_MC) + const matches = logLine.match(re) + + if (!matches) { + console.log('[ERROR] Could not parse message: ' + logLine) + return null + } + + const username = MinecraftHandler.fixMinecraftUsername(matches[1]) + const message = matches[2] + if (this.config.DEBUG) { + console.log('[DEBUG] Username: ' + matches[1]) + console.log('[DEBUG] Text: ' + matches[2]) + } + return { username, message } + } else if ( + this.config.SHOW_PLAYER_CONN_STAT && ( + logLine.includes('left the game') || + logLine.includes('joined the game') + ) + ) { + // handle disconnection etc. + if (this.config.DEBUG){ + console.log(`[DEBUG]: A player's connection status changed`) + } + + return { username: serverUsername, message: logLine } + } else if (this.config.SHOW_PLAYER_ADVANCEMENT && logLine.includes('made the advancement')) { + // handle advancements + if (this.config.DEBUG){ + console.log('[DEBUG] A player has made an advancement') + } + return { username: `${this.config.SERVER_NAME} - Server`, message: logLine } + } else if (this.config.SHOW_PLAYER_ME && logLine.startsWith('* ')) { + // /me commands have the bolded name and the action they did + const usernameMatch = data.match(/: \* ([a-zA-Z0-9_]{1,16}) (.*)/) + if (usernameMatch) { + const username = usernameMatch[1] + const rest = usernameMatch[2] + return { username: serverUsername, message: `**${username}** ${rest}` } + } + } else if (this.config.SHOW_PLAYER_DEATH) { + for (let word of this.config.DEATH_KEY_WORDS){ + if (data.includes(word)){ + if (this.config.DEBUG) { + console.log( + `[DEBUG] A player died. Matched key word "${word}"` + ) + } + return { username: serverUsername, message: logLine } + } + } + } + + return null + } + + private initWebServer (callback: Callback) { + // init the webserver + this.app = express() + + this.app.use((request, response, next) => { + request.rawBody = '' + request.setEncoding('utf8') + + request.on('data', (chunk: string) => { + request.rawBody += chunk + }) + + request.on('end', function () { + next() + }) + }) + + this.app.post(this.config.WEBHOOK, (req, res) => { + if (req.rawBody) { + const logLine = this.parseLogLine(req.rawBody) + callback(logLine) + } + res.json({ received: true }) + }) + + const port: number = Number(process.env.PORT) || this.config.PORT + + this.app.listen(port, () => { + console.log('[INFO] Bot listening on *:' + port) + + if (!this.config.IS_LOCAL_FILE && this.config.SHOW_INIT_MESSAGE) { + // in case someone inputs the actual path and url in the config here... + let mcPath: string = this.config.PATH_TO_MINECRAFT_SERVER_INSTALL || 'PATH_TO_MINECRAFT_SERVER_INSTALL' + const url: string = this.config.YOUR_URL || 'YOUR_URL' + + const defaultPath = mcPath === 'PATH_TO_MINECRAFT_SERVER_INSTALL' + const defaultUrl = url === 'YOUR_URL' + + console.log('[INFO] Please enter the following command on your server running the Minecraft server:') + if (defaultPath) { + console.log(' Replace "PATH_TO_MINECRAFT_SERVER_INSTALL" with the path to your Minecraft server install') + if (defaultUrl) console.log(' and "YOUR_URL" with the URL/IP of the server running Shulker!') + } else { + if (defaultUrl) console.log(' Replace "YOUR_URL" with the URL/IP of the server running Shulker') + } + + mcPath = (defaultPath ? '/' : '') + path.join(mcPath, '/logs/latest.log') + + let grepMatch = ': <' + if (this.config.SHOW_PLAYER_DEATH || this.config.SHOW_PLAYER_ME || this.config.SHOW_PLAYER_ADVANCEMENT || this.config.SHOW_PLAYER_CONN_STAT) { + grepMatch = this.config.REGEX_SERVER_PREFIX + } + console.log(` \`tail -F ${mcPath} | grep --line-buffered "${grepMatch}" | while read x ; do echo -ne $x | curl -X POST -d @- http://${url}:${port}${this.config.WEBHOOK} ; done\``) + if (grepMatch !== ': <') { + console.log(' Please note that the above command can send a lot of requests to the server. Disable the non-text messages (such as "SHOW_PLAYER_CONN_STAT") to reduce this if necessary.') + } + } + }) + } + + private initTail (callback: Callback) { + if (fs.existsSync(this.config.LOCAL_FILE_PATH)) { + console.log(`[INFO] Using configuration for local log file at "${this.config.LOCAL_FILE_PATH}"`) + this.tail = new Tail(this.config.LOCAL_FILE_PATH) + } else { + throw new Error(`[ERROR] Local log file not found at "${this.config.LOCAL_FILE_PATH}"`) + } + this.tail.on('line', (data: string) => { + // Parse the line to see if we care about it + let logLine = this.parseLogLine(data) + if (data) { + callback(logLine) + } + }) + this.tail.on('error', (error: any) => { + console.log('[ERROR] Error tailing log file: ' + error) + }) + } + + public init (callback: Callback) { + if (this.config.IS_LOCAL_FILE) { + this.initTail(callback) + } else { + this.initWebServer(callback) + } + } +} + +export default MinecraftHandler diff --git a/src/Rcon.ts b/src/Rcon.ts new file mode 100644 index 0000000..180ad38 --- /dev/null +++ b/src/Rcon.ts @@ -0,0 +1,124 @@ +// Credits to M4GNV5 for this library + +import net from 'net' + +class Rcon { + socket: net.Socket + timeout: number + nextId: number + + connected: boolean + authed: boolean + debug: boolean + + ip: string + port: number + + packages: { [key: number]: (type: number, response: string) => void } + + constructor (ip: string, port: number, debug: boolean) { + this.ip = ip + this.port = port + this.debug = debug + + this.timeout = 5000 + this.nextId = 0 + this.connected = false + this.authed = false + this.packages = [] + + this.socket = net.connect(port, ip, () => { + this.connected = true + console.log('[INFO] Authenticated with ' + ip + ':' + port) + }) + + this.socket.on('data', (data: Buffer) => { + const id = data.readInt32LE(4) + const type = data.readInt32LE(8) + const response = data.toString('ascii', 12, data.length - 2) + + if (this.packages[id]) { + this.packages[id](type, response) + } else { + console.log('Unexpected rcon response', id, type, response) + } + }).on('end', () => { + if (debug) { + console.log('[DEBUG] Rcon closed!') + } + }) + } + + public close () { + this.connected = false + this.socket.end() + } + + public async auth (password: string): Promise<void> { + if (this.authed) { throw new Error('Already authed') } + + if (this.connected){ + try { + await this.sendPackage(3, password) + } catch (e) { + console.log('[ERROR] Could not send password to Rcon server!') + if (this.debug) console.error(e) + } + } else { + return new Promise((resolve, reject) => { + this.socket.on('connect', async () => { + try { + await this.sendPackage(3, password) + resolve() + } catch (e) { + console.log('[ERROR] Could not send password to Rcon server!') + if (this.debug) console.error(e) + reject(e) + } + }) + }) + } + } + + public command (cmd: string): Promise<string> { + return this.sendPackage(2, cmd) + } + + public sendPackage (type: number, payload: string): Promise<string> { + const id = this.nextId + this.nextId++ + + if (!this.connected) { throw new Error('Cannot send package while not connected') } + + const length = 14 + payload.length + const buff = Buffer.alloc(length) + buff.writeInt32LE(length - 4, 0) + buff.writeInt32LE(id, 4) + buff.writeInt32LE(type, 8) + + buff.write(payload, 12) + buff.writeInt8(0, length - 2) + buff.writeInt8(0, length - 1) + + this.socket.write(buff) + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + delete this.packages[id] + return reject('Server sent no request in ' + this.timeout / 1000 + ' seconds') + }, this.timeout) + + this.packages[id] = (type: number, response: string) => { + clearTimeout(timeout) + const err = type >= 0 ? false : 'Server sent package code ' + type + if (this.debug) { + console.log('[DEBUG] Received response: ' + response) + } + if (err) return reject(err) + return resolve(response) + } + }) + } +} + +export default Rcon diff --git a/src/Shulker.ts b/src/Shulker.ts new file mode 100644 index 0000000..4c79c78 --- /dev/null +++ b/src/Shulker.ts @@ -0,0 +1,52 @@ +import DiscordClient from './Discord' +import Handler, { LogLine } from './MinecraftHandler' + +import { Config } from './Config' + +class Shulker { + config: Config + discordClient: DiscordClient + handler: Handler + + constructor() { + } + + loadConfig () { + const configFile = (process.argv.length > 2) ? process.argv[2] : '../config.json' + console.log('[INFO] Using configuration file:', configFile) + this.config = require(configFile) + if (!this.config) { + console.log('[ERROR] Could not load config file!') + return false + } + + if (this.config.USE_WEBHOOKS) { + console.log('[INFO] Using Discord WebHooks to send messages') + } else { + console.log('[INFO] Using the Discord bot to send messages') + } + + return true + } + + onDiscordReady () { + this.handler.init(async (data: LogLine) => { + if (data) { + const { username, message } = data + await this.discordClient.sendMessage(username, message) + } + }) + } + + async init () { + const loaded = this.loadConfig() + if (!loaded) return + + this.discordClient = new DiscordClient(this.config, () => this.onDiscordReady()) + this.handler = new Handler(this.config) + + await this.discordClient.init() + } +} + +export default Shulker diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..d69cdb0 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,8 @@ +import Shulker from './Shulker' + +const main = async () => { + const shulker = new Shulker() + await shulker.init() +} + +main() |