summary refs log tree commit diff homepage
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--src/Config.ts5
-rw-r--r--src/Discord.ts199
-rw-r--r--src/MinecraftHandler.ts20
-rw-r--r--src/Rcon.ts4
-rw-r--r--src/Shulker.ts16
-rw-r--r--src/lib/util.ts4
6 files changed, 165 insertions, 83 deletions
diff --git a/src/Config.ts b/src/Config.ts
index 8b10801..485d6ae 100644
--- a/src/Config.ts
+++ b/src/Config.ts
@@ -1,5 +1,6 @@
 export interface Config {
   PORT: number
+  DEBUG: boolean
 
   USE_WEBHOOKS: boolean
   WEBHOOK_URL: string
@@ -18,6 +19,7 @@ export interface Config {
 
   IS_LOCAL_FILE: boolean
   LOCAL_FILE_PATH: string
+  FS_WATCH_FILE: boolean
 
   PATH_TO_MINECRAFT_SERVER_INSTALL?: string
   YOUR_URL?: string
@@ -34,10 +36,11 @@ export interface Config {
   REGEX_MATCH_CHAT_MC: string
   REGEX_DEATH_MESSAGE: string
   REGEX_IGNORED_CHAT: string
-  DEBUG: boolean
 
   SERVER_NAME: string
   SERVER_IMAGE: string
+  HEAD_IMAGE_URL: string
+  DEFAULT_PLAYER_HEAD: string
   SHOW_SERVER_STATUS: boolean
   SHOW_PLAYER_CONN_STAT: boolean
   SHOW_PLAYER_ADVANCEMENT: boolean
diff --git a/src/Discord.ts b/src/Discord.ts
index 7a4f5d6..8071d7e 100644
--- a/src/Discord.ts
+++ b/src/Discord.ts
@@ -1,47 +1,72 @@
-import {Client, Message, Snowflake, TextChannel} from 'discord.js'
+import { Client, Intents, Message, TextChannel, User } from 'discord.js'
 
 import emojiStrip from 'emoji-strip'
 import axios from 'axios'
 
-import { Config } from './Config'
+import type { Config } from './Config'
 
 import Rcon from './Rcon'
+import { escapeUnicode } from './lib/util'
 
 class Discord {
   config: Config
   client: Client
 
-  channel: Snowflake
+  channel: TextChannel | null
+
+  uuidCache: Map<string, string>
+  mentionCache: Map<string, User>
 
   constructor (config: Config, onReady?: () => void) {
     this.config = config
 
-    this.client = new Client()
+    this.client = new Client({ intents: [Intents.FLAGS.GUILDS, Intents.FLAGS.GUILD_MESSAGES] })
     if (onReady) this.client.once('ready', () => onReady())
-    this.client.on('message', (message: Message) => this.onMessage(message))
+    this.client.on('messageCreate', (message: Message) => this.onMessage(message))
+
+    this.channel = null
 
-    this.channel = config.DISCORD_CHANNEL_ID || ''
+    this.uuidCache = new Map()
+    this.mentionCache = new Map()
   }
 
   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)
+      process.exit(1)
+    }
+
+    if (this.config.DISCORD_CHANNEL_NAME && !this.config.DISCORD_CHANNEL_ID) {
+      await this.getChannelIdFromName(this.config.DISCORD_CHANNEL_NAME)
+    } else if (this.config.DISCORD_CHANNEL_ID) {
+      const channel = await this.client.channels.fetch(this.config.DISCORD_CHANNEL_ID) as TextChannel
+      if (!channel) {
+        console.log(`[INFO] Could not find channel with ID ${this.config.DISCORD_CHANNEL_ID}. Please check that the ID is correct and that the bot has access to it.`)
+        process.exit(1)
+      }
+      this.channel = channel
+    }
+
+    if (this.channel) {
+      console.log(`[INFO] Using channel #${this.channel.name} (id: ${this.channel.id}) in the server "${this.channel.guild.name}"`)
     }
   }
 
-  private getChannelIdFromName (name: string) {
+  private async 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)
+
+    // fetch all the channels in every server
+    for (const guild of this.client.guilds.cache.values()) {
+      await guild.channels.fetch()
+    }
+
+    const channel = this.client.channels.cache.find((c) => c.isText() && c.type === 'GUILD_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}"`)
+      this.channel = channel as TextChannel
     } 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)
