From ff75ac01b575f49c8e32d18dc75516c71c9cd902 Mon Sep 17 00:00:00 2001 From: khuxkm fbexl Date: Mon, 11 May 2020 16:00:46 -0400 Subject: [PATCH] Revamp bot Moves minerbot2 to the framework I use for the badger and reminder bots. --- .gitignore | 118 ++++++++++++++++++++++++++++++ README.md | 22 ++++++ bot.py | 165 ++++++++++++++++++++++++++++++++++++++++++ dictdata.py | 17 +++++ events.py | 22 ++++++ impmod.py | 19 +++++ plugin.py | 78 ++++++++++++++++++++ plugins/admin.py | 44 +++++++++++ plugins/backronym.py | 28 +++++++ plugins/botlist.py | 26 +++++++ plugins/cheerup.py | 49 +++++++++++++ plugins/choose.py | 38 ++++++++++ plugins/commands.py | 18 +++++ plugins/counting.py | 115 +++++++++++++++++++++++++++++ plugins/emoji_text.py | 44 +++++++++++ plugins/getstock.py | 45 ++++++++++++ plugins/gibi.py | 21 ++++++ plugins/memegen.py | 61 ++++++++++++++++ plugins/opinion.py | 39 ++++++++++ plugins/raw.py | 11 +++ plugins/shortenimg.py | 54 ++++++++++++++ plugins/stats.py | 67 +++++++++++++++++ pluralslib.py | 10 +++ words.py | 17 +++++ 24 files changed, 1128 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 bot.py create mode 100644 dictdata.py create mode 100644 events.py create mode 100644 impmod.py create mode 100644 plugin.py create mode 100644 plugins/admin.py create mode 100644 plugins/backronym.py create mode 100644 plugins/botlist.py create mode 100644 plugins/cheerup.py create mode 100644 plugins/choose.py create mode 100644 plugins/commands.py create mode 100644 plugins/counting.py create mode 100644 plugins/emoji_text.py create mode 100644 plugins/getstock.py create mode 100644 plugins/gibi.py create mode 100644 plugins/memegen.py create mode 100644 plugins/opinion.py create mode 100644 plugins/raw.py create mode 100644 plugins/shortenimg.py create mode 100644 plugins/stats.py create mode 100644 pluralslib.py create mode 100644 words.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..53fc0b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,118 @@ + +# Created by https://www.gitignore.io/api/python +# Edit at https://www.gitignore.io/?templates=python + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# Mr Developer +.mr.developer.cfg +.project +.pydevproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# End of https://www.gitignore.io/api/python + +# NickServ password (be sure to chmod to 600) +.password + +# data files +*.json +*.psv +.finnhub_token diff --git a/README.md b/README.md new file mode 100644 index 0000000..250c794 --- /dev/null +++ b/README.md @@ -0,0 +1,22 @@ +# badger + +The Twitch Plays Pokemon badge system, ported to tildeverse IRC! + +## How it works + +Every 20 to 50 messages, the person talking will recieve a random badge. These badges have a distribution like so: + +|Badge Name |Chance of pull| +|-----------|--------------| +|Berrybadge |65.00% | +|Firebadge |15.00% | +|Rockbadge |10.00% | +|Waterbadge |5.00% | +|Shadybadge |2.00% | +|Musicbadge |1.90% | +|Sportsbadge|1.00% | +|Tildebadge |0.10% | + +You can transmute 3 or more badges. When you do this, it will create one, hopefully rarer badge and take the badges you put in away. + +For technical documentation on how transmutation works, refer to TPP docs [here](https://twitchplayspokemon.tv/transmutation_calculations). diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..9fbef7f --- /dev/null +++ b/bot.py @@ -0,0 +1,165 @@ +import impmod, socket, traceback, events, time, os +from irc.client import NickMask +class Socket: + def __init__(self,server): + self.sock = socket.socket(socket.AF_INET,socket.SOCK_STREAM) + self.sock.connect(server) + self._read_buffer=b"" + def read(self): + try: + data=self.sock.recv(4096) + if not data: + return None + except: return traceback.print_exc() + data = self._read_buffer+data + self._read_buffer=b"" + lines = [line.strip(b"\r") for line in data.split(b"\n")] + if lines[-1]: + self._read_buffer=lines[-1] + lines.pop(-1) + lines = [line.decode("utf-8") for line in lines] + return lines + def send(self,line): + self.sock.send(line.encode("utf-8")) + def close(self): + self.sock.shutdown(socket.SHUT_RDWR) + self.sock.close() + +def unescape(value): + return value.replace(r"\:",";").replace(r"\s"," ").replace(r"\\","\\").replace(r"\r","\r").replace(r"\n","\n") + +def escape(value): + return value.replace(";",r"\:").replace(" ",r"\s").replace("\\",r"\\").replace("\r",r"\r").replace("\n",r"\n") + +MISSING = None + +class IRCLine: + def __init__(self,command,*params,tags=dict(),hostmask=""): + self.command=command + if len(params)==0: + self.params=[] + elif len(params)==1 and type(params[0]) in (list,tuple): + self.params=list(params[0]) + else: + self.params=list(params) + self.tags=tags + self.hostmask=NickMask(hostmask) if hostmask else None + @property + def line(self): + prefix="" + if len(list(self.tags.keys()))>0: + tagc = len(list(self.tags.keys())) + prefix+="@" + for i,tag in enumerate(self.tags.keys()): + prefix+=tag + if self.tags[tag] is not MISSING: + prefix+="="+escape(str(self.tags[tag])) + if (i+1)0 and not parts[i].startswith(":"): i-=1 + if i!=0: parts[i:]=[" ".join(parts[i:])] + return cls(*parts,tags=tags,hostmask=hostmask) + def encode(self,*args,**kwargs): + # clearly, if we're here, I'm an idiot and am trying to send an + # IRCLine object down the tube. just do it. + return self.line.encode(*args,**kwargs) + +PLUGIN_MODULES={} +class IRCBot: + def __init__(self,nickname,username,realname="IRCBot",server=("localhost",6667),channels=["#bots"]): + self.nickname=nickname + self.username=username + self.realname=realname + self.server=server + self.channels=channels + self.event_manager=events.EventManager() + def load_modules(self): + self.event_manager.clear() + for name in os.listdir("plugins"): + if name.endswith(".py"): + self.load_module(name[:-3],os.path.join("plugins",name)) + def load_module(self,modname,path): + try: + if modname in PLUGIN_MODULES: + print("{} already imported, reloading".format(modname)) + PLUGIN_MODULES[modname].reload() + else: + try: + print("importing {}".format(modname)) + PLUGIN_MODULES[modname]=impmod.Module(modname,path) + except: + print("Unable to load plugin {}".format(modname)) + traceback.print_exc() + register_func = getattr(PLUGIN_MODULES[modname].module,"register",None) + if not register_func: + print(f"Plugin {modname} has no register function!") + print("Remember, if porting plugins from a minerbot-based architecture,") + print("you have to add a register function to use the new system.") + return + register_func(self) + except: + traceback.print_exc() + pass + def handle_line(self,line): + if type(line)!=IRCLine: line = IRCLine.parse_line(line) + self.event_manager(events.Event("raw_line",text=line.line,parsed=line)) + if line.command=="PING": + line.command="PONG" + self.socket.send(line.line) + return + if line.hostmask is None: return + if line.hostmask.nick==self.nickname: + return + if line.command in "PRIVMSG NOTICE".split(): + target = line.params[0] + message = line.params[1][1:] + self.event_manager(events.Event(line.command.lower(),target=target,message=message,tags=line.tags,hostmask=line.hostmask)) + elif line.command == "TAGMSG": + self.event_manager(events.Event("tagmsg",hostmask=line.hostmask,tags=line.tags,target=line.params[0])) + elif line.command == "INVITE": + self.event_manager(events.Event("invite",to=line.params[1][1:],hostmask=line.hostmask)) + elif line.command == "PING": + self.socket.send(IRCLine("PONG",line.params).line) + def start(self): + self.socket = Socket(self.server) + self.socket.send("NICK {}\r\n".format(self.nickname)) + self.socket.send("USER {} * * :{}\r\n".format(self.username,self.realname)) + time.sleep(2) # give the server some time to record my username + self.event_manager(events.Event("connection_established")) + for channel in self.channels: + self.socket.send(f"JOIN {channel}\r\n") + time.sleep(1) + self.socket.send("CAP REQ account-tag\r\n") + self.running=True + while self.running: + lines = self.socket.read() + if lines: + for line in lines: + self.handle_line(line) + self.socket.close() + del self.socket + +if __name__=="__main__": + bot = IRCBot("minerbot2","minerbot2",channels=["#khuxkm"]) + bot.load_modules() + bot.start() diff --git a/dictdata.py b/dictdata.py new file mode 100644 index 0000000..07cced4 --- /dev/null +++ b/dictdata.py @@ -0,0 +1,17 @@ +import plugin +class DictData(plugin.JSONData): + def __init__(self,filename,**kwargs): + plugin.JSONData.__init__(self,kwargs) + self.filename = filename + self.load(self.filename) + def __getitem__(self,k): + self.load(self.filename) + return self.value[k] + def __setitem__(self,k,v): + self.value[k]=v + self.save(self.filename) + def __contains__(self,k): + return k in self.value + def get(self,k,default=None): + self.load(self.filename) + return self.value.get(k,default) diff --git a/events.py b/events.py new file mode 100644 index 0000000..6432638 --- /dev/null +++ b/events.py @@ -0,0 +1,22 @@ +from collections import defaultdict + +class Event: + def __init__(self,name,**kwargs): + self.data=kwargs + self.name=name + def __getitem__(self,k): + return self.data[k] + def __getattr__(self,k): + if k in self.data: return self.data[k] + +class EventManager: + def __init__(self): + self.handlers=defaultdict(list) + def clear(self): + self.__init__() + def on(self,event,func): + self.handlers[event].append(func) + def __call__(self,event_obj): + print(event_obj.name,event_obj.data) + handlers = self.handlers[event_obj.name] + for handler in handlers: handler(event_obj) diff --git a/impmod.py b/impmod.py new file mode 100644 index 0000000..db430cb --- /dev/null +++ b/impmod.py @@ -0,0 +1,19 @@ +import importlib, importlib.util, sys + +class Module: + """A module. Stores module object, spec object, and handles reloading.""" + def __init__(self,modname,path=None): + if path is None: + path = modname+".py" + self.modname, self.path = modname, path + self.spec = importlib.util.spec_from_file_location(modname,path) + self.module = importlib.util.module_from_spec(self.spec) + self.spec.loader.exec_module(self.module) + def reload(self): + if self.modname not in sys.modules: + sys.modules[self.modname]=self.module + # Alright, this needs some explaining. + # When you do importlib.reload, it does some juju magic shist to find the spec and calls importlib._bootstrap._exec. + # When you dynamically import a module it won't do its magic correctly and it'll error. + # Luckily, we can skip all the juju magic since we can just store the spec. + importlib._bootstrap._exec(self.spec,self.module) diff --git a/plugin.py b/plugin.py new file mode 100644 index 0000000..e6c9043 --- /dev/null +++ b/plugin.py @@ -0,0 +1,78 @@ +import json, traceback +help = {} +class CommandGroup: + def __init__(self,f,default="help"): + self.base = f + self.subcmds = {} + self.subcmds_help = {} + self.default = default + def command(self,name,help=""): + self.subcmds_help[name]=help + def _register_subcmd(f): + self.subcmds[name]=f + return f + return _register_subcmd + def __call__(self,bot,channel,nick,subcmd,*args): + print("Calling base") + if self.base(bot,channel,nick,subcmd,*args): + return + print("Calling subcommand {}".format(subcmd if subcmd in self.subcmds else self.default)) + if subcmd in self.subcmds: + return self.subcmds[subcmd](bot,channel,nick,subcmd,*args) + else: + return self.subcmds[self.default](bot,channel,nick,subcmd,*args) + +class Data: + """A class for plugin data.""" + def __init__(self,value): + self.value = value + def serialize(self): + return self.value + def deserialize(self,value): + self.value = value + def save(self,filename): + with open(filename,"w") as f: + f.write(self.serialize()) + def load(self,filename): + try: + with open(filename) as f: + self.deserialize(f.read()) + except: + print("Error loading data from {!r}:".format(filename)) + traceback.print_exc() + pass # You should've initialized this with a sane default, so just keep the default on error + +class JSONData(Data): + """Data, but can be serialized to JSON (and should be).""" + def serialize(self): + return json.dumps(self.value) + def deserialize(self,value): + self.value = json.loads(value) + +def clear(): + cmds.clear() + help.clear() + listeners.clear() + +#def command(name,helptext="No help available for this command."): +# def _register_cmd(func): +# cmds[name]=func +# help[name]=helptext +# return func +# return _register_cmd + +def group(name,helptext=""): + def _register_group(f): + gr = CommandGroup(f) + return gr + return _register_group + +def listener(name): + def _register_cmd(func): + listeners[name]=func + return func + return _register_cmd + +def alias(name,target): + cmds[name]=cmds[target] + help[name]=help[target] diff --git a/plugins/admin.py b/plugins/admin.py new file mode 100644 index 0000000..0e2be2f --- /dev/null +++ b/plugins/admin.py @@ -0,0 +1,44 @@ +from events import Event +from bot import IRCLine +ADMIN_HOSTMASKS = [x+"!khuxkm@sudoers.tilde.team" for x in "khuxkm khuxkm|lounge".split()] +BOT = None + +def admin(event): + if BOT is None: return + if event.hostmask not in ADMIN_HOSTMASKS: + BOT.socket.send(IRCLine("PRIVMSG","#meta","You're not the boss of me! (hostmask {!s})".format(event.hostmask)).line) + return + if len(event.parts)==0: return + if event.parts[0]=="reload": + BOT.load_modules() + elif event.parts[0]=="quit": + BOT.socket.send(IRCLine("QUIT",[":goodbye"]).line) + BOT.running=False + elif event.parts[0]=="msg": + target = event.parts[1] + message = ":"+" ".join(event.parts[2:]) + BOT.socket.send(IRCLine("PRIVMSG",target,message).line) + else: + event_out = Event("admin_"+event.parts[0]) + event_out.data.update(event.data) + event_out.data["parts"]=event.parts[1:] + BOT.event_manager(event_out) + +def on_invite(event): + if BOT is None: return + if event.hostmask not in ADMIN_HOSTMASKS: return + BOT.socket.send(IRCLine("JOIN",[event.to]).line) + +PASSWORD="whoops" +try: + with open(".password") as f: PASSWORD=f.read().strip() +except: pass +def login(event): + BOT.socket.send(IRCLine("NS","IDENTIFY",PASSWORD).line) + +def register(bot): + global BOT + BOT=bot + bot.event_manager.on("command_admin",admin) + bot.event_manager.on("invite",on_invite) + bot.event_manager.on("connection_established",login) diff --git a/plugins/backronym.py b/plugins/backronym.py new file mode 100644 index 0000000..1c4dabe --- /dev/null +++ b/plugins/backronym.py @@ -0,0 +1,28 @@ +import words, plugin, random +from titlecase import titlecase +from bot import IRCLine +words.loadDict("words") + +BOT = None + +def say(target, msg): + BOT.socket.send(IRCLine("PRIVMSG",target,":"+msg)) + +def backronym(event): + if not BOT: return None + channel = event.target + nick = event.hostmask.nick + word = event.parts[0] + result = [] + for char in word: + fwords = words.getWords("^{}.*".format(char)) + if not fwords: + say(channel,nick+": Don't be a meanie! (No words start with '{}'!)".format(char)) + else: + result.append(random.choice(fwords)) + say(channel,nick+": "+titlecase(" ".join(result))) + +def register(bot): + global BOT + BOT = bot + bot.event_manager.on("command_backronym",backronym) diff --git a/plugins/botlist.py b/plugins/botlist.py new file mode 100644 index 0000000..4685d1e --- /dev/null +++ b/plugins/botlist.py @@ -0,0 +1,26 @@ +from bot import IRCLine +from events import Event +BOT = None + +def on_botlist(event): + if not BOT: return None + commands = [] + for handler in BOT.event_manager.handlers.keys(): + if handler.startswith("command_"): + command = handler[len("command_"):] + if command not in "admin botlist".split(): commands.append(command) + #print(commands) + BOT.socket.send(IRCLine("PRIVMSG",event.target,":{}: I'm minerbot2, rewritten again! Commands include {}".format(event.hostmask.nick,", ".join(["!"+x for x in commands])))) + +def on_privmsg(event): + if BOT and BOT.prefix=="!": return + if event.message in ("!botlist", "!rollcall"): + ev = Event("command_botlist",parts=[]) + ev.data.update(event.data) + on_botlist(ev) + +def register(bot): + global BOT + BOT=bot + bot.event_manager.on("command_botlist",on_botlist) + bot.event_manager.on("privmsg",on_privmsg) diff --git a/plugins/cheerup.py b/plugins/cheerup.py new file mode 100644 index 0000000..6173d64 --- /dev/null +++ b/plugins/cheerup.py @@ -0,0 +1,49 @@ +import time, requests, traceback, random +from bot import IRCLine +BOT = None + +def time_secs(): + return time.time() + +USER_AGENT = "minerbot2 on tilde.chat IRC (ran by /u/kd2bwz2)" + +def grab_posts(): + r = requests.get("https://reddit.com/r/eyebleach/.json",headers={"User-Agent":USER_AGENT}) + if r.status_code==200: return [x["data"] for x in r.json()["data"]["children"]] + try: + r.raise_for_status() + except: traceback.print_exc() # print status code error + return [] # return empty list + +LAST_GRAB = time_secs() +_posts = grab_posts() + +def posts(): + global LAST_GRAB,_posts + if (time_secs()-LAST_GRAB)>=(5*60): # cache for 5 minutes + LAST_GRAB = time_secs() + _posts = grab_posts() + return _posts + +def respond(event,msg): + target = event.target if event.target.startswith("#") else event.hostmask.nick + prefix = "" + if event.target.startswith("#"): prefix=event.hostmask.nick+": " + BOT.socket.send(IRCLine("PRIVMSG",target,":"+prefix+msg)) + +RNG = random.SystemRandom() + +def on_cheerup(event): + if not BOT: return + try: + post = RNG.choice(posts()) + respond(event,f"{post['title']} - {post['url']}") + except: + traceback.print_exc() + respond(event,"Something went wrong!") + +def register(bot): + global BOT + BOT=bot + bot.event_manager.on("command_cheerup",on_cheerup) + bot.event_manager.on("command_cute",on_cheerup) diff --git a/plugins/choose.py b/plugins/choose.py new file mode 100644 index 0000000..ac98a37 --- /dev/null +++ b/plugins/choose.py @@ -0,0 +1,38 @@ +import plugin, random +from bot import IRCLine + +BOT = None + +def say(target,msg): + BOT.socket.send(IRCLine("PRIVMSG",target,":"+msg)) + +RNG = random.SystemRandom() + +RESPONSES = ["I dunno, I think I'll go with \"{}\".","Hmm, it's a hard choice, but \"{}\".","Hmm... \"{}\". Hands down."] + +def choose(event): + choices = [] + s = "" + quote = False + for choice in event.parts: + if quote: + s+=" "+choice + if choice.endswith('"'): + quote = False + choices.append(s.strip('"')) + s = "" + elif choice.startswith('"'): + quote = True + s+=choice + else: + choices.append(choice) + if sorted([x.lower() for x in choices])==list("dl"): + choice = "l" if "l" in choices else "L" + else: + choice = RNG.choice(choices) + say(event.target if event.target.startswith("#") else event.hostmask.nick,("{}: ".format(event.hostmask.nick) if event.target.startswith("#") else " ".strip())+"{}".format(RNG.choice(RESPONSES).format(choice))) + +def register(bot): + global BOT + BOT=bot + bot.event_manager.on("command_choose",choose) diff --git a/plugins/commands.py b/plugins/commands.py new file mode 100644 index 0000000..248e09b --- /dev/null +++ b/plugins/commands.py @@ -0,0 +1,18 @@ +import events +BOT=None + +def on_privmsg(event): + if BOT is None: return + prefix = BOT.prefix if event.target.startswith("#") else "" + if event.message.startswith(prefix): + parts = event.message.split(" ") + parts[0]=parts[0][len(prefix):] + event_out = events.Event("command_"+parts.pop(0),parts=parts) + event_out.data.update(event.data) + BOT.event_manager(event_out) + +def register(bot): + global BOT + BOT=bot + bot.prefix="!" + bot.event_manager.on("privmsg",on_privmsg) diff --git a/plugins/counting.py b/plugins/counting.py new file mode 100644 index 0000000..d31549a --- /dev/null +++ b/plugins/counting.py @@ -0,0 +1,115 @@ +import plugin, re, pluralslib, os.path +from dictdata import DictData +import importlib +from bot import IRCLine +importlib.reload(plugin) + +BOT = None + +def say(target,msg): + if not BOT: return None + BOT.socket.send(IRCLine("PRIVMSG",target,":"+msg)) + +milestones = DictData("counting_milestones.json") +milestones_stack = [] if "stack" not in milestones else milestones["stack"] +current_number = 7 if "current_num" not in milestones else milestones["current_num"] +last_poster = " " if "last_poster" not in milestones else milestones["last_poster"] + +@plugin.group("milestone") +def milestone(bot,channel,nick,subcmd,*args): + if subcmd not in "add check dedupe list manual dist now next": + say(channel,"{}: Usage: {}milestone [args]".format(nick,bot.prefix)) + return True + +@milestone.command("dedupe") +def dedupe(bot, channel, nick, subcmd, *args): + milestones["stack"]=sorted(list(set(milestones["stack"]))) + milestones["stack"]=[x for x in milestones["stack"] if x>current_number] + +@milestone.command("add") +def add(bot,channel,nick,subcmd,*args): + if len(args)<1: + say(channel,"{}: Usage: {}milestone add [number number...]".format(nick,bot.prefix)) + return + try: + milestones_stack.extend([int(x) for x in args]) + milestones_stack.sort() + while milestones_stack[0]current_number]))) + except: + say(channel,"ACCESS VIOLATION: Numbers must be integers") + +@milestone.command("dist") +def dist(bot,channel,nick,subcmd,*args): + try: + number_to_check = int(args[0]) + except ValueError: + say(channel,"ACCESS VIOLATION: Invalid number "+args[0]) + d = number_to_check-current_number + if len(args)>1 and args[1]=="milestone": + say(channel,"{}: The next milestone ({!s}) will be hit in {}".format(nick,number_to_check,pluralslib.plural(d,"number"))) + else: + say(channel,"{}: {!s} will be hit in {}".format(nick,number_to_check,pluralslib.plural(d,"number"))) + +@milestone.command("check") +@milestone.command("next") +def check(bot,channel,nick,subcmd,*args): + if len(args)!=0: + say(channel,"{}: Usage: {}milestone {}".format(nick,bot.prefix,subcmd)) + return + number_to_check = milestones_stack[0] if len(milestones_stack)>0 else None + if number_to_check is None: + say(channel,"{}: Please add a milestone by using {}milestone add ".format(nick,bot.prefix)) + return + dist(bot,channel,nick,"dist",str(number_to_check),"milestone") + +@milestone.command("list") +def list_milestones(bot,channel,nick,subcmd,*args): + say(channel,nick+": Upcoming milestones: {}".format(", ".join([str(x) for x in milestones["stack"][:10]]))) + +@milestone.command("now") +def now(bot,channel,nick,subcmd,*args): + say(channel,nick+": The correct next number is `{}`.".format(current_number+1)) + +def on_milestone(event): + if not BOT: return None + milestone(BOT,event.target,event.hostmask.nick,*event.parts) + +def listen_counting(event): + if not BOT: return None + bot = BOT + channel = event.target + nick = event.hostmask.nick + msg = event.message + if channel!="#counting" or (re.match("^\d+$",msg) is None): # if not in #counting or if not a number + return + global current_number, last_poster + if int(msg)!=(current_number+1): + say("#counting-meta","ERROR: {} attempted to enter {} (should be {})".format(nick,msg,str(current_number+1))) + say(nick,"Hey, just wanted to let you know that you just put the wrong number in. Try putting {} in.".format(str(current_number+1))) + return + if last_poster==nick: + say("#counting-meta","ERROR: {} went twice".format(nick)) + say(nick,"Hey, just wanted to let you know that you can't go twice in #counting!") + return + current_number=int(msg) + milestones["current_num"]=current_number + last_poster=nick + milestones["last_poster"]=last_poster + if current_number in milestones_stack: + milestones_stack.remove(current_number) + milestones["stack"]=milestones_stack + milestones[current_number]=nick + say("#counting-meta","CONGRATS {} ON THE {} GET!".format(nick,current_number)) +# if zipcode.has_zipcode(current_number) and current_number>10000: +# plugin.cmds["zipcode"](bot,"#counting-meta","automated-trigger",str(current_number),"quiet") + +def register(bot): + global BOT + BOT = bot + bot.event_manager.on("command_milestone",on_milestone) + bot.event_manager.on("privmsg",listen_counting) diff --git a/plugins/emoji_text.py b/plugins/emoji_text.py new file mode 100644 index 0000000..2873dd5 --- /dev/null +++ b/plugins/emoji_text.py @@ -0,0 +1,44 @@ +import plugin +from bot import IRCLine + +BOT = None +def say(target,msg): + BOT.socket.send(IRCLine("PRIVMSG",target,":"+msg)) + +class DefaultDict(dict): + def __init__(self,f,takes_args=True): + self.f = f + self.takes_args = takes_args + def __get__(self,k): + if k not in self: + if self.takes_args: + self[k]=self.f(k) + else: + self[k]=self.f() + return self[k] + +PREFIX = "regional_indicator_" + +mapping = dict() +for letter in "abcdefghijklmnopqrstuvwxyz": + mapping[letter]=":"+PREFIX+letter+":" +mapping["a"]=":a:" +mapping["b"]=":b:" +mapping[" "]=" " + +def emojitext(event): + if not BOT: return None + channel = event.target if event.target.startswith("#") else event.hostmask.nick + words = event.parts + nick = event.hostmask.nick + try: + text = " ".join(words) + say(nick," ".join([mapping[c] for c in text]).replace(":a: :b:",":ab:")) + if channel!=nick: say(channel,nick+": PMed!") + except Exception as e: + say(nick,"ACCESS VIOLATON: "+e.__class__.__name__+": "+e.args[0]) + +def register(bot): + global BOT + BOT = bot + bot.event_manager.on("command_emojitext",emojitext) diff --git a/plugins/getstock.py b/plugins/getstock.py new file mode 100644 index 0000000..9c8e41d --- /dev/null +++ b/plugins/getstock.py @@ -0,0 +1,45 @@ +import requests +from urllib.parse import urlencode +from bot import IRCLine +BOT = None + +def respond(event,msg): + is_pub = event.target.startswith("#") + BOT.socket.send(IRCLine("PRIVMSG",event.target if is_pub else event.hostmask.nick,":"+(event.hostmask.nick+": " if is_pub else "")+msg)) + +def get_delta(fv,tv): + d = (tv-fv) + d = d/fv + return d + +TOKEN="" +try: + with open(".finnhub_token") as f: TOKEN=f.read().strip() +except: print("Token not found!") + +def on_get_stock(event): + if not BOT: return + if not TOKEN: + respond(event,"khuxkm is an idiot and forgot to supply an API token. I can't do anything about that!") + return + if len(event.parts)!=1: + respond(event,"You can only request 1 symbol at a time.") + return + symbol = event.parts[0] + r = requests.get("https://finnhub.io/api/v1/quote?"+urlencode(dict(symbol=symbol,token=TOKEN))) + if r.status_code!=200: + respond(event,"The finnhub API returned a status code of "+str(r.status_code)+".") + return + try: + res = r.json() + except: + respond(event,r.text) + return + previous_close, current_value = res["pc"], res["c"] + delta = "{:+0.2%}".format(get_delta(previous_close,current_value)) + respond(event,f"{symbol} is currently valued at {current_value} ({delta} from previous close)") + +def register(bot): + global BOT + BOT=bot + bot.event_manager.on("command_getStock",on_get_stock) diff --git a/plugins/gibi.py b/plugins/gibi.py new file mode 100644 index 0000000..15eb25f --- /dev/null +++ b/plugins/gibi.py @@ -0,0 +1,21 @@ +import plugin, requests +from bot import IRCLine +BOT = None + +def say(target,msg): + if not BOT: return + BOT.socket.send(IRCLine("PRIVMSG",target,":"+msg)) + +def get_gibi(): + r = requests.get("https://khuxkm.tilde.team/gibi/gibi.php") + r.raise_for_status() + return r.json() + +def gibi(event): + g = get_gibi() + say(event.target if event.target.startswith("#") else event.hostmask.nick,("{}: ".format(event.hostmask.nick) if event.target.startswith("#") else " ".strip())+"Good idea: {i[good]}; Bad idea: {i[bad]} (Source: {i[source]})".format(i=g)) + +def register(bot): + global BOT + BOT=bot + bot.event_manager.on("command_gibi",gibi) diff --git a/plugins/memegen.py b/plugins/memegen.py new file mode 100644 index 0000000..89ef35f --- /dev/null +++ b/plugins/memegen.py @@ -0,0 +1,61 @@ +import requests,plugin,traceback,sys +from bot import IRCLine + +BOT = None +def say(target,msg): + if not BOT: return + BOT.socket.send(IRCLine("PRIVMSG",target,":"+msg)) + +def shorten(url): + r = requests.post("https://ttm.sh",data=dict(url=url)) + r.raise_for_status() + return r.text.strip() + +def fixUp(text): + if not text: return "_" # escape empty string + text = text.replace("_","__") # escape underscores + text = text.replace(" ","_") # escape spaces + text = text.replace("-","--") # escape dashes + text = text.replace("''",'"') # escape double quote + text = text.replace("?","~q") # escape question marks + text = text.replace("%","~p") # escape question marks + text = text.replace("#","~h") # escape question marks + text = text.replace("/","~s") # escape question marks + return text + +def memegen(bot,channel,nick,template,*msg): + if nick=="jan6": return + if not msg: return + msg = " ".join(msg) + if "|" in msg: + top, bottom = msg.split("|") + else: + top, bottom = "", msg + top = fixUp(top) + bottom = fixUp(bottom) + if "://" in template: # URL + url = "https://memegen.link/custom/{}/{}.jpg?alt={}".format(top,bottom,template) + else: + try: + r = requests.get("https://memegen.link/{}/{}/{}".format(template,top,bottom)) + r.raise_for_status() + r = r.json() + url = r["direct"]["masked"] + except requests.exceptions.HTTPError: + say(channel,"ACCESS VIOLATION: Cannot find meme format {}.".format(template)) + return + url = shorten(url) + say(channel,"{}: {}".format(nick,url)) + +def on_memegen(event): + if not BOT: return + channel = event.target if event.target.startswith("#") else event.hostmask.nick + try: + memegen(BOT,channel,event.hostmask.nick,*event.parts) + except Exception as e: + say(channel,traceback.format_exception_only(*sys.exc_info()[:2])[0]) + +def register(bot): + global BOT + BOT = bot + bot.event_manager.on("command_memegen",on_memegen) diff --git a/plugins/opinion.py b/plugins/opinion.py new file mode 100644 index 0000000..a3aac5e --- /dev/null +++ b/plugins/opinion.py @@ -0,0 +1,39 @@ +import plugin, dictdata, hashlib +from bot import IRCLine +BOT = None + +sha256 = lambda s: hashlib.sha256(s.encode("utf-8")).hexdigest() + +fixed_opinions = dictdata.DictData("fixed_opinions.json") + +BOLD = b"\x02".decode("ascii") +RESET = b"\x0f".decode("ascii") +OPINIONS = ["suck","neat","cool","bad"] + +def suckify(s): + return BOLD+" ".join(list(s))+RESET + +def chunkify(o,s): + ret = [] + for i in range(0,len(o),s): + ret.append(o[i:i+s]) + return ret + +def get_opinion(s): + if s in fixed_opinions: return fixed_opinions[s] + hash = chunkify(sha256(s),2) + h = int(hash[len(s)%len(hash)],16) + return OPINIONS[h%len(OPINIONS)] + +def opinion(event): + if not BOT: return None + channel = event.target if event.target.startswith("#") else event.hostmask.nick + nick = event.hostmask.nick + args = event.parts + if not args: return + BOT.socket.send(IRCLine("PRIVMSG",channel,":"+("{}: ".format(nick) if channel!=nick else " ".strip())+"{} {}".format(" ".join(args),suckify(get_opinion(" ".join(args)))))) + +def register(bot): + global BOT + BOT = bot + bot.event_manager.on("command_opinion",opinion) diff --git a/plugins/raw.py b/plugins/raw.py new file mode 100644 index 0000000..cb65fbb --- /dev/null +++ b/plugins/raw.py @@ -0,0 +1,11 @@ +from bot import IRCLine +BOT = None + +def on_admin_raw(event): + # normalize and send line + BOT.socket.send(IRCLine.parse_line(" ".join(event.parts)).line) + +def register(bot): + global BOT + BOT=bot + bot.event_manager.on("admin_raw",on_admin_raw) diff --git a/plugins/shortenimg.py b/plugins/shortenimg.py new file mode 100644 index 0000000..ac7e092 --- /dev/null +++ b/plugins/shortenimg.py @@ -0,0 +1,54 @@ +import requests,dictdata,os.path,re,traceback +from bot import IRCLine + +BOT = None +def say(target,msg): + if not BOT: return + BOT.socket.send(IRCLine("PRIVMSG",target,":"+msg)) + +def shorten(url): + r = requests.post("https://ttm.sh",data=dict(url=url)) + r.raise_for_status() + return r.text.strip() + +IMAGE_URL = dictdata.DictData("image_urls.json") +URL = re.compile(r"((?:https?|ftp)://[^\s/$.?#].[^\s]*)") + +image_exts = ".png .jpg .jpeg .gif".split() +def is_image(url): + return os.path.splitext(os.path.basename(url))[1] in image_exts + +def on_privmsg(event): + matches = [url for url in URL.findall(event.message) if is_image(url)] + if not matches: return + target = event.target if event.target.startswith("#") else event.hostmask.nick + url = matches[-1] + IMAGE_URL[target]=url + +def on_shortenimg(event): + if not BOT: return + url = None + target = event.target if event.target.startswith("#") else event.hostmask.nick + if len(event.parts)>1: + say(target,(event.hostmask.nick+": " if target!=event.hostmask.nick else '')+"Usage: "+BOT.prefix+"shortenimg [url]") + return + elif len(event.parts)==1: + matches = [url for url in URL.findall(event.message) if is_image(url)] + if len(matches)>0: url=matches[-1] + elif len(event.parts)==0: + if target not in IMAGE_URL: + say(target,(event.hostmask.nick+": " if target!=event.hostmask.nick else '')+"I haven't seen an image here to shorten.") + return + url=IMAGE_URL[target] + try: + new_url = shorten(url) + except: + say(target,"An error occurred!") + traceback.print_exc() + say(target,(event.hostmask.nick+": " if target!=event.hostmask.nick else '')+"Shortened URL: "+new_url) + +def register(bot): + global BOT + BOT = bot + bot.event_manager.on("privmsg",on_privmsg) + bot.event_manager.on("command_shortenimg",on_shortenimg) diff --git a/plugins/stats.py b/plugins/stats.py new file mode 100644 index 0000000..cc9d6d7 --- /dev/null +++ b/plugins/stats.py @@ -0,0 +1,67 @@ +import plugin, time, requests +from jsonpath import jsonpath +from pluralslib import plural +from bot import IRCLine +BOT = None + +def say(target,msg): + if not BOT: return None + BOT.socket.send(IRCLine("PRIVMSG",target,":"+msg)) + +def query(jpath,reducer=lambda x: x[0]): + def _query(stats): + return reducer(jsonpath(stats,jpath)) + return _query + +QUERIES = dict( + users = query("$.usercount"), + channels = query("$.channelcount") +) + +NOUNS = dict( + users="user", + channels="channel" +) + +def time_secs(): + return time.time() + +def grab_stats(): + r = requests.get("https://tilde.chat/stats.json") + r.raise_for_status() + return r.json() + +LAST_GRAB = time_secs() +_stats = grab_stats() + +def stats(): + global LAST_GRAB,_stats + if (time_secs()-LAST_GRAB)>=(5*60): # cache for 5 minutes + LAST_GRAB = time_secs() + _stats = grab_stats() + return _stats + +USAGE_STR = "<{}>".format("/".join(QUERIES.keys())) + +def stat(bot,channel,nick,subcmd): + if subcmd not in QUERIES.keys(): + say(channel,("{}: ".format(nick) if channel.startswith("#") else " ".strip())+"Usage: !stats {}".format(USAGE_STR)) + return + st = stats() + res = plural(QUERIES.get(subcmd,lambda x: 0)(st),NOUNS.get(subcmd,"fuck")) + say(channel,": ".join(([nick] if channel.startswith("#") else [])+[res])) + +def on_stats(event): + if not BOT: return None + channel = event.target if event.target.startswith("#") else event.hostmask.nick + try: + stat(BOT,channel,event.hostmask.nick,*event.parts) + except TypeError: + say(channel,"Usage: !stats <{}>".format("/".join(QUERIES.keys()))) + except Exception as e: + traceback.print_exc() + +def register(bot): + global BOT + BOT=bot + bot.event_manager.on("command_stats",on_stats) diff --git a/pluralslib.py b/pluralslib.py new file mode 100644 index 0000000..315f53d --- /dev/null +++ b/pluralslib.py @@ -0,0 +1,10 @@ +def are(amount): + if amount == 1: + return 'is' + else: + return 'are' +def plural(amount, base, plural='s', singular=''): + if amount == 1: + return str(amount) + ' ' + base + singular + else: + return str(amount) + ' ' + base + plural diff --git a/words.py b/words.py new file mode 100644 index 0000000..1aa223b --- /dev/null +++ b/words.py @@ -0,0 +1,17 @@ +import random,re + +WORDS = [] + +def _isASCII(s): + for c in s: + if ord(c) not in range(128): + return False + return True + +def loadDict(name="words"): + global WORDS + with open("/usr/share/dict/"+name) as f: + WORDS = [l.strip() for l in f if l.strip() and _isASCII(l.strip())] + +def getWords(pattern=".*"): + return list(filter(lambda x: (re.match(pattern,x) is not None),WORDS))