Completed
Push — master ( ac2779...d89639 )
by dgw
17s queued 13s
created

sopel.bot.Sopel.remove_plugin()   B

Complexity

Conditions 7

Size

Total Lines 21
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 14
dl 0
loc 21
rs 8
c 0
b 0
f 0
cc 7
nop 6
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
400
        It is possible to have a callable with rules, commands, and nick
401
        commands configured. It should not be possible to have a callable with
402
        commands or nick commands but without rules. Callables without rules
403
        are usually event handlers.
404
        """
405
        # Append module's shutdown function to the bot's list of functions to
406
        # call on shutdown
407
        self.shutdown_methods += shutdowns
408
        match_any = re.compile('.*')
409
        for callbl in callables:
410
            callable_name = getattr(callbl, "__name__", 'UNKNOWN')
411
            rules = getattr(callbl, 'rule', [])
412
            commands = getattr(callbl, 'commands', [])
413
            nick_commands = getattr(callbl, 'nickname_commands', [])
414
            events = getattr(callbl, 'event', [])
415
            is_rule_only = rules and not commands and not nick_commands
416
417
            if rules:
418
                for rule in rules:
419
                    self._callables[callbl.priority][rule].append(callbl)
420
                    if is_rule_only:
421
                        # Command & Nick Command are logged later:
422
                        # here we log rule only callable
423
                        LOGGER.debug(
424
                            'Rule callable "%s" registered for "%s"',
425
                            callable_name,
426
                            rule.pattern)
427
                if commands:
428
                    LOGGER.debug(
429
                        'Command callable "%s" registered for "%s"',
430
                        callable_name,
431
                        '|'.join(commands))
432
                if nick_commands:
433
                    LOGGER.debug(
434
                        'Nick command callable "%s" registered for "%s"',
435
                        callable_name,
436
                        '|'.join(nick_commands))
437
                if events:
438
                    LOGGER.debug(
439
                        'Event callable "%s" registered for "%s"',
440
                        callable_name,
441
                        '|'.join(events))
442
            else:
443
                self._callables[callbl.priority][match_any].append(callbl)
444
                if events:
445
                    LOGGER.debug(
446
                        'Event callable "%s" registered '
447
                        'with "match any" rule for "%s"',
448
                        callable_name,
449
                        '|'.join(events))
450
                else:
451
                    LOGGER.debug(
452
                        'Rule callable "%s" registered with "match any" rule',
453
                        callable_name)
454
455
            if commands:
456
                module_name = callbl.__module__.rsplit('.', 1)[-1]
457
                # TODO doc and make decorator for this. Not sure if this is how
458
                # it should work yet, so not making it public for 6.0.
459
                category = getattr(callbl, 'category', module_name)
460
                self._command_groups[category].append(commands[0])
461
462
            for command, docs in callbl._docs.items():
463
                self.doc[command] = docs
464
465
        for func in jobs:
466
            for interval in func.interval:
467
                job = sopel.tools.jobs.Job(interval, func)
468
                self.scheduler.add_job(job)
469
                callable_name = getattr(func, "__name__", 'UNKNOWN')
470
                LOGGER.debug(
471
                    'Job added "%s", will run every %d seconds',
472
                    callable_name,
473
                    interval)
474
475
        for func in urls:
476
            for regex in func.url_regex:
477
                self.register_url_callback(regex, func)
478
                callable_name = getattr(func, "__name__", 'UNKNOWN')
479
                LOGGER.debug(
480
                    'URL Callback added "%s" for URL pattern "%s"',
481
                    callable_name,
482
                    regex)
483
484
    def part(self, channel, msg=None):
485
        """Leave a channel.
486
487
        :param str channel: the channel to leave
488
        :param str msg: the message to display when leaving a channel
489
        """
490
        self.write(['PART', channel], msg)
491
492
    def join(self, channel, password=None):
493
        """Join a channel.
494
495
        :param str channel: the channel to join
496
        :param str password: an optional channel password
497
498
        If ``channel`` contains a space, and no ``password`` is given, the
499
        space is assumed to split the argument into the channel to join and its
500
        password. ``channel`` should not contain a space if ``password``
501
        is given.
