core.py 29.5 KB
Newer Older
Guillaume BINET's avatar
Guillaume BINET committed
1 2 3 4 5 6 7 8 9 10 11 12 13
#    This program is free software; you can redistribute it and/or modify
#    it under the terms of the GNU General Public License as published by
#    the Free Software Foundation; either version 3 of the License, or
#    (at your option) any later version.
#
#    This program is distributed in the hope that it will be useful,
#    but WITHOUT ANY WARRANTY; without even the implied warranty of
#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#    GNU General Public License for more details.
#
#    You should have received a copy of the GNU General Public License
#    along with this program; if not, write to the Free Software
#    Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
14
import difflib
15
import inspect
Guillaume BINET's avatar
Guillaume BINET committed
16
import logging
17
import re
18
import traceback
19 20 21
from datetime import datetime
from threading import RLock

22
import collections
23
from multiprocessing.pool import ThreadPool
24

25 26
from errbot import CommandError
from errbot.flow import FlowExecutor, FlowRoot
Guillaume Binet's avatar
Guillaume Binet committed
27
from .backends.base import Backend, Room, Identifier, Message
28
from .storage import StoreMixin
29
from .streaming import Tee
30
from .templating import tenv
31
from .utils import split_string_after
Nick Groenen's avatar
Nick Groenen committed
32

33 34
log = logging.getLogger(__name__)

Guillaume Binet's avatar
Guillaume Binet committed
35

36
# noinspection PyAbstractClass
37
class ErrBot(Backend, StoreMixin):
38
    """ ErrBot is the layer taking care of commands management and dispatching.
39
    """
40
    __errdoc__ = """ Commands related to the bot administration """
41
    MSG_ERROR_OCCURRED = 'Computer says nooo. See logs for details'
Guillaume BINET's avatar
Guillaume BINET committed
42
    MSG_UNKNOWN_COMMAND = 'Unknown command: "%(command)s". '
43
    startup_time = datetime.now()
44

45
    def __init__(self, bot_config):
46
        log.debug("ErrBot init.")
47
        super().__init__(bot_config)
48 49
        self.bot_config = bot_config
        self.prefix = bot_config.BOT_PREFIX
50
        if bot_config.BOT_ASYNC:
51 52
            self.thread_pool = ThreadPool(bot_config.BOT_ASYNC_POOLSIZE)
            log.debug('created a thread pool of size %d.', bot_config.BOT_ASYNC_POOLSIZE)
53 54
        self.commands = {}  # the dynamically populated list of commands available on the bot
        self.re_commands = {}  # the dynamically populated list of regex-based commands available on the bot
55
        self.command_filters = []  # the dynamically populated list of filters
56 57 58 59 60 61
        self.MSG_UNKNOWN_COMMAND = 'Unknown command: "%(command)s". ' \
                                   'Type "' + bot_config.BOT_PREFIX + 'help" for available commands.'
        if bot_config.BOT_ALT_PREFIX_CASEINSENSITIVE:
            self.bot_alt_prefixes = tuple(prefix.lower() for prefix in bot_config.BOT_ALT_PREFIXES)
        else:
            self.bot_alt_prefixes = bot_config.BOT_ALT_PREFIXES
62
        self.repo_manager = None
63 64
        self.plugin_manager = None
        self.storage_plugin = None
65
        self._plugin_errors_during_startup = None
66
        self.flow_executor = FlowExecutor(self)
67
        self._gbl = RLock()  # this protects internal structures of this class
68

69 70 71
    def attach_repo_manager(self, repo_manager):
        self.repo_manager = repo_manager

72 73 74 75 76 77
    def attach_plugin_manager(self, plugin_manager):
        self.plugin_manager = plugin_manager

    def attach_storage_plugin(self, storage_plugin):
        # the storage_plugin is needed by the plugins
        self.storage_plugin = storage_plugin
78

79 80 81 82 83 84 85
    def initialize_backend_storage(self):
        """
        Initialize storage for the backend to use.
        """
        log.debug("Initializing backend storage")
        assert self.plugin_manager is not None
        assert self.storage_plugin is not None
86
        self.open_storage(self.storage_plugin, f'{self.mode}_backend')
87

88 89 90
    @property
    def all_commands(self):
        """Return both commands and re_commands together."""
