diff --git a/.gitignore b/.gitignore index 35ff8b2..f1d5ff7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ # App-specific settings.json +settings.demo.json +data/*.json +irc/ # ---> Python # Byte-compiled / optimized / DLL files diff --git a/LICENSE b/LICENSE index 547d63d..c33d4be 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) . All rights reserved. +Copyright (c) 2018 Austin Ewens. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. diff --git a/actions/__init__.py b/actions/__init__.py new file mode 100644 index 0000000..f3fa1a9 --- /dev/null +++ b/actions/__init__.py @@ -0,0 +1,63 @@ +from actions.botlist import botlist +from actions.web import summon, whois +from actions.access import banish, pardon +from actions.control import puppet, nomad +from actions.stupid import hmm, hmmscore, hmmscoreboard + +actions = [ + { + "type": "response", + "pattern": "!botlist", + "callback": botlist + }, + { + "type": "response", + "pattern": "!rollcall", + "callback": botlist + }, + { + "type": "response", + "pattern": ";;!summon \S+ .+", + "callback": summon + }, + { + "type": "response", + "pattern": ";;!banish \S+ .+", + "callback": banish + }, + { + "type": "response", + "pattern": ";;!pardon \S+", + "callback": pardon + }, + { + "type": "response", + "pattern": ";;!puppet [^|]+\|.+", + "callback": puppet + }, + { + "type": "response", + "pattern": ";;!nomad \S+ \S+", + "callback": nomad + }, + { + "type": "response", + "pattern": ";;hm+", + "callback": hmm + }, + { + "type": "response", + "pattern": ";;!hmmscore", + "callback": hmmscore + }, + { + "type": "response", + "pattern": "!hmmscoreboard", + "callback": hmmscoreboard + }, + { + "type": "response", + "pattern": ";;!whois \S+", + "callback": whois + } +] \ No newline at end of file diff --git a/actions/access.py b/actions/access.py new file mode 100644 index 0000000..e639df6 --- /dev/null +++ b/actions/access.py @@ -0,0 +1,37 @@ +from datetime import datetime + +def banish(self, name, source, response): + botnick = self.bot.botnick + author = self.bot.author + user, reason = response.split("!banish ")[1].split(" ", 1) + + if name != author: + return + + if user not in self.bot.memories["users"]: + self.bot.memories["users"][user] = dict() + + self.bot.memories["users"][user]["blacklist"] = { + "reason": reason, + "when": datetime.now().timestamp() + } + + self.bot.save_memories() + + confirmation = "{} has been banished for reason: {}".format(user, reason) + self.bot.send_message(source, confirmation) + +def pardon(self, name, source, response): + botnick = self.bot.botnick + author = self.bot.author + user = response.split("!pardon ")[1] + + if name != author: + return + + del self.bot.memories["users"][user]["blacklist"] + + self.bot.save_memories() + + confirmation = "{} has been pardoned".format(user) + self.bot.send_message(source, confirmation) \ No newline at end of file diff --git a/actions/botlist.py b/actions/botlist.py new file mode 100644 index 0000000..54ce622 --- /dev/null +++ b/actions/botlist.py @@ -0,0 +1,18 @@ +def botlist(self, name, source, response): + botnick = self.bot.botnick + author = self.bot.author + email = self.bot.settings["email"] + about = "the meta chat bot" + commands = ", ".join([ + "!botlist", + "!rollcall", + "!summon", + "!banish", + "!pardon", + "!hmmscore", + "!hmmscoreboard", + "!whois" + ]) + args = (botnick, author, email, about, commands) + message = "{} | {} <{}> | {} | {}".format(*args) + self.bot.send_message(source, message) \ No newline at end of file diff --git a/actions/control.py b/actions/control.py new file mode 100644 index 0000000..dae0774 --- /dev/null +++ b/actions/control.py @@ -0,0 +1,27 @@ +def puppet(self, name, source, response): + botnick = self.bot.botnick + author = self.bot.author + command = response.split("!puppet ")[1] + place, message = command.split("|", 1) + + if name != author: + return + + self.bot.send_message(place, message) + +def nomad(self, name, source, response): + botnick = self.bot.botnick + author = self.bot.author + command = response.split("!nomad ")[1] + action, place = command.split(" ", 1) + + if name != author: + return + + actions = { + "join": self.bot.join, + "leave": self.bot.leave + } + default = lambda p: self.bot.send_message(source, "Invalid action!") + + actions.get(action, default)(place) \ No newline at end of file diff --git a/actions/stupid.py b/actions/stupid.py new file mode 100644 index 0000000..5da6ca2 --- /dev/null +++ b/actions/stupid.py @@ -0,0 +1,58 @@ +import re +import operator + +def hmm(self, name, source, response): + botnick = self.bot.botnick + pattern = re.compile("hm+") + matches = re.findall(pattern, response) + score = len(matches) + + if name not in self.bot.memories["users"]: + self.bot.memories["users"][name] = dict() + + if "hmmscore" not in self.bot.memories["users"][name]: + self.bot.memories["users"][name]["hmmscore"] = 0 + + current_score = self.bot.memories["users"][name]["hmmscore"] + self.bot.memories["users"][name]["hmmscore"] = current_score + score + + self.bot.save_memories() + +def hmmscore(self, name, source, response): + botnick = self.bot.botnick + score = 0 + score_format = "Hmm score for '{}': {}" + + if " " in response: + name = response.split(" ", 1)[1] + + if name not in self.bot.memories["users"]: + self.bot.send_message(source, score_format.format(name, score)) + return + + if "hmmscore" in self.bot.memories["users"][name]: + score = self.bot.memories["users"][name]["hmmscore"] + self.bot.send_message(source, score_format.format(name, score)) + return + +def hmmscoreboard(self, name, source, response): + botnick = self.bot.botnick + hmmscores = list() + + for user, values in self.bot.memories["users"].items(): + hmmscores.append({ + "name": user, + "score": values.get("hmmscore", 0) + }) + + size = 3 + start = -size + + sort_scores = sorted(hmmscores, key=lambda k: k["score"]) + top_scores = sort_scores[start:][::-1] + + leaders = " | ".join([ + "{} {}".format(ts["name"], ts["score"]) for ts in top_scores + ]) + + self.bot.send_message(source, "Hmm Score Leaderboard: {}".format(leaders)) \ No newline at end of file diff --git a/actions/web.py b/actions/web.py new file mode 100644 index 0000000..ddc2895 --- /dev/null +++ b/actions/web.py @@ -0,0 +1,101 @@ +from subprocess import Popen, PIPE +from email.mime.text import MIMEText + +from urllib.request import Request, urlopen +from urllib.parse import urlencode +from urllib.error import HTTPError +from json import loads + +def get_iden(devices, device_name): + for device in devices: + if device.get("nickname", "") == device_name: + return device.get("iden", "") + +def push_note(bot, title, body): + api_url = "https://api.pushbullet.com/v2" + extra_settings = bot.settings.get("extras", dict()) + pb_settings = extra_settings.get("pushbullet", dict()) + api_key = pb_settings.get("api", "") + device_name = pb_settings.get("device", "") + + list_devices = Request("{}/devices".format(api_url)) + list_devices.add_header("Access-Token", api_key) + + try: + data = loads(urlopen(list_devices).read()) + except HTTPError: + return + + devices = data.get("devices", list()) + iden = get_iden(devices, device_name) + + params = { + "device_iden": iden, + "type": "note", + "title": title, + "body": body + } + + post_params = urlencode(params).encode() + + pushes = Request("{}/pushes".format(api_url), post_params) + pushes.add_header("Access-Token", api_key) + + try: + response = loads(urlopen(pushes).read()) + except HTTPError as e: + return + +def summon(self, name, source, response): + botnick = self.bot.botnick + author = self.bot.author + user, reason = response.split("!summon ")[1].split(" ", 1) + + email = "{}@tilde.team" + subject = "You have been summoned!" + + text = " ".join([ + "My bot, {}, received a summoning request for you".format(botnick), + "from {} in channel {} for reason: {}".format(name, source, reason) + ]) + message = MIMEText(text) + + message["From"] = email.format(botnick) + message["To"] = email.format(user) + message["Subject"] = subject + + command = "/usr/sbin/sendmail -t -oi".split(" ") + p = Popen(command, stdin=PIPE, universal_newlines=True) + p.communicate(message.as_string()) + + if user == author: + push_note(self.bot, subject, text) + + confirmation = "{}: You have summoned {}".format(name, user) + self.bot.send_message(source, confirmation) + +def whois(self, name, source, response): + botnick = self.bot.botnick + domain = response.split("!whois ")[1] + + api_url = "https://api.jsonwhoisapi.com/v1/whois" + api_key = self.bot.settings.get("extras", dict()).get("jsonwhoisapi", "") + + req = Request("{}?identifier={}".format(api_url, domain)) + req.add_header("Authorization", api_key) + + try: + data = loads(urlopen(req).read()) + except HTTPError: + self.bot.send_message(source, "{} cannot exist".format(domain)) + return + + registered = data.get("registered", None) + if registered is not None: + nameservers = len(data.get("nameservers", "")) + registrar = data.get("registrar", dict()) + is_registered = "id" in registrar or nameservers > 0 + status = "registered" if is_registered else "available" + self.bot.send_message(source, "{} is '{}'".format(domain, status)) + else: + self.bot.send_message(source, "{} might be available".format(domain)) \ No newline at end of file diff --git a/app.py b/app.py index 203cad7..4e170e3 100755 --- a/app.py +++ b/app.py @@ -1,12 +1,85 @@ #!/usr/bin/env python3 -from bot import Bot +from os.path import dirname, realpath -bot = Bot("127.0.0.1", 6667, "BabiliBot|py", ["#bots"]) +from bot import Bot, Tasks, Responses +from actions import actions +from coroutines import coroutines -def processor(name, source, response): - #bot.send_message(source, "Got response") - print(name, source, response) +debug = False +kingme = [] if debug else ["#chaos"] +channels = ["#bots", "#insane"] +if not debug: + channels.extend([ + "#meta", + "#team", + "#chaos", + "#tildeverse" + ]) + +bot = Bot("127.0.0.1", 6667, "BabiliBot", channels) +responses = Responses(bot) +tasks = Tasks(bot) + +for action in actions: + if "type" in action and "pattern" in action and "callback" in action: + responses.add_trigger( + action["type"], + action["pattern"], + action["callback"] + ) + +# for coro in coroutines: +# worker = coro["worker"] +# interval = coro["interval"] +# state = coro.get("state", None) +# coro_state = state if state is not None else (bot,) +# tasks.add_coroutine(worker, interval, coro_state) +tasks.coroutines = coroutines + +def try_to_king_me(channel): + bot.send_message("ChanServ", "REGISTER {}", channel) + bot.send_message("ChanServ", "SET Successor {} {}", channel, bot.botnick) + bot.send_message("ChanServ", "SET Founder {} {}", channel, bot.author) + +def handle_pm(name, response): + print("PM: {} - {}".format(name, response)) + +def handle_mode(channel, mode): + if mode == "-r": + try_to_king_me(channel) + +def handle_invite(channel, name): + if channel in kingme: + try_to_king_me(channel) + + users = bot.memories["users"] + if name not in users: + bot.memories["users"][name] = dict() + + if "invites" not in users[name]: + bot.memories["users"][name]["invites"] = list() + + bot.memories["users"][name]["invites"].append(channel) + +def handle_kick(name): + users = bot.memories["users"] + if name not in users: + bot.memories["users"][name] = dict() + + bot.memories["users"][name]["kicker"] = True + +def handle_message(name, source, response): + responses.parse(name, source, response) + if response == "!debug": + print("::", bot.memories) if __name__ == "__main__": - bot.start(processor, "settings.json") \ No newline at end of file + bot.tasks = tasks + bot.start(dirname(realpath(__file__)), { + "pm": handle_pm, + "mode": handle_mode, + "invite": handle_invite, + "kick": handle_kick, + "message": handle_message + }) \ No newline at end of file diff --git a/bot/__init__.py b/bot/__init__.py index cd656e5..eabec22 100755 --- a/bot/__init__.py +++ b/bot/__init__.py @@ -1,113 +1,3 @@ -import json -import socket - -class Bot: - def __init__(self, server, port, botnick, channels): - self.ircsock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - self.server = server - self.port = port - self.botnick = botnick - self.channels = channels - self.running = True - self.users = dict() - - self.recv_size = 2048 - - def send(self, message, *args): - response = message.format(*args) + "\n" - print("DEBUG: ", response) - self.ircsock.send(response.encode()) - - def send_message(self, target, message, *args): - msg = message.format(*args) - response = "PRIVMSG {0} :{1}".format(target, msg) + "\n" - print("DEBUG: ", response) - self.ircsock.send(response.encode()) - - def join(self, chan, confirmed=False): - self.send("JOIN {}", chan) - - message = "" - magic_string = "End of /NAMES list." - while magic_string not in message: - message = self.ircsock.recv(self.recv_size).decode() - message = message.strip("\n\r") - print(message) - - user_list = "= {} :".format(chan) - raw_users = message.split(user_list)[1].split(" \r\n")[0].split(" ") - prefix_filter = lambda u: u[1:] if "~" in u or "@" in u else u - users = list(filter(prefix_filter, raw_users)) - for user in users: - if user not in self.users: - self.users[user] = dict() - - def ping(self, message): - response = message.split("PING :")[1] - self.send("PONG :{0}", response) - - def parse(self, message): - before, after = message.split("PRIVMSG ", 1) - name = before.split("!", 1)[0][1:] - source, response = after.split(" :", 1) - return name, source, response - - def track_rename(self, message): - # :aewens|otg!aewensotg@tilde.team NICK aewens|otg2 - before, new_name = message.split("NICK ", 1) - name = before.split("!", 1)[0][1:] - user = self.users[name] - del self.users[name] - self.users[new_name] = user - - def load_settings(self, location): - with open(location, "r") as f: - self.settings = json.loads(f.read()) - - def start(self, callback, settings): - message = "" - registered = False - confirmed = True - - self.ircsock.connect((self.server, self.port)) - self.send("USER {0} {0} {0} {0}", self.botnick) - self.send("NICK {0}", self.botnick) - - self.load_settings(settings) - - password = self.settings["password"] or "" - confirm = self.settings["confirm"] or "" - email = self.settings["email"] or "" - - magic_string = "MODE {} +r".format(self.botnick) - while magic_string not in message: - message = self.ircsock.recv(self.recv_size).decode() - message = message.strip("\n\r") - print(message) - if not registered and "Password accepted" in message: - registered = True - if not registered and "choose a different nick" in message: - self.send_message("NickServ", "IDENTIFY {}", password) - if not confirmed and "Your account will expire" in message: - self.send_message("NickServ", "CONFIRM {}", self.confirm) - confirmed = True - - print("DEBUG: Joining") - - for channel in self.channels: - self.join(channel) - - print("DEBUG: Joined") - - while self.running: - message = self.ircsock.recv(self.recv_size).decode("UTF-8") - message = message.strip("\n\r") - print(message) - - if "PING :" in message: - self.ping(message) - elif "NICK " in message: - self.track_rename(message) - elif "PRIVMSG" in message: - name, source, response = self.parse(message) - callback(name, source, response) \ No newline at end of file +from bot.core import Bot +from bot.tasks import Tasks +from bot.responses import Responses \ No newline at end of file diff --git a/bot/core.py b/bot/core.py new file mode 100644 index 0000000..f1511b0 --- /dev/null +++ b/bot/core.py @@ -0,0 +1,246 @@ +import re +import json +import socket +import os.path + +class Bot: + def __init__(self, server, port, botnick, channels): + self.ircsock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.server = server + self.port = port + self.botnick = botnick + self.channels = channels + self.running = True + + self.settings = dict() + self.places = list() + self.tasks = None + self.author = "" + + self.recv_size = 2048 + self.splitter = "\n\r" + + def send(self, message, *args): + response = message.format(*args) + "\n" + print("DEBUG: ", response) + self.ircsock.send(response.encode()) + + def send_message(self, target, message, *args): + msg = message.format(*args) + response = "PRIVMSG {0} :{1}".format(target, msg) + "\n" + print("DEBUG: ", response) + self.ircsock.send(response.encode()) + + def send_action(self, target, message, *args): + self.send_message(target, "\001ACTION {}\001".format(message), *args) + + def join(self, chan): + self.send("JOIN {}", chan) + self.places.append(chan) + + message = "" + magic_string = "End of /NAMES list." + while magic_string not in message: + message = self.ircsock.recv(self.recv_size).decode() + message = message.strip(self.splitter) + print(message) + + user_list = "= {} :".format(chan) + raw_users = message.split(user_list)[1].split(" \r\n")[0].split(" ") + prefix_filter = lambda u: u[1:] if "~" in u or "@" in u else u + users = list(filter(prefix_filter, raw_users)) + remember = self.memories["users"] + for user in users: + if user[0] == "~" or user[0] == "@": + user = user[1:] + + if user not in remember: + self.memories["users"][user] = dict() + + def leave(self, chan): + message = "PART {} :Bye-bye!" + self.send(message, chan) + self.places.remove(chan) + + def ping(self, message): + response = message.split("PING :")[1] + self.send("PONG :{0}", response) + + def get_name(self, text): + return text.split("!", 1)[0][1:] + + def parse(self, message): + before, after = message.split("PRIVMSG ", 1) + name = self.get_name(before) + source, response = after.split(" :", 1) + return name, source, response + + def handle_mode(self, message): + before, after = message.split("MODE ", 1) + name = self.get_name(before) + channel, mode = after.split(" ")[:2] + return channel, mode + + def handle_rename(self, message): + before, new_name = message.split("NICK ", 1) + name = self.get_name(before) + user = self.memories["users"][name] + del self.memories["users"][name] + self.memories["users"][new_name] = user + return user, new_name + + def handle_invite(self, message): + before, after = message.split("INVITE ", 1) + name = self.get_name(before) + channel = after.split(":", 1)[1] + self.join(channel) + return channel, name + + def handle_kick(self, message): + regex = "KICK #\S+ {} :".format(self.botnick) + before, kicker = re.split(regex, message) + return kicker + + def handle_join(self, message): + before, after = message.split("JOIN ", 1) + user = self.get_name(before) + + if user not in self.memories["users"]: + self.memories["users"][user] = dict() + + return user + + def handle_part(self, message): + before, after = message.split("PART ", 1) + user = self.get_name(before) + return user + + def load_memories(self, location): + path = "{}/{}".format(self.location, location) + self.memories_path = path + + if not os.path.isfile(path): + self.memories = { + "users": dict() + } + else: + with open(path, "r") as f: + self.memories = json.loads(f.read()) + + def save_memories(self): + with open(self.memories_path, "w") as f: + try: + f.write(json.dumps(self.memories)) + except ValueError as e: + f.write("") + + def load_settings(self, location): + set_vars = [ + "author" + ] + + path = "{}/{}".format(self.location, location) + with open(path, "r") as f: + self.settings = json.loads(f.read()) + + for name, attr in self.settings.items(): + if name in set_vars: + setattr(self, name, attr) + + def stop(self): + self.running = False + self.send("QUIT") + + def start(self, location, callback): + message = "" + registered = False + confirmed = True + + self.ircsock.connect((self.server, self.port)) + self.send("USER {0} {0} {0} {0}", self.botnick) + self.send("NICK {0}", self.botnick) + + self.location = location + self.load_settings("settings.json") + self.load_memories("data/memories.json") + + password = self.settings["password"] or "" + confirm = self.settings["confirm"] or "" + email = self.settings["email"] or "" + + magic_phrase = { + "has_registered": "Password", + "needs_to_register": "choose a different nick", + "needs_to_confirm": "Your account will expire" + } + + magic_string = "MODE {} +r".format(self.botnick) + while magic_string not in message: + message = self.ircsock.recv(self.recv_size).decode() + message = message.strip(self.splitter) + print(message) + if not registered and magic_phrase["has_registered"] in message: + registered = True + if not registered and magic_phrase["needs_to_register"] in message: + self.send_message("NickServ", "IDENTIFY {}", password) + if not confirmed and magic_phrase["needs_to_confirm"] in message: + self.send_message("NickServ", "CONFIRM {}", self.confirm) + confirmed = True + + self.send("MODE {} +B".format(self.botnick)) + + print("DEBUG: Joining") + + for channel in self.channels: + self.join(channel) + + print("DEBUG: Joined") + + if self.tasks is not None: + if getattr(self.tasks, "run", None) is not None: + self.tasks.run() + + while self.running: + message = self.ircsock.recv(self.recv_size).decode() + message = message.strip(self.splitter) + print(message) + + if "raw" in callback: + callback["raw"](message) + + if "PING :" in message: + self.ping(message) + if "ping" in callback: + callback["ping"]() + elif "MODE " in message: + channel, mode = self.handle_mode(message) + if "mode" in callback: + callback["mode"](channel, mode) + elif "NICK " in message: + old_name, new_name = self.handle_rename(message) + if "nick" in callback: + callback["nick"](old_name, new_name) + elif "KICK " in message: + kicker = self.handle_kick(message) + if "kick" in callback: + callback["kick"](kicker) + elif "JOIN " in message: + user = self.handle_join(message) + if "join" in callback: + callback["join"](user) + elif "PART " in message: + user = self.handle_part(message) + if "part" in callback: + callback["part"](user) + elif "INVITE " in message: + channel, name = self.handle_invite(message) + if "invite" in callback: + callback["invite"](channel, name) + elif "PRIVMSG " in message: + name, source, response = self.parse(message) + if source == self.botnick and "pm" in callback: + callback["pm"](name, response) + elif "message" in callback: + callback["message"](name, source, response) + elif "unhandled" in callback: + callback["unhandled"](message) diff --git a/bot/responses.py b/bot/responses.py new file mode 100644 index 0000000..ae50f6e --- /dev/null +++ b/bot/responses.py @@ -0,0 +1,73 @@ +import re +from datetime import datetime + +class Responses: + def __init__(self, bot): + self.bot = bot + self.triggers = { + "name": dict(), + "source": dict(), + "response": dict() + } + + def add_trigger(self, trigger_type, pattern, callback): + if trigger_type in self.triggers: + self.triggers[trigger_type][pattern] = callback + + def allowed(self, name, source): + memories = self.bot.memories + users = memories["users"] + if name not in users: + return False + + if name in users and "blacklist" in users[name]: + reason = users[name]["blacklist"]["reason"] + message = "is ignoring {} for reason '{}'".format(name, reason) + self.bot.send_action(source, message) + return False + + last_response = 0 + if "last_response" in self.bot.memories["users"][name]: + last_response = self.bot.memories["users"][name]["last_response"] + + now = datetime.now().timestamp() + author = self.bot.author + wait = 1 + + if name != author and last_response > 0 and now - last_response < wait: + self.bot.memories["users"][name]["blacklist"] = { + "reason": "Auto-banished", + "when": now + } + return False + + return True + + def parse(self, name, source, response): + users = self.bot.memories["users"] + if name not in users: + return False + + response = response.lower() + check = { + "name": name, + "source": source, + "response": response + } + + marker = ";;" + mlen = len(marker) + + for trigger in list(self.triggers.keys()): + for pattern, callback in self.triggers[trigger].items(): + if pattern[:mlen] != marker: + if pattern == response and self.allowed(name, source): + callback(self, name, source, response) + else: + regex = re.compile(pattern[mlen:]) + if regex.match(check[trigger]) is not None: + if self.allowed(name, source): + callback(self, name, source, response) + + now = datetime.now().timestamp() + self.bot.memories["users"][name]["last_response"] = now \ No newline at end of file diff --git a/bot/tasks.py b/bot/tasks.py new file mode 100644 index 0000000..4718c7d --- /dev/null +++ b/bot/tasks.py @@ -0,0 +1,37 @@ +import time +import sched +from threading import Thread + +class Tasks: + def __init__(self, bot): + self.bot = bot + self.scheduler = sched.scheduler(time.time, time.sleep) + self.thread = Thread(target=self.worker, args=(self,)) + self.coroutines = list() + self.states = dict() + + def periodic(self, scheduler, interval, action, index, state=dict()): + self.states[index] = action(state) + scheduler.enter(interval, 1, self.periodic, ( + scheduler, interval, action, index, self.states[index] + )) + + def worker(self, tasks): + for c, coro in enumerate(tasks.coroutines): + interval = coro["interval"] + worker = coro["worker"] + state = coro.get("state", dict()) + state["bot"] = tasks.bot + tasks.periodic(tasks.scheduler, interval, worker, c, state) + tasks.scheduler.run() + + def add_coroutine(self, worker, interval, state=dict()): + self.coroutines.append({ + "worker": worker, + "interval": interval, + "state": state + }) + + def run(self): + self.thread.daemon = True + self.thread.start() \ No newline at end of file diff --git a/coroutines/__init__.py b/coroutines/__init__.py new file mode 100644 index 0000000..bd7b4da --- /dev/null +++ b/coroutines/__init__.py @@ -0,0 +1,41 @@ +from coroutines.bbj import BBJ +from coroutines.rss import RSS + +# { +# "worker": test, +# "interval": 3 +# } +# def test(bot): +# print("Testing {}".format(bot.botnick)) + +coroutines = [ + { + "worker": lambda state: BBJ(state).start(), + "interval": 5, + "state": { + "alias": "bbj", + "source": "http://localhost:7099/api", + "channels": ["#team"] + } + }, + { + "worker": lambda state: RSS(state).start(), + "interval": 6, + "state": { + "alias": "title", + "source": "https://tilde.news/newest.rss", + "use": "title", + "channels": ["#meta", "#tildeverse"] + } + }, + { + "worker": lambda state: RSS(state).start(), + "interval": 8, + "state": { + "alias": "links-comments", + "source": "https://tilde.news/comments.rss", + "use": "summary", + "channels": ["#tildeverse"] + } + } +] \ No newline at end of file diff --git a/coroutines/bbj.py b/coroutines/bbj.py new file mode 100644 index 0000000..4880019 --- /dev/null +++ b/coroutines/bbj.py @@ -0,0 +1,89 @@ +from urllib.request import Request, urlopen +from urllib.parse import urlencode +from urllib.error import HTTPError +from datetime import datetime +from json import loads, dumps +from re import sub + +class BBJ: + def __init__(self, state): + self.name = "BBJ" + self.bot = state["bot"] + self.alias = state["alias"] + self.source = state["source"] + self.channels = state["channels"] + self.memory = state.get("memory", { + "initialized": False, + "known": dict() + }) + + def start(self): + if not self.memory["initialized"]: + self.memory["initialized"] = True + self.fetch(self.cache) + return self.run() + + def run(self): + self.fetch(self.mirror) + return { + "bot": self.bot, + "alias": self.alias, + "source": self.source, + "channels": self.channels, + "memory": self.memory + } + + def cache(self, item): + self.memory["known"][item["thread_id"]] = item["last_mod"] + + def process_thread(self, thread_id, thread): + data = thread.get("data", dict()) + title = data.get("title", "") + replies = data.get("reply_count", "") + messages = data.get("messages", "") + usermap = thread.get("usermap", dict()) + reply = messages[replies] + author = reply.get("author", "") + username = usermap[author].get("user_name", "") + body = reply.get("body", "") + body = sub(r">>\d\n\n", r"", body) + body = sub(r"\n", r" ", body) + php = "https://bbj.tilde.team/index.php" + link = "{}?thread_id={}".format(php, thread_id) + for channel in self.channels: + response = "'{}' ({}) : {} <{}>".format(title, username, body, link) + message = "[{}] {}".format(self.alias, response) + self.bot.send_message(channel, message) + + def get_thread(self, thread_id, callback): + params = { + "thread_id": thread_id + } + post_params = str(dumps(params)).encode() + thread_load = Request("{}/thread_load".format(self.source), post_params) + thread_load.add_header("Content-Type", "application/json") + + try: + response = callback(thread_id, loads(urlopen(thread_load).read())) + except HTTPError: + return + + def mirror(self, item): + thread_id = item["thread_id"] + last_mod = self.memory["known"][thread_id] + if last_mod == item["last_mod"]: + return + + self.memory["known"][thread_id] = item["last_mod"] + self.get_thread(thread_id, self.process_thread) + + def fetch(self, callback): + thread_index = Request("{}/thread_index".format(self.source)) + + try: + response = loads(urlopen(thread_index).read()) + threads = response.get("data", dict()) + for thread in threads: + callback(thread) + except HTTPError: + return \ No newline at end of file diff --git a/coroutines/rss.py b/coroutines/rss.py new file mode 100644 index 0000000..b24d3c1 --- /dev/null +++ b/coroutines/rss.py @@ -0,0 +1,69 @@ +from xml.etree import ElementTree as etree +from urllib.request import Request, urlopen +from urllib.error import HTTPError +from json import loads, dumps +from re import sub + +class RSS: + def __init__(self, state): + self.name = "RSS" + self.bot = state["bot"] + self.alias = state["alias"] + self.use = state["use"] + self.source = state["source"] + self.channels = state["channels"] + self.memory = state.get("memory", { + "initialized": False, + "known": list() + }) + + def start(self): + if not self.memory["initialized"]: + self.memory["initialized"] = True + self.fetch(self.cache) + return self.run() + + def run(self): + self.fetch(self.mirror) + return { + "bot": self.bot, + "alias": self.alias, + "use": self.use, + "source": self.source, + "channels": self.channels, + "memory": self.memory + } + + def cache(self, item): + guid = item.findtext("guid", None) + if guid is not None: + self.memory["known"].append(guid) + + def mirror(self, item): + guid = item.findtext("guid", None) + if guid is None: + return + + if guid in self.memory["known"]: + return + + self.memory["known"].append(guid) + + use = sub(r"(<\/?[^>]+>)|\\n", "", item.findtext(self.use, "")) + user = item.findtext("author", "").split("@")[0] + post = "{} (posted by {}) <{}>".format(use, user, guid) + response = "[{}] {}".format(self.alias, post) + for channel in self.channels: + self.bot.send_message(channel, response) + + def fetch(self, callback): + req = Request(self.source) + try: + response = urlopen(req).read() + except HTTPError: + return + + feed = etree.fromstring(response) + items = feed.findall("channel/item") + for item in items: + callback(item) \ No newline at end of file diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29