diff --git a/bot.js b/bot.js index 613e3b0..53d6ebe 100644 --- a/bot.js +++ b/bot.js @@ -52,6 +52,7 @@ function createBot (options = {}) { bot.autoReconnect = options.autoReconnect bot.randomizeUsername = options.randomizeUsername bot['online-mode'] = options['online-mode'] + // set the client and add listeners bot.on('set_client', (client) => { client.on('connect', () => bot.emit('connect')) diff --git a/index.js b/index.js index 71ab981..87fb1bc 100644 --- a/index.js +++ b/index.js @@ -1,57 +1,54 @@ -const readline = require('readline') -const createBot = require('./bot.js') -const fs = require('fs') +const fs = require('fs/promises') const path = require('path') -const moment = require('moment') +const readline = require('readline') const json5 = require('json5') const matrix = require('matrix-js-sdk') -const configPath = process.argv[2] ?? 'config.json5' -if (!fs.existsSync(configPath)) { - fs.copyFileSync(path.join(__dirname, 'default.json5'), configPath) - console.info('No config file was found, so a default one was created.') -} -const config = json5.parse(fs.readFileSync(configPath, 'utf-8')) +const createBot = require('./bot.js') +const fileExists = require('./util/file_exists') +const Console = require('./util/console') -const logdir = config.paths.logs ?? 'logs' -if (!fs.existsSync(logdir)) fs.mkdirSync(logdir) +async function main () { + // config loading + const configPath = process.argv[2] ?? 'config.json5' + if (!await fileExists(configPath)) { + await fs.copyFile(path.join(__dirname, 'default.json5'), configPath) + console.info('No config file was found, so a default one was created') + } + const config = json5.parse(await fs.readFile(configPath, 'utf-8')) -let logfile = path.join(logdir, moment().format('YYYY-MM-DD')) -if (fs.existsSync(logfile)) { - const pathWithSeparator = logfile + '-' - let i = 0 - while (fs.existsSync(logfile)) { - logfile = pathWithSeparator + (i++) + // logging + const logdir = config.paths.logs ?? 'logs' + if (!await fileExists(logdir)) await fs.mkdir(logdir) + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + prefix: '> ' + }) + + const console = new Console({ readline: rl }) + await console.createLogFile(logdir) + + const matrixClients = {} + for (const key in config.matrixClients) { + const client = matrix.createClient(config.matrixClients[key]) + matrixClients[key] = client + + client.startClient() + } + + const bots = [] + for (const options of config.bots) { + const mergedOptions = { console, ...(config.all ?? {}), ...options } + if (mergedOptions.matrix && typeof mergedOptions.matrix.client !== 'object') mergedOptions.matrix.client = matrixClients[mergedOptions.matrix.client] + + const bot = createBot(mergedOptions) + bots.push(bot) + bot.bots = bots + + bot.on('error', error => bot.console.error(error)) } } -logfile += '.log' -fs.writeFileSync(logfile, '') -const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - prefix: '> ' -}) - -const matrixClients = {} -for (const key in config.matrixClients) { - const client = matrix.createClient(config.matrixClients[key]) - matrixClients[key] = client - - client.startClient() -} - -const bots = [] -for (const options of config.bots) { - const mergedOptions = { ...(config.all ?? {}), ...options } - if (mergedOptions.matrix && typeof mergedOptions.matrix.client !== 'object') mergedOptions.matrix.client = matrixClients[mergedOptions.matrix.client] - - const bot = createBot(mergedOptions) - bots.push(bot) - bot.bots = bots - - bot.on('error', console.error) - - bot.console.filepath = logfile - bot.console.setRl(rl) -} +main() diff --git a/plugins/!console.js b/plugins/!console.js index 70145ff..749966b 100644 --- a/plugins/!console.js +++ b/plugins/!console.js @@ -1,77 +1,27 @@ const fs = require('fs') const util = require('util') -const colorCodeStringify = require('../util/chat/stringify/color_code') -const ansiStringify = require('../util/chat/stringify/ansi') const CommandSource = require('../util/command/command_source') +const Console = require('../util/console') -function inject (bot) { - bot.console = { - filepath: null, - host: 'all', - log, - warn, - error, - _log, - setRl, - _rl: null - } - function log (data) { - _log('\u00a72INFO', process.stdout, data) - } - function warn (data) { - _log('\u00a7eWARN', process.stderr, data) - } - function error (data) { - _log('\u00a7cERROR', process.stderr, data) - } - function _log (prefix, stdout, data) { - // format it - const _prefix = `[${formatDate()} ${prefix}\u00a7r] [${bot.host}] ` - const stringifyOptions = { lang: bot.registry?.language ?? {} } +function inject (bot, options) { + bot.console = options.console ? options.console.fork(bot.host) : new Console({ namespace: bot.host }) - const formattedData = _prefix + colorCodeStringify(data, stringifyOptions) + '\n' - const ansi = ansiStringify(_prefix, stringifyOptions) + ansiStringify(data, stringifyOptions) + '\x1b[0m\n' + bot.on('registry_loaded', () => (bot.console.language = bot.registry.language)) - // log to file - const filepath = bot.console.filepath - if (filepath != null) { - fs.appendFile(filepath, formattedData, err => { - if (err) console.error(err) - }) + bot.console.on('line', handleLine) + + function handleLine (line) { + // if (bot.host !== bot.console.host && bot.console.host !== 'all') return + if (line.startsWith('.')) { + const source = new CommandSource({ bot, permissionLevel: Infinity, sendFeedback, displayName: '_ChipMC_' }) + bot.commands.execute(line.substring(1), source) + } else { + bot.fancyMsg('test', '_ChipMC_', line) } - - // log to stdout - 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) { - rl?.prompt(true) - rl?.on('line', handleLine) - // bot.console._rl?.removeListener('line', handleLine) - bot.console._rl = rl - - function handleLine (line) { - if (bot.host !== bot.console.host && bot.console.host !== 'all') return - if (line.startsWith('.')) { - const source = new CommandSource({ bot, permissionLevel: Infinity, sendFeedback, displayName: 'console' }) - bot.commands.execute(line.substring(1), source) - } else { - bot.fancyMsg('test', '_ChipMC_', line) - rl?.prompt(true) - } - } - - function sendFeedback (text, sendFeedback) { - bot.console.log(text) - } + function sendFeedback (text, broadcase) { + bot.console.log(text) } } diff --git a/util/console.js b/util/console.js new file mode 100644 index 0000000..28928c2 --- /dev/null +++ b/util/console.js @@ -0,0 +1,179 @@ +const fs = require('fs') +const path = require('path') +const zlib = require('zlib') +const stream = require('stream/promises') +const EventEmitter = require('events') + +const fileExists = require('./file_exists') +const colorCodeStringify = require('../util/chat/stringify/color_code') +const ansiStringify = require('../util/chat/stringify/ansi') + +class Console extends EventEmitter { + #writeStream = null + #forks = [] + + logdir = null + filename = null + parent = null + + constructor ({ lang = {}, namespace = '', stdout = process.stdout, stderr = process.stderr, readline = null } = {}) { + super() + + this.language = lang + this.namespace = namespace + this.stdout = stdout + this.stderr = stderr + this.readline = readline + + this.readline?.on('line', line => { + this.emit('line', line) + this.readline.prompt(true) + }) + + this.on('local_error', error => this.emit('error', error)) + + this.on('error', error => this.#forks.forEach(fork => fork.emit('error', error))) + this.on('line', line => this.#forks.forEach(fork => fork.emit('line', line))) + this.on('end', () => this.#forks.forEach(fork => fork.emit('end'))) + } + + info (text) { + this.#log(text, '\xa72INFO', this.stdout) + } + + warn (text) { + this.#log(text, '\xa7eWARN', this.stderr) + } + + error (text) { + this.#log(text, '\xa7cERROR', this.stderr) + } + + log (text) { + this.info(text) + } + + #log (text, prefix, stdout) { + const raw = [`[${formatTimestamp()} ${prefix}\xa7r] `, text] + if (this.namespace) raw[0] += `[${this.namespace}] ` + + const options = { lang: this.language } + const colorStr = colorCodeStringify(raw, options) + const ansiStr = ansiStringify(raw, options) + + if (stdout) stdout.write(ansiStr + '\x1b[0m\n') + this.#writeToLog(colorStr + '\n') + } + + #writeToLog (text) { + if (this.parent) return this.parent.#writeToLog(text) + this.#writeStream?.write(text) + } + + fork (namespace) { + if (this.namespace) namespace = this.namespace + '/' + namespace + + const fork = new Console({ lang: this.language, namespace }) + fork.parent = this + + this.#forks.push(fork) + fork.on('end', () => (this.#forks = this.#forks.filter(_fork => _fork !== fork))) + + return fork + } + + end () { + this.#writeStream?.end() + this.stdout = null + this.stderr = null + this.parent = null + + this.emit('end') + } + + async createLogFile (logdir) { + this.logdir = logdir + const filename = path.join(logdir, 'latest.log') + + if (await fileExists(filename)) { + const inStream = fs.createReadStream(filename) + let outStreamRaw + let outStreamGzip + + inStream.on('data', chunk => { + if (!outStreamRaw) { + // Attempt to read the first line (assumed to have a date) + let idx = -1 + for (let i = 0; i < chunk.length; i++) { + if (chunk[i] === 0x0a || chunk[i] === 0x0d) { + idx = i + 1 + break + } + } + if (chunk[idx] === 0x0d && chunk[idx + 1] === 0x0a) idx++ + + let date + if (idx !== -1) { + date = chunk.subarray(0, idx).toString().trim() + + // sanity checking + const parts = date.split('-').map(Number) + if (parts.length !== 3) idx = -1 + else if (parts.some(part => Number.isNaN(part))) idx = -1 + } + + if (idx !== -1) { + // The sanity checks passed, so we can skip the date now + chunk = chunk.subarray(idx) + } else { + // stat the file for the creation date + const stat = fs.statSync(filename) + date = formatDate(stat.birthtime) + } + + // handle duplicates + const prefix = this.logdir + '/' + const suffix = '.log.gz' + let outFilename = prefix + date + suffix + let count = 1 + while (fs.existsSync(outFilename)) { + outFilename = prefix + date + '-' + count++ + suffix + } + + // so much for just some output streams... + outStreamRaw = fs.createWriteStream(outFilename) + outStreamGzip = zlib.createGzip() + + outStreamGzip.on('data', chunk => outStreamRaw.write(chunk)) + outStreamGzip.on('end', () => outStreamRaw.end()) + } + + outStreamGzip.write(chunk) + }) + + await stream.finished(inStream) + outStreamGzip?.end() + } + + this.filename = filename + this.#writeStream = fs.createWriteStream(filename) + this.#writeStream.setDefaultEncoding('utf-8') + this.#writeStream.write(formatDate() + '\n') + } +} + +function formatDate (date = new Date()) { + const year = date.getFullYear() + const month = date.getMonth() + 1 + const day = date.getDay() + return `${year.toString().padStart(4, '0')}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}` +} + +function formatTimestamp (date = new Date()) { + const hours = date.getHours() + const minutes = date.getMinutes() + const seconds = date.getSeconds() + return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}` +} + +module.exports = Console diff --git a/util/file_exists.js b/util/file_exists.js new file mode 100644 index 0000000..48c1494 --- /dev/null +++ b/util/file_exists.js @@ -0,0 +1,12 @@ +const fs = require('fs/promises') + +async function fileExists (filename) { + try { + await fs.stat(filename) + return true + } catch { + return false + } +} + +module.exports = fileExists