diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/Config.ts | 5 | ||||
-rw-r--r-- | src/Discord.ts | 199 | ||||
-rw-r--r-- | src/MinecraftHandler.ts | 20 | ||||
-rw-r--r-- | src/Rcon.ts | 4 | ||||
-rw-r--r-- | src/Shulker.ts | 16 | ||||
-rw-r--r-- | src/lib/util.ts | 4 |
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, '') |