sopel.bot.Sopel.call()   F
last analyzed

Complexity

Conditions 27

Size

Total Lines 82
Code Lines 57

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 57
dl 0
loc 82
rs 0
c 0
b 0
f 0
cc 27
nop 4

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

Complexity

Complex classes like sopel.bot.Sopel.call() often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

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)
0 ignored issues
show
introduced by
The variable rule does not seem to be defined in case the for loop on line 364 is not entered. Are you sure this can never be the case?
Loading history...
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
The variable ent does not seem to be defined.
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):
0 ignored issues
show
introduced by
The variable basestring does not seem to be defined for all execution paths.
Loading history...
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):
0 ignored issues
show
introduced by
The variable basestring does not seem to be defined for all execution paths.
Loading history...
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