502
        """
503
        if password is None:
504
            self.write(('JOIN', channel))
505
        else:
506
            self.write(['JOIN', channel, password])
507
508
    @deprecated
509
    def msg(self, recipient, text, max_messages=1):
510
        """
511
        .. deprecated:: 6.0
512
            Use :meth:`say` instead. Will be removed in Sopel 8.
513
        """
514
        self.say(text, recipient, max_messages)
515
516
    def say(self, text, recipient, max_messages=1):
517
        """Send a PRIVMSG to a user or channel.
518
519
        :param str text: the text to send
520
        :param str recipient: the message recipient
521
        :param int max_messages: the maximum number of messages to break the
522
                                 text into
523
524
        In the context of a triggered callable, the ``recipient`` defaults to
525
        the channel (or nickname, if a private message) from which the message
526
        was received.
527
528
        By default, this will attempt to send the entire ``text`` in one
529
        message. If the text is too long for the server, it may be truncated.
530
        If ``max_messages`` is given, the ``text`` will be split into at most
531
        that many messages, each no more than 400 bytes. The split is made at
532
        the last space character before the 400th byte, or at the 400th byte if
533
        no such space exists. If the ``text`` is too long to fit into the
534
        specified number of messages using the above splitting, the final
535
        message will contain the entire remainder, which may be truncated by
536
        the server.
537
        """
538
        excess = ''
539
        if not isinstance(text, unicode):
540
            # Make sure we are dealing with unicode string
541
            text = text.decode('utf-8')
542
543
        if max_messages > 1:
544
            # Manage multi-line only when needed
545
            text, excess = tools.get_sendable_message(text)
546
547
        try:
548
            self.sending.acquire()
549
550
            recipient_id = Identifier(recipient)
551
            recipient_stack = self.stack.setdefault(recipient_id, {
552
                'messages': [],
553
                'flood_left': self.config.core.flood_burst_lines,
554
            })
555
556
            if recipient_stack['messages']:
557
                elapsed = time.time() - recipient_stack['messages'][-1][0]
558
            else:
559
                # Default to a high enough value that we won't care.
560
                # Five minutes should be enough not to matter anywhere below.
561
                elapsed = 300
562
563
            # If flood bucket is empty, refill the appropriate number of lines
564
            # based on how long it's been since our last message to recipient
565
            if not recipient_stack['flood_left']:
566
                recipient_stack['flood_left'] = min(
567
                    self.config.core.flood_burst_lines,
568
                    int(elapsed) * self.config.core.flood_refill_rate)
569
570
            # If it's too soon to send another message, wait
571
            if not recipient_stack['flood_left']:
572
                penalty = float(max(0, len(text) - 50)) / 70
573
                wait = min(self.config.core.flood_empty_wait + penalty, 2)  # Maximum wait time is 2 sec
574
                if elapsed < wait:
575
                    time.sleep(wait - elapsed)
576
577
            # Loop detection
578
            messages = [m[1] for m in recipient_stack['messages'][-8:]]
579
580
            # If what we're about to send repeated at least 5 times in the last
581
            # two minutes, replace it with '...'
582
            if messages.count(text) >= 5 and elapsed < 120:
583
                text = '...'
584
                if messages.count('...') >= 3:
585
                    # If we've already said '...' 3 times, discard message
586
                    return
587
588
            self.write(('PRIVMSG', recipient), text)
589
            recipient_stack['flood_left'] = max(0, recipient_stack['flood_left'] - 1)
590
            recipient_stack['messages'].append((time.time(), self.safe(text)))
591
            recipient_stack['messages'] = recipient_stack['messages'][-10:]
592
        finally:
593
            self.sending.release()
594
        # Now that we've sent the first part, we need to send the rest. Doing
595
        # this recursively seems easier to me than iteratively
596
        if excess:
597
            self.say(excess, max_messages - 1, recipient)
598
599
    def notice(self, text, dest):
600
        """Send an IRC NOTICE to a user or channel.
601
602
        :param str text: the text to send in the NOTICE
603
        :param str dest: the destination of the NOTICE
604
605
        Within the context of a triggered callable, ``dest`` will default to
606
        the channel (or nickname, if a private message), in which the trigger
607
        happened.
