Simp-O-Matic

Dumb Discord bot in TS.
git clone git://git.knutsen.co/Simp-O-Matic
Log | Files | Refs | README | LICENSE

commit c7f656f166503a2ee6832b4b89f4c7f95693f8c6
parent 3ed98527d9d9b82b1eec8695aaf1b538ab9fa697
Author: Demonstrandum <moi@knutsen.co>
Date:   Mon, 16 Mar 2020 16:49:54 +0000

Added history expansion, but just for commands.

Diffstat:
MHELP.md | 46+++++++++++++++++++++++++++++++++++++++++++---
Mbuild.sh | 2++
Rlib/api/web.ts -> lib/api/contextual.ts | 0
Mlib/default.ts | 16+++++++++++++++-
Mlib/extensions.ts | 53+++++++++++++++++++++++++++++++++++++++++++++++------
Alib/format_oed.ts | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mlib/main.ts | 253++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
Mlib/utils.ts | 4++--
Mpackage.json | 6++++--
9 files changed, 371 insertions(+), 79 deletions(-)

diff --git a/HELP.md b/HELP.md @@ -1,16 +1,35 @@ **KEY:** +How to read this help page (the notation it uses): + ` ! ` — is the standard command prefix. `[...]` — specifies an option/argument to the command (required). `<...>` — specifies an optional option/argument to the command (not-required). +`{a,b}` — represents a choice between a either writing `a` or `b` in its place. +` _ ` — represents the absence of a value at that particular place. ▬▬▬ -- `!help` — Shows this page. +- `!help` — Shows help messages. + - `!help all` — shows page of help for all commands. + - `!help <help>` — shows help on how to use the help command. + - `!help key` — shows how to read the help messages. + - `!help source` — shows information about the source code for this bot. + - `!help [!command]` — shows help on a certain command. +- `!!` — Expands into the previously issued command: + - `!!@ [@user-name]` — Expands into the previously issued command by that user. + - `!!^` — Expands into the previous message sent (this counts commands as being messages too, since they are). + - `!!^@` — Expands into the previously sent message by that user. + - `!!{^,@,^@,_}<ordinal-number>` — Works like all the `!!`-commands, but with an index number to get the nth-to-last message. + - **Example:** `!mock !!^@3 @Danny` — Repeats what `@Danny`'s 3rd-from-last message was back to him, but in a mocking way. + - **Example:** `!! hello` — Executes the command that had just been executed, but with an extra argument (namely: `hello`). - `!export` — Exports current configuration, and saves it. - `!prefix [new]` — Changes the prefix for sending this bot commands (default is `!`). Can only be one (1) character/symbol/grapheme/rune long. - `!ping` — Test the response-time/latency of the bot, by observing the time elapsed between the sending of this command, and the subsequent (one-word) response from the bot. - `!id <who>` — Print ID of user, or self if no-one is specified. +- `!uptime` — Display how long the bot has been running for. +- `!clear #[number-of-messages] <@user-name>` — Clear a number of messages, from latest sent in the current channel. Will delete any recent messages, unless a specific username is provided, in which case it will only clear messages sent from that user. - `!alias` — Manage aliases to commands: + - **Have a look at the aliases list, for alternative to long commands!** - `!alias ![the-alias] ![the-command]` — to add a new alias. - `!alias <ls>` — lists all aliases numerically. - `!alias rm ![the-alias]` — removes the alias by name. @@ -39,12 +58,33 @@ - `!cron [minute] [hour] [day-of-month] [month] [day-of-week] ![command] <...>` — runs a command (with or without arguments) repeatedly as specified by the schedule signature. - `!cron <ls>` — lists all active cron-jobs numerically. - `!cron rm #[job-index]` — removes a cron-job by index. +- `!choose [comma-separated-values]` — Choose randomly from a list of items, separated by commas. - `!define [word]` — Looks a word up in the Oxford English Dictionary. - `!urban [slang]` — Looks up a piece of slang in the _Urban Dictionary_. - `!search [web-search-terms]` — Performs a web-search and returns the most appropriate URL found. - `!image [image-search-terms]` — Searches for images specified by the terms given, and send a link to the most relevant one. -- `!news [news-search-term]` — Sends you the most relevant new on the specified topic area. +- `!gif [gif-search-terms]` — Searches for and returns a GIF matching your search. +- `!cat` — Pussycat pictures... +- `!news [news-search-term]` — Sends you the most relevant news on the specified topic area. - `!youtube [youtube-search-terms]` — Searches for and returns a relevant _YouTube_ video. +- `!wikipedia` — Search through Wikipedia, returning the most relevant wiki-link. +- `!translate <language> [phrase]` — Translate a phrase from a language (if none specified, it will auto-detect). +- `!wolfram` — Query Wolfram|Alpha. - `!say [phrase]` — Repeats what you told it to say. - `!milkies` — In case you're feeling thirsty... -- `!cowsay [phrase]` — Make a cow say something. +- `!cowsay <options> [phrase]` — Make a cow say something, using Unix-like command line arguments. +- `!figlet <options> [phrase]` — Print text in ASCII format, using Unix-like command line arguments. +- `!roll <upper-bound>` — Roll a dice, default upper bound is 6. +- `!summon [@user-name]` — Summon someone to the server by making the bot poke them in their DMs about it. +- `!mock [phrase]` — Say something, _bUt iN a MocKiNg WaY BaCk_... +- `!boomer [phrase]` — Say something, but in the way your demented boomer uncle would write it on Facebook. + +▬▬▬ + +**Source Code & Bugs:** + +- `!github` — Get GitHub link. (https://github.com/Demonstrandum/Simp-O-Matic) +- `!fork` — Fork the repository and send me a pull-request for your patches. (https://github.com/Demonstrandum/Simp-O-Matic/fork) +- `!issue` — Spot a bug, have an issue or want to request a new feature? There's a page for that. (https://github.com/Demonstrandum/Simp-O-Matic/issues) + +**Licenced under GNU GPLv3! _Free_ as in Freedom!** diff --git a/build.sh b/build.sh @@ -14,4 +14,6 @@ echo "${bold}Compiling TypeScript...${reset}" ./node_modules/.bin/tsc -b ./tsconfig.json +[ -f "./export_secrets.sh" ] && source ./export_secrets.sh + echo -e "\n${bold}Build done.${reset}" diff --git a/lib/api/web.ts b/lib/api/contextual.ts diff --git a/lib/default.ts b/lib/default.ts @@ -10,6 +10,7 @@ export default { commands: { prefix: '!', + max_history: 100, not_understood: "Command not understood", aliases: { 'img': 'image', @@ -17,6 +18,7 @@ export default { 'h': 'help', 's': 'search', 'yt': 'youtube', + 'y': 'youtube', 'd': 'define', 'oed': 'define', 'oxford': 'define', @@ -26,7 +28,19 @@ export default { 'whitelist': 'ignore whitelist', 'w': 'weather', 'reply': 'respond', - 'reject': 'delete' + 'reject': 'delete', + 'wa': 'wolfram', + 'wolf': 'wolfram', + 'toilet': 'figlet', + 'wiki': 'wikipedia', + 'aliases': 'alias', + 'boomerfy': 'boomer', + 'mocking': 'mock', + 'pull': 'fork', + 'git': 'github', + 'bug': 'issue', + 'source': 'github', + 'save': 'export' }, }, diff --git a/lib/extensions.ts b/lib/extensions.ts @@ -1,47 +1,88 @@ // Global Extensions: declare global { interface Array<T> { + head(): T + tail(): Array<T> + first(): T + last(off? : number | undefined): T unique(): Array<T> + squeeze(): Array<T> + each(callbackfn : (value : T, index : number, array : T[]) => void, thisArg? : T): void mut_unique(): Array<T> mut_map(f: (T) => any): Array<any> } + interface String { squeeze(): string capitalize(): string + leading_space(): string + head(): string + tail(): string + first(): string + last(off? : number): string } + interface Number { round_to(dp: number): number } - } // Array Extensions: +Array.prototype.head = function () { + return this[0]; +}; + +Array.prototype.first = Array.prototype.head; + +Array.prototype.tail = function () { + return this.slice(1); +}; + +Array.prototype.last = function (off=0) { + return this[this.length - 1 - off]; +}; + Array.prototype.unique = function () { return this.filter((e, i) => this.indexOf(e) === i) -} +}; + +Array.prototype.squeeze = function () { return this.filter(e => !!e); }; + +Array.prototype.each = Array.prototype.forEach; Array.prototype.mut_unique = function () { const uniq = this.unique(); Object.assign(this, uniq); this.splice(uniq.length); return this; -} +}; Array.prototype.mut_map = function (f) { for (const i in this) { this[i] = f(this[i]); } return this; -} +}; // String Extensions: String.prototype.squeeze = function () { - return this.split(/[ ]+/).join(' '); + return this + .replace(/[ ]+/g, ' ') + .replace(/\n[ ]/g, '\n'); +}; + +String.prototype.leading_space = function () { + return this.replace(/\n[ ]([^ ]+)/g, '\n$1'); }; String.prototype.capitalize = function () { return this.charAt(0).toUpperCase() + this.slice(1); -} +}; + +String.prototype.head = Array.prototype.head as any; +String.prototype.tail = Array.prototype.tail as any; +String.prototype.first = Array.prototype.first as any; +String.prototype.last = Array.prototype.last as any; // Number Extensions: Number.prototype.round_to = function (dp : number) { diff --git a/lib/format_oed.ts b/lib/format_oed.ts @@ -0,0 +1,70 @@ +import { Attachment } from 'discord.js'; +import { pp } from './utils'; + +// Mmm... spaghetti... +export default (res, message) => { + let msg = `Definition for ‘${res.word}’, yielded:\n`; + let has_sent_audio = false; + + const lex_entries = res['results'][0].lexicalEntries; + let entry_n = 1; + for (const lex_entry of lex_entries) { + if (lex_entries.length > 1) { + msg += `\nLexical Entry №${entry_n}:\n` + entry_n += 1; + } + console.log('Lex entry:', pp(lex_entries)) + for (const entry of Object.values(lex_entry.entries)) { + const senses = entry['senses']; + + for (const sense of Object.values(senses) as any) { + let sense_msg = ""; + if (!!sense.definitions && sense.definitions.length > 0) { + for (const definition + of Object.values(sense.definitions) as any) { + sense_msg += ` Defined as (${lex_entry.lexicalCategory.text.toLowerCase()}):\n> ${definition.capitalize()}\n`; + } + } + if (!!sense.synonyms && sense.synonyms.length > 0) { + const synonyms = sense.synonyms + .map(s => `‘${s.text}’`) + .join(', '); + sense_msg += ` Synonyms include: ${synonyms}\n`; + } + if (sense_msg.trim().length > 0) { + msg += "\nIn the sense:\n" + msg += sense_msg; + } + } + const etys = entry['etymologies']; + if (!!etys && etys.length > 0) { + msg += '\nEtymology:\n '; + msg += etys.join(';\n '); + msg += '\n'; + } + } + if (!!lex_entry.pronunciations) { + const prons = Object.values(lex_entry.pronunciations) as any; + if (!!prons && prons.length > 0) { + msg += "\nPronunciations:\n" + for (const pron of prons) { + msg += ` Dialects of ${pron.dialects.join(', ')}:\n`; + msg += ` ${pron.phoneticNotation}: [${pron.phoneticSpelling}]\n`; + if (pron.audioFile) { + msg += ` Audio file: ${pron.audioFile}\n`; + if (!has_sent_audio) { + has_sent_audio = !has_sent_audio; + const attach = new Attachment( + pron.audioFile, + pron.audioFile.split('/').slice(-1)[0] + ); + message.channel.send('', attach); + } + } + } + } + } + } + + return msg; +} diff --git a/lib/main.ts b/lib/main.ts @@ -1,20 +1,31 @@ +// Don't exit immediately. +process.stdin.resume(); + +// Discord Bot API. import { Discord, On, Client } from '@typeit/discord'; import { Message, Attachment } from 'discord.js'; +// System interaction modules. import { readFileSync as read_file, writeFileSync as write_file, } from 'fs'; import { execSync as shell } from 'child_process'; -import { inspect } from 'util'; +// Local misc/utility functions. import './extensions'; import { deep_merge, pp, compile_match, export_config } from './utils'; +import format_oed from './format_oed'; // O.E.D. JSON entry to markdown. + +// Default bot configuration JSON. import DEFAULT_CONFIG from './default'; -import web_search from './api/web'; + +// API specific modules. +import web_search from './api/contextual'; import oed_lookup from './api/oxford'; import urban_search from './api/urban'; + // Anything that hasn't been defined in `bot.json` // will be taken care of by the defaults. const CONFIG = deep_merge( @@ -23,7 +34,7 @@ const CONFIG = deep_merge( // Precompile all regular-expressions in known places. ['respond', 'reject', 'replace'] - .forEach(name => CONFIG.rules[name].mut_map(compile_match)) + .each(name => CONFIG.rules[name].mut_map(compile_match)) // Store secrets in an object, retrieved from shell's // environment variables. @@ -40,15 +51,18 @@ const help_sections = HELP.toString() let acc = ""; let new_messages = []; +// This assumes no two help-entries would ever +// be greater than 2000 characters long for (const msg of help_sections) if (acc.length + msg.length >= 2000) { new_messages.push(acc); acc = msg; } else { acc += msg; } - new_messages.push(acc); + const HELP_MESSAGES = new_messages; +// Log where __dirname and cwd are for deployment. console.log('File/Execution locations:', { '__dirname': __dirname, 'process.cwd()': process.cwd() @@ -57,6 +71,7 @@ console.log('File/Execution locations:', { @Discord export class SimpOMatic { private static _client : Client; + private _COMMAND_HISTORY : Message[] = []; constructor() { console.log('Secrets:', pp(SECRETS)); @@ -72,16 +87,25 @@ export class SimpOMatic { } process_command(message : Message) { - const words = message.content.slice(1).split(' '); + this._COMMAND_HISTORY.push(message); + + const content = message.content.trim().squeeze(); + const words = content.tail().split(' '); + const args = words.tail(); let command = words[0]; if (CONFIG.commands.aliases.hasOwnProperty(command)) - command = CONFIG.commands.aliases[command]; - command = command.toLowerCase(); + command = CONFIG.commands.aliases[command].trim().squeeze(); - const args = words.slice(1); + const expanded_command_words = command.split(' '); + if (expanded_command_words.length > 1) { + // This means the alias has expanded to more than just one word. + command = expanded_command_words.shift(); + expanded_command_words.each(e => args.unshift(e)); + } - console.log('Received command: ', [command, args]); + command = command.toLowerCase(); + console.log('Received command:', [command, args]); switch (command) { case 'ping': { @@ -99,6 +123,48 @@ export class SimpOMatic { console.log(`Replied: ${reply}`); message.answer(reply); break; + } case 'alias': { + const p = CONFIG.commands.prefix; + + if (args.length === 0 || args[0] === 'ls') { + const lines = Object.keys(CONFIG.commands.aliases) + .map((e, i) => `${i + 1}. \`${p}${e}\` ↦ \`${p}${CONFIG.commands.aliases[e]}\``); + message.answer('List of **Aliases**:\n') + message.channel.send('**KEY: `Alias` ↦ `Command it maps to`**\n\n' + + lines.join('\n')); + } + + // Check last: + if (args.length > 1) { // Actually aliasing something. + args[0] = args[0].trim(); + args[1] = args[1].trim(); + + if (args[0][0] === CONFIG.commands.prefix) + args[0] = args[0].tail(); + + if (args[1][0] === CONFIG.commands.prefix) + args[1] = args[1].tail(); + + CONFIG.commands.aliases[args[0]] = args.tail().join(' '); + message.channel.send( + '**Alias added:**\n >>> ' + + `\`${p}${args[0]}\` now maps to \`${p}${args.tail().join(' ')}\``); + } + break; + } case 'prefix': { + if (args.length == 1) { + if (args[0].length !== 1) { + message.answer(`You may only use a prefix that is + exactly one character/symbol/grapheme/rune long.` + .squeeze()); + break; + } + CONFIG.commands.prefix = args[0]; + message.answer(`Command prefix changed to: \`${CONFIG.commands.prefix}\`.`); + break; + } + message.answer(`Current command prefix is: \`${CONFIG.commands.prefix}\`.`); + break; } case 'search': { const query = args.join(' '); @@ -111,8 +177,8 @@ export class SimpOMatic { message.answer('No such results found.'); return; } - message.answer(`Web search for ‘${query}’, - found: ${res['value'][0].url}`); + message.answer(`Web search for ‘${query}’, \ + found: ${res['value'][0].url}`.squeeze()); }).catch(e => message.answer(`Error fetching results:\n${e}`)); break; } case 'image': { @@ -127,8 +193,8 @@ export class SimpOMatic { message.answer('No such images found.'); return; } - message.answer(`Image found for ‘${query}’: - ${res['value'][0].url}`); + message.answer(`Image found for ‘${query}’: \ + ${res['value'][0].url}`.squeeze()); }).catch(e => message.answer(`Error fetching image:\n${e}`)); break; @@ -149,62 +215,37 @@ export class SimpOMatic { id: SECRETS.oxford.id, key: SECRETS.oxford.key }).then(res => { - let msg = `Definition for ‘${query}’, yielded:\n`; - + console.log('Dictionary response:', pp(res)); if (!res['results'] - || res['results'].length == 0 - || !res['results'][0].lexicalEntries - || res['results'][0].lexicalEntries.length == 0 - || res['results'][0].lexicalEntries[0].entries.length == 0 - || res['results'][0].lexicalEntries[0].entries[0].senses.length == 0) { + || res['results'].length == 0 + || !res['results'][0].lexicalEntries + || res['results'][0].lexicalEntries.length == 0 + || res['results'][0].lexicalEntries[0].entries.length == 0 + || res['results'][0].lexicalEntries[0].entries[0].senses.length == 0) { message.answer(nasty_reply); return; } + // Format the dictionary entry as a string. + const msg = format_oed(res, message); + + if (msg.length >= 2000) { // This should be rare (try defining `run'). + let part_msg = ""; + // This assumes no two lines would ever + // amount to more than 2000 characters. + for (const line of msg.split(/\n/g)) + if (part_msg.length + line.length >= 2000) { + message.channel.send(part_msg); + part_msg = line + '\n'; + } else { part_msg += line + '\n' } + // Send what's left over, and not >2000 characters. + message.channel.send(part_msg); - const entry = res['results'][0].lexicalEntries[0]; - const senses = entry.entries[0].senses; - console.log('Senses:', pp(senses)); - for (const sense of Object.values(senses) as any) { - let sense_msg = ""; - if (!!sense.definitions && sense.definitions.length > 0) { - for (const definition - of Object.values(sense.definitions) as any) { - sense_msg += ` Defined as:\n> ${definition.capitalize()}\n`; - } - } - if (!!sense.synonyms && sense.synonyms.length > 0) { - const synonyms = sense.synonyms - .map(s => `‘${s.text}’`) - .join(', '); - sense_msg += ` Synonyms include: ${synonyms}\n`; - } - if (sense_msg.length > 0) { - msg += "In the sense:\n" - msg += sense_msg; - } - } - if (!!entry.pronunciations) { - const prons = Object.values(entry.pronunciations) as any; - if (!!prons && prons.length > 0) { - msg += "Pronunciations:\n" - for (const pron of prons) { - msg += ` Dialects of ${pron.dialects.join(', ')}:\n`; - msg += ` ${pron.phoneticNotation}: [${pron.phoneticSpelling}]\n`; - if (pron.audioFile) { - msg += ` Audio file: ${pron.audioFile}\n`; - const attach = new Attachment( - pron.audioFile, - pron.audioFile.split('/').slice(-1)[0] - ); - message.channel.send('', attach); - } - } - } + return; } message.channel.send(msg); }).catch(e => { if (e.status == 404) { - message.channel.send(nasty_reply); + message.channel.send(`That 404'd. ${nasty_reply}`); } else { message.channel.send(`Error getting definition:\n${e}`); } @@ -237,7 +278,10 @@ export class SimpOMatic { message.answer(`${(4 + Math.random() * 15).round_to(3)} gallons \ of milkies have been deposited in your mouth.`.squeeze()); break; - } case 'say': {2 + } case 'ily': { + message.answer('Y-you too...'); + break; + }case 'say': {2 message.answer(`Me-sa says: “${args.join(' ')}”`); break; } case 'export': { @@ -283,6 +327,70 @@ export class SimpOMatic { // TODO: Process _rules_ appropriately. } + last_message(opts) : string { + if (!opts.offset) opts.offset = 1; + if (opts.mention) opts.mentioning = opts.mentioning.trim(); + + if (opts.command) { + let commands = this._COMMAND_HISTORY + .filter(m => m.channel.id === opts.channel.id) + + if (opts.mention) commands = commands.filter(m => + m.author.toString() === opts.mentioning); + + const command = commands.last(opts.offset - 1); + if (!command) { + opts.channel.send('Cannot expand, no such' + + ' command exists in history.'); + return 'EXPANSION_ERROR'; + } + return command.content; + } + + // TODO: Deal with non command expansions, i.e. !!^ + return 'last-message'; + } + + expand(message : Message) : string { + // History expansion with !!, !!@, !!^@, !!<ordinal>, etc. + const expansions = message.content + .replace(/(!!@?\^?\d*)/g, '@EXP$1@EXP') + .replace(/(\s)/g, '@EXP$1@EXP') + .split('@EXP').squeeze(); + + for (let i = 0; i < expansions.length; ++i) { + if (expansions[i].startsWith('!!')) { + if (expansions[i].length === 2) { // !! expansion + expansions[i] = this.last_message({ + command: true, + channel: message.channel + }); + continue; + } + + const [opts, offset] = expansions[i].slice(2) + .replace(/(\d+)/, '#$1') + .split('#'); + + const mention = opts.includes('@'); + const mentioning = expansions[i + 2]; + if (mention) expansions[i + 2] = ''; + + expansions[i] = this.last_message({ + command: !opts.includes('^'), + mention: mention, + mentioning: mentioning, + offset: offset || 1, + channel: message.channel + }); + } + } + + // TODO: Deal with 'EXPANSION_ERROR's in `expansions`. + + return expansions.join(''); + } + @On("message") async on_message(message : Message, client : Client) { console.log('Message acknowledged.'); @@ -292,9 +400,13 @@ export class SimpOMatic { console.log('Message received:', message.content); const trimmed = message.content.trim(); - if (trimmed[0] === CONFIG.commands.prefix) { + message.content = trimmed; + message.content = this.expand(message); + + console.log('Expanded message:', message.content); + + if (message.content[0] === CONFIG.commands.prefix) { console.log('Message type: command.') - message.content = trimmed; this.process_command(message); } else { console.log('Message type: generic.') @@ -303,8 +415,19 @@ export class SimpOMatic { } } +const on_termination = () => { + // Back-up the resultant CONFIG to an external file. + write_file(`${process.cwd()}/export-exit.json`, export_config(CONFIG, {})); + console.log('Last config before exit saved! (`export-exit.json`)'); + process.exit(0); +}; + // Start The Simp'O'Matic. SimpOMatic.start(); -// Back-up the resultant CONFIG to an external file. -write_file(`${process.cwd()}/export.json`, export_config(CONFIG, {})); +// Handle exits. +process.on('exit', on_termination); +process.on('SIGINT', on_termination); +process.on('SIGUSR1', on_termination); +process.on('SIGUSR2', on_termination); +process.on('uncaughtException', on_termination); diff --git a/lib/utils.ts b/lib/utils.ts @@ -13,11 +13,11 @@ export const type: (obj: any) => string = (global => obj => export const pp = o => inspect(o, { colors: true, showHidden: false, - depth: 50 + depth: 23 }); export const deep_merge_pair = (target, source) => { - Object.keys(source).forEach(key => { + Object.keys(source).each(key => { const target_value = target[key]; const source_value = source[key]; diff --git a/package.json b/package.json @@ -15,10 +15,12 @@ "main": "./build/main.js", "types": "./build/main.d.ts", "scripts": { - "build": "node -v && ./build.sh", + "build": "node -v && . ./build.sh", "reset": "rm -rf ./build ./node_modules ./yarn.lock ./packages-lock.json", "start": "node .", - "quick": "yarn run build && yarn run start" + "quick": ". ./build.sh && yarn run start", + "deploy-scale": "heroku ps:scale service=1 -a simp-o-matic", + "deploy-restart": "heroku restart - simp-o-matic" }, "homepage": "https://github.com/Demonstrandum/simpomatic", "repository": {