91 92 93
        with self._gbl:
            newd = dict(**self.commands)
            newd.update(self.re_commands)
94 95
        return newd

96
    def _dispatch_to_plugins(self, method, *args, **kwargs):
97 98 99 100 101 102 103 104 105
        """
        Dispatch the given method to all active plugins.

        Will catch and log any exceptions that occur.

        :param method: The name of the function to dispatch.
        :param *args: Passed to the callback function.
        :param **kwargs: Passed to the callback function.
        """
106
        for plugin in self.plugin_manager.get_all_active_plugins():
107
            plugin_name = plugin.name
108
            log.debug('Triggering %s on %s.', method, plugin_name)
109 110 111
            # noinspection PyBroadException
            try:
                getattr(plugin, method)(*args, **kwargs)
112
            except Exception:
113
                log.exception('%s on %s crashed.', method, plugin_name)
114

115
    def send(self, identifier, text, in_reply_to=None, groupchat_nick_reply=False):
116
        """ Sends a simple message to the specified user.
117

118
            :param identifier:
119 120 121 122 123 124 125 126
                an identifier from build_identifier or from an incoming message
            :param in_reply_to:
                the original message the bot is answering from
            :param text:
                the markdown text you want to send
            :param groupchat_nick_reply:
                authorized the prefixing with the nick form the user
        """
127
        # protect a little bit the backends here
128 129
        if not isinstance(identifier, Identifier):
            raise ValueError("identifier should be an Identifier")
130

131 132 133
        msg = self.build_message(text)
        msg.to = identifier
        msg.frm = in_reply_to.to if in_reply_to else self.bot_identifier
134
        msg.parent = in_reply_to
135 136

        nick_reply = self.bot_config.GROUPCHAT_NICK_PREFIXED
137
        if isinstance(identifier, Room) and in_reply_to and (nick_reply or groupchat_nick_reply):
138
            self.prefix_groupchat_reply(msg, in_reply_to.frm)
139

140
        self.split_and_send_message(msg)
141

142
    def send_templated(self, identifier, template_name, template_parameters, in_reply_to=None,
143 144
                       groupchat_nick_reply=False):
        """ Sends a simple message to the specified user using a template.
145

146 147
            :param template_parameters: the parameters for the template.
            :param template_name: the template name you want to use.
148 149
            :param identifier:
                an identifier from build_identifier or from an incoming message, a room etc.
150 151 152 153 154 155
            :param in_reply_to:
                the original message the bot is answering from
            :param groupchat_nick_reply:
                authorized the prefixing with the nick form the user
        """
        text = self.process_template(template_name, template_parameters)
156
        return self.send(identifier, text, in_reply_to, groupchat_nick_reply)
157

158 159 160
    def split_and_send_message(self, msg):
        for part in split_string_after(msg.body, self.bot_config.MESSAGE_SIZE_LIMIT):
            partial_message = msg.clone()
161
            partial_message.body = part
162
            partial_message.partial = True
163 164
            self.send_message(partial_message)

165
    def send_message(self, msg):
166 167
        """
        This needs to be overridden by the backends with a super() call.
168

169
        :param msg: the message to send.
170 171
        :return: None
        """
172
        for bot in self.plugin_manager.get_all_active_plugins():
Nick Groenen's avatar
Nick Groenen committed
173
            # noinspection PyBroadException
174
            try:
175
                bot.callback_botmessage(msg)
176
            except Exception:
177
                log.exception("Crash in a callback_botmessage handler")
Guillaume BINET's avatar
Guillaume BINET committed
178

Guillaume Binet's avatar
Guillaume Binet committed
179 180 181 182 183 184 185 186 187
    def send_card(self, card):
        """
        Sends a card, this can be overriden by the backends *without* a super() call.

        :param card: the card to send.
        :return: None
        """
        self.send_templated(card.to, 'card', {'card': card})

188
    def send_simple_reply(self, msg, text, private=False, threaded=False):
189
        """Send a simple response to a given incoming message
190

191
        :param private: if True will force a response in private.
192
        :param threaded: if True and if the backend supports it, sends the response in a threaded message.
193
        :param text: the markdown text of the message.
194
        :param msg: the message you are replying to.
195
        """
