From 6ce8dd8ebe8616013185e0ecaa36c171b9f52b0d Mon Sep 17 00:00:00 2001 From: perronosaurio <27968093+perronosaurio@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:56:48 +0000 Subject: [PATCH 1/3] Check rank rules by permission level, not by who is asking Several commands skipped rank checks for anything that wasn't a player, such as "you can only give ranks lower than your own" or /sudo. That was fine while only the console could run commands without being in game, but a relay running commands for a Discord user would have bypassed them. They now compare permission levels; the console has an infinite one, so nothing changes for it. createConsoleActor() takes a rank to build such a limited sender, and the staff chat fires a staffChat event. --- lib/level/level.js | 2 +- lib/server.js | 37 ++++++++++++++++++-------------- plugins/core-building/index.js | 6 +++--- plugins/core-essentials/more.js | 2 +- plugins/core-moderation/index.js | 6 +++--- plugins/core-moderation/more.js | 9 +++++--- plugins/zones/index.js | 2 +- 7 files changed, 36 insertions(+), 28 deletions(-) diff --git a/lib/level/level.js b/lib/level/level.js index dc327c6..43109b8 100644 --- a/lib/level/level.js +++ b/lib/level/level.js @@ -163,7 +163,7 @@ class Level { // Build rank or ownership canBuild (player) { - if (player.isConsole || this.isOwner(player)) return true + if (player.permission === Infinity || this.isOwner(player)) return true const ranks = this.server && this.server.ranks return !ranks || player.permission >= ranks.permissionOf(this.buildRank) } diff --git a/lib/server.js b/lib/server.js index 6e551c6..15c660d 100644 --- a/lib/server.js +++ b/lib/server.js @@ -3,7 +3,6 @@ const net = require('net') const fs = require('fs') const path = require('path') -const crypto = require('crypto') const readline = require('readline') const { Connection } = require('./network/connection') @@ -34,7 +33,6 @@ class MCScriptServer { this.version = require('../package.json').version this.log = new Logger({ dir: config.logToFile ? path.join(this.root, 'logs') : null, debug: config.debug, silent: config.silent }) this.events = new EventBus(this.log) - this.salt = randomSalt(16) this.players = [] this.entities = new Map() // non-player entities (bots/NPCs): id -> entity this.customModels = new Map() // name -> { id, model } (CustomModels) @@ -63,7 +61,12 @@ class MCScriptServer { dataDir: path.join(dataDir, 'plugins'), disabled: config.disabledPlugins }) - this.heartbeat = new Heartbeat(this) + // classicube.net first, then any extra lists (betacraft.uk...) from config.extraHeartbeats + this.heartbeats = [new Heartbeat(this, { url: config.heartbeatUrl, primary: true })] + for (const extra of config.extraHeartbeats || []) { + if (extra && extra.url) this.heartbeats.push(new Heartbeat(this, extra)) + } + this.heartbeat = this.heartbeats[0] this.tabList = new TabList(this) this.console = new ConsolePlayer(this) this.CommandError = CommandError @@ -72,15 +75,25 @@ class MCScriptServer { this.generators = generators } - // A command sender with console powers whose messages go to `onMessage` (web panel, relays...) - createConsoleActor (name, onMessage) { + // A command sender without a body in the game whose messages go to `onMessage` (web panel, relays...). + // It has console powers, unless `rank` is given: then it is limited to what that rank can do. + createConsoleActor (name, onMessage, { rank = null, color = '&4' } = {}) { const actor = new ConsolePlayer(this) actor.name = name - actor.displayName = actor.coloredName = `&4${name}` + actor.displayName = actor.coloredName = `${color}${name}` + actor.color = color actor.message = (msg) => onMessage(msg) + if (rank) { + const r = this.ranks.get(rank) || this.ranks.default + Object.defineProperty(actor, 'rank', { value: r }) + actor.permission = r.permission + } return actor } + // Salt of the main server list (classicube.net) + get salt () { return this.heartbeat.salt } + // Persists changes made to server.config (e.g. by commands) to config/server.json saveConfig () { saveConfig(this.config) } @@ -123,7 +136,7 @@ class MCScriptServer { this.events.on('levelSave', ({ level }) => { level.changedSinceBackup = true }) } // the heartbeat also registers the salt that name verification relies on - if (cfg.public || cfg.verifyNames) this.heartbeat.start() + if (cfg.public || cfg.verifyNames) for (const hb of this.heartbeats) hb.start() if (cfg.checkForUpdates) require('./update-check').checkForUpdates(this) this.events.fire('serverStart', { server: this }) return this @@ -137,7 +150,7 @@ class MCScriptServer { for (const p of [...this.players]) p.kick(reason) clearInterval(this._autosave) clearInterval(this._backups) - this.heartbeat.stop() + for (const hb of this.heartbeats) hb.stop() this.plugins.unloadAll() this.levels.saveAll(true) if (this.playerDB.close) this.playerDB.close() @@ -549,12 +562,4 @@ class ConsolePlayer { message (msg) { if (msg) this.server.log.info(msg) } } -function randomSalt (length) { - const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' - const bytes = crypto.randomBytes(length) - let s = '' - for (let i = 0; i < length; i++) s += chars[bytes[i] % chars.length] - return s -} - module.exports = { MCScriptServer, ConsolePlayer, TabList } diff --git a/plugins/core-building/index.js b/plugins/core-building/index.js index 2a5cdd7..8b81b72 100644 --- a/plugins/core-building/index.js +++ b/plugins/core-building/index.js @@ -69,7 +69,7 @@ module.exports = { } function checkVolume (player, volume) { - const limit = player.isConsole ? Infinity : player.rank.drawLimit + const limit = player.permission === Infinity ? Infinity : player.rank.drawLimit if (volume > limit) throw new CommandError(`You tried to change ${volume.toLocaleString()} blocks, your limit is ${Number(limit).toLocaleString()}.`) } @@ -126,7 +126,7 @@ module.exports = { if (!level.inBounds(x, y, z)) continue const old = level.getBlock(x, y, z) if (old === block) continue - if (!player.isConsole && !server.blockPerms.canDelete(player, old)) { denied.add(old); continue } + if (!server.blockPerms.canDelete(player, old)) { denied.add(old); continue } allowed.push(c) } const ev = server.events.fire('drawOperation', { player, level, changes: allowed, name: opName }) @@ -466,7 +466,7 @@ module.exports = { if (!self) { if (player.permission < server.ranks.permissionOf('Operator')) throw new CommandError('Only operators can undo other players.') const record = server.playerDB.get(target) - if (record && !player.isConsole && (server.ranks.get(record.rank) || server.ranks.default).permission >= player.permission) { + if (record && (server.ranks.get(record.rank) || server.ranks.default).permission >= player.permission) { throw new CommandError(`You can't undo ${target}'s changes, their rank is not lower than yours.`) } } diff --git a/plugins/core-essentials/more.js b/plugins/core-essentials/more.js index 99f33f1..67669f1 100644 --- a/plugins/core-essentials/more.js +++ b/plugins/core-essentials/more.js @@ -57,7 +57,7 @@ const EIGHT_BALL = [ module.exports = function more (ctx, { find }) { const { server, text, CommandError } = ctx - const isOp = p => p.isConsole || p.permission >= server.ranks.permissionOf('Operator') + const isOp = p => p.permission >= server.ranks.permissionOf('Operator') // chat emotes: "(heart)" -> ♥ ctx.on('playerChat', (ev) => { diff --git a/plugins/core-moderation/index.js b/plugins/core-moderation/index.js index e00d727..79bec94 100644 --- a/plugins/core-moderation/index.js +++ b/plugins/core-moderation/index.js @@ -70,7 +70,7 @@ module.exports = { const rank = ranks.get(args[1]) if (!rank) throw new CommandError(`Unknown rank. Ranks: ${ranks.all.map(r => r.name).join(', ')}`) checkHigher(player, record) - if (!player.isConsole && rank.permission >= player.permission) throw new CommandError('You can only give ranks lower than your own.') + if (rank.permission >= player.permission) throw new CommandError('You can only give ranks lower than your own.') server.setRank(record.name, rank, actorName(player)) server.broadcast(`&e${record.name} is now ${rank.color}${rank.name}&e.`) } @@ -84,7 +84,7 @@ module.exports = { const i = all.indexOf(rankOf(record)) const rank = all[i + dir] if (!rank) throw new CommandError(`${record.name} can't be ${dir > 0 ? 'promoted' : 'demoted'} any further.`) - if (!player.isConsole && rank.permission >= player.permission) throw new CommandError('You can only give ranks lower than your own.') + if (rank.permission >= player.permission) throw new CommandError('You can only give ranks lower than your own.') server.setRank(record.name, rank, actorName(player)) server.broadcast(`&e${record.name} was ${dir > 0 ? 'promoted' : 'demoted'} to ${rank.color}${rank.name}&e.`) } @@ -248,7 +248,7 @@ module.exports = { run (player, args, { usage }) { if (args.length < 2) return usage() const target = find(player, args[0]) - if (!player.isConsole && target.permission >= player.permission && target !== player) throw new CommandError('You can only use /sudo on lower ranks.') + if (target.permission >= player.permission && target !== player) throw new CommandError('You can only use /sudo on lower ranks.') const line = args.slice(1).join(' ') if (line.startsWith('/')) server.commands.execute(target, line) else target.chat(line) diff --git a/plugins/core-moderation/more.js b/plugins/core-moderation/more.js index 9cb2572..1c0442a 100644 --- a/plugins/core-moderation/more.js +++ b/plugins/core-moderation/more.js @@ -83,7 +83,7 @@ module.exports = function more (ctx, h) { const ms = text.parseDuration(args[2]) if (!rank || !ms) return usage() checkHigher(player, record) - if (!player.isConsole && rank.permission >= player.permission) throw new CommandError('You can only give ranks lower than your own.') + if (rank.permission >= player.permission) throw new CommandError('You can only give ranks lower than your own.') const previous = record.tempRank ? record.tempRank.previous : rankOf(record).name server.setRank(record.name, rank, actorName(player)) record.tempRank = { previous, until: Date.now() + ms, by: actorName(player) } @@ -102,7 +102,7 @@ module.exports = function more (ctx, h) { description: 'Reports a player to the staff', run (player, args, { usage }) { const sub = (args[0] || '').toLowerCase() - const staff = player.isConsole || player.permission >= ranks.permissionOf('Operator') + const staff = player.permission >= ranks.permissionOf('Operator') if (sub === 'list' && staff) { if (!reports.length) return player.message('&eNo reports.') reports.slice(-15).forEach(r => player.message(`&f${r.target}&7 by ${r.by} (${text.formatDuration(Date.now() - r.at)} ago): ${r.reason}`)) @@ -216,8 +216,11 @@ module.exports = function more (ctx, h) { const staffChat = (minRank, label, color) => (sender, message) => { const perm = ranks.permissionOf(minRank) - server.broadcast(`${color}[${label}] ${sender.coloredName || sender.name}${color}: &f${text.sanitize(message)}`, p => p.permission >= perm) + message = text.sanitize(message) + server.broadcast(`${color}[${label}] ${sender.coloredName || sender.name}${color}: &f${message}`, p => p.permission >= perm) if (sender.isConsole !== true && sender.permission < perm) sender.message(`${color}[${label}] (sent)`) + // relays (Discord, IRC) forward this to their staff channel + server.events.fire('staffChat', { sender, message, channel: label.toLowerCase(), rank: minRank }) } const opChat = staffChat('Operator', 'Op', '&c') const adminChat = staffChat('Admin', 'Admin', '&e') diff --git a/plugins/zones/index.js b/plugins/zones/index.js index 71b2529..1ea1c9e 100644 --- a/plugins/zones/index.js +++ b/plugins/zones/index.js @@ -21,7 +21,7 @@ module.exports = { x >= zn.x1 && x <= zn.x2 && y >= zn.y1 && y <= zn.y2 && z >= zn.z1 && z <= zn.z2) const canBuildIn = (player, zone) => { - if (player.isConsole) return true + if (player.permission === Infinity) return true // the console if (zone.owners && zone.owners.includes(player.name.toLowerCase())) return true return player.permission >= server.ranks.permissionOf(zone.rank) } From 9b6ce782be385aafbbe22bae6c48a0ed4a47c54b Mon Sep 17 00:00:00 2001 From: perronosaurio <27968093+perronosaurio@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:56:49 +0000 Subject: [PATCH 2/3] Support BetaCraft and several server lists, test ViaFabricPlus extraHeartbeats announces the server on more lists (BetaCraft) with a salt per list, an optional name suffix to keep accounts from different lists apart, and Mojang session checks for Minecraft accounts, the same way MCGalaxy's authservices work. New tests play ViaFabricPlus (the 9 CPE extensions ViaLegacy implements) and the vanilla Classic 0.30 client and check they never get a packet they don't understand. --- lib/config.js | 2 + lib/heartbeat.js | 75 +++++++++++++++++++++++++++--------- lib/player.js | 34 +++++++++++++--- test/auth.test.js | 84 ++++++++++++++++++++++++++++++++++++++++ test/clients.test.js | 92 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 264 insertions(+), 23 deletions(-) create mode 100644 test/auth.test.js create mode 100644 test/clients.test.js diff --git a/lib/config.js b/lib/config.js index f6be627..c63152a 100644 --- a/lib/config.js +++ b/lib/config.js @@ -14,6 +14,8 @@ const DEFAULTS = { verifyNames: true, heartbeatUrl: 'https://www.classicube.net/server/heartbeat/', heartbeatInterval: 45, + // more server lists, e.g. BetaCraft: [{ "url": "...", "nameSuffix": "", "skinPrefix": "", "mojangAuth": true }] + extraHeartbeats: [], allowWebClient: true, trustProxy: false, mainLevel: 'main', diff --git a/lib/heartbeat.js b/lib/heartbeat.js index 6cd6b19..0738834 100644 --- a/lib/heartbeat.js +++ b/lib/heartbeat.js @@ -1,12 +1,26 @@ 'use strict' -// Announces the server on the ClassiCube server list (classicube.net). +const fs = require('fs') +const path = require('path') +const crypto = require('crypto') + +// Announces the server on a server list (classicube.net, betacraft.uk...). Every list gets its own +// salt, which is what players' mppass is checked against when they log in through that list. class Heartbeat { - constructor (server) { + constructor (server, { url, nameSuffix = '', skinPrefix = '', mojangAuth = false, primary = false } = {}) { this.server = server + this.listUrl = url + this.nameSuffix = nameSuffix + this.skinPrefix = skinPrefix + this.mojangAuth = mojangAuth + this.primary = primary + this.salt = randomSalt(16) this.timer = null - this.url = null + this.url = null // the server's page on that list, once it answered this.lastError = null + let host = url + try { host = new URL(url).hostname.replace(/^(www|api)\./, '') } catch (err) {} + this.label = host } start () { @@ -25,36 +39,44 @@ class Heartbeat { const s = this.server const cfg = s.config return new URLSearchParams({ - name: cfg.name, + name: s.text.stripColors(cfg.name), port: String(s.port), users: String(s.online.filter(p => !p.hidden).length), max: String(cfg.maxPlayers), public: cfg.public ? 'True' : 'False', - salt: s.salt, + salt: this.salt, software: `&bMCScript &f${s.version}`, web: cfg.allowWebClient ? 'True' : 'False', version: '7' }) } + // mppass the list hands out for `name` + mppassFor (name) { + return crypto.createHash('md5').update(this.salt + name).digest('hex') + } + async beat () { const server = this.server - const ev = server.events.fire('heartbeat', { params: this.params() }) + const ev = server.events.fire('heartbeat', { params: this.params(), list: this }) if (ev.cancelled) return try { - const res = await fetch(server.config.heartbeatUrl, { + const res = await fetch(this.listUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: ev.params.toString(), signal: AbortSignal.timeout(15000) }) const body = (await res.text()).trim() - if (body.startsWith('{')) { - const json = JSON.parse(body) - const err = json.errors && json.errors[0] && json.errors[0][0] - if (err && err !== this.lastError) { + if (body.startsWith('{') || !res.ok) { + let err = body.slice(0, 200) || `HTTP ${res.status}` + try { + const json = JSON.parse(body) + err = (json.errors && json.errors[0] && json.errors[0][0]) || json.error || err + } catch (e) {} + if (err !== this.lastError) { this.lastError = err - server.log.warn(`Heartbeat error: ${err}`) + server.log.warn(`Heartbeat to ${this.label} failed: ${err}`) if (/port/i.test(err)) server.log.warn(`Port ${server.port} does not seem to be open. You may need to port forward it.`) } return @@ -62,19 +84,36 @@ class Heartbeat { this.lastError = null if (body && body !== this.url) { this.url = body - server.log.info(`Server list URL: &b${body}`) - try { - require('fs').writeFileSync(require('path').join(server.root, 'data', 'externalurl.txt'), body) - } catch (err) {} + server.log.info(`Server page on ${this.label}: &b${body}`) + if (this.primary) { + try { fs.writeFileSync(path.join(server.root, 'data', 'externalurl.txt'), body) } catch (err) {} + } } } catch (err) { const msg = err.name === 'TimeoutError' ? 'timed out' : err.message if (msg !== this.lastError) { this.lastError = msg - server.log.warn(`Heartbeat failed: ${msg}`) + server.log.warn(`Heartbeat to ${this.label} failed: ${msg}`) } } } } -module.exports = { Heartbeat } +function randomSalt (length) { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' + const bytes = crypto.randomBytes(length) + let out = '' + for (let i = 0; i < length; i++) out += chars[bytes[i] % chars.length] + return out +} + +// Checks a Minecraft account with Mojang's session server. BetaCraft clients call joinServer with +// serverId = sha1(the player's IP address) before connecting (same check as MCGalaxy's mojang-auth). +async function mojangHasJoined (name, ip, { fetchImpl = fetch } = {}) { + const serverId = crypto.createHash('sha1').update(ip).digest('hex') + const url = `https://sessionserver.mojang.com/session/minecraft/hasJoined?username=${encodeURIComponent(name)}&serverId=${serverId}` + const res = await fetchImpl(url, { signal: AbortSignal.timeout(5000) }) + return res.status === 200 +} + +module.exports = { Heartbeat, randomSalt, mojangHasJoined } diff --git a/lib/player.js b/lib/player.js index fb70dcb..31b58e4 100644 --- a/lib/player.js +++ b/lib/player.js @@ -1,12 +1,12 @@ 'use strict' const zlib = require('zlib') -const crypto = require('crypto') const { EventEmitter } = require('events') const { EXTENSIONS } = require('./protocol/packets') const Blocks = require('./blocks') const text = require('./util/text') const { ENV_COLORS, ENV_PROPS } = require('./level/level') +const { mojangHasJoined } = require('./heartbeat') const MESSAGE_TYPES = { chat: 0, @@ -113,13 +113,19 @@ class Player extends EventEmitter { if (!NAME_RE.test(packet.username) || RESERVED_NAMES.has(packet.username.toLowerCase())) return this.kick('Invalid username') this.name = packet.username + // skins are looked up by the account name, before any suffix is added + this.skinName = this.name if (cfg.verifyNames && !this._isLocalIp()) { - const expected = crypto.createHash('md5').update(server.salt + this.name).digest('hex') - const key = packet.key.toLowerCase().replace(/^0+/, '') - if (expected.replace(/^0+/, '') !== key) { + const list = await this._verify(packet.key) + if (this.conn.closed) return + if (!list) { server.log.info(`${this.name} (${this.ip}) failed name verification`) return this.kick('Login failed! Close the game and sign in again.') } + this.verifiedVia = list.label + // a suffix keeps accounts from different lists apart (e.g. "Notch" on BetaCraft vs ClassiCube) + if (list.nameSuffix) this.name += list.nameSuffix + this.skinName = list.skinPrefix + this.skinName } const connecting = server.events.fire('playerConnecting', { name: this.name, ip: this.ip, player: this }) @@ -174,6 +180,24 @@ class Player extends EventEmitter { server._onPlayerJoin(this) } + // Returns the server list (heartbeat) that vouches for this player's name, or null + async _verify (mppass) { + const key = String(mppass || '').toLowerCase().replace(/^0+/, '') + const lists = this.server.heartbeats + for (const list of lists) { + if (key && list.mppassFor(this.name).replace(/^0+/, '') === key) return list + } + // BetaCraft players log in with a Minecraft account instead of an mppass + const mojangList = lists.find(l => l.mojangAuth) + if (!mojangList) return null + try { + if (await mojangHasJoined(this.name, this.ip)) return mojangList + } catch (err) { + this.server.log.warn(`Could not check ${this.name} with Mojang: ${err.message}`) + } + return null + } + // Connections from this machine (without a proxy in between) skip name verification _isLocalIp () { const ip = this.conn.socketIp @@ -501,7 +525,7 @@ class Player extends EventEmitter { id: this.id, name: this.displayName, listName: this.name, - skin: r.skin || this.name, + skin: r.skin || this.skinName || this.name, model: this.currentModel || r.model || 'humanoid', scale: this.data.modelScale || null, rotation: this.data.entityRotation || null, diff --git a/test/auth.test.js b/test/auth.test.js new file mode 100644 index 0000000..bcd4fbc --- /dev/null +++ b/test/auth.test.js @@ -0,0 +1,84 @@ +'use strict' + +const test = require('node:test') +const assert = require('node:assert/strict') +const http = require('http') +const crypto = require('crypto') + +const { startServer, sleep } = require('./helpers/server') +const { TestClient } = require('./helpers/client') +const { Player } = require('../lib/player') + +const md5 = s => crypto.createHash('md5').update(s).digest('hex') + +// Stand-in for classicube.net and betacraft.uk: remembers the salt each heartbeat sends +function fakeList () { + const salts = {} + const srv = http.createServer((req, res) => { + let body = '' + req.on('data', c => { body += c }) + req.on('end', () => { + salts[req.url] = new URLSearchParams(body).get('salt') + res.end(`http://list.example${req.url}/play`) + }) + }) + return new Promise(resolve => srv.listen(0, '127.0.0.1', () => resolve({ srv, salts, base: `http://127.0.0.1:${srv.address().port}` }))) +} + +async function login (server, name, key) { + const c = new TestClient({ port: server.port, name, key, cpe: false }) + await c.connect() + const p = await c.waitFor(p => p.name === 'levelFinalize' || p.name === 'disconnect', 8000) + c.close() + return p.name === 'disconnect' ? { kicked: p.reason } : { ok: true } +} + +test('name verification against several server lists and Mojang sessions', async (t) => { + const list = await fakeList() + t.after(() => list.srv.close()) + + // tests connect from 127.0.0.1, which normally skips verification + const isLocal = Player.prototype._isLocalIp + Player.prototype._isLocalIp = () => false + t.after(() => { Player.prototype._isLocalIp = isLocal }) + + // pretend to be sessionserver.mojang.com: only Carl joined, from 127.0.0.1 + const realFetch = globalThis.fetch + const mojangCalls = [] + globalThis.fetch = async (url, opts) => { + if (String(url).startsWith('https://sessionserver.mojang.com/')) { + const u = new URL(url) + mojangCalls.push(u.searchParams.get('username')) + const ok = u.searchParams.get('username') === 'Carl' && u.searchParams.get('serverId') === crypto.createHash('sha1').update('127.0.0.1').digest('hex') + return new Response(ok ? '{"id":"x","name":"Carl"}' : '', { status: ok ? 200 : 204 }) + } + return realFetch(url, opts) + } + t.after(() => { globalThis.fetch = realFetch }) + + const { server } = await startServer({ + verifyNames: true, + heartbeatUrl: `${list.base}/classicube`, + extraHeartbeats: [{ url: `${list.base}/betacraft`, nameSuffix: '+', mojangAuth: true }] + }) + t.after(() => server.stop()) + for (let i = 0; i < 50 && Object.keys(list.salts).length < 2; i++) await sleep(50) + + const cc = list.salts['/classicube'] + const bc = list.salts['/betacraft'] + assert.ok(cc && bc && cc !== bc, 'each list gets its own salt') + assert.equal(server.heartbeats[1].url, 'http://list.example/betacraft/play') + + assert.deepEqual(await login(server, 'Alice', md5(cc + 'Alice')), { ok: true }) + assert.ok(server.playerDB.get('Alice')) + + assert.deepEqual(await login(server, 'Bob', md5(bc + 'Bob')), { ok: true }) + assert.ok(server.playerDB.get('Bob+'), 'BetaCraft accounts get the suffix') + assert.equal(server.playerDB.get('Bob'), null) + + assert.deepEqual(await login(server, 'Carl', ''), { ok: true }) + assert.ok(server.playerDB.get('Carl+')) + + assert.match((await login(server, 'Dave', 'wrong')).kicked, /Login failed/) + assert.deepEqual(mojangCalls, ['Carl', 'Dave']) +}) diff --git a/test/clients.test.js b/test/clients.test.js new file mode 100644 index 0000000..4b4682e --- /dev/null +++ b/test/clients.test.js @@ -0,0 +1,92 @@ +'use strict' + +// Compatibility with clients other than ClassiCube. They must never receive a packet from an +// extension they did not ask for, or they disconnect with "unknown packet". + +const test = require('node:test') +const assert = require('node:assert/strict') + +const { startServer, sleep } = require('./helpers/server') +const { TestClient } = require('./helpers/client') + +const CLASSIC_PACKETS = ['serverIdentification', 'ping', 'levelInitialize', 'levelDataChunk', 'levelFinalize', 'setBlock', + 'spawnPlayer', 'teleport', 'posAndOrientUpdate', 'posUpdate', 'orientUpdate', 'despawnPlayer', 'message', 'disconnect', 'updateUserType'] + +// ViaLegacy (used by ViaFabricPlus) only implements these CPE extensions, see +// ClassicProtocolExtension.java in https://github.com/ViaVersion/ViaLegacy +const VIALEGACY_EXTENSIONS = [['CustomBlocks', 1], ['BlockPermissions', 1], ['HackControl', 1], ['EmoteFix', 1], ['LongerMessages', 1], + ['FullCP437', 1], ['BulkBlockUpdate', 1], ['TwoWayPing', 1], ['InstantMOTD', 1]] +const VIALEGACY_PACKETS = [...CLASSIC_PACKETS, 'extInfo', 'extEntry', 'customBlockSupportLevel', 'hackControl', 'setBlockPermission', + 'bulkBlockUpdate', 'twoWayPing'] + +async function join (server, name, opts) { + const c = new TestClient({ port: server.port, name, ...opts }) + await c.connect() + await c.waitFor('levelFinalize') + await c.waitFor(p => p.name === 'spawnPlayer' && p.id === -1) + return c +} + +async function command (client, cmd) { + client.chat(cmd) + await sleep(150) +} + +// Runs through features that normally use CPE packets +async function exercise (server, owner, other) { + const commands = [ + '/newlvl second 64 32 64 flat', '/goto second', '/env sky ff0000', '/weather rain', '/texture https://example.com/t.zip', + '/model zombie', '/skin Notch', '/reach 10', '/fly', '/hold 20', '/nick Tester', '/color c', '/title Boss', + '/gb create 70 Glowstone', '/effect sparkle', '/cinematic on', '/blocklist open', + '/bot add Guide', '/zone add test', '/goto main', '/tp Other', '/afk', '/me waves', '/announce Hello', '/say hi', + '/msg Other hi', '/modelscale 2', '/entityrot 0 0 45' + ] + for (const cmd of commands) await command(owner, cmd) + // place and draw so block changes go out to both clients + owner.send('setBlock', { x: 5, y: 16, z: 5, mode: 1, block: 1 }) + await command(owner, '/cuboid 1') + owner.send('setBlock', { x: 2, y: 20, z: 2, mode: 1, block: 1 }) + owner.send('setBlock', { x: 4, y: 22, z: 4, mode: 1, block: 1 }) + await sleep(300) + server.levels.main.setBlock(10, 20, 10, 70) + server.levels.main.setBlock(11, 20, 11, 60) + other.chat('hello from the other side') + await sleep(500) +} + +function unexpected (client, allowed) { + return [...new Set(client.received.map(p => p.name))].filter(n => !allowed.includes(n)) +} + +test('ViaFabricPlus (ViaLegacy CPE) only gets packets it understands', async (t) => { + const { server } = await startServer({ owners: ['Owner'] }) + t.after(() => server.stop()) + const owner = await join(server, 'Owner', { extensions: VIALEGACY_EXTENSIONS }) + const other = await join(server, 'Other', { extensions: VIALEGACY_EXTENSIONS }) + assert.deepEqual([...server.findPlayerExact('Owner').extensions.keys()].sort(), VIALEGACY_EXTENSIONS.map(e => e[0]).sort()) + + await exercise(server, owner, other) + + assert.deepEqual(unexpected(owner, VIALEGACY_PACKETS), []) + assert.deepEqual(unexpected(other, VIALEGACY_PACKETS), []) + assert.ok(!owner.received.some(p => p.name === 'disconnect')) + // custom block 70 reaches it as its fallback, CPE block 60 as itself (CustomBlocks level 1) + const ids = owner.received.filter(p => p.name === 'setBlock').map(p => p.block) + assert.ok(!ids.some(b => b > 65), 'no block ids above the CPE range') + assert.ok(ids.includes(60)) +}) + +test('vanilla Classic 0.30 (BetaCraft) only gets classic packets', async (t) => { + const { server } = await startServer({ owners: ['Owner'] }) + t.after(() => server.stop()) + const owner = await join(server, 'Owner', { cpe: false }) + const other = await join(server, 'Other', { cpe: false }) + + await exercise(server, owner, other) + + assert.deepEqual(unexpected(owner, CLASSIC_PACKETS), []) + assert.deepEqual(unexpected(other, CLASSIC_PACKETS), []) + assert.ok(!owner.received.some(p => p.name === 'disconnect')) + const ids = owner.received.filter(p => p.name === 'setBlock').map(p => p.block) + assert.ok(!ids.some(b => b > 49), 'no block ids above the classic range') +}) From f4324189f855586b3139b0850e52161a3bc4268e Mon Sep 17 00:00:00 2001 From: perronosaurio <27968093+perronosaurio@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:56:49 +0000 Subject: [PATCH 3/3] Turn the Discord relay into a bot like MCGalaxy's Chat channels and staff channels (linked to /opchat), !command with ranks mapped from Discord roles and users, !players/.who, a status with the player count and /discord for the invite link. The gateway client resumes dropped sessions, notices dead connections and explains fatal errors such as a missing Message Content intent. Setup guide in docs/DISCORD.md. --- CHANGELOG.md | 16 ++ README.md | 7 +- docs/CONFIGURATION.md | 37 ++++ docs/DISCORD.md | 100 +++++++++ docs/PLUGINS.md | 7 +- plugins/relay-discord/gateway.js | 159 +++++++++++++ plugins/relay-discord/index.js | 370 +++++++++++++++++++++---------- test/discord.test.js | 172 ++++++++++++++ 8 files changed, 754 insertions(+), 114 deletions(-) create mode 100644 docs/DISCORD.md create mode 100644 plugins/relay-discord/gateway.js create mode 100644 test/discord.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 8230fd0..cea9094 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ ## Unreleased +### Added +- The Discord bot now works like MCGalaxy's: chat channels, staff channels linked to `/opchat`, and + `!command` from Discord with ranks given by Discord roles or users (`roleRanks`, `userRanks`). + `!players` / `.who` list who is online, the bot shows the player count as its status, and `/discord` shows + an invite link. Setup guide in `docs/DISCORD.md`. +- `extraHeartbeats`: announce the server on more lists at once (BetaCraft), each with its own salt, an + optional name suffix and Mojang session checks for Minecraft accounts. +- Tests that play ViaFabricPlus (ViaLegacy) and the vanilla Classic 0.30 client and check they only get + packets they understand. +- `staffChat` event, and `createConsoleActor` can take a rank. + +### Fixed +- Several commands skipped rank checks for anything that was not a player (for example "only give ranks + lower than your own"). They now compare permission levels, so actions from Discord or other relays follow + the rank rules. The real console is unaffected. + ## 2.0.0 MCScript is back after seven years. The server was rewritten from scratch; none of the 1.x code (`src/`, diff --git a/README.md b/README.md index 5f4ac32..46a40cf 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,10 @@ several levels at once, supports custom blocks and texture packs, and keeps most [MCGalaxy](https://github.com/ClassiCube/MCGalaxy). It has no runtime dependencies, only Node.js. Both the desktop [ClassiCube](https://github.com/ClassiCube/ClassiCube) -client and the browser client work; the browser client connects over WebSocket on the same port. +client and the browser client work; the browser client connects over WebSocket on the same port. Players can +also join with [ViaFabricPlus](https://github.com/ViaVersion/ViaFabricPlus) (modern Minecraft Java) or the +original Classic 0.30 client from [BetaCraft](https://betacraft.uk), and the server can be listed on both +classicube.net and BetaCraft. - [Requirements](#requirements) - [Installing](#installing) @@ -185,7 +188,7 @@ anti-grief. | custom-models | `/cmodel` (example 3D models plus your own JSON ones, used with `/model`) | | announcer | `/announcer` | | relay-irc | IRC chat bridge (off by default) | -| relay-discord | Discord chat bridge, webhook or bot (off by default) | +| relay-discord | Discord bot: chat channel, staff channel and `!commands`, like MCGalaxy's ([guide](docs/DISCORD.md), off by default) | | web-panel | admin panel in the browser (off by default) | | example | a commented example plugin | diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index b04b9b9..0330bcc 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -18,6 +18,7 @@ write its own copy over your changes on shutdown. | `verifyNames` | `true` | Check with classicube.net that players are who they say they are | | `heartbeatUrl` | classicube.net | Where heartbeats are sent | | `heartbeatInterval` | `45` | Seconds between heartbeats | +| `extraHeartbeats` | `[]` | More server lists to announce the server on, such as BetaCraft. See below | | `allowWebClient` | `true` | Accept the browser client (WebSocket on the same port) | | `trustProxy` | `false` | Use the `X-Forwarded-For` header as the player IP. Only turn this on behind your own reverse proxy | | `mainLevel` | `main` | Level players spawn in | @@ -67,6 +68,42 @@ Connections from `127.0.0.1` skip the check, so you can always join your own ser the IP address by hand, without going through classicube.net, are refused. Turn `verifyNames` off only for private LAN games. With it off, anyone can join as anyone, including as one of the `owners`. +## BetaCraft and other server lists + +The server can be listed on more than one server list at the same time, like MCGalaxy. Add them to +`extraHeartbeats`. For [BetaCraft](https://betacraft.uk), which lets people play with the original Minecraft +Classic 0.30 client and a Minecraft account: + +```json +"extraHeartbeats": [ + { "url": "", "nameSuffix": "+", "mojangAuth": true } +] +``` + +Get the heartbeat address from betacraft.uk. Each entry takes: + +- `url`: where to send the heartbeat. Every list gets its own salt, and a player is accepted if their key + matches any of them. +- `nameSuffix`: added to the name of everyone who logs in through that list. `Notch` on ClassiCube and `Notch` + on BetaCraft are different people; with `"+"` the BetaCraft one becomes `Notch+`, so they never share ranks, + bans or data. Use it whenever you have more than one list. +- `skinPrefix`: put in front of the skin name of those players, if their skins come from somewhere else. +- `mojangAuth`: also accept players that Mojang's session server vouches for. BetaCraft's launcher signs in + with a Minecraft account and tells Mojang it is joining before it connects; this is how the server checks it. + +Owners and ranks refer to the full name, so a BetaCraft owner goes in `owners` as `Name+`. + +## Clients + +- **ClassiCube** (desktop, mobile and browser) gets everything. +- **ViaFabricPlus** (a mod that lets modern Minecraft Java join classic servers) understands only a few CPE + extensions. The server notices and falls back automatically: custom blocks become their fallback block, + and particles, models, environment colors and similar features are simply not sent. +- **Minecraft Classic 0.30** (for example from the BetaCraft launcher) works without any extensions. + +Older Classic versions (before 0.30) use a different protocol and can't join. With ViaFabricPlus, choose the +Classic 0.30 version with CPE in its version list. + ## Ranks (config/ranks.json) Each rank has a numeric `permission`. Commands, blocks and levels ask for a minimum rank, and anyone with that diff --git a/docs/DISCORD.md b/docs/DISCORD.md new file mode 100644 index 0000000..9361a4b --- /dev/null +++ b/docs/DISCORD.md @@ -0,0 +1,100 @@ +# Discord bot + +The `relay-discord` plugin connects the server to a Discord server, the same way MCGalaxy's Discord bot does: + +- **Chat channels.** In-game chat, joins and leaves are posted there, and whatever people write there shows up + in the game as `[Discord] name: message`. +- **Staff channels.** Linked to the in-game staff chat: `/opchat` and `#message` from the game go there, and + messages written there reach operators in the game. +- **Commands.** `!command args` runs a server command from any of those channels, and the output comes back + as a message. `!players` (also `.who` and `.players`, as in MCGalaxy) lists who is online. + +## 1. Create the bot + +1. Go to the [Discord Developer Portal](https://discord.com/developers/applications) and click **New Application**. +2. Open **Bot**. Click **Reset Token** and copy the token; this is `botToken`. Keep it secret: anyone with it + controls the bot. +3. On the same page, under **Privileged Gateway Intents**, turn on **Message Content Intent** and save. Without + it the bot can't read messages and the console says so. +4. Open **OAuth2 → URL Generator**, tick the `bot` scope and these permissions: *View Channels*, + *Send Messages* and *Read Message History*. Open the generated link and add the bot to your Discord server. + +## 2. Get the IDs + +In Discord, open **User Settings → Advanced** and turn on **Developer Mode**. Now you can right-click a +channel, a role or a user and choose **Copy ID**. + +## 3. Configure the plugin + +Edit `config/plugins/relay-discord.json` (it is created the first time the server starts): + +```json +{ + "enabled": true, + "botToken": "paste the token here", + "chatChannelIds": ["123456789012345678"], + "staffChannelIds": ["234567890123456789"], + "roleRanks": { + "345678901234567890": "Operator", + "456789012345678901": "Admin" + }, + "inviteUrl": "https://discord.gg/yourinvite" +} +``` + +Then run `/preload relay-discord` (or restart). The console shows `Connected to Discord as ...` when it works. + +| Setting | Default | What it does | +| --- | --- | --- | +| `enabled` | `false` | Turns the bot on | +| `botToken` | | The bot token from the Developer Portal | +| `chatChannelIds` | `[]` | Channels linked to the public chat | +| `staffChannelIds` | `[]` | Channels linked to the staff chat (operators and up) | +| `commandPrefix` | `!` | What commands start with | +| `publicCommands` | players, serverinfo, rules, levels, whois, top, baltop, faq, news | Commands anyone can use from a chat or staff channel | +| `staffRank` | `Operator` | Rank used for commands typed in a staff channel | +| `roleRanks` | `{}` | Discord role ID → server rank. Members with that role can use that rank's commands from any linked channel | +| `userRanks` | `{}` | Discord user ID → server rank, for single people (the owner, for example) | +| `bannedCommands` | pinstall, puninstall, pcreate | Commands that can never be run from Discord | +| `ignoredUserIds` | `[]` | Discord users whose messages are ignored | +| `useNicknames` | `true` | Show server nicknames instead of Discord usernames | +| `relayJoins` | `true` | Post joins and leaves | +| `relayStaffChat` | `true` | Post the in-game staff chat to the staff channels | +| `status` | `with {players}/{max} players` | The bot's "Playing ..." status. Empty to turn it off | +| `inviteUrl` | | Adds `/discord`, which shows this link in the game | +| `discordPrefix` | `&9[Discord] ` | How Discord messages are shown in the game | +| `webhookUrl` | | Sends chat through a webhook instead of a bot (one way only, no commands) | + +## Who can run what + +A command from Discord runs with a server rank, exactly as if a player with that rank typed it in the game: + +1. The highest rank from `userRanks` and `roleRanks` for that person. +2. In a staff channel, at least `staffRank`. Make sure only staff can read and write in that channel. +3. Anyone else can only use `publicCommands`, with the default rank. + +Rank rules still apply, so an Operator on Discord can't give someone Admin or `/sudo` an Owner. Commands that +need to be in the game (`/tp`, `/cuboid`...) answer that they can only be used in-game. Every command run from +Discord is written to the server log with the name of the person who ran it. + +## Examples + +``` +!players who is online +!rules the server rules +!kick Griefer spamming kick a player (needs Operator) +!ban Griefer 1d grief one-day ban +!mute Loud 10m ten-minute mute +!say Restart in 5 min announce in the game +!save all save every level +``` + +## Troubleshooting + +- **"the Message Content intent is not enabled"**: turn it on in the Developer Portal (step 1.3) and + `/preload relay-discord`. +- **"the bot token is wrong"**: copy the token again (Reset Token gives you a new one). +- **Nothing is posted**: check that the bot can see the channel and send messages there, and that the IDs + are channel IDs (not server IDs). +- **Commands answer "You don't have permission"**: the person needs a role listed in `roleRanks`, or has to + write in a staff channel. diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index a54e50b..f5e975b 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -100,6 +100,7 @@ Priorities run in this order: `critical`, `high`, `normal`, `low`, `monitor`. On | `explosion` | `level, x, y, z, radius` (physics plugin) | | | `pluginMessage` | `player, channel, data` (64 byte Buffer, CPE PluginMessages) | | | `notifyAction` | `player, action, value` or `position` (CPE NotifyAction: `blockListSelected`, `levelSaved`, `thirdPersonChanged`...) | | +| `staffChat` | `sender, message, channel` (`op` or `admin`), `rank` (the Discord bot forwards it) | | | `pluginLoad` / `pluginUnload` | `plugin` | | | `serverStart` / `serverStop` | `server` | | @@ -122,7 +123,11 @@ Priorities run in this order: `critical`, `high`, `normal`, `low`, `monitor`. On `plugins/custom-models/index.js`) and `server.removeModel(name)`. - Particles: `server.defineParticle(name, { tint, count, size, speed, gravity, lifetime, ... })` and `server.spawnParticles(level, name, x, y, z)`. -- `server.createConsoleActor(name, onMessage)`: runs commands with console rights and captures what they print. +- `server.createConsoleActor(name, onMessage, { rank })`: something that runs commands without being in the game + and captures what they print. Without `rank` it has console rights; with a rank it can do exactly what that + rank can (the Discord bot uses this). +- `server.heartbeats`: the server lists it announces itself on (`label`, `url`, `nameSuffix`...); + `player.verifiedVia` says which one vouched for a player. - `server.log.subscribe(fn)`: receives every log line. - `server.config` and `server.saveConfig()`. diff --git a/plugins/relay-discord/gateway.js b/plugins/relay-discord/gateway.js new file mode 100644 index 0000000..78a7da1 --- /dev/null +++ b/plugins/relay-discord/gateway.js @@ -0,0 +1,159 @@ +'use strict' + +const { EventEmitter } = require('events') + +const GATEWAY = 'wss://gateway.discord.gg/?v=10&encoding=json' +// GUILDS, GUILD_MESSAGES and MESSAGE_CONTENT (the last one must be switched on in the Developer Portal) +const INTENTS = (1 << 0) | (1 << 9) | (1 << 15) + +// Close codes after which reconnecting is pointless +const FATAL = { + 4004: 'the bot token is wrong', + 4010: 'invalid shard', + 4011: 'the bot is in too many servers and needs sharding', + 4012: 'invalid API version', + 4013: 'invalid intents', + 4014: 'the Message Content intent is not enabled. Turn it on in the Discord Developer Portal (Bot > Privileged Gateway Intents)' +} + +// Minimal Discord gateway client: identify, heartbeat, resume and presence. Emits 'ready', 'message' (MESSAGE_CREATE data), +// 'fatal' (reason) and 'log' (level, text). +class Gateway extends EventEmitter { + constructor ({ token, url = GATEWAY, WebSocketImpl = globalThis.WebSocket, presence = null }) { + super() + this.token = token + this.url = url + this.WebSocket = WebSocketImpl + this.presence = presence + this.ws = null + this.seq = null + this.sessionId = null + this.resumeUrl = null + this.heartbeat = null + this.acked = true + this.stopped = false + this.user = null + this.retryDelay = 5000 + } + + connect (resume = false) { + if (this.stopped) return + const url = resume && this.resumeUrl ? `${this.resumeUrl}/?v=10&encoding=json` : this.url + let ws + try { + ws = this.ws = new this.WebSocket(url) + } catch (err) { + this.emit('log', 'warn', `Could not connect to Discord: ${err.message}`) + return this._onClose({ code: 0 }) + } + ws.onmessage = (event) => { + let msg + try { msg = JSON.parse(event.data) } catch (err) { return } + this._onPacket(msg, resume) + } + ws.onclose = (event) => this._onClose(event) + ws.onerror = () => {} // onclose follows and handles it + } + + send (op, d) { + if (this.ws && this.ws.readyState === 1) this.ws.send(JSON.stringify({ op, d })) + } + + setPresence (presence) { + this.presence = presence + this.send(3, presence) + } + + stop () { + this.stopped = true + clearInterval(this.heartbeat) + clearTimeout(this.firstBeat) + if (this.ws) try { this.ws.close(1000) } catch (err) {} + } + + _onPacket (msg, resume) { + if (msg.s !== null && msg.s !== undefined) this.seq = msg.s + switch (msg.op) { + case 10: { // hello + const interval = msg.d.heartbeat_interval + clearInterval(this.heartbeat) + clearTimeout(this.firstBeat) + this.acked = true + const beat = () => { + // no ack since the last beat: the connection is dead even if the socket looks open + if (!this.acked) return this._reconnect(true) + this.acked = false + this.send(1, this.seq) + } + this.firstBeat = later(() => { beat(); this.heartbeat = setInterval(beat, interval).unref() }, interval * Math.random()) + if (resume && this.sessionId) { + this.send(6, { token: this.token, session_id: this.sessionId, seq: this.seq }) + } else { + this.send(2, { + token: this.token, + intents: INTENTS, + properties: { os: process.platform, browser: 'mcscript', device: 'mcscript' }, + presence: this.presence || undefined + }) + } + break + } + case 11: this.acked = true; break // heartbeat ack + case 1: this.send(1, this.seq); break // heartbeat requested + case 7: this._reconnect(true); break // reconnect requested + case 9: // invalid session: resumable only if d is true + if (!msg.d) { this.sessionId = null; this.seq = null } + later(() => this._reconnect(!!msg.d), 1000 + Math.random() * 4000) + break + case 0: + if (msg.t === 'READY') { + this.sessionId = msg.d.session_id + this.resumeUrl = msg.d.resume_gateway_url + this.user = msg.d.user + this.retryDelay = 5000 + this.emit('ready', msg.d.user) + } else if (msg.t === 'RESUMED') { + this.retryDelay = 5000 + this.emit('log', 'debug', 'Discord session resumed') + } else if (msg.t === 'MESSAGE_CREATE') { + this.emit('message', msg.d) + } + break + } + } + + _reconnect (resume) { + const old = this.ws + this.ws = null + if (old) { + old.onclose = null + try { old.close(4000) } catch (err) {} + } + clearInterval(this.heartbeat) + clearTimeout(this.firstBeat) + this.connect(resume) + } + + _onClose (event) { + clearInterval(this.heartbeat) + clearTimeout(this.firstBeat) + if (this.stopped) return + const code = event && event.code + if (FATAL[code]) { + this.stopped = true + return this.emit('fatal', `Discord closed the connection: ${FATAL[code]} (code ${code})`) + } + this.emit('log', 'warn', `Discord connection closed (code ${code}), reconnecting in ${this.retryDelay / 1000}s`) + later(() => this.connect(!!this.sessionId), this.retryDelay) + this.retryDelay = Math.min(this.retryDelay * 2, 120000) + } +} + +// timers that don't keep the process alive on shutdown +function later (fn, ms) { + const t = setTimeout(fn, ms) + if (t.unref) t.unref() + return t +} + +module.exports = { Gateway, INTENTS } diff --git a/plugins/relay-discord/index.js b/plugins/relay-discord/index.js index 8694117..136faea 100644 --- a/plugins/relay-discord/index.js +++ b/plugins/relay-discord/index.js @@ -1,162 +1,310 @@ 'use strict' -/* global WebSocket */ - -// Two-way chat bridge with a Discord channel. -// - Outgoing only: set "webhookUrl" (Channel settings > Integrations > Webhooks). -// - Two-way: set "botToken" and "channelId" (needs the "Message Content" intent enabled for the bot). +// Discord bot, along the lines of MCGalaxy's: +// - chat channels: in-game chat goes there and messages from there show up in game +// - staff channels: linked to the in-game staff chat (/opchat, #message) +// - commands: "!command args" runs a server command. Everyone can use the ones in publicCommands; +// other commands need a Discord role or user that is mapped to a server rank (roleRanks, userRanks), +// or being in a staff channel (staffRank). +// - "!players" (also .who and .players, like MCGalaxy) lists who is online. +// Needs a bot with the "Message Content" intent. See docs/DISCORD.md. // Disabled by default: set "enabled": true in config/plugins/relay-discord.json and /preload relay-discord. +const { Gateway } = require('./gateway') + const API = 'https://discord.com/api/v10' -const GATEWAY = 'wss://gateway.discord.gg/?v=10&encoding=json' -const INTENTS = (1 << 0) | (1 << 9) | (1 << 15) // GUILDS, GUILD_MESSAGES, MESSAGE_CONTENT +const MAX_MESSAGE = 1900 module.exports = { name: 'relay-discord', - version: '1.0.0', - description: 'Relays chat to and from Discord', + version: '2.0.0', + description: 'Discord bot: chat bridge, staff channel and commands', author: 'MCScript', defaultConfig: { enabled: false, - webhookUrl: '', botToken: '', - channelId: '', - discordPrefix: '&9[Discord] ', + chatChannelIds: [], + staffChannelIds: [], + commandPrefix: '!', + publicCommands: ['players', 'serverinfo', 'rules', 'levels', 'whois', 'top', 'baltop', 'faq', 'news'], + staffRank: 'Operator', + roleRanks: {}, + userRanks: {}, + bannedCommands: ['pinstall', 'puninstall', 'pcreate'], + ignoredUserIds: [], + useNicknames: true, relayJoins: true, - avatarUrl: 'https://cdn.classicube.net/face/{name}.png' + relayStaffChat: true, + status: 'with {players}/{max} players', + inviteUrl: '', + discordPrefix: '&9[Discord] ', + webhookUrl: '' }, load (ctx) { const { server, config, log, text } = ctx + const ranks = server.ranks + + if (config.inviteUrl) { + ctx.command({ + name: 'discord', + category: 'other', + usage: '/discord', + description: 'Shows the link to our Discord server', + run (player) { player.message(`&eJoin us on Discord: &f${config.inviteUrl}`) } + }) + } + if (!config.enabled) { log.info('Disabled. Edit config/plugins/relay-discord.json to enable it.') return } - if (!config.webhookUrl && !(config.botToken && config.channelId)) { - log.warn('Set webhookUrl, or botToken + channelId, in config/plugins/relay-discord.json') + // 1.x config had a single channelId + const chatChannels = [...new Set([...(config.chatChannelIds || []), config.channelId].filter(Boolean).map(String))] + const staffChannels = (config.staffChannelIds || []).map(String) + if (!config.botToken && !config.webhookUrl) { + log.warn('Set botToken (and chatChannelIds) in config/plugins/relay-discord.json') return } - const clean = msg => text.stripColors(msg).replace(/([*_~`|>\\])/g, '\\$1').slice(0, 1900) - const queue = [] - let sending = false + // outgoing + + // Lines are grouped per channel and sent together, which keeps well under Discord's rate limits + const pending = new Map() // channelId -> [lines] + let flushing = false + let flushTimer = null + + const post = async (url, body, webhook = false) => { + for (let attempt = 0; attempt < 3; attempt++) { + const headers = { 'Content-Type': 'application/json' } + if (!webhook) headers.Authorization = `Bot ${config.botToken}` + const res = await fetch(url, { method: 'POST', headers, body: JSON.stringify({ ...body, allowed_mentions: { parse: [] } }) }) + if (res.status !== 429) { + if (!res.ok) log.warn(`Discord answered ${res.status}: ${(await res.text()).slice(0, 200)}`) + return + } + const info = await res.json().catch(() => ({})) + await new Promise(resolve => setTimeout(resolve, (info.retry_after || 1) * 1000)) + } + } - // Messages are sent one at a time to respect Discord's rate limits const flush = async () => { - if (sending) return - sending = true - while (queue.length) { - const { name, content } = queue.shift() - try { - let res - if (config.webhookUrl) { - res = await fetch(config.webhookUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - username: name || server.config.name.slice(0, 32), - avatar_url: name ? config.avatarUrl.replace('{name}', encodeURIComponent(name)) : undefined, - content, - allowed_mentions: { parse: [] } - }) - }) - } else { - res = await fetch(`${API}/channels/${config.channelId}/messages`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', Authorization: `Bot ${config.botToken}` }, - body: JSON.stringify({ content: name ? `**${name}**: ${content}` : content, allowed_mentions: { parse: [] } }) - }) - } - if (res.status === 429) { - const retry = Number(res.headers.get('retry-after') || 1) - queue.unshift({ name, content }) - await new Promise(resolve => setTimeout(resolve, retry * 1000)) - } else if (!res.ok) { - log.warn(`Discord responded ${res.status}: ${(await res.text()).slice(0, 200)}`) + flushTimer = null + if (flushing) return + flushing = true + try { + for (const [channel, lines] of pending) { + pending.delete(channel) + for (const chunk of chunks(lines)) { + if (channel === 'webhook') await post(config.webhookUrl, { content: chunk, username: text.stripColors(server.config.name).slice(0, 32) }, true) + else await post(`${API}/channels/${channel}/messages`, { content: chunk }) } - } catch (err) { - log.warn(`Could not send to Discord: ${err.message}`) } + } catch (err) { + log.warn(`Could not send to Discord: ${err.message}`) + } finally { + flushing = false + if (pending.size) schedule() } - sending = false } + const schedule = () => { if (!flushTimer) flushTimer = ctx.setTimeout(flush, 300) } - const send = (name, content) => { - if (queue.length > 50) queue.shift() - queue.push({ name, content }) - flush() + const sendTo = (channels, line) => { + for (const channel of channels) { + const lines = pending.get(channel) || [] + if (lines.length < 100) lines.push(line) + pending.set(channel, lines) + } + schedule() } + const toChat = line => sendTo(config.botToken ? chatChannels : ['webhook'], line) + const toStaff = line => { if (config.botToken) sendTo(staffChannels, line) } + + const clean = msg => escapeMarkdown(text.stripColors(String(msg))).slice(0, 500) + const countLine = () => `(${server.online.filter(p => !p.hidden).length}/${server.config.maxPlayers})` - ctx.on('playerChat', (ev) => { if (!ev.cancelled) send(ev.player.name, clean(ev.message)) }, { priority: 'monitor' }) + ctx.on('playerChat', (ev) => { + if (!ev.cancelled) toChat(`**${clean(ev.player.name)}**: ${clean(ev.message)}`) + }, { priority: 'monitor' }) if (config.relayJoins) { - ctx.on('playerJoin', ({ player }) => send(null, `**${clean(player.name)}** joined the game`), { priority: 'monitor' }) - ctx.on('playerLeave', ({ player }) => { if (player.loggedIn) send(null, `**${clean(player.name)}** left the game`) }, { priority: 'monitor' }) + ctx.on('playerJoin', ({ player }) => { if (!player.hidden) toChat(`**${clean(player.name)}** joined the game ${countLine()}`) }, { priority: 'monitor' }) + ctx.on('playerLeave', ({ player }) => { + if (player.loggedIn && !player.hidden) ctx.setTimeout(() => toChat(`**${clean(player.name)}** left the game ${countLine()}`), 0) + }, { priority: 'monitor' }) } - ctx.on('serverStart', () => send(null, 'Server started'), { priority: 'monitor' }) + if (config.relayStaffChat) { + ctx.on('staffChat', ({ sender, message, channel }) => { + if (!sender.fromDiscord) toStaff(`[${channel === 'admin' ? 'Admin' : 'Op'}] **${clean(sender.name)}**: ${clean(message)}`) + }, { priority: 'monitor' }) + } + ctx.on('serverStart', () => toChat('Server started'), { priority: 'monitor' }) - // incoming (gateway) + // incoming - if (!config.botToken || !config.channelId) return - if (typeof WebSocket === 'undefined') { - log.warn('Receiving messages from Discord needs Node 22 or newer (global WebSocket). Only sending is enabled.') + if (!config.botToken) return // webhook only: nothing comes back + if (typeof globalThis.WebSocket === 'undefined') { + log.warn('This Node.js version has no WebSocket client, so messages from Discord are not received.') return } - let ws = null - let heartbeat = null - let seq = null - let stopping = false - - const connect = () => { - ws = new WebSocket(GATEWAY) - ws.onmessage = (event) => { - const msg = JSON.parse(event.data) - if (msg.s !== null && msg.s !== undefined) seq = msg.s - switch (msg.op) { - case 10: // hello - clearInterval(heartbeat) - heartbeat = setInterval(() => ws.send(JSON.stringify({ op: 1, d: seq })), msg.d.heartbeat_interval) - ws.send(JSON.stringify({ - op: 2, - d: { token: config.botToken, intents: INTENTS, properties: { os: process.platform, browser: 'mcscript', device: 'mcscript' } } - })) - break - case 7: // reconnect - case 9: // invalid session - ws.close() - break - case 0: - if (msg.t === 'READY') log.info(`Connected to Discord as ${msg.d.user.username}`) - if (msg.t === 'MESSAGE_CREATE') onMessage(msg.d) - break - } - } - ws.onclose = () => { - clearInterval(heartbeat) - if (!stopping) setTimeout(() => { if (!stopping) connect() }, 10000) + const presence = () => ({ + since: null, + afk: false, + status: 'online', + activities: config.status + ? [{ type: 0, name: config.status.replace('{players}', server.online.filter(p => !p.hidden).length).replace('{max}', server.config.maxPlayers) }] + : [] + }) + const gateway = new Gateway({ token: config.botToken, presence: presence() }) + gateway.on('ready', user => log.info(`Connected to Discord as ${user.username}`)) + gateway.on('fatal', reason => log.error(reason)) + gateway.on('log', (level, msg) => log[level](msg)) + gateway.on('message', d => { + try { onMessage(d) } catch (err) { log.error('Error handling a Discord message:', err) } + }) + ctx.onUnload(() => gateway.stop()) + gateway.connect() + + // presence changes are rate limited by Discord, so at most one every 20 seconds + let presenceTimer = null + const updatePresence = () => { + if (presenceTimer || !config.status) return + presenceTimer = ctx.setTimeout(() => { presenceTimer = null; gateway.setPresence(presence()) }, 20000) + } + ctx.on('playerJoin', updatePresence, { priority: 'monitor' }) + ctx.on('playerLeave', updatePresence, { priority: 'monitor' }) + + const lastReply = new Map() // throttles !players and error replies + const throttled = (key, seconds) => { + const now = Date.now() + if ((lastReply.get(key) || 0) > now - seconds * 1000) return true + lastReply.set(key, now) + return false + } + + // Highest server rank the Discord user has through userRanks / roleRanks (null if none) + const mappedRank = (d) => { + const names = [config.userRanks[d.author.id], ...((d.member && d.member.roles) || []).map(r => config.roleRanks[r])] + let best = null + for (const name of names) { + const rank = name && ranks.get(name) + if (rank && (!best || rank.permission > best.permission)) best = rank } - ws.onerror = (err) => log.warn(`Discord gateway error: ${err.message || err.type}`) + return best } - const onMessage = (d) => { - if (d.channel_id !== config.channelId || d.author.bot || d.webhook_id) return - const name = (d.member && d.member.nick) || d.author.global_name || d.author.username + const displayName = d => text.sanitize(text.stripColors( + (config.useNicknames && d.member && d.member.nick) || d.author.global_name || d.author.username + )).slice(0, 32) + + // Discord markup -> plain text for the game + const plain = (d) => { let content = d.content || '' + for (const u of d.mentions || []) content = content.replace(new RegExp(`<@!?${u.id}>`, 'g'), `@${u.global_name || u.username}`) + content = content + .replace(/<@&\d+>/g, '@role') + .replace(/<#\d+>/g, '#channel') + .replace(//g, '$1') + .replace(/\s*\n\s*/g, ' ') if (d.attachments && d.attachments.length) content += ' [attachment]' - content = text.sanitize(content.replace(/\n/g, ' ').replace(//g, '$1')) - if (content.trim() === '!players') { - const names = server.online.filter(p => !p.hidden).map(p => p.name) - return send(null, `Online (${names.length}/${server.config.maxPlayers}): ${names.join(', ') || 'nobody'}`) + if (d.sticker_items && d.sticker_items.length) content += ' [sticker]' + return text.sanitize(content).trim().slice(0, 300) + } + + const reply = (channel, lines) => sendTo([channel], lines) + + const listPlayers = (channel, staff) => { + const shown = server.online.filter(p => staff || !p.hidden) + const names = shown.map(p => clean(p.name) + (staff ? ` (${p.level ? p.level.name : '?'}${p.hidden ? ', hidden' : ''})` : '')) + reply(channel, `**Online ${countLine()}:** ${names.join(', ') || 'nobody'}`) + } + + const runCommand = async (d, channel, staff, line) => { + const label = line.split(/\s+/)[0] + const cmd = server.commands.find(label) + if (!cmd) { + if (!throttled('unknown:' + d.author.id, 10)) reply(channel, `Unknown command \`${escapeMarkdown(label)}\`.`) + return + } + if (config.bannedCommands.some(b => b.toLowerCase() === cmd.name)) { + return reply(channel, `\`${cmd.name}\` can't be used from Discord.`) } - if (content.trim()) server.broadcast(`${config.discordPrefix}&f${text.stripColors(name)}: ${content}`) + const isPublic = config.publicCommands.some(c => c.toLowerCase() === cmd.name) + let rank = mappedRank(d) + if (staff && config.staffRank) { + const staffRank = ranks.get(config.staffRank) + if (staffRank && (!rank || staffRank.permission > rank.permission)) rank = staffRank + } + if (!rank && !isPublic) { + if (!throttled('denied:' + d.author.id, 30)) reply(channel, `You don't have permission to use \`${cmd.name}\` from Discord.`) + return + } + if (!rank) rank = ranks.default + + const name = displayName(d) + const output = [] + const actor = server.createConsoleActor(`(Discord) ${name}`, m => { if (m) output.push(text.stripColors(String(m))) }, { rank, color: '&9' }) + actor.fromDiscord = true + log.info(`${name} (Discord, ${rank.name}) used ${config.commandPrefix}${line}`) + await server.commands.execute(actor, '/' + line) + if (!output.length) output.push('Done.') + for (const chunk of chunks(output, true)) reply(channel, chunk) } - connect() - ctx.onUnload(() => { - stopping = true - clearInterval(heartbeat) - if (ws) ws.close() - }) + const onMessage = (d) => { + if (!d.author || d.author.bot || d.webhook_id) return + if (gateway.user && d.author.id === gateway.user.id) return + if (config.ignoredUserIds.includes(d.author.id)) return + const channel = d.channel_id + const staff = staffChannels.includes(channel) + if (!staff && !chatChannels.includes(channel)) return + + const raw = (d.content || '').trim() + const lower = raw.toLowerCase() + if (['.who', '.players', `${config.commandPrefix}players`, `${config.commandPrefix}who`].includes(lower)) { + if (!throttled('who:' + channel, 5)) listPlayers(channel, staff) + return + } + if (config.commandPrefix && raw.startsWith(config.commandPrefix) && raw.length > config.commandPrefix.length) { + runCommand(d, channel, staff, raw.slice(config.commandPrefix.length).trim()).catch(err => log.error('Discord command failed:', err)) + return + } + + const message = plain(d) + if (!message) return + const name = displayName(d) + if (staff) { + const perm = ranks.permissionOf('Operator') + server.broadcast(`&c[Op] &9(Discord) ${name}&c: &f${message}`, p => p.permission >= perm) + server.log.info(`[Op] (Discord) ${name}: ${message}`) + } else { + server.broadcast(`${config.discordPrefix}&f${name}: ${message}`) + server.log.info(`(Discord) ${name}: ${message}`) + } + } + + module.exports.api = { gateway, handleMessage: onMessage } } } + +function escapeMarkdown (s) { + return s.replace(/([*_~`|>\\])/g, '\\$1').replace(/@(everyone|here)/g, '@\u200b$1') +} + +// Joins lines into messages under Discord's 2000 character limit (optionally as code blocks) +function chunks (lines, code = false) { + const out = [] + let cur = '' + const limit = code ? MAX_MESSAGE - 8 : MAX_MESSAGE + for (let line of lines) { + if (code) line = line.replace(/```/g, "'''") + if (line.length > limit) line = line.slice(0, limit) + if (cur && cur.length + line.length + 1 > limit) { out.push(cur); cur = '' } + cur = cur ? cur + '\n' + line : line + } + if (cur) out.push(cur) + return code ? out.map(c => '```\n' + c + '\n```') : out +} + +module.exports.chunks = chunks diff --git a/test/discord.test.js b/test/discord.test.js new file mode 100644 index 0000000..5e24874 --- /dev/null +++ b/test/discord.test.js @@ -0,0 +1,172 @@ +'use strict' + +const test = require('node:test') +const assert = require('node:assert/strict') + +const { startServer, sleep } = require('./helpers/server') +const { TestClient } = require('./helpers/client') +const { chunks } = require('../plugins/relay-discord') + +const CHAT = '100' +const STAFF = '200' +const MOD_ROLE = '300' + +// Plays the Discord gateway: says hello, answers identify with READY and lets the test push messages +class FakeGateway { + constructor (url) { + FakeGateway.last = this + this.url = url + this.readyState = 1 + this.sent = [] + this.seq = 0 + setImmediate(() => this.onmessage({ data: JSON.stringify({ op: 10, d: { heartbeat_interval: 45000 } }) })) + } + + send (raw) { + const msg = JSON.parse(raw) + this.sent.push(msg) + if (msg.op === 2) setImmediate(() => this.dispatch('READY', { session_id: 'abc', resume_gateway_url: 'wss://resume.example', user: { id: '1', username: 'MCBot' } })) + } + + dispatch (t, d) { this.onmessage({ data: JSON.stringify({ op: 0, t, s: ++this.seq, d }) }) } + close () { this.readyState = 3 } +} + +let nextId = 1000 +function discordMessage (channel, user, content, { roles = [], mentions = [] } = {}) { + FakeGateway.last.dispatch('MESSAGE_CREATE', { + id: String(nextId++), + channel_id: channel, + content, + author: { id: user.id, username: user.name, bot: !!user.bot }, + member: { nick: null, roles }, + mentions + }) +} + +async function join (server, name) { + const c = new TestClient({ port: server.port, name }) + await c.connect() + await c.waitFor('levelFinalize') + await c.waitFor(p => p.name === 'extAddEntity2' && p.id === -1) + return c +} + +const chatLines = c => c.received.filter(p => p.name === 'message').map(p => p.message).join('\n') + +test('Discord bot: chat bridge, staff channel and commands', async (t) => { + const posted = [] + const realFetch = globalThis.fetch + const realWebSocket = globalThis.WebSocket + globalThis.fetch = async (url, opts) => { + const m = String(url).match(/^https:\/\/discord\.com\/api\/v10\/channels\/(\d+)\/messages$/) + if (!m) return realFetch(url, opts) + assert.equal(opts.headers.Authorization, 'Bot secret-token') + const body = JSON.parse(opts.body) + assert.deepEqual(body.allowed_mentions, { parse: [] }) + posted.push({ channel: m[1], content: body.content }) + return new Response('{}', { status: 200 }) + } + globalThis.WebSocket = FakeGateway + t.after(() => { globalThis.fetch = realFetch; globalThis.WebSocket = realWebSocket }) + + const { server } = await startServer({ owners: ['Boss'] }, null, { + 'relay-discord': { + enabled: true, + botToken: 'secret-token', + chatChannelIds: [CHAT], + staffChannelIds: [STAFF], + roleRanks: { [MOD_ROLE]: 'Operator' }, + inviteUrl: 'https://discord.gg/example' + } + }) + t.after(() => server.stop()) + await sleep(100) + + const identify = FakeGateway.last.sent.find(m => m.op === 2) + assert.equal(identify.d.token, 'secret-token') + assert.equal(identify.d.intents, (1 << 0) | (1 << 9) | (1 << 15)) + assert.equal(identify.d.presence.activities[0].name, 'with 0/20 players') + + const boss = await join(server, 'Boss') + const alice = await join(server, 'Alice') + const said = async (ms = 500) => { await sleep(ms); const out = posted.splice(0); return out } + await said() + + // game -> Discord + alice.chat('hello *everyone*') + const out1 = await said() + assert.deepEqual(out1, [{ channel: CHAT, content: '**Alice**: hello \\*everyone\\*' }]) + + // Discord -> game, with a mention turned into a name + alice.mark() + discordMessage(CHAT, { id: '42', name: 'dana' }, 'hi <@43>!', { mentions: [{ id: '43', username: 'eve' }] }) + await sleep(200) + assert.match(chatLines({ received: alice.received.slice(alice.cursor) }), /\[Discord\] &fdana: hi @eve!/) + + // the bot ignores itself and other bots + discordMessage(CHAT, { id: '1', name: 'MCBot' }, 'echo') + discordMessage(CHAT, { id: '7', name: 'otherbot', bot: true }, 'beep') + await sleep(200) + assert.doesNotMatch(chatLines(alice), /echo|beep/) + + // !players and MCGalaxy's .who + discordMessage(CHAT, { id: '42', name: 'dana' }, '!players') + let out = await said() + assert.match(out[0].content, /\*\*Online \(2\/20\):\*\* Boss, Alice/) + + // public commands work for anyone, and the output comes back as a code block + discordMessage(CHAT, { id: '42', name: 'dana' }, '!rules') + out = await said() + assert.match(out[0].content, /^```\n[\s\S]*Be respectful/) + + // other commands need a mapped role + discordMessage(CHAT, { id: '42', name: 'dana' }, '!kick Alice spam') + out = await said() + assert.match(out[0].content, /don't have permission to use `kick`/) + assert.ok(server.findPlayerExact('Alice')) + + // banned commands are refused even for staff + discordMessage(STAFF, { id: '50', name: 'mod' }, '!pinstall https://example.com/x.js') + out = await said() + assert.match(out[0].content, /can't be used from Discord/) + + // staff channel <-> in-game staff chat + boss.mark(); alice.mark() + discordMessage(STAFF, { id: '50', name: 'mod' }, 'watch Alice') + await sleep(200) + assert.match(chatLines({ received: boss.received.slice(boss.cursor) }), /\[Op\] &9\(Discord\) mod&c: &fwatch Alice/) + assert.doesNotMatch(chatLines({ received: alice.received.slice(alice.cursor) }), /watch Alice/) + boss.chat('#on it') + out = await said() + assert.deepEqual(out, [{ channel: STAFF, content: '[Op] **Boss**: on it' }]) + assert.ok(!out.some(o => o.channel === CHAT)) + + // staff commands run with the staff rank, never with console powers + discordMessage(STAFF, { id: '50', name: 'mod' }, '!rank Alice Owner') + out = await said() + assert.match(out[0].content, /only give ranks lower than your own/) + assert.notEqual(server.playerDB.get('Alice').rank, 'Owner') + + // a user with a mapped role can moderate from the public channel + discordMessage(CHAT, { id: '60', name: 'helper' }, '!kick Alice spam', { roles: [MOD_ROLE] }) + await sleep(500) + assert.equal(server.findPlayerExact('Alice'), null) + + // /discord shows the invite link + boss.mark() + boss.chat('/discord') + await sleep(150) + assert.match(chatLines({ received: boss.received.slice(boss.cursor) }), /discord\.gg\/example/) +}) + +test('Discord messages are split under the 2000 character limit', () => { + const lines = Array.from({ length: 100 }, (_, i) => `line ${i} `.padEnd(60, 'x')) + const parts = chunks(lines, true) + assert.ok(parts.length > 1) + for (const p of parts) { + assert.ok(p.length <= 2000) + assert.ok(p.startsWith('```\n') && p.endsWith('\n```')) + } + assert.equal(parts.join('').split('line ').length - 1, 100) +})