Incorporate o3-base

This commit is contained in:
Juhani Krekelä 2021-01-20 18:03:43 +02:00
commit 3c41450b21
9 changed files with 976 additions and 2 deletions

4
.gitignore vendored
View File

@ -2,6 +2,6 @@ msgs.txt
trusted.txt
gods.txt
startcmd.txt
*.pyc
ircbot.sh
*.swp
__pycache__
bot.conf

116
CC0 Normal file
View File

@ -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
<http://creativecommons.org/publicdomain/zero/1.0/>

7
bot.conf.example Normal file
View File

@ -0,0 +1,7 @@
[server]
host = irc.freenode.net
port = 6667
nick = oonbotti2
username = oonbotti2
realname = oonbotti2
channels = ##ingsoc

42
botcmd.py Normal file
View File

@ -0,0 +1,42 @@
# initialize(*, config)
# Called to initialize the IRC bot
# Runs before even logger is brought up, and blocks further bringup until it's done
# config is a configpatser.ConfigParser object containig contents of bot.conf
def initialize(*, config):
...
# on_connect(*, irc)
# Called after IRC bot has connected and sent the USER/NICk commands but not yet attempted anything else
# Called for every reconnect
# Blocks the bot until it's done, including PING/PONG handling
# irc is the IRC API object
def on_connect(*, irc):
...
# on_quit(*, irc)
# Called just before IRC bot sends QUIT
# Blocks the bot until it's done, including PING/PONG handling
# irc is the IRC API object
def on_quit(*, irc):
...
# handle_message(*, prefix, message, nick, channel, irc)
# Called for PRIVMSGs.
# prefix is the prefix at the start of the message, without the leading ':'
# message is the contents of the message
# nick is who sent the message
# channel is where you should send the response (note: in queries nick == channel)
# irc is the IRC API object
# All strings are bytestrings
def handle_message(*, prefix, message, nick, channel, irc):
...
# handle_nonmessage(*, prefix, command, arguments, irc)
# Called for all other commands than PINGs and PRIVMSGs.
# prefix is the prefix at the start of the message, without the leading ':'
# command is the command or number code
# arguments is rest of the arguments of the command, represented as a list. ':'-arguments are handled automatically
# irc is the IRC API object
# All strings are bytestrings
def handle_nonmessage(*, prefix, command, arguments, irc):
...

63
channel.py Normal file
View File

@ -0,0 +1,63 @@
import select
import socket
import threading
class Channel:
"""An asynchronic communication channel that can be used to send python object and can be poll()ed."""
def __init__(self):
# We use a socket to enable polling and blocking reads
self.write_socket, self.read_socket = socket.socketpair()
self.poll = select.poll()
self.poll.register(self.read_socket, select.POLLIN)
# Store messages in a list
self.messages = []
self.messages_lock = threading.Lock()
def send(self, message):
# Add message to the list of messages and write to the write socket to signal there's data to read
with self.messages_lock:
self.write_socket.sendall(b'!')
self.messages.append(message)
def recv(self, blocking = True):
# Timeout of -1 will make poll wait until data is available
# Timeout of 0 will make poll exit immediately if there's no data
if blocking:
timeout = -1
else:
timeout = 0
# See if there is data to read / wait until there is
results = self.poll.poll(timeout)
# None of the sockets were ready. This can only happen if we weren't blocking
# Return None to signal lack of data
if len(results) == 0:
assert not blocking
return None
# Remove first message from the list (FIFO principle), and read one byte from the socket
# This keeps the number of available messages and the number of bytes readable in the socket in sync
with self.messages_lock:
message = self.messages.pop(0)
self.read_socket.recv(1)
return message
def fileno(self):
# Allows for a Channel object to be passed directly to poll()
return self.read_socket.fileno()
def close(self):
# Close the file descriptors, so that we aren't leaking them
self.write_socket.close()
self.read_socket.close()
# Support with-statements
def __enter__(self):
return self
def __exit__(self, exception_type, exception_value, traceback):
self.close()

13
constants.py Normal file
View File

@ -0,0 +1,13 @@
import enum
class logmessage_types(enum.Enum):
sent, received, internal, status = range(4)
class internal_submessage_types(enum.Enum):
quit, error = range(2)
class controlmessage_types(enum.Enum):
quit, reconnect, send_line, ping, ping_timeout = range(5)
class cronmessage_types(enum.Enum):
quit, schedule, delete, reschedule = range(4)