196
        reply = self.build_reply(msg, text, private=private, threaded=threaded)
197
        if isinstance(reply.to, Room) and self.bot_config.GROUPCHAT_NICK_PREFIXED:
198
            self.prefix_groupchat_reply(reply, msg.frm)
199
        self.split_and_send_message(reply)
200

201
    def process_message(self, msg):
202 203
        """Check if the given message is a command for the bot and act on it.
        It return True for triggering the callback_messages on the .callback_messages on the plugins.
204

205
        :param msg: the incoming message.
206 207
        """
        # Prepare to handle either private chats or group chats
208

209 210 211
        frm = msg.frm
        text = msg.body
        if not hasattr(msg.frm, 'person'):
212 213
            raise Exception(f'msg.frm not an Identifier as it misses the "person" property.'
                            f' Class of frm : {msg.frm.__class__}.')
214

215
        username = msg.frm.person
216 217
        user_cmd_history = self.cmd_history[username]

218
        if msg.delayed:
219
            log.debug('Message from history, ignore it.')
220 221
            return False

222
        if self.is_from_self(msg):
223
            log.debug("Ignoring message from self.")
224 225
            return False

226 227 228
        log.debug('*** frm = %s', frm)
        log.debug('*** username = %s', username)
        log.debug('*** text = %s', text)
229

230
        suppress_cmd_not_found = self.bot_config.SUPPRESS_CMD_NOT_FOUND
231 232 233 234 235 236 237 238 239 240 241 242

        prefixed = False  # Keeps track whether text was prefixed with a bot prefix
        only_check_re_command = False  # Becomes true if text is determed to not be a regular command
        tomatch = text.lower() if self.bot_config.BOT_ALT_PREFIX_CASEINSENSITIVE else text
        if len(self.bot_config.BOT_ALT_PREFIXES) > 0 and tomatch.startswith(self.bot_alt_prefixes):
            # Yay! We were called by one of our alternate prefixes. Now we just have to find out
            # which one... (And find the longest matching, in case you have 'err' and 'errbot' and
            # someone uses 'errbot', which also matches 'err' but would leave 'bot' to be taken as
            # part of the called command in that case)
            prefixed = True
            longest = 0
            for prefix in self.bot_alt_prefixes:
243 244 245
                length = len(prefix)
                if tomatch.startswith(prefix) and length > longest:
                    longest = length
246
            log.debug('Called with alternate prefix "%s"', text[:longest])
247 248 249 250 251 252
            text = text[longest:]

            # Now also remove the separator from the text
            for sep in self.bot_config.BOT_ALT_PREFIX_SEPARATORS:
                # While unlikely, one may have separators consisting of
                # more than one character
253 254 255
                length = len(sep)
                if text[:length] == sep:
                    text = text[length:]
256
        elif msg.is_direct and self.bot_config.BOT_PREFIX_OPTIONAL_ON_CHAT:
257
            log.debug('Assuming "%s" to be a command because BOT_PREFIX_OPTIONAL_ON_CHAT is True', text)
258 259 260
            # In order to keep noise down we surpress messages about the command
            # not being found, because it's possible a plugin will trigger on what
            # was said with trigger_message.
261
            suppress_cmd_not_found = True
262 263 264 265 266 267 268 269 270 271 272 273
        elif not text.startswith(self.bot_config.BOT_PREFIX):
            only_check_re_command = True
        if text.startswith(self.bot_config.BOT_PREFIX):
            text = text[len(self.bot_config.BOT_PREFIX):]
            prefixed = True

        text = text.strip()
        text_split = text.split(' ')
        cmd = None
        command = None
        args = ''
        if not only_check_re_command:
274 275 276
            i = len(text_split)
            while cmd is None:
                command = '_'.join(text_split[:i])
277

278 279 280
                with self._gbl:
                    if command in self.commands:
                        cmd = command
281 282 283 284 285
                        args = ' '.join(text_split[i:])
                    else:
                        i -= 1
                if i == 0:
                    break
286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301

            if command == self.bot_config.BOT_PREFIX:  # we did "!!" so recall the last command
                if len(user_cmd_history):
                    cmd, args = user_cmd_history[-1]
                else:
                    return False  # no command in history
            elif command.isdigit():  # we did "!#" so we recall the specified command
                index = int(command)
                if len(user_cmd_history) >= index:
                    cmd, args = user_cmd_history[-index]
                else:
                    return False  # no command in history

        # Try to match one of the regex commands if the regular commands produced no match
        matched_on_re_command = False
        if not cmd:
