diff options
Diffstat (limited to '')
-rw-r--r-- | src/Config.ts | 39 | ||||
-rw-r--r-- | src/Discord.ts | 160 | ||||
-rw-r--r-- | src/MinecraftHandler.ts | 184 | ||||
-rw-r--r-- | src/Rcon.ts | 113 | ||||
-rw-r--r-- | src/Shulker.ts | 56 | ||||
-rw-r--r-- | src/index.ts | 8 |
6 files changed, 560 insertions, 0 deletions
diff --git a/src/Config.ts b/src/Config.ts new file mode 100644 index 0000000..036e057 --- /dev/null +++ b/src/Config.ts @@ -0,0 +1,39 @@ +export interface Config { + PORT: number + + USE_WEBHOOKS: boolean + WEBHOOK_URL: string + DISCORD_TOKEN: string + DISCORD_CHANNEL_ID: 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 + + 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 + RCON_RECONNECT_DELAY: number + 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..7088c0e --- /dev/null +++ b/src/Discord.ts @@ -0,0 +1,160 @@ +import { Client, Message, 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 + + 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)) + } + + async init () { + try { + await this.client.login(this.config.DISCORD_TOKEN) + } catch (e) { + console.log('[ERROR] Could not authenticate with Discord: ' + e) + if (this.config.DEBUG) console.error(e) + } + } + + async onMessage (message: Message) { + // don't want to check other channels + if (message.channel.id !== this.config.DISCORD_CHANNEL_ID || 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))) { + // 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() + } + + 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) + } + + 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) { + if (this.config.ALLOW_USER_MENTIONS) { + 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 + } + + 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, + } + } + + makeDiscordMessage(username: string, message: string) { + message = this.replaceDiscordMentions(message) + + return this.config.DISCORD_MESSAGE_TEMPLATE + .replace('%username%', username) + .replace('%message%', message) + } + + 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..39ab914 --- /dev/null +++ b/src/MinecraftHandler.ts @@ -0,0 +1,184 @@ +import fs from 'fs' +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 + } + + fixMinecraftUsername (username: string) { + return username.replace(/(§[A-Z-a-z0-9])/g, '') + } + + private parseLogLine (data: string): LogLine { + if (this.config.DEBUG) console.log('[DEBUG] Received ' + data) + + const ignored = new RegExp(this.config.REGEX_IGNORED_CHAT) + + if (ignored.test(data)) { + if (this.config.DEBUG) console.log('[DEBUG] Line ignored') + return null + } + + const logLineDataRegex = new RegExp( + `${(this.config.REGEX_SERVER_PREFIX || "\\[Server thread/INFO\\]:")} (.*)` + ) + + const logLineData = data.match(logLineDataRegex) + + if (data.includes('Rcon connection')) return null + if (!logLineDataRegex.test(data) || !logLineData) { + console.log('[ERROR] Regex could not match the string! Please verify it is correct!') + console.log('Received: "' + data + '", Regex matches lines that start with: "' + this.config.REGEX_SERVER_PREFIX + '"') + return null + } + + const logLine = logLineData[1] + + 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 = this.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('granted the advancement')) { + // handle achievement earning + if (this.config.DEBUG){ + console.log('[DEBUG] A player has earned an achievement') + } + return { username: `${this.config.SERVER_NAME} - Server`, message: logLine } + } else if (this.config.SHOW_PLAYER_ME && logLine.startsWith('* ')) { + 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() + const http = require('http').Server(this.app) + + this.app.use((request: express.Request, response: express.Response, next: express.NextFunction) => { + 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 = process.env.PORT || this.config.PORT + + http.listen(port, () => { + console.log('[INFO] Bot listening on *:' + port) + + if (!this.config.IS_LOCAL_FILE && this.config.SHOW_INIT_MESSAGE) { + console.log('[INFO] Please enter the following command on your server to send the logs to the server.') + console.log(' Be sure to replace "PATH_TO_MINECRAFT_SERVER_INSTALL" with the path to your Minecraft install') + console.log(' and replace "YOUR_URL" with the URL/IP of the server running Shulker!') + console.log(` \`tail -F /PATH_TO_MINECRAFT_SERVER_INSTALL/logs/latest.log | grep --line-buffered ": <" | while read x ; do echo -ne $x | curl -X POST -d @- http://YOUR_URL:${port}${this.config.WEBHOOK} ; done\``) + } + }) + } + + private initTail (callback: Callback) { + if (fs.existsSync(this.config.LOCAL_FILE_PATH)) { + console.log(`[INFO] Using configuration for local file at "${this.config.LOCAL_FILE_PATH}"`) + this.tail = new Tail(this.config.LOCAL_FILE_PATH) + } else { + throw new Error(`[ERROR] Local 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 file: ' + error) + }) + } + + 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..81cf3ea --- /dev/null +++ b/src/Rcon.ts @@ -0,0 +1,113 @@ +// 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: any + + 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) => { + 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!') + } + }) + } + + close () { + this.connected = false + this.socket.end() + } + + async auth (password: string) { + if (this.authed) { throw new Error('Already authed') } + + if (this.connected){ + await this.sendPackage(3, password) + } else { + return new Promise(resolve => { + this.socket.on('connect', async () => { + await this.sendPackage(3, password) + resolve() + }) + }) + } + } + + command (cmd: string) { + return this.sendPackage(2, cmd) + } + + sendPackage (type: number, payload: 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: any) => { + 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..9de927d --- /dev/null +++ b/src/Shulker.ts @@ -0,0 +1,56 @@ +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 Discord bot to send messages') + } + + return true + } + + fixMinecraftUsername (username: string) { + return username.replace(/(§[A-Z-a-z0-9])/g, '') + } + + 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() |