Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`,
Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 |

Expand Down
37 changes: 37 additions & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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": "<BetaCraft heartbeat 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
Expand Down
100 changes: 100 additions & 0 deletions docs/DISCORD.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 6 additions & 1 deletion docs/PLUGINS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` | |

Expand All @@ -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()`.

Expand Down
2 changes: 2 additions & 0 deletions lib/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
75 changes: 57 additions & 18 deletions lib/heartbeat.js
Original file line number Diff line number Diff line change
@@ -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 () {
Expand All @@ -25,56 +39,81 @@ 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
}
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 }
2 changes: 1 addition & 1 deletion lib/level/level.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Loading
Loading