302
            with self._gbl:
303
                if prefixed or (msg.is_direct and self.bot_config.BOT_PREFIX_OPTIONAL_ON_CHAT):
304 305 306 307
                    commands = dict(self.re_commands)
                else:
                    commands = {k: self.re_commands[k] for k in self.re_commands
                                if not self.re_commands[k]._err_command_prefix_required}
308 309 310 311 312 313 314

            for name, func in commands.items():
                if func._err_command_matchall:
                    match = list(func._err_command_re_pattern.finditer(text))
                else:
                    match = func._err_command_re_pattern.search(text)
                if match:
Guillaume Binet's avatar
Guillaume Binet committed
315 316
                    log.debug('Matching "%s" against "%s" produced a match.', text,
                              func._err_command_re_pattern.pattern)
317
                    matched_on_re_command = True
318
                    self._process_command(msg, name, text, match)
319
                else:
320 321
                    log.debug('Matching "%s" against "%s" produced no match.',
                              text, func._err_command_re_pattern.pattern)
322 323 324 325
        if matched_on_re_command:
            return True

        if cmd:
326
            self._process_command(msg, cmd, args, match=None)
327 328
        elif not only_check_re_command:
            log.debug("Command not found")
329 330
            for cmd_filter in self.command_filters:
                if getattr(cmd_filter, 'catch_unprocessed', False):
331
                    try:
332
                        reply = cmd_filter(msg, cmd, args, False, emptycmd=True)
333
                        if reply:
334
                            self.send_simple_reply(msg, reply)
335 336 337
                        # continue processing the other unprocessed cmd filters.
                    except Exception:
                        log.exception("Exception in a command filter command.")
338 339
        return True

340 341 342 343 344 345 346 347 348 349 350
    def _process_command_filters(self, msg, cmd, args, dry_run=False):
        try:
            for cmd_filter in self.command_filters:
                msg, cmd, args = cmd_filter(msg, cmd, args, dry_run)
                if msg is None:
                    return None, None, None
            return msg, cmd, args
        except Exception:
            log.exception("Exception in a filter command, blocking the command in doubt")
            return None, None, None

351
    def _process_command(self, msg, cmd, args, match):
352 353
        """Process and execute a bot command"""

354
        # first it must go through the command filters
355 356
        msg, cmd, args = self._process_command_filters(msg, cmd, args, False)
        if msg is None:
357
            log.info('Command %s blocked or deferred.', cmd)
358 359
            return

360
        frm = msg.frm
361 362 363
        username = frm.person
        user_cmd_history = self.cmd_history[username]

364
        log.info(f'Processing command "{cmd}" with parameters "{args}" from {frm}')
365 366 367 368

        if (cmd, args) in user_cmd_history:
            user_cmd_history.remove((cmd, args))  # Avoids duplicate history items

369 370
        with self._gbl:
            f = self.re_commands[cmd] if match else self.commands[cmd]
371 372 373 374

        if f._err_command_admin_only and self.bot_config.BOT_ASYNC:
            # If it is an admin command, wait until the queue is completely depleted so
            # we don't have strange concurrency issues on load/unload/updates etc...
375 376 377
            self.thread_pool.close()
            self.thread_pool.join()
            self.thread_pool = ThreadPool(self.bot_config.BOT_ASYNC_POOLSIZE)
378 379 380 381 382 383 384 385 386 387 388 389 390 391 392

        if f._err_command_historize:
            user_cmd_history.append((cmd, args))  # add it to the history only if it is authorized to be so

        # Don't check for None here as None can be a valid argument to str.split.
        # '' was chosen as default argument because this isn't a valid argument to str.split()
        if not match and f._err_command_split_args_with != '':
            try:
                if hasattr(f._err_command_split_args_with, "parse_args"):
                    args = f._err_command_split_args_with.parse_args(args)
                elif callable(f._err_command_split_args_with):
                    args = f._err_command_split_args_with(args)
                else:
                    args = args.split(f._err_command_split_args_with)
            except Exception as e:
