├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── bot.py ├── core ├── config.py ├── db.py ├── irc.py ├── main.py └── reload.py ├── docs ├── Configuration.md ├── Home.md ├── Installation.md ├── Plugin-development.md └── rfc │ ├── ctcp.txt │ ├── rfc1459_irc.txt │ ├── rfc2810_architecture.txt │ ├── rfc2811_channelmanagement.txt │ ├── rfc2812_ircclient.txt │ └── rfc2813_ircserver.txt ├── plugins ├── bf.py ├── bitcoin.py ├── cdecl.py ├── chatgpt.py ├── choose.py ├── crowdcontrol.py ├── crypto.py ├── dice.py ├── dictionary.py ├── down.py ├── eval.py ├── frinkiac.py ├── gcalc.py ├── gif.py ├── google.py ├── hackernews.py ├── hash.py ├── help.py ├── imdb.py ├── lastfm.py ├── log.py ├── mastodon.py ├── mem.py ├── metacritic.py ├── misc.py ├── mtg.py ├── pre.py ├── quote.py ├── religion.py ├── remember.py ├── rottentomatoes.py ├── seen.py ├── sieve.py ├── snopes.py ├── somethingawful.py ├── soundcloud.py ├── stock.py ├── suggest.py ├── tag.py ├── tarot.py ├── tell.py ├── tinyurl.py ├── translate.py ├── tvdb.py ├── twitter.py ├── urlhistory.py ├── util │ ├── __init__.py │ ├── hook.py │ ├── http.py │ ├── timesince.py │ └── urlnorm.py ├── vimeo.py ├── weather.py ├── wikipedia.py ├── wolframalpha.py └── youtube.py ├── requirements.txt ├── test ├── __main__.py ├── helpers.py └── plugins │ ├── __init__.py │ ├── test_bf.py │ ├── test_bitcoin.py │ ├── test_bitcoin │ ├── bitcoin.json │ └── ethereum.json │ ├── test_cdecl.py │ ├── test_choose.py │ ├── test_crowdcontrol.py │ ├── test_crypto.py │ ├── test_crypto │ ├── crypto_btc_to_btc.json │ ├── crypto_btc_to_cad.json │ ├── crypto_btc_to_default.json │ ├── crypto_btc_to_eth.json │ ├── crypto_btc_to_invalid.json │ ├── crypto_eth_to_default.json │ ├── crypto_invalid_to_usd.json │ └── example.json │ ├── test_dice.py │ ├── test_hackernews.py │ ├── test_hackernews │ ├── 9943431.json │ ├── 9943897.json │ └── 9943987.json │ ├── test_twitter.py │ └── test_twitter │ ├── 1014260007771295745.json │ ├── hashtag_nyc.json │ └── user_loneblockbuster.json └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | .*.swp 2 | *.pyc 3 | *.orig 4 | .tox/ 5 | persist 6 | config 7 | config.json 8 | pep8.py 9 | .project 10 | .pydevproject 11 | *.db 12 | web 13 | .vscode/ 14 | .idea/ 15 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | dist: xenial 3 | language: python 4 | python: 5 | - "2.7" 6 | - "3.7" 7 | install: pip install tox-travis 8 | script: tox 9 | 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Skybot 2 | 3 | ## Goals 4 | * simplicity 5 | * little boilerplate 6 | * minimal magic 7 | * power 8 | * multithreading 9 | * automatic reloading 10 | * extensibility 11 | 12 | # Features 13 | * Multithreaded dispatch and the ability to connect to multiple networks at a time. 14 | * Easy plugin development with automatic reloading and a simple hooking API. 15 | 16 | # Requirements 17 | To install dependencies, run: 18 | 19 | pip install -r requirements.txt 20 | 21 | Skybot runs on Python 2.7 and Python 3.7. 22 | 23 | ## License 24 | Skybot is public domain. If you find a way to make money using it, I'll be very impressed. 25 | 26 | See LICENSE for precise terms. 27 | -------------------------------------------------------------------------------- /bot.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from __future__ import print_function 4 | import os 5 | import queue 6 | import sys 7 | import traceback 8 | import time 9 | 10 | 11 | class Bot: 12 | def __init__(self): 13 | self.conns = {} 14 | self.persist_dir = os.path.abspath("persist") 15 | if not os.path.exists(self.persist_dir): 16 | os.mkdir(self.persist_dir) 17 | 18 | 19 | bot = Bot() 20 | 21 | 22 | def main(): 23 | sys.path += ["plugins"] # so 'import hook' works without duplication 24 | sys.path += ["lib"] 25 | os.chdir( 26 | os.path.dirname(__file__) or "." 27 | ) # do stuff relative to the install directory 28 | 29 | print("Loading plugins") 30 | 31 | # bootstrap the reloader 32 | eval( 33 | compile( 34 | open(os.path.join("core", "reload.py"), "r").read(), 35 | os.path.join("core", "reload.py"), 36 | "exec", 37 | ), 38 | globals(), 39 | ) 40 | reload(init=True) 41 | 42 | print("Connecting to IRC") 43 | 44 | try: 45 | config() 46 | if not hasattr(bot, "config"): 47 | exit() 48 | except Exception as e: 49 | print("ERROR: malformed config file:", e) 50 | traceback.print_exc() 51 | sys.exit() 52 | 53 | print("Running main loop") 54 | 55 | while True: 56 | reload() # these functions only do things 57 | config() # if changes have occurred 58 | 59 | for conn in bot.conns.values(): 60 | try: 61 | out = conn.out.get_nowait() 62 | main(conn, out) 63 | except queue.Empty: 64 | pass 65 | while all(conn.out.empty() for conn in iter(bot.conns.values())): 66 | time.sleep(0.1) 67 | 68 | 69 | if __name__ == "__main__": 70 | main() 71 | -------------------------------------------------------------------------------- /core/config.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | import inspect 3 | import json 4 | import os 5 | 6 | 7 | def find_config(): 8 | # for backwards compatibility, look for either 'config' or 'config.json' 9 | if os.path.exists("config"): 10 | return "config" 11 | return "config.json" 12 | 13 | 14 | def save(conf): 15 | json.dump(conf, open(find_config(), "w"), sort_keys=True, indent=2) 16 | 17 | 18 | if not os.path.exists(find_config()): 19 | open("config.json", "w").write( 20 | inspect.cleandoc( 21 | r""" 22 | { 23 | "connections": 24 | { 25 | "local irc": 26 | { 27 | "server": "localhost", 28 | "nick": "skybot", 29 | "channels": ["#test"] 30 | } 31 | }, 32 | "prefix": ".", 33 | "disabled_plugins": [], 34 | "disabled_commands": [], 35 | "acls": {}, 36 | "api_keys": {}, 37 | "censored_strings": 38 | [ 39 | "DCC SEND", 40 | "1nj3ct", 41 | "thewrestlinggame", 42 | "startkeylogger", 43 | "hybux", 44 | "\\0", 45 | "\\x01", 46 | "!coz", 47 | "!tell /x" 48 | ] 49 | }""" 50 | ) 51 | + "\n" 52 | ) 53 | 54 | 55 | def config(): 56 | # reload config from file if file has changed 57 | config_mtime = os.stat(find_config()).st_mtime 58 | if bot._config_mtime != config_mtime: 59 | try: 60 | bot.config = json.load(open(find_config())) 61 | bot._config_mtime = config_mtime 62 | 63 | for name, conf in bot.config["connections"].items(): 64 | conf.setdefault( 65 | "censored_strings", bot.config.get("censored_strings", []) 66 | ) 67 | 68 | if name in bot.conns: 69 | bot.conns[name].set_conf(conf) 70 | else: 71 | if conf.get("ssl"): 72 | bot.conns[name] = SSLIRC(conf) 73 | else: 74 | bot.conns[name] = IRC(conf) 75 | except ValueError as e: 76 | print("ERROR: malformed config!", e) 77 | 78 | 79 | bot._config_mtime = 0 80 | -------------------------------------------------------------------------------- /core/db.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sqlite3 3 | 4 | 5 | def get_db_connection(conn, name=""): 6 | "returns an sqlite3 connection to a persistent database" 7 | 8 | if not name: 9 | name = "%s.%s.db" % (conn.nick, conn.server_host) 10 | 11 | filename = os.path.join(bot.persist_dir, name) 12 | return sqlite3.connect(filename, timeout=10) 13 | 14 | 15 | bot.get_db_connection = get_db_connection 16 | -------------------------------------------------------------------------------- /core/main.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | from builtins import str 3 | from builtins import range 4 | from builtins import object 5 | import re 6 | import _thread 7 | import traceback 8 | from queue import Queue 9 | from future.builtins import str 10 | 11 | _thread.stack_size(1024 * 512) # reduce vm size 12 | 13 | 14 | class Input(dict): 15 | def __init__( 16 | self, conn, raw, prefix, command, params, nick, user, host, paraml, msg 17 | ): 18 | 19 | server = conn.server_host 20 | 21 | chan = paraml[0].lower() 22 | if chan == conn.nick.lower(): # is a PM 23 | chan = nick 24 | 25 | def say(msg): 26 | conn.msg(chan, msg) 27 | 28 | def reply(msg): 29 | if chan == nick: # PMs don't need prefixes 30 | self.say(msg) 31 | else: 32 | self.say(nick + ": " + msg) 33 | 34 | def pm(msg, nick=nick): 35 | conn.msg(nick, msg) 36 | 37 | def set_nick(nick): 38 | conn.set_nick(nick) 39 | 40 | def me(msg): 41 | self.say("\x01%s %s\x01" % ("ACTION", msg)) 42 | 43 | def notice(msg): 44 | conn.cmd("NOTICE", [nick, msg]) 45 | 46 | def kick(target=None, reason=None): 47 | conn.cmd("KICK", [chan, target or nick, reason or ""]) 48 | 49 | def ban(target=None): 50 | conn.cmd("MODE", [chan, "+b", target or host]) 51 | 52 | def unban(target=None): 53 | conn.cmd("MODE", [chan, "-b", target or host]) 54 | 55 | dict.__init__( 56 | self, 57 | conn=conn, 58 | raw=raw, 59 | prefix=prefix, 60 | command=command, 61 | params=params, 62 | nick=nick, 63 | user=user, 64 | host=host, 65 | paraml=paraml, 66 | msg=msg, 67 | server=server, 68 | chan=chan, 69 | notice=notice, 70 | say=say, 71 | reply=reply, 72 | pm=pm, 73 | bot=bot, 74 | kick=kick, 75 | ban=ban, 76 | unban=unban, 77 | me=me, 78 | set_nick=set_nick, 79 | lastparam=paraml[-1], 80 | ) 81 | 82 | # make dict keys accessible as attributes 83 | def __getattr__(self, key): 84 | return self[key] 85 | 86 | def __setattr__(self, key, value): 87 | self[key] = value 88 | 89 | 90 | def run(func, input): 91 | args = func._args 92 | 93 | if "inp" not in input: 94 | input.inp = input.paraml 95 | 96 | if args: 97 | if "db" in args and "db" not in input: 98 | input.db = get_db_connection(input.conn) 99 | if "input" in args: 100 | input.input = input 101 | if 0 in args: 102 | out = func(input.inp, **input) 103 | else: 104 | kw = dict((key, input[key]) for key in args if key in input) 105 | out = func(input.inp, **kw) 106 | else: 107 | out = func(input.inp) 108 | if out is not None: 109 | input.reply(str(out)) 110 | 111 | 112 | def do_sieve(sieve, bot, input, func, type, args): 113 | try: 114 | return sieve(bot, input, func, type, args) 115 | except Exception: 116 | print("sieve error", end=" ") 117 | traceback.print_exc() 118 | return None 119 | 120 | 121 | class Handler(object): 122 | 123 | """Runs plugins in their own threads (ensures order)""" 124 | 125 | def __init__(self, func): 126 | self.func = func 127 | self.input_queue = Queue() 128 | _thread.start_new_thread(self.start, ()) 129 | 130 | def start(self): 131 | uses_db = "db" in self.func._args 132 | db_conns = {} 133 | while True: 134 | input = self.input_queue.get() 135 | 136 | if input == StopIteration: 137 | break 138 | 139 | if uses_db: 140 | db = db_conns.get(input.conn) 141 | if db is None: 142 | db = bot.get_db_connection(input.conn) 143 | db_conns[input.conn] = db 144 | input.db = db 145 | 146 | try: 147 | run(self.func, input) 148 | except: 149 | traceback.print_exc() 150 | 151 | def stop(self): 152 | self.input_queue.put(StopIteration) 153 | 154 | def put(self, value): 155 | self.input_queue.put(value) 156 | 157 | 158 | def dispatch(input, kind, func, args, autohelp=False): 159 | for (sieve,) in bot.plugs["sieve"]: 160 | input = do_sieve(sieve, bot, input, func, kind, args) 161 | if input == None: 162 | return 163 | 164 | if ( 165 | autohelp 166 | and args.get("autohelp", True) 167 | and not input.inp 168 | and func.__doc__ is not None 169 | ): 170 | input.reply(func.__doc__) 171 | return 172 | 173 | if hasattr(func, "_apikeys"): 174 | bot_keys = bot.config.get("api_keys", {}) 175 | keys = {key: bot_keys.get(key) for key in func._apikeys} 176 | 177 | missing = [keyname for keyname, value in keys.items() if value is None] 178 | if missing: 179 | input.reply("error: missing api keys - {}".format(missing)) 180 | return 181 | 182 | # Return a single key as just the value, and multiple keys as a dict. 183 | if len(keys) == 1: 184 | input.api_key = list(keys.values())[0] 185 | else: 186 | input.api_key = keys 187 | 188 | if func._thread: 189 | bot.threads[func].put(input) 190 | else: 191 | _thread.start_new_thread(run, (func, input)) 192 | 193 | 194 | def match_command(command): 195 | commands = list(bot.commands) 196 | 197 | # do some fuzzy matching 198 | prefix = [x for x in commands if x.startswith(command)] 199 | if len(prefix) == 1: 200 | return prefix[0] 201 | elif prefix and command not in prefix: 202 | return prefix 203 | 204 | return command 205 | 206 | 207 | def make_command_re(bot_prefix, is_private, bot_nick): 208 | if not isinstance(bot_prefix, list): 209 | bot_prefix = [bot_prefix] 210 | if is_private: 211 | bot_prefix.append("") # empty prefix 212 | bot_prefix = "|".join(re.escape(p) for p in bot_prefix) 213 | bot_prefix += "|" + bot_nick + r"[:,]+\s+" 214 | command_re = r"(?:%s)(\w+)(?:$|\s+)(.*)" % bot_prefix 215 | return re.compile(command_re) 216 | 217 | 218 | def test_make_command_re(): 219 | match = make_command_re(".", False, "bot").match 220 | assert not match("foo") 221 | assert not match("bot foo") 222 | for _ in range(2): 223 | assert match(".test").groups() == ("test", "") 224 | assert match("bot: foo args").groups() == ("foo", "args") 225 | match = make_command_re(".", True, "bot").match 226 | assert match("foo").groups() == ("foo", "") 227 | match = make_command_re([".", "!"], False, "bot").match 228 | assert match("!foo args").groups() == ("foo", "args") 229 | 230 | 231 | def main(conn, out): 232 | inp = Input(conn, *out) 233 | 234 | # EVENTS 235 | for func, args in bot.events[inp.command] + bot.events["*"]: 236 | dispatch(Input(conn, *out), "event", func, args) 237 | 238 | if inp.command == "PRIVMSG": 239 | # COMMANDS 240 | config_prefix = bot.config.get("prefix", ".") 241 | is_private = inp.chan == inp.nick # no prefix required 242 | command_re = make_command_re(config_prefix, is_private, inp.conn.nick) 243 | 244 | m = command_re.match(inp.lastparam) 245 | 246 | if m: 247 | trigger = m.group(1).lower() 248 | command = match_command(trigger) 249 | 250 | if isinstance(command, list): # multiple potential matches 251 | input = Input(conn, *out) 252 | input.reply( 253 | "did you mean %s or %s?" % (", ".join(command[:-1]), command[-1]) 254 | ) 255 | elif command in bot.commands: 256 | input = Input(conn, *out) 257 | input.trigger = trigger 258 | input.inp_unstripped = m.group(2) 259 | input.inp = input.inp_unstripped.strip() 260 | 261 | func, args = bot.commands[command] 262 | dispatch(input, "command", func, args, autohelp=True) 263 | 264 | # REGEXES 265 | for func, args in bot.plugs["regex"]: 266 | m = args["re"].search(inp.lastparam) 267 | if m: 268 | input = Input(conn, *out) 269 | input.inp = m 270 | 271 | dispatch(input, "regex", func, args) 272 | -------------------------------------------------------------------------------- /core/reload.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | 3 | from future import standard_library 4 | 5 | standard_library.install_aliases() 6 | import collections 7 | import glob 8 | import os 9 | import re 10 | import sys 11 | import traceback 12 | 13 | 14 | if "mtimes" not in globals(): 15 | mtimes = {} 16 | 17 | if "lastfiles" not in globals(): 18 | lastfiles = set() 19 | 20 | 21 | def make_signature(f): 22 | return f.__code__.co_filename, f.__name__, f.__code__.co_firstlineno 23 | 24 | 25 | def format_plug(plug, kind="", lpad=0, width=40): 26 | out = " " * lpad + "%s:%s:%s" % make_signature(plug[0]) 27 | if kind == "command": 28 | out += " " * (50 - len(out)) + plug[1]["name"] 29 | 30 | if kind == "event": 31 | out += " " * (50 - len(out)) + ", ".join(plug[1]["events"]) 32 | 33 | if kind == "regex": 34 | out += " " * (50 - len(out)) + plug[1]["regex"] 35 | 36 | return out 37 | 38 | 39 | def reload(init=False): 40 | changed = False 41 | 42 | if init: 43 | bot.plugs = collections.defaultdict(list) 44 | bot.threads = {} 45 | 46 | core_fileset = set(glob.glob(os.path.join("core", "*.py"))) 47 | 48 | for filename in core_fileset: 49 | mtime = os.stat(filename).st_mtime 50 | if mtime != mtimes.get(filename): 51 | mtimes[filename] = mtime 52 | 53 | changed = True 54 | 55 | try: 56 | eval(compile(open(filename, "r").read(), filename, "exec"), globals()) 57 | except Exception: 58 | traceback.print_exc() 59 | if init: # stop if there's an error (syntax?) in a core 60 | sys.exit() # script on startup 61 | continue 62 | 63 | if filename == os.path.join("core", "reload.py"): 64 | reload(init=init) 65 | return 66 | 67 | fileset = set(glob.glob(os.path.join("plugins", "*.py"))) 68 | 69 | # remove deleted/moved plugins 70 | for name, data in bot.plugs.items(): 71 | bot.plugs[name] = [x for x in data if x[0]._filename in fileset] 72 | 73 | for filename in list(mtimes): 74 | if filename not in fileset and filename not in core_fileset: 75 | mtimes.pop(filename) 76 | 77 | for func, handler in list(bot.threads.items()): 78 | if func._filename not in fileset: 79 | handler.stop() 80 | del bot.threads[func] 81 | 82 | # compile new plugins 83 | for filename in fileset: 84 | mtime = os.stat(filename).st_mtime 85 | if mtime != mtimes.get(filename): 86 | mtimes[filename] = mtime 87 | 88 | changed = True 89 | 90 | try: 91 | code = compile(open(filename, "r").read(), filename, "exec") 92 | namespace = {} 93 | eval(code, namespace) 94 | except Exception: 95 | traceback.print_exc() 96 | continue 97 | 98 | # remove plugins already loaded from this filename 99 | for name, data in bot.plugs.items(): 100 | bot.plugs[name] = [x for x in data if x[0]._filename != filename] 101 | 102 | for func, handler in list(bot.threads.items()): 103 | if func._filename == filename: 104 | handler.stop() 105 | del bot.threads[func] 106 | 107 | for obj in namespace.values(): 108 | if hasattr(obj, "_hook"): # check for magic 109 | if obj._thread: 110 | bot.threads[obj] = Handler(obj) 111 | 112 | for type, data in obj._hook: 113 | bot.plugs[type] += [data] 114 | 115 | if not init: 116 | print( 117 | "### new plugin (type: %s) loaded:" % type, 118 | format_plug(data), 119 | ) 120 | 121 | if changed: 122 | bot.commands = {} 123 | for plug in bot.plugs["command"]: 124 | name = plug[1]["name"].lower() 125 | if not re.match(r"^\w+$", name): 126 | print( 127 | '### ERROR: invalid command name "%s" (%s)' 128 | % (name, format_plug(plug)) 129 | ) 130 | continue 131 | if name in bot.commands: 132 | print( 133 | "### ERROR: command '%s' already registered (%s, %s)" 134 | % (name, format_plug(bot.commands[name]), format_plug(plug)) 135 | ) 136 | continue 137 | bot.commands[name] = plug 138 | 139 | bot.events = collections.defaultdict(list) 140 | for func, args in bot.plugs["event"]: 141 | for event in args["events"]: 142 | bot.events[event].append((func, args)) 143 | 144 | if init: 145 | print(" plugin listing:") 146 | 147 | if bot.commands: 148 | # hack to make commands with multiple aliases 149 | # print nicely 150 | 151 | print(" command:") 152 | commands = collections.defaultdict(list) 153 | 154 | for name, (func, args) in bot.commands.items(): 155 | commands[make_signature(func)].append(name) 156 | 157 | for sig, names in sorted(commands.items()): 158 | names.sort(key=lambda x: (-len(x), x)) # long names first 159 | out = " " * 6 + "%s:%s:%s" % sig 160 | out += " " * (50 - len(out)) + ", ".join(names) 161 | print(out) 162 | 163 | for kind, plugs in sorted(bot.plugs.items()): 164 | if kind == "command": 165 | continue 166 | print(" %s:" % kind) 167 | for plug in plugs: 168 | print(format_plug(plug, kind=kind, lpad=6)) 169 | print() 170 | -------------------------------------------------------------------------------- /docs/Configuration.md: -------------------------------------------------------------------------------- 1 | # Configuration # 2 | 3 | 4 | Skybot uses a JSON configuration file to hold settings: `config.json` 5 | 6 | On first run this file is created with default settings: 7 | 8 | ```json 9 | { 10 | "connections": 11 | { 12 | "local irc": 13 | { 14 | "server": "localhost", 15 | "nick": "skybot", 16 | "channels": ["#test"] 17 | } 18 | } 19 | } 20 | ``` 21 | 22 | 23 | ## Options ## 24 | 25 | Connections is an associative array of connection_name : connection_settings 26 | key/value pairs. 27 | 28 | `connection_settings:` 29 | 30 | Required: 31 | 32 | * nick: the name of the bot. 33 | * server: the hostname of the irc server. 34 | * channels: channels to join. A list of strings. Can be [] 35 | 36 | Optional: 37 | 38 | * port: defaults to 6667. The port to connect to. 39 | * user: defaults to "skybot". (user@netmask) 40 | * realname: defaults to "Python bot - http://github.com/rmmh/skybot" 41 | (Shown in whois) 42 | * server_password: the server password. Omit if not needed. 43 | * nickserv_password: defaults to "" (no login is performed) 44 | * nickserv_name: defaults to "nickserv" (standard on most networks) 45 | * nickserv_command: defaults to "IDENTIFY %s" (interpolated with password) 46 | * ssl: defaults to false. Set to true to connect to the server using SSL 47 | * ignore_cert: defaults to `false`. Set to `true` to disable validation of certificates 48 | from servers - thus weakening the security of your connection. 49 | 50 | 51 | ## Examples ## 52 | 53 | A single skybot instance can have multiple connections and multiple channels: 54 | 55 | ```json 56 | { 57 | "connections": 58 | { 59 | "public bot": 60 | { 61 | "server": "irc.example.org", 62 | "nick": "publicbot", 63 | "channels": ["#main"] 64 | }, 65 | "private bot": 66 | { 67 | "server": "irc.example.org", 68 | "nick": "privatebot", 69 | "channels": ["#secret", "#admin"] 70 | } 71 | } 72 | } 73 | ``` 74 | 75 | The user and realname can be set. 76 | 77 | * user: defaults to "skybot" 78 | * realname: defaults to "Python bot - http://github.com/rmmh/skybot" 79 | 80 | ```json 81 | { 82 | "connections": 83 | { 84 | "poker irc": 85 | { 86 | "server": "irc.poker.example.com", 87 | "nick": "pokerbot", 88 | "channels": ["#poker"], 89 | "user": "pokerbot", 90 | "realname": "Pokerbot - a fork of Skybot", 91 | } 92 | } 93 | } 94 | ``` 95 | 96 | Automatic identification is possible. 97 | 98 | * nickserv_password: defaults to "" (no login is performed) 99 | * nickserv_name: defaults to "nickserv" (standard on most networks) 100 | * nickserv_command: defaults to "IDENTIFY %s" (interpolated with password) 101 | 102 | ```json 103 | { 104 | "connections": 105 | { 106 | "poker irc": 107 | { 108 | "server": "irc.poker.example.com", 109 | "nick": "pokerbot", 110 | "nickserv_password": "aceofspades", 111 | "channels": ["#poker"] 112 | } 113 | } 114 | } 115 | ``` 116 | -------------------------------------------------------------------------------- /docs/Home.md: -------------------------------------------------------------------------------- 1 | # Skybot documentation # 2 | 3 | 4 | Skybot is a python IRC bot. 5 | 6 | ## Introduction ## 7 | 8 | ### Goals ### 9 | 10 | * simplicity 11 | * little boilerplate 12 | * minimal magic 13 | * power 14 | * multithreading 15 | * automatic reloading 16 | * extensibility 17 | 18 | ### Features ### 19 | 20 | * multithreaded dispatch and the ability to connect to multiple networks at 21 | a time 22 | * easy plugin development with automatic reloading and a simple hooking API 23 | 24 | ### Requirements ### 25 | 26 | Skybot runs on Python 2.6 and 2.7. Many of the plugins require 27 | [lxml](http://lxml.de/). 28 | 29 | 30 | ## Table of contents ## 31 | 32 | * [[Installation]] 33 | * [[Configuration]] 34 | * [[Plugin development]] -------------------------------------------------------------------------------- /docs/Installation.md: -------------------------------------------------------------------------------- 1 | # Installation # 2 | 3 | 4 | ## Requirements ## 5 | 6 | * Python 2.6 or 2.7 7 | * Many of the plugins require [lxml](http://lxml.de/) 8 | 9 | 10 | ## Download ## 11 | 12 | You can easily retrieve Skybot's files using git. Browse to the directory in 13 | which you wish to place the bot and run the following command: 14 | 15 | git clone git://github.com/rmmh/skybot.git 16 | 17 | 18 | ## First run ## 19 | 20 | Browse to Skybot's directory and run the following command: 21 | 22 | python bot.py 23 | 24 | On first run, Skybot will create a default configuration file in its 25 | directory. You can then stop the bot and follow the [[Configuration]] page's 26 | instructions. -------------------------------------------------------------------------------- /docs/Plugin-development.md: -------------------------------------------------------------------------------- 1 | # Plugin development # 2 | 3 | 4 | > This documentation page needs to be improved. Contributions are welcomed. 5 | 6 | ##Overview ## 7 | 8 | Skybot continually scans the `plugins/` directory for new or changed .py 9 | files. When it finds one, it runs it and examines each function to see whether 10 | it is a plugin hook. 11 | 12 | All plugins need to `from util import hook` in order to be callable. 13 | 14 | 15 | ## A simple example ## 16 | 17 | plugins/echo.py: 18 | 19 | ```python 20 | from util import hook 21 | 22 | @hook.command 23 | def echo(inp): 24 | return inp + inp 25 | ``` 26 | 27 | usage: 28 | 29 | .echo hots 30 | Scaevolus: hotshots 31 | 32 | 33 | This plugin example defines a command that replies with twice its input. It 34 | can be invoked by saying phrases in a channel the bot is in, notably ".echo", 35 | "skybot: echo", and "skybot, echo" (assuming the bot's nick is "skybot"). 36 | 37 | 38 | ## Plugin hooks ## 39 | 40 | There are four types of plugin hooks: commands, regexes, events, and sieves. 41 | The hook type is assigned to plugin functions using decorators found 42 | in `util/hook.py`. 43 | 44 | 45 | There is also a secondary hook decorator: `@hook.singlethread` 46 | 47 | It indicates that the function should run in its own thread. Note that, in 48 | that case, you can't use the existing database connection object. 49 | 50 | ### Shared arguments ### 51 | 52 | > This section has to be verified. 53 | 54 | These arguments are shared by functions of all hook types: 55 | 56 | * nick -- string, the nickname of whoever sent the message. 57 | * channel -- string, the channel the message was sent on. Equal to nick if 58 | it's a private message. 59 | * msg -- string, the line that was sent. 60 | * raw -- string, the raw full line that was sent. 61 | * re -- the result of doing `re.match(hook, msg)`. 62 | * bot -- the running bot object. 63 | * db -- the database connection object. 64 | * input -- the triggering line of text 65 | 66 | ### Commands hook ### 67 | 68 | `@hook.command` 69 | `@hook.command(command_name)` 70 | 71 | Commands run when the beginning of a normal chat line matches one of 72 | `.command`, `botnick: command`, or `botnick, command`, where `command` is the 73 | command name, and `botnick` is the bot's nick on the server. 74 | 75 | Commands respond to abbreviated forms: a command named "`dictionary`" will be 76 | invoked on both "`.dictionary`" and "`.dict`". If an abbreviated command is 77 | ambiguous, the bot will return with a list of possibilities: given commands 78 | "`dictionary`" and "`dice`", attempting to run command "`.di`" will make the 79 | bot say "`did you mean dictionary or dice?`". 80 | 81 | When `@hook.command` is used without arguments, the command name is set to the 82 | function name. When given an argument, it is used as the command name. This 83 | allows one function to respond to multiple commands: 84 | 85 | ```python 86 | from util import hook 87 | 88 | @hook.command('hi') 89 | @hook.command 90 | def hello(inp): 91 | return "Hey there!" 92 | ``` 93 | 94 | Users can invoke this function with either "`.hello`" or "`.hi`". 95 | 96 | ### Regexes hook ### 97 | 98 | > This section needs to be improved. 99 | 100 | `@hook.regex(pattern)` 101 | 102 | Each line of chat is matched against the provided regex pattern. If it is 103 | successful, the hook function will be called with the matched object. 104 | 105 | ```python 106 | from util import hook 107 | 108 | @hook.regex("lame bot") 109 | def hurtfulcomment(match): 110 | return "I have FEELINGS!" 111 | ``` 112 | 113 | ### Events hook ### 114 | 115 | > This section needs to be improved. 116 | 117 | `@hook.event(irc_command)` 118 | 119 | Event hooks are called whenever a specific IRC command is issued. For example, 120 | if you provide "*" as parameter, it will trigger on every line. If you provide 121 | "PRIVMSG", it will only trigger on actual lines of chat (not nick-changes). 122 | 123 | The first argument in these cases will be a two-element list of the form 124 | ["#channel", "text"]. 125 | 126 | ### Sieves hook ### 127 | 128 | > This section needs to be improved. 129 | 130 | `@hook.sieve` 131 | 132 | Sieves can prevent commands, regexes, and events from running. 133 | 134 | For instance, commands could be tagged as admin-only, and then a sieve would 135 | verify that the user invoking the command has the necessary privileges. 136 | 137 | The function must take 5 arguments: (bot, input, func, type, args). 138 | To cancel a call, return None. 139 | 140 | ## Available objects ## 141 | 142 | > This section needs to be written. 143 | 144 | ### The bot object ### 145 | 146 | ### The db object ### 147 | 148 | ### The input object ### 149 | -------------------------------------------------------------------------------- /plugins/bf.py: -------------------------------------------------------------------------------- 1 | """brainfuck interpreter adapted from (public domain) code at 2 | http://brainfuck.sourceforge.net/brain.py""" 3 | 4 | from builtins import chr 5 | from builtins import range 6 | import re 7 | import random 8 | import unittest 9 | 10 | from util import hook 11 | 12 | 13 | @hook.command 14 | def bf(inp, max_steps=1000000, buffer_size=5000): 15 | ".bf -- executes brainfuck program " "" 16 | 17 | program = re.sub("[^][<>+-.,]", "", inp) 18 | 19 | # create a dict of brackets pairs, for speed later on 20 | brackets = {} 21 | open_brackets = [] 22 | for pos in range(len(program)): 23 | if program[pos] == "[": 24 | open_brackets.append(pos) 25 | elif program[pos] == "]": 26 | if len(open_brackets) > 0: 27 | brackets[pos] = open_brackets[-1] 28 | brackets[open_brackets[-1]] = pos 29 | open_brackets.pop() 30 | else: 31 | return "unbalanced brackets" 32 | if len(open_brackets) != 0: 33 | return "unbalanced brackets" 34 | 35 | # now we can start interpreting 36 | ip = 0 # instruction pointer 37 | mp = 0 # memory pointer 38 | steps = 0 39 | memory = [0] * buffer_size # initial memory area 40 | rightmost = 0 41 | output = "" # we'll save the output here 42 | 43 | # the main program loop: 44 | while ip < len(program): 45 | c = program[ip] 46 | if c == "+": 47 | memory[mp] = (memory[mp] + 1) % 256 48 | elif c == "-": 49 | memory[mp] = (memory[mp] - 1) % 256 50 | elif c == ">": 51 | mp += 1 52 | if mp > rightmost: 53 | rightmost = mp 54 | if mp >= len(memory): 55 | # no restriction on memory growth! 56 | memory.extend([0] * buffer_size) 57 | elif c == "<": 58 | mp = (mp - 1) % len(memory) 59 | elif c == ".": 60 | output += chr(memory[mp]) 61 | if len(output) > 500: 62 | break 63 | elif c == ",": 64 | memory[mp] = random.randint(1, 255) 65 | elif c == "[": 66 | if memory[mp] == 0: 67 | ip = brackets[ip] 68 | elif c == "]": 69 | if memory[mp] != 0: 70 | ip = brackets[ip] 71 | 72 | ip += 1 73 | steps += 1 74 | if steps > max_steps: 75 | if output == "": 76 | output = "no output" 77 | output += " [exceeded %d iterations]" % max_steps 78 | break 79 | 80 | stripped_output = re.sub(r"[\x00-\x1F]", "", output) 81 | 82 | if stripped_output == "": 83 | if output != "": 84 | return "no printable output" 85 | return "no output" 86 | 87 | return stripped_output[:430] 88 | -------------------------------------------------------------------------------- /plugins/bitcoin.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from util import http, hook 4 | 5 | 6 | @hook.command(autohelp=False) 7 | def bitcoin(inp, say=None): 8 | ".bitcoin -- gets current exchange rate for bitcoins from Bitstamp" 9 | data = http.get_json("https://www.bitstamp.net/api/ticker/") 10 | say( 11 | "USD/BTC: \x0307${:,.2f}\x0f - High: \x0307${:,.2f}\x0f -" 12 | " Low: \x0307${:,.2f}\x0f - Volume: {:,.2f} BTC".format( 13 | float(data["last"]), 14 | float(data["high"]), 15 | float(data["low"]), 16 | float(data["volume"]), 17 | ) 18 | ) 19 | 20 | 21 | @hook.command(autohelp=False) 22 | def ethereum(inp, say=None): 23 | ".ethereum -- gets current exchange rate for ethereum from Bitstamp" 24 | data = http.get_json("https://www.bitstamp.net/api/v2/ticker/ethusd") 25 | say( 26 | "USD/ETH: \x0307${:,.2f}\x0f - High: \x0307${:,.2f}\x0f -" 27 | " Low: \x0307${:,.2f}\x0f - Volume: {:,.2f} ETH".format( 28 | float(data["last"]), 29 | float(data["high"]), 30 | float(data["low"]), 31 | float(data["volume"]), 32 | ) 33 | ) 34 | -------------------------------------------------------------------------------- /plugins/cdecl.py: -------------------------------------------------------------------------------- 1 | import re 2 | from functools import wraps 3 | from time import time 4 | 5 | from util import hook, http 6 | 7 | 8 | CDECL_URL = "https://cdecl.org/" 9 | RE_QUERY_ENDPOINT = re.compile('var QUERY_ENDPOINT = "([^"]+)"') 10 | QUERY_URL_MEMO_TTL = 600 11 | 12 | 13 | def memoize(ttl): 14 | def _memoizer(f): 15 | @wraps(f) 16 | def wrapper(*args, **kwargs): 17 | if hasattr(wrapper, "memo") and wrapper.memo: 18 | memo_time, result = wrapper.memo 19 | if time() - memo_time < ttl: 20 | return result 21 | 22 | result = f(*args, **kwargs) 23 | wrapper.memo = (time(), result) 24 | return result 25 | 26 | return wrapper 27 | 28 | return _memoizer 29 | 30 | 31 | @memoize(QUERY_URL_MEMO_TTL) 32 | def get_cdecl_query_url(): 33 | print("RUNNING") 34 | result = http.get(CDECL_URL) 35 | 36 | match = RE_QUERY_ENDPOINT.search(result) 37 | 38 | if not match: 39 | return None 40 | 41 | return match.group(1) 42 | 43 | 44 | @hook.command 45 | def cdecl(inp): 46 | """.cdecl -- translate between C declarations and English, using cdecl.org""" 47 | query_url = get_cdecl_query_url() 48 | 49 | if not query_url: 50 | return "cannot find CDECL query url" 51 | 52 | return http.get(query_url, q=inp) 53 | -------------------------------------------------------------------------------- /plugins/chatgpt.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from util import hook, http 4 | 5 | prompt = [0, 6 | {"role": "system", "content": 7 | "You are a terse and helpful assistant named SkyboGPT, answering questions briefly with one or two sentences. Answer as concisely as possible."}, 8 | ] 9 | 10 | 11 | @hook.api_key("openai") 12 | @hook.command("gpt") 13 | @hook.command 14 | def chatgpt(inp, api_key=None, reply=None): 15 | ".gpt/.chatgpt -- asks ChatGPT a question" 16 | 17 | global prompt 18 | 19 | if not api_key: 20 | return 21 | 22 | if inp == 'clear': 23 | prompt = prompt[:2] 24 | return 25 | 26 | if prompt[0] < time.time() - 60 * 10: # forget after 10 minutes 27 | prompt = prompt[:2] 28 | 29 | prompt.append({"role": "user", "content": inp}) 30 | 31 | url = "https://api.openai.com/v1/chat/completions" 32 | result = http.get_json(url, headers={'Authorization': 'Bearer ' + api_key}, json_data={ 33 | "model": "gpt-3.5-turbo", 34 | "messages": prompt[1:] 35 | }) 36 | 37 | if 'error' in result: 38 | reply(result['error']['message']) 39 | 40 | msg = result['choices'][0]['message'] 41 | prompt[0] = time.time() 42 | prompt.append(msg) 43 | 44 | for line in prompt[1:]: 45 | print(line['role'] + ':', line['content']) 46 | print(result['usage']) 47 | 48 | if result['usage']['total_tokens'] > 2048: 49 | prompt.pop(2) 50 | 51 | reply(msg['content'].replace('\n', ' ')) 52 | -------------------------------------------------------------------------------- /plugins/choose.py: -------------------------------------------------------------------------------- 1 | import re 2 | import random 3 | 4 | from util import hook 5 | 6 | 7 | @hook.command 8 | def choose(inp): 9 | ".choose , , ... -- makes a decision" 10 | 11 | c = re.findall(r"([^,]+)", inp) 12 | if len(c) == 1: 13 | c = re.findall(r"(\S+)", inp) 14 | if len(c) == 1: 15 | return "the decision is up to you" 16 | 17 | return random.choice(c).strip() 18 | -------------------------------------------------------------------------------- /plugins/crowdcontrol.py: -------------------------------------------------------------------------------- 1 | # crowdcontrol.py by craisins in 2014 2 | # Bot must have some sort of op or admin privileges to be useful 3 | 4 | import re 5 | import time 6 | from util import hook 7 | 8 | # Use "crowdcontrol" array in config 9 | # syntax 10 | # rule: 11 | # re: RegEx. regular expression to match 12 | # msg: String. message to display either with kick or as a warning 13 | # kick: Integer. 1 for True, 0 for False on if to kick user 14 | # ban_length: Integer. (optional) Length of time (seconds) to ban user. (-1 to never unban, 0 to not ban, > 1 for time) 15 | 16 | 17 | @hook.regex(r".*") 18 | def crowdcontrol(inp, kick=None, ban=None, unban=None, reply=None, bot=None): 19 | inp = inp.group(0) 20 | for rule in bot.config.get("crowdcontrol", []): 21 | if re.search(rule["re"], inp) is not None: 22 | should_kick = rule.get("kick", 0) 23 | ban_length = rule.get("ban_length", 0) 24 | reason = rule.get("msg") 25 | if ban_length != 0: 26 | ban() 27 | if should_kick: 28 | kick(reason=reason) 29 | elif "msg" in rule: 30 | reply(reason) 31 | if ban_length > 0: 32 | time.sleep(ban_length) 33 | unban() 34 | -------------------------------------------------------------------------------- /plugins/crypto.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from util import http, hook 4 | 5 | 6 | @hook.command() 7 | def crypto(inp, say=None): 8 | fsym = inp.split(" ")[0].upper() 9 | price_response = http.get_json( 10 | "https://min-api.cryptocompare.com/data/pricemultifull", fsyms=fsym, tsyms="USD" 11 | ) 12 | 13 | if price_response.get("Response") == "Error": 14 | return "Error: " + price_response.get( 15 | "Message", "An API Error Has Occured. https://min-api.cryptocompare.com/" 16 | ) 17 | 18 | if not price_response["DISPLAY"].get(fsym): 19 | return "Error: Unable to lookup pricing for {}".format(fsym) 20 | 21 | values = price_response["DISPLAY"][fsym]["USD"] 22 | 23 | say( 24 | ( 25 | "USD/{FROMSYMBOL}: \x0307{PRICE}\x0f - High: \x0307{HIGHDAY}\x0f " 26 | "- Low: \x0307{LOWDAY}\x0f - Volume: {VOLUMEDAY}" 27 | " ({VOLUMEDAYTO}) - Total Supply: {SUPPLY} - MktCap: {MKTCAP}" 28 | ).format(**values) 29 | ) 30 | -------------------------------------------------------------------------------- /plugins/dice.py: -------------------------------------------------------------------------------- 1 | """ 2 | dice.py: written by Scaevolus 2008, updated 2009 3 | simulates dicerolls 4 | """ 5 | from builtins import str 6 | from builtins import map 7 | from builtins import range 8 | import re 9 | import random 10 | import unittest 11 | 12 | from util import hook 13 | 14 | 15 | whitespace_re = re.compile(r"\s+") 16 | valid_diceroll = r"^([+-]?(?:\d+|\d*d(?:\d+|F))(?:[+-](?:\d+|\d*d(?:\d+|F)))*)( .+)?$" 17 | valid_diceroll_re = re.compile(valid_diceroll, re.I) 18 | sign_re = re.compile(r"[+-]?(?:\d*d)?(?:\d+|F)", re.I) 19 | split_re = re.compile(r"([\d+-]*)d?(F|\d*)", re.I) 20 | 21 | 22 | def nrolls(count, n): 23 | "roll an n-sided die count times" 24 | if n == "F": 25 | return [random.randint(-1, 1) for x in range(min(count, 100))] 26 | if n < 2: # it's a coin 27 | if count < 5000: 28 | return [random.randint(0, 1) for x in range(count)] 29 | else: # fake it 30 | return [int(random.normalvariate(0.5 * count, (0.75 * count) ** 0.5))] 31 | else: 32 | if count < 5000: 33 | return [random.randint(1, n) for x in range(count)] 34 | else: # fake it 35 | return [ 36 | int( 37 | random.normalvariate( 38 | 0.5 * (1 + n) * count, 39 | (((n + 1) * (2 * n + 1) / 6.0 - (0.5 * (1 + n)) ** 2) * count) 40 | ** 0.5, 41 | ) 42 | ) 43 | ] 44 | 45 | 46 | @hook.command("roll") 47 | # @hook.regex(valid_diceroll, re.I) 48 | @hook.command 49 | def dice(inp): 50 | ".dice -- simulates dicerolls, e.g. .dice 2d20-d5+4 roll 2 " "D20s, subtract 1D5, add 4" 51 | 52 | try: # if inp is a re.match object... 53 | (inp, desc) = inp.groups() 54 | except AttributeError: 55 | (inp, desc) = valid_diceroll_re.match(inp).groups() 56 | 57 | if "d" not in inp: 58 | return 59 | 60 | spec = whitespace_re.sub("", inp) 61 | if not valid_diceroll_re.match(spec): 62 | return "Invalid diceroll" 63 | groups = sign_re.findall(spec) 64 | 65 | total = 0 66 | rolls = [] 67 | 68 | for roll in groups: 69 | count, side = split_re.match(roll).groups() 70 | if count in ["", " ", "+", "-"]: 71 | count = int("%s1" % count) 72 | else: 73 | count = int(count) 74 | 75 | if side.upper() == "F": # fudge dice are basically 1d3-2 76 | for fudge in nrolls(count, "F"): 77 | if fudge == 1: 78 | rolls.append("\x033+\x0F") 79 | elif fudge == -1: 80 | rolls.append("\x034-\x0F") 81 | else: 82 | rolls.append("0") 83 | total += fudge 84 | elif side == "": 85 | total += count 86 | else: 87 | side = int(side) 88 | try: 89 | if count > 0: 90 | dice = nrolls(count, side) 91 | rolls += list(map(str, dice)) 92 | total += sum(dice) 93 | else: 94 | dice = nrolls(-count, side) 95 | rolls += [str(-x) for x in dice] 96 | total -= sum(dice) 97 | except OverflowError: 98 | return "Thanks for overflowing a float, jerk >:[" 99 | 100 | if desc: 101 | return "%s: %d (%s=%s)" % (desc.strip(), total, inp, ", ".join(rolls)) 102 | else: 103 | return "%d (%s=%s)" % (total, inp, ", ".join(rolls)) 104 | 105 | 106 | class DiceTest(unittest.TestCase): 107 | def test_complex_roll_with_subtraction(self): 108 | actual = dice("2d20-d5+4") 109 | 110 | match = re.match( 111 | "(?P\d+) \(2d20-d5\+4=(?P\d+), (?P\d+), -(?P\d+)\)", actual 112 | ) 113 | 114 | assert match is not None 115 | 116 | expected_result = ( 117 | int(match.group("a")) + int(match.group("b")) - int(match.group("c")) + 4 118 | ) 119 | 120 | assert expected_result == int(match.group("result")) 121 | -------------------------------------------------------------------------------- /plugins/dictionary.py: -------------------------------------------------------------------------------- 1 | 2 | import re 3 | 4 | from util import hook, http 5 | from urllib.error import HTTPError 6 | 7 | 8 | @hook.command("u") 9 | @hook.command 10 | def urban(inp): 11 | """.u/.urban -- looks up on urbandictionary.com""" 12 | 13 | page = http.get_json("https://api.urbandictionary.com/v0/define", term=inp) 14 | defs = page["list"] 15 | 16 | if not defs: 17 | return "not found." 18 | 19 | out = defs[0]["word"] + ": " + defs[0]["definition"].replace("\r\n", " ") 20 | 21 | if len(out) > 400: 22 | out = out[: out.rfind(" ", 0, 400)] + "..." 23 | 24 | return out 25 | 26 | 27 | # define plugin by GhettoWizard & Scaevolus 28 | @hook.command("dictionary") 29 | @hook.command 30 | def define(inp): 31 | ".define/.dictionary -- fetches definition of " 32 | 33 | url = "http://ninjawords.com/" 34 | 35 | h = http.get_html(url + http.quote_plus(inp)) 36 | 37 | definition = h.xpath( 38 | '//dd[@class="article"] | ' 39 | '//div[@class="definition"] |' 40 | '//div[@class="example"]' 41 | ) 42 | 43 | if not definition: 44 | return "No results for " + inp 45 | 46 | def format_output(show_examples): 47 | result = "%s: " % h.xpath('//dt[@class="title-word"]/a/text()')[0] 48 | 49 | correction = h.xpath('//span[@class="correct-word"]/text()') 50 | if correction: 51 | result = 'definition for "%s": ' % correction[0] 52 | 53 | sections = [] 54 | for section in definition: 55 | if section.attrib["class"] == "article": 56 | sections += [[section.text_content() + ": "]] 57 | elif section.attrib["class"] == "example": 58 | if show_examples: 59 | sections[-1][-1] += " " + section.text_content() 60 | else: 61 | sections[-1] += [section.text_content()] 62 | 63 | for article in sections: 64 | result += article[0] 65 | if len(article) > 2: 66 | result += " ".join( 67 | "%d. %s" % (n + 1, section) for n, section in enumerate(article[1:]) 68 | ) 69 | else: 70 | result += article[1] + " " 71 | 72 | synonyms = h.xpath('//dd[@class="synonyms"]') 73 | if synonyms: 74 | result += synonyms[0].text_content() 75 | 76 | result = re.sub(r"\s+", " ", result) 77 | result = re.sub("\xb0", "", result) 78 | return result 79 | 80 | result = format_output(True) 81 | if len(result) > 450: 82 | result = format_output(False) 83 | 84 | if len(result) > 450: 85 | result = result[: result.rfind(" ", 0, 450)] 86 | result = re.sub(r"[^A-Za-z]+\.?$", "", result) + " ..." 87 | 88 | return result 89 | 90 | 91 | @hook.command("e") 92 | @hook.command 93 | def etymology(inp): 94 | ".e/.etymology -- Retrieves the etymology of chosen word" 95 | 96 | try: 97 | h = http.get_html(f'https://www.etymonline.com/word/{http.quote_plus(inp)}') 98 | etym = h.xpath('//section[starts-with(@class, "prose-lg")]/section')[0].text_content() 99 | except IndexError: 100 | return "No etymology found for " + inp 101 | except HTTPError: 102 | return "No etymology found for " + inp 103 | 104 | etym = etym.replace(inp, "\x02%s\x02" % inp) 105 | 106 | if len(etym) > 400: 107 | etym = etym[: etym.rfind(" ", 0, 400)] + " ..." 108 | 109 | return etym 110 | -------------------------------------------------------------------------------- /plugins/down.py: -------------------------------------------------------------------------------- 1 | from builtins import str 2 | import urllib.parse 3 | 4 | from util import hook, http 5 | 6 | 7 | @hook.command 8 | def down(inp): 9 | """.down -- checks to see if the website is down""" 10 | 11 | # urlparse follows RFC closely, so we have to check for schema existence and prepend empty schema if necessary 12 | if not inp.startswith("//") and "://" not in inp: 13 | inp = "//" + inp 14 | 15 | urlp = urllib.parse.urlparse(str(inp), "http") 16 | 17 | if urlp.scheme not in ("http", "https"): 18 | return inp + " is not a valid HTTP URL" 19 | 20 | inp = "%s://%s" % (urlp.scheme, urlp.netloc) 21 | 22 | # http://mail.python.org/pipermail/python-list/2006-December/589854.html 23 | try: 24 | http.get(inp, get_method="HEAD") 25 | return inp + " seems to be up" 26 | except http.URLError as error: 27 | return inp + " seems to be down. Error: %s" % error.reason 28 | -------------------------------------------------------------------------------- /plugins/eval.py: -------------------------------------------------------------------------------- 1 | import time 2 | import uuid 3 | import json 4 | 5 | from util import hook, http 6 | 7 | 8 | def piston(language, version, content, filename=None): 9 | api_url = "https://emkc.org/api/v2/piston/execute" 10 | 11 | if not filename: 12 | filename = str(uuid.uuid1()) 13 | 14 | payload = { 15 | "language": language, 16 | "version": version, 17 | "files": [{"name": filename, "content": content}], 18 | "stdin": "", 19 | "args": [], 20 | "compile_timeout": 5000, 21 | "run_timeout": 5000, 22 | "compile_memory_limit": -1, 23 | "run_memory_limit": -1, 24 | } 25 | 26 | headers = {"Content-Type", "application/json"} 27 | 28 | result = http.get_json(api_url, json_data=payload, get_methods="POST") 29 | 30 | if result["run"]["stderr"] != "": 31 | return "Error: " + result["run"]["stderr"] 32 | 33 | return result["run"]["output"].replace("\n", " ") 34 | 35 | @hook.command 36 | def cpp(inp, nick=None): 37 | headers = ['algorithm', 'any', 'array', 'atomic', 'bit', 'bitset', 38 | 'cassert', 'ccomplex', 'cctype', 'cerrno', 'cfenv', 'cfloat', 39 | 'charconv', 'chrono', 'cinttypes', 'ciso646', 'climits', 40 | 'clocale', 'cmath', 'codecvt', 'complex', 'complex.h', 41 | 'condition_variable', 'csetjmp', 'csignal', 'cstdalign', 42 | 'cstdarg', 'cstdbool', 'cstddef', 'cstdint', 'cstdio', 43 | 'cstdlib', 'cstring', 'ctgmath', 'ctime', 'cuchar', 'cwchar', 44 | 'cwctype', 'cxxabi.h', 'deque', 'exception', 'execution', 45 | 'fenv.h', 'filesystem', 'forward_list', 'fstream', 46 | 'functional', 'future', 'initializer_list', 'iomanip', 'ios', 47 | 'iosfwd', 'iostream', 'istream', 'iterator', 'limits', 'list', 48 | 'locale', 'map', 'math.h', 'memory', 'memory_resource', 'mutex', 49 | 'new', 'numeric', 'optional', 'ostream', 'queue', 'random', 50 | 'ratio', 'regex', 'scoped_allocator', 'set', 'shared_mutex', 51 | 'sstream', 'stack', 'stdexcept', 'stdlib.h', 'streambuf', 52 | 'string', 'string_view', 'system_error', 'tgmath.h', 'thread', 53 | 'tuple', 'typeindex', 'typeinfo', 'type_traits', 54 | 'unordered_map', 'unordered_set', 'utility', 'valarray', 55 | 'variant', 'vector', 'version'] 56 | header_includes = "\n".join([f"#include <{x}>" for x in headers]) 57 | contents = f""" 58 | {header_includes} 59 | using namespace std; 60 | using namespace std::chrono_literals; 61 | using namespace std::complex_literals; 62 | using namespace std::string_literals; 63 | using namespace std::string_view_literals; 64 | int main() {{ 65 | {inp}; 66 | }} 67 | """ 68 | return piston("c++", "10.2.0", contents, filename=nick) 69 | 70 | 71 | @hook.command 72 | def lua(inp, nick=None): 73 | return piston("lua", "5.4.2", inp, filename=nick) 74 | 75 | 76 | @hook.command("cl") 77 | def lisp(inp, nick=None): 78 | return piston("lisp", "2.1.2", inp, filename=nick) 79 | 80 | 81 | @hook.command("elisp") 82 | @hook.command("el") 83 | def emacs(inp, nick=None): 84 | return piston("emacs", "27.1.0", inp, filename=nick) 85 | 86 | 87 | @hook.command("clj") 88 | def clojure(inp, nick=None): 89 | return piston("clojure", "1.10.3", inp, filename=nick) 90 | 91 | 92 | @hook.command("rs") 93 | def rust(inp, nick=None): 94 | contents = "func main() {" + inp + "}"; 95 | return piston("rust", "1.50.0", contents, filename=nick) 96 | 97 | 98 | @hook.command("py") 99 | @hook.command("py3") 100 | def python(inp, nick=None): 101 | return piston("python", "3.10.0", inp, filename=nick) 102 | 103 | 104 | @hook.command("py2") 105 | def python2(inp, nick=None): 106 | return piston("python2", "2.7.18", inp, filename=nick) 107 | 108 | 109 | @hook.command("rb") 110 | def ruby(inp, nick=None): 111 | return piston("ruby", "3.0.1", inp, filename=nick) 112 | 113 | 114 | @hook.command("ts") 115 | def typescript(inp, nick=None): 116 | return piston("typescript", "4.2.3", inp, filename=nick) 117 | 118 | 119 | @hook.command("js") 120 | @hook.command("node") 121 | def javascript(inp, nick=None): 122 | return piston("javascript", "16.3.0", inp, filename=nick) 123 | 124 | 125 | @hook.command 126 | def perl(inp, nick=None): 127 | return piston("perl", "5.26.1", inp, filename=nick) 128 | 129 | 130 | @hook.command 131 | def php(inp, nick=None): 132 | contents = "" 133 | return piston("php", "8.0.2", contents, filename=nick) 134 | 135 | 136 | @hook.command 137 | def swift(inp, nick=None): 138 | return piston("swift", "5.3.3", inp, filename=nick) 139 | 140 | 141 | if __name__ == "__main__": 142 | result = piston("py", "3.9.4", "print('test')") 143 | 144 | print(result) 145 | -------------------------------------------------------------------------------- /plugins/frinkiac.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from util import hook, http 4 | 5 | MORBOTRON_URL = "https://morbotron.com" 6 | FRINKIAC_URL = "https://frinkiac.com" 7 | MASTEROFALLSCIENCE_URL = "https://masterofallscience.com" 8 | 9 | SEARCH_ENDPOINT = "/api/search" 10 | CAPTIONS_ENDPOINT = "/api/caption" 11 | LINK_URL = "/caption" 12 | 13 | 14 | def get_frames(site_root, query): 15 | try: 16 | url = site_root + SEARCH_ENDPOINT 17 | content = http.get_json(url, q=query) 18 | except IOError: 19 | return None 20 | 21 | return content 22 | 23 | 24 | def get_caption(site_root, episode, timestamp): 25 | try: 26 | url = site_root + CAPTIONS_ENDPOINT 27 | content = http.get_json(url, e=episode, t=timestamp) 28 | except IOError: 29 | return None 30 | 31 | return content 32 | 33 | 34 | def get_response(site_root, input): 35 | frames = get_frames(site_root, input) 36 | 37 | if frames is None or len(frames) == 0: 38 | return 'no results' 39 | 40 | frame = frames[0] 41 | 42 | episode = frame['Episode'] 43 | timestamp = frame['Timestamp'] 44 | 45 | caption = get_caption(site_root, episode, timestamp) 46 | url = '{}/{}/{}'.format(site_root + LINK_URL, episode, timestamp) 47 | 48 | subtitles = caption['Subtitles'] 49 | if len(subtitles) == 0: 50 | return '{} - {}'.format(episode, url) 51 | 52 | subtitle = ' '.join(s['Content'] for s in subtitles) 53 | 54 | return '{} - {} {} '.format(subtitle, episode, url) 55 | 56 | 57 | @hook.command('simpsons') 58 | @hook.command() 59 | def frinkiac(inp): 60 | """.frinkiac -- Get a frame from The Simpsons.""" 61 | return get_response(FRINKIAC_URL, inp) 62 | 63 | 64 | @hook.command('futurama') 65 | @hook.command() 66 | def morbotron(inp): 67 | """.morbotron -- Get a frame from Futurama.""" 68 | return get_response(MORBOTRON_URL, inp) 69 | 70 | 71 | @hook.command('morty') 72 | @hook.command('rick') 73 | @hook.command() 74 | def rickandmorty(inp): 75 | """.rickandmorty -- Get a frame from Rick and Morty.""" 76 | return get_response(MASTEROFALLSCIENCE_URL, inp) 77 | 78 | 79 | @hook.regex(r"(?i)https://(frinkiac|morbotron|masterofallscience)\.com" 80 | r"/(caption|img)/S(\d{2})E(\d{2})/(\d+)") 81 | def lookup(match): 82 | domain = match.group(1) 83 | 84 | season_episode = 'S{}E{}'.format(match.group(3), match.group(4)) 85 | timestamp = match.group(5) 86 | 87 | urls = { 88 | 'frinkiac': FRINKIAC_URL, 89 | 'morbotron': MORBOTRON_URL, 90 | 'masterofallscience': MASTEROFALLSCIENCE_URL 91 | } 92 | 93 | site_root = urls[domain] 94 | caption = get_caption(site_root, season_episode, timestamp) 95 | 96 | episode = caption['Episode'] 97 | episode_key = episode['Key'] 98 | title = episode['Title'] 99 | 100 | subtitles = caption['Subtitles'] 101 | if len(subtitles) == 0: 102 | return '{} - {}'.format(episode_key, title) 103 | 104 | subtitle = ' '.join(s['Content'] for s in subtitles) 105 | 106 | return '{} - {}'.format(subtitle, episode_key) 107 | -------------------------------------------------------------------------------- /plugins/gcalc.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from util import hook, http 4 | 5 | 6 | @hook.command 7 | def calc(inp): 8 | """.calc -- returns Google Calculator result""" 9 | 10 | h = http.get_html("http://www.google.com/search", q=inp) 11 | 12 | m = h.xpath('//h2[@class="r"]/text()') 13 | 14 | if not m: 15 | return "could not calculate " + inp 16 | 17 | res = " ".join(m[0].split()) 18 | 19 | return res 20 | -------------------------------------------------------------------------------- /plugins/gif.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | from util import hook, http 4 | 5 | 6 | @hook.api_key("giphy") 7 | @hook.command("gif") 8 | @hook.command 9 | def giphy(inp, api_key=None): 10 | """.gif/.giphy -- returns first giphy search result""" 11 | url = "http://api.giphy.com/v1/gifs/search" 12 | try: 13 | response = http.get_json(url, q=inp, limit=10, api_key=api_key) 14 | except http.HTTPError as e: 15 | return e.msg 16 | 17 | results = response.get("data") 18 | if results: 19 | return random.choice(results).get("bitly_gif_url") 20 | else: 21 | return "no results found" 22 | -------------------------------------------------------------------------------- /plugins/google.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import random 4 | 5 | from util import hook, http 6 | 7 | 8 | def api_get(query, key, is_image=None, num=1): 9 | url = ( 10 | "https://www.googleapis.com/customsearch/v1?cx=007629729846476161907:ud5nlxktgcw" 11 | "&fields=items(title,link,snippet)&safe=off&nfpr=1" 12 | + ("&searchType=image" if is_image else "") 13 | ) 14 | return http.get_json(url, key=key, q=query, num=num) 15 | 16 | 17 | @hook.api_key("google") 18 | @hook.command("can i get a picture of") 19 | @hook.command("can you grab me a picture of") 20 | @hook.command("give me a print out of") 21 | @hook.command 22 | def gis(inp, api_key=None): 23 | """.gis -- finds an image using google images (safesearch off)""" 24 | 25 | parsed = api_get(inp, api_key, is_image=True, num=10) 26 | if "items" not in parsed: 27 | return "no images found" 28 | return random.choice(parsed["items"])["link"] 29 | 30 | 31 | @hook.api_key("google") 32 | @hook.command("g") 33 | @hook.command 34 | def google(inp, api_key=None): 35 | """.g/.google -- returns first google search result""" 36 | 37 | parsed = api_get(inp, api_key) 38 | if "items" not in parsed: 39 | return "no results found" 40 | 41 | out = '{link} -- \x02{title}\x02: "{snippet}"'.format(**parsed["items"][0]) 42 | out = " ".join(out.split()) 43 | 44 | if len(out) > 300: 45 | out = out[: out.rfind(" ")] + '..."' 46 | 47 | return out 48 | -------------------------------------------------------------------------------- /plugins/hackernews.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from util import http, hook 4 | 5 | 6 | @hook.regex(r"(?i)https://(?:www\.)?news\.ycombinator\.com\S*id=(\d+)") 7 | def hackernews(match): 8 | base_api = "https://hacker-news.firebaseio.com/v0/item/" 9 | entry = http.get_json(base_api + match.group(1) + ".json") 10 | 11 | if entry["type"] == "story": 12 | entry["title"] = http.unescape(entry["title"]) 13 | return "{title} by {by} with {score} points and {descendants} comments ({url})".format( 14 | **entry 15 | ) 16 | 17 | if entry["type"] == "comment": 18 | entry["text"] = http.unescape(entry["text"].replace("