608
        """
609
        self.write(('NOTICE', dest), text)
610
611
    def action(self, text, dest):
612
        """Send a CTCP ACTION PRIVMSG to a user or channel.
613
614
        :param str text: the text to send in the CTCP ACTION
615
        :param str dest: the destination of the CTCP ACTION
616
617
        The same loop detection and length restrictions apply as with
618
        :func:`say`, though automatic message splitting is not available.
619
620
        Within the context of a triggered callable, ``dest`` will default to
621
        the channel (or nickname, if a private message), in which the trigger
622
        happened.
623
        """
624
        self.say('\001ACTION {}\001'.format(text), dest)
625
626
    def reply(self, text, dest, reply_to, notice=False):
627
        """Send a PRIVMSG to a user or channel, prepended with ``reply_to``.
628
629
        :param str text: the text of the reply
630
        :param str dest: the destination of the reply
631
        :param str reply_to: the nickname that the reply will be prepended with
632
        :param bool notice: whether to send the reply as a NOTICE or not,
633
                            defaults to ``False``
634
635
        If ``notice`` is ``True``, send a NOTICE rather than a PRIVMSG.
636
637
        The same loop detection and length restrictions apply as with
638
        :func:`say`, though automatic message splitting is not available.
639
640
        Within the context of a triggered callable, ``reply_to`` will default to
641
        the nickname of the user who triggered the call, and ``dest`` to the
642
        channel (or nickname, if a private message), in which the trigger
643
        happened.
644
        """
645
        text = '%s: %s' % (reply_to, text)
646
        if notice:
647
            self.notice(text, dest)
648
        else:
649
            self.say(text, dest)
650
651
    def kick(self, nick, channel, text=None):
652
        """Send an IRC KICK command.
653
        Within the context of a triggered callable, ``channel`` will default to the
654
        channel in which the call was triggered. If triggered from a private message,
655
        ``channel`` is required (or the call to ``kick()`` will be ignored).
656
        The bot must be a channel operator in specified channel for this to work.
657
        .. versionadded:: 7.0
658
        """
659
        self.write(['KICK', channel, nick], text)
660
661
    def call(self, func, sopel, trigger):
662
        """Call a function, applying any rate-limiting or restrictions.
663
664
        :param func: the function to call
665
        :type func: :term:`function`
666
        :param sopel: a SopelWrapper instance
667
        :type sopel: :class:`SopelWrapper`
668
        :param Trigger trigger: the Trigger object for the line from the server
669
                                that triggered this call
670
        """
671
        nick = trigger.nick
672
        current_time = time.time()
673
        if nick not in self._times:
674
            self._times[nick] = dict()
675
        if self.nick not in self._times:
676
            self._times[self.nick] = dict()
677
        if not trigger.is_privmsg and trigger.sender not in self._times:
678
            self._times[trigger.sender] = dict()
679
680
        if not trigger.admin and not func.unblockable:
681
            if func in self._times[nick]:
682
                usertimediff = current_time - self._times[nick][func]
683
                if func.rate > 0 and usertimediff < func.rate:
684
                    LOGGER.info(
685
                        "%s prevented from using %s in %s due to user limit: %d < %d",
686
                        trigger.nick, func.__name__, trigger.sender, usertimediff,
687
                        func.rate
688
                    )
689
                    return
690
            if func in self._times[self.nick]:
691
                globaltimediff = current_time - self._times[self.nick][func]
692
                if func.global_rate > 0 and globaltimediff < func.global_rate:
693
                    LOGGER.info(
694
                        "%s prevented from using %s in %s due to global limit: %d < %d",
695
                        trigger.nick, func.__name__, trigger.sender, globaltimediff,
696
                        func.global_rate
697
                    )
698
                    return
699
700
            if not trigger.is_privmsg and func in self._times[trigger.sender]:
701
                chantimediff = current_time - self._times[trigger.sender][func]
702
                if func.channel_rate > 0 and chantimediff < func.channel_rate:
703
                    LOGGER.info(
704
                        "%s prevented from using %s in %s due to channel limit: %d < %d",
705
                        trigger.nick, func.__name__, trigger.sender, chantimediff,
706
                        func.channel_rate
707
                    )
708
                    return
709
710
        # if channel has its own config section, check for excluded modules/modules methods
711
        if trigger.sender in self.config:
712
            channel_config = self.config[trigger.sender]
713
714
            # disable listed modules completely on provided channel
715
            if 'disable_modules' in channel_config:
716
                disabled_modules = channel_config.disable_modules.split(',')
717
718
                # if "*" is used, we are disabling all modules on provided channel
719
                if '*' in disabled_modules:
720
                    return
721
                if func.__module__ in disabled_modules:
722
                    return
723
724
            # disable chosen methods from modules
725
            if 'disable_commands' in channel_config:
726
                disabled_commands = literal_eval(channel_config.disable_commands)
727
728
                if func.__module__ in disabled_commands:
729
                    if func.__name__ in disabled_commands[func.__module__]:
730
                        return
731
732
        try:
733
            exit_code = func(sopel, trigger)
734
        except Exception as error:  # TODO: Be specific
735
            exit_code = None
736
            self.error(trigger, exception=error)
737
738
        if exit_code != NOLIMIT:
739
            self._times[nick][func] = current_time
740
            self._times[self.nick][func] = current_time
741
            if not trigger.is_privmsg:
742
                self._times[trigger.sender][func] = current_time
743
744
    def dispatch(self, pretrigger):
745
        """Dispatch a parsed message to any registered callables.