393
                self.send_simple_reply(msg, f"Sorry, I couldn't parse your arguments. {e}")
394 395 396
                return

        if self.bot_config.BOT_ASYNC:
397
            result = self.thread_pool.apply_async(
398 399
                self._execute_and_send,
                [],
400
                {'cmd': cmd, 'args': args, 'match': match, 'msg': msg, 'template_name': f._err_command_template}
401 402 403 404
            )
            if f._err_command_admin_only:
                # Again, if it is an admin command, wait until the queue is completely
                # depleted so we don't have strange concurrency issues.
405
                result.wait()
406
        else:
407
            self._execute_and_send(cmd=cmd, args=args, match=match, msg=msg,
408 409
                                   template_name=f._err_command_template)

410 411 412
    @staticmethod
    def process_template(template_name, template_parameters):
        # integrated templating
413 414 415
        # The template needs to be set and the answer from the user command needs to be a mapping
        # If not just convert the answer to string.
        if template_name and isinstance(template_parameters, collections.Mapping):
416 417
            return tenv().get_template(template_name + '.md').render(**template_parameters)

418
        # Reply should be all text at this point (See https://github.com/errbotio/errbot/issues/96)
419 420
        return str(template_parameters)

421
    def _execute_and_send(self, cmd, args, match, msg, template_name=None):
422 423
        """Execute a bot command and send output back to the caller

424 425 426 427 428
        :param cmd: The command that was given to the bot (after being expanded)
        :param args: Arguments given along with cmd
        :param match: A re.MatchObject if command is coming from a regex-based command, else None
        :param msg: The message object
        :param template_name: The name of the jinja template which should be used to render
429
            the markdown output, if any
430 431

        """
432
        private = cmd in self.bot_config.DIVERT_TO_PRIVATE
433
        threaded = cmd in self.bot_config.DIVERT_TO_THREAD
434 435
        commands = self.re_commands if match else self.commands
        try:
436 437
            with self._gbl:
                method = commands[cmd]
438
            # first check if we need to reattach a flow context
439
            flow, _ = self.flow_executor.check_inflight_flow_triggered(cmd, msg.frm)
440 441
            if flow:
                log.debug("Reattach context from flow %s to the message", flow._root.name)
442
                msg.ctx = flow.ctx
443
            elif method._err_command_flow_only:
444 445 446
                # check if it is a flow_only command but we are not in a flow.
                log.debug("%s is tagged flow_only and we are not in a flow. Ignores the command.", cmd)
                return
447

448
            if inspect.isgeneratorfunction(method):
449
                replies = method(msg, match) if match else method(msg, args)
450 451
                for reply in replies:
                    if reply:
452
                        self.send_simple_reply(msg, self.process_template(template_name, reply), private, threaded)
453
            else:
454
                reply = method(msg, match) if match else method(msg, args)
455
                if reply:
456
                    self.send_simple_reply(msg, self.process_template(template_name, reply), private, threaded)
457 458

            # The command is a success, check if this has not made a flow progressed
459
            self.flow_executor.trigger(cmd, msg.frm, msg.ctx)
460 461 462 463 464

        except CommandError as command_error:
            reason = command_error.reason
            if command_error.template:
                reason = self.process_template(command_error.template, reason)
465
            self.send_simple_reply(msg, reason, private, threaded)
466

467 468
        except Exception as e:
            tb = traceback.format_exc()
469 470
            log.exception(f'An error happened while processing a message ("{msg.body}"): {tb}"')
            self.send_simple_reply(msg, self.MSG_ERROR_OCCURRED + f':\n{e}', private, threaded)
471 472 473 474 475 476

    def unknown_command(self, _, cmd, args):
        """ Override the default unknown command behavior
        """
        full_cmd = cmd + ' ' + args.split(' ')[0] if args else None
        if full_cmd:
477
            msg = f'Command "{cmd}" / "{full_cmd}" not found.'
478
        else:
479
            msg = f'Command "{cmd}" not found.'
480
        ununderscore_keys = [m.replace('_', ' ') for m in self.commands.keys()]
481 482 483 484 485
        matches = difflib.get_close_matches(cmd, ununderscore_keys)
        if full_cmd:
            matches.extend(difflib.get_close_matches(full_cmd, ununderscore_keys))
        matches = set(matches)
        if matches:
