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/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/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/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/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/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/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) } 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') +}) 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) +})