746
747
        :param PreTrigger pretrigger: a parsed message from the server
748
        """
749
        args = pretrigger.args
750
        text = args[-1] if args else ''
751
        event = pretrigger.event
752
        intent = pretrigger.tags.get('intent')
753
        nick = pretrigger.nick
754
        is_echo_message = nick.lower() == self.nick.lower()
755
        user_obj = self.users.get(nick)
756
        account = user_obj.account if user_obj else None
757
758
        if self.config.core.nick_blocks or self.config.core.host_blocks:
759
            nick_blocked = self._nick_blocked(pretrigger.nick)
760
            host_blocked = self._host_blocked(pretrigger.host)
761
        else:
762
            nick_blocked = host_blocked = None
763
        blocked = bool(nick_blocked or host_blocked)
764
765
        list_of_blocked_functions = []
766
        for priority in ('high', 'medium', 'low'):
767
            for regexp, funcs in self._callables[priority].items():
768
                match = regexp.match(text)
769
                if not match:
770
                    continue
771
772
                for func in funcs:
773
                    trigger = Trigger(self.config, pretrigger, match, account)
774
775
                    # check blocked nick/host
776
                    if blocked and not func.unblockable and not trigger.admin:
777
                        function_name = "%s.%s" % (
778
                            func.__module__, func.__name__
779
                        )
780
                        list_of_blocked_functions.append(function_name)
781
                        continue
782
783
                    # check event
784
                    if event not in func.event:
785
                        continue
786
787
                    # check intents
788
                    if hasattr(func, 'intents'):
789
                        if not intent:
790
                            continue
791
792
                        match = any(
793
                            func_intent.match(intent)
794
                            for func_intent in func.intents
795
                        )
796
                        if not match:
797
                            continue
798
799
                    # check echo-message feature
800
                    if is_echo_message and not func.echo:
801
                        continue
802
803
                    # call triggered function
804
                    wrapper = SopelWrapper(self, trigger)
805
                    if func.thread:
806
                        targs = (func, wrapper, trigger)
807
                        t = threading.Thread(target=self.call, args=targs)
808
                        t.start()
809
                    else:
810
                        self.call(func, wrapper, trigger)
811
812
        if list_of_blocked_functions:
813
            if nick_blocked and host_blocked:
814
                block_type = 'both'
815
            elif nick_blocked:
816
                block_type = 'nick'
817
            else:
818
                block_type = 'host'
819
            LOGGER.info(
820
                "[%s]%s prevented from using %s.",
821
                block_type,
822
                nick,
823
                ', '.join(list_of_blocked_functions)
824
            )
825
826
    def _host_blocked(self, host):
827
        bad_masks = self.config.core.host_blocks
828
        for bad_mask in bad_masks:
829
            bad_mask = bad_mask.strip()
830
            if not bad_mask:
831
                continue
832
            if (re.match(bad_mask + '$', host, re.IGNORECASE) or
833
                    bad_mask == host):
834
                return True
835
        return False
836
837
    def _nick_blocked(self, nick):
838
        bad_nicks = self.config.core.nick_blocks
839
        for bad_nick in bad_nicks:
840
            bad_nick = bad_nick.strip()
841
            if not bad_nick:
842
                continue
843
            if (re.match(bad_nick + '$', nick, re.IGNORECASE) or
844
                    Identifier(bad_nick) == nick):
845
                return True
846
        return False
847
848
    def _shutdown(self):
849
        # Stop Job Scheduler
850
        LOGGER.info('Stopping the Job Scheduler.')
851
        self.scheduler.stop()
852
853
        try:
854
            self.scheduler.join(timeout=15)
855
        except RuntimeError:
856
            LOGGER.exception('Unable to stop the Job Scheduler.')
857
        else:
858
            LOGGER.info('Job Scheduler stopped.')
859
860
        self.scheduler.clear_jobs()
861
862
        # Shutdown plugins
863
        LOGGER.info(
864
            'Calling shutdown for %d modules.', len(self.shutdown_methods))
865
866
        for shutdown_method in self.shutdown_methods:
867
            try:
868
                LOGGER.debug(
869
                    'Calling %s.%s',
870
                    shutdown_method.__module__,
871
                    shutdown_method.__name__)
872
                shutdown_method(self)
873
            except Exception as e:
874
                LOGGER.exception('Error calling shutdown method: %s', e)
875
876
        # Avoid calling shutdown methods if we already have.
877
        self.shutdown_methods = []
878
879
    def cap_req(self, module_name, capability, arg=None, failure_callback=None,
880
                success_callback=None):
881
        """Tell Sopel to request a capability when it starts.
