summary refs log tree commit diff homepage
path: root/src
diff options
context:
space:
mode:
authordestruc7i0n <6181960+destruc7i0n@users.noreply.github.com>2020-02-11 15:23:23 -0500
committerGitHub <noreply@github.com>2020-02-11 15:23:23 -0500
commitd2cff4ced30817c2d02bc9c133cbabcf380b6de7 (patch)
treed79b7e9f1e959d22dd9b68cec8a65e49658a16fa /src
parentMerge pull request #37 from destruc7i0n/webhooks (diff)
parentPrecedence (diff)
downloadshulker-d2cff4ced30817c2d02bc9c133cbabcf380b6de7.tar.gz
shulker-d2cff4ced30817c2d02bc9c133cbabcf380b6de7.zip
Merge pull request #47 from destruc7i0n/refactor
Refactor
Diffstat (limited to 'src')
-rw-r--r--src/Config.ts42
-rw-r--r--src/Discord.ts180
-rw-r--r--src/MinecraftHandler.ts209
-rw-r--r--src/Rcon.ts124
-rw-r--r--src/Shulker.ts52
-rw-r--r--src/index.ts8
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()