", " // ")) 19 | return '"{text}" -- {by}'.format(**entry) 20 | -------------------------------------------------------------------------------- /plugins/hash.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | 3 | from util import hook 4 | 5 | 6 | @hook.command("md5") 7 | def hash_md5(inp): 8 | return hashlib.md5(inp.encode("utf-8")).hexdigest() 9 | 10 | 11 | @hook.command("sha1") 12 | def hash_sha1(inp): 13 | return hashlib.sha1(inp.encode("utf-8")).hexdigest() 14 | 15 | 16 | @hook.command("sha256") 17 | def hash_sha256(inp): 18 | return hashlib.sha256(inp.encode("utf-8")).hexdigest() 19 | 20 | 21 | @hook.command 22 | def hash(inp): 23 | """.hash -- returns hashes of """ 24 | 25 | hashes = [ 26 | (x, hashlib.__dict__[x](inp.encode("utf-8")).hexdigest()) 27 | for x in "md5 sha1 sha256".split() 28 | ] 29 | 30 | return ", ".join("%s: %s" % i for i in hashes) 31 | -------------------------------------------------------------------------------- /plugins/help.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from util import hook 4 | 5 | 6 | @hook.command(autohelp=False) 7 | def help(inp, bot=None, pm=None): 8 | ".help [command] -- gives a list of commands/help for a command" 9 | 10 | funcs = {} 11 | disabled = bot.config.get("disabled_plugins", []) 12 | disabled_comm = bot.config.get("disabled_commands", []) 13 | for command, (func, args) in bot.commands.items(): 14 | fn = re.match(r"^plugins.(.+).py$", func._filename) 15 | if fn.group(1).lower() not in disabled: 16 | if command not in disabled_comm: 17 | if func.__doc__ is not None: 18 | if func in funcs: 19 | if len(funcs[func]) < len(command): 20 | funcs[func] = command 21 | else: 22 | funcs[func] = command 23 | 24 | commands = dict((value, key) for key, value in iter(funcs.items())) 25 | 26 | if not inp: 27 | pm("available commands: " + " ".join(sorted(commands))) 28 | else: 29 | if inp in commands: 30 | pm(commands[inp].__doc__) 31 | -------------------------------------------------------------------------------- /plugins/imdb.py: -------------------------------------------------------------------------------- 1 | # IMDb lookup plugin by Ghetto Wizard (2011). 2 | from __future__ import unicode_literals 3 | 4 | from util import hook, http 5 | 6 | 7 | # http://www.omdbapi.com/apikey.aspx 8 | @hook.api_key("omdbapi") 9 | @hook.command 10 | def imdb(inp, api_key=None): 11 | """.imdb -- gets information about from IMDb""" 12 | 13 | if not api_key: 14 | return None 15 | 16 | content = http.get_json("https://www.omdbapi.com/", t=inp, apikey=api_key) 17 | 18 | if content["Response"] == "Movie Not Found": 19 | return "movie not found" 20 | elif content["Response"] == "True": 21 | content["URL"] = "http://www.imdb.com/title/%(imdbID)s" % content 22 | 23 | out = "\x02%(Title)s\x02 (%(Year)s) (%(Genre)s): %(Plot)s" 24 | if content["Runtime"] != "N/A": 25 | out += " \x02%(Runtime)s\x02." 26 | if content["imdbRating"] != "N/A" and content["imdbVotes"] != "N/A": 27 | out += " \x02%(imdbRating)s/10\x02 with \x02%(imdbVotes)s\x02 votes." 28 | out += " %(URL)s" 29 | return out % content 30 | else: 31 | return "unknown error" 32 | -------------------------------------------------------------------------------- /plugins/lastfm.py: -------------------------------------------------------------------------------- 1 | """ 2 | The Last.fm API key is retrieved from the bot config file. 3 | """ 4 | 5 | from util import hook, http 6 | 7 | 8 | api_url = "http://ws.audioscrobbler.com/2.0/?format=json" 9 | 10 | 11 | @hook.api_key("lastfm") 12 | @hook.command(autohelp=False) 13 | def lastfm(inp, chan="", nick="", reply=None, api_key=None, db=None): 14 | ".lastfm [dontsave] | @ -- gets current or last played " "track from lastfm" 15 | 16 | db.execute( 17 | "create table if not exists " 18 | "lastfm(chan, nick, user, primary key(chan, nick))" 19 | ) 20 | 21 | if inp[0:1] == "@": 22 | nick = inp[1:].strip() 23 | user = None 24 | dontsave = True 25 | else: 26 | user = inp 27 | 28 | dontsave = user.endswith(" dontsave") 29 | if dontsave: 30 | user = user[:-9].strip().lower() 31 | 32 | if not user: 33 | user = db.execute( 34 | "select user from lastfm where chan=? and nick=lower(?)", (chan, nick) 35 | ).fetchone() 36 | if not user: 37 | return lastfm.__doc__ 38 | user = user[0] 39 | 40 | response = http.get_json( 41 | api_url, method="user.getrecenttracks", api_key=api_key, user=user, limit=1 42 | ) 43 | 44 | if "error" in response: 45 | return "error: %s" % response["message"] 46 | 47 | if ( 48 | not "track" in response["recenttracks"] 49 | or len(response["recenttracks"]["track"]) == 0 50 | ): 51 | return "no recent tracks for user \x02%s\x0F found" % user 52 | 53 | tracks = response["recenttracks"]["track"] 54 | 55 | if type(tracks) == list: 56 | # if the user is listening to something, the tracks entry is a list 57 | # the first item is the current track 58 | track = tracks[0] 59 | status = "current track" 60 | elif type(tracks) == dict: 61 | # otherwise, they aren't listening to anything right now, and 62 | # the tracks entry is a dict representing the most recent track 63 | track = tracks 64 | status = "last track" 65 | else: 66 | return "error parsing track listing" 67 | 68 | title = track["name"] 69 | album = track["album"]["#text"] 70 | artist = track["artist"]["#text"] 71 | 72 | ret = "\x02%s\x0F's %s - \x02%s\x0f" % (user, status, title) 73 | if artist: 74 | ret += " by \x02%s\x0f" % artist 75 | if album: 76 | ret += " on \x02%s\x0f" % album 77 | 78 | reply(ret) 79 | 80 | if inp and not dontsave: 81 | db.execute( 82 | "insert or replace into lastfm(chan, nick, user) " "values (?, ?, ?)", 83 | (chan, nick.lower(), inp), 84 | ) 85 | db.commit() 86 | -------------------------------------------------------------------------------- /plugins/log.py: -------------------------------------------------------------------------------- 1 | """ 2 | log.py: written by Scaevolus 2009 3 | """ 4 | 5 | from __future__ import print_function, unicode_literals 6 | 7 | from builtins import str 8 | import os 9 | import codecs 10 | import time 11 | import re 12 | 13 | from util import hook 14 | 15 | 16 | log_fds = {} # '%(net)s %(chan)s' : (filename, fd) 17 | 18 | timestamp_format = "%H:%M:%S" 19 | 20 | formats = { 21 | "PRIVMSG": "<%(nick)s> %(msg)s", 22 | "PART": "-!- %(nick)s [%(user)s@%(host)s] has left %(chan)s", 23 | "JOIN": "-!- %(nick)s [%(user)s@%(host)s] has joined %(param0)s", 24 | "MODE": "-!- mode/%(chan)s [%(param_tail)s] by %(nick)s", 25 | "KICK": "-!- %(param1)s was kicked from %(chan)s by %(nick)s [%(msg)s]", 26 | "TOPIC": "-!- %(nick)s changed the topic of %(chan)s to: %(msg)s", 27 | "QUIT": "-!- %(nick)s has quit [%(msg)s]", 28 | "PING": "", 29 | "NOTICE": "", 30 | } 31 | 32 | ctcp_formats = {"ACTION": "* %(nick)s %(ctcpmsg)s"} 33 | 34 | irc_color_re = re.compile(r"(\x03(\d+,\d+|\d)|[\x0f\x02\x16\x1f])") 35 | 36 | 37 | def get_log_filename(dir, server, chan): 38 | return os.path.join( 39 | dir, "log", gmtime("%Y"), server, (gmtime("%%s.%m-%d.log") % chan).lower() 40 | ) 41 | 42 | 43 | def gmtime(format): 44 | return time.strftime(format, time.gmtime()) 45 | 46 | 47 | def beautify(input): 48 | format = formats.get(input.command, "%(raw)s") 49 | args = dict(input) 50 | 51 | leng = len(args["paraml"]) 52 | for n, p in enumerate(args["paraml"]): 53 | args["param" + str(n)] = p 54 | args["param_" + str(abs(n - leng))] = p 55 | 56 | args["param_tail"] = " ".join(args["paraml"][1:]) 57 | args["msg"] = irc_color_re.sub("", args["msg"]) 58 | 59 | if input.command == "PRIVMSG" and input.msg.count("\x01") >= 2: 60 | ctcp = input.msg.split("\x01", 2)[1].split(" ", 1) 61 | if len(ctcp) == 1: 62 | ctcp += [""] 63 | args["ctcpcmd"], args["ctcpmsg"] = ctcp 64 | format = ctcp_formats.get( 65 | args["ctcpcmd"], 66 | "%(nick)s [%(user)s@%(host)s] requested unknown CTCP " 67 | "%(ctcpcmd)s from %(chan)s: %(ctcpmsg)s", 68 | ) 69 | 70 | return format % args 71 | 72 | 73 | def get_log_fd(dir, server, chan): 74 | fn = get_log_filename(dir, server, chan) 75 | cache_key = "%s %s" % (server, chan) 76 | filename, fd = log_fds.get(cache_key, ("", 0)) 77 | 78 | if fn != filename: # we need to open a file for writing 79 | if fd != 0: # is a valid fd 80 | fd.flush() 81 | fd.close() 82 | dir = os.path.split(fn)[0] 83 | if not os.path.exists(dir): 84 | os.makedirs(dir) 85 | fd = codecs.open(fn, "a", "utf-8") 86 | log_fds[cache_key] = (fn, fd) 87 | 88 | return fd 89 | 90 | 91 | @hook.singlethread 92 | @hook.event("*") 93 | def log(paraml, input=None, bot=None): 94 | timestamp = gmtime(timestamp_format) 95 | 96 | if input.command == "QUIT": # these are temporary fixes until proper 97 | input.chan = "quit" # presence tracking is implemented 98 | if input.command == "NICK": 99 | input.chan = "nick" 100 | 101 | beau = beautify(input) 102 | 103 | if beau == "": # don't log this 104 | return 105 | 106 | if input.chan: 107 | fd = get_log_fd(bot.persist_dir, input.server, input.chan) 108 | fd.write(timestamp + " " + beau + "\n") 109 | 110 | print(timestamp, input.chan, beau) 111 | -------------------------------------------------------------------------------- /plugins/mastodon.py: -------------------------------------------------------------------------------- 1 | from time import strftime 2 | from datetime import datetime 3 | 4 | from util import hook, http 5 | 6 | @hook.regex(r"(?Phttps)?://(?P[.a-zA-Z_\-0-9]+)/(#!/)?(@\w+)(@[a-zA-Z0-9._\-]+)?/(?P\d+)") 7 | def show_toot(match): 8 | scheme = match.group("scheme") 9 | domain = match.group("domain") 10 | id = match.group("id") 11 | request_url = f"{scheme}://{domain}/api/v1/statuses/{id}" 12 | toot = http.get_json(request_url) 13 | time = toot["created_at"] 14 | time = datetime.fromisoformat(time.replace("Z", "+00:00")).strftime("%Y-%m-%d %H:%M:%S") 15 | username = toot["account"]["username"] 16 | text = http.unescape(toot["content"].replace("

