Passed
Push — master ( 684b8f...0b2c08 )
by dgw
02:10 queued 10s
created

sopel.bot.Sopel.remove_plugin()   C

Complexity

Conditions 9

Size

Total Lines 24
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 16
dl 0
loc 24
rs 6.6666
c 0
b 0
f 0
cc 9
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 os
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 stderr, 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 = logger.get_logger(__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)
215
216
    def setup_plugins(self):
217
        load_success = 0
218
        load_error = 0
219
        load_disabled = 0
220
221
        stderr("Loading plugins...")
222
        usable_plugins = plugins.get_usable_plugins(self.config)
223
        for name, info in usable_plugins.items():
224
            plugin, is_enabled = info
225
            if not is_enabled:
226
                load_disabled = load_disabled + 1
227
                continue
228
229
            try:
230
                plugin.load()
231
            except Exception as e:
232
                load_error = load_error + 1
233
                filename, lineno = tools.get_raising_file_and_line()
234
                rel_path = os.path.relpath(filename, os.path.dirname(__file__))
235
                raising_stmt = "%s:%d" % (rel_path, lineno)
236
                stderr("Error loading %s: %s (%s)" % (name, e, raising_stmt))
237
            else:
238
                try:
239
                    if plugin.has_setup():
240
                        plugin.setup(self)
241
                    plugin.register(self)
242
                except Exception as e:
243
                    load_error = load_error + 1
244
                    filename, lineno = tools.get_raising_file_and_line()
245
                    rel_path = os.path.relpath(
246
                        filename, os.path.dirname(__file__)
247
                    )
248
                    raising_stmt = "%s:%d" % (rel_path, lineno)
249
                    stderr("Error in %s setup procedure: %s (%s)"
250
                           % (name, e, raising_stmt))
251
                else:
252
                    load_success = load_success + 1
253
                    print('Loaded: %s' % name)
254
255
        total = sum([load_success, load_error, load_disabled])
256
        if total and load_success:
257
            stderr('Registered %d modules' % (load_success - 1))
258
            stderr('%d modules failed to load' % load_error)
259
            stderr('%d modules disabled' % load_disabled)
260
        else:
261
            stderr("Warning: Couldn't load any modules")
262
263
    def reload_plugin(self, name):
264
        """Reload a plugin
265
266
        :param str name: name of the plugin to reload
267
        :raise PluginNotRegistered: when there is no ``name`` plugin registered
268
269
        It runs the plugin's shutdown routine and unregisters it. Then it
270
        reloads it, runs its setup routines, and registers it again.
271
        """
272
        if not self.has_plugin(name):
273
            raise plugins.exceptions.PluginNotRegistered(name)
274
275
        plugin = self._plugins[name]
276
        # tear down
277
        plugin.shutdown(self)
278
        plugin.unregister(self)
279
        print('Unloaded: %s' % name)
280
        # reload & setup
281
        plugin.reload()
282
        plugin.setup(self)
283
        plugin.register(self)
284
        print('Reloaded: %s' % name)
285
286
    def reload_plugins(self):
287
        """Reload all plugins
288
289
        First, run all plugin shutdown routines and unregister all plugins.
290
        Then reload all plugins, run their setup routines, and register them
291
        again.
292
        """
293
        registered = list(self._plugins.items())
294
        # tear down all plugins
295
        for name, plugin in registered:
296
            plugin.shutdown(self)
297
            plugin.unregister(self)
298
            print('Unloaded: %s' % name)
299
300
        # reload & setup all plugins
301
        for name, plugin in registered:
302
            plugin.reload()
303
            plugin.setup(self)
304
            plugin.register(self)
305
            print('Reloaded: %s' % name)
306
307
    def add_plugin(self, plugin, callables, jobs, shutdowns, urls):
308
        """Add a loaded plugin to the bot's registry"""
309
        self._plugins[plugin.name] = plugin
310
        self.register(callables, jobs, shutdowns, urls)
311
312
    def remove_plugin(self, plugin, callables, jobs, shutdowns, urls):
313
        """Remove a loaded plugin from the bot's registry"""
314
        name = plugin.name
