987 lines
39 KiB
Python
987 lines
39 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
|
|
import socket
|
|
import ssl
|
|
import sys
|
|
import threading
|
|
import time
|
|
from xmlrpc.server import SimpleXMLRPCRequestHandler, SimpleXMLRPCServer
|
|
|
|
import irc.client
|
|
import irc.modes
|
|
from irc.bot import Channel
|
|
from irc.connection import Factory
|
|
from irc.dict import IRCDict
|
|
from jaraco.stream import buffer
|
|
|
|
import ircbot.lib as ircbotlib
|
|
from dr_botzo._version import __version__
|
|
from ircbot.models import Alias, IrcChannel, IrcPlugin, IrcServer
|
|
|
|
log = logging.getLogger('ircbot.bot')
|
|
|
|
|
|
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 = 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."""
|
|
|
|
# used by Reactor.server() to initialize
|
|
connection_class = LenientServerConnection
|
|
|
|
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 add_global_regex_handler(self, events, regex, handler, priority=0):
|
|
"""Add 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):
|
|
"""Remove 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
|
|
if connection.server_config.additional_addressed_nicks:
|
|
all_nicks = '|'.join(connection.server_config.additional_addressed_nicks.split('\n') +
|
|
[connection.get_nickname()])
|
|
else:
|
|
all_nicks = connection.get_nickname()
|
|
addressed_pattern = r'^(({nicks})[:,]|@({nicks})[:,]?)\s+(?P<addressed_msg>.*)'.format(nicks=all_nicks)
|
|
|
|
# ignore the first word, a nick, if the speaker is the bridge
|
|
try:
|
|
channel = IrcChannel.objects.get(name=sent_location)
|
|
if sender_nick == channel.discord_bridge:
|
|
short_what = ' '.join(what.split(' ')[1:])
|
|
real_source = re.sub(r'^<(\S+)> .*', r'\1!user@discord-bridge', what)
|
|
|
|
match = re.match(addressed_pattern, short_what, re.IGNORECASE)
|
|
event.arguments[0] = short_what
|
|
event.source = real_source
|
|
sender_nick = irc.client.NickMask(event.source).nick
|
|
event.sender_nick = sender_nick
|
|
event.in_privmsg = sender_nick == sent_location
|
|
else:
|
|
match = re.match(addressed_pattern, what, re.IGNORECASE)
|
|
except IrcChannel.DoesNotExist:
|
|
match = re.match(addressed_pattern, what, re.IGNORECASE)
|
|
|
|
if match:
|
|
event.addressed = True
|
|
event.addressed_msg = match.group('addressed_msg')
|
|
|
|
log.debug("all_nicks: %s, addressed: %s", all_nicks, event.addressed)
|
|
|
|
# only do aliasing for pubmsg/privmsg
|
|
log.debug("checking for alias for %s", event.arguments[0])
|
|
|
|
for alias in Alias.objects.all():
|
|
repl = alias.replace(event.arguments[0])
|
|
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.exception("caught exception!")
|
|
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=SimpleXMLRPCRequestHandler, 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.reactor.scheduler.execute_after(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.reactor.scheduler.execute_after(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
|
|
if self.server_config.post_connect:
|
|
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, server=connection.server_config):
|
|
log.info("autojoining %s", chan.name)
|
|
self.connection.join(chan.name)
|
|
|
|
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):
|
|
"""Return the bot version.
|
|
|
|
Used when answering a CTCP VERSION request.
|
|
"""
|
|
return f"dr.botzo {__version__}"
|
|
|
|
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):
|
|
"""Handle 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
|
|
|
|
# convert characters that don't make sense for Discord (like ^C^B)
|
|
if self.connection.server_config.replace_irc_control_with_markdown:
|
|
log.debug("old replystr: %s", replystr)
|
|
replystr = replystr.replace('\x02', '**')
|
|
replystr = replystr.replace('\x0F', '')
|
|
replystr = re.sub('\x03..', '', replystr)
|
|
log.debug("new replystr: %s", replystr)
|
|
|
|
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...")
|