diff --git a/packages/servers/ircbot/.gitignore b/packages/servers/ircbot/.gitignore new file mode 100644 index 0000000..225fc6f --- /dev/null +++ b/packages/servers/ircbot/.gitignore @@ -0,0 +1 @@ +/__pycache__ diff --git a/packages/servers/ircbot/LICENSE b/packages/servers/ircbot/LICENSE new file mode 100644 index 0000000..2b0bd4a --- /dev/null +++ b/packages/servers/ircbot/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2022 Tiago Carvalho + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/packages/servers/ircbot/README b/packages/servers/ircbot/README new file mode 100644 index 0000000..fe0e6b1 --- /dev/null +++ b/packages/servers/ircbot/README @@ -0,0 +1,4 @@ +private-void-bot +================ + +IRC bot for the Private Void IRC network. diff --git a/packages/servers/ircbot/hooks/.gitignore b/packages/servers/ircbot/hooks/.gitignore new file mode 100644 index 0000000..225fc6f --- /dev/null +++ b/packages/servers/ircbot/hooks/.gitignore @@ -0,0 +1 @@ +/__pycache__ diff --git a/packages/servers/ircbot/hooks/fistbump.py b/packages/servers/ircbot/hooks/fistbump.py new file mode 100644 index 0000000..33cea53 --- /dev/null +++ b/packages/servers/ircbot/hooks/fistbump.py @@ -0,0 +1,7 @@ +import main + +class EventHandler(main.EventHandler): + def on_message(bot, e): + if e.message == '.fistbump': + msg = f'vroooooooooooo fiiiist, {e.sender}! :^)' + bot.send_message(e.channel, msg) diff --git a/packages/servers/ircbot/hooks/quit.py b/packages/servers/ircbot/hooks/quit.py new file mode 100644 index 0000000..cc5bd53 --- /dev/null +++ b/packages/servers/ircbot/hooks/quit.py @@ -0,0 +1,7 @@ +import main + +class EventHandler(main.EventHandler): + def on_message(bot, e): + if e.message == '.quit': + bot.send_message(e.channel, 'exiting...') + bot.emit('quit') diff --git a/packages/servers/ircbot/hooks/reload.py b/packages/servers/ircbot/hooks/reload.py new file mode 100644 index 0000000..604337d --- /dev/null +++ b/packages/servers/ircbot/hooks/reload.py @@ -0,0 +1,7 @@ +import main + +class EventHandler(main.EventHandler): + def on_message(bot, e): + if e.message == '.reload': + bot.send_message(e.channel, 'reloading hooks...') + bot.emit('reload-hooks') diff --git a/packages/servers/ircbot/hooks/roll.py b/packages/servers/ircbot/hooks/roll.py new file mode 100644 index 0000000..2850f09 --- /dev/null +++ b/packages/servers/ircbot/hooks/roll.py @@ -0,0 +1,8 @@ +import main +import random + +class EventHandler(main.EventHandler): + def on_message(bot, e): + if e.message == '.roll': + msg = f'{e.sender}: rolled a {random.randint(1, 6)}' + bot.send_message(e.channel, msg) diff --git a/packages/servers/ircbot/justirc.py b/packages/servers/ircbot/justirc.py new file mode 100644 index 0000000..1c1e6db --- /dev/null +++ b/packages/servers/ircbot/justirc.py @@ -0,0 +1,329 @@ +# Copyright (c) 2015 Gökberk Yaltıraklı +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + + +import socket + +from collections import defaultdict +from collections import namedtuple + + +_IRCPacket = namedtuple("IRCPacket", "prefix command arguments") + + +def _parse_irc_packet(packet): + prefix = "" + command = "" + arguments = [] + + if packet.startswith(":"): + prefix = packet[1:].split(" ")[0] + packet = packet.split(" ", 1)[1] + + if " " in packet: + if " :" in packet: + last_argument = packet.split(" :")[1] + packet = packet.split(" :")[0] + for splitted in packet.split(" "): + if not command: + command = splitted + else: + arguments.append(splitted) + arguments.append(last_argument) + else: + for splitted in packet.split(" "): + if not command: + command = splitted + else: + arguments.append(splitted) + else: + command = packet + + return _IRCPacket(prefix, command, arguments) + + +_IRCPacket.parse = _parse_irc_packet + + +class EventEmitter: + def __init__(self): + self.handlers = defaultdict(lambda: []) + + def add_listener(self, name, handler): + self.handlers[name].append(handler) + + def remove_listener(self, name, handler): + self.handlers[name].remove(handler) + + def emit(self, name, data=None): + """Emit an event + + This function emits an event to all listeners registered to it. + + Parameters + ---------- + name : str + Event name. Case sensitive. + data + Event data. Can be any type and passed directly to the event + handlers. + + """ + for handler in list(self.handlers[name]): + handler(data) + + def on(self, name): + """ + Decorate a function as an event handler. + + Parameters + ---------- + name : str + The event name to handle + """ + + def inner(func): + self.add_listener(name, func) + return func + + return inner + + +# Event data types +_IRCEvent = namedtuple("IRCEvent", "bot") +_PacketEvent = namedtuple("PacketEvent", "bot packet") +_MessageEvent = namedtuple("MessageEvent", "bot channel sender message") +_JoinEvent = namedtuple("JoinEvent", "bot channel nick") +_PartEvent = namedtuple("PartEvent", "bot channel nick") + + +class IRCConnection(EventEmitter): + def __init__(self): + """Create an IRC connection + + After creating the object and adding all the event handlers, you need to + call .connect on it to actually connect to a server. + + """ + super().__init__() + self.socket = None + + self.nick = "" + + def run_once(self): + """Run one iteration of the IRC client. + + This function is called in a loop by the run_loop function. It can be + called separately, but most of the time there is no need to do this. + + """ + + line = next(self.lines) + packet = _IRCPacket.parse(line) + sender = packet.prefix.split("!")[0] + + ev = _PacketEvent(self, packet) + self.emit("packet", ev) + self.emit(f"packet_{packet.command}", ev) + + if packet.command == "PRIVMSG": + channel = packet.arguments[0] + message = packet.arguments[1] + ev = _MessageEvent(self, channel, sender, message) + self.emit("message", ev) + self.emit(f"message_{channel}", ev) + self.emit(f"message_{sender}", ev) + + if channel[0] == "#": + self.emit("message#", ev) + else: + self.emit("pm", ev) + elif packet.command == "PING": + # Handle a PING message + self.send_line("PONG :{}".format(packet.arguments[0])) + self.emit("ping", _IRCEvent(self)) + elif packet.command == "433" or packet.command == "437": + # Command 433 is "Nick in use" + # Add underscore to the nick + + self.set_nick("{}_".format(self.nick)) + elif packet.command == "001": + self.emit("welcome", _IRCEvent(self)) + elif packet.command == "JOIN": + ev = _JoinEvent(self, packet.arguments[0], sender) + self.emit("join", ev) + elif packet.command == "PART": + ev = _PartEvent(self, packet.arguments[0], sender) + self.emit("part", ev) + + def run_loop(self): + """Runs the main loop of the client + + This function is usually called after you add all the callbacks and + connect to the server. It will block until the connection to the server + is broken. + + """ + while True: + self.run_once() + + def _read_lines(self): + buff = "" + while True: + buff += self.socket.recv(1024).decode("utf-8", "replace") + while "\n" in buff: + line, buff = buff.split("\n", 1) + line = line.replace("\r", "") + yield line + + def connect(self, server, port=6667, tls=False): + """Connects to the IRC server + + Parameters + ---------- + server : str + The server IP or domain to connect to + port : int + The server port to connect to + tls : bool + Enable the use of TLS + + """ + + self.socket = socket.create_connection((server, port)) + + if tls: + import ssl + + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + context.minimum_version = ssl.TLSVersion.TLSv1_3 + context.maximum_version = ssl.TLSVersion.TLSv1_3 + self.socket = context.wrap_socket(self.socket) + + self.lines = self._read_lines() + self.emit("connect", _IRCEvent(self)) + + def send_line(self, line): + """Sends a line directly to the server. + + This is a low-level function that can be used to implement functionality + that's not covered by this library. Almost all of the time, you should + have no need to use this function. + + Parameters + ---------- + line : str + The line to send to the server + + """ + self.socket.send(f"{line}\r\n".encode("utf-8")) + + def send_message(self, to, message): + """Sends a message to a user or a channel + + This is the main method of interaction as an IRC bot or client. This + function results in a PRIVMSG packet to the server. + + Parameters + ---------- + to : str + The target of the message + message : str + The message content + + """ + self.send_line(f"PRIVMSG {to} :{message}") + + def send_notice(self, to, message): + """Send a notice message + + Notice messages usually have special formatting on clients. + + Parameters + ---------- + to : str + The target of the message + message : str + The message content + + """ + self.send_line(f"NOTICE {to} :{message}") + + def send_action_message(self, to, action): + """Send an action message to a channel or user. + + Action messages can have special formatting on clients and are usually + send like /me is happy + + Parameters + ---------- + to : str + The target of the message. Can be a channel or a user. + action : str + The message content + + """ + self.send_message(to, f"\x01ACTION {action}\x01") + + def join_channel(self, channel): + """Join a channel + + This function joins a given channel. After the channel is joined, the + "join" event is emitted with your nick. + + Parameters + ---------- + channel : str + The channel to join + + """ + self.send_line(f"JOIN {channel}") + + def set_nick(self, nick): + """Sets or changes your nick + + This should be called before joining channels, but can be called at any + time afterwards. If the requested nick is not available, the library + will keep adding underscores until an available nick is found. + + Parameters + ---------- + nick : str + The nickname to use + + """ + self.nick = nick + self.send_line(f"NICK {nick}") + + def send_user_packet(self, username): + """Send a user packet + + This should be sent after your nickname. It is displayed on the clients + when they view your details and look at "Real Name". + + Parameters + ---------- + username : str + The name to set + + """ + self.send_line(f"USER {username} 0 * :{username}") diff --git a/packages/servers/ircbot/main.py b/packages/servers/ircbot/main.py new file mode 100644 index 0000000..83965f5 --- /dev/null +++ b/packages/servers/ircbot/main.py @@ -0,0 +1,84 @@ +import os +import sys +import justirc +import importlib + +class EventHandler(object): + def on_message(bot, event): + ... + + def on_reload(bot): + ... + +def main(): + config = dict( + debug=False, + nick='smith', + channel='#general', + server='irc.privatevoid.net', + port=6697, + tls=True, + ) + run_bot(config) + +def shutdown_bot_hooks(bot): + for name, hook in bot.hooks: + try: + hook.EventHandler.on_reload(bot) + except Exception as e: + print(f'exception running hook {name}: {e}') + +def run_bot(c): + bot = justirc.IRCConnection() + + bot.db = () # TODO: store a database handle here + bot.hooks = [] # storage for all bot hooks + + if c['debug']: + @bot.on('packet') + def new_packet(e): + print(e.packet) + + @bot.on('reload-hooks') + def reload_hooks(e): + shutdown_bot_hooks(bot) + bot.hooks.clear() + + for path in filter(lambda h: h[-3:] == '.py', os.listdir('hooks')): + name = '.'.join(['hooks', path[:-3]]) + if name in sys.modules.keys(): + del sys.modules[name] + try: + mod = importlib.import_module(name, package=name) + bot.hooks.append((name, mod)) + except Exception as e: + print(f'failed to load hook {name}: {e}') + + @bot.on('quit') + def quit(e): + shutdown_bot_hooks(bot) + exit(0) + + @bot.on('connect') + def connect(e): + bot.send_line(f'NICK {c["nick"]}') + bot.send_line(f'USER {c["nick"]} 8 * {c["nick"]}') + bot.emit('reload-hooks') + + @bot.on('welcome') + def welcome(e): + bot.join_channel(c['channel']) + + @bot.on('message') + def message(e): + for name, hook in bot.hooks: + try: + hook.EventHandler.on_message(bot, e) + except Exception as e: + print(f'exception running hook {name}: {e}') + + bot.connect(c['server'], port=c['port'], tls=c['tls']) + bot.run_loop() + +if __name__ == '__main__': + main()