315
        if not self.has_plugin(name):
316
            raise plugins.exceptions.PluginNotRegistered(name)
317
318
        try:
319
            # remove commands, jobs, and shutdown functions
320
            for func in itertools.chain(callables, jobs, shutdowns):
321
                self.unregister(func)
322
323
            # remove URL callback handlers
324
            if "url_callbacks" in self.memory:
325
                for func in urls:
326
                    regexes = func.url_regex
327
                    for regex in regexes:
328
                        if func == self.memory['url_callbacks'].get(regex):
329
                            self.unregister_url_callback(regex)
330
        except:  # noqa
331
            # TODO: consider logging?
332
            raise  # re-raised
333
        else:
334
            # remove plugin from registry
335
            del self._plugins[name]
336
337
    def has_plugin(self, name):
338
        """Tell if the bot has registered this plugin by its name"""
339
        return name in self._plugins
340
341
    def unregister(self, obj):
342
        """Unregister a callable.
343
344
        :param obj: the callable to unregister
345
        :type obj: :term:`object`
346
        """
347
        if not callable(obj):
348
            return
349
        if hasattr(obj, 'rule'):  # commands and intents have it added
350
            for rule in obj.rule:
351
                callb_list = self._callables[obj.priority][rule]
352
                if obj in callb_list:
353
                    callb_list.remove(obj)
354
        if hasattr(obj, 'interval'):
355
            self.scheduler.remove_callable_job(obj)
356
        if (
357
                getattr(obj, "__name__", None) == "shutdown" and
358
                obj in self.shutdown_methods
359
        ):
360
            self.shutdown_methods.remove(obj)
361
362
    def register(self, callables, jobs, shutdowns, urls):
363
        """Register a callable.
364
365
        :param callables: an iterable of callables to register
366
        :type callables: :term:`iterable`
367
        :param jobs: an iterable of functions to periodically invoke
368
        :type jobs: :term:`iterable`
369
        :param shutdowns: an iterable of functions to call on shutdown
370
        :type shutdowns: :term:`iterable`
371
        :param urls: an iterable of functions to call when matched against a URL
372
        :type urls: :term:`iterable`
373
        """
374
        # Append module's shutdown function to the bot's list of functions to
375
        # call on shutdown
376
        self.shutdown_methods += shutdowns
377
        for callbl in callables:
378
            if hasattr(callbl, 'rule'):
379
                for rule in callbl.rule:
380
                    self._callables[callbl.priority][rule].append(callbl)
381
            else:
382
                self._callables[callbl.priority][re.compile('.*')].append(callbl)
383
            if hasattr(callbl, 'commands'):
384
                module_name = callbl.__module__.rsplit('.', 1)[-1]
385
                # TODO doc and make decorator for this. Not sure if this is how
386
                # it should work yet, so not making it public for 6.0.
387
                category = getattr(callbl, 'category', module_name)
388
                self._command_groups[category].append(callbl.commands[0])
389
            for command, docs in callbl._docs.items():
390
                self.doc[command] = docs
391
        for func in jobs:
392
            for interval in func.interval:
393
                job = sopel.tools.jobs.Job(interval, func)
394
                self.scheduler.add_job(job)
395
396
        for func in urls:
397
            for regex in func.url_regex:
398
                self.register_url_callback(regex, func)
399
400
    def part(self, channel, msg=None):
401
        """Leave a channel.
402
403
        :param str channel: the channel to leave
404
        :param str msg: the message to display when leaving a channel
405
        """
406
        self.write(['PART', channel], msg)
407
408
    def join(self, channel, password=None):
409
        """Join a channel.
410
411
        :param str channel: the channel to join
412
        :param str password: an optional channel password
413
414
        If ``channel`` contains a space, and no ``password`` is given, the
415
        space is assumed to split the argument into the channel to join and its
416
        password. ``channel`` should not contain a space if ``password``
417
        is given.
418
        """
419
        if password is None:
420
            self.write(('JOIN', channel))
421
        else:
422
            self.write(['JOIN', channel, password])
423
424
    @deprecated
425
    def msg(self, recipient, text, max_messages=1):
