import path from 'path'; import net from 'net'; import { fork } from 'child_process'; import { Plugin, Configurable, EventListener, DependencyLoad, } from '@squeebot/core/lib/plugin'; import { IMessage, MessageResolver } from '@squeebot/core/lib/types'; import { httpGET, parseTimeToSeconds, readableTime, } from '@squeebot/core/lib/common'; import { logger } from '@squeebot/core/lib/core'; import { createUnitIndex, getUnitAbbreviation, hexToRgb, rgbToHex, RGBToHSL, convert, wipeCaches, ASCIIBinaryConverter, ASCIIHexConverter, base64Converter, baseConverter, } from './convert'; type CEXResponse = { [key: string]: number }; // Run mathjs in a separate thread to avoid the killing of the main process function opMath(expression: string): Promise { return new Promise((resolve, reject) => { // Fork the script const mathThread = fork(path.join(__dirname, 'math.js')); let done = false; // Time the request out when user enters something too complex const timeItOut = setTimeout(() => { mathThread.kill('SIGKILL'); done = true; return reject(new Error('Timed out')); }, 8000); // Send data to the thread to process mathThread.send(expression); // Recieve data mathThread.on('message', (chunk) => { clearTimeout(timeItOut); if (done) { return; } const line = chunk.toString().trim(); if (line.length > 280) { return reject(new Error('The response was too large')); } done = true; if (line === 'null') { return reject(new Error('Nothing was returned')); } resolve(line); }); mathThread.on('exit', () => { clearTimeout(timeItOut); if (!done) { reject(new Error('Nothing was returned')); } }); }); } function pingTcpServer(host: string, port: number): Promise { return new Promise((resolve, reject) => { let isFinished = false; let timeA = new Date().getTime(); const timeB = new Date().getTime(); function returnResults(status: boolean, info: number | Error): void { if (!isFinished) { isFinished = true; if (info instanceof Error) { return reject(info); } resolve(info); } } const pingHost = net.connect({ port, host }, () => { timeA = new Date().getTime(); returnResults(true, timeA - timeB); pingHost.end(); pingHost.destroy(); }); pingHost.setTimeout(5000); pingHost.on('timeout', () => { pingHost.end(); pingHost.destroy(); returnResults(false, new Error('timeout')); }); pingHost.on('error', (e) => { pingHost.end(); pingHost.destroy(); returnResults(false, e); }); pingHost.on('close', () => { returnResults(false, new Error('closed')); }); }); } function addCommands(plugin: UtilityPlugin, commands: any): void { const cmds = []; cmds.push({ name: 'binary', execute: async ( msg: IMessage, msr: MessageResolver, spec: any, prefix: string, ...simplified: any[] ): Promise => { try { const result = ASCIIBinaryConverter(msg.text, simplified); msg.resolve(`> ${result}`); } catch (e: any) { msg.resolve('Failed to convert.'); } return true; }, description: 'Encode/decode binary (ASCII only)', usage: ' ', }); cmds.push({ name: 'hexstr', execute: async ( msg: IMessage, msr: MessageResolver, spec: any, prefix: string, ...simplified: any[] ): Promise => { try { const result = ASCIIHexConverter(msg.text, simplified); msg.resolve(`> ${result}`); } catch (e: any) { msg.resolve('Failed to convert.'); } return true; }, description: 'Encode/decode hexadecimal (ASCII only)', usage: ' ', }); cmds.push({ name: 'base64', execute: async ( msg: IMessage, msr: MessageResolver, spec: any, prefix: string, ...simplified: any[] ): Promise => { try { const result = base64Converter(msg.text, simplified); msg.resolve(`> ${result}`); } catch (e: any) { msg.resolve('Failed to convert.'); } return true; }, description: 'Encode/decode base64 (ASCII only)', usage: ' ', }); cmds.push({ name: 'numsys', execute: async ( msg: IMessage, msr: MessageResolver, spec: any, prefix: string, ...simplified: any[] ): Promise => { try { const result = baseConverter(simplified); msg.resolve(`> ${result}`); } catch (e: any) { msg.resolve('Failed to convert.'); } return true; }, description: 'Convert a value into a value in another numbering system.', usage: ' ', aliases: ['convertnumbers', 'cvnums'], }); cmds.push({ name: 'convertseconds', execute: async ( msg: IMessage, msr: MessageResolver, spec: any, prefix: string, ...simplified: any[] ): Promise => { msg.resolve(readableTime(parseInt(simplified[0], 10))); return true; }, description: 'Convert seconds to years days hours minutes seconds.', usage: '', aliases: ['cvs', 'parseseconds'], }); cmds.push({ name: 'converttime', execute: async ( msg: IMessage, msr: MessageResolver, spec: any, prefix: string, ...simplified: any[] ): Promise => { const str = msg.text.split(' ').slice(1).join(' '); if (!str) { msg.resolve('Invalid input'); return true; } msg.resolve(parseTimeToSeconds(str), 'seconds'); return true; }, description: 'Convert ywdhms to seconds.', usage: '[y] [w] [d] [h] [m] [s]', aliases: ['cvt', 'parsetime'], }); cmds.push({ name: 'reconverttime', execute: async ( msg: IMessage, msr: MessageResolver, spec: any, prefix: string, ...simplified: any[] ): Promise => { const str = msg.text.split(' ').slice(1).join(' '); if (!str) { msg.resolve('Invalid input'); return true; } const sec = parseTimeToSeconds(str); msg.resolve(readableTime(sec)); return true; }, aliases: ['rcvt'], }); cmds.push({ name: 'eval', execute: async ( msg: IMessage, msr: MessageResolver, spec: any, prefix: string, ...simplified: any[] ): Promise => { if (!simplified[0]) { return true; } const wholeRow = msg.text.split(' ').slice(1).join(' '); try { const repl = await opMath(wholeRow); msg.resolve(repl); } catch (e: any) { msg.resolve('Could not evaluate expression:', e.message); } return true; }, aliases: ['math', 'calc'], usage: '', description: 'Evaluate a math expression (See https://mathjs.org/)', }); cmds.push({ name: 'userid', execute: async ( msg: IMessage, msr: MessageResolver, spec: any, prefix: string, ...simplified: any[] ): Promise => { msg.resolve('Your userId is %s.', msg.fullSenderID); return true; }, description: 'Display your userId (internal user identification)', hidden: true, }); cmds.push({ name: 'roomid', execute: async ( msg: IMessage, msr: MessageResolver, spec: any, prefix: string, ...simplified: any[] ): Promise => { msg.resolve('Current roomId is %s.', msg.fullRoomID); return true; }, description: 'Display the internal identification of this room', hidden: true, }); cmds.push({ name: 'serverid', execute: async ( msg: IMessage, msr: MessageResolver, spec: any, prefix: string, ...simplified: any[] ): Promise => { if (msg.target && msg.target.server) { msg.resolve('Current server ID is s:%s.', msg.target.server); return true; } msg.resolve( 'This protocol does not specify a server. ' + 'Either the protocol is for a single server only or the server variable is not supported.' ); return true; }, description: 'Display the internal identification of this room', hidden: true, }); cmds.push({ name: 'rgb2hex', execute: async ( msg: IMessage, msr: MessageResolver, spec: any, prefix: string, ...simplified: any[] ): Promise => { if (!simplified[0]) { return true; } const fullmsg = msg.text.split(' ').slice(1).join(' '); const channels = fullmsg.match( /(rgb)?\(?(\d{1,3}),?\s(\d{1,3}),?\s(\d{1,3})\)?/i ); if (!channels || channels[2] == null) { msg.resolve('Invalid parameter'); return true; } const r = parseInt(channels[2], 10); const g = parseInt(channels[3], 10); const b = parseInt(channels[4], 10); if (r > 255 || g > 255 || b > 255) { msg.resolve('Invalid colors'); return true; } msg.resolve(rgbToHex(r, g, b)); return true; }, description: 'Convert RGB to HEX colors', usage: '[rgb](, , )| ', }); cmds.push({ name: 'rgb2hsl', execute: async ( msg: IMessage, msr: MessageResolver, spec: any, prefix: string, ...simplified: any[] ): Promise => { if (!simplified[0]) { return true; } const fullmsg = msg.text.split(' ').slice(1).join(' '); const channels = fullmsg.match( /(rgb)?\(?(\d{1,3}),?\s(\d{1,3}),?\s(\d{1,3})\)?/i ); if (!channels || channels[2] == null) { msg.resolve('Invalid parameter'); return true; } const r = parseInt(channels[2], 10); const g = parseInt(channels[3], 10); const b = parseInt(channels[4], 10); if (r > 255 || g > 255 || b > 255) { msg.resolve('Invalid colors'); return true; } const hsl = RGBToHSL(r, g, b); msg.resolve('hsl(%d, %d%, %d%)', hsl.h, hsl.s, hsl.l); return true; }, description: 'Convert RGB to HSL colors', usage: '[rgb](, , )| ', }); cmds.push({ name: 'hex2rgb', execute: async ( msg: IMessage, msr: MessageResolver, spec: any, prefix: string, ...simplified: any[] ): Promise => { if (!simplified[0]) { return true; } let hexcode = simplified[0]; if (hexcode.indexOf('#') === -1) { hexcode = '#' + hexcode; } if (hexcode.length !== 4 && hexcode.length !== 7) { msg.resolve('Invalid length'); return true; } const rgb = hexToRgb(hexcode); if (!rgb) { msg.resolve('Invalid HEX notation'); return true; } msg.resolve('rgb(%d, %d, %d)', rgb.r, rgb.g, rgb.b); return true; }, description: 'Convert HEX to RGB colors', usage: '#|#', }); cmds.push({ name: 'isup', execute: async ( msg: IMessage, msr: MessageResolver, spec: any, prefix: string, ...simplified: any[] ): Promise => { if (!simplified[0]) { msg.resolve('Please specify host name!'); return true; } if (!simplified[1]) { msg.resolve('Please specify port!'); return true; } const host = simplified[0]; const port = parseInt(simplified[1], 10); if (isNaN(port) || port <= 0 || port > 65535) { msg.resolve('Invalid port number!'); return true; } let statusString = msg.source.format.format('bold', 'closed'); let status; try { status = await pingTcpServer(host, port); statusString = msg.source.format.format('bold', 'open'); } catch (e: any) { status = e.message; } if (!isNaN(parseFloat(status))) { status = status + ' ms'; } msg.resolve(`Port ${port} on ${host} is ${statusString} (${status})`); return true; }, description: 'Ping a host', usage: ' ', aliases: ['tcpup', 'tping'], hidden: true, }); cmds.push({ name: 'convert', execute: async ( msg: IMessage, msr: MessageResolver, spec: any, prefix: string, ...simplified: any[] ): Promise => { const tqnt = parseFloat(simplified[0]); if (isNaN(tqnt)) { msg.resolve('Please specify a quantity, either an integer or a float!'); return true; } let src = simplified[1]; let dst = simplified[2]; if (dst && dst.toLowerCase() === 'to') { dst = simplified[3]; } if (!src) { msg.resolve('Please specify source unit!'); return true; } if (!dst) { msg.resolve('Please specify destination unit!'); return true; } let res = null; src = getUnitAbbreviation(src); dst = getUnitAbbreviation(dst); if (!src) { msg.resolve('Source unit not found!'); return true; } if (!dst) { msg.resolve('Destination unit not found!'); return true; } try { res = convert(tqnt).from(src).to(dst); } catch (e) { res = null; } if (res) { const srcdesc = convert().describe(src); const dstdesc = convert().describe(dst); const bsrcdesc = Math.floor(tqnt) !== 1 ? srcdesc.plural : srcdesc.singular; const bdstdesc = Math.floor(res) !== 1 ? dstdesc.plural : dstdesc.singular; msg.resolve(` ${tqnt} ${bsrcdesc} => ${res} ${bdstdesc}`); return true; } msg.resolve('Failed to convert.'); return true; }, description: 'Convert between quantities in different units.', usage: ' ', aliases: ['cv', 'unit'], }); cmds.push({ name: 'currency', execute: async ( msg: IMessage, msr: MessageResolver, spec: any, prefix: string, ...simplified: any[] ): Promise => { let cexData: CEXResponse | null = plugin.cexCache.cache as CEXResponse; const currentStamp = new Date().toISOString().replace(/T(.*)$/, ''); if (plugin.cexCache.expiry < Date.now()) { let fetched; try { const data = await httpGET(`https://api.exchangerate.host/latest?v=${currentStamp}`); fetched = JSON.parse(data); logger.log('[utility] Fetched currency exchange rates successfully.'); } catch (e) { fetched = null; } if (!fetched?.rates) { msg.resolve( 'Could not fetch currency exchange rates at this time. Please try again later.' ); return true; } Object.assign(plugin.cexCache, { cache: fetched.rates, date: fetched.date, expiry: Date.now() + 86400000 / 2 // half-day }); cexData = fetched.rates as CEXResponse; } if (simplified[0] === 'date') { msg.resolve( 'Currency exchange rates are as of %s', plugin.cexCache.date ); return true; } else if (simplified[0] === 'list') { msg.resolve( 'Currently supported currencies: %s', Object.keys(cexData).join(', ') ); return true; } const inputValue = parseFloat(simplified[0]); let fromCurrency = simplified[1]; let toCurrency = simplified[2]; if (isNaN(inputValue) || !fromCurrency || !toCurrency) { msg.resolve('Invalid parameters.'); return true; } fromCurrency = fromCurrency.toUpperCase(); toCurrency = toCurrency.toUpperCase(); if (fromCurrency !== 'EUR' && !cexData[fromCurrency]) { msg.resolve('This currency is currently not supported.'); return true; } if (toCurrency !== 'EUR' && !cexData[toCurrency]) { msg.resolve('This currency is currently not supported.'); return true; } if (fromCurrency === toCurrency) { msg.resolve('%f %s', inputValue, fromCurrency); return true; } const resultValue = ((cexData[toCurrency] * inputValue) / cexData[fromCurrency]).toFixed(4); const conversionRate = (cexData[toCurrency] / cexData[fromCurrency]).toFixed(4); const conversionRateString = inputValue !== 1 ? `(1 = ${conversionRate})` : ''; msg.resolve(`${inputValue} ${fromCurrency} => ${resultValue} ${toCurrency} ${conversionRateString}`); return true; }, description: 'Convert between currencies.', usage: ' | [date | list] [] []', aliases: ['cex', 'exchange'], }); cmds.push({ name: 'randomnumber', execute: async ( msg: IMessage, msr: MessageResolver, spec: any, prefix: string, ...simplified: any[] ): Promise => { if (simplified.length < 2) { msg.resolve('Too few arguments!'); return true; } let min = parseInt(simplified[0], 10); let max = parseInt(simplified[1], 10); let count = parseInt(simplified[2], 10); const countMax = plugin.config.config.randomMax || 64; if (isNaN(min) || isNaN(max)) { msg.resolve('Invalid numbers.'); return true; } if (min > max) { const realMax = min + 0; min = max; max = realMax; } if (isNaN(count)) { count = 1; } if ( String(Math.abs(min)).length > 9 || String(Math.abs(max)).length > 9 ) { msg.resolve('The numbers are too large!'); return true; } if (count > countMax) { msg.resolve('Too many to generate. Maximum: ' + countMax); return true; } const numbers = []; for (let i = 0; i < count; i++) { numbers.push(Math.floor(Math.random() * (max - min + 1)) + min); } msg.resolve(numbers.join(' ')); return true; }, description: 'Generate a random number between and .', usage: ' []', aliases: ['rnum', 'rand', 'rng'], }); // FIXME: temporary code for removal cmds.push({ name: 'cmtest', execute: async ( msg: IMessage, msr: MessageResolver, spec: any, prefix: string, ...simplified: any[] ): Promise => { console.log(simplified); msg.resolve( `argument 1: '${simplified[0]}' argument 2: '${simplified[1]}'` ); return true; }, description: 'Test the command argument parser', usage: '', hidden: true, }); commands.registerCommand( cmds.map((x: any) => { x.plugin = plugin.manifest.name; return x; }) ); } @Configurable({ ipfsGateway: 'https://ipfs.io', randomMax: 64, }) class UtilityPlugin extends Plugin { public cexCache: { [key: string]: number | CEXResponse } = { expiry: 0, date: 0, cache: {}, }; @DependencyLoad('simplecommands') addCommands(cmd: any): void { addCommands(this, cmd); } @EventListener('pluginUnload') public unloadEventHandler(plugin: string | Plugin): void { if (plugin === this.name || plugin === this) { wipeCaches(); this.cexCache.expiry = 0; this.cexCache.date = 0; this.cexCache.cache = {}; this.emit('pluginUnloaded', this); } } initialize(): void { this.on('message', (msg: IMessage) => { // Pre-regex check if (msg.text.indexOf('ipfs://') === -1 && msg.text.indexOf('Qm') === -1) { return; } // IPFS urlify const mmatch = msg.text.match(/(?:ipfs:\/\/|\s|^)(Qm[\w\d]{44})(?:\s|$)/); if (mmatch && mmatch[1]) { msg.resolve(this.config.config.ipfsGateway + '/ipfs/' + mmatch[1]); } }); // Initialize list of units createUnitIndex(); } } module.exports = UtilityPlugin;