486
            alternatives = ('" or "' + self.bot_config.BOT_PREFIX).join(matches)
487
            msg += f'\n\nDid you mean "{self.bot_config.BOT_PREFIX}{alternatives}" ?'
488
        return msg
489 490

    def inject_commands_from(self, instance_to_inject):
491
        with self._gbl:
492
            plugin_name = instance_to_inject.name
493 494 495 496 497 498 499
            for name, value in inspect.getmembers(instance_to_inject, inspect.ismethod):
                if getattr(value, '_err_command', False):
                    commands = self.re_commands if getattr(value, '_err_re_command') else self.commands
                    name = getattr(value, '_err_command_name')

                    if name in commands:
                        f = commands[name]
500
                        new_name = (plugin_name + '-' + name).lower()
501 502
                        self.warn_admins(f'{plugin_name}.{name} clashes with {type(f.__self__).__name__}.{f.__name__} '
                                         f'so it has been renamed {new_name}')
503
                        name = new_name
504
                        value.__func__._err_command_name = new_name  # To keep track of the renaming.
505 506 507
                    commands[name] = value

                    if getattr(value, '_err_re_command'):
508
                        log.debug('Adding regex command : %s -> %s.', name, value.__name__)
509 510
                        self.re_commands = commands
                    else:
511
                        log.debug('Adding command : %s -> %s.', name, value.__name__)
512
                        self.commands = commands
513

514 515 516 517 518 519 520 521 522 523 524 525 526
    def inject_flows_from(self, instance_to_inject):
        classname = instance_to_inject.__class__.__name__
        for name, method in inspect.getmembers(instance_to_inject, inspect.ismethod):
            if getattr(method, '_err_flow', False):
                log.debug('Found new flow %s: %s', classname, name)
                flow = FlowRoot(name, method.__doc__)
                try:
                    method(flow)
                except Exception:
                    log.exception("Exception initializing a flow")

                self.flow_executor.add_flow(flow)

527
    def inject_command_filters_from(self, instance_to_inject):
528 529 530
        with self._gbl:
            for name, method in inspect.getmembers(instance_to_inject, inspect.ismethod):
                if getattr(method, '_err_command_filter', False):
531
                    log.debug('Adding command filter: %s', name)
532
                    self.command_filters.append(method)
533

534 535 536 537 538 539
    def remove_flows_from(self, instance_to_inject):
        for name, value in inspect.getmembers(instance_to_inject, inspect.ismethod):
            if getattr(value, '_err_flow', False):
                log.debug('Remove flow %s', name)
                # TODO(gbin)

540
    def remove_commands_from(self, instance_to_inject):
541 542 543 544 545
        with self._gbl:
            for name, value in inspect.getmembers(instance_to_inject, inspect.ismethod):
                if getattr(value, '_err_command', False):
                    name = getattr(value, '_err_command_name')
                    if getattr(value, '_err_re_command') and name in self.re_commands:
546
                        del self.re_commands[name]
547
                    elif not getattr(value, '_err_re_command') and name in self.commands:
548
                        del self.commands[name]
549

550
    def remove_command_filters_from(self, instance_to_inject):
551 552 553
        with self._gbl:
            for name, method in inspect.getmembers(instance_to_inject, inspect.ismethod):
                if getattr(method, '_err_command_filter', False):
554
                    log.debug('Removing command filter: %s', name)
555
                    self.command_filters.remove(method)
556

557 558 559 560 561 562 563
    def _admins_to_notify(self):
        """
        Creates a list of administrators to notify
        """
        admins_to_notify = self.bot_config.BOT_ADMINS_NOTIFICATIONS
        return admins_to_notify

564 565 566 567
    def warn_admins(self, warning: str) -> None:
        """
        Send a warning to the administrators of the bot.

568
        :param warning: The markdown-formatted text of the message to send.
569
        """
570
        for admin in self._admins_to_notify():
571
            self.send(self.build_identifier(admin), warning)
572

573
    def callback_message(self, msg):
574
        """Processes for commands and dispatches the message to all the plugins."""
575
        if self.process_message(msg):
576
            # Act only in the backend tells us that this message is OK to broadcast
