sopel-irc /
sopel
| 1 | # coding=utf-8 |
||
| 2 | # Copyright 2008, Sean B. Palmer, inamidst.com |
||
| 3 | # Copyright © 2012, Elad Alfassa <[email protected]> |
||
| 4 | # Copyright 2012-2015, Elsie Powell, http://embolalia.com |
||
| 5 | # |
||
| 6 | # Licensed under the Eiffel Forum License 2. |
||
| 7 | |||
| 8 | from __future__ import unicode_literals, absolute_import, print_function, division |
||
| 9 | |||
| 10 | from ast import literal_eval |
||
| 11 | import collections |
||
| 12 | import itertools |
||
| 13 | import logging |
||
| 14 | import re |
||
| 15 | import sys |
||
| 16 | import threading |
||
| 17 | import time |
||
| 18 | |||
| 19 | from sopel import irc, logger, plugins, tools |
||
| 20 | from sopel.db import SopelDB |
||
| 21 | from sopel.tools import Identifier, deprecated |
||
| 22 | import sopel.tools.jobs |
||
| 23 | from sopel.trigger import Trigger |
||
| 24 | from sopel.module import NOLIMIT |
||
| 25 | import sopel.loader |
||
| 26 | |||
| 27 | |||
| 28 | __all__ = ['Sopel', 'SopelWrapper'] |
||
| 29 | |||
| 30 | LOGGER = logging.getLogger(__name__) |
||
| 31 | |||
| 32 | if sys.version_info.major >= 3: |
||
| 33 | unicode = str |
||
| 34 | basestring = str |
||
| 35 | py3 = True |
||
| 36 | else: |
||
| 37 | py3 = False |
||
| 38 | |||
| 39 | |||
| 40 | class _CapReq(object): |
||
| 41 | def __init__(self, prefix, module, failure=None, arg=None, success=None): |
||
| 42 | def nop(bot, cap): |
||
| 43 | pass |
||
| 44 | # TODO at some point, reorder those args to be sane |
||
| 45 | self.prefix = prefix |
||
| 46 | self.module = module |
||
| 47 | self.arg = arg |
||
| 48 | self.failure = failure or nop |
||
| 49 | self.success = success or nop |
||
| 50 | |||
| 51 | |||
| 52 | class Sopel(irc.Bot): |
||
| 53 | def __init__(self, config, daemon=False): |
||
| 54 | irc.Bot.__init__(self, config) |
||
| 55 | self._daemon = daemon # Used for iPython. TODO something saner here |
||
| 56 | # `re.compile('.*') is re.compile('.*')` because of caching, so we need |
||
| 57 | # to associate a list with each regex, since they are unexpectedly |
||
| 58 | # indistinct. |
||
| 59 | self._callables = { |
||
| 60 | 'high': collections.defaultdict(list), |
||
| 61 | 'medium': collections.defaultdict(list), |
||
| 62 | 'low': collections.defaultdict(list) |
||
| 63 | } |
||
| 64 | self._plugins = {} |
||
| 65 | self.config = config |
||
| 66 | """The :class:`sopel.config.Config` for the current Sopel instance.""" |
||
| 67 | |||
| 68 | self.doc = {} |
||
| 69 | """A dictionary of command names to their documentation. |
||
| 70 | |||
| 71 | Each command is mapped to its docstring and any available examples, if |
||
| 72 | declared in the module's code. |
||
| 73 | |||
| 74 | .. versionchanged:: 3.2 |
||
| 75 | Use the first item in each callable's commands list as the key, |
||
| 76 | instead of the function name as declared in the source code. |
||
| 77 | """ |
||
| 78 | |||
| 79 | self._command_groups = collections.defaultdict(list) |
||
| 80 | """A mapping of module names to a list of commands in it.""" |
||
| 81 | |||
| 82 | self.stats = {} # deprecated, remove in 7.0 |
||
| 83 | self._times = {} |
||
| 84 | """ |
||
| 85 | A dictionary mapping lowercased nicks to dictionaries which map |
||
| 86 | function names to the time which they were last used by that nick. |
||
| 87 | """ |
||
| 88 | |||
| 89 | self.server_capabilities = {} |
||
| 90 | """A dict mapping supported IRCv3 capabilities to their options. |
||
| 91 | |||
| 92 | For example, if the server specifies the capability ``sasl=EXTERNAL``, |
||
| 93 | it will be here as ``{"sasl": "EXTERNAL"}``. Capabilities specified |
||
| 94 | without any options will have ``None`` as the value. |
||
| 95 | |||
| 96 | For servers that do not support IRCv3, this will be an empty set. |
||
| 97 | """ |
||
| 98 | |||
| 99 | self.enabled_capabilities = set() |
||
| 100 | """A set containing the IRCv3 capabilities that the bot has enabled.""" |
||
| 101 | |||
| 102 | self._cap_reqs = dict() |
||
| 103 | """A dictionary of capability names to a list of requests.""" |
||
| 104 | |||
| 105 | self.privileges = dict() |
||
| 106 | """A dictionary of channels to their users and privilege levels. |
||
| 107 | |||
| 108 | The value associated with each channel is a dictionary of |
||
| 109 | :class:`sopel.tools.Identifier`\\s to |
||
| 110 | a bitwise integer value, determined by combining the appropriate |
||
| 111 | constants from :mod:`sopel.module`. |
||
| 112 | |||
| 113 | .. deprecated:: 6.2.0 |
||
| 114 | Use :attr:`channels` instead. Will be removed in Sopel 8. |
||
| 115 | """ |
||
| 116 | |||
| 117 | self.channels = tools.SopelMemory() # name to chan obj |
||
| 118 | """A map of the channels that Sopel is in. |
||
| 119 | |||
| 120 | The keys are :class:`sopel.tools.Identifier`\\s of the channel names, |
||
| 121 | and map to :class:`sopel.tools.target.Channel` objects which contain |
||
| 122 | the users in the channel and their permissions. |
||
| 123 | """ |
||
| 124 | |||
| 125 | self.users = tools.SopelMemory() # name to user obj |
||
| 126 | """A map of the users that Sopel is aware of. |
||
| 127 | |||
| 128 | The keys are :class:`sopel.tools.Identifier`\\s of the nicknames, and |
||
| 129 | map to :class:`sopel.tools.target.User` instances. In order for Sopel |
||
| 130 | to be aware of a user, it must be in at least one channel which they |
||
| 131 | are also in. |
||
| 132 | """ |
||
| 133 | |||
| 134 | self.db = SopelDB(config) |
||
| 135 | """The bot's database, as a :class:`sopel.db.SopelDB` instance.""" |
||
| 136 | |||
| 137 | self.memory = tools.SopelMemory() |
||
| 138 | """ |
||
| 139 | A thread-safe dict for storage of runtime data to be shared between |
||
| 140 | modules. See :class:`sopel.tools.SopelMemory`. |
||
| 141 | """ |
||
| 142 | |||
| 143 | self.shutdown_methods = [] |
||
| 144 | """List of methods to call on shutdown.""" |
||
| 145 | |||
| 146 | self.scheduler = sopel.tools.jobs.JobScheduler(self) |
||
| 147 | """Job Scheduler. See :func:`sopel.module.interval`.""" |
||
| 148 | |||
| 149 | # Set up block lists |
||
| 150 | # Default to empty |
||
| 151 | if not self.config.core.nick_blocks: |
||
| 152 | self.config.core.nick_blocks = [] |
||
| 153 | if not self.config.core.host_blocks: |
||
| 154 | self.config.core.host_blocks = [] |
||
| 155 | |||
| 156 | @property |
||
| 157 | def hostmask(self): |
||
| 158 | """The current hostmask for the bot :class:`sopel.tools.target.User`. |
||
| 159 | |||
| 160 | :return: the bot's current hostmask |
||
| 161 | :rtype: str |
||
| 162 | |||
| 163 | Bot must be connected and in at least one channel. |
||
| 164 | """ |
||
| 165 | if not self.users or self.nick not in self.users: |
||
| 166 | raise KeyError("'hostmask' not available: bot must be connected and in at least one channel.") |
||
| 167 | |||
| 168 | return self.users.get(self.nick).hostmask |
||
| 169 | |||
| 170 | # Backwards-compatibility aliases to attributes made private in 6.2. Remove |
||
| 171 | # these in 7.0 |
||
| 172 | times = property(lambda self: getattr(self, '_times')) |
||
| 173 | command_groups = property(lambda self: getattr(self, '_command_groups')) |
||
| 174 | |||
| 175 | def write(self, args, text=None): # Shim this in here for autodocs |
||
| 176 | """Send a command to the server. |
||
| 177 | |||
| 178 | :param args: an iterable of strings, which will be joined by spaces |
||
| 179 | :type args: :term:`iterable` |
||
| 180 | :param str text: a string that will be prepended with a ``:`` and added |
||
| 181 | to the end of the command |
||
| 182 | |||
| 183 | ``args`` is an iterable of strings, which are joined by spaces. |
||
| 184 | ``text`` is treated as though it were the final item in ``args``, but |
||
| 185 | is preceeded by a ``:``. This is a special case which means that |
||
| 186 | ``text``, unlike the items in ``args`` may contain spaces (though this |
||
| 187 | constraint is not checked by ``write``). |
||
| 188 | |||
| 189 | In other words, both ``sopel.write(('PRIVMSG',), 'Hello, world!')`` |
||
| 190 | and ``sopel.write(('PRIVMSG', ':Hello, world!'))`` will send |
||
| 191 | ``PRIVMSG :Hello, world!`` to the server. |
||
| 192 | |||
| 193 | Newlines and carriage returns (``'\\n'`` and ``'\\r'``) are removed |
||
| 194 | before sending. Additionally, if the message (after joining) is longer |
||
| 195 | than than 510 characters, any remaining characters will not be sent. |
||
| 196 | """ |
||
| 197 | irc.Bot.write(self, args, text=text) |
||
| 198 | |||
| 199 | def setup(self): |
||
| 200 | """Set up Sopel bot before it can run |
||
| 201 | |||
| 202 | The setup phase manages to: |
||
| 203 | |||
| 204 | * setup logging (configure Python's built-in :mod:`logging`), |
||
| 205 | * setup the bot's plugins (load, setup, and register) |
||
| 206 | * start the job scheduler |
||
| 207 | |||
| 208 | """ |
||
| 209 | self.setup_logging() |
||
| 210 | self.setup_plugins() |
||
| 211 | self.scheduler.start() |
||
| 212 | |||
| 213 | def setup_logging(self): |
||
| 214 | logger.setup_logging(self.config) |
||
| 215 | base_level = self.config.core.logging_level or 'INFO' |
||
| 216 | base_format = self.config.core.logging_format |
||
| 217 | base_datefmt = self.config.core.logging_datefmt |
||
| 218 | |||
| 219 | # configure channel logging if required by configuration |
||
| 220 | if self.config.core.logging_channel: |
||
| 221 | channel_level = self.config.core.logging_channel_level or base_level |
||
| 222 | channel_format = self.config.core.logging_channel_format or base_format |
||
| 223 | channel_datefmt = self.config.core.logging_channel_datefmt or base_datefmt |
||
| 224 | channel_params = {} |
||
| 225 | if channel_format: |
||
| 226 | channel_params['fmt'] = channel_format |
||
| 227 | if channel_datefmt: |
||
| 228 | channel_params['datefmt'] = channel_datefmt |
||
| 229 | formatter = logger.ChannelOutputFormatter(**channel_params) |
||
| 230 | handler = logger.IrcLoggingHandler(self, channel_level) |
||
| 231 | handler.setFormatter(formatter) |
||
| 232 | |||
| 233 | # set channel handler to `sopel` logger |
||
| 234 | LOGGER = logging.getLogger('sopel') |
||
| 235 | LOGGER.addHandler(handler) |
||
| 236 | |||
| 237 | def setup_plugins(self): |
||
| 238 | load_success = 0 |
||
| 239 | load_error = 0 |
||
| 240 | load_disabled = 0 |
||
| 241 | |||
| 242 | LOGGER.info('Loading plugins...') |
||
| 243 | usable_plugins = plugins.get_usable_plugins(self.config) |
||
| 244 | for name, info in usable_plugins.items(): |
||
| 245 | plugin, is_enabled = info |
||
| 246 | if not is_enabled: |
||
| 247 | load_disabled = load_disabled + 1 |
||
| 248 | continue |
||
| 249 | |||
| 250 | try: |
||
| 251 | plugin.load() |
||
| 252 | except Exception as e: |
||
| 253 | load_error = load_error + 1 |
||
| 254 | LOGGER.exception('Error loading %s: %s', name, e) |
||
| 255 | else: |
||
| 256 | try: |
||
| 257 | if plugin.has_setup(): |
||
| 258 | plugin.setup(self) |
||
| 259 | plugin.register(self) |
||
| 260 | except Exception as e: |
||
| 261 | load_error = load_error + 1 |
||
| 262 | LOGGER.exception('Error in %s setup: %s', name, e) |
||
| 263 | else: |
||
| 264 | load_success = load_success + 1 |
||
| 265 | LOGGER.info('Plugin loaded: %s', name) |
||
| 266 | |||
| 267 | total = sum([load_success, load_error, load_disabled]) |
||
| 268 | if total and load_success: |
||
| 269 | LOGGER.info( |
||
| 270 | 'Registered %d plugins, %d failed, %d disabled', |
||
| 271 | (load_success - 1), |
||
| 272 | load_error, |
||
| 273 | load_disabled) |
||
| 274 | else: |
||
| 275 | LOGGER.warning("Warning: Couldn't load any plugins") |
||
| 276 | |||
| 277 | def reload_plugin(self, name): |
||
| 278 | """Reload a plugin |
||
| 279 | |||
| 280 | :param str name: name of the plugin to reload |
||
| 281 | :raise PluginNotRegistered: when there is no ``name`` plugin registered |
||
| 282 | |||
| 283 | It runs the plugin's shutdown routine and unregisters it. Then it |
||
| 284 | reloads it, runs its setup routines, and registers it again. |
||
| 285 | """ |
||
| 286 | if not self.has_plugin(name): |
||
| 287 | raise plugins.exceptions.PluginNotRegistered(name) |
||
| 288 | |||
| 289 | plugin = self._plugins[name] |
||
| 290 | # tear down |
||
| 291 | plugin.shutdown(self) |
||
| 292 | plugin.unregister(self) |
||
| 293 | LOGGER.info('Unloaded plugin %s', name) |
||
| 294 | # reload & setup |
||
| 295 | plugin.reload() |
||
| 296 | plugin.setup(self) |
||
| 297 | plugin.register(self) |
||
| 298 | LOGGER.info('Reloaded plugin %s', name) |
||
| 299 | |||
| 300 | def reload_plugins(self): |
||
| 301 | """Reload all plugins |
||
| 302 | |||
| 303 | First, run all plugin shutdown routines and unregister all plugins. |
||
| 304 | Then reload all plugins, run their setup routines, and register them |
||
| 305 | again. |
||
| 306 | """ |
||
| 307 | registered = list(self._plugins.items()) |
||
| 308 | # tear down all plugins |
||
| 309 | for name, plugin in registered: |
||
| 310 | plugin.shutdown(self) |
||
| 311 | plugin.unregister(self) |
||
| 312 | LOGGER.info('Unloaded plugin %s', name) |
||
| 313 | |||
| 314 | # reload & setup all plugins |
||
| 315 | for name, plugin in registered: |
||
| 316 | plugin.reload() |
||
| 317 | plugin.setup(self) |
||
| 318 | plugin.register(self) |
||
| 319 | LOGGER.info('Reloaded plugin %s', name) |
||
| 320 | |||
| 321 | def add_plugin(self, plugin, callables, jobs, shutdowns, urls): |
||
| 322 | """Add a loaded plugin to the bot's registry""" |
||
| 323 | self._plugins[plugin.name] = plugin |
||
| 324 | self.register(callables, jobs, shutdowns, urls) |
||
| 325 | |||
| 326 | def remove_plugin(self, plugin, callables, jobs, shutdowns, urls): |
||
| 327 | """Remove a loaded plugin from the bot's registry""" |
||
| 328 | name = plugin.name |
||
| 329 | if not self.has_plugin(name): |
||
| 330 | raise plugins.exceptions.PluginNotRegistered(name) |
||
| 331 | |||
| 332 | # remove commands, jobs, and shutdown functions |
||
| 333 | for func in itertools.chain(callables, jobs, shutdowns): |
||
| 334 | self.unregister(func) |
||
| 335 | |||
| 336 | # remove URL callback handlers |
||
| 337 | if "url_callbacks" in self.memory: |
||
| 338 | for func in urls: |
||
| 339 | regexes = func.url_regex |
||
| 340 | for regex in regexes: |
||
| 341 | if func == self.memory['url_callbacks'].get(regex): |
||
| 342 | self.unregister_url_callback(regex) |
||
| 343 | LOGGER.debug('URL Callback unregistered: %r', regex) |
||
| 344 | |||
| 345 | # remove plugin from registry |
||
| 346 | del self._plugins[name] |
||
| 347 | |||
| 348 | def has_plugin(self, name): |
||
| 349 | """Tell if the bot has registered this plugin by its name""" |
||
| 350 | return name in self._plugins |
||
| 351 | |||
| 352 | def unregister(self, obj): |
||
| 353 | """Unregister a callable. |
||
| 354 | |||
| 355 | :param obj: the callable to unregister |
||
| 356 | :type obj: :term:`object` |
||
| 357 | """ |
||
| 358 | if not callable(obj): |
||
| 359 | LOGGER.warning('Cannot unregister obj %r: not a callable', obj) |
||
| 360 | return |
||
| 361 | callable_name = getattr(obj, "__name__", 'UNKNOWN') |
||
| 362 | |||
| 363 | if hasattr(obj, 'rule'): # commands and intents have it added |
||
| 364 | for rule in obj.rule: |
||
| 365 | callb_list = self._callables[obj.priority][rule] |
||
| 366 | if obj in callb_list: |
||
| 367 | callb_list.remove(obj) |
||
| 368 | LOGGER.debug( |
||
| 369 | 'Rule callable "%s" unregistered', |
||
| 370 | callable_name, |
||
| 371 | rule.pattern) |
||
| 372 | |||
| 373 | if hasattr(obj, 'interval'): |
||
| 374 | self.scheduler.remove_callable_job(obj) |
||
| 375 | LOGGER.debug('Job callable removed: %s', callable_name) |
||
| 376 | |||
| 377 | if callable_name == "shutdown" and obj in self.shutdown_methods: |
||
| 378 | self.shutdown_methods.remove(obj) |
||
| 379 | |||
| 380 | def register(self, callables, jobs, shutdowns, urls): |
||
| 381 | """Register rules, jobs, shutdown methods, and URL callbacks. |
||
| 382 | |||
| 383 | :param callables: an iterable of callables to register |
||
| 384 | :type callables: :term:`iterable` |
||
| 385 | :param jobs: an iterable of functions to periodically invoke |
||
| 386 | :type jobs: :term:`iterable` |
||
| 387 | :param shutdowns: an iterable of functions to call on shutdown |
||
| 388 | :type shutdowns: :term:`iterable` |
||
| 389 | :param urls: an iterable of functions to call when matched against a URL |
||
| 390 | :type urls: :term:`iterable` |
||
| 391 | |||
| 392 | The ``callables`` argument contains a list of "callable objects", i.e. |
||
| 393 | objects for which :func:`callable` will return ``True``. They can be: |
||
| 394 | |||
| 395 | * a callable with rules (will match triggers with a regex pattern) |
||
| 396 | * a callable without rules (will match any triggers, such as events) |
||
| 397 | * a callable with commands |
||
| 398 | * a callable with nick commands |
||
| 399 | * a callable with action commands |
||
| 400 | |||
| 401 | It is possible to have a callable with rules, commands, and nick |
||
| 402 | commands configured. It should not be possible to have a callable with |
||
| 403 | commands or nick commands but without rules. Callables without rules |
||
| 404 | are usually event handlers. |
||
| 405 | """ |
||
| 406 | # Append module's shutdown function to the bot's list of functions to |
||
| 407 | # call on shutdown |
||
| 408 | self.shutdown_methods += shutdowns |
||
| 409 | match_any = re.compile('.*') |
||
| 410 | for callbl in callables: |
||
| 411 | callable_name = getattr(callbl, "__name__", 'UNKNOWN') |
||
| 412 | rules = getattr(callbl, 'rule', []) |
||
| 413 | commands = getattr(callbl, 'commands', []) |
||
| 414 | nick_commands = getattr(callbl, 'nickname_commands', []) |
||
| 415 | action_commands = getattr(callbl, 'action_commands', []) |
||
| 416 | events = getattr(callbl, 'event', []) |
||
| 417 | is_rule_only = rules and not commands and not nick_commands |
||
| 418 | |||
| 419 | if rules: |
||
| 420 | for rule in rules: |
||
| 421 | self._callables[callbl.priority][rule].append(callbl) |
||
| 422 | if is_rule_only: |
||
| 423 | # Command & Nick Command are logged later: |
||
| 424 | # here we log rule only callable |
||
| 425 | LOGGER.debug( |
||
| 426 | 'Rule callable "%s" registered for "%s"', |
||
| 427 | callable_name, |
||
| 428 | rule.pattern) |
||
| 429 | if commands: |
||
| 430 | LOGGER.debug( |
||
| 431 | 'Command callable "%s" registered for "%s"', |
||
| 432 | callable_name, |
||
| 433 | '|'.join(commands)) |
||
| 434 | if nick_commands: |
||
| 435 | LOGGER.debug( |
||
| 436 | 'Nick command callable "%s" registered for "%s"', |
||
| 437 | callable_name, |
||
| 438 | '|'.join(nick_commands)) |
||
| 439 | if action_commands: |
||
| 440 | LOGGER.debug( |
||
| 441 | 'Action command callable "%s" registered for "%s"', |
||
| 442 | callable_name, |
||
| 443 | '|'.join(action_commands)) |
||
| 444 | if events: |
||
| 445 | LOGGER.debug( |
||
| 446 | 'Event callable "%s" registered for "%s"', |
||
| 447 | callable_name, |
||
| 448 | '|'.join(events)) |
||
| 449 | else: |
||
| 450 | self._callables[callbl.priority][match_any].append(callbl) |
||
| 451 | if events: |
||
| 452 | LOGGER.debug( |
||
| 453 | 'Event callable "%s" registered ' |
||
| 454 | 'with "match any" rule for "%s"', |
||
| 455 | callable_name, |
||
| 456 | '|'.join(events)) |
||
| 457 | else: |
||
| 458 | LOGGER.debug( |
||
| 459 | 'Rule callable "%s" registered with "match any" rule', |
||
| 460 | callable_name) |
||
| 461 | |||
| 462 | if commands: |
||
| 463 | module_name = callbl.__module__.rsplit('.', 1)[-1] |
||
| 464 | # TODO doc and make decorator for this. Not sure if this is how |
||
| 465 | # it should work yet, so not making it public for 6.0. |
||
| 466 | category = getattr(callbl, 'category', module_name) |
||
| 467 | self._command_groups[category].append(commands[0]) |
||
| 468 | |||
| 469 | for command, docs in callbl._docs.items(): |
||
| 470 | self.doc[command] = docs |
||
| 471 | |||
| 472 | for func in jobs: |
||
| 473 | for interval in func.interval: |
||
| 474 | job = sopel.tools.jobs.Job(interval, func) |
||
| 475 | self.scheduler.add_job(job) |
||
| 476 | callable_name = getattr(func, "__name__", 'UNKNOWN') |
||
| 477 | LOGGER.debug( |
||
| 478 | 'Job added "%s", will run every %d seconds', |
||
| 479 | callable_name, |
||
| 480 | interval) |
||
| 481 | |||
| 482 | for func in urls: |
||
| 483 | for regex in func.url_regex: |
||
| 484 | self.register_url_callback(regex, func) |
||
| 485 | callable_name = getattr(func, "__name__", 'UNKNOWN') |
||
| 486 | LOGGER.debug( |
||
| 487 | 'URL Callback added "%s" for URL pattern "%s"', |
||
| 488 | callable_name, |
||
| 489 | regex) |
||
| 490 | |||
| 491 | def part(self, channel, msg=None): |
||
| 492 | """Leave a channel. |
||
| 493 | |||
| 494 | :param str channel: the channel to leave |
||
| 495 | :param str msg: the message to display when leaving a channel |
||
| 496 | """ |
||
| 497 | self.write(['PART', channel], msg) |
||
| 498 | |||
| 499 | def join(self, channel, password=None): |
||
| 500 | """Join a channel. |
||
| 501 | |||
| 502 | :param str channel: the channel to join |
||
| 503 | :param str password: an optional channel password |
||
| 504 | |||
| 505 | If ``channel`` contains a space, and no ``password`` is given, the |
||
| 506 | space is assumed to split the argument into the channel to join and its |
||
| 507 | password. ``channel`` should not contain a space if ``password`` |
||
| 508 | is given. |
||
| 509 | """ |
||
| 510 | if password is None: |
||
| 511 | self.write(('JOIN', channel)) |
||
| 512 | else: |
||
| 513 | self.write(['JOIN', channel, password]) |
||
| 514 | |||
| 515 | @deprecated |
||
| 516 | def msg(self, recipient, text, max_messages=1): |
||
| 517 | """ |
||
| 518 | .. deprecated:: 6.0 |
||
| 519 | Use :meth:`say` instead. Will be removed in Sopel 8. |
||
| 520 | """ |
||
| 521 | self.say(text, recipient, max_messages) |
||
| 522 | |||
| 523 | def say(self, text, recipient, max_messages=1): |
||
| 524 | """Send a PRIVMSG to a user or channel. |
||
| 525 | |||
| 526 | :param str text: the text to send |
||
| 527 | :param str recipient: the message recipient |
||
| 528 | :param int max_messages: the maximum number of messages to break the |
||
| 529 | text into |
||
| 530 | |||
| 531 | In the context of a triggered callable, the ``recipient`` defaults to |
||
| 532 | the channel (or nickname, if a private message) from which the message |
||
| 533 | was received. |
||
| 534 | |||
| 535 | By default, this will attempt to send the entire ``text`` in one |
||
| 536 | message. If the text is too long for the server, it may be truncated. |
||
| 537 | If ``max_messages`` is given, the ``text`` will be split into at most |
||
| 538 | that many messages, each no more than 400 bytes. The split is made at |
||
| 539 | the last space character before the 400th byte, or at the 400th byte if |
||
| 540 | no such space exists. If the ``text`` is too long to fit into the |
||
| 541 | specified number of messages using the above splitting, the final |
||
| 542 | message will contain the entire remainder, which may be truncated by |
||
| 543 | the server. |
||
| 544 | """ |
||
| 545 | excess = '' |
||
| 546 | if not isinstance(text, unicode): |
||
| 547 | # Make sure we are dealing with unicode string |
||
| 548 | text = text.decode('utf-8') |
||
| 549 | |||
| 550 | if max_messages > 1: |
||
| 551 | # Manage multi-line only when needed |
||
| 552 | text, excess = tools.get_sendable_message(text) |
||
| 553 | |||
| 554 | try: |
||
| 555 | self.sending.acquire() |
||
| 556 | |||
| 557 | recipient_id = Identifier(recipient) |
||
| 558 | recipient_stack = self.stack.setdefault(recipient_id, { |
||
| 559 | 'messages': [], |
||
| 560 | 'flood_left': self.config.core.flood_burst_lines, |
||
| 561 | }) |
||
| 562 | |||
| 563 | if recipient_stack['messages']: |
||
| 564 | elapsed = time.time() - recipient_stack['messages'][-1][0] |
||
| 565 | else: |
||
| 566 | # Default to a high enough value that we won't care. |
||
| 567 | # Five minutes should be enough not to matter anywhere below. |
||
| 568 | elapsed = 300 |
||
| 569 | |||
| 570 | # If flood bucket is empty, refill the appropriate number of lines |
||
| 571 | # based on how long it's been since our last message to recipient |
||
| 572 | if not recipient_stack['flood_left']: |
||
| 573 | recipient_stack['flood_left'] = min( |
||
| 574 | self.config.core.flood_burst_lines, |
||
| 575 | int(elapsed) * self.config.core.flood_refill_rate) |
||
| 576 | |||
| 577 | # If it's too soon to send another message, wait |
||
| 578 | if not recipient_stack['flood_left']: |
||
| 579 | penalty = float(max(0, len(text) - 50)) / 70 |
||
| 580 | wait = min(self.config.core.flood_empty_wait + penalty, 2) # Maximum wait time is 2 sec |
||
| 581 | if elapsed < wait: |
||
| 582 | time.sleep(wait - elapsed) |
||
| 583 | |||
| 584 | # Loop detection |
||
| 585 | messages = [m[1] for m in recipient_stack['messages'][-8:]] |
||
| 586 | |||
| 587 | # If what we're about to send repeated at least 5 times in the last |
||
| 588 | # two minutes, replace it with '...' |
||
| 589 | if messages.count(text) >= 5 and elapsed < 120: |
||
| 590 | text = '...' |
||
| 591 | if messages.count('...') >= 3: |
||
| 592 | # If we've already said '...' 3 times, discard message |
||
| 593 | return |
||
| 594 | |||
| 595 | self.write(('PRIVMSG', recipient), text) |
||
| 596 | recipient_stack['flood_left'] = max(0, recipient_stack['flood_left'] - 1) |
||
| 597 | recipient_stack['messages'].append((time.time(), self.safe(text))) |
||
| 598 | recipient_stack['messages'] = recipient_stack['messages'][-10:] |
||
| 599 | finally: |
||
| 600 | self.sending.release() |
||
| 601 | # Now that we've sent the first part, we need to send the rest. Doing |
||
| 602 | # this recursively seems easier to me than iteratively |
||
| 603 | if excess: |
||
| 604 | self.say(excess, max_messages - 1, recipient) |
||
| 605 | |||
| 606 | def notice(self, text, dest): |
||
| 607 | """Send an IRC NOTICE to a user or channel. |
||
| 608 | |||
| 609 | :param str text: the text to send in the NOTICE |
||
| 610 | :param str dest: the destination of the NOTICE |
||
| 611 | |||
| 612 | Within the context of a triggered callable, ``dest`` will default to |
||
| 613 | the channel (or nickname, if a private message), in which the trigger |
||
| 614 | happened. |
||
| 615 | """ |
||
| 616 | self.write(('NOTICE', dest), text) |
||
| 617 | |||
| 618 | def action(self, text, dest): |
||
| 619 | """Send a CTCP ACTION PRIVMSG to a user or channel. |
||
| 620 | |||
| 621 | :param str text: the text to send in the CTCP ACTION |
||
| 622 | :param str dest: the destination of the CTCP ACTION |
||
| 623 | |||
| 624 | The same loop detection and length restrictions apply as with |
||
| 625 | :func:`say`, though automatic message splitting is not available. |
||
| 626 | |||
| 627 | Within the context of a triggered callable, ``dest`` will default to |
||
| 628 | the channel (or nickname, if a private message), in which the trigger |
||
| 629 | happened. |
||
| 630 | """ |
||
| 631 | self.say('\001ACTION {}\001'.format(text), dest) |
||
| 632 | |||
| 633 | def reply(self, text, dest, reply_to, notice=False): |
||
| 634 | """Send a PRIVMSG to a user or channel, prepended with ``reply_to``. |
||
| 635 | |||
| 636 | :param str text: the text of the reply |
||
| 637 | :param str dest: the destination of the reply |
||
| 638 | :param str reply_to: the nickname that the reply will be prepended with |
||
| 639 | :param bool notice: whether to send the reply as a NOTICE or not, |
||
| 640 | defaults to ``False`` |
||
| 641 | |||
| 642 | If ``notice`` is ``True``, send a NOTICE rather than a PRIVMSG. |
||
| 643 | |||
| 644 | The same loop detection and length restrictions apply as with |
||
| 645 | :func:`say`, though automatic message splitting is not available. |
||
| 646 | |||
| 647 | Within the context of a triggered callable, ``reply_to`` will default to |
||
| 648 | the nickname of the user who triggered the call, and ``dest`` to the |
||
| 649 | channel (or nickname, if a private message), in which the trigger |
||
| 650 | happened. |
||
| 651 | """ |
||
| 652 | text = '%s: %s' % (reply_to, text) |
||
| 653 | if notice: |
||
| 654 | self.notice(text, dest) |
||
| 655 | else: |
||
| 656 | self.say(text, dest) |
||
| 657 | |||
| 658 | def kick(self, nick, channel, text=None): |
||
| 659 | """Send an IRC KICK command. |
||
| 660 | Within the context of a triggered callable, ``channel`` will default to the |
||
| 661 | channel in which the call was triggered. If triggered from a private message, |
||
| 662 | ``channel`` is required (or the call to ``kick()`` will be ignored). |
||
| 663 | The bot must be a channel operator in specified channel for this to work. |
||
| 664 | .. versionadded:: 7.0 |
||
| 665 | """ |
||
| 666 | self.write(['KICK', channel, nick], text) |
||
| 667 | |||
| 668 | def call(self, func, sopel, trigger): |
||
| 669 | """Call a function, applying any rate-limiting or restrictions. |
||
| 670 | |||
| 671 | :param func: the function to call |
||
| 672 | :type func: :term:`function` |
||
| 673 | :param sopel: a SopelWrapper instance |
||
| 674 | :type sopel: :class:`SopelWrapper` |
||
| 675 | :param Trigger trigger: the Trigger object for the line from the server |
||
| 676 | that triggered this call |
||
| 677 | """ |
||
| 678 | nick = trigger.nick |
||
| 679 | current_time = time.time() |
||
| 680 | if nick not in self._times: |
||
| 681 | self._times[nick] = dict() |
||
| 682 | if self.nick not in self._times: |
||
| 683 | self._times[self.nick] = dict() |
||
| 684 | if not trigger.is_privmsg and trigger.sender not in self._times: |
||
| 685 | self._times[trigger.sender] = dict() |
||
| 686 | |||
| 687 | if not trigger.admin and not func.unblockable: |
||
| 688 | if func in self._times[nick]: |
||
| 689 | usertimediff = current_time - self._times[nick][func] |
||
| 690 | if func.rate > 0 and usertimediff < func.rate: |
||
| 691 | LOGGER.info( |
||
| 692 | "%s prevented from using %s in %s due to user limit: %d < %d", |
||
| 693 | trigger.nick, func.__name__, trigger.sender, usertimediff, |
||
| 694 | func.rate |
||
| 695 | ) |
||
| 696 | return |
||
| 697 | if func in self._times[self.nick]: |
||
| 698 | globaltimediff = current_time - self._times[self.nick][func] |
||
| 699 | if func.global_rate > 0 and globaltimediff < func.global_rate: |
||
| 700 | LOGGER.info( |
||
| 701 | "%s prevented from using %s in %s due to global limit: %d < %d", |
||
| 702 | trigger.nick, func.__name__, trigger.sender, globaltimediff, |
||
| 703 | func.global_rate |
||
| 704 | ) |
||
| 705 | return |
||
| 706 | |||
| 707 | if not trigger.is_privmsg and func in self._times[trigger.sender]: |
||
| 708 | chantimediff = current_time - self._times[trigger.sender][func] |
||
| 709 | if func.channel_rate > 0 and chantimediff < func.channel_rate: |
||
| 710 | LOGGER.info( |
||
| 711 | "%s prevented from using %s in %s due to channel limit: %d < %d", |
||
| 712 | trigger.nick, func.__name__, trigger.sender, chantimediff, |
||
| 713 | func.channel_rate |
||
| 714 | ) |
||
| 715 | return |
||
| 716 | |||
| 717 | # if channel has its own config section, check for excluded modules/modules methods |
||
| 718 | if trigger.sender in self.config: |
||
| 719 | channel_config = self.config[trigger.sender] |
||
| 720 | |||
| 721 | # disable listed modules completely on provided channel |
||
| 722 | if 'disable_modules' in channel_config: |
||
| 723 | disabled_modules = channel_config.disable_modules.split(',') |
||
| 724 | |||
| 725 | # if "*" is used, we are disabling all modules on provided channel |
||
| 726 | if '*' in disabled_modules: |
||
| 727 | return |
||
| 728 | if func.__module__ in disabled_modules: |
||
| 729 | return |
||
| 730 | |||
| 731 | # disable chosen methods from modules |
||
| 732 | if 'disable_commands' in channel_config: |
||
| 733 | disabled_commands = literal_eval(channel_config.disable_commands) |
||
| 734 | |||
| 735 | if func.__module__ in disabled_commands: |
||
| 736 | if func.__name__ in disabled_commands[func.__module__]: |
||
| 737 | return |
||
| 738 | |||
| 739 | try: |
||
| 740 | exit_code = func(sopel, trigger) |
||
| 741 | except Exception as error: # TODO: Be specific |
||
| 742 | exit_code = None |
||
| 743 | self.error(trigger, exception=error) |
||
| 744 | |||
| 745 | if exit_code != NOLIMIT: |
||
| 746 | self._times[nick][func] = current_time |
||
| 747 | self._times[self.nick][func] = current_time |
||
| 748 | if not trigger.is_privmsg: |
||
| 749 | self._times[trigger.sender][func] = current_time |
||
| 750 | |||
| 751 | def dispatch(self, pretrigger): |
||
| 752 | """Dispatch a parsed message to any registered callables. |
||
| 753 | |||
| 754 | :param PreTrigger pretrigger: a parsed message from the server |
||
| 755 | """ |
||
| 756 | args = pretrigger.args |
||
| 757 | text = args[-1] if args else '' |
||
| 758 | event = pretrigger.event |
||
| 759 | intent = pretrigger.tags.get('intent') |
||
| 760 | nick = pretrigger.nick |
||
| 761 | is_echo_message = nick.lower() == self.nick.lower() |
||
| 762 | user_obj = self.users.get(nick) |
||
| 763 | account = user_obj.account if user_obj else None |
||
| 764 | |||
| 765 | if self.config.core.nick_blocks or self.config.core.host_blocks: |
||
| 766 | nick_blocked = self._nick_blocked(pretrigger.nick) |
||
| 767 | host_blocked = self._host_blocked(pretrigger.host) |
||
| 768 | else: |
||
| 769 | nick_blocked = host_blocked = None |
||
| 770 | blocked = bool(nick_blocked or host_blocked) |
||
| 771 | |||
| 772 | list_of_blocked_functions = [] |
||
| 773 | for priority in ('high', 'medium', 'low'): |
||
| 774 | for regexp, funcs in self._callables[priority].items(): |
||
| 775 | match = regexp.match(text) |
||
| 776 | if not match: |
||
| 777 | continue |
||
| 778 | |||
| 779 | for func in funcs: |
||
| 780 | trigger = Trigger(self.config, pretrigger, match, account) |
||
| 781 | |||
| 782 | # check blocked nick/host |
||
| 783 | if blocked and not func.unblockable and not trigger.admin: |
||
| 784 | function_name = "%s.%s" % ( |
||
| 785 | func.__module__, func.__name__ |
||
| 786 | ) |
||
| 787 | list_of_blocked_functions.append(function_name) |
||
| 788 | continue |
||
| 789 | |||
| 790 | # check event |
||
| 791 | if event not in func.event: |
||
| 792 | continue |
||
| 793 | |||
| 794 | # check intents |
||
| 795 | if hasattr(func, 'intents'): |
||
| 796 | if not intent: |
||
| 797 | continue |
||
| 798 | |||
| 799 | match = any( |
||
| 800 | func_intent.match(intent) |
||
| 801 | for func_intent in func.intents |
||
| 802 | ) |
||
| 803 | if not match: |
||
| 804 | continue |
||
| 805 | |||
| 806 | # check echo-message feature |
||
| 807 | if is_echo_message and not func.echo: |
||
| 808 | continue |
||
| 809 | |||
| 810 | # call triggered function |
||
| 811 | wrapper = SopelWrapper(self, trigger) |
||
| 812 | if func.thread: |
||
| 813 | targs = (func, wrapper, trigger) |
||
| 814 | t = threading.Thread(target=self.call, args=targs) |
||
| 815 | t.start() |
||
| 816 | else: |
||
| 817 | self.call(func, wrapper, trigger) |
||
| 818 | |||
| 819 | if list_of_blocked_functions: |
||
| 820 | if nick_blocked and host_blocked: |
||
| 821 | block_type = 'both' |
||
| 822 | elif nick_blocked: |
||
| 823 | block_type = 'nick' |
||
| 824 | else: |
||
| 825 | block_type = 'host' |
||
| 826 | LOGGER.info( |
||
| 827 | "[%s]%s prevented from using %s.", |
||
| 828 | block_type, |
||
| 829 | nick, |
||
| 830 | ', '.join(list_of_blocked_functions) |
||
| 831 | ) |
||
| 832 | |||
| 833 | def _host_blocked(self, host): |
||
| 834 | bad_masks = self.config.core.host_blocks |
||
| 835 | for bad_mask in bad_masks: |
||
| 836 | bad_mask = bad_mask.strip() |
||
| 837 | if not bad_mask: |
||
| 838 | continue |
||
| 839 | if (re.match(bad_mask + '$', host, re.IGNORECASE) or |
||
| 840 | bad_mask == host): |
||
| 841 | return True |
||
| 842 | return False |
||
| 843 | |||
| 844 | def _nick_blocked(self, nick): |
||
| 845 | bad_nicks = self.config.core.nick_blocks |
||
| 846 | for bad_nick in bad_nicks: |
||
| 847 | bad_nick = bad_nick.strip() |
||
| 848 | if not bad_nick: |
||
| 849 | continue |
||
| 850 | if (re.match(bad_nick + '$', nick, re.IGNORECASE) or |
||
| 851 | Identifier(bad_nick) == nick): |
||
| 852 | return True |
||
| 853 | return False |
||
| 854 | |||
| 855 | def _shutdown(self): |
||
| 856 | # Stop Job Scheduler |
||
| 857 | LOGGER.info('Stopping the Job Scheduler.') |
||
| 858 | self.scheduler.stop() |
||
| 859 | |||
| 860 | try: |
||
| 861 | self.scheduler.join(timeout=15) |
||
| 862 | except RuntimeError: |
||
| 863 | LOGGER.exception('Unable to stop the Job Scheduler.') |
||
| 864 | else: |
||
| 865 | LOGGER.info('Job Scheduler stopped.') |
||
| 866 | |||
| 867 | self.scheduler.clear_jobs() |
||
| 868 | |||
| 869 | # Shutdown plugins |
||
| 870 | LOGGER.info( |
||
| 871 | 'Calling shutdown for %d modules.', len(self.shutdown_methods)) |
||
| 872 | |||
| 873 | for shutdown_method in self.shutdown_methods: |
||
| 874 | try: |
||
| 875 | LOGGER.debug( |
||
| 876 | 'Calling %s.%s', |
||
| 877 | shutdown_method.__module__, |
||
| 878 | shutdown_method.__name__) |
||
| 879 | shutdown_method(self) |
||
| 880 | except Exception as e: |
||
| 881 | LOGGER.exception('Error calling shutdown method: %s', e) |
||
| 882 | |||
| 883 | # Avoid calling shutdown methods if we already have. |
||
| 884 | self.shutdown_methods = [] |
||
| 885 | |||
| 886 | def cap_req(self, module_name, capability, arg=None, failure_callback=None, |
||
| 887 | success_callback=None): |
||
| 888 | """Tell Sopel to request a capability when it starts. |
||
| 889 | |||
| 890 | :param str module_name: the module requesting the capability |
||
| 891 | :param str capability: the capability requested, optionally prefixed |
||
| 892 | with ``+`` or ``=`` |
||
| 893 | :param str arg: arguments for the capability request |
||
| 894 | :param failure_callback: a function that will be called if the |
||
| 895 | capability request fails |
||
| 896 | :type failure_callback: :term:`function` |
||
| 897 | :param success_callback: a function that will be called if the |
||
| 898 | capability is successfully requested |
||
| 899 | :type success_callback: :term:`function` |
||
| 900 | |||
| 901 | By prefixing the capability with ``-``, it will be ensured that the |
||
| 902 | capability is not enabled. Similarly, by prefixing the capability with |
||
| 903 | ``=``, it will be ensured that the capability is enabled. Requiring and |
||
| 904 | disabling is "first come, first served"; if one module requires a |
||
| 905 | capability, and another prohibits it, this function will raise an |
||
| 906 | exception in whichever module loads second. An exception will also be |
||
| 907 | raised if the module is being loaded after the bot has already started, |
||
| 908 | and the request would change the set of enabled capabilities. |
||
| 909 | |||
| 910 | If the capability is not prefixed, and no other module prohibits it, it |
||
| 911 | will be requested. Otherwise, it will not be requested. Since |
||
| 912 | capability requests that are not mandatory may be rejected by the |
||
| 913 | server, as well as by other modules, a module which makes such a |
||
| 914 | request should account for that possibility. |
||
| 915 | |||
| 916 | The actual capability request to the server is handled after the |
||
| 917 | completion of this function. In the event that the server denies a |
||
| 918 | request, the ``failure_callback`` function will be called, if provided. |
||
| 919 | The arguments will be a :class:`sopel.bot.Sopel` object, and the |
||
| 920 | capability which was rejected. This can be used to disable callables |
||
| 921 | which rely on the capability. It will be be called either if the server |
||
| 922 | NAKs the request, or if the server enabled it and later DELs it. |
||
| 923 | |||
| 924 | The ``success_callback`` function will be called upon acknowledgement |
||
| 925 | of the capability from the server, whether during the initial |
||
| 926 | capability negotiation, or later. |
||
| 927 | |||
| 928 | If ``arg`` is given, and does not exactly match what the server |
||
| 929 | provides or what other modules have requested for that capability, it is |
||
| 930 | considered a conflict. |
||
| 931 | """ |
||
| 932 | # TODO raise better exceptions |
||
| 933 | cap = capability[1:] |
||
| 934 | prefix = capability[0] |
||
| 935 | |||
| 936 | entry = self._cap_reqs.get(cap, []) |
||
| 937 | if any((ent.arg != arg for ent in entry)): |
||
|
0 ignored issues
–
show
Comprehensibility
Best Practice
introduced
by
Loading history...
|
|||
| 938 | raise Exception('Capability conflict') |
||
| 939 | |||
| 940 | if prefix == '-': |
||
| 941 | if self.connection_registered and cap in self.enabled_capabilities: |
||
| 942 | raise Exception('Can not change capabilities after server ' |
||
| 943 | 'connection has been completed.') |
||
| 944 | if any((ent.prefix != '-' for ent in entry)): |
||
| 945 | raise Exception('Capability conflict') |
||
| 946 | entry.append(_CapReq(prefix, module_name, failure_callback, arg, |
||
| 947 | success_callback)) |
||
| 948 | self._cap_reqs[cap] = entry |
||
| 949 | else: |
||
| 950 | if prefix != '=': |
||
| 951 | cap = capability |
||
| 952 | prefix = '' |
||
| 953 | if self.connection_registered and (cap not in |
||
| 954 | self.enabled_capabilities): |
||
| 955 | raise Exception('Can not change capabilities after server ' |
||
| 956 | 'connection has been completed.') |
||
| 957 | # Non-mandatory will callback at the same time as if the server |
||
| 958 | # rejected it. |
||
| 959 | if any((ent.prefix == '-' for ent in entry)) and prefix == '=': |
||
| 960 | raise Exception('Capability conflict') |
||
| 961 | entry.append(_CapReq(prefix, module_name, failure_callback, arg, |
||
| 962 | success_callback)) |
||
| 963 | self._cap_reqs[cap] = entry |
||
| 964 | |||
| 965 | def register_url_callback(self, pattern, callback): |
||
| 966 | """Register a ``callback`` for URLs matching the regex ``pattern``. |
||
| 967 | |||
| 968 | :param pattern: compiled regex pattern to register |
||
| 969 | :type pattern: :ref:`re.Pattern <python:re-objects>` |
||
| 970 | :param callback: callable object to handle matching URLs |
||
| 971 | :type callback: :term:`function` |
||
| 972 | |||
| 973 | .. versionadded:: 7.0 |
||
| 974 | |||
| 975 | This method replaces manual management of ``url_callbacks`` in |
||
| 976 | Sopel's plugins, so instead of doing this in ``setup()``:: |
||
| 977 | |||
| 978 | if 'url_callbacks' not in bot.memory: |
||
| 979 | bot.memory['url_callbacks'] = tools.SopelMemory() |
||
| 980 | |||
| 981 | regex = re.compile(r'http://example.com/path/.*') |
||
| 982 | bot.memory['url_callbacks'][regex] = callback |
||
| 983 | |||
| 984 | use this much more concise pattern:: |
||
| 985 | |||
| 986 | regex = re.compile(r'http://example.com/path/.*') |
||
| 987 | bot.register_url_callback(regex, callback) |
||
| 988 | |||
| 989 | """ |
||
| 990 | if 'url_callbacks' not in self.memory: |
||
| 991 | self.memory['url_callbacks'] = tools.SopelMemory() |
||
| 992 | |||
| 993 | if isinstance(pattern, basestring): |
||
| 994 | pattern = re.compile(pattern) |
||
| 995 | |||
| 996 | self.memory['url_callbacks'][pattern] = callback |
||
| 997 | |||
| 998 | def unregister_url_callback(self, pattern): |
||
| 999 | """Unregister the callback for URLs matching the regex ``pattern``. |
||
| 1000 | |||
| 1001 | :param pattern: compiled regex pattern to unregister callback |
||
| 1002 | :type pattern: :ref:`re.Pattern <python:re-objects>` |
||
| 1003 | |||
| 1004 | .. versionadded:: 7.0 |
||
| 1005 | |||
| 1006 | This method replaces manual management of ``url_callbacks`` in |
||
| 1007 | Sopel's plugins, so instead of doing this in ``shutdown()``:: |
||
| 1008 | |||
| 1009 | regex = re.compile(r'http://example.com/path/.*') |
||
| 1010 | try: |
||
| 1011 | del bot.memory['url_callbacks'][regex] |
||
| 1012 | except KeyError: |
||
| 1013 | pass |
||
| 1014 | |||
| 1015 | use this much more concise pattern:: |
||
| 1016 | |||
| 1017 | regex = re.compile(r'http://example.com/path/.*') |
||
| 1018 | bot.unregister_url_callback(regex) |
||
| 1019 | |||
| 1020 | """ |
||
| 1021 | if 'url_callbacks' not in self.memory: |
||
| 1022 | # nothing to unregister |
||
| 1023 | return |
||
| 1024 | |||
| 1025 | if isinstance(pattern, basestring): |
||
| 1026 | pattern = re.compile(pattern) |
||
| 1027 | |||
| 1028 | try: |
||
| 1029 | del self.memory['url_callbacks'][pattern] |
||
| 1030 | except KeyError: |
||
| 1031 | pass |
||
| 1032 | |||
| 1033 | def search_url_callbacks(self, url): |
||
| 1034 | """Yield callbacks found for ``url`` matching their regex pattern. |
||
| 1035 | |||
| 1036 | :param str url: URL found in a trigger |
||
| 1037 | :return: yield 2-value tuples of ``(callback, match)`` |
||
| 1038 | |||
| 1039 | For each pattern that matches the ``url`` parameter, it yields a |
||
| 1040 | 2-value tuple of ``(callable, match)`` for that pattern. |
||
| 1041 | |||
| 1042 | The ``callable`` is the one registered with |
||
| 1043 | :meth:`register_url_callback`, and the ``match`` is the result of |
||
| 1044 | the regex pattern's ``search`` method. |
||
| 1045 | |||
| 1046 | .. versionadded:: 7.0 |
||
| 1047 | |||
| 1048 | .. seealso:: |
||
| 1049 | |||
| 1050 | The Python documentation for the `re.search`__ function and |
||
| 1051 | the `match object`__. |
||
| 1052 | |||
| 1053 | .. __: https://docs.python.org/3.6/library/re.html#re.search |
||
| 1054 | .. __: https://docs.python.org/3.6/library/re.html#match-objects |
||
| 1055 | |||
| 1056 | """ |
||
| 1057 | if 'url_callbacks' not in self.memory: |
||
| 1058 | # nothing to search |
||
| 1059 | return |
||
| 1060 | |||
| 1061 | for regex, function in tools.iteritems(self.memory['url_callbacks']): |
||
| 1062 | match = regex.search(url) |
||
| 1063 | if match: |
||
| 1064 | yield function, match |
||
| 1065 | |||
| 1066 | |||
| 1067 | class SopelWrapper(object): |
||
| 1068 | """Wrapper around a Sopel instance and a Trigger |
||
| 1069 | |||
| 1070 | :param sopel: Sopel instance |
||
| 1071 | :type sopel: :class:`~sopel.bot.Sopel` |
||
| 1072 | :param trigger: IRC Trigger line |
||
| 1073 | :type trigger: :class:`sopel.trigger.Trigger` |
||
| 1074 | |||
| 1075 | This wrapper will be used to call Sopel's triggered commands and rules as |
||
| 1076 | their ``bot`` argument. It acts as a proxy to :meth:`send messages<say>` to |
||
| 1077 | the sender (either a channel or in a private message) and even to |
||
| 1078 | :meth:`reply to someone<reply>` in a channel. |
||
| 1079 | """ |
||
| 1080 | def __init__(self, sopel, trigger): |
||
| 1081 | # The custom __setattr__ for this class sets the attribute on the |
||
| 1082 | # original bot object. We don't want that for these, so we set them |
||
| 1083 | # with the normal __setattr__. |
||
| 1084 | object.__setattr__(self, '_bot', sopel) |
||
| 1085 | object.__setattr__(self, '_trigger', trigger) |
||
| 1086 | |||
| 1087 | def __dir__(self): |
||
| 1088 | classattrs = [attr for attr in self.__class__.__dict__ |
||
| 1089 | if not attr.startswith('__')] |
||
| 1090 | return list(self.__dict__) + classattrs + dir(self._bot) |
||
| 1091 | |||
| 1092 | def __getattr__(self, attr): |
||
| 1093 | return getattr(self._bot, attr) |
||
| 1094 | |||
| 1095 | def __setattr__(self, attr, value): |
||
| 1096 | return setattr(self._bot, attr, value) |
||
| 1097 | |||
| 1098 | def say(self, message, destination=None, max_messages=1): |
||
| 1099 | """Override ``Sopel.say`` to send message to sender |
||
| 1100 | |||
| 1101 | :param str message: message to say |
||
| 1102 | :param str destination: channel or person; defaults to trigger's sender |
||
| 1103 | :param int max_messages: max number of message splits |
||
| 1104 | |||
| 1105 | .. seealso:: |
||
| 1106 | |||
| 1107 | :meth:`sopel.bot.Sopel.say` |
||
| 1108 | """ |
||
| 1109 | if destination is None: |
||
| 1110 | destination = self._trigger.sender |
||
| 1111 | self._bot.say(message, destination, max_messages) |
||
| 1112 | |||
| 1113 | def action(self, message, destination=None): |
||
| 1114 | """Override ``Sopel.action`` to send action to sender |
||
| 1115 | |||
| 1116 | :param str message: action message |
||
| 1117 | :param str destination: channel or person; defaults to trigger's sender |
||
| 1118 | |||
| 1119 | .. seealso:: |
||
| 1120 | |||
| 1121 | :meth:`sopel.bot.Sopel.action` |
||
| 1122 | """ |
||
| 1123 | if destination is None: |
||
| 1124 | destination = self._trigger.sender |
||
| 1125 | self._bot.action(message, destination) |
||
| 1126 | |||
| 1127 | def notice(self, message, destination=None): |
||
| 1128 | """Override ``Sopel.notice`` to send a notice to sender |
||
| 1129 | |||
| 1130 | :param str message: notice message |
||
| 1131 | :param str destination: channel or person; defaults to trigger's sender |
||
| 1132 | |||
| 1133 | .. seealso:: |
||
| 1134 | |||
| 1135 | :meth:`sopel.bot.Sopel.notice` |
||
| 1136 | """ |
||
| 1137 | if destination is None: |
||
| 1138 | destination = self._trigger.sender |
||
| 1139 | self._bot.notice(message, destination) |
||
| 1140 | |||
| 1141 | def reply(self, message, destination=None, reply_to=None, notice=False): |
||
| 1142 | """Override ``Sopel.reply`` to reply to someone |
||
| 1143 | |||
| 1144 | :param str message: reply message |
||
| 1145 | :param str destination: channel or person; defaults to trigger's sender |
||
| 1146 | :param str reply_to: person to reply to; defaults to trigger's nick |
||
| 1147 | :param bool notice: reply as an IRC notice or with a simple message |
||
| 1148 | |||
| 1149 | .. seealso:: |
||
| 1150 | |||
| 1151 | :meth:`sopel.bot.Sopel.reply` |
||
| 1152 | """ |
||
| 1153 | if destination is None: |
||
| 1154 | destination = self._trigger.sender |
||
| 1155 | if reply_to is None: |
||
| 1156 | reply_to = self._trigger.nick |
||
| 1157 | self._bot.reply(message, destination, reply_to, notice) |
||
| 1158 | |||
| 1159 | def kick(self, nick, channel=None, message=None): |
||
| 1160 | if channel is None: |
||
| 1161 | if self._trigger.is_privmsg: |
||
| 1162 | raise RuntimeError('Error: KICK requires a channel.') |
||
| 1163 | else: |
||
| 1164 | channel = self._trigger.sender |
||
| 1165 | if nick is None: |
||
| 1166 | raise RuntimeError('Error: KICK requires a nick.') |
||
| 1167 | self._bot.kick(nick, channel, message) |
||
| 1168 |