Refactor the chat system

not sure if it's better or worse now. also, i have tried to optimize color codes, but this optimization seems unreliable (i might fix or remove it in the future)
This commit is contained in:
Chipmunk 2024-03-17 23:52:58 -04:00
parent a67478cfd9
commit 5f3560910b
18 changed files with 546 additions and 328 deletions

View file

@ -1,16 +1,37 @@
{
bots: [
{
host: 'localhost',
host: 'example.com',
port: 25565,
brand: 'kaboom', // TODO: Rename this
username: ' ',
prefix: "'",
colors: { primary: 'green', secondary: 'dark_green', error: 'red' },
version: '1.20.4',
randomizeUsername: true,
autoReconnect: true
matrix: {
enabled: false,
client: 'example',
roomId: 'put the matrix room id here',
commandPrefix: '!'
}
}
]
}
],
all: {
username: 'chipmunkbot',
prefix: "default.",
colors: { primary: 'green', secondary: 'dark_green', error: 'red' },
version: '1.20.4',
randomizeUsername: true,
autoReconnect: true,
features: {
amnesicCommandBlocks: true,
commandNamespaces: true
}
},
matrixClients: {
example: {
baseUrl: 'put the homeserver url here',
accessToken: 'put your access token here',
userId: 'put the user id here'
}
}
}

View file