426
        """
427
        .. deprecated:: 6.0
428
            Use :meth:`say` instead. Will be removed in Sopel 8.
429
        """
430
        self.say(text, recipient, max_messages)
431
432
    def say(self, text, recipient, max_messages=1):
433
        """Send a PRIVMSG to a user or channel.
434
435
        :param str text: the text to send
436
        :param str recipient: the message recipient
437
        :param int max_messages: the maximum number of messages to break the
438
                                 text into
439
440
        In the context of a triggered callable, the ``recipient`` defaults to
441
        the channel (or nickname, if a private message) from which the message
442
        was received.
443
444
        By default, this will attempt to send the entire ``text`` in one
445
        message. If the text is too long for the server, it may be truncated.
446
        If ``max_messages`` is given, the ``text`` will be split into at most
447
        that many messages, each no more than 400 bytes. The split is made at
448
        the last space character before the 400th byte, or at the 400th byte if
449
        no such space exists. If the ``text`` is too long to fit into the
450
        specified number of messages using the above splitting, the final
451
        message will contain the entire remainder, which may be truncated by
452
        the server.
453
        """
454
        excess = ''
455
        if not isinstance(text, unicode):
456
            # Make sure we are dealing with unicode string
457
            text = text.decode('utf-8')
458
459
        if max_messages > 1:
460
            # Manage multi-line only when needed
461
            text, excess = tools.get_sendable_message(text)
462
463
        try:
464
            self.sending.acquire()
465
466
            recipient_id = Identifier(recipient)
467
            recipient_stack = self.stack.setdefault(recipient_id, {
468
                'messages': [],
469
                'flood_left': self.config.core.flood_burst_lines,
470
            })
471
472
            if recipient_stack['messages']:
473
                elapsed = time.time() - recipient_stack['messages'][-1][0]
474
            else:
475
                # Default to a high enough value that we won't care.
476
                # Five minutes should be enough not to matter anywhere below.
477
                elapsed = 300
478
479
            # If flood bucket is empty, refill the appropriate number of lines
480
            # based on how long it's been since our last message to recipient
481
            if not recipient_stack['flood_left']:
482
                recipient_stack['flood_left'] = min(
483
                    self.config.core.flood_burst_lines,
484
                    int(elapsed) * self.config.core.flood_refill_rate)
485
486
            # If it's too soon to send another message, wait
487
            if not recipient_stack['flood_left']:
488
                penalty = float(max(0, len(text) - 50)) / 70
489
                wait = min(self.config.core.flood_empty_wait + penalty, 2)  # Maximum wait time is 2 sec
490
                if elapsed < wait:
491
                    time.sleep(wait - elapsed)
492
493
            # Loop detection
494
            messages = [m[1] for m in recipient_stack['messages'][-8:]]
495
496
            # If what we're about to send repeated at least 5 times in the last
497
            # two minutes, replace it with '...'
498
            if messages.count(text) >= 5 and elapsed < 120:
499
                text = '...'
500
                if messages.count('...') >= 3:
501
                    # If we've already said '...' 3 times, discard message
502
                    return
503
504
            self.write(('PRIVMSG', recipient), text)
505
            recipient_stack['flood_left'] = max(0, recipient_stack['flood_left'] - 1)
506
            recipient_stack['messages'].append((time.time(), self.safe(text)))
507
            recipient_stack['messages'] = recipient_stack['messages'][-10:]
508
        finally:
509
            self.sending.release()
510
        # Now that we've sent the first part, we need to send the rest. Doing
511
        # this recursively seems easier to me than iteratively
512
        if excess:
513
            self.say(excess, max_messages - 1, recipient)
514
515
    def notice(self, text, dest):
516
        """Send an IRC NOTICE to a user or channel.
517
518
        :param str text: the text to send in the NOTICE
519
        :param str dest: the destination of the NOTICE
520
521
        Within the context of a triggered callable, ``dest`` will default to
522
        the channel (or nickname, if a private message), in which the trigger
523
        happened.
524
        """
525
        self.write(('NOTICE', dest), text)
526
527
    def action(self, text, dest):
