Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bedrock connection & chat support #3539

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
20 changes: 20 additions & 0 deletions examples/bedrock/chat_ping_pong.js
Original file line number Diff line number Diff line change
@@ -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))
})
11 changes: 8 additions & 3 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
205 changes: 205 additions & 0 deletions lib/bedrockPlugins/chat.js
Original file line number Diff line number Diff line change
@@ -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 <sender> 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
}
54 changes: 43 additions & 11 deletions lib/loader.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
const mc = require('minecraft-protocol')
const { EventEmitter } = require('events')
const pluginLoader = require('./plugin_loader')
const plugins = {
Expand Down Expand Up @@ -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,
Expand All @@ -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)
}

Expand All @@ -83,20 +89,38 @@ 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'
}).map(key => options.plugins[key])
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')
})
Expand All @@ -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`)

Expand All @@ -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
}
16 changes: 11 additions & 5 deletions lib/version.js
Original file line number Diff line number Diff line change
@@ -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]
}
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion test/externalTest.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading