From acc7e5a828a17a2e4c9033108c469084b8f9839f Mon Sep 17 00:00:00 2001 From: Juhani Haverinen Date: Mon, 4 Sep 2017 22:00:01 +0300 Subject: [PATCH 01/47] First commit --- .gitignore | 3 +++ UNLICENSE | 24 ++++++++++++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 .gitignore create mode 100644 UNLICENSE diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..15c993e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__ +*.pyc +*.swp diff --git a/UNLICENSE b/UNLICENSE new file mode 100644 index 0000000..69843e4 --- /dev/null +++ b/UNLICENSE @@ -0,0 +1,24 @@ +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or +distribute this software, either in source code form or as a compiled +binary, for any purpose, commercial or non-commercial, and by any +means. + +In jurisdictions that recognize copyright laws, the author or authors +of this software dedicate any and all copyright interest in the +software to the public domain. We make this dedication for the benefit +of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of +relinquishment in perpetuity of all present and future rights to this +software under copyright law. + +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 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. + +For more information, please refer to [http://unlicense.org] From ee530a4acfaf1aab248a5364158a47034b6f651f Mon Sep 17 00:00:00 2001 From: Juhani Haverinen Date: Mon, 4 Sep 2017 22:49:01 +0300 Subject: [PATCH 02/47] Start work on the base of the IRC bot --- ircbot.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 ircbot.py diff --git a/ircbot.py b/ircbot.py new file mode 100644 index 0000000..0a6fe8e --- /dev/null +++ b/ircbot.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +import socket +import threading +from collections import namedtuple + +Server = namedtuple('Server', ['host', 'port']) + +# ServerThread(server, control_socket) +# Creates a new server main loop thread +class ServerThread(threading.Thread): + def __init__(self, server, control_socket): + self.server = server + self.control_socket = control_socket + + threading.Thread.__init__(self) + + def run(self): + # Connect to given server + address = (self.server.host, self.server.port) + sock = socket.create_connection(address) + + sock.sendall(b'Testi\n') + sock.close() + +# spawn_serverthread(server) → control_socket +# Creates a ServerThread for given server and returns the socket for controlling it +def spawn_serverthread(server): + thread_control_socket, spawner_control_socket = socket.socketpair() + ServerThread(server, thread_control_socket).start() + return spawner_control_socket + +if __name__ == '__main__': + spawn_serverthread(Server('localhost', 6667)) From 8cfec71fb1edf828c3140354eecdc8f89e30d989 Mon Sep 17 00:00:00 2001 From: Juhani Haverinen Date: Tue, 5 Sep 2017 00:10:10 +0300 Subject: [PATCH 03/47] Skeleton of the IRC bot --- ircbot.py | 122 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 118 insertions(+), 4 deletions(-) diff --git a/ircbot.py b/ircbot.py index 0a6fe8e..e67c5c2 100644 --- a/ircbot.py +++ b/ircbot.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +import select import socket import threading from collections import namedtuple @@ -14,13 +15,105 @@ class ServerThread(threading.Thread): 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] + + self.server_socket.sendall(line + b'\r\n') + + # FIXME: print is not thread safe + print('>' + line.decode(encoding = 'utf-8', errors = 'replace')) + + def handle_line(self, line): + # TODO: implement line handling + # FIXME: print is not thread safe + print('<' + line.decode(encoding = 'utf-8', errors = 'replace')) + + def mainloop(self): + # Register both the server and the control socket to our polling object + poll = select.poll() + poll.register(self.server_socket, select.POLLIN) + poll.register(self.control_socket, select.POLLIN) + + # Keep buffers for input and output + server_input_buffer = bytearray() + server_output_buffer = bytearray() + control_input_buffer = bytearray() + + quitting = False + writing = False + while not quitting: + # 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) + 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 + if 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) + + # Ready to send, send buffered output as far as possible + if event | select.POLLOUT: + sent = self.server_socket.send(server_output_buffer) + server_output_buffer = server_output_buffer[sent:] + + # Control + elif fd == self.control_socket.fileno(): + # Read into buffer and handle full commands + data = self.control_socket.recv(1024) + control_input_buffer.extend(data) + + # TODO: implement command handling + if len(control_input_buffer) > 1: + quitting = True + + else: + assert False #unreachable + + # See if we have to change what we're listening for + if not writing and len(server_output_buffer) > 0: + # Mark we're listening to socket becoming writable, and start listening + writing = True + poll.modify(self.server_socket, select.POLLIN | select.POLLOUT) + + elif writing and len(server_output_buffer) == 0: + # Mark we're not listening to socket becoming writable, and stop listening + writing = False + poll.modify(self.server_socket, select.POLLIN) + def run(self): # Connect to given server address = (self.server.host, self.server.port) - sock = socket.create_connection(address) + try: + self.server_socket = socket.create_connection(address) + except ConnectionRefusedError: + # Tell controller we failed + self.control_socket.sendall(b'F') + self.control_socket.close() - sock.sendall(b'Testi\n') - sock.close() + # Run initialization + # TODO: read nick/username/etc. from a config + self.send_line_raw(b'NICK HynneFlip') + self.send_line_raw(b'USER HynneFlip a a :HynneFlip IRC bot') + + # Run mainloop + self.mainloop() + + # Tell the server we're quiting + self.send_line_raw(b'QUIT :HynneFlip exiting normally') + self.server_socket.close() + + # Tell controller we're quiting + self.control_socket.sendall(b'Q' + b'\n') + self.control_socket.close() # spawn_serverthread(server) → control_socket # Creates a ServerThread for given server and returns the socket for controlling it @@ -30,4 +123,25 @@ def spawn_serverthread(server): return spawner_control_socket if __name__ == '__main__': - spawn_serverthread(Server('localhost', 6667)) + control_socket = spawn_serverthread(Server('irc.freenode.net', 6667)) + + while True: + cmd = input(': ') + if cmd == '': + control_messages = bytearray() + while True: + data = control_socket.recv(1024) + + if not data: + break + + control_messages.extend(data) + + print(control_messages.decode(encoding = 'utf-8', errors = 'replace')) + + elif cmd == 'q': + control_socket.close() + break + + else: + control_socket.sendall(cmd.encode('utf-8') + b'\n') From 4e1efa5b610248b85eb9179d1cc480d4008c6eac Mon Sep 17 00:00:00 2001 From: Juhani Haverinen Date: Tue, 5 Sep 2017 10:05:02 +0300 Subject: [PATCH 04/47] Use a lock in send_line_raw and don't thread sending of data through the mainloop --- ircbot.py | 24 +++++------------------- 1 file changed, 5 insertions(+), 19 deletions(-) diff --git a/ircbot.py b/ircbot.py index e67c5c2..abaa600 100644 --- a/ircbot.py +++ b/ircbot.py @@ -13,13 +13,16 @@ class ServerThread(threading.Thread): self.server = server self.control_socket = control_socket + self.server_socket_write_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] - self.server_socket.sendall(line + b'\r\n') + with self.server_socket_write_lock: + self.server_socket.sendall(line + b'\r\n') # FIXME: print is not thread safe print('>' + line.decode(encoding = 'utf-8', errors = 'replace')) @@ -37,11 +40,9 @@ class ServerThread(threading.Thread): # Keep buffers for input and output server_input_buffer = bytearray() - server_output_buffer = bytearray() control_input_buffer = bytearray() quitting = False - writing = False while not quitting: # Wait until we can do something for fd, event in poll.poll(): @@ -60,11 +61,6 @@ class ServerThread(threading.Thread): self.handle_line(line) - # Ready to send, send buffered output as far as possible - if event | select.POLLOUT: - sent = self.server_socket.send(server_output_buffer) - server_output_buffer = server_output_buffer[sent:] - # Control elif fd == self.control_socket.fileno(): # Read into buffer and handle full commands @@ -78,17 +74,6 @@ class ServerThread(threading.Thread): else: assert False #unreachable - # See if we have to change what we're listening for - if not writing and len(server_output_buffer) > 0: - # Mark we're listening to socket becoming writable, and start listening - writing = True - poll.modify(self.server_socket, select.POLLIN | select.POLLOUT) - - elif writing and len(server_output_buffer) == 0: - # Mark we're not listening to socket becoming writable, and stop listening - writing = False - poll.modify(self.server_socket, select.POLLIN) - def run(self): # Connect to given server address = (self.server.host, self.server.port) @@ -140,6 +125,7 @@ if __name__ == '__main__': print(control_messages.decode(encoding = 'utf-8', errors = 'replace')) elif cmd == 'q': + control_socket.sendall(b'Q\n') control_socket.close() break From 0657f423f3081606bf3d509692618ffab4d1b814 Mon Sep 17 00:00:00 2001 From: Juhani Haverinen Date: Tue, 5 Sep 2017 10:55:33 +0300 Subject: [PATCH 05/47] Use channels for internal communication --- channel.py | 51 +++++++++++++++++++++++++++++++++++++ ircbot.py | 75 +++++++++++++++++++++++++++--------------------------- 2 files changed, 88 insertions(+), 38 deletions(-) create mode 100644 channel.py diff --git a/channel.py b/channel.py new file mode 100644 index 0000000..4d15dc1 --- /dev/null +++ b/channel.py @@ -0,0 +1,51 @@ +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.mesages = [] + 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.mesages.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.mesages.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() diff --git a/ircbot.py b/ircbot.py index abaa600..5d38f9b 100644 --- a/ircbot.py +++ b/ircbot.py @@ -4,14 +4,17 @@ import socket import threading from collections import namedtuple +import channel + Server = namedtuple('Server', ['host', 'port']) # ServerThread(server, control_socket) # Creates a new server main loop thread class ServerThread(threading.Thread): - def __init__(self, server, control_socket): + def __init__(self, server, control_channel, logging_channel): self.server = server - self.control_socket = control_socket + self.control_channel = control_channel + self.logging_channel = logging_channel self.server_socket_write_lock = threading.Lock() @@ -24,23 +27,22 @@ class ServerThread(threading.Thread): with self.server_socket_write_lock: self.server_socket.sendall(line + b'\r\n') - # FIXME: print is not thread safe - print('>' + line.decode(encoding = 'utf-8', errors = 'replace')) + # FIXME: use a real data structure + self.logging_channel.send('>' + line.decode(encoding = 'utf-8', errors = 'replace')) def handle_line(self, line): # TODO: implement line handling - # FIXME: print is not thread safe - print('<' + line.decode(encoding = 'utf-8', errors = 'replace')) + # FIXME: use a real data structure + self.logging_channel.send('<' + line.decode(encoding = 'utf-8', errors = 'replace')) def mainloop(self): - # Register both the server and the control socket to our polling object + # Register both the server socket and the control channel to or polling object poll = select.poll() poll.register(self.server_socket, select.POLLIN) - poll.register(self.control_socket, select.POLLIN) + poll.register(self.control_channel, select.POLLIN) - # Keep buffers for input and output + # Keep buffer for input server_input_buffer = bytearray() - control_input_buffer = bytearray() quitting = False while not quitting: @@ -62,15 +64,21 @@ class ServerThread(threading.Thread): self.handle_line(line) # Control - elif fd == self.control_socket.fileno(): - # Read into buffer and handle full commands - data = self.control_socket.recv(1024) - control_input_buffer.extend(data) + elif fd == self.control_channel.fileno(): + command = self.control_channel.recv() - # TODO: implement command handling - if len(control_input_buffer) > 1: + # FIXME: use a real data structure + if command == 'q': quitting = True + elif len(command) > 0 and command[0] == '/': + irc_command, space, arguments = command[1:].encode('utf-8').partition(b' ') + line = irc_command.upper() + space + arguments + self.send_line_raw(line) + + else: + self.logging_channel.send('?') + else: assert False #unreachable @@ -81,8 +89,8 @@ class ServerThread(threading.Thread): self.server_socket = socket.create_connection(address) except ConnectionRefusedError: # Tell controller we failed - self.control_socket.sendall(b'F') - self.control_socket.close() + self.logging_channel.send('f') + return # Run initialization # TODO: read nick/username/etc. from a config @@ -97,37 +105,28 @@ class ServerThread(threading.Thread): self.server_socket.close() # Tell controller we're quiting - self.control_socket.sendall(b'Q' + b'\n') - self.control_socket.close() + self.logging_channel.send('q') -# spawn_serverthread(server) → control_socket -# Creates a ServerThread for given server and returns the socket for controlling it +# spawn_serverthread(server) → control_channel, logging_channel +# Creates a ServerThread for given server and returns the channels for controlling and monitoring it def spawn_serverthread(server): thread_control_socket, spawner_control_socket = socket.socketpair() - ServerThread(server, thread_control_socket).start() - return spawner_control_socket + control_channel = channel.Channel() + logging_channel = channel.Channel() + ServerThread(server, control_channel, logging_channel).start() + return (control_channel, logging_channel) if __name__ == '__main__': - control_socket = spawn_serverthread(Server('irc.freenode.net', 6667)) + control_channel, logging_channel = spawn_serverthread(Server('irc.freenode.net', 6667)) while True: cmd = input(': ') if cmd == '': - control_messages = bytearray() - while True: - data = control_socket.recv(1024) - - if not data: - break - - control_messages.extend(data) - - print(control_messages.decode(encoding = 'utf-8', errors = 'replace')) + print(logging_channel.recv(blocking = False)) elif cmd == 'q': - control_socket.sendall(b'Q\n') - control_socket.close() + control_channel.send('q') break else: - control_socket.sendall(cmd.encode('utf-8') + b'\n') + control_channel.send(cmd) From fcc1978743464179a7f6b96bc4f95e263f7da573 Mon Sep 17 00:00:00 2001 From: Juhani Haverinen Date: Tue, 5 Sep 2017 10:58:58 +0300 Subject: [PATCH 06/47] Handle the case of having several full messages in input buffer --- ircbot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ircbot.py b/ircbot.py index 5d38f9b..1965327 100644 --- a/ircbot.py +++ b/ircbot.py @@ -57,7 +57,7 @@ class ServerThread(threading.Thread): # Try to see if we have a full line ending with \r\n in the buffer # If yes, handle it - if b'\r\n' in server_input_buffer: + while b'\r\n' in server_input_buffer: # Newline was found, split buffer line, _, server_input_buffer = server_input_buffer.partition(b'\r\n') From 044fc0d4bd0cdfe031357dde9e642b8b64414a47 Mon Sep 17 00:00:00 2001 From: Juhani Haverinen Date: Tue, 5 Sep 2017 11:03:41 +0300 Subject: [PATCH 07/47] Make the debug interface nicer to use --- ircbot.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ircbot.py b/ircbot.py index 1965327..375f0a8 100644 --- a/ircbot.py +++ b/ircbot.py @@ -122,7 +122,11 @@ if __name__ == '__main__': while True: cmd = input(': ') if cmd == '': - print(logging_channel.recv(blocking = False)) + while True: + data = logging_channel.recv(blocking = False) + if data == None: + break + print(data) elif cmd == 'q': control_channel.send('q') From 2e1bc6a6b147742515f1efa30c75fccb85331921 Mon Sep 17 00:00:00 2001 From: Juhani Haverinen Date: Tue, 5 Sep 2017 11:24:58 +0300 Subject: [PATCH 08/47] Use enums for message types in channels --- constants.py | 10 ++++++++++ ircbot.py | 46 +++++++++++++++++++++++++++++----------------- 2 files changed, 39 insertions(+), 17 deletions(-) create mode 100644 constants.py diff --git a/constants.py b/constants.py new file mode 100644 index 0000000..8981855 --- /dev/null +++ b/constants.py @@ -0,0 +1,10 @@ +import enum + +class logmessage_types(enum.Enum): + sent, received, internal = range(3) + +class internal_submessage_types(enum.Enum): + quit, error = range(2) + +class controlmessage_types(enum.Enum): + quit, send_line = range(2) diff --git a/ircbot.py b/ircbot.py index 375f0a8..9c4ed69 100644 --- a/ircbot.py +++ b/ircbot.py @@ -5,6 +5,7 @@ import threading from collections import namedtuple import channel +from constants import logmessage_types, internal_submessage_types, controlmessage_types Server = namedtuple('Server', ['host', 'port']) @@ -27,13 +28,11 @@ class ServerThread(threading.Thread): with self.server_socket_write_lock: self.server_socket.sendall(line + b'\r\n') - # FIXME: use a real data structure - self.logging_channel.send('>' + line.decode(encoding = 'utf-8', errors = 'replace')) + self.logging_channel.send((logmessage_types.sent, line.decode(encoding = 'utf-8', errors = 'replace'))) def handle_line(self, line): # TODO: implement line handling - # FIXME: use a real data structure - self.logging_channel.send('<' + line.decode(encoding = 'utf-8', errors = 'replace')) + self.logging_channel.send((logmessage_types.received, line.decode(encoding = 'utf-8', errors = 'replace'))) def mainloop(self): # Register both the server socket and the control channel to or polling object @@ -65,19 +64,18 @@ class ServerThread(threading.Thread): # Control elif fd == self.control_channel.fileno(): - command = self.control_channel.recv() - - # FIXME: use a real data structure - if command == 'q': + command_type, *arguments = self.control_channel.recv() + if command_type == controlmessage_types.quit: quitting = True - elif len(command) > 0 and command[0] == '/': - irc_command, space, arguments = command[1:].encode('utf-8').partition(b' ') + 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) else: - self.logging_channel.send('?') + self.logging_channel.send((logmessage_types.internal, internal_submessage_types.error)) else: assert False #unreachable @@ -89,7 +87,8 @@ class ServerThread(threading.Thread): self.server_socket = socket.create_connection(address) except ConnectionRefusedError: # Tell controller we failed - self.logging_channel.send('f') + self.logging_channel.send((logmessage_types.internal, internal_submessage_types.error)) + self.logging_channel.send((logmessage_types.internal, internal_submessage_types.quit)) return # Run initialization @@ -105,7 +104,7 @@ class ServerThread(threading.Thread): self.server_socket.close() # Tell controller we're quiting - self.logging_channel.send('q') + self.logging_channel.send((logmessage_types.internal, internal_submessage_types.quit)) # spawn_serverthread(server) → control_channel, logging_channel # Creates a ServerThread for given server and returns the channels for controlling and monitoring it @@ -126,11 +125,24 @@ if __name__ == '__main__': data = logging_channel.recv(blocking = False) if data == None: break - print(data) + message_type, message_data = data + if message_type == logmessage_types.sent: + print('>' + message_data) + elif message_type == logmessage_types.received: + print('<' + message_data) + elif message_type == logmessage_types.internal: + if message_data == internal_submessage_types.quit: + print('--- Quit') + elif message_data == internal_submessage_types.error: + print('--- Error') + else: + print('--- ???', message_data) + else: + print('???', message_type, message_data) elif cmd == 'q': - control_channel.send('q') + control_channel.send((controlmessage_types.quit,)) break - else: - control_channel.send(cmd) + elif len(cmd) > 0 and cmd[0] == '/': + control_channel.send((controlmessage_types.send_line, cmd[1:])) From 4c7fe5695011b6d28da12082e14c54f8b15f2daa Mon Sep 17 00:00:00 2001 From: Juhani Haverinen Date: Tue, 5 Sep 2017 11:40:44 +0300 Subject: [PATCH 09/47] Implement PING/PONG --- ircbot.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/ircbot.py b/ircbot.py index 9c4ed69..72b9cf4 100644 --- a/ircbot.py +++ b/ircbot.py @@ -31,8 +31,12 @@ class ServerThread(threading.Thread): self.logging_channel.send((logmessage_types.sent, line.decode(encoding = 'utf-8', errors = 'replace'))) def handle_line(self, line): - # TODO: implement line handling - self.logging_channel.send((logmessage_types.received, line.decode(encoding = 'utf-8', errors = 'replace'))) + command, _, arguments = line.partition(b' ') + if command.upper() == b'PING': + self.send_line_raw(b'PONG ' + arguments) + else: + # TODO: implement line handling + self.logging_channel.send((logmessage_types.received, line.decode(encoding = 'utf-8', errors = 'replace'))) def mainloop(self): # Register both the server socket and the control channel to or polling object @@ -43,6 +47,7 @@ class ServerThread(threading.Thread): # Keep buffer for input server_input_buffer = bytearray() + # TODO: Implement timeouting quitting = False while not quitting: # Wait until we can do something From 36ab28cd71b4c4c7c758f16411c4e5f861095b63 Mon Sep 17 00:00:00 2001 From: Juhani Haverinen Date: Tue, 5 Sep 2017 12:47:47 +0300 Subject: [PATCH 10/47] Add start of line handling, don't log PONGs --- ircbot.py | 32 ++++++++---- line_handling.py | 124 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+), 10 deletions(-) create mode 100644 line_handling.py diff --git a/ircbot.py b/ircbot.py index 72b9cf4..8b64a34 100644 --- a/ircbot.py +++ b/ircbot.py @@ -7,6 +7,8 @@ from collections import namedtuple import channel from constants import logmessage_types, internal_submessage_types, controlmessage_types +import line_handling + Server = namedtuple('Server', ['host', 'port']) # ServerThread(server, control_socket) @@ -28,15 +30,17 @@ class ServerThread(threading.Thread): with self.server_socket_write_lock: self.server_socket.sendall(line + b'\r\n') - self.logging_channel.send((logmessage_types.sent, line.decode(encoding = 'utf-8', errors = 'replace'))) + # Don't log PONGs + if not (len(line) >= 5 and 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' ') if command.upper() == b'PING': self.send_line_raw(b'PONG ' + arguments) else: - # TODO: implement line handling self.logging_channel.send((logmessage_types.received, line.decode(encoding = 'utf-8', errors = 'replace'))) + line_handling.handle_line(line, irc = self.api) def mainloop(self): # Register both the server socket and the control channel to or polling object @@ -80,7 +84,8 @@ class ServerThread(threading.Thread): self.send_line_raw(line) else: - self.logging_channel.send((logmessage_types.internal, internal_submessage_types.error)) + 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 @@ -92,10 +97,13 @@ class ServerThread(threading.Thread): self.server_socket = socket.create_connection(address) except ConnectionRefusedError: # Tell controller we failed - self.logging_channel.send((logmessage_types.internal, internal_submessage_types.error)) + self.logging_channel.send((logmessage_types.internal, internal_submessage_types.error, "Can't connect to %s:%s" % address)) self.logging_channel.send((logmessage_types.internal, internal_submessage_types.quit)) return + # Create an API object to give to outside line handler + self.api = line_handling.API(self) + # Run initialization # TODO: read nick/username/etc. from a config self.send_line_raw(b'NICK HynneFlip') @@ -130,16 +138,20 @@ if __name__ == '__main__': data = logging_channel.recv(blocking = False) if data == None: break - message_type, message_data = data + message_type, *message_data = data if message_type == logmessage_types.sent: - print('>' + message_data) + assert len(message_data) == 1 + print('>' + message_data[0]) elif message_type == logmessage_types.received: - print('<' + message_data) + assert len(message_data) == 1 + print('<' + message_data[0]) elif message_type == logmessage_types.internal: - if message_data == internal_submessage_types.quit: + if message_data[0] == internal_submessage_types.quit: + assert len(message_data) == 1 print('--- Quit') - elif message_data == internal_submessage_types.error: - print('--- Error') + elif message_data[0] == internal_submessage_types.error: + assert len(message_data) == 2 + print('--- Error', message_data[1]) else: print('--- ???', message_data) else: diff --git a/line_handling.py b/line_handling.py new file mode 100644 index 0000000..d650360 --- /dev/null +++ b/line_handling.py @@ -0,0 +1,124 @@ +import constants + +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 + + def send(self, line): + self.serverthread_object.send_line_raw(line) + + def msg(self, recipient, message): + """Make sending PRIVMSGs much nicer""" + line = 'PRIVMSG ' + recipient + ' :' + message + self.serverthread_object.send_line_raw(line) + + def error(self, message): + self.serverthread_object.logging_channel.send((constants.logmessage_types.internal, constants.internal_submessage_types.error, message)) + +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 + +def handle_line(line, *, irc): + try: + prefix, command, arguments = parse_line(line) + except LineParsingError: + irc.error("Cannot parse line" + line.decode(encoding = 'utf-8', errors = 'replace')) + + # TODO: handle line From 3a2fb425383b2e6563d1ba38845fd1ffe8514eb8 Mon Sep 17 00:00:00 2001 From: Juhani Haverinen Date: Tue, 5 Sep 2017 13:11:08 +0300 Subject: [PATCH 11/47] Have thread be spawned for non-trivial messages --- line_handling.py | 46 ++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/line_handling.py b/line_handling.py index d650360..63b98e1 100644 --- a/line_handling.py +++ b/line_handling.py @@ -1,5 +1,9 @@ +import threading + import constants +import botcmd + class API: def __init__(self, serverthread_object): # We need to access the internal functions of the ServerThread object in order to send lines etc. @@ -115,10 +119,40 @@ def parse_line(line): return prefix, command, arguments -def handle_line(line, *, irc): - try: - prefix, command, arguments = parse_line(line) - except LineParsingError: - irc.error("Cannot parse line" + line.decode(encoding = 'utf-8', errors = 'replace')) +class LineHandlerThread(threading.Thread): + def __init__(self, line, *, irc): + self.line = line + self.irc = irc - # TODO: handle line + 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() From f4077cdd3e2ad5b209d391cc842256c6adc78994 Mon Sep 17 00:00:00 2001 From: Juhani Haverinen Date: Tue, 5 Sep 2017 13:45:28 +0300 Subject: [PATCH 12/47] Make bot autojoin channel and move nick and username to server config --- ircbot.py | 21 ++++++++++++++++----- line_handling.py | 18 ++++++++++++++++-- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/ircbot.py b/ircbot.py index 8b64a34..62a0ea0 100644 --- a/ircbot.py +++ b/ircbot.py @@ -9,7 +9,7 @@ from constants import logmessage_types, internal_submessage_types, controlmessag import line_handling -Server = namedtuple('Server', ['host', 'port']) +Server = namedtuple('Server', ['host', 'port', 'nick', 'realname', 'channels']) # ServerThread(server, control_socket) # Creates a new server main loop thread @@ -21,6 +21,12 @@ class ServerThread(threading.Thread): self.server_socket_write_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): @@ -105,9 +111,13 @@ class ServerThread(threading.Thread): self.api = line_handling.API(self) # Run initialization - # TODO: read nick/username/etc. from a config - self.send_line_raw(b'NICK HynneFlip') - self.send_line_raw(b'USER HynneFlip a a :HynneFlip IRC bot') + self.send_line_raw(b'USER HynneFlip a a :' + self.server.realname.encode('utf-8')) + + # Set up nick and channels + self.api.nick(self.server.nick.encode('utf-8')) + + for channel in self.server.channels: + self.api.join(channel.encode('utf-8')) # Run mainloop self.mainloop() @@ -129,7 +139,8 @@ def spawn_serverthread(server): return (control_channel, logging_channel) if __name__ == '__main__': - control_channel, logging_channel = spawn_serverthread(Server('irc.freenode.net', 6667)) + server = Server(host = 'irc.freenode.net', port = 6667, nick = 'HynneFlip', realname = 'HynneFlip IRC bot', channels = ['##ingsoc']) + control_channel, logging_channel = spawn_serverthread(server) while True: cmd = input(': ') diff --git a/line_handling.py b/line_handling.py index 63b98e1..b504c93 100644 --- a/line_handling.py +++ b/line_handling.py @@ -9,14 +9,28 @@ class API: # We need to access the internal functions of the ServerThread object in order to send lines etc. self.serverthread_object = serverthread_object - def send(self, line): + def send_raw(self, line): self.serverthread_object.send_line_raw(line) def msg(self, recipient, message): """Make sending PRIVMSGs much nicer""" - line = 'PRIVMSG ' + recipient + ' :' + message + line = b'PRIVMSG ' + recipient + b' :' + message self.serverthread_object.send_line_raw(line) + 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 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 error(self, message): self.serverthread_object.logging_channel.send((constants.logmessage_types.internal, constants.internal_submessage_types.error, message)) From abb579bc88d1e4d796105063898cdc2dfa4fca17 Mon Sep 17 00:00:00 2001 From: Juhani Haverinen Date: Tue, 5 Sep 2017 13:47:32 +0300 Subject: [PATCH 13/47] Move API class to ircbot.py --- ircbot.py | 35 ++++++++++++++++++++++++++++++++++- line_handling.py | 30 ------------------------------ 2 files changed, 34 insertions(+), 31 deletions(-) diff --git a/ircbot.py b/ircbot.py index 62a0ea0..4d6abdb 100644 --- a/ircbot.py +++ b/ircbot.py @@ -11,6 +11,39 @@ import line_handling Server = namedtuple('Server', ['host', 'port', 'nick', 'realname', 'channels']) +# 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 + + def send_raw(self, line): + 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 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 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 error(self, message): + self.serverthread_object.logging_channel.send((constants.logmessage_types.internal, constants.internal_submessage_types.error, message)) + + # ServerThread(server, control_socket) # Creates a new server main loop thread class ServerThread(threading.Thread): @@ -108,7 +141,7 @@ class ServerThread(threading.Thread): return # Create an API object to give to outside line handler - self.api = line_handling.API(self) + self.api = API(self) # Run initialization self.send_line_raw(b'USER HynneFlip a a :' + self.server.realname.encode('utf-8')) diff --git a/line_handling.py b/line_handling.py index b504c93..6b84b45 100644 --- a/line_handling.py +++ b/line_handling.py @@ -4,36 +4,6 @@ import constants import botcmd -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 - - def send_raw(self, line): - 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 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 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 error(self, message): - self.serverthread_object.logging_channel.send((constants.logmessage_types.internal, constants.internal_submessage_types.error, message)) - class LineParsingError(Exception): None # parse_line(line) → prefix, command, arguments From 164cede5009c7b25e184cd5bd88c0901441430d9 Mon Sep 17 00:00:00 2001 From: Juhani Haverinen Date: Tue, 5 Sep 2017 14:11:15 +0300 Subject: [PATCH 14/47] Have logger as its own thread --- ircbot.py | 92 ++++++++++++++++++++++++++++++++++++------------------- 1 file changed, 61 insertions(+), 31 deletions(-) diff --git a/ircbot.py b/ircbot.py index 4d6abdb..ee93b83 100644 --- a/ircbot.py +++ b/ircbot.py @@ -11,6 +11,46 @@ import line_handling Server = namedtuple('Server', ['host', 'port', 'nick', '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') + + # TODO: don't quit, restart + 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) + + else: + print('???', message_type, message_data) + # API(serverthread_object) # Create a new API object corresponding to given ServerThread object class API: @@ -162,46 +202,36 @@ class ServerThread(threading.Thread): # Tell controller we're quiting self.logging_channel.send((logmessage_types.internal, internal_submessage_types.quit)) -# spawn_serverthread(server) → control_channel, logging_channel -# Creates a ServerThread for given server and returns the channels for controlling and monitoring it -def spawn_serverthread(server): +# spawn_serverthread(server, logging_channel) → control_channel +# Creates a ServerThread for given server and returns the channel for controlling it +def spawn_serverthread(server, logging_channel): thread_control_socket, spawner_control_socket = socket.socketpair() control_channel = channel.Channel() - logging_channel = channel.Channel() ServerThread(server, control_channel, logging_channel).start() - return (control_channel, logging_channel) + 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 if __name__ == '__main__': server = Server(host = 'irc.freenode.net', port = 6667, nick = 'HynneFlip', realname = 'HynneFlip IRC bot', channels = ['##ingsoc']) - control_channel, logging_channel = spawn_serverthread(server) + + logging_channel, dead_notify_channel = spawn_loggerthread() + control_channel = spawn_serverthread(server, logging_channel) while True: - cmd = input(': ') - if cmd == '': - while True: - data = logging_channel.recv(blocking = False) - if data == None: - break - message_type, *message_data = data - 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]) - elif message_type == logmessage_types.internal: - if message_data[0] == internal_submessage_types.quit: - assert len(message_data) == 1 - print('--- Quit') - elif message_data[0] == internal_submessage_types.error: - assert len(message_data) == 2 - print('--- Error', message_data[1]) - else: - print('--- ???', message_data) - else: - print('???', message_type, message_data) + message = dead_notify_channel.recv(blocking = False) + if message is not None: + if message[0] == controlmessage_types.quit: + break - elif cmd == 'q': + cmd = input('') + if cmd == 'q': control_channel.send((controlmessage_types.quit,)) break From 4bc3f12d4200e3b502d7c6d74d0b0d52d9b4f0ce Mon Sep 17 00:00:00 2001 From: Juhani Haverinen Date: Tue, 5 Sep 2017 14:15:01 +0300 Subject: [PATCH 15/47] Add get_nick() to API and add docstrings to API commands --- ircbot.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/ircbot.py b/ircbot.py index ee93b83..8f47a1c 100644 --- a/ircbot.py +++ b/ircbot.py @@ -59,6 +59,8 @@ class API: self.serverthread_object = serverthread_object def send_raw(self, line): + """Sends a raw line (will terminate it itself. + Don't use unless you are completely sure you know wha you're doing.""" self.serverthread_object.send_line_raw(line) def msg(self, recipient, message): @@ -67,20 +69,26 @@ class API: self.serverthread_object.send_line_raw(line) def nick(self, nick): - # Send a NICK command and update the internal nick tracking state + """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 join(self, channel): - # Send a JOIN command and update the internal channel tracking state + """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 error(self, message): + """Log an error""" self.serverthread_object.logging_channel.send((constants.logmessage_types.internal, constants.internal_submessage_types.error, message)) From ce251272999d135a00b3241ffb5b7312ff3591ee Mon Sep 17 00:00:00 2001 From: Juhani Haverinen Date: Tue, 5 Sep 2017 15:09:57 +0300 Subject: [PATCH 16/47] Implement the hymmnos dictionary search feature --- ircbot.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/ircbot.py b/ircbot.py index 8f47a1c..d35965c 100644 --- a/ircbot.py +++ b/ircbot.py @@ -8,6 +8,7 @@ import channel from constants import logmessage_types, internal_submessage_types, controlmessage_types import line_handling +import botcmd Server = namedtuple('Server', ['host', 'port', 'nick', 'realname', 'channels']) @@ -229,6 +230,8 @@ def spawn_loggerthread(): if __name__ == '__main__': server = Server(host = 'irc.freenode.net', port = 6667, nick = 'HynneFlip', realname = 'HynneFlip IRC bot', channels = ['##ingsoc']) + botcmd.initialize() + logging_channel, dead_notify_channel = spawn_loggerthread() control_channel = spawn_serverthread(server, logging_channel) @@ -240,7 +243,9 @@ if __name__ == '__main__': cmd = input('') if cmd == 'q': + print('Keyboard quit') control_channel.send((controlmessage_types.quit,)) + logging_channel.send((logmessage_types.internal, internal_submessage_types.quit)) break elif len(cmd) > 0 and cmd[0] == '/': From 401f65735efa0c7d4cb876392c39e3778a5180f3 Mon Sep 17 00:00:00 2001 From: Juhani Haverinen Date: Tue, 5 Sep 2017 19:18:17 +0300 Subject: [PATCH 17/47] Bot is mature enough, point it at ##hymnnos --- ircbot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ircbot.py b/ircbot.py index d35965c..c54581d 100644 --- a/ircbot.py +++ b/ircbot.py @@ -228,7 +228,7 @@ def spawn_loggerthread(): return logging_channel, dead_notify_channel if __name__ == '__main__': - server = Server(host = 'irc.freenode.net', port = 6667, nick = 'HynneFlip', realname = 'HynneFlip IRC bot', channels = ['##ingsoc']) + server = Server(host = 'irc.freenode.net', port = 6667, nick = 'HynneFlip', realname = 'HynneFlip IRC bot', channels = ['##hymmnos']) botcmd.initialize() From bf2bc2d215be948be34dfb527a2f8f69c8b92761 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juhani=20Krekel=C3=A4?= Date: Wed, 6 Sep 2017 20:45:31 +0300 Subject: [PATCH 18/47] Add a new README.md for the o3-base project --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..b726488 --- /dev/null +++ b/README.md @@ -0,0 +1,9 @@ +o3-base +======= +This project seeks to replace o2-base as an easy python base to build IRC bots +on. It is not related to the faile oonbotti3 project, but is rather based on +the IRC bot framework of hynneflip. + +License +------- +Everything in this repo is under UNLICENSE / CC0. From 984af5b68e21b4f15695ede5d2e47ca1b9258c99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juhani=20Krekel=C3=A4?= Date: Wed, 6 Sep 2017 20:47:16 +0300 Subject: [PATCH 19/47] Modify the built-in config in ircbot.py --- ircbot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ircbot.py b/ircbot.py index c54581d..0d9276d 100644 --- a/ircbot.py +++ b/ircbot.py @@ -228,7 +228,7 @@ def spawn_loggerthread(): return logging_channel, dead_notify_channel if __name__ == '__main__': - server = Server(host = 'irc.freenode.net', port = 6667, nick = 'HynneFlip', realname = 'HynneFlip IRC bot', channels = ['##hymmnos']) + server = Server(host = 'irc.freenode.net', port = 6667, nick = 'o3-base', realname = 'IRC bot based on o3-base', channels = ['##ingsoc']) botcmd.initialize() From a0b4f51fef71a7c60e5b0b7feaf8eaf2ba73271c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juhani=20Krekel=C3=A4?= Date: Wed, 6 Sep 2017 20:47:32 +0300 Subject: [PATCH 20/47] Add a stub botcmd.py --- botcmd.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 botcmd.py diff --git a/botcmd.py b/botcmd.py new file mode 100644 index 0000000..5bdf880 --- /dev/null +++ b/botcmd.py @@ -0,0 +1,25 @@ +# initialize() +# Called to initialize the IRC bot +# Runs before even logger is brought up, and blocks further bringup until it's done +def initialize(): + ... + +# 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 or bytearrays +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 +# All strings are bytestrings or bytearrays +def handle_nonmessage(*, prefix, command, arguments, irc): + ... From df7758ae0f433f8f68b6416c9e431364c42a3fd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juhani=20Krekel=C3=A4?= Date: Wed, 6 Sep 2017 20:49:53 +0300 Subject: [PATCH 21/47] Fix capitalization of HynneFlip --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b726488..996cf30 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ o3-base ======= This project seeks to replace o2-base as an easy python base to build IRC bots on. It is not related to the faile oonbotti3 project, but is rather based on -the IRC bot framework of hynneflip. +the IRC bot framework of HynneFlip. License ------- From 845fb1bab2126944e19ffdcebe681e8fa139ca59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juhani=20Krekel=C3=A4?= Date: Wed, 6 Sep 2017 20:50:58 +0300 Subject: [PATCH 22/47] =?UTF-8?q?Fix=20typo=20faile=20=E2=86=92=20failed?= =?UTF-8?q?=20in=20README.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 996cf30..7871387 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ o3-base ======= This project seeks to replace o2-base as an easy python base to build IRC bots -on. It is not related to the faile oonbotti3 project, but is rather based on +on. It is not related to the failed oonbotti3 project, but is rather based on the IRC bot framework of HynneFlip. License From 6689131bc795d43f985efa2fba02fd717ab432ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juhani=20Krekel=C3=A4?= Date: Wed, 13 Sep 2017 23:17:30 +0300 Subject: [PATCH 23/47] Add bot_response() to API --- ircbot.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ircbot.py b/ircbot.py index 0d9276d..2de60d7 100644 --- a/ircbot.py +++ b/ircbot.py @@ -69,6 +69,10 @@ class API: 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.""" + self.msg(recipient, ('\u200b' + message).encode('utf-8')) + def nick(self, nick): """Send a NICK command and update the internal nick tracking state""" with self.serverthread_object.nick_lock: From 7ce9b451666825e99a5ce8ae0aa3d37fd4ba1c03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juhani=20Krekel=C3=A4?= Date: Sun, 10 Dec 2017 14:42:12 +0200 Subject: [PATCH 24/47] Fix typo in API docstrings --- ircbot.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ircbot.py b/ircbot.py index 2de60d7..b78b034 100644 --- a/ircbot.py +++ b/ircbot.py @@ -60,8 +60,8 @@ class API: self.serverthread_object = serverthread_object def send_raw(self, line): - """Sends a raw line (will terminate it itself. - Don't use unless you are completely sure you know wha you're doing.""" + """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): From 72056025fb489befb6880254f04bb71a03917cbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juhani=20Krekel=C3=A4?= Date: Sun, 10 Dec 2017 15:46:53 +0200 Subject: [PATCH 25/47] Fix API.error() --- ircbot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ircbot.py b/ircbot.py index b78b034..5fa527c 100644 --- a/ircbot.py +++ b/ircbot.py @@ -94,7 +94,7 @@ class API: def error(self, message): """Log an error""" - self.serverthread_object.logging_channel.send((constants.logmessage_types.internal, constants.internal_submessage_types.error, message)) + self.serverthread_object.logging_channel.send((logmessage_types.internal, internal_submessage_types.error, message)) # ServerThread(server, control_socket) From d3a214ec86affa65bdf9835b964f55312bc99f44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juhani=20Krekel=C3=A4?= Date: Sun, 10 Dec 2017 17:43:39 +0200 Subject: [PATCH 26/47] Add ping timeouting --- constants.py | 5 +- cron.py | 150 +++++++++++++++++++++++++++++++++++++++++++++++++++ ircbot.py | 41 +++++++++++--- 3 files changed, 187 insertions(+), 9 deletions(-) create mode 100644 cron.py diff --git a/constants.py b/constants.py index 8981855..93719ee 100644 --- a/constants.py +++ b/constants.py @@ -7,4 +7,7 @@ class internal_submessage_types(enum.Enum): quit, error = range(2) class controlmessage_types(enum.Enum): - quit, send_line = range(2) + quit, send_line, ping, ping_timeout = range(4) + +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 index 5fa527c..91a564f 100644 --- a/ircbot.py +++ b/ircbot.py @@ -7,8 +7,9 @@ from collections import namedtuple import channel from constants import logmessage_types, internal_submessage_types, controlmessage_types -import line_handling import botcmd +import cron +import line_handling Server = namedtuple('Server', ['host', 'port', 'nick', 'realname', 'channels']) @@ -100,9 +101,10 @@ class API: # ServerThread(server, control_socket) # Creates a new server main loop thread class ServerThread(threading.Thread): - def __init__(self, server, control_channel, logging_channel): + 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() @@ -122,14 +124,18 @@ class ServerThread(threading.Thread): with self.server_socket_write_lock: self.server_socket.sendall(line + b'\r\n') - # Don't log PONGs - if not (len(line) >= 5 and line[:5] == b'PONG '): + # 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' ') - if command.upper() == b'PING': + 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'))) line_handling.handle_line(line, irc = self.api) @@ -163,6 +169,10 @@ class ServerThread(threading.Thread): self.handle_line(line) + # Remove possible pending ping timeout timer and reset ping timer to 5 minutes + cron.delete(self.cron_control_channel, self.control_channel, (controlmessage_types.ping_timeout,)) + cron.reschedule(self.cron_control_channel, 5 * 60, self.control_channel, (controlmessage_types.ping,)) + # Control elif fd == self.control_channel.fileno(): command_type, *arguments = self.control_channel.recv() @@ -175,6 +185,16 @@ class ServerThread(threading.Thread): 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 3 minutes + cron.reschedule(self.cron_control_channel, 3 * 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')) + quitting = 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)) @@ -215,12 +235,15 @@ class ServerThread(threading.Thread): # 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, logging_channel) → control_channel # Creates a ServerThread for given server and returns the channel for controlling it -def spawn_serverthread(server, logging_channel): +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, logging_channel).start() + ServerThread(server, control_channel, cron_control_channel, logging_channel).start() return control_channel # spawn_loggerthread() → logging_channel, dead_notify_channel @@ -236,8 +259,9 @@ if __name__ == '__main__': botcmd.initialize() + cron_control_channel = cron.start() logging_channel, dead_notify_channel = spawn_loggerthread() - control_channel = spawn_serverthread(server, logging_channel) + control_channel = spawn_serverthread(server, cron_control_channel, logging_channel) while True: message = dead_notify_channel.recv(blocking = False) @@ -250,6 +274,7 @@ if __name__ == '__main__': 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 len(cmd) > 0 and cmd[0] == '/': From 11d770010f1558d329880d5182969b0747ee535d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juhani=20Krekel=C3=A4?= Date: Sun, 31 Dec 2017 22:03:01 +0200 Subject: [PATCH 27/47] Remove a TODO for timeouting, as it has been implemented --- ircbot.py | 1 - 1 file changed, 1 deletion(-) diff --git a/ircbot.py b/ircbot.py index 91a564f..bca069e 100644 --- a/ircbot.py +++ b/ircbot.py @@ -149,7 +149,6 @@ class ServerThread(threading.Thread): # Keep buffer for input server_input_buffer = bytearray() - # TODO: Implement timeouting quitting = False while not quitting: # Wait until we can do something From 4f523ef53431459a92784fe73b232f2194c2dd08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juhani=20Krekel=C3=A4?= Date: Sun, 31 Dec 2017 22:08:33 +0200 Subject: [PATCH 28/47] Update comments to match reality --- ircbot.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/ircbot.py b/ircbot.py index bca069e..5d19e02 100644 --- a/ircbot.py +++ b/ircbot.py @@ -98,7 +98,7 @@ class API: self.serverthread_object.logging_channel.send((logmessage_types.internal, internal_submessage_types.error, message)) -# ServerThread(server, control_socket) +# 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): @@ -141,7 +141,7 @@ class ServerThread(threading.Thread): line_handling.handle_line(line, irc = self.api) def mainloop(self): - # Register both the server socket and the control channel to or polling object + # 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) @@ -192,6 +192,7 @@ class ServerThread(threading.Thread): elif command_type == controlmessage_types.ping_timeout: self.logging_channel.send((logmessage_types.internal, internal_submessage_types.error, 'Ping timeout')) + # TODO: Don't quit, restart quitting = True else: @@ -237,7 +238,7 @@ class ServerThread(threading.Thread): # Tell cron we're quiting cron.quit(cron_control_channel) -# spawn_serverthread(server, logging_channel) → 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() From c910f08b1e176b5b3ac4045be0d06659b1f74871 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juhani=20Krekel=C3=A4?= Date: Mon, 1 Jan 2018 00:26:54 +0200 Subject: [PATCH 29/47] Add flood protection --- ircbot.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/ircbot.py b/ircbot.py index 5d19e02..69af3c1 100644 --- a/ircbot.py +++ b/ircbot.py @@ -2,6 +2,7 @@ import select import socket import threading +import time from collections import namedtuple import channel @@ -109,6 +110,9 @@ class ServerThread(threading.Thread): 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() @@ -121,6 +125,20 @@ class ServerThread(threading.Thread): # 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: self.server_socket.sendall(line + b'\r\n') From 1081a6d09257e0c75e137916482342c8c05842df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juhani=20Krekel=C3=A4?= Date: Tue, 2 Jan 2018 18:31:23 +0200 Subject: [PATCH 30/47] Add botcmd.on_connect hook, to allow better control over bot bringup --- botcmd.py | 6 ++++++ ircbot.py | 6 +++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/botcmd.py b/botcmd.py index 5bdf880..aadc318 100644 --- a/botcmd.py +++ b/botcmd.py @@ -4,6 +4,12 @@ def initialize(): ... +# on_connect(*, irc) +# Called after IRC bot has connected and sent the USER/NICk commands but not yet attempted anything else +# Blocks the bot until it's done, including PING/PONG handling +def on_connect(*, irc): + ... + # handle_message(*, prefix, message, nick, channel, irc) # Called for PRIVMSGs. # prefix is the prefix at the start of the message, without the leading ':' diff --git a/ircbot.py b/ircbot.py index 69af3c1..398138b 100644 --- a/ircbot.py +++ b/ircbot.py @@ -237,9 +237,13 @@ class ServerThread(threading.Thread): # Run initialization self.send_line_raw(b'USER HynneFlip a a :' + self.server.realname.encode('utf-8')) - # Set up nick and channels + # 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')) From e292dc2b4cf7c45491246411df3716463b62a5e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juhani=20Krekel=C3=A4?= Date: Tue, 2 Jan 2018 18:33:04 +0200 Subject: [PATCH 31/47] Add API.cron, for easier bot access to the cron functionality --- ircbot.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ircbot.py b/ircbot.py index 398138b..0bcb61f 100644 --- a/ircbot.py +++ b/ircbot.py @@ -61,6 +61,9 @@ class API: # 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.""" From bb71e5ae9c35ee62c896ea57fbf6d78addb9f862 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juhani=20Krekel=C3=A4?= Date: Tue, 2 Jan 2018 20:33:17 +0200 Subject: [PATCH 32/47] Have bot reconnect by default on connection failures --- constants.py | 2 +- ircbot.py | 130 +++++++++++++++++++++++++++++++++++++++------------ 2 files changed, 100 insertions(+), 32 deletions(-) diff --git a/constants.py b/constants.py index 93719ee..1764ee3 100644 --- a/constants.py +++ b/constants.py @@ -7,7 +7,7 @@ class internal_submessage_types(enum.Enum): quit, error = range(2) class controlmessage_types(enum.Enum): - quit, send_line, ping, ping_timeout = range(4) + quit, reconnect, send_line, ping, ping_timeout = range(5) class cronmessage_types(enum.Enum): quit, schedule, delete, reschedule = range(4) diff --git a/ircbot.py b/ircbot.py index 0bcb61f..79489b6 100644 --- a/ircbot.py +++ b/ircbot.py @@ -171,7 +171,8 @@ class ServerThread(threading.Thread): server_input_buffer = bytearray() quitting = False - while not quitting: + reconnecting = False + while not quitting and not reconnecting: # Wait until we can do something for fd, event in poll.poll(): # Server @@ -179,6 +180,14 @@ class ServerThread(threading.Thread): # 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 @@ -189,9 +198,13 @@ class ServerThread(threading.Thread): self.handle_line(line) - # Remove possible pending ping timeout timer and reset ping timer to 5 minutes - cron.delete(self.cron_control_channel, self.control_channel, (controlmessage_types.ping_timeout,)) - cron.reschedule(self.cron_control_channel, 5 * 60, self.control_channel, (controlmessage_types.ping,)) + # Remove possible pending ping timeout timer and reset ping timer to 5 minutes + cron.delete(self.cron_control_channel, self.control_channel, (controlmessage_types.ping_timeout,)) + cron.reschedule(self.cron_control_channel, 5 * 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(): @@ -213,8 +226,10 @@ class ServerThread(threading.Thread): elif command_type == controlmessage_types.ping_timeout: self.logging_channel.send((logmessage_types.internal, internal_submessage_types.error, 'Ping timeout')) - # TODO: Don't quit, restart - quitting = True + reconnecting = True + + elif command_type == controlmessage_types.reconnect: + reconnecting = True else: error_message = 'Unknown control message: %s' % repr((command_type, *arguments)) @@ -223,39 +238,87 @@ class ServerThread(threading.Thread): else: assert False #unreachable + if reconnecting: + return True + else: + return False + def run(self): - # Connect to given server - address = (self.server.host, self.server.port) - try: - self.server_socket = socket.create_connection(address) - except ConnectionRefusedError: - # Tell controller we failed - self.logging_channel.send((logmessage_types.internal, internal_submessage_types.error, "Can't connect to %s:%s" % address)) - self.logging_channel.send((logmessage_types.internal, internal_submessage_types.quit)) - return + while True: + # Connect to given server + address = (self.server.host, self.server.port) + try: + self.server_socket = socket.create_connection(address) + except ConnectionRefusedError: + # Tell controller we failed + self.logging_channel.send((logmessage_types.internal, internal_submessage_types.error, "Can't connect to %s:%s" % address)) - # Create an API object to give to outside line handler - self.api = API(self) + # Try reconnecting in a minute + cron.reschedule(self.cron_control_channel, 60, self.control_channel, (controlmessage_types.reconnect,)) - # Run initialization - self.send_line_raw(b'USER HynneFlip a a :' + self.server.realname.encode('utf-8')) + # Handle messages + reconnect = True + while True: + command_type, *arguments = self.control_channel.recv() - # Set up nick - self.api.nick(self.server.nick.encode('utf-8')) + if command_type == controlmessage_types.reconnect: + break - # Run the on_connect hook, to allow further setup - botcmd.on_connect(irc = self.api) + elif command_type == controlmessage_types.quit: + reconnect = False + break - # Join channels - for channel in self.server.channels: - self.api.join(channel.encode('utf-8')) + 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)) - # Run mainloop - self.mainloop() + # Remove the reconnect message in case we were told to reconnnect manually + cron.delete(self.cron_control_channel, self.control_channel, (controlmessage_types.reconnect,)) - # Tell the server we're quiting - self.send_line_raw(b'QUIT :HynneFlip exiting normally') - self.server_socket.close() + 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 HynneFlip a a :' + 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 5 minutes of no activity + cron.reschedule(self.cron_control_channel, 5 * 60, self.control_channel, (controlmessage_types.ping,)) + + # Run mainloop + reconnecting = self.mainloop() + + if not reconnecting: + # Tell the server we're quiting + self.send_line_raw(b'QUIT :HynneFlip exiting normally') + self.server_socket.close() + + break + + else: + # Tell server we're reconnecting + self.send_line_raw(b'QUIT :Reconnecting') + self.server_socket.close() + + except BrokenPipeError as err: + # Connection broke, log it and try to reconnect + self.logging_channel.send((logmessage_types.internal, internal_submessage_types.error, 'Broken socket/pipe')) + self.server_socket.close() # Tell controller we're quiting self.logging_channel.send((logmessage_types.internal, internal_submessage_types.quit)) @@ -280,6 +343,7 @@ def spawn_loggerthread(): return logging_channel, dead_notify_channel if __name__ == '__main__': + # TODO: read from a configuration file server = Server(host = 'irc.freenode.net', port = 6667, nick = 'o3-base', realname = 'IRC bot based on o3-base', channels = ['##ingsoc']) botcmd.initialize() @@ -302,5 +366,9 @@ if __name__ == '__main__': 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:])) From df82f06fa9860e4a6ae96e3323bd725c3067dd5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juhani=20Krekel=C3=A4?= Date: Tue, 2 Jan 2018 20:46:50 +0200 Subject: [PATCH 33/47] Ping timeout faster, and don't crash if network is unreachable --- ircbot.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/ircbot.py b/ircbot.py index 79489b6..0ae9fbb 100644 --- a/ircbot.py +++ b/ircbot.py @@ -198,9 +198,9 @@ class ServerThread(threading.Thread): self.handle_line(line) - # Remove possible pending ping timeout timer and reset ping timer to 5 minutes + # 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, 5 * 60, self.control_channel, (controlmessage_types.ping,)) + cron.reschedule(self.cron_control_channel, 3 * 60, self.control_channel, (controlmessage_types.ping,)) else: error_message = 'Event on server socket: %s' % event @@ -221,8 +221,8 @@ class ServerThread(threading.Thread): elif command_type == controlmessage_types.ping: assert len(arguments) == 0 self.send_line_raw(b'PING :foo') - # Reset ping timeout timer to 3 minutes - cron.reschedule(self.cron_control_channel, 3 * 60, self.control_channel, (controlmessage_types.ping_timeout,)) + # 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')) @@ -249,7 +249,7 @@ class ServerThread(threading.Thread): address = (self.server.host, self.server.port) try: self.server_socket = socket.create_connection(address) - except ConnectionRefusedError: + 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)) @@ -297,8 +297,8 @@ class ServerThread(threading.Thread): for channel in self.server.channels: self.api.join(channel.encode('utf-8')) - # Schedule a ping to be sent in 5 minutes of no activity - cron.reschedule(self.cron_control_channel, 5 * 60, self.control_channel, (controlmessage_types.ping,)) + # 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() From 029083b6617b2911eea33ef524986550f1c99f2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juhani=20Krekel=C3=A4?= Date: Wed, 3 Jan 2018 01:11:21 +0200 Subject: [PATCH 34/47] Remove an out-of-date TODO --- ircbot.py | 1 - 1 file changed, 1 deletion(-) diff --git a/ircbot.py b/ircbot.py index 0ae9fbb..ed2f7cf 100644 --- a/ircbot.py +++ b/ircbot.py @@ -40,7 +40,6 @@ class LoggerThread(threading.Thread): assert len(message_data) == 1 print('--- Quit') - # TODO: don't quit, restart self.dead_notify_channel.send((controlmessage_types.quit,)) break From 002f1eecf89a8a2c9f90a66d6950ab4e9a89bf67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juhani=20Krekel=C3=A4?= Date: Wed, 3 Jan 2018 18:08:24 +0200 Subject: [PATCH 35/47] Read config from a configuration file --- .gitignore | 1 + README.md | 6 ++++++ bot.conf.example | 7 +++++++ botcmd.py | 7 +++++-- ircbot.py | 31 ++++++++++++++++++++++++------- 5 files changed, 43 insertions(+), 9 deletions(-) create mode 100644 bot.conf.example diff --git a/.gitignore b/.gitignore index 15c993e..5b91b8e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ __pycache__ *.pyc *.swp +bot.conf diff --git a/README.md b/README.md index 7871387..b83dcc5 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,12 @@ This project seeks to replace o2-base as an easy python base to build IRC bots on. It is not related to the failed oonbotti3 project, but is rather based on the IRC bot framework of HynneFlip. +Setup +===== +Copy `bot.conf.example` to `bot.conf` and run `python3 ircbot.py` to bring up +the bot skeleton, which can be used as a very quick and dirty IRC client. User +code goes in `botcmd.py`. + License ------- Everything in this repo is under UNLICENSE / CC0. diff --git a/bot.conf.example b/bot.conf.example new file mode 100644 index 0000000..accc678 --- /dev/null +++ b/bot.conf.example @@ -0,0 +1,7 @@ +[server] +host = irc.freenode.net +port = 6667 +nick = o3-base +username = o3-base +realname = IRC bot based on o3-base +channels = ##ingsoc diff --git a/botcmd.py b/botcmd.py index aadc318..ce409f2 100644 --- a/botcmd.py +++ b/botcmd.py @@ -1,12 +1,14 @@ -# initialize() +# initialize(*, config) # Called to initialize the IRC bot # Runs before even logger is brought up, and blocks further bringup until it's done -def initialize(): +# 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 # Blocks the bot until it's done, including PING/PONG handling +# irc is the IRC API object def on_connect(*, irc): ... @@ -26,6 +28,7 @@ def handle_message(*, prefix, message, nick, channel, irc): # 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 or bytearrays def handle_nonmessage(*, prefix, command, arguments, irc): ... diff --git a/ircbot.py b/ircbot.py index ed2f7cf..bfbeae2 100644 --- a/ircbot.py +++ b/ircbot.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +import configparser import select import socket import threading @@ -12,7 +13,7 @@ import botcmd import cron import line_handling -Server = namedtuple('Server', ['host', 'port', 'nick', 'realname', 'channels']) +Server = namedtuple('Server', ['host', 'port', 'nick', 'username', 'realname', 'channels']) class LoggerThread(threading.Thread): def __init__(self, logging_channel, dead_notify_channel): @@ -284,7 +285,7 @@ class ServerThread(threading.Thread): try: # Run initialization - self.send_line_raw(b'USER HynneFlip a a :' + self.server.realname.encode('utf-8')) + 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')) @@ -304,7 +305,7 @@ class ServerThread(threading.Thread): if not reconnecting: # Tell the server we're quiting - self.send_line_raw(b'QUIT :HynneFlip exiting normally') + self.send_line_raw(b'QUIT :%s exiting normally' % self.server.username.encode('utf-8')) self.server_socket.close() break @@ -341,11 +342,27 @@ def spawn_loggerthread(): LoggerThread(logging_channel, dead_notify_channel).start() return logging_channel, dead_notify_channel -if __name__ == '__main__': - # TODO: read from a configuration file - server = Server(host = 'irc.freenode.net', port = 6667, nick = 'o3-base', realname = 'IRC bot based on o3-base', channels = ['##ingsoc']) +# 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') - botcmd.initialize() + 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() From 6a2a7503cb5b7ebc1368c79dd0d6e7a869becd36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juhani=20Krekel=C3=A4?= Date: Wed, 3 Jan 2018 21:32:00 +0200 Subject: [PATCH 36/47] Add bot_response_bytes --- ircbot.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ircbot.py b/ircbot.py index bfbeae2..3adbb5d 100644 --- a/ircbot.py +++ b/ircbot.py @@ -78,6 +78,10 @@ class API: """Prefix message with ZWSP and convert from unicode to bytestring.""" self.msg(recipient, ('\u200b' + message).encode('utf-8')) + def bot_response_bytes(self, recipient, message): + """Prefix message (bytestring) with ZWSP""" + 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: From a74173fee2d09f92ca81a5c29ada87e84cb197af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juhani=20Krekel=C3=A4?= Date: Sat, 20 Jan 2018 16:49:37 +0200 Subject: [PATCH 37/47] Replace irc.bot_response and irc.bot_response_bytes with an interface that autoconverts if needed --- ircbot.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/ircbot.py b/ircbot.py index 3adbb5d..17b602c 100644 --- a/ircbot.py +++ b/ircbot.py @@ -75,11 +75,10 @@ class API: self.serverthread_object.send_line_raw(line) def bot_response(self, recipient, message): - """Prefix message with ZWSP and convert from unicode to bytestring.""" - self.msg(recipient, ('\u200b' + message).encode('utf-8')) + """Prefix message with ZWSP and convert from unicode to bytestring if necessary.""" + if isinstance(message, str): + message = message.encode('utf-8') - def bot_response_bytes(self, recipient, message): - """Prefix message (bytestring) with ZWSP""" self.msg(recipient, '\u200b'.encode('utf-8') + message) def nick(self, nick): From 03f0011e623f4adc4fd4a9e9c045a48591dab15d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juhani=20Krekel=C3=A4?= Date: Sat, 20 Jan 2018 16:54:13 +0200 Subject: [PATCH 38/47] Add botcmd.on_quit hook for bot cleanup --- botcmd.py | 7 +++++++ ircbot.py | 3 +++ 2 files changed, 10 insertions(+) diff --git a/botcmd.py b/botcmd.py index ce409f2..25dbce8 100644 --- a/botcmd.py +++ b/botcmd.py @@ -12,6 +12,13 @@ def initialize(*, config): 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 ':' diff --git a/ircbot.py b/ircbot.py index 17b602c..0320f11 100644 --- a/ircbot.py +++ b/ircbot.py @@ -307,6 +307,9 @@ class ServerThread(threading.Thread): 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() From 64a31a61e8d632b634f900aef4c2a7e21ecace68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juhani=20Krekel=C3=A4?= Date: Sat, 20 Jan 2018 17:01:02 +0200 Subject: [PATCH 39/47] Add irc.part and irc.get_channels --- ircbot.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/ircbot.py b/ircbot.py index 0320f11..6735754 100644 --- a/ircbot.py +++ b/ircbot.py @@ -100,6 +100,18 @@ class API: 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 error(self, message): """Log an error""" self.serverthread_object.logging_channel.send((logmessage_types.internal, internal_submessage_types.error, message)) From 49cd9794495b107843cbaad8681c0adc3a6c929b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juhani=20Krekel=C3=A4?= Date: Fri, 9 Feb 2018 23:06:59 +0200 Subject: [PATCH 40/47] Apparently poll.register can fail with TimeoutError. Handle those --- ircbot.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ircbot.py b/ircbot.py index 6735754..aafd9ab 100644 --- a/ircbot.py +++ b/ircbot.py @@ -333,9 +333,9 @@ class ServerThread(threading.Thread): self.send_line_raw(b'QUIT :Reconnecting') self.server_socket.close() - except BrokenPipeError as err: + 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')) + 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 From 53caed0d1c32d2c9983ef71e7cc87e47de8f3a3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juhani=20Krekel=C3=A4?= Date: Sun, 25 Feb 2018 21:03:05 +0200 Subject: [PATCH 41/47] Add API.set_nick, .set_channels, .log --- constants.py | 2 +- ircbot.py | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/constants.py b/constants.py index 1764ee3..402a494 100644 --- a/constants.py +++ b/constants.py @@ -1,7 +1,7 @@ import enum class logmessage_types(enum.Enum): - sent, received, internal = range(3) + sent, received, internal, status = range(4) class internal_submessage_types(enum.Enum): quit, error = range(2) diff --git a/ircbot.py b/ircbot.py index aafd9ab..cdeb1e5 100644 --- a/ircbot.py +++ b/ircbot.py @@ -51,6 +51,11 @@ class LoggerThread(threading.Thread): else: print('--- ???', message_data) + # Messages about status from the bot code + elif message_type == logmessage_types.status: + assert len(message_data) == 1 + print('*', message_data[0]) + else: print('???', message_type, message_data) @@ -93,6 +98,11 @@ class API: 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: @@ -112,6 +122,15 @@ class API: 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 log(self, message): + """Log a status message""" + self.serverthread_object.logging_channel.send((logmessage_types.status, message)) + def error(self, message): """Log an error""" self.serverthread_object.logging_channel.send((logmessage_types.internal, internal_submessage_types.error, message)) From b4233fda4a56b2a973b15d598a53c65676e9537f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juhani=20Krekel=C3=A4?= Date: Sun, 25 Feb 2018 21:18:28 +0200 Subject: [PATCH 42/47] Support full print()-style control in API.log() --- ircbot.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/ircbot.py b/ircbot.py index cdeb1e5..97ae380 100644 --- a/ircbot.py +++ b/ircbot.py @@ -53,8 +53,9 @@ class LoggerThread(threading.Thread): # Messages about status from the bot code elif message_type == logmessage_types.status: - assert len(message_data) == 1 - print('*', message_data[0]) + assert len(message_data) == 2 + print('*', end='') + print(*message_data[0], **message_data[1]) else: print('???', message_type, message_data) @@ -127,9 +128,9 @@ class API: with self.serverthread_object.channels_lock: self.serverthread_object.channels = channels - def log(self, message): - """Log a status message""" - self.serverthread_object.logging_channel.send((logmessage_types.status, message)) + 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""" From cdd91bb49e73282c0a593f0444ee0729631f831d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juhani=20Krekel=C3=A4?= Date: Fri, 30 Mar 2018 23:14:24 +0300 Subject: [PATCH 43/47] Add support for closing the channel --- channel.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/channel.py b/channel.py index 4d15dc1..2c754ad 100644 --- a/channel.py +++ b/channel.py @@ -12,14 +12,14 @@ class Channel: self.poll.register(self.read_socket, select.POLLIN) # Store messages in a list - self.mesages = [] + 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.mesages.append(message) + self.messages.append(message) def recv(self, blocking = True): # Timeout of -1 will make poll wait until data is available @@ -41,7 +41,7 @@ class Channel: # 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.mesages.pop(0) + message = self.messages.pop(0) self.read_socket.recv(1) return message @@ -49,3 +49,15 @@ class Channel: 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() From b2ca8a059964a834b8832cb9f5b2a4c9efd0f0dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juhani=20Krekel=C3=A4?= Date: Thu, 14 Jun 2018 10:50:30 +0300 Subject: [PATCH 44/47] Only pass bytestrings (and not bytearrays) to user code --- botcmd.py | 5 +++-- ircbot.py | 2 ++ line_handling.py | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/botcmd.py b/botcmd.py index 25dbce8..217ab41 100644 --- a/botcmd.py +++ b/botcmd.py @@ -7,6 +7,7 @@ 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): @@ -26,7 +27,7 @@ def on_quit(*, irc): # 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 or bytearrays +# All strings are bytestrings def handle_message(*, prefix, message, nick, channel, irc): ... @@ -36,6 +37,6 @@ def handle_message(*, prefix, message, nick, channel, irc): # 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 or bytearrays +# All strings are bytestrings def handle_nonmessage(*, prefix, command, arguments, irc): ... diff --git a/ircbot.py b/ircbot.py index 97ae380..2c5214c 100644 --- a/ircbot.py +++ b/ircbot.py @@ -194,6 +194,8 @@ class ServerThread(threading.Thread): 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): diff --git a/line_handling.py b/line_handling.py index 6b84b45..21ccccf 100644 --- a/line_handling.py +++ b/line_handling.py @@ -114,7 +114,7 @@ class LineHandlerThread(threading.Thread): try: prefix, command, arguments = parse_line(self.line) except LineParsingError: - irc.error("Cannot parse line" + self.line.decode(encoding = 'utf-8', errors = 'replace')) + 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 From baaa93d2fa1ca3393ffdd8faf2836952e9abe261 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juhani=20Krekel=C3=A4?= Date: Sun, 19 Aug 2018 19:52:42 +0300 Subject: [PATCH 45/47] Add irc.quit() --- ircbot.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/ircbot.py b/ircbot.py index 2c5214c..5af87fb 100644 --- a/ircbot.py +++ b/ircbot.py @@ -128,6 +128,11 @@ class API: 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)) From 13c62c85775b97fa30765bec57bb4a9e2f68e1e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juhani=20Krekel=C3=A4?= Date: Sat, 11 May 2019 14:37:22 +0300 Subject: [PATCH 46/47] Use the CC0 file --- CC0 | 116 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ UNLICENSE | 24 ----------- 2 files changed, 116 insertions(+), 24 deletions(-) create mode 100644 CC0 delete mode 100644 UNLICENSE 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/UNLICENSE b/UNLICENSE deleted file mode 100644 index 69843e4..0000000 --- a/UNLICENSE +++ /dev/null @@ -1,24 +0,0 @@ -This is free and unencumbered software released into the public domain. - -Anyone is free to copy, modify, publish, use, compile, sell, or -distribute this software, either in source code form or as a compiled -binary, for any purpose, commercial or non-commercial, and by any -means. - -In jurisdictions that recognize copyright laws, the author or authors -of this software dedicate any and all copyright interest in the -software to the public domain. We make this dedication for the benefit -of the public at large and to the detriment of our heirs and -successors. We intend this dedication to be an overt act of -relinquishment in perpetuity of all present and future rights to this -software under copyright law. - -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 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. - -For more information, please refer to [http://unlicense.org] From 635cab74408064e71699136c929833b000ab8453 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juhani=20Krekel=C3=A4?= Date: Sat, 11 May 2019 15:32:27 +0300 Subject: [PATCH 47/47] Don't allow sending to the closed server socket if we are reconnecting --- ircbot.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/ircbot.py b/ircbot.py index 5af87fb..a5ab9dd 100644 --- a/ircbot.py +++ b/ircbot.py @@ -183,7 +183,10 @@ class ServerThread(threading.Thread): time.sleep(wait) with self.server_socket_write_lock: - self.server_socket.sendall(line + b'\r\n') + 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 ')): @@ -351,6 +354,7 @@ class ServerThread(threading.Thread): # 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 @@ -358,7 +362,9 @@ class ServerThread(threading.Thread): else: # Tell server we're reconnecting self.send_line_raw(b'QUIT :Reconnecting') - self.server_socket.close() + 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