summary refs log tree commit diff homepage
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/Config.ts39
-rw-r--r--src/Discord.ts160
-rw-r--r--src/MinecraftHandler.ts184
-rw-r--r--src/Rcon.ts113
-rw-r--r--src/Shulker.ts56
-rw-r--r--src/index.ts8
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()