import path from 'path'; import net from 'net'; import cprog from 'child_process'; import configureMeasurements, { allMeasures } from 'convert-units'; 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'; type CEXResponse = {[key: string]: number}; const convert = configureMeasurements(allMeasures); const cexCache: {[key: string]: number | CEXResponse} = { expiry: 0, date: 0, cache: {}, }; const bases: {[key: number]: string[]} = { 2: ['bin', 'binary'], 8: ['oct', 'octal'], 10: ['dec', 'decimal'], 16: ['hex', 'hexadecimal'] }; function getBaseNum(base: string): number | null { let result = null; for (const i in bases) { const defs = bases[i]; if (defs.indexOf(base) === -1) { continue; } result = parseInt(i, 10); } if (result) { return result; } if (base.indexOf('b') !== 0) { return null; } const matcher = base.match(/b(?:ase-?)?(\d+)/); if (!matcher || !matcher[1] || isNaN(parseInt(matcher[1], 10))) { return null; } return parseInt(matcher[1], 10); } const urlRegex = /(((ftp|https?):\/\/)[-\w@:%_+.~#?,&//=]+)/g; const fork = cprog.fork; // 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 rgbToHex(r: number, g: number, b: number): string { // tslint:disable-next-line: no-bitwise return '#' + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1); } function RGBToHSL(r: number, g: number, b: number): {[key: string]: number} | null { // Make r, g, and b fractions of 1 r /= 255; g /= 255; b /= 255; // Find greatest and smallest channel values const cmin = Math.min(r, g, b); const cmax = Math.max(r, g, b); const delta = cmax - cmin; let h = 0; let s = 0; let l = 0; // Calculate hue // No difference if (delta === 0) { h = 0; } // Red is max else if (cmax === r) { h = ((g - b) / delta) % 6; } // Green is max else if (cmax === g) { h = (b - r) / delta + 2; } // Blue is max else { h = (r - g) / delta + 4; } h = Math.round(h * 60); // Make negative hues positive behind 360° if (h < 0) { h += 360; } // Calculate lightness l = (cmax + cmin) / 2; // Calculate saturation s = delta === 0 ? 0 : delta / (1 - Math.abs(2 * l - 1)); // Multiply l and s by 100 s = +(s * 100).toFixed(1); l = +(l * 100).toFixed(1); return { h, s, l }; } function hexToRgb(hex: string): {[key: string]: number} | null { // Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF") const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i; hex = hex.replace(shorthandRegex, (m, r, g, b) => { return r + r + g + g + b + b; }); const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); return result ? { r: parseInt(result[1], 16), g: parseInt(result[2], 16), b: parseInt(result[3], 16) } : null; } 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 => { let response = ''; let strArr; let i; let text = msg.text.split(' ').slice(2).join(' '); try { switch (simplified[0] ? simplified[0].toUpperCase() : null) { case 'ENCODE': strArr = text.split(''); for (i in strArr) { response += ' ' + ('0000000' + parseInt(Buffer.from(strArr[i].toString(), 'utf8').toString('hex'), 16).toString(2)).slice(-8); } response = response.substr(1); break; case 'DECODE': text = text.split(' ').join(''); i = 0; while (8 * (i + 1) <= text.length) { response += Buffer.from(parseInt(text.substr(8 * i, 8), 2).toString(16), 'hex').toString('utf8'); i++; } response = 'Decoded: ' + response.replace(/\n/g, '\\n').replace(/\r/g, '\\r'); } } catch (e) { msg.resolve('Operation failed.'); return true; } msg.resolve(response); 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 => { let response = ''; let i; let text = msg.text.split(' ').slice(2).join(' '); try { switch (simplified[0] ? simplified[0].toUpperCase() : null) { case 'DECODE': text = text.replace(/\s/g, ''); for (i = 0; i < text.length; i += 2) { response += String.fromCharCode(parseInt(text.substr(i, 2), 16)); } response = 'Decoded: ' + response.replace(/\n/g, '\\n').replace(/\r/g, '\\r'); break; case 'ENCODE': for (i = 0; i < text.length; i++) { response += text.charCodeAt(i).toString(16) + ' '; } break; } } catch (e) { msg.resolve('Operation failed.'); return true; } msg.resolve(response); 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 => { let response = ''; const text = msg.text.split(' ').slice(2).join(' '); try { switch (simplified[0] ? simplified[0].toUpperCase() : null) { case 'DECODE': response = 'Decoded: ' + (Buffer.from(text, 'base64').toString('ascii')).replace(/\n/g, '\\n').replace(/\r/g, '\\r'); break; case 'ENCODE': response = Buffer.from(text).toString('base64'); break; } } catch (e) { msg.resolve('Operation failed.'); return true; } msg.resolve(response); 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 => { if (simplified.length < 3) { msg.resolve('Too few arguments!'); return true; } const input = simplified[0]; const src = simplified[1].toLowerCase(); const dst = simplified[2].toLowerCase(); const srcBase = getBaseNum(src); const dstBase = getBaseNum(dst); if (!srcBase || !dstBase || dstBase > 36 || dstBase < 2 || srcBase > 36 || srcBase < 2) { msg.resolve('Invalid conversion.'); return false; } const decimal = parseInt(input, srcBase); const result = decimal.toString(dstBase); msg.resolve('Result:', result); 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; } const 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; 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 (abbreviations only).', usage: ' ', aliases: ['cv', 'unit'] }); cmds.push({ name: 'currency', execute: async (msg: IMessage, msr: MessageResolver, spec: any, prefix: string, ...simplified: any[]): Promise => { let ctw: CEXResponse | null = cexCache.cache as CEXResponse; if (cexCache.expiry < Date.now()) { let fetched; try { const data = await httpGET('https://api.exchangerate.host/latest'); fetched = JSON.parse(data); logger.log('[utility] Fetched currency exchange rates successfully.'); } catch (e) { ctw = null; } if (!ctw || !fetched.rates) { msg.resolve('Could not fetch currency exchange rates at this time. Please try again later.'); return true; } cexCache.cache = fetched.rates; cexCache.date = fetched.date; cexCache.expiry = Date.now() + 86400000; // day ctw = cexCache.cache as CEXResponse; } if (simplified[0] === 'date') { msg.resolve('Currency exchange rates are as of %s', cexCache.date); return true; } else if (simplified[0] === 'list') { msg.resolve('Currently supported currencies: EUR, %s', Object.keys(cexCache.cache).join(', ')); return true; } const n = parseFloat(simplified[0]); let f = simplified[1]; let t = simplified[2]; if (isNaN(n) || !f || !t) { msg.resolve('Invalid parameters.'); return true; } f = f.toUpperCase(); t = t.toUpperCase(); if (f !== 'EUR' && !ctw[f]) { msg.resolve('This currency is currently not supported.'); return true; } if (t !== 'EUR' && !ctw[t]) { msg.resolve('This currency is currently not supported.'); return true; } if (f === t) { msg.resolve('%f %s', n, f); return true; } let ramnt: string; if (f === 'EUR') { ramnt = (n * ctw[t]).toFixed(4); msg.resolve('%f EUR => %f %s', n, ramnt, t); return true; } else if (t === 'EUR') { ramnt = (n / ctw[f]).toFixed(4); msg.resolve('%f %s => %f EUR', n, f, ramnt); return true; } const amnt = (ctw[t] * n / ctw[f]).toFixed(4); msg.resolve('%f %s => %f %s', n, f, amnt, t); 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 { @DependencyLoad('simplecommands') addCommands(cmd: any): void { addCommands(this, cmd); } @EventListener('pluginUnload') public unloadEventHandler(plugin: string | Plugin): void { if (plugin === this.name || plugin === this) { 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]); } }); } } module.exports = UtilityPlugin;