@ -9,20 +9,19 @@ function inject (bot) {
_lines: Array(100).fill(''),
_filter
}
/*
bot.on('chat', ({ raw }) => {
const filtered = _filter(raw)
bot.on('chat_color_code', message => {
const filtered = _filter(message)
bot.chatFilter._lines = [...bot.chatFilter._lines, ...filtered.split('\n')]
while (bot.chatFilter._lines.length > 100) {
bot.chatFilter._lines.shift()
}
if (raw !== filtered) {
if (message !== filtered) {
bot._client.write('set_creative_slot', {
slot: 36,
item: {
present: true,
itemId: /* id *//*1,
itemId: 1,
itemCount: 1,
nbtData: nbt.comp({
i: nbt.string('\xa7r' + bot.chatFilter._lines.join('\xa7r\n'))
@ -30,11 +29,10 @@ function inject (bot) {
}
})
setTimeout(() => {
bot.core.run('minecraft:tellraw @a ' + JSON.stringify({ nbt: 'SelectedItem.tag.i', entity: bot.uuid }))
bot.core.run('minecraft:tellmessage @a ' + JSON.stringify({ nbt: 'SelectedItem.tag.i', entity: bot.uuid }))
}, 50)
}
})
*/
}
function _filter (message) {

View file

@ -1,10 +1,17 @@
const { parseNbtText } = require('../util/chat/serialization.js')
const parseText = require('../util/text_parser.js')
const { parseNbtText } = require('../util/chat/utility')
const plainStringify = require('../util/chat/stringify/plain')
const colorCodeStringify = require('../util/chat/stringify/color_code')
const htmlStringify = require('../util/chat/stringify/html')
const ansiStringify = require('../util/chat/stringify/ansi')
const kaboomParser = require('../util/chat/message_parser/kaboom')
function inject (bot) {
bot.chat = {
queue: [],
patterns: [],
parsers: [kaboomParser],
message (message) {
bot._client.write('chat_message', {
message,
@ -31,7 +38,7 @@ function inject (bot) {
}
setInterval(() => {
if (!bot.loggedIn) { return }
if (!bot.loggedIn) return
const message = bot.chat.queue.shift()
if (message != null) {
@ -47,15 +54,20 @@ function inject (bot) {
bot.emit('profileless_chat', { message, senderName, type })
bot.emit('chat', message)
tryParsingMessage(message, { senderName, players: bot.players, lang: bot.registry.language })
})
bot.on('packet.player_chat', (packet) => {console
bot.on('packet.player_chat', (packet) => {
const plain = packet.plainMessage
const unsigned = parseNbtText(packet.unsignedChatContent)
const sender = bot.players.find(player => player.uuid === packet.senderUuid)
const type = bot.registry?.chatFormattingById[packet.type]
bot.emit('player_chat', { unsigned, sender, type })
bot.emit('player_chat', { plain, unsigned, sender, type: type.name })
bot.emit('chat', unsigned)
tryParsingMessage(unsigned, { senderUuid: sender.uuid, players: bot.players, lang: bot.registry.language, plain })
})
bot.on('packet.system_chat', (packet) => {
@ -67,6 +79,28 @@ function inject (bot) {
bot.emit('system_chat', message)
bot.emit('chat', message)
})
bot.on('chat', message => {
const stringifyOptions = { lang: bot.registry.language }
bot.emit('chat_plain', plainStringify(message, stringifyOptions))
bot.emit('chat_color_code', colorCodeStringify(message, stringifyOptions))
bot.emit('chat_ansi', ansiStringify(message, stringifyOptions))
bot.emit('chat_html', htmlStringify(message, stringifyOptions))
bot.console.log(message)
})
function tryParsingMessage (message, data) {
let parsed
for (const parser of bot.chat.parsers) {
parsed = parser(message, data)
if (parsed) break
}
if (!parsed) return
bot.emit('message', parsed)
}
}
module.exports = inject

View file

@ -4,6 +4,7 @@ const util = require('util')
const { CommandDispatcher, builder: { LiteralArgumentBuilder: { literal }, RequiredArgumentBuilder: { argument } }, arguments: { StringArgumentType: { greedyString } }, exceptions: { CommandSyntaxException } } = require('brigadier-commands')
const CommandSource = require('../util/command/command_source')
const TextMessage = require('../util/command/text_message')
const colorCodeStringify = require('../util/chat/stringify/color_code')
function inject (bot) {
bot.commands = {
@ -16,16 +17,14 @@ function inject (bot) {
isValid
}
/*
bot.on('message', (player, message) => {
if (!message.startsWith(bot.prefix)) return
bot.on('message', ({ sender, plain }) => {
if (!plain.startsWith(bot.prefix)) return
function sendFeedback (message) {
bot.core.run('minecraft:tellraw @a ' + JSON.stringify(message))
bot.tellraw(message, '@a')
}
bot.commands.execute(message.substring(bot.prefix.length), new CommandSource({ bot, player, sendFeedback }))
bot.commands.execute(plain.substring(bot.prefix.length), new CommandSource({ bot, player: sender, sendFeedback }))
})
*/
function add (command) {
if (command.register) {

View file

@ -1,32 +1,8 @@
const fs = require('fs')
const util = require('util')
const moment = require('moment')
const colorCodeStringify = require('../util/chat/stringify/color_code')
const ansiStringify = require('../util/chat/stringify/ansi')
const CommandSource = require('../util/command/command_source')
const parseText = require('../util/text_parser')
const ansimap = {
0: '\x1b[0m\x1b[30m',
1: '\x1b[0m\x1b[34m',
2: '\x1b[0m\x1b[32m',
3: '\x1b[0m\x1b[36m',
4: '\x1b[0m\x1b[31m',
5: '\x1b[0m\x1b[35m',
6: '\x1b[0m\x1b[33m',
7: '\x1b[0m\x1b[37m',
8: '\x1b[0m\x1b[90m',
9: '\x1b[0m\x1b[94m',
a: '\x1b[0m\x1b[92m',
b: '\x1b[0m\x1b[96m',
c: '\x1b[0m\x1b[91m',
d: '\x1b[0m\x1b[95m',
e: '\x1b[0m\x1b[93m',
f: '\x1b[0m\x1b[97m',
r: '\x1b[0m',
l: '\x1b[1m',
o: '\x1b[3m',
n: '\x1b[4m',
m: '\x1b[9m',
k: '\x1b[6m'
}
function inject (bot) {
bot.console = {
@ -50,19 +26,30 @@ function inject (bot) {
}
function _log (prefix, stdout, data) {
// format it
data = `[${moment().format('HH:mm:ss')} ${prefix}\u00a7r] ${data}\n`
const _prefix = `[${formatDate()} ${prefix}\u00a7r] `
const stringifyOptions = { lang: bot.registry.language }
const formattedData = _prefix + colorCodeStringify(data, stringifyOptions) + '\n'
const ansi = ansiStringify(_prefix, stringifyOptions) + ansiStringify(data, stringifyOptions) + '\x1b[0m\n'
// log to file
const filepath = bot.console.filepath
if (filepath != null) {
fs.appendFile(filepath, data, err => {
fs.appendFile(filepath, formattedData, err => {
if (err) console.error(err)
})
}
bot.tellraw(formattedData, '_ChipMC_')
// log to stdout
data = data.replace(/\u00a7.?/g, m => ansimap[m.slice(1)] ?? '') + '\x1b[0m'
stdout.write(data)
stdout.write(ansi + '')
}
function formatDate (date = new Date()) {
const hours = date.getHours()
const minutes = date.getMinutes()
const seconds = date.getSeconds()
return [hours, minutes, seconds].map(n => n.toString().padStart(2, '0')).join(':')
}
function setRl (rl) {
@ -83,8 +70,7 @@ function inject (bot) {
}
function sendFeedback (text, sendFeedback) {
const { raw } = parseText(message)
bot.console.log(raw)
bot.console.log(text)
}
}
}

View file

@ -1,6 +1,6 @@
const matrix = require('matrix-js-sdk')
const htmlStringify = require('../util/chat/html_stringifier')
const CommandSource = require('../util/command/command_source')
const htmlStringify = require('../util/chat/stringify/html')
function inject (bot, options) {
if (!options.matrix?.enabled) return
@ -8,17 +8,33 @@ function inject (bot, options) {
bot.matrix = {
client: options.matrix.client ?? matrix.createClient(options.matrix),
roomId: options.matrix.roomId,
commandPrefix: options.matrix.commandPrefix
commandPrefix: options.matrix.commandPrefix,
inviteUrl: String(options.matrix.inviteUrl)
}
bot.on('chat', async message => {
sendMessage(message)
const startTime = Date.now()
bot.on('chat_html', async html => {
sendMessage(html)
})
const matrixPrefix = {
text: 'ChipmunkBot Matrix',
hoverEvent: {
action: 'show_text',
contents: 'Click to copy the invite link for the Matrix space to your clipboard!'
},
clickEvent: {
action: 'copy_to_clipboard', // * Minecraft, and Java's URL class in general, seem to hate `#`, so open_url does not work.
value: bot.matrix.inviteUrl
}
}
bot.matrix.client.on('Room.timeline', (event, room, toStartOfTimeline) => {
if (event.getRoomId() !== bot.matrix.roomId || event.getType() !== 'm.room.message' || event.sender.userId === bot.matrix.client.getUserId()) return
if (event.getRoomId() !== bot.matrix.roomId || event.getType() !== 'm.room.message' || event.getTs() < startTime || event.sender.userId === bot.matrix.client.getUserId()) return
const content = event.getContent()
const permissionLevel = event.sender.powerLevelNorm
let message = content.body
if (content.url) {
@ -31,7 +47,7 @@ function inject (bot, options) {
}
}
} else if (message.startsWith(bot.matrix.commandPrefix)) {
const source = new CommandSource({ bot, permissionLevel: 1, sendFeedback })
const source = new CommandSource({ bot, permissionLevel, sendFeedback })
bot.commands.execute(message.substring(bot.matrix.commandPrefix.length), source)
return
@ -41,21 +57,19 @@ function inject (bot, options) {
text: String(event.sender.rawDisplayName || event.sender.name || event.sender.userId),
hoverEvent: {
action: 'show_text',
contents: ['User ID: ', String(event.sender.userId), '\nClick to copy the user id']
contents: [String(event.sender.userId), '\nPermission Level: ', String(permissionLevel), '\n\nClick to copy the User ID']
},
clickEvent: {
action: 'copy_to_clipboard',
value: String(event.sender.userId)
}
}
bot.fancyMsg('ChipmunkBot Matrix', senderText, message)
bot.fancyMsg(matrixPrefix, senderText, message)
})
let dequeuingMessages = false
let queue = []
async function sendMessage (message) {
const html = htmlStringify(message, { lang: bot.registry.language })
async function sendMessage (html) {
queue.push(html)
if (dequeuingMessages) return
@ -85,7 +99,8 @@ function inject (bot, options) {
}
function sendFeedback (text, sendFeedback) {
sendMessage(text)
const html = htmlStringify(text, { lang: bot.registry.language })
sendMessage(html)
}
}

View file

@ -1,4 +1,4 @@
const { parseNbtText } = require('../util/chat/serialization.js')
const { parseNbtText } = require('../util/chat/utility')
const gamemodes = ['survival', 'creative', 'adventure', 'spectator']

View file

@ -1,79 +0,0 @@
const { colors, formatting, reset } = require('./formatting.json')
function escapeSequenceStringify (text, { sequences, lang = {}, parent } = {}) {
text = normalize(text)
let rootText = false
if (parent == null) {
parent = {}
rootText = true
}
let formattingString = ''
formattingString += sequences.colors[text.color ?? parent.color] || ''
if (text.bold ?? parent.bold) formattingString += sequences.formatting.bold
if (text.italic ?? parent.italic) formattingString += sequences.formatting.italic
if (text.underlined ?? parent.underlined) formattingString += sequences.formatting.underlined
if (text.strikethrough ?? parent.strikethrough) formattingString += sequences.formatting.strikethrough
if (text.obfuscated ?? parent.obfuscated) formattingString += sequences.formatting.obfuscated
if (!formattingString && !rootText) formattingString = sequences.reset
let string = formattingString
if (text.text != null) string += text.text
if (text.translate != null) {
let format
if (Object.hasOwn(lang, text.translate)) format = lang[text.translate]
else if (text.fallback != null) format = text.fallback
else format = text.translate
const _with = text.with || []
string += format.replace(/%(?:(\d+)\$)?(s|%)/g, (g0, g1) => {
if (g0 === '%%') return '%'
const idx = g1 ? parseInt(g1) : i++
if (_with[idx]) {
return escapeSequenceStringify(_with[idx], { sequences, lang, parent: text }) + formattingString
}
return ''
})
}
if (text.selector != null) string += text.selector
if (text.keybind) {
// TODO
}
if (text.extra) {
for (const extra of text.extra) {
string += escapeSequenceStringify(extra, { sequences, lang, parent: text })
}
}
return string
}
const colorCodeMapper = colors => Object.fromEntries(colors.map(color => [color.name, color.code]))
const colorCodeSequences = { colors: colorCodeMapper(colors), formatting: colorCodeMapper(formatting), reset: reset.code }
const colorCodeStringify = (text, { lang }) => escapeSequenceStringify(text, { sequences: colorCodeSequences, lang })
const ansiMapper = colors => Object.fromEntries(colors.map(color => [color.name, color.ansi]))
const ansiSequences = { colors: ansiMapper(colors), formatting: ansiMapper(formatting), reset: reset.ansi }
const ansiSringify = (text, { lang }) => escapeSequenceStringify(text, { sequences: ansiSequences, lang })
function normalize (text) {
if (typeof text === 'string') return { text }
if (Array.isArray(text)) {
const text2 = [...text]
const text3 = { ...normalize(text2.shift()) }
text3.extra = text2
return text3
}
return text
}
module.exports = { escapeSequenceStringify, colorCodeStringify, ansiSringify }

View file

@ -18,11 +18,11 @@
{"name": "white", "code": "§f", "ansi": "\u001b[0m\u001b[97m", "rgb": 16777215}
],
"formatting": [
{"name": "bold", "code": "§l"},
{"name": "italic", "code": "§o"},
{"name": "underlined", "code": "§n"},
{"name": "strikethrough", "code": "§m"},
{"name": "obfuscated", "code": "§k"}
{"name": "bold", "code": "§l", "ansi": "\u001b[1m"},
{"name": "italic", "code": "§o", "ansi": "\u001b[3m"},
{"name": "underlined", "code": "§n", "ansi": "\u001b[4m"},
{"name": "strikethrough", "code": "§m", "ansi": "\u001b[9m"},
{"name": "obfuscated", "code": "§k", "ansi": "\u001b[6m"}
],
"reset": {"name": "reset", "code": "§r", "ansi": "\u001b[0m"}
}

View file

@ -0,0 +1,92 @@
const util = require('util')
const { getChangedFormatting } = require('../utility')
const { colors } = require('../formatting.json')
const colormap = Object.fromEntries(colors.map(color => [color.name, '&' + color.code[1]]))
// This is the regex used for matching urls in extras, with ^ and $ added
const urlRegex = /^((https?:\/\/(ww(w|\d)\.)?|ww(w|\d))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9]{2,6}\b([-a-zA-Z0-9@:%_+.~#?&/=]*))$/
function parseMessage (message, data) {
if (message === null || typeof message !== 'object') return
if (message.text !== '' || !Array.isArray(message.extra) || message.extra.length < 3) return
const children = message.extra
const prefix = children[0]
let displayName = data.senderName ?? { text: '' }
let contents = { text: '' }
if (isSeparatorAt(children, 1)) { // Missing/blank display name
if (children.length > 3) contents = children[3]
} else if (isSeparatorAt(children, 2)) {
displayName = children[1]
if (children.length > 4) contents = children[4]
} else {
return undefined
}
const playerListDisplayName = { extra: [prefix, displayName], text: '' }
let sender
if (data.senderUuid) {
sender = data.players.find(player => player.uuid === data.senderUuid)
} else {
const playerListDisplayName = { extra: [prefix, displayName], text: '' }
sender = data.players.find(player => util.isDeepStrictEqual(player.displayName, playerListDisplayName))
}
if (!sender) return undefined
let plain = data.plain
if (!plain) plain = getPlainContents(contents)
return { sender, contents, type: 'minecraft:chat', displayName, plain }
}
function getPlainContents (contents) {
let string = ''
let format = getPlainFormatting(contents, {})
if (format.startsWith('&r')) format = format.substring(2)
string += format
if (contents.text) string += contents.text
if (contents.extra) {
let previousChild = contents
for (const child of contents.extra) {
string += getPlainFormatting(child, previousChild)
string += child.text
previousChild = child
}
}
return string
}
function getPlainFormatting (text, previousText) {
if (isUrl(text)) return ''
const format = getChangedFormatting(text, previousText, {})
let string = ''
if ('color' in format) string += colormap[format.color] || '&r'
if (format.bold) string += '&l'
if (format.italic) string += '&o'
if (format.underlined) string += '&n'
if (format.strikethrough) string += '&m'
if (format.obfuscated) string += '&k'
return string
}
function isSeparatorAt (children, start) {
return (children[start]?.text === ':' || children[start]?.text === '\xa7f:') && children[start + 1]?.text === ' '
}
const isUrl = text => text.color === 'blue' && text.underlined && text.clickEvent?.action === 'open_url' && text.clickEvent?.value === (text.text?.includes?.('://') ? text.text : 'https://' + text.text) && urlRegex.test(text.text)
module.exports = parseMessage

View file

@ -1,12 +0,0 @@
function parseJsonText (json) {
return JSON.parse(json)
}
function parseNbtText (data) {
if (typeof data.value !== 'object') return data.value
if (Array.isArray(data.value)) return [...data.value]
if (data.type === 'list') return data.value.value.map(value => parseNbtText({ value }))
return Object.fromEntries(Object.entries(data.value).map(([key, value]) => ([key === '' ? 'text' : key, parseNbtText(value)])))
}
module.exports = { parseJsonText, parseNbtText }

100
util/chat/stringify/ansi.js Normal file
View file

@ -0,0 +1,100 @@
const { colors, formatting, reset } = require('../formatting.json')
const { normalize, intToRgb, getChangedFormatting } = require('../utility')
const colormap = Object.fromEntries(colors.map(color => [color.name, color.ansi]))
const formatmap = Object.fromEntries(formatting.map(format => [format.name, format.ansi]))
const colorcodemap = Object.fromEntries(colors.map(color => [color.code, color.ansi]))
const formatcodemap = Object.fromEntries(formatting.map(format => [format.code, format.ansi]))
const baseColors = colors.map(color => intToRgb(color.rgb))
function ansiStringify (text, { lang = {}, previousText, parent } = {}) {
text = normalize(text)
let string = getFormatting(text, previousText, parent)
if (text.text != null) string += preprocessText(text.text)
else if (text.translate != null) {
let format
if (Object.hasOwn(lang, text.translate)) format = lang[text.translate]
else if (text.fallback != null) format = text.fallback
else format = text.translate
const _with = text.with || []
let i = 0
string += preprocessText(format).replace(/%(?:(\d+)\$)?(s|%)/g, (g0, g1) => {
if (g0 === '%%') return '%'
const idx = g1 ? parseInt(g1) : i++
if (_with[idx]) {
return ansiStringify(_with[idx], { lang, previousText: text, parent: text }) + getFormatting(text, _with[idx], parent)
}
return ''
})
}
else if (text.selector != null) string += preprocessText(text.selector)
else if (text.keybind) {
// TODO
}
if (text.extra) {
let previousChild = text
for (const child of text.extra) {
string += ansiStringify(child, { lang, previousText: previousChild, parent: text })
previousChild = child
}
}
return string
}
function getFormatting (text, previousText, parent) {
const format = getChangedFormatting(text, previousText, parent)
let string = ''
if ('color' in format) {
if (format.color && format.color[0] === '#') {
const rgb = parseInt(format.color.substring(1), 16) & 0xffffff
const [r, g, b] = intToRgb(rgb)
string += `\x1b[38;2;${r};${g};${b}m`
}
else if (colormap[format.color]) string += colormap[format.color]
else if (parent) string += reset.ansi
}
if (format.bold) string += formatmap.bold
if (format.italic) string += formatmap.italic
if (format.underlined) string += formatmap.underlined
if (format.strikethrough) string += formatmap.strikethrough
if (format.obfuscated) string += formatmap.obfuscated
return string
}
function preprocessText (input) {
let string = ''
for (let i = 0; i < input.length; i++) {
const c = input[i]
if (c === '§') {
if ((i + 1) >= input.length) break
const code = input.substring(i, i + 2)
i++
if (colorcodemap[code]) string += colorcodemap[code]
else if (formatcodemap[code]) string += formatcodemap[code]
else if (code === reset.code) string += reset.ansi
continue
}
else string += c
}
return string
}
module.exports = ansiStringify

View file

@ -0,0 +1,71 @@
const { colors, formatting, reset } = require('../formatting.json')
const { normalize, intToRgb, getNearestColor, getChangedFormatting } = require('../utility')
const colormap = Object.fromEntries(colors.map(color => [color.name, color.code]))
const formatmap = Object.fromEntries(formatting.map(format => [format.name, format.code]))
function colorCodeStringify (text, { lang = {}, previousText, parent } = {}) {
text = normalize(text)
let string = getFormatting(text, previousText, parent)
if (text.text != null) string += text.text
else if (text.translate != null) {
let format
if (Object.hasOwn(lang, text.translate)) format = lang[text.translate]
else if (text.fallback != null) format = text.fallback
else format = text.translate
const _with = text.with || []
let i = 0
string += format.replace(/%(?:(\d+)\$)?(s|%)/g, (g0, g1) => {
if (g0 === '%%') return '%'
const idx = g1 ? parseInt(g1) : i++
if (_with[idx]) {
return colorCodeStringify(_with[idx], { lang, previousText: text, parent: text }) + getFormatting(text, _with[idx], parent)
}
return ''
})
}
else if (text.selector != null) string += text.selector
else if (text.keybind) {
// TODO
}
if (text.extra) {
let previousChild = text
for (const child of text.extra) {
string += colorCodeStringify(child, { lang, previousText: previousChild, parent: text })
previousChild = child
}
}
return string
}
function getFormatting (text, previousText, parent) {
const format = getChangedFormatting(text, previousText, parent)
let string = ''
if ('color' in format) {
if (format.color && format.color[0] === '#') {
const rgb = parseInt(format.color.substring(1), 16) & 0xffffff
string += colors[getNearestColor(rgb)].code
}
else if (colormap[format.color]) string += colormap[format.color]
else if (parent) string += reset.code
}
if (format.bold) string += formatmap.bold
if (format.italic) string += formatmap.italic
if (format.underlined) string += formatmap.underlined
if (format.strikethrough) string += formatmap.strikethrough
if (format.obfuscated) string += formatmap.obfuscated
return string
}
module.exports = colorCodeStringify

View file

@ -1,12 +1,8 @@
const { colors } = require('./formatting.json')
const { colors } = require('../formatting.json')
const { normalize } = require('../utility')
const colorsByName = {}
const colorsByCode = {}
for (const color of colors) {
const hex = '#' + color.rgb.toString(16).padStart(6, '0')
colorsByName[color.name] = hex
colorsByCode[color.code] = hex
}
const colormap = Object.fromEntries(colors.map(color => [color.name, color.rgb]))
const colorcodemap = Object.fromEntries(colors.map(color => [color.code, color.rgb]))
const reservedCharacters = {
'"': '&quot;',
@ -22,7 +18,7 @@ function htmlStringify (text, { lang = {} } = {}) {
let string = ''
if (text.text != null) string += preprocessText(text.text)
if (text.translate != null) {
else if (text.translate != null) {
let format
if (Object.hasOwn(lang, text.translate)) format = lang[text.translate]
else if (text.fallback != null) format = text.fallback
@ -42,8 +38,8 @@ function htmlStringify (text, { lang = {} } = {}) {
return ''
})
}
if (text.selector != null) string += preprocessText(text.selector)
if (text.keybind) {
else if (text.selector != null) string += preprocessText(text.selector)
else if (text.keybind) {
// TODO
}
@ -53,7 +49,10 @@ function htmlStringify (text, { lang = {} } = {}) {
}
}
if (text.color) string = `<font color="${text.color[0] === '#' ? ('#' + text.color.substring(1).padStart(6, '0')) : colorsByName[text.color]}">${string}</font>`
if (text.color) {
const rgb = text.color[0] === '#' ? parseInt(text.color.substring(1), 16) : colormap[text.color]
if (rgb) string = `<font color="${rgb.toString(16).padStart(6, '0')}">${string}</font>`
}
// formatting
if (text.bold) string = `<b>${string}</b>`
@ -78,7 +77,7 @@ function preprocessText (input) {
const code = input.substring(i, i + 2)
i++
const hex = colorsByCode[code]
const hex = colorcodemap[code]
if (hex) {
string += closing
string += `<font color="${hex}">`
@ -93,10 +92,10 @@ function preprocessText (input) {
}
if (code === '§l') { string += '<b>'; closing += '</b>' }
if (code === '§o') { string += '<i>'; closing += '</i>' }
if (code === '§n') { string += '<u>'; closing += '</u>' }
if (code === '§m') { string += '<strike>'; closing += '</strike>' }
if (code === '§k') { string += '<blink>'; closing += '</blink>' }
else if (code === '§o') { string += '<i>'; closing += '</i>' }
else if (code === '§n') { string += '<u>'; closing += '</u>' }
else if (code === '§m') { string += '<strike>'; closing += '</strike>' }
else if (code === '§k') { string += '<blink>'; closing += '</blink>' }
continue // Do not append the escape sequence itself to the string
}
@ -109,15 +108,4 @@ function preprocessText (input) {
return string
}
function normalize (text) {
if (typeof text === 'string') return { text }
if (Array.isArray(text)) {
const text2 = [...text]
const text3 = { ...normalize(text2.shift()) }
text3.extra = text2
return text3
}
return text
}
module.exports = htmlStringify

View file

@ -0,0 +1,43 @@
const { normalize } = require('../utility')
function plainStringify (text, { lang = {} } = {}) {
text = normalize(text)
let string = ''
if (text.text != null) string += text.text
else if (text.translate != null) {
let format
if (Object.hasOwn(lang, text.translate)) format = lang[text.translate]
else if (text.fallback != null) format = text.fallback
else format = text.translate
const _with = text.with || []
let i = 0
string += format.replace(/%(?:(\d+)\$)?(s|%)/g, (g0, g1) => {
if (g0 === '%%') return '%'
const idx = g1 ? parseInt(g1) : i++
if (_with[idx]) {
return plainStringify(_with[idx], { lang })
}
return ''
})
}
else if (text.selector != null) string += text.selector
else if (text.keybind) {
// TODO
}
if (text.extra) {
for (const extra of text.extra) {
string += plainStringify(extra, { lang })
}
}
return string
}
module.exports = plainStringify

89
util/chat/utility.js Normal file
View file

@ -0,0 +1,89 @@
const { colors, formatting } = require('./formatting.json')
const formatNames = Object.fromEntries(formatting.map(format => [format.name, true]))
const baseColors = colors.map(color => intToRgb(color.rgb))
function parseJsonText (json) {
return JSON.parse(json)
}
function parseNbtText (data) {
if (typeof data.value !== 'object') return data.value
if (Array.isArray(data.value)) return [...data.value]
if (data.type === 'list') return data.value.value.map(value => parseNbtText({ value }))
return Object.fromEntries(Object.entries(data.value).map(([key, value]) => ([key === '' ? 'text' : key, parseNbtText(value)])))
}
function normalize (text) {
if (typeof text === 'string') return { text }
if (Array.isArray(text)) {
const text2 = [...text]
const text3 = { ...normalize(text2.shift()) }
text3.extra = text2
return text3
}
return text
}
function rgbToInt (r, g, b) {
return (r & 0xff) << 16 | (g & 0xff) << 8 | (b & 0xff)
}
function intToRgb (int) {
const r = (int >> 16) & 0xff
const g = (int >> 8) & 0xff
const b = int & 0xff
return [r, g, b]
}
// skidded from https://github.com/edqx/amongus-experiments/blob/master/index5.js#L34C1-L56C2
function getNearestColor (rgb) {
if (typeof rgb === 'number') rgb = intToRgb(rgb)
const [red, green, blue] = rgb
let nearestDist = Infinity
let nearestColorIdx
for (let i = 0; i < baseColors.length; i++) {
const baseColor = baseColors[i]
const [baseR, baseG, baseB] = baseColor
const diffR = (red - baseR) * (red - baseR)
const diffG = (green - baseG) * (green - baseG)
const diffB = (blue - baseB) * (blue - baseB)
const dist = Math.sqrt(diffR + diffG + diffB)
if (dist < nearestDist) {
nearestDist = dist
nearestColorIdx = i
}
}
return nearestColorIdx
}
function getChangedFormatting (text, previousText, parent = {}) {
// Merge both texts with their parents
text = { ...parent, ...text }
if (previousText) previousText = { ...parent, ...previousText }
const getAllFormatting = () => Object.fromEntries(Object.entries(text).filter(([key, value]) => formatNames[key] || key === 'color'))
if (!previousText || text.color !== previousText.color) return getAllFormatting()
const format = {}
for (const key in formatNames) {
if (text[key] && previousText[key]) continue
if (previousText[key] && !text[key]) return getAllFormatting() // Color codes do not have any way to unset specific formattings
if (text[key] == null) continue
format[key] = format[key]
}
format.color = text.color
return format
}
module.exports = { parseJsonText, parseNbtText, normalize, rgbToInt, intToRgb, getNearestColor, getChangedFormatting }

View file

@ -1,4 +1,4 @@
const parseText = require('../text_parser.js')
const plainStringify = require('../chat/stringify/plain')
class TextMessage {
constructor (text) {
@ -6,8 +6,7 @@ class TextMessage {
}
getString () {
const { clean } = parseText(this.text)
return clean
return plainStringify(this.text, { lang: {} })
}
toString () {

View file

@ -1,126 +0,0 @@
const { language } = require('minecraft-data')('1.20.4')
const colormap = {
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'
}
const ansimap = {
'§0': '\x1b[0m\x1b[30m',
'§1': '\x1b[0m\x1b[34m',
'§2': '\x1b[0m\x1b[32m',
'§3': '\x1b[0m\x1b[36m',
'§4': '\x1b[0m\x1b[31m',
'§5': '\x1b[0m\x1b[35m',
'§6': '\x1b[0m\x1b[33m',
'§7': '\x1b[0m\x1b[37m',
'§8': '\x1b[0m\x1b[90m',
'§9': '\x1b[0m\x1b[94m',
'§a': '\x1b[0m\x1b[92m',
'§b': '\x1b[0m\x1b[96m',
'§c': '\x1b[0m\x1b[91m',
'§d': '\x1b[0m\x1b[95m',
'§e': '\x1b[0m\x1b[93m',
'§f': '\x1b[0m\x1b[97m',
'§r': '\x1b[0m',
'§l': '\x1b[1m',
'§o': '\x1b[3m',
'§n': '\x1b[4m',
'§m': '\x1b[9m',
'§k': '\x1b[6m'
}
/**
* Parses a native minecraft text component in string form.
* @param {string} json_string - A text component string, such as the chat packet's 'message' property.
* @returns {object} Parsed message in { raw, clean, ansi } form.
*/
function parseText (json) {
let raw = parseJson(json, { color: 'reset' })
if (raw.startsWith('§r')) {
raw = raw.substring(2)
}
const clean = raw.replace(/§./g, '').replace(/§/g, '')
const ansi = raw.replace(/§[a-f0-9rlonmk]/g, (m) => ansimap[m])
return { raw, clean, ansi }
}
/**
* Parses a native minecraft text component in JSON form.
* @param {object} json - The json message.
* @param {object} parent - The parent json.
* @returns {string} The parsed raw string.
*/
function parseJson (json, parent) {
if (typeof json === 'string') {
json = { text: json }
} else if (Array.isArray(json)) {
const root = json.shift()
root.extra = json
json = root
}
json.color ??= parent.color
json.bold ??= parent.bold
json.italic ??= parent.italic
json.underlined ??= parent.underlined
json.strikethrough ??= parent.strikethrough
json.obfuscated ??= parent.obfuscated
let raw = ''
// if (json.color.startsWith('#'))
// raw += '§' + color
// else
raw += colormap[json.color] || ''
if (json.bold) raw += '§l'
if (json.italic) raw += '§o'
if (json.underlined) raw += '§n'
if (json.strikethrough) raw += '§m'
if (json.obfuscated) raw += '§k'
if (json.text) {
raw += json.text
}
if (json.translate) { // I checked with the native minecraft code. This is how Minecraft does the matching and group indexing. -hhhzzzsss
let format = language[json.translate]
if (typeof format !== 'string') format = json.fallback
if (typeof format !== 'string') format = json.translate
const _with = json.with ?? []
let i = 0
raw += format.replace(/%(?:(\d+)\$)?(s|%)/g, (g0, g1) => {
if (g0 === '%%') {
return '%'
} else {
const idx = g1 ? parseInt(g1) : i++
if (_with[idx]) {
return parseJson(_with[idx], json)
} else {
return ''
}
}
})
}
if (json.extra) {
json.extra.forEach((extra) => {
raw += parseJson(extra, json)
})
}
return raw
}
module.exports = parseText