├── .gitattributes ├── .gitignore ├── LICENCE ├── Procfile ├── README.md ├── add.html ├── dev_requirements.txt ├── images ├── blue_heart.png ├── calculator.png ├── calculator.svg ├── downarrow.png ├── logo.png ├── patreon.png ├── tex.png └── wa-logo.jpg ├── index.html ├── logo.svg ├── mathbot ├── __init__.py ├── __main__.py ├── advertising.py ├── autoreload ├── bot.py ├── calc ├── calculator │ ├── __init__.py │ ├── __main__.py │ ├── ast.md │ ├── blackbox.py │ ├── bytecode.md │ ├── bytecode.py │ ├── crucible.py │ ├── errors.py │ ├── formatter.py │ ├── functions.py │ ├── grammar.md │ ├── interpereter.py │ ├── library.c5 │ ├── operators.py │ ├── parser.py │ ├── runtime.py │ └── scripts │ │ ├── addition.c5 │ │ ├── aperture.c5 │ │ ├── assignment.c5 │ │ ├── c6.c5 │ │ ├── cycle.c5 │ │ ├── error_evaluation.c5 │ │ ├── error_parse.c5 │ │ ├── exponential_bytecode.c5 │ │ ├── fib.c5 │ │ ├── filter.c5 │ │ ├── graph.c5 │ │ ├── large.c5 │ │ ├── lists.c5 │ │ ├── nothing.c5 │ │ ├── queue.c5 │ │ ├── quicksort.c5 │ │ ├── sayaks.c5 │ │ ├── simple.c5 │ │ └── user.c5 ├── core │ ├── __init__.py │ ├── blame.py │ ├── help.py │ ├── keystore.py │ ├── parameters.py │ ├── settings.py │ └── util.py ├── count_objects.py ├── fonts │ └── roboto │ │ ├── LICENSE.txt │ │ ├── Roboto-Black.ttf │ │ ├── Roboto-BlackItalic.ttf │ │ ├── Roboto-Bold.ttf │ │ ├── Roboto-BoldItalic.ttf │ │ ├── Roboto-Italic.ttf │ │ ├── Roboto-Light.ttf │ │ ├── Roboto-LightItalic.ttf │ │ ├── Roboto-Medium.ttf │ │ ├── Roboto-MediumItalic.ttf │ │ ├── Roboto-Regular.ttf │ │ ├── Roboto-Thin.ttf │ │ └── Roboto-ThinItalic.ttf ├── help │ ├── about.md │ ├── blame.md │ ├── calculator_brief.md │ ├── calculator_full.md │ ├── calculator_history.md │ ├── calculator_libraries.md │ ├── commands.md │ ├── help.md │ ├── latex.md │ ├── management.md │ ├── oeis.md │ ├── prefix.md │ ├── purge.md │ ├── roll.md │ ├── settings.md │ ├── theme.md │ ├── turing.md │ ├── turing_functions.md │ ├── units.md │ └── wolfram.md ├── imageutil.py ├── modules │ ├── __init__.py │ ├── about.py │ ├── analytics.py │ ├── blame.py │ ├── calcmod.py │ ├── dice.py │ ├── echo.py │ ├── greeter.py │ ├── heartbeat.py │ ├── help.py │ ├── latex │ │ ├── __init__.py │ │ ├── replacements.json │ │ └── template.tex │ ├── oeis.py │ ├── purge.py │ ├── reboot.py │ ├── reporter.py │ ├── settings.py │ ├── throws.py │ └── wolfram.py ├── not_an_image ├── open_relative.py ├── parameters_default.json ├── patrons.py ├── queuedict.py ├── safe.py ├── startup_queue.py ├── test ├── test_specific ├── update_logo.py ├── utils.py ├── wolfapi.py └── wordfilter │ ├── __init__.py │ ├── __main__.py │ └── bad_words.txt ├── requirements.txt ├── runtime.txt ├── scripts ├── _install_deployment_deps.sh ├── _push_code.sh ├── cleanup_redis_store.py ├── grab_recent_logs.sh ├── install_deployment_deps.sh ├── pm2_main.sh ├── pull_redis_creds_from_heroku.sh ├── push_code.sh ├── push_config.sh └── startup_queue.sh ├── stats.txt ├── style.css └── tests ├── __init__.py ├── _conftest.py ├── _test_using_automata.py ├── test_calc_helpers.py ├── test_calc_library.py ├── test_calculator.py ├── test_core_help.py ├── test_parameters.py ├── test_safe_print.py ├── test_supress_params.py └── test_wordfilter.py /.gitattributes: -------------------------------------------------------------------------------- 1 | *.svg binary -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.png 2 | __pycache__ 3 | *.sublime* 4 | .cache 5 | *.zip 6 | ignore 7 | keystore.json 8 | parameters.json 9 | # virtualenv directory 10 | venv/ 11 | .flake8 12 | # coverage.py 13 | .coverage* 14 | htmlcov 15 | .mypy_cache 16 | *.pyc 17 | .pytest_cache 18 | .vscode/ 19 | Pipfile.lock 20 | .venv 21 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | amethyst: cd mathbot && python entrypoint.py PARAMETERS.env '{"shards": {"mine": [6, 7]}}' 2 | emerald: cd mathbot && python entrypoint.py PARAMETERS.env '{"shards": {"mine": [8, 9]}}' 3 | saphire: cd mathbot && python entrypoint.py PARAMETERS.env '{"shards": {"mine": [10, 11]}}' 4 | topaz: cd mathbot && python entrypoint.py PARAMETERS.env '{"shards": {"mine": [12, 13]}}' 5 | opal: cd mathbot && python entrypoint.py PARAMETERS.env '{"shards": {"mine": [14]}}' 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MathBot 2 | 3 | MathBot is a discord bot that contains a number of features to help with mathematics. 4 | 5 | It's primary features are: 6 | - LaTeX rendering 7 | - Querying Wolfram|Alpha 8 | - A Turing complete calculator 9 | 10 | The bot is currently developed on python `3.8.10`, but should work on later versions of Python. 11 | 12 | ## Links 13 | 14 | - [Add the bot to your own server](https://dxsmiley.github.io/mathbot/add.html) 15 | - [Official Discord Server](https://discord.gg/JbJbRZS) 16 | 17 | ## Setup for use 18 | 19 | ```bash 20 | git clone https://github.com/DXsmiley/mathbot.git 21 | cd mathbot 22 | cp mathbot/parameters_default.json ./parameters.json 23 | python3.8 -m venv .venv # or later version 24 | source .venv/bin/activate 25 | pip install -r requirements.txt 26 | ``` 27 | 28 | Then open parameters.json and change `tokens` to the token of the bot used for development. Optionally change the other parameters. 29 | 30 | It is *strongly* recommend that you setup an instance of Redis if you want to use the bot on even a moderate scale. The disk-based keystore is easy to setup but runs very slowly, and as such is only useful of a development tool. 31 | 32 | Run the bot with `python -m mathbot parameters.json`. 33 | 34 | ## Setup for development 35 | 36 | Follow above instructions, but additionally run `pip install -r dev_requirements.txt` 37 | 38 | ## Contributing guide 39 | 40 | Relevent discussion takes place on [the MathBot Discord server](https://discord.gg/JbJbRZS). 41 | 42 | ## Setting up Wolfram|Alpha 43 | 44 | 1. [Grab yourself an API key](https://products.wolframalpha.com/api/) 45 | 2. Open parameters.json and change `wolfram > key`. 46 | 47 | This should really only be used for development and personal use. 48 | 49 | ## Test Suite 50 | 51 | Invoke `pytest` to run the test suite. 52 | 53 | ~~Some of the tests require that a bot is running and connected to Discord. To enable them, use the `--run-automata` command line argument. In addition a file with the necessary tokens filled out needs to be provided to the `--parameter-file` argument. To get all tests running, the *token*, *automata* and *wolfram* parameters need to be filled out.~~ 54 | 55 | ~~For the sake of example, I run my tests with the command `./test --run-automata --parameter-file=dev.json`. You should replace `dev.json` with a path to your own parameter file.~~ 56 | 57 | ~~There are some additional tests that require a human to verify the bot's output. These can be enabled with `--run-automata-human`.~~ 58 | 59 | ## Guide to `parameters.json` 60 | 61 | - *release* : Release mode for the bot, one of `"development"`, `"beta"` or `"production"` 62 | - *token* : Token to use for running the bot 63 | - *wolfram* 64 | - *key* : API key for making Wolfram|Alpha queries 65 | - *keystore* 66 | - *disk* 67 | - *filename* : The file to write read and write data to when in disk mode 68 | - *redis* 69 | - *url* : url used to access the redis server 70 | - *number* : the number of the database, should be a non-negative integer 71 | - *mode* : Either `"disk"` or `"redis"`, depending on which store you want to use. Disk mode is not recommended for deployment. 72 | - *patrons* : list of patrons 73 | - Each *key* should be a Discord user ID. 74 | - Each *value* should be a string starting with one one of `"linear"`, `"quadratic"`, `"exponential"` or `"special"`. The string may contains additional information after this for human use, such as usernames or other notes. 75 | - *analytics* : Keys used to post information to various bot listings. 76 | - *carbon*: Details for [carbonitex](http://carbonitex.net/) 77 | - *discord-bots*: API Key for [bots.discord.pw](https://bots.discord.pw/#g=1) 78 | - *bots-org*: API Key for [discordbots.org](https://discordbots.org/) 79 | - *automata* 80 | - *token* : token to use for the automata bot 81 | - *target* : the username of the bot that the automata should target 82 | - *channel*: the ID of the channel that the tests should be run in 83 | - *advertising* 84 | - *enable* : should be `true` or `false`. When `true`, the bot will occasionally mention the Patreon page when running queries. 85 | - *interval* : the number of queries between mentions of the Patreon page. This is measured on a per-channel basis. 86 | - *starting-amount* : Can be increased to lower the number of commands until the Patreon page is first mention. 87 | - *error-reporting* 88 | - *channel*: ID of channel to send error reports to. 89 | - *webhook*: Webhook to send error reports to. 90 | - *shards* 91 | - *total*: The total number of shards that the bot is running on. 92 | - *mine*: A list of integers (starting at `0`) specifying which shards should be run in this process. 93 | 94 | ## Additional Installation Issues (Ubuntu only) 95 | 96 | If you don't have python 3.8 97 | ``` 98 | sudo add-apt-repository ppa:deadsnakes/ppa 99 | sudo apt update 100 | sudo apt install python3.8 101 | ``` 102 | 103 | If you have installation troubles with cffi or psutil 104 | ``` 105 | sudo apt-get install python3.8-dev 106 | ``` 107 | -------------------------------------------------------------------------------- /add.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | MathBot 6 | 9 | 10 | 11 | Click here if you aren't redirected 12 | 13 | 14 | -------------------------------------------------------------------------------- /dev_requirements.txt: -------------------------------------------------------------------------------- 1 | pytest==7.4.3 2 | pytest-quickcheck==0.9.0 3 | -------------------------------------------------------------------------------- /images/blue_heart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DXsmiley/mathbot/723d86ba0169823e4e076e796121af93efdd74e1/images/blue_heart.png -------------------------------------------------------------------------------- /images/calculator.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DXsmiley/mathbot/723d86ba0169823e4e076e796121af93efdd74e1/images/calculator.png -------------------------------------------------------------------------------- /images/downarrow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DXsmiley/mathbot/723d86ba0169823e4e076e796121af93efdd74e1/images/downarrow.png -------------------------------------------------------------------------------- /images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DXsmiley/mathbot/723d86ba0169823e4e076e796121af93efdd74e1/images/logo.png -------------------------------------------------------------------------------- /images/patreon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DXsmiley/mathbot/723d86ba0169823e4e076e796121af93efdd74e1/images/patreon.png -------------------------------------------------------------------------------- /images/tex.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DXsmiley/mathbot/723d86ba0169823e4e076e796121af93efdd74e1/images/tex.png -------------------------------------------------------------------------------- /images/wa-logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DXsmiley/mathbot/723d86ba0169823e4e076e796121af93efdd74e1/images/wa-logo.jpg -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | MathBot 8 | 15 | 16 | 17 | 28 |
29 | 30 |
31 | 35 |
36 |
37 | 68 | 75 | 82 | 89 | 105 | 127 | 128 | 129 | -------------------------------------------------------------------------------- /logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 22 | 24 | 43 | 45 | 46 | 48 | image/svg+xml 49 | 51 | 52 | 53 | 54 | 55 | 61 | 68 | 69 | 75 | 82 | 83 | 97 | 103 | 111 | 119 | 120 | 134 | 135 | -------------------------------------------------------------------------------- /mathbot/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DXsmiley/mathbot/723d86ba0169823e4e076e796121af93efdd74e1/mathbot/__init__.py -------------------------------------------------------------------------------- /mathbot/__main__.py: -------------------------------------------------------------------------------- 1 | if __name__ != '__main__': 2 | 3 | print('Not main process. Probably crucible?') 4 | 5 | import logging 6 | logging.basicConfig(level = logging.INFO) 7 | 8 | else: 9 | 10 | print('Main process starting up') 11 | 12 | import os 13 | import sys 14 | import json 15 | import re 16 | from . import bot 17 | from . import utils 18 | from mathbot import core 19 | import aiohttp 20 | import asyncio 21 | import time 22 | import logging 23 | import warnings 24 | 25 | @utils.apply(core.parameters.load_parameters, list) 26 | def retrieve_parameters(): 27 | for i in sys.argv[1:]: 28 | if re.fullmatch(r'\w+\.env', i): 29 | yield json.loads(os.environ.get(i[:-4])) 30 | elif i.startswith('{') and i.endswith('}'): 31 | yield json.loads(i) 32 | else: 33 | with open(i) as f: 34 | yield json.load(f) 35 | 36 | # async def wait_for_slot_in_gateway_queue(): 37 | # try: 38 | # async with aiohttp.ClientSession() as session: 39 | # async with session.post('http://127.0.0.1:7023/', timeout=5) as resp: 40 | # wait_until = float(await resp.text()) 41 | # cur_time = time.time() 42 | # sleep_time = wait_until - cur_time 43 | # if sleep_time > 0: 44 | # print(f'Sleeping in gateway queue for {sleep_time} seconds') 45 | # await asyncio.sleep(sleep_time) 46 | # except aiohttp.ClientConnectorError: 47 | # print('Could not find gateway queue to connect to') 48 | 49 | # asyncio.run(wait_for_slot_in_gateway_queue()) 50 | 51 | parameters = retrieve_parameters() 52 | 53 | warnings.simplefilter('default') 54 | logging.basicConfig(level = logging.INFO) 55 | logging.getLogger('discord.state').setLevel(logging.ERROR) 56 | 57 | bot.run(parameters) 58 | -------------------------------------------------------------------------------- /mathbot/advertising.py: -------------------------------------------------------------------------------- 1 | from mathbot import patrons 2 | import random 3 | import discord 4 | from mathbot.utils import is_private 5 | 6 | MESSAGES = [ 7 | 'Every little bit helps', 8 | 'Keep it running', 9 | ] 10 | 11 | class AdvertisingMixin: 12 | 13 | async def advertise_to(self, user, channel, destination): 14 | if self.parameters.get('advertising enable') and (await self.patron_tier(user.id)) == patrons.TIER_NONE: 15 | chan_id = str(channel.id if is_private(channel) else channel.guild.id) 16 | counter = (await self.keystore.get('advert_counter', chan_id)) or 0 17 | interval = self.parameters.get('advertising interval') 18 | await self.keystore.set('advert_counter', chan_id, counter + 1) 19 | if counter % interval == 0: 20 | await destination.send(embed=discord.Embed( 21 | title='Support the bot on Patreon!', 22 | description=random.choice(MESSAGES), 23 | colour=discord.Colour.blue(), 24 | url='https://www.patreon.com/dxsmiley' 25 | )) 26 | -------------------------------------------------------------------------------- /mathbot/autoreload: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | ''' Small script to reload a program when a python 4 | file is changed. 5 | 6 | Derived from: https://github.com/stevekrenzel/autoreload 7 | ''' 8 | 9 | import os 10 | import sys 11 | import subprocess 12 | import signal 13 | import time 14 | 15 | 16 | PATH = '.' 17 | COMMAND = ' '.join(sys.argv[1:]) 18 | WAIT = 1 19 | 20 | def file_filter(name): 21 | ''' Determine is a filename should be watched or not ''' 22 | return name.endswith('.py') 23 | 24 | 25 | def _interesting_files(path): 26 | ''' Lists all the interesting files in a directory tree ''' 27 | for root, _, files in os.walk(path): 28 | for file in filter(file_filter, files): 29 | yield os.path.join(root, file) 30 | 31 | 32 | def file_times(files): 33 | ''' Iterates over the edit time of a set of files ''' 34 | for file in files: 35 | yield os.stat(file).st_mtime 36 | 37 | 38 | def print_stdout(process): 39 | ''' Pipes the standard output of a process to the console ''' 40 | stdout = process.stdout 41 | if stdout != None: 42 | print(stdout) 43 | 44 | 45 | def text_box(text): 46 | ''' Displays text in a pretty box :) ''' 47 | print() 48 | print(' +-' + '-' * len(text) + '-+') 49 | print(' | ' + text + ' |') 50 | print(' +-' + '-' * len(text) + '-+') 51 | print() 52 | 53 | 54 | def main(): 55 | ''' The main function. 56 | Does all the stuff. 57 | ''' 58 | 59 | try: 60 | 61 | text_box('Autorestart enabled') 62 | 63 | interesting_files = list(_interesting_files(PATH)) 64 | process = subprocess.Popen(COMMAND, shell=True, preexec_fn=os.setsid) 65 | last_mtime = max(file_times(interesting_files), default=0) 66 | is_running = True 67 | 68 | while True: 69 | max_mtime = max(file_times(interesting_files), default=0) 70 | print_stdout(process) 71 | if is_running and process.poll() is not None: 72 | is_running = False 73 | text_box('Process Terminated') 74 | if max_mtime > last_mtime: 75 | last_mtime = max_mtime 76 | text_box('Restarting Process') 77 | os.killpg(os.getpgid(process.pid), signal.SIGTERM) 78 | time.sleep(1) 79 | process = subprocess.Popen(COMMAND, shell=True, preexec_fn=os.setsid) 80 | is_running = True 81 | time.sleep(WAIT) 82 | 83 | except KeyboardInterrupt: 84 | 85 | text_box('Shutting down') 86 | time.sleep(1) 87 | 88 | finally: 89 | 90 | if process and process.poll() is not None: 91 | os.killpg(os.getpgid(process.pid), signal.SIGTERM) 92 | 93 | main() 94 | -------------------------------------------------------------------------------- /mathbot/calc: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | python3.6 -m calculator 4 | 5 | -------------------------------------------------------------------------------- /mathbot/calculator/__init__.py: -------------------------------------------------------------------------------- 1 | ''' Calculator module 2 | 3 | This module is an implementation of a calculator, 4 | which is actually a turing-complete programming language. 5 | 6 | Go figure. 7 | 8 | ''' 9 | 10 | from . import interpereter 11 | from . import parser 12 | from . import runtime 13 | from . import bytecode 14 | from . import functions 15 | from . import errors 16 | 17 | 18 | def calculate(equation, tick_limit=None, trace=False, use_runtime=True): 19 | ''' Evaluate an expression ''' 20 | interp = interpereter.Interpereter(trace=trace) 21 | builder = bytecode.Builder() 22 | # Setup the runtime 23 | if use_runtime: 24 | segment_runtime = runtime.prepare_runtime(builder) 25 | interp.run(segment=segment_runtime) 26 | # Run the actual program 27 | _, ast = parser.parse(equation) 28 | segment_program = builder.build(ast) 29 | return interp.run(segment=segment_program, tick_limit=tick_limit, error_if_exhausted=True) 30 | 31 | 32 | async def calculate_async(equation): 33 | ''' Evaluate an expression asyncronously ''' 34 | _, ast = parser.parse(equation) 35 | bc = runtime.wrap_simple(ast) 36 | interp = interpereter.Interpereter(bc) 37 | return await interp.run_async() 38 | -------------------------------------------------------------------------------- /mathbot/calculator/__main__.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | 3 | import json 4 | import asyncio 5 | import traceback 6 | import sys 7 | 8 | from .interpereter import Interpereter 9 | from . import parser 10 | from . import bytecode 11 | from . import runtime 12 | from . import errors 13 | from .runtime import prepare_runtime 14 | from .blackbox import Terminal, format_error_place 15 | 16 | 17 | def main(): 18 | if len(sys.argv) == 1: 19 | interactive_terminal() 20 | return 21 | # Some options, gotta run file 22 | try: 23 | args = parse_arguments() 24 | filename = proc_filename(args.filename) 25 | sys.exit(run_file(filename)) 26 | except parser.ParseFailed as e: 27 | print(format_error_place(code, e.position)) 28 | 29 | 30 | def parse_arguments(): 31 | parser = argparse.ArgumentParser() 32 | parser.add_argument('filename', help = 'The filename of the program to run') 33 | action = parser.add_mutually_exclusive_group() 34 | action.add_argument('-t', '--trace', action = 'store_true', help = 'Display details of the program as it is running') 35 | action.add_argument('-c', '--compile', action = 'store_true', help = 'Dumps the bytecode of the program rather than running it') 36 | return parser.parse_args() 37 | 38 | 39 | def proc_filename(filename): 40 | if filename[0] == '+': 41 | return './calculator/scripts/' + filename[1:] + '.c5' 42 | return filename 43 | 44 | 45 | def print_token_parse_caret(to): 46 | print(' '.join(to.tokens)) 47 | print((sum(map(len, to.tokens[:to.rightmost])) + to.rightmost) * ' ' + '^') 48 | 49 | 50 | def run_file(filename): 51 | code = open(filename).read() 52 | 53 | terminal = Terminal.new_blackbox_sync( 54 | allow_special_commands=False, 55 | yield_rate=1, 56 | trap_unknown_errors=False 57 | ) 58 | 59 | output, worked, details = terminal.execute(code) 60 | print(output) 61 | 62 | return 0 if worked else 1 63 | 64 | 65 | def interactive_terminal(): 66 | terminal = Terminal.new_blackbox_sync( 67 | allow_special_commands=True, 68 | yield_rate=1, 69 | trap_unknown_errors=True 70 | ) 71 | while True: 72 | try: 73 | line = input('> ') 74 | except (EOFError, KeyboardInterrupt): 75 | break 76 | if line in [':q', ':x', ':quit', ':exit']: 77 | break 78 | if line == '': 79 | continue 80 | try: 81 | output, worked, details = terminal.execute(line) 82 | print(output) 83 | except KeyboardInterrupt: 84 | print('Operation halted by keyboard interupt') 85 | 86 | main() 87 | -------------------------------------------------------------------------------- /mathbot/calculator/ast.md: -------------------------------------------------------------------------------- 1 | # Abstract Syntax Tree Reference 2 | 3 | The AST is made of a number of dictionaries. Each one has a `#` key specifying what type of object it is. 4 | 5 | ## `number` (numeric constant) 6 | 7 | - `string`: A string containing the number. 8 | 9 | ## `bin_op` (binary operator) 10 | 11 | - `left`: AST of the left-hand operand. 12 | - `right` AST of the right-hand operand. 13 | - `operator`: String containing the operator symbol. 14 | - `token`: Token of the operator. 15 | 16 | ## `percent_op` (unary percentage operator) 17 | 18 | - `token`: Token of the operator. 19 | - `value`: AST of a `number`. 20 | 21 | ## `not` (unary ! operator) 22 | 23 | - `expression`: AST of the value. 24 | - `token`: Token of the operator. 25 | 26 | ## `uminus` (unary minus operator) 27 | 28 | - `value`: AST of the value. 29 | 30 | ## `head_op` (get head of list, unary operator ') 31 | 32 | - `token`: Token of the ' operator 33 | - `expression`: AST of the thing to get the head of. 34 | 35 | ## `tail_op` (get tail of the list, unqry operator \) 36 | 37 | - `token`: Token of the \ operator 38 | - `expression`: AST of the thing to get the tail of. 39 | 40 | ## `function_call` (when a function is actually called) 41 | 42 | This does not differentiate between macros and normal functions. 43 | 44 | - `function`: AST of the expression used to get the function object. 45 | - `augments`: Dictionary 46 | - `items`: List of the arguments 47 | - `edges`: Dictionary 48 | - `start`: The '(' token at the beginning of the argument list. 49 | - `end`: The ')' token at the end of the argument list. 50 | 51 | ## `word` (a variable name) 52 | 53 | - `string`: name of variable 54 | 55 | ## `list_literal` [1 2 3] 56 | 57 | - `items`: AST of the elements of the list 58 | 59 | ## `factorial` (unary operator) 60 | 61 | - `value`: AST 62 | - `token`: Token of the ! operator 63 | 64 | ## `assignment` (statement) 65 | 66 | - `variable`: A `word`. 67 | - `value`: AST. 68 | 69 | ## `program` (DEPRECATED?) 70 | 71 | - `items`: List of statements? 72 | 73 | ## `function_definition` 74 | 75 | - `parameters`: Dictionary with key `items` being a list of `word`s 76 | - `kind`: Either the string `"->"` or `"~>"` depending if it's a normal function or a macro function. 77 | - `variadic`: Whether the last parameter is variadic or not. 78 | - `expression`: The body of the function. 79 | 80 | ## `comparison` 81 | 82 | - `first`: Leftmost thing, AST. 83 | - `rest`: List of comparison blobs. 84 | 85 | ### blob 86 | 87 | - `operator`: The comparison operator. Sits to the left of the value. 88 | - `value`: An AST. 89 | 90 | ## `output` (deprecated) 91 | 92 | - `expression`: AST. 93 | 94 | # Error info 95 | 96 | Error info can appear on (almost) any node. It specifies what section of the original source code to blame if something goes wrong, and can usually be found as node\['token'\]\['source'\]. 97 | 98 | Blame information looks like this: 99 | 100 | { 101 | 'name': 'source_filename.c5', 102 | 'location': 10, 103 | 'code': 'Original source code' 104 | } 105 | -------------------------------------------------------------------------------- /mathbot/calculator/bytecode.md: -------------------------------------------------------------------------------- 1 | # Bytecode Reference 2 | 3 | ## Notation 4 | 5 | - Words inside square brackets represent data stored in the bytecode directly after the insruction. For example `function [address]` represents that the `function` instruction should be followed by an address. 6 | - Stack manipulation is listed in parenthesis. For example, the Binary Additional instructio coud be represented as `(a, b -> c)` representing that it pops the top two items from the stack and pushes a single item to the stack. 7 | 8 | ## List of Instructions 9 | 10 | - 0 - Nothing 11 | 12 | - 1 - Constant [value] ( -> t) 13 | - 50 - Constant Empty Array ( -> empty_array) 14 | 15 | - 2 - Binary Addition (a, b, -> c) 16 | - 3 - Binary Subtraction (a, b, -> c) 17 | - 4 - Binary Multiplication (a, b, -> c) 18 | - 5 - Binary Division (a, b, -> c) 19 | - 6 - Binary Modulo (a, b, -> c) 20 | - 7 - Binary Power (a, b, -> c) 21 | - 8 - Binary Logical And (a, b, -> c) 22 | - 9 - Binary Logical Or (a, b, -> c) 23 | - 10 - Binary Die roll (a, b, -> c) 24 | 25 | - 11 - Unary Not (t -> t) 26 | - 12 - Unary Minus (t -> t) 27 | - 13 - Unary Factorial (t -> t) 28 | 29 | - 14 - Jump if macro [destination] (f -> f) 30 | - 15 - Argument list end [number of arguments] 31 | - 54 - Argument list end disable cache [number of arguments] 32 | - 16 - Word (variable access) (DEAD) 33 | - 17 - Assignment [address] (value -> ) 34 | - 18 - Swap top two items of the stack (a, b -> b, a) 35 | - 19 - End program 36 | - 20 - Declare macro function [address] ( -> f) (DEAD) 37 | - 21 - Declare function [address] ( -> f) 38 | - 22 - Return from function 39 | - 23 - Jump [address] 40 | - 24 - Jump if true [addres] (b -> ) 41 | - 25 - Jump if false [address] (b -> ) 42 | - 26 - Duplicate top value (a -> a, a) 43 | - 27 - Discord top value from stack (a -> ) 44 | 45 | - 28 - Binary Comparison Less (l, r -> x) 46 | - 29 - Binary Comparison More (l, r -> x) 47 | - 30 - Binary Comparison Less Equal (l, r -> x) 48 | - 31 - Binary Comparison More Equal (l, r -> x) 49 | - 33 - Binary Comparison Equal (l, r -> x) 50 | - 34 - Binary Comparison Not Equal (l, r -> x) 51 | (where x = (l compare r)) 52 | 53 | - 35 - Chain Comparison Less (x, l, r -> y, r) 54 | - 36 - Chain Comparison More (x, l, r -> y, r) 55 | - 37 - Chain Comparison Less Equal (x, l, r -> y, r) 56 | - 38 - Chain Comparison More Equal (x, l, r -> y, r) 57 | - 39 - Chain Comparison Equal (x, l, r -> y, r) 58 | - 40 - Chain Comparison Not Equal (x, l, r -> y, r) 59 | (where y = (x and (l compare r))) 60 | 61 | - 41 - Store in cache 62 | - 42 - Demacroify (DEAD) 63 | - 43 - Store-demacrod (demacro'd function) (DEAD) 64 | 65 | - 44 - Access Global [index, variable name] 66 | - 45 - Access Local [index] 67 | - 46 - Access Semi Local [index, depth] 68 | - 53 - Access Array Element (array, index -> value) 69 | 70 | - 48 - Special Map 71 | - 51 - Special Map Store 72 | - 49 - Special Reduce 73 | - 52 - Special Reduce Store 74 | - 55 - Special Filter 75 | - 56 - Special Filter Store 76 | 77 | - 66 - Push Error Stopgap [handler address, pass error] (stopgap marker) 78 | 79 | ## Chain comparators 80 | 81 | To start a set of chain comparisons, the number 1 is pushed to the stack, followed by the first operand. 82 | 83 | Each of the chain comparitor instructions do the following: 84 | - pop form the stack and store in R 85 | - pop form the stack and store in L 86 | - pop from the stack and store in C 87 | - push ((L compare R) and C) to the stack (logical and) 88 | - push R to stack 89 | 90 | To end a set of chain comparisons, the top item of the stack (which would be the very last R) is popped. 91 | 92 | ## Functions 93 | 94 | ### Function call bytecode 95 | 96 | push the function to the stack 97 | push the arguments to the stack (note that this might change depending on whether the function is a macro or not) 98 | argument list end instruction [number of arguments] 99 | 100 | ### Entering a function 101 | 102 | push return address 103 | push return scope 104 | If results are going to be cached, push cache key. 'NONE' can be pushed if the function has a `STORE_IN_CACHE` instruction but you want to prevent caching. 105 | goto function bytecode 106 | 107 | After the `RETURN` function is executed, the address and scope will be reset and the top of the stack will be the result of the function. 108 | 109 | ### Function definition 110 | 111 | #### Creating a function object 112 | 113 | `FUNCTION_NORMAL` 114 | start_address 115 | 116 | #### Function definiton in the byte code 117 | 118 | - start address (be aware that this takes up a byte, and it's empty) 119 | - number of parameters 120 | - 1 if variadic, 0 otherwise 121 | - 1 if macro, 0 otherwise 122 | - executable code starts here 123 | - If not a macro: `STORE_IN_CACHE` insturction 124 | - `RETURN` instruction 125 | 126 | ## Specials 127 | 128 | ### Map 129 | 130 | :push function to stack 131 | :push array to stack 132 | :push empty array to stack 133 | Run map instruction repeatedly 134 | 135 | SPECIAL_MAP 136 | SPECIAL_MAP_STORE 137 | 138 | ### Reduce 139 | 140 | :push function to stack 141 | :push array to stack 142 | :push first element of array to stack 143 | :push number one to stack 144 | Run reduce instruction repeatedly 145 | 146 | SPECIAL_REDUCE 147 | SPECIAL_REDUCE_STORE 148 | 149 | ### Filter 150 | 151 | :push function to stack (prediate) 152 | :push array to stack (source) 153 | :push empty array to stack (destination) 154 | :push integer zero to the stack (iterator) 155 | Run filter instruction repeatedly 156 | 157 | SPECIAL_FILTER 158 | SPECIAL_FILTER_STORE -------------------------------------------------------------------------------- /mathbot/calculator/crucible.py: -------------------------------------------------------------------------------- 1 | ''' 2 | A small library that uses multiprocessing 3 | to run small functions that might explode 4 | in a bad way. 5 | ''' 6 | 7 | 8 | import asyncio 9 | import multiprocessing 10 | import async_timeout 11 | import time 12 | import traceback 13 | import random 14 | import logging 15 | 16 | 17 | log = logging.getLogger(__name__) 18 | log.setLevel(logging.INFO) 19 | 20 | 21 | if multiprocessing.get_start_method(allow_none=True) is None: 22 | # Using the default, 'fork', causes 23 | # =calc 3^7^7^7^7^7^8^8^7^7^7^8^8^8^7 24 | # to break, and also the interrupe handlers in 25 | # bot.py cause issues. 26 | multiprocessing.set_start_method('spawn') 27 | log.info('Crucible set multiprocessing start method') 28 | 29 | 30 | def worker(pipe): 31 | log.info('Crucible worker has started!') 32 | while True: 33 | if pipe.poll(None): 34 | func, args = pipe.recv() 35 | pipe.send(func(*args)) 36 | del func, args 37 | time.sleep(0.1) 38 | 39 | 40 | def echo(argument): 41 | return argument 42 | 43 | 44 | class Process: 45 | 46 | __slots__ = ['_pipe', '_process'] 47 | 48 | def __init__(self): 49 | self._pipe, child_pipe = multiprocessing.Pipe() 50 | self._process = multiprocessing.Process(target=worker, args=(child_pipe,), daemon=True) 51 | self._process.start() 52 | 53 | def send(self, value): 54 | self._pipe.send(value) 55 | 56 | def recv(self): 57 | return self._pipe.recv() 58 | 59 | def poll(self): 60 | return self._pipe.poll() 61 | 62 | def terminate(self): 63 | self._process.terminate() 64 | 65 | 66 | class StartupFailure(Exception): 67 | pass 68 | 69 | 70 | class Pool: 71 | 72 | __slots__ = ['_semaphore', '_idle'] 73 | 74 | def __init__(self, max_processess): 75 | self._semaphore = asyncio.Semaphore(max_processess) 76 | self._idle = [] 77 | 78 | async def run(self, function, arguments, *, timeout=5): 79 | proc = None 80 | result = None 81 | async with self._semaphore: 82 | try: 83 | if self._idle: 84 | proc = self._idle.pop() 85 | else: 86 | proc = Process() 87 | log.info(f'Starting new process: {id(proc)}') 88 | # Starting a new process has an overhead, so we shoudld wait 89 | # for it before starting the real timer. 90 | secret = random.randint(0, 1 << 20) 91 | result = await self._roundtrip(proc, echo, (secret,), 20) 92 | if result == secret: 93 | log.info(f'Process successfully started {id(proc)}') 94 | else: 95 | log.warning('Crucible failed to start subprocess') 96 | raise StartupFailure 97 | result = await self._roundtrip(proc, function, arguments, timeout) 98 | except Exception: 99 | log.error(f'Process has failed: {id(proc)}') 100 | try: 101 | proc.terminate() 102 | except Exception: 103 | print('Termination caused an exception') 104 | else: 105 | print('Termination succeeded') 106 | raise 107 | else: 108 | self._idle.append(proc) 109 | return result 110 | 111 | @staticmethod 112 | async def _roundtrip(proc, function, arguments, timeout): 113 | async with async_timeout.timeout(timeout): 114 | proc.send((function, arguments)) 115 | while not proc.poll(): 116 | await asyncio.sleep(0.01) 117 | return proc.recv() 118 | 119 | GLOBAL_POOL = Pool(4) 120 | async def run(function, arguments, *, timeout=5): 121 | return await GLOBAL_POOL.run(function, arguments, timeout=timeout) 122 | 123 | 124 | def large(): 125 | print('Being large...') 126 | large = 10000000 127 | return large ** large 128 | 129 | 130 | def small(x): 131 | return x * x 132 | 133 | 134 | async def guard(f): 135 | start = time.time() 136 | try: 137 | return await f 138 | except Exception: 139 | print('Guard', time.time() - start) 140 | 141 | async def many(): 142 | return sum(await asyncio.gather(*[run(small, (i,), timeout=1) for i in range(100)])) 143 | 144 | 145 | if __name__ == '__main__': 146 | coroutine = asyncio.gather( 147 | guard(run(large, (), timeout=2)), 148 | many(), 149 | guard(run(large, (), timeout=3)), 150 | many(), 151 | guard(run(large, (), timeout=4)), 152 | many(), 153 | guard(run(large, (), timeout=5)), 154 | many(), 155 | ) 156 | loop = asyncio.get_event_loop() 157 | print(loop.run_until_complete(coroutine)) 158 | -------------------------------------------------------------------------------- /mathbot/calculator/errors.py: -------------------------------------------------------------------------------- 1 | import re 2 | from . import formatter 3 | 4 | 5 | def wrap_if_plus(s): 6 | if '+' in s or '-' in s: 7 | return '(' + s + ')' 8 | return s 9 | 10 | 11 | def format_value(x): 12 | if x is None: 13 | return 'null' 14 | if isinstance(x, complex): 15 | real = wrap_if_plus(format_value(x.real)) 16 | imag = wrap_if_plus(format_value(x.imag)) 17 | if real != '0' and imag != '0': 18 | return '{}+{}**i**'.format(real, imag) 19 | elif imag != '0': 20 | return imag + '**i**' 21 | else: 22 | return real 23 | if isinstance(x, int): 24 | return str(x) 25 | if isinstance(x, float): 26 | if abs(x) < 1e-22: 27 | return '0' 28 | if abs(x) > 1e10 or abs(x) < 1e-6: 29 | s = '{:.8e}'.format(x) 30 | return re.sub(r'\.?0*e', 'e', s) 31 | return '{:.8f}'.format(x).rstrip('0').rstrip('.') 32 | return '"{}"'.format(str(x)) 33 | 34 | 35 | class TooMuchOutputError(Exception): 36 | pass 37 | 38 | 39 | class FormattedError(Exception): 40 | 41 | def __init__(self, description, *values): 42 | if len(values) == 0: 43 | self.description = description 44 | else: 45 | formatted = list(map(lambda x: formatter.format(x, limit = 2000), values)) 46 | self.description = description.format(*formatted) 47 | 48 | def __str__(self): 49 | return self.description 50 | 51 | 52 | class CompilationError(Exception): 53 | ''' Problem in the code found during compilation ''' 54 | 55 | def __init__(self, description, source = None): 56 | self.description = description 57 | if source is None: 58 | self.position = None 59 | else: 60 | self.position = source['source']['position'] 61 | 62 | def __str__(self): 63 | return self.description 64 | 65 | 66 | class EvaluationError(FormattedError): 67 | ''' Things that go wrong at runtime ''' 68 | 69 | class SystemError(FormattedError): 70 | ''' Problem due to a bug in the system, not the user's code ''' 71 | 72 | class AccessFailedError(EvaluationError): 73 | ''' Failed to access a variable ''' 74 | def __init__(self, name): 75 | super().__init__('Failed to access variable {}', name) 76 | self.name = name -------------------------------------------------------------------------------- /mathbot/calculator/formatter.py: -------------------------------------------------------------------------------- 1 | ''' Calculator formatter. 2 | 3 | Used to take complicated data structures, including 4 | lists, arrays, and sympy objects and convert them 5 | into a flat, human-readable string. 6 | ''' 7 | 8 | import sympy 9 | from . import functions 10 | from . import errors 11 | import re 12 | 13 | 14 | ALL_SYMPY_CLASSES = tuple(sympy.core.all_classes) # pylint: disable=no-member 15 | ELEMENT_SEPARATOR = ' ' 16 | 17 | 18 | class Collector: 19 | 20 | ''' Buffers print-like commands in order to make things 21 | fast and also limit the total size of the output. 22 | ''' 23 | 24 | def __init__(self, limit=None): 25 | self.parts = [] 26 | self.length = 0 27 | self.limit = limit 28 | 29 | def print(self, *args): 30 | ''' Add some stuff to the buffer. 31 | Raises an exception if it overlfows. 32 | ''' 33 | self.parts += args 34 | self.length += sum(map(len, args)) 35 | if self.limit and self.length > self.limit: 36 | raise errors.TooMuchOutputError 37 | 38 | def drop(self): 39 | ''' Remove the last item from the buffer. ''' 40 | self.parts.pop() 41 | 42 | def __str__(self): 43 | ''' Reduce to a string. ''' 44 | output = ''.join(self.parts) 45 | if self.limit and len(output) > self.limit: 46 | output = output[:self.limit - 3] + '...' 47 | return output 48 | 49 | 50 | class CustomSympyPrinter(sympy.printing.str.StrPrinter): 51 | 52 | def _print_Mul(self, expr): 53 | string = sympy.printing.str.StrPrinter._print_Mul(self, expr) 54 | return re.sub(r'^1\.0\*', '', string) 55 | 56 | def _print_ImaginaryUnit(self, expr): 57 | return 'i' 58 | 59 | def _print_Infinity(self, expr): 60 | return '∞' if self._settings.get('unicode', True) else 'infinity' 61 | 62 | def _print_NegativeInfinity(self, expr): 63 | return '-∞' if self._settings.get('unicode', True) else 'negitive_infinity' 64 | 65 | def _print_ComplexInfinity(self, expr): 66 | return 'complex_infinity' 67 | 68 | def _print_NaN(self, expr): 69 | return 'not_a_number' 70 | 71 | def _print_Pi(self, expre): 72 | return 'π' if self._settings.get('unicode', True) else 'pi' 73 | 74 | def _print_Integer(self, expr): 75 | SEP = '\N{SINGLE LOW-9 QUOTATION MARK}' 76 | normal = super()._print_Integer(expr)[::-1] 77 | out = [] 78 | for i, c in enumerate(normal): 79 | if i % 3 == 0 and i != 0: 80 | out.append(SEP) 81 | out.append(c) 82 | return ''.join(out[::-1]).replace('-' + SEP, '-') 83 | 84 | 85 | class SimpleFormatter: 86 | 87 | ''' Simplest implementation of the formatter. 88 | Currently used to format things in all cases, 89 | but in theory could be subclassed to produce 90 | different behaviour for specific cases. 91 | ''' 92 | 93 | def __init__(self, limit=None): 94 | self._collector = Collector(limit=limit) 95 | 96 | def drop(self): 97 | ''' Remove the most recently added item ''' 98 | self._collector.drop() 99 | 100 | def fmt(self, *args): 101 | ''' Format a number of objects ''' 102 | for i in args: 103 | # print(i.__class__, i.__class__.__mro__) 104 | if i is None: 105 | self._collector.print('null') 106 | elif isinstance(i, bool): 107 | self.fmt_py_bool(i) 108 | elif isinstance(i, str): 109 | self.fmt_py_string(i) 110 | elif isinstance(i, list): 111 | self.fmt_py_list(i) 112 | elif isinstance(i, functions.Array): 113 | self.fmt_array(i) 114 | elif isinstance(i, functions.ListBase): 115 | self.fmt_list(i) 116 | elif isinstance(i, functions.Glyph): 117 | self.fmt_glyph(i) 118 | elif isinstance(i, ALL_SYMPY_CLASSES): 119 | self.fmt_sympy_object(i) 120 | else: 121 | self.fmt_py_string(str(i)) 122 | 123 | def fmt_py_bool(self, b): 124 | ''' Format a boolean value ''' 125 | self._collector.print('true' if b else 'false') 126 | 127 | def fmt_py_string(self, i): 128 | ''' Format a string, which means just add it to the output ''' 129 | self._collector.print(i) 130 | 131 | def fmt_glyph(self, glyph): 132 | ''' Format a single glyph ''' 133 | o = '\\t' if glyph.value == '\t' else glyph.value 134 | o = '\\n' if glyph.value == '\n' else o 135 | self.fmt(o) 136 | # self.fmt('`', glyph.value, '`') 137 | 138 | def fmt_array(self, array): 139 | ''' Format an array ''' 140 | self.fmt('array(') 141 | for i in array: 142 | self.fmt(i, ELEMENT_SEPARATOR) 143 | self.drop() 144 | self.fmt(')') 145 | 146 | def fmt_list(self, lst): 147 | ''' Format a list ''' 148 | is_string = True 149 | for i in lst: 150 | if not isinstance(i, functions.Glyph): 151 | is_string = False 152 | if len(lst) == 0: 153 | self.fmt('[]') 154 | elif is_string: 155 | self.fmt('"') 156 | for i in lst: 157 | if i.value == '"': 158 | self._collector.print('\\"') 159 | else: 160 | self._collector.print(i.value) 161 | self.fmt('"') 162 | else: 163 | self.fmt('[') 164 | for i in lst: 165 | self.fmt(i, ELEMENT_SEPARATOR) 166 | self.drop() 167 | self.fmt(']') 168 | 169 | def fmt_py_list(self, lst): 170 | ''' Format a python list ''' 171 | self.fmt('(') 172 | for i in lst: 173 | self.fmt(i, ', ') # leave alone as it needs to remain Python syntax 174 | if lst: 175 | self.drop() 176 | self.fmt(')') 177 | 178 | def fmt_sympy_object(self, obj): 179 | ''' Format a sympy object ''' 180 | self._collector.print(sympy_cleanup(CustomSympyPrinter().doprint(obj))) 181 | 182 | def __str__(self): 183 | return str(self._collector) 184 | 185 | 186 | def format(*values, limit=None): # pylint: disable=redefined-builtin 187 | ''' Format some values, producing a human-readable string. ''' 188 | fmtr = SimpleFormatter(limit=limit) 189 | fmtr.fmt(*values) 190 | return str(fmtr) 191 | 192 | 193 | def sympy_cleanup(string): 194 | return string.replace('**', '^').replace('*', '×') 195 | 196 | 197 | ESCAPE_REGEX = re.compile(r'\\(.)') 198 | ESCAPE_DICT = {'n': '\n', 't': '\t'} 199 | 200 | def string_backslash_escaping(string): 201 | return ESCAPE_REGEX.sub(lambda x: ESCAPE_DICT.get(x.group(1), x.group(1)), string) 202 | -------------------------------------------------------------------------------- /mathbot/calculator/grammar.md: -------------------------------------------------------------------------------- 1 | # Grammar 2 | 3 | ``` 4 | superscript_number (regex) = [⁰¹²³⁴⁵⁶⁷⁸⁹]+ 5 | number (regex) = \d*\.?\d+([eE]-?\d+)?i? 6 | word (regex) = π|τ|∞|[a-zA-Z_][a-zA-Z0-9_]* 7 | 8 | wrapped_expression = '(', expression, ')' 9 | 10 | function_call = atom | function_call, '(', argument_list, ')' 11 | logical_not = function_call | '!', logical_not 12 | factorial = logical_not | factorial, '!' 13 | uminus = dieroll | '-', uminus 14 | superscript = uminus, {superscript_number} 15 | 16 | power = superscript | superscript, '^', power 17 | modulo = [modulo, '%'], power 18 | prod_op = '*', '/', '÷', '×' 19 | product = [product, prod_op], modulo 20 | addition_op = '+', '-' 21 | addition = [addition, add_op], addition 22 | logic_and = [logic_and, '&'], addition 23 | logic_or = [logic_or, '|'], logic_and 24 | 25 | comparison_op = '>', '<', '==', '!=', '>=', '<=' 26 | comparison = logic_or, {comparison_op, logic_or} 27 | 28 | parameter_list = '(', [{atom, ','}, atom, ['.']], ')' 29 | function_op = '->', '~>' 30 | function_definition = parameter_list, function_op, expression 31 | 32 | expression = function_definition 33 | 34 | statement = [atom, '='] expression 35 | 36 | program = {statement} 37 | ``` 38 | 39 | atom = token_oneof('number', 'word', 'period', 'string', 'glyph') 40 | number = token('number') 41 | percentage = number | percentage: number '%' 42 | 43 | wrapped_expression = round_brackets -------------------------------------------------------------------------------- /mathbot/calculator/library.c5: -------------------------------------------------------------------------------- 1 | # This is actually required for the if function to be 2 | # passed around as a 'function'. 3 | # Unfortunately there's no way to implement a nice equivilent 4 | # for ifelse 5 | 6 | if = (c t f) ~> if(c() t() f()) 7 | 8 | # Kind of needed but might be dissapearing soon in favour 9 | # of lists 10 | array (a.) = a 11 | 12 | # A more robust equals function that wont crash cause you 13 | # compare a glyph to a non-glyph and whatnot. 14 | 15 | equals(a,b) = try(a == b, false) 16 | 17 | # Functions that wrap around the standard operators 18 | sum (a b) = a + b 19 | mul (a b) = a * b 20 | dif (a b) = a - b 21 | div (a b) = a / b 22 | pow (a b) = a ^ b 23 | mod (a b) = a ~mod b 24 | and (a b) = a && b 25 | or (a b) = a || b 26 | 27 | # Functions that wrap around the list operators 28 | head(l) = 'l 29 | rest(l) = \l 30 | car(l) = 'l 31 | cdr(l) = \l 32 | 33 | caar(l) = car(car(l)) 34 | cadr(l) = car(cdr(l)) 35 | cdar(l) = cdr(car(l)) 36 | cddr(l) = cdr(cdr(l)) 37 | 38 | # Useful comparitive things 39 | max (a b) = if(a < b b a) 40 | min (a b) = if(a < b a b) 41 | 42 | _zip(a b r) = if (!a || !b r _zip(\a, \b, (['a,'b]):r)) 43 | zip(a b) = reverse(_zip(a b [])) 44 | 45 | # Bread and butter list mapipulation functions 46 | 47 | _repeat(item times result) = if (times <= 0 result _repeat(item times - 1 item : result)) 48 | repeat(item times) = _repeat(item times []) 49 | 50 | _reverse(i o) = if(!i o _reverse(\i 'i:o)) 51 | reverse(l) = _reverse(l []) 52 | # doesn't preserve order 53 | join_iter = _reverse 54 | 55 | _map(f l r) = if(!l r _map(f \l f('l):r)) 56 | map(f l) = reverse(_map(f l [])) 57 | 58 | _filter(f l r) = ifelse( 59 | !l r 60 | f('l) _filter(f \l 'l:r) 61 | _filter(f \l r) 62 | ) 63 | filter(f l) = reverse(_filter(f l [])) 64 | 65 | foldl(f x l) = if (!l x 66 | foldl(f f(x 'l) \l) 67 | ) 68 | 69 | foldr(f x l) = if (!l x 70 | f('l foldr(f x \l)) 71 | ) 72 | 73 | _list(a i r) = if (i < 0 r _list(a i - 1 a(i):r)) 74 | list(x.) = _list(x length(x) - 1 []) 75 | 76 | _range(a b r) = if (a == b r _range(a, b - 1, (b - 1):r)) 77 | range(a b) = _range(a b []) 78 | 79 | toarray(l) = array(expand(l)) 80 | 81 | _tolist(a o) = if (a _tolist(\a 'a:o) o) 82 | tolist(a) = reverse(_tolist(a [])) 83 | 84 | join(a b) = if (!a b 'a:join(\a b)) 85 | 86 | _merge(a b) = ifelse( 87 | !a, b, 88 | !b, a, 89 | 'b < 'a, 'b:_merge(a \b) 90 | 'a:_merge(\a b) 91 | ) 92 | _sort(x h) = _merge(sort(take(x h)) sort(drop(x h))) 93 | sort(x) = if (length(x) <= 1 x _sort(x int(length(x) / 2))) 94 | 95 | _interleave(x ls new_ls) = if(ls == [], new_ls, _interleave(x, \ls, 'ls:x:new_ls)) 96 | interleave(x ls) = if(ls == [], [], reverse(_interleave(x \ls ['ls]))) 97 | 98 | flatten(x) = if(is_sequence(x), foldr(join, [], map(flatten, x)), [x]) 99 | 100 | _remove_f(ls f new_ls) = ifelse( 101 | !ls, new_ls, 102 | f('ls), join_iter(\ls, new_ls), 103 | _remove_f(\ls, f, 'ls : new_ls) 104 | ) 105 | 106 | remove_f(ls f) = reverse(_remove_f(ls f [])) 107 | 108 | remove(ls elem) = remove_f(ls, (e) -> e == elem) 109 | 110 | in(s elem) = ifelse( 111 | !s, false, 112 | equals('s, elem), true, 113 | in(\s elem) 114 | ) 115 | 116 | # assoc-lists 117 | apair(key value) = [key, value] 118 | akey(pair) = 'pair 119 | avalue(pair) = cadr(pair) 120 | 121 | _assoc(ass key value new_ass) = ifelse( 122 | !ass, apair(key value) : new_ass, 123 | equals(akey('ass), key), join_iter((apair(key value) : \ass), new_ass), 124 | _assoc(\ass, key, value, 'ass : new_ass) 125 | ) 126 | 127 | assoc(ass key value) = _assoc(ass key value []) 128 | 129 | get(ass key) = ifelse( 130 | !ass, [], 131 | equals(akey('ass), key), avalue('ass), 132 | get(\ass key) 133 | ) 134 | 135 | aremove(ass key) = remove_f(ass, (e) -> equals('e, key)) 136 | aremove_value(ass value) = filter((e) -> !equals(cadr(e), value), ass) 137 | 138 | update(ass key f) = assoc(ass key f(get(ass key))) 139 | 140 | values(ass) = map(cadr ass) 141 | keys(ass) = map(car ass) 142 | 143 | # sets 144 | _set_insert(s elem new_s) = ifelse( 145 | !s, elem : new_s, 146 | equals('s, elem), join_iter(s, new_s), 147 | _set_insert(\s, elem, 's : new_s) 148 | ) 149 | 150 | set_insert(s elem) = _set_insert(s elem []) 151 | 152 | set_remove = remove 153 | 154 | to_set(ls) = foldr((a b) -> set_insert(b a), [], ls) 155 | 156 | _slow_set_equals(a b) = length(a) == length(b) && foldr((x,y) -> x && y, true, map(x -> in(b x), a)) 157 | # initially i had this but it hit weird edge cases with 158 | # set_equals([5,1,"hi",;b,0,true,f],[;b,f,"hi",1,true,5,0]) 159 | # set_equals(a b) = try(sort(a) == sort(b), _slow_set_equals(a b)) 160 | set_equals = _slow_set_equals 161 | 162 | # Trig functions for degrees 163 | sind(d) = sin(rad(d)) 164 | cosd(d) = cos(rad(d)) 165 | tand(d) = tan(rad(d)) 166 | cotd(d) = cot(rad(d)) 167 | cscd(d) = csc(rad(d)) 168 | secd(d) = sec(rad(d)) 169 | asind(d) = deg(asin(d)) 170 | acosd(d) = deg(acos(d)) 171 | atand(d) = deg(atan(d)) 172 | acotd(d) = deg(acot(d)) 173 | acscd(d) = deg(acsc(d)) 174 | asecd(d) = deg(asec(d)) 175 | 176 | choose(n, k) = n! / k! / (n - k)! 177 | 178 | startswith(x z) = if(!z, true, (x && ('x == 'z) && startswith(\x \z))) 179 | drop(x n) = if (n <= 0 x drop(\x n - 1)) 180 | take(x n) = if (n <= 0 [] 'x:take(\x n - 1)) 181 | 182 | _split(x z) = ifelse ( 183 | !x, [""], 184 | startswith(x z), "":split(drop(x length(z)) z), 185 | (k -> ('x:'k):\k)(_split(\x z)) 186 | ) 187 | split(x z) = filter(i -> i, _split(x z)) 188 | 189 | display(x.) = \join(expand(map(i -> ; :str(i), x))) 190 | 191 | chrs(s) = map(chr s) 192 | ords(s) = map(ord s) 193 | -------------------------------------------------------------------------------- /mathbot/calculator/runtime.py: -------------------------------------------------------------------------------- 1 | # Bytecode that gets included with every program that runs. 2 | # This contains a number of builtin functions and things. 3 | 4 | import math 5 | import cmath 6 | import itertools 7 | import os 8 | import sympy 9 | import types 10 | import collections 11 | import functools 12 | 13 | from .bytecode import * 14 | from .functions import * 15 | from .errors import EvaluationError 16 | from . import parser 17 | from . import formatter 18 | from . import crucible 19 | 20 | 21 | ALL_SYMPY_CLASSES = tuple(sympy.core.all_classes) 22 | 23 | 24 | def protect_sympy_function(func): 25 | def replacement(*args): 26 | for i in args: 27 | if not isinstance(i, ALL_SYMPY_CLASSES): 28 | raise TypeError 29 | return func(*args) 30 | replacement.__name__ = func.__name__ 31 | return replacement 32 | 33 | 34 | def is_function(val): 35 | return int(isinstance(val, (Function, BuiltinFunction))) 36 | 37 | 38 | def is_sequence(val): 39 | return int(isinstance(val, (Array, ListBase))) 40 | 41 | 42 | def format_normal(val): 43 | try: 44 | string = formatter.format(val, limit=5000) 45 | except errors.TooMuchOutputError: 46 | raise errors.EvaluationError('repr function received object that was too large') 47 | else: 48 | glyphs = list(map(Glyph, string)) 49 | return create_list(glyphs) 50 | 51 | 52 | def is_string(val): 53 | return is_sequence(val) and all(isinstance(i, Glyph) for i in val) 54 | 55 | 56 | def format_smart(val): 57 | if is_string(val): 58 | if not val: 59 | return EMPTY_LIST 60 | try: 61 | string = formatter.format(val, limit=5000) 62 | except errors.TooMuchOutputError: 63 | raise errors.EvaluationError('repr function received object that was too large') 64 | else: 65 | glyphs = list(map(Glyph, string[1:-1])) 66 | return create_list(glyphs) 67 | return format_normal(val) 68 | 69 | 70 | def array_length(val): 71 | if not isinstance(val, (Array, Interval, ListBase)): 72 | raise EvaluationError('Cannot get the length of non-array object') 73 | return len(val) 74 | 75 | 76 | def array_expand(*arrays): 77 | for i in arrays: 78 | if not isinstance(i, (Array, ListBase)): 79 | raise EvaluationError('Cannot expand something that\'s not an array or list') 80 | return Expanded(arrays) 81 | 82 | 83 | def make_range(start, end): 84 | start = int(start) 85 | end = int(end) 86 | if not isinstance(start, int): 87 | raise EvaluationError('Cannot create range on non-int') 88 | if not isinstance(end, int): 89 | raise EvaluationError('Cannot create range on non-int') 90 | if end < start: 91 | raise EvaluationError('Cannot create backwards ranges') 92 | return Interval(start, 1, end - start) 93 | 94 | 95 | @protect_sympy_function 96 | def mylog(e, b = 10): 97 | return sympy.log(e, b) 98 | 99 | 100 | @protect_sympy_function 101 | def to_degrees(r): 102 | return r * sympy.Number(180) / sympy.pi 103 | 104 | 105 | @protect_sympy_function 106 | def to_radians(d): 107 | return d * sympy.pi / sympy.Number(180) 108 | 109 | 110 | def glyph_to_int(glyph): 111 | if not isinstance(glyph, Glyph): 112 | raise EvaluationError('ord received non-glyph') 113 | return sympy.Integer(ord(glyph.value)) 114 | 115 | 116 | def int_to_glyph(integer): 117 | if not isinstance(integer, (int, sympy.Integer)): 118 | raise EvaluationError('chr received non-integer') 119 | return Glyph(chr(int(integer))) 120 | 121 | 122 | @protect_sympy_function 123 | def reduce_to_float(value): 124 | return sympy.Number(float(value)) 125 | 126 | 127 | BUILTIN_FUNCTIONS = { 128 | 'log': mylog, 129 | 'ln': sympy.log, 130 | 'is_function': is_function, 131 | 'is_sequence': is_sequence, 132 | 'length': array_length, 133 | 'expand': array_expand, 134 | 'int': sympy.Integer, 135 | 'decimal': reduce_to_float, 136 | 'float': reduce_to_float, 137 | 'subs': lambda expr, symbol, value: expr.subs(symbol, value), 138 | 'deg': to_degrees, 139 | 'rad': to_radians, 140 | 'repr': format_normal, 141 | 'str': format_smart, 142 | 'ord': glyph_to_int, 143 | 'chr': int_to_glyph, 144 | } 145 | 146 | 147 | BUILTIN_COROUTINES = {} 148 | 149 | 150 | FIXED_VALUES = { 151 | 'π': sympy.pi, 152 | 'τ': sympy.pi * 2, 153 | 'tau': sympy.pi * 2, 154 | 'true': True, 155 | 'false': False 156 | } 157 | 158 | 159 | FIXED_VALUES_EXPORTABLE = { 160 | 'π': math.pi, 161 | 'τ': math.pi * 2, 162 | 'pi': math.pi, 163 | 'tau': math.pi * 2, 164 | 'true': True, 165 | 'false': False 166 | } 167 | 168 | 169 | def _extract_from_sympy(): 170 | def _wrap_with_crucible(function, condition=lambda x: True): 171 | async def _replacement(*args): 172 | if condition(*args): 173 | return await crucible.run(function, args, timeout=2) 174 | return function(*args) 175 | return protect_sympy_function(_replacement) 176 | names = ''' 177 | re im sign Abs arg conjugate 178 | sin cos tan cot sec csc sinc asin acos atan acot asec acsc atan2 sinh cosh 179 | tanh coth sech csch asinh acosh atanh acoth asech acsch ceiling floor frac 180 | exp root sqrt pi E I gcd lcm 181 | oo:infinity:∞ zoo:complex_infinity nan:not_a_number 182 | ''' 183 | for i in names.split(): 184 | parts = i.split(':') 185 | internal_name = parts[0] 186 | external_names = parts[1:] or [internal_name] 187 | value = getattr(sympy, internal_name) 188 | for name in map(str.lower, external_names): 189 | if isinstance(value, (sympy.FunctionClass, types.FunctionType)): 190 | BUILTIN_FUNCTIONS[name] = protect_sympy_function(value) 191 | else: 192 | FIXED_VALUES[name] = value 193 | BUILTIN_COROUTINES['factorial'] = _wrap_with_crucible(sympy.factorial, lambda x: x > 100) 194 | BUILTIN_COROUTINES['gamma'] = _wrap_with_crucible(sympy.gamma, lambda x: x > 100) 195 | _extract_from_sympy() 196 | 197 | 198 | # Code that is really useful to it's included by default 199 | with open(os.path.join(os.path.dirname(__file__), 'library.c5')) as f: 200 | LIBRARY_CODE = f.read() 201 | 202 | 203 | def _assignment_code(name, value, add_terminal_byte=False): 204 | return { 205 | '#': 'assignment', 206 | 'variable': { 207 | 'string': name, 208 | }, 209 | 'value': { 210 | '#': '_exact_item_hack', 211 | 'value': value 212 | } 213 | } 214 | 215 | 216 | def _prepare_runtime(exportable=False): 217 | if exportable: 218 | for name, value in FIXED_VALUES_EXPORTABLE.items(): 219 | yield _assignment_code(name, value) 220 | else: 221 | for name, value in FIXED_VALUES.items(): 222 | yield _assignment_code(name, value) 223 | for name, func in BUILTIN_FUNCTIONS.items(): 224 | yield _assignment_code(name, BuiltinFunction(func, name)) 225 | for name, func in BUILTIN_COROUTINES.items(): 226 | yield _assignment_code(name, BuiltinFunction(func, name, is_coroutine=True)) 227 | _, ast = parser.parse(LIBRARY_CODE, source_name='_system_library') 228 | yield ast 229 | 230 | 231 | @functools.lru_cache(4) 232 | def prepare_runtime(builder, **kwargs): 233 | return builder.build(*list(_prepare_runtime(**kwargs)), unsafe=True) 234 | 235 | 236 | def wrap_simple(ast): 237 | builder = CodeBuilder() 238 | return wrap_with_runtime(builder, ast) 239 | -------------------------------------------------------------------------------- /mathbot/calculator/scripts/addition.c5: -------------------------------------------------------------------------------- 1 | 1 + 2 -------------------------------------------------------------------------------- /mathbot/calculator/scripts/aperture.c5: -------------------------------------------------------------------------------- 1 | arccos = (s) -> if( 2 | is_complex(s) > 0, 3 | acos(sqrt((-(re(s)^2)-(im(s)^2)-1+sqrt((re(s)^2+1+im(s)^2)^2+4*re(s)^2)/2)))+iacosh(sqrt((re(s)^2+im(s)^2+1+sqrt((re(s)^2+1+im(s)^2)^2+4*re(s)^2))/2)), 4 | if(is_real(s) > 0, acos(s), acosh(s)) 5 | ), 6 | 7 | arccos(3 + 4i) -------------------------------------------------------------------------------- /mathbot/calculator/scripts/assignment.c5: -------------------------------------------------------------------------------- 1 | x = 1, 2 | 3 | f = () -> ( 4 | x = 2, 5 | x 6 | ), 7 | 8 | << f(), 9 | << x, 10 | 11 | 0 12 | -------------------------------------------------------------------------------- /mathbot/calculator/scripts/c6.c5: -------------------------------------------------------------------------------- 1 | function(1, 2, 3) 2 | -------------------------------------------------------------------------------- /mathbot/calculator/scripts/cycle.c5: -------------------------------------------------------------------------------- 1 | cycle_create(l) = [l .] 2 | 3 | _cycle_head(a, b) = if (a 'a 'reverse(b)) 4 | cycle_head(l) = _cycle_head('l, '\l) 5 | 6 | _cycle_tail(a, b) = if (a [\a 'a:b] [reverse(b) .]) 7 | cycle_tail(l) = _cycle_tail('l, '\l) 8 | 9 | c = cycle_create([1, 2, 3]) 10 | 11 | cycle_head(c) 12 | 13 | cycle_tail(c) 14 | -------------------------------------------------------------------------------- /mathbot/calculator/scripts/error_evaluation.c5: -------------------------------------------------------------------------------- 1 | 1 + [] 2 | -------------------------------------------------------------------------------- /mathbot/calculator/scripts/error_parse.c5: -------------------------------------------------------------------------------- 1 | x = -------------------------------------------------------------------------------- /mathbot/calculator/scripts/exponential_bytecode.c5: -------------------------------------------------------------------------------- 1 | f = (x) -> x, 2 | 3 | f(f(f(f(f(f(f(f(f(f(f(f(f(1))))))))))))) -------------------------------------------------------------------------------- /mathbot/calculator/scripts/fib.c5: -------------------------------------------------------------------------------- 1 | fib = (n) -> if(n < 2 , 1, fib(n - 2) + fib(n - 1)), 2 | stackless = (n) -> reduce((x, i) -> fib(i), range(0, n)), 3 | stackless(2000) -------------------------------------------------------------------------------- /mathbot/calculator/scripts/filter.c5: -------------------------------------------------------------------------------- 1 | is_even = (x) -> (x % 2 == 0), 2 | values = range(0, 100), 3 | f = filter(is_even, values), 4 | length(f) 5 | -------------------------------------------------------------------------------- /mathbot/calculator/scripts/graph.c5: -------------------------------------------------------------------------------- 1 | binary = (x) -> if(x == 0, 0, 2 * binary(int(x / 2)) + x % 10) 2 | 3 | max_nodes = 4 4 | 5 | bit_get = (num, index) -> int(num / 2 ^ index) % 2 6 | bit_set = (num, index) -> num + if(bit_get(num, index) == 0, 2 ^ index, 0) 7 | 8 | has_path = (graph, from, to) -> bit_get(graph, (from * max_nodes) + to) 9 | add_path = (graph, from, to) -> bit_set(graph, (from * max_nodes) + to) 10 | 11 | path_exists_2 = (graph, seen, current, next, goal) -> 12 | if(next == max_nodes | bit_get(seen, current) == 1, 13 | 0, 14 | ( 15 | res_next = path_exists_2(graph, seen, current, next + 1, goal), 16 | res_go = if (has_path(graph, current, next) == 0, path_exists_2(graph, bit_set(seen, current), current, 0, goal), 0), 17 | res_next | res_go 18 | ) 19 | ) 20 | 21 | path_exists = (graph, start, goal) -> path_exists_2(graph, 0, start, 0, goal) 22 | 23 | graph = binary(1111111111111111) 24 | 25 | << path_exists(graph, 1, 1) 26 | -------------------------------------------------------------------------------- /mathbot/calculator/scripts/large.c5: -------------------------------------------------------------------------------- 1 | map = (list, func) -> 2 | if (list == empty_list, empty_list, 3 | cons( func(car(list)), map(cdr(list), func))), 4 | 5 | fold = (list, init, func) -> 6 | if (list == empty_list, init, 7 | func(fold(cdr(list),init,func), car(list))), 8 | 9 | length = (list) -> 10 | fold(list, 0, (acc, s) -> 1 + acc) , 11 | 12 | print_iter = (list, acc) -> 13 | if(list == empty_list, acc, 14 | print_iter(cdr(list), 15 | (acc * ( 10 ^ int(log(car(list)) + 2)) + car(list)))), 16 | 17 | print_list = (list) -> print_iter(cons(1,list), 0), 18 | 19 | take = (list, n) -> 20 | if((n == 0) + (list == empty_list), empty_list, 21 | cons(car(list), take(cdr(list), n - 1))), 22 | 23 | split = (number) -> 24 | if(number == 0, empty_list, 25 | cons(number % 10, split(int(number / 10)))), 26 | 27 | reverse_iter = (list, acc) -> 28 | if(list == empty_list, acc, 29 | reverse_iter(cdr(list), cons(car(list), acc))), 30 | 31 | reverse = (list) -> reverse_iter(list, empty_list), 32 | 33 | first_split_iter = (list, acc) -> 34 | if(list == empty_list, acc, 35 | if(length(list) == 1, acc + 1, 36 | if((car(list) == 0) * (cadr(list) != 0), acc, 37 | first_split_iter(cdr(list), acc + 1)))), 38 | 39 | first_split = (list) -> first_split_iter(list, 0) 40 | -------------------------------------------------------------------------------- /mathbot/calculator/scripts/lists.c5: -------------------------------------------------------------------------------- 1 | makelist = (arguments.) -> arguments, 2 | 3 | _reduce = (list, function, place) -> 4 | if (place <= 0, 5 | l(0), 6 | function(list(place), _reduce(list, function, place - 1)) 7 | ), 8 | 9 | reduce = (list, function) -> if (list(list) == 0, makelist(), reduce(list, function, length(list) - 1)), 10 | 11 | _map = (list, function, place) -> if (place == len(list), 12 | makelist(), 13 | prepend(function(list(0), _map(list, function, place + 1))) 14 | ), 15 | 16 | map = (list, function) -> _map, 17 | 18 | min = (list) -> reduce(list, (a, b) -> if (a < b, a, b)), 19 | max = (list) -> reduce(list, (a, b) -> if (a > b, a, b)), 20 | 21 | prepend = (item, list) -> join(makelist(item), list), 22 | append = (list, item) -> join(list, makelist(item)), 23 | rest = (list) -> splice(list, 1, length(list)), 24 | 25 | reverse = (list) -> if(length(list) == 1, list, append(reverse(rest(list)), list(0))), 26 | 27 | makelist( 28 | reverse(makelist(0, 1, 2, 3, 4, 5, 6, 7, 8, 9)), 29 | join( 30 | makelist(1, 2, 3), 31 | makelist(4, 5, 6), 32 | makelist(7, 8, 9) 33 | ) 34 | ) 35 | -------------------------------------------------------------------------------- /mathbot/calculator/scripts/nothing.c5: -------------------------------------------------------------------------------- 1 | 0 2 | -------------------------------------------------------------------------------- /mathbot/calculator/scripts/queue.c5: -------------------------------------------------------------------------------- 1 | # Internally used to create a queue 2 | _qBuild(a b) -> array(a b) 3 | 4 | # Create a queue from a list 5 | qCreate(list) -> _qBuild(list .) 6 | 7 | # Push a new element onto a queue, returns the new queue 8 | qPush(q e) -> _qBuild(e:q(0) q(1)) 9 | 10 | # Remove the element from the head of the queue, returns the new queue 11 | qPop(q) -> if(q(1) 12 | _qBuild(q(0) \q(1)) 13 | _qBuild(. \reverse(q(0))) 14 | ) 15 | 16 | # Returns the element that is currently at the head of the queue 17 | qHead(q) -> if(q(1) 'q(1) 'reverse(q(0))) -------------------------------------------------------------------------------- /mathbot/calculator/scripts/quicksort.c5: -------------------------------------------------------------------------------- 1 | 2 | ################################################# 3 | # This implementation takes 7.8 seconds per run # 4 | ############################################### # 5 | 6 | __concat(a, b) -> if(!a, b, 7 | __concat(\a, 'a : b) 8 | ), 9 | 10 | _concat(a, b) -> __concat(reverse(a), b), 11 | 12 | concat(x.) -> reducel(_concat, x), 13 | 14 | # sort(x) -> if(!x, ., 15 | # concat( 16 | # sort(filter((i) -> i < 'x, x)), 17 | # filter((i) -> i == 'x, x), 18 | # sort(filter((i) -> i > 'x, x)) 19 | # ) 20 | # ), 21 | 22 | ################################################# 23 | # This implementation takes 6.4 seconds per run # 24 | ############################################### # 25 | 26 | _sort(x, h) -> if (!x, h:., 27 | concat( 28 | sort(filter((i) -> i < h, x)), 29 | filter((i) -> i == h, x), 30 | sort(filter((i) -> i > h, x)) 31 | ) 32 | ), 33 | 34 | sort(x) -> if(x, _sort(\x, 'x), .), 35 | 36 | ################# 37 | # Testing stuff # 38 | ################# 39 | 40 | is_sorted(l) -> reduce(and, map((x) -> 'x < '\x, zip(l, \l))), 41 | 42 | _LIST_ = list(5172, 7702, 6509, 3575, 4687, 389, 5230, 6848, 1837, 9020, 2479, 4624, 3747, 123, 2509, 1017, 3352, 6647, 5152, 5656, 4594, 9056, 6403, 8894, 6827, 470, 2541, 2488, 1825, 9978, 926, 5721, 6736, 9075, 225, 9899, 3153, 6343, 5614, 1905, 5978, 3131, 3719, 1419, 1784, 1650, 3686, 2222, 762, 6376, 6084, 658, 9878, 5637, 469, 2230, 4818, 7107, 9098, 9312, 9182, 779, 6279, 9693, 146, 40, 4829, 6238, 2995, 6904, 2465, 8898, 3005, 1211, 4309, 9483, 8291, 6032, 369, 9380, 5101, 331, 9125, 5532, 899, 5001, 416, 1794, 427, 5693, 3644, 8036, 8404, 1051, 802, 2244, 3054, 807, 3154, 4407, 1322, 6266, 4271, 7717, 6385, 2478, 9999, 6106, 9919, 7790, 3400, 6394, 3533, 2311, 2534, 6579, 4824, 3870, 9692, 9995, 4890, 2945, 8175, 7673, 4633, 3044, 5804, 6916, 2264, 6432, 4602, 8183, 4881, 3452, 7477, 3652, 3440, 320, 3741, 8232, 9564, 5465, 4611, 6173, 7639, 1135, 5522, 697, 8909, 8641, 3130, 4524, 2184, 5782, 5569, 8523, 306, 4516, 2903, 9548, 5068, 288, 6718, 355, 7502, 5735, 6227, 5594, 7674, 5383, 4563, 8555, 783, 1422, 4266, 254, 1038, 3124, 9488, 4572, 1472, 4436, 7628, 1358, 5353, 8835, 747, 9864, 6020, 4043, 3151, 7551, 8068, 2535, 1921, 7755, 9468, 3699, 576, 7235, 5507, 1953, 3571, 9614, 3654, 6013, 6372, 8513, 9540, 7035, 3906, 3846, 3499, 8792, 2755, 3811, 290, 9284, 350, 3016, 5318, 6957, 3274, 2396, 6608, 7309, 3936, 4499, 8278, 4191, 990, 7366, 5962, 7963, 422, 5765, 5200, 6675, 1309, 2312, 3010, 4681, 1978, 2338, 1486, 848, 9374, 5239, 2801, 4660, 4933, 4148, 712, 918, 6476, 2619, 1700, 5924, 1370, 4346, 9617, 1067, 4759, 2517, 7672, 7492, 1033, 7683, 6317, 1060, 3893, 8767, 9696, 6693, 9977, 1604, 3971, 1793, 8505, 2354, 7630, 6224, 2539, 278, 3712, 6980, 3783, 5028, 1515, 7197, 5268, 4388, 6425, 7609, 1499, 503, 9826, 3645, 1577, 3142, 7099, 7428, 1161, 8807, 5069, 597, 36, 6284, 8504, 6707, 1107, 6640, 1317, 8318, 6270, 8115, 983, 5860, 2549, 1137, 4282, 7469, 883, 2997, 9901, 9196, 4504, 3228, 657, 7291, 7943, 3978, 4262, 2978, 8007, 1568, 6215, 9795, 5598, 8432, 375, 1719, 8272, 4326, 2820, 3633, 3048, 9331, 8623, 3429, 8495, 7754, 7215, 7329, 7516, 3279, 4041, 5647, 4855, 5491, 8185, 8985, 8990, 1258, 3092, 8256, 5302, 6967, 9802, 4727, 625, 2483, 7411, 4696, 1138, 2990, 993, 5658, 5514, 4125, 3798, 6920, 8967, 937, 6010, 3683, 1685, 8324, 1344, 932, 372, 6847, 8563, 6365, 1621, 2467, 7819, 6614, 909, 7100, 6888, 7219, 621, 5206, 9033, 500), 43 | 44 | is_sorted(sort(_LIST_)), 45 | 46 | sort(_LIST_) 47 | 48 | # is_sorted(list(1, 2, 3, 4)) -------------------------------------------------------------------------------- /mathbot/calculator/scripts/sayaks.c5: -------------------------------------------------------------------------------- 1 | map = (list, func) -> 2 | if (list == empty_list, empty_list, 3 | cons( func(car(list)), map(cdr(list), func))), 4 | 5 | fold = (list, init, func) -> 6 | if (list == empty_list, init, 7 | func(fold(cdr(list),init,func), car(list))), 8 | 9 | length = (list) -> 10 | fold(list, 0, (acc, s) -> 1 + acc), 11 | 12 | print_iter = (list, acc) -> 13 | if(list == empty_list, acc, 14 | print_iter(cdr(list), 15 | (acc * ( 10 ^ int(log(car(list)) + 2)) + car(list)))), 16 | 17 | print_list = (list) -> print_iter(cons(1,list), 0), 18 | 19 | take = (list, n) -> 20 | if((n == 0) + (list == empty_list), empty_list, 21 | cons(car(list), take(cdr(list), n - 1))), 22 | 23 | split = (number) -> 24 | if(number == 0, empty_list, 25 | cons(number % 10, split(int(number / 10)))), 26 | 27 | reverse_iter = (list, acc) -> 28 | if(list == empty_list, acc, 29 | reverse_iter(cdr(list), cons(car(list), acc))), 30 | 31 | reverse = (list) -> reverse_iter(list, empty_list), 32 | 33 | first_split_iter = (list, acc) -> 34 | if(list == empty_list, acc, 35 | if(length(list) == 1, acc + 1, 36 | if((car(list) == 0) * (cadr(list) != 0), acc, 37 | first_split_iter(cdr(list), acc + 1)))), 38 | 39 | first_split = (list) -> first_split_iter(list, 0), 40 | 41 | cons = (car, cdr) -> ((f) -> f(car, cdr)), 42 | car = (list) -> list( (a, b) -> a ), 43 | cdr = (list) -> list( (a,b) -> b ), 44 | cadr = (list) -> car(cdr(list)), 45 | cdar = (list) -> cdr(car(list)), 46 | empty_list = () -> 0, 47 | 48 | split_iter = (number) -> 49 | if(number == 0, empty_list, 50 | cons(number % 10, split_iter(int(number / 10)))), 51 | 52 | split = (number) -> reverse(split_iter(number)) 53 | -------------------------------------------------------------------------------- /mathbot/calculator/scripts/simple.c5: -------------------------------------------------------------------------------- 1 | ((n, a.) -> a(n))(0, 7, 8, 9) -------------------------------------------------------------------------------- /mathbot/calculator/scripts/user.c5: -------------------------------------------------------------------------------- 1 | abs = (a) -> if(a < 0, -a, a), 2 | gcd = (a, b) -> if (b == 0, a, gcd(b, a % b)), 3 | lcm = (a, b) -> if( (a == 0) * (b == 0), 0, abs(a * b) / gcd(a, b)) 4 | -------------------------------------------------------------------------------- /mathbot/core/__init__.py: -------------------------------------------------------------------------------- 1 | # Stop mypy complaining 2 | from . import parameters 3 | from . import blame 4 | from . import help 5 | from . import keystore 6 | from . import settings 7 | from . import util 8 | -------------------------------------------------------------------------------- /mathbot/core/blame.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from discord.ext.commands import Context 3 | from discord.abc import Messageable 4 | 5 | 6 | LOG = logging.getLogger(__name__) 7 | 8 | 9 | async def set_blame(keystore, sent, blame): 10 | ''' Assigns blame to a particular message. 11 | i.e. specifies the user that the was responsible for causing the 12 | bot the send a particular message. 13 | ''' 14 | blob = { 15 | 'mention': blame.mention, 16 | 'name': blame.name, 17 | 'discriminator': blame.discriminator, 18 | 'id': blame.id 19 | } 20 | await keystore.set_json('blame', sent.id, blob, expire = 60 * 60 * 80) 21 | -------------------------------------------------------------------------------- /mathbot/core/help.py: -------------------------------------------------------------------------------- 1 | import codecs 2 | import difflib 3 | 4 | 5 | TOPICS = {} 6 | PRIMARY_TOPICS = [] 7 | 8 | 9 | class DuplicateTopicError(Exception): 10 | def __init__(self, topic): 11 | self.topic = topic 12 | def __str__(self): 13 | return 'Multiple entries for help topic "{}"'.format(self.topic) 14 | 15 | 16 | def add(topics, message, from_file = False): 17 | if not from_file: 18 | print('Still using core.help.add for topics', topics) 19 | if isinstance(topics, str): 20 | topics = topics.split(' ') 21 | # The first topic in a list is the 'primary' topic, which gets listed 22 | if topics[0] != '': 23 | PRIMARY_TOPICS.append(topics[0]) 24 | if isinstance(message, str): 25 | message = [message] 26 | for i in topics: 27 | if i in TOPICS: 28 | raise DuplicateTopicError(i) 29 | TOPICS[i] = message 30 | 31 | 32 | def get(topic): 33 | return TOPICS.get(topic.lower()) 34 | 35 | 36 | def listing(): 37 | return sorted(PRIMARY_TOPICS) 38 | 39 | 40 | def get_similar(topic): 41 | return sorted(difflib.get_close_matches(topic.lower(), PRIMARY_TOPICS, len(PRIMARY_TOPICS))) 42 | 43 | 44 | def load_from_file(filename, topics = None): 45 | if topics is None: 46 | topics = [] 47 | pages = [[]] 48 | with codecs.open(filename, 'r', 'utf-8') as f: 49 | lines = f.readlines() 50 | remove_section = False 51 | for i in map(str.rstrip, lines): 52 | if remove_section: 53 | if i.startswith(':::endblock'): 54 | remove_section = False 55 | else: 56 | if i.startswith('#'): 57 | pages[-1].append('**{}**'.format(i.strip('# '))) 58 | elif not i.startswith(':::'): 59 | pages[-1].append(i) 60 | else: 61 | command = i[3:].split(' ') 62 | if command[0] == 'topics': 63 | topics += command[1:] 64 | elif command[0] == 'page-break': 65 | pages.append([]) 66 | elif command[0] == 'endblock': 67 | pass 68 | elif command[0] == 'discord': 69 | pass 70 | elif command[0] == 'webpage': 71 | remove_section = True 72 | else: 73 | print('Unknown command sequence in help page:', command[0]) 74 | pages = ['\n'.join(lines) for lines in pages] 75 | for i in pages: 76 | if len(i) >= 1800: 77 | print('Help page is too long, add a `:::page-break` to start a new page') 78 | print('-------------------------------------------------') 79 | print(i) 80 | print('-------------------------------------------------') 81 | add(topics, pages, from_file = True) 82 | -------------------------------------------------------------------------------- /mathbot/core/parameters.py: -------------------------------------------------------------------------------- 1 | # This file handles the bot's parameter loading. 2 | 3 | # Parameters in the parameters.json file can be used 4 | # to alter how the bot behaves. 5 | 6 | 7 | import os 8 | import json 9 | import sys 10 | from pydantic import BaseModel 11 | from typing import Literal, List, Dict, Any, Optional 12 | 13 | 14 | DEFAULT_PARAMETER_FILE = './mathbot/parameters_default.json' 15 | 16 | 17 | def _dictionary_overwrite(old, new): 18 | if not isinstance(new, dict): 19 | return new 20 | if not isinstance(old, dict): 21 | return new 22 | for key in new: 23 | old[key] = _dictionary_overwrite(old.get(key), new[key]) 24 | return old 25 | 26 | 27 | def dictionary_overwrite(*dicts): 28 | result = [{}] 29 | for i in dicts: 30 | result = _dictionary_overwrite(result, i) 31 | return result 32 | 33 | 34 | def resolve_parameters(params): 35 | if isinstance(params, dict): 36 | return {key : resolve_parameters(value) for key, value in params.items()} 37 | elif isinstance(params, list): 38 | return [resolve_parameters(i) for i in params] 39 | elif isinstance(params, str): 40 | if params.startswith('env:'): 41 | return os.environ.get(params[4:]) 42 | if params.startswith('escape:'): 43 | return params[7:] 44 | return params 45 | 46 | 47 | def load_parameters(sources): 48 | if not isinstance(sources, list): 49 | raise TypeError('Sources should be a list') 50 | default = _load_json_file(DEFAULT_PARAMETER_FILE) 51 | Parameters.model_validate(default) 52 | dictionary = resolve_parameters(dictionary_overwrite(default, *sources)) 53 | return Parameters.model_validate(dictionary) 54 | 55 | 56 | def _load_json_file(filename): 57 | with open(filename) as f: 58 | return json.load(f) 59 | 60 | 61 | class KeyStoreModel(BaseModel): 62 | mode: Literal['memory'] 63 | 64 | 65 | class WolframModel(BaseModel): 66 | key: str 67 | 68 | 69 | class ErrorReportingModel(BaseModel): 70 | channel: Optional[str] 71 | webhook: Optional[str] 72 | 73 | 74 | class ShardsModel(BaseModel): 75 | total: int 76 | mine: List[int] 77 | 78 | 79 | class CalculatorModel(BaseModel): 80 | persistent: bool 81 | libraries: bool 82 | 83 | 84 | class AdvertisingModel(BaseModel): 85 | enable: bool 86 | interval: int 87 | starting_amount: int 88 | 89 | 90 | class Parameters(BaseModel): 91 | release: Literal['development', 'release', 'beta'] 92 | token: str 93 | keystore: KeyStoreModel 94 | wolfram: WolframModel 95 | error_reporting: ErrorReportingModel 96 | shards: ShardsModel 97 | calculator: CalculatorModel 98 | blocked_users: List[int] 99 | advertising: AdvertisingModel 100 | -------------------------------------------------------------------------------- /mathbot/core/util.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import discord 3 | 4 | def permission_names(perm): 5 | for permission, has in perm: 6 | if has: 7 | yield permission.replace('_', ' ').title() 8 | 9 | # Decorator to make command respond with whatever the command returns 10 | def respond(coro): 11 | @functools.wraps(coro) 12 | async def internal(self, ctx, *args, **kwargs): 13 | result = await coro(self, ctx, *args, **kwargs) 14 | if result is not None: 15 | if isinstance(result, discord.Embed): 16 | await ctx.send(embed=result) 17 | else: 18 | await ctx.send(result) 19 | return internal 20 | -------------------------------------------------------------------------------- /mathbot/count_objects.py: -------------------------------------------------------------------------------- 1 | # I swear this is like the fifth time I've programmed this. 2 | 3 | import os 4 | import sys 5 | import json 6 | import re 7 | from mathbot import utils 8 | from mathbot import core 9 | from mathbot.core.parameters import Parameters 10 | import objgraph 11 | import discord 12 | import discord.ext.commands 13 | import logging 14 | import gc 15 | 16 | 17 | logging.basicConfig(level = logging.INFO) 18 | 19 | 20 | class MyBot(discord.ext.commands.AutoShardedBot): 21 | 22 | def __init__(self, parameters: Parameters): 23 | super().__init__( 24 | command_prefix='nobodyisgonnausethis', 25 | pm_help=True, 26 | shard_count=parameters.shards.total, 27 | shard_ids=parameters.shards.mine, 28 | max_messages=2000, 29 | fetch_offline_members=False 30 | ) 31 | self.parameters = parameters 32 | self.release = parameters.release 33 | # self.keystore = _create_keystore(parameters) 34 | # self.settings = core.settings.Settings(self.keystore) 35 | # self.command_output_map = QueueDict(timeout = 60 * 10) # 10 minute timeout 36 | self.remove_command('help') 37 | 38 | def run(self): 39 | super().run(self.parameters.token) 40 | 41 | 42 | async def on_ready(self): 43 | # objgraph.show_most_common_types() 44 | # self._connection.emoji = [] 45 | # gc.collect() 46 | objgraph.show_most_common_types() 47 | 48 | 49 | 50 | if __name__ == '__main__': 51 | 52 | @utils.apply(core.parameters.load_parameters, list) 53 | def retrieve_parameters(): 54 | for i in sys.argv[1:]: 55 | if re.fullmatch(r'\w+\.env', i): 56 | yield json.loads(os.environ.get(i[:-4])) 57 | elif i.startswith('{') and i.endswith('}'): 58 | yield json.loads(i) 59 | else: 60 | with open(i) as f: 61 | yield json.load(f) 62 | 63 | MyBot(retrieve_parameters()).run() 64 | -------------------------------------------------------------------------------- /mathbot/fonts/roboto/Roboto-Black.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DXsmiley/mathbot/723d86ba0169823e4e076e796121af93efdd74e1/mathbot/fonts/roboto/Roboto-Black.ttf -------------------------------------------------------------------------------- /mathbot/fonts/roboto/Roboto-BlackItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DXsmiley/mathbot/723d86ba0169823e4e076e796121af93efdd74e1/mathbot/fonts/roboto/Roboto-BlackItalic.ttf -------------------------------------------------------------------------------- /mathbot/fonts/roboto/Roboto-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DXsmiley/mathbot/723d86ba0169823e4e076e796121af93efdd74e1/mathbot/fonts/roboto/Roboto-Bold.ttf -------------------------------------------------------------------------------- /mathbot/fonts/roboto/Roboto-BoldItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DXsmiley/mathbot/723d86ba0169823e4e076e796121af93efdd74e1/mathbot/fonts/roboto/Roboto-BoldItalic.ttf -------------------------------------------------------------------------------- /mathbot/fonts/roboto/Roboto-Italic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DXsmiley/mathbot/723d86ba0169823e4e076e796121af93efdd74e1/mathbot/fonts/roboto/Roboto-Italic.ttf -------------------------------------------------------------------------------- /mathbot/fonts/roboto/Roboto-Light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DXsmiley/mathbot/723d86ba0169823e4e076e796121af93efdd74e1/mathbot/fonts/roboto/Roboto-Light.ttf -------------------------------------------------------------------------------- /mathbot/fonts/roboto/Roboto-LightItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DXsmiley/mathbot/723d86ba0169823e4e076e796121af93efdd74e1/mathbot/fonts/roboto/Roboto-LightItalic.ttf -------------------------------------------------------------------------------- /mathbot/fonts/roboto/Roboto-Medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DXsmiley/mathbot/723d86ba0169823e4e076e796121af93efdd74e1/mathbot/fonts/roboto/Roboto-Medium.ttf -------------------------------------------------------------------------------- /mathbot/fonts/roboto/Roboto-MediumItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DXsmiley/mathbot/723d86ba0169823e4e076e796121af93efdd74e1/mathbot/fonts/roboto/Roboto-MediumItalic.ttf -------------------------------------------------------------------------------- /mathbot/fonts/roboto/Roboto-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DXsmiley/mathbot/723d86ba0169823e4e076e796121af93efdd74e1/mathbot/fonts/roboto/Roboto-Regular.ttf -------------------------------------------------------------------------------- /mathbot/fonts/roboto/Roboto-Thin.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DXsmiley/mathbot/723d86ba0169823e4e076e796121af93efdd74e1/mathbot/fonts/roboto/Roboto-Thin.ttf -------------------------------------------------------------------------------- /mathbot/fonts/roboto/Roboto-ThinItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DXsmiley/mathbot/723d86ba0169823e4e076e796121af93efdd74e1/mathbot/fonts/roboto/Roboto-ThinItalic.ttf -------------------------------------------------------------------------------- /mathbot/help/about.md: -------------------------------------------------------------------------------- 1 | :::topics about info 2 | 3 | Add MathBot to your server using this link: <{{add_link}}> 4 | 5 | MathBot is developed by DXsmiley. You can talk to her via the official MathBot server: <{{server_link}}> 6 | 7 | Source code: https://github.com/DXsmiley/mathbot 8 | 9 | MathBot is written in Python using discord.py (). 10 | 11 | Thankyou to the following patrons for supporting the bot: 12 | {{patreon_listing}} 13 | -------------------------------------------------------------------------------- /mathbot/help/blame.md: -------------------------------------------------------------------------------- 1 | :::topics blame 2 | 3 | # Blame 4 | 5 | The `{{prefix}}blame` command is used to find who caused the bot to post a certain message. 6 | 7 | Usage: `{{prefix}}blame message_id` or `{{prefix}}blame recent` 8 | 9 | The `message_id` should be the id of the message that the bot posted. You can find this by enabling developer tools, and then right-clicking on the message you wish to investigate. Using `recent` will get the blame for the most recent message that the bot posted in this channel. 10 | 11 | The bot keeps message blames for only 50 hours. After this, blame records are removed to conserve space. 12 | -------------------------------------------------------------------------------- /mathbot/help/calculator_brief.md: -------------------------------------------------------------------------------- 1 | :::topics calculator calc 2 | 3 | # Calculator 4 | 5 | The calculator can be invoked with the command `{{prefix}}calc`. It supports performing operations (`+`, `-`, `*`, `/`) on numbers. 6 | 7 | A number of functions such as `abs(x)` and `sin(x)` can be used as well. 8 | 9 | Constants such as `pi`, `i`, and `e` are defined. 10 | 11 | For the extended list of features, see `=help calc-more`. 12 | -------------------------------------------------------------------------------- /mathbot/help/calculator_full.md: -------------------------------------------------------------------------------- 1 | :::topics calc-more 2 | 3 | # Calculator 4 | 5 | The calculator can be invoked with the command `{{prefix}}calc`. 6 | 7 | It is also possible to use the shortcut `==`, however *this may be disabled on some servers or channels*. Using the shortcut will not produce an error message if it is disabled. 8 | 9 | See `{{prefix}}help turing` for a list of extended features, such as function definition and list manipulation. 10 | See `{{prefix}}help turing-library` for builtin functions that operate over more complicated data structures. 11 | 12 | The calculator supports the following arithmetic operations: 13 | 14 | - `+` : addition 15 | - `-` : subtraction 16 | - `*` : multiplication (can also use `×`) 17 | - `/` : division (can also use `÷`) 18 | - `^` : exponentiation 19 | - `!` : factorial (e.g. `5!`) 20 | 21 | The following comparisons are supported 22 | 23 | - `>` : Greater than. 24 | - `<` : Less than. 25 | - `==` : Equal to (two `=` signs for comparison) 26 | - `>=` : Greater or equal. 27 | - `<=` : Less than or equal. 28 | - `!=` : Not equal. 29 | 30 | For example, `2 < 5 < 8` evaluates to `True`, whereas `3 == 6` equates to `False`. 31 | 32 | The following logical operators are supported: 33 | 34 | - `||` : Or (`x || y`) 35 | - `&&` : And (`x && y`) 36 | - `!` : Not (`!x`) 37 | 38 | `false`, `0` and the empty list is considered falsy. Everything else is considered truthy. 39 | 40 | The following constants exist: 41 | 42 | - `pi` : 3.141592... (also `π`) 43 | - `tau` : 6.283185... (twice pi) 44 | - `e` : 2.178281... 45 | - `true` : 1 46 | - `false` : 0 47 | - `i` : The imaginary unit 48 | - `infinity` : representation of infinity 49 | - `complex_infinity` : infinity, but it's complicated 50 | - `not_a_number` : produced by some functions when given invalid input 51 | 52 | :::page-break 53 | 54 | The following functions are available: 55 | 56 | ``` 57 | |------------------------------------------------------------------| 58 | | function | radians | degress | inverse radians | inverse degrees | 59 | |------------------------------------------------------------------| 60 | | Sine | sin | sind | asin | asind | 61 | | Cosine | cos | cosd | acos | acosd | 62 | | Tangent | tan | tand | atan | atand | 63 | | Cosecant | csc | cscd | acsc | acscd | 64 | | Secant | sec | secd | asec | asecd | 65 | | Cotan | cot | cotd | acot | acotd | 66 | | Hyp-Sin | sinh | | asinh | | 67 | | Hyp-Cos | cosh | | acosh | | 68 | | Hyp-Tan | tanh | | atanh | | 69 | |------------------------------------------------------------------| 70 | ``` 71 | 72 | :::page-break 73 | 74 | - `abs(x)` : the absolute value of x 75 | - `deg(r)` : converts radians to degrees 76 | - `rad(d)` : converts degrees to radians 77 | - `log(x)` : log in base 10 78 | - `log(x, b)` : log of `x` in base `b` 79 | - `ln(x)` : natural logarithm `e` 80 | - `sqrt(x)` : calculates the square root of a number 81 | - `root(x, y)` : the `y`th root of `x`, equal to `x^(1/y)` 82 | - `ceiling(x)` : rounds a number *up* to the nearest integer 83 | - `floor(x)` : rounds *down* to the nearest integer 84 | - `exp(x)` : equivilent to e^x 85 | - `gamma(x)` : computes the gamma function 86 | - `factorial(x)` : computes the factorial function 87 | - `gcd(a, b)` : computes the greatest common denominator or a and b 88 | - `lcm(a, b)` : computes the lowest common multiple of a and b 89 | - `choose(n, k)` : computes `n` choose `k` 90 | - `re(x)` : extract the real part of a complex number 91 | - `im(x)` : extract the imaginary part of a complex number 92 | 93 | :::page-break 94 | 95 | ## Examples 96 | 97 | `{{prefix}}calc 2 ^ (1 + 3) * 5` 98 | 99 | `{{prefix}}calc round(10 / 3)` 100 | 101 | `{{prefix}}calc sin(pi / 2)` 102 | 103 | `{{prefix}}calc (4 + 3i) ^ 3` 104 | -------------------------------------------------------------------------------- /mathbot/help/calculator_history.md: -------------------------------------------------------------------------------- 1 | :::topics history calchistory calc-history 2 | 3 | # Calculator History 4 | 5 | **This feature is still in development** 6 | 7 | By default, the calculator's memory (for assigned values, defined functions) is *at most* 24 hours, and resets whenever the bot restarts. 8 | 9 | However, on servers *owned by Patrons* or in DMs *with Patrons*, the bot will remember values and functions for a much longer period of time. 10 | 11 | To check the status of the calculator's history, run `=calc-history`. Remember that only commands which changed the *state* of the calculator are remembered. Simple commands such as `1 + 3` or `sin(4)` are not retained. 12 | 13 | ## Memory Clearance 14 | 15 | Even with history enabled, the bot will clear it's memory *for a particuar channel* if the calc command has not been invoked in that channel for a bit over a week. 16 | 17 | Commands that result in an error are not stored in the history. 18 | 19 | Commands that result in an error during re-run are removed from the history. 20 | 21 | ## Patronage 22 | 23 | Become a Patron and support the bot: https://www.patreon.com/dxsmiley 24 | 25 | You must subscribe to the quadratic tier or higher to get access to the calculator history. 26 | -------------------------------------------------------------------------------- /mathbot/help/calculator_libraries.md: -------------------------------------------------------------------------------- 1 | :::topics libraries lib libs libs-add libs-remove libs-list 2 | 3 | # Calculator Libraries 4 | 5 | **This feature is still in development** 6 | 7 | The mathbot calculator supports loading of custom libraries. Libraries can be used to specify new functions and values. 8 | 9 | Libraries are set up on a per-server basis, and are applied to all channels in that server. Libraries cannot be added to private chats with the bot. 10 | 11 | ## List libraries: `libs-list` 12 | 13 | Lists all libraries in the current server. 14 | 15 | ## Add a new library: `libs-add url` 16 | 17 | Add a new library at the given `url` to the current server. 18 | 19 | `url` should be the url of a gist, such as this one: 20 | 21 | ## Remove a library: `libs-remove url` 22 | 23 | Remove the library at the given `url` from the current server. 24 | 25 | ## Writing libraries. 26 | 27 | Writing libraries is quite simple. Create a gist at and add two files to it: `readme.md` containing documentation, and `source`, containing the code itself. 28 | 29 | See this gist for an example: 30 | -------------------------------------------------------------------------------- /mathbot/help/commands.md: -------------------------------------------------------------------------------- 1 | :::topics commands command 2 | 3 | List of commands: 4 | - `{{prefix}}about` - General information about the bot 5 | - `{{prefix}}blame` - Find who was responsible for the bot sending a message 6 | - `{{prefix}}calc` - Basic calculator (aliased to `==`) 7 | - `{{prefix}}help` - Get help on various subjects 8 | - `{{prefix}}oeis` - Search the Online Encyclopedia of Integer Sequences 9 | - `{{prefix}}prefix` - Get the bot's prefix 10 | - `{{prefix}}purge` - Delete messages sent by the bot 11 | - `{{prefix}}roll` - Roll dice 12 | - `{{prefix}}rollu` - Roll dice, but do not sort the output 13 | - `{{prefix}}set` - Modify bot settings and permissions 14 | - `{{prefix}}setprefix` - Set the bot's prefix 15 | - `{{prefix}}stats` - Number of servers, uptime, etc. 16 | - `{{prefix}}tex` - Render LaTeX 17 | - `{{prefix}}theme` - Changes the colour of the output 18 | - `{{prefix}}wolf` - Query Wofram|Alpha 19 | -------------------------------------------------------------------------------- /mathbot/help/help.md: -------------------------------------------------------------------------------- 1 | :::topics help 2 | 3 | Hello! I'm a bot that provides some maths-related features. 4 | 5 | Add MathBot to your server using this link: <{{add_link}}> 6 | 7 | MathBot is developed by DXsmiley. You can talk to her via the official MathBot server: <{{server_link}}> 8 | 9 | The following user commands are available: 10 | 11 | `{{prefix}}tex` - Renders *LaTeX* equations. See `{{prefix}}help latex` for details. 12 | `{{prefix}}wolf` - Query wolfram alpha. Can be slow. See `{{prefix}}help wolf` for details. 13 | `{{prefix}}calc` - Does calculations. See `{{prefix}}help calc` for full list of features. 14 | `{{prefix}}roll` - Rolls dice. See `{{prefix}}help roll` for details. 15 | `{{prefix}}oeis` - Search the Online Encyclopedia of Integer Sequences. 16 | `{{prefix}}theme theme` - Change the colour of the `{{prefix}}tex` and `{{prefix}}wolf` results. See `{{prefix}}help theme` for details. 17 | 18 | More help is also available: 19 | 20 | `{{prefix}}help commands` - Get the full list of commands. 21 | `{{prefix}}help topic` - Get help about a particular topic or command. 22 | `{{prefix}}help management` - Get information about commands for server owners and moderators. 23 | `{{prefix}}help settings` - Get information about settings. 24 | `{{prefix}}about` - General information about the bot. 25 | 26 | All commands can be invoked by mentioning the bot. For example, the command `@MathBot prefix` will always tell you the bot's prefix on the current server even if you don't know what it is. 27 | -------------------------------------------------------------------------------- /mathbot/help/latex.md: -------------------------------------------------------------------------------- 1 | :::topics latex tex rtex texw texp 2 | 3 | # LaTeX 4 | 5 | The `{{prefix}}tex` command is used render *LaTeX* equations. 6 | 7 | The *LaTeX* rendering is done by 8 | 9 | You can use the `{{prefix}}theme` command to change the colour of the results. 10 | 11 | ## Examples 12 | 13 | `{{prefix}}tex x = 7` 14 | 15 | `{{prefix}}tex \sqrt{a^2 + b^2} = c` 16 | 17 | `{{prefix}}tex \int_0^{2\pi} \sin{(4\theta)} \mathrm{d}\theta` 18 | 19 | ## Limitations 20 | 21 | The bot currently uses an external rendering service. The following features are known to break things: 22 | 23 | - `$` to start and end the equations. These are not required, and may do strange things. Use `\text{words}` to place text. 24 | - `\usepackage`, for any reason. 25 | - Macros such as `\@ifpackageloaded`. 26 | - Loading of external images, and other resources. 27 | 28 | :::page-break 29 | 30 | ## Inline LaTeX 31 | 32 | *This feature is currently disabled by default and must be turned on by the server owner. The server owner should run the command `=set server f-tex-inline enable`.* 33 | 34 | You can insert LaTeX into the middle of a message by wrapping it in `$$` signs. 35 | 36 | Examples 37 | 38 | `The answer is $$x^{2 + y}$$.` 39 | 40 | `$$1 + 2$$ equals $$3$$.` 41 | 42 | ## Alternative forms 43 | 44 | The `{{prefix}}texw` command (**w** for **wide**) will increase the width of the "paper", allowing for wider equations. 45 | 46 | The `{{prefix}}texp` command (**p** for **plain**) will remove the `\begin` and `\end` boilerplate, allowing you to add your own. This is useful if you're using `tikzcd` or similar. 47 | 48 | 49 | ## Deleting Commands 50 | 51 | You can get the bot to automatically delete invokation commands after a short time by setting `=set server f-tex-delete enable`. 52 | The bot will require the *manage messages* permission for this work properly. 53 | 54 | ## Custom Macros 55 | 56 | Some custom commands have been added to make typing quick LaTeX easy. These include `\bbR`, `\bbN` etc. for `\mathbb{R}`, `mathbb{N}` and other common set symbols and `\bigO` for `\mathcal{O}`. Some unicode characters (such as greek letters) are automatically convered to LaTeX macros for ease of use. 57 | -------------------------------------------------------------------------------- /mathbot/help/management.md: -------------------------------------------------------------------------------- 1 | :::topics management manage admin 2 | 3 | The following management commands exist: 4 | 5 | `@MathBot prefix` - Display the bot's current prefix. 6 | `{{prefix}}setprefix value` - Changed the bot's prefix from `=` to `value`. If no value is specified, it display the current prefix. 7 | `{{prefix}}set context setting value` - Manage parameters to control how the bot functions. See `{{prefix}}help settings` for details. 8 | `{{prefix}}blame message_id` - Find out who caused the bot to post a particular message. 9 | `{{prefix}}purge number` - Remove recent messages that the bot sent. 10 | -------------------------------------------------------------------------------- /mathbot/help/oeis.md: -------------------------------------------------------------------------------- 1 | :::topics oeis 2 | 3 | # OEIS 4 | 5 | The `{{prefix}}oeis` command is used to search the Online Encyclopedia of Integer Sequences. 6 | 7 | Currently, the command only returns the "most relevent" search result. 8 | 9 | ## Examples 10 | 11 | `{{prefix}}oeis 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89` 12 | 13 | `{{prefix}}oeis ackermann sequence` 14 | 15 | Adding commas between numbers (`1, 2, 3`), as opposed to ommitting them (`1 2 3`) may produce different results. 16 | -------------------------------------------------------------------------------- /mathbot/help/prefix.md: -------------------------------------------------------------------------------- 1 | :::topics prefix 2 | 3 | Running \`{{mention}} prefix\` will simply display the bot's current prefix. 4 | 5 | If you are a server admin, \`{{mention}} setprefix new_prefix\` will set the bot's prefix to `new_prefix` for the server. Use \`{{mention}} setprefix =\` to reset it to the default value. 6 | 7 | In the case you want the prefix have leading or trailing whitespace, `new_prefix` should be wrapped in *backticks* (\` symbols). 8 | 9 | Prefix changes are reflected in the help commands when they are run from the server itself. 10 | -------------------------------------------------------------------------------- /mathbot/help/purge.md: -------------------------------------------------------------------------------- 1 | :::topics purge delete 2 | 3 | # Purge 4 | 5 | `{{prefix}}purge number` 6 | 7 | This command will search through most recent `number` messages and delete any *that were sent by the bot*. The bot will not search more than the 200 most recent messages. 8 | 9 | This command can only be run by users who have the *manage messages* permission in the respective channel. It will *not* work in any private channels. 10 | -------------------------------------------------------------------------------- /mathbot/help/roll.md: -------------------------------------------------------------------------------- 1 | :::topics roll rollu dice die random 2 | 3 | # Die Rolling 4 | 5 | `{{prefix}}roll number faces` 6 | 7 | The amount of dice shown is by default limited, if you wish to remove this limit you can enable the setting `f-roll-unlimited`. 8 | 9 | Example: `{{prefix}}roll 2d8` 10 | 11 | By default, dice are sorted in increasing order. Use the `{{prefix}}rollu` command to not sort the dice. 12 | -------------------------------------------------------------------------------- /mathbot/help/settings.md: -------------------------------------------------------------------------------- 1 | :::topics setting set settings options option setup permissions 2 | 3 | # Settings 4 | 5 | MathBot has a number of settings that can be changed in order to modify its behaviour. 6 | 7 | Server admins are able to enable or disable certain commands on their own servers, on a per-channel basis. 8 | 9 | There are also settings for customising command output. 10 | 11 | ## Command Description 12 | 13 | The general structure of the settings command is: 14 | 15 | ``` 16 | {{prefix}}set context setting value 17 | ``` 18 | 19 | `context` should be either `channel` or `server`. 20 | `setting` should be the name of one of the settings, listed below. 21 | `value` should be a valid value for the setting. Either `enable`, `disable` or `reset`. 22 | 23 | :::page-break 24 | 25 | ## Permission Settings 26 | 27 | These settings can be applied to the `server` and `channel` contexts, but only by server admins. 28 | 29 | `value` should be either `enable`, `disable`, or `reset`. `enable` will allow people to use the command in the given context and `disable` will stop people from using it. `reset` will reset the value to the default. Note that if both a server-wide option and a channel-specific option apply to a specific channel, setting the channel to `reset` will mean that it defers to the setting used by the server. 30 | 31 | :::page-break 32 | 33 | - `c-calc` : Toggles the `{{prefix}}calc` command. Default: *Enabled* 34 | - `c-tex` : Toggles the `{{prefix}}tex` command. Default: *Enabled* 35 | - `c-wolf` : Toggles the `{{prefix}}wolf` command. Default: *Enabled* 36 | - `c-roll` : Toggles the `{{prefix}}roll` command. Default: *Enabled* 37 | - `c-oeis` : Toggles the `{{prefix}}oeis` command. Default: *Enabled* 38 | - `f-calc-shortcut` : Toggles the `==` shortcut for the `{{prefix}}calc` command. Disabling this **will not** produce an error message if a user attempts to use this command. This is intended to be used *only* if this command conflicts with other bots on your server. Default: *Enabled* 39 | - `f-tex-inline`: Toggles the ability to add inline tex to messages. See `=help tex` for details. Default: *Disabled*. 40 | - `f-tex-delete`: When enabled, the bot will delete messages used to invoke the tex command after a few seconds. Default: *Disabled*. 41 | - `f-tex-trashcan`: When enabled, the bot will add a trashcan reaction to output messages which can be used by the invoker to delete them. Even if disabled, users can still add the reaction manually to delete the message. Default *Enabled*. 42 | - `f-wolf-filter`: Toggles the word filter for W|A queries. This is enabled by default for all non-nsfw channels. 43 | - `f-wolf-mention`: Toggles the `@mention` in the footer of W|A results. Default: *Enabled*. 44 | - `f-roll-unlimited`: When enabled there is no limit (apart from the discord message limit) to the amount of rolls shown with the roll command. 45 | - `m-disabled-cmd`: Toggles the "That command cannot be used in this location" message. Default: *Enabled*. 46 | - `x-bonus` : Toggles bonus commands. Default: *Enabled* 47 | 48 | :::page-break 49 | 50 | ## Examples 51 | 52 | Disable the `=wolf` command on a server: 53 | `{{prefix}}set server c-wolf disable` 54 | 55 | Enable the `=calc` command on a single channel: 56 | `{{prefix}}set channel c-calc enable` 57 | 58 | ## Troubleshooting 59 | 60 | Ensure you are typing words *exactly* as shown. 61 | If you are still having problems you can ask on the official server: {{server_link}} 62 | 63 | ## Extra tools 64 | 65 | To check which settings apply to a particular channel, run the `{{prefix}}checkallsettings` command in that channel. 66 | -------------------------------------------------------------------------------- /mathbot/help/theme.md: -------------------------------------------------------------------------------- 1 | :::topics theme themes 2 | 3 | # Themes 4 | 5 | The `{{prefix}}theme` command is used to change the colour of the results for the `{{prefix}}tex` and `{{prefix}}wolf` commands. 6 | 7 | Use `{{prefix}}theme dark` to apply the dark theme. 8 | Use `{{prefix}}theme light` to apply the light theme. 9 | 10 | The colours are designed to match Discord's dark and light schemes respectively. 11 | 12 | Note that this only impacts commands that *you* invoke. If someone else calls one of the above commands, the results with use their chosen theme. 13 | -------------------------------------------------------------------------------- /mathbot/help/turing.md: -------------------------------------------------------------------------------- 1 | :::topics turing 2 | 3 | # Turing Completeness 4 | 5 | See `{{prefix}}help calc-more` for a list of basic builtin functions. 6 | See `{{prefix}}help turing-library` for builtin functions that operate over more complicated data structures. 7 | 8 | ## Introduction 9 | 10 | The calculator is actually Turing complete, and can function as it's own small programming language, allowing you to perform more complicated operations. 11 | 12 | Note that these features are currently in *beta* and may be unstable. More complete documentation will be coming later. 13 | 14 | - Any variables that are assigned are contained to the channel that they are created in. 15 | - Values are purged at least once every 24 hours, so don't rely on the bot to store important information for you. 16 | 17 | ## A note on commas 18 | 19 | Commas are used in most programming languages to separate expressions, for example in lists of arguments. In the MathBot calculator, expressions are *mostly* optional, however may have to be used in some situations in order to prevent the parser joining adjacent expressions. 20 | 21 | For example `[x y z]` is the same as `[x, y, z]`. `[x y (z)]` is *not* the same as `[x, y, (z)]`. In the first case, `y(z)` is interpreted as a function call. Adding the comma prevents this from happening. 22 | 23 | Adding additional commas is a bad idea and will probably result in parsing errors. 24 | 25 | :::page-break 26 | 27 | ## Language Features 28 | 29 | ### Assignments 30 | 31 | Assigning variables is simple: 32 | ``` 33 | x = 7 34 | ``` 35 | 36 | Recalling them works the same as it normally would 37 | ``` 38 | x 39 | ``` 40 | 41 | ### If Statements 42 | 43 | If statements are use as follows: 44 | ``` 45 | if (condition, expression_if_true, expression_if_false) 46 | ``` 47 | 48 | So for instance `if(1 < 2, 3, 4)` would evaluate to `3`, but `if(1 > 2, 3, 4)` would evaluate to `4`. 49 | 50 | Unlike normal functions, the arguments that are passed to the `if` statement are only evaluated when they are used internally, not beforehand. This means that only one of `expression_if_true` and `expression_if_false` will ever be evaluated. 51 | 52 | Additional arguments can be passed in order to make the function act as an if-elif-else block. 53 | ``` 54 | ifelse(cond_1, expr_1, 55 | cond_2, expr_2, 56 | cond_3, expr_3, 57 | otherwise 58 | ) 59 | ``` 60 | 61 | Which is the same as 62 | ``` 63 | if (cond_1, expr_1, 64 | if (cond_2, expr_2, 65 | if (cond_3, expr_3, 66 | otherwise 67 | ) 68 | ) 69 | ) 70 | ``` 71 | 72 | :::page-break 73 | 74 | ### Defining Functions 75 | 76 | Anonymous functions are defined using the following syntax 77 | ``` 78 | (arg1 arg2 arg3 ...) -> expression 79 | ``` 80 | 81 | Example: 82 | ``` 83 | (x y z) -> (x * y) / z 84 | ``` 85 | 86 | These can then be assigned to variables 87 | ``` 88 | double = (x) -> x * 2 89 | ``` 90 | 91 | Alternatively, functions defined at the top level can be given names. 92 | ``` 93 | add(x y) = x + y 94 | ``` 95 | 96 | Anonymous functions that take a single argument can be defined using a shorthand that excludes the parenthesis. 97 | ``` 98 | a -> a + 1 99 | ``` 100 | 101 | ### Macro Functions 102 | 103 | *Macros may be removed at some point in the future, since they serve no purpose in a lazy language.* 104 | 105 | Macros are similar to normal functions, with the difference that all arguments have to be evaluated on demand: Your function gets a series of functions, which take no arguments and return the original value. 106 | 107 | Macros are defined using the following syntax. 108 | ``` 109 | (arg1 arg2 arg3 ...) ~> expression 110 | ``` 111 | Note the use of `~>` instead of `->`. 112 | 113 | For example, the following snippet will evaluate to `8` 114 | ``` 115 | sum_macro(x, y) ~> x() + y() 116 | sum_macro(3, 5) 117 | ``` 118 | 119 | :::page-break 120 | 121 | ### Lists 122 | 123 | "Lists" are data structures that act like stacks: you can quickly access the head of a list, adding and removing things from it as required. You can access things further down a list but it'll be slower. 124 | 125 | Lists are defined with square braces: `[1 2 3 4]`. The empty list is declared with `[]`. 126 | 127 | The `:` operator inserts an item at the head of a list. 128 | ``` 129 | 1:[2 3 4] # Results in [1 2 3 4] 130 | ``` 131 | 132 | The `'` operator retrieves an item from the head of a list. If the list is empty, an error occurs. 133 | ``` 134 | '[1 2 3 4] # Results in 1 135 | ``` 136 | 137 | The `\` operator retrieves the tail of the list, i.e. all elements except the first. If the list is empty, an error occurs. 138 | ``` 139 | \[1 2 3 4] # Results in [2 3 4] 140 | ``` 141 | 142 | ### Text 143 | 144 | "Strings" are defined as lists of "glyphs", where a glyph is a single character (like the letter `a`, digit `7`, or the poop emoji). String are denoted with double-quotes: "Hello, world!". Glyphs are denoted with the `;` character followed by the glyph itself. 145 | 146 | Appending a glyph to the start of a string (the brakets aren't required here) 147 | ``` 148 | (;h):("ello") 149 | ``` 150 | 151 | Note that `; ` is represents a single space. 152 | 153 | :::page-break 154 | 155 | ### Assoc-Lists 156 | 157 | "Assoc-Lists" are a data structure similar to dictionaries. That means you can associate certain keys to certain values. For instance i could create a dictionary that says certain books refer to certain authors. 158 | 159 | Assoc-Lists are implemented as lists, and they consist of a list of pairs: `[["a" 1] ["b" 2] ["c" 3]]` would be an assoc list mapping. 160 | ``` 161 | "a" -> 1 162 | "b" -> 2 163 | "c" -> 3 164 | ``` 165 | You can add more key-value pairs to the list using the `assoc` function: `assoc([["a" 1] ["b" 2] ["c" 3]] "hello" 55)` would result in the assoc-list `[["a" 1] ["b" 2] ["c" 3] ["hello" 55]]`. One key can only be mapped to one element, for instance you can't get `[["a" 1] ["a" 2] ["b" 3]]`. 166 | 167 | You can get a value given a key by using the function `get`: `get([["a" 1] ["b" 2] ["c" 3] ["hello" 55]] "hello")` would return `55`. 168 | 169 | You can remove a key with the function `aremove`: `aremove([["a" 1] ["b" 2] ["c" 3] ["hello" 55]] "b")` would return `[["a" 1] ["c" 3] ["hello" 55]]`. 170 | 171 | ### Sets 172 | 173 | Sets are lists where every element is unique, in order to make a set simply use the function `to_set`. As an example: `to_set([2,1,5,2,3,5,1])` would return `[3,1,5,2]`. Note that order isn't preserved, for instance `to_set([1,2,3,4])` gives `[1, 3, 4, 2]`. 174 | 175 | Adding an element to the set is done with `set_insert`: `set_insert([1,2,3,4], 2)` becomes `[4,3,2,1]`. 176 | 177 | :::page-break 178 | 179 | # Memoisation 180 | 181 | User-defined non-macro functions have memoisation applied to them automatically. 182 | 183 | Thus, the following code executes quickly, even for large `x`: 184 | ``` 185 | fib(x) -> if (x < 2, 1, fib(x - 2) + fib(x - 1)) 186 | ``` 187 | 188 | The memoisation is not foolproof and could probably do with some improvement. 189 | 190 | # Tail recursion optimisation 191 | 192 | Simple situations for tail-recursion are optimised in order to conserve stack frames. 193 | 194 | For example, the standard library defines the `reverse` function as the following: 195 | ``` 196 | _reverse(input output) -> if(!input output _reverse(\input 'inputs:output)) 197 | reverse(list) -> _reverse(input .) 198 | ``` 199 | Which works by repeatedly taking the top item from the input list and perpending it to the output list. 200 | -------------------------------------------------------------------------------- /mathbot/help/turing_functions.md: -------------------------------------------------------------------------------- 1 | :::topics turing-functions turing-library 2 | 3 | # Calc command builtin functions 4 | 5 | ## Operator replacements 6 | 7 | ``` 8 | sum (a b) -> a + b 9 | mul (a b) -> a * b 10 | dif (a b) -> a - b 11 | div (a b) -> a / b 12 | pow (a b) -> a ^ b 13 | mod (a b) -> a ~mod b 14 | and (a b) -> a && b 15 | or (a b) -> a || b 16 | ``` 17 | 18 | ## Comparitor utilities 19 | 20 | ### `max(a, b)` 21 | Returns the larger of a and b, as compared by `<`. 22 | If they are considered 'equal', will return a. 23 | 24 | ### `min(a, b)` 25 | Returns the smaller of a and b, as compared by `<`. 26 | If they are considered 'equal', will return a. 27 | 28 | :::page-break 29 | 30 | ## Sequence manipulation 31 | 32 | ### `zip(a, b)` 33 | Takes in two *sequences* and returns a *list* containing lists of pairs of elements from the two sequences. 34 | 35 | The length of the result will be equal to the length of the shorter input sequence. 36 | 37 | Example: 38 | ``` 39 | zip(list(1, 2, 3), list(4, 5, 6)) 40 | ``` 41 | produces 42 | ``` 43 | list(list(1, 4), list(2, 5), list(3, 6)) 44 | ``` 45 | 46 | ### `repeat(item, times)` 47 | Returns a *list* with the item `item` repeated `times` times. 48 | 49 | ### `reverse(seq)` 50 | Takes a *sequence* `seq` and produces a *list* containing `seq`'s elements in reverse order. 51 | 52 | ### `map(function, sequence)` 53 | Apply's `function` to all the elements in `sequence` and produces a *list*. 54 | 55 | ### `filter(predicate, sequence)` 56 | Produces a *list* containing the items in `sequence` for which the predicate returns a truthy value. 57 | 58 | Example: 59 | ``` 60 | is_even(x) -> x % 2 == 0, 61 | filter(is_even, list(4, 8, 3, 6, 3, 7, 8)) 62 | ``` 63 | produces 64 | ``` 65 | list(4, 8, 6, 8) 66 | ``` 67 | 68 | :::page-break 69 | 70 | ### `reduce(function, sequence)` 71 | Example: 72 | ``` 73 | reduce(sum, array(0, 1, 2, 3, 4)) 74 | ``` 75 | produces 76 | ``` 77 | 10 78 | ``` 79 | 80 | ### `interleave(element, sequence)` 81 | Example: 82 | ``` 83 | interleave(;, "hello") 84 | ``` 85 | produces 86 | ``` 87 | "h,e,l,l,o" 88 | ``` 89 | 90 | ### `flatten(sequence)` 91 | Flattens a list of lists. 92 | Example: 93 | ``` 94 | flatten([[1 2] [3 4]]) 95 | ``` 96 | produces 97 | ``` 98 | [1 2 3 4] 99 | ``` 100 | 101 | ### `in(a, sequence)` 102 | Checks if a is in the sequence. 103 | 104 | ### `apair(a, b)` 105 | Creates a new key-value pair from two objects. 106 | 107 | ### `akey(pair)` 108 | Gets the key of a key-value pair. 109 | 110 | ### `avalue(pair)` 111 | Gets the value of a key-value pair. 112 | 113 | ### `assoc(assoc_list, key, value)` 114 | Associates a key with a value in the association list given. 115 | Example: 116 | ``` 117 | assoc([["a" 1] ["b" 2] ["c" 3]] "hello" 55) 118 | ``` 119 | 120 | produces 121 | ``` 122 | [["a" 1] ["b" 2] ["c" 3] ["hello" 55]] 123 | ``` 124 | 125 | ### `aremove(assoc_list, key)` 126 | Removes the given key from the assoc list. 127 | 128 | ### `aremove_value(assoc_list, value)` 129 | Removes the all given values from the assoc list. 130 | 131 | ### `update(assoc_list, key, function)` 132 | Updates the value of the key using function in the given association list. 133 | 134 | :::page-break 135 | 136 | ### `to_set(list)` 137 | Converts a list to a set. 138 | 139 | ### `set_insert(set a)` 140 | Add the object a to the set. 141 | 142 | ### `array(...)` (variadic) 143 | Produces an array containing the specified elements. 144 | 145 | ### `list(...)` (variadic) 146 | Produces a list containing the specified elements. 147 | 148 | ### `toarray(sequence)` 149 | Converts `sequence` into an *array*. 150 | 151 | ### `tolist(sequence)` 152 | Converts `sequence` into a *list*. 153 | 154 | ## String manipulation 155 | These functions are designed to be used with strings, but may also be applied to other lists and arrays, since they are generic. No guarantees about whether they will work in this regard is made. 156 | 157 | ### `startswith(haystack needle)` 158 | Returns whether `haystack` begins with `needle`. 159 | 160 | ### `drop(number sequence)` 161 | Removes the first `number` elements from `sequences`. 162 | 163 | ### `split(haystack needle)` 164 | Breaks apart the `haystack` by every `needle` found. Returns a sequence of strings. 165 | 166 | Example: 167 | ``` 168 | split("this is a sentence", " ") 169 | ``` 170 | Produces: 171 | ``` 172 | ["this", "is", "a", "sentence"] 173 | ``` 174 | 175 | ### `display(...)` (variadic) 176 | Takes any number of arguments and produces a string with the values in a human-readable format. Values are seperated by spaces. 177 | 178 | ### `ord(glyph)` 179 | Returns an integral representation for the `glyph`. 180 | 181 | ### `chr(integer)` 182 | Returns the glyph represented by `integer`. 183 | -------------------------------------------------------------------------------- /mathbot/help/units.md: -------------------------------------------------------------------------------- 1 | :::topics unit units 2 | 3 | # Units 4 | 5 | The `{{prefix}}units` command is used to change your units for the `{{prefix}}wolf` commands. 6 | 7 | Use `{{prefix}}units metric` or `{{prefix}}units imperial`. 8 | 9 | If you have not set your units, the bot will use metric. 10 | -------------------------------------------------------------------------------- /mathbot/help/wolfram.md: -------------------------------------------------------------------------------- 1 | :::topics wolfram wolf wolframalpha wolfram|alpha wa alpha pup 2 | 3 | # Wolfram|Alpha 4 | 5 | The `{{prefix}}wolf` command is used to query Wolfram|Alpha. 6 | 7 | The `{{prefix}}pup` command will produce fewer results. 8 | 9 | You can use the `{{prefix}}theme` command to change the colour of the results. 10 | You can use the `{{prefix}}units` command to set your default units (metric or imperial). 11 | 12 | This command can be very slow at times, so please be patient. 13 | 14 | ## Examples 15 | 16 | `{{prefix}}wolf intersection of sin(x) and cos(x)` 17 | 18 | `{{prefix}}wolf x^3 - x^2 + x - 1` 19 | 20 | ## Refining your results 21 | 22 | This command will give you *all* the information that Wolfram|Alpha spits out, which is often more than you want. It understand some english, so you can use words to refine your query. For example, you might use `roots of x^2 - x - 1` rather than `y = x^2 - x - 1` if you only want the solutions to the equation. 23 | 24 | :::page-break 25 | 26 | ## Assumptions 27 | 28 | Sometimes Wolfram|Alpha will make some assumptions about your intentions. These will be displayed at the bottom of the message. If you wish to change what W|A assumes, you can click on the letter reactions (🇦, 🇧, etc...), and then click the 🔄 reaction to re-run the query. 29 | 30 | ## Query Filters 31 | 32 | To avoid people abusing the bot, some queries will not be run. The filter applies to all channels except for direct messages and channels marked as nsfw. 33 | 34 | A server admin can be manually disable the filter in a channel by running `{{prefix}}set channel f-wolf-filter disable`. 35 | It can be re-enabled again with `{{prefix}}set channel f-wolf-filter enable`. 36 | 37 | See `{{prefix}}help settings` for more details on managing settings. 38 | 39 | ## Mention 40 | 41 | By default, the bot will mention the person who invoked the command. The `f-wolf-mention` setting can be modified to change this. 42 | 43 | Use `{{prefix}}set server f-wolf-mention disable` to prevent the bot from mentioning people on the whole server, and `{{prefix}}set server f-wolf-mention enable` to re-enable it. 44 | -------------------------------------------------------------------------------- /mathbot/imageutil.py: -------------------------------------------------------------------------------- 1 | import PIL 2 | import PIL.Image 3 | import PIL.ImageDraw 4 | import PIL.ImageFont 5 | 6 | 7 | def hex_to_tuple(t): 8 | return ( 9 | int(t[0:2], base = 16), 10 | int(t[2:4], base = 16), 11 | int(t[4:6], base = 16) 12 | ) 13 | 14 | 15 | def hex_to_tuple_a(t): 16 | return ( 17 | int(t[0:2], base = 16), 18 | int(t[2:4], base = 16), 19 | int(t[4:6], base = 16), 20 | int(t[6:8], base = 16) 21 | ) 22 | 23 | 24 | def new_monocolour(size, colour): 25 | # size is a tuple of (width, height) 26 | return PIL.Image.new('RGBA', size, colour) 27 | 28 | 29 | def add_border(image, size, colour): 30 | w, h = image.size 31 | base = new_monocolour((w + size * 2, h + size * 2), colour) 32 | base.paste(image, (size, size), image) 33 | return base 34 | 35 | 36 | def paste_to_background(image, colour = (255,255,255, 255), padding = 0): 37 | w, h = image.size 38 | background = PIL.Image.new('RGBA', (w + padding * 2, h + padding * 2), colour) 39 | background.paste(image, (padding, padding), image) 40 | return background 41 | 42 | 43 | def trim_image(im): 44 | bg = PIL.Image.new('RGBA', im.size, (255, 255, 255, 255)) 45 | diff = PIL.ImageChops.difference(im, bg) 46 | # diff = PIL.ImageChops.add(diff, diff, 2.0, -100) 47 | bbox = diff.getbbox() 48 | if bbox: 49 | return im.crop(bbox) 50 | return im 51 | 52 | 53 | def colour_difference(a, b): 54 | return sum(abs(i - j) for i, j in zip(a, b)) 55 | 56 | 57 | def replace_colour(image, original, new, threshhold = 30): 58 | width, height = image.size 59 | for y in range(height): 60 | for x in range(width): 61 | if colour_difference(image.getpixel((x, y)), original) <= threshhold: 62 | image.putpixel((x, y), new) 63 | 64 | 65 | def image_invert(image): 66 | width, height = image.size 67 | for y in range(height): 68 | for x in range(width): 69 | r, g, b, a = image.getpixel((x, y)) 70 | image.putpixel((x, y), (255 - r, 255 - g, 255 - b, a)) 71 | 72 | 73 | def image_scale_channels(image, minima, maxima): 74 | width, height = image.size 75 | for y in range(height): 76 | for x in range(width): 77 | pixel = list(image.getpixel((x, y))) 78 | for i in range(3): 79 | a = minima[i] 80 | b = maxima[i] 81 | k = pixel[i] 82 | pixel[i] = int(a + (b - a) * (k / 255)) 83 | image.putpixel((x, y), tuple(pixel)) 84 | 85 | 86 | TEXT_HEIGHT = 19 87 | TEXT_WIDTH = 300 88 | TEXT_SCALE = 3 89 | TEXT_FONTSIZE = 44 90 | TEXT_FONTFACE = "./mathbot/fonts/roboto/Roboto-Thin.ttf" 91 | TEXT_FONTFACE_HEADDING = "./mathbot/fonts/roboto/Roboto-Regular.ttf" 92 | # TEXT_COLOUR = (32, 102, 148) 93 | TEXT_COLOUR = (40, 40, 40) 94 | TEXT_COLOUR_HEADDING = (240, 80, 0) 95 | TEXT_BACKGROUND = (0, 0, 0, 0) 96 | 97 | 98 | def textimage(txt, format = 'RGBA'): 99 | image = PIL.Image.new(format, (TEXT_WIDTH * TEXT_SCALE, TEXT_HEIGHT * TEXT_SCALE), TEXT_BACKGROUND) 100 | draw = PIL.ImageDraw.Draw(image) 101 | font = PIL.ImageFont.truetype(TEXT_FONTFACE, TEXT_FONTSIZE) 102 | draw.text((2, 0), txt, TEXT_COLOUR, font = font) 103 | return image.resize((TEXT_WIDTH, TEXT_HEIGHT), PIL.Image.Resampling.LANCZOS) 104 | -------------------------------------------------------------------------------- /mathbot/modules/__init__.py: -------------------------------------------------------------------------------- 1 | # Stop mypy complaining 2 | -------------------------------------------------------------------------------- /mathbot/modules/about.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import discord 3 | import psutil 4 | import os 5 | import asyncio 6 | import aiohttp 7 | import datetime 8 | from mathbot import core 9 | from discord.ext.commands import Cog, Bot, command, Context 10 | import codecs 11 | from mathbot.modules.reporter import report 12 | from mathbot.core.settings import command_allowed 13 | 14 | from discord import Interaction 15 | from discord.ext.commands.hybrid import hybrid_command 16 | 17 | 18 | BOT_COLOUR = 0x19BAE5 19 | STARTUP_TIME = datetime.datetime.now() 20 | 21 | 22 | STATS_MESSAGE = """\ 23 | Servers: {} 24 | Uptime: {} days {} hours {} minutes {} seconds 25 | """ 26 | 27 | 28 | # TODO: Make paths relative to code file 29 | core.help.load_from_file('./mathbot/help/help.md', topics = ['']) 30 | core.help.load_from_file('./mathbot/help/about.md') 31 | core.help.load_from_file('./mathbot/help/management.md') 32 | core.help.load_from_file('./mathbot/help/commands.md') 33 | 34 | 35 | async def get_bot_total_servers(id): 36 | async with aiohttp.ClientSession() as session: 37 | url = 'https://discordbots.org/api/bots/{}/stats'.format(id) 38 | async with session.get(url) as response: 39 | jdata = await response.json() 40 | return jdata.get('server_count') 41 | 42 | 43 | class AboutModule(Cog): 44 | 45 | # Send a message detailing the shard number, server count, 46 | # uptime and and memory using of this shard 47 | # @hybrid_command() 48 | # async def stats(self, context: Context): 49 | # embed = discord.Embed(title='MathBot Stats', colour=BOT_COLOUR) 50 | # embed.add_field( 51 | # name='Total Servers', 52 | # # MathBot's ID, hard coded for proper testing. 53 | # value=await get_bot_total_servers('134073775925886976'), 54 | # inline=True 55 | # ) 56 | # embed.add_field( 57 | # name='Visible Servers', 58 | # value=len(context.bot.guilds), 59 | # inline=True 60 | # ) 61 | # embed.add_field( 62 | # name='Shard IDs', 63 | # value=', '.join([str(i + 1) for i in context.bot.shard_ids]), 64 | # inline=True 65 | # ) 66 | # embed.add_field( 67 | # name='Uptime', 68 | # value=get_uptime(), 69 | # inline=True 70 | # ) 71 | # embed.add_field( 72 | # name='Memory Usage', 73 | # value='{} MB'.format(get_memory_usage()), 74 | # inline=True 75 | # ) 76 | # embed.set_footer(text='Time is in hh:mm') 77 | # await context.send(embed=embed) 78 | 79 | @hybrid_command() 80 | async def ping(self, ctx: Context): 81 | await ctx.reply(f'Pong! Latency {ctx.bot.latency}.') 82 | 83 | # Aliases for the help command 84 | @hybrid_command() 85 | async def about(self, ctx: Context): 86 | cmd = ctx.bot.get_command('help') 87 | await ctx.invoke(cmd, topic='about') 88 | 89 | @command(name=codecs.encode('shefhvg', 'rot_13')) 90 | @command_allowed('x-bonus') 91 | async def ignore_pls(self, context): 92 | with open('not_an_image', 'rb') as f: 93 | await context.send(file=discord.File(f, 'youaskedforit.png')) 94 | await report(context.bot, ':fox:') 95 | 96 | 97 | def get_uptime(): 98 | ''' Returns a string representing how long the bot has been running for ''' 99 | cur_time = datetime.datetime.now() 100 | up_time = cur_time - STARTUP_TIME 101 | up_hours = up_time.seconds // (60 * 60) + (up_time.days * 24) 102 | up_minutes = (up_time.seconds // 60) % 60 103 | return '{:02d}:{:02d}'.format(up_hours, up_minutes) 104 | 105 | 106 | def get_memory_usage(): 107 | ''' Returns the amount of memory the bot is using, in MB ''' 108 | proc = psutil.Process(os.getpid()) 109 | mem = proc.memory_info().rss 110 | return mem // (1024 * 1024) 111 | 112 | 113 | def setup(bot: Bot): 114 | return bot.add_cog(AboutModule()) 115 | -------------------------------------------------------------------------------- /mathbot/modules/analytics.py: -------------------------------------------------------------------------------- 1 | # Provides stats on the bot to bot listing services 2 | 3 | import aiohttp 4 | from discord.ext.commands import Cog 5 | import typing 6 | 7 | if typing.TYPE_CHECKING: 8 | from bot import MathBot 9 | 10 | BOTS_ORG_URL = 'https://discordbots.org/api/bots/{bot_id}/stats' 11 | BOTS_GG_URL = 'https://discord.bots.gg/api/v1/bots/{bot_id}/stats' 12 | 13 | HITLIST = [ 14 | (BOTS_ORG_URL, 'bots-org', 'server_count', 'shard_count', 'shard_id'), 15 | (BOTS_GG_URL, 'bots-gg', 'guildCount', 'shardCount', 'shardId') 16 | ] 17 | 18 | class AnalyticsModule(Cog): 19 | 20 | def __init__(self, bot: 'MathBot'): 21 | self.bot = bot 22 | self.done_report = False 23 | 24 | async def identify_bot_farms(self): 25 | ''' This function lists any medium / large servers with more bots 26 | than humans. The eventual goal is to identify and leave any 27 | servers that are just full of bots and don't actually have any 28 | proper activity in them. I should also add some metric gathering 29 | to figure out how much the bot gets used in various servers. 30 | ''' 31 | print(' Humans | Bots | Server Name') 32 | for server in self.bot.guilds: 33 | num_humans = 0 34 | num_bots = 0 35 | for user in server.members: 36 | if user.bot: 37 | num_bots += 1 38 | else: 39 | num_humans += 1 40 | num_members = num_humans + num_bots 41 | if num_bots > num_humans and num_members > 20: 42 | print(' {:6d} | {:5d} | {}'.format( 43 | num_humans, 44 | num_bots, 45 | server.name 46 | )) 47 | 48 | @Cog.listener() 49 | async def on_ready(self): 50 | # gets triggered on every reconnect, but we only want to 51 | # report things when the bot as a whole restarts, 52 | # which is abouve once a day 53 | if not self.done_report: 54 | self.done_report = True 55 | num_servers = len(self.bot.guilds) 56 | num_shards = self.bot.parameters.shards.total 57 | print('Shards', self.bot.shard_ids, 'are on', num_servers, 'servers') 58 | async with aiohttp.ClientSession() as session: 59 | for shard_id in self.bot.shard_ids: 60 | for (url_template, key_location, k_servers, k_shard, k_sid) in HITLIST: 61 | # UHHHHHHH I think we're not using this anymore 62 | key = self.bot.parameters.get('analytics ' + key_location) 63 | if key: 64 | url = url_template.format(bot_id = self.bot.user.id) 65 | payload = { 66 | 'json': { 67 | k_servers: num_servers, 68 | k_shard: num_shards, 69 | k_sid: shard_id 70 | }, 71 | 'headers': { 72 | 'Authorization': key, 73 | 'Content-Type': 'application/json' 74 | } 75 | } 76 | async with session.post(url, **payload) as response: 77 | print(f'Analytics ({url}): {response.status}') 78 | if response.status not in [200, 204]: 79 | print(await response.text()) 80 | num_servers = 1 81 | 82 | 83 | def setup(bot): 84 | bot.add_cog(AnalyticsModule(bot)) 85 | -------------------------------------------------------------------------------- /mathbot/modules/blame.py: -------------------------------------------------------------------------------- 1 | # Used to keep track of who was responsible for causing the bot to send 2 | # a particular message. This exists because people can use the =tex 3 | # command then delete their message to get the bot to say offensive or 4 | # innapropriate things. This allows server moderators (or anyone else really) 5 | # to track down the person responsible 6 | 7 | from mathbot import core 8 | from mathbot.core.util import respond 9 | from discord.ext.commands import Cog, Context 10 | from discord import Embed, Colour 11 | from discord.ext.commands.hybrid import hybrid_command 12 | 13 | 14 | class BlameModule(Cog): 15 | 16 | @hybrid_command() 17 | @respond 18 | async def blame(self, context: Context, message_id: str): 19 | if message_id == 'recent': 20 | async for m in context.channel.history(limit=100): 21 | if m.author == context.bot.user: 22 | user = await context.bot.keystore.get_json('blame', str(m.id)) 23 | if user is not None: 24 | return found_response(user, 'was responsible for the most recent message in this channel.') 25 | return error_response('Could not find any recent message') 26 | elif message_id.isnumeric(): 27 | user = await context.bot.keystore.get_json('blame', message_id) 28 | if user is None: 29 | return error_response('Could not find the blame information for that message') 30 | return found_response(user, 'was responsible for that message.') 31 | return error_response('Argument was not a valid message ID') 32 | 33 | 34 | def found_response(blob, description): 35 | user = '{mention} ({name}#{discriminator})'.format(**blob) 36 | return Embed(description=f'{user} {description}', colour=Colour.blue()) 37 | 38 | 39 | def error_response(text): 40 | return Embed(description=text, colour=Colour.red()) 41 | 42 | 43 | def setup(bot): 44 | core.help.load_from_file('./mathbot/help/blame.md') 45 | return bot.add_cog(BlameModule()) 46 | -------------------------------------------------------------------------------- /mathbot/modules/dice.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | ''' Module to allow the user to roll dice ''' 3 | 4 | import re 5 | import random 6 | 7 | from mathbot import core 8 | from mathbot import core 9 | from mathbot import core 10 | from discord.ext.commands import Cog, Context 11 | from discord.ext.commands.hybrid import hybrid_command 12 | 13 | import math 14 | 15 | core.help.load_from_file('./mathbot/help/roll.md') 16 | 17 | FORMAT_REGEX = re.compile(r'^(?:(\d*)[ d]+)?(\d+)$') 18 | 19 | 20 | class DiceException(Exception): pass 21 | 22 | 23 | class ValuesTooBigException(DiceException): pass 24 | 25 | 26 | class DiceModule(Cog): 27 | 28 | ''' Module to allow the user to roll dice ''' 29 | 30 | @hybrid_command() 31 | @core.settings.command_allowed('c-roll') 32 | @core.util.respond 33 | async def roll(self, ctx: Context, dice: str) -> str: 34 | ''' Roll command. `dice` should be of the format `2d6` or similar. ''' 35 | return await self.handle_roll(ctx, dice, should_sort=True) 36 | 37 | @hybrid_command() 38 | @core.settings.command_allowed('c-roll') 39 | @core.util.respond 40 | async def rollu(self, ctx: Context, dice: str) -> str: 41 | ''' Variant of the roll command that does not sort the output. ''' 42 | return await self.handle_roll(ctx, dice, should_sort=False) 43 | 44 | async def handle_roll(self, ctx: Context, arg: str, should_sort: bool) -> str: 45 | match = FORMAT_REGEX.match(arg.strip('`')) 46 | if match is None or match.group(2) is None: 47 | return '🎲 Format your rolls like `2d6`.' 48 | dice, faces = match.group(1, 2) 49 | dice = int(dice or 1) 50 | if dice <= 0: 51 | return '🎲 At least one dice must be rolled.' 52 | faces = int(faces or 6) 53 | if faces <= 0: 54 | return '🎲 Dice must have a positive number of faces.' 55 | 56 | limit = await self.get_limit(ctx) 57 | 58 | # this is the minimal length of this query, it is used to determine 59 | # whether it's possible for the result to be short enough to fit 60 | # within the limit. 61 | # 62 | # I got this by assuming each die rolled 1, plus 1 space per die 63 | # giving me 2 * dice. Then i add the length of the total, and the 64 | # length of the extra stuff that's always in the result. 65 | min_len = 2 * dice + 9 + math.log10(dice) 66 | 67 | # gaussian roll is faster so try that if we can't show all the rolls 68 | if min_len >= limit: 69 | total = 0 70 | try: 71 | total = self.gaussian_roll(dice, faces) 72 | except ValuesTooBigException: 73 | return '🎲 Values are too large.' 74 | 75 | return f'🎲 total: {total}' 76 | else: 77 | rolls, total = self.formatted_roll(dice, faces, should_sort=should_sort) 78 | final_message = f'🎲 {rolls}' 79 | return final_message if len(final_message) <= limit else f'🎲 total: {total}' 80 | 81 | async def get_limit(self, ctx): 82 | ''' Get the character limit for messages. ''' 83 | unlimited = await ctx.bot.settings.resolve_message('f-roll-unlimited', ctx.message) 84 | return 2000 if unlimited else 200 85 | 86 | def formatted_roll(self, dice, faces, should_sort=True): 87 | ''' Roll dice and return a string of the results as well as the total. ''' 88 | rolls = [random.randint(1, faces) for _ in range(dice)] 89 | total = sum(rolls) 90 | ordered_rolls = sorted(rolls) if should_sort else rolls 91 | s = f'{" ".join(map(str, ordered_rolls))} (total: {total})' 92 | return (s if dice > 1 else str(total)), total 93 | 94 | def gaussian_roll(self, dice, faces, limit=100000): 95 | ''' [random.randint(1, faces) for _ in range(dice)] 96 | Simulate a roll using normal distributions. Do it as 97 | many times as neccessary to avoid float inaccuracy, unless that means 98 | rolling more times than limit. 99 | ''' 100 | # if it passes this first test, then it's safe to do it in one roll 101 | # 53 is how many bits of precision we have with python's doubles. This 102 | # means that if we have a number which is greater than 2.0^53 the 103 | # precision will fall and we can only generate even numbers 104 | # 105 | # faces gets squared in the formula, so we need to check against half of 26 106 | PREC = 53 107 | 108 | if math.log2(faces) < (PREC / 2) and math.log2(dice * faces) < PREC: 109 | return self.gaussian_roll_single(dice, faces) 110 | # passing this second test means we can do multiple rolls safely 111 | elif math.log2(faces) < (PREC / 2): 112 | dice_per = 2**(PREC - round(math.log2(faces))) 113 | times = round(dice / dice_per) 114 | if times > limit: 115 | raise ValuesTooBigException() 116 | return sum([self.gaussian_roll_single(dice_per, faces) for _ in range(times)]) 117 | else: 118 | raise ValuesTooBigException() 119 | 120 | def gaussian_roll_single(self, dice, faces): 121 | ''' Use a normal distribution to roll some dice. Method hits float 122 | inaccuracies rather easily. In order to avoid float inaccuracy you'll need 123 | to make sure that: 124 | 1. dice has fewer than 16 digits 125 | 2. faces has fewer than 8 digits 126 | 3. dice and faces have fewer than 16 digits combined 127 | ''' 128 | mean = (faces + 1) * dice / 2 129 | std = math.sqrt((dice * (faces * faces - 1)) / 12) 130 | return int(random.gauss(mean, std)) 131 | 132 | def setup(bot): 133 | return bot.add_cog(DiceModule()) 134 | -------------------------------------------------------------------------------- /mathbot/modules/echo.py: -------------------------------------------------------------------------------- 1 | # Has a command which echoes whatever text was given to it. 2 | # Used only for testing purposes. 3 | 4 | from discord.ext.commands import command, Cog 5 | 6 | 7 | 8 | class EchoModule(Cog): 9 | 10 | def __init__(self, bot): 11 | self.bot = bot 12 | 13 | @command() 14 | async def echo(self, context, *, text: str): 15 | await context.send(text) 16 | 17 | 18 | def setup(bot): 19 | return bot.add_cog(EchoModule(bot)) 20 | -------------------------------------------------------------------------------- /mathbot/modules/greeter.py: -------------------------------------------------------------------------------- 1 | from mathbot import core 2 | from mathbot import core 3 | 4 | GREETING_MESSAGE = '''\ 5 | Welcome to the MathBot server! 6 | Type `=help` to get started with the bot. 7 | ''' 8 | 9 | class GreeterModule(core.module.Module): 10 | 11 | # These are the server IDs of the MathBot server, and my personal development server. 12 | @core.handles.on_member_joined(servers = ['233826358369845251', '134074627331719168']) 13 | async def greet(self, member): 14 | await self.send_message(member, GREETING_MESSAGE, blame = member) 15 | -------------------------------------------------------------------------------- /mathbot/modules/heartbeat.py: -------------------------------------------------------------------------------- 1 | ''' Module to update the status icon of the bot whever it's being restarted or something. ''' 2 | 3 | import time 4 | import asyncio 5 | import discord 6 | import traceback 7 | from discord.ext.commands import Cog, Context 8 | from discord.ext.commands.hybrid import hybrid_command 9 | 10 | 11 | class Heartbeat(Cog): 12 | 13 | def __init__(self, bot): 14 | self.bot = bot 15 | self.background_task = None 16 | 17 | @Cog.listener() 18 | async def on_ready(self): 19 | if self.background_task is None: 20 | self.background_task = self.bot.loop.create_task(self.pulse()) 21 | 22 | async def pulse(self): 23 | ''' Repeatedly update the status of the bot ''' 24 | print('Heartbeat coroutine is running') 25 | tick = 0 26 | while not self.bot.is_closed(): # TODO: Stop the task if the cog is unloaded 27 | current_time = int(time.time()) 28 | for shard in self.bot.shard_ids: 29 | await self.bot.keystore.set('heartbeat', str(shard), current_time) 30 | tick += 1 31 | if tick % 5 == 0: 32 | # Find the slowest shard 33 | slowest = min([ 34 | (await self.bot.keystore.get('heartbeat', str(shard)) or 1) 35 | for shard in range(self.bot.shard_count) 36 | ]) 37 | try: 38 | await self.bot.change_presence( 39 | activity=discord.Game('! use @MathBot !'), 40 | status=discord.Status.idle if current_time - slowest >= 30 else discord.Status.online 41 | ) 42 | except Exception: 43 | print('Error while changing presence based on heartbeat') 44 | traceback.print_exc() 45 | await asyncio.sleep(3) 46 | # Specify that the current shard is no longer running. Helps the other shards update sooner. 47 | for i in self.bot.shard_ids: 48 | await self.bot.keystore.set('heartbeat', str(i), 1) 49 | 50 | @hybrid_command() 51 | async def heartbeat(self, context: Context): 52 | error_queue_length = await self.bot.keystore.llen('error-report') 53 | current_time = int(time.time()) 54 | lines = ['```'] 55 | for i in range(self.bot.shard_count): 56 | last_time = await self.bot.keystore.get('heartbeat', str(i)) or 1 57 | timediff = min(60 * 60 - 1, current_time - last_time) 58 | lines.append('{} {:2d} - {:2d}m {:2d}s'.format( 59 | '>' if i in self.bot.shard_ids else ' ', 60 | i + 1, 61 | timediff // 60, 62 | timediff % 60 63 | )) 64 | lines += ['', f'Error queue length: {error_queue_length}', '```'] 65 | await context.send('\n'.join(lines)) 66 | 67 | def setup(bot): 68 | return bot.add_cog(Heartbeat(bot)) 69 | -------------------------------------------------------------------------------- /mathbot/modules/help.py: -------------------------------------------------------------------------------- 1 | ''' Module used to send help documents to users. ''' 2 | 3 | from mathbot import core 4 | from discord.ext.commands import Cog, Context 5 | import discord 6 | from discord.ext.commands.hybrid import hybrid_command 7 | 8 | from mathbot.utils import is_private 9 | 10 | 11 | 12 | SERVER_LINK = 'https://discord.gg/JbJbRZS' 13 | PREFIX_MEMORY_EXPIRE = 60 * 60 * 24 * 3 # 3 days 14 | 15 | 16 | def doubleformat(string, **replacements): 17 | ''' Acts a but like format, but works on things wrapped in *two* curly braces. ''' 18 | for key, value in replacements.items(): 19 | string = string.replace('{{' + key + '}}', value) 20 | return string 21 | 22 | 23 | class HelpModule(Cog): 24 | ''' Module that serves help pages. ''' 25 | 26 | @hybrid_command() 27 | async def support(self, context: Context): 28 | await context.send(f'Mathbot support server: {SERVER_LINK}') 29 | 30 | @hybrid_command() 31 | async def invite(self, context: Context): 32 | await context.send('Add mathbot to your server: https://dxsmiley.github.io/mathbot/add.html') 33 | 34 | @hybrid_command() 35 | async def help(self, context: Context, *, topic: str = 'help'): 36 | ''' Help command itself. 37 | Help is sent via DM, but a small message is also sent in the public chat. 38 | Specifying a non-existent topic will show an error and display a list 39 | of topics that the user could have meant. 40 | ''' 41 | if topic in ['topics', 'topic', 'list']: 42 | await context.reply(self._topic_list()) 43 | return 44 | 45 | found_doc = core.help.get(topic) 46 | if found_doc is None: 47 | await context.reply(self._suggest_topics(topic)) 48 | return 49 | 50 | # Display the default prefix if the user is in DMs and uses no prefix. 51 | prefix = context.prefix or '=' 52 | 53 | print(prefix, context.bot.user.id) 54 | if prefix.strip() in [f'<@{context.bot.user.id}>', f'<@!{context.bot.user.id}>']: 55 | prefix = '@MathBot ' 56 | 57 | try: 58 | for index, page in enumerate(found_doc): 59 | page = doubleformat( 60 | page, 61 | prefix=prefix, 62 | mention=context.bot.user.mention, 63 | add_link='https://dxsmiley.github.io/mathbot/add.html', 64 | server_link=SERVER_LINK, 65 | patreon_listing=await context.bot.get_patron_listing() 66 | ) 67 | await context.message.author.send(page) 68 | if not is_private(context.channel): 69 | await context.reply('Help has been sent to your DMs!') 70 | except discord.Forbidden: 71 | await context.reply(embed=discord.Embed( 72 | title='The bot was unable to slide into your DMs', 73 | description=f'Please try modifying your privacy settings to allow DMs from server members. If you are still experiencing problems, contact the developer at the mathbot server: {SERVER_LINK}', 74 | colour=discord.Colour.red() 75 | )) 76 | 77 | def _topic_list(self) -> str: 78 | topics = core.help.listing() 79 | column_width = max(map(len, topics)) 80 | columns = 3 81 | reply = 'The following help topics exist:\n```\n' 82 | for i, t in enumerate(topics): 83 | reply += t.ljust(column_width) 84 | reply += '\n' if (i + 1) % columns == 0 else ' '; 85 | reply += '```\n' 86 | return reply 87 | 88 | def _suggest_topics(self, typo): 89 | suggestions = core.help.get_similar(typo) 90 | if not suggestions: 91 | return f"That help topic does not exist." 92 | elif len(suggestions) == 1: 93 | return f"That help topic does not exist.\nMaybe you meant `{suggestions[0]}`?" 94 | return f"That help topic does not exist.\nMaybe you meant one of: {', '.join(map('`{}`'.format, suggestions))}?" 95 | 96 | def setup(bot): 97 | return bot.add_cog(HelpModule()) 98 | -------------------------------------------------------------------------------- /mathbot/modules/latex/replacements.json: -------------------------------------------------------------------------------- 1 | { 2 | "Γ": " \\Gamma ", 3 | "Δ": " \\Delta ", 4 | "Θ": " \\Theta ", 5 | "Λ": " \\Lambda ", 6 | "Ξ": " \\Xi ", 7 | "Π": " \\Pi ", 8 | "Σ": " \\Sigma", 9 | "Υ": " \\Upsilon", 10 | "Φ": " \\Phi ", 11 | "Ψ": " \\Psi ", 12 | "Ω": " \\Omega", 13 | "α": " \\alpha ", 14 | "β": " \\beta ", 15 | "γ": " \\gamma ", 16 | "δ": " \\delta ", 17 | "ε": " \\epsilon ", 18 | "ζ": " \\zeta ", 19 | "η": " \\eta ", 20 | "θ": " \\theta ", 21 | "ι": " \\iota ", 22 | "κ": " \\kappa ", 23 | "λ": " \\lambda ", 24 | "μ": " \\mu ", 25 | "ν": " \\nu ", 26 | "ξ": " \\xi ", 27 | "π": " \\pi ", 28 | "ρ": " \\rho ", 29 | "ς": " \\varsigma ", 30 | "σ": " \\sigma ", 31 | "τ": " \\tau ", 32 | "υ": " \\upsilon ", 33 | "φ": " \\phi ", 34 | "χ": " \\chi ", 35 | "ψ": " \\psi ", 36 | "ω": " \\omega ", 37 | "×": " \\times ", 38 | "÷": " \\div ", 39 | "ש": " \\shin ", 40 | "א": " \\alef ", 41 | "ב": " \\beth ", 42 | "ג": " \\gimel ", 43 | "ד": " \\daleth ", 44 | "ל": " \\lamed ", 45 | "מ": " \\mim ", 46 | "ם": " \\mim ", 47 | "ע": " \\ayin ", 48 | "צ": " \\tsadi ", 49 | "ץ": " \\tsadi ", 50 | "ק": " \\qof ", 51 | "≠": " \\neq ", 52 | "·": " \\cdot ", 53 | "•": " \\cdot " 54 | } -------------------------------------------------------------------------------- /mathbot/modules/latex/template.tex: -------------------------------------------------------------------------------- 1 | \documentclass{article} 2 | 3 | \usepackage[utf8]{inputenc} 4 | \usepackage{amsfonts} 5 | \usepackage{amssymb} 6 | \usepackage{mathrsfs} 7 | \usepackage{chemfig} 8 | \usepackage[siunitx, american]{circuitikz} 9 | \usepackage{mathtools} 10 | \usepackage{esint} % Having this before mathtools causes problems 11 | \usepackage{mhchem} 12 | \usepackage{tikz-cd} 13 | \usepackage{color} 14 | \usepackage{xcolor} 15 | \usepackage{cancel} 16 | \usepackage[#PAPERTYPE]{geometry} 17 | \usepackage{dsfont} 18 | \usepackage{bm} 19 | \usepackage{nicefrac} 20 | \usepackage{physics} 21 | 22 | % The physics package redifines \div to be "divergence", 23 | % but that's not what anyone wants 24 | \renewcommand{\div}{\divisionsymbol} 25 | 26 | % Hebrew characters (from https://tex.stackexchange.com/a/226850) 27 | \DeclareFontFamily{U}{rcjhbltx}{} 28 | \DeclareFontShape{U}{rcjhbltx}{m}{n}{<->rcjhbltx}{} 29 | \DeclareSymbolFont{hebrewletters}{U}{rcjhbltx}{m}{n} 30 | % remove the definitions from amssymb 31 | \let\aleph\relax\let\beth\relax 32 | \let\gimel\relax\let\daleth\relax 33 | \DeclareMathSymbol{\aleph}{\mathord}{hebrewletters}{39} 34 | \DeclareMathSymbol{\beth}{\mathord}{hebrewletters}{98}\let\bet\beth 35 | \DeclareMathSymbol{\gimel}{\mathord}{hebrewletters}{103} 36 | \DeclareMathSymbol{\daleth}{\mathord}{hebrewletters}{100}\let\dalet\daleth 37 | \DeclareMathSymbol{\lamed}{\mathord}{hebrewletters}{108} 38 | \DeclareMathSymbol{\mem}{\mathord}{hebrewletters}{109}\let\mim\mem 39 | \DeclareMathSymbol{\ayin}{\mathord}{hebrewletters}{96} 40 | \DeclareMathSymbol{\tsadi}{\mathord}{hebrewletters}{118} 41 | \DeclareMathSymbol{\qof}{\mathord}{hebrewletters}{113} 42 | \DeclareMathSymbol{\shin}{\mathord}{hebrewletters}{152} 43 | 44 | \newcommand{\bbR}{\mathbb{R}} 45 | \newcommand{\bbQ}{\mathbb{Q}} 46 | \newcommand{\bbC}{\mathbb{C}} 47 | \newcommand{\bbZ}{\mathbb{Z}} 48 | \newcommand{\bbN}{\mathbb{N}} 49 | \newcommand{\bbH}{\mathbb{H}} 50 | \newcommand{\bbK}{\mathbb{K}} 51 | \newcommand{\bbG}{\mathbb{G}} 52 | \newcommand{\bbP}{\mathbb{P}} 53 | \newcommand{\bbX}{\mathbb{X}} 54 | \newcommand{\bbD}{\mathbb{D}} 55 | \newcommand{\bbO}{\mathbb{O}} 56 | \newcommand{\bigO}{\mathcal{O}} 57 | \newcommand{\ceil}[1]{\left\lceil{#1}\right\rceil} 58 | \newcommand{\floor}[1]{\left\lfloor{#1}\right\rfloor} 59 | \newcommand{\lif}{\rightarrow} 60 | 61 | \newcommand{\calA}{\mathcal{A}} 62 | \newcommand{\calB}{\mathcal{B}} 63 | \newcommand{\calC}{\mathcal{C}} 64 | \newcommand{\calD}{\mathcal{D}} 65 | \newcommand{\calE}{\mathcal{E}} 66 | \newcommand{\calF}{\mathcal{F}} 67 | \newcommand{\calG}{\mathcal{G}} 68 | \newcommand{\calH}{\mathcal{H}} 69 | \newcommand{\calI}{\mathcal{I}} 70 | \newcommand{\calJ}{\mathcal{J}} 71 | \newcommand{\calK}{\mathcal{K}} 72 | \newcommand{\calL}{\mathcal{L}} 73 | \newcommand{\calM}{\mathcal{M}} 74 | \newcommand{\calN}{\mathcal{N}} 75 | \newcommand{\calO}{\mathcal{O}} 76 | \newcommand{\calP}{\mathcal{P}} 77 | \newcommand{\calQ}{\mathcal{Q}} 78 | \newcommand{\calR}{\mathcal{R}} 79 | \newcommand{\calS}{\mathcal{S}} 80 | \newcommand{\calT}{\mathcal{T}} 81 | \newcommand{\calU}{\mathcal{U}} 82 | \newcommand{\calV}{\mathcal{V}} 83 | \newcommand{\calW}{\mathcal{W}} 84 | \newcommand{\calX}{\mathcal{X}} 85 | \newcommand{\calY}{\mathcal{Y}} 86 | \newcommand{\calZ}{\mathcal{Z}} 87 | 88 | \begin{document} 89 | 90 | \pagenumbering{gobble} 91 | 92 | % #COLOUR is the replaced with specified text colour. 93 | \definecolor{my_colour}{HTML}{#COLOUR} 94 | \color{my_colour} 95 | \ctikzset{color=my_colour} 96 | \ctikzset{bipoles/thickness=1} 97 | 98 | % #BLOCK is replaced by either gather* or flushleft, depending on 99 | % whether it's an explicit invocation or a an inline command 100 | \begin{#BLOCK} 101 | % #CONTENT gets replaced by users's actual input 102 | #CONTENT 103 | \end{#BLOCK} 104 | 105 | \end{document} 106 | -------------------------------------------------------------------------------- /mathbot/modules/oeis.py: -------------------------------------------------------------------------------- 1 | import aiohttp 2 | import json 3 | 4 | from urllib.parse import urlencode 5 | from discord.ext.commands import Cog, Context 6 | from mathbot.core.settings import command_allowed 7 | from discord.ext.commands.hybrid import hybrid_command 8 | from mathbot import core 9 | 10 | core.help.load_from_file('./mathbot/help/oeis.md') 11 | 12 | class OEIS(Cog): 13 | 14 | @hybrid_command() 15 | @command_allowed('c-oeis') 16 | async def oeis(self, ctx: Context, *, query=''): 17 | if query == '': 18 | await ctx.send(f'The `{ctx.prefix}oeis` command is used to query the Online Encyclopedia of Integer Sequences. See `{ctx.prefix}help oeis` for details.') 19 | return 20 | async with ctx.typing(): 21 | async with aiohttp.ClientSession() as session: 22 | params = { 23 | 'q': query, 24 | 'start': 0, 25 | 'fmt': 'json' 26 | } 27 | async with session.get('https://oeis.org/search', params=params, timeout=10) as req: 28 | j = await req.json() 29 | # print(json.dumps(j, indent=4)) 30 | count = j.get('count', 0) 31 | res = j.get('results', None) 32 | if count == 0: 33 | await ctx.send('No sequences were found.') 34 | elif res is None: 35 | await ctx.send(f'There are {count} relevant sequences. Please be more specific.') 36 | else: 37 | name = res[0]['name'] 38 | number = res[0]['number'] 39 | digits = res[0]['data'].replace(',', ', ').strip() 40 | match_text = ( 41 | f"There were {count} relevant sequences. Here is one:" 42 | if count > 1 43 | else "There was 1 relevant sequence:" 44 | ) 45 | m = f"{match_text}\n\n**{name}**\nhttps://oeis.org/A{number}\n\n{digits}\n" 46 | # for c in res[0]['comment']: 47 | # if len(m) + len(c) + 10 < 2000: 48 | # m += f'\n> {c}' 49 | await ctx.send(m) 50 | 51 | 52 | def setup(bot): 53 | return bot.add_cog(OEIS()) 54 | -------------------------------------------------------------------------------- /mathbot/modules/purge.py: -------------------------------------------------------------------------------- 1 | from mathbot import core 2 | import discord 3 | import asyncio 4 | 5 | 6 | from discord.ext.commands import guild_only, has_permissions, Cog, Context 7 | from discord.ext.commands.hybrid import hybrid_command 8 | 9 | 10 | USER_PERM_ERROR = '''\ 11 | You do not have the permissions required to perform that operation in this channel. 12 | You need to have permission to *manage messages*. 13 | ''' 14 | 15 | PRIVATE_ERROR = '''\ 16 | The `=purge` command cannot be used in a private channel. 17 | See `=help purge` for more details. 18 | ''' 19 | 20 | core.help.load_from_file('./mathbot/help/purge.md') 21 | 22 | class PurgeModule(Cog): 23 | @hybrid_command() 24 | @guild_only() 25 | @has_permissions(manage_messages=True) 26 | async def purge(self, ctx, number: int): 27 | if number > 0: 28 | number = min(200, number) 29 | async for message in ctx.channel.history(limit=200): 30 | if message.author.id == ctx.bot.user.id and number > 0: 31 | try: 32 | await message.delete() 33 | number -= 1 34 | except discord.errors.NotFound: 35 | pass 36 | await asyncio.sleep(1) 37 | 38 | def setup(bot): 39 | return bot.add_cog(PurgeModule()) 40 | -------------------------------------------------------------------------------- /mathbot/modules/reboot.py: -------------------------------------------------------------------------------- 1 | from discord.ext.commands import command, Cog, Context 2 | import typing 3 | 4 | if typing.TYPE_CHECKING: 5 | from bot import MathBot 6 | 7 | 8 | class Reboot(Cog): 9 | 10 | # Used to have an actual "reboot" command but we're no longer on Heroku so I just deleted it 11 | 12 | @command() 13 | async def sync_commands_global(self, ctx: 'Context[MathBot]'): 14 | # TODO: Make this userid set in parameters.json 15 | if ctx.author.id == 133804143721578505: 16 | await ctx.send('Syncing global commands') 17 | async with ctx.typing(): 18 | print('Syncing global commands...') 19 | await ctx.bot.tree.sync() 20 | print('Done') 21 | await ctx.send('Done') 22 | 23 | def setup(bot: 'MathBot'): 24 | return bot.add_cog(Reboot()) 25 | -------------------------------------------------------------------------------- /mathbot/modules/reporter.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import asyncio 3 | import traceback 4 | import discord 5 | from discord.ext.commands import Cog 6 | import termcolor 7 | import aiohttp 8 | 9 | from mathbot import core 10 | 11 | import typing 12 | 13 | if typing.TYPE_CHECKING: 14 | from bot import MathBot 15 | 16 | 17 | 18 | # This queue is a global object, which actually means that multiple 19 | # shards might fiddle with it. HOWEVER, this is fine since there'll 20 | # just be multiple shards pushing from the queue to the redis store 21 | # every 10 seconds or so. Because we're using coroutines and not 22 | # real threads, this is OK. 23 | QUEUE = typing.Deque[str]() 24 | QUEUE = collections.deque() 25 | 26 | 27 | class ReporterModule(Cog): 28 | 29 | def __init__(self, bot): 30 | self.bot = bot 31 | self.task = None 32 | 33 | @Cog.listener() 34 | async def on_ready(self): 35 | if self.task is not None: 36 | self.task.end() 37 | self.task = ReporterTask(self.bot) 38 | 39 | 40 | class ReporterTask: 41 | 42 | def __init__(self, bot: 'MathBot'): 43 | self.bot = bot 44 | self.should_end = False 45 | self.bot.loop.create_task(self.send_reports()) 46 | 47 | def end(self): 48 | self.should_end = True 49 | 50 | async def send_reports(self): 51 | print('Shard', self.bot.shard_ids, 'started reporting task.') 52 | await asyncio.sleep(10) 53 | try: 54 | report_channel = await self.get_report_channel() 55 | if report_channel is None: 56 | await cprint_and_report(self.bot, 'green', f'Shard {self.bot.shard_ids} has started') 57 | return 58 | await cprint_and_report(self.bot, 'green', f'Shard `{self.bot.shard_ids}` will report errors') 59 | while not self.should_end: 60 | try: 61 | message = await self.bot.keystore.rpop('error-report') 62 | if message: 63 | # Errors should have already been trimmed before they reach this point, 64 | # but this is just in case something slips past 65 | termcolor.cprint('Sending error report', 'yellow') 66 | termcolor.cprint(message, 'yellow') 67 | termcolor.cprint('--------------------', 'yellow') 68 | if len(message) > 1900: 69 | message = message[:1900] + ' **(emergency trim)**' 70 | await report_channel.send(message) 71 | else: 72 | await asyncio.sleep(10) 73 | except asyncio.CancelledError: 74 | raise 75 | except Exception: 76 | m = f'Exception in ReporterModule.send_reports on shard {self.bot.shard_id}. This is bad.' 77 | termcolor.cprint('*' * len(m), 'red') 78 | termcolor.cprint(m, 'red') 79 | termcolor.cprint('*' * len(m), 'red') 80 | traceback.print_exc() 81 | print('Report sending task has finished') 82 | except Exception: 83 | m = f'Exception in ReporterModule.send_reports on shard {self.bot.shard_id} has killed the task.' 84 | termcolor.cprint('*' * len(m), 'red') 85 | termcolor.cprint(m, 'red') 86 | termcolor.cprint('*' * len(m), 'red') 87 | traceback.print_exc() 88 | 89 | async def get_report_channel(self) -> typing.Optional[discord.TextChannel]: 90 | channel_id = self.bot.parameters.error_reporting.channel 91 | if channel_id: 92 | try: 93 | return self.bot.get_channel(channel_id) 94 | except Exception: 95 | pass 96 | 97 | 98 | async def cprint_and_report(bot, color: str, string: str): 99 | termcolor.cprint(string, color) 100 | await report(bot, string) 101 | 102 | 103 | async def report(bot: 'MathBot', string: str): 104 | if bot.parameters.error_reporting.channel: 105 | await bot.keystore.lpush('error-report', string) 106 | await report_via_webhook_only(bot, string) 107 | 108 | 109 | async def report_via_webhook_only(bot: 'MathBot', string: str): 110 | webhook_url = bot.parameters.error_reporting.webhook 111 | if webhook_url is not None: 112 | async with aiohttp.ClientSession() as session: 113 | if len(string) > 2000: 114 | string = string[:2000 - 3] + "..." 115 | payload = { "content": string } 116 | async with session.post(webhook_url, json=payload) as resp: 117 | if resp.status != 200: 118 | termcolor.cprint(resp.status, 'red') 119 | termcolor.cprint(await resp.text(), 'red') 120 | 121 | 122 | def setup(bot): 123 | return bot.add_cog(ReporterModule(bot)) 124 | -------------------------------------------------------------------------------- /mathbot/modules/settings.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | import aioredis 3 | import os 4 | import re 5 | from mathbot import core 6 | from mathbot import core 7 | from mathbot import core 8 | 9 | from discord.ext.commands import command, guild_only, has_permissions, Cog, Context 10 | from discord.ext.commands.hybrid import hybrid_command 11 | 12 | 13 | core.help.load_from_file('./mathbot/help/settings.md') 14 | core.help.load_from_file('./mathbot/help/theme.md') 15 | core.help.load_from_file('./mathbot/help/units.md') 16 | core.help.load_from_file('./mathbot/help/prefix.md') 17 | 18 | 19 | CHECKSETTING_TEMPLATE = '''\ 20 | Setting "{}" has the following values: 21 | ``` 22 | TextChannel: {} 23 | Server: {} 24 | Default: {} 25 | ``` 26 | ''' 27 | 28 | 29 | class ProblemReporter: 30 | 31 | def __init__(self, ctx): 32 | self.problems = [] 33 | self.ctx = ctx 34 | 35 | async def __aenter__(self): 36 | return self._report 37 | 38 | async def __aexit__(self, type, value, traceback): 39 | if self.problems: 40 | msg = '\n'.join(self.problems) 41 | await self.ctx.send(msg) 42 | raise WasProblems 43 | 44 | def _report(self, text): 45 | self.problems.append(text) 46 | 47 | 48 | class WasProblems(Exception): 49 | pass 50 | 51 | 52 | class SettingsModule(Cog): 53 | 54 | reduce_value = { 55 | 'enable': 1, 56 | 'disable': 0, 57 | 'reset': None, 58 | 'original': None, 59 | 'e': 1, 60 | 'd': 0, 61 | 'r': None, 62 | 'o': None 63 | }.get 64 | 65 | expand_value = { 66 | None: '--------', 67 | True: 'enabled', 68 | False: 'disabled' 69 | }.get 70 | 71 | @hybrid_command(name='set') 72 | @guild_only() 73 | @has_permissions(administrator=True) 74 | async def _set( 75 | self, 76 | ctx: Context, 77 | context: Literal['server', 'guild', 'channel'], 78 | setting: str, 79 | value: Literal['enable', 'disable', 'original', 'reset'] 80 | ): 81 | try: 82 | async with ProblemReporter(ctx) as problem: 83 | setting_details = core.settings.details(setting) 84 | if setting_details is None: 85 | problem(f'`{setting}` is not a valid setting. See `=help settings` for a list of valid settings.') 86 | if context not in ['server', 'guild', 'channel', 's', 'c', 'g']: 87 | problem(f'`{context}` is not a valid context. Options are: `server` or `channel`') 88 | if value not in ['enable', 'disable', 'original', 'e', 'd', 'o', 'reset', 'r']: 89 | problem(f'`{value}` is not a valid value. Options are `enable`, `disable`, `reset`.') 90 | except WasProblems: 91 | pass 92 | else: 93 | context = ctx.message.channel if context[0] == 'c' else ctx.message.guild 94 | val = SettingsModule.reduce_value(value) 95 | await ctx.bot.settings.set(setting, context, val) 96 | await ctx.reply('Setting applied.') 97 | 98 | @hybrid_command() 99 | async def theme(self, ctx: Context, theme: Literal['light', 'dark']): 100 | theme = theme.lower() 101 | if theme not in ['light', 'dark']: 102 | return f'`{theme}` is not a valid theme. Valid options are `light` and `dark`.' 103 | await ctx.bot.keystore.set(f'p-tex-colour:{ctx.message.author.id}', theme) 104 | await ctx.reply(f'Your theme has been set to `{theme}`.') 105 | 106 | @hybrid_command() 107 | async def units(self, ctx: Context, units: Literal['metric', 'imperial']): 108 | units = units.lower() 109 | if units not in ['metric', 'imperial']: 110 | await ctx.reply(f'`{units}` is not a unit system. Valid units are `metric` and `imperial`.') 111 | else: 112 | await ctx.bot.keystore.set(f'p-wolf-units:{ctx.author.id}', units) 113 | await ctx.reply(f'Your units have been set to `{units}`.') 114 | 115 | @hybrid_command() 116 | @guild_only() 117 | async def checksetting(self, ctx: Context, setting: str): 118 | if core.settings.details(setting) is None: 119 | return '`{}` is not a valid setting. See `=help settings` for a list of valid settings.' 120 | value_server = await ctx.bot.settings.get_single(setting, ctx.message.guild) 121 | value_channel = await ctx.bot.settings.get_single(setting, ctx.message.channel) 122 | print('Details for', setting) 123 | print('Server: ', value_server) 124 | print('Channel:', value_channel) 125 | default = core.settings.details(setting).get('default') 126 | await ctx.reply(CHECKSETTING_TEMPLATE.format( 127 | setting, 128 | SettingsModule.expand_value(value_channel), 129 | SettingsModule.expand_value(value_server), 130 | SettingsModule.expand_value(default) 131 | )) 132 | 133 | @hybrid_command() 134 | @guild_only() 135 | async def checkallsettings(self, ctx: Context): 136 | lines = [ 137 | ' Setting | Channel | Server | Default', 138 | '------------------+----------+----------+----------' 139 | ] 140 | items = [ 141 | (core.settings.get_cannon_name(name), details) 142 | for name, details in core.settings.SETTINGS.items() 143 | if 'redirect' not in details 144 | ] 145 | for setting, s_details in sorted(items, key=lambda x: x[0]): 146 | value_channel = await ctx.bot.settings.get_single(setting, ctx.message.channel) 147 | value_server = await ctx.bot.settings.get_single(setting, ctx.message.guild) 148 | lines.append(' {: <16} | {: <8} | {: <8} | {: <8}'.format( 149 | setting, 150 | SettingsModule.expand_value(value_channel), 151 | SettingsModule.expand_value(value_server), 152 | SettingsModule.expand_value(s_details['default']) 153 | )) 154 | await ctx.reply('```\n{}\n```'.format('\n'.join(lines))) 155 | 156 | # TODO: Figure out what this is even for? 157 | @command() 158 | async def checkdmsettings(self, ctx): 159 | lines = [ 160 | ' Setting | Default | Resolved ', 161 | '------------------+----------+----------' 162 | ] 163 | items = [ 164 | (core.settings.get_cannon_name(name), details) 165 | for name, details in core.settings.SETTINGS.items() 166 | if 'redirect' not in details 167 | ] 168 | for setting, s_details in sorted(items, key=lambda x: x[0]): 169 | resolved = await ctx.bot.settings.resolve_message(setting, ctx.message) 170 | lines.append(' {: <16} | {: <8} | {: <8}'.format( 171 | setting, 172 | SettingsModule.expand_value(s_details['default']), 173 | resolved 174 | )) 175 | await ctx.send('```\n{}\n```'.format('\n'.join(lines))) 176 | 177 | # Intentionally not making a slash command of this 178 | # @command() 179 | # @guild_only() 180 | # async def prefix(self, ctx, *, arg=''): 181 | # prefix = await ctx.bot.settings.get_server_prefix(ctx.message.guild) 182 | # p_text = prefix or '=' 183 | # if p_text in [None, '=']: 184 | # m = 'The prefix for this server is `=`, which is the default.' 185 | # else: 186 | # m = f'The prefix for this server is `{p_text}`, which has been customised.' 187 | # if arg: 188 | # m += '\nServer admins can use the `setprefix` command to change the prefix.' 189 | # await ctx.send(m) 190 | 191 | # Intentionally not making a slash command of this 192 | # @command() 193 | # @guild_only() 194 | # @has_permissions(administrator=True) 195 | # async def setprefix(self, ctx, *, new_prefix): 196 | # prefix = new_prefix.strip().replace('`', '') 197 | # await ctx.bot.settings.set_server_prefix(ctx.guild, prefix) 198 | # await ctx.send(f'Bot prefix for this server has been changed to `{prefix}`.') 199 | 200 | def setup(bot): 201 | return bot.add_cog(SettingsModule()) 202 | -------------------------------------------------------------------------------- /mathbot/modules/throws.py: -------------------------------------------------------------------------------- 1 | from discord.ext.commands import command, Cog 2 | 3 | class ThrowsModule(Cog): 4 | 5 | @command() 6 | async def throw(self, context): 7 | raise Exception('I wonder what went wrong?') 8 | 9 | def setup(bot): 10 | return bot.add_cog(ThrowsModule()) 11 | -------------------------------------------------------------------------------- /mathbot/not_an_image: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DXsmiley/mathbot/723d86ba0169823e4e076e796121af93efdd74e1/mathbot/not_an_image -------------------------------------------------------------------------------- /mathbot/open_relative.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import os 3 | 4 | def open_relative(filename, *args, **kwargs): 5 | codefile = inspect.stack()[1].filename 6 | abspath = os.path.abspath(codefile) 7 | directory = os.path.dirname(abspath) 8 | path = os.path.join(directory, filename) 9 | return open(path, *args, **kwargs) -------------------------------------------------------------------------------- /mathbot/parameters_default.json: -------------------------------------------------------------------------------- 1 | { 2 | "release": "development", 3 | "token": "NEED THIS", 4 | "keystore": { 5 | "mode": "memory" 6 | }, 7 | "wolfram": { 8 | "key": "NEED THIS" 9 | }, 10 | "patrons": { 11 | 12 | }, 13 | "automata": { 14 | "token": null, 15 | "target": null, 16 | "channel": null 17 | }, 18 | "analytics": { 19 | "carbon": null, 20 | "discord-bots": null, 21 | "bots-org": null 22 | }, 23 | "advertising": { 24 | "enable": true, 25 | "interval": 200, 26 | "starting_amount": 50 27 | }, 28 | "error_reporting": { 29 | "channel": null, 30 | "webhook": null 31 | }, 32 | "shards": { 33 | "total": 1, 34 | "mine": [0] 35 | }, 36 | "calculator": { 37 | "persistent": false, 38 | "libraries": false 39 | }, 40 | "blocked_users": [] 41 | } 42 | -------------------------------------------------------------------------------- /mathbot/patrons.py: -------------------------------------------------------------------------------- 1 | from discord.ext.commands import command, Cog 2 | import discord 3 | 4 | TIER_NONE = 0 5 | TIER_CONSTANT = 1 6 | TIER_QUADRATIC = 2 7 | TIER_EXPONENTIAL = 3 8 | TIER_SPECIAL = 4 9 | 10 | 11 | class InvalidPatronRankError(Exception): 12 | pass 13 | 14 | 15 | class PatronageMixin: 16 | 17 | async def patron_tier(self, uid): 18 | if not isinstance(uid, (str, int)): 19 | raise TypeError('User ID looks invalid') 20 | return await self.keystore.get('patron', str(uid)) or 0 21 | 22 | async def get_patron_listing(self): 23 | return (await self.keystore.get('patron', 'listing')) or 'nobody?' 24 | 25 | 26 | class PatronModule(Cog): 27 | 28 | def __init__(self, bot): 29 | self.bot = bot 30 | 31 | @command() 32 | async def check_patronage(self, ctx): 33 | m = [] 34 | tier = await ctx.bot.patron_tier(ctx.author.id) 35 | m.append(f'Your patronage tier is {get_tier_name(tier)}') 36 | if isinstance(ctx.channel, discord.TextChannel): 37 | tier = await ctx.bot.patron_tier(ctx.channel.guild.owner_id) 38 | m.append(f'The patrongage of this server\'s owner is {get_tier_name(tier)}') 39 | await ctx.send('\n'.join(m)) 40 | 41 | @Cog.listener() 42 | async def on_ready(self): 43 | guild = self.bot.get_guild(233826358369845251) 44 | listing = [] 45 | if guild is None: 46 | print('Could not get mathbot guild in order to find patrons') 47 | else: 48 | for member in guild.members: 49 | tier = max(role_id_to_tier(r.id) for r in member.roles) 50 | if tier != 0: 51 | print(member, 'is tier', get_tier_name(tier)) 52 | if tier != TIER_SPECIAL: 53 | # replacement to avoid anyone putting in a link or something 54 | listing.append((member.nick or member.name).replace('.', '\N{zero width non-joiner}')) 55 | await self.bot.keystore.set('patron', str(member.id), tier, expire = 60 * 60 * 24 * 3) 56 | if listing: 57 | await self.bot.keystore.set('patron', 'listing', '\n'.join(f' - {i}' for i in sorted(listing))) 58 | 59 | 60 | def get_tier_name(tier): 61 | try: 62 | return { 63 | TIER_NONE: 'None', 64 | TIER_CONSTANT: 'Constant', 65 | TIER_QUADRATIC: 'Quadratic', 66 | TIER_EXPONENTIAL: 'Exponential', 67 | TIER_SPECIAL: 'Ackermann' 68 | }[tier] 69 | except KeyError: 70 | raise InvalidPatronRankError 71 | 72 | 73 | def role_id_to_tier(name): 74 | return { 75 | 491182624258129940: TIER_CONSTANT, 76 | 491182701806878720: TIER_QUADRATIC, 77 | 491182737026449410: TIER_EXPONENTIAL, 78 | 294413896893071365: TIER_SPECIAL, 79 | 233826884113268736: TIER_SPECIAL 80 | }.get(name, TIER_NONE) 81 | 82 | 83 | def setup(bot): 84 | return bot.add_cog(PatronModule(bot)) 85 | -------------------------------------------------------------------------------- /mathbot/queuedict.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Dictionary class whose items are deleted after a certain time. 3 | Inspired by: https://github.com/mailgun/expiringdict/blob/master/expiringdict/__init__.py 4 | however this is more agressive with deleting things. 5 | 6 | Note that it is unsafe to use the following pattern: 7 | 8 | if key in mydict: 9 | mydict[key] 10 | 11 | Since the key may be deleted between the time that it is initially checked, 12 | and when it is subsequently used. 13 | 14 | ''' 15 | 16 | import collections 17 | import time 18 | 19 | class QueueDict: 20 | 21 | def __init__(self, *, timeout=120, max_size=None): 22 | self._dict = collections.OrderedDict() 23 | self._timeout = timeout 24 | self._max_size = max_size 25 | 26 | def __contains__(self, key): 27 | self._cleanup() 28 | return key in self._dict 29 | 30 | def __setitem__(self, key, value): 31 | self._cleanup() 32 | curtime = int(time.time()) 33 | self._dict[key] = (curtime, value) 34 | self._dict.move_to_end(key, last=False) 35 | 36 | def __getitem__(self): 37 | self._cleanup() 38 | return self._dict[key][1] 39 | 40 | def __delitem__(self, key): 41 | del self._dict[key] 42 | self._cleanup() 43 | 44 | def get(self, key, default=None): 45 | self._cleanup() 46 | return self._dict.get(key, (None, default))[1] 47 | 48 | def pop(self, key, default=None): 49 | self._cleanup() 50 | return self._dict.pop(key, (None, default))[1] 51 | 52 | def _cleanup(self): 53 | while self._max_size and len(self._dict) > self._max_size: 54 | self._dict.popitem(last=True) 55 | while self._dict: 56 | curtime = int(time.time()) 57 | key, (keytime, value) = self._dict.popitem(last=True) 58 | if curtime - keytime < self._timeout: 59 | self._dict[key] = (keytime, value) 60 | self._dict.move_to_end(key, last=True) 61 | break 62 | 63 | def __str__(self): 64 | return f'QueueDict({self._dict})' 65 | -------------------------------------------------------------------------------- /mathbot/safe.py: -------------------------------------------------------------------------------- 1 | def sprint(*args, **kwargs): 2 | try: 3 | print(*args, **kwargs) 4 | except: 5 | pass -------------------------------------------------------------------------------- /mathbot/startup_queue.py: -------------------------------------------------------------------------------- 1 | from aiohttp import web 2 | import time 3 | 4 | QUEUE_INTERVAL = 10 5 | 6 | next_time_slot = 0 7 | 8 | async def request_spot_in_queue(request): 9 | global next_time_slot 10 | next_time_slot = max(time.time(), next_time_slot) + QUEUE_INTERVAL 11 | return web.Response(text=str(next_time_slot)) 12 | 13 | app = web.Application() 14 | app.add_routes([web.post('/', request_spot_in_queue)]) 15 | 16 | web.run_app(app, host='127.0.0.1', port=7023) 17 | -------------------------------------------------------------------------------- /mathbot/test: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | python3.6 -m pytest tests -rs --cov=. --cov-branch $@ 4 | 5 | -------------------------------------------------------------------------------- /mathbot/test_specific: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | python3 -m pytest -rs -p no:logging $@ 4 | 5 | -------------------------------------------------------------------------------- /mathbot/update_logo.py: -------------------------------------------------------------------------------- 1 | # Usage: 2 | # python update_logo.py parameters.json 3 | # 4 | # Will load a profile picture from logo.png 5 | 6 | 7 | import discord 8 | import asyncio 9 | from mathbot import core 10 | from mathbot import utils 11 | 12 | @utils.apply(core.parameters.load_parameters, list) 13 | def retrieve_parameters(): 14 | for i in sys.argv[1:]: 15 | if re.fullmatch(r'\w+\.env', i): 16 | yield json.loads(os.environ.get(i[:-4])) 17 | elif i.startswith('{') and i.endswith('}'): 18 | yield json.loads(i) 19 | else: 20 | with open(i) as f: 21 | yield json.load(f) 22 | 23 | parameters = retrive_parameters() 24 | 25 | client = discord.Client( 26 | shard_id=0, 27 | shard_count=parameters.shards.total 28 | ) 29 | 30 | @client.event 31 | async def on_ready(): 32 | print('Logged in as', client.user.name) 33 | with open('logo.png', 'rb') as avatar: 34 | await client.edit(avatar=avatar) 35 | 36 | print('Done updating profile picture') 37 | client.close() 38 | 39 | client.run(parameters.token) 40 | -------------------------------------------------------------------------------- /mathbot/utils.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import functools 3 | import discord 4 | from discord.ext.commands import Context 5 | import io 6 | from mathbot import core 7 | 8 | 9 | class MessageEditedException(Exception): 10 | pass 11 | 12 | 13 | class MessageEditGuard: 14 | ''' Used to handle message cleanup for commands that 15 | may be edited in order to re-invoke them. 16 | ''' 17 | 18 | def __init__(self, message, destination, bot): 19 | self._message = message 20 | self._destination = destination 21 | self._bot = bot 22 | self._initial_content = self._message.clean_content 23 | 24 | def __enter__(self): 25 | return self 26 | 27 | def __exit__(self, type, value, traceback): 28 | return isinstance(value, MessageEditedException) 29 | 30 | async def send(self, *args, **kwargs): 31 | if self._initial_content != self._message.clean_content: 32 | print('Edit guard prevented sending of message') 33 | raise MessageEditedException 34 | return await self._bot.send_patch(self._message, self._destination.send)(*args, **kwargs) 35 | 36 | async def reply(self, context: Context, *args, **kwargs): 37 | if self._initial_content != self._message.clean_content: 38 | print('Edit guard prevented sending of message') 39 | raise MessageEditedException 40 | # TODO: Handle blame 41 | return await context.reply(*args, **kwargs) 42 | 43 | 44 | def listify(function): 45 | @functools.wraps(function) 46 | def wrapper(*args, **kwargs): 47 | return list(function(*args, **kwargs)) 48 | return wrapper 49 | 50 | 51 | def apply(*functions): 52 | def decorator(internal): 53 | @functools.wraps(internal) 54 | def wrapper(*args, **kwargs): 55 | result = internal(*args, **kwargs) 56 | for i in functions[::-1]: 57 | result = i(result) 58 | return result 59 | return wrapper 60 | return decorator 61 | 62 | 63 | def err(*args, **kwargs): 64 | return print(*args, **kwargs, file=sys.stderr) 65 | 66 | 67 | def is_private(channel): 68 | return isinstance(channel, discord.abc.PrivateChannel) 69 | 70 | 71 | def image_to_discord_file(image, fname): 72 | ''' Converts a PIL image to a discord.File object, 73 | so that it may be sent over the internet. 74 | ''' 75 | fobj = io.BytesIO() 76 | image.save(fobj, format='PNG') 77 | fobj = io.BytesIO(fobj.getvalue()) 78 | return discord.File(fobj, fname) 79 | -------------------------------------------------------------------------------- /mathbot/wordfilter/__init__.py: -------------------------------------------------------------------------------- 1 | import string 2 | import itertools 3 | import os 4 | import re 5 | 6 | 7 | WORDFILE = os.path.dirname(os.path.abspath(__file__)) + '/bad_words.txt' 8 | BAD_WORD_REGEX = None 9 | 10 | 11 | with open(WORDFILE) as f: 12 | bad_words = [[word, word + 's', word + 'ed'] for word in map(str.strip, f)] 13 | BAD_WORD_REGEX = re.compile( 14 | '|'.join( 15 | '(' + i[0] + r'1.' + \ 16 | ''.join(j + '..' for j in i[1:-1]) + \ 17 | i[-1] + r'.1' + ')' 18 | for i in itertools.chain.from_iterable(bad_words) 19 | ) 20 | ) 21 | 22 | 23 | def is_bad(sentence): 24 | sentence = sentence.lower() 25 | marked_characters = [ 26 | ( sentence[i] 27 | , i == 0 or not sentence[i - 1].isalpha() 28 | , i == len(sentence) - 1 or not sentence[i + 1].isalpha() 29 | ) 30 | for i in range(len(sentence)) if sentence[i].isalpha() 31 | ] 32 | searchable_string = ''.join([ 33 | c + ('1' if start else '0') + ('1' if end else '0') 34 | for (c, start, end) in marked_characters 35 | ]) 36 | return BAD_WORD_REGEX.search(searchable_string) or complex_rules(sentence) 37 | 38 | 39 | def complex_rules(sentence): 40 | words = sentence.split() 41 | return ('rectum' in words and not {'latus', 'semilatus'} & words) 42 | -------------------------------------------------------------------------------- /mathbot/wordfilter/__main__.py: -------------------------------------------------------------------------------- 1 | # Interactive command for checkig if a word is bad 2 | # This was written for testing purposes 3 | 4 | from wordfilter import is_bad 5 | 6 | line = input('> ') 7 | while line: 8 | if is_bad(line): 9 | print('That\'s bad!') 10 | else: 11 | print('All good!') 12 | line = input('> ') 13 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp==3.8.5 2 | aioredis==1.2.0 3 | aiosignal==1.2.0 4 | async-timeout==4.0.2 5 | attrs==21.4.0 6 | cffi==1.15.0 7 | chardet==3.0.4 8 | charset-normalizer==2.1.0 9 | colorama==0.4.4 10 | discord.py==2.3.2 11 | frozenlist==1.3.1 12 | graphviz==0.20 13 | hiredis==2.0.0 14 | idna==3.3 15 | mpmath==1.3.0 16 | multidict==4.7.6 17 | objgraph==3.5.0 18 | orjson==3.6.8 19 | Pillow==9.3.0 20 | psutil==5.9.0 21 | pycparser==2.21 22 | pydantic==2.5.1 23 | sympy==1.4 24 | termcolor==1.1.0 25 | websockets==9.1 26 | xmltodict==0.12.0 27 | yarl==1.5.1 28 | -------------------------------------------------------------------------------- /runtime.txt: -------------------------------------------------------------------------------- 1 | python-3.6.3 2 | -------------------------------------------------------------------------------- /scripts/_install_deployment_deps.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd ~ 4 | 5 | set -eux 6 | 7 | export DEBIAN_FRONTEND=noninteractive 8 | export LC_ALL=C.UTF-8 9 | export LANG=C.UTF-8 10 | 11 | apt-get install curl -y 12 | curl --version 13 | 14 | apt-get install git -y 15 | git --version 16 | 17 | apt-get install software-properties-common -y 18 | apt-get update 19 | 20 | apt-add-repository ppa:deadsnakes/ppa 21 | apt-get update 22 | apt-get install python3.8 -y 23 | apt-get install python3.8-dev -y 24 | apt-get install python3-pip -y 25 | apt-get install python3.8-venv -y 26 | 27 | python3.8 -m pip install --upgrade pip 28 | 29 | curl -sL https://deb.nodesource.com/setup_12.x | bash - 30 | apt-get install nodejs -y 31 | node --version 32 | 33 | npm install pm2@4.4.0 -g 34 | 35 | apt-get install build-essential -y 36 | apt-get install jq -y 37 | 38 | apt-get autoremove -y 39 | -------------------------------------------------------------------------------- /scripts/_push_code.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eux 4 | 5 | export LC_ALL=C.UTF-8 6 | export LANG=C.UTF-8 7 | 8 | cd ~ 9 | 10 | LAST_SHARD=$(( $(jq -rM '.shards.total' config.json) - 1 )) 11 | 12 | if [ ! -d "mathbot" ]; then 13 | git clone "https://github.com/DXsmiley/mathbot.git" 14 | fi 15 | 16 | cd mathbot 17 | 18 | git checkout master 19 | git fetch 20 | git pull 21 | 22 | echo "Stopping shards" 23 | # Returns 1 if there are no processes, so we need to ignore this "error" 24 | pm2 stop all || true 25 | 26 | # export PIPENV_YES=1 27 | # pipenv install 28 | 29 | if [ ! -d ".venv" ]; then 30 | python3.8 -m venv .venv 31 | fi 32 | 33 | .venv/bin/pip install --upgrade pip 34 | .venv/bin/pip install -r requirements.txt 35 | 36 | pm2 start "./scripts/pm2_main.sh" --name "mathbot-new" -- 0 37 | -------------------------------------------------------------------------------- /scripts/cleanup_redis_store.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import re 4 | import asyncio 5 | import collections 6 | import json 7 | import aioredis 8 | import time 9 | import warnings 10 | import abc 11 | 12 | 13 | async def connect(url): 14 | user, password, host, port = re.split(r':|@', url[8:]) 15 | if password == '': 16 | password = None 17 | redis = await aioredis.create_redis_pool( 18 | (host, int(port)), 19 | password = password, 20 | db = 1, 21 | timeout = 5 22 | ) 23 | print('Connected!') 24 | return redis 25 | 26 | 27 | async def iter_all_keys(redis, match): 28 | cur = b'0' 29 | while cur: 30 | cur, keys = await redis.scan(cur, match=match) 31 | for key in keys: 32 | yield key 33 | 34 | 35 | async def groups_of(n, async_generator): 36 | group = [] 37 | async for i in async_generator: 38 | if len(group) == n: 39 | yield group 40 | group = [] 41 | group.append(i) 42 | if len(group) != 0: 43 | yield group 44 | 45 | 46 | async def purge_blame(redis): 47 | async for keys in groups_of(20, iter_all_keys(redis, 'blame:*')): 48 | print(keys) 49 | await asyncio.gather(*[redis.delete(i) for i in keys]) 50 | 51 | 52 | async def main(): 53 | redis_url = sys.argv[1] 54 | redis = await connect(redis_url) 55 | await purge_blame(redis) 56 | 57 | 58 | if __name__ == '__main__': 59 | loop = asyncio.get_event_loop() 60 | loop.run_until_complete(main()) 61 | -------------------------------------------------------------------------------- /scripts/grab_recent_logs.sh: -------------------------------------------------------------------------------- 1 | ssh mathbot "pm2 logs --lines 30" 2 | -------------------------------------------------------------------------------- /scripts/install_deployment_deps.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cd "$(dirname "${BASH_SOURCE[0]}")" 3 | ssh mathbot 'bash -s' < "./_install_deployment_deps.sh" 4 | -------------------------------------------------------------------------------- /scripts/pm2_main.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eu 4 | 5 | export LC_ALL=C.UTF-8 6 | export LANG=C.UTF-8 7 | 8 | if [ $# -ne 1 ]; 9 | then echo "illegal number of parameters" 10 | exit 1 11 | fi 12 | 13 | cd ~/mathbot 14 | 15 | REDIS_URL=$(bash ./scripts/pull_redis_creds_from_heroku.sh "../config.json") 16 | 17 | ./.venv/bin/python --version 18 | ./.venv/bin/python -m mathbot ~/config.json \ 19 | "{\"shards\": {\"mine\": [$1]}, \"keystore\": {\"redis\": {\"url\": \"${REDIS_URL}\"}}}" 20 | -------------------------------------------------------------------------------- /scripts/pull_redis_creds_from_heroku.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # This is a temporary hack to deal with Heroku rolling 4 | # the redis creds occasionally. Once the redis server 5 | # is migrated, this script can be deleted. 6 | 7 | # First and only argument: path to config file 8 | 9 | 10 | # Don't use -x here because it'll end up leaking 11 | # creds into the logs 12 | set -eu 13 | 14 | API_KEY=$(jq -rM '.reboot.heroku_key' "$1") 15 | 16 | REDIS_URL=$(curl --silent --max-time 10 -n -X GET 'https://api.heroku.com/apps/dxbot/config-vars' \ 17 | -H 'Content-Type: application/json' \ 18 | -H 'Accept: application/vnd.heroku+json; version=3' \ 19 | -H "Authorization: Bearer $API_KEY" \ 20 | | jq -j '.REDIS_URL') 21 | 22 | echo $REDIS_URL 23 | -------------------------------------------------------------------------------- /scripts/push_code.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cd "$( dirname "${BASH_SOURCE[0]}" )" 3 | ssh mathbot 'bash -s' < "./_push_code.sh" 4 | -------------------------------------------------------------------------------- /scripts/push_config.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eux 4 | 5 | if [ $# -ne 1 ]; 6 | then echo "illegal number of parameters" 7 | else 8 | scp "$1" mathbot:config.json 9 | fi 10 | -------------------------------------------------------------------------------- /scripts/startup_queue.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eu 4 | 5 | export LC_ALL=C.UTF-8 6 | export LANG=C.UTF-8 7 | 8 | cd ~/mathbot/mathbot 9 | exec pipenv run python -u startup_queue.py 10 | -------------------------------------------------------------------------------- /stats.txt: -------------------------------------------------------------------------------- 1 | Original discord.py: 2 | 3 | Member 184484 4 | User 154373 5 | KeyedRef 154223 6 | _Overwrites 149067 7 | list 88968 8 | Colour 74905 9 | Role 74745 10 | Permissions 74745 11 | TextChannel 52258 12 | Emoji 36466 13 | 14 | 15 | After stripping some fields, notably colour from role: 16 | 17 | Member 184135 18 | User 154029 19 | KeyedRef 153928 20 | _Overwrites 149019 21 | list 88201 22 | Role 74734 23 | TextChannel 52252 24 | Emoji 36469 25 | VoiceChannel 17604 26 | dict 14059 27 | 28 | 29 | After stripping custom emoji support: 30 | 31 | Member 184042 32 | User 153974 33 | KeyedRef 153862 34 | _Overwrites 149018 35 | list 91870 36 | Role 74734 37 | TextChannel 52251 38 | VoiceChannel 17604 39 | dict 14058 40 | CategoryChannel 12387 41 | 42 | After using a weakrefdict to store members 43 | 44 | _Overwrites 152474 45 | list 95298 46 | Role 76122 47 | TextChannel 53273 48 | function 26504 49 | dict 22046 50 | VoiceChannel 17799 51 | tuple 15181 52 | CategoryChannel 12570 53 | weakref 7043 54 | (on startup, number of member objects expected to grow over time) 55 | 56 | -------------------------------------------------------------------------------- /style.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0px; 3 | box-sizing: border-box; 4 | overflow-x: hidden; 5 | /*font-weight: normal;*/ 6 | color: rgb(46, 46, 46); 7 | padding: 0px; 8 | } 9 | 10 | div { 11 | box-sizing: border-box; 12 | } 13 | 14 | div.banner { 15 | font-family: 'Josefin Sans', sans-serif; 16 | width: 100vw; 17 | min-height: 100vh; 18 | height: 100vh; 19 | max-height: 700px; 20 | padding: 40px; 21 | margin: 0px; 22 | overflow: hidden; 23 | font-size: 1.5em; 24 | position: relative; 25 | } 26 | 27 | div.banner-large { 28 | min-height: 100vh; 29 | height: auto; 30 | max-height: 9000px; 31 | } 32 | 33 | div.footer { 34 | font-size: 1.5em; 35 | padding: 50px 40px; 36 | } 37 | 38 | div.center-text { 39 | text-align: center; 40 | } 41 | 42 | div.center-content { 43 | display: flex; 44 | justify-content: center; 45 | align-items: center; 46 | } 47 | 48 | div.bg-blue { 49 | background-color: rgb(28, 190, 233); 50 | } 51 | 52 | div.text-blue { 53 | color: rgb(28, 190, 233); 54 | } 55 | 56 | div.bottom { 57 | left: 0px; 58 | right: 0px; 59 | width: 100%; 60 | position: absolute; 61 | bottom: 0px; 62 | } 63 | 64 | div.bottom div { 65 | margin: 0px auto; 66 | text-align: center; 67 | } 68 | 69 | button { 70 | border: none; 71 | background-color: rgb(28, 190, 233); 72 | color: white; 73 | font-size: 1em; 74 | position: relative; 75 | /*border-radius: 10px;*/ 76 | /*bottom: 0px;*/ 77 | transition: 0.3s; 78 | transform: scale(1); 79 | cursor: pointer; 80 | } 81 | 82 | button:hover { 83 | transform: scale(1.1); 84 | /*bottom: 10px;*/ 85 | } 86 | 87 | 88 | div.navbox, div.mobile-navbox { 89 | text-align: right; 90 | position: fixed; 91 | top: 0px; 92 | right: 0px; 93 | width: 100vw; 94 | background-color: rgb(46, 46, 46); 95 | padding: 5px 8px; 96 | z-index: 1; 97 | font-size: 1.2em; 98 | } 99 | 100 | div.navbox a, div.mobile-navbox a { 101 | text-decoration: none; 102 | color: rgb(28, 190, 233); 103 | margin: 0px 10px; 104 | } 105 | 106 | div.navbox p, div.mobile-navbox p { 107 | margin: 0px; 108 | } 109 | 110 | div.mobile-navbox { 111 | display: none; 112 | } 113 | 114 | #mobile-menu { 115 | display: none; 116 | } 117 | 118 | #server-count-widgit { 119 | position: relative; 120 | top: 3px; 121 | } 122 | 123 | @media (max-width: 540px) { 124 | #server-count-widgit { 125 | display: none; 126 | } 127 | } 128 | 129 | @media (max-width: 380px) { 130 | div.navbox { 131 | display: none; 132 | } 133 | div.mobile-navbox { 134 | display: block; 135 | } 136 | } 137 | 138 | @media (min-width: 400px) { 139 | 140 | div.banner { 141 | font-size: 2em; 142 | } 143 | 144 | div.navbox { 145 | padding: 10px 30px; 146 | } 147 | 148 | div.navbox a { 149 | margin: 0px 6px; 150 | } 151 | 152 | } 153 | 154 | @media (min-width: 1600px) { 155 | 156 | div.navbox { 157 | font-size: 1.4em; 158 | } 159 | 160 | } 161 | 162 | div.navbox a:visited, div.mobile-navbox a:visited { 163 | color: rgb(28, 190, 233); 164 | } 165 | 166 | a.orange { 167 | color: rgb(233, 127, 28) !important; 168 | } 169 | 170 | img { 171 | width: auto; 172 | height: auto; 173 | max-width: calc(100vw - 20px); 174 | } 175 | 176 | #downarrow { 177 | width: 40px; 178 | height: 40px; 179 | position: relative; 180 | top: 27px; 181 | } 182 | 183 | #heart { 184 | width: 30px; 185 | height: auto; 186 | position: relative; 187 | top: 3px; 188 | } 189 | 190 | p, li { 191 | font-family: 'Titillium Web', sans-serif; 192 | } 193 | 194 | ul, li { 195 | list-style-type: '- '; 196 | } 197 | 198 | h1, h2, h3, h4, h5, h6 { 199 | font-family: 'Josefin Sans', sans-serif; 200 | } 201 | 202 | div.doc-nav, div.doc-body { 203 | font-size: 1.3em; 204 | } 205 | 206 | div.doc-nav { 207 | position: fixed; 208 | width: 200px; 209 | height: 100vh; 210 | /*padding: 30px;*/ 211 | border-right: 1px solid rgb(28, 190, 233); 212 | padding: 10px 0px; 213 | } 214 | 215 | div.doc-nav p, div.doc-nav h1 { 216 | padding: 0px 30px; 217 | } 218 | 219 | div.doc-nav a { 220 | color: rgb(28, 190, 233); 221 | text-decoration: none; 222 | } 223 | 224 | div.doc-nav p:hover { 225 | background-color: rgb(28, 190, 233); 226 | color: white; 227 | } 228 | 229 | div.doc-body { 230 | margin-left: 200px; 231 | padding: 10px 70px; 232 | } 233 | 234 | div.doc-body h1 { 235 | position: relative; 236 | left: -16px; 237 | border-left: 8px solid rgb(28, 190, 233); 238 | padding-left: 16px; 239 | } 240 | 241 | code { 242 | font-family: 'Inconsolata', monospace; 243 | border: 1px solid lightgrey; 244 | margin: 2px; 245 | padding: 2px 4px; 246 | } 247 | 248 | h2 code { 249 | border: none; 250 | } 251 | 252 | span.imp { 253 | color: #1cbee9; 254 | } 255 | 256 | div.doc-body a { 257 | color: rgb(155, 28, 233); 258 | } 259 | 260 | @media (max-width: 700px) { 261 | div.doc-nav { 262 | padding: 10px 5px; 263 | width: 160px; 264 | } 265 | div.doc-nav p, div.doc-nav h1 { 266 | padding: 0px 10px; 267 | } 268 | div.doc-body { 269 | margin-left: 160px; 270 | padding: 10px 40px; 271 | } 272 | } 273 | 274 | @media (max-width: 500px) { 275 | div.doc-nav { 276 | display: none; 277 | } 278 | div.doc-body { 279 | margin-left: 0px; 280 | padding: 10px 20px; 281 | } 282 | } 283 | 284 | button::-moz-focus-inner { 285 | border: 0; 286 | } 287 | 288 | p.float-left { 289 | float: left; 290 | } 291 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DXsmiley/mathbot/723d86ba0169823e4e076e796121af93efdd74e1/tests/__init__.py -------------------------------------------------------------------------------- /tests/_conftest.py: -------------------------------------------------------------------------------- 1 | import dismock as automata 2 | import pytest 3 | import asyncio 4 | from mathbot import core 5 | 6 | from typing import Callable 7 | 8 | def pytest_addoption(parser): 9 | parser.addoption( 10 | "--run-automata", 11 | action = "store_true", 12 | default = False, 13 | help = "Run tests reliant on the automata" 14 | ) 15 | parser.addoption( 16 | "--run-automata-human", 17 | action = "store_true", 18 | default = False, 19 | help = "Run tests reliant on the automata and human interaction" 20 | ) 21 | parser.addoption( 22 | "--parameter-file", 23 | action = "store", 24 | default = None, 25 | help = "Load parameters from a file" 26 | ) 27 | 28 | ########################################################################### 29 | ### Stuff neede to shoehorn the automata bot into pytest ################## 30 | ########################################################################### 31 | 32 | 33 | def automata_test(function: Callable[[automata.Interface], None]) \ 34 | -> Callable[[automata.DiscordBot], None]: 35 | ''' Mark a function as an automata test ''' 36 | @pytest.mark.automata 37 | @pytest.mark.second 38 | def _internal(__automata_fixture: automata.DiscordBot) -> None: 39 | channel_id = core.parameters.get('automata channel') 40 | channel = __automata_fixture.get_channel(channel_id) 41 | test = automata.Test(function.__name__, function) 42 | loop = asyncio.get_event_loop() 43 | loop.run_until_complete(__automata_fixture.run_test(test, channel)) 44 | loop.run_until_complete(asyncio.sleep(1)) 45 | _internal.__name__ = function.__name__ 46 | return _internal 47 | 48 | 49 | def automata_test_human(function: Callable[[automata.Interface], None]) \ 50 | -> Callable[[automata.DiscordBot], None]: 51 | ''' Mark a function as an automata test ''' 52 | @pytest.mark.automata 53 | @pytest.mark.needs_human 54 | @pytest.mark.first 55 | def _internal(__automata_fixture: automata.DiscordBot) -> None: 56 | if not pytest.config.getoption('--run-automata-human'): 57 | pytest.skip('Needs --run-automata-human command line option to run human-interaction automata tests.') 58 | channel_id = core.parameters.get('automata channel') 59 | channel = __automata_fixture.get_channel(channel_id) 60 | test = automata.Test(function.__name__, function) 61 | loop = asyncio.get_event_loop() 62 | loop.run_until_complete(__automata_fixture.run_test(test, channel)) 63 | loop.run_until_complete(asyncio.sleep(1)) 64 | _internal.__name__ = function.__name__ 65 | return _internal 66 | 67 | 68 | @pytest.fixture(scope='session') 69 | def __automata_fixture(): 70 | 71 | if not pytest.config.getoption('--run-automata'): 72 | pytest.skip('Needs --run-automata command line option to run automata tests.') 73 | 74 | param_file = pytest.config.getoption('--parameter-file') 75 | if param_file is None: 76 | pytest.skip('Needs a specified --parameter-file to run automata tests.') 77 | 78 | loop = asyncio.get_event_loop() 79 | 80 | # TODO: Make this better 81 | core.parameters.reset() 82 | core.parameters.add_source_filename(param_file) 83 | core.parameters.add_source({ 84 | 'keystore': { 85 | 'mode': 'disk', 86 | 'disk': { 87 | 'filename': None 88 | } 89 | } 90 | }) 91 | 92 | token = core.parameters.get('automata token') 93 | target = core.parameters.get('automata target') 94 | channel = core.parameters.get('automata channel') 95 | 96 | if not token: 97 | pytest.skip('No [automata token] specified.') 98 | if not target: 99 | pytest.skip('No [automata target] specified.') 100 | if not channel: 101 | pytest.skip('No [automata channel] specified.') 102 | 103 | try: 104 | 105 | from mathbot import bot 106 | if not bot.DONE_SETUP: 107 | bot.do_setup() 108 | 109 | manager = bot.create_shard_manager(0, 1) 110 | loop.create_task(manager.run_async()) 111 | auto = automata.DiscordBot(target) 112 | loop.create_task(auto.start(token)) 113 | loop.run_until_complete(manager.client.wait_until_ready()) 114 | loop.run_until_complete(auto.wait_until_ready()) 115 | 116 | yield auto 117 | 118 | loop.run_until_complete(asyncio.sleep(1)) 119 | loop.run_until_complete(manager.shutdown()) 120 | loop.run_until_complete(auto.logout()) 121 | loop.run_until_complete(asyncio.sleep(1)) 122 | 123 | finally: 124 | 125 | for task in asyncio.Task.all_tasks(): 126 | task.cancel() 127 | loop.close() 128 | 129 | core.parameters.reset() 130 | -------------------------------------------------------------------------------- /tests/_test_using_automata.py: -------------------------------------------------------------------------------- 1 | ''' Test the bot by running an instance of it and interfacing 2 | with it through Discord itself. 3 | ''' 4 | 5 | # pylint: disable=missing-docstring 6 | 7 | import asyncio 8 | from conftest import automata_test, automata_test_human 9 | 10 | # @auto.setup() 11 | # async def setup(interface): 12 | # await interface.wait_for_reply('=set channel f-delete-tex original') 13 | # await interface.wait_for_reply('=set server f-delete-tex original') 14 | # await interface.wait_for_reply('=set channel f-inline-tex original') 15 | # await interface.wait_for_reply('=set server f-inline-tex original') 16 | # await interface.wait_for_reply('=set channel f-calc-shortcut original') 17 | # await interface.wait_for_reply('=set server f-calc-shortcut original') 18 | 19 | 20 | # @automata_test 21 | # async def test_sample(interface): 22 | # assert True 23 | 24 | 25 | @automata_test 26 | async def test_addition(interface): 27 | await interface.assert_reply_equals('=calc 2+2', '4') 28 | 29 | 30 | @automata_test 31 | async def test_calc_shortcut(interface): 32 | await interface.assert_reply_equals('== 8 * 9', '72') 33 | 34 | 35 | @automata_test 36 | async def test_calc_settings(interface): 37 | await interface.assert_reply_equals('== 2*3', '6') 38 | await interface.assert_reply_contains('=set channel f-calc-shortcut disable', 'applied') 39 | await interface.send_message('== 5-3') 40 | await interface.ensure_silence() 41 | await interface.assert_reply_contains('=set channel f-calc-shortcut original', 'applied') 42 | await interface.assert_reply_equals('== 8/2', '4') 43 | 44 | 45 | @automata_test 46 | async def test_permissions(interface): 47 | err = 'That command may not be used in this location.' 48 | await interface.assert_reply_contains('=set channel c-calc original', 'applied') 49 | await interface.assert_reply_contains('=set server c-calc original', 'applied') 50 | await interface.assert_reply_equals('=calc 1+1', '2') 51 | await interface.assert_reply_contains('=set channel c-calc disable', 'applied') 52 | await interface.assert_reply_equals('=calc 1+1', err) 53 | await interface.assert_reply_contains('=set server c-calc disable', 'applied') 54 | await interface.assert_reply_equals('=calc 1+1', err) 55 | await interface.assert_reply_contains('=set channel c-calc enable', 'applied') 56 | await interface.assert_reply_equals('=calc 1+1', '2') 57 | await interface.assert_reply_contains('=set channel c-calc original', 'applied') 58 | await interface.assert_reply_equals('=calc 1+1', err) 59 | await interface.assert_reply_contains('=set server c-calc original', 'applied') 60 | await interface.assert_reply_equals('=calc 1+1', '2') 61 | 62 | 63 | @automata_test 64 | async def test_supress_permission_warning(interface): 65 | err = 'That command may not be used in this location.' 66 | await interface.assert_reply_equals('=calc 1+1', '2') 67 | await interface.assert_reply_contains('=set channel c-calc disable', 'applied') 68 | await interface.assert_reply_contains('=calc 1+1', err) 69 | await interface.assert_reply_contains('=set channel m-disabled-cmd disable', 'applied') 70 | await interface.send_message('=calc 1+1') 71 | await interface.ensure_silence() 72 | await interface.assert_reply_contains('=set channel c-calc original', 'applied') 73 | await interface.assert_reply_contains('=set channel m-disabled-cmd original', 'applied') 74 | 75 | 76 | @automata_test 77 | async def test_latex(interface): 78 | for message in ['=tex Hello', '=tex\nHello', '=tex `Hello`']: 79 | await interface.send_message(message) 80 | response = await interface.wait_for_message() 81 | assert len(response.attachments) == 1 82 | 83 | 84 | @automata_test_human 85 | async def test_latex_settings(interface): 86 | await interface.send_message('=theme light') 87 | await interface.wait_for_message() 88 | await interface.send_message(r'=tex \text{This should be light}') 89 | response = await interface.wait_for_message() 90 | assert len(response.attachments) == 1 91 | await interface.ask_human('Is the above result *light*?') 92 | await interface.send_message('=theme dark') 93 | await interface.wait_for_message() 94 | await interface.send_message(r'=tex \text{This should be dark}') 95 | response = await interface.wait_for_message() 96 | assert len(response.attachments) == 1 97 | await interface.ask_human('Is the above result *dark*?') 98 | 99 | 100 | @automata_test_human 101 | async def test_latex_inline(interface): 102 | await interface.wait_for_reply('=set channel f-inline-tex enable') 103 | await interface.wait_for_reply('Testing $$x^2$$ Testing') 104 | await interface.ask_human('Does the above image say `Testing x^2 Testing`?') 105 | 106 | 107 | # @automata_test_human 108 | # async def test_latex_edit(interface): 109 | # command = await interface.send_message('=tex One') 110 | # result = await interface.wait_for_message() 111 | # await interface.ask_human('Does the above message have an image that says `One`?') 112 | # command = await interface.edit_message(command, '=tex Two') 113 | # await interface.wait_for_delete(result) 114 | # await interface.wait_for_message() 115 | # await interface.ask_human('Does the above message have an image that says `Two`?') 116 | 117 | 118 | @automata_test 119 | async def test_wolfram_simple(interface): 120 | await interface.send_message('=wolf hello') 121 | num_images = 0 122 | while True: 123 | result = await interface.wait_for_message() 124 | if result.content != '': 125 | break 126 | num_images += 1 127 | await interface.ensure_silence() 128 | assert num_images > 0 129 | 130 | 131 | @automata_test 132 | async def test_wolfram_pup_simple(interface): 133 | await interface.send_message('=pup solve (x + 3)(2x - 5)') 134 | assert (await interface.wait_for_message()).content == '' 135 | assert (await interface.wait_for_message()).content != '' 136 | await interface.ensure_silence() 137 | 138 | 139 | @automata_test 140 | async def test_wolfram_no_data(interface): 141 | await interface.send_message('=wolf cos(x^x) = sin(y^y)') 142 | result = await interface.wait_for_message() 143 | assert result.content != '' 144 | await interface.ensure_silence() 145 | 146 | 147 | @automata_test 148 | async def test_error_throw(interface): 149 | await interface.assert_reply_contains('=throw', 'Something went wrong') 150 | 151 | 152 | @automata_test 153 | async def test_calc5_storage(interface): 154 | ''' This will fail if there's a history in this channel, or any libraries ''' 155 | await interface.send_message('=calc x = 3') 156 | await asyncio.sleep(1) 157 | await interface.assert_reply_equals('=calc x ^ x', '27') 158 | 159 | 160 | @automata_test 161 | async def test_calc5_timeout(interface): 162 | await interface.send_message( 163 | '=calc f = (x, h) -> if (h - 40, f(x * 2, h + 1) + f(x * 2 + 1, h + 1), 0)') 164 | await asyncio.sleep(1) 165 | await interface.assert_reply_equals('=calc f(1, 1)', 'Operation timed out') 166 | 167 | 168 | @automata_test 169 | async def test_calc5_token_failure(interface): 170 | await interface.assert_reply_contains('=calc 4 @ 5', 'Tokenization error') 171 | 172 | 173 | @automata_test 174 | async def test_calc5_syntax_failure(interface): 175 | await interface.assert_reply_contains('=calc 4 + 6 -', 'Parse error') 176 | 177 | 178 | @automata_test 179 | async def test_dice_rolling(interface): 180 | for i in ['', 'x', '1 2 3', '3d']: 181 | await interface.assert_reply_contains('=roll ' + i, 'Format your rolls like') 182 | for i in ['1000000', '100001', '800000 d 1', '500000 500000']: 183 | message = await interface.wait_for_reply('=roll ' + i) 184 | assert 'went wrong' not in message.content 185 | for i in ['6', 'd6', '1d6', '1 6', '1 d 6']: 186 | message = await interface.wait_for_reply('=roll ' + i) 187 | assert int(message.content[2]) in range(1, 7) 188 | -------------------------------------------------------------------------------- /tests/test_calc_helpers.py: -------------------------------------------------------------------------------- 1 | from mathbot import calculator 2 | import pytest 3 | import math 4 | import cmath 5 | import sympy 6 | import collections.abc 7 | 8 | from random import randint 9 | 10 | TIMEOUT = 30000 11 | 12 | class Ignore: pass 13 | 14 | def check_string(list, expected): 15 | if not isinstance(expected, str): 16 | return False 17 | assert isinstance(list, calculator.functions.ListBase) 18 | assert len(list) == len(expected) 19 | for g, c in zip(list, expected): 20 | assert g.value == c 21 | return True 22 | 23 | def doit(equation, expected, use_runtime=False): 24 | result = calculator.calculate(equation, tick_limit = TIMEOUT, use_runtime=use_runtime, trace=False) 25 | assert expected is Ignore \ 26 | or isinstance(result, calculator.functions.Glyph) and result.value == expected \ 27 | or check_string(result, expected) \ 28 | or result is None and expected is None \ 29 | or isinstance(result, sympy.boolalg.BooleanAtom) and bool(result) == expected \ 30 | or sympy.simplify(result - expected) == 0 31 | 32 | def asrt(equation): 33 | dort(equation, True) 34 | 35 | def dort(equation, expected): 36 | doit(equation, expected, use_runtime=True) 37 | 38 | def doformatted(equation, expected): 39 | result = calculator.calculate(equation, tick_limit = TIMEOUT) 40 | formatted = calculator.formatter.format(result) 41 | assert formatted == expected 42 | 43 | def repeat(equation, start, end): 44 | for _ in range(20): 45 | r = calculator.calculate(equation, tick_limit = TIMEOUT) 46 | assert start <= r <= end 47 | 48 | def throws(equation): 49 | with pytest.raises(calculator.errors.EvaluationError): 50 | calculator.calculate(equation, tick_limit = TIMEOUT, use_runtime=True) 51 | 52 | def tokenization_fail(equation): 53 | with pytest.raises(calculator.parser.TokenizationFailed): 54 | calculator.calculate(equation, tick_limit = TIMEOUT) 55 | 56 | def compile_fail(equation): 57 | with pytest.raises(calculator.errors.CompilationError): 58 | calculator.calculate(equation, tick_limit = TIMEOUT) 59 | 60 | def parse_fail(equation): 61 | with pytest.raises(calculator.parser.ParseFailed): 62 | calculator.calculate(equation, tick_limit = TIMEOUT) 63 | 64 | def gen_random_deep_list(i=5): 65 | l = [0] 66 | 67 | for _ in range(i): 68 | l.append(gen_random_deep_list(randint(0,i - 1))) 69 | 70 | return l 71 | 72 | def flatten(x): 73 | ''' 74 | Flattens an irregularly nested list of lists. 75 | https://stackoverflow.com/a/2158522 76 | ''' 77 | if isinstance(x, collections.abc.Iterable): 78 | return [a for i in x for a in flatten(i)] 79 | else: 80 | return [x] 81 | 82 | 83 | def joinit(iterable, delimiter): 84 | ''' 85 | Interleave an element into an iterable. 86 | https://stackoverflow.com/a/5656097 87 | ''' 88 | it = iter(iterable) 89 | yield next(it) 90 | for x in it: 91 | yield delimiter 92 | yield x -------------------------------------------------------------------------------- /tests/test_calc_library.py: -------------------------------------------------------------------------------- 1 | import operator 2 | import pytest 3 | from random import randint 4 | 5 | from tests.test_calc_helpers import * 6 | 7 | def list_of(thetype, min_items=0, max_items=20): 8 | return pytest.list_of(thetype, 9 | min_items=min_items, 10 | max_items=max_items) 11 | 12 | def _test_bin_op(name, func): 13 | @pytest.mark.randomize(a=int, b=int) 14 | def _internal(a, b): 15 | dort(f'{name}({a}, {b})', func(a, b)) 16 | return _internal 17 | 18 | test_sum = _test_bin_op('sum', operator.add) 19 | test_mul = _test_bin_op('mul', operator.mul) 20 | test_dif = _test_bin_op('dif', operator.sub) 21 | test_dif = _test_bin_op('max', max) 22 | test_dif = _test_bin_op('min', min) 23 | 24 | @pytest.mark.randomize(x=list_of(int)) 25 | def test_reverse_length(x): 26 | asrt(f'length(reverse({x})) == length({x})') 27 | 28 | @pytest.mark.randomize(x=list_of(int)) 29 | def test_reverse_twice(x): 30 | asrt(f'reverse(reverse({x})) == {x}') 31 | 32 | @pytest.mark.randomize(x=list_of(int)) 33 | def test_reverse_maintains_elements(x): 34 | asrt(f'sort(reverse({x})) == sort({x})') 35 | 36 | @pytest.mark.randomize(x=list_of(int)) 37 | def test_sort_length(x): 38 | asrt(f'length(sort({x})) == length({x})') 39 | 40 | @pytest.mark.randomize(x=list_of(int)) 41 | def test_sort_multiapplication(x): 42 | asrt(f'sort(sort({x})) == sort({x})') 43 | 44 | @pytest.mark.randomize(x=list_of(int)) 45 | def test_sort_sorted(x): 46 | asrt(fr''' 47 | sorted(k) = if(length(k) <= 1, true, ('k <= '\k) && sorted(\k)) 48 | sorted(sort({x})) 49 | ''') 50 | 51 | @pytest.mark.randomize(x=list_of(int), y=list_of(int)) 52 | def test_zip_length(x, y): 53 | asrt(f'length(zip({x}, {y})) == min(length({x}), length({y}))') 54 | 55 | @pytest.mark.randomize(n=int, min_num=0, max_num=20) 56 | def test_repeat_length(n): 57 | asrt(f'length(repeat(true {n})) == {n}') 58 | 59 | @pytest.mark.randomize(n=int, v=int, min_num=0, max_num=20) 60 | def test_repeat_correct_values(n, v): 61 | asrt(f'foldl(and true map((x) -> x == {v}, repeat({v} {n})))') 62 | 63 | @pytest.mark.randomize(v=int, l=list_of(int)) 64 | def test_interleave_correct(v, l): 65 | asrt(f'interleave({v}, {l}) == {list(joinit(l, v))}') 66 | 67 | def test_flatten_list(): 68 | l = gen_random_deep_list() 69 | 70 | asrt(f'flatten({l}) == {list(flatten(l))}') 71 | 72 | @pytest.mark.randomize(l=list_of(int)) 73 | def test_in_for_list(l): 74 | if len(l) == 0: 75 | return 76 | 77 | x = l[randint(0, len(l) - 1)] 78 | asrt(f'in({l}, {x})') 79 | asrt(f'!in({l}, ;h)') 80 | 81 | def test_assoc_create_get(): 82 | asrt(''' 83 | ass = foldr((a,b) -> assoc(b, a, a), [], range(1,10)), 84 | ass_bool = map(a -> get(ass, a) == a, range(1,10)), 85 | foldr((a,b) -> a && b, true, ass_bool) 86 | ''') 87 | 88 | def test_assoc_remove(): 89 | asrt(''' 90 | ass = foldr((a,b) -> assoc(b, a, a), [], range(1,10)), 91 | ass_removed = foldr((a,b) -> aremove(b, a), ass, range(1,10)), 92 | ass_removed == [] && sort(ass) == zip(range(1,10), range(1,10)) 93 | ''') 94 | 95 | def test_assoc_values_keys(): 96 | asrt(''' 97 | ass = foldr((a,b) -> assoc(b, a, a), [], range(1,10)), 98 | sort(values(ass)) == range(1,10), 99 | sort(keys(ass)) == range(1,10) 100 | ''') 101 | 102 | def test_assoc_update(): 103 | asrt(''' 104 | ass = foldr((a,b) -> assoc(b, a, a), [], range(1,10)), 105 | ass_squared = values(update(ass, 5, x -> x * x)), 106 | sort(ass_squared) == [1,2,3,4,6,7,8,9,25] 107 | ''') 108 | 109 | def test_set_equals(): 110 | asrt(''' 111 | f = () -> 2, 112 | set_equals([5,1,"hi",;b,0,true,f],[;b,f,"hi",1,true,5,0]) && 113 | !set_equals([5,1,"hi",;b,0,true,f],[;c,f,"hi",1,true,5,0]) 114 | ''') 115 | 116 | def test_set_create(): 117 | asrt(''' 118 | lst1 = [2,1,3,7,1,2,1,5,2,3], 119 | lst2 = [1,2,3,5,7,1,2,3,5,7], 120 | sort(to_set(lst1)) == sort(to_set(lst2)) 121 | ''') -------------------------------------------------------------------------------- /tests/test_core_help.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.fixture(scope = 'function') 5 | def chelp(): 6 | from mathbot import core 7 | yield core.help 8 | core.help.TOPICS = {} 9 | 10 | 11 | def test_simple(chelp): 12 | chelp.add('topic', 'message') 13 | assert chelp.get('topic') == ['message'] 14 | 15 | 16 | def test_empty(chelp): 17 | assert chelp.get('topic') == None 18 | 19 | 20 | def test_duplicate(chelp): 21 | chelp.add('topic', 'message') 22 | with pytest.raises(chelp.DuplicateTopicError): 23 | chelp.add('topic', 'message') 24 | 25 | 26 | def test_array(chelp): 27 | chelp.add('topic', ['one', 'two']) 28 | assert chelp.get('topic') == ['one', 'two'] 29 | 30 | 31 | def test_multiple_topics(chelp): 32 | chelp.add('one two three', 'message') 33 | assert chelp.get('one') == ['message'] 34 | assert chelp.get('two') == ['message'] 35 | assert chelp.get('three') == ['message'] 36 | 37 | 38 | # TODO: Implement this 39 | # Will need tests to check the various special functions as well 40 | def test_load_from_file(chelp): 41 | pass 42 | -------------------------------------------------------------------------------- /tests/test_parameters.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import os 3 | 4 | from mathbot.core.parameters import load_parameters 5 | 6 | @pytest.mark.xfail 7 | def test_parameters(): 8 | 9 | os.environ['core_parameter_test'] = 'result' 10 | 11 | parameters = load_parameters([ 12 | {'test-1': 'first'}, 13 | {'test-1': 'second'}, 14 | {'test-1': 'third'}, 15 | {'test-2': 'value'}, 16 | {'test-2': {'dict': True}}, 17 | {'test-3': 'env:core_parameter_test'}, 18 | {'test-4': 'escape:string'} 19 | ]) 20 | 21 | assert parameters.get('test-1') == 'third' 22 | assert parameters.get('test-2') == {'dict': True} 23 | assert parameters.get('test-2.dict') == True 24 | assert parameters.get('test-3') == 'result' 25 | assert parameters.get('test-4') == 'string' 26 | -------------------------------------------------------------------------------- /tests/test_safe_print.py: -------------------------------------------------------------------------------- 1 | from mathbot import safe 2 | 3 | def test_sprint_working(capsys): 4 | safe.sprint('Hello, world!') 5 | captured = capsys.readouterr() 6 | assert captured.out == 'Hello, world!\n' 7 | safe.sprint('One', end='') 8 | safe.sprint('Two') 9 | captured = capsys.readouterr() 10 | assert captured.out == 'OneTwo\n' 11 | safe.sprint('A', 'B', 'C') 12 | captured = capsys.readouterr() 13 | assert captured.out == 'A B C\n' 14 | 15 | class ThrowOnPrint: 16 | def __repr__(self): 17 | raise Exception 18 | 19 | def test_sprint_throwing(): 20 | safe.sprint(ThrowOnPrint()) 21 | -------------------------------------------------------------------------------- /tests/test_supress_params.py: -------------------------------------------------------------------------------- 1 | from mathbot import core 2 | core.parameters.PREVENT_ARG_PARSING = True 3 | -------------------------------------------------------------------------------- /tests/test_wordfilter.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from mathbot.wordfilter import is_bad 3 | 4 | def test_friendly(): 5 | assert not is_bad('Hello, world!') 6 | assert not is_bad('THESE WORDS ARE FRIENDLY') 7 | 8 | def test_malicious(): 9 | assert is_bad('CRAP') 10 | assert is_bad('oh fuck this') 11 | assert is_bad('This is shit.') 12 | 13 | def test_esoteric(): 14 | assert is_bad('\u200Bfuck') 15 | assert is_bad('sh\u200Bit') 16 | --------------------------------------------------------------------------------