diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e307beace..32db1e842 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,7 +32,7 @@ jobs: - id: set-matrix run: | node -e " - const testedVersions = require('./lib/version').testedVersions; + const testedVersions = require('./lib/version').pc.testedVersions; console.log('matrix='+JSON.stringify({'include': testedVersions.map(mcVersion => ({mcVersion}))})) " >> $GITHUB_OUTPUT diff --git a/examples/bedrock/chat_ping_pong.js b/examples/bedrock/chat_ping_pong.js new file mode 100644 index 000000000..218d2a202 --- /dev/null +++ b/examples/bedrock/chat_ping_pong.js @@ -0,0 +1,20 @@ +const mineflayer = require('mineflayer') + +const username = 'Bot' +const server = 'localhost' + +const options = { + version: 'bedrock', + host: server, + port: 19132, + offline: true, + username +} + +const bot = mineflayer.createBot(options) + +bot.on('message', (message, type, sender, verified) => { + if (sender === bot._client.username) return + console.log([message, type, sender, verified]) + bot.chat(message.getText(0)) +}) diff --git a/index.d.ts b/index.d.ts index ed6c697ad..fcd314e23 100644 --- a/index.d.ts +++ b/index.d.ts @@ -890,8 +890,13 @@ export class Particle { ); } -export let testedVersions: string[] -export let latestSupportedVersion: string -export let oldestSupportedVersion: string +export interface VersionDetails { + testedVersions: string[]; + latestSupportedVersion: string; + oldestSupportedVersion: string; +} + +export let supportedVersionsPC: VersionDetails +export let supportedVersionsBedrock: VersionDetails export function supportFeature (feature: string, version: string): boolean diff --git a/lib/bedrockPlugins/chat.js b/lib/bedrockPlugins/chat.js new file mode 100644 index 000000000..aeca9ce32 --- /dev/null +++ b/lib/bedrockPlugins/chat.js @@ -0,0 +1,205 @@ +const assert = require('assert') + +const USERNAME_REGEX = '(?:\\(.{1,15}\\)|\\[.{1,15}\\]|.){0,5}?(\\w+)' +const LEGACY_VANILLA_CHAT_REGEX = new RegExp(`^${USERNAME_REGEX}\\s?[>:\\-ยป\\]\\)~]+\\s(.*)$`) + +module.exports = inject + +function inject (bot, options) { + const CHAT_LENGTH_LIMIT = options.chatLengthLimit ?? (bot.supportFeature('lessCharsInChat') ? 100 : 256) + const defaultChatPatterns = options.defaultChatPatterns ?? true + + const ChatMessage = require('prismarine-chat')(bot.registry) + // chat.pattern.type will emit an event for bot.on() of the same type, eg chatType = whisper will trigger bot.on('whisper') + const _patterns = {} + let _length = 0 + // deprecated + bot.chatAddPattern = (patternValue, typeValue) => { + return bot.addChatPattern(typeValue, patternValue, { deprecated: true }) + } + + bot.addChatPatternSet = (name, patterns, opts = {}) => { + if (!patterns.every(p => p instanceof RegExp)) throw new Error('Pattern parameter should be of type RegExp') + const { repeat = true, parse = false } = opts + _patterns[_length++] = { + name, + patterns, + position: 0, + matches: [], + messages: [], + repeat, + parse + } + return _length + } + + bot.addChatPattern = (name, pattern, opts = {}) => { + if (!(pattern instanceof RegExp)) throw new Error('Pattern parameter should be of type RegExp') + const { repeat = true, deprecated = false, parse = false } = opts + _patterns[_length] = { + name, + patterns: [pattern], + position: 0, + matches: [], + messages: [], + deprecated, + repeat, + parse + } + return _length++ // increment length after we give it back to the user + } + + bot.removeChatPattern = name => { + if (typeof name === 'number') { + _patterns[name] = undefined + } else { + const matchingPatterns = Object.entries(_patterns).filter(pattern => pattern[1]?.name === name) + matchingPatterns.forEach(([indexString]) => { + _patterns[+indexString] = undefined + }) + } + } + + function findMatchingPatterns (msg) { + const found = [] + for (const [indexString, pattern] of Object.entries(_patterns)) { + if (!pattern) continue + const { position, patterns } = pattern + if (patterns[position].test(msg)) { + found.push(+indexString) + } + } + return found + } + + bot.on('messagestr', (msg, _, originalMsg) => { + const foundPatterns = findMatchingPatterns(msg) + + for (const ix of foundPatterns) { + _patterns[ix].matches.push(msg) + _patterns[ix].messages.push(originalMsg) + _patterns[ix].position++ + + if (_patterns[ix].deprecated) { + const [, ...matches] = _patterns[ix].matches[0].match(_patterns[ix].patterns[0]) + bot.emit(_patterns[ix].name, ...matches, _patterns[ix].messages[0].translate, ..._patterns[ix].messages) + _patterns[ix].messages = [] // clear out old messages + } else { // regular parsing + if (_patterns[ix].patterns.length > _patterns[ix].matches.length) return // we have all the matches, so we can emit the done event + if (_patterns[ix].parse) { + const matches = _patterns[ix].patterns.map((pattern, i) => { + const [, ...matches] = _patterns[ix].matches[i].match(pattern) // delete full message match + return matches + }) + bot.emit(`chat:${_patterns[ix].name}`, matches) + } else { + bot.emit(`chat:${_patterns[ix].name}`, _patterns[ix].matches) + } + // these are possibly null-ish if the user deletes them as soon as the event for the match is emitted + } + if (_patterns[ix]?.repeat) { + _patterns[ix].position = 0 + _patterns[ix].matches = [] + } else { + _patterns[ix] = undefined + } + } + }) + + addDefaultPatterns() + + bot._client.on('text', (data) => { + const msg = ChatMessage.fromNotch(data.message) + + if (['chat', 'whisper', 'announcement'].includes(data.type)) { + bot.emit('message', msg, 'chat', data.source_name, null) + bot.emit('messagestr', msg.toString(), data.type, msg, data.source_name, null) + } else if (['popup', 'jukebox_popup'].includes(data.type)) { + bot.emit('actionBar', msg, null) + } else { + bot.emit('message', msg, data.type, null) + bot.emit('messagestr', msg.toString(), data.type, msg, null) + } + }) + + function chatWithHeader (message) { + if (typeof message === 'number') message = message.toString() + if (typeof message !== 'string') { + throw new Error('Chat message type must be a string or number: ' + typeof message) + } + + if (message.startsWith('/')) { + // Do not try and split a command without a header + bot._client.write('command_request', { + command: message, + origin: { + type: 'player', + uuid: bot.player.uuid, + request_id: '' + }, + internal: false, + version: 76 + }) + return + } + + const lengthLimit = CHAT_LENGTH_LIMIT + message.split('\n').forEach((subMessage) => { + if (!subMessage) return + let i + let smallMsg + for (i = 0; i < subMessage.length; i += lengthLimit) { + smallMsg = subMessage.substring(i, i + lengthLimit) + bot._client.write('text', { + type: 'chat', + needs_translation: false, + source_name: bot._client.username, + message: smallMsg, + xuid: bot._client.profile.xuid.toString(), // bot._client.startGameData, + platform_chat_id: '', + filtered_message: '' + }) + } + }) + } + + async function tabComplete (text, assumeCommand = false, sendBlockInSight = true, timeout = 5000) { + assert(false, 'Unimplemented') + return [] + } + + bot.whisper = (username, message) => { + chatWithHeader(`/tell ${username} ${message}`) + } + bot.chat = (message) => { + chatWithHeader(message) + } + + bot.tabComplete = tabComplete + + function addDefaultPatterns () { + // 1.19 changes the chat format to move prefix from message contents to a seperate field. + // TODO: new chat lister to handle this + if (!defaultChatPatterns) return + bot.addChatPattern('whisper', new RegExp(`^${USERNAME_REGEX} whispers(?: to you)?:? (.*)$`), { deprecated: true }) + bot.addChatPattern('whisper', new RegExp(`^\\[${USERNAME_REGEX} -> \\w+\\s?\\] (.*)$`), { deprecated: true }) + bot.addChatPattern('chat', LEGACY_VANILLA_CHAT_REGEX, { deprecated: true }) + } + + function awaitMessage (...args) { + return new Promise((resolve, reject) => { + const resolveMessages = args.flatMap(x => x) + + function messageListener (msg) { + if (resolveMessages.some(x => x instanceof RegExp ? x.test(msg) : msg === x)) { + resolve(msg) + bot.off('messagestr', messageListener) + } + } + + bot.on('messagestr', messageListener) + }) + } + + bot.awaitMessage = awaitMessage +} diff --git a/lib/loader.js b/lib/loader.js index 1dc831711..529552fd5 100644 --- a/lib/loader.js +++ b/lib/loader.js @@ -1,4 +1,3 @@ -const mc = require('minecraft-protocol') const { EventEmitter } = require('events') const pluginLoader = require('./plugin_loader') const plugins = { @@ -45,8 +44,16 @@ const plugins = { particle: require('./plugins/particle') } +const bedrockPlugins = { + chat: require('./bedrockPlugins/chat') +} + const minecraftData = require('minecraft-data') -const { testedVersions, latestSupportedVersion, oldestSupportedVersion } = require('./version') + +const { pc: supportedVersionsPC, bedrock: supportedVersionsBedrock } = require('./version') + +const BEDROCK_PREFIX = 'bedrock' +const BEDROCK_VERSION_PREFIX = BEDROCK_PREFIX + '_' module.exports = { createBot, @@ -55,9 +62,8 @@ module.exports = { ScoreBoard: require('./scoreboard'), BossBar: require('./bossbar'), Particle: require('./particle'), - latestSupportedVersion, - oldestSupportedVersion, - testedVersions, + supportedVersionsPC, + supportedVersionsBedrock, supportFeature: (feature, version) => minecraftData(version).supportFeature(feature) } @@ -83,12 +89,18 @@ function createBot (options = {}) { } pluginLoader(bot, options) - const internalPlugins = Object.keys(plugins) + + const isBedrock = options.version ? options.version.includes(BEDROCK_PREFIX) : false + const isBedrockSpecificVersion = isBedrock ? options.version.includes(BEDROCK_VERSION_PREFIX) : false + + const pluginsToLoad = isBedrock ? bedrockPlugins : plugins + + const internalPlugins = Object.keys(pluginsToLoad) .filter(key => { if (typeof options.plugins[key] === 'function') return false if (options.plugins[key] === false) return false return options.plugins[key] || options.loadInternalPlugins - }).map(key => plugins[key]) + }).map(key => pluginsToLoad[key]) const externalPlugins = Object.keys(options.plugins) .filter(key => { return typeof options.plugins[key] === 'function' @@ -96,7 +108,19 @@ function createBot (options = {}) { bot.loadPlugins([...internalPlugins, ...externalPlugins]) options.validateChannelProtocol = false - bot._client = bot._client ?? mc.createClient(options) + + const protocol = isBedrock ? require('bedrock-protocol') : require('minecraft-protocol') + + if (isBedrock) { + options.skipPing = false + if (isBedrockSpecificVersion) { + options.version = options.version.replace(BEDROCK_VERSION_PREFIX, '') + } else { + delete options.version + } + } + + bot._client = bot._client ?? protocol.createClient({ ...options }) bot._client.on('connect', () => { bot.emit('connect') }) @@ -108,8 +132,12 @@ function createBot (options = {}) { }) if (!bot._client.wait_connect) next() else bot._client.once('connect_allowed', next) + function next () { - const serverPingVersion = bot._client.version + const { latestSupportedVersion, oldestSupportedVersion } = isBedrock ? supportedVersionsBedrock : supportedVersionsPC + const serverPingVersion = isBedrock + ? BEDROCK_VERSION_PREFIX + bot._client.options.version + : bot._client.version bot.registry = require('prismarine-registry')(serverPingVersion) if (!bot.registry?.version) throw new Error(`Server version '${serverPingVersion}' is not supported, no data for version`) @@ -122,10 +150,14 @@ function createBot (options = {}) { bot.protocolVersion = versionData.version bot.majorVersion = versionData.majorVersion - bot.version = versionData.minecraftVersion - options.version = versionData.minecraftVersion + const version = isBedrock + ? BEDROCK_VERSION_PREFIX + versionData.minecraftVersion + : versionData.minecraftVersion + bot.version = version + options.version = version bot.supportFeature = bot.registry.supportFeature setTimeout(() => bot.emit('inject_allowed'), 0) } + return bot } diff --git a/lib/version.js b/lib/version.js index 291917cb0..0e8e6bb0d 100644 --- a/lib/version.js +++ b/lib/version.js @@ -1,8 +1,14 @@ const testedVersions = ['1.8.8', '1.9.4', '1.10.2', '1.11.2', '1.12.2', '1.13.2', '1.14.4', '1.15.2', '1.16.5', '1.17.1', '1.18.2', '1.19', '1.19.2', '1.19.3', '1.19.4', '1.20.1', '1.20.2', '1.20.4', '1.20.6', '1.21.1'] +const bedrockTestedVersions = ['1.17.10', '1.18.30', '1.19.1', '1.19.30', '1.19.63', '1.19.70', '1.19.80', '1.20.40', '1.20.61', '1.20.71', '1.21.50'] module.exports = { - - testedVersions, - latestSupportedVersion: testedVersions[testedVersions.length - 1], - oldestSupportedVersion: testedVersions[0] - + bedrock: { + testedVersions: bedrockTestedVersions, + latestSupportedVersion: bedrockTestedVersions[bedrockTestedVersions.length - 1], + oldestSupportedVersion: bedrockTestedVersions[0] + }, + pc: { + testedVersions, + latestSupportedVersion: testedVersions[testedVersions.length - 1], + oldestSupportedVersion: testedVersions[0] + } } diff --git a/package.json b/package.json index aee23aa12..a01bc33d2 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "dependencies": { "minecraft-data": "^3.76.0", "minecraft-protocol": "^1.50.0", + "bedrock-protocol": "^3.42.2", "prismarine-biome": "^1.1.1", "prismarine-block": "^1.17.0", "prismarine-chat": "^1.7.1", diff --git a/test/externalTest.js b/test/externalTest.js index c6fca04f5..a5f016732 100644 --- a/test/externalTest.js +++ b/test/externalTest.js @@ -33,7 +33,7 @@ const download = require('minecraft-wrap').download const MC_SERVER_PATH = path.join(__dirname, 'server') -for (const supportedVersion of mineflayer.testedVersions) { +for (const supportedVersion of mineflayer.supportedVersionsPC.testedVersions) { let PORT = 25565 const registry = require('prismarine-registry')(supportedVersion) const version = registry.version diff --git a/test/internalTest.js b/test/internalTest.js index 2f74e03d1..8333fac08 100644 --- a/test/internalTest.js +++ b/test/internalTest.js @@ -7,7 +7,7 @@ const assert = require('assert') const { sleep } = require('../lib/promise_utils') const nbt = require('prismarine-nbt') -for (const supportedVersion of mineflayer.testedVersions) { +for (const supportedVersion of mineflayer.supportedVersionsPC.testedVersions) { const registry = require('prismarine-registry')(supportedVersion) const version = registry.version const Chunk = require('prismarine-chunk')(supportedVersion)