528
        """Send a CTCP ACTION PRIVMSG to a user or channel.
529
530
        :param str text: the text to send in the CTCP ACTION
531
        :param str dest: the destination of the CTCP ACTION
532
533
        The same loop detection and length restrictions apply as with
534
        :func:`say`, though automatic message splitting is not available.
535
536
        Within the context of a triggered callable, ``dest`` will default to
537
        the channel (or nickname, if a private message), in which the trigger
538
        happened.
539
        """
540
        self.say('\001ACTION {}\001'.format(text), dest)
541
542
    def reply(self, text, dest, reply_to, notice=False):
543
        """Send a PRIVMSG to a user or channel, prepended with ``reply_to``.
544
545
        :param str text: the text of the reply
546
        :param str dest: the destination of the reply
547
        :param str reply_to: the nickname that the reply will be prepended with
548
        :param bool notice: whether to send the reply as a NOTICE or not,
549
                            defaults to ``False``
550
551
        If ``notice`` is ``True``, send a NOTICE rather than a PRIVMSG.
552
553
        The same loop detection and length restrictions apply as with
554
        :func:`say`, though automatic message splitting is not available.
555
556
        Within the context of a triggered callable, ``reply_to`` will default to
557
        the nickname of the user who triggered the call, and ``dest`` to the
558
        channel (or nickname, if a private message), in which the trigger
559
        happened.
560
        """
561
        text = '%s: %s' % (reply_to, text)
562
        if notice:
563
            self.notice(text, dest)
564
        else:
565
            self.say(text, dest)
566
567
    def kick(self, nick, channel, text=None):
568
        """Send an IRC KICK command.
569
        Within the context of a triggered callable, ``channel`` will default to the
570
        channel in which the call was triggered. If triggered from a private message,
571
        ``channel`` is required (or the call to ``kick()`` will be ignored).
572
        The bot must be a channel operator in specified channel for this to work.
573
        .. versionadded:: 7.0
574
        """
575
        self.write(['KICK', channel, nick], text)
576
577
    def call(self, func, sopel, trigger):
578
        """Call a function, applying any rate-limiting or restrictions.
579
580
        :param func: the function to call
581
        :type func: :term:`function`
582
        :param sopel: a SopelWrapper instance
583
        :type sopel: :class:`SopelWrapper`
584
        :param Trigger trigger: the Trigger object for the line from the server
585
                                that triggered this call
586
        """
587
        nick = trigger.nick
588
        current_time = time.time()
589
        if nick not in self._times:
590
            self._times[nick] = dict()
591
        if self.nick not in self._times:
592
            self._times[self.nick] = dict()
593
        if not trigger.is_privmsg and trigger.sender not in self._times:
594
            self._times[trigger.sender] = dict()
595
596
        if not trigger.admin and not func.unblockable:
597
            if func in self._times[nick]:
598
                usertimediff = current_time - self._times[nick][func]
599
                if func.rate > 0 and usertimediff < func.rate:
600
                    LOGGER.info(
601
                        "%s prevented from using %s in %s due to user limit: %d < %d",
602
                        trigger.nick, func.__name__, trigger.sender, usertimediff,
603
                        func.rate
604
                    )
605
                    return
606
            if func in self._times[self.nick]:
607
                globaltimediff = current_time - self._times[self.nick][func]
608
                if func.global_rate > 0 and globaltimediff < func.global_rate:
609
                    LOGGER.info(
610
                        "%s prevented from using %s in %s due to global limit: %d < %d",
611
                        trigger.nick, func.__name__, trigger.sender, globaltimediff,
612
                        func.global_rate
613
                    )
614
                    return
615
616
            if not trigger.is_privmsg and func in self._times[trigger.sender]:
617
                chantimediff = current_time - self._times[trigger.sender][func]
618
                if func.channel_rate > 0 and chantimediff < func.channel_rate:
619
                    LOGGER.info(
620
                        "%s prevented from using %s in %s due to channel limit: %d < %d",
621
                        trigger.nick, func.__name__, trigger.sender, chantimediff,
622
                        func.channel_rate
623
                    )
624
                    return
625
626
        # if channel has its own config section, check for excluded modules/modules methods
627
        if trigger.sender in self.config:
