diff --git a/src/minecraft_coords_table.js b/src/minecraft_coords_table.js new file mode 100644 index 0000000..75c3ac4 --- /dev/null +++ b/src/minecraft_coords_table.js @@ -0,0 +1,91 @@ +const DISCORD_MESSAGE_LIMIT = 2000; +const CODEBLOCK_FENCE_LENGTH = '```\n'.length + '\n```'.length; +const MAX_TABLE_LENGTH = DISCORD_MESSAGE_LIMIT - CODEBLOCK_FENCE_LENGTH; + +const NO_WAYPOINTS_MESSAGE = 'No publicly shared waypoints available.'; + +const COLUMNS = ['owner', 'name', 'world', 'coords']; +const HEADERS = ['Owner', 'Name', 'World', '(x, y, z)']; + +function getDateString() { + const date = new Date(); + const pad = (n) => String(n).padStart(2, '0'); + return `${pad(date.getDate())}/${pad(date.getMonth() + 1)}/${date.getFullYear()}`; +} + +function parseWaypointsCsv(csv) { + const [, ...rows] = csv.split('\n').filter((line) => line.length > 0); + return rows + .filter((row) => row.split(',').length >= 6) + .map((row) => { + const [owner, name, world, x, y, z] = row.split(','); + return { owner, name, world, x, y, z }; + }); +} + +// Mirrors the table's previous merged-cell look: blank out an Owner/World +// cell when it repeats the value directly above it. +function collapseRepeatedColumns(waypoints) { + let lastOwner = ''; + let lastWorld = ''; + return waypoints.map((waypoint) => { + const row = { + owner: waypoint.owner === lastOwner ? '' : waypoint.owner, + name: waypoint.name, + world: waypoint.world === lastWorld ? '' : waypoint.world, + coords: `(${waypoint.x}, ${waypoint.y}, ${waypoint.z})`, + }; + lastOwner = waypoint.owner; + lastWorld = waypoint.world; + return row; + }); +} + +function buildTableLines(rows) { + const widths = COLUMNS.map((column, i) => Math.max(HEADERS[i].length, ...rows.map((row) => row[column].length))); + const buildSeparator = () => `+${widths.map((width) => '-'.repeat(width + 2)).join('+')}+`; + const buildRow = (cells) => `| ${cells.map((cell, i) => cell.padEnd(widths[i])).join(' | ')} |`; + + const separator = buildSeparator(); + const headerLines = [separator, buildRow(HEADERS), separator]; + const dataLines = rows.map((row) => buildRow(COLUMNS.map((column) => row[column]))); + + return { headerLines, dataLines, separator }; +} + +// Packs the table into as few codeblocks as fit Discord's message length +// limit, repeating the header in every continuation block. +function packIntoCodeblocks(headerLines, dataLines, separator, titleLine) { + const chunks = []; + let current = [titleLine, ...headerLines]; + let hasRows = false; + + dataLines.forEach((dataLine) => { + const candidateLength = [...current, dataLine, separator].join('\n').length; + if (hasRows && candidateLength > MAX_TABLE_LENGTH) { + chunks.push([...current, separator].join('\n')); + current = [...headerLines]; + hasRows = false; + } + current.push(dataLine); + hasRows = true; + }); + + chunks.push([...current, separator].join('\n')); + return chunks.map((chunk) => `\`\`\`\n${chunk}\n\`\`\``); +} + +function buildCoordsMessages(csv) { + const waypoints = parseWaypointsCsv(csv); + if (waypoints.length === 0) { + return [`\`\`\`\n${NO_WAYPOINTS_MESSAGE}\n\`\`\``]; + } + + const rows = collapseRepeatedColumns(waypoints); + const { headerLines, dataLines, separator } = buildTableLines(rows); + const titleLine = `Publicly shared waypoints (updated: ${getDateString()})`; + + return packIntoCodeblocks(headerLines, dataLines, separator, titleLine); +} + +module.exports = { buildCoordsMessages }; diff --git a/src/robo_scorp.js b/src/robo_scorp.js index ac996a9..5080683 100644 --- a/src/robo_scorp.js +++ b/src/robo_scorp.js @@ -1,6 +1,7 @@ const { Client, GatewayIntentBits, Events } = require('discord.js'); const { MessageGenerator } = require('./message_generation/message_generator'); const { TcpServer } = require('./tcp_server'); +const { buildCoordsMessages } = require('./minecraft_coords_table'); const logger = require('./logger'); class RoboScorp { @@ -89,21 +90,27 @@ class RoboScorp { } #createTcpServer() { - this.#tcpServer = new TcpServer(process.env.TCP_CONNECTION_PORT, (tcpMessage) => { - this.#discordClient.channels - .fetch(process.env.MINECRAFT_COORDS_CHANNEL_ID) - .then((channel) => { - channel.messages - .fetch(process.env.MINECRAFT_COORDS_MESSAGE_ID) - .then((discordMessage) => { - const codeblock = '```\n'; - discordMessage.edit(codeblock + tcpMessage + codeblock); - }) - .catch(logger.error, RoboScorp.LogLabel.Discord); - }) - .catch(logger.error, RoboScorp.LogLabel.Discord); + this.#tcpServer = new TcpServer(process.env.TCP_CONNECTION_PORT, (csvMessage) => { + this.#updateCoordsChannel(csvMessage).catch((err) => logger.error(err, RoboScorp.LogLabel.Discord)); }); } + + async #updateCoordsChannel(csvMessage) { + logger.info(csvMessage); + const channel = await this.#discordClient.channels.fetch(process.env.MINECRAFT_COORDS_CHANNEL_ID); + + const previousMessages = await channel.messages.fetch({ limit: 100 }); + const ownMessages = previousMessages.filter((message) => message.author.id === this.#discordClient.user.id); + await Promise.allSettled(ownMessages.map((message) => message.delete())); + + const coordsMessages = buildCoordsMessages(csvMessage); + logger.info(coordsMessages); + for (const messageContent of coordsMessages) { + await channel.send(messageContent).catch((err) => { + logger.error(`Failed to send coords message (${messageContent.length} chars): ${err}`, RoboScorp.LogLabel.Discord); + }); + } + } } module.exports = { RoboScorp }; diff --git a/src/tcp_server.js b/src/tcp_server.js index 1f6ee76..0488fd1 100644 --- a/src/tcp_server.js +++ b/src/tcp_server.js @@ -18,8 +18,14 @@ class TcpServer { } #onClientConnection = (sock) => { + const chunks = []; + sock.on('data', (data) => { - const dataString = data.toString('utf8'); + chunks.push(data); + }); + + sock.on('end', () => { + const dataString = Buffer.concat(chunks).toString('utf8'); if (dataString.length > 0) { logger.debug(`Received message: ${dataString}`, TcpServer.LogLabel); this.#onMessageFunction(dataString);