diff --git a/bridge.py b/bridge.py index 50ca987..a5d92ea 100644 --- a/bridge.py +++ b/bridge.py @@ -1,35 +1,43 @@ -import logging - - -from irc import IRC -from shoutbox import Shoutbox -from cookies import cookies -from sasl import nick, password -from users import users - - -LOG_FORMAT = "%(asctime)s [%(levelname)s] <%(filename)s> %(funcName)s: %(message)s" -logging.basicConfig(format=LOG_FORMAT, level=logging.INFO) - -channels = ["hs", "projets", "annonces"] - -irc = IRC('irc.planet-casio.com', 6697) -shoutbox = Shoutbox(cookies) - - -@irc.on(lambda m: m.to[1:] in channels) -def handle_irc(m): - shoutbox.post(m.author, m.text, m.to[1:], users) - - -@shoutbox.on(lambda m: m.channel in channels and m.author != "IRC" and not m.text.endswith("[IRC]")) -def handle_shoutbox(m): - irc.send(f"#{m.channel}", f"{m.author}: {m.text}") - - -irc.start("Shoutbox", password, nick) -for c in channels: - irc.join(f"#{c}") - -shoutbox.run() -irc.run() +import logging +import sys + + +from irc import IRC +from shoutbox import Shoutbox +from cookies import cookies +from sasl import nick, password +from users import USERS + + +LOG_FORMAT = "%(asctime)s [%(levelname)s] %(threadName)s <%(filename)s> %(funcName)s: %(message)s" +logging.basicConfig(format=LOG_FORMAT, level=logging.DEBUG) + +channels = ["hs", "projets", "annonces"] + +irc = IRC('irc.planet-casio.com', 6697) +shoutbox = Shoutbox(cookies) + + +@irc.on(lambda m: m.to[1:] in channels and not m.author.endswith("[s]")) +def handle_irc(m): + shoutbox.post(m.author, m.text, m.to[1:], USERS) + + +@shoutbox.on(lambda m: m.channel in channels) +def handle_shoutbox(m): + author = Shoutbox.normalize(m.author) + shoutbox.irc_clients[author][0].send(f"#{m.channel}", f"{m.text}") + + +irc.start(f"Shoutbox[{nick}]", password, nick) +for c in channels: + irc.join(f"#{c}") + +try: + shoutbox.run() + irc.run() + +except KeyboardInterrupt: + irc.stop() + shoutbox.stop() + sys.exit(0) diff --git a/irc.py b/irc.py index 974b552..fbf1ecf 100644 --- a/irc.py +++ b/irc.py @@ -1,133 +1,172 @@ -# Manage the IRC layer of GLaDOS - -import logging, re, socket, ssl -from functools import wraps -from queue import Queue -from threading import Thread - -class IRC(object): - def __init__(self, host, port): - """ Initialize an IRC wrapper """ - # Public attributes - self.connected = False # Simple lock - - # 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) - self._callbacks = [] - - # Public methods - - def start(self, nick, password, sasl_nick=None): - """ Start the IRC layer. Manage authentication as well """ - sasl_nick = sasl_nick or nick - 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 {sasl_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(f"received {message}") - if message is not None: - for event, callback in self._callbacks: - if event(message): - logging.info(f"matched {event.__name__}") - callback(message) - - def send(self, target, message): - """ Send a message to the specified target (channel or user) """ - self._send(f"PRIVMSG {target} :{message}") - - def receive(self): - """ Receive a private message """ - while True: - message = self._recv() - if " PRIVMSG " in message: - msg = IRCMessage(message) - if msg: - return msg - - def join(self, channel): - """ Join a channel """ - self._send(f"JOIN {channel}") - logging.info(f"joined {channel}") - - def on(self, event): - """ Adds a callback to the IRC handler - Event is a function taking in parameter a IRCMessage and returning - True if the callback should be executed on the message """ - - def callback(func): - @wraps(func) - def wrapper(message): - func(message) - self._callbacks.append((event, wrapper)) - logging.info(f"added callback {func.__name__}") - return wrapper - - return callback - - - # 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 m in data.split('\r\n'): - # Manage ping - if m.startswith("PING"): - self._send(m.replace("PING", "PONG")) - # Or add a new message to inbox - elif len(m): - self._inbox.put(m) - logging.debug(f"received {m}") - - def _send(self, raw): - """ Wrap and encode raw message to send """ - self._socket.send(f"{raw}\r\n".encode()) - - def _recv(self): - m = self._inbox.get() - return m - - def _waitfor(self, condition): - """ Wait for a raw message that matches the condition """ - msg = self._recv() - while not condition(msg): - msg = self._recv() - return msg - - -class IRCMessage(object): - r = re.compile("^:(?P[\w.~|]+)(?:!(?P\S+))? PRIVMSG (?P\S+) :(?P.+)") - - def __init__(self, raw): - match = re.search(IRCMessage.r, raw) - if match: - self.author = match.group("author") - self.to = match.group("to") - self.text = match.group("text") - logging.debug(f"sucessfully parsed {raw} into {self}") - else: - self.author = "" - self.to = "" - self.text = "" - logging.warning(f"failed to parse {raw} into valid message") - - def __str__(self): - return f"{self.author} to {self.to}: {self.text}" +# 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(object): + def __init__(self, host, port): + """ Initialize an IRC wrapper """ + # Public attributes + self.connected = False # Simple lock + self.running = False + + # 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) + self._callbacks = [] + + # Public methods + + def start(self, nick, password, sasl_nick=None): + """ Start the IRC layer. Manage authentication as well + Return True if authentication succeed, False if failed""" + sasl_nick = sasl_nick or nick + self.nick = nick + self.running = True + logging.debug("Thread start") + 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) + + for i in range(3): + self._send(f"AUTH {sasl_nick}:{password}") + msg = self._waitfor(lambda m: "You are now logged in" in m or "Authentication failed" in m) + + if "You are now logged in" in msg: + self.connected = True + return True + + logging.info(f"Authentication for {nick} ({sasl_nick}) failed") + self.connected = False + self._handler.stop() + return False + + def stop(self): + """ Stop the IRC layer """ + self._send("QUIT :Bye bye") + logging.debug("STOP: sent QUIT message") + self.running = False + self._handler.join() + logging.debug("STOP: thread has terminated") + self._socket.close() + logging.debug("STOP: socket close") + + def run(self): + """ Handle new messages """ + while True: + message = self.receive() + logging.info(f"received {message}") + if message is not None: + for event, callback in self._callbacks: + if event(message): + logging.info(f"matched {event.__name__}") + callback(message) + + def send(self, target, message): + """ Send a message to the specified target (channel or user) """ + self._send(f"PRIVMSG {target} :{message}") + + def receive(self): + """ Receive a private message """ + while True: + message = self._recv() + if " PRIVMSG " in message: + msg = IRCMessage(message) + if msg: + return msg + logging.debug(f"skipped message {msg}") + + def join(self, channel): + """ Join a channel """ + self._send(f"JOIN {channel}") + logging.info(f"joined {channel}") + + def on(self, event): + """ Adds a callback to the IRC handler + Event is a function taking in parameter a IRCMessage and returning + True if the callback should be executed on the message """ + + def callback(func): + @wraps(func) + def wrapper(message): + func(message) + self._callbacks.append((event, wrapper)) + logging.info(f"added callback {func.__name__}") + return wrapper + + return callback + + # Private methods + def _handle(self, store=True): + """ Handle raw messages from irc and manage ping """ + while self.running: + # Get incoming messages + data = self._socket.recv(4096).decode() + + # Split multiple lines + for m in data.split('\r\n'): + # Manage ping + if m.startswith("PING"): + self._send(m.replace("PING", "PONG")) + # Or add a new message to inbox + elif len(m): + if store: + self._inbox.put(m) + logging.debug(f"_handle has quit: running={self.running}") + + def _send(self, raw): + """ Wrap and encode raw message to send """ + try: + self._socket.send(f"{raw}\r\n".encode()) + except OSError as e: + logging.warning(e) + # Do not display password in logs + if raw.startswith("AUTH"): + raw = raw.split(":")[0] + ":*REDACTED*" + logging.debug(raw) + + def _recv(self): + try: + m = self._inbox.get() + except OSError as e: + logging.warning(e) + logging.debug(m) + return m + + def _waitfor(self, condition): + """ Wait for a raw message that matches the condition """ + msg = self._recv() + while not condition(msg): + msg = self._recv() + return msg + + +class IRCMessage(object): + r = re.compile("^:(?P[\w.~|\[\]]+)(?:!(?P\S+))? PRIVMSG (?P\S+) :(?P.+)") + + def __init__(self, raw): + match = re.search(IRCMessage.r, raw) + if match: + self.author = match.group("author") + self.to = match.group("to") + self.text = match.group("text") + logging.debug(f"sucessfully parsed {raw} into {self}") + else: + self.author = "" + self.to = "" + self.text = "" + logging.warning(f"failed to parse {raw} into valid message") + + def __str__(self): + return f"{self.author} to {self.to}: {self.text}" diff --git a/shoutbox.py b/shoutbox.py index 485cb04..689e12f 100644 --- a/shoutbox.py +++ b/shoutbox.py @@ -1,70 +1,136 @@ -import json -import requests as r -import logging -import time -from functools import wraps -from threading import Thread - - -class Shoutbox(object): - def __init__(self, cookies): - self.channels = {'annonces': 0, 'projets': 0, 'hs': 0} - self.cookies = cookies - self._callbacks = [] - - for channel, last_id in self.channels.items(): - messages = json.loads(r.get(f"https://www.planet-casio.com/Fr/shoutbox/api/read?since={last_id}&channel={channel}&format=text").text)['messages'] - for m in messages: - self.channels[channel] = m['id'] - - def run(self): - def handler(): - while True: - for channel, last_id in self.channels.items(): - messages = json.loads(r.get(f"https://www.planet-casio.com/Fr/shoutbox/api/read?since={last_id}&channel={channel}&format=text").text)['messages'] - for m in messages: - logging.debug(m) - self.channels[channel] = m['id'] - message = SBMessage(m, channel) - for event, callback in self._callbacks: - if event(message): - logging.info(f"matched {event.__name__}") - callback(message) - time.sleep(1) - Thread(target=handler).start() - - def on(self, event): - """ Adds a callback to the IRC handler - Event is a function taking in parameter a SBMessage and returning - True if the callback should be executed on the message """ - - def callback(func): - @wraps(func) - def wrapper(message): - func(message) - self._callbacks.append((event, wrapper)) - logging.info(f"added callback {func.__name__}") - return wrapper - - return callback - - def post(self, user, msg, channel, users): - if msg.startswith("ACTION"): - msg = msg.replace("ACTION", "/me") - if any(user in t for t in users): - for i in users: - if i[1] == user: - r.post("https://www.planet-casio.com/Fr/shoutbox/api/post-as", - data={"user": i[0], "message": msg, "channel": channel}, - cookies=self.cookies) - else: - r.post("https://www.planet-casio.com/Fr/shoutbox/api/post-as", - data={"user": "IRC", "message": f"{user} : {msg}", "channel": channel}, - cookies=self.cookies) - - -class SBMessage(object): - def __init__(self, raw, channel): - self.author = raw['author'] - self.channel = channel - self.text = raw['content'] +import json +import requests as r +import logging +import time +import datetime +import re +from functools import wraps +from threading import Thread +from irc import IRC + +from sasl import nick, password +from users import USERS + + +class Shoutbox(object): + def __init__(self, cookies): + self.channels = {'annonces': 0, 'projets': 0, 'hs': 0} + self.cookies = cookies + self._callbacks = [] + self.irc_clients = {} # pseudo: [IRC(), date] + self.running = False + self._handler = Thread(target=self._handle) + + for channel, last_id in self.channels.items(): + messages = json.loads(r.get(f"https://www.planet-casio.com/Fr/shoutbox/api/read?since={last_id}&channel={channel}&format=text").text)['messages'] + for m in messages: + self.channels[channel] = m['id'] + + def run(self): + self.running = True + logging.debug("Thread start") + self._handler.start() + + def stop(self): + logging.debug("STOP: Stop requests to planet-casio.com") + self.running = False + logging.debug("STOP: Halt all irc user threads") + for client in self.irc_clients: + self.irc_clients[client][0].stop() + self.irc_clients.pop(client) + self._handler.join() + logging.debug("STOP: Shoutbox thread closed") + + def on(self, event): + """ Adds a callback to the IRC handler + Event is a function taking in parameter a SBMessage and returning + True if the callback should be executed on the message """ + + def callback(func): + @wraps(func) + def wrapper(message): + func(message) + self._callbacks.append((event, wrapper)) + logging.info(f"added callback {func.__name__}") + return wrapper + + return callback + + def post(self, user, msg, channel, users): + if msg.startswith("ACTION"): + msg = msg.replace("ACTION", "/me") + # Look for pseudo v43-v5 translation + for v43_name, v5_name in users: + if v5_name.lower() == user.lower(): + r.post("https://www.planet-casio.com/Fr/shoutbox/api/post-as", + data={"user": v43_name, "message": msg, "channel": channel}, + cookies=self.cookies) + return + # No translation found + r.post("https://www.planet-casio.com/Fr/shoutbox/api/post-as", + data={"user": "IRC", "message": f"{user} : {msg}", "channel": channel}, + cookies=self.cookies) + + def normalize(pseudo): + if pseudo.lower() in [u[0].lower() for u in USERS]: + return [u[1] for u in USERS if u[0].lower() == pseudo.lower()][0] + return re.sub(r'[^.A-Za-z0-9_]', '_', pseudo) + + def _handle(self): + while self.running: + try: + for channel, last_id in self.channels.items(): + # Do not spam with logs + logging.getLogger().setLevel(logging.INFO) + messages = json.loads(r.get(f"https://www.planet-casio.com/Fr/shoutbox/api/read?since={last_id}&channel={channel}&format=text").text)['messages'] + logging.getLogger().setLevel(logging.DEBUG) + for m in messages: + logging.debug(m) + # If message comes from IRC, drop it (no loops allowed) + if m['source'] == "IRC": + continue + # Get channel id, parse SBMessage + self.channels[channel] = m['id'] + message = SBMessage(m, channel) + # If handler needs to be killed + if not self.running: + logging.debug("going to stop") + break + # For each callback defined with @decorator + for event, callback in self._callbacks: + author = Shoutbox.normalize(message.author) + # client is not known or is disconnected + if author not in self.irc_clients.keys() \ + or self.irc_clients[author][0].running == False: + self.irc_clients[author] = [ + IRC('irc.planet-casio.com', 6697), + datetime.datetime.now() + ] + # Start a thread for new client + if self.irc_clients[author][0].start(f"{author}[s]", password, nick): + logging.debug(f"{author} has joined IRC") + # client is known but AFK + else: + self.irc_clients[author][1] = datetime.datetime.now() + logging.debug(f"{author} has updated IRC") + + if event(message): + logging.info(f"matched {event.__name__}") + callback(message) + # kill afk clients + for k, c in self.irc_clients.items(): + if datetime.datetime.now() - c[1] > datetime.timedelta(hours=1): + logging.info(f"killing {c[0].nick}") + c[0].stop() + self.irc_clients.pop(k) + except Exception as e: + logging.error(f"Faillure in Shoutbox thread {e}") + finally: + time.sleep(3) + + +class SBMessage(object): + def __init__(self, raw, channel): + self.author = raw['author'] + self.channel = channel + self.text = raw['content'] diff --git a/users.py b/users.py index cf4317f..25b853b 100644 --- a/users.py +++ b/users.py @@ -1,6 +1,7 @@ -users = [ +USERS = [ # The gods - ("Lephenixnoir", "Lephenixnoir") + ("Lephenixnoir", "Lephenixnoir"), + ("Lephenixnoir", "Lephe"), ("Shadow15510", "Shadow"), ("Critor", "Critor"), # The old gods @@ -8,10 +9,21 @@ users = [ ("Dark Storm", "Eldeberen"), # The priests ("Breizh_craft", "Breizh"), + # Senators + ("Massena", "massena"), + ("Potter360", "potter360"), + ("Tituya", "Tituya"), # The masters of the puzzles ("Eragon", "eragon"), ("Hackcell", "Alice"), + # Judges + ("Kikoodx", "KikooDX"), + # Old ones, they don't work anymore + # the - char is not allowed on IRC + ("-florian66-", "florian66"), # This ends the list of sacred users # This is the start of the plebs - ("Acrocasio", "Acrocasio") + ("Acrocasio", "Acrocasio"), + ("FlamingKite", "FKite"), + ("FlamingKite", "FKite_"), ]