628
            channel_config = self.config[trigger.sender]
629
630
            # disable listed modules completely on provided channel
631
            if 'disable_modules' in channel_config:
632
                disabled_modules = channel_config.disable_modules.split(',')
633
634
                # if "*" is used, we are disabling all modules on provided channel
635
                if '*' in disabled_modules:
636
                    return
637
                if func.__module__ in disabled_modules:
638
                    return
639
640
            # disable chosen methods from modules
641
            if 'disable_commands' in channel_config:
642
                disabled_commands = literal_eval(channel_config.disable_commands)
643
644
                if func.__module__ in disabled_commands:
645
                    if func.__name__ in disabled_commands[func.__module__]:
646
                        return
647
648
        try:
649
            exit_code = func(sopel, trigger)
650
        except Exception:  # TODO: Be specific
651
            exit_code = None
652
            self.error(trigger)
653
654
        if exit_code != NOLIMIT:
655
            self._times[nick][func] = current_time
656
            self._times[self.nick][func] = current_time
657
            if not trigger.is_privmsg:
658
                self._times[trigger.sender][func] = current_time
659
660
    def dispatch(self, pretrigger):
661
        """Dispatch a parsed message to any registered callables.
662
663
        :param PreTrigger pretrigger: a parsed message from the server
664
        """
665
        args = pretrigger.args
666
        text = args[-1] if args else ''
667
        event = pretrigger.event
668
        intent = pretrigger.tags.get('intent')
669
        nick = pretrigger.nick
670
        is_echo_message = nick.lower() == self.nick.lower()
671
        user_obj = self.users.get(nick)
672
        account = user_obj.account if user_obj else None
673
674
        if self.config.core.nick_blocks or self.config.core.host_blocks:
675
            nick_blocked = self._nick_blocked(pretrigger.nick)
676
            host_blocked = self._host_blocked(pretrigger.host)
677
        else:
678
            nick_blocked = host_blocked = None
679
        blocked = bool(nick_blocked or host_blocked)
680
681
        list_of_blocked_functions = []
682
        for priority in ('high', 'medium', 'low'):
683
            for regexp, funcs in self._callables[priority].items():
684
                match = regexp.match(text)
685
                if not match:
686
                    continue
687
688
                for func in funcs:
689
                    trigger = Trigger(self.config, pretrigger, match, account)
690
691
                    # check blocked nick/host
692
                    if blocked and not func.unblockable and not trigger.admin:
693
                        function_name = "%s.%s" % (
694
                            func.__module__, func.__name__
695
                        )
696
                        list_of_blocked_functions.append(function_name)
697
                        continue
698
699
                    # check event
700
                    if event not in func.event:
701
                        continue
702
703
                    # check intents
704
                    if hasattr(func, 'intents'):
705
                        if not intent:
706
                            continue
707
708
                        match = any(
709
                            func_intent.match(intent)
710
                            for func_intent in func.intents
711
                        )
712
                        if not match:
713
                            continue
714
715
                    # check echo-message feature
716
                    if is_echo_message and not func.echo:
717
                        continue
718
719
                    # call triggered function
720
                    wrapper = SopelWrapper(self, trigger)
721
                    if func.thread:
722
                        targs = (func, wrapper, trigger)
723
                        t = threading.Thread(target=self.call, args=targs)
724
                        t.start()
725
                    else:
726
                        self.call(func, wrapper, trigger)
727
728
        if list_of_blocked_functions:
729
            if nick_blocked and host_blocked:
730
                block_type = 'both'
731
            elif nick_blocked:
732
                block_type = 'nick'
733
            else:
734
                block_type = 'host'
735
            LOGGER.info(
736
                "[%s]%s prevented from using %s.",
737
                block_type,
738
                nick,
739
                ', '.join(list_of_blocked_functions)
740
            )
741
742
    def _host_blocked(self, host):
743
        bad_masks = self.config.core.host_blocks
744
        for bad_mask in bad_masks:
745
            bad_mask = bad_mask.strip()
746
            if not bad_mask:
747
                continue
748
            if (re.match(bad_mask + '$', host, re.IGNORECASE) or
749
                    bad_mask == host):
