2024-06-08 03:02:48 -04:00
|
|
|
const mojangson = require('mojangson')
|
2024-06-14 08:01:48 -04:00
|
|
|
const vsprintf = require('./format')
|
2024-06-08 03:02:48 -04:00
|
|
|
const debug = require('debug')('minecraft-protocol')
|
|
|
|
const nbt = require('prismarine-nbt')
|
|
|
|
const getValueSafely = (obj, key, def) => Object.hasOwn(obj, key) ? obj[key] : def
|
|
|
|
|
|
|
|
function loader (registryOrVersion) {
|
|
|
|
const registry = typeof registryOrVersion === 'string' ? require('prismarine-registry')(registryOrVersion) : registryOrVersion
|
|
|
|
const defaultLang = registry.language
|
|
|
|
const defaultAnsiCodes = {
|
|
|
|
'§0': '\u001b[30m',
|
|
|
|
'§1': '\u001b[34m',
|
|
|
|
'§2': '\u001b[32m',
|
|
|
|
'§3': '\u001b[36m',
|
|
|
|
'§4': '\u001b[31m',
|
|
|
|
'§5': '\u001b[35m',
|
|
|
|
'§6': '\u001b[33m',
|
|
|
|
'§7': '\u001b[37m',
|
|
|
|
'§8': '\u001b[90m',
|
|
|
|
'§9': '\u001b[94m',
|
|
|
|
'§a': '\u001b[92m',
|
|
|
|
'§b': '\u001b[96m',
|
|
|
|
'§c': '\u001b[91m',
|
|
|
|
'§d': '\u001b[95m',
|
|
|
|
'§e': '\u001b[93m',
|
|
|
|
'§f': '\u001b[97m',
|
|
|
|
'§l': '\u001b[1m',
|
|
|
|
'§o': '\u001b[3m',
|
|
|
|
'§n': '\u001b[4m',
|
|
|
|
'§m': '\u001b[9m',
|
|
|
|
'§k': '\u001b[6m',
|
|
|
|
'§r': '\u001b[0m'
|
|
|
|
}
|
|
|
|
const cssDefaultStyles = {
|
|
|
|
black: 'color:#000000',
|
|
|
|
dark_blue: 'color:#0000AA',
|
|
|
|
dark_green: 'color:#00AA00',
|
|
|
|
dark_aqua: 'color:#00AAAA',
|
|
|
|
dark_red: 'color:#AA0000',
|
|
|
|
dark_purple: 'color:#AA00AA',
|
|
|
|
gold: 'color:#FFAA00',
|
|
|
|
gray: 'color:#AAAAAA',
|
|
|
|
dark_gray: 'color:#555555',
|
|
|
|
blue: 'color:#5555FF',
|
|
|
|
green: 'color:#55FF55',
|
|
|
|
aqua: 'color:#55FFFF',
|
|
|
|
red: 'color:#FF5555',
|
|
|
|
light_purple: 'color:#FF55FF',
|
|
|
|
yellow: 'color:#FFFF55',
|
|
|
|
white: 'color:#FFFFFF',
|
|
|
|
bold: 'font-weight:900',
|
|
|
|
strikethrough: 'text-decoration:line-through',
|
|
|
|
underlined: 'text-decoration:underline',
|
|
|
|
italic: 'font-style:italic'
|
|
|
|
}
|
|
|
|
const formatMembers = ['color', 'bold', 'strikethrough', 'underlined', 'italic']
|
|
|
|
const { MessageBuilder } = require('./MessageBuilder')(registry)
|
|
|
|
|
|
|
|
/**
|
|
|
|
* ChatMessage Constructor
|
|
|
|
* @param {String|Object|Number} message content of ChatMessage
|
|
|
|
*/
|
|
|
|
class ChatMessage {
|
|
|
|
constructor (message, displayWarning = false) {
|
|
|
|
if (typeof message === 'string') {
|
|
|
|
if (message === '') {
|
|
|
|
this.json = { text: '' }
|
|
|
|
} else {
|
|
|
|
this.json = MessageBuilder.fromString(message, { colorSeparator: '§' })
|
|
|
|
}
|
|
|
|
} else if (typeof message === 'number') {
|
|
|
|
this.json = { text: message }
|
|
|
|
} else if (typeof message === 'object' && Array.isArray(message)) {
|
|
|
|
this.json = { extra: message }
|
|
|
|
} else if (typeof message === 'object') {
|
|
|
|
this.json = message
|
|
|
|
} else {
|
|
|
|
throw new Error('Expected String or Object for Message argument')
|
|
|
|
}
|
|
|
|
this.warn = displayWarning ? console.warn : debug
|
|
|
|
this.parse(displayWarning)
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Parses the this.json property to decorate the properties of the ChatMessage.
|
|
|
|
* Called by the Constructor
|
|
|
|
* @return {void}
|
|
|
|
*/
|
|
|
|
parse () {
|
|
|
|
const json = this.json
|
|
|
|
// Message scope for callback functions
|
|
|
|
// There is EITHER, a text property or a translate property
|
|
|
|
// If there is no translate property, there is no with property
|
|
|
|
// HOWEVER! If there is a translate property, there may not be a with property
|
|
|
|
if (typeof json.text === 'string' || typeof json.text === 'number') {
|
|
|
|
this.text = json.text
|
|
|
|
} else if (typeof json.translate === 'string') {
|
|
|
|
this.translate = json.translate
|
|
|
|
if (typeof json.with === 'object') {
|
|
|
|
if (!Array.isArray(json.with)) {
|
|
|
|
throw new Error('Expected with property to be an Array in ChatMessage')
|
|
|
|
}
|
|
|
|
this.with = json.with.map(entry => new ChatMessage(entry))
|
|
|
|
}
|
|
|
|
}
|
|
|
|
// Parse extra property
|
|
|
|
// Extras are appended to the initial text
|
|
|
|
if (typeof json.extra === 'object') {
|
|
|
|
if (!Array.isArray(json.extra)) {
|
|
|
|
throw new Error('Expected extra property to be an Array in ChatMessage')
|
|
|
|
}
|
|
|
|
this.extra = json.extra.map(entry => new ChatMessage(entry))
|
|
|
|
}
|
|
|
|
// Text modifiers
|
|
|
|
this.bold = json.bold
|
|
|
|
this.italic = json.italic
|
|
|
|
this.underlined = json.underlined
|
|
|
|
this.strikethrough = json.strikethrough
|
|
|
|
this.obfuscated = json.obfuscated
|
|
|
|
|
|
|
|
// Supported constants @ 2014-04-21
|
|
|
|
const supportedColors = [
|
|
|
|
'black',
|
|
|
|
'dark_blue',
|
|
|
|
'dark_green',
|
|
|
|
'dark_aqua',
|
|
|
|
'dark_red',
|
|
|
|
'dark_purple',
|
|
|
|
'gold',
|
|
|
|
'gray',
|
|
|
|
'dark_gray',
|
|
|
|
'blue',
|
|
|
|
'green',
|
|
|
|
'aqua',
|
|
|
|
'red',
|
|
|
|
'light_purple',
|
|
|
|
'yellow',
|
|
|
|
'white',
|
|
|
|
'obfuscated',
|
|
|
|
'bold',
|
|
|
|
'strikethrough',
|
|
|
|
'underlined',
|
|
|
|
'italic',
|
|
|
|
'reset'
|
|
|
|
]
|
|
|
|
const supportedClick = [
|
|
|
|
'open_url',
|
|
|
|
'open_file',
|
|
|
|
'run_command',
|
|
|
|
'suggest_command'
|
|
|
|
]
|
|
|
|
const supportedHover = [
|
|
|
|
'show_text',
|
|
|
|
'show_achievement',
|
|
|
|
'show_item',
|
|
|
|
'show_entity'
|
|
|
|
]
|
|
|
|
|
|
|
|
// Parse color
|
|
|
|
this.color = json.color
|
|
|
|
switch (this.color) {
|
|
|
|
case 'obfuscated':
|
|
|
|
this.obfuscated = true
|
|
|
|
this.color = null
|
|
|
|
break
|
|
|
|
case 'bold':
|
|
|
|
this.bold = true
|
|
|
|
this.color = null
|
|
|
|
break
|
|
|
|
case 'strikethrough':
|
|
|
|
this.strikethrough = true
|
|
|
|
this.color = null
|
|
|
|
break
|
|
|
|
case 'underlined':
|
|
|
|
this.underlined = true
|
|
|
|
this.color = null
|
|
|
|
break
|
|
|
|
case 'italic':
|
|
|
|
this.italic = true
|
|
|
|
this.color = null
|
|
|
|
break
|
|
|
|
case 'reset':
|
|
|
|
this.reset = true
|
|
|
|
this.color = null
|
|
|
|
break
|
|
|
|
}
|
|
|
|
// Make sure color is valid
|
|
|
|
if (this.color && !supportedColors.includes(this.color) && !this.color.match(/#[a-fA-F\d]{6}/)) {
|
|
|
|
this.warn('ChatMessage parsed with unsupported color', this.color)
|
|
|
|
this.color = null
|
|
|
|
}
|
|
|
|
|
|
|
|
// Parse click event
|
|
|
|
if (typeof json.clickEvent === 'object') {
|
|
|
|
this.clickEvent = json.clickEvent
|
|
|
|
if (typeof this.clickEvent.action !== 'string') {
|
|
|
|
throw new Error('ClickEvent action missing in ChatMessage')
|
|
|
|
} else if (!supportedClick.includes(this.clickEvent.action)) {
|
|
|
|
this.warn('ChatMessage parsed with unsupported clickEvent', this.clickEvent.action)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Parse hover event
|
|
|
|
if (typeof json.hoverEvent === 'object') {
|
|
|
|
this.hoverEvent = json.hoverEvent
|
|
|
|
if (typeof this.hoverEvent.action !== 'string') {
|
|
|
|
throw new Error('HoverEvent action missing in ChatMessage')
|
|
|
|
} else if (!supportedHover.includes(this.hoverEvent.action)) {
|
|
|
|
this.warn('ChatMessage parsed with unsupported hoverEvent', this.hoverEvent.action)
|
|
|
|
}
|
|
|
|
// Special case
|
|
|
|
if (this.hoverEvent.action === 'show_item') {
|
|
|
|
let content
|
|
|
|
if (this.hoverEvent.value instanceof Array) {
|
|
|
|
if (this.hoverEvent.value[0] instanceof Object) {
|
|
|
|
content = this.hoverEvent.value[0].text
|
|
|
|
} else {
|
|
|
|
content = this.hoverEvent.value[0]
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
if (this.hoverEvent.value instanceof Object) {
|
|
|
|
content = this.hoverEvent.value.text
|
|
|
|
} else {
|
|
|
|
content = this.hoverEvent.value
|
|
|
|
}
|
|
|
|
}
|
|
|
|
try {
|
|
|
|
this.hoverEvent.value = mojangson.parse(content)
|
|
|
|
} catch (err) {
|
|
|
|
debug(err)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Append one or more ChatMessages
|
|
|
|
* @param {...object|string} messages ChatMessage
|
|
|
|
* @return {void}
|
|
|
|
*/
|
|
|
|
append (...messages) {
|
|
|
|
if (this.extra === undefined) this.extra = []
|
|
|
|
messages.forEach((message) => {
|
|
|
|
if (typeof message === 'object' && !Array.isArray(message)) {
|
|
|
|
this.extra.push(message)
|
|
|
|
} else if (typeof message === 'string') {
|
|
|
|
this.extra.push(new ChatMessage(message))
|
|
|
|
}
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Returns a clone of the ChatMessage
|
|
|
|
* @return {ChatMessage}
|
|
|
|
*/
|
|
|
|
clone () {
|
|
|
|
return new ChatMessage(JSON.parse(JSON.stringify(this.json)))
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Returns the count of text extras and child ChatMessages
|
|
|
|
* Does not count recursively in to the children
|
|
|
|
* @return {Number}
|
|
|
|
*/
|
|
|
|
length () {
|
|
|
|
let count = 0
|
|
|
|
if (this.text) count++
|
|
|
|
else if (this.with) count += this.with.length
|
|
|
|
|
|
|
|
if (this.extra) count += this.extra.length
|
|
|
|
return count
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Returns a text part from the message
|
|
|
|
* @param {Number} idx Index of the part
|
|
|
|
* @return {String}
|
|
|
|
*/
|
|
|
|
getText (idx, lang = defaultLang) {
|
|
|
|
// If the index is not defined is is invalid, return toString
|
|
|
|
if (typeof idx !== 'number') return this.toString(lang)
|
|
|
|
// If we are not a translating message, return the text
|
|
|
|
if (this.text && idx === 0) return this.text.replace(/§[0-9a-flnmokr]/g, '')
|
|
|
|
// Else return the with child if it's in range
|
|
|
|
else if (this.with.length > idx) return this.with[idx].toString(lang)
|
|
|
|
// Else return the extra if it's in range
|
|
|
|
if (this.extra && this.extra.length + (this.text ? 1 : this.with.length) > idx) {
|
|
|
|
return this.extra[idx - (this.text ? 1 : this.with.length)].toString(lang)
|
|
|
|
}
|
|
|
|
// Not sure how you want to default this
|
|
|
|
// Undefined, an error ?
|
|
|
|
return ''
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Flattens the message in to plain-text
|
|
|
|
* @return {String}
|
|
|
|
*/
|
2024-06-14 08:01:48 -04:00
|
|
|
toString (lang = defaultLang) {
|
2024-06-08 03:02:48 -04:00
|
|
|
let message = ''
|
|
|
|
if (typeof this.text === 'string' || typeof this.text === 'number') message += this.text
|
|
|
|
else if (this.translate !== undefined) {
|
|
|
|
const _with = this.with ?? []
|
|
|
|
|
2024-06-14 08:01:48 -04:00
|
|
|
const args = _with.map(entry => entry.toString(lang))
|
2024-06-08 03:02:48 -04:00
|
|
|
const format = getValueSafely(lang, this.translate, this.translate)
|
|
|
|
message += vsprintf(format, args)
|
|
|
|
}
|
|
|
|
if (this.extra) {
|
2024-06-14 08:01:48 -04:00
|
|
|
message += this.extra.map((entry) => entry.toString(lang)).join('')
|
2024-06-08 03:02:48 -04:00
|
|
|
}
|
2024-06-14 08:01:48 -04:00
|
|
|
return message.replace(/§[0-9a-flnmokr]/g, '')
|
2024-06-08 03:02:48 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
valueOf () {
|
|
|
|
return this.toString()
|
|
|
|
}
|
|
|
|
|
2024-06-14 08:01:48 -04:00
|
|
|
toMotd (lang = defaultLang, parent = {}) {
|
2024-06-08 03:02:48 -04:00
|
|
|
const codes = {
|
|
|
|
color: {
|
|
|
|
black: '§0',
|
|
|
|
dark_blue: '§1',
|
|
|
|
dark_green: '§2',
|
|
|
|
dark_aqua: '§3',
|
|
|
|
dark_red: '§4',
|
|
|
|
dark_purple: '§5',
|
|
|
|
gold: '§6',
|
|
|
|
gray: '§7',
|
|
|
|
dark_gray: '§8',
|
|
|
|
blue: '§9',
|
|
|
|
green: '§a',
|
|
|
|
aqua: '§b',
|
|
|
|
red: '§c',
|
|
|
|
light_purple: '§d',
|
|
|
|
yellow: '§e',
|
|
|
|
white: '§f',
|
|
|
|
reset: '§r'
|
|
|
|
},
|
|
|
|
bold: '§l',
|
|
|
|
italic: '§o',
|
|
|
|
underlined: '§n',
|
|
|
|
strikethrough: '§m',
|
|
|
|
obfuscated: '§k'
|
|
|
|
}
|
|
|
|
|
|
|
|
let message = Object.keys(codes).map((code) => {
|
|
|
|
this[code] = this[code] || parent[code]
|
|
|
|
if (!this[code] || this[code] === 'false'/* || this.text === '' */) return null
|
|
|
|
if (code === 'color') {
|
|
|
|
// return hex codes in this format
|
|
|
|
if (this.color.startsWith('#')) return `§${this.color}`
|
|
|
|
return codes.color[this.color]
|
|
|
|
}
|
|
|
|
return codes[code]
|
|
|
|
}).join('')
|
|
|
|
|
|
|
|
if ((typeof this.text === 'string') || (typeof this.text === 'number')) message += this.text
|
|
|
|
else if (this.translate !== undefined) {
|
|
|
|
const _with = this.with ?? []
|
|
|
|
|
|
|
|
const args = _with.map(entry => {
|
2024-06-14 08:01:48 -04:00
|
|
|
const entryAsMotd = entry.toMotd(lang, this)
|
2024-06-08 03:02:48 -04:00
|
|
|
return entryAsMotd + (entryAsMotd.includes('§') ? '§r' + message : '')
|
|
|
|
})
|
|
|
|
const format = getValueSafely(lang, this.translate, this.translate)
|
|
|
|
message += vsprintf(format, args)
|
|
|
|
}
|
|
|
|
if (this.extra) {
|
2024-06-14 08:01:48 -04:00
|
|
|
message += this.extra.map(entry => entry.toMotd(lang, this)).join('§r')
|
2024-06-08 03:02:48 -04:00
|
|
|
}
|
2024-06-14 08:01:48 -04:00
|
|
|
return message
|
2024-06-08 03:02:48 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
toAnsi (lang = defaultLang, codes = defaultAnsiCodes) {
|
|
|
|
let message = this.toMotd(lang)
|
|
|
|
for (const k in codes) {
|
|
|
|
message = message.replace(new RegExp(k, 'g'), codes[k])
|
|
|
|
}
|
|
|
|
const hexRegex = /§#?([a-fA-F\d]{2})([a-fA-F\d]{2})([a-fA-F\d]{2})/
|
|
|
|
while (message.search(hexRegex) !== -1) {
|
|
|
|
// Stolen from https://stackoverflow.com/questions/5623838/rgb-to-hex-and-hex-to-rgb
|
|
|
|
const hexCodes = hexRegex.exec(message)
|
|
|
|
// Iterate over each hexColorCode match (§#69420, §#ABCDEF, §#A1B2C3)
|
|
|
|
const red = parseInt(hexCodes[1], 16)
|
|
|
|
const green = parseInt(hexCodes[2], 16)
|
|
|
|
const blue = parseInt(hexCodes[3], 16)
|
|
|
|
// ANSI from https://gist.github.com/fnky/458719343aabd01cfb17a3a4f7296797#rgb-colors
|
|
|
|
message = message.replace(hexRegex, `\u001b[38;2;${red};${green};${blue}m`)
|
|
|
|
}
|
2024-06-14 08:01:48 -04:00
|
|
|
return codes['§r'] + message + codes['§r']
|
2024-06-08 03:02:48 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
// NOTE : Have to be be mindful here as bad HTML gen may lead to arbitrary code execution from server
|
2024-06-14 08:01:48 -04:00
|
|
|
toHTML (lang = registry.language, styles = cssDefaultStyles, allowedFormats = formatMembers) {
|
2024-06-08 03:02:48 -04:00
|
|
|
let str = ''
|
|
|
|
if (allowedFormats.some(member => this[member])) {
|
|
|
|
const cssProps = allowedFormats.reduce((acc, cur) => this[cur]
|
|
|
|
? acc.push(cur === 'color'
|
|
|
|
? (this.color.startsWith('#') ? escapeRGB(this.color.slice(1)) : styles[this.color])
|
|
|
|
: styles[cur]) &&
|
|
|
|
acc
|
|
|
|
: acc, [])
|
|
|
|
str += `<span style="${cssProps.join(';')}">`
|
|
|
|
} else {
|
|
|
|
str += '<span>'
|
|
|
|
}
|
|
|
|
|
|
|
|
if (this.text) {
|
|
|
|
str += escapeHtml(this.text)
|
|
|
|
} else if (this.translate) {
|
|
|
|
const params = []
|
|
|
|
if (this.with) {
|
|
|
|
for (const param of this.with) {
|
2024-06-14 08:01:48 -04:00
|
|
|
params.push(param.toHTML(lang, styles, allowedFormats))
|
2024-06-08 03:02:48 -04:00
|
|
|
}
|
|
|
|
}
|
|
|
|
const format = getValueSafely(lang, this.translate, this.translate)
|
|
|
|
str += vsprintf(escapeHtml(format), params)
|
|
|
|
}
|
|
|
|
|
|
|
|
if (this.extra) {
|
2024-06-14 08:01:48 -04:00
|
|
|
str += this.extra.map(entry => entry.toHTML(lang, styles, allowedFormats)).join('')
|
2024-06-08 03:02:48 -04:00
|
|
|
}
|
|
|
|
str += '</span>'
|
2024-06-14 08:01:48 -04:00
|
|
|
return str
|
2024-06-08 03:02:48 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
static fromNotch (msg) {
|
|
|
|
if (registry.supportFeature('chatPacketsUseNbtComponents') && msg.type) {
|
|
|
|
const json = processNbtMessage(msg)
|
|
|
|
return new ChatMessage(json ? JSON.parse(json) : '')
|
|
|
|
} else {
|
|
|
|
try {
|
|
|
|
return new ChatMessage(JSON.parse(msg))
|
|
|
|
} catch (e) {
|
|
|
|
return new ChatMessage(msg)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// 1.19 applies chat formatting on the client side. A format string is provided like in C printf
|
|
|
|
// syntax, including positional arguments which we poll from the supplied parameters map.
|
|
|
|
// For example,
|
|
|
|
// printf("<%s> %s" /* fmt string */, [sender], [content])
|
|
|
|
static fromNetwork (type, params) {
|
|
|
|
const format = getValueSafely(registry.chatFormattingById, type)
|
|
|
|
if (format == null) {
|
|
|
|
throw new Error('unknown chat format code: ' + type) // Server may be attempting to send a chat message before sending a login codec, which is not allowed
|
|
|
|
}
|
|
|
|
return new ChatMessage({ translate: format.formatString, with: format.parameters.map(p => getValueSafely(params, p, '')) })
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
ChatMessage.MessageBuilder = MessageBuilder
|
|
|
|
return ChatMessage
|
|
|
|
}
|
|
|
|
|
|
|
|
module.exports = loader
|
|
|
|
// mcpc 1.20.3 uses NBT instead of JSON in some places to store chat, so the schema is a bit different
|
|
|
|
// processNbtMessage normalizes the JS object obtained from nbt derealization to the old JSON schema
|
|
|
|
function uuidFromIntArray (arr) {
|
|
|
|
const buf = Buffer.alloc(16)
|
|
|
|
arr.forEach((num, index) => { buf.writeInt32BE(num, index * 4) })
|
|
|
|
return buf.toString('hex')
|
|
|
|
}
|
|
|
|
function processNbtMessage (msg) {
|
|
|
|
if (!msg || msg.type === 'end') return null
|
|
|
|
const simplified = nbt.simplify(msg)
|
|
|
|
const json = JSON.stringify(simplified, (key, val) => {
|
|
|
|
if (key === 'id' && Array.isArray(val)) return uuidFromIntArray(val)
|
|
|
|
return val
|
|
|
|
})
|
|
|
|
return json
|
|
|
|
}
|
|
|
|
module.exports.processNbtMessage = processNbtMessage
|
|
|
|
|
|
|
|
const escapeHtml = (unsafe) => unsafe.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''')
|
|
|
|
const escapeRGB = (unsafe) => `color:rgb(${unsafe.match(/.{2}/g).map(e => parseInt(e, 16)).join(',')})`
|