882
883
        :param str module_name: the module requesting the capability
884
        :param str capability: the capability requested, optionally prefixed
885
                               with ``+`` or ``=``
886
        :param str arg: arguments for the capability request
887
        :param failure_callback: a function that will be called if the
888
                                 capability request fails
889
        :type failure_callback: :term:`function`
890
        :param success_callback: a function that will be called if the
891
                                 capability is successfully requested
892
        :type success_callback: :term:`function`
893
894
        By prefixing the capability with ``-``, it will be ensured that the
895
        capability is not enabled. Similarly, by prefixing the capability with
896
        ``=``, it will be ensured that the capability is enabled. Requiring and
897
        disabling is "first come, first served"; if one module requires a
898
        capability, and another prohibits it, this function will raise an
899
        exception in whichever module loads second. An exception will also be
900
        raised if the module is being loaded after the bot has already started,
901
        and the request would change the set of enabled capabilities.
902
903
        If the capability is not prefixed, and no other module prohibits it, it
904
        will be requested. Otherwise, it will not be requested. Since
905
        capability requests that are not mandatory may be rejected by the
906
        server, as well as by other modules, a module which makes such a
907
        request should account for that possibility.
908
909
        The actual capability request to the server is handled after the
910
        completion of this function. In the event that the server denies a
911
        request, the ``failure_callback`` function will be called, if provided.
912
        The arguments will be a :class:`sopel.bot.Sopel` object, and the
913
        capability which was rejected. This can be used to disable callables
914
        which rely on the capability. It will be be called either if the server
915
        NAKs the request, or if the server enabled it and later DELs it.
916
917
        The ``success_callback`` function will be called upon acknowledgement
918
        of the capability from the server, whether during the initial
919
        capability negotiation, or later.
920
921
        If ``arg`` is given, and does not exactly match what the server
922
        provides or what other modules have requested for that capability, it is
923
        considered a conflict.