150
cron.py Normal file
View File

@ -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))

441
ircbot.py Normal file
View File

@ -0,0 +1,441 @@
#!/usr/bin/env python3
import configparser
import select
import socket
import threading
import time
from collections import namedtuple
import channel
from constants import logmessage_types, internal_submessage_types, controlmessage_types
import botcmd
import cron
import line_handling
Server = namedtuple('Server', ['host', 'port', 'nick', 'username', 'realname', 'channels'])
class LoggerThread(threading.Thread):
def __init__(self, logging_channel, dead_notify_channel):
self.logging_channel = logging_channel
self.dead_notify_channel = dead_notify_channel
threading.Thread.__init__(self)
def run(self):
while True:
message_type, *message_data = self.logging_channel.recv()
# Lines that were sent between server and client
if message_type == logmessage_types.sent:
assert len(message_data) == 1
print('>' + message_data[0])
elif message_type == logmessage_types.received:
assert len(message_data) == 1
print('<' + message_data[0])
# Messages that are from internal components
elif message_type == logmessage_types.internal:
if message_data[0] == internal_submessage_types.quit:
assert len(message_data) == 1
print('--- Quit')
self.dead_notify_channel.send((controlmessage_types.quit,))
break
elif message_data[0] == internal_submessage_types.error:
assert len(message_data) == 2
print('--- Error', message_data[1])
else:
print('--- ???', message_data)
# Messages about status from the bot code
elif message_type == logmessage_types.status:
assert len(message_data) == 2
print('*', end='')
print(*message_data[0], **message_data[1])
else:
print('???', message_type, message_data)
# API(serverthread_object)
# Create a new API object corresponding to given ServerThread object
class API:
def __init__(self, serverthread_object):
# We need to access the internal functions of the ServerThread object in order to send lines etc.
self.serverthread_object = serverthread_object
# Have the cron object accessible more easily
self.cron = serverthread_object.cron_control_channel
def send_raw(self, line):
"""Sends a raw line (will terminate it itself.)
Don't use unless you are completely sure you know what you're doing."""
self.serverthread_object.send_line_raw(line)
def msg(self, recipient, message):
"""Make sending PRIVMSGs much nicer"""
line = b'PRIVMSG ' + recipient + b' :' + message
self.serverthread_object.send_line_raw(line)
def bot_response(self, recipient, message):
"""Prefix message with ZWSP and convert from unicode to bytestring if necessary."""
if isinstance(message, str):
message = message.encode('utf-8')
self.msg(recipient, '\u200b'.encode('utf-8') + message)
def nick(self, nick):
"""Send a NICK command and update the internal nick tracking state"""
with self.serverthread_object.nick_lock:
line = b'NICK ' + nick
self.serverthread_object.send_line_raw(line)
self.serverthread_object.nick = nick
def get_nick(self):
"""Returns current nick"""
with self.serverthread_object.nick_lock:
return self.serverthread_object.nick
def set_nick(self, nick):
"""Set the internal nick tracking state"""
with self.serverthread_object.nick_lock:
self.serverthread_object.nick = nick
def join(self, channel):
"""Send a JOIN command and update the internal channel tracking state"""
with self.serverthread_object.channels_lock:
line = b'JOIN ' + channel
self.serverthread_object.send_line_raw(line)
self.serverthread_object.channels.add(channel)
def part(self, channel, message = b''):
"""Send a PART command and update the internal channel tracking state"""
with self.serverthread_object.channels_lock:
line = b'PART %s :%s' % (channel, message)
self.serverthread_object.send_line_raw(line)
self.serverthread_object.channels.removeadd(channel)
def get_channels(self):
"""Returns the current set of channels"""
with self.serverthread_object.channels_lock:
return self.serverthread_object.channels
def set_channels(self, channels):
"""Set the current set of channels variable"""
with self.serverthread_object.channels_lock:
self.serverthread_object.channels = channels
def quit(self):
self.serverthread_object.control_channel.send((controlmessage_types.quit,))
self.serverthread_object.logging_channel.send((logmessage_types.internal, internal_submessage_types.quit))
cron.quit(self.cron)
def log(self, *args, **kwargs):
"""Log a status message. Supports normal print() arguments."""
self.serverthread_object.logging_channel.send((logmessage_types.status, args, kwargs))
def error(self, message):
"""Log an error"""
self.serverthread_object.logging_channel.send((logmessage_types.internal, internal_submessage_types.error, message))
# ServerThread(server, control_channel, cron_control_channel, logging_channel)
# Creates a new server main loop thread
class ServerThread(threading.Thread):
def __init__(self, server, control_channel, cron_control_channel, logging_channel):
self.server = server
self.control_channel = control_channel
self.cron_control_channel = cron_control_channel
self.logging_channel = logging_channel
self.server_socket_write_lock = threading.Lock()
self.last_send = 0
self.last_send_lock = threading.Lock()
self.nick = None
self.nick_lock = threading.Lock()
self.channels = set()
self.channels_lock = threading.Lock()
threading.Thread.__init__(self)
def send_line_raw(self, line):
# Sanitize line just in case
line = line.replace(b'\r', b'').replace(b'\n', b'')[:510]
with self.last_send_lock:
now = time.monotonic()
if now - self.last_send < 1:
# Schedule our message sending one second after the last one
self.last_send += 1
wait = self.last_send - now
else:
self.last_send = now
wait = 0
if wait > 0:
time.sleep(wait)
with self.server_socket_write_lock:
if self.server_socket is not None:
self.server_socket.sendall(line + b'\r\n')
else:
return
# Don't log PINGs or PONGs
if not (len(line) >= 5 and (line[:5] == b'PING ' or line[:5] == b'PONG ')):
self.logging_channel.send((logmessage_types.sent, line.decode(encoding = 'utf-8', errors = 'replace')))
def handle_line(self, line):
command, _, arguments = line.partition(b' ')
split = line.split(b' ')
if len(split) >= 1 and split[0].upper() == b'PING':
self.send_line_raw(b'PONG ' + arguments)
elif len(split) >= 2 and split[0][0:1] == b':' and split[1].upper() == b'PONG':
# No need to do anything special for PONGs
pass
else:
self.logging_channel.send((logmessage_types.received, line.decode(encoding = 'utf-8', errors = 'replace')))
# Ensure we have a bytestring, because bytearray can be annoying to deal with
line = bytes(line)
line_handling.handle_line(line, irc = self.api)
def mainloop(self):
# Register both the server socket and the control channel to a polling object
poll = select.poll()
poll.register(self.server_socket, select.POLLIN)
poll.register(self.control_channel, select.POLLIN)
# Keep buffer for input
server_input_buffer = bytearray()
quitting = False
reconnecting = False
while not quitting and not reconnecting:
# Wait until we can do something
for fd, event in poll.poll():
# Server
if fd == self.server_socket.fileno():
# Ready to receive, read into buffer and handle full messages
if event | select.POLLIN:
data = self.server_socket.recv(1024)
# Mo data to be read even as POLLIN triggered → connection has broken
# Log it and try reconnecting
if data == b'':
self.logging_channel.send((logmessage_types.internal, internal_submessage_types.error, 'Empty read'))
reconnecting = True
break
server_input_buffer.extend(data)
# Try to see if we have a full line ending with \r\n in the buffer
# If yes, handle it
while b'\r\n' in server_input_buffer:
# Newline was found, split buffer
line, _, server_input_buffer = server_input_buffer.partition(b'\r\n')
self.handle_line(line)
# Remove possible pending ping timeout timer and reset ping timer to 3 minutes
cron.delete(self.cron_control_channel, self.control_channel, (controlmessage_types.ping_timeout,))
cron.reschedule(self.cron_control_channel, 3 * 60, self.control_channel, (controlmessage_types.ping,))
else:
error_message = 'Event on server socket: %s' % event
self.logging_channel.send((logmessage_types.internal, internal_submessage_types.error, error_message))
# Control
elif fd == self.control_channel.fileno():
command_type, *arguments = self.control_channel.recv()
if command_type == controlmessage_types.quit:
quitting = True
elif command_type == controlmessage_types.send_line:
assert len(arguments) == 1
irc_command, space, arguments = arguments[0].encode('utf-8').partition(b' ')
line = irc_command.upper() + space + arguments
self.send_line_raw(line)
elif command_type == controlmessage_types.ping:
assert len(arguments) == 0
self.send_line_raw(b'PING :foo')
# Reset ping timeout timer to 2 minutes
cron.reschedule(self.cron_control_channel, 2 * 60, self.control_channel, (controlmessage_types.ping_timeout,))
elif command_type == controlmessage_types.ping_timeout:
self.logging_channel.send((logmessage_types.internal, internal_submessage_types.error, 'Ping timeout'))
reconnecting = True
elif command_type == controlmessage_types.reconnect:
reconnecting = True
else:
error_message = 'Unknown control message: %s' % repr((command_type, *arguments))
self.logging_channel.send((logmessage_types.internal, internal_submessage_types.error, error_message))
else:
assert False #unreachable
if reconnecting:
return True
else:
return False
def run(self):
while True:
# Connect to given server
address = (self.server.host, self.server.port)
try:
self.server_socket = socket.create_connection(address)
except (ConnectionRefusedError, socket.gaierror):
# Tell controller we failed
self.logging_channel.send((logmessage_types.internal, internal_submessage_types.error, "Can't connect to %s:%s" % address))
# Try reconnecting in a minute
cron.reschedule(self.cron_control_channel, 60, self.control_channel, (controlmessage_types.reconnect,))
# Handle messages
reconnect = True
while True:
command_type, *arguments = self.control_channel.recv()
if command_type == controlmessage_types.reconnect:
break
elif command_type == controlmessage_types.quit:
reconnect = False
break
else:
error_message = 'Control message not supported when not connected: %s' % repr((command_type, *arguments))
self.logging_channel.send((logmessage_types.internal, internal_submessage_types.error, error_message))
# Remove the reconnect message in case we were told to reconnnect manually
cron.delete(self.cron_control_channel, self.control_channel, (controlmessage_types.reconnect,))
if reconnect:
continue
else:
break
# Create an API object to give to outside line handler
self.api = API(self)
try:
# Run initialization
self.send_line_raw(b'USER %s a a :%s' % (self.server.username.encode('utf-8'), self.server.realname.encode('utf-8')))
# Set up nick
self.api.nick(self.server.nick.encode('utf-8'))
# Run the on_connect hook, to allow further setup
botcmd.on_connect(irc = self.api)
# Join channels
for channel in self.server.channels:
self.api.join(channel.encode('utf-8'))
# Schedule a ping to be sent in 3 minutes of no activity
cron.reschedule(self.cron_control_channel, 3 * 60, self.control_channel, (controlmessage_types.ping,))
# Run mainloop
reconnecting = self.mainloop()
if not reconnecting:
# Run bot cleanup code
botcmd.on_quit(irc = self.api)
# Tell the server we're quiting
self.send_line_raw(b'QUIT :%s exiting normally' % self.server.username.encode('utf-8'))
self.server_socket.close()
break
else:
# Tell server we're reconnecting
self.send_line_raw(b'QUIT :Reconnecting')
with self.server_socket_write_lock:
self.server_socket.close()
self.server_socket = None
except (BrokenPipeError, TimeoutError) as err:
# Connection broke, log it and try to reconnect
self.logging_channel.send((logmessage_types.internal, internal_submessage_types.error, 'Broken socket/pipe or timeout'))
self.server_socket.close()
# Tell controller we're quiting
self.logging_channel.send((logmessage_types.internal, internal_submessage_types.quit))
# Tell cron we're quiting
cron.quit(cron_control_channel)
# spawn_serverthread(server, cron_control_channel, logging_channel) → control_channel
# Creates a ServerThread for given server and returns the channel for controlling it
def spawn_serverthread(server, cron_control_channel, logging_channel):
thread_control_socket, spawner_control_socket = socket.socketpair()
control_channel = channel.Channel()
ServerThread(server, control_channel, cron_control_channel, logging_channel).start()
return control_channel
# spawn_loggerthread() → logging_channel, dead_notify_channel
# Spawn logger thread and returns the channel it logs and the channel it uses to notify about quiting
def spawn_loggerthread():
logging_channel = channel.Channel()
dead_notify_channel = channel.Channel()
LoggerThread(logging_channel, dead_notify_channel).start()
return logging_channel, dead_notify_channel
# read_config() → config, server
# Reads the configuration file and returns the configuration object as well as a server object for spawn_serverthread
def read_config():
config = configparser.ConfigParser()
config.read('bot.conf')
host = config['server']['host']
port = int(config['server']['port'])
nick = config['server']['nick']
username = config['server']['username']
realname = config['server']['realname']
channels = config['server']['channels'].split()
server = Server(host = host, port = port, nick = nick, username = username, realname = realname, channels = channels)
return config, server
if __name__ == '__main__':
config, server = read_config()
botcmd.initialize(config = config)
cron_control_channel = cron.start()
logging_channel, dead_notify_channel = spawn_loggerthread()
control_channel = spawn_serverthread(server, cron_control_channel, logging_channel)
while True:
message = dead_notify_channel.recv(blocking = False)
if message is not None:
if message[0] == controlmessage_types.quit:
break
cmd = input('')
if cmd == 'q':
print('Keyboard quit')
control_channel.send((controlmessage_types.quit,))
logging_channel.send((logmessage_types.internal, internal_submessage_types.quit))
cron.quit(cron_control_channel)
break
elif cmd == 'r':
print('Keyboard reconnect')
control_channel.send((controlmessage_types.reconnect,))
elif len(cmd) > 0 and cmd[0] == '/':
control_channel.send((controlmessage_types.send_line, cmd[1:]))

