Incorporate o3-base
This commit is contained in:
commit
3c41450b21
|
@ -2,6 +2,6 @@ msgs.txt
|
|||
trusted.txt
|
||||
gods.txt
|
||||
startcmd.txt
|
||||
*.pyc
|
||||
ircbot.sh
|
||||
*.swp
|
||||
__pycache__
|
||||
bot.conf
|
||||
|
|
|
@ -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/>
|
|
@ -0,0 +1,7 @@
|
|||
[server]
|
||||
host = irc.freenode.net
|
||||
port = 6667
|
||||
nick = oonbotti2
|
||||
username = oonbotti2
|
||||
realname = oonbotti2
|
||||
channels = ##ingsoc
|
|
@ -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):
|
||||
...
|
|
@ -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()
|
|
@ -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)
|
|
@ -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))
|
|
@ -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:]))
|
|
@ -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()
|
Loading…
Reference in New Issue