750
                return True
751
        return False
752
753
    def _nick_blocked(self, nick):
754
        bad_nicks = self.config.core.nick_blocks
755
        for bad_nick in bad_nicks:
756
            bad_nick = bad_nick.strip()
757
            if not bad_nick:
758
                continue
759
            if (re.match(bad_nick + '$', nick, re.IGNORECASE) or
760
                    Identifier(bad_nick) == nick):
761
                return True
762
        return False
763
764
    def _shutdown(self):
765
        # Stop Job Scheduler
766
        stderr('Stopping the Job Scheduler.')
767
        self.scheduler.stop()
768
769
        try:
770
            self.scheduler.join(timeout=15)
771
        except RuntimeError:
772
            stderr('Unable to stop the Job Scheduler.')
773
        else:
774
            stderr('Job Scheduler stopped.')
775
776
        self.scheduler.clear_jobs()
777
778
        # Shutdown plugins
779
        stderr(
780
            'Calling shutdown for %d modules.' % (len(self.shutdown_methods),)
781
        )
782
        for shutdown_method in self.shutdown_methods:
783
            try:
784
                stderr(
785
                    "calling %s.%s" % (
786
                        shutdown_method.__module__, shutdown_method.__name__,
787
                    )
788
                )
789
                shutdown_method(self)
790
            except Exception as e:
791
                stderr(
792
                    "Error calling shutdown method for module %s:%s" % (
793
                        shutdown_method.__module__, e
794
                    )
795
                )
796
        # Avoid calling shutdown methods if we already have.
797
        self.shutdown_methods = []
798
799
    def cap_req(self, module_name, capability, arg=None, failure_callback=None,
800
                success_callback=None):
801
        """Tell Sopel to request a capability when it starts.
802
803
        :param str module_name: the module requesting the capability
804
        :param str capability: the capability requested, optionally prefixed
805
                               with ``+`` or ``=``
806
        :param str arg: arguments for the capability request
807
        :param failure_callback: a function that will be called if the
808
                                 capability request fails
809
        :type failure_callback: :term:`function`
810
        :param success_callback: a function that will be called if the
811
                                 capability is successfully requested
812
        :type success_callback: :term:`function`
813
814
        By prefixing the capability with ``-``, it will be ensured that the
815
        capability is not enabled. Similarly, by prefixing the capability with
816
        ``=``, it will be ensured that the capability is enabled. Requiring and
817
        disabling is "first come, first served"; if one module requires a
818
        capability, and another prohibits it, this function will raise an
819
        exception in whichever module loads second. An exception will also be
820
        raised if the module is being loaded after the bot has already started,
821
        and the request would change the set of enabled capabilities.
822
823
        If the capability is not prefixed, and no other module prohibits it, it
824
        will be requested. Otherwise, it will not be requested. Since
825
        capability requests that are not mandatory may be rejected by the
826
        server, as well as by other modules, a module which makes such a
827
        request should account for that possibility.
828
829
        The actual capability request to the server is handled after the
830
        completion of this function. In the event that the server denies a
831
        request, the ``failure_callback`` function will be called, if provided.
832
        The arguments will be a :class:`sopel.bot.Sopel` object, and the
833
        capability which was rejected. This can be used to disable callables
834
        which rely on the capability. It will be be called either if the server
835
        NAKs the request, or if the server enabled it and later DELs it.
836
837
        The ``success_callback`` function will be called upon acknowledgement
838
        of the capability from the server, whether during the initial
839
        capability negotiation, or later.
840
841
        If ``arg`` is given, and does not exactly match what the server
842
        provides or what other modules have requested for that capability, it is
843
        considered a conflict.
844
        """
845
        # TODO raise better exceptions
846
        cap = capability[1:]
847
        prefix = capability[0]
848
849
        entry = self._cap_reqs.get(cap, [])
850
        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...
851
            raise Exception('Capability conflict')
852
853
        if prefix == '-':
854
            if self.connection_registered and cap in self.enabled_capabilities:
855
                raise Exception('Can not change capabilities after server '
856
                                'connection has been completed.')
