2015-12-07 02:40:13 +01:00
|
|
|
"""
|
|
|
|
utils.py - PyLink utilities module.
|
|
|
|
|
|
|
|
This module contains various utility functions related to IRC and/or the PyLink
|
|
|
|
framework.
|
|
|
|
"""
|
|
|
|
|
2015-04-25 07:37:07 +02:00
|
|
|
import string
|
2015-06-17 05:46:01 +02:00
|
|
|
import re
|
2015-11-23 05:14:47 +01:00
|
|
|
import importlib
|
|
|
|
import os
|
2016-05-14 18:55:46 +02:00
|
|
|
import collections
|
2017-02-22 06:45:43 +01:00
|
|
|
import argparse
|
2015-04-25 07:37:07 +02:00
|
|
|
|
2016-06-21 03:18:54 +02:00
|
|
|
from .log import log
|
|
|
|
from . import world, conf
|
2017-03-09 07:30:32 +01:00
|
|
|
|
|
|
|
# Load the protocol and plugin packages.
|
2016-07-03 08:57:20 +02:00
|
|
|
from pylinkirc import protocols, plugins
|
2015-05-31 21:20:09 +02:00
|
|
|
|
2017-03-09 07:30:32 +01:00
|
|
|
PLUGIN_PREFIX = plugins.__name__ + '.'
|
|
|
|
PROTOCOL_PREFIX = protocols.__name__ + '.'
|
2016-12-06 08:33:03 +01:00
|
|
|
NORMALIZEWHITESPACE_RE = re.compile(r'\s+')
|
2017-08-29 05:01:28 +02:00
|
|
|
_proto_utils_class = None # Set by classes.py when loaded
|
2016-12-06 08:33:03 +01:00
|
|
|
|
2016-08-25 09:43:44 +02:00
|
|
|
class NotAuthorizedError(Exception):
|
2016-05-14 19:05:18 +02:00
|
|
|
"""
|
2017-02-25 03:30:34 +01:00
|
|
|
Exception raised by the PyLink permissions system when a user fails access requirements.
|
2016-05-14 19:05:18 +02:00
|
|
|
"""
|
|
|
|
pass
|
|
|
|
|
2017-02-22 06:45:43 +01:00
|
|
|
class InvalidArgumentsError(TypeError):
|
|
|
|
"""
|
|
|
|
Exception raised (by IRCParser and potentially others) when a bot command is given invalid arguments.
|
|
|
|
"""
|
|
|
|
|
2017-08-22 07:20:20 +02:00
|
|
|
class ProtocolError(RuntimeError):
|
|
|
|
"""
|
|
|
|
Exception raised when a network protocol violation is encountered in some way.
|
|
|
|
"""
|
|
|
|
|
2016-07-01 02:57:40 +02:00
|
|
|
def add_cmd(func, name=None, **kwargs):
|
2015-11-23 05:14:47 +01:00
|
|
|
"""Binds an IRC command function to the given command name."""
|
2016-07-01 02:57:40 +02:00
|
|
|
world.services['pylink'].add_cmd(func, name=name, **kwargs)
|
2015-10-24 03:47:11 +02:00
|
|
|
return func
|
2015-06-08 04:31:56 +02:00
|
|
|
|
2015-06-24 04:08:43 +02:00
|
|
|
def add_hook(func, command):
|
2015-11-23 05:14:47 +01:00
|
|
|
"""Binds a hook function to the given command name."""
|
2015-07-05 04:00:29 +02:00
|
|
|
command = command.upper()
|
2015-09-27 19:53:25 +02:00
|
|
|
world.hooks[command].append(func)
|
2015-10-24 03:47:11 +02:00
|
|
|
return func
|
2015-06-24 04:08:43 +02:00
|
|
|
|
2017-08-29 05:01:28 +02:00
|
|
|
# DEPRECATED
|
2015-06-17 05:46:01 +02:00
|
|
|
def isNick(s, nicklen=None):
|
2017-08-29 05:01:28 +02:00
|
|
|
"""Returns whether the string given is a valid nick.
|
2015-06-17 05:46:01 +02:00
|
|
|
|
2017-08-29 05:01:28 +02:00
|
|
|
Deprecated since 2.0: use irc.is_nick() instead."""
|
|
|
|
|
|
|
|
log.warning('utils.isNick() is deprecated since PyLink 2.0, use irc.is_nick() instead.')
|
|
|
|
return _proto_utils_class.is_nick(s, nicklen=nicklen)
|
|
|
|
|
|
|
|
# DEPRECATED
|
2015-06-17 05:46:01 +02:00
|
|
|
def isChannel(s):
|
2017-08-29 05:01:28 +02:00
|
|
|
"""Returns whether the string given is a valid channel name.
|
2015-06-22 00:00:33 +02:00
|
|
|
|
2017-08-29 05:01:28 +02:00
|
|
|
Deprecated since 2.0: use irc.is_channel() instead."""
|
2015-06-22 00:00:33 +02:00
|
|
|
|
2017-08-29 05:01:28 +02:00
|
|
|
log.warning('utils.isChannel() is deprecated since PyLink 2.0, use irc.is_channel() instead.')
|
|
|
|
return _proto_utils_class.is_channel(s)
|
|
|
|
|
|
|
|
# DEPRECATED
|
2015-06-22 00:00:33 +02:00
|
|
|
def isServerName(s):
|
2017-08-29 05:01:28 +02:00
|
|
|
"""Returns whether the string given is a valid server name.
|
|
|
|
|
|
|
|
Deprecated since 2.0: use irc.is_server_name() instead."""
|
2015-06-21 05:36:35 +02:00
|
|
|
|
2017-08-29 05:01:28 +02:00
|
|
|
log.warning('utils.isServerName() is deprecated since PyLink 2.0, use irc.is_server_name() instead.')
|
|
|
|
return _proto_utils_class.is_server_name(s)
|
|
|
|
|
|
|
|
# DEPRECATED
|
2015-09-13 07:28:34 +02:00
|
|
|
def isHostmask(text):
|
2017-08-29 05:01:28 +02:00
|
|
|
"""Returns whether the given text is a valid hostmask.
|
|
|
|
|
|
|
|
Deprecated since 2.0: use irc.is_hostmask() instead."""
|
|
|
|
log.warning('utils.isHostmask() is deprecated since PyLink 2.0, use irc.is_hostmask() instead.')
|
|
|
|
return _proto_utils_class.is_hostmask(text)
|
2015-09-13 07:28:34 +02:00
|
|
|
|
2017-08-29 05:28:10 +02:00
|
|
|
def expand_path(path):
|
2017-03-09 07:30:32 +01:00
|
|
|
"""
|
|
|
|
Returns a path expanded with environment variables and home folders (~) expanded, in that order."""
|
|
|
|
return os.path.expanduser(os.path.expandvars(path))
|
2017-08-29 05:28:10 +02:00
|
|
|
expandpath = expand_path # Consistency with os.path
|
2017-03-09 07:30:32 +01:00
|
|
|
|
2017-08-29 05:28:10 +02:00
|
|
|
def reset_module_dirs():
|
2017-03-09 07:30:32 +01:00
|
|
|
"""
|
|
|
|
(Re)sets custom protocol module and plugin directories to the ones specified in the config.
|
|
|
|
"""
|
|
|
|
# Note: This assumes that the first element of the package path is the default one.
|
2017-07-14 14:51:29 +02:00
|
|
|
plugins.__path__ = [plugins.__path__[0]] + [expandpath(path) for path in conf.conf['pylink'].get('plugin_dirs', [])]
|
2017-08-29 05:28:10 +02:00
|
|
|
log.debug('reset_module_dirs: new pylinkirc.plugins.__path__: %s', plugins.__path__)
|
2017-07-14 14:51:29 +02:00
|
|
|
protocols.__path__ = [protocols.__path__[0]] + [expandpath(path) for path in conf.conf['pylink'].get('protocol_dirs', [])]
|
2017-08-29 05:28:10 +02:00
|
|
|
log.debug('reset_module_dirs: new pylinkirc.protocols.__path__: %s', protocols.__path__)
|
|
|
|
resetModuleDirs = reset_module_dirs
|
2017-03-09 07:30:32 +01:00
|
|
|
|
2017-08-29 05:28:10 +02:00
|
|
|
def load_plugin(name):
|
2016-07-03 08:57:20 +02:00
|
|
|
"""
|
|
|
|
Imports and returns the requested plugin.
|
|
|
|
"""
|
2016-12-17 04:25:41 +01:00
|
|
|
return importlib.import_module(PLUGIN_PREFIX + name)
|
2017-08-29 05:28:10 +02:00
|
|
|
loadPlugin = load_plugin
|
2016-07-03 08:57:20 +02:00
|
|
|
|
2017-08-29 05:28:10 +02:00
|
|
|
def get_protocol_module(name):
|
2015-12-26 23:45:28 +01:00
|
|
|
"""
|
|
|
|
Imports and returns the protocol module requested.
|
|
|
|
"""
|
2016-12-17 04:25:41 +01:00
|
|
|
return importlib.import_module(PROTOCOL_PREFIX + name)
|
2017-08-29 05:28:10 +02:00
|
|
|
getProtocolModule = get_protocol_module
|
2015-12-07 02:13:47 +01:00
|
|
|
|
2017-08-29 05:28:10 +02:00
|
|
|
def split_hostmask(mask):
|
2016-07-18 00:20:48 +02:00
|
|
|
"""
|
|
|
|
Returns a nick!user@host hostmask split into three fields: nick, user, and host.
|
|
|
|
"""
|
|
|
|
nick, identhost = mask.split('!', 1)
|
|
|
|
ident, host = identhost.split('@', 1)
|
|
|
|
return [nick, ident, host]
|
2017-08-29 05:28:10 +02:00
|
|
|
splitHostmask = split_hostmask
|
2016-07-18 00:20:48 +02:00
|
|
|
|
2016-05-14 18:55:46 +02:00
|
|
|
class ServiceBot():
|
2016-07-01 03:22:45 +02:00
|
|
|
"""
|
|
|
|
PyLink IRC Service class.
|
|
|
|
"""
|
|
|
|
|
2017-08-22 06:19:38 +02:00
|
|
|
def __init__(self, name, default_help=True, default_list=True, manipulatable=False, default_nick=None, desc=None):
|
|
|
|
# Service name and default nick
|
2016-05-14 18:55:46 +02:00
|
|
|
self.name = name
|
2017-08-22 06:19:38 +02:00
|
|
|
self.default_nick = default_nick
|
2016-05-14 19:17:40 +02:00
|
|
|
|
2016-05-14 21:52:32 +02:00
|
|
|
# Tracks whether the bot should be manipulatable by the 'bots' plugin and other commands.
|
|
|
|
self.manipulatable = manipulatable
|
|
|
|
|
2016-05-14 18:55:46 +02:00
|
|
|
# We make the command definitions a dict of lists of functions. Multiple
|
|
|
|
# plugins are actually allowed to bind to one function name; this just causes
|
|
|
|
# them to be called in the order that they are bound.
|
|
|
|
self.commands = collections.defaultdict(list)
|
|
|
|
|
|
|
|
# This tracks the UIDs of the service bot on different networks, as they are
|
|
|
|
# spawned.
|
|
|
|
self.uids = {}
|
|
|
|
|
2016-06-25 23:09:41 +02:00
|
|
|
# Track what channels other than those defined in the config
|
|
|
|
# that the bot should join by default.
|
2017-01-21 20:43:26 +01:00
|
|
|
self.extra_channels = collections.defaultdict(set)
|
2016-06-25 23:09:41 +02:00
|
|
|
|
2016-07-01 02:30:44 +02:00
|
|
|
# Service description, used in the default help command if one is given.
|
|
|
|
self.desc = desc
|
|
|
|
|
|
|
|
# List of command names to "feature"
|
|
|
|
self.featured_cmds = set()
|
|
|
|
|
2017-07-10 03:18:45 +02:00
|
|
|
# Maps command aliases to the respective primary commands
|
|
|
|
self.alias_cmds = {}
|
|
|
|
|
2016-05-14 18:55:46 +02:00
|
|
|
if default_help:
|
|
|
|
self.add_cmd(self.help)
|
|
|
|
|
|
|
|
if default_list:
|
|
|
|
self.add_cmd(self.listcommands, 'list')
|
|
|
|
|
|
|
|
def spawn(self, irc=None):
|
2016-07-01 03:22:45 +02:00
|
|
|
"""
|
|
|
|
Spawns instances of this service on all connected networks.
|
|
|
|
"""
|
2016-05-14 18:55:46 +02:00
|
|
|
# Spawn the new service by calling the PYLINK_NEW_SERVICE hook,
|
|
|
|
# which is handled by coreplugin.
|
|
|
|
if irc is None:
|
|
|
|
for irc in world.networkobjects.values():
|
2017-06-30 08:02:34 +02:00
|
|
|
irc.call_hooks([None, 'PYLINK_NEW_SERVICE', {'name': self.name}])
|
2016-05-14 18:55:46 +02:00
|
|
|
else:
|
|
|
|
raise NotImplementedError("Network specific plugins not supported yet.")
|
|
|
|
|
2016-09-24 08:39:12 +02:00
|
|
|
def join(self, irc, channels, autojoin=True):
|
|
|
|
"""
|
|
|
|
Joins the given service bot to the given channel(s).
|
|
|
|
"""
|
2016-09-24 21:33:57 +02:00
|
|
|
|
2017-07-12 23:29:34 +02:00
|
|
|
if isinstance(irc, str):
|
2016-09-24 21:33:57 +02:00
|
|
|
netname = irc
|
|
|
|
else:
|
|
|
|
netname = irc.name
|
2016-09-24 08:39:12 +02:00
|
|
|
|
|
|
|
# Ensure type safety: pluralize strings if only one channel was given, then convert to set.
|
2017-07-12 23:29:34 +02:00
|
|
|
if isinstance(channels, str):
|
2016-09-24 08:39:12 +02:00
|
|
|
channels = [channels]
|
|
|
|
channels = set(channels)
|
|
|
|
|
2016-09-24 21:33:57 +02:00
|
|
|
if autojoin:
|
|
|
|
log.debug('(%s/%s) Adding channels %s to autojoin', netname, self.name, channels)
|
|
|
|
self.extra_channels[netname] |= channels
|
|
|
|
|
|
|
|
# If the network was given as a string, look up the Irc object here.
|
|
|
|
try:
|
|
|
|
irc = world.networkobjects[netname]
|
|
|
|
except KeyError:
|
2017-05-08 02:33:15 +02:00
|
|
|
log.debug('(%s/%s) Skipping join(), IRC object not initialized yet', netname, self.name)
|
2016-09-24 21:33:57 +02:00
|
|
|
return
|
|
|
|
|
|
|
|
try:
|
|
|
|
u = self.uids[irc.name]
|
|
|
|
except KeyError:
|
|
|
|
log.debug('(%s/%s) Skipping join(), UID not initialized yet', irc.name, self.name)
|
|
|
|
return
|
|
|
|
|
2016-09-24 08:39:12 +02:00
|
|
|
# Specify modes to join the services bot with.
|
|
|
|
joinmodes = irc.serverdata.get("%s_joinmodes" % self.name) or conf.conf.get(self.name, {}).get('joinmodes') or ''
|
|
|
|
joinmodes = ''.join([m for m in joinmodes if m in irc.prefixmodes])
|
|
|
|
|
|
|
|
for chan in channels:
|
2017-08-31 22:19:34 +02:00
|
|
|
if irc.is_channel(chan):
|
2017-08-25 11:31:26 +02:00
|
|
|
if chan in irc.channels and u in irc.channels[chan].users:
|
2016-09-24 08:39:12 +02:00
|
|
|
log.debug('(%s) Skipping join of services %s to channel %s - it is already present', irc.name, self.name, chan)
|
|
|
|
continue
|
|
|
|
log.debug('(%s) Joining services %s to channel %s with modes %r', irc.name, self.name, chan, joinmodes)
|
|
|
|
if joinmodes: # Modes on join were specified; use SJOIN to burst our service
|
|
|
|
irc.proto.sjoin(irc.sid, chan, [(joinmodes, u)])
|
|
|
|
else:
|
|
|
|
irc.proto.join(u, chan)
|
|
|
|
|
2017-06-30 08:02:34 +02:00
|
|
|
irc.call_hooks([irc.sid, 'PYLINK_SERVICE_JOIN', {'channel': chan, 'users': [u]}])
|
2016-09-24 08:39:12 +02:00
|
|
|
else:
|
|
|
|
log.warning('(%s) Ignoring invalid autojoin channel %r.', irc.name, chan)
|
|
|
|
|
2017-02-17 02:41:07 +01:00
|
|
|
def reply(self, irc, text, notice=None, private=None):
|
2016-07-01 03:22:45 +02:00
|
|
|
"""Replies to a message as the service in question."""
|
2016-05-14 19:17:40 +02:00
|
|
|
servuid = self.uids.get(irc.name)
|
|
|
|
if not servuid:
|
|
|
|
log.warning("(%s) Possible desync? UID for service %s doesn't exist!", irc.name, self.name)
|
|
|
|
return
|
|
|
|
|
2016-07-01 03:22:45 +02:00
|
|
|
irc.reply(text, notice=notice, source=servuid, private=private)
|
2016-12-05 08:35:16 +01:00
|
|
|
|
2017-02-17 02:41:07 +01:00
|
|
|
def error(self, irc, text, notice=None, private=None):
|
2016-11-19 07:01:23 +01:00
|
|
|
"""Replies with an error, as the service in question."""
|
|
|
|
servuid = self.uids.get(irc.name)
|
|
|
|
if not servuid:
|
|
|
|
log.warning("(%s) Possible desync? UID for service %s doesn't exist!", irc.name, self.name)
|
|
|
|
return
|
2016-05-14 19:17:40 +02:00
|
|
|
|
2016-11-19 07:01:23 +01:00
|
|
|
irc.error(text, notice=notice, source=servuid, private=private)
|
2016-12-05 08:35:16 +01:00
|
|
|
|
2016-07-01 03:22:45 +02:00
|
|
|
def call_cmd(self, irc, source, text, called_in=None):
|
2016-05-14 19:17:40 +02:00
|
|
|
"""
|
|
|
|
Calls a PyLink bot command. source is the caller's UID, and text is the
|
|
|
|
full, unparsed text of the message.
|
|
|
|
"""
|
2016-07-01 03:22:45 +02:00
|
|
|
irc.called_in = called_in or source
|
|
|
|
irc.called_by = source
|
2016-05-14 19:17:40 +02:00
|
|
|
|
|
|
|
cmd_args = text.strip().split(' ')
|
|
|
|
cmd = cmd_args[0].lower()
|
|
|
|
cmd_args = cmd_args[1:]
|
|
|
|
if cmd not in self.commands:
|
2017-03-29 07:18:51 +02:00
|
|
|
# XXX: we really need abstraction for this kind of config fetching...
|
|
|
|
show_unknown_cmds = irc.serverdata.get('%s_show_unknown_commands' % self.name,
|
|
|
|
conf.conf.get(self.name, {}).get('show_unknown_commands',
|
|
|
|
conf.conf['pylink'].get('show_unknown_commands', True)))
|
|
|
|
|
|
|
|
if cmd and show_unknown_cmds and not cmd.startswith('\x01'):
|
2017-03-21 06:22:43 +01:00
|
|
|
# Ignore empty commands and invalid command errors from CTCPs.
|
2016-06-15 20:26:30 +02:00
|
|
|
self.reply(irc, 'Error: Unknown command %r.' % cmd)
|
2017-06-30 08:02:34 +02:00
|
|
|
log.info('(%s/%s) Received unknown command %r from %s', irc.name, self.name, cmd, irc.get_hostmask(source))
|
2016-05-14 19:17:40 +02:00
|
|
|
return
|
|
|
|
|
2017-06-30 08:02:34 +02:00
|
|
|
log.info('(%s/%s) Calling command %r for %s', irc.name, self.name, cmd, irc.get_hostmask(source))
|
2016-05-14 19:17:40 +02:00
|
|
|
for func in self.commands[cmd]:
|
|
|
|
try:
|
|
|
|
func(irc, source, cmd_args)
|
2017-03-07 01:30:19 +01:00
|
|
|
except (NotAuthorizedError, InvalidArgumentsError) as e:
|
2016-08-25 09:57:21 +02:00
|
|
|
self.reply(irc, 'Error: %s' % e)
|
2016-05-14 19:17:40 +02:00
|
|
|
except Exception as e:
|
|
|
|
log.exception('Unhandled exception caught in command %r', cmd)
|
|
|
|
self.reply(irc, 'Uncaught exception in command %r: %s: %s' % (cmd, type(e).__name__, str(e)))
|
2016-05-14 18:55:46 +02:00
|
|
|
|
2017-07-11 06:59:29 +02:00
|
|
|
def add_cmd(self, func, name=None, featured=False, aliases=None):
|
2016-05-14 18:55:46 +02:00
|
|
|
"""Binds an IRC command function to the given command name."""
|
|
|
|
if name is None:
|
|
|
|
name = func.__name__
|
|
|
|
name = name.lower()
|
|
|
|
|
2016-07-01 02:30:44 +02:00
|
|
|
# Mark as a featured command if requested to do so.
|
|
|
|
if featured:
|
|
|
|
self.featured_cmds.add(name)
|
2017-07-11 06:59:29 +02:00
|
|
|
|
|
|
|
# If this is an alias, store the primary command in the alias_cmds dict
|
|
|
|
if aliases is not None:
|
|
|
|
for alias in aliases:
|
2017-07-11 07:18:01 +02:00
|
|
|
if name == alias:
|
|
|
|
log.error('Refusing to alias command %r (in plugin %r) to itself!', name, func.__module__)
|
|
|
|
continue
|
|
|
|
|
2017-07-11 06:59:29 +02:00
|
|
|
self.add_cmd(func, name=alias) # Bind the alias as well.
|
|
|
|
self.alias_cmds[alias] = name
|
2016-07-01 02:30:44 +02:00
|
|
|
|
2016-05-14 18:55:46 +02:00
|
|
|
self.commands[name].append(func)
|
|
|
|
return func
|
|
|
|
|
2017-08-22 07:22:01 +02:00
|
|
|
def get_nick(self, irc, fails=0):
|
2017-08-22 06:19:38 +02:00
|
|
|
"""
|
2017-08-22 07:22:01 +02:00
|
|
|
If the 'fails' argument is set to zero, this method returns the preferred nick for this
|
|
|
|
service bot on the given network. The following fields are checked in the given order:
|
2017-08-22 06:19:38 +02:00
|
|
|
# 1) Network specific nick settings for this service (servers:<netname>:servicename_nick)
|
|
|
|
# 2) Global settings for this service (servicename:nick)
|
|
|
|
# 3) The service's hardcoded default nick.
|
|
|
|
# 4) The literal service name.
|
2017-08-22 07:22:01 +02:00
|
|
|
|
|
|
|
If the 'fails' argument is set to a non-zero value, a list of *alternate* (fallback) nicks
|
|
|
|
will be fetched from these fields in this order:
|
|
|
|
# 1) Network specific altnick settings for this service (servers:<netname>:servicename_altnicks)
|
|
|
|
# 2) Global altnick settings for this service (servicename:altnicks)
|
|
|
|
|
|
|
|
If such an alternate nicks list exists, an alternate nick will be chosen based on the value
|
|
|
|
of the 'fails' argument:
|
|
|
|
- If nick fetching fails once, return the 1st alternate nick from the list,
|
|
|
|
- If nick fetching fails twice, return the 2nd alternate nick from the list, ...
|
|
|
|
|
|
|
|
Otherwise, if the alternate nicks list doesn't exist, or if there is no corresponding value
|
|
|
|
for the current 'fails' value, the preferred nick plus the 'fails' number of underscores (_)
|
|
|
|
will be used instead.
|
|
|
|
- fails=1 => preferred_nick_
|
|
|
|
- fails=2 => preferred_nick__
|
|
|
|
|
|
|
|
If the resulting nick is too long for the given network, ProtocolError will be raised.
|
2017-08-22 06:19:38 +02:00
|
|
|
"""
|
|
|
|
sbconf = conf.conf.get(self.name, {})
|
2017-08-22 07:22:01 +02:00
|
|
|
nick = irc.serverdata.get("%s_nick" % self.name) or sbconf.get('nick') or self.default_nick or self.name
|
|
|
|
|
|
|
|
if fails >= 1:
|
|
|
|
altnicks = irc.serverdata.get("%s_altnicks" % self.name) or sbconf.get('altnicks') or []
|
|
|
|
try:
|
|
|
|
nick = altnicks[fails-1]
|
|
|
|
except IndexError:
|
|
|
|
nick += ('_' * fails)
|
|
|
|
|
|
|
|
if irc.maxnicklen > 0 and len(nick) > irc.maxnicklen:
|
|
|
|
raise ProtocolError("Nick %r too long for network (maxnicklen=%s)" % (nick, irc.maxnicklen))
|
|
|
|
|
|
|
|
assert nick
|
|
|
|
return nick
|
2017-08-22 06:19:38 +02:00
|
|
|
|
|
|
|
def get_ident(self, irc):
|
|
|
|
"""
|
|
|
|
Returns the preferred ident for this service bot on the given network. The following fields are checked in the given order:
|
|
|
|
# 1) Network specific ident settings for this service (servers:<netname>:servicename_ident)
|
|
|
|
# 2) Global settings for this service (servicename:ident)
|
|
|
|
# 3) The service's hardcoded default nick.
|
|
|
|
# 4) The literal service name.
|
|
|
|
"""
|
|
|
|
sbconf = conf.conf.get(self.name, {})
|
|
|
|
return irc.serverdata.get("%s_ident" % self.name) or sbconf.get('ident') or self.default_nick or self.name
|
|
|
|
|
|
|
|
def get_host(self, irc):
|
|
|
|
"""
|
|
|
|
Returns the preferred hostname for this service bot on the given network. The following fields are checked in the given order:
|
|
|
|
# 1) Network specific hostname settings for this service (servers:<netname>:servicename_host)
|
|
|
|
# 2) Global settings for this service (servicename:host)
|
|
|
|
# 3) The PyLink server hostname.
|
|
|
|
"""
|
|
|
|
sbconf = conf.conf.get(self.name, {})
|
|
|
|
return irc.serverdata.get("%s_host" % self.name) or sbconf.get('host') or irc.hostname()
|
|
|
|
|
|
|
|
def get_realname(self, irc):
|
|
|
|
"""
|
|
|
|
Returns the preferred real name for this service bot on the given network. The following fields are checked in the given order:
|
|
|
|
# 1) Network specific realname settings for this service (servers:<netname>:servicename_realname)
|
|
|
|
# 2) Global settings for this service (servicename:realname)
|
|
|
|
# 3) The globally configured real name (pylink:realname).
|
|
|
|
# 4) The literal service name.
|
|
|
|
"""
|
|
|
|
sbconf = conf.conf.get(self.name, {})
|
|
|
|
return irc.serverdata.get("%s_realname" % self.name) or sbconf.get('realname') or conf.conf['pylink'].get('realname') or self.name
|
|
|
|
|
2016-07-01 03:52:35 +02:00
|
|
|
def _show_command_help(self, irc, command, private=False, shortform=False):
|
2016-07-01 03:37:14 +02:00
|
|
|
"""
|
|
|
|
Shows help for the given command.
|
|
|
|
"""
|
|
|
|
def _reply(text):
|
|
|
|
"""
|
|
|
|
reply() wrapper to handle the private argument.
|
|
|
|
"""
|
|
|
|
self.reply(irc, text, private=private)
|
|
|
|
|
2017-02-16 02:06:16 +01:00
|
|
|
def _reply_format(next_line):
|
|
|
|
"""
|
|
|
|
Formats and outputs the given line.
|
|
|
|
"""
|
|
|
|
next_line = next_line.strip()
|
|
|
|
next_line = NORMALIZEWHITESPACE_RE.sub(' ', next_line)
|
|
|
|
_reply(next_line)
|
|
|
|
|
2016-05-14 21:22:00 +02:00
|
|
|
if command not in self.commands:
|
2016-07-01 03:37:14 +02:00
|
|
|
_reply('Error: Unknown command %r.' % command)
|
2016-05-14 21:22:00 +02:00
|
|
|
return
|
|
|
|
else:
|
|
|
|
funcs = self.commands[command]
|
|
|
|
if len(funcs) > 1:
|
2016-07-01 03:37:14 +02:00
|
|
|
_reply('The following \x02%s\x02 plugins bind to the \x02%s\x02 command: %s'
|
|
|
|
% (len(funcs), command, ', '.join([func.__module__ for func in funcs])))
|
2016-05-14 21:22:00 +02:00
|
|
|
for func in funcs:
|
|
|
|
doc = func.__doc__
|
|
|
|
mod = func.__module__
|
|
|
|
if doc:
|
2016-08-02 21:38:15 +02:00
|
|
|
lines = doc.splitlines()
|
2016-05-14 21:22:00 +02:00
|
|
|
# Bold the first line, which usually just tells you what
|
|
|
|
# arguments the command takes.
|
2016-12-06 08:33:03 +01:00
|
|
|
args_desc = '\x02%s %s\x02' % (command, lines[0])
|
|
|
|
|
|
|
|
_reply(args_desc.strip())
|
|
|
|
if not shortform:
|
|
|
|
# Note: we handle newlines in docstrings a bit differently. Per
|
2017-02-16 02:06:16 +01:00
|
|
|
# https://github.com/GLolol/PyLink/issues/307, only double newlines (and
|
|
|
|
# combinations of more) have the effect of showing a new line on IRC.
|
|
|
|
# Single newlines are stripped so that word wrap can be applied in source
|
|
|
|
# code without affecting the output on IRC.
|
|
|
|
# TODO: we should probably verify that the output line doesn't exceed IRC
|
|
|
|
# line length limits...
|
|
|
|
next_line = ''
|
|
|
|
for linenum, line in enumerate(lines[1:], 1):
|
|
|
|
stripped_line = line.strip()
|
|
|
|
log.debug("_show_command_help: Current line (%s): %r", linenum, stripped_line)
|
|
|
|
log.debug("_show_command_help: Last line (%s-1=%s): %r", linenum, linenum-1, lines[linenum-1].strip())
|
|
|
|
|
|
|
|
if stripped_line:
|
|
|
|
# If this line has content, join it with the previous one.
|
|
|
|
next_line += line.rstrip()
|
|
|
|
next_line += ' '
|
|
|
|
elif linenum > 0 and not lines[linenum-1].strip():
|
|
|
|
# The line before us was empty, so treat this one as a legitimate
|
|
|
|
# newline/break.
|
|
|
|
log.debug("_show_command_help: Adding an extra break...")
|
|
|
|
_reply(' ')
|
|
|
|
else:
|
|
|
|
# Otherwise, output it to IRC.
|
|
|
|
_reply_format(next_line)
|
|
|
|
next_line = '' # Reset the next line buffer
|
|
|
|
else:
|
|
|
|
_reply_format(next_line)
|
2016-05-14 21:22:00 +02:00
|
|
|
else:
|
2016-07-01 03:37:14 +02:00
|
|
|
_reply("Error: Command %r doesn't offer any help." % command)
|
2017-07-11 06:59:29 +02:00
|
|
|
|
2017-07-10 03:40:08 +02:00
|
|
|
# Regardless of whether help text is available, mention aliases.
|
|
|
|
if not shortform:
|
|
|
|
if command in self.alias_cmds:
|
2017-07-11 07:12:53 +02:00
|
|
|
_reply(' ')
|
|
|
|
_reply('This command is an alias for \x02%s\x02.' % self.alias_cmds[command])
|
2017-07-10 03:40:08 +02:00
|
|
|
aliases = set(alias for alias, primary in self.alias_cmds.items() if primary == command)
|
|
|
|
if aliases:
|
2017-07-11 07:12:53 +02:00
|
|
|
_reply(' ')
|
|
|
|
_reply('Available aliases: \x02%s\x02' % ', '.join(aliases))
|
2016-05-14 18:55:46 +02:00
|
|
|
|
2016-07-01 02:39:53 +02:00
|
|
|
def help(self, irc, source, args):
|
|
|
|
"""<command>
|
|
|
|
|
|
|
|
Gives help for <command>, if it is available."""
|
|
|
|
try:
|
|
|
|
command = args[0].lower()
|
|
|
|
except IndexError:
|
|
|
|
# No argument given: show service description (if present), 'list' output, and a list
|
|
|
|
# of featured commands.
|
|
|
|
if self.desc:
|
|
|
|
self.reply(irc, self.desc)
|
|
|
|
self.reply(irc, " ") # Extra newline to unclutter the output text
|
|
|
|
|
|
|
|
self.listcommands(irc, source, args)
|
|
|
|
return
|
|
|
|
else:
|
|
|
|
self._show_command_help(irc, command)
|
|
|
|
|
2016-05-14 18:55:46 +02:00
|
|
|
def listcommands(self, irc, source, args):
|
2016-12-17 04:25:41 +01:00
|
|
|
"""[<plugin name>]
|
2016-05-14 21:22:00 +02:00
|
|
|
|
2016-12-17 04:25:41 +01:00
|
|
|
Returns a list of available commands this service has to offer. The optional
|
|
|
|
plugin name argument also allows you to filter commands by plugin (case
|
|
|
|
insensitive)."""
|
|
|
|
|
|
|
|
try:
|
|
|
|
plugin_filter = args[0].lower()
|
|
|
|
except IndexError:
|
|
|
|
plugin_filter = None
|
2016-05-14 21:22:00 +02:00
|
|
|
|
2017-07-10 03:23:52 +02:00
|
|
|
# Don't show CTCP handlers or aliases in the public command list.
|
|
|
|
cmds = sorted(cmd for cmd in self.commands.keys() if '\x01' not in cmd and cmd not in self.alias_cmds)
|
2016-12-17 04:25:41 +01:00
|
|
|
|
|
|
|
if plugin_filter is not None:
|
|
|
|
# Filter by plugin, if the option was given.
|
|
|
|
new_cmds = []
|
|
|
|
|
|
|
|
# Add the pylinkirc.plugins prefix to the module name, so it can be used for matching.
|
|
|
|
plugin_module = PLUGIN_PREFIX + plugin_filter
|
|
|
|
|
|
|
|
for cmd_definition in cmds:
|
|
|
|
for cmdfunc in self.commands[cmd_definition]:
|
|
|
|
if cmdfunc.__module__.lower() == plugin_module:
|
|
|
|
new_cmds.append(cmd_definition)
|
|
|
|
|
|
|
|
# Replace the old command list.
|
|
|
|
cmds = new_cmds
|
2016-07-01 02:30:44 +02:00
|
|
|
|
|
|
|
if cmds:
|
|
|
|
self.reply(irc, 'Available commands include: %s' % ', '.join(cmds))
|
|
|
|
self.reply(irc, 'To see help on a specific command, type \x02help <command>\x02.')
|
2016-12-17 04:25:41 +01:00
|
|
|
elif not plugin_filter:
|
2016-07-01 03:37:14 +02:00
|
|
|
self.reply(irc, 'This service doesn\'t provide any public commands.')
|
2016-12-17 04:25:41 +01:00
|
|
|
else:
|
|
|
|
self.reply(irc, 'This service doesn\'t provide any public commands from the plugin %s.' % plugin_filter)
|
2016-07-01 02:30:44 +02:00
|
|
|
|
2016-07-01 02:39:53 +02:00
|
|
|
# If there are featured commands, list them by showing the help for each.
|
2016-07-01 03:37:14 +02:00
|
|
|
# These definitions are sent in private to prevent flooding in channels.
|
2016-12-17 04:25:41 +01:00
|
|
|
if self.featured_cmds and not plugin_filter:
|
2016-07-01 03:37:14 +02:00
|
|
|
self.reply(irc, " ", private=True)
|
|
|
|
self.reply(irc, 'Featured commands include:', private=True)
|
2016-07-01 03:05:12 +02:00
|
|
|
for cmd in sorted(self.featured_cmds):
|
2016-12-17 04:25:41 +01:00
|
|
|
if cmd in cmds:
|
2016-07-01 02:39:53 +02:00
|
|
|
# Only show featured commands that are both defined and loaded.
|
|
|
|
# TODO: perhaps plugin unload should remove unused featured command
|
|
|
|
# definitions automatically?
|
2016-07-01 03:52:35 +02:00
|
|
|
self._show_command_help(irc, cmd, private=True, shortform=True)
|
2016-07-01 03:55:16 +02:00
|
|
|
self.reply(irc, 'End of command listing.', private=True)
|
2016-05-14 18:55:46 +02:00
|
|
|
|
2017-08-29 05:28:10 +02:00
|
|
|
def register_service(name, *args, **kwargs):
|
2016-05-14 23:52:12 +02:00
|
|
|
"""Registers a service bot."""
|
2016-05-14 18:55:46 +02:00
|
|
|
name = name.lower()
|
|
|
|
if name in world.services:
|
|
|
|
raise ValueError("Service name %s is already bound!" % name)
|
|
|
|
|
2017-02-18 21:54:26 +01:00
|
|
|
# Allow disabling service spawning either globally or by service.
|
|
|
|
elif name != 'pylink' and not (conf.conf.get(name, {}).get('spawn_service',
|
2017-07-14 14:51:29 +02:00
|
|
|
conf.conf['pylink'].get('spawn_services', True))):
|
2017-02-18 21:54:26 +01:00
|
|
|
return world.services['pylink']
|
|
|
|
|
2016-05-14 18:55:46 +02:00
|
|
|
world.services[name] = sbot = ServiceBot(name, *args, **kwargs)
|
|
|
|
sbot.spawn()
|
2016-05-14 23:23:52 +02:00
|
|
|
return sbot
|
2017-08-29 05:28:10 +02:00
|
|
|
registerService = register_service
|
2016-05-14 23:52:12 +02:00
|
|
|
|
2017-08-29 05:28:10 +02:00
|
|
|
def unregister_service(name):
|
2016-05-14 23:52:12 +02:00
|
|
|
"""Unregisters an existing service bot."""
|
2016-07-08 07:41:39 +02:00
|
|
|
name = name.lower()
|
2017-02-18 21:45:43 +01:00
|
|
|
|
|
|
|
if name not in world.services:
|
|
|
|
# Service bot doesn't exist; ignore.
|
|
|
|
return
|
|
|
|
|
2016-05-14 23:52:12 +02:00
|
|
|
sbot = world.services[name]
|
|
|
|
for ircnet, uid in sbot.uids.items():
|
2016-07-27 01:26:01 +02:00
|
|
|
ircobj = world.networkobjects[ircnet]
|
|
|
|
# Special case for the main PyLink client. If we're unregistering that,
|
|
|
|
# clear the irc.pseudoclient entry.
|
|
|
|
if name == 'pylink':
|
|
|
|
ircobj.pseudoclient = None
|
|
|
|
|
|
|
|
ircobj.proto.quit(uid, "Service unloaded.")
|
2016-05-14 23:52:12 +02:00
|
|
|
|
|
|
|
del world.services[name]
|
2017-08-29 05:28:10 +02:00
|
|
|
unregisterService = unregister_service
|
2017-01-01 08:35:27 +01:00
|
|
|
|
2017-08-29 05:28:10 +02:00
|
|
|
def wrap_arguments(prefix, args, length, separator=' ', max_args_per_line=0):
|
2017-01-01 08:35:27 +01:00
|
|
|
"""
|
|
|
|
Takes a static prefix and a list of arguments, and returns a list of strings
|
|
|
|
with the arguments wrapped across multiple lines. This is useful for breaking up
|
|
|
|
long SJOIN or MODE strings so they aren't cut off by message length limits.
|
|
|
|
"""
|
|
|
|
strings = []
|
|
|
|
|
2017-08-29 05:28:10 +02:00
|
|
|
assert args, "wrap_arguments: no arguments given"
|
2017-01-01 08:35:27 +01:00
|
|
|
|
|
|
|
buf = prefix
|
|
|
|
|
2017-01-07 07:12:49 +01:00
|
|
|
args = list(args)
|
|
|
|
|
2017-01-01 08:35:27 +01:00
|
|
|
while args:
|
|
|
|
assert len(prefix+args[0]) <= length, \
|
2017-08-29 05:28:10 +02:00
|
|
|
"wrap_arguments: Argument %r is too long for the given length %s" % (args[0], length)
|
2017-01-01 08:35:27 +01:00
|
|
|
|
|
|
|
# Add arguments until our buffer is up to the length limit.
|
2017-01-12 08:08:16 +01:00
|
|
|
if (len(buf + args[0]) + 1) <= length and ((not max_args_per_line) or len(buf.split(' ')) < max_args_per_line):
|
2017-01-01 08:35:27 +01:00
|
|
|
if buf != prefix: # Only add a separator if this isn't the first argument of a line
|
|
|
|
buf += separator
|
|
|
|
buf += args.pop(0)
|
|
|
|
else:
|
|
|
|
# Once this is full, add the string to the list and reset the buffer.
|
|
|
|
strings.append(buf)
|
|
|
|
buf = prefix
|
|
|
|
else:
|
|
|
|
strings.append(buf)
|
|
|
|
|
|
|
|
return strings
|
2017-08-29 05:28:10 +02:00
|
|
|
wrapArguments = wrap_arguments
|
2017-02-22 06:45:43 +01:00
|
|
|
|
|
|
|
class IRCParser(argparse.ArgumentParser):
|
|
|
|
"""
|
|
|
|
Wrapper around argparse.ArgumentParser, without quitting on usage errors.
|
|
|
|
"""
|
2017-03-08 19:01:11 +01:00
|
|
|
REMAINDER = argparse.REMAINDER
|
2017-02-22 06:45:43 +01:00
|
|
|
|
2017-03-07 01:28:38 +01:00
|
|
|
def print_help(self, *args, **kwargs):
|
|
|
|
# XXX: find a way to somehow route this through IRC
|
|
|
|
raise InvalidArgumentsError("Use help <commandname> to receive help for PyLink commands.")
|
|
|
|
|
|
|
|
def error(self, message, *args, **kwargs):
|
2017-02-22 06:45:43 +01:00
|
|
|
raise InvalidArgumentsError(message)
|
2017-03-07 01:28:38 +01:00
|
|
|
_print_message = error # XXX: ugly
|
2017-02-25 07:28:26 +01:00
|
|
|
|
2017-03-07 01:07:43 +01:00
|
|
|
def exit(self, *args):
|
|
|
|
return
|