diff --git a/irc.py b/irc.py index 3d3e998..6f8ea87 100644 --- a/irc.py +++ b/irc.py @@ -20,7 +20,7 @@ from generic import PesterList from version import _pcVersion from oyoyo import services -from oyoyo.parse import parse_raw_irc_command +from oyoyo.ircevents import numeric_events import scripts.irc.outgoing @@ -99,7 +99,6 @@ class PesterIRC(QtCore.QThread): self.host = self.config.server() self.port = self.config.port() self.ssl = self.config.ssl() - self.nick = self.mainwindow.profile().handle self.timeout = 120 self.blocking = True self._end = False @@ -107,7 +106,7 @@ class PesterIRC(QtCore.QThread): self.command_handler = self self.parent = self - self.send_irc = scripts.irc.outgoing.send_irc() + self.send_irc = scripts.irc.outgoing.SendIRC() def get_ssl_context(self): """Returns an SSL context for connecting over SSL/TLS. @@ -176,99 +175,42 @@ class PesterIRC(QtCore.QThread): elif self.blocking: self.socket.setblocking(True) - self.send_irc.nick(self.nick) + self.send_irc.nick(self.mainwindow.profile().handle) self.send_irc.user("pcc31", "pcc31") # if self.connect_cb: # self.connect_cb(self) - def conn(self): + def conn_generator(self): """returns a generator object.""" try: buffer = b"" while not self._end: - # Block for connection-killing exceptions try: - tries = 1 - while tries < 10: - # Check if alive - if self._end == True: - break - if self.socket.fileno() == -1: - self._end = True - break - try: - ready_to_read, ready_to_write, in_error = select.select( - [self.socket], [], [] - ) - for x in ready_to_read: - buffer += x.recv(1024) - break - except ssl.SSLWantReadError as e: - PchumLog.warning("ssl.SSLWantReadError on send, " + str(e)) - select.select([self.socket], [], []) - if tries >= 9: - raise e - except ssl.SSLWantWriteError as e: - PchumLog.warning("ssl.SSLWantWriteError on send, " + str(e)) - select.select([], [self.socket], []) - if tries >= 9: - raise e - except ssl.SSLEOFError as e: - # ssl.SSLEOFError guarantees a broken connection. - PchumLog.warning("ssl.SSLEOFError in on send, " + str(e)) - raise e - except (socket.timeout, TimeoutError) as e: - # socket.timeout is deprecated in 3.10 - PchumLog.warning("TimeoutError in on send, " + str(e)) - raise socket.timeout - except (OSError, IndexError, ValueError, Exception) as e: - PchumLog.debug("Miscellaneous exception in conn, " + str(e)) - if tries >= 9: - raise e - tries += 1 - PchumLog.debug( - "Possibly retrying recv. (attempt %s)" % str(tries) - ) - time.sleep(0.1) - - except socket.timeout as e: - PchumLog.warning("timeout in client.py, " + str(e)) - if self._end: - break - raise e - except ssl.SSLEOFError as e: - raise e + buffer += self.socket.recv(1024) except OSError as e: PchumLog.warning("conn exception {} in {}".format(e, self)) if self._end: break - if not self.blocking and e.errno == 11: - pass - else: - raise e + raise e else: if self._end: break - if len(buffer) == 0 and self.blocking: + if not buffer and self.blocking: PchumLog.debug("len(buffer) = 0") raise OSError("Connection closed") - - data = buffer.split(bytes("\n", "UTF-8")) - buffer = data.pop() - - PchumLog.debug("data = " + str(data)) - - for el in data: - tags, prefix, command, args = parse_raw_irc_command(el) + + data, buffer = self.parse_buffer(buffer) + for line in data: + tags, prefix, command, args = self.parse_irc_line(line) # print(tags, prefix, command, args) try: # Only need tags with tagmsg - if command.upper() == "TAGMSG": + if command.casefold() == "tagmsg": self.run_command(command, prefix, tags, *args) else: self.run_command(command, prefix, *args) except CommandError as e: - PchumLog.warning("CommandError %s" % str(e)) + PchumLog.warning(f"CommandError: {e}") yield True except socket.timeout as se: @@ -281,7 +223,7 @@ class PesterIRC(QtCore.QThread): self.socket.close() raise se except Exception as e: - PchumLog.debug("other exception: %s" % str(e)) + PchumLog.exception("Non-socket related exception in conn_generator().") raise e else: PchumLog.debug("ending while, end is %s" % self._end) @@ -290,6 +232,61 @@ class PesterIRC(QtCore.QThread): self.socket.close() yield False + def parse_buffer(self, buffer): + """Parse lines from bytes buffer, returns lines and emptied buffer.""" + try: + decoded_buffer = buffer.decode(encoding="utf-8") + except UnicodeDecodeError as exception: + PchumLog.warning(f"Failed to decode with utf-8, falling back to latin-1.") + try: + decoded_buffer = buffer.decode(encoding="latin-1") + except ValueError as exception: + PchumLog.warning("latin-1 failed too xd") + return "", buffer # throw it back in the cooker + + data = decoded_buffer.split("\r\n") + if data[-1]: + # Last entry has incomplete data, add back to buffer + buffer = data[-1].encode("utf-8") + buffer + return data[:-1], buffer + + def parse_irc_line(self, line: str): + parts = line.split(" ") + tags = None + prefix = None + print(line) + if parts[0].startswith(":"): + prefix = parts[0][1:] + command = parts[1] + args = parts[2:] + elif parts[0].startswith("@"): + tags = parts[0] # IRCv3 message tag + prefix = parts[1][1:] + command = parts[2] + args = parts[3:] + else: + command = parts[0] + args = parts[1:] + + if command.isdigit(): + try: + command = numeric_events[command] + except KeyError: + PchumLog.info("Server send unknown numeric event {command}.") + command = command.lower() + + # If ':' is present the subsequent args are one parameter. + fused_args = [] + for idx, arg in enumerate(args): + if arg.startswith(":"): + final_param = ' '.join(args[idx:]) + fused_args.append(final_param[1:]) + break + else: + fused_args.append(arg) + + return (tags, prefix, command, fused_args) + def close(self): # with extreme prejudice if self.socket: @@ -316,7 +313,7 @@ class PesterIRC(QtCore.QThread): # Ask if users wants to connect anyway self.askToConnect.emit(e) raise e - self.conn = self.conn() + self.conn = self.conn_generator() def run(self): try: @@ -367,15 +364,47 @@ class PesterIRC(QtCore.QThread): except (OSError, ValueError, IndexError) as se: raise se except StopIteration: - self.conn = self.conn() + self.conn = self.conn_generator() return True else: return res @QtCore.pyqtSlot(PesterProfile) def getMood(self, *chums): - if hasattr(self, "cli"): - self.command_handler.getMood(*chums) + """Get mood via metadata if supported""" + + # Get via metadata or via legacy method + if self.parent.metadata_supported: + # Metadata + for chum in chums: + try: + self.send_irc.metadata(chum.handle, "get", "mood") + except OSError as e: + PchumLog.warning(e) + self.parent.setConnectionBroken() + else: + # Legacy + PchumLog.warning( + "Server doesn't seem to support metadata, using legacy GETMOOD." + ) + chumglub = "GETMOOD " + for chum in chums: + if len(chumglub + chum.handle) >= 350: + try: + self.send_irc.msg("#pesterchum", chumglub) + except OSError as e: + PchumLog.warning(e) + self.parent.setConnectionBroken() + chumglub = "GETMOOD " + # No point in GETMOOD-ing services + if chum.handle.casefold() not in SERVICES: + chumglub += chum.handle + if chumglub != "GETMOOD ": + try: + self.send_irc.msg("#pesterchum", chumglub) + except OSError as e: + PchumLog.warning(e) + self.parent.setConnectionBroken() @QtCore.pyqtSlot(PesterList) def getMoods(self, chums): @@ -462,8 +491,7 @@ class PesterIRC(QtCore.QThread): if hasattr(self, "cli"): h = str(handle) try: - self.send_irc.msg( - self, h, "COLOR >%s" % (self.mainwindow.profile().colorcmd()) + self.send_irc.msg(h, "COLOR >%s" % (self.mainwindow.profile().colorcmd()) ) if initiated: self.send_irc.msg(h, "PESTERCHUM:BEGIN") @@ -530,7 +558,6 @@ class PesterIRC(QtCore.QThread): for h in list(self.mainwindow.convos.keys()): try: self.send_irc.msg( - self, h, "COLOR >%s" % (self.mainwindow.profile().colorcmd()), ) @@ -766,13 +793,11 @@ class PesterIRC(QtCore.QThread): PchumLog.info('---> recv "CTCP {} :{}"'.format(handle, msg[1:-1])) # VERSION, return version if msg[1:-1].startswith("VERSION"): - self.send_irc.ctcp_reply( - self.parent.cli, handle, "VERSION", "Pesterchum %s" % (_pcVersion) + self.send_irc.ctcp(handle, "VERSION", "Pesterchum %s" % (_pcVersion) ) # CLIENTINFO, return supported CTCP commands. elif msg[1:-1].startswith("CLIENTINFO"): - self.send_irc.ctcp_reply( - self.parent.cli, + self.send_irc.ctcp( handle, "CLIENTINFO", "ACTION VERSION CLIENTINFO PING SOURCE NOQUIRKS GETMOOD", @@ -780,15 +805,13 @@ class PesterIRC(QtCore.QThread): # PING, return pong elif msg[1:-1].startswith("PING"): if len(msg[1:-1].split("PING ")) > 1: - self.send_irc.ctcp_reply( - self.parent.cli, handle, "PING", msg[1:-1].split("PING ")[1] + self.send_irc.ctcp(handle, "PING", msg[1:-1].split("PING ")[1] ) else: - self.send_irc.ctcp_reply(self.parent.cli, handle, "PING") + self.send_irc.ctcp(handle, "PING") # SOURCE, return source elif msg[1:-1].startswith("SOURCE"): - self.send_irc.ctcp_reply( - self.parent.cli, + self.send_irc.ctcp( handle, "SOURCE", "https://github.com/Dpeta/pesterchum-alt-servers", @@ -802,7 +825,7 @@ class PesterIRC(QtCore.QThread): # GETMOOD via CTCP # Maybe we can do moods like this in the future... mymood = self.mainwindow.profile().mood.value() - self.send_irc.ctcp_reply(self.parent.cli, handle, "MOOD >%d" % (mymood)) + self.send_irc.ctcp(handle, "MOOD >%d" % (mymood)) # Backwards compatibility self.send_irc.msg("#pesterchum", "MOOD >%d" % (mymood)) return @@ -1237,42 +1260,6 @@ class PesterIRC(QtCore.QThread): """Respond to server PING with PONG.""" self.send_irc.pong(token) - def getMood(self, *chums): - """Get mood via metadata if supported""" - - # Get via metadata or via legacy method - if self.parent.metadata_supported: - # Metadata - for chum in chums: - try: - self.send_irc.metadata(chum.handle, "get", "mood") - except OSError as e: - PchumLog.warning(e) - self.parent.setConnectionBroken() - else: - # Legacy - PchumLog.warning( - "Server doesn't seem to support metadata, using legacy GETMOOD." - ) - chumglub = "GETMOOD " - for chum in chums: - if len(chumglub + chum.handle) >= 350: - try: - self.send_irc.msg("#pesterchum", chumglub) - except OSError as e: - PchumLog.warning(e) - self.parent.setConnectionBroken() - chumglub = "GETMOOD " - # No point in GETMOOD-ing services - if chum.handle.casefold() not in SERVICES: - chumglub += chum.handle - if chumglub != "GETMOOD ": - try: - self.send_irc.msg("#pesterchum", chumglub) - except OSError as e: - PchumLog.warning(e) - self.parent.setConnectionBroken() - def get(self, in_command_parts): PchumLog.debug("in_command_parts: %s" % in_command_parts) """ finds a command @@ -1310,10 +1297,7 @@ class PesterIRC(QtCore.QThread): def run_command(self, command, *args): """finds and runs a command""" - arguments_str = "" - for x in args: - arguments_str += str(x) + " " - PchumLog.debug("processCommand {}({})".format(command, arguments_str.strip())) + PchumLog.debug("processCommand {}({})".format(command, args)) try: f = self.get(command) diff --git a/oyoyo/client.py b/oyoyo/client.py deleted file mode 100644 index ed5411c..0000000 --- a/oyoyo/client.py +++ /dev/null @@ -1,489 +0,0 @@ -# Copyright (c) 2008 Duncan Fordyce -# 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 sys -import time -import ssl -import socket -import select -import logging -import datetime -import traceback - -from oyoyo.parse import parse_raw_irc_command -from oyoyo import helpers -from oyoyo.cmdhandler import CommandError - -PchumLog = logging.getLogger("pchumLogger") - -try: - import certifi -except ImportError: - if sys.platform == "darwin": - # Certifi is required to validate certificates on MacOS with pyinstaller builds. - PchumLog.warning( - "Failed to import certifi, which is recommended on MacOS. " - "Pesterchum might not be able to validate certificates unless " - "Python's root certs are installed." - ) - else: - PchumLog.info( - "Failed to import certifi, Pesterchum will not be able to validate " - "certificates if the system-provided root certificates are invalid." - ) - - -class IRCClientError(Exception): - pass - - -class IRCClient: - """IRC Client class. This handles one connection to a server. - This can be used either with or without IRCApp ( see connect() docs ) - """ - - def __init__(self, cmd_handler, **kwargs): - """the first argument should be an object with attributes/methods named - as the irc commands. You may subclass from one of the classes in - oyoyo.cmdhandler for convenience but it is not required. The - methods should have arguments (prefix, args). prefix is - normally the sender of the command. args is a list of arguments. - Its recommened you subclass oyoyo.cmdhandler.DefaultCommandHandler, - this class provides defaults for callbacks that are required for - normal IRC operation. - - all other arguments should be keyword arguments. The most commonly - used will be nick, host and port. You can also specify an "on connect" - callback. ( check the source for others ) - - Warning: By default this class will not block on socket operations, this - means if you use a plain while loop your app will consume 100% cpu. - To enable blocking pass blocking=True. - - >>> class My_Handler(DefaultCommandHandler): - ... def privmsg(self, prefix, command, args): - ... print "%s said %s" % (prefix, args[1]) - ... - >>> def connect_callback(c): - ... helpers.join(c, '#myroom') - ... - >>> cli = IRCClient(My_Handler, - ... host="irc.freenode.net", - ... port=6667, - ... nick="myname", - ... connect_cb=connect_callback) - ... - >>> cli_con = cli.connect() - >>> while 1: - ... cli_con.next() - ... - """ - - self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - - self.nick = None - self.realname = None - self.username = None - self.host = None - self.port = None - # self.connect_cb = None - self.timeout = None - self.blocking = None - self.ssl = None - - self.__dict__.update(kwargs) - self.command_handler = cmd_handler(self) - - self._end = False - - def send(self, *args, **kwargs): - """send a message to the connected server. all arguments are joined - with a space for convenience, for example the following are identical - - >>> cli.send("JOIN %s" % some_room) - >>> cli.send("JOIN", some_room) - - In python 2, all args must be of type str or unicode, *BUT* if they are - unicode they will be converted to str with the encoding specified by - the 'encoding' keyword argument (default 'utf8'). - In python 3, all args must be of type str or bytes, *BUT* if they are - str they will be converted to bytes with the encoding specified by the - 'encoding' keyword argument (default 'utf8'). - """ - if self._end == True: - return - # Convert all args to bytes if not already - encoding = kwargs.get("encoding") or "utf8" - bargs = [] - for arg in args: - if isinstance(arg, str): - bargs.append(bytes(arg, encoding)) - elif isinstance(arg, bytes): - bargs.append(arg) - elif type(arg).__name__ == "unicode": - bargs.append(arg.encode(encoding)) - else: - PchumLog.warning( - "Refusing to send one of the args from provided: %s" - % repr([(type(arg), arg) for arg in args]) - ) - raise IRCClientError( - "Refusing to send one of the args from provided: %s" - % repr([(type(arg), arg) for arg in args]) - ) - - msg = bytes(" ", "UTF-8").join(bargs) - PchumLog.info('---> send "%s"' % msg) - try: - tries = 1 - while tries < 10: - # Check if alive - if self._end == True: - break - if self.socket.fileno() == -1: - self._end = True - break - try: - ready_to_read, ready_to_write, in_error = select.select( - [], [self.socket], [] - ) - for x in ready_to_write: - x.sendall(msg + bytes("\r\n", "UTF-8")) - break - except ssl.SSLWantReadError as e: - PchumLog.warning("ssl.SSLWantReadError on send, " + str(e)) - select.select([self.socket], [], []) - if tries >= 9: - raise e - except ssl.SSLWantWriteError as e: - PchumLog.warning("ssl.SSLWantWriteError on send, " + str(e)) - select.select([], [self.socket], []) - if tries >= 9: - raise e - except ssl.SSLEOFError as e: - # ssl.SSLEOFError guarantees a broken connection. - PchumLog.warning("ssl.SSLEOFError in on send, " + str(e)) - raise ssl.SSLEOFError - except (socket.timeout, TimeoutError) as e: - # socket.timeout is deprecated in 3.10 - PchumLog.warning("TimeoutError in on send, " + str(e)) - raise socket.timeout - except (OSError, IndexError, ValueError, Exception) as e: - PchumLog.warning("Unkown error on send, " + str(e)) - if tries >= 9: - raise e - tries += 1 - PchumLog.warning("Retrying send. (attempt %s)" % str(tries)) - time.sleep(0.1) - - PchumLog.debug( - "ready_to_write (len %s): " % str(len(ready_to_write)) - + str(ready_to_write) - ) - except Exception as se: - PchumLog.warning("Send Exception %s" % str(se)) - try: - if not self.blocking and se.errno == 11: - pass - else: - # raise se - self._end = True # This ok? - except AttributeError: - # raise se - self._end = True # This ok? - - def get_ssl_context(self): - """Returns an SSL context for connecting over SSL/TLS. - Loads the certifi root certificate bundle if the certifi module is less - than a year old or if the system certificate store is empty. - - The cert store on Windows also seems to have issues, so it's better - to use the certifi provided bundle assuming it's a recent version. - - On MacOS the system cert store is usually empty, as Python does not use - the system provided ones, instead relying on a bundle installed with the - python installer.""" - default_context = ssl.create_default_context() - if "certifi" not in sys.modules: - return default_context - - # Get age of certifi module - certifi_date = datetime.datetime.strptime(certifi.__version__, "%Y.%m.%d") - current_date = datetime.datetime.now() - certifi_age = current_date - certifi_date - - empty_cert_store = ( - list(default_context.cert_store_stats().values()).count(0) == 3 - ) - # 31557600 seconds is approximately 1 year - if empty_cert_store or certifi_age.total_seconds() <= 31557600: - PchumLog.info( - "Using SSL/TLS context with certifi-provided root certificates." - ) - return ssl.create_default_context(cafile=certifi.where()) - PchumLog.info("Using SSL/TLS context with system-provided root certificates.") - return default_context - - def connect(self, verify_hostname=True): - """initiates the connection to the server set in self.host:self.port - self.ssl decides whether the connection uses ssl. - - Certificate validation when using SSL/TLS may be disabled by - passing the 'verify_hostname' parameter. The user is asked if they - want to disable it if this functions raises a certificate validation error, - in which case the function may be called again with 'verify_hostname'.""" - PchumLog.info("connecting to {}:{}".format(self.host, self.port)) - - # Open connection - plaintext_socket = socket.create_connection((self.host, self.port)) - - if self.ssl: - # Upgrade connection to use SSL/TLS if enabled - context = self.get_ssl_context() - context.check_hostname = verify_hostname - self.socket = context.wrap_socket( - plaintext_socket, server_hostname=self.host - ) - else: - # SSL/TLS is disabled, connection is plaintext - self.socket = plaintext_socket - - # setblocking is a shorthand for timeout, - # we shouldn't use both. - if self.timeout: - self.socket.settimeout(self.timeout) - elif not self.blocking: - self.socket.setblocking(False) - elif self.blocking: - self.socket.setblocking(True) - - helpers.nick(self, self.nick) - helpers.user(self, self.username, self.realname) - # if self.connect_cb: - # self.connect_cb(self) - - def conn(self): - """returns a generator object.""" - try: - buffer = b"" - while not self._end: - # Block for connection-killing exceptions - try: - tries = 1 - while tries < 10: - # Check if alive - if self._end == True: - break - if self.socket.fileno() == -1: - self._end = True - break - try: - ready_to_read, ready_to_write, in_error = select.select( - [self.socket], [], [] - ) - for x in ready_to_read: - buffer += x.recv(1024) - break - except ssl.SSLWantReadError as e: - PchumLog.warning("ssl.SSLWantReadError on send, " + str(e)) - select.select([self.socket], [], []) - if tries >= 9: - raise e - except ssl.SSLWantWriteError as e: - PchumLog.warning("ssl.SSLWantWriteError on send, " + str(e)) - select.select([], [self.socket], []) - if tries >= 9: - raise e - except ssl.SSLEOFError as e: - # ssl.SSLEOFError guarantees a broken connection. - PchumLog.warning("ssl.SSLEOFError in on send, " + str(e)) - raise e - except (socket.timeout, TimeoutError) as e: - # socket.timeout is deprecated in 3.10 - PchumLog.warning("TimeoutError in on send, " + str(e)) - raise socket.timeout - except (OSError, IndexError, ValueError, Exception) as e: - PchumLog.debug("Miscellaneous exception in conn, " + str(e)) - if tries >= 9: - raise e - tries += 1 - PchumLog.debug( - "Possibly retrying recv. (attempt %s)" % str(tries) - ) - time.sleep(0.1) - - except socket.timeout as e: - PchumLog.warning("timeout in client.py, " + str(e)) - if self._end: - break - raise e - except ssl.SSLEOFError as e: - raise e - except OSError as e: - PchumLog.warning("conn exception {} in {}".format(e, self)) - if self._end: - break - if not self.blocking and e.errno == 11: - pass - else: - raise e - else: - if self._end: - break - if len(buffer) == 0 and self.blocking: - PchumLog.debug("len(buffer) = 0") - raise OSError("Connection closed") - - data = buffer.split(bytes("\n", "UTF-8")) - buffer = data.pop() - - PchumLog.debug("data = " + str(data)) - - for el in data: - tags, prefix, command, args = parse_raw_irc_command(el) - # print(tags, prefix, command, args) - try: - # Only need tags with tagmsg - if command.upper() == "TAGMSG": - self.command_handler.run(command, prefix, tags, *args) - else: - self.command_handler.run(command, prefix, *args) - except CommandError as e: - PchumLog.warning("CommandError %s" % str(e)) - - yield True - except socket.timeout as se: - PchumLog.debug("passing timeout") - raise se - except (OSError, ssl.SSLEOFError) as se: - PchumLog.debug("problem: %s" % (str(se))) - if self.socket: - PchumLog.info("error: closing socket") - self.socket.close() - raise se - except Exception as e: - PchumLog.debug("other exception: %s" % str(e)) - raise e - else: - PchumLog.debug("ending while, end is %s" % self._end) - if self.socket: - PchumLog.info("finished: closing socket") - self.socket.close() - yield False - - def close(self): - # with extreme prejudice - if self.socket: - PchumLog.info("shutdown socket") - # print("shutdown socket") - self._end = True - try: - self.socket.shutdown(socket.SHUT_RDWR) - except OSError as e: - PchumLog.debug( - "Error while shutting down socket, already broken? %s" % str(e) - ) - try: - self.socket.close() - except OSError as e: - PchumLog.debug( - "Error while closing socket, already broken? %s" % str(e) - ) - - -class IRCApp: - """This class manages several IRCClient instances without the use of threads. - (Non-threaded) Timer functionality is also included. - """ - - class _ClientDesc: - def __init__(self, **kwargs): - self.con = None - self.autoreconnect = False - self.__dict__.update(kwargs) - - def __init__(self): - self._clients = {} - self._timers = [] - self.running = False - self.sleep_time = 0.5 - - def addClient(self, client, autoreconnect=False): - """add a client object to the application. setting autoreconnect - to true will mean the application will attempt to reconnect the client - after every disconnect. you can also set autoreconnect to a number - to specify how many reconnects should happen. - - warning: if you add a client that has blocking set to true, - timers will no longer function properly""" - PchumLog.info("added client {} (ar={})".format(client, autoreconnect)) - self._clients[client] = self._ClientDesc(autoreconnect=autoreconnect) - - def addTimer(self, seconds, cb): - """add a timed callback. accuracy is not specified, you can only - garuntee the callback will be called after seconds has passed. - ( the only advantage to these timers is they dont use threads ) - """ - assert callable(cb) - PchumLog.info("added timer to call {} in {}s".format(cb, seconds)) - self._timers.append((time.time() + seconds, cb)) - - def run(self): - """run the application. this will block until stop() is called""" - # TODO: convert this to use generators too? - self.running = True - while self.running: - found_one_alive = False - - for client, clientdesc in self._clients.items(): - if clientdesc.con is None: - clientdesc.con = client.connect() - - try: - next(clientdesc.con) - except Exception as e: - PchumLog.error("client error %s" % str(e)) - PchumLog.error(traceback.format_exc()) - if clientdesc.autoreconnect: - clientdesc.con = None - if isinstance(clientdesc.autoreconnect, (int, float)): - clientdesc.autoreconnect -= 1 - found_one_alive = True - else: - clientdesc.con = False - else: - found_one_alive = True - - if not found_one_alive: - PchumLog.info("nothing left alive... quiting") - self.stop() - - now = time.time() - timers = self._timers[:] - self._timers = [] - for target_time, cb in timers: - if now > target_time: - PchumLog.info("calling timer cb %s" % cb) - cb() - else: - self._timers.append((target_time, cb)) - - time.sleep(self.sleep_time) - - def stop(self): - """stop the application""" - self.running = False diff --git a/scripts/irc/outgoing.py b/scripts/irc/outgoing.py index 942e1c5..ef7a044 100644 --- a/scripts/irc/outgoing.py +++ b/scripts/irc/outgoing.py @@ -1,16 +1,14 @@ """Class and functions for sending outgoing IRC commands.""" -import socket import logging log = logging.getLogger("pchumLogger") -class send_irc: +class SendIRC: """Provides functions for outgoing IRC commands.""" def __init__(self): self.socket = None # INET socket connected with server. - self._end = None # Keep track of if we're disconnected. def send(self, *args: str, text=None): """Send a command to the IRC server. @@ -19,7 +17,9 @@ class send_irc: The 'text' argument is for the final parameter, which can have spaces.""" # Return if disconnected if not self.socket or self.socket.fileno() == -1: - log.error(f"Send attempted while disconnected, args: {args}, text: {text}.") + log.error( + "Send attempted while disconnected, args: %s, text: %s.", args, text + ) return command = "" @@ -35,17 +35,17 @@ class send_irc: outgoing_bytes = command.encode(encoding="utf-8", errors="replace") try: - log.debug(f"Sending: {command}") + log.debug("Sending: %s", command) self.socket.send(outgoing_bytes) except OSError: - log.exception(f"Error while sending: '{command.strip()}'") + log.exception("Error while sending: '%s'", command.strip()) self.socket.close() - def ping(self, token: str): + def ping(self, token): """Send PING command to server to check for connectivity.""" self.send("PING", text=token) - def pong(self, token: str): + def pong(self, token): """Send PONG command to reply to server PING.""" self.send("PONG", token) @@ -59,10 +59,10 @@ class send_irc: def msg(self, target, text): """Send PRIVMSG command to send a message.""" - for line in msg.split("\n"): - self.send("PRIVMSG", target, text=text) + for line in text.split("\n"): + self.send("PRIVMSG", target, text=line) - def names(self, channel: str): + def names(self, channel): """Send NAMES command to view channel members.""" self.send("NAMES", channel) @@ -80,37 +80,43 @@ class send_irc: def ctcp(self, target, command, msg=""): """Send Client-to-Client Protocol message.""" - outgoing_ctcp = " ".join([command, msg]).strip() # Extra spaces break protocol, so strip. + outgoing_ctcp = " ".join( + [command, msg] + ).strip() # Extra spaces break protocol, so strip. self.msg(target, f"\x01{outgoing_ctcp}\x01") - def metadata(self, target: str, subcommand: str, *params: str): - # IRC metadata draft specification - # https://gist.github.com/k4bek4be/92c2937cefd49990fbebd001faf2b237 + def metadata(self, target, subcommand, *params): + """Send Metadata command to get or set metadata. + + See IRC metadata draft specification: + https://gist.github.com/k4bek4be/92c2937cefd49990fbebd001faf2b237 + """ self.send("METADATA", target, subcommand, *params) - def cap(self, subcommand: str, *params: str): - # Capability Negotiation - # https://ircv3.net/specs/extensions/capability-negotiation.html + def cap(self, subcommand, *params): + """Send IRCv3 CAP command for capability negotiation. + + See: https://ircv3.net/specs/extensions/capability-negotiation.html""" self.send("CAP", subcommand, *params) - def join(self, channel: str, key=""): + def join(self, channel, key=""): """Send JOIN command to join a channel/memo. Keys or joining multiple channels is possible in the specification, but unused.""" channel_and_key = " ".join([channel, key]).strip() self.send("JOIN", channel_and_key) - def part(self, channel: str): + def part(self, channel): """Send PART command to leave a channel/memo. Providing a reason or leaving multiple channels is possible in the specification.""" self.send("PART", channel) - def notice(self, target: str, text: str): + def notice(self, target, text): """Send a NOTICE to a user or channel.""" self.send("NOTICE", target, text=text) - def invite(self, nick: str, channel: str): + def invite(self, nick, channel): """Send INVITE command to invite a user to a channel.""" self.send("INVITE", nick, channel) @@ -122,7 +128,3 @@ class send_irc: self.send("AWAY", text=text) else: self.send("AWAY") - - def banana(self): - """Do you want a banana? diz bananan 4 u""" - self.join("#banana_kingdom", key="Shoplift8723")