142
line_handling.py Normal file
View File

@ -0,0 +1,142 @@
import threading
import constants
import botcmd
class LineParsingError(Exception): None
# parse_line(line) → prefix, command, arguments
# Split the line into its component parts
def parse_line(line):
def read_byte():
# Read one byte and advance the index
nonlocal line, index
if eol():
raise LineParsingError
byte = line[index]
index += 1
return byte
def peek_byte():
# Look at current byte, don't advance index
nonlocal line, index
if eol():
raise LineParsingError
return line[index]
def eol():
# Test if we've reached the end of the line
nonlocal line, index
return index >= len(line)
def skip_space():
# Skip until we run into a non-space character or eol.
while not eol() and peek_byte() == ord(' '):
read_byte()
def read_until_space():
nonlocal line, index
if eol():
raise LineParsingError
# Try to find a space
until = line[index:].find(b' ')
if until == -1:
# Space not found, read until end of line
until = len(line)
else:
# Space found, add current index to it to get right index
until += index
# Slice line upto the point of next space / end and update index
data = line[index:until]
index = until
return data
def read_until_end():
nonlocal line, index
if eol():
raise LineParsingError
# Read all of the data, and make index point to eol
data = line[index:]
index = len(line)
return data
index = 0
prefix = None
command = None
arguments = []
if peek_byte() == ord(':'):
read_byte()
prefix = read_until_space()
skip_space()
command = read_until_space()
skip_space()
while not eol():
if peek_byte() == ord(':'):
read_byte()
argument = read_until_end()
else:
argument = read_until_space()
arguments.append(argument)
skip_space()
return prefix, command, arguments
class LineHandlerThread(threading.Thread):
def __init__(self, line, *, irc):
self.line = line
self.irc = irc
threading.Thread.__init__(self)
def run(self):
try:
prefix, command, arguments = parse_line(self.line)
except LineParsingError:
irc.error("Cannot parse line: " + self.line.decode(encoding = 'utf-8', errors = 'replace'))
if command.upper() == b'PRIVMSG':
# PRIVMSG should have two parameters: recipient and the message
assert len(arguments) == 2
recipients, message = arguments
# Prefix contains the nick of the sender, delimited from user and host by '!'
nick = prefix.split(b'!')[0]
# Recipients are in a comma-separate list
for recipient in recipients.split(b','):
# 'channel' is bit of a misnomer. This is where we'll send the response to
# Usually it's the channel, but in queries it's not
channel = recipient if recipient[0] == ord('#') else nick
# Delegate rest to botcmd.handle_message
botcmd.handle_message(prefix = prefix, message = message, nick = nick, channel = channel, irc = self.irc)
else:
# Delegate to botcmd.handle_nonmessage
botcmd.handle_nonmessage(prefix = prefix, command = command, arguments = arguments, irc = self.irc)
def handle_line(line, *, irc):
# Spawn a thread to handle the line
LineHandlerThread(line, irc = irc).start()