From 2343364eac519bcb7eeab923b56da5bd060a3e00 Mon Sep 17 00:00:00 2001 From: Tiago Carvalho Date: Sat, 24 Sep 2022 15:09:18 +0100 Subject: [PATCH 1/8] packages/ircbot: initial commit --- packages/servers/ircbot/LICENSE | 19 ++ packages/servers/ircbot/README | 4 + packages/servers/ircbot/justirc.py | 325 +++++++++++++++++++++++++++++ 3 files changed, 348 insertions(+) create mode 100644 packages/servers/ircbot/LICENSE create mode 100644 packages/servers/ircbot/README create mode 100644 packages/servers/ircbot/justirc.py 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/justirc.py b/packages/servers/ircbot/justirc.py new file mode 100644 index 0000000..46233bd --- /dev/null +++ b/packages/servers/ircbot/justirc.py @@ -0,0 +1,325 @@ +# 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() + self.socket = context.wrap_socket(self.socket, server) + + 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}") From 9c0917027b42ff2296549d75a495f9bca2e808ce Mon Sep 17 00:00:00 2001 From: Tiago Carvalho Date: Sat, 24 Sep 2022 15:50:47 +0100 Subject: [PATCH 2/8] packages/ircbot: git ignore --- packages/servers/ircbot/.gitignore | 1 + 1 file changed, 1 insertion(+) create mode 100644 packages/servers/ircbot/.gitignore 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__ From a1d2ed677c19a7d6bce6471d1491cfbf29d3ecc1 Mon Sep 17 00:00:00 2001 From: Tiago Carvalho Date: Sat, 24 Sep 2022 15:51:01 +0100 Subject: [PATCH 3/8] packages/ircbot: mod justirc --- packages/servers/ircbot/justirc.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/servers/ircbot/justirc.py b/packages/servers/ircbot/justirc.py index 46233bd..1c1e6db 100644 --- a/packages/servers/ircbot/justirc.py +++ b/packages/servers/ircbot/justirc.py @@ -212,8 +212,12 @@ class IRCConnection(EventEmitter): if tls: import ssl - context = ssl.SSLContext() - self.socket = context.wrap_socket(self.socket, server) + 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)) From 9c8abab1e31245a083627a55dbd289daf0b4292a Mon Sep 17 00:00:00 2001 From: Tiago Carvalho Date: Sat, 24 Sep 2022 15:51:13 +0100 Subject: [PATCH 4/8] packages/ircbot: add bot code --- packages/servers/ircbot/main.py | 37 +++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 packages/servers/ircbot/main.py diff --git a/packages/servers/ircbot/main.py b/packages/servers/ircbot/main.py new file mode 100644 index 0000000..e294a98 --- /dev/null +++ b/packages/servers/ircbot/main.py @@ -0,0 +1,37 @@ +import justirc + +NICK = 'smith' + +def main(): + bot = justirc.IRCConnection() + + @bot.on('packet') + def new_packet(e): + print(e.packet) + + @bot.on('connected') + def reload_plugins(e): + print('bot has connected') + + @bot.on('connect') + def connect(e): + bot.send_line(f'NICK {NICK}') + bot.send_line(f'USER {NICK} 8 * {NICK}') + bot.emit('connected') + + @bot.on('welcome') + def welcome(e): + bot.join_channel("#general") + + @bot.on('message') + def message(e): + message = e.message.lower() + if message == '.fistbump': + message = f'vroooooooooooo fiiiist, {e.sender} :vvvv)))' + bot.send_message(e.channel, message) + + bot.connect('irc.privatevoid.net', port=6697, tls=True) + bot.run_loop() + +if __name__ == '__main__': + main() From 5e787f49cef95d169c36d3e27f077cbe71aac6a4 Mon Sep 17 00:00:00 2001 From: Tiago Carvalho Date: Sat, 24 Sep 2022 15:56:08 +0100 Subject: [PATCH 5/8] packages/ircbot: a bit of refactoring --- packages/servers/ircbot/main.py | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/packages/servers/ircbot/main.py b/packages/servers/ircbot/main.py index e294a98..87479bb 100644 --- a/packages/servers/ircbot/main.py +++ b/packages/servers/ircbot/main.py @@ -1,23 +1,21 @@ import justirc -NICK = 'smith' - def main(): + config = dict(nick='smith', debug=False) + run_bot(config) + +def run_bot(c): bot = justirc.IRCConnection() - @bot.on('packet') - def new_packet(e): - print(e.packet) - - @bot.on('connected') - def reload_plugins(e): - print('bot has connected') + if c['debug']: + @bot.on('packet') + def new_packet(e): + print(e.packet) @bot.on('connect') def connect(e): - bot.send_line(f'NICK {NICK}') - bot.send_line(f'USER {NICK} 8 * {NICK}') - bot.emit('connected') + bot.send_line(f'NICK {c["nick"]}') + bot.send_line(f'USER {c["nick"]} 8 * {c["nick"]}') @bot.on('welcome') def welcome(e): From dff97b97a52cb2633c194ea2c1c41216bc7beed5 Mon Sep 17 00:00:00 2001 From: Tiago Carvalho Date: Sat, 24 Sep 2022 21:04:35 +0100 Subject: [PATCH 6/8] packages/ircbot: organize code in hooks --- packages/servers/ircbot/hooks/.gitignore | 1 + packages/servers/ircbot/hooks/fistbump.py | 7 +++ packages/servers/ircbot/hooks/quit.py | 7 +++ packages/servers/ircbot/hooks/reload.py | 7 +++ packages/servers/ircbot/main.py | 63 ++++++++++++++++++++--- 5 files changed, 78 insertions(+), 7 deletions(-) create mode 100644 packages/servers/ircbot/hooks/.gitignore create mode 100644 packages/servers/ircbot/hooks/fistbump.py create mode 100644 packages/servers/ircbot/hooks/quit.py create mode 100644 packages/servers/ircbot/hooks/reload.py 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/main.py b/packages/servers/ircbot/main.py index 87479bb..f86ca8c 100644 --- a/packages/servers/ircbot/main.py +++ b/packages/servers/ircbot/main.py @@ -1,34 +1,83 @@ +import os +import sys import justirc +import importlib + +class EventHandler(object): + def on_message(bot, event): + ... + + def on_reload(bot): + ... def main(): - config = dict(nick='smith', debug=False) + 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.split('.py')[0]]) + 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("#general") + bot.join_channel(c['channel']) @bot.on('message') def message(e): - message = e.message.lower() - if message == '.fistbump': - message = f'vroooooooooooo fiiiist, {e.sender} :vvvv)))' - bot.send_message(e.channel, message) + 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('irc.privatevoid.net', port=6697, tls=True) + bot.connect(c['server'], port=c['port'], tls=c['tls']) bot.run_loop() if __name__ == '__main__': From d54bcec082c9d8179bb041eeb3dbaa9a4f84b993 Mon Sep 17 00:00:00 2001 From: Tiago Carvalho Date: Sat, 24 Sep 2022 21:30:10 +0100 Subject: [PATCH 7/8] packages/ircbot: dice roll --- packages/servers/ircbot/hooks/roll.py | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 packages/servers/ircbot/hooks/roll.py 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) From 24c2712e71dcbafaedf5ba405dd965002d15013d Mon Sep 17 00:00:00 2001 From: Tiago Carvalho Date: Sat, 24 Sep 2022 23:45:30 +0100 Subject: [PATCH 8/8] packages/ircbot: meme --- packages/servers/ircbot/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/servers/ircbot/main.py b/packages/servers/ircbot/main.py index f86ca8c..83965f5 100644 --- a/packages/servers/ircbot/main.py +++ b/packages/servers/ircbot/main.py @@ -45,7 +45,7 @@ def run_bot(c): bot.hooks.clear() for path in filter(lambda h: h[-3:] == '.py', os.listdir('hooks')): - name = '.'.join(['hooks', path.split('.py')[0]]) + name = '.'.join(['hooks', path[:-3]]) if name in sys.modules.keys(): del sys.modules[name] try: