diff --git a/.gitignore b/.gitignore index 14b32f7..f3d0e1e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ config.json +whitelist.json +usercache.json package-lock.json node_modules/ diff --git a/index.js b/index.js index 5e25ca9..235e68b 100644 --- a/index.js +++ b/index.js @@ -4,7 +4,7 @@ import {REST} from '@discordjs/rest' import {Routes} from 'discord-api-types/v9' const config = JSON.parse(fs.readFileSync('./config.json')) -const client = new Client({intents: [Intents.FLAGS.GUILDS, Intents.FLAGS.GUILD_MESSAGES]}) +const client = new Client({intents: [Intents.FLAGS.GUILDS, Intents.FLAGS.GUILD_MESSAGES, Intents.FLAGS.GUILD_MEMBERS]}) client.on('ready', () => { console.log(`Logged in as ${client.user.tag}!`) diff --git a/modules/whitelist/commands/pipe.js b/modules/whitelist/commands/pipe.js new file mode 100644 index 0000000..a9a8429 --- /dev/null +++ b/modules/whitelist/commands/pipe.js @@ -0,0 +1,5 @@ +import fs from 'fs/promises' + +export async function sendCommands(pipe, ...commands) { + await fs.writeFile(pipe, commands.map(cmd => cmd + '\n').join(), {flag: 'a'}) +} \ No newline at end of file diff --git a/modules/whitelist/commands/rcon.js b/modules/whitelist/commands/rcon.js new file mode 100644 index 0000000..1d4b7a1 --- /dev/null +++ b/modules/whitelist/commands/rcon.js @@ -0,0 +1,40 @@ +import util from 'minecraft-server-util' + +const {RCON} = util + +const connections = {} +const TIMEOUT = Symbol() + +export async function sendCommands(params, ...commands) { + const connection = await ensureConnection(params) + const results = [] + for (const command of commands) { + results.push(await connection.execute(command)) + } + return results +} + +async function ensureConnection(params) { + const key = params.host + ':' + params.port + let connection = connections[key] + if (!connection) { + connection = new RCON(params.host, {port: params.port, password: params.password}) + await connection.connect() + connections[key] = connection + } + connection[TIMEOUT] = Math.max(Date.now() + 5000, connection[TIMEOUT] || 0) + setTimeout(checkRemoveConnection.bind(null, key), 5100) + return connection +} + +async function checkRemoveConnection(key) { + const connection = connections[key] + if (connection[TIMEOUT] <= Date.now()) { + delete connections[key] + try { + await connection.close() + } catch (e) { + console.error(e) + } + } +} \ No newline at end of file diff --git a/modules/whitelist/database.js b/modules/whitelist/database.js new file mode 100644 index 0000000..8ade6ec --- /dev/null +++ b/modules/whitelist/database.js @@ -0,0 +1,131 @@ +import fs, { read } from 'fs' +import { getUUID } from './usercache.js' + +export class Database { + #file + #users = {} + #removed = new Set() + + constructor(file) { + this.#file = file + this.load() + this.save() + } + + load() { + if (!fs.existsSync(this.#file)) return + const data = JSON.parse(fs.readFileSync(this.#file)) + this.#users = data.users + for (const user in this.#users) { + this.#users[user].uuids = this.#users[user].uuids || [] + } + this.#removed = new Set(data.removed || []) + } + + save() { + fs.writeFileSync(this.#file, JSON.stringify(this.dump(), null, 2)) + } + + dump() { + return { + users: this.#users, + removed: [...this.#removed] + } + } + + async convertNamesToUuids() { + const names = new Set() + for (const user in this.#users) { + for (const name of this.#users[user].names || []) { + names.add(name) + } + } + if (!names.size) return false + const uuids = await getUUID([...names]) + for (const user in this.#users) { + const userUuids = new Set(this.#users[user].uuids) + for (const name of this.#users[user].names || []) { + const uuidForName = uuids[name.toLowerCase()] + if (!uuidForName) { + console.warn('Invalid username ' + name + ', skipping') + } else { + userUuids.add(uuidForName) + } + } + this.#users[user].uuids = [...userUuids] + delete this.#users[user].names + } + for (const name in uuids) this.#removed.delete(uuids[name]) + this.save() + return true + } + + getUser(id) { + return this.#users[id] || {uuids: []} + } + + getAllByUUID() { + const byUuid = {} + for (const id in this.#users) { + for (const uuid of this.#users[id].uuids) { + byUuid[uuid] = id + } + } + return byUuid + } + + getBannedUUIDs() { + const banned = new Set() + for (const user of Object.values(this.#users)) { + if (!user.banned) continue + for (const uuid of user.uuids) { + banned.add(uuid) + } + } + return banned + } + + getLinkedUser(uuid) { + for (const id in this.#users) { + if (this.#users[id].uuids.includes(uuid)) return {id, user: this.#users[id]} + } + } + + linkUser(id, uuid) { + const user = this.getUser(id) + user.uuids = [...new Set([...user.uuids, uuid])] + this.#users[id] = user + this.#removed.delete(uuid) + this.save() + return user + } + + unlinkUser(id, uuid) { + const user = this.getUser(id) + const uuids = new Set(user.uuids) + uuids.delete(uuid) + user.uuids = [...uuids] + if (user.uuids.length) { + this.#users[id] = user + } else { + delete this.#users[id] + } + this.#removed.add(uuid) + this.save() + return user + } + + removeUser(id) { + const user = this.#users[id] + delete this.#users[id] + for (const uuid of user.uuids) { + this.#removed.add(uuid) + } + this.save() + return user + } + + get removed() { + return [...this.#removed] + } +} \ No newline at end of file diff --git a/modules/whitelist/fs/ftp.js b/modules/whitelist/fs/ftp.js new file mode 100644 index 0000000..d0e252d --- /dev/null +++ b/modules/whitelist/fs/ftp.js @@ -0,0 +1,53 @@ +import path from 'path' +import Client from 'ftp' +import {readFully} from '../../../utils.js' + +export class FTPFileManager { + #params + + constructor(params) { + this.#params = params + } + + async #connect() { + return new Promise((resolve, reject) => { + const c = new Client() + c.on('ready', () => resolve(c)) + c.on('error', reject) + c.connect({ + host: this.#params.host, + port: this.#params.port || 21, + user: this.#params.username, + password: this.#params.password + }) + }) + } + + async readFile(file) { + const c = await this.#connect() + return new Promise((resolve, reject) => { + c.get(path.resolve('/', this.#params.path, file), async (err, stream) => { + if (err) { + reject(err) + return + } + const data = await readFully(stream) + c.end() + resolve(data) + }) + }) + } + + async writeFile(file, data) { + const c = await this.#connect() + return new Promise((resolve, reject) => { + c.put(data, path.resolve('/', this.#params.path, file), err => { + if (err) { + reject(err) + } else { + resolve() + } + }) + }) + } +} \ No newline at end of file diff --git a/modules/whitelist/fs/local.js b/modules/whitelist/fs/local.js new file mode 100644 index 0000000..4a9e446 --- /dev/null +++ b/modules/whitelist/fs/local.js @@ -0,0 +1,18 @@ +import fs from 'fs/promises' +import path from 'path' + +export class LocalFileManager { + #path + + constructor(params) { + this.#path = params.path + } + + async readFile(file) { + return fs.readFile(path.resolve(this.#path, file)) + } + + async writeFile(file, data) { + return fs.writeFile(path.resolve(this.#path, file), data) + } +} \ No newline at end of file diff --git a/modules/whitelist/fs/sftp.js b/modules/whitelist/fs/sftp.js new file mode 100644 index 0000000..b763674 --- /dev/null +++ b/modules/whitelist/fs/sftp.js @@ -0,0 +1,49 @@ +import fs from 'fs' +import path from 'path' +import {Client} from 'ssh2' +import {readFully} from '../../../utils.js' + +export class SFTPFileManager { + #params + + constructor(params) { + this.#params = { + ...params, + privateKey: params.privateKey && fs.readFileSync(params.privateKey) + } + } + + async readFile(file) { + const fullPath = path.resolve(this.#params.path, file) + return new Promise((resolve, reject) => { + const conn = new Client() + conn.on('ready', () => { + conn.sftp((err, sftp) => { + if (err) return reject(err) + readFully(sftp.createReadStream(fullPath, {encoding: 'utf8'})).then(data => { + conn.end() + resolve(data) + }).catch(reject) + }) + }).connect(this.#params) + }) + } + + async writeFile(file, data) { + const fullPath = path.resolve(this.#params.path, file) + return new Promise((resolve, reject) => { + const conn = new Client() + conn.on('ready', () => { + conn.sftp((err, sftp) => { + if (err) return reject(err) + const stream = sftp.createWriteStream(fullPath, {encoding: 'utf8'}) + stream.on('error', reject) + stream.end(data, () => { + conn.end() + resolve() + }) + }) + }).connect(this.#params) + }) + } +} \ No newline at end of file diff --git a/modules/whitelist/index.js b/modules/whitelist/index.js new file mode 100644 index 0000000..ce2392e --- /dev/null +++ b/modules/whitelist/index.js @@ -0,0 +1,444 @@ +import {Database} from './database.js' +import {getUUID, getUsers} from './usercache.js' +import {sendCommands as rconCommands} from './commands/rcon.js' +import {sendCommands as pipeCommands} from './commands/pipe.js' +import {reformatUUID} from '../../utils.js' +import {SlashCommandBuilder} from '@discordjs/builders' +import {LocalFileManager} from './fs/local.js' +import {FTPFileManager} from './fs/ftp.js' +import {SFTPFileManager} from './fs/sftp.js' + +let client, globalConfig, config, database +const servers = {} + +export default function(_client, _globalConfig, _config) { + client = _client + globalConfig = _globalConfig + config = _config + database = new Database('./whitelist.json') + + client.on('interactionCreate', async interaction => { + if (!interaction.isCommand() || interaction.commandName !== 'whitelist') return + try { + console.log(interaction.options) + await functions[interaction.options.getSubcommand()](interaction) + } catch (e) { + console.error(e) + try { + await interaction[interaction.deferred ? 'editReply' : 'reply']('An error occured trying to execute this command') + } catch (_) {} + } + }) + + for (const serverId in config.servers) { + const server = config.servers[serverId] + const methods = servers[serverId] = {} + if (server.pipe) { + methods.runCommands = pipeCommands.bind(null, server.pipe.path) + } else if (server.rcon) { + methods.runCommands = rconCommands.bind(null, server.rcon) + } + if (server.local) { + methods.fs = new LocalFileManager(server.local) + } else if (server.sftp) { + methods.fs = new SFTPFileManager(server.sftp) + } else if (server.ftp) { + methods.fs = new FTPFileManager(server.ftp) + } + } + + client.on('ready', async () => { + await database.convertNamesToUuids() + scheduleUpdate() + }) + + client.on('guildMemberUpdate', (oldMember, newMember) => { + const oldRoles = new Set(oldMember.roles.cache.keys()) + const newRoles = new Set(newMember.roles.cache.keys()) + const allRoles = new Set([...oldRoles, ...newRoles]) + for (const role of allRoles) { + if (newRoles.has(role) && oldRoles.has(role)) continue + console.log(`Role update: ${role}`) + if (role in config.roles) { + console.log('Scheduling update') + scheduleUpdate() + break + } + } + }) + + return [new SlashCommandBuilder() + .setName('whitelist') + .setDescription('Manages the whitelists') + .addSubcommand(sub => sub + .setName('add') + .setDescription('Add yourself (or another user) to the whitelist') + .addStringOption(option => option.setName('name').setDescription('The Minecraft username').setRequired(true)) + .addUserOption(option => option.setName('user').setDescription('The Discord user')) + ) + .addSubcommand(sub => sub + .setName('remove') + .setDescription('Remove one or all linked Minecraft accounts') + .addStringOption(option => option.setName('name').setDescription('The Minecraft username')) + .addStringOption(option => option.setName('uuid').setDescription('The Minecraft UUID')) + .addUserOption(option => option.setName('user').setDescription('The Discord user')) + ) + .addSubcommand(sub => sub + .setName('info') + .setDescription('Get info about a whitelisted user') + .addStringOption(option => option.setName('name').setDescription('The Minecraft username')) + .addStringOption(option => option.setName('uuid').setDescription('The Minecraft UUID')) + .addUserOption(option => option.setName('user').setDescription('The Discord user')) + ) + .addSubcommand(sub => sub + .setName('dump') + .setDescription('Dump the whitelist state as human readable json') + ) + .addSubcommand(sub => sub + .setName('reload') + .setDescription('Reload the whitelist database') + ) + ] +} + +const functions = { + async add(interaction) { + const target = interaction.options.getUser('user') || interaction.user + if (!(await canModify(interaction.user, target))) { + await interaction.reply({content: 'You\'re not allowed to modify the whitelist settings for ' + target.username, ephemeral: true}) + return + } + const minecraftName = interaction.options.getString('name') + await interaction.deferReply({ephemeral: true}) + const uuid = await getUUID(minecraftName) + if (!uuid) { + await interaction.editReply('Cannot find a Minecraft player by that name') + return + } + const current = database.getUser(target.id) + if (current.uuids.includes(uuid)) { + await interaction.editReply(`${minecraftName} (${uuid}) is already added to this user`) + return + } + const otherLink = database.getLinkedUser(uuid) + if (otherLink) { + await interaction.editReply(`${minecraftName} (${uuid}) is already linked to another user`) + return + } + const allowedCount = await allowedLinks(await getMember(target)) + if (current.uuids.length + 1 > allowedCount) { + await interaction.editReply(`This account is only allowed ${allowedCount} linked minecraft account${allowedCount === 1 ? '' : 's'}`) + return + } + const linked = database.linkUser(target.id, uuid) + scheduleUpdate() + const embed = await makeEmbed(target.id, linked) + await interaction.editReply({embeds: [embed]}) + await log(interaction, embed) + }, + + async remove(interaction) { + const {error, target, uuid} = await getTargetedUser(interaction, true) + if (error) return + if (!interaction.deferred) await interaction.deferReply({ephemeral: true}) + if (!target || !database.getUser(target.id).uuids.length) { + await interaction.editReply('Unknown user') + return + } + if (!(await canModify(interaction.user, target))) { + await interaction.editReply('You\'re not allowed to modify the whitelist settings for ' + target.username) + return + } + if (uuid) { + const linked = database.unlinkUser(target.id, uuid) + scheduleUpdate() + const embed = await makeEmbed(target.id, linked) + await interaction.editReply({embeds: [embed]}) + await log(interaction, embed) + } else { + database.removeUser(target.id) + scheduleUpdate() + await interaction.editReply('Removed all linked accounts for <@' + target.id + '>') + await log(interaction) + } + }, + + async info(interaction) { + const {error, target, uuid} = await getTargetedUser(interaction) + if (error) return + const info = uuid ? database.getLinkedUser(uuid) : {id: target.id, user: database.getUser(target.id)} + if (!info || !info.user.uuids.length) { + await interaction.editReply('Unknown user') + return + } + const embed = await makeEmbed(info.id, info.user) + await interaction.editReply({embeds: [embed]}) + }, + + async dump(interaction) { + if (!isAdmin(interaction.member)) { + await interaction.reply({content: 'You do not have permission to use this command.', ephemeral: true}) + return + } + await interaction.deferReply({ephemeral: true}) + const {serversForId, names, byUuid, members} = await calculateState() + const banned = database.getBannedUUIDs() + const users = {} + for (const uuid in byUuid) { + const id = byUuid[uuid] + const member = members.get(id) + const user = users[id] = users[id] || {} + if (member) user.discord = member.user.tag + if (banned.has(uuid)) user.banned = true + user.uuids = user.uuids || {} + user.uuids[uuid] = names[uuid] + if (id in serversForId) user.servers = [...serversForId[id]] + } + const removed = {} + for (const uuid of database.removed) { + removed[uuid] = names[uuid] + } + const json = JSON.stringify({users, removed}, null, 2) + interaction.editReply({ + files: [{ + attachment: Buffer.from(json, 'utf8'), + name: 'dump.json' + }], + ephemeral: true + }) + }, + + async reload(interaction) { + if (!isAdmin(interaction.member)) { + await interaction.reply({content: 'You do not have permission to use this command.', ephemeral: true}) + return + } + await interaction.deferReply({ephemeral: true}) + database.load() + await database.convertNamesToUuids() + scheduleUpdate() + await interaction.editReply({content: 'Database reloaded', ephemeral: true}) + await log(interaction) + } +} + +async function canModify(user, other) { + if (user.id === other.id) return true + const guild = await client.guilds.fetch(globalConfig.guild) + const member = await guild.members.fetch(user) + const otherMember = await guild.members.fetch(other) + const manageRoles = member.permissions.has('MANAGE_ROLES', true) + const higherRole = member.roles.highest.comparePositionTo(otherMember.roles.highest) > 0 + return manageRoles && higherRole +} + +async function log(interaction, embed) { + if (!config.log) return + let command = interaction.commandName + console.log(JSON.stringify(interaction.options.data)) + for (const data of interaction.options.data) { + if (data.type === 'SUB_COMMAND') { + command += ` ${data.name}` + for (const opt of data.options) { + command += ` ${opt.name}:${opt.value}` + } + } + } + const channel = await client.channels.fetch(config.log) + await channel.send({ + content: interaction.user.tag + ': /' + command, + embeds: embed ? [embed] : undefined + }) +} + +async function isAdmin(member) { + return member.permissions.has('ADMINISTRATOR') +} + +async function getMember(user) { + const guild = await client.guilds.fetch(globalConfig.guild) + return guild.members.fetch(user) +} + +async function allowedLinks(member) { + let allowed = 0 + for (const role in config.roles) { + if (!member.roles.cache.has(role)) continue + allowed = Math.max(allowed, config.roles[role].allowedLinks) + } + return allowed +} + +async function getTargetedUser(interaction, requireArgument = false) { + let uuid = interaction.options.getString('uuid') + const name = interaction.options.getString('name') + let target = interaction.options.getUser('user') + if ((uuid && name) || (uuid && target) || (name && target)) { + await interaction.reply({content: 'At most one of `uuid`, `name` and `user` expected', ephemeral: true}) + return {error: true} + } + await interaction.deferReply() + if (name) { + uuid = await getUUID(name) + if (!uuid) { + await interaction.editReply('Cannot find a Minecraft player by that name') + return {error: true} + } + } + if (uuid) { + uuid = reformatUUID(uuid) + const linkedUser = database.getLinkedUser(uuid) + if (linkedUser) { + target = await client.users.fetch(linkedUser.id) + } + } else if (!requireArgument) { + target = target || interaction.user + } + if (requireArgument && !target && !uuid) { + await interaction.reply({content: 'One of `uuid`, `name` and `user` expected', ephemeral: true}) + return {error: true} + } + return {error: false, uuid, name, target} +} + +async function makeEmbed(id, userInfo) { + const user = await client.users.fetch(id) + const member = await getMember(user) + const users = await getUsers(userInfo.uuids) + const limit = await allowedLinks(member) + const servers = getServersForMember(member) || [] + return { + title: user.username, + fields: [{ + name: `Username${userInfo.uuids.length === 1 ? '' : 's'} (${users.length}/${isFinite(limit) ? limit : 'unlimited'})`, + value: users.map(u => `${u.name} \`${u.uuid}\``).join(',\n') || 'None' + }, { + name: 'Servers', + value: [...servers].join('\n') || 'None' + }], + footer: { + text: user.tag, + icon_url: user.displayAvatarURL() + } + } +} + +let updating = false +function scheduleUpdate() { + setTimeout(async () => { + if (updating) { + scheduleUpdate() + return + } + updating = true + try { + await update() + } catch (e) { + console.error(e) + } finally { + updating = false + } + }, 5000) +} + +function getServersForRole(roleId) { + return config.roles[roleId].servers.flatMap(glob => { + if (!glob.startsWith('*.')) return [glob] + return Object.keys(servers).filter(id => id.endsWith(glob.slice(2))) + }) +} + +function getServersForMember(member) { + const serverIds = [] + for (const roleId in config.roles) { + if (member.roles.cache.has(roleId)) { + serverIds.push(...getServersForRole(roleId)) + } + } + return new Set(serverIds) +} + +async function calculateState() { + const byUuid = database.getAllByUUID() + const names = {} + for (const {uuid, name} of await getUsers(Object.keys(byUuid))) { + names[uuid] = name + } + const ids = new Set(Object.values(byUuid)) + const guild = await client.guilds.fetch(globalConfig.guild) + const members = await guild.members.fetch({user: [...ids]}) + const serversForId = {} + for (const member of members.values()) { + serversForId[member.id] = getServersForMember(member) + } + console.log(serversForId) + const serversForUuid = {} + for (const uuid of database.removed) { + serversForUuid[uuid] = new Set() + } + for (const uuid in byUuid) { + const serversForThisUuid = serversForId[byUuid[uuid]] + if (!serversForThisUuid) { + console.warn(`Could not find servers for ${uuid} (${byUuid[uuid]})`) + continue + } + serversForUuid[uuid] = serversForThisUuid + } + for (const uuid of database.getBannedUUIDs()) { + serversForUuid[uuid] = new Set() + } + return {serversForUuid, serversForId, names, members, byUuid} +} + +async function update() { + console.log('Updating') + const start = Date.now() + const {serversForUuid, names} = await calculateState() + console.log(serversForUuid) + const allUpdates = {} + for (const serverId in servers) { + const server = servers[serverId] + const currentWhitelist = JSON.parse(await server.fs.readFile('whitelist.json')) + const alreadyPresent = new Set() + const newWhitelist = [] + const unmanaged = [] + const additions = [] + const removals = [] + for (const entry of currentWhitelist) { + if (!(entry.uuid in serversForUuid)) { + unmanaged.push(entry) + newWhitelist.push(entry) + } else { + const allowedServers = serversForUuid[entry.uuid] + if (allowedServers.has(serverId)) { + alreadyPresent.add(entry.uuid) + newWhitelist.push(entry) + } else { + removals.push(entry.uuid) + } + } + } + for (const uuid in serversForUuid) { + if (alreadyPresent.has(uuid) || !serversForUuid[uuid].has(serverId)) continue + newWhitelist.push({uuid, name: names[uuid]}) + additions.push(uuid) + } + //console.log(serverId, newWhitelist) + if (additions.length || removals.length) { + allUpdates[serverId] = {additions, removals} + await server.fs.writeFile('whitelist.json', JSON.stringify(newWhitelist, null, 2)) + const commands = [] + for (const uuid of removals) { + commands.push('deop ' + names[uuid]) + commands.push('kick ' + names[uuid]) + } + if (config.servers[serverId].opEveryone) { + for (const uuid of additions) commands.push('op ' + names[uuid]) + } + commands.push('whitelist reload') + await server.runCommands(...commands) + } + } + console.log(allUpdates) + console.log(`Done in ${Date.now() - start}ms`) +} \ No newline at end of file diff --git a/modules/whitelist/usercache.js b/modules/whitelist/usercache.js new file mode 100644 index 0000000..f96eace --- /dev/null +++ b/modules/whitelist/usercache.js @@ -0,0 +1,95 @@ +import fs from 'fs' +import fetch from 'node-fetch' +import {reformatUUID} from '../../utils.js' + +const FILE = './usercache.json' +const CACHE_TIMEOUT = 86400e3 + +let cache = fs.existsSync(FILE) ? JSON.parse(fs.readFileSync(FILE)) : {} + +export async function getUUID(username) { + if (!Array.isArray(username)) { + return getUUIDIfCached(username) || lookupUUID(username) + } + const results = {} + const missing = [] + for (const name of username) { + const cached = getUUIDIfCached(name) + if (cached) { + results[name.toLowerCase()] = cached + } else { + missing.push(name) + } + } + const ps = [] + for (let i = 0; i < missing.length; i+= 10) { + ps.push(lookupUUID(missing.slice(i, Math.min(i + 10, missing.length)))) + } + const objs = await Promise.all(ps) + for (const obj of objs) { + Object.assign(results, obj) + } + return results +} + +function getUUIDIfCached(username) { + const lc = username.toLowerCase() + if (cache[lc] && new Date(cache[lc].expires) > new Date()) { + return cache[lc].uuid + } + return null +} + +export async function getUsers(uuids) { + const cached = new Set(Object.values(cache).map(u => u.uuid)) + const missing = uuids.filter(uuid => !cached.has(uuid)) + if (missing.length) { + await lookupNames(missing) + } + return Object.values(cache).filter(u => uuids.includes(u.uuid)) +} + +async function lookupUUID(username) { + try { + console.log(`Fetching ${username}`) + const res = await fetch('https://api.mojang.com/profiles/minecraft', { + method: 'POST', + body: JSON.stringify(Array.isArray(username) ? username : [username]), + headers: {'content-type': 'application/json'} + }) + const data = await res.json() + const results = {} + for (const userData of data) { + const uuid = reformatUUID(userData.id) + cache[userData.name.toLowerCase()] = {name: userData.name, uuid, expires: new Date(Date.now() + CACHE_TIMEOUT)} + results[userData.name.toLowerCase()] = uuid + } + console.log(results) + fs.writeFileSync(FILE, JSON.stringify(cache, null, 2)) + return Array.isArray(username) ? results : results[username] + } catch (e) { + console.error(e) + throw Error('Cannot lookup uuid for username ' + username) + } +} + +async function lookupNames(uuids) { + await Promise.all(uuids.map(uuid => lookupName(uuid, false))) + fs.writeFileSync(FILE, JSON.stringify(cache, null, 2)) +} + +async function lookupName(uuid, writeCache = true) { + try { + console.log(`Fetching ${uuid}`) + const res = await fetch(`https://api.mojang.com/user/profiles/${uuid}/names`) + const data = await res.json() + if (!data.length) throw 'Empty result from Mojang API' + const name = data[data.length - 1].name + cache[name.toLowerCase()] = {name, uuid, expires: new Date(Date.now() + CACHE_TIMEOUT)} + if (writeCache) fs.writeFileSync(FILE, JSON.stringify(cache, null, 2)) + return name + } catch (e) { + console.error(e) + throw Error('Cannot lookup username for uuid ' + uuid) + } +} \ No newline at end of file diff --git a/package.json b/package.json index 1aaf805..cc2161b 100644 --- a/package.json +++ b/package.json @@ -18,10 +18,13 @@ "@discordjs/rest": "^0.1.0-canary.0", "discord-api-types": "^0.23.1", "discord.js": "^13.1.0", + "ftp": "^0.3.10", "html-entities": "^2.3.2", "jira-client": "^6.11.0", + "minecraft-server-util": "^4.1.2", "node-fetch": "^2.6.1", "request": "^2.88.2", - "request-promise-native": "^1.0.8" + "request-promise-native": "^1.0.8", + "ssh2": "^1.5.0" } } diff --git a/utils.js b/utils.js index 1c22a6b..7f046e4 100644 --- a/utils.js +++ b/utils.js @@ -14,4 +14,18 @@ export function editNoMention(msg, response) { response = {content: response, allowedMentions: {repliedUser: false}} } return msg.edit(response) +} + +export function reformatUUID(uuid) { + if (uuid.includes('-')) return reformatUUID(uuid.replace(/-/g, '')) + return uuid.slice(0, 8) + '-' + uuid.slice(8, 12) + '-' + uuid.slice(12, 16) + '-' + uuid.slice(16, 20) + '-' + uuid.slice(20) +} + +export function readFully(stream) { + return new Promise((resolve, reject) => { + let data = '' + stream.on('data', d => data += d) + stream.on('end', () => resolve(data)) + stream.on('err', reject) + }) } \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index cb01844..b39e87b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -94,6 +94,16 @@ ajv@^6.5.5: json-schema-traverse "^0.4.1" uri-js "^4.2.2" +ansi-styles@^5.0.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-5.2.0.tgz#07449690ad45777d1924ac2abb2fc8895dba836b" + +asn1@^0.2.4: + version "0.2.6" + resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.6.tgz#0d3a7bb6e64e02a90c0303b31f292868ea09a08d" + dependencies: + safer-buffer "~2.1.0" + asn1@~0.2.3: version "0.2.4" resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.4.tgz#8d2475dfab553bb33e77b54e59e880bb8ce23136" @@ -116,7 +126,7 @@ aws4@^1.8.0: version "1.9.1" resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.9.1.tgz#7e33d8f7d449b3f673cd72deb9abdc552dbe528e" -bcrypt-pbkdf@^1.0.0: +bcrypt-pbkdf@^1.0.0, bcrypt-pbkdf@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e" dependencies: @@ -144,6 +154,16 @@ core-util-is@1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" +core-util-is@~1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" + +cpu-features@0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/cpu-features/-/cpu-features-0.0.2.tgz#9f636156f1155fd04bdbaa028bb3c2fbef3cea7a" + dependencies: + nan "^2.14.1" + dashdash@^1.12.0: version "1.14.1" resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0" @@ -236,6 +256,13 @@ form-data@~2.3.2: combined-stream "^1.0.6" mime-types "^2.1.12" +ftp@^0.3.10: + version "0.3.10" + resolved "https://registry.yarnpkg.com/ftp/-/ftp-0.3.10.tgz#9197d861ad8142f3e63d5a83bfe4c59f7330885d" + dependencies: + readable-stream "1.1.x" + xregexp "2.0.0" + getpass@^0.1.1: version "0.1.7" resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa" @@ -265,6 +292,10 @@ http-signature@~1.2.0: jsprim "^1.2.2" sshpk "^1.7.0" +inherits@~2.0.1: + version "2.0.4" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" + is-obj@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-2.0.0.tgz#473fb05d973705e3fd9620545018ca8e22ef4982" @@ -273,6 +304,10 @@ is-typedarray@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" +isarray@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf" + isstream@~0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a" @@ -328,6 +363,21 @@ mime-types@^2.1.12, mime-types@~2.1.19: dependencies: mime-db "1.43.0" +minecraft-motd-util@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/minecraft-motd-util/-/minecraft-motd-util-1.1.3.tgz#1da3d2de06ec1ba49a3fc0e6217a3b3cb7d44d73" + +minecraft-server-util@^4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/minecraft-server-util/-/minecraft-server-util-4.1.2.tgz#b49b7576521bbb6c1ba6cb4be953a11925da8399" + dependencies: + ansi-styles "^5.0.0" + minecraft-motd-util "^1.1.3" + +nan@^2.14.1, nan@^2.15.0: + version "2.15.0" + resolved "https://registry.yarnpkg.com/nan/-/nan-2.15.0.tgz#3f34a473ff18e15c1b5626b62903b5ad6e665fee" + node-fetch@^2.6.1: version "2.6.1" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052" @@ -363,6 +413,15 @@ qs@~6.5.2: version "6.5.2" resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36" +readable-stream@1.1.x: + version "1.1.14" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.1.14.tgz#7cf4c54ef648e3813084c636dd2079e166c081d9" + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.1" + isarray "0.0.1" + string_decoder "~0.10.x" + regenerator-runtime@^0.13.4: version "0.13.5" resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.5.tgz#d878a1d094b4306d10b9096484b33ebd55e26697" @@ -423,6 +482,16 @@ safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: version "2.1.2" resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" +ssh2@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/ssh2/-/ssh2-1.5.0.tgz#4dc559ba98a1cbb420e8d42998dfe35d0eda92bc" + dependencies: + asn1 "^0.2.4" + bcrypt-pbkdf "^1.0.2" + optionalDependencies: + cpu-features "0.0.2" + nan "^2.15.0" + sshpk@^1.7.0: version "1.16.1" resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.16.1.tgz#fb661c0bef29b39db40769ee39fa70093d6f6877" @@ -441,6 +510,10 @@ stealthy-require@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/stealthy-require/-/stealthy-require-1.1.1.tgz#35b09875b4ff49f26a777e509b3090a3226bf24b" +string_decoder@~0.10.x: + version "0.10.31" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94" + tough-cookie@^2.3.3, tough-cookie@~2.5.0: version "2.5.0" resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.5.0.tgz#cd9fb2a0aa1d5a12b473bd9fb96fa3dcff65ade2" @@ -495,3 +568,7 @@ verror@1.10.0: ws@^7.5.1: version "7.5.5" resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.5.tgz#8b4bc4af518cfabd0473ae4f99144287b33eb881" + +xregexp@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/xregexp/-/xregexp-2.0.0.tgz#52a63e56ca0b84a7f3a5f3d61872f126ad7a5943"