", " ").strip()) 17 | media = "" 18 | for attachment in toot["media_attachments"]: 19 | if attachment["remote_url"]: 20 | media = media + " " + attachment["remote_url"] 21 | else: 22 | media = media + " " + attachment["url"] 23 | media = media.strip() 24 | 25 | return f"{time} \x02{username}\x02: {text} {media}".strip() 26 | -------------------------------------------------------------------------------- /plugins/mem.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | 4 | from util import hook 5 | 6 | 7 | @hook.command(autohelp=False) 8 | def mem(inp): 9 | ".mem -- returns bot's current memory usage -- linux/windows only" 10 | 11 | if os.name == "posix": 12 | status_file = open("/proc/%d/status" % os.getpid()).read() 13 | line_pairs = re.findall(r"^(\w+):\s*(.*)\s*$", status_file, re.M) 14 | status = dict(line_pairs) 15 | keys = "VmSize VmLib VmData VmExe VmRSS VmStk".split() 16 | return ", ".join(key + ":" + status[key] for key in keys) 17 | 18 | elif os.name == "nt": 19 | cmd = 'tasklist /FI "PID eq %s" /FO CSV /NH' % os.getpid() 20 | out = os.popen(cmd).read() 21 | 22 | total = 0 23 | for amount in re.findall(r"([,0-9]+) K", out): 24 | total += int(amount.replace(",", "")) 25 | 26 | return "memory usage: %d kB" % total 27 | 28 | return mem.__doc__ 29 | -------------------------------------------------------------------------------- /plugins/metacritic.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function, unicode_literals 2 | 3 | # metacritic.com scraper 4 | 5 | import re 6 | from urllib.error import HTTPError 7 | 8 | from util import hook, http 9 | 10 | SCORE_CLASSES = { 11 | "\x0304": ["negative", "bad", "score_terrible", "score_unfavorable"], 12 | "\x0308": ["mixed", "forty", "fifty", "score_mixed"], 13 | "\x0303": [ 14 | "sixtyone", 15 | "seventyfive", 16 | "good", 17 | "positive", 18 | "score_favorable", 19 | "score_outstanding", 20 | ], 21 | } 22 | 23 | 24 | def get_score_color(element_classes): 25 | for (color, score_classes) in SCORE_CLASSES.items(): 26 | for score_class in score_classes: 27 | if score_class in element_classes: 28 | return color 29 | 30 | 31 | @hook.command("mc") 32 | def metacritic(inp): 33 | ".mc [all|movie|tv|album|x360|ps3|pc|gba|ds|3ds|wii|vita|wiiu|xone|ps4] -- gets rating for" " <title> from metacritic on the specified medium" 34 | 35 | # if the results suck, it's metacritic's fault 36 | 37 | args = inp.strip() 38 | 39 | game_platforms = ( 40 | "x360", 41 | "ps3", 42 | "pc", 43 | "gba", 44 | "ds", 45 | "3ds", 46 | "wii", 47 | "vita", 48 | "wiiu", 49 | "xone", 50 | "ps4", 51 | ) 52 | all_platforms = game_platforms + ("all", "movie", "tv", "album") 53 | 54 | try: 55 | plat, title = args.split(" ", 1) 56 | if plat not in all_platforms: 57 | # raise the ValueError so that the except block catches it 58 | # in this case, or in the case of the .split above raising the 59 | # ValueError, we want the same thing to happen 60 | raise ValueError 61 | except ValueError: 62 | plat = "all" 63 | title = args 64 | 65 | cat = "game" if plat in game_platforms else plat 66 | 67 | title_safe = http.quote_plus(title) 68 | 69 | url = "http://www.metacritic.com/search/%s/%s/results" % (cat, title_safe) 70 | 71 | print(url) 72 | 73 | try: 74 | doc = http.get_html(url) 75 | except HTTPError: 76 | return "error fetching results" 77 | 78 | # get the proper result element we want to pull data from 79 | 80 | result = None 81 | 82 | if not doc.find_class("query_results"): 83 | return "no results found" 84 | 85 | # if they specified an invalid search term, the input box will be empty 86 | if doc.get_element_by_id("primary_search_box").value == "": 87 | return "invalid search term" 88 | 89 | if plat not in game_platforms: 90 | # for [all] results, or non-game platforms, get the first result 91 | result = doc.find_class("result first_result")[0] 92 | 93 | # find the platform, if it exists 94 | result_type = result.find_class("result_type") 95 | if result_type: 96 | 97 | # if the result_type div has a platform div, get that one 98 | platform_div = result_type[0].find_class("platform") 99 | if platform_div: 100 | plat = platform_div[0].text_content().strip() 101 | else: 102 | # otherwise, use the result_type text_content 103 | plat = result_type[0].text_content().strip() 104 | 105 | else: 106 | # for games, we want to pull the first result with the correct 107 | # platform 108 | results = doc.find_class("result") 109 | for res in results: 110 | result_plat = res.find_class("platform")[0].text_content().strip() 111 | if result_plat == plat.upper(): 112 | result = res 113 | break 114 | 115 | if not result: 116 | return "no results found" 117 | 118 | # get the name, release date, and score from the result 119 | product_title_element = result.find_class("product_title")[0] 120 | 121 | review = { 122 | "platform": plat.upper(), 123 | "title": product_title_element.text_content().strip(), 124 | "link": "http://metacritic.com" 125 | + product_title_element.find("a").attrib["href"], 126 | } 127 | 128 | try: 129 | score_element = result.find_class("metascore_w")[0] 130 | 131 | review["score"] = score_element.text_content().strip() 132 | 133 | review["score_color"] = get_score_color(score_element.classes) 134 | except IndexError: 135 | review["score"] = "unknown" 136 | 137 | return "[{platform}] {title} - \x02{score_color}{score}\x0f - {link}".format( 138 | **review 139 | ) 140 | -------------------------------------------------------------------------------- /plugins/misc.py: -------------------------------------------------------------------------------- 1 | import socket 2 | import subprocess 3 | import time 4 | 5 | from util import hook, http 6 | 7 | socket.setdefaulttimeout(10) # global setting 8 | 9 | 10 | def get_version(): 11 | try: 12 | stdout = subprocess.check_output(["git", "log", "--format=%h"]) 13 | except: 14 | revnumber = 0 15 | shorthash = "????" 16 | else: 17 | revs = stdout.splitlines() 18 | revnumber = len(revs) 19 | shorthash = revs[0] 20 | 21 | shorthash = shorthash.decode("utf-8") 22 | 23 | http.ua_skybot = "Skybot/r%d %s (http://github.com/rmmh/skybot)" % ( 24 | revnumber, 25 | shorthash, 26 | ) 27 | 28 | return shorthash, revnumber 29 | 30 | 31 | # autorejoin channels 32 | @hook.event("KICK") 33 | def rejoin(paraml, conn=None): 34 | if paraml[1] == conn.nick: 35 | if paraml[0].lower() in conn.channels: 36 | conn.join(paraml[0]) 37 | 38 | 39 | # join channels when invited 40 | @hook.event("INVITE") 41 | def invite(paraml, conn=None): 42 | conn.join(paraml[-1]) 43 | 44 | 45 | @hook.event("004") 46 | def onjoin(paraml, conn=None): 47 | # identify to services 48 | nickserv_password = conn.nickserv_password 49 | nickserv_name = conn.nickserv_name 50 | nickserv_command = conn.nickserv_command 51 | if nickserv_password: 52 | conn.msg(nickserv_name, nickserv_command % nickserv_password) 53 | time.sleep(1) 54 | 55 | # set mode on self 56 | mode = conn.user_mode 57 | if mode: 58 | conn.cmd("MODE", [conn.nick, mode]) 59 | 60 | conn.join_channels() 61 | 62 | # set user-agent as a side effect of reading the version 63 | get_version() 64 | 65 | 66 | @hook.regex(r"^\x01VERSION\x01$") 67 | def version(inp, notice=None): 68 | ident, rev = get_version() 69 | notice( 70 | "\x01VERSION skybot %s r%d - http://github.com/rmmh/" 71 | "skybot/\x01" % (ident, rev) 72 | ) 73 | -------------------------------------------------------------------------------- /plugins/mtg.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | from builtins import range 3 | from util import hook, http 4 | import random 5 | 6 | 7 | def card_search(name): 8 | matching_cards = http.get_json( 9 | "https://api.magicthegathering.io/v1/cards", name=name 10 | ) 11 | for card in matching_cards["cards"]: 12 | if card["name"].lower() == name.lower(): 13 | return card 14 | return random.choice(matching_cards["cards"]) 15 | 16 | 17 | @hook.command 18 | def mtg(inp, say=None): 19 | """.mtg <name> - Searches for Magic the Gathering card given <name>""" 20 | 21 | try: 22 | card = card_search(inp) 23 | except IndexError: 24 | return "Card not found." 25 | symbols = { 26 | "{0}": "0", 27 | "{1}": "1", 28 | "{2}": "2", 29 | "{3}": "3", 30 | "{4}": "4", 31 | "{5}": "5", 32 | "{6}": "6", 33 | "{7}": "7", 34 | "{8}": "8", 35 | "{9}": "9", 36 | "{10}": "10", 37 | "{11}": "11", 38 | "{12}": "12", 39 | "{13}": "13", 40 | "{14}": "14", 41 | "{15}": "15", 42 | "{16}": "16", 43 | "{17}": "17", 44 | "{18}": "18", 45 | "{19}": "19", 46 | "{20}": "20", 47 | "{T}": "\u27F3", 48 | "{S}": "\u2744", 49 | "{Q}": "\u21BA", 50 | "{C}": "\u27E1", 51 | "{W}": "W", 52 | "{U}": "U", 53 | "{B}": "B", 54 | "{R}": "R", 55 | "{G}": "G", 56 | "{W/P}": "\u03D5", 57 | "{U/P}": "\u03D5", 58 | "{B/P}": "\u03D5", 59 | "{R/P}": "\u03D5", 60 | "{G/P}": "\u03D5", 61 | "{X}": "X", 62 | "\n": " ", 63 | } 64 | results = { 65 | "name": card["name"], 66 | "type": card["type"], 67 | "cost": card.get("manaCost", ""), 68 | "text": card.get("text", ""), 69 | "power": card.get("power"), 70 | "toughness": card.get("toughness"), 71 | "loyalty": card.get("loyalty"), 72 | "multiverseid": card.get("multiverseid"), 73 | } 74 | 75 | for fragment, rep in symbols.items(): 76 | results["text"] = results["text"].replace(fragment, rep) 77 | results["cost"] = results["cost"].replace(fragment, rep) 78 | 79 | template = ["{name} -"] 80 | template.append("{type}") 81 | template.append("- {cost} |") 82 | if results["loyalty"]: 83 | template.append("{loyalty} Loyalty |") 84 | if results["power"]: 85 | template.append("{power}/{toughness} |") 86 | template.append( 87 | "{text} | http://gatherer.wizards.com/Pages/Card/Details.aspx?multiverseid={multiverseid}" 88 | ) 89 | 90 | return " ".join(template).format(**results) 91 | 92 | 93 | if __name__ == "__main__": 94 | print(card_search("Black Lotus")) 95 | print(mtg("Black Lotus")) 96 | -------------------------------------------------------------------------------- /plugins/pre.py: -------------------------------------------------------------------------------- 1 | from time import gmtime 2 | import re 3 | 4 | from util import hook, http 5 | 6 | 7 | PRE_DB_SEARCH_URL = "https://pr3.us/search.php" 8 | PRE_DB_RE_NOT_FOUND = re.compile("^Nothing found for: .+$") 9 | 10 | 11 | def get_predb_release(release_name): 12 | timestamp = gmtime() 13 | 14 | try: 15 | # Without accept headers, the underlying 16 | # site falls apart for some reason 17 | h = http.get_html( 18 | PRE_DB_SEARCH_URL, 19 | search=release_name, 20 | ts=timestamp, 21 | pretimezone=0, 22 | timezone=0, 23 | headers={"Accept": "*/*", "Accept-Language": "en-US,en;q=0.5"}, 24 | ) 25 | except http.HTTPError: 26 | return None 27 | 28 | results = h.xpath("//tr") 29 | 30 | if not results: 31 | return None 32 | 33 | first_result = results[0] 34 | 35 | if PRE_DB_RE_NOT_FOUND.match(first_result.text_content()): 36 | return None 37 | 38 | section_field, name_field, _, size_field, ts_field = first_result.xpath("./td")[:5] 39 | 40 | date, time = ts_field.text_content().split() 41 | 42 | return { 43 | "date": date, 44 | "time": time, 45 | "section": section_field.text_content(), 46 | "name": name_field.text_content().strip(), 47 | "size": size_field.text_content(), 48 | } 49 | 50 | 51 | @hook.command 52 | def predb(inp): 53 | """.predb <query> -- searches scene releases""" 54 | 55 | release = get_predb_release(inp) 56 | 57 | if not release: 58 | return "zero results" 59 | 60 | if release["size"]: 61 | release["size"] = " - %s" % release["size"] 62 | 63 | return "{date} - {section} - {name}{size}".format(**release) 64 | -------------------------------------------------------------------------------- /plugins/quote.py: -------------------------------------------------------------------------------- 1 | import random 2 | import re 3 | import time 4 | import unittest 5 | 6 | from util import hook 7 | 8 | 9 | def add_quote(db, chan, nick, add_nick, msg): 10 | db.execute( 11 | """insert or fail into quote (chan, nick, add_nick, 12 | msg, time) values(?,?,?,?,?)""", 13 | (chan, nick, add_nick, msg, time.time()), 14 | ) 15 | db.commit() 16 | 17 | 18 | def del_quote(db, chan, nick, msg): 19 | updated = db.execute( 20 | """update quote set deleted = 1 where 21 | chan=? and lower(nick)=lower(?) and msg=?""", 22 | (chan, nick, msg), 23 | ) 24 | db.commit() 25 | 26 | if updated.rowcount == 0: 27 | return False 28 | else: 29 | return True 30 | 31 | 32 | def get_quotes_by_nick(db, chan, nick): 33 | return db.execute( 34 | "select time, nick, msg from quote where deleted!=1 " 35 | "and chan=? and lower(nick)=lower(?) order by time", 36 | (chan, nick), 37 | ).fetchall() 38 | 39 | 40 | def get_quotes_by_chan(db, chan): 41 | return db.execute( 42 | "select time, nick, msg from quote where deleted!=1 " 43 | "and chan=? order by time", 44 | (chan,), 45 | ).fetchall() 46 | 47 | 48 | def get_quote_by_id(db, num): 49 | return db.execute( 50 | "select time, nick, msg from quote where deleted!=1 " "and rowid=?", (num,) 51 | ).fetchall() 52 | 53 | 54 | def format_quote(q, num, n_quotes): 55 | ctime, nick, msg = q 56 | return "[%d/%d] %s <%s> %s" % ( 57 | num, 58 | n_quotes, 59 | time.strftime("%Y-%m-%d", time.gmtime(ctime)), 60 | nick, 61 | msg, 62 | ) 63 | 64 | 65 | @hook.command("q") 66 | @hook.command 67 | def quote(inp, nick="", chan="", db=None, admin=False): 68 | ".q/.quote [#chan] [nick] [#n]/.quote add|delete <nick> <msg> -- gets " "random or [#n]th quote by <nick> or from <#chan>/adds or deletes " "quote" 69 | 70 | db.execute( 71 | "create table if not exists quote" 72 | "(chan, nick, add_nick, msg, time real, deleted default 0, " 73 | "primary key (chan, nick, msg))" 74 | ) 75 | db.commit() 76 | 77 | add = re.match(r"add[^\w@]+(\S+?)>?\s+(.*)", inp, re.I) 78 | delete = re.match(r"delete[^\w@]+(\S+?)>?\s+(.*)", inp, re.I) 79 | retrieve = re.match(r"(\S+)(?:\s+#?(-?\d+))?$", inp) 80 | retrieve_chan = re.match(r"(#\S+)\s+(\S+)(?:\s+#?(-?\d+))?$", inp) 81 | retrieve_id = re.match(r"(\d+)$", inp) 82 | 83 | if add: 84 | quoted_nick, msg = add.groups() 85 | try: 86 | add_quote(db, chan, quoted_nick, nick, msg) 87 | db.commit() 88 | except db.IntegrityError: 89 | return "message already stored, doing nothing." 90 | return "quote added." 91 | if delete: 92 | if not admin: 93 | return "only admins can delete quotes" 94 | quoted_nick, msg = delete.groups() 95 | if del_quote(db, chan, quoted_nick, msg): 96 | return "deleted quote '%s'" % msg 97 | else: 98 | return "found no matching quotes to delete" 99 | elif retrieve_id: 100 | (quote_id,) = retrieve_id.groups() 101 | num = 1 102 | quotes = get_quote_by_id(db, quote_id) 103 | elif retrieve: 104 | select, num = retrieve.groups() 105 | if select.startswith("#"): 106 | quotes = get_quotes_by_chan(db, select) 107 | else: 108 | quotes = get_quotes_by_nick(db, chan, select) 109 | elif retrieve_chan: 110 | chan, nick, num = retrieve_chan.groups() 111 | 112 | quotes = get_quotes_by_nick(db, chan, nick) 113 | else: 114 | return quote.__doc__ 115 | 116 | if num: 117 | num = int(num) 118 | 119 | n_quotes = len(quotes) 120 | 121 | if not n_quotes: 122 | return "no quotes found" 123 | 124 | if num: 125 | if num > n_quotes or (num < 0 and num < -n_quotes): 126 | return "I only have %d quote%s for %s" % ( 127 | n_quotes, 128 | ("s", "")[n_quotes == 1], 129 | select, 130 | ) 131 | elif num < 0: 132 | selected_quote = quotes[num] 133 | num = n_quotes + num + 1 134 | else: 135 | selected_quote = quotes[num - 1] 136 | else: 137 | num = random.randint(1, n_quotes) 138 | selected_quote = quotes[num - 1] 139 | 140 | return format_quote(selected_quote, num, n_quotes) 141 | 142 | 143 | class QuoteTest(unittest.TestCase): 144 | def setUp(self): 145 | import sqlite3 146 | 147 | self.db = sqlite3.connect(":memory:") 148 | 149 | quote("", db=self.db) # init DB 150 | 151 | def quote(self, arg, **kwargs): 152 | return quote(arg, chan="#test", nick="alice", db=self.db, **kwargs) 153 | 154 | def add_quote(self, msg=""): 155 | add_quote( 156 | self.db, 157 | "#test", 158 | "Socrates", 159 | "Plato", 160 | msg 161 | or "Education is the kindling of a flame," " not the filling of a vessel.", 162 | ) 163 | 164 | def test_retrieve_chan(self): 165 | self.add_quote() 166 | assert "<Socrates> Education" in self.quote("#test") 167 | 168 | def test_retrieve_user(self): 169 | self.add_quote() 170 | assert "<Socrates> Education" in self.quote("socrates") 171 | 172 | def test_no_quotes(self): 173 | assert "no quotes found" in self.quote("#notachan") 174 | 175 | def test_quote_too_high(self): 176 | self.add_quote() 177 | assert "I only have 1 quote for #test" in self.quote("#test 4") 178 | 179 | def test_add(self): 180 | self.quote("add <someone> witty phrase") 181 | assert "witty" in self.quote("#test") 182 | 183 | def test_add_twice(self): 184 | self.quote("add <someone> lol") 185 | assert "already stored" in self.quote("add <someone> lol") 186 | 187 | def test_del_not_admin(self): 188 | assert "only admins" in self.quote("delete whoever 4") 189 | 190 | def test_del_not_exists(self): 191 | assert "found no matching" in self.quote("delete whoever 4", admin=True) 192 | 193 | def test_del(self): 194 | self.add_quote("hi") 195 | assert "deleted quote 'hi'" in self.quote("delete socrates hi", admin=True) 196 | 197 | def test_retrieve_id(self): 198 | self.add_quote() 199 | assert "Education is" in self.quote("1") 200 | 201 | def test_retrieve_chan_user(self): 202 | self.add_quote() 203 | assert "Education" in self.quote("#test socrates") 204 | assert "Education" in self.quote("#test socrates 1") 205 | 206 | def test_nth(self): 207 | self.add_quote("first quote") 208 | self.add_quote("second quote") 209 | self.add_quote("third quote") 210 | self.add_quote("fourth quote") 211 | assert "third" in self.quote("socrates -2") 212 | assert "only have 4" in self.quote("socrates -9") 213 | 214 | 215 | if __name__ == "__main__": 216 | unittest.main() 217 | -------------------------------------------------------------------------------- /plugins/religion.py: -------------------------------------------------------------------------------- 1 | from util import hook, http 2 | 3 | # https://api.esv.org/account/create-application/ 4 | @hook.api_key("bible") 5 | @hook.command("god") 6 | @hook.command 7 | def bible(inp, api_key=None): 8 | ".bible <passage> -- gets <passage> from the Bible (ESV)" 9 | 10 | base_url = ( 11 | "https://api.esv.org/v3/passage/text/?" 12 | "include-headings=false&" 13 | "include-passage-horizontal-lines=false&" 14 | "include-heading-horizontal-lines=false&" 15 | "include-passage-references=false&" 16 | "include-short-copyright=false&" 17 | "include-footnotes=false&" 18 | ) 19 | 20 | text = http.get_json(base_url, q=inp, headers={"Authorization": "Token " + api_key}) 21 | 22 | text = " ".join(text["passages"]).strip() 23 | 24 | if len(text) > 400: 25 | text = text[: text.rfind(" ", 0, 400)] + "..." 26 | 27 | return text 28 | 29 | 30 | @hook.command("allah") 31 | @hook.command 32 | def koran(inp): # Koran look-up plugin by Ghetto Wizard 33 | ".koran <chapter.verse> -- gets <chapter.verse> from the Koran" 34 | 35 | url = "http://quod.lib.umich.edu/cgi/k/koran/koran-idx?type=simple" 36 | 37 | results = http.get_html(url, q1=inp).xpath("//li") 38 | 39 | if not results: 40 | return "No results for " + inp 41 | 42 | return results[0].text_content() 43 | -------------------------------------------------------------------------------- /plugins/rottentomatoes.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import json 4 | import re 5 | 6 | from util import http, hook 7 | 8 | 9 | MOVIE_SEARCH_URL = "https://www.rottentomatoes.com/api/private/v2.0/search/" 10 | MOVIE_PAGE_URL = "https://www.rottentomatoes.com/m/%s" 11 | 12 | 13 | def get_rottentomatoes_data(movie_id): 14 | if movie_id.startswith("/m/"): 15 | movie_id = movie_id[3:] 16 | 17 | document = http.get_html(MOVIE_PAGE_URL % movie_id) 18 | 19 | # JSON for the page is stored in the script with ID 'jsonLdSchema' 20 | # So we can pull that for tons of information. 21 | ld_schema_element = document.xpath("//script[@type='application/ld+json']")[0] 22 | ld_schema = json.loads(ld_schema_element.text_content()) 23 | 24 | scripts = "\n".join(document.xpath("//script/text()")) 25 | score_info = json.loads(re.search(r"scoreInfo = (.*);", scripts).group(1))[ 26 | "tomatometerAllCritics" 27 | ] 28 | 29 | try: 30 | audience_score = document.xpath( 31 | '//span[contains(@class, "audience") and contains(@class, "rating")]/text()' 32 | )[0].strip() 33 | except IndexError: 34 | audience_score = "" 35 | 36 | return { 37 | "title": ld_schema["name"], 38 | "critics_score": score_info["score"], 39 | "audience_score": audience_score, 40 | "fresh": score_info["freshCount"], 41 | "rotten": score_info["rottenCount"], 42 | "url": MOVIE_PAGE_URL % movie_id, 43 | } 44 | 45 | 46 | def search_rottentomatoes(inp): 47 | results = http.get_json(MOVIE_SEARCH_URL, limit=1, q=inp) 48 | 49 | if not results or not results["movieCount"]: 50 | return None 51 | 52 | return results["movies"][0]["url"] 53 | 54 | 55 | @hook.command("rt") 56 | @hook.command 57 | def rottentomatoes(inp): 58 | ".rt <title> -- gets ratings for <title> from Rotten Tomatoes" 59 | 60 | movie_id = search_rottentomatoes(inp) 61 | 62 | if not movie_id: 63 | return "no results" 64 | 65 | movie = get_rottentomatoes_data(movie_id) 66 | 67 | return ( 68 | "{title} - critics: \x02{critics_score}%\x02 " 69 | "({fresh}\u2191{rotten}\u2193) " 70 | "audience: \x02{audience_score}\x02 - {url}" 71 | ).format(**movie) 72 | -------------------------------------------------------------------------------- /plugins/seen.py: -------------------------------------------------------------------------------- 1 | " seen.py: written by sklnd in about two beers July 2009" 2 | 3 | from builtins import object 4 | import time 5 | import unittest 6 | 7 | from util import hook, timesince 8 | 9 | 10 | def db_init(db): 11 | "check to see that our db has the the seen table and return a connection." 12 | db.execute( 13 | "create table if not exists seen(name, time, quote, chan, " 14 | "primary key(name, chan))" 15 | ) 16 | db.commit() 17 | 18 | 19 | @hook.singlethread 20 | @hook.event("PRIVMSG", ignorebots=False) 21 | def seeninput(paraml, input=None, db=None, bot=None): 22 | db_init(db) 23 | db.execute( 24 | "insert or replace into seen(name, time, quote, chan)" "values(?,?,?,?)", 25 | (input.nick.lower(), time.time(), input.msg, input.chan), 26 | ) 27 | db.commit() 28 | 29 | 30 | @hook.command 31 | def seen(inp, nick="", chan="", db=None, input=None): 32 | ".seen <nick> -- Tell when a nickname was last in active in irc" 33 | 34 | inp = inp.lower() 35 | 36 | if input.conn.nick.lower() == inp: 37 | # user is looking for us, being a smartass 38 | return "You need to get your eyes checked." 39 | 40 | if inp == nick.lower(): 41 | return "Have you looked in a mirror lately?" 42 | 43 | db_init(db) 44 | 45 | last_seen = db.execute( 46 | "select name, time, quote from seen where" " name = ? and chan = ?", (inp, chan) 47 | ).fetchone() 48 | 49 | if last_seen: 50 | reltime = timesince.timesince(last_seen[1]) 51 | if last_seen[2][0:1] == "\x01": 52 | return "%s was last seen %s ago: *%s %s*" % ( 53 | inp, 54 | reltime, 55 | inp, 56 | last_seen[2][8:-1], 57 | ) 58 | else: 59 | return "%s was last seen %s ago saying: %s" % (inp, reltime, last_seen[2]) 60 | else: 61 | return "I've never seen %s" % inp 62 | 63 | 64 | class SeenTest(unittest.TestCase): 65 | class Mock(object): 66 | def __init__(self, **kwargs): 67 | self.__dict__.update(kwargs) 68 | 69 | def setUp(self): 70 | import sqlite3 71 | 72 | self.db = sqlite3.connect(":memory:") 73 | 74 | def seeninput(self, nick, msg, chan="#test"): 75 | seeninput(None, db=self.db, input=self.Mock(nick=nick, msg=msg, chan=chan)) 76 | 77 | def seen(self, inp, nick="bob", chan="#test", bot_nick="skybot"): 78 | return seen( 79 | inp, 80 | nick=nick, 81 | chan=chan, 82 | db=self.db, 83 | input=self.Mock(conn=self.Mock(nick=bot_nick)), 84 | ) 85 | 86 | def test_missing(self): 87 | assert "I've never seen nemo" in self.seen("NEMO") 88 | 89 | def test_seen(self): 90 | self.seeninput("nemo", "witty banter") 91 | assert "nemo was last seen" in self.seen("nemo") 92 | assert "witty banter" in self.seen("nemo") 93 | 94 | def test_seen_missing_channel(self): 95 | self.seeninput("nemo", "msg", chan="#secret") 96 | assert "never seen" in self.seen("nemo") 97 | 98 | def test_seen_ctcp(self): 99 | self.seeninput("nemo", "\x01ACTION test lol\x01") 100 | assert self.seen("nemo").endswith("ago: *nemo test lol*") 101 | 102 | def test_snark_eyes(self): 103 | assert "eyes checked" in self.seen("skybot", bot_nick="skybot") 104 | 105 | def test_snark_mirror(self): 106 | assert "mirror" in self.seen("bob", nick="bob") 107 | -------------------------------------------------------------------------------- /plugins/sieve.py: -------------------------------------------------------------------------------- 1 | from builtins import map 2 | import re 3 | 4 | from util import hook 5 | 6 | 7 | @hook.sieve 8 | def sieve_suite(bot, input, func, kind, args): 9 | if ( 10 | input.command == "PRIVMSG" 11 | and bot.config.get("ignorebots", True) 12 | and input.nick.lower()[-3:] == "bot" 13 | and args.get("ignorebots", True) 14 | ): 15 | return None 16 | 17 | if kind == "command": 18 | if input.trigger in bot.config.get("disabled_commands", []): 19 | return None 20 | 21 | ignored = bot.config.get("ignored", []) 22 | if input.host in ignored or input.nick in ignored: 23 | return None 24 | 25 | fn = re.match(r"^plugins.(.+).py$", func._filename) 26 | disabled = bot.config.get("disabled_plugins", []) 27 | if fn and fn.group(1).lower() in disabled: 28 | return None 29 | 30 | acls = bot.config.get("acls", {}) 31 | for acl in [acls.get(func.__name__), acls.get(input.chan), acls.get(input.server)]: 32 | if acl is None: 33 | continue 34 | if "deny-except" in acl: 35 | allowed_channels = [x.lower() for x in acl["deny-except"]] 36 | if input.chan.lower() not in allowed_channels: 37 | return None 38 | if "allow-except" in acl: 39 | denied_channels = [x.lower() for x in acl["allow-except"]] 40 | if input.chan.lower() in denied_channels: 41 | return None 42 | if "whitelist" in acl: 43 | if func.__name__ not in acl["whitelist"]: 44 | return None 45 | if "blacklist" in acl: 46 | if func.__name__ in acl["whitelist"]: 47 | return None 48 | if "blacklist-nicks" in acl: 49 | if input.nick.lower() in acl["blacklist-nicks"]: 50 | return None 51 | 52 | admins = input.conn.admins 53 | input.admin = input.host in admins or input.nick in admins 54 | 55 | if args.get("adminonly", False): 56 | if not input.admin: 57 | return None 58 | 59 | return input 60 | -------------------------------------------------------------------------------- /plugins/snopes.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import json 4 | import re 5 | 6 | from util import hook, http 7 | 8 | 9 | SEARCH_URL = ( 10 | "https://yfrdx308zd-dsn.algolia.net/1/indexes/wp_live_searchable_posts/query" 11 | "?x-algolia-application-id=YFRDX308ZD&x-algolia-api-key=7da15c5275374261c3a4bdab2ce5d321" 12 | ) 13 | 14 | 15 | @hook.command 16 | def snopes(inp): 17 | ".snopes <topic> -- searches snopes for an urban legend about <topic>" 18 | 19 | params = {"params": http.urlencode({"query": inp, "hitsPerPage": 5})} 20 | results = http.get_json(SEARCH_URL, post_data=json.dumps(params).encode("utf8"))[ 21 | "hits" 22 | ] 23 | 24 | if not results: 25 | return "no matching pages found" 26 | 27 | post = results[0] 28 | for post in results: 29 | if post["post_type"] == "fact_check": 30 | return fmt(post) 31 | 32 | 33 | def fmt(post): 34 | permalink = post["permalink"] 35 | 36 | if "fact_check_claim" in post: 37 | claim = post["fact_check_claim"] 38 | if post["taxonomies"].get("fact_check_category") == ["Fake News"]: 39 | status = "Fake News" 40 | else: 41 | status = post["taxonomies"]["fact_check_rating"][0] 42 | else: 43 | content = post["content"] 44 | m = re.search(r"(?:Claim|Glurge|Legend|FACT CHECK): (.*)", content) 45 | if m: 46 | claim = m.group(1).strip() 47 | else: 48 | claim = content.split("\n")[0] 49 | print("???", claim) 50 | if claim == "Claim": 51 | print("!!!", content) 52 | m = re.search( 53 | r"FALSE|TRUE|MIXTURE|UNDETERMINED|CORRECTLY ATTRIBUTED|(?<=Status:).*", 54 | content, 55 | ) 56 | if m: 57 | status = m.group(0) 58 | else: 59 | status = "???" 60 | 61 | claim = re.sub( 62 | r"[\s\xa0]+", " ", http.unescape(claim) 63 | ).strip() # compress whitespace 64 | status = re.sub(r"[\s\xa0]+", " ", http.unescape(status)).title().strip() 65 | 66 | if len(claim) > 300: 67 | claim = claim[:300] + "..." 68 | 69 | return "Claim: {0} Status: {1} {2}".format(claim, status, permalink) 70 | 71 | 72 | if __name__ == "__main__": 73 | a = http.get_json( 74 | SEARCH_URL, 75 | post_data=json.dumps( 76 | {"params": http.urlencode({"query": "people", "hitsPerPage": 1000})} 77 | ).encode("utf8"), 78 | ) 79 | print(len(a["hits"])) 80 | try: 81 | for x in a["hits"]: 82 | if x["post_type"] == "fact_check": 83 | f = fmt(x) 84 | print(f) 85 | assert len(f) < 400 86 | except AttributeError: 87 | print(x["permalink"]) 88 | print(x["content"]) 89 | raise 90 | -------------------------------------------------------------------------------- /plugins/soundcloud.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import re 4 | from util import hook, http 5 | 6 | 7 | soundcloud_track_re = ( 8 | r"soundcloud.com/([-_a-z0-9]+)/([-_a-z0-9/]+)", 9 | re.I, 10 | ) 11 | 12 | BASE_URL = "https://soundcloud.com/" 13 | 14 | 15 | @hook.regex(*soundcloud_track_re) 16 | def soundcloud_track(match): 17 | url = BASE_URL + match.group(1) + "/" + match.group(2) 18 | d = http.get_html(url) 19 | 20 | # iso 8601 fmt expected, e.g. PT01H53M22S 21 | duration = d.xpath('.//meta[@itemprop="duration"]/@content')[ 22 | 0].replace("PT", "").replace("00H", "").lower().strip('0') 23 | 24 | # iso 8601 fmt expected, e.g. 2022-12-30T21:09:15Z 25 | published = d.find('.//time[@pubdate]').text[:10] 26 | 27 | title = d.find('.//a[@itemprop="url"]').text 28 | name = d.xpath('.//meta[@itemprop="name"]/@content')[0] 29 | plays = d.xpath('.//meta[@property="soundcloud:play_count"]/@content')[0] 30 | title = d.xpath('//meta[@property="og:title"]/@content')[0] 31 | likes = d.xpath('//meta[@itemprop="interactionCount" ' 32 | 'and starts-with(@content, "UserLikes:")]/@content')[0].split(":")[1] 33 | 34 | out = ( 35 | "\x02{title}\x02 by " 36 | "\x02{name}\x02 - " 37 | "length \x02{duration}\x02 - " 38 | "{likes}\u2191 - " 39 | "\x02{plays}\x02 plays - " 40 | "posted on \x02{published}\x02" 41 | ) 42 | 43 | output = out.format(title=title, duration=duration, likes=likes, 44 | plays=plays, name=name, published=published) 45 | 46 | return output + " - " + url 47 | -------------------------------------------------------------------------------- /plugins/stock.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from __future__ import division, unicode_literals, print_function 4 | from past.utils import old_div 5 | import re 6 | 7 | from util import hook, http 8 | 9 | 10 | def human_price(x): 11 | if x > 1e9: 12 | return "{:,.2f}B".format(old_div(x, 1e9)) 13 | elif x > 1e6: 14 | return "{:,.2f}M".format(old_div(x, 1e6)) 15 | return "{:,.0f}".format(x) 16 | 17 | 18 | @hook.api_key("iexcloud") 19 | @hook.command 20 | def stock(inp, api_key=None): 21 | """.stock <symbol> [info] -- retrieves a weeks worth of stats for given symbol. Optionally displays information about the company.""" 22 | 23 | if not api_key: 24 | return "missing api key" 25 | 26 | arguments = inp.split(" ") 27 | 28 | symbol = arguments[0].upper() 29 | 30 | try: 31 | quote = http.get_json( 32 | "https://cloud.iexapis.com/stable/stock/{symbol}/quote".format( 33 | symbol=symbol 34 | ), 35 | token=api_key, 36 | ) 37 | except http.HTTPError: 38 | return "{} is not a valid stock symbol.".format(symbol) 39 | 40 | if ( 41 | quote["extendedPriceTime"] 42 | and quote["latestUpdate"] < quote["extendedPriceTime"] 43 | ): 44 | price = quote["extendedPrice"] 45 | change = quote["extendedChange"] 46 | elif quote["latestSource"] == "Close" and quote.get("iexRealtimePrice"): 47 | # NASDAQ stocks don't have extendedPrice anymore :( 48 | price = quote["iexRealtimePrice"] 49 | change = price - quote["previousClose"] 50 | else: 51 | price = quote["latestPrice"] 52 | change = quote["change"] 53 | 54 | def maybe(name, key, fmt=human_price): 55 | if quote.get(key): 56 | return " | {0}: {1}".format(name, fmt(float(quote[key]))) 57 | return "" 58 | 59 | response = { 60 | "name": quote["companyName"], 61 | "change": change, 62 | "percent_change": 100 * change / (price - change), 63 | "symbol": quote["symbol"], 64 | "price": price, 65 | "color": "05" if change < 0 else "03", 66 | "high": quote["high"], 67 | "low": quote["low"], 68 | "average_volume": maybe("Volume", "latestVolume"), 69 | "market_cap": maybe("MCAP", "marketCap"), 70 | "pe_ratio": maybe("P/E", "peRatio", fmt="{:.2f}".format), 71 | } 72 | 73 | return ( 74 | "{name} ({symbol}) ${price:,.2f} \x03{color}{change:,.2f} ({percent_change:,.2f}%)\x03" 75 | + ( 76 | " | Day Range: ${low:,.2f} - ${high:,.2f}" 77 | if response["high"] and response["low"] 78 | else "" 79 | ) 80 | + "{pe_ratio}{average_volume}{market_cap}" 81 | ).format(**response) 82 | 83 | 84 | if __name__ == "__main__": 85 | import os, sys 86 | 87 | for arg in sys.argv[1:]: 88 | print(stock(arg, api_key=os.getenv("KEY"))) 89 | -------------------------------------------------------------------------------- /plugins/suggest.py: -------------------------------------------------------------------------------- 1 | import random 2 | import re 3 | 4 | from util import hook, http 5 | 6 | 7 | @hook.command 8 | def suggest(inp, inp_unstripped=None): 9 | ".suggest [#n] <phrase> -- gets a random/the nth suggested google search" 10 | 11 | if inp_unstripped is not None: 12 | inp = inp_unstripped 13 | m = re.match("^#(\d+) (.+)$", inp) 14 | num = 0 15 | if m: 16 | num, inp = m.groups() 17 | num = int(num) 18 | 19 | json = http.get_json( 20 | "http://suggestqueries.google.com/complete/search", client="firefox", q=inp 21 | ) 22 | suggestions = json[1] 23 | if not suggestions: 24 | return "no suggestions found" 25 | 26 | if not num: 27 | num = random.randint(1, len(suggestions)) 28 | if len(suggestions) + 1 <= num: 29 | return "only got %d suggestions" % len(suggestions) 30 | out = suggestions[num - 1] 31 | return "#%d: %s" % (num, out) 32 | -------------------------------------------------------------------------------- /plugins/tag.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import division, unicode_literals 3 | from builtins import range, object 4 | from past.utils import old_div 5 | import math 6 | import random 7 | import re 8 | import threading 9 | 10 | from util import hook 11 | 12 | 13 | def sanitize(s): 14 | return re.sub(r"[\x00-\x1f]", "", s) 15 | 16 | 17 | @hook.command 18 | def munge(inp, munge_count=0): 19 | reps = 0 20 | for n in range(len(inp)): 21 | rep = character_replacements.get(inp[n]) 22 | if rep: 23 | inp = inp[:n] + rep + inp[n + 1 :] 24 | reps += 1 25 | if reps == munge_count: 26 | break 27 | return inp 28 | 29 | 30 | class PaginatingWinnower(object): 31 | def __init__(self): 32 | self.lock = threading.Lock() 33 | self.inputs = {} 34 | 35 | def winnow(self, inputs, limit=400, ordered=False): 36 | "remove random elements from the list until it's short enough" 37 | with self.lock: 38 | combiner = lambda l: ", ".join(sorted(l)) 39 | 40 | # try to remove elements that were *not* removed recently 41 | inputs_sorted = combiner(inputs) 42 | if inputs_sorted in self.inputs: 43 | same_input = True 44 | recent = self.inputs[inputs_sorted] 45 | if len(recent) == len(inputs): 46 | recent.clear() 47 | else: 48 | same_input = False 49 | if len(self.inputs) >= 100: 50 | # a true lru is effort, random replacement is easy 51 | self.inputs.pop(random.choice(list(self.inputs))) 52 | recent = set() 53 | self.inputs[inputs_sorted] = recent 54 | 55 | suffix = "" 56 | 57 | while len(combiner(inputs)) >= limit: 58 | if same_input and any(inp in recent for inp in inputs): 59 | if ordered: 60 | for inp in recent: 61 | if inp in inputs: 62 | inputs.remove(inp) 63 | else: 64 | inputs.remove( 65 | random.choice([inp for inp in inputs if inp in recent]) 66 | ) 67 | else: 68 | if ordered: 69 | inputs.pop() 70 | else: 71 | inputs.pop(random.randint(0, len(inputs) - 1)) 72 | suffix = " ..." 73 | 74 | recent.update(inputs) 75 | return combiner(inputs) + suffix 76 | 77 | 78 | winnow = PaginatingWinnower().winnow 79 | 80 | 81 | def add_tag(db, chan, nick, subject): 82 | match = db.execute( 83 | "select * from tag where lower(nick)=lower(?) and" 84 | " chan=? and lower(subject)=lower(?)", 85 | (nick, chan, subject), 86 | ).fetchall() 87 | if match: 88 | return "already tagged" 89 | 90 | db.execute( 91 | "replace into tag(chan, subject, nick) values(?,?,?)", (chan, subject, nick) 92 | ) 93 | db.commit() 94 | 95 | return "tag added" 96 | 97 | 98 | def delete_tag(db, chan, nick, del_tag): 99 | count = db.execute( 100 | "delete from tag where lower(nick)=lower(?) and" 101 | " chan=? and lower(subject)=lower(?)", 102 | (nick, chan, del_tag), 103 | ).rowcount 104 | db.commit() 105 | 106 | if count: 107 | return "deleted" 108 | else: 109 | return "tag not found" 110 | 111 | 112 | def get_tag_counts_by_chan(db, chan): 113 | tags = db.execute( 114 | "select subject, count(*) from tag where chan=?" 115 | " group by lower(subject)" 116 | " order by lower(subject)", 117 | (chan,), 118 | ).fetchall() 119 | 120 | tags.sort(key=lambda x: x[1], reverse=True) 121 | if not tags: 122 | return "no tags in %s" % chan 123 | return winnow(["%s (%d)" % row for row in tags], ordered=True) 124 | 125 | 126 | def get_tags_by_nick(db, chan, nick): 127 | return db.execute( 128 | "select subject from tag where lower(nick)=lower(?)" 129 | " and chan=?" 130 | " order by lower(subject)", 131 | (nick, chan), 132 | ).fetchall() 133 | 134 | 135 | def get_tags_string_by_nick(db, chan, nick): 136 | tags = get_tags_by_nick(db, chan, nick) 137 | 138 | if tags: 139 | return 'tags for "%s": ' % munge(nick, 1) + winnow([tag[0] for tag in tags]) 140 | else: 141 | return "" 142 | 143 | 144 | def get_nicks_by_tagset(db, chan, tagset): 145 | nicks = None 146 | for tag in tagset.split("&"): 147 | tag = tag.strip() 148 | 149 | current_nicks = db.execute( 150 | "select lower(nick) from tag where " + "lower(subject)=lower(?)" 151 | " and chan=?", 152 | (tag, chan), 153 | ).fetchall() 154 | 155 | if not current_nicks: 156 | return "tag '%s' not found" % tag 157 | 158 | if nicks is None: 159 | nicks = set(current_nicks) 160 | else: 161 | nicks.intersection_update(current_nicks) 162 | 163 | nicks = [munge(x[0], 1) for x in sorted(nicks)] 164 | if not nicks: 165 | return 'no nicks found with tags "%s"' % tagset 166 | return 'nicks tagged "%s": ' % tagset + winnow(nicks) 167 | 168 | 169 | @hook.command 170 | def tag(inp, chan="", db=None): 171 | ".tag <nick> <tag> -- marks <nick> as <tag> {related: .untag, .tags, .tagged, .is}" 172 | 173 | db.execute("create table if not exists tag(chan, subject, nick)") 174 | 175 | add = re.match(r"(\S+) (.+)", inp) 176 | 177 | if add: 178 | nick, subject = add.groups() 179 | if nick.lower() == "list": 180 | return "tag syntax has changed. try .tags or .tagged instead" 181 | elif nick.lower() == "del": 182 | return 'tag syntax has changed. try ".untag %s" instead' % subject 183 | return add_tag(db, chan, sanitize(nick), sanitize(subject)) 184 | else: 185 | tags = get_tags_string_by_nick(db, chan, inp) 186 | if tags: 187 | return tags 188 | else: 189 | return tag.__doc__ 190 | 191 | 192 | @hook.command 193 | def untag(inp, chan="", db=None): 194 | ".untag <nick> <tag> -- unmarks <nick> as <tag> {related: .tag, .tags, .tagged, .is}" 195 | 196 | delete = re.match(r"(\S+) (.+)$", inp) 197 | 198 | if delete: 199 | nick, del_tag = delete.groups() 200 | return delete_tag(db, chan, nick, del_tag) 201 | else: 202 | return untag.__doc__ 203 | 204 | 205 | @hook.command 206 | def tags(inp, chan="", db=None): 207 | ".tags <nick>/<nick1> <nick2>/list -- get list of tags for <nick>, or a list of tags {related: .tag, .untag, .tagged, .is}" 208 | if inp == "list": 209 | return get_tag_counts_by_chan(db, chan) 210 | elif " " in inp: 211 | tag_intersect = None 212 | nicks = [nick.strip() for nick in inp.split(" ")] 213 | munged_nicks = [munge(x, 1) for x in nicks] 214 | for nick in nicks: 215 | curr_tags = get_tags_by_nick(db, chan, nick) 216 | 217 | if not curr_tags: 218 | return 'nick "%s" not found' % nick 219 | 220 | if tag_intersect is None: 221 | tag_intersect = set(curr_tags) 222 | else: 223 | tag_intersect.intersection_update(curr_tags) 224 | 225 | msg = 'shared tags for nicks "%s"' % winnow(munged_nicks) 226 | if not tag_intersect: 227 | return f"no {msg}" 228 | else: 229 | return f"{msg}: " + winnow( 230 | [tag[0] for tag in tag_intersect], 400 - len(msg) 231 | ) 232 | 233 | tags = get_tags_string_by_nick(db, chan, inp) 234 | if tags: 235 | return tags 236 | else: 237 | return get_nicks_by_tagset(db, chan, inp) 238 | 239 | 240 | @hook.command 241 | def tagged(inp, chan="", db=None): 242 | ".tagged <tag> [& tag...] -- get nicks marked as <tag> (separate multiple tags with &) {related: .tag, .untag, .tags, .is}" 243 | 244 | return get_nicks_by_tagset(db, chan, inp) 245 | 246 | 247 | @hook.command("is") 248 | def is_tagged(inp, chan="", db=None): 249 | ".is <nick> <tag> -- checks if <nick> has been marked as <tag> {related: .tag, .untag, .tags, .tagged}" 250 | 251 | args = re.match(r"(\S+) (.+)$", inp) 252 | 253 | if args: 254 | nick, tag = args.groups() 255 | found = db.execute( 256 | "select 1 from tag" 257 | " where lower(nick)=lower(?)" 258 | " and lower(subject)=lower(?)" 259 | " and chan=?", 260 | (nick, tag, chan), 261 | ).fetchone() 262 | if found: 263 | return "yes" 264 | else: 265 | return "no" 266 | else: 267 | return is_tagged.__doc__ 268 | 269 | 270 | def distance(lat1, lon1, lat2, lon2): 271 | deg_to_rad = old_div(math.pi, 180) 272 | lat1 *= deg_to_rad 273 | lat2 *= deg_to_rad 274 | lon1 *= deg_to_rad 275 | lon2 *= deg_to_rad 276 | 277 | R = 6371 # km 278 | d = ( 279 | math.acos( 280 | math.sin(lat1) * math.sin(lat2) 281 | + math.cos(lat1) * math.cos(lat2) * math.cos(lon2 - lon1) 282 | ) 283 | * R 284 | ) 285 | return d 286 | 287 | 288 | @hook.command(autohelp=False) 289 | def near(inp, nick="", chan="", db=None): 290 | try: 291 | loc = db.execute( 292 | "select lat, lon from location where chan=? and nick=lower(?)", (chan, nick) 293 | ).fetchone() 294 | except db.OperationError: 295 | loc = None 296 | 297 | if loc is None: 298 | return "use .weather <loc> first to set your location" 299 | 300 | lat, lon = loc 301 | 302 | db.create_function("distance", 4, distance) 303 | nearby = db.execute( 304 | "select nick, distance(lat, lon, ?, ?) as dist from location where chan=?" 305 | " and nick != lower(?) order by dist limit 20", 306 | (lat, lon, chan, nick), 307 | ).fetchall() 308 | 309 | in_miles = "mi" in inp.lower() 310 | 311 | out = "(km) " 312 | factor = 1.0 313 | if in_miles: 314 | out = "(mi) " 315 | factor = 0.621 316 | 317 | while nearby and len(out) < 200: 318 | nick, dist = nearby.pop(0) 319 | out += "%s:%.0f " % (munge(nick, 1), dist * factor) 320 | 321 | return out 322 | 323 | 324 | character_replacements = { 325 | "a": "ä", 326 | # 'b': 'Б', 327 | "c": "ċ", 328 | "d": "đ", 329 | "e": "ë", 330 | "f": "ƒ", 331 | "g": "ġ", 332 | "h": "ħ", 333 | "i": "í", 334 | "j": "ĵ", 335 | "k": "ķ", 336 | "l": "ĺ", 337 | # 'm': 'ṁ', 338 | "n": "ñ", 339 | "o": "ö", 340 | "p": "ρ", 341 | # 'q': 'ʠ', 342 | "r": "ŗ", 343 | "s": "š", 344 | "t": "ţ", 345 | "u": "ü", 346 | # 'v': '', 347 | "w": "ω", 348 | "x": "χ", 349 | "y": "ÿ", 350 | "z": "ź", 351 | "A": "Å", 352 | "B": "Β", 353 | "C": "Ç", 354 | "D": "Ď", 355 | "E": "Ē", 356 | # 'F': 'Ḟ', 357 | "G": "Ġ", 358 | "H": "Ħ", 359 | "I": "Í", 360 | "J": "Ĵ", 361 | "K": "Ķ", 362 | "L": "Ĺ", 363 | "M": "Μ", 364 | "N": "Ν", 365 | "O": "Ö", 366 | "P": "Р", 367 | # 'Q': 'Q', 368 | "R": "Ŗ", 369 | "S": "Š", 370 | "T": "Ţ", 371 | "U": "Ů", 372 | # 'V': 'Ṿ', 373 | "W": "Ŵ", 374 | "X": "Χ", 375 | "Y": "Ỳ", 376 | "Z": "Ż", 377 | } 378 | -------------------------------------------------------------------------------- /plugins/tarot.py: -------------------------------------------------------------------------------- 1 | """ 2 | 🔮 Spooky fortunes and assistance for witches 3 | """ 4 | from future.standard_library import hooks 5 | 6 | with hooks(): 7 | from urllib.error import HTTPError 8 | from urllib.parse import quote 9 | 10 | from util import hook, http 11 | 12 | @hook.command 13 | def tarot(inp): 14 | ".tarot <cardname> -- finds a card by name" 15 | search = quote(inp) 16 | 17 | try: 18 | card = http.get_json(f"https://tarot-api.com/find/{search}") 19 | except HTTPError: 20 | return "The spirits are displeased." 21 | 22 | return card["name"] + ": " + ", ".join(card["keywords"]) 23 | 24 | @hook.command(autohelp=False) 25 | def fortune(inp): 26 | """ 27 | .fortune -- returns one random card and it's fortune 28 | """ 29 | 30 | try: 31 | cards = http.get_json("https://tarot-api.com/draw/1") 32 | except HTTPError: 33 | return "The spirits are displeased." 34 | 35 | card = cards[0] 36 | 37 | return card["name"] + ": " + ", ".join(card["keywords"]) 38 | 39 | -------------------------------------------------------------------------------- /plugins/tell.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import time 4 | 5 | from util import hook, timesince 6 | 7 | 8 | def db_init(db): 9 | "check to see that our db has the tell table and return a dbection." 10 | db.execute( 11 | "create table if not exists tell" 12 | "(user_to, user_from, message, chan, time," 13 | "primary key(user_to, message))" 14 | ) 15 | db.commit() 16 | 17 | return db 18 | 19 | 20 | def get_tells(db, user_to): 21 | return db.execute( 22 | "select user_from, message, time, chan from tell where" 23 | " user_to=lower(?) order by time", 24 | (user_to.lower(),), 25 | ).fetchall() 26 | 27 | 28 | @hook.singlethread 29 | @hook.event("PRIVMSG") 30 | def tellinput(paraml, input=None, db=None): 31 | if "showtells" in input.msg.lower(): 32 | return 33 | 34 | db_init(db) 35 | 36 | tells = get_tells(db, input.nick) 37 | 38 | if tells: 39 | user_from, message, time, chan = tells[0] 40 | reltime = timesince.timesince(time) 41 | 42 | reply = "%s said %s ago in %s: %s" % (user_from, reltime, chan, message) 43 | if len(tells) > 1: 44 | reply += " (+%d more, .showtells to view)" % (len(tells) - 1) 45 | 46 | db.execute( 47 | "delete from tell where user_to=lower(?) and message=?", 48 | (input.nick, message), 49 | ) 50 | db.commit() 51 | input.pm(reply) 52 | 53 | 54 | @hook.command(autohelp=False) 55 | def showtells(inp, nick="", chan="", pm=None, db=None): 56 | ".showtells -- view all pending tell messages (sent in PM)." 57 | 58 | db_init(db) 59 | 60 | tells = get_tells(db, nick) 61 | 62 | if not tells: 63 | pm("You have no pending tells.") 64 | return 65 | 66 | for tell in tells: 67 | user_from, message, time, chan = tell 68 | past = timesince.timesince(time) 69 | pm("%s said %s ago in %s: %s" % (user_from, past, chan, message)) 70 | 71 | db.execute("delete from tell where user_to=lower(?)", (nick,)) 72 | db.commit() 73 | 74 | 75 | @hook.command 76 | def tell(inp, nick="", chan="", db=None, conn=None): 77 | ".tell <nick> <message> -- relay <message> to <nick> when <nick> is around" 78 | 79 | query = inp.split(" ", 1) 80 | 81 | if len(query) != 2: 82 | return tell.__doc__ 83 | 84 | user_to = query[0].lower() 85 | message = query[1].strip() 86 | user_from = nick 87 | 88 | if chan.lower() == user_from.lower(): 89 | chan = "a pm" 90 | 91 | if user_to in (user_from.lower(), conn.nick.lower()): 92 | return "No." 93 | 94 | db_init(db) 95 | 96 | if ( 97 | db.execute("select count() from tell where user_to=?", (user_to,)).fetchone()[0] 98 | >= 5 99 | ): 100 | return "That person has too many things queued." 101 | 102 | try: 103 | db.execute( 104 | "insert into tell(user_to, user_from, message, chan," 105 | "time) values(?,?,?,?,?)", 106 | (user_to, user_from, message, chan, time.time()), 107 | ) 108 | db.commit() 109 | except db.IntegrityError: 110 | return "Message has already been queued." 111 | 112 | return "I'll pass that along." 113 | -------------------------------------------------------------------------------- /plugins/tinyurl.py: -------------------------------------------------------------------------------- 1 | from util import hook, http 2 | 3 | 4 | @hook.regex(r"(?i)http://(?:www\.)?tinyurl.com/([A-Za-z0-9\-]+)") 5 | def tinyurl(match): 6 | try: 7 | return http.open(match.group()).url.strip() 8 | except http.URLError as e: 9 | pass 10 | -------------------------------------------------------------------------------- /plugins/translate.py: -------------------------------------------------------------------------------- 1 | """ 2 | A Google API key is required and retrieved from the bot config file. 3 | Since December 1, 2011, the Google Translate API is a paid service only. 4 | """ 5 | 6 | from builtins import zip 7 | from builtins import chr 8 | import html.entities 9 | import re 10 | 11 | from util import hook, http 12 | 13 | ########### from http://effbot.org/zone/re-sub.htm#unescape-html ############# 14 | 15 | 16 | def unescape(text): 17 | def fixup(m): 18 | text = m.group(0) 19 | if text[:2] == "&#": 20 | # character reference 21 | try: 22 | if text[:3] == "&#x": 23 | return chr(int(text[3:-1], 16)) 24 | else: 25 | return chr(int(text[2:-1])) 26 | except ValueError: 27 | pass 28 | else: 29 | # named entity 30 | try: 31 | text = chr(html.entities.name2codepoint[text[1:-1]]) 32 | except KeyError: 33 | pass 34 | return text # leave as is 35 | 36 | return re.sub("&#?\w+;", fixup, text) 37 | 38 | 39 | ############################################################################## 40 | 41 | 42 | def goog_trans(api_key, text, slang, tlang): 43 | url = "https://www.googleapis.com/language/translate/v2" 44 | parsed = http.get_json(url, key=api_key, q=text, source=slang, target=tlang) 45 | if not 200 <= parsed["responseStatus"] < 300: 46 | raise IOError( 47 | "error with the translation server: %d: %s" 48 | % (parsed["responseStatus"], parsed["responseDetails"]) 49 | ) 50 | if not slang: 51 | return unescape( 52 | "(%(detectedSourceLanguage)s) %(translatedText)s" 53 | % (parsed["responseData"]["data"]["translations"][0]) 54 | ) 55 | return unescape( 56 | "%(translatedText)s" % parsed["responseData"]["data"]["translations"][0] 57 | ) 58 | 59 | 60 | def match_language(fragment): 61 | fragment = fragment.lower() 62 | for short, _ in lang_pairs: 63 | if fragment in short.lower().split(): 64 | return short.split()[0] 65 | 66 | for short, full in lang_pairs: 67 | if fragment in full.lower(): 68 | return short.split()[0] 69 | 70 | return None 71 | 72 | 73 | @hook.api_key("google") 74 | @hook.command 75 | def translate(inp, api_key=""): 76 | ".translate [source language [target language]] <sentence> -- translates" " <sentence> from source language (default autodetect) to target" " language (default English) using Google Translate" 77 | 78 | args = inp.split(" ", 2) 79 | 80 | try: 81 | if len(args) >= 2: 82 | sl = match_language(args[0]) 83 | if not sl: 84 | return goog_trans(api_key, inp, "", "en") 85 | if len(args) == 2: 86 | return goog_trans(api_key, args[1], sl, "en") 87 | if len(args) >= 3: 88 | tl = match_language(args[1]) 89 | if not tl: 90 | if sl == "en": 91 | return "unable to determine desired target language" 92 | return goog_trans(api_key, args[1] + " " + args[2], sl, "en") 93 | return goog_trans(api_key, args[2], sl, tl) 94 | return goog_trans(api_key, inp, "", "en") 95 | except IOError as e: 96 | return e 97 | 98 | 99 | languages = "ja fr de ko ru zh".split() 100 | language_pairs = zip(languages[:-1], languages[1:]) 101 | 102 | 103 | def babel_gen(inp): 104 | for language in languages: 105 | inp = inp.encode("utf8") 106 | trans = goog_trans(api_key, inp, "en", language).encode("utf8") 107 | inp = goog_trans(api_key, trans, language, "en") 108 | yield language, trans, inp 109 | 110 | 111 | @hook.api_key("google") 112 | @hook.command 113 | def babel(inp, api_key=""): 114 | ".babel <sentence> -- translates <sentence> through multiple languages" 115 | 116 | try: 117 | return list(babel_gen(api_key, inp))[-1][2] 118 | except IOError as e: 119 | return e 120 | 121 | 122 | @hook.api_key("google") 123 | @hook.command 124 | def babelext(inp, api_key=""): 125 | ".babelext <sentence> -- like .babel, but with more detailed output" 126 | 127 | try: 128 | babels = list(babel_gen(api_key, inp)) 129 | except IOError as e: 130 | return e 131 | 132 | out = "" 133 | for lang, trans, text in babels: 134 | out += '%s:"%s", ' % (lang, text.decode("utf8")) 135 | 136 | out += 'en:"' + babels[-1][2].decode("utf8") + '"' 137 | 138 | if len(out) > 300: 139 | out = out[:150] + " ... " + out[-150:] 140 | 141 | return out 142 | 143 | 144 | lang_pairs = [ 145 | ("no", "Norwegian"), 146 | ("it", "Italian"), 147 | ("ht", "Haitian Creole"), 148 | ("af", "Afrikaans"), 149 | ("sq", "Albanian"), 150 | ("ar", "Arabic"), 151 | ("hy", "Armenian"), 152 | ("az", "Azerbaijani"), 153 | ("eu", "Basque"), 154 | ("be", "Belarusian"), 155 | ("bg", "Bulgarian"), 156 | ("ca", "Catalan"), 157 | ("zh-CN zh", "Chinese"), 158 | ("hr", "Croatian"), 159 | ("cs", "Czech"), 160 | ("da", "Danish"), 161 | ("nl", "Dutch"), 162 | ("en", "English"), 163 | ("et", "Estonian"), 164 | ("tl", "Filipino"), 165 | ("fi", "Finnish"), 166 | ("fr", "French"), 167 | ("gl", "Galician"), 168 | ("ka", "Georgian"), 169 | ("de", "German"), 170 | ("el", "Greek"), 171 | ("ht", "Haitian Creole"), 172 | ("iw", "Hebrew"), 173 | ("hi", "Hindi"), 174 | ("hu", "Hungarian"), 175 | ("is", "Icelandic"), 176 | ("id", "Indonesian"), 177 | ("ga", "Irish"), 178 | ("it", "Italian"), 179 | ("ja jp jpn", "Japanese"), 180 | ("ko", "Korean"), 181 | ("lv", "Latvian"), 182 | ("lt", "Lithuanian"), 183 | ("mk", "Macedonian"), 184 | ("ms", "Malay"), 185 | ("mt", "Maltese"), 186 | ("no", "Norwegian"), 187 | ("fa", "Persian"), 188 | ("pl", "Polish"), 189 | ("pt", "Portuguese"), 190 | ("ro", "Romanian"), 191 | ("ru", "Russian"), 192 | ("sr", "Serbian"), 193 | ("sk", "Slovak"), 194 | ("sl", "Slovenian"), 195 | ("es", "Spanish"), 196 | ("sw", "Swahili"), 197 | ("sv", "Swedish"), 198 | ("th", "Thai"), 199 | ("tr", "Turkish"), 200 | ("uk", "Ukrainian"), 201 | ("ur", "Urdu"), 202 | ("vi", "Vietnamese"), 203 | ("cy", "Welsh"), 204 | ("yi", "Yiddish"), 205 | ] 206 | -------------------------------------------------------------------------------- /plugins/tvdb.py: -------------------------------------------------------------------------------- 1 | """ 2 | TV information, written by Lurchington 2010 3 | modified by rmmh 2010, 2013 4 | """ 5 | 6 | from builtins import map 7 | import datetime 8 | 9 | from util import hook, http, timesince 10 | 11 | 12 | base_url = "http://thetvdb.com/api/" 13 | api_key = "469B73127CA0C411" 14 | 15 | 16 | def get_episodes_for_series(seriesname): 17 | res = {"error": None, "ended": False, "episodes": None, "name": None} 18 | # http://thetvdb.com/wiki/index.php/API:GetSeries 19 | try: 20 | query = http.get_xml(base_url + "GetSeries.php", seriesname=seriesname) 21 | except http.URLError: 22 | res["error"] = "error contacting thetvdb.com" 23 | return res 24 | 25 | series_id = query.xpath("//seriesid/text()") 26 | 27 | if not series_id: 28 | res["error"] = "unknown tv series (using www.thetvdb.com)" 29 | return res 30 | 31 | series_id = series_id[0] 32 | 33 | try: 34 | series = http.get_xml( 35 | base_url + "%s/series/%s/all/en.xml" % (api_key, series_id) 36 | ) 37 | except http.URLError: 38 | res["error"] = "error contacting thetvdb.com" 39 | return res 40 | 41 | series_name = series.xpath("//SeriesName/text()")[0] 42 | 43 | if series.xpath("//Status/text()")[0] == "Ended": 44 | res["ended"] = True 45 | 46 | res["episodes"] = series.xpath("//Episode") 47 | res["name"] = series_name 48 | return res 49 | 50 | 51 | def get_episode_info(episode): 52 | episode_air_date = episode.findtext("FirstAired") 53 | 54 | try: 55 | airdate = datetime.date(*list(map(int, episode_air_date.split("-")))) 56 | except (ValueError, TypeError): 57 | return None 58 | 59 | episode_num = "S%02dE%02d" % ( 60 | int(episode.findtext("SeasonNumber")), 61 | int(episode.findtext("EpisodeNumber")), 62 | ) 63 | 64 | episode_name = episode.findtext("EpisodeName") 65 | # in the event of an unannounced episode title, users either leave the 66 | # field out (None) or fill it with TBA 67 | if episode_name == "TBA": 68 | episode_name = None 69 | 70 | episode_desc = "%s" % episode_num 71 | if episode_name: 72 | episode_desc += " - %s" % episode_name 73 | return (episode_air_date, airdate, episode_desc) 74 | 75 | 76 | @hook.command 77 | @hook.command("tv") 78 | def tv_next(inp): 79 | ".tv_next <series> -- get the next episode of <series>" 80 | episodes = get_episodes_for_series(inp) 81 | 82 | if episodes["error"]: 83 | return episodes["error"] 84 | 85 | series_name = episodes["name"] 86 | ended = episodes["ended"] 87 | episodes = episodes["episodes"] 88 | 89 | if ended: 90 | return "%s has ended." % series_name 91 | 92 | next_eps = [] 93 | today = datetime.date.today() 94 | 95 | for episode in reversed(episodes): 96 | ep_info = get_episode_info(episode) 97 | 98 | if ep_info is None: 99 | continue 100 | 101 | (episode_air_date, airdate, episode_desc) = ep_info 102 | 103 | if airdate > today: 104 | next_eps = [ 105 | "%s (%s) (%s)" 106 | % ( 107 | episode_air_date, 108 | timesince.timeuntil( 109 | datetime.datetime.strptime(episode_air_date, "%Y-%m-%d") 110 | ), 111 | episode_desc, 112 | ) 113 | ] 114 | elif airdate == today: 115 | next_eps = ["Today (%s)" % episode_desc] + next_eps 116 | else: 117 | # we're iterating in reverse order with newest episodes last 118 | # so, as soon as we're past today, break out of loop 119 | break 120 | 121 | if not next_eps: 122 | return "there are no new episodes scheduled for %s" % series_name 123 | 124 | if len(next_eps) == 1: 125 | return "the next episode of %s airs %s" % (series_name, next_eps[0]) 126 | else: 127 | next_eps = ", ".join(next_eps) 128 | return "the next episodes of %s: %s" % (series_name, next_eps) 129 | 130 | 131 | @hook.command 132 | @hook.command("tv_prev") 133 | def tv_last(inp): 134 | ".tv_last <series> -- gets the most recently aired episode of <series>" 135 | episodes = get_episodes_for_series(inp) 136 | 137 | if episodes["error"]: 138 | return episodes["error"] 139 | 140 | series_name = episodes["name"] 141 | ended = episodes["ended"] 142 | episodes = episodes["episodes"] 143 | 144 | prev_ep = None 145 | today = datetime.date.today() 146 | 147 | for episode in reversed(episodes): 148 | ep_info = get_episode_info(episode) 149 | 150 | if ep_info is None: 151 | continue 152 | 153 | (episode_air_date, airdate, episode_desc) = ep_info 154 | 155 | if airdate < today: 156 | # iterating in reverse order, so the first episode encountered 157 | # before today was the most recently aired 158 | prev_ep = "%s (%s)" % (episode_air_date, episode_desc) 159 | break 160 | 161 | if not prev_ep: 162 | return "there are no previously aired episodes for %s" % series_name 163 | if ended: 164 | return "%s ended. The last episode aired %s" % (series_name, prev_ep) 165 | return "the last episode of %s aired %s" % (series_name, prev_ep) 166 | -------------------------------------------------------------------------------- /plugins/twitter.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import math 4 | import random 5 | import re 6 | import string 7 | from time import strptime, strftime 8 | from urllib.parse import quote 9 | 10 | from util import hook, http 11 | 12 | 13 | b36_alphabet = string.digits + string.ascii_lowercase 14 | 15 | def base36(x): 16 | frac, x = math.modf(x) 17 | r = [] 18 | x = int(x) 19 | f = [] 20 | # fractional part translated from V8 DoubleToRadixCString 21 | delta = max(0.5 * (math.nextafter(x, x+1) - x), math.nextafter(0, 1)) 22 | while frac >= delta: 23 | frac *= 36 24 | delta *= 36 25 | d = int(frac) 26 | f.append(d) 27 | frac -= d 28 | if frac > .5 or frac == .5 and d & 1: 29 | if frac + delta > 1: 30 | i = len(f) 31 | while True: 32 | i -= 1 33 | if i == -1: 34 | x += 1 35 | break 36 | if f[i] + 1 < 36: 37 | f[i] += 1 38 | break 39 | f = [b36_alphabet[x] for x in f] 40 | while x: 41 | x, rem = divmod(x, 36) 42 | r.append(b36_alphabet[rem]) 43 | 44 | if f: 45 | return ''.join(r[::-1]) + '.' + ''.join(f) 46 | else: 47 | return ''.join(r[::-1]) 48 | 49 | 50 | @hook.command 51 | def twitter(inp): 52 | ".twitter <id> -- get tweet <id>" 53 | 54 | if re.match(r"^\d+$", inp): 55 | x = int(inp)/1e15*math.pi 56 | token = re.sub(r'0+|\.', '', base36(x)) 57 | request_url = "https://cdn.syndication.twimg.com/tweet-result?lang=en" 58 | else: 59 | return 'error: can only get tweets by id' 60 | 61 | try: 62 | tweet = http.get_json(request_url, id=inp, token=token) 63 | except http.HTTPError as e: 64 | errors = { 65 | 400: "bad request (ratelimited?)", 66 | 401: "unauthorized", 67 | 403: "forbidden", 68 | 404: "invalid user/id", 69 | 500: "twitter is broken", 70 | 502: 'twitter is down ("getting upgraded")', 71 | 503: "twitter is overloaded (lol, RoR)", 72 | 410: "twitter shut off api v1.", 73 | } 74 | if e.code == 404: 75 | return "error: invalid tweet id" 76 | if e.code in errors: 77 | return "error: " + errors[e.code] 78 | return "error: unknown %s" % e.code 79 | 80 | if "retweeted_status" in tweet: 81 | rt = tweet["retweeted_status"] 82 | rt_text = http.unescape(rt["full_text"]).replace("\n", " ") 83 | text = "RT @%s %s" % (rt["user"]["screen_name"], rt_text) 84 | else: 85 | text = http.unescape(tweet["text"]).replace("\n", " ") 86 | for url in tweet.get('entities', {}).get('urls', []): 87 | new_text = text.replace(url['url'], url['expanded_url']) 88 | if len(new_text) < 350: 89 | text = new_text 90 | for url in tweet.get('mediaDetails', []) + tweet.get('entities', {}).get('media', []): 91 | if url.get('type') in ('video', 'animated_gif'): 92 | try: 93 | media_url = max(url['video_info']['variants'], key=lambda x: x.get('bitrate', 0))['url'] 94 | except KeyError: 95 | continue 96 | elif 'media_url_https' in url: 97 | media_url = url['media_url_https'] 98 | else: 99 | continue 100 | if url['url'] in text: 101 | new_text = text.replace(url['url'], media_url) 102 | else: 103 | new_text = text + ' ' + media_url 104 | if len(new_text) < 400: 105 | text = new_text 106 | screen_name = tweet["user"]["screen_name"] 107 | time = tweet["created_at"] 108 | 109 | time = strftime("%Y-%m-%d %H:%M:%S", strptime(time, "%Y-%m-%dT%H:%M:%S.000Z")) 110 | 111 | return "%s \x02%s\x02: %s" % (time, screen_name, text) 112 | 113 | 114 | @hook.regex(r"https?://((mobile\.|fx|vx)?twitter|x).com/(#!/)?([_0-9a-zA-Z]+)/status/(?P<id>\d+)") 115 | def show_tweet(match): 116 | return twitter(match.group("id")) 117 | -------------------------------------------------------------------------------- /plugins/urlhistory.py: -------------------------------------------------------------------------------- 1 | from __future__ import division, unicode_literals 2 | from past.utils import old_div 3 | import math 4 | import time 5 | 6 | from util import hook, urlnorm, timesince 7 | 8 | 9 | expiration_period = 60 * 60 * 24 # 1 day 10 | 11 | ignored_urls = [urlnorm.normalize("http://google.com")] 12 | 13 | 14 | def db_init(db): 15 | db.execute("create table if not exists urlhistory" "(chan, url, nick, time)") 16 | db.commit() 17 | 18 | 19 | def insert_history(db, chan, url, nick): 20 | db.execute( 21 | "insert into urlhistory(chan, url, nick, time) " "values(?,?,?,?)", 22 | (chan, url, nick, time.time()), 23 | ) 24 | db.commit() 25 | 26 | 27 | def get_history(db, chan, url): 28 | db.execute( 29 | "delete from urlhistory where time < ?", (time.time() - expiration_period,) 30 | ) 31 | return db.execute( 32 | "select nick, time from urlhistory where " 33 | "chan=? and url=? order by time desc", 34 | (chan, url), 35 | ).fetchall() 36 | 37 | 38 | def nicklist(nicks): 39 | nicks.sort(key=lambda n: n.lower()) 40 | 41 | if len(nicks) <= 2: 42 | return " and ".join(nicks) 43 | else: 44 | return ", and ".join((", ".join(nicks[:-1]), nicks[-1])) 45 | 46 | 47 | def format_reply(history): 48 | if not history: 49 | return 50 | 51 | last_nick, recent_time = history[0] 52 | last_time = timesince.timesince(recent_time) 53 | 54 | if len(history) == 1: 55 | return "%s linked that %s ago." % (last_nick, last_time) 56 | 57 | hour_span = math.ceil(old_div((time.time() - history[-1][1]), 3600)) 58 | hour_span = "%.0f hours" % hour_span if hour_span > 1 else "hour" 59 | 60 | hlen = len(history) 61 | ordinal = ["once", "twice", "%d times" % hlen][min(hlen, 3) - 1] 62 | 63 | if len(dict(history)) == 1: 64 | last = "last linked %s ago" % last_time 65 | else: 66 | last = "last linked by %s %s ago" % (last_nick, last_time) 67 | 68 | return "that url has been posted %s in the past %s by %s (%s)." % ( 69 | ordinal, 70 | hour_span, 71 | nicklist([h[0] for h in history]), 72 | last, 73 | ) 74 | 75 | 76 | @hook.regex(r"([a-zA-Z]+://|www\.)[^ ]+") 77 | def urlinput(match, nick="", chan="", db=None, bot=None): 78 | db_init(db) 79 | url = urlnorm.normalize(match.group()) 80 | if url not in ignored_urls: 81 | url = url 82 | history = get_history(db, chan, url) 83 | insert_history(db, chan, url, nick) 84 | 85 | inp = match.string.lower() 86 | 87 | for name in dict(history): 88 | if name.lower() in inp: # person was probably quoting a line 89 | return # that had a link. don't remind them. 90 | 91 | if nick not in dict(history): 92 | return format_reply(history) 93 | -------------------------------------------------------------------------------- /plugins/util/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rmmh/skybot/76eca17bbc23e3e0b262a6b533eac4d58d55e5d1/plugins/util/__init__.py -------------------------------------------------------------------------------- /plugins/util/hook.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import re 3 | 4 | 5 | def _hook_add(func, add, name=""): 6 | if not hasattr(func, "_hook"): 7 | func._hook = [] 8 | func._hook.append(add) 9 | 10 | if not hasattr(func, "_filename"): 11 | func._filename = func.__code__.co_filename 12 | 13 | if not hasattr(func, "_args"): 14 | argspec = inspect.getfullargspec(func) 15 | if name: 16 | n_args = len(argspec.args) 17 | if argspec.defaults: 18 | n_args -= len(argspec.defaults) 19 | if argspec.varkw: 20 | n_args -= 1 21 | if argspec.varargs: 22 | n_args -= 1 23 | if n_args != 1: 24 | err = "%ss must take 1 non-keyword argument (%s)" % ( 25 | name, 26 | func.__name__, 27 | ) 28 | raise ValueError(err) 29 | 30 | args = [] 31 | if argspec.defaults: 32 | end = bool(argspec.varkw) + bool(argspec.varargs) 33 | args.extend(argspec.args[-len(argspec.defaults) : end if end else None]) 34 | if argspec.varkw: 35 | args.append(0) # means kwargs present 36 | func._args = args 37 | 38 | if not hasattr(func, "_thread"): # does function run in its own thread? 39 | func._thread = False 40 | 41 | 42 | def sieve(func): 43 | if func.__code__.co_argcount != 5: 44 | raise ValueError("sieves must take 5 arguments: (bot, input, func, type, args)") 45 | _hook_add(func, ["sieve", (func,)]) 46 | return func 47 | 48 | 49 | def command(arg=None, **kwargs): 50 | args = {} 51 | 52 | def command_wrapper(func): 53 | args.setdefault("name", func.__name__) 54 | _hook_add(func, ["command", (func, args)], "command") 55 | return func 56 | 57 | if kwargs or not inspect.isfunction(arg): 58 | if arg is not None: 59 | args["name"] = arg 60 | args.update(kwargs) 61 | return command_wrapper 62 | else: 63 | return command_wrapper(arg) 64 | 65 | 66 | def event(arg=None, **kwargs): 67 | args = kwargs 68 | 69 | def event_wrapper(func): 70 | args["name"] = func.__name__ 71 | args.setdefault("events", ["*"]) 72 | _hook_add(func, ["event", (func, args)], "event") 73 | return func 74 | 75 | if inspect.isfunction(arg): 76 | return event_wrapper(arg, kwargs) 77 | else: 78 | if arg is not None: 79 | args["events"] = arg.split() 80 | return event_wrapper 81 | 82 | 83 | def singlethread(func): 84 | func._thread = True 85 | return func 86 | 87 | 88 | def api_key(*keys): 89 | def annotate(func): 90 | func._apikeys = keys 91 | return func 92 | 93 | return annotate 94 | 95 | 96 | def regex(regex, flags=0, **kwargs): 97 | args = kwargs 98 | 99 | def regex_wrapper(func): 100 | args["name"] = func.__name__ 101 | args["regex"] = regex 102 | args["re"] = re.compile(regex, flags) 103 | _hook_add(func, ["regex", (func, args)], "regex") 104 | return func 105 | 106 | if inspect.isfunction(regex): 107 | raise ValueError("regex decorators require a regex to match against") 108 | else: 109 | return regex_wrapper 110 | -------------------------------------------------------------------------------- /plugins/util/http.py: -------------------------------------------------------------------------------- 1 | from future.standard_library import hooks 2 | 3 | from lxml import etree, html 4 | 5 | import binascii 6 | import collections 7 | import hmac 8 | import json 9 | import random 10 | import time 11 | 12 | from hashlib import sha1 13 | 14 | from builtins import str 15 | from builtins import range 16 | 17 | try: 18 | from http.cookiejar import CookieJar 19 | except: 20 | from future.backports.http.cookiejar import CookieJar 21 | 22 | with hooks(): 23 | import urllib.request, urllib.parse, urllib.error 24 | 25 | from urllib.parse import ( 26 | quote, 27 | unquote, 28 | urlencode, 29 | urlparse, 30 | parse_qsl, 31 | quote_plus as _quote_plus, 32 | ) 33 | from urllib.error import HTTPError, URLError 34 | 35 | 36 | ua_skybot = "Skybot/1.0 https://github.com/rmmh/skybot" 37 | ua_firefox = ( 38 | "Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US; rv:1.8.1.6) " 39 | "Gecko/20070725 Firefox/2.0.0.6" 40 | ) 41 | ua_internetexplorer = "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1)" 42 | 43 | 44 | def get_cookie_jar(): 45 | if not hasattr(get_cookie_jar, "memo"): 46 | get_cookie_jar.memo = CookieJar() 47 | 48 | return get_cookie_jar.memo 49 | 50 | 51 | def clear_expired_cookies(): 52 | get_cookie_jar().clear_expired_cookies() 53 | 54 | 55 | def get(*args, **kwargs): 56 | return open(*args, **kwargs).read().decode("utf-8") 57 | 58 | 59 | def get_html(*args, **kwargs): 60 | return html.fromstring(open(*args, **kwargs).read()) 61 | 62 | 63 | def get_xml(*args, **kwargs): 64 | return etree.fromstring(open(*args, **kwargs).read()) 65 | 66 | 67 | def get_json(*args, **kwargs): 68 | return json.loads(open(*args, **kwargs).read()) 69 | 70 | 71 | def open( 72 | url, 73 | query_params=None, 74 | post_data=None, 75 | json_data=None, 76 | get_method=None, 77 | cookies=False, 78 | oauth=False, 79 | oauth_keys=None, 80 | headers=None, 81 | **kwargs 82 | ): 83 | if query_params is None: 84 | query_params = {} 85 | 86 | query_params.update(kwargs) 87 | 88 | url = prepare_url(url, query_params) 89 | 90 | if post_data and isinstance(post_data, collections.Mapping): 91 | post_data = urllib.parse.urlencode(post_data) 92 | post_data = post_data.encode("UTF-8") 93 | 94 | if json_data and isinstance(json_data, dict): 95 | post_data = json.dumps(json_data).encode("utf-8") 96 | 97 | request = urllib.request.Request(url, post_data) 98 | 99 | if json_data: 100 | request.add_header("Content-Type", "application/json") 101 | 102 | if get_method is not None: 103 | request.get_method = lambda: get_method 104 | 105 | if headers is not None: 106 | for header_key, header_value in headers.items(): 107 | request.add_header(header_key, header_value) 108 | 109 | if "User-Agent" not in request.headers: 110 | request.add_header("User-Agent", ua_skybot) 111 | 112 | if oauth: 113 | nonce = oauth_nonce() 114 | timestamp = oauth_timestamp() 115 | api_url, req_data = url.split("?") 116 | unsigned_request = oauth_unsigned_request( 117 | nonce, timestamp, req_data, oauth_keys["consumer"], oauth_keys["access"] 118 | ) 119 | 120 | signature = oauth_sign_request( 121 | "GET", 122 | api_url, 123 | req_data, 124 | unsigned_request, 125 | oauth_keys["consumer_secret"], 126 | oauth_keys["access_secret"], 127 | ) 128 | 129 | header = oauth_build_header( 130 | nonce, signature, timestamp, oauth_keys["consumer"], oauth_keys["access"] 131 | ) 132 | request.add_header("Authorization", header) 133 | 134 | if cookies: 135 | opener = urllib.request.build_opener( 136 | urllib.request.HTTPCookieProcessor(get_cookie_jar()) 137 | ) 138 | else: 139 | opener = urllib.request.build_opener() 140 | 141 | return opener.open(request) 142 | 143 | 144 | def prepare_url(url, queries): 145 | if queries: 146 | scheme, netloc, path, query, fragment = urllib.parse.urlsplit(str(url)) 147 | 148 | query = dict(urllib.parse.parse_qsl(query)) 149 | query.update(queries) 150 | query = urllib.parse.urlencode( 151 | dict((to_utf8(key), to_utf8(value)) for key, value in query.items()) 152 | ) 153 | 154 | url = urllib.parse.urlunsplit((scheme, netloc, path, query, fragment)) 155 | 156 | return url 157 | 158 | 159 | def to_utf8(s): 160 | if isinstance(s, str): 161 | return s.encode("utf8", "ignore") 162 | else: 163 | return str(s) 164 | 165 | 166 | def quote_plus(s): 167 | return _quote_plus(to_utf8(s)) 168 | 169 | 170 | def oauth_nonce(): 171 | return "".join([str(random.randint(0, 9)) for i in range(8)]) 172 | 173 | 174 | def oauth_timestamp(): 175 | return str(int(time.time())) 176 | 177 | 178 | def oauth_unsigned_request(nonce, timestamp, req, consumer, token): 179 | d = { 180 | "oauth_consumer_key": consumer, 181 | "oauth_nonce": nonce, 182 | "oauth_signature_method": "HMAC-SHA1", 183 | "oauth_timestamp": timestamp, 184 | "oauth_token": token, 185 | "oauth_version": "1.0", 186 | } 187 | 188 | d.update(urllib.parse.parse_qsl(req)) 189 | 190 | request_items = d.items() 191 | 192 | # TODO: Remove this when Python 2 is no longer supported. 193 | # some of the fields are actual string and others are 194 | # a wrapper of str for the python 3 migration. 195 | # Convert them all so that they sort correctly. 196 | request_items = [(str(k), str(v)) for k, v in request_items] 197 | 198 | return quote(urllib.parse.urlencode(sorted(request_items, key=lambda key: key[0]))) 199 | 200 | 201 | def oauth_build_header(nonce, signature, timestamp, consumer, token): 202 | d = { 203 | "oauth_consumer_key": consumer, 204 | "oauth_nonce": nonce, 205 | "oauth_signature": signature, 206 | "oauth_signature_method": "HMAC-SHA1", 207 | "oauth_timestamp": timestamp, 208 | "oauth_token": token, 209 | "oauth_version": "1.0", 210 | } 211 | 212 | header = "OAuth " 213 | 214 | for x in sorted(d, key=lambda key: key[0]): 215 | header += x + '="' + d[x] + '", ' 216 | 217 | return header[:-1] 218 | 219 | 220 | def oauth_sign_request( 221 | method, url, params, unsigned_request, consumer_secret, token_secret 222 | ): 223 | key = consumer_secret + "&" + token_secret 224 | key = key.encode("utf-8", "replace") 225 | 226 | base = method + "&" + quote(url, "") + "&" + unsigned_request 227 | base = base.encode("utf-8", "replace") 228 | 229 | hash = hmac.new(key, base, sha1) 230 | 231 | signature = quote(binascii.b2a_base64(hash.digest())[:-1]) 232 | 233 | return signature 234 | 235 | 236 | def unescape(s): 237 | if not s.strip(): 238 | return s 239 | return html.fromstring(s).text_content() 240 | -------------------------------------------------------------------------------- /plugins/util/timesince.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Django Software Foundation and individual contributors. 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or without 5 | # modification, are permitted provided that the following conditions are met: 6 | # 7 | # 1. Redistributions of source code must retain the above copyright notice, 8 | # this list of conditions and the following disclaimer. 9 | # 10 | # 2. Redistributions in binary form must reproduce the above copyright 11 | # notice, this list of conditions and the following disclaimer in the 12 | # documentation and/or other materials provided with the distribution. 13 | # 14 | # 3. Neither the name of Django nor the names of its contributors may be used 15 | # to endorse or promote products derived from this software without 16 | # specific prior written permission. 17 | # 18 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"AND 19 | # ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | # DISCLAIMED.IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 22 | # ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | # ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | 29 | import datetime 30 | 31 | 32 | def timesince(d, now=None): 33 | """ 34 | Takes two datetime objects and returns the time between d and now 35 | as a nicely formatted string, e.g. "10 minutes". If d occurs after now, 36 | then "0 minutes" is returned. 37 | 38 | Units used are years, months, weeks, days, hours, and minutes. 39 | Seconds and microseconds are ignored. Up to two adjacent units will be 40 | displayed. For example, "2 weeks, 3 days" and "1 year, 3 months" are 41 | possible outputs, but "2 weeks, 3 hours" and "1 year, 5 days" are not. 42 | 43 | Adapted from http://blog.natbat.co.uk/archive/2003/Jun/14/time_since 44 | """ 45 | chunks = ( 46 | (60 * 60 * 24 * 365, ("year", "years")), 47 | (60 * 60 * 24 * 30, ("month", "months")), 48 | (60 * 60 * 24 * 7, ("week", "weeks")), 49 | (60 * 60 * 24, ("day", "days")), 50 | (60 * 60, ("hour", "hours")), 51 | (60, ("minute", "minutes")), 52 | ) 53 | 54 | # Convert int or float (unix epoch) to datetime.datetime for comparison 55 | if isinstance(d, int) or isinstance(d, float): 56 | d = datetime.datetime.fromtimestamp(d) 57 | 58 | # Convert datetime.date to datetime.datetime for comparison. 59 | if not isinstance(d, datetime.datetime): 60 | d = datetime.datetime(d.year, d.month, d.day) 61 | if now and not isinstance(now, datetime.datetime): 62 | now = datetime.datetime(now.year, now.month, now.day) 63 | 64 | if not now: 65 | now = datetime.datetime.now() 66 | 67 | # ignore microsecond part of 'd' since we removed it from 'now' 68 | delta = now - (d - datetime.timedelta(0, 0, d.microsecond)) 69 | since = delta.days * 24 * 60 * 60 + delta.seconds 70 | if since <= 0: 71 | # d is in the future compared to now, stop processing. 72 | return "0 " + "minutes" 73 | for i, (seconds, name) in enumerate(chunks): 74 | count = since // seconds 75 | if count != 0: 76 | break 77 | 78 | if count == 1: 79 | s = "%(number)d %(type)s" % {"number": count, "type": name[0]} 80 | else: 81 | s = "%(number)d %(type)s" % {"number": count, "type": name[1]} 82 | 83 | if i + 1 < len(chunks): 84 | # Now get the second item 85 | seconds2, name2 = chunks[i + 1] 86 | count2 = (since - (seconds * count)) // seconds2 87 | if count2 != 0: 88 | if count2 == 1: 89 | s += ", %d %s" % (count2, name2[0]) 90 | else: 91 | s += ", %d %s" % (count2, name2[1]) 92 | return s 93 | 94 | 95 | def timeuntil(d, now=None): 96 | """ 97 | Like timesince, but returns a string measuring the time until 98 | the given time. 99 | """ 100 | if not now: 101 | now = datetime.datetime.now() 102 | return timesince(now, d) 103 | -------------------------------------------------------------------------------- /plugins/util/urlnorm.py: -------------------------------------------------------------------------------- 1 | """ 2 | URI Normalization function: 3 | * Always provide the URI scheme in lowercase characters. 4 | * Always provide the host, if any, in lowercase characters. 5 | * Only perform percent-encoding where it is essential. 6 | * Always use uppercase A-through-F characters when percent-encoding. 7 | * Prevent dot-segments appearing in non-relative URI paths. 8 | * For schemes that define a default authority, use an empty authority if the 9 | default is desired. 10 | * For schemes that define an empty path to be equivalent to a path of "/", 11 | use "/". 12 | * For schemes that define a port, use an empty port if the default is desired 13 | * All portions of the URI must be utf-8 encoded NFC from Unicode strings 14 | 15 | implements: 16 | http://gbiv.com/protocols/uri/rev-2002/rfc2396bis.html#canonical-form 17 | http://www.intertwingly.net/wiki/pie/PaceCanonicalIds 18 | 19 | inspired by: 20 | Tony J. Ibbs, http://starship.python.net/crew/tibs/python/tji_url.py 21 | Mark Nottingham, http://www.mnot.net/python/urlnorm.py 22 | """ 23 | 24 | from builtins import str 25 | from builtins import object 26 | 27 | __license__ = "Python" 28 | 29 | from future.builtins import str 30 | 31 | import re 32 | import unicodedata 33 | import urllib.parse 34 | from urllib.parse import quote, unquote 35 | 36 | default_port = { 37 | "http": 80, 38 | } 39 | 40 | 41 | class Normalizer(object): 42 | def __init__(self, regex, normalize_func): 43 | self.regex = regex 44 | self.normalize = normalize_func 45 | 46 | 47 | normalizers = ( 48 | Normalizer( 49 | re.compile( 50 | r"(?:https?://)?(?:[a-zA-Z0-9\-]+\.)?(?:amazon|amzn){1}\.(?P<tld>[a-zA-Z\.]{2,})\/(gp/(?:product|offer-listing|customer-media/product-gallery)/|exec/obidos/tg/detail/-/|o/ASIN/|dp/|(?:[A-Za-z0-9\-]+)/dp/)?(?P<ASIN>[0-9A-Za-z]{10})" 51 | ), 52 | lambda m: r"http://amazon.%s/dp/%s" % (m.group("tld"), m.group("ASIN")), 53 | ), 54 | Normalizer( 55 | re.compile(r".*waffleimages\.com.*/([0-9a-fA-F]{40})"), 56 | lambda m: r"http://img.waffleimages.com/%s" % m.group(1), 57 | ), 58 | Normalizer( 59 | re.compile( 60 | r"(?:youtube.*?(?:v=|/v/)|youtu\.be/|yooouuutuuube.*?id=)([-_a-z0-9]+)" 61 | ), 62 | lambda m: r"http://youtube.com/watch?v=%s" % m.group(1), 63 | ), 64 | ) 65 | 66 | 67 | def normalize(url): 68 | """Normalize a URL.""" 69 | 70 | scheme, auth, path, query, fragment = urllib.parse.urlsplit(str(url.strip())) 71 | userinfo, host, port = re.search("([^@]*@)?([^:]*):?(.*)", auth).groups() 72 | 73 | # Always provide the URI scheme in lowercase characters. 74 | scheme = scheme.lower() 75 | 76 | # Always provide the host, if any, in lowercase characters. 77 | host = host.lower() 78 | if host and host[-1] == ".": 79 | host = host[:-1] 80 | if host and host.startswith("www."): 81 | if not scheme: 82 | scheme = "http" 83 | host = host[4:] 84 | elif path and path.startswith("www."): 85 | if not scheme: 86 | scheme = "http" 87 | path = path[4:] 88 | 89 | # Only perform percent-encoding where it is essential. 90 | # Always use uppercase A-through-F characters when percent-encoding. 91 | # All portions of the URI must be utf-8 encoded NFC from Unicode strings 92 | def clean(string): 93 | string = str(unquote(string)) 94 | return unicodedata.normalize("NFC", string).encode("utf-8") 95 | 96 | path = quote(clean(path), "~:/?#[]@!$&'()*+,;=") 97 | fragment = quote(clean(fragment), "~") 98 | 99 | # note care must be taken to only encode & and = characters as values 100 | query = "&".join( 101 | [ 102 | "=".join([quote(clean(t), "~:/?#[]@!$'()*+,;=") for t in q.split("=", 1)]) 103 | for q in query.split("&") 104 | ] 105 | ) 106 | 107 | # Prevent dot-segments appearing in non-relative URI paths. 108 | if scheme in ["", "http", "https", "ftp", "file"]: 109 | output = [] 110 | for input in path.split("/"): 111 | if input == "": 112 | if not output: 113 | output.append(input) 114 | elif input == ".": 115 | pass 116 | elif input == "..": 117 | if len(output) > 1: 118 | output.pop() 119 | else: 120 | output.append(input) 121 | if input in ["", ".", ".."]: 122 | output.append("") 123 | path = "/".join(output) 124 | 125 | # For schemes that define a default authority, use an empty authority if 126 | # the default is desired. 127 | if userinfo in ["@", ":@"]: 128 | userinfo = "" 129 | 130 | # For schemes that define an empty path to be equivalent to a path of "/", 131 | # use "/". 132 | if path == "" and scheme in ["http", "https", "ftp", "file"]: 133 | path = "/" 134 | 135 | # For schemes that define a port, use an empty port if the default is 136 | # desired 137 | if port and scheme in list(default_port.keys()): 138 | if port.isdigit(): 139 | port = str(int(port)) 140 | if int(port) == default_port[scheme]: 141 | port = "" 142 | 143 | # Put it all back together again 144 | auth = (userinfo or "") + host 145 | if port: 146 | auth += ":" + port 147 | if url.endswith("#") and query == "" and fragment == "": 148 | path += "#" 149 | normal_url = urllib.parse.urlunsplit((scheme, auth, path, query, fragment)).replace( 150 | "http:///", "http://" 151 | ) 152 | for norm in normalizers: 153 | m = norm.regex.match(normal_url) 154 | if m: 155 | return norm.normalize(m) 156 | return normal_url 157 | -------------------------------------------------------------------------------- /plugins/vimeo.py: -------------------------------------------------------------------------------- 1 | from util import hook, http 2 | 3 | 4 | @hook.regex(r"vimeo.com/([0-9]+)") 5 | def vimeo_url(match): 6 | info = http.get_json("http://vimeo.com/api/v2/video/%s.json" % match.group(1)) 7 | 8 | if info: 9 | return ( 10 | "\x02%(title)s\x02 - length \x02%(duration)ss\x02 - " 11 | "\x02%(stats_number_of_likes)s\x02 likes - " 12 | "\x02%(stats_number_of_plays)s\x02 plays - " 13 | "\x02%(user_name)s\x02 on \x02%(upload_date)s\x02" % info[0] 14 | ) 15 | -------------------------------------------------------------------------------- /plugins/weather.py: -------------------------------------------------------------------------------- 1 | """Weather, thanks to pirateweather and google geocoding.""" 2 | from __future__ import unicode_literals 3 | 4 | from util import hook, http 5 | 6 | 7 | GEOCODING_URL = "https://maps.googleapis.com/maps/api/geocode/json" 8 | PIRATEWEATHER_URL = "https://api.pirateweather.net/forecast/" 9 | 10 | 11 | def geocode_location(api_key, loc): 12 | """Get a geocoded location from gooogle's geocoding api.""" 13 | try: 14 | parsed_json = http.get_json(GEOCODING_URL, address=loc, key=api_key) 15 | except IOError: 16 | return None 17 | 18 | return parsed_json 19 | 20 | 21 | def get_weather_data(api_key, lat, long): 22 | """Get weather data from pirateweather.""" 23 | query = "{key}/{lat},{long}".format(key=api_key, lat=lat, long=long) 24 | url = PIRATEWEATHER_URL + query 25 | try: 26 | parsed_json = http.get_json(url) 27 | except IOError: 28 | return None 29 | 30 | return parsed_json 31 | 32 | 33 | def f_to_c(temp_f): 34 | """Convert F to C.""" 35 | return (temp_f - 32) * 5 / 9 36 | 37 | 38 | def mph_to_kph(mph): 39 | """Convert mph to kph.""" 40 | return mph * 1.609 41 | 42 | 43 | @hook.api_key("google", "pirateweather") 44 | @hook.command(autohelp=False) 45 | def weather(inp, chan="", nick="", reply=None, db=None, api_key=None): 46 | """.weather <location> [dontsave] | @<nick> -- Get weather data.""" 47 | if "google" not in api_key and "pirateweather" not in api_key: 48 | return None 49 | 50 | # this database is used by other plugins interested in user's locations, 51 | # like .near in tag.py 52 | db.execute( 53 | "create table if not exists " 54 | "location(chan, nick, loc, lat, lon, primary key(chan, nick))" 55 | ) 56 | 57 | if inp[0:1] == "@": 58 | nick = inp[1:].strip() 59 | loc = None 60 | dontsave = True 61 | else: 62 | dontsave = inp.endswith(" dontsave") 63 | # strip off the " dontsave" text if it exists and set it back to `inp` 64 | # so we don't report it back to the user incorrectly 65 | if dontsave: 66 | inp = inp[:-9].strip().lower() 67 | loc = inp 68 | 69 | if not loc: # blank line 70 | loc = db.execute( 71 | "select loc, lat, lon from location where chan=? and nick=lower(?)", 72 | (chan, nick), 73 | ).fetchone() 74 | if not loc: 75 | return weather.__doc__ 76 | addr, lat, lng = loc 77 | else: 78 | location = geocode_location(api_key["google"], loc) 79 | 80 | if not location or location.get("status") != "OK": 81 | reply("Failed to determine location for {}".format(inp)) 82 | return 83 | 84 | geo = location.get("results", [{}])[0].get("geometry", {}).get("location", None) 85 | if not geo or "lat" not in geo or "lng" not in geo: 86 | reply("Failed to determine location for {}".format(inp)) 87 | return 88 | 89 | addr = location["results"][0]["formatted_address"] 90 | lat = geo["lat"] 91 | lng = geo["lng"] 92 | 93 | parsed_json = get_weather_data(api_key["pirateweather"], lat, lng) 94 | current = parsed_json.get("currently") 95 | 96 | if not current: 97 | reply("Failed to get weather data for {}".format(inp)) 98 | return 99 | 100 | forecast = parsed_json["daily"]["data"][0] 101 | 102 | info = { 103 | "city": addr, 104 | "t_f": current["temperature"], 105 | "t_c": f_to_c(current["temperature"]), 106 | "h_f": forecast["temperatureHigh"], 107 | "h_c": f_to_c(forecast["temperatureHigh"]), 108 | "l_f": forecast["temperatureLow"], 109 | "l_c": f_to_c(forecast["temperatureLow"]), 110 | "weather": current["summary"], 111 | "humid": int(current["humidity"] * 100), 112 | "wind": "Wind: {mph:.1f}mph/{kph:.1f}kph".format( 113 | mph=current["windSpeed"], kph=mph_to_kph(current["windSpeed"]) 114 | ), 115 | "forecast": parsed_json.get("hourly", {}).get("summary", ""), 116 | } 117 | reply( 118 | "{city}: {weather}, {t_f:.1f}F/{t_c:.1f}C" 119 | "(H:{h_f:.1f}F/{h_c:.1f}C L:{l_f:.1f}F/{l_c:.1f}C)" 120 | ", Humidity: {humid}%, {wind} \x02{forecast}\x02".format(**info) 121 | ) 122 | 123 | if inp and not dontsave: 124 | db.execute( 125 | "insert or replace into " 126 | "location(chan, nick, loc, lat, lon) " 127 | "values (?, ?, ?, ?, ?)", 128 | (chan, nick.lower(), addr, lat, lng), 129 | ) 130 | db.commit() 131 | -------------------------------------------------------------------------------- /plugins/wikipedia.py: -------------------------------------------------------------------------------- 1 | """Searches wikipedia and returns first sentence of article 2 | Scaevolus 2009""" 3 | 4 | from __future__ import unicode_literals 5 | 6 | import re 7 | 8 | from util import hook, http 9 | 10 | 11 | OUTPUT_LIMIT = 240 # character limit 12 | INSTANCES = { 13 | "encyclopediadramatica": { 14 | "name": "Encyclopedia Dramatica", 15 | "search": "https://encyclopediadramatica.wiki/api.php", 16 | "regex": r"(https?://encyclopediadramatica\.wiki/index\.php/[^ ]+)", 17 | }, 18 | "wikipedia_en": { 19 | "name": "Wikipedia", 20 | "search": "https://en.wikipedia.org/w/api.php", 21 | "regex": r"(https?://en\.wikipedia\.org/wiki/[^ ]+)", 22 | }, 23 | } 24 | 25 | 26 | def search(instance, query): 27 | if instance not in INSTANCES: 28 | return 29 | 30 | wiki = INSTANCES[instance] 31 | search = http.get_json( 32 | wiki["search"], 33 | query_params={ 34 | "action": "opensearch", 35 | "format": "json", 36 | "limit": 2, 37 | "search": query, 38 | }, 39 | ) 40 | 41 | print(search) 42 | 43 | titles = search[1] 44 | descriptions = search[2] 45 | urls = search[3] 46 | 47 | return (titles, descriptions, urls) 48 | 49 | 50 | def scrape_text(url): 51 | h = http.get_html(url) 52 | 53 | title = h.xpath("string(//h1[@id='firstHeading'])") 54 | body = h.xpath("//div[@id='mw-content-text']/descendant-or-self::p") 55 | 56 | if title: 57 | title = title.strip() 58 | 59 | if body is None: 60 | return "Error reading the article" 61 | 62 | output = [] 63 | 64 | for paragraph in body: 65 | text = paragraph.text_content() 66 | if len(text) > 4: # skip empty paragraphs 67 | output.append(text) 68 | 69 | output = " ".join(output) 70 | 71 | return output, title 72 | 73 | 74 | def command_wrapper(instance, inp): 75 | titles, descriptions, urls = search(instance, inp) 76 | 77 | if not titles: 78 | return "No results found." 79 | 80 | title = titles[0] 81 | url = urls[0] 82 | 83 | # `real_title` shows the article title after a 84 | # redirect. its generally longer than `title` 85 | print(url) 86 | output, real_title = scrape_text(url) 87 | 88 | if len(output) > OUTPUT_LIMIT: 89 | output = output[:OUTPUT_LIMIT] + "..." 90 | 91 | if title == real_title: 92 | return "\x02{} -\x02 {} \x02-\x02 {}".format(title, output, url) 93 | else: 94 | return "\x02{} -\x02 {} \x02-\x02 {} (redirected from {})".format( 95 | real_title, output, url, title 96 | ) 97 | 98 | 99 | def url_wrapper(instance, url): 100 | output, title = scrape_text(url) 101 | 102 | if len(output) > OUTPUT_LIMIT: 103 | output = output[:OUTPUT_LIMIT] + "..." 104 | 105 | return "\x02{} -\x02 {}".format(title, output) 106 | 107 | 108 | @hook.command("ed") 109 | @hook.command 110 | def drama(inp): 111 | "drama <article> -- search an Encyclopedia Dramatica article" 112 | return command_wrapper("encyclopediadramatica", inp) 113 | 114 | 115 | @hook.command("w") 116 | @hook.command 117 | def wikipedia(inp): 118 | "wikipedia <article> -- search a wikipedia article" 119 | return command_wrapper("wikipedia_en", inp) 120 | -------------------------------------------------------------------------------- /plugins/wolframalpha.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from builtins import chr 4 | import re 5 | import urllib.parse 6 | 7 | from util import hook, http 8 | 9 | 10 | @hook.api_key("wolframalpha") 11 | @hook.command("who") 12 | @hook.command("what") 13 | @hook.command("where") 14 | @hook.command("when") 15 | @hook.command("why") 16 | @hook.command 17 | def ask(inp, say=None, trigger=None, nick=None, api_key=None): 18 | ".ask <query> -- ask wolfram a question, get an answer" 19 | 20 | if trigger in ["who", "what", "where", "when", "why"]: 21 | inp = f"{trigger} {inp}" 22 | 23 | base_url = "http://api.wolframalpha.com/v1/conversation.jsp" 24 | query = urllib.parse.quote_plus(inp) 25 | url = f"{base_url}?appid={api_key}&i={query}" 26 | 27 | response = http.get_json(url) 28 | result = response.get("result") 29 | 30 | if not result: 31 | say(f"Sorry, I do not know how to answer that {nick}.") 32 | 33 | return result 34 | 35 | 36 | @hook.api_key("wolframalpha") 37 | @hook.command("wa") 38 | @hook.command 39 | def wolframalpha(inp, api_key=None): 40 | ".wa/.wolframalpha <query> -- computes <query> using Wolfram Alpha" 41 | 42 | url = "http://api.wolframalpha.com/v2/query?format=plaintext" 43 | 44 | result = http.get_xml(url, input=inp, appid=api_key) 45 | 46 | pod_texts = [] 47 | for pod in result.xpath("//pod"): 48 | title = "" if pod.attrib["title"] == "Result" else pod.attrib["title"] + ": " 49 | if pod.attrib["id"] == "Input": 50 | continue 51 | 52 | results = [] 53 | for subpod in pod.xpath("subpod/plaintext/text()"): 54 | subpod = subpod.strip().replace("\\n", "; ") 55 | subpod = re.sub(r"\s+", " ", subpod) 56 | if subpod: 57 | results.append(subpod) 58 | if results: 59 | pod_texts.append(title + "|".join(results)) 60 | 61 | ret = ". ".join(pod_texts) 62 | 63 | if not pod_texts: 64 | return "no results" 65 | 66 | ret = re.sub(r"\\(.)", r"\1", ret) 67 | 68 | def unicode_sub(match): 69 | return chr(int(match.group(1), 16)) 70 | 71 | ret = re.sub(r"\\:([0-9a-z]{4})", unicode_sub, ret) 72 | 73 | if len(ret) > 430: 74 | ret = ret[: ret.rfind(" ", 0, 430)] 75 | ret = re.sub(r"\W+$", "", ret) + "..." 76 | 77 | if not ret: 78 | return "no results" 79 | 80 | return ret 81 | -------------------------------------------------------------------------------- /plugins/youtube.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from builtins import str 4 | import re 5 | import time 6 | 7 | from util import hook, http 8 | 9 | 10 | youtube_re = ( 11 | r"(?:youtube.*?(?:v=|/v/|/shorts/)|youtu\.be/|yooouuutuuube.*?id=)" "([-_a-z0-9]+)", 12 | re.I, 13 | ) 14 | 15 | BASE_URL = "https://www.googleapis.com/youtube/v3/" 16 | INFO_URL = BASE_URL + "videos?part=snippet,contentDetails,statistics&hl=en" 17 | SEARCH_API_URL = BASE_URL + "search" 18 | VIDEO_URL = "https://youtube.com/watch?v=%s" 19 | 20 | 21 | def get_video_description(vid_id, api_key): 22 | j = http.get_json(INFO_URL, id=vid_id, key=api_key) 23 | 24 | if not j["pageInfo"]["totalResults"]: 25 | return 26 | 27 | j = j["items"][0] 28 | 29 | duration = j["contentDetails"]["duration"].replace("PT", "").lower() 30 | 31 | published = j["snippet"]["publishedAt"].replace(".000Z", "Z") 32 | published = time.strptime(published, "%Y-%m-%dT%H:%M:%SZ") 33 | published = time.strftime("%Y.%m.%d", published) 34 | 35 | views = group_int_digits(j["statistics"]["viewCount"], ",") 36 | likes = j["statistics"].get("likeCount", 0) 37 | dislikes = j["statistics"].get("dislikeCount", 0) 38 | title = j["snippet"]["title"] 39 | if "localized" in j["snippet"]: 40 | title = j["snippet"]["localized"].get("title") or title 41 | 42 | out = ( 43 | "\x02{title}\x02 - length \x02{duration}\x02 - " 44 | "{likes}\u2191{dislikes}\u2193 - " 45 | "\x02{views}\x02 views - " 46 | "\x02{snippet[channelTitle]}\x02 on \x02{published}\x02" 47 | ).format( 48 | duration=duration, 49 | likes=likes, 50 | dislikes=dislikes, 51 | views=views, 52 | published=published, 53 | title=title, 54 | **j 55 | ) 56 | 57 | # TODO: figure out how to detect NSFW videos 58 | 59 | return out 60 | 61 | 62 | def group_int_digits(number, delimiter=" ", grouping=3): 63 | base = str(number).strip() 64 | builder = [] 65 | while base: 66 | builder.append(base[-grouping:]) 67 | base = base[:-grouping] 68 | builder.reverse() 69 | return delimiter.join(builder) 70 | 71 | 72 | @hook.api_key("google") 73 | @hook.regex(*youtube_re) 74 | def youtube_url(match, api_key=None): 75 | return get_video_description(match.group(1), api_key) 76 | 77 | 78 | @hook.api_key("google") 79 | @hook.command("yt") 80 | @hook.command("y") 81 | @hook.command 82 | def youtube(inp, api_key=None): 83 | ".youtube <query> -- returns the first YouTube search result for <query>" 84 | 85 | params = { 86 | "key": api_key, 87 | "fields": "items(id(videoId))", 88 | "part": "snippet", 89 | "type": "video", 90 | "maxResults": "1", 91 | "q": inp, 92 | } 93 | 94 | j = http.get_json(SEARCH_API_URL, **params) 95 | 96 | if "error" in j: 97 | return "error while performing the search" 98 | 99 | results = j.get("items") 100 | 101 | if not results: 102 | return "no results found" 103 | 104 | vid_id = j["items"][0]["id"]["videoId"] 105 | 106 | return get_video_description(vid_id, api_key) + " - " + VIDEO_URL % vid_id 107 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | lxml 2 | future 3 | -------------------------------------------------------------------------------- /test/__main__.py: -------------------------------------------------------------------------------- 1 | from os import path 2 | from unittest import TestSuite, TestLoader, TextTestRunner 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | # Because the project is structured differently than 7 | # any tooling expects, we need to modify the python 8 | # path during runtime (or before) to get it to 9 | # properly import plugins and other code correctly. 10 | project_root_directory = path.dirname(path.dirname(__file__)) 11 | 12 | sys.path.append(path.join(project_root_directory, "plugins")) 13 | sys.path.append(path.join(project_root_directory)) 14 | 15 | discovered_tests = TestLoader().discover(path.dirname(__file__)) 16 | run_result = TextTestRunner().run(discovered_tests) 17 | 18 | if not run_result.wasSuccessful(): 19 | sys.exit(1) 20 | -------------------------------------------------------------------------------- /test/helpers.py: -------------------------------------------------------------------------------- 1 | from os import path 2 | import json 3 | import inspect 4 | 5 | 6 | def get_fixture_file(testcase, file): 7 | filename = inspect.getfile(testcase.__class__) 8 | 9 | test_name, _ = path.splitext(filename) 10 | dir_path = path.dirname(path.realpath(filename)) 11 | 12 | with open(path.join(dir_path, test_name, file)) as f: 13 | return f.read() 14 | 15 | 16 | def get_fixture_file_data(testcase, file): 17 | return json.loads(get_fixture_file(testcase, file)) 18 | 19 | 20 | def execute_skybot_regex(command_method, inp, **kwargs): 21 | for type, (func, func_args) in command_method._hook: 22 | if not type == "regex" or "re" not in func_args: 23 | continue 24 | 25 | search_result = func_args["re"].search(inp) 26 | 27 | if search_result: 28 | return command_method(search_result, **kwargs) 29 | 30 | return None 31 | -------------------------------------------------------------------------------- /test/plugins/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rmmh/skybot/76eca17bbc23e3e0b262a6b533eac4d58d55e5d1/test/plugins/__init__.py -------------------------------------------------------------------------------- /test/plugins/test_bf.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from bf import bf 3 | 4 | 5 | class TestBF(TestCase): 6 | def test_hello(self): 7 | expected = "Hello world!" 8 | actual = bf( 9 | "--[>--->->->++>-<<<<<-------]>--.>---------.>--..+++.>---" 10 | "-.>+++++++++.<<.+++.------.<-.>>+." 11 | ) 12 | 13 | assert expected == actual 14 | 15 | def test_unbalanced(self): 16 | expected = "unbalanced brackets" 17 | 18 | actual_a = bf("[[++]]]") 19 | actual_b = bf("[[[++]]") 20 | 21 | assert expected == actual_a 22 | assert expected == actual_b 23 | 24 | def test_comment(self): 25 | expected = "*" 26 | actual = bf("[this is a comment!]++++++[>+++++++<-]>.") 27 | 28 | assert expected == actual 29 | 30 | def test_unprintable(self): 31 | expected = "no printable output" 32 | actual = bf("+.") 33 | 34 | assert expected == actual 35 | 36 | def test_empty(self): 37 | expected = "no output" 38 | actual = bf("+++[-]") 39 | 40 | assert expected == actual 41 | 42 | def test_exceeded(self): 43 | expected = "no output [exceeded 1000 iterations]" 44 | actual = bf("+[>,[-]<]", 1000) 45 | 46 | assert expected == actual 47 | 48 | def test_inf_mem(self): 49 | expected = "no output [exceeded 1000 iterations]" 50 | actual = bf("+[>[.-]+]", 1000, buffer_size=10) 51 | 52 | assert expected == actual 53 | 54 | def test_left_wrap(self): 55 | # eventually, wrap around and hit ourselves 56 | expected = "aaaaaaa [exceeded 2000 iterations]" 57 | actual = bf("+[<[-" + "+" * ord("a") + ".[-]]+]", 2000, buffer_size=5) 58 | 59 | assert expected == actual 60 | 61 | def test_too_much_output(self): 62 | expected = "a" * 430 63 | actual = bf("+" * ord("a") + "[.]") 64 | 65 | assert expected == actual 66 | -------------------------------------------------------------------------------- /test/plugins/test_bitcoin.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from mock import patch, Mock 3 | 4 | from helpers import get_fixture_file_data 5 | from bitcoin import bitcoin, ethereum 6 | 7 | 8 | class TestBitcoin(TestCase): 9 | @patch("util.http.get_json") 10 | def test_bitcoin(self, mock_http_get): 11 | mock_http_get.return_value = get_fixture_file_data(self, "bitcoin.json") 12 | 13 | say_mock = Mock() 14 | 15 | bitcoin("", say=say_mock) 16 | 17 | expected = ( 18 | "USD/BTC: \x0307$6,390.67\x0f - High: \x0307$6,627." 19 | "00\x0f - Low: \x0307$6,295.73\x0f - Volume: " 20 | "10,329.67 BTC" 21 | ) 22 | say_mock.assert_called_once_with(expected) 23 | 24 | @patch("util.http.get_json") 25 | def test_ethereum(self, mock_http_get): 26 | mock_http_get.return_value = get_fixture_file_data(self, "ethereum.json") 27 | 28 | say_mock = Mock() 29 | 30 | ethereum("", say=say_mock) 31 | 32 | expected = ( 33 | "USD/ETH: \x0307$355.02\x0f - High: \x0307$370.43" 34 | "\x0f - Low: \x0307$350.00\x0f - Volume: " 35 | "16,408.97 ETH" 36 | ) 37 | 38 | say_mock.assert_called_once_with(expected) 39 | -------------------------------------------------------------------------------- /test/plugins/test_bitcoin/bitcoin.json: -------------------------------------------------------------------------------- 1 | {"high": "6627.00000000", "last": "6390.67", "timestamp": "1533932533", "bid": "6390.66", "vwap": "6432.04", "volume": "10329.66980521", "low": "6295.73000000", "ask": "6390.67", "open": 6544.98} -------------------------------------------------------------------------------- /test/plugins/test_bitcoin/ethereum.json: -------------------------------------------------------------------------------- 1 | {"high": "370.43", "last": "355.02", "timestamp": "1533932548", "bid": "354.53", "vwap": "361.09", "volume": "16408.96753222", "low": "350.00", "ask": "354.98", "open": "363.96"} -------------------------------------------------------------------------------- /test/plugins/test_cdecl.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from mock import patch 3 | 4 | from cdecl import cdecl 5 | 6 | 7 | class TestCDecl(TestCase): 8 | @patch("util.http.get") 9 | def test_cdecl(self, mock_http_get): 10 | mock_http_get.side_effect = [ 11 | 'var QUERY_ENDPOINT = "http://foo.bar"', 12 | '"declare x as array 3 of pointer to function returning pointer to array 5 of char"', 13 | ] 14 | 15 | expected = '"declare x as array 3 of pointer to function returning pointer to array 5 of char"' 16 | actual = cdecl("char (*(*x())[5])()") 17 | 18 | assert expected == actual 19 | -------------------------------------------------------------------------------- /test/plugins/test_choose.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from mock import patch 3 | 4 | from choose import choose 5 | 6 | 7 | class TestChoose(TestCase): 8 | def test_choose_one_choice(self): 9 | expected = "the decision is up to you" 10 | actual = choose("foo") 11 | 12 | assert expected == actual 13 | 14 | def test_choose_same_thing(self): 15 | expected = "foo" 16 | actual = choose("foo, foo, foo") 17 | 18 | assert expected == actual 19 | 20 | def test_choose_two_choices(self): 21 | actual = choose("foo, bar") 22 | 23 | assert actual in ["foo", "bar"] 24 | 25 | def test_choose_choices_space(self): 26 | expected_values = ["foo", "bar"] 27 | actual = choose("foo bar") 28 | 29 | assert actual in expected_values 30 | 31 | def test_choose_strips_whitespace(self): 32 | expected_values = ["foo", "bar"] 33 | actual = choose(" foo ," " bar ") 34 | 35 | assert actual in expected_values 36 | 37 | @patch("random.choice") 38 | def test_choose_end_comma_behavior(self, mock_random_choice): 39 | mock_random_choice.side_effect = lambda arr: arr[0] 40 | 41 | expected = "the decision is up to you" 42 | actual = choose("foo,") 43 | 44 | assert actual == expected 45 | 46 | @patch("random.choice") 47 | def test_choose_collapse_commas(self, mock_random_choice): 48 | # Should never be an empty string here 49 | mock_random_choice.side_effect = lambda arr: arr[1] 50 | 51 | expected = "bar" 52 | actual = choose("foo,,bar") 53 | 54 | assert actual == expected 55 | -------------------------------------------------------------------------------- /test/plugins/test_crowdcontrol.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | from unittest import TestCase 3 | from mock import Mock, patch, call 4 | 5 | from helpers import execute_skybot_regex 6 | from crowdcontrol import crowdcontrol 7 | 8 | 9 | class TestCrowdcontrol(TestCase): 10 | def call_crowd_control(self, input, called=None, rules=None): 11 | mock_names = ["kick", "ban", "unban", "reply"] 12 | 13 | mocks = {} 14 | 15 | for name in mock_names: 16 | mocks[name] = Mock(name=name) 17 | 18 | config = {"crowdcontrol": rules} if rules else {} 19 | bot = namedtuple("Bot", "config")(config=config) 20 | 21 | execute_skybot_regex(crowdcontrol, input, bot=bot, **mocks) 22 | 23 | return mocks 24 | 25 | def test_no_rules(self): 26 | mocks = self.call_crowd_control("Hello world!") 27 | 28 | for m in mocks.values(): 29 | m.assert_not_called() 30 | 31 | def test_no_matches(self): 32 | mocks = self.call_crowd_control("Hello world!", rules=[{"re": "No match"}]) 33 | 34 | for m in mocks.values(): 35 | m.assert_not_called() 36 | 37 | def test_match_no_action(self): 38 | mocks = self.call_crowd_control("Hello world!", rules=[{"re": "Hello"}]) 39 | 40 | for m in mocks.values(): 41 | m.assert_not_called() 42 | 43 | def test_match_only_msg(self): 44 | mocks = self.call_crowd_control( 45 | "Hello world!", rules=[{"re": "Hello", "msg": "Hello!"}] 46 | ) 47 | 48 | for n, m in mocks.items(): 49 | if n == "reply": 50 | m.assert_called_once_with("Hello!") 51 | else: 52 | m.assert_not_called() 53 | 54 | def test_match_ban_forever_no_kick(self): 55 | mocks = self.call_crowd_control( 56 | "Hello world!", rules=[{"re": "Hello", "ban_length": -1}] 57 | ) 58 | 59 | for n, m in mocks.items(): 60 | if n == "ban": 61 | m.assert_called_once() 62 | else: 63 | m.assert_not_called() 64 | 65 | def test_match_kick_no_ban(self): 66 | mocks = self.call_crowd_control( 67 | "Hello world!", rules=[{"re": "Hello", "kick": 1}] 68 | ) 69 | 70 | for n, m in mocks.items(): 71 | if n == "kick": 72 | m.assert_called_once() 73 | else: 74 | m.assert_not_called() 75 | 76 | def test_match_kick_with_msg(self): 77 | mocks = self.call_crowd_control( 78 | "Hello world!", rules=[{"re": "Hello", "kick": 1, "msg": "Hello!"}] 79 | ) 80 | 81 | for n, m in mocks.items(): 82 | if n == "kick": 83 | m.assert_called_once_with(reason="Hello!") 84 | else: 85 | m.assert_not_called() 86 | 87 | def test_match_kick_ban_forever(self): 88 | mocks = self.call_crowd_control( 89 | "Hello world!", rules=[{"re": "Hello", "kick": 1, "ban_length": -1}] 90 | ) 91 | 92 | for n, m in mocks.items(): 93 | if n == "kick" or n == "ban": 94 | m.assert_called_once() 95 | else: 96 | m.assert_not_called() 97 | 98 | @patch("time.sleep") 99 | def test_match_ban_only_time_limit(self, mock_time_sleep): 100 | 101 | mocks = self.call_crowd_control( 102 | "Hello world!", rules=[{"re": "Hello", "ban_length": 5}] 103 | ) 104 | 105 | mock_time_sleep.assert_called_once_with(5) 106 | 107 | for n, m in mocks.items(): 108 | if n == "ban" or n == "unban": 109 | m.assert_called_once() 110 | else: 111 | m.assert_not_called() 112 | 113 | @patch("time.sleep") 114 | def test_match_kick_ban_time_limit(self, mock_time_sleep): 115 | 116 | mocks = self.call_crowd_control( 117 | "Hello world!", rules=[{"re": "Hello", "kick": 1, "ban_length": 5}] 118 | ) 119 | 120 | mock_time_sleep.assert_called_once_with(5) 121 | 122 | for n, m in mocks.items(): 123 | if n == "kick" or n == "ban" or n == "unban": 124 | m.assert_called_once() 125 | else: 126 | m.assert_not_called() 127 | 128 | def test_match_multiple_rules_in_order(self): 129 | mocks = self.call_crowd_control( 130 | "Hello world!", 131 | rules=[ 132 | {"re": "Hello", "msg": "1"}, 133 | {"re": "Fancy", "msg": "2"}, 134 | {"re": "[wW]orld", "msg": "3"}, 135 | ], 136 | ) 137 | 138 | for n, m in mocks.items(): 139 | if n == "reply": 140 | m.assert_has_calls([call("1"), call("3")]) 141 | else: 142 | m.assert_not_called() 143 | -------------------------------------------------------------------------------- /test/plugins/test_crypto.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from mock import Mock, patch 3 | 4 | from helpers import get_fixture_file_data 5 | from crypto import crypto 6 | 7 | 8 | class TestCrypto(TestCase): 9 | @patch("util.http.get_json") 10 | def test_crypto_btc_to_default(self, mock_http_get): 11 | mock_http_get.return_value = get_fixture_file_data( 12 | self, "crypto_btc_to_default.json" 13 | ) 14 | 15 | expected = ( 16 | u"USD/\u0243: \x0307$ 6,084.30\x0f - High: " 17 | u"\x0307$ 6,178.90\x0f - Low: \x0307$ 6,014.26" 18 | u"\x0f - Volume: \u0243 21,428.7 " 19 | u"($ 131,067,493.3) - Total Supply: " 20 | u"\u0243 17,202,675.0 - MktCap: $ 104.67 B" 21 | ) 22 | 23 | say_mock = Mock() 24 | crypto("btc", say=say_mock) 25 | 26 | say_mock.assert_called_once_with(expected) 27 | 28 | @patch("util.http.get_json") 29 | def test_crypto_eth_to_default(self, mock_http_get): 30 | mock_http_get.return_value = get_fixture_file_data( 31 | self, "crypto_eth_to_default.json" 32 | ) 33 | 34 | expected = ( 35 | u"USD/\u039e: \x0307$ 315.91\x0f - High: " 36 | u"\x0307$ 332.41\x0f - Low: \x0307$ 312.35\x0f " 37 | u"- Volume: \u039e 163,011.9 ($ 52,513,531.9) - " 38 | u"Total Supply: \u039e 101,251,550.4 - " 39 | u"MktCap: $ 31.99 B" 40 | ) 41 | 42 | say_mock = Mock() 43 | crypto("eth", say_mock) 44 | 45 | say_mock.assert_called_once_with(expected) 46 | -------------------------------------------------------------------------------- /test/plugins/test_crypto/crypto_btc_to_btc.json: -------------------------------------------------------------------------------- 1 | {"RAW":{"BTC":{"BTC":{"TYPE":"5","MARKET":"CCCAGG","FROMSYMBOL":"BTC","TOSYMBOL":"BTC","FLAGS":"4","PRICE":1,"LASTUPDATE":1533965230,"LASTVOLUME":0,"LASTVOLUMETO":0,"LASTTRADEID":0,"VOLUMEDAY":0,"VOLUMEDAYTO":0,"VOLUME24HOUR":0,"VOLUME24HOURTO":0,"OPENDAY":0.9579629629629629,"HIGHDAY":1.000193348801237,"LOWDAY":0.9563690146052872,"OPEN24HOUR":0.9247407937075438,"HIGH24HOUR":1.002713704206242,"LOW24HOUR":0.913957597173145,"LASTMARKET":"Cexio","CHANGE24HOUR":0.0752592062924562,"CHANGEPCT24HOUR":8.138410980088922,"CHANGEDAY":0.042037037037037095,"CHANGEPCTDAY":4.388169340808048,"SUPPLY":17202675,"MKTCAP":17202675,"TOTALVOLUME24H":462329.4035614009,"TOTALVOLUME24HTO":462329.4035614009}}},"DISPLAY":{"BTC":{"BTC":{"FROMSYMBOL":"Ƀ","TOSYMBOL":"Ƀ","MARKET":"CryptoCompare Index","PRICE":"Ƀ 1.00","LASTUPDATE":"Just now","LASTVOLUME":"Ƀ 0","LASTVOLUMETO":"Ƀ 0","LASTTRADEID":0,"VOLUMEDAY":"Ƀ 0","VOLUMEDAYTO":"Ƀ 0","VOLUME24HOUR":"Ƀ 0","VOLUME24HOURTO":"Ƀ 0","OPENDAY":"Ƀ 0.9580","HIGHDAY":"Ƀ 1.00","LOWDAY":"Ƀ 0.9564","OPEN24HOUR":"Ƀ 0.9247","HIGH24HOUR":"Ƀ 1.00","LOW24HOUR":"Ƀ 0.9140","LASTMARKET":"Cexio","CHANGE24HOUR":"Ƀ 0.075","CHANGEPCT24HOUR":"8.14","CHANGEDAY":"Ƀ 0.042","CHANGEPCTDAY":"4.39","SUPPLY":"Ƀ 17,202,675.0","MKTCAP":"Ƀ 17.20 M","TOTALVOLUME24H":"Ƀ 462.33 K","TOTALVOLUME24HTO":"Ƀ 462.33 K"}}}} -------------------------------------------------------------------------------- /test/plugins/test_crypto/crypto_btc_to_cad.json: -------------------------------------------------------------------------------- 1 | {"RAW":{"BTC":{"CAD":{"TYPE":"5","MARKET":"CCCAGG","FROMSYMBOL":"BTC","TOSYMBOL":"CAD","FLAGS":"4","PRICE":8608.76,"LASTUPDATE":1533964566,"LASTVOLUME":0.00278958,"LASTVOLUMETO":24.3886563366,"LASTTRADEID":"3562813","VOLUMEDAY":54.70759288999996,"VOLUMEDAYTO":468402.7143309134,"VOLUME24HOUR":412.1872334000001,"VOLUME24HOURTO":3594321.404294858,"OPENDAY":8647.55,"HIGHDAY":8816.09,"LOWDAY":8459.29,"OPEN24HOUR":8921.47,"HIGH24HOUR":8989.06,"LOW24HOUR":8491.39,"LASTMARKET":"QuadrigaCX","CHANGE24HOUR":-312.7099999999991,"CHANGEPCT24HOUR":-3.505139848029519,"CHANGEDAY":-38.789999999999054,"CHANGEPCTDAY":-0.4485663569450198,"SUPPLY":17202675,"MKTCAP":148093700433,"TOTALVOLUME24H":458413.46110797784,"TOTALVOLUME24HTO":3946417367.8848057}}},"DISPLAY":{"BTC":{"CAD":{"FROMSYMBOL":"Ƀ","TOSYMBOL":"CAD","MARKET":"CryptoCompare Index","PRICE":"CAD 8,608.76","LASTUPDATE":"1 min ago","LASTVOLUME":"Ƀ 0.002790","LASTVOLUMETO":"CAD 24.39","LASTTRADEID":"3562813","VOLUMEDAY":"Ƀ 54.71","VOLUMEDAYTO":"CAD 468,402.7","VOLUME24HOUR":"Ƀ 412.19","VOLUME24HOURTO":"CAD 3,594,321.4","OPENDAY":"CAD 8,647.55","HIGHDAY":"CAD 8,816.09","LOWDAY":"CAD 8,459.29","OPEN24HOUR":"CAD 8,921.47","HIGH24HOUR":"CAD 8,989.06","LOW24HOUR":"CAD 8,491.39","LASTMARKET":"QuadrigaCX","CHANGE24HOUR":"CAD -312.71","CHANGEPCT24HOUR":"-3.51","CHANGEDAY":"CAD -38.79","CHANGEPCTDAY":"-0.45","SUPPLY":"Ƀ 17,202,675.0","MKTCAP":"CAD 148.09 B","TOTALVOLUME24H":"Ƀ 458.41 K","TOTALVOLUME24HTO":"CAD 3,946.42 M"}}}} -------------------------------------------------------------------------------- /test/plugins/test_crypto/crypto_btc_to_default.json: -------------------------------------------------------------------------------- 1 | {"RAW":{"BTC":{"USD":{"TYPE":"5","MARKET":"CCCAGG","FROMSYMBOL":"BTC","TOSYMBOL":"USD","FLAGS":"1","PRICE":6084.3,"LASTUPDATE":1533965210,"LASTVOLUME":0.0049,"LASTVOLUMETO":29.968890000000002,"LASTTRADEID":"7779588","VOLUMEDAY":21428.734067810907,"VOLUMEDAYTO":131067493.30908191,"VOLUME24HOUR":105750.41163186147,"VOLUME24HOURTO":663220908.6037983,"OPENDAY":6153.41,"HIGHDAY":6178.9,"LOWDAY":6014.26,"OPEN24HOUR":6452.85,"HIGH24HOUR":6541.03,"LOW24HOUR":6009.51,"LASTMARKET":"Cexio","CHANGE24HOUR":-368.5500000000002,"CHANGEPCT24HOUR":-5.711429833329461,"CHANGEDAY":-69.10999999999967,"CHANGEPCTDAY":-1.1231171009245227,"SUPPLY":17202675,"MKTCAP":104666235502.5,"TOTALVOLUME24H":462035.95442587347,"TOTALVOLUME24HTO":2830969036.625406}}},"DISPLAY":{"BTC":{"USD":{"FROMSYMBOL":"Ƀ","TOSYMBOL":"$","MARKET":"CryptoCompare Index","PRICE":"$ 6,084.30","LASTUPDATE":"Just now","LASTVOLUME":"Ƀ 0.004900","LASTVOLUMETO":"$ 29.97","LASTTRADEID":"7779588","VOLUMEDAY":"Ƀ 21,428.7","VOLUMEDAYTO":"$ 131,067,493.3","VOLUME24HOUR":"Ƀ 105,750.4","VOLUME24HOURTO":"$ 663,220,908.6","OPENDAY":"$ 6,153.41","HIGHDAY":"$ 6,178.90","LOWDAY":"$ 6,014.26","OPEN24HOUR":"$ 6,452.85","HIGH24HOUR":"$ 6,541.03","LOW24HOUR":"$ 6,009.51","LASTMARKET":"Cexio","CHANGE24HOUR":"$ -368.55","CHANGEPCT24HOUR":"-5.71","CHANGEDAY":"$ -69.11","CHANGEPCTDAY":"-1.12","SUPPLY":"Ƀ 17,202,675.0","MKTCAP":"$ 104.67 B","TOTALVOLUME24H":"Ƀ 462.04 K","TOTALVOLUME24HTO":"$ 2,830.97 M"}}}} -------------------------------------------------------------------------------- /test/plugins/test_crypto/crypto_btc_to_eth.json: -------------------------------------------------------------------------------- 1 | {"RAW":{"BTC":{"ETH":{"TYPE":"5","MARKET":"CCCAGG","FROMSYMBOL":"BTC","TOSYMBOL":"ETH","FLAGS":"4","PRICE":18.01,"LASTUPDATE":1533961008,"LASTVOLUME":0.0003108,"LASTVOLUMETO":0.005656949403826,"LASTTRADEID":"162309","VOLUMEDAY":0.1679238600000003,"VOLUMEDAYTO":3.087088803783464,"VOLUME24HOUR":0.36601415000000004,"VOLUME24HOURTO":6.624954482906522,"OPENDAY":17.08,"HIGHDAY":18.9,"LOWDAY":17.08,"OPEN24HOUR":17.02,"HIGH24HOUR":18.9,"LOW24HOUR":16.98,"LASTMARKET":"OpenLedger","CHANGE24HOUR":0.990000000000002,"CHANGEPCT24HOUR":5.816686251468872,"CHANGEDAY":0.9300000000000033,"CHANGEPCTDAY":5.444964871194399,"SUPPLY":17202675,"MKTCAP":309820176.75,"TOTALVOLUME24H":459191.20724751457,"TOTALVOLUME24HTO":8270033.675567379}}},"DISPLAY":{"BTC":{"ETH":{"FROMSYMBOL":"Ƀ","TOSYMBOL":"Ξ","MARKET":"CryptoCompare Index","PRICE":"Ξ 18.01","LASTUPDATE":"1 hour ago","LASTVOLUME":"Ƀ 0.0003108","LASTVOLUMETO":"Ξ 0.005657","LASTTRADEID":"162309","VOLUMEDAY":"Ƀ 0.1679","VOLUMEDAYTO":"Ξ 3.09","VOLUME24HOUR":"Ƀ 0.3660","VOLUME24HOURTO":"Ξ 6.62","OPENDAY":"Ξ 17.08","HIGHDAY":"Ξ 18.90","LOWDAY":"Ξ 17.08","OPEN24HOUR":"Ξ 17.02","HIGH24HOUR":"Ξ 18.90","LOW24HOUR":"Ξ 16.98","LASTMARKET":"OpenLedger","CHANGE24HOUR":"Ξ 0.99","CHANGEPCT24HOUR":"5.82","CHANGEDAY":"Ξ 0.93","CHANGEPCTDAY":"5.44","SUPPLY":"Ƀ 17,202,675.0","MKTCAP":"Ξ 309.82 M","TOTALVOLUME24H":"Ƀ 459.19 K","TOTALVOLUME24HTO":"Ξ 8,270.03 K"}}}} -------------------------------------------------------------------------------- /test/plugins/test_crypto/crypto_btc_to_invalid.json: -------------------------------------------------------------------------------- 1 | {"Response":"Error","Message":"There is no data for any of the toSymbols INVALID .","Type":1,"Aggregated":false,"Data":[],"Warning":"There is no data for the toSymbol/s INVALID ","HasWarning":true} -------------------------------------------------------------------------------- /test/plugins/test_crypto/crypto_eth_to_default.json: -------------------------------------------------------------------------------- 1 | {"RAW":{"ETH":{"USD":{"TYPE":"5","MARKET":"CCCAGG","FROMSYMBOL":"ETH","TOSYMBOL":"USD","FLAGS":"4","PRICE":315.91,"LASTUPDATE":1533964817,"LASTVOLUME":0.44537967,"LASTVOLUMETO":140.63753839589998,"LASTTRADEID":"278603674","VOLUMEDAY":163011.90779591043,"VOLUMEDAYTO":52513531.941159755,"VOLUME24HOUR":555836.3497450602,"VOLUME24HOURTO":188832440.6669058,"OPENDAY":331.57,"HIGHDAY":332.41,"LOWDAY":312.35,"OPEN24HOUR":359.95,"HIGH24HOUR":363.96,"LOW24HOUR":311.86,"LASTMARKET":"Bitfinex","CHANGE24HOUR":-44.039999999999964,"CHANGEPCT24HOUR":-12.235032643422688,"CHANGEDAY":-15.659999999999968,"CHANGEPCTDAY":-4.722984588473013,"SUPPLY":101251550.374,"MKTCAP":31986377278.65034,"TOTALVOLUME24H":3396774.5259628375,"TOTALVOLUME24HTO":1086313219.915864}}},"DISPLAY":{"ETH":{"USD":{"FROMSYMBOL":"Ξ","TOSYMBOL":"$","MARKET":"CryptoCompare Index","PRICE":"$ 315.91","LASTUPDATE":"Just now","LASTVOLUME":"Ξ 0.4454","LASTVOLUMETO":"$ 140.64","LASTTRADEID":"278603674","VOLUMEDAY":"Ξ 163,011.9","VOLUMEDAYTO":"$ 52,513,531.9","VOLUME24HOUR":"Ξ 555,836.3","VOLUME24HOURTO":"$ 188,832,440.7","OPENDAY":"$ 331.57","HIGHDAY":"$ 332.41","LOWDAY":"$ 312.35","OPEN24HOUR":"$ 359.95","HIGH24HOUR":"$ 363.96","LOW24HOUR":"$ 311.86","LASTMARKET":"Bitfinex","CHANGE24HOUR":"$ -44.04","CHANGEPCT24HOUR":"-12.24","CHANGEDAY":"$ -15.66","CHANGEPCTDAY":"-4.72","SUPPLY":"Ξ 101,251,550.4","MKTCAP":"$ 31.99 B","TOTALVOLUME24H":"Ξ 3,396.77 K","TOTALVOLUME24HTO":"$ 1,086.31 M"}}}} -------------------------------------------------------------------------------- /test/plugins/test_crypto/crypto_invalid_to_usd.json: -------------------------------------------------------------------------------- 1 | {"Response":"Error","Message":"There is no data for any of the toSymbols INVALID .","Type":1,"Aggregated":false,"Data":[],"Warning":"There is no data for the toSymbol/s INVALID ","HasWarning":true} -------------------------------------------------------------------------------- /test/plugins/test_crypto/example.json: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rmmh/skybot/76eca17bbc23e3e0b262a6b533eac4d58d55e5d1/test/plugins/test_crypto/example.json -------------------------------------------------------------------------------- /test/plugins/test_dice.py: -------------------------------------------------------------------------------- 1 | from unittest import skip, TestCase 2 | from mock import patch 3 | 4 | from dice import dice 5 | 6 | 7 | class TestDice(TestCase): 8 | @patch("random.randint") 9 | def test_one_d20(self, mock_random_randint): 10 | mock_random_randint.return_value = 5 11 | 12 | expected = "5 (d20=5)" 13 | actual = dice("d20") 14 | 15 | assert expected == actual 16 | 17 | @skip("skip until https://github.com/rmmh/skybot/pull/187 is merged") 18 | @patch("random.randint") 19 | def test_complex_roll(self, mock_random_randint): 20 | mock_random_randint.side_effect = iter([1, 2, 3]) 21 | 22 | expected = u"4 (2d20-d5+4=1, 2, -3)" 23 | actual = dice("2d20-d5+4") 24 | 25 | assert expected == actual 26 | 27 | def test_constant_roll(self): 28 | # This fails so it is "none" 29 | actual = dice("1234") 30 | 31 | assert actual is None 32 | 33 | @patch("random.randint") 34 | def test_fudge_dice(self, mock_random_randint): 35 | mock_random_randint.side_effect = iter([-1, 0, 1, 0, -1]) 36 | 37 | expected = "-1 (5dF=\x034-\x0f, 0, \x033+\x0f, 0, \x034-\x0f)" 38 | actual = dice("5dF") 39 | 40 | assert expected == actual 41 | 42 | @patch("random.randint") 43 | def test_fudge_dice(self, mock_random_randint): 44 | mock_random_randint.side_effect = iter([-1, 0, 1, 0, -1]) 45 | 46 | expected = "-1 (5dF=\x034-\x0f, 0, \x033+\x0f, 0, \x034-\x0f)" 47 | actual = dice("5dF") 48 | 49 | assert expected == actual 50 | -------------------------------------------------------------------------------- /test/plugins/test_hackernews.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from mock import patch 4 | 5 | from helpers import get_fixture_file_data, execute_skybot_regex 6 | from hackernews import hackernews 7 | 8 | 9 | class TestHackernews(TestCase): 10 | @patch("util.http.get_json") 11 | def test_story(self, mock_http_get): 12 | mock_http_get.return_value = get_fixture_file_data(self, "9943431.json") 13 | 14 | expected = ( 15 | u"Can Desalination Counter the Drought? by cwal37 " 16 | u"with 51 points and 94 comments " 17 | u"(http://www.newyorker.com/tech/elements/can-" 18 | u"desalination-counter-the-drought)" 19 | ) 20 | 21 | url = "https://news.ycombinator.com/item?id=9943431" 22 | actual = execute_skybot_regex(hackernews, url) 23 | 24 | assert expected == actual 25 | 26 | @patch("util.http.get_json") 27 | def test_comment(self, mock_http_get): 28 | mock_http_get.return_value = get_fixture_file_data(self, "9943987.json") 29 | 30 | expected = ( 31 | u'"Yes, they must have meant kilowatt hours. Was ' 32 | u'there no editor?" -- oaktowner' 33 | ) 34 | 35 | url = "https://news.ycombinator.com/item?id=9943987" 36 | actual = execute_skybot_regex(hackernews, url) 37 | 38 | assert expected == actual 39 | 40 | @patch("util.http.get_json") 41 | def test_comment_encoding(self, mock_http_get): 42 | mock_http_get.return_value = get_fixture_file_data(self, "9943897.json") 43 | 44 | expected = ( 45 | u'"> All told, it takes about 3460 kilowatts per ' 46 | u"acre-foot to pump water from Northern California " 47 | u"to San Diego; Carlsbad will use about thirty per " 48 | u"cent more energy, five thousand kilowatts per " 49 | u"acre-foot, to desalinate ocean water and deliver " 50 | u"it to households, according to Poseidon\u2019s " 51 | u"report to the Department of Water Resources // " 52 | u"These units are abominations. Couldn't just " 53 | u"say 2.8 Watts per liter vs 4.0 Watts per liter? " 54 | u"Or even 10.6 and 15.3 Watts per gallon? I'm " 55 | u"not a metric purist, but the only advantage to " 56 | u"using imperial units is that they are more " 57 | u"familiar to the average American, but when does " 58 | u'the average person deal with acre-feet?" ' 59 | u"-- alwaysdoit" 60 | ) 61 | 62 | url = "https://news.ycombinator.com/item?id=9943897" 63 | actual = execute_skybot_regex(hackernews, url) 64 | 65 | assert expected == actual 66 | -------------------------------------------------------------------------------- /test/plugins/test_hackernews/9943431.json: -------------------------------------------------------------------------------- 1 | {"by":"cwal37","descendants":94,"id":9943431,"kids":[9943897,9943970,9945149,9943728,9943555,9944002,9944159,9943505,9944946,9944207,9944377,9944089],"score":51,"time":1437757443,"title":"Can Desalination Counter the Drought?","type":"story","url":"http://www.newyorker.com/tech/elements/can-desalination-counter-the-drought"} -------------------------------------------------------------------------------- /test/plugins/test_hackernews/9943897.json: -------------------------------------------------------------------------------- 1 | {"by":"alwaysdoit","id":9943897,"kids":[9943947,9943932,9944612,9944060,9944161,9944100,9945559],"parent":9943431,"text":"> All told, it takes about 3460 kilowatts per acre-foot to pump water from Northern California to San Diego; Carlsbad will use about thirty per cent more energy, five thousand kilowatts per acre-foot, to desalinate ocean water and deliver it to households, according to Poseidon’s report to the Department of Water Resources<p>These units are abominations. Couldn't just say 2.8 Watts per liter vs 4.0 Watts per liter? Or even 10.6 and 15.3 Watts per gallon? I'm not a metric purist, but the only advantage to using imperial units is that they are more familiar to the average American, but when does the average person deal with acre-feet?","time":1437760700,"type":"comment"} -------------------------------------------------------------------------------- /test/plugins/test_hackernews/9943987.json: -------------------------------------------------------------------------------- 1 | {"by":"oaktowner","id":9943987,"kids":[9944095],"parent":9943932,"text":"Yes, they must have meant kilowatt hours. Was there no editor?","time":1437761436,"type":"comment"} -------------------------------------------------------------------------------- /test/plugins/test_twitter.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | 4 | from mock import patch 5 | 6 | from helpers import get_fixture_file_data, execute_skybot_regex 7 | from twitter import show_tweet, twitter 8 | 9 | 10 | FAKE_API_KEY = { 11 | "consumer": "AAAAAAAAAAAAAAAAAAAAAAAAA", 12 | "consumer_secret": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", 13 | "access": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", 14 | "access_secret": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", 15 | } 16 | 17 | 18 | class TestTwitter(TestCase): 19 | @patch("util.http.get_json") 20 | def test_tweet_regex(self, mock_http_get): 21 | mock_http_get.return_value = get_fixture_file_data( 22 | self, "1014260007771295745.json" 23 | ) 24 | 25 | expected = ( 26 | u"2018-07-03 21:30:04 \x02jk_rowling\x02: " 27 | u"hahahahahahahahahahahahahahahahahahahahaha" 28 | u"hahahahahahahahahahahahahahahahahahahahaha" 29 | u"hahahahahahahahahahahahahahahahahahahahaha" 30 | u"hahahaha " 31 | u"*draws breath* " 32 | u"hahahahahahahahahahahahahahahahahahahahaha" 33 | u"hahahahahahahahahahahahahahahahahahahahaha" 34 | u"hahahahahahahahahahahahahahahahahahahahaha" 35 | u"haha https://t.co/gbionAPK9Z" 36 | ) 37 | 38 | url = "https://twitter.com/jk_rowling/status/1014260007771295745" 39 | actual = execute_skybot_regex(show_tweet, url, api_key=FAKE_API_KEY) 40 | 41 | assert expected == actual 42 | 43 | @patch("util.http.get_json") 44 | def test_twitter_username_no_tweet_number(self, mock_http_get): 45 | mock_http_get.return_value = get_fixture_file_data( 46 | self, "user_loneblockbuster.json" 47 | ) 48 | 49 | expected = ( 50 | u"2018-08-16 17:38:52 \x02loneblockbuster\x02: " 51 | u"We had the motto \"When you're here you're " 52 | u'family" before Olive Garden but like ' 53 | u"everything else it was taken away " 54 | u"from us." 55 | ) 56 | 57 | actual = twitter("loneblockbuster", api_key=FAKE_API_KEY) 58 | 59 | assert expected == actual 60 | 61 | @patch("util.http.get_json") 62 | def test_twitter_username_with_tweet_number(self, mock_http_get): 63 | mock_http_get.return_value = get_fixture_file_data( 64 | self, "user_loneblockbuster.json" 65 | ) 66 | 67 | expected = ( 68 | u"2018-07-24 19:30:59 \x02loneblockbuster\x02: " 69 | u"We never would have planted the ferns out " 70 | u"front if we knew we'd get so many death threats." 71 | ) 72 | 73 | actual = twitter("loneblockbuster 10", api_key=FAKE_API_KEY) 74 | 75 | assert expected == actual 76 | 77 | @patch("random.randint") 78 | @patch("util.http.get_json") 79 | def test_twitter_hashtag_no_tweet_number(self, mock_http_get, mock_random_randint): 80 | mock_http_get.return_value = get_fixture_file_data(self, "hashtag_nyc.json") 81 | 82 | # This plugin chooses a random value. 83 | # I chose this value randomly by rolling a D20. 84 | mock_random_randint.return_value = 6 85 | 86 | expected = ( 87 | u"2018-08-17 20:19:56 \x02The_Carl_John\x02: " 88 | u"RT @ItVisn How many records can your " 89 | u"company afford to lose? https://t.co/iJzFYtJmCh " 90 | u"Be proactive and Protect Your Business " 91 | u"#CyberSecurity #DataProtection " 92 | u"#DataBreaches #SmallBusiness #Tech " 93 | u"#Data #NYC #Technology #DarkWeb #Ransomware " 94 | u"#Malware #Phishing #Business " 95 | u"https://t.co/xAJVRhjOww" 96 | ) 97 | 98 | actual = twitter("#NYC", api_key=FAKE_API_KEY) 99 | 100 | assert expected == actual 101 | 102 | @patch("util.http.get_json") 103 | def test_twitter_hashtag_with_tweet_number(self, mock_http_get): 104 | mock_http_get.return_value = get_fixture_file_data(self, "hashtag_nyc.json") 105 | 106 | expected = ( 107 | u"2018-08-17 20:19:32 \x02Kugey\x02: " 108 | u"I know for sure that life is beautiful " 109 | u"around the world... \u2022 \u2022 Here " 110 | u"is yet another iconic piece of the " 111 | u"NYC skyline... \u2022 \u2022 #nyc " 112 | u"#photography #streetphotography " 113 | u"#urbanphotography\u2026 " 114 | u"https://t.co/bq9i0FZN89" 115 | ) 116 | 117 | actual = twitter("#NYC 10", api_key=FAKE_API_KEY) 118 | 119 | assert expected == actual 120 | -------------------------------------------------------------------------------- /test/plugins/test_twitter/1014260007771295745.json: -------------------------------------------------------------------------------- 1 | {"created_at":"Tue Jul 03 21:30:04 +0000 2018","id":1014260007771295745,"id_str":"1014260007771295745","full_text":"hahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahaha *draws breath* hahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahahaha https:\/\/t.co\/gbionAPK9Z","truncated":false,"display_text_range":[0,280],"entities":{"hashtags":[],"symbols":[],"user_mentions":[],"urls":[{"url":"https:\/\/t.co\/gbionAPK9Z","expanded_url":"https:\/\/twitter.com\/realDonaldTrump\/status\/1014257237945176071","display_url":"twitter.com\/realDonaldTrum\u2026","indices":[281,304]}]},"source":"\u003ca href=\"http:\/\/twitter.com\" rel=\"nofollow\"\u003eTwitter Web Client\u003c\/a\u003e","in_reply_to_status_id":null,"in_reply_to_status_id_str":null,"in_reply_to_user_id":null,"in_reply_to_user_id_str":null,"in_reply_to_screen_name":null,"user":{"id":62513246,"id_str":"62513246","name":"J.K. Rowling","screen_name":"jk_rowling","location":"Scotland","description":"Writer","url":"https:\/\/t.co\/7iaKMs3iC6","entities":{"url":{"urls":[{"url":"https:\/\/t.co\/7iaKMs3iC6","expanded_url":"http:\/\/www.jkrowling.com","display_url":"jkrowling.com","indices":[0,23]}]},"description":{"urls":[]}},"protected":false,"followers_count":14354031,"friends_count":621,"listed_count":37861,"created_at":"Mon Aug 03 13:23:45 +0000 2009","favourites_count":22020,"utc_offset":null,"time_zone":null,"geo_enabled":false,"verified":true,"statuses_count":10691,"lang":"en","contributors_enabled":false,"is_translator":false,"is_translation_enabled":false,"profile_background_color":"1A1B1F","profile_background_image_url":"http:\/\/abs.twimg.com\/images\/themes\/theme9\/bg.gif","profile_background_image_url_https":"https:\/\/abs.twimg.com\/images\/themes\/theme9\/bg.gif","profile_background_tile":true,"profile_image_url":"http:\/\/pbs.twimg.com\/profile_images\/1029136897506004992\/9xvEQiO0_normal.jpg","profile_image_url_https":"https:\/\/pbs.twimg.com\/profile_images\/1029136897506004992\/9xvEQiO0_normal.jpg","profile_banner_url":"https:\/\/pbs.twimg.com\/profile_banners\/62513246\/1534200280","profile_link_color":"C0C0C0","profile_sidebar_border_color":"000000","profile_sidebar_fill_color":"000000","profile_text_color":"000000","profile_use_background_image":true,"has_extended_profile":true,"default_profile":false,"default_profile_image":false,"following":false,"follow_request_sent":false,"notifications":false,"translator_type":"regular"},"geo":null,"coordinates":null,"place":null,"contributors":null,"is_quote_status":true,"retweet_count":34885,"favorite_count":158199,"favorited":false,"retweeted":false,"possibly_sensitive":false,"possibly_sensitive_appealable":false,"lang":"tl"} 2 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py27,py37 3 | skipsdist=True 4 | 5 | [testenv] 6 | commands = pip install -qqq lxml future 7 | pip install -qqq mock==2.0.0 8 | python test 9 | --------------------------------------------------------------------------------