diff --git a/.gitignore b/.gitignore index 2aaf706..d8b6c30 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # App-specific settings.json +data/*.json irc/ # ---> Python 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 index 3fed4d6..f8b1fd2 100644 --- a/actions/__init__.py +++ b/actions/__init__.py @@ -1,4 +1,8 @@ from actions.botlist import botlist +from actions.summon import summon +from actions.access import banish, pardon +from actions.control import puppet, nomad +from actions.stupid import hmm, hmmscore, hmmscoreboard actions = [ { @@ -10,5 +14,45 @@ actions = [ "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 } ] \ 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 index 91ff332..1766a02 100644 --- a/actions/botlist.py +++ b/actions/botlist.py @@ -1,12 +1,15 @@ def botlist(self, name, source, response): - name = self.bot.botnick + botnick = self.bot.botnick author = self.bot.author email = self.bot.settings["email"] about = "the meta chat bot" commands = ", ".join([ "!botlist", - "!rollcall" + "!rollcall", + "!summon", + "!banish", + "!pardon" ]) - args = (name, author, email, about, commands) + 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/nomad.py b/actions/nomad.py new file mode 100644 index 0000000..e69de29 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/summon.py b/actions/summon.py new file mode 100644 index 0000000..647307e --- /dev/null +++ b/actions/summon.py @@ -0,0 +1,25 @@ +from subprocess import Popen, PIPE +from email.mime.text import MIMEText + +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" + + message = MIMEText(" ".join([ + "My bot, {}, received a summoning request for you".format(botnick), + "from {} in channel {} for reason: {}".format(name, source, reason) + ])) + + message["From"] = email.format(botnick) + message["To"] = email.format(user) + message["Subject"] = "You have been summoned!" + + command = "/usr/sbin/sendmail -t -oi".split(" ") + p = Popen(command, stdin=PIPE, universal_newlines=True) + p.communicate(message.as_string()) + + confirmation = "{}: You have summoned {}".format(name, user) + self.bot.send_message(source, confirmation) \ No newline at end of file diff --git a/app.py b/app.py index 23355c7..27f4ffb 100755 --- a/app.py +++ b/app.py @@ -1,15 +1,18 @@ #!/usr/bin/env python3 +from os.path import dirname, realpath + from bot import Bot, Tasks, Responses from actions import actions -kickers = list() -inviters = dict() kingme = [ "#chaos" ] -bot = Bot("127.0.0.1", 6667, "BabiliBot|py", ["#bots"]) +bot = Bot("127.0.0.1", 6667, "BabiliBot|py", [ + "#bots", + "#insane" +]) responses = Responses(bot) for action in actions: @@ -20,7 +23,7 @@ for action in actions: action["callback"] ) -def try_to_king_me(bot, channel): +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) @@ -30,26 +33,35 @@ def handle_pm(name, response): def handle_mode(channel, mode): if mode == "-r": - try_to_king_me(bot, channel) + try_to_king_me(channel) def handle_invite(channel, name): if channel in kingme: - try_to_king_me(bot, channel) + try_to_king_me(channel) - invites.append({ - "channel": channel, - "name": name - }) + users = bot.memories["users"] + if name not in users: + bot.memories["users"][name] = dict() -def handle_kick(kicker): - kickers.append(kicker) + 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): - print("MSG: {} {} - {}".format(name, source, response)) responses.parse(name, source, response) + if response == "!debug": + print("::", bot.memories) if __name__ == "__main__": - bot.start("settings.json", { + bot.start(dirname(realpath(__file__)), { "pm": handle_pm, "mode": handle_mode, "invite": handle_invite, diff --git a/bot/core.py b/bot/core.py index 13141b6..838f17f 100644 --- a/bot/core.py +++ b/bot/core.py @@ -1,6 +1,7 @@ import re import json import socket +import os.path class Bot: def __init__(self, server, port, botnick, channels): @@ -12,7 +13,7 @@ class Bot: self.running = True self.settings = dict() - self.users = dict() + self.places = list() self.author = "" self.recv_size = 2048 @@ -29,8 +30,12 @@ class Bot: 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." @@ -43,9 +48,18 @@ class Bot: 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 not in self.users: - self.users[user] = dict() + 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] @@ -69,9 +83,9 @@ class Bot: def handle_rename(self, message): before, new_name = message.split("NICK ", 1) name = self.get_name(before) - user = self.users[name] - del self.users[name] - self.users[new_name] = user + 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): @@ -86,12 +100,46 @@ class Bot: 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" ] - with open(location, "r") as f: + path = "{}/{}".format(self.location, location) + with open(path, "r") as f: self.settings = json.loads(f.read()) for name, attr in self.settings.items(): @@ -102,7 +150,7 @@ class Bot: self.running = False self.send("QUIT") - def start(self, settings, callback): + def start(self, location, callback): message = "" registered = False confirmed = True @@ -111,7 +159,9 @@ class Bot: self.send("USER {0} {0} {0} {0}", self.botnick) self.send("NICK {0}", self.botnick) - self.load_settings(settings) + self.location = location + self.load_settings("settings.json") + self.load_memories("data/memories.json") password = self.settings["password"] or "" confirm = self.settings["confirm"] or "" @@ -169,6 +219,14 @@ class Bot: 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: diff --git a/bot/responses.py b/bot/responses.py index 39dbad6..dfacd2d 100644 --- a/bot/responses.py +++ b/bot/responses.py @@ -1,4 +1,5 @@ import re +from datetime import datetime class Responses: def __init__(self, bot): @@ -13,6 +14,32 @@ class Responses: 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 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): check = { "name": name, @@ -20,11 +47,19 @@ class Responses: "response": response } + marker = ";;" + mlen = len(marker) + for trigger in list(self.triggers.keys()): for pattern, callback in self.triggers[trigger].items(): - if pattern[0] == "!" and pattern == response: - callback(self, name, source, response) + if pattern[:mlen] != marker: + if pattern == response and self.allowed(name, source): + callback(self, name, source, response) else: - regex = re.compile(pattern) + regex = re.compile(pattern[mlen:]) if regex.match(check[trigger]) is not None: - callback(self, name, source, response) \ No newline at end of file + 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/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29