857
            if any((ent.prefix != '-' for ent in entry)):
858
                raise Exception('Capability conflict')
859
            entry.append(_CapReq(prefix, module_name, failure_callback, arg,
860
                                 success_callback))
861
            self._cap_reqs[cap] = entry
862
        else:
863
            if prefix != '=':
864
                cap = capability
865
                prefix = ''
866
            if self.connection_registered and (cap not in
867
                                               self.enabled_capabilities):
868
                raise Exception('Can not change capabilities after server '
869
                                'connection has been completed.')
870
            # Non-mandatory will callback at the same time as if the server
871
            # rejected it.
872
            if any((ent.prefix == '-' for ent in entry)) and prefix == '=':
873
                raise Exception('Capability conflict')
874
            entry.append(_CapReq(prefix, module_name, failure_callback, arg,
875
                                 success_callback))
876
            self._cap_reqs[cap] = entry
877
878
    def register_url_callback(self, pattern, callback):
879
        """Register a ``callback`` for URLs matching the regex ``pattern``.
880
881
        :param pattern: compiled regex pattern to register
882
        :type pattern: :ref:`re.Pattern <python:re-objects>`
883
        :param callback: callable object to handle matching URLs
884
        :type callback: :term:`function`
885
886
        .. versionadded:: 7.0
887
888
            This method replaces manual management of ``url_callbacks`` in
889
            Sopel's plugins, so instead of doing this in ``setup()``::
890
891
                if 'url_callbacks' not in bot.memory:
892
                    bot.memory['url_callbacks'] = tools.SopelMemory()
893
894
                regex = re.compile(r'http://example.com/path/.*')
895
                bot.memory['url_callbacks'][regex] = callback
896
897
            use this much more concise pattern::
898
899
                regex = re.compile(r'http://example.com/path/.*')
900
                bot.register_url_callback(regex, callback)
901
902
        """
903
        if 'url_callbacks' not in self.memory:
904
            self.memory['url_callbacks'] = tools.SopelMemory()
905
906
        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...
907
            pattern = re.compile(pattern)
908
909
        self.memory['url_callbacks'][pattern] = callback
910
911
    def unregister_url_callback(self, pattern):
912
        """Unregister the callback for URLs matching the regex ``pattern``.
913
914
        :param pattern: compiled regex pattern to unregister callback
915
        :type pattern: :ref:`re.Pattern <python:re-objects>`
916
917
        .. versionadded:: 7.0
918
919
            This method replaces manual management of ``url_callbacks`` in
920
            Sopel's plugins, so instead of doing this in ``shutdown()``::
921
922
                regex = re.compile(r'http://example.com/path/.*')
923
                try:
924
                    del bot.memory['url_callbacks'][regex]
925
                except KeyError:
926
                    pass
927
928
            use this much more concise pattern::
929
930
                regex = re.compile(r'http://example.com/path/.*')
931
                bot.unregister_url_callback(regex)
932
933
        """
934
        if 'url_callbacks' not in self.memory:
935
            # nothing to unregister
936
            return
937
938
        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...
939
            pattern = re.compile(pattern)
940
941
        try:
942
            del self.memory['url_callbacks'][pattern]
943
        except KeyError:
944
            pass
945
946
    def search_url_callbacks(self, url):
947
        """Yield callbacks found for ``url`` matching their regex pattern.
948
949
        :param str url: URL found in a trigger
950
        :return: yield 2-value tuples of ``(callback, match)``
951
952
        For each pattern that matches the ``url`` parameter, it yields a
953
        2-value tuple of ``(callable, match)`` for that pattern.
954
955
        The ``callable`` is the one registered with
956
        :meth:`register_url_callback`, and the ``match`` is the result of
957
        the regex pattern's ``search`` method.
958
959
        .. versionadded:: 7.0
960
961
        .. seealso::
962
963
            The Python documentation for the `re.search`__ function and
964
            the `match object`__.
965
966
        .. __: https://docs.python.org/3.6/library/re.html#re.search
967
        .. __: https://docs.python.org/3.6/library/re.html#match-objects
968
969
        """
970
        if 'url_callbacks' not in self.memory:
971
            # nothing to search