@@ -49,7 +74,7 @@ class Discord {
   }
 
   private parseDiscordWebhook (url: string) {
-    const re = /discordapp.com\/api\/webhooks\/([^\/]+)\/([^\/]+)/
+    const re = /discord[app]?.com\/api\/webhooks\/([^\/]+)\/([^\/]+)/
 
     // the is of the webhook
     let id = null
@@ -73,9 +98,9 @@ class Discord {
     // 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 (message.channel.id !== this.channel.id || message.channel.type !== 'GUILD_TEXT') return
     // if using webhooks, ignore this!
-    if (message.webhookID) {
+    if (message.webhookId) {
       // backwards compatability with older config
       if (this.config.USE_WEBHOOKS && this.config.IGNORE_WEBHOOKS === undefined) return
 
@@ -85,16 +110,20 @@ class Discord {
       } else if (this.config.USE_WEBHOOKS) {
         // otherwise, ignore all webhooks that are not the same as this one
         const { id } = this.parseDiscordWebhook(this.config.WEBHOOK_URL)
-        if (id === message.webhookID) {
+        if (id === message.webhookId) {
           if (this.config.DEBUG) console.log('[INFO] Ignoring webhook from self')
           return
         }
       }
     }
+    // ensure that the message has a sender
+    if (!message.author) return
+    // ensure that the message is a text message
+    if (message.type !== 'DEFAULT') return
     // if the same user as the bot, ignore
-    if (message.author.id === this.client.user.id) return
+    if (message.author.id === this.client.user?.id) return
     // ignore any attachments
-    if (message.attachments.array().length) return
+    if (message.attachments.size) return
 
     const rcon = new Rcon(this.config.MINECRAFT_SERVER_RCON_IP, this.config.MINECRAFT_SERVER_RCON_PORT, this.config.DEBUG)
     try {
@@ -104,10 +133,10 @@ class Discord {
       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))) {
+    let command: string | undefined;
+    if (this.config.ALLOW_SLASH_COMMANDS && this.config.SLASH_COMMAND_ROLES && message.cleanContent.startsWith('/') && message.member) {
+      const hasSlashCommandRole = message.member.roles.cache.find(r => this.config.SLASH_COMMAND_ROLES.includes(r.name))
+      if (hasSlashCommandRole) {
         // send the raw command, can be dangerous...
         command = message.cleanContent
       } else {
@@ -115,50 +144,57 @@ class Discord {
       }
     } else {
       if (this.config.MINECRAFT_TELLRAW_DOESNT_EXIST) {
-        command = `/say ${this.makeMinecraftTellraw(message)}`
+        command = `/say ${this.makeMinecraftMessage(message)}`
       } else {
-        command = `/tellraw @a ${this.makeMinecraftTellraw(message)}`
+        command = `/tellraw @a ${this.makeMinecraftMessage(message)}`
       }
     }
 
     if (this.config.DEBUG) console.log(`[DEBUG] Sending command "${command}" to the server`)
 
     if (command) {
-      await rcon.command(command).catch((e) => {
+      let response: string | undefined;
+      try {
+        response = await rcon.command(command)
+      } catch (e) {
         console.log('[ERROR] Could not send command!')
         if (this.config.DEBUG) console.error(e)
-      }).then((str) => {
-        if (str === 'Unknown command. Try /help for a list of commands') {
-            console.error('[ERROR] Could not send command! (Unknown command)')
-            console.error('if this was a chat message, please look into MINECRAFT_TELLRAW_DOESNT_EXIST!')
-            console.error('command: ' + command)
+      }
+
+      if (response?.startsWith('Unknown command') || response?.startsWith('Unknown or incomplete command')) {
+        console.log('[ERROR] Could not send command! (Unknown command)')
+        if (command.startsWith('/tellraw')) {
+          console.log('Your Minecraft version may not support tellraw, please look into MINECRAFT_TELLRAW_DOESNT_EXIST!')
         }
-      })
+      }
     }
     rcon.close()
   }
 
-  private makeMinecraftTellraw(message: Message): string {
+  private makeMinecraftMessage(message: Message): string {
+    const username = emojiStrip(message.author.username)
+
     const variables: {[index: string]: string} = {
-      username: emojiStrip(message.author.username),
-      nickname: message.member.nickname ? emojiStrip(message.member.nickname) : emojiStrip(message.author.username),
+      username,
+      nickname: !!message.member?.nickname ? emojiStrip(message.member.nickname) : username,
       discriminator: message.author.discriminator,
-      text: emojiStrip(message.cleanContent)
+      text: emojiStrip(message.cleanContent),
     }
-    // hastily use JSON to encode the strings
-    for (const v of Object.keys(variables)) {
-      variables[v] = JSON.stringify(variables[v]).slice(1,-1)
+
+    // use JSON to encode the strings for tellraw
+    for (const [k, v] of Object.entries(variables)) {
+      variables[k] = JSON.stringify(v).slice(1,-1)
     }
     
-    if (this.config.MINECRAFT_TELLRAW_DOESNT_EXIST)
-    {
-        return this.config.MINECRAFT_TELLRAW_DOESNT_EXIST_SAY_TEMPLATE
-                .replace(/%username%/g, variables.username)
-                .replace(/%nickname%/g, variables.nickname)
-                .replace(/%discriminator%/g, variables.discriminator)
-                .replace(/%message%/g, variables.text)
+    if (this.config.MINECRAFT_TELLRAW_DOESNT_EXIST) {
+      return this.config.MINECRAFT_TELLRAW_DOESNT_EXIST_SAY_TEMPLATE
+        .replace(/%username%/g, variables.username)
+        .replace(/%nickname%/g, variables.nickname)
+        .replace(/%discriminator%/g, variables.discriminator)
+        .replace(/%message%/g, variables.text)
     }
 
+    variables.text = escapeUnicode(variables.text)
 
     return this.config.MINECRAFT_TELLRAW_TEMPLATE
       .replace(/%username%/g, variables.username)
@@ -167,7 +203,7 @@ class Discord {
       .replace(/%message%/g, variables.text)
   }
 
-  private replaceDiscordMentions(message: string): string {
+  private async replaceDiscordMentions(message: string): Promise<string> {
     const possibleMentions = message.match(/@[^#\s]*[#]?[0-9]{4}/gim)
     if (possibleMentions) {
       for (let mention of possibleMentions) {
@@ -175,9 +211,18 @@ class Discord {
         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])
+            let user = this.mentionCache.get(mention)
+            if (!user) {
+              // try fetching members by username to update cache
+              await this.channel!.guild.members.fetch({ query: username })
+              user = this.client.users.cache.find(user => user.username === username && user.discriminator === mentionParts[1])
+            }
+
             if (user) {
               message = message.replace(mention, '<@' + user.id + '>')
+              if (!this.mentionCache.has(mention)) this.mentionCache.set(mention, user)
+            } else {
+              console.log(`[ERROR] Could not find user by mention: "${mention}"`)
             }
           }
         }
@@ -195,48 +240,72 @@ class Discord {
     return message
   }
 
-  private makeDiscordWebhook (username: string, message: string) {
-    message = this.replaceDiscordMentions(message)
+  private async getUUIDFromUsername (username: string): Promise<string | null> {
+    username = username.toLowerCase()
+    if (this.uuidCache.has(username)) return this.uuidCache.get(username)!
+    // otherwise fetch and store
+    try {
+      const response = await (await axios.get('https://api.mojang.com/users/profiles/minecraft/' + username)).data
+      const uuid = response.id
+      this.uuidCache.set(username, uuid)
+      if (this.config.DEBUG) console.log(`[DEBUG] Fetched UUID ${uuid} for username "${username}"`)
+      return uuid
+    } catch (e) {
+      console.log(`[ERROR] Could not fetch uuid for ${username}, falling back to Steve for the skin`)
+      return null
+    }
+  }
+
+  private getHeadUrl(uuid: string): string {
+    const url = this.config.HEAD_IMAGE_URL || 'https://mc-heads.net/avatar/%uuid%/256'
+    return url.replace(/%uuid%/, uuid)
+  }
+
+  private async makeDiscordWebhook (username: string, message: string) {
+    const defaultHead = this.getHeadUrl(this.config.DEFAULT_PLAYER_HEAD || 'c06f89064c8a49119c29ea1dbd1aab82') // MHF_Steve
 
     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'
+      avatarURL = this.config.SERVER_IMAGE || defaultHead
     } else { // use avatar for player
-      avatarURL = `https://minotar.net/helm/${username}/256.png`
+      const uuid = await this.getUUIDFromUsername(username)
+      avatarURL = !!uuid ? this.getHeadUrl(uuid) : defaultHead
     }
 
     return {
-      username: 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) {
+    message = await this.replaceDiscordMentions(message)
+
     if (this.config.USE_WEBHOOKS) {
-      const webhook = this.makeDiscordWebhook(username, message)
+      const webhook = await this.makeDiscordWebhook(username, message)
       try {
         await axios.post(this.config.WEBHOOK_URL, webhook, { headers: { 'Content-Type': 'application/json' } })
+        return
       } catch (e) {
-        console.log('[ERROR] Could not send Discord message through WebHook!')
+        console.log('[ERROR] Could not send Discord message through WebHook! Falling back to sending through bot.')
         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}!`)
-      }
+    }
+
+    const messageContent = this.makeDiscordMessage(username, message)
+
+    try {
+      await this.channel!.send(messageContent)
+    } catch (e) {
+      console.log('[ERROR] Could not send Discord message through bot!')
+      process.exit(1)
     }
   }
 }
diff --git a/src/MinecraftHandler.ts b/src/MinecraftHandler.ts
index faf1828..48c30b4 100644
--- a/src/MinecraftHandler.ts
+++ b/src/MinecraftHandler.ts
@@ -3,7 +3,9 @@ import path from 'path'
 import { Tail } from 'tail'
 import express from 'express'
 
-import { Config } from './Config'
+import type { Config } from './Config'
+
+import { fixMinecraftUsername } from './lib/util'
 
 export type LogLine = {
   username: string
@@ -22,10 +24,6 @@ class MinecraftHandler {
     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)
 
@@ -58,7 +56,7 @@ class MinecraftHandler {
 
     if (logLine.startsWith('<')) {
       if (this.config.DEBUG){
-        console.log('[DEBUG]: A player sent a chat message')
+        console.log('[DEBUG] A player sent a chat message')
       }
 
       const re = new RegExp(this.config.REGEX_MATCH_CHAT_MC)
@@ -69,7 +67,7 @@ class MinecraftHandler {
         return null
       }
 
-      const username = MinecraftHandler.fixMinecraftUsername(matches[1])
+      const username = fixMinecraftUsername(matches[1])
       const message = matches[2]
       if (this.config.DEBUG) {
         console.log('[DEBUG] Username: ' + matches[1])
@@ -84,18 +82,18 @@ class MinecraftHandler {
     ) {
       // handle disconnection etc.
       if (this.config.DEBUG){
-        console.log(`[DEBUG]: A player's connection status changed`)
+        console.log(`[DEBUG] A player's connection status changed`)
       }
 
       return { username: serverUsername, message: logLine }
     } else if (this.config.SHOW_SERVER_STATUS && (logLine.includes('Starting minecraft server'))) {
         if (this.config.DEBUG) {
-          console.log('[DEBUG]: Server has started')
+          console.log('[DEBUG] Server has started')
         }
         return { username: serverUsername, message: 'Server is online' }
     } else if (this.config.SHOW_SERVER_STATUS && (logLine.includes('Stopping the server'))) {
         if (this.config.DEBUG) {
-          console.log('[DEBUG]: Server has stopped')
+          console.log('[DEBUG] Server has stopped')
         }
         return { username: serverUsername, message: 'Server is offline' }
     } else if (this.config.SHOW_PLAYER_ADVANCEMENT && logLine.includes('made the advancement')) {
@@ -189,7 +187,7 @@ class MinecraftHandler {
   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, {useWatchFile: true})
+      this.tail = new Tail(this.config.LOCAL_FILE_PATH, {useWatchFile: this.config.FS_WATCH_FILE ?? true})
     } else {
       throw new Error(`[ERROR] Local log file not found at "${this.config.LOCAL_FILE_PATH}"`)
     }
diff --git a/src/Rcon.ts b/src/Rcon.ts
index 180ad38..fc3b989 100644
--- a/src/Rcon.ts
+++ b/src/Rcon.ts
@@ -55,7 +55,7 @@ class Rcon {
   }
 
   public async auth (password: string): Promise<void> {
-    if (this.authed) { throw new Error('Already authed') }
+    if (this.authed) throw new Error('Already authed')
 
     if (this.connected){
       try {
@@ -88,7 +88,7 @@ class Rcon {
     const id = this.nextId
     this.nextId++
 
-    if (!this.connected) { throw new Error('Cannot send package while not connected') }
+    if (!this.connected) throw new Error('Cannot send package while not connected')
 
     const length = 14 + payload.length
     const buff = Buffer.alloc(length)
diff --git a/src/Shulker.ts b/src/Shulker.ts
index d9373ee..517d1e2 100644
--- a/src/Shulker.ts
+++ b/src/Shulker.ts
@@ -1,7 +1,9 @@
+import fs from 'fs'
+
 import DiscordClient from './Discord'
 import Handler, { LogLine } from './MinecraftHandler'
 
-import { Config } from './Config'
+import type { Config } from './Config'
 
 class Shulker {
   config: Config
@@ -14,10 +16,16 @@ class Shulker {
   }
 
   loadConfig () {
-    const configFile = (process.argv.length > 2) ? process.argv[2] : '../config.json'
+    const configFile = process.argv.length > 2 ? process.argv[2] : './config.json'
+    if (!fs.existsSync(configFile)) {
+      console.log('[ERROR] Could not find config file!')
+      return false
+    }
     console.log('[INFO] Using configuration file:', configFile)
-    this.config = require(configFile)
-    if (!this.config) {
+
+    try {
+      this.config = JSON.parse(fs.readFileSync(configFile, 'utf8'))
+    } catch (e) {
       console.log('[ERROR] Could not load config file!')
       return false
     }
diff --git a/src/lib/util.ts b/src/lib/util.ts
new file mode 100644
index 0000000..bd94795
--- /dev/null
+++ b/src/lib/util.ts
@@ -0,0 +1,4 @@
+export const escapeUnicode = (str: string) =>
+  str.replace(/[^\x00-\x7F]/g, (char: string) => '\\u' + char.charCodeAt(0).toString(16).padStart(4, '0'))
+
+export const fixMinecraftUsername = (username: string) => username.replace(/(§[A-Z-a-z0-9])/g, '')