924
        """
925
        # TODO raise better exceptions
926
        cap = capability[1:]
927
        prefix = capability[0]
928
929
        entry = self._cap_reqs.get(cap, [])
930
        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...
931
            raise Exception('Capability conflict')
932
933
        if prefix == '-':
934
            if self.connection_registered and cap in self.enabled_capabilities:
935
                raise Exception('Can not change capabilities after server '
936
                                'connection has been completed.')
937
            if any((ent.prefix != '-' for ent in entry)):
938
                raise Exception('Capability conflict')
939
            entry.append(_CapReq(prefix, module_name, failure_callback, arg,
940
                                 success_callback))
941
            self._cap_reqs[cap] = entry
942
        else:
943
            if prefix != '=':
944
                cap = capability
945
                prefix = ''
946
            if self.connection_registered and (cap not in
947
                                               self.enabled_capabilities):
948
                raise Exception('Can not change capabilities after server '
949
                                'connection has been completed.')
950
            # Non-mandatory will callback at the same time as if the server
951
            # rejected it.
952
            if any((ent.prefix == '-' for ent in entry)) and prefix == '=':
953
                raise Exception('Capability conflict')
954
            entry.append(_CapReq(prefix, module_name, failure_callback, arg,
955
                                 success_callback))
956
            self._cap_reqs[cap] = entry
957
958
    def register_url_callback(self, pattern, callback):
959
        """Register a ``callback`` for URLs matching the regex ``pattern``.
960
961
        :param pattern: compiled regex pattern to register
962
        :type pattern: :ref:`re.Pattern <python:re-objects>`
963
        :param callback: callable object to handle matching URLs
964
        :type callback: :term:`function`
965
966
        .. versionadded:: 7.0
967
968
            This method replaces manual management of ``url_callbacks`` in
969
            Sopel's plugins, so instead of doing this in ``setup()``::
970
971
                if 'url_callbacks' not in bot.memory:
972
                    bot.memory['url_callbacks'] = tools.SopelMemory()
973
974
                regex = re.compile(r'http://example.com/path/.*')
975
                bot.memory['url_callbacks'][regex] = callback
976
977
            use this much more concise pattern::
978
979
                regex = re.compile(r'http://example.com/path/.*')
980
                bot.register_url_callback(regex, callback)
981
982
        """
983
        if 'url_callbacks' not in self.memory:
984
            self.memory['url_callbacks'] = tools.SopelMemory()
985
986
        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...
987
            pattern = re.compile(pattern)
988
989
        self.memory['url_callbacks'][pattern] = callback
990
991
    def unregister_url_callback(self, pattern):
992
        """Unregister the callback for URLs matching the regex ``pattern``.
993
994
        :param pattern: compiled regex pattern to unregister callback
995
        :type pattern: :ref:`re.Pattern <python:re-objects>`
996
997
        .. versionadded:: 7.0
998
999
            This method replaces manual management of ``url_callbacks`` in
1000
            Sopel's plugins, so instead of doing this in ``shutdown()``::
1001
1002
                regex = re.compile(r'http://example.com/path/.*')
1003
                try:
1004
                    del bot.memory['url_callbacks'][regex]
1005
                except KeyError:
1006
                    pass
1007
1008
            use this much more concise pattern::
1009
1010
                regex = re.compile(r'http://example.com/path/.*')
1011
                bot.unregister_url_callback(regex)
1012
1013
        """
1014
        if 'url_callbacks' not in self.memory:
1015
            # nothing to unregister
1016
            return
1017
1018
        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...
1019
            pattern = re.compile(pattern)
1020
1021
        try:
1022
            del self.memory['url_callbacks'][pattern]
1023
        except KeyError:
1024
            pass
1025
1026
    def search_url_callbacks(self, url):
1027
        """Yield callbacks found for ``url`` matching their regex pattern.
1028
1029
        :param str url: URL found in a trigger
1030
        :return: yield 2-value tuples of ``(callback, match)``
1031
1032
        For each pattern that matches the ``url`` parameter, it yields a
1033
        2-value tuple of ``(callable, match)`` for that pattern.
1034
1035
        The ``callable`` is the one registered with
1036
        :meth:`register_url_callback`, and the ``match`` is the result of
1037
        the regex pattern's ``search`` method.
1038
1039
        .. versionadded:: 7.0
1040
1041
        .. seealso::
1042
1043
            The Python documentation for the `re.search`__ function and
1044
            the `match object`__.
1045
1046
        .. __: https://docs.python.org/3.6/library/re.html#re.search
1047
        .. __: https://docs.python.org/3.6/library/re.html#match-objects