972
            return
973
974
        for regex, function in tools.iteritems(self.memory['url_callbacks']):
975
            match = regex.search(url)
976
            if match:
977
                yield function, match
978
979
980
class SopelWrapper(object):
981
    """Wrapper around a Sopel instance and a Trigger
982
983
    :param sopel: Sopel instance
984
    :type sopel: :class:`~sopel.bot.Sopel`
985
    :param trigger: IRC Trigger line
986
    :type trigger: :class:`sopel.trigger.Trigger`
987
988
    This wrapper will be used to call Sopel's triggered commands and rules as
989
    their ``bot`` argument. It acts as a proxy to :meth:`send messages<say>` to
990
    the sender (either a channel or in a private message) and even to
991
    :meth:`reply to someone<reply>` in a channel.
992
    """
993
    def __init__(self, sopel, trigger):
994
        # The custom __setattr__ for this class sets the attribute on the
995
        # original bot object. We don't want that for these, so we set them
996
        # with the normal __setattr__.
997
        object.__setattr__(self, '_bot', sopel)
998
        object.__setattr__(self, '_trigger', trigger)
999
1000
    def __dir__(self):
1001
        classattrs = [attr for attr in self.__class__.__dict__
1002
                      if not attr.startswith('__')]
1003
        return list(self.__dict__) + classattrs + dir(self._bot)
1004
1005
    def __getattr__(self, attr):
1006
        return getattr(self._bot, attr)
1007
1008
    def __setattr__(self, attr, value):
1009
        return setattr(self._bot, attr, value)
1010
1011
    def say(self, message, destination=None, max_messages=1):
1012
        """Override ``Sopel.say`` to send message to sender
1013
1014
        :param str message: message to say
1015
        :param str destination: channel or person; defaults to trigger's sender
1016
        :param int max_messages: max number of message splits
1017
1018
        .. seealso::
1019
1020
            :meth:`sopel.bot.Sopel.say`
1021
        """
1022
        if destination is None:
1023
            destination = self._trigger.sender
1024
        self._bot.say(message, destination, max_messages)
1025
1026
    def action(self, message, destination=None):
1027
        """Override ``Sopel.action`` to send action to sender
1028
1029
        :param str message: action message
1030
        :param str destination: channel or person; defaults to trigger's sender
1031
1032
        .. seealso::
1033
1034
            :meth:`sopel.bot.Sopel.action`
1035
        """
1036
        if destination is None:
1037
            destination = self._trigger.sender
1038
        self._bot.action(message, destination)
1039
1040
    def notice(self, message, destination=None):
1041
        """Override ``Sopel.notice`` to send a notice to sender
1042
1043
        :param str message: notice message
1044
        :param str destination: channel or person; defaults to trigger's sender
1045
1046
        .. seealso::
1047
1048
            :meth:`sopel.bot.Sopel.notice`
1049
        """
1050
        if destination is None:
1051
            destination = self._trigger.sender
1052
        self._bot.notice(message, destination)
1053
1054
    def reply(self, message, destination=None, reply_to=None, notice=False):
1055
        """Override ``Sopel.reply`` to reply to someone
1056
1057
        :param str message: reply message
1058
        :param str destination: channel or person; defaults to trigger's sender
1059
        :param str reply_to: person to reply to; defaults to trigger's nick
1060
        :param bool notice: reply as an IRC notice or with a simple message
1061
1062
        .. seealso::
1063
1064
            :meth:`sopel.bot.Sopel.reply`
1065
        """
1066
        if destination is None:
1067
            destination = self._trigger.sender
1068
        if reply_to is None:
1069
            reply_to = self._trigger.nick
1070
        self._bot.reply(message, destination, reply_to, notice)
1071
1072
    def kick(self, nick, channel=None, message=None):
1073
        if channel is None:
1074
            if self._trigger.is_privmsg:
1075
                raise RuntimeError('Error: KICK requires a channel.')
1076
            else:
1077
                channel = self._trigger.sender
1078
        if nick is None:
1079
            raise RuntimeError('Error: KICK requires a nick.')
1080
        self._bot.kick(nick, channel, message)
1081