577
            self._dispatch_to_plugins('callback_message', msg)
Guillaume BINET's avatar
Guillaume BINET committed
578

579
    def callback_mention(self, msg, people):
580
        log.debug("%s has/have been mentioned", ', '.join(str(p) for p in people))
581
        self._dispatch_to_plugins('callback_mention', msg, people)
582

583
    def callback_presence(self, pres):
584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614
        self._dispatch_to_plugins('callback_presence', pres)

    def callback_room_joined(self, room):
        """
            Triggered when the bot has joined a MUC.

            :param room:
                An instance of :class:`~errbot.backends.base.MUCRoom`
                representing the room that was joined.
        """
        self._dispatch_to_plugins('callback_room_joined', room)

    def callback_room_left(self, room):
        """
            Triggered when the bot has left a MUC.

            :param room:
                An instance of :class:`~errbot.backends.base.MUCRoom`
                representing the room that was left.
        """
        self._dispatch_to_plugins('callback_room_left', room)

    def callback_room_topic(self, room):
        """
            Triggered when the topic in a MUC changes.

            :param room:
                An instance of :class:`~errbot.backends.base.MUCRoom`
                representing the room for which the topic changed.
        """
        self._dispatch_to_plugins('callback_room_topic', room)
615

616
    def callback_stream(self, stream):
617
        log.info('Initiated an incoming transfer %s.', stream)
618
        Tee(stream, self.plugin_manager.get_all_active_plugins()).start()
619

Guillaume BINET's avatar
Guillaume BINET committed
620
    def signal_connect_to_all_plugins(self):
621
        for bot in self.plugin_manager.get_all_active_plugins():
622
            if hasattr(bot, 'callback_connect'):
Nick Groenen's avatar
Nick Groenen committed
623
                # noinspection PyBroadException
Guillaume BINET's avatar
Guillaume BINET committed
624
                try:
625
                    log.debug('Trigger callback_connect on %s.', bot.__class__.__name__)
626
                    bot.callback_connect()
627
                except Exception:
628
                    log.exception(f'callback_connect failed for {bot}.')
629

630
    def connect_callback(self):
631
        log.info('Activate internal commands')
632
        if self._plugin_errors_during_startup:
633
            errors = f'Some plugins failed to start during bot startup:\n\n{self._plugin_errors_during_startup}'
634
        else:
635
            errors = ''
636 637 638 639
        errors += self.plugin_manager.activate_non_started_plugins()
        if errors:
            self.warn_admins(errors)
        log.info(errors)
640
        log.info('Notifying connection to all the plugins...')
641
        self.signal_connect_to_all_plugins()
642
        log.info('Plugin activation done.')
643 644

    def disconnect_callback(self):
645
        log.info('Disconnect callback, deactivating all the plugins.')
646
        self.plugin_manager.deactivate_all_plugins()
Guillaume BINET's avatar
Guillaume BINET committed
647

648 649 650 651 652
    def get_doc(self, command):
        """Get command documentation
        """
        if not command.__doc__:
            return '(undocumented)'
653
        if self.prefix == '!':
654
            return command.__doc__
655
        ununderscore_keys = (m.replace('_', ' ') for m in self.all_commands.keys())
656
        pat = re.compile(fr'!({"|".join(ununderscore_keys)})')
657
        return re.sub(pat, self.prefix + '\1', command.__doc__)
658

659 660 661 662 663 664 665
    @staticmethod
    def get_plugin_class_from_method(meth):
        for cls in inspect.getmro(type(meth.__self__)):
            if meth.__name__ in cls.__dict__:
                return cls
        return None

Guillaume BINET's avatar
Guillaume BINET committed
666
    def get_command_classes(self):
667
        return (self.get_plugin_class_from_method(command)
668
                for command in self.all_commands.values())
669 670

    def shutdown(self):
671
        self.close_storage()
Guillaume Binet's avatar
Guillaume Binet committed
672
        self.plugin_manager.shutdown()
673
        self.repo_manager.shutdown()
674 675 676 677 678 679

    def prefix_groupchat_reply(self, message: Message, identifier: Identifier):
        if message.body.startswith('#'):
            # Markdown heading, insert an extra newline to ensure the
            # markdown rendering doesn't break.
            message.body = "\n" + message.body