diff --git a/glados_cmnds.py b/glados_cmnds.py index 78d7b1b..ec98214 100644 --- a/glados_cmnds.py +++ b/glados_cmnds.py @@ -1,4 +1,5 @@ -from irc_bot_api import commands +from irc_api import commands + class GladosV4(commands.CommandsPack): @commands.command(name="bye", event=lambda m: "au revoir glados" in m.text.lower()) diff --git a/irc_api/__pycache__/commands.cpython-311.pyc b/irc_api/__pycache__/commands.cpython-311.pyc new file mode 100644 index 0000000..53a242b Binary files /dev/null and b/irc_api/__pycache__/commands.cpython-311.pyc differ diff --git a/irc_api/__pycache__/irc.cpython-311.pyc b/irc_api/__pycache__/irc.cpython-311.pyc new file mode 100644 index 0000000..a45bbf6 Binary files /dev/null and b/irc_api/__pycache__/irc.cpython-311.pyc differ diff --git a/irc_api/__pycache__/secrets.cpython-311.pyc b/irc_api/__pycache__/secrets.cpython-311.pyc new file mode 100644 index 0000000..9d21ccc Binary files /dev/null and b/irc_api/__pycache__/secrets.cpython-311.pyc differ diff --git a/irc_api/__pycache__/v5.cpython-311.pyc b/irc_api/__pycache__/v5.cpython-311.pyc new file mode 100644 index 0000000..11cad63 Binary files /dev/null and b/irc_api/__pycache__/v5.cpython-311.pyc differ diff --git a/irc_api/commands.py b/irc_api/commands.py new file mode 100644 index 0000000..e978ce3 --- /dev/null +++ b/irc_api/commands.py @@ -0,0 +1,130 @@ +from functools import wraps +import logging +from irc_api.secrets import USER, PASSWORD +from irc_api.irc import IRC +from irc_api.v5 import V5 + + +PREFIX = "" + +def command(name, event=None): + """Decorate a function and return a Command instance.""" + def decorator(func): + desc = name + if func.__doc__: + desc = func.__doc__ + return Command( + name=name, + desc=desc, + func=func, + event=event + ) + return decorator + + +class Command: + def __init__(self, name, desc, func, event=None): + self.name = name + self.desc = desc + self.func = func + if not event: + self.event = lambda m: m.text.startswith(PREFIX + name) + else: + self.event = event + + self.cmnd_pack = None + + def __call__(self, msg): + return self.func(self.cmnd_pack, msg) + + +class CommandsPack: + def __init__(self, bot): + self.bot = bot + + +class Bot: + """Run the connexion between IRC's server and V5 one. + + Attributes + ---------- + irc : IRC, public + IRC wrapper which handle communication with IRC server. + v5 : V5, public + V5 wrapper which handle communication with V5 server. + channels : list, public + The channels the bot will listen. + + Methods + ------- + start : NoneType, public + Runs the bot and connects it to IRC and V5 servers. + """ + def __init__(self, irc_params: tuple, v5_params: tuple, channels: list, prefix: str=""): + """Initialize the Bot instance. + + Parameters + ---------- + irc_params : tuple + Contains the IRC server informations (host, port) + v5_params : tuple + Contains the V5 server informations (host, port) + channels : list + Contains the names of the channels on which the bot will connect. + prefix : str, optionnal + The prefix on which the bot will react. + """ + global PREFIX + PREFIX = prefix + + self.irc = IRC(*irc_params) + self.v5 = V5(v5_params, self.irc) + self.channels = channels + + def start(self): + """Starts the bot and connect it to the given IRC and V5 servers.""" + # Start IRC + self.irc.start(USER, PASSWORD) + + # Join channels + for channel in self.channels: + self.irc.join(channel) + + # Start V5 hadndler + self.v5.start() + + # Run IRC + self.irc.run() + + def add_help(self): + help_callback = Command( + "aide", + "Affiche la liste des commandes disponibles.", + help_cmnd + ) + help_callback.cmnd_pack = self + self.irc.callbacks.append(help_callback) + + def add_commands_pack(self, commands_pack): + """Add a package of commands to the bot. + + Parameters + ---------- + commands_pack : CommandsPack + A commands pack which contains command's instances. + """ + cmnd_pack = commands_pack(self) + + for cmnd_name in dir(commands_pack): + if not cmnd_name.startswith("__") and not cmnd_name.endswith("__"): + cmnd = getattr(commands_pack, cmnd_name) + cmnd.cmnd_pack = cmnd_pack + + self.irc.callbacks.append(cmnd) + + +def help_cmnd(bot, msg): + """Documentation des fonctions disponibles.""" + bot.irc.send(msg.to, f"Aide des commandes") + for cmnd in bot.irc.callbacks: + bot.irc.send(msg.to, f" – {cmnd.name} : {cmnd.desc}") diff --git a/irc_api/irc.py b/irc_api/irc.py new file mode 100644 index 0000000..79c2087 --- /dev/null +++ b/irc_api/irc.py @@ -0,0 +1,234 @@ +""" +irc (GLaDOS) +============ + +Description +----------- +Manage the IRC layer of GLaDOS. +""" + +import logging +import re +import socket +import ssl + +from functools import wraps +from queue import Queue +from threading import Thread + + +class IRC: + """Manage connexion to an IRC server, authentication and callbacks. + + Attributes + ---------- + connected : bool, public + If the bot is connected to an IRC server or not. + callbacks : list, public + List of the registred callbacks. + + socket : ssl.SSLSocket, private + The IRC's socket. + inbox : Queue, private + Queue of the incomming messages. + handler : Thread, private + + Methods + ------- + start : NoneType, public + Starts the IRC layer and manage authentication. + run : NoneType, public + Mainloop, allows to handle public messages. + send : NoneType, public + Sends a message to a given channel. + receive : Message, public + Same as ``run`` for private messages. + join : NoneType, public + Allows to join a given channel. + on : function, public + Add a callback on a given message. + + handle : NoneType, private + Handles the ping and store incoming messages into the inbox attribute. + send : NoneType, private + Send message to a target. + recv : str, private + Get the oldest incoming message and returns it. + waitfor : str, private + Wait for a raw message that matches the given condition. + """ + def __init__(self, host: str, port: int): + """Initialize an IRC wrapper. + + Parameters + ---------- + host : str + The adress of the IRC server. + port : int + The port of the IRC server. + """ + + # Public attributes + self.connected = False # Simple lock + self.callbacks = [] + + + # Private attributes + self.__socket = ssl.create_default_context().wrap_socket( + socket.create_connection((host, port)), + server_hostname=host + ) + self.__inbox = Queue() + self.__handler = Thread(target=self.__handle) + + # Public methods + def start(self, nick: str, password: str): + """Start the IRC layer. Manage authentication as well. + + Parameters + ---------- + nick : str + The username for login and nickname once connected. + password : str + The password for authentification. + """ + self.__handler.start() + + self.__send(f"USER {nick} * * :{nick}") + self.__send(f"NICK {nick}") + self.__waitfor(lambda m: "NOTICE" in m and "/AUTH" in m) + self.__send(f"AUTH {nick}:{password}") + self.__waitfor(lambda m: "You are now logged in" in m) + + self.connected = True + + def run(self): + """Handle new messages.""" + while True: + message = self.receive() + logging.info("received %s", message) + if message is not None: + for callback in self.callbacks: + if callback.event(message): + logging.info("matched %s", callback.name) + callback(message) + + def send(self, target: str, message: str): + """Send a message to the specified target (channel or user). + + Parameters + ---------- + target : str + The target of the message. It can be a channel or user (private message). + message : str + The content of the message to send. + """ + self.__send(f"PRIVMSG {target} :{message}") + + def receive(self): + """Receive a private message. + + Returns + ------- + msg : Message + The incoming processed private message. + """ + while True: + message = self.__inbox.get() + if " PRIVMSG " in message: + msg = Message(message) + if msg: + return msg + + def join(self, channel: str): + """Join a channel. + + Parameters + ---------- + channel : str + The name of the channel to join. + """ + self.__send(f"JOIN {channel}") + logging.info("joined %s", channel) + + # Private methods + def __handle(self): + """Handle raw messages from irc and manage ping.""" + while True: + # Get incoming messages + data = self.__socket.recv(4096).decode() + + # Split multiple lines + for msg in data.split('\r\n'): + # Manage ping + if msg.startswith("PING"): + self.__send(msg.replace("PING", "PONG")) + + # Or add a new message to inbox + elif len(msg): + self.__inbox.put(msg) + logging.debug("received %s", msg) + + def __send(self, raw: str): + """Wrap and encode raw message to send. + + Parameters + ---------- + raw : str + The raw message to send. + """ + self.__socket.send(f"{raw}\r\n".encode()) + + def __waitfor(self, condition): + """Wait for a raw message that matches the condition. + + Parameters + ---------- + condition : function + ``condition`` is a function that must taking a raw message in parameter and returns a + boolean. + + Returns + ------- + msg : str + The last message received that doesn't match the condition. + """ + msg = self.__inbox.get() + while not condition(msg): + msg = self.__inbox.get() + return msg + + +class Message: + """Parse the raw message in three fields : author, the channel, and text. + + Attributes + ---------- + pattern : re.Pattern, public + The message parsing pattern. + author : str, public + The message's author. + to : str, public + The message's origin (channel or DM). + text : str, public + The message's content. + """ + pattern = re.compile( + r"^:(?P[\w.~|\-\[\]]+)(?:!(?P\S+))? PRIVMSG (?P\S+) :(?P.+)" + ) + + def __init__(self, raw: str): + match = re.search(Message.pattern, raw) + if match: + self.author = match.group("author") + self.to = match.group("to") + self.text = match.group("text") + logging.debug("sucessfully parsed %s into %s", raw, self.__str__()) + else: + self.author = "" + self.to = "" + self.text = "" + logging.warning("failed to parse %s into valid message", raw) + + def __str__(self): + return f"{self.author} to {self.to}: {self.text}" diff --git a/irc_api/v5.py b/irc_api/v5.py new file mode 100644 index 0000000..25dfaa7 --- /dev/null +++ b/irc_api/v5.py @@ -0,0 +1,92 @@ +""" +v5 (GLaDOS) +=========== + +Description +----------- +Manage the V5 layer of GLaDOS. +""" + +import logging +import socket +from threading import Thread +from functools import wraps + + +class V5: + """Manage connexion beetween the bot and the V5 server, and manage callbacks. + + Attributes + ---------- + irc : irc.IRC, public + An IRC instance. + + sock : ssl.SSLSocket, private + The V5 socket. + handler : Thread, private + callbacks : list, private + List of the registred callbacks. + + Methods + ------- + start : NoneType, public + Start v5 handler. + on : function, public + Add a callback to the v5 handler. + + handle : NoneType, private + Handle the incoming messages and callbacks. + """ + + def __init__(self, v5_params: tuple, irc): + """Initialize V5 handle. + + Parameters + ---------- + v5 : tuple + The information on V5 server (host, port). + irc : irc.IRC + An initialized IRC instance. + """ + self.irc = irc + self.__sock = socket.socket(socket.AF_INET,socket.SOCK_DGRAM) + self.__sock.bind(v5_params) + self.__handler = Thread(target=self.__handle) + self.__callbacks = [] + + def start(self): + """Start v5 handler.""" + self.__handler.start() + logging.info("started") + + def on(self, event): + """Adds a callback to the v5 handler. + + Parameters + ---------- + event : function + ``event`` is a function taking in parameter a list of channels and a string, and return + ``True`` if the callback should be executed. + """ + def callback(func): + @wraps(func) + def wrapper(channels, message): + func(channels, message) + self.__callbacks.append((event, wrapper)) + return wrapper + + return callback + + def __handle(self): + """Handle the incoming messages and callbacks.""" + while True: + data, addr = self.__sock.recvfrom(4096) + data = data.decode() + logging.debug("received %s", data) + channels, message = data.split(":", 1) + channels = channels.split(" ") + + for event, callback in self.__callbacks: + if event(channels, message): + logging.info("passed %s", event.__name__) + callback(channels, message) diff --git a/main.py b/main.py index a351afc..5d1072a 100755 --- a/main.py +++ b/main.py @@ -10,7 +10,7 @@ Create a bot's instance and manages it. import logging import re -from irc_bot_api import commands +from irc_api import commands from glados_cmnds import GladosV4, GladosV5 @@ -25,9 +25,13 @@ glados = commands.Bot( "!" ) + glados.add_commands_pack(GladosV4) glados.add_commands_pack(GladosV5) glados.add_help() +for i in glados.irc.callbacks: + print(i.name) + glados.start()