diff --git a/.gitignore b/.gitignore index 9eca4e8..6d69eac 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,6 @@ msgs.txt trusted.txt gods.txt startcmd.txt -*.pyc ircbot.sh -*.swp +__pycache__ +bot.conf diff --git a/CC0 b/CC0 new file mode 100644 index 0000000..670154e --- /dev/null +++ b/CC0 @@ -0,0 +1,116 @@ +CC0 1.0 Universal + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator and +subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for the +purpose of contributing to a commons of creative, cultural and scientific +works ("Commons") that the public can reliably and without fear of later +claims of infringement build upon, modify, incorporate in other works, reuse +and redistribute as freely as possible in any form whatsoever and for any +purposes, including without limitation commercial purposes. These owners may +contribute to the Commons to promote the ideal of a free culture and the +further production of creative, cultural and scientific works, or to gain +reputation or greater distribution for their Work in part through the use and +efforts of others. + +For these and/or other purposes and motivations, and without any expectation +of additional consideration or compensation, the person associating CC0 with a +Work (the "Affirmer"), to the extent that he or she is an owner of Copyright +and Related Rights in the Work, voluntarily elects to apply CC0 to the Work +and publicly distribute the Work under its terms, with knowledge of his or her +Copyright and Related Rights in the Work and the meaning and intended legal +effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not limited +to, the following: + + i. the right to reproduce, adapt, distribute, perform, display, communicate, + and translate a Work; + + ii. moral rights retained by the original author(s) and/or performer(s); + + iii. publicity and privacy rights pertaining to a person's image or likeness + depicted in a Work; + + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + + v. rights protecting the extraction, dissemination, use and reuse of data in + a Work; + + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation thereof, + including any amended or successor version of such directive); and + + vii. other similar, equivalent or corresponding rights throughout the world + based on applicable law or treaty, and any national implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention of, +applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and +unconditionally waives, abandons, and surrenders all of Affirmer's Copyright +and Related Rights and associated claims and causes of action, whether now +known or unknown (including existing as well as future claims and causes of +action), in the Work (i) in all territories worldwide, (ii) for the maximum +duration provided by applicable law or treaty (including future time +extensions), (iii) in any current or future medium and for any number of +copies, and (iv) for any purpose whatsoever, including without limitation +commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes +the Waiver for the benefit of each member of the public at large and to the +detriment of Affirmer's heirs and successors, fully intending that such Waiver +shall not be subject to revocation, rescission, cancellation, termination, or +any other legal or equitable action to disrupt the quiet enjoyment of the Work +by the public as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason be +judged legally invalid or ineffective under applicable law, then the Waiver +shall be preserved to the maximum extent permitted taking into account +Affirmer's express Statement of Purpose. In addition, to the extent the Waiver +is so judged Affirmer hereby grants to each affected person a royalty-free, +non transferable, non sublicensable, non exclusive, irrevocable and +unconditional license to exercise Affirmer's Copyright and Related Rights in +the Work (i) in all territories worldwide, (ii) for the maximum duration +provided by applicable law or treaty (including future time extensions), (iii) +in any current or future medium and for any number of copies, and (iv) for any +purpose whatsoever, including without limitation commercial, advertising or +promotional purposes (the "License"). The License shall be deemed effective as +of the date CC0 was applied by Affirmer to the Work. Should any part of the +License for any reason be judged legally invalid or ineffective under +applicable law, such partial invalidity or ineffectiveness shall not +invalidate the remainder of the License, and in such case Affirmer hereby +affirms that he or she will not (i) exercise any of his or her remaining +Copyright and Related Rights in the Work or (ii) assert any associated claims +and causes of action with respect to the Work, in either case contrary to +Affirmer's express Statement of Purpose. + +4. Limitations and Disclaimers. + + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + + b. Affirmer offers the Work as-is and makes no representations or warranties + of any kind concerning the Work, express, implied, statutory or otherwise, + including without limitation warranties of title, merchantability, fitness + for a particular purpose, non infringement, or the absence of latent or + other defects, accuracy, or the present or absence of errors, whether or not + discoverable, all to the greatest extent permissible under applicable law. + + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without limitation + any person's Copyright and Related Rights in the Work. Further, Affirmer + disclaims responsibility for obtaining any necessary consents, permissions + or other rights required for any use of the Work. + + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to this + CC0 or use of the Work. + +For more information, please see + diff --git a/bot.conf.example b/bot.conf.example new file mode 100644 index 0000000..20233d2 --- /dev/null +++ b/bot.conf.example @@ -0,0 +1,7 @@ +[server] +host = irc.freenode.net +port = 6667 +nick = oonbotti2 +username = oonbotti2 +realname = oonbotti2 +channels = ##ingsoc diff --git a/botcmd.py b/botcmd.py new file mode 100644 index 0000000..217ab41 --- /dev/null +++ b/botcmd.py @@ -0,0 +1,42 @@ +# initialize(*, config) +# Called to initialize the IRC bot +# Runs before even logger is brought up, and blocks further bringup until it's done +# config is a configpatser.ConfigParser object containig contents of bot.conf +def initialize(*, config): + ... + +# on_connect(*, irc) +# Called after IRC bot has connected and sent the USER/NICk commands but not yet attempted anything else +# Called for every reconnect +# Blocks the bot until it's done, including PING/PONG handling +# irc is the IRC API object +def on_connect(*, irc): + ... + +# on_quit(*, irc) +# Called just before IRC bot sends QUIT +# Blocks the bot until it's done, including PING/PONG handling +# irc is the IRC API object +def on_quit(*, irc): + ... + +# handle_message(*, prefix, message, nick, channel, irc) +# Called for PRIVMSGs. +# prefix is the prefix at the start of the message, without the leading ':' +# message is the contents of the message +# nick is who sent the message +# channel is where you should send the response (note: in queries nick == channel) +# irc is the IRC API object +# All strings are bytestrings +def handle_message(*, prefix, message, nick, channel, irc): + ... + +# handle_nonmessage(*, prefix, command, arguments, irc) +# Called for all other commands than PINGs and PRIVMSGs. +# prefix is the prefix at the start of the message, without the leading ':' +# command is the command or number code +# arguments is rest of the arguments of the command, represented as a list. ':'-arguments are handled automatically +# irc is the IRC API object +# All strings are bytestrings +def handle_nonmessage(*, prefix, command, arguments, irc): + ... diff --git a/channel.py b/channel.py new file mode 100644 index 0000000..2c754ad --- /dev/null +++ b/channel.py @@ -0,0 +1,63 @@ +import select +import socket +import threading + +class Channel: + """An asynchronic communication channel that can be used to send python object and can be poll()ed.""" + + def __init__(self): + # We use a socket to enable polling and blocking reads + self.write_socket, self.read_socket = socket.socketpair() + self.poll = select.poll() + self.poll.register(self.read_socket, select.POLLIN) + + # Store messages in a list + self.messages = [] + self.messages_lock = threading.Lock() + + def send(self, message): + # Add message to the list of messages and write to the write socket to signal there's data to read + with self.messages_lock: + self.write_socket.sendall(b'!') + self.messages.append(message) + + def recv(self, blocking = True): + # Timeout of -1 will make poll wait until data is available + # Timeout of 0 will make poll exit immediately if there's no data + if blocking: + timeout = -1 + else: + timeout = 0 + + # See if there is data to read / wait until there is + results = self.poll.poll(timeout) + + # None of the sockets were ready. This can only happen if we weren't blocking + # Return None to signal lack of data + if len(results) == 0: + assert not blocking + return None + + # Remove first message from the list (FIFO principle), and read one byte from the socket + # This keeps the number of available messages and the number of bytes readable in the socket in sync + with self.messages_lock: + message = self.messages.pop(0) + self.read_socket.recv(1) + + return message + + def fileno(self): + # Allows for a Channel object to be passed directly to poll() + return self.read_socket.fileno() + + def close(self): + # Close the file descriptors, so that we aren't leaking them + self.write_socket.close() + self.read_socket.close() + + # Support with-statements + def __enter__(self): + return self + + def __exit__(self, exception_type, exception_value, traceback): + self.close() diff --git a/constants.py b/constants.py new file mode 100644 index 0000000..402a494 --- /dev/null +++ b/constants.py @@ -0,0 +1,13 @@ +import enum + +class logmessage_types(enum.Enum): + sent, received, internal, status = range(4) + +class internal_submessage_types(enum.Enum): + quit, error = range(2) + +class controlmessage_types(enum.Enum): + quit, reconnect, send_line, ping, ping_timeout = range(5) + +class cronmessage_types(enum.Enum): + quit, schedule, delete, reschedule = range(4) diff --git a/cron.py b/cron.py new file mode 100644 index 0000000..8acebbf --- /dev/null +++ b/cron.py @@ -0,0 +1,150 @@ +import select +import threading +import time +from collections import namedtuple + +import channel +from constants import cronmessage_types + +# time field uses the monotonic time returned by time.monotonic() +Event = namedtuple('Event', ['time', 'channel', 'message']) + +class CronThread(threading.Thread): + def __init__(self, cron_control_channel): + self.cron_control_channel = cron_control_channel + + # Sorted array of events + self.events = [] + + threading.Thread.__init__(self) + + def get_timeout_value(self): + if len(self.events) == 0: + # No events, block until we get a message + # Timeout of -1 makes poll block indefinitely + return -1 + + else: + # First event in the array is always the earliest + seconds_to_wait = self.events[0].time - time.monotonic() + + # time.monotonic() uses fractional second but poll uses milliseconds, convert + ms_to_wait = int(seconds_to_wait * 1000) + + # In case we somehow took long enough that next one should be run by now, make it run now + if ms_to_wait < 0: + ms_to_wait = 0 + + return ms_to_wait + + def run_events(self): + assert len(self.events) > 0 + + current_time = time.monotonic() + + # Look for first event that should be run after current time, and split the array there + # index should point at the first to be after current time, or at end of array + # Either way, we can split the array at that location, first part being what to run and second rest + index = 0 + while index < len(self.events): + if self.events[index].time > current_time: + break + index += 1 + + # Split array + to_run = self.events[:index] + self.events = self.events[index:] + + # Run events + for event in to_run: + event.channel.send(event.message) + + def add_event(self, event): + # Look for first event that should be run after event, and split the array there + # index should point at the first to be after event, or at end of array + # Either way, we can split the array at that location safely + index = 0 + while index < len(self.events): + if self.events[index].time > event.time: + break + index += 1 + + self.events = self.events[:index] + [event] + self.events[index:] + + def delete_event(self, event): + # Try to find the element with same channel and message + index = 0 + while index < len(self.events): + if self.events[index].channel == event.channel and self.events[index].message == event.message: + break + index += 1 + + if index < len(self.events): + # The event at index is the one we need to delete + self.events = self.events[:index] + self.events[index + 1:] + + def reschedule_event(self, event): + self.delete_event(event) + self.add_event(event) + + def run(self): + # Create poll object and register the control channel + # The poll object is used to implement both waiting and control of the cron thread + poll = select.poll() + poll.register(self.cron_control_channel, select.POLLIN) + + while True: + timeout = self.get_timeout_value() + poll_result = poll.poll(timeout) + + if len(poll_result) == 0: + # No fds were ready → we timed out. Time to run some events + self.run_events() + + else: + # New message was received, handle it + command_type, *arguments = self.cron_control_channel.recv() + + if command_type == cronmessage_types.quit: + break + + elif command_type == cronmessage_types.schedule: + event, = arguments + self.add_event(event) + + elif command_type == cronmessage_types.delete: + event, = arguments + self.delete_event(event) + + elif command_type == cronmessage_types.reschedule: + event, = arguments + self.reschedule_event(event) + + else: + assert False #unreachable + +def start(): + cron_control_channel = channel.Channel() + CronThread(cron_control_channel).start() + return cron_control_channel + +def quit(cron_control_channel): + """Stop the cron instance""" + cron_control_channel.send((cronmessage_types.quit,)) + +def schedule(cron_control_channel, seconds, channel, message): + """Schedules message to be send to channel""" + event = Event(time.monotonic() + seconds, channel, message) + cron_control_channel.send((cronmessage_types.schedule, event)) + +def delete(cron_control_channel, channel, message): + """Remove an event. If event is not found, this is a no-op. + Matches events based on channel and message, and only applies to the earlier one found.""" + event = Event(None, channel, message) + cron_control_channel.send((cronmessage_types.delete, event)) + +def reschedule(cron_control_channel, seconds, channel, message): + """Reschedules message to be send to channel. If event is not found, a new one is created. + Matches events based on channel and message, and only applies to the earlier one found.""" + event = Event(time.monotonic() + seconds, channel, message) + cron_control_channel.send((cronmessage_types.reschedule, event)) diff --git a/ircbot.py b/ircbot.py new file mode 100644 index 0000000..a5ab9dd --- /dev/null +++ b/ircbot.py @@ -0,0 +1,441 @@ +#!/usr/bin/env python3 +import configparser +import select +import socket +import threading +import time +from collections import namedtuple + +import channel +from constants import logmessage_types, internal_submessage_types, controlmessage_types + +import botcmd +import cron +import line_handling + +Server = namedtuple('Server', ['host', 'port', 'nick', 'username', 'realname', 'channels']) + +class LoggerThread(threading.Thread): + def __init__(self, logging_channel, dead_notify_channel): + self.logging_channel = logging_channel + self.dead_notify_channel = dead_notify_channel + + threading.Thread.__init__(self) + + def run(self): + while True: + message_type, *message_data = self.logging_channel.recv() + + # Lines that were sent between server and client + if message_type == logmessage_types.sent: + assert len(message_data) == 1 + print('>' + message_data[0]) + + elif message_type == logmessage_types.received: + assert len(message_data) == 1 + print('<' + message_data[0]) + + # Messages that are from internal components + elif message_type == logmessage_types.internal: + if message_data[0] == internal_submessage_types.quit: + assert len(message_data) == 1 + print('--- Quit') + + self.dead_notify_channel.send((controlmessage_types.quit,)) + break + + elif message_data[0] == internal_submessage_types.error: + assert len(message_data) == 2 + print('--- Error', message_data[1]) + + else: + print('--- ???', message_data) + + # Messages about status from the bot code + elif message_type == logmessage_types.status: + assert len(message_data) == 2 + print('*', end='') + print(*message_data[0], **message_data[1]) + + else: + print('???', message_type, message_data) + +# API(serverthread_object) +# Create a new API object corresponding to given ServerThread object +class API: + def __init__(self, serverthread_object): + # We need to access the internal functions of the ServerThread object in order to send lines etc. + self.serverthread_object = serverthread_object + + # Have the cron object accessible more easily + self.cron = serverthread_object.cron_control_channel + + def send_raw(self, line): + """Sends a raw line (will terminate it itself.) + Don't use unless you are completely sure you know what you're doing.""" + self.serverthread_object.send_line_raw(line) + + def msg(self, recipient, message): + """Make sending PRIVMSGs much nicer""" + line = b'PRIVMSG ' + recipient + b' :' + message + self.serverthread_object.send_line_raw(line) + + def bot_response(self, recipient, message): + """Prefix message with ZWSP and convert from unicode to bytestring if necessary.""" + if isinstance(message, str): + message = message.encode('utf-8') + + self.msg(recipient, '\u200b'.encode('utf-8') + message) + + def nick(self, nick): + """Send a NICK command and update the internal nick tracking state""" + with self.serverthread_object.nick_lock: + line = b'NICK ' + nick + self.serverthread_object.send_line_raw(line) + self.serverthread_object.nick = nick + + def get_nick(self): + """Returns current nick""" + with self.serverthread_object.nick_lock: + return self.serverthread_object.nick + + def set_nick(self, nick): + """Set the internal nick tracking state""" + with self.serverthread_object.nick_lock: + self.serverthread_object.nick = nick + + def join(self, channel): + """Send a JOIN command and update the internal channel tracking state""" + with self.serverthread_object.channels_lock: + line = b'JOIN ' + channel + self.serverthread_object.send_line_raw(line) + self.serverthread_object.channels.add(channel) + + def part(self, channel, message = b''): + """Send a PART command and update the internal channel tracking state""" + with self.serverthread_object.channels_lock: + line = b'PART %s :%s' % (channel, message) + self.serverthread_object.send_line_raw(line) + self.serverthread_object.channels.removeadd(channel) + + def get_channels(self): + """Returns the current set of channels""" + with self.serverthread_object.channels_lock: + return self.serverthread_object.channels + + def set_channels(self, channels): + """Set the current set of channels variable""" + with self.serverthread_object.channels_lock: + self.serverthread_object.channels = channels + + def quit(self): + self.serverthread_object.control_channel.send((controlmessage_types.quit,)) + self.serverthread_object.logging_channel.send((logmessage_types.internal, internal_submessage_types.quit)) + cron.quit(self.cron) + + def log(self, *args, **kwargs): + """Log a status message. Supports normal print() arguments.""" + self.serverthread_object.logging_channel.send((logmessage_types.status, args, kwargs)) + + def error(self, message): + """Log an error""" + self.serverthread_object.logging_channel.send((logmessage_types.internal, internal_submessage_types.error, message)) + + +# ServerThread(server, control_channel, cron_control_channel, logging_channel) +# Creates a new server main loop thread +class ServerThread(threading.Thread): + def __init__(self, server, control_channel, cron_control_channel, logging_channel): + self.server = server + self.control_channel = control_channel + self.cron_control_channel = cron_control_channel + self.logging_channel = logging_channel + + self.server_socket_write_lock = threading.Lock() + + self.last_send = 0 + self.last_send_lock = threading.Lock() + + self.nick = None + self.nick_lock = threading.Lock() + + self.channels = set() + self.channels_lock = threading.Lock() + + threading.Thread.__init__(self) + + def send_line_raw(self, line): + # Sanitize line just in case + line = line.replace(b'\r', b'').replace(b'\n', b'')[:510] + + with self.last_send_lock: + now = time.monotonic() + if now - self.last_send < 1: + # Schedule our message sending one second after the last one + self.last_send += 1 + wait = self.last_send - now + + else: + self.last_send = now + wait = 0 + + if wait > 0: + time.sleep(wait) + + with self.server_socket_write_lock: + if self.server_socket is not None: + self.server_socket.sendall(line + b'\r\n') + else: + return + + # Don't log PINGs or PONGs + if not (len(line) >= 5 and (line[:5] == b'PING ' or line[:5] == b'PONG ')): + self.logging_channel.send((logmessage_types.sent, line.decode(encoding = 'utf-8', errors = 'replace'))) + + def handle_line(self, line): + command, _, arguments = line.partition(b' ') + split = line.split(b' ') + if len(split) >= 1 and split[0].upper() == b'PING': + self.send_line_raw(b'PONG ' + arguments) + elif len(split) >= 2 and split[0][0:1] == b':' and split[1].upper() == b'PONG': + # No need to do anything special for PONGs + pass + else: + self.logging_channel.send((logmessage_types.received, line.decode(encoding = 'utf-8', errors = 'replace'))) + # Ensure we have a bytestring, because bytearray can be annoying to deal with + line = bytes(line) + line_handling.handle_line(line, irc = self.api) + + def mainloop(self): + # Register both the server socket and the control channel to a polling object + poll = select.poll() + poll.register(self.server_socket, select.POLLIN) + poll.register(self.control_channel, select.POLLIN) + + # Keep buffer for input + server_input_buffer = bytearray() + + quitting = False + reconnecting = False + while not quitting and not reconnecting: + # Wait until we can do something + for fd, event in poll.poll(): + # Server + if fd == self.server_socket.fileno(): + # Ready to receive, read into buffer and handle full messages + if event | select.POLLIN: + data = self.server_socket.recv(1024) + + # Mo data to be read even as POLLIN triggered → connection has broken + # Log it and try reconnecting + if data == b'': + self.logging_channel.send((logmessage_types.internal, internal_submessage_types.error, 'Empty read')) + reconnecting = True + break + + server_input_buffer.extend(data) + + # Try to see if we have a full line ending with \r\n in the buffer + # If yes, handle it + while b'\r\n' in server_input_buffer: + # Newline was found, split buffer + line, _, server_input_buffer = server_input_buffer.partition(b'\r\n') + + self.handle_line(line) + + # Remove possible pending ping timeout timer and reset ping timer to 3 minutes + cron.delete(self.cron_control_channel, self.control_channel, (controlmessage_types.ping_timeout,)) + cron.reschedule(self.cron_control_channel, 3 * 60, self.control_channel, (controlmessage_types.ping,)) + + else: + error_message = 'Event on server socket: %s' % event + self.logging_channel.send((logmessage_types.internal, internal_submessage_types.error, error_message)) + + # Control + elif fd == self.control_channel.fileno(): + command_type, *arguments = self.control_channel.recv() + if command_type == controlmessage_types.quit: + quitting = True + + elif command_type == controlmessage_types.send_line: + assert len(arguments) == 1 + irc_command, space, arguments = arguments[0].encode('utf-8').partition(b' ') + line = irc_command.upper() + space + arguments + self.send_line_raw(line) + + elif command_type == controlmessage_types.ping: + assert len(arguments) == 0 + self.send_line_raw(b'PING :foo') + # Reset ping timeout timer to 2 minutes + cron.reschedule(self.cron_control_channel, 2 * 60, self.control_channel, (controlmessage_types.ping_timeout,)) + + elif command_type == controlmessage_types.ping_timeout: + self.logging_channel.send((logmessage_types.internal, internal_submessage_types.error, 'Ping timeout')) + reconnecting = True + + elif command_type == controlmessage_types.reconnect: + reconnecting = True + + else: + error_message = 'Unknown control message: %s' % repr((command_type, *arguments)) + self.logging_channel.send((logmessage_types.internal, internal_submessage_types.error, error_message)) + + else: + assert False #unreachable + + if reconnecting: + return True + else: + return False + + def run(self): + while True: + # Connect to given server + address = (self.server.host, self.server.port) + try: + self.server_socket = socket.create_connection(address) + except (ConnectionRefusedError, socket.gaierror): + # Tell controller we failed + self.logging_channel.send((logmessage_types.internal, internal_submessage_types.error, "Can't connect to %s:%s" % address)) + + # Try reconnecting in a minute + cron.reschedule(self.cron_control_channel, 60, self.control_channel, (controlmessage_types.reconnect,)) + + # Handle messages + reconnect = True + while True: + command_type, *arguments = self.control_channel.recv() + + if command_type == controlmessage_types.reconnect: + break + + elif command_type == controlmessage_types.quit: + reconnect = False + break + + else: + error_message = 'Control message not supported when not connected: %s' % repr((command_type, *arguments)) + self.logging_channel.send((logmessage_types.internal, internal_submessage_types.error, error_message)) + + # Remove the reconnect message in case we were told to reconnnect manually + cron.delete(self.cron_control_channel, self.control_channel, (controlmessage_types.reconnect,)) + + if reconnect: + continue + else: + break + + # Create an API object to give to outside line handler + self.api = API(self) + + try: + # Run initialization + self.send_line_raw(b'USER %s a a :%s' % (self.server.username.encode('utf-8'), self.server.realname.encode('utf-8'))) + + # Set up nick + self.api.nick(self.server.nick.encode('utf-8')) + + # Run the on_connect hook, to allow further setup + botcmd.on_connect(irc = self.api) + + # Join channels + for channel in self.server.channels: + self.api.join(channel.encode('utf-8')) + + # Schedule a ping to be sent in 3 minutes of no activity + cron.reschedule(self.cron_control_channel, 3 * 60, self.control_channel, (controlmessage_types.ping,)) + + # Run mainloop + reconnecting = self.mainloop() + + if not reconnecting: + # Run bot cleanup code + botcmd.on_quit(irc = self.api) + + # Tell the server we're quiting + self.send_line_raw(b'QUIT :%s exiting normally' % self.server.username.encode('utf-8')) + + self.server_socket.close() + + break + + else: + # Tell server we're reconnecting + self.send_line_raw(b'QUIT :Reconnecting') + with self.server_socket_write_lock: + self.server_socket.close() + self.server_socket = None + + except (BrokenPipeError, TimeoutError) as err: + # Connection broke, log it and try to reconnect + self.logging_channel.send((logmessage_types.internal, internal_submessage_types.error, 'Broken socket/pipe or timeout')) + self.server_socket.close() + + # Tell controller we're quiting + self.logging_channel.send((logmessage_types.internal, internal_submessage_types.quit)) + + # Tell cron we're quiting + cron.quit(cron_control_channel) + +# spawn_serverthread(server, cron_control_channel, logging_channel) → control_channel +# Creates a ServerThread for given server and returns the channel for controlling it +def spawn_serverthread(server, cron_control_channel, logging_channel): + thread_control_socket, spawner_control_socket = socket.socketpair() + control_channel = channel.Channel() + ServerThread(server, control_channel, cron_control_channel, logging_channel).start() + return control_channel + +# spawn_loggerthread() → logging_channel, dead_notify_channel +# Spawn logger thread and returns the channel it logs and the channel it uses to notify about quiting +def spawn_loggerthread(): + logging_channel = channel.Channel() + dead_notify_channel = channel.Channel() + LoggerThread(logging_channel, dead_notify_channel).start() + return logging_channel, dead_notify_channel + +# read_config() → config, server +# Reads the configuration file and returns the configuration object as well as a server object for spawn_serverthread +def read_config(): + config = configparser.ConfigParser() + config.read('bot.conf') + + host = config['server']['host'] + port = int(config['server']['port']) + nick = config['server']['nick'] + username = config['server']['username'] + realname = config['server']['realname'] + channels = config['server']['channels'].split() + + server = Server(host = host, port = port, nick = nick, username = username, realname = realname, channels = channels) + + return config, server + +if __name__ == '__main__': + config, server = read_config() + + botcmd.initialize(config = config) + + cron_control_channel = cron.start() + logging_channel, dead_notify_channel = spawn_loggerthread() + control_channel = spawn_serverthread(server, cron_control_channel, logging_channel) + + while True: + message = dead_notify_channel.recv(blocking = False) + if message is not None: + if message[0] == controlmessage_types.quit: + break + + cmd = input('') + if cmd == 'q': + print('Keyboard quit') + control_channel.send((controlmessage_types.quit,)) + logging_channel.send((logmessage_types.internal, internal_submessage_types.quit)) + cron.quit(cron_control_channel) + break + + elif cmd == 'r': + print('Keyboard reconnect') + control_channel.send((controlmessage_types.reconnect,)) + + elif len(cmd) > 0 and cmd[0] == '/': + control_channel.send((controlmessage_types.send_line, cmd[1:])) diff --git a/line_handling.py b/line_handling.py new file mode 100644 index 0000000..21ccccf --- /dev/null +++ b/line_handling.py @@ -0,0 +1,142 @@ +import threading + +import constants + +import botcmd + +class LineParsingError(Exception): None + +# parse_line(line) → prefix, command, arguments +# Split the line into its component parts +def parse_line(line): + def read_byte(): + # Read one byte and advance the index + nonlocal line, index + + if eol(): + raise LineParsingError + + byte = line[index] + index += 1 + + return byte + + def peek_byte(): + # Look at current byte, don't advance index + nonlocal line, index + + if eol(): + raise LineParsingError + + return line[index] + + def eol(): + # Test if we've reached the end of the line + nonlocal line, index + return index >= len(line) + + def skip_space(): + # Skip until we run into a non-space character or eol. + while not eol() and peek_byte() == ord(' '): + read_byte() + + def read_until_space(): + nonlocal line, index + + if eol(): + raise LineParsingError + + # Try to find a space + until = line[index:].find(b' ') + + if until == -1: + # Space not found, read until end of line + until = len(line) + else: + # Space found, add current index to it to get right index + until += index + + # Slice line upto the point of next space / end and update index + data = line[index:until] + index = until + + return data + + def read_until_end(): + nonlocal line, index + + if eol(): + raise LineParsingError + + # Read all of the data, and make index point to eol + data = line[index:] + index = len(line) + + return data + + index = 0 + + prefix = None + command = None + arguments = [] + + if peek_byte() == ord(':'): + read_byte() + prefix = read_until_space() + + skip_space() + + command = read_until_space() + + skip_space() + + while not eol(): + if peek_byte() == ord(':'): + read_byte() + argument = read_until_end() + else: + argument = read_until_space() + + arguments.append(argument) + + skip_space() + + return prefix, command, arguments + +class LineHandlerThread(threading.Thread): + def __init__(self, line, *, irc): + self.line = line + self.irc = irc + + threading.Thread.__init__(self) + + def run(self): + try: + prefix, command, arguments = parse_line(self.line) + except LineParsingError: + irc.error("Cannot parse line: " + self.line.decode(encoding = 'utf-8', errors = 'replace')) + + if command.upper() == b'PRIVMSG': + # PRIVMSG should have two parameters: recipient and the message + assert len(arguments) == 2 + recipients, message = arguments + + # Prefix contains the nick of the sender, delimited from user and host by '!' + nick = prefix.split(b'!')[0] + + # Recipients are in a comma-separate list + for recipient in recipients.split(b','): + # 'channel' is bit of a misnomer. This is where we'll send the response to + # Usually it's the channel, but in queries it's not + channel = recipient if recipient[0] == ord('#') else nick + + # Delegate rest to botcmd.handle_message + botcmd.handle_message(prefix = prefix, message = message, nick = nick, channel = channel, irc = self.irc) + + else: + # Delegate to botcmd.handle_nonmessage + botcmd.handle_nonmessage(prefix = prefix, command = command, arguments = arguments, irc = self.irc) + +def handle_line(line, *, irc): + # Spawn a thread to handle the line + LineHandlerThread(line, irc = irc).start()