Brian S. Stephan
6136127c5f
this is the first step in trying to get the bot to support multiple servers with different channels, countdown triggers, and so on this also ends up affecting some configuration around: * dispatch * markov * admin privmsg form
1137 lines
42 KiB
Python
1137 lines
42 KiB
Python
"""Provide the base IRC client bot which other code can latch onto."""
|
|
|
|
import bisect
|
|
import collections
|
|
import copy
|
|
import importlib
|
|
import logging
|
|
import re
|
|
from xmlrpc.server import SimpleXMLRPCServer, SimpleXMLRPCRequestHandler
|
|
import socket
|
|
import ssl
|
|
import sys
|
|
import threading
|
|
import time
|
|
|
|
from django.conf import settings
|
|
|
|
import irc.buffer
|
|
import irc.client
|
|
from irc.connection import Factory
|
|
from irc.dict import IRCDict
|
|
import irc.modes
|
|
|
|
import ircbot.lib as ircbotlib
|
|
from ircbot.models import Alias, IrcChannel, IrcPlugin, IrcServer
|
|
|
|
|
|
log = logging.getLogger('ircbot.bot')
|
|
|
|
|
|
class IrcBotXMLRPCRequestHandler(SimpleXMLRPCRequestHandler):
|
|
"""Override the basic request handler to change the logging."""
|
|
|
|
def log_message(self, format, *args):
|
|
"""Use a logger rather than stderr."""
|
|
log.debug("XML-RPC - %s - %s", self.client_address[0], format % args)
|
|
|
|
|
|
class PrioritizedRegexHandler(collections.namedtuple('Base', ('priority', 'regex', 'callback'))):
|
|
"""Regex handler that still uses the normal handler priority stuff."""
|
|
|
|
def __lt__(self, other):
|
|
"""When sorting prioritized handlers, only use the priority"""
|
|
return self.priority < other.priority
|
|
|
|
|
|
class LenientServerConnection(irc.client.ServerConnection):
|
|
"""
|
|
An IRC server connection.
|
|
|
|
ServerConnection objects are instantiated by calling the server
|
|
method on a Reactor object.
|
|
"""
|
|
|
|
buffer_class = irc.buffer.LenientDecodingLineBuffer
|
|
|
|
server_config = None
|
|
|
|
def _prep_message(self, string):
|
|
"""Override SimpleIRCClient._prep_message to add some logging."""
|
|
log.debug("preparing message %s", string)
|
|
return super(LenientServerConnection, self)._prep_message(string)
|
|
|
|
|
|
class DrReactor(irc.client.Reactor):
|
|
"""Customize the basic IRC library's Reactor with more features."""
|
|
|
|
def __do_nothing(*args, **kwargs):
|
|
pass
|
|
|
|
def __init__(self, on_connect=__do_nothing, on_disconnect=__do_nothing):
|
|
"""Initialize our custom stuff."""
|
|
super(DrReactor, self).__init__(on_connect=on_connect, on_disconnect=on_disconnect)
|
|
self.regex_handlers = {}
|
|
|
|
def server(self):
|
|
"""Creates and returns a ServerConnection object."""
|
|
c = LenientServerConnection(self)
|
|
with self.mutex:
|
|
self.connections.append(c)
|
|
return c
|
|
|
|
def add_global_regex_handler(self, events, regex, handler, priority=0):
|
|
"""Adds a global handler function for a specific event type and regex.
|
|
|
|
Arguments:
|
|
|
|
events --- Event type(s) (a list of strings).
|
|
|
|
handler -- Callback function taking connection and event
|
|
parameters.
|
|
|
|
priority --- A number (the lower the number, the higher priority).
|
|
|
|
The handler function is called whenever the specified event is
|
|
triggered in any of the connections and the regex matches.
|
|
See documentation for the Event class.
|
|
|
|
The handler functions are called in priority order (lowest
|
|
number is highest priority). If a handler function returns
|
|
"NO MORE", no more handlers will be called.
|
|
|
|
This is basically an extension of add_global_handler(), either may
|
|
work, though it turns out most modules probably want this one.
|
|
"""
|
|
if type(events) != list:
|
|
events = [events]
|
|
|
|
handler = PrioritizedRegexHandler(priority, regex, handler)
|
|
for event in events:
|
|
with self.mutex:
|
|
log.debug("in add_global_regex_handler")
|
|
event_regex_handlers = self.regex_handlers.setdefault(event, [])
|
|
bisect.insort(event_regex_handlers, handler)
|
|
|
|
def remove_global_regex_handler(self, events, handler):
|
|
"""Removes a global regex handler function.
|
|
|
|
Arguments:
|
|
|
|
events -- Event type(s) (a list of strings).
|
|
handler -- Callback function.
|
|
|
|
Returns 1 on success, otherwise 0.
|
|
"""
|
|
ret = 1
|
|
|
|
if type(events) != list:
|
|
events = [events]
|
|
|
|
for event in events:
|
|
with self.mutex:
|
|
if event not in self.regex_handlers:
|
|
ret = 0
|
|
for h in self.regex_handlers[event]:
|
|
if handler == h.callback:
|
|
self.regex_handlers[event].remove(h)
|
|
return ret
|
|
|
|
def _handle_event(self, connection, event):
|
|
"""Handle an Event event incoming on ServerConnection connection.
|
|
|
|
Also supports regex handlers.
|
|
"""
|
|
try:
|
|
log.debug("EVENT: e[%s] s[%s] t[%s] a[%s]", event.type, event.source,
|
|
event.target, event.arguments)
|
|
|
|
# set up some default stuff
|
|
event.recursing = False
|
|
event.addressed = False
|
|
event.original_msg = None
|
|
event.addressed_msg = None
|
|
|
|
sender_nick = irc.client.NickMask(event.source).nick
|
|
sent_location = ircbotlib.reply_destination_for_event(event)
|
|
event.sender_nick = sender_nick
|
|
event.sent_location = sent_location
|
|
event.in_privmsg = sender_nick == sent_location
|
|
|
|
self.try_recursion(connection, event)
|
|
|
|
if event.type in ['pubmsg', 'privmsg']:
|
|
what = event.arguments[0]
|
|
event.original_msg = what
|
|
|
|
# check if we were addressed or not
|
|
all_nicks = '|'.join(connection.server_config.additional_addressed_nicks.split('\n') +
|
|
[connection.get_nickname()])
|
|
addressed_pattern = r'^(({nicks})[:,]|@({nicks}))\s+(?P<addressed_msg>.*)'.format(nicks=all_nicks)
|
|
match = re.match(addressed_pattern, what, re.IGNORECASE)
|
|
if match:
|
|
event.addressed = True
|
|
event.addressed_msg = match.group('addressed_msg')
|
|
|
|
# only do aliasing for pubmsg/privmsg
|
|
log.debug("checking for alias for %s", what)
|
|
|
|
for alias in Alias.objects.all():
|
|
repl = alias.replace(what)
|
|
if repl:
|
|
# we found an alias for our given string, doing a replace
|
|
event.arguments[0] = repl
|
|
# recurse, in case there's another alias in this one
|
|
return self._handle_event(connection, event)
|
|
|
|
with self.mutex:
|
|
# doing regex version first as it has the potential to be more specific
|
|
log.debug("checking regex handlers for %s", event.type)
|
|
matching_handlers = sorted(
|
|
self.regex_handlers.get("all_events", []) +
|
|
self.regex_handlers.get(event.type, [])
|
|
)
|
|
log.debug("got %d", len(matching_handlers))
|
|
for handler in matching_handlers:
|
|
log.debug("checking %s vs. %s", handler, event.arguments)
|
|
for line in event.arguments:
|
|
match = re.search(handler.regex, line)
|
|
if match:
|
|
log.debug("match!")
|
|
result = handler.callback(connection, event, match)
|
|
if result == "NO MORE":
|
|
return
|
|
|
|
matching_handlers = sorted(
|
|
self.handlers.get("all_events", []) +
|
|
self.handlers.get(event.type, [])
|
|
)
|
|
for handler in matching_handlers:
|
|
log.debug("not-match")
|
|
result = handler.callback(connection, event)
|
|
if result == "NO MORE":
|
|
return
|
|
except Exception as ex:
|
|
log.error("caught exception!")
|
|
log.exception(ex)
|
|
connection.privmsg(event.target, str(ex))
|
|
|
|
def try_recursion(self, connection, event):
|
|
"""Scan message for subcommands to execute and use as part of this command.
|
|
|
|
Upon seeing a line intended for this module, see if there are subcommands
|
|
that we should do what is basically a text replacement on. The intent is to
|
|
allow things like the following:
|
|
|
|
command arg1 [anothercommand arg1 arg2]
|
|
|
|
where the output of anothercommand is command's arg2..n.
|
|
|
|
Args:
|
|
connection source connection
|
|
event incoming event
|
|
|
|
"""
|
|
log.debug("RECURSING EVENT: e[%s] s[%s] t[%s] a[%s]", event.type, event.source,
|
|
event.target, event.arguments)
|
|
|
|
try:
|
|
# begin recursion search
|
|
attempt = event.arguments[0]
|
|
|
|
log.debug("checking it against %s", attempt)
|
|
|
|
start_idx = attempt.find('[')
|
|
subcmd = attempt[start_idx + 1:]
|
|
end_idx = subcmd.rfind(']')
|
|
subcmd = subcmd[:end_idx]
|
|
|
|
if start_idx != -1 and end_idx != -1 and len(subcmd) > 0:
|
|
log.debug("subcmd: %s", subcmd)
|
|
|
|
# found recursion candidate
|
|
# copy the event and see if IT has recursion to do
|
|
newevent = copy.deepcopy(event)
|
|
newevent.arguments[0] = subcmd
|
|
newevent.recursing = True
|
|
|
|
log.debug("new event copied")
|
|
|
|
self.try_recursion(connection, newevent)
|
|
|
|
# now that we have a string that has been
|
|
# recursed all the way deeper into its text, see if any
|
|
# modules can do something with it. this calls the same
|
|
# event handlers in the same way as if this were a native
|
|
# event.
|
|
replacements = self.try_to_replace_event_text_with_module_text(connection, newevent)
|
|
if replacements:
|
|
# we have done all we can do with the sub-event. whatever
|
|
# the text of that event now is, we should replace the parent
|
|
# event's [] section with it.
|
|
oldtext = event.arguments[0]
|
|
newtext = oldtext.replace('[' + subcmd + ']', newevent.arguments[0])
|
|
log.debug("oldtext: '%s' newtext: '%s'", oldtext, newtext)
|
|
event.arguments[0] = newtext
|
|
|
|
# we have now resolved the []. recursion will unfold, replacing
|
|
# it further and further, until we eventually get back to the
|
|
# original irc event in _handle_event, which will do one
|
|
# last search on the text.
|
|
else:
|
|
log.debug("no more recursion here")
|
|
except IndexError:
|
|
log.debug("no more recursion here")
|
|
|
|
def try_to_replace_event_text_with_module_text(self, connection, event):
|
|
"""Do something very similar to _handle_event, but for recursion.
|
|
|
|
The intent here is that we replace [text] with whatever a module
|
|
provides to us.
|
|
|
|
Args:
|
|
connection source connection
|
|
event incoming event
|
|
|
|
"""
|
|
replacement = False
|
|
replies = []
|
|
|
|
# only do aliasing for pubmsg/privmsg
|
|
if event.type in ['pubmsg', 'privmsg']:
|
|
what = event.arguments[0]
|
|
log.debug("checking for (recursion) alias for %s", what)
|
|
|
|
for alias in Alias.objects.all():
|
|
repl = alias.replace(what)
|
|
if repl:
|
|
# we found an alias for our given string, doing a replace
|
|
event.arguments[0] = repl
|
|
|
|
with self.mutex:
|
|
# doing regex version first as it has the potential to be more specific
|
|
log.debug("checking (recursion) regex handlers for %s", event.type)
|
|
matching_handlers = sorted(
|
|
self.regex_handlers.get("all_events", []) +
|
|
self.regex_handlers.get(event.type, [])
|
|
)
|
|
log.debug("got %d", len(matching_handlers))
|
|
for handler in matching_handlers:
|
|
log.debug("checking (recursion) %s vs. %s", handler, event.arguments)
|
|
for line in event.arguments:
|
|
match = re.search(handler.regex, line)
|
|
if match:
|
|
log.debug("match (recursion)!")
|
|
result = handler.callback(connection, event, match)
|
|
log.debug("result: %s", result)
|
|
if result:
|
|
log.debug("appending %s to replies", result)
|
|
replacement = True
|
|
replies.append(result)
|
|
|
|
matching_handlers = sorted(
|
|
self.handlers.get("all_events", []) +
|
|
self.handlers.get(event.type, [])
|
|
)
|
|
for handler in matching_handlers:
|
|
log.debug("not-match (recursion)")
|
|
result = handler.callback(connection, event)
|
|
log.debug("result: %s", result)
|
|
if result:
|
|
log.debug("appending %s to replies", result)
|
|
replacement = True
|
|
replies.append(result)
|
|
|
|
if len(replies):
|
|
event.arguments[0] = '\n'.join(replies)
|
|
|
|
return replacement
|
|
|
|
|
|
class IRCBot(irc.client.SimpleIRCClient):
|
|
"""A single-server IRC bot class."""
|
|
|
|
reactor_class = DrReactor
|
|
splitter = "..."
|
|
|
|
def __init__(self, server_name, reconnection_interval=60):
|
|
"""Initialize bot."""
|
|
super(IRCBot, self).__init__()
|
|
|
|
self.channels = IRCDict()
|
|
self.plugins = []
|
|
|
|
self.server_config = IrcServer.objects.get(name=server_name)
|
|
# the reactor made the connection, save the server reference in it since we pass that around
|
|
self.connection.server_config = self.server_config
|
|
|
|
# set reconnection interval
|
|
if not reconnection_interval or reconnection_interval < 0:
|
|
reconnection_interval = 2 ** 31
|
|
self.reconnection_interval = reconnection_interval
|
|
|
|
# set basic stuff
|
|
self._nickname = self.server_config.nickname
|
|
self._realname = self.server_config.realname
|
|
|
|
# guess at nickmask. hopefully _on_welcome() will set this, but this should be
|
|
# a pretty good guess if not
|
|
nick = self._nickname
|
|
user = self._nickname
|
|
host = socket.getfqdn()
|
|
self.nickmask = "{0:s}!~{1:s}@{2:s}".format(nick, user, host)
|
|
log.debug("guessing at nickmask '{0:s}'".format(self.nickmask))
|
|
|
|
# handlers
|
|
for i in ['disconnect', 'join', 'kick', 'mode', 'namreply', 'nick', 'part', 'quit', 'welcome']:
|
|
self.connection.add_global_handler(i, getattr(self, '_on_' + i), -20)
|
|
self.connection.reactor.add_global_regex_handler('pubmsg', r'^!load\s+([\S]+)$',
|
|
getattr(self, 'handle_load'), -20)
|
|
self.connection.reactor.add_global_regex_handler('privmsg', r'^!load\s+([\S]+)$',
|
|
getattr(self, 'handle_load'), -20)
|
|
self.connection.reactor.add_global_regex_handler('pubmsg', r'^!unload\s+([\S]+)$',
|
|
getattr(self, 'handle_unload'), -20)
|
|
self.connection.reactor.add_global_regex_handler('privmsg', r'^!unload\s+([\S]+)$',
|
|
getattr(self, 'handle_unload'), -20)
|
|
self.connection.reactor.add_global_regex_handler('pubmsg', r'^!reload\s+([\S]+)$',
|
|
getattr(self, 'handle_reload'), -20)
|
|
self.connection.reactor.add_global_regex_handler('privmsg', r'^!reload\s+([\S]+)$',
|
|
getattr(self, 'handle_reload'), -20)
|
|
|
|
# load XML-RPC server
|
|
self.xmlrpc = SimpleXMLRPCServer((self.server_config.xmlrpc_host, self.server_config.xmlrpc_port),
|
|
requestHandler=IrcBotXMLRPCRequestHandler, allow_none=True)
|
|
self.xmlrpc.register_introspection_functions()
|
|
|
|
t = threading.Thread(target=self._xmlrpc_listen, args=())
|
|
t.daemon = True
|
|
t.start()
|
|
|
|
# register XML-RPC stuff
|
|
self.xmlrpc_register_function(self.privmsg, 'privmsg')
|
|
self.xmlrpc_register_function(self.reply, 'reply')
|
|
|
|
def _connected_checker(self):
|
|
if not self.connection.is_connected():
|
|
self.connection.execute_delayed(self.reconnection_interval,
|
|
self._connected_checker)
|
|
self.jump_server()
|
|
|
|
def _connect(self):
|
|
try:
|
|
# build the connection factory as determined by IPV6/SSL settings
|
|
if self.server_config.use_ssl:
|
|
connect_factory = Factory(wrapper=ssl.wrap_socket, ipv6=self.server_config.use_ipv6)
|
|
else:
|
|
connect_factory = Factory(ipv6=self.server_config.use_ipv6)
|
|
|
|
self.connect(self.server_config.hostname, self.server_config.port, self._nickname,
|
|
self.server_config.password, ircname=self._realname, connect_factory=connect_factory)
|
|
except irc.client.ServerConnectionError:
|
|
pass
|
|
|
|
def _on_disconnect(self, c, e):
|
|
self.channels = IRCDict()
|
|
self.connection.execute_delayed(self.reconnection_interval,
|
|
self._connected_checker)
|
|
|
|
def _on_join(self, c, e):
|
|
ch = e.target
|
|
nick = e.source.nick
|
|
if nick == c.get_nickname():
|
|
self.channels[ch] = Channel()
|
|
self.channels[ch].add_user(nick)
|
|
|
|
def _on_kick(self, c, e):
|
|
nick = e.arguments[0]
|
|
channel = e.target
|
|
|
|
if nick == c.get_nickname():
|
|
del self.channels[channel]
|
|
else:
|
|
self.channels[channel].remove_user(nick)
|
|
|
|
def _on_mode(self, c, e):
|
|
modes = irc.modes.parse_channel_modes(" ".join(e.arguments))
|
|
t = e.target
|
|
if irc.client.is_channel(t):
|
|
ch = self.channels[t]
|
|
for mode in modes:
|
|
if mode[0] == "+":
|
|
f = ch.set_mode
|
|
else:
|
|
f = ch.clear_mode
|
|
f(mode[1], mode[2])
|
|
else:
|
|
# Mode on self... XXX
|
|
pass
|
|
|
|
def _on_namreply(self, c, e):
|
|
"""Get the list of names in a channel.
|
|
|
|
e.arguments[0] == "@" for secret channels,
|
|
"*" for private channels,
|
|
"=" for others (public channels)
|
|
e.arguments[1] == channel
|
|
e.arguments[2] == nick list
|
|
"""
|
|
ch_type, channel, nick_list = e.arguments
|
|
|
|
if channel == '*':
|
|
# User is not in any visible channel
|
|
# http://tools.ietf.org/html/rfc2812#section-3.2.5
|
|
return
|
|
|
|
for nick in nick_list.split():
|
|
nick_modes = []
|
|
|
|
if nick[0] in self.connection.features.prefix:
|
|
nick_modes.append(self.connection.features.prefix[nick[0]])
|
|
nick = nick[1:]
|
|
|
|
for mode in nick_modes:
|
|
self.channels[channel].set_mode(mode, nick)
|
|
|
|
self.channels[channel].add_user(nick)
|
|
|
|
def _on_nick(self, c, e):
|
|
before = e.source.nick
|
|
after = e.target
|
|
for ch in list(self.channels.values()):
|
|
if ch.has_user(before):
|
|
ch.change_nick(before, after)
|
|
|
|
def _on_part(self, c, e):
|
|
nick = e.source.nick
|
|
channel = e.target
|
|
|
|
if nick == c.get_nickname():
|
|
del self.channels[channel]
|
|
else:
|
|
self.channels[channel].remove_user(nick)
|
|
|
|
def _on_quit(self, c, e):
|
|
nick = e.source.nick
|
|
for ch in list(self.channels.values()):
|
|
if ch.has_user(nick):
|
|
ch.remove_user(nick)
|
|
|
|
def _on_welcome(self, connection, event):
|
|
"""Initialize/run a bunch of on-connection stuff.
|
|
|
|
Set the nickmask that the ircd tells us is us, autojoin channels, etc.
|
|
|
|
Args:
|
|
connection source connection
|
|
event incoming event
|
|
|
|
"""
|
|
what = event.arguments[0]
|
|
|
|
log.debug("welcome: %s", what)
|
|
|
|
# run automsg commands
|
|
for cmd in self.server_config.post_connect.split('\n'):
|
|
# TODO NOTE: if the bot is sending something that changes the vhost
|
|
# (like 'hostserv on') we don't pick it up
|
|
self.connection.privmsg(cmd.split(' ')[0], ' '.join(cmd.split(' ')[1:]))
|
|
|
|
# sleep before doing autojoins
|
|
time.sleep(self.server_config.delay_before_joins)
|
|
|
|
for chan in IrcChannel.objects.filter(autojoin=True):
|
|
log.info("autojoining %s", chan.name)
|
|
self.connection.join(chan)
|
|
|
|
for plugin in IrcPlugin.objects.filter(autoload=True):
|
|
log.info("autoloading %s", plugin.path)
|
|
self._load_plugin(connection, event, plugin.path, feedback=False)
|
|
|
|
match = re.search(r'(\S+!\S+@\S+)', what)
|
|
if match:
|
|
self.nickmask = match.group(1)
|
|
log.debug("setting nickmask: %s", self.nickmask)
|
|
|
|
def die(self, msg="Bye, cruel world!"):
|
|
"""Let the bot die.
|
|
|
|
Arguments:
|
|
|
|
msg -- Quit message.
|
|
"""
|
|
self._xmlrpc_shutdown()
|
|
self.connection.disconnect(msg)
|
|
sys.exit(0)
|
|
|
|
def disconnect(self, msg="I'll be back!"):
|
|
"""Disconnect the bot.
|
|
|
|
The bot will try to reconnect after a while.
|
|
|
|
Arguments:
|
|
|
|
msg -- Quit message.
|
|
"""
|
|
self.connection.disconnect(msg)
|
|
|
|
def get_version(self):
|
|
"""Returns the bot version.
|
|
|
|
Used when answering a CTCP VERSION request.
|
|
"""
|
|
return "Python irc.bot ({version})".format(
|
|
version=irc.client.VERSION_STRING)
|
|
|
|
def jump_server(self, msg="Changing servers"):
|
|
"""Connect to a new server, potentially disconnecting from the current one."""
|
|
if self.connection.is_connected():
|
|
self.connection.disconnect(msg)
|
|
|
|
self._connect()
|
|
|
|
def on_ctcp(self, c, e):
|
|
"""Default handler for ctcp events.
|
|
|
|
Replies to VERSION and PING requests and relays DCC requests
|
|
to the on_dccchat method.
|
|
"""
|
|
nick = e.source.nick
|
|
if e.arguments[0] == "VERSION":
|
|
c.ctcp_reply(nick, "VERSION " + self.get_version())
|
|
elif e.arguments[0] == "PING":
|
|
if len(e.arguments) > 1:
|
|
c.ctcp_reply(nick, "PING " + e.arguments[1])
|
|
elif e.arguments[0] == "DCC" and e.arguments[1].split(" ", 1)[0] == "CHAT":
|
|
self.on_dccchat(c, e)
|
|
|
|
def on_dccchat(self, c, e):
|
|
"""Do nothing."""
|
|
pass
|
|
|
|
def handle_load(self, connection, event, match):
|
|
"""Handle IRC requests to load a plugin.
|
|
|
|
This loads a plugin, as requested, and the interpreter takes care of
|
|
loading dependencies.
|
|
|
|
:param connection: connection for this load request
|
|
:type connection: LenientServerConnection
|
|
:param event: associated irc event object
|
|
:type event: Event
|
|
:param match: the matched regex (via add_global_regex_handler)
|
|
:type match: Match
|
|
"""
|
|
has_perm = ircbotlib.has_permission(event.source, 'ircbot.manage_loaded_plugins')
|
|
log.debug("has permission to load?: %s", str(has_perm))
|
|
if has_perm:
|
|
plugin_path = match.group(1)
|
|
log.debug("calling _load_plugin on %s", plugin_path)
|
|
self._load_plugin(connection, event, plugin_path)
|
|
|
|
def _load_plugin(self, connection, event, plugin_path, feedback=True):
|
|
"""Load an IRC plugin.
|
|
|
|
A plugin's init must initialize its hooks and handlers as references to this bot, this
|
|
is handed off but otherwise nothing is done with that here, this just loads it from a
|
|
Python sense.
|
|
|
|
:param connection: connection for this load request
|
|
:type connection: LenientServerConnection
|
|
:param event: associated irc event object
|
|
:type event: Event
|
|
:param plugin_path: path (likely a relative one) of the plugin to attempt to load
|
|
:type plugin_path: str
|
|
:param feedback: whether or not to send messages to IRC regarding the load attempt
|
|
:type feedback: bool
|
|
"""
|
|
log.debug("trying to load plugin %s", plugin_path)
|
|
|
|
dest = None
|
|
if feedback:
|
|
dest = ircbotlib.reply_destination_for_event(event)
|
|
|
|
# loop over the sys.modules entries, for debugging
|
|
log.debug("loaded modules:")
|
|
for module_name, module in sys.modules.items():
|
|
log.debug(" %s", module_name)
|
|
|
|
for path, plugin in self.plugins:
|
|
log.debug("looping already loaded plugin: %s %s", plugin, path)
|
|
if plugin_path == path:
|
|
if feedback:
|
|
self.privmsg(dest, "Plugin '{0:s}' is already loaded.".format(plugin_path))
|
|
return
|
|
|
|
# not loaded, let's get to work
|
|
try:
|
|
__import__(plugin_path)
|
|
module = sys.modules[plugin_path]
|
|
plugin = module.plugin(self, connection, event)
|
|
plugin.start()
|
|
self.plugins.append((plugin_path, plugin))
|
|
|
|
# give it a model
|
|
plugin_model, c = IrcPlugin.objects.get_or_create(path=plugin_path)
|
|
|
|
if feedback:
|
|
self.privmsg(dest, "Plugin '{0:s}' loaded.".format(plugin_path))
|
|
except ImportError as e:
|
|
log.error("Error loading '{0:s}'".format(plugin_path))
|
|
log.exception(e)
|
|
# i don't think this would get populated if __import__ failed del sys.modules[plugin_path]
|
|
if feedback:
|
|
self.privmsg(dest, "Plugin '{0:s}' could not be loaded: {1:s}".format(plugin_path, str(e)))
|
|
except AttributeError as e:
|
|
log.error("Error loading '{0:s}'".format(plugin_path))
|
|
log.exception(e)
|
|
del sys.modules[plugin_path]
|
|
if feedback:
|
|
self.privmsg(dest, "Plugin '{0:s}' could not be loaded: {1:s}".format(plugin_path, str(e)))
|
|
|
|
# loop over the sys.modules entries, for debugging
|
|
log.debug("new list of loaded modules:")
|
|
for module_name, module in sys.modules.items():
|
|
log.debug(" %s", module_name)
|
|
|
|
def handle_reload(self, connection, event, match):
|
|
"""Handle IRC requests to reload a plugin (module or package).
|
|
|
|
This allows code for a plugin (and potentially also its sibling modules in the package)
|
|
to be reloaded on demand, in the event that changes were made.
|
|
|
|
:param connection: connection for this reload request
|
|
:type connection: LenientServerConnection
|
|
:param event: associated irc event object
|
|
:type event: Event
|
|
:param match: the matched regex (via add_global_regex_handler)
|
|
:type match: Match
|
|
"""
|
|
has_perm = ircbotlib.has_permission(event.source, 'ircbot.manage_loaded_plugins')
|
|
log.debug("has permission to reload?: %s", str(has_perm))
|
|
if has_perm:
|
|
plugin_path = match.group(1)
|
|
log.debug("calling _reload_plugin on %s", plugin_path)
|
|
self._reload_plugin(connection, event, plugin_path)
|
|
|
|
def _reload_plugin(self, connection, event, plugin_path, feedback=True):
|
|
"""Execute the built-in reload() on a plugin (module) or it and its siblings (package).
|
|
|
|
:param connection: connection for this load request
|
|
:type connection: LenientServerConnection
|
|
:param event: associated irc event object
|
|
:type event: Event
|
|
:param plugin_path: path (likely a relative one) of the plugin to attempt to reload
|
|
:type plugin_path: str
|
|
:param feedback: whether or not to send messages to IRC regarding the reload attempt
|
|
:type feedback: bool
|
|
"""
|
|
log.debug("trying to unload plugin %s", plugin_path)
|
|
|
|
dest = ircbotlib.reply_destination_for_event(event)
|
|
|
|
# loop over the sys.modules entries, for debugging
|
|
log.debug("loaded modules:")
|
|
for module_name, module in sys.modules.items():
|
|
log.debug(" %s", module_name)
|
|
|
|
# make sure what was provided was a legitimate plugin
|
|
# (so that we don't go reloading totally arbitrary code)
|
|
search_path = None
|
|
for path, plugin in self.plugins:
|
|
# first check exact path
|
|
if path == plugin_path:
|
|
log.debug("requested %s is %s, so we will try to reload %s",
|
|
plugin_path, path, plugin_path)
|
|
search_path = plugin_path
|
|
if plugin_path[-1] == '.' and path.startswith(plugin_path):
|
|
log.debug("requested %s is substring of %s, so we will try to reload all %s items",
|
|
plugin_path, path, plugin_path)
|
|
search_path = plugin_path[:-1]
|
|
|
|
# we need to know the plugin names to do some extra logic with reload
|
|
plugin_names = [x[0] for x in self.plugins]
|
|
|
|
# do reload(s)
|
|
if search_path:
|
|
plugins_to_reload = []
|
|
for module_name, module in sys.modules.items():
|
|
if module_name.startswith(search_path):
|
|
if module_name in plugin_names:
|
|
# we need to take the plugin reload path for this, but not
|
|
# before all other code is reloaded, so we save this for the end
|
|
# if we did this here, the ircplugin might still reference the old
|
|
# version of its dependencies
|
|
plugins_to_reload.append(module_name)
|
|
else:
|
|
log.debug("reloading %s", module_name)
|
|
importlib.reload(module)
|
|
|
|
if feedback:
|
|
self.privmsg(dest, "'{0:s}' reloaded.".format(module_name))
|
|
|
|
# the references to event handlers will be stale (reload() doesn't go that deep)
|
|
# so we need to tear down and reinitialize plugins
|
|
for plugin_to_reload in plugins_to_reload:
|
|
log.debug("unloading plugin %s", plugin_to_reload)
|
|
self._unload_plugin(event, plugin_to_reload, False)
|
|
log.debug("loading plugin %s", plugin_to_reload)
|
|
self._load_plugin(connection, event, plugin_to_reload, False)
|
|
|
|
def handle_unload(self, connection, event, match):
|
|
"""Handle IRC requests to unload a plugin.
|
|
|
|
Unloading includes disconnecting the IRC plugin hooks, so this is the
|
|
way to "remove" a plugin from the IRC bot. Note that this only
|
|
removes the module for the specified plugin, not its dependencies, so
|
|
if you're trying to unload in order to refresh modules, this won't
|
|
do what you want.
|
|
|
|
:param connection: connection for this unload request
|
|
:type connection: LenientServerConnection
|
|
:param event: associated irc event object
|
|
:type event: Event
|
|
:param match: the matched regex (via add_global_regex_handler)
|
|
:type match: Match
|
|
"""
|
|
has_perm = ircbotlib.has_permission(event.source, 'ircbot.manage_loaded_plugins')
|
|
log.debug("has permission to unload?: %s", str(has_perm))
|
|
if has_perm:
|
|
plugin_path = match.group(1)
|
|
log.debug("calling _unload_plugin on %s", plugin_path)
|
|
self._unload_plugin(event, plugin_path)
|
|
|
|
def _unload_plugin(self, event, plugin_path, feedback=True):
|
|
"""Attempt to unload and del a module if it's loaded.
|
|
|
|
This stops the plugin, which should (if it's coded properly) disconnect its
|
|
event handlers and kill threads and whatnot.
|
|
|
|
:param event: associated irc event object
|
|
:type event: Event
|
|
:param plugin_path: path (likely a relative one) of the plugin to attempt to unload
|
|
:type plugin_path: str
|
|
:param feedback: whether or not to send messages to IRC regarding the unload attempt
|
|
:type feedback: bool
|
|
"""
|
|
log.debug("trying to unload plugin %s", plugin_path)
|
|
|
|
dest = ircbotlib.reply_destination_for_event(event)
|
|
|
|
# loop over the sys.modules entries, for debugging
|
|
log.debug("loaded modules:")
|
|
for module_name, module in sys.modules.items():
|
|
log.debug(" %s", module_name)
|
|
|
|
unloaded = False
|
|
for path, plugin in self.plugins:
|
|
if plugin_path == path:
|
|
unloaded = True
|
|
self.plugins.remove((path, plugin))
|
|
plugin.stop()
|
|
del plugin
|
|
del sys.modules[plugin_path]
|
|
|
|
if feedback:
|
|
self.privmsg(dest, "Plugin '{0:s}' unloaded.".format(plugin_path))
|
|
|
|
if not unloaded and feedback:
|
|
self.privmsg(dest, "Plugin '{0:s}' is not loaded.".format(plugin_path))
|
|
|
|
# loop over the sys.modules entries, for debugging
|
|
log.debug("new list of loaded modules:")
|
|
for module_name, module in sys.modules.items():
|
|
log.debug(" %s", module_name)
|
|
|
|
def privmsg(self, target, text):
|
|
"""Send a PRIVMSG command.
|
|
|
|
Args:
|
|
target the destination nick/channel
|
|
text the message to send
|
|
|
|
"""
|
|
if not target:
|
|
return
|
|
|
|
log.debug("OUTGOING PRIVMSG: t[%s] m[%s]", target, text)
|
|
self.connection.send_raw("PRIVMSG {0:s} :{1:s}".format(target, text))
|
|
|
|
def reply(self, event, replystr, stop=False, explicit_target=None):
|
|
"""Reply over IRC to replypath or return a string with the reply.
|
|
|
|
The primary utility for this is to properly handle recursion. The
|
|
recursion code will set up a couple hints that this method
|
|
picks up on and will appropriately send an IRC event or return a
|
|
string.
|
|
|
|
Unless you know what you are doing, the modules you write should use
|
|
this method rather than send a privmsg reply, as failing to call this
|
|
method will certainly have recursion do odd things with your module.
|
|
|
|
Args:
|
|
event incoming event
|
|
replystr the message to reply with
|
|
stop whether or not to let other handlers see this
|
|
explicit_target if event is none, use this for the destination
|
|
|
|
Returns:
|
|
The replystr if the event is inside recursion, or, potentially,
|
|
"NO MORE" to stop other event handlers from acting.
|
|
"""
|
|
if event:
|
|
log.debug("in reply for e[%s] r[%s]", event, replystr)
|
|
replypath = ircbotlib.reply_destination_for_event(event)
|
|
recursing = getattr(event, 'recursing', False)
|
|
log.debug("determined recursing to be %s", recursing)
|
|
elif explicit_target:
|
|
log.debug("in reply for e[NO EVENT] r[%s]", replystr)
|
|
replypath = explicit_target
|
|
recursing = False
|
|
else:
|
|
log.warning("reply() called with no event and no explicit target, aborting")
|
|
return
|
|
|
|
log.debug("replypath: %s", replypath)
|
|
|
|
if replystr is not None:
|
|
if recursing:
|
|
return replystr
|
|
else:
|
|
# try doing outbound recursion
|
|
log.debug("old replystr: %s", replystr)
|
|
if event:
|
|
fake_event = copy.deepcopy(event)
|
|
fake_event.arguments[0] = copy.deepcopy(replystr).replace('{', '[').replace('}', ']')
|
|
else:
|
|
fake_event = irc.client.Event(type='pubmsg', source='fake', target='fake')
|
|
fake_event.arguments.append(copy.deepcopy(replystr).replace('{', '[').replace('}', ']'))
|
|
log.debug("hacked replystr: %s", fake_event.arguments[0])
|
|
self.connection.reactor.try_recursion(self.connection, fake_event)
|
|
replystr = fake_event.arguments[0]
|
|
log.debug("final replystr: %s", replystr)
|
|
|
|
lines = 0
|
|
replies = replystr.split('\n')
|
|
for reply in replies:
|
|
# split messages that are too long. max length is 512, but we also need to
|
|
# account for display and that'll show our hostname
|
|
space = 512 - len(':{0:s} PRIVMSG {1:s} :\r\n'.format(self.nickmask, replypath))
|
|
splitspace = space - (len(self.splitter) + 1)
|
|
|
|
while len(reply) > space:
|
|
# do splitting
|
|
splitpos = reply.rfind(' ', 0, splitspace)
|
|
splittext = reply[0:splitpos] + ' ' + self.splitter
|
|
reply = self.splitter + ' ' + reply[splitpos + 1:]
|
|
self.privmsg(replypath, splittext)
|
|
|
|
# antiflood
|
|
lines += 1
|
|
time.sleep(int(0.5 * lines))
|
|
|
|
# done splitting, or it was never necessary
|
|
self.privmsg(replypath, reply)
|
|
if stop:
|
|
return "NO MORE"
|
|
|
|
def xmlrpc_register_function(self, func, name):
|
|
"""Add a method to the XML-RPC interface.
|
|
|
|
:param func: the method to register
|
|
:type func: a method
|
|
:param name: the name to expose the method as
|
|
:type name: str
|
|
"""
|
|
if func and self.xmlrpc:
|
|
if hasattr(func, '__call__'):
|
|
self.xmlrpc.register_function(func, name)
|
|
|
|
def _xmlrpc_listen(self):
|
|
"""Begin listening. Hopefully this was called in a new thread."""
|
|
self.xmlrpc.serve_forever()
|
|
|
|
def _xmlrpc_shutdown(self):
|
|
"""Shut down the XML-RPC server."""
|
|
if self.xmlrpc is not None:
|
|
self.xmlrpc.shutdown()
|
|
self.xmlrpc.server_close()
|
|
|
|
def start(self):
|
|
"""Start the bot."""
|
|
self._connect()
|
|
super(IRCBot, self).start()
|
|
|
|
def sigint_handler(self, signal, frame):
|
|
"""Cleanly shutdown on SIGINT."""
|
|
log.debug("shutting down")
|
|
for path, plugin in self.plugins:
|
|
log.debug("trying to shut down %s", path)
|
|
self.plugins.remove((path, plugin))
|
|
plugin.stop()
|
|
del plugin
|
|
del sys.modules[path]
|
|
|
|
self.die(msg="Shutting down...")
|
|
|
|
|
|
class Channel(object):
|
|
"""A class for keeping information about an IRC channel."""
|
|
|
|
def __init__(self):
|
|
"""Initialize channel object."""
|
|
self.userdict = IRCDict()
|
|
self.operdict = IRCDict()
|
|
self.voiceddict = IRCDict()
|
|
self.ownerdict = IRCDict()
|
|
self.halfopdict = IRCDict()
|
|
self.modes = {}
|
|
|
|
def users(self):
|
|
"""Returns an unsorted list of the channel's users."""
|
|
return list(self.userdict.keys())
|
|
|
|
def opers(self):
|
|
"""Returns an unsorted list of the channel's operators."""
|
|
return list(self.operdict.keys())
|
|
|
|
def voiced(self):
|
|
"""Returns an unsorted list of the persons that have voice mode set in the channel."""
|
|
return list(self.voiceddict.keys())
|
|
|
|
def owners(self):
|
|
"""Returns an unsorted list of the channel's owners."""
|
|
return list(self.ownerdict.keys())
|
|
|
|
def halfops(self):
|
|
"""Returns an unsorted list of the channel's half-operators."""
|
|
return list(self.halfopdict.keys())
|
|
|
|
def has_user(self, nick):
|
|
"""Check whether the channel has a user."""
|
|
return nick in self.userdict
|
|
|
|
def is_oper(self, nick):
|
|
"""Check whether a user has operator status in the channel."""
|
|
return nick in self.operdict
|
|
|
|
def is_voiced(self, nick):
|
|
"""Check whether a user has voice mode set in the channel."""
|
|
return nick in self.voiceddict
|
|
|
|
def is_owner(self, nick):
|
|
"""Check whether a user has owner status in the channel."""
|
|
return nick in self.ownerdict
|
|
|
|
def is_halfop(self, nick):
|
|
"""Check whether a user has half-operator status in the channel."""
|
|
return nick in self.halfopdict
|
|
|
|
def add_user(self, nick):
|
|
"""Add user."""
|
|
self.userdict[nick] = 1
|
|
|
|
def remove_user(self, nick):
|
|
"""Remove user."""
|
|
for d in self.userdict, self.operdict, self.voiceddict:
|
|
if nick in d:
|
|
del d[nick]
|
|
|
|
def change_nick(self, before, after):
|
|
"""Handle a nick change."""
|
|
self.userdict[after] = self.userdict.pop(before)
|
|
if before in self.operdict:
|
|
self.operdict[after] = self.operdict.pop(before)
|
|
if before in self.voiceddict:
|
|
self.voiceddict[after] = self.voiceddict.pop(before)
|
|
|
|
def set_userdetails(self, nick, details):
|
|
"""Set user details."""
|
|
if nick in self.userdict:
|
|
self.userdict[nick] = details
|
|
|
|
def set_mode(self, mode, value=None):
|
|
"""Set mode on the channel.
|
|
|
|
Arguments:
|
|
|
|
mode -- The mode (a single-character string).
|
|
|
|
value -- Value
|
|
"""
|
|
if mode == "o":
|
|
self.operdict[value] = 1
|
|
elif mode == "v":
|
|
self.voiceddict[value] = 1
|
|
elif mode == "q":
|
|
self.ownerdict[value] = 1
|
|
elif mode == "h":
|
|
self.halfopdict[value] = 1
|
|
else:
|
|
self.modes[mode] = value
|
|
|
|
def clear_mode(self, mode, value=None):
|
|
"""Clear mode on the channel.
|
|
|
|
Arguments:
|
|
|
|
mode -- The mode (a single-character string).
|
|
|
|
value -- Value
|
|
"""
|
|
try:
|
|
if mode == "o":
|
|
del self.operdict[value]
|
|
elif mode == "v":
|
|
del self.voiceddict[value]
|
|
elif mode == "q":
|
|
del self.ownerdict[value]
|
|
elif mode == "h":
|
|
del self.halfopdict[value]
|
|
else:
|
|
del self.modes[mode]
|
|
except KeyError:
|
|
pass
|
|
|
|
def has_mode(self, mode):
|
|
"""Return if mode is in channel modes."""
|
|
return mode in self.modes
|
|
|
|
def is_moderated(self):
|
|
"""Return if the channel is +m."""
|
|
return self.has_mode("m")
|
|
|
|
def is_secret(self):
|
|
"""Return if the channel is +s."""
|
|
return self.has_mode("s")
|
|
|
|
def is_protected(self):
|
|
"""Return if the channel is +p."""
|
|
return self.has_mode("p")
|
|
|
|
def has_topic_lock(self):
|
|
"""Return if the channel is +t."""
|
|
return self.has_mode("t")
|
|
|
|
def is_invite_only(self):
|
|
"""Return if the channel is +i."""
|
|
return self.has_mode("i")
|
|
|
|
def has_allow_external_messages(self):
|
|
"""Return if the channel is +n."""
|
|
return self.has_mode("n")
|
|
|
|
def has_limit(self):
|
|
"""Return if the channel is +l."""
|
|
return self.has_mode("l")
|
|
|
|
def limit(self):
|
|
"""Return the channel limit count."""
|
|
if self.has_limit():
|
|
return self.modes["l"]
|
|
else:
|
|
return None
|
|
|
|
def has_key(self):
|
|
"""Return if the channel is +k."""
|
|
return self.has_mode("k")
|