1048
1049
        """
1050
        if 'url_callbacks' not in self.memory:
1051
            # nothing to search
1052
            return
1053
1054
        for regex, function in tools.iteritems(self.memory['url_callbacks']):
1055
            match = regex.search(url)
1056
            if match:
1057
                yield function, match
1058
1059
1060
class SopelWrapper(object):
1061
    """Wrapper around a Sopel instance and a Trigger
1062
1063
    :param sopel: Sopel instance
1064
    :type sopel: :class:`~sopel.bot.Sopel`
1065
    :param trigger: IRC Trigger line
1066
    :type trigger: :class:`sopel.trigger.Trigger`
1067
1068
    This wrapper will be used to call Sopel's triggered commands and rules as
1069
    their ``bot`` argument. It acts as a proxy to :meth:`send messages<say>` to
1070
    the sender (either a channel or in a private message) and even to
1071
    :meth:`reply to someone<reply>` in a channel.
1072
    """
1073
    def __init__(self, sopel, trigger):
1074
        # The custom __setattr__ for this class sets the attribute on the
1075
        # original bot object. We don't want that for these, so we set them
1076
        # with the normal __setattr__.
1077
        object.__setattr__(self, '_bot', sopel)
1078
        object.__setattr__(self, '_trigger', trigger)
1079
1080
    def __dir__(self):
1081
        classattrs = [attr for attr in self.__class__.__dict__
1082
                      if not attr.startswith('__')]
1083
        return list(self.__dict__) + classattrs + dir(self._bot)
1084
1085
    def __getattr__(self, attr):
1086
        return getattr(self._bot, attr)
1087
1088
    def __setattr__(self, attr, value):
1089
        return setattr(self._bot, attr, value)
1090
1091
    def say(self, message, destination=None, max_messages=1):
1092
        """Override ``Sopel.say`` to send message to sender
1093
1094
        :param str message: message to say
1095
        :param str destination: channel or person; defaults to trigger's sender
1096
        :param int max_messages: max number of message splits
1097
1098
        .. seealso::
1099
1100
            :meth:`sopel.bot.Sopel.say`
1101
        """
1102
        if destination is None:
1103
            destination = self._trigger.sender
1104
        self._bot.say(message, destination, max_messages)
1105
1106
    def action(self, message, destination=None):
1107
        """Override ``Sopel.action`` to send action to sender
1108
1109
        :param str message: action message
1110
        :param str destination: channel or person; defaults to trigger's sender
1111
1112
        .. seealso::
1113
1114
            :meth:`sopel.bot.Sopel.action`
1115
        """
1116
        if destination is None:
1117
            destination = self._trigger.sender
1118
        self._bot.action(message, destination)
1119
1120
    def notice(self, message, destination=None):
1121
        """Override ``Sopel.notice`` to send a notice to sender
1122
1123
        :param str message: notice message
1124
        :param str destination: channel or person; defaults to trigger's sender
1125
1126
        .. seealso::
1127
1128
            :meth:`sopel.bot.Sopel.notice`
1129
        """
1130
        if destination is None:
1131
            destination = self._trigger.sender
1132
        self._bot.notice(message, destination)
1133
1134
    def reply(self, message, destination=None, reply_to=None, notice=False):
1135
        """Override ``Sopel.reply`` to reply to someone
1136
1137
        :param str message: reply message
1138
        :param str destination: channel or person; defaults to trigger's sender
1139
        :param str reply_to: person to reply to; defaults to trigger's nick
1140
        :param bool notice: reply as an IRC notice or with a simple message
1141
1142
        .. seealso::
1143
1144
            :meth:`sopel.bot.Sopel.reply`
1145
        """
1146
        if destination is None:
1147
            destination = self._trigger.sender
1148
        if reply_to is None:
1149
            reply_to = self._trigger.nick
1150
        self._bot.reply(message, destination, reply_to, notice)
1151
1152
    def kick(self, nick, channel=None, message=None):
1153
        if channel is None:
1154
            if self._trigger.is_privmsg:
1155
                raise RuntimeError('Error: KICK requires a channel.')
1156
            else:
1157
                channel = self._trigger.sender
1158
        if nick is None:
1159
            raise RuntimeError('Error: KICK requires a nick.')
1160
        self._bot.kick(nick, channel, message)
1161