Incorporate o3-base
This commit is contained in:
commit
3c41450b21
|
@ -2,6 +2,6 @@ msgs.txt
|
||||||
trusted.txt
|
trusted.txt
|
||||||
gods.txt
|
gods.txt
|
||||||
startcmd.txt
|
startcmd.txt
|
||||||
*.pyc
|
|
||||||
ircbot.sh
|
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