]> kaliko git repositories - sid.git/blob - sid/sid.py
Handles disconnections
[sid.git] / sid / sid.py
1 # -*- coding: utf-8 -*-
2
3 # Copyright (C) 2007-2012 Thomas Perl <thp.io/about>
4 # Copyright (C) 2010, 2011 Anaël Verrier <elghinn@free.fr>
5 # Copyright (C) 2014, 2015, 2020 kaliko <kaliko@azylum.org>
6
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU General Public License as published by
9 # the Free Software Foundation, version 3 only.
10
11 # This program is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14 # GNU General Public License for more details.
15
16 # You should have received a copy of the GNU General Public License
17 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
18
19
20 import inspect
21 import logging
22 import traceback
23
24 import slixmpp
25
26 from sid import __url__
27
28
29 def botcmd(*args, **kwargs):
30     """Decorator for bot command functions
31
32     :param bool hidden: is the command hidden in global help
33     :param str name: command name, default to decorated function name
34     """
35
36     def decorate(func, hidden=False, name=None):
37         setattr(func, '_bot_command', True)
38         setattr(func, '_bot_command_hidden', hidden)
39         setattr(func, '_bot_command_name', name or func.__name__)
40         if func.__doc__ is None:
41             func.__doc__ = ''
42         return func
43
44     if len(args):
45         return decorate(args[0], **kwargs)
46     else:
47         return lambda func: decorate(func, **kwargs)
48
49
50 class MUCBot(slixmpp.ClientXMPP):
51     """
52     :param str jid: jid to log with
53     :param str password: jid password
54     :param str room: conference room to join
55     :param str nick: Nickname to use in the room
56     """
57
58     #: Class attribute to define bot's command prefix
59     #:
60     #: Defaults to "!"
61     prefix = '!'
62
63     def __init__(self, jid, password, room, nick, log_file=None,
64                  log_level=logging.INFO):
65         super(MUCBot, self).__init__(jid, password)
66
67         # Clean sphinx autodoc for self documentation
68         # (cf. MUCBot.help)
69         self.__doc__ = None
70         self.log = logging.getLogger(__package__)
71         self.plugins = list()
72         self.commands = dict()
73         self.room = room
74         self.nick = nick
75         self.__set_logger(log_file, log_level)
76         self.__seen = dict()
77         self.register_plugin('xep_0030')  # Service Discovery
78         self.register_plugin('xep_0045')  # Multi-User Chat
79         self.register_plugin('xep_0071')  # xhtml-im
80         self.register_plugin('xep_0199')  # self Ping
81
82         # The session_start event will be triggered when
83         # the bot establishes its connection with the server
84         # and the XML streams are ready for use. We want to
85         # listen for this event so that we we can initialize
86         # our roster.
87         self.add_event_handler('session_start', self.start)
88
89         # Handles MUC message and dispatch
90         self.add_event_handler('message', self.message)
91         self.add_event_handler('got_online', self._view)
92
93         # Handles disconnection
94         self.add_event_handler('disconnected', self.disconn)
95
96         # Discover bot internal command (ie. help)
97         for name, value in inspect.getmembers(self):
98             if inspect.ismethod(value) and \
99                getattr(value, '_bot_command', False):
100                 name = getattr(value, '_bot_command_name')
101                 self.log.debug('Registered command: %s', name)
102                 self.commands[name] = value
103
104     def __set_logger(self, log_file=None, log_level=logging.INFO):
105         """Create console/file handler"""
106         log_fd = open(log_file, 'w') if log_file else None
107         chandler = logging.StreamHandler(log_fd)
108         formatter = logging.Formatter(
109             '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
110             )
111         chandler.setFormatter(formatter)
112         self.log.addHandler(chandler)
113         self.log.setLevel(log_level)
114         self.log.debug('set logger, log level : %s', log_level)
115
116     def disconn(self, event):
117         """disconnected handler"""
118         msg = ": %s" % event if event else "‽"
119         self.log.info('Disconnected from server%s', msg)
120         self.connect()
121
122     def message(self, msg):
123         """Messages handler
124
125         Parses message received to detect :py:obj:`prefix`
126         """
127         if msg['type'] not in ('groupchat', 'chat'):
128             self.log.warning('Unhandled message')
129             return
130         if msg['mucnick'] == self.nick:
131             return
132         body = msg['body'].strip()
133         if not body.startswith(MUCBot.prefix):
134             return
135         self.log.debug(msg['from'])
136         if msg['from'] not in self.__seen and msg['type'] == 'chat':
137             self.log.warning('Will not handle direct message'
138                              'from unseen jid: %s', msg['from'])
139             return
140         args = body[1:].split()
141         cmd = args.pop(0)
142         if cmd not in self.commands:
143             return
144         self.log.debug('cmd: %s', cmd)
145         if args:
146             self.log.debug('arg: %s', args)
147         try:
148             self.commands[cmd](msg, args)
149         except Exception as err:
150             reply = ''.join(traceback.format_exc())
151             self.log.exception('An error occurred processing: %s: %s', body, reply)
152             if self.log.level < 10 and reply:
153                 self.send_message(mto=msg['from'].bare, mbody=reply,
154                                   mtype='groupchat')
155
156     def _view(self, pres):
157         """Track known nick"""
158         nick = pres['from']
159         status = (pres['type'], pres['status'])
160         self.__seen.update({nick: status})
161
162     async def start(self, event):
163         """
164         Process the session_start event.
165
166         Typical actions for the session_start event are
167         requesting the roster and broadcasting an initial
168         presence stanza.
169
170         :param dict event: An empty dictionary. The session_start
171                      event does not provide any additional data.
172         """
173         await self.get_roster()
174         self.send_presence()
175         self.plugin['xep_0045'].join_muc(self.room,
176                                          self.nick,
177                                          # If a room password is needed, use:
178                                          # password=the_room_password,
179                                          wait=True)
180
181     def register_bot_plugin(self, plugin_cls):
182         """Registers plugin, takes a class, the method instanciates the plugin
183
184         :param `sid.plugin.Plugin` plugin_cls: A :py:obj:`sid.plugin.Plugin` class
185         """
186         self.plugins.append(plugin_cls(self))
187         for name, value in inspect.getmembers(self.plugins[-1]):
188             if inspect.ismethod(value) and \
189                getattr(value, '_bot_command', False):
190                 name = getattr(value, '_bot_command_name')
191                 self.log.debug('Registered command: %s', name)
192                 self.commands[name] = value
193
194     def foreach_plugin(self, method, *args, **kwds):
195         for plugin in self.plugins:
196             self.log.debug('calling %s for %s', method, plugin)
197             getattr(plugin, method)(*args, **kwds)
198
199     def shutdown_plugins(self):
200         # TODO: also use event session_end|disconnected?
201         self.log.info('shuting down')
202         for plugin in self.plugins:
203             self.log.debug('shuting down %s', plugin)
204             getattr(plugin, 'shutdown')()
205
206     @botcmd
207     def help(self, message, args):
208         """Returns a help string listing available options.
209
210         Automatically assigned to the "help" command."""
211         help_cmd = ('Type {}help <command name>'.format(self.prefix) +
212                     ' to get more info about that specific command.\n\n' +
213                     f'SRC: {__url__}')
214         if not args:
215             if self.__doc__:
216                 description = self.__doc__.strip()
217             else:
218                 description = 'Available commands:'
219
220             cmd_list = list()
221             for name, cmd in self.commands.items():
222                 if name == 'help' or cmd._bot_command_hidden:
223                     continue
224                 doc = (cmd.__doc__.strip() or 'undocumented').split('\n', 1)[0]
225                 cmd_list.append('{0}: {1}'.format(name, doc))
226
227             usage = '\n'.join(cmd_list)
228             usage = usage + '\n\n' + help_cmd
229             text = '{}\n\n{}'.format(description, usage)
230         else:
231             if args[0] in self.commands.keys():
232                 text = self.commands[args[0]].__doc__ or 'undocumented'
233                 text = inspect.cleandoc(text)
234             else:
235                 text = 'That command is not defined.'
236         if message['type'] == 'groupchat':
237             to = message['from'].bare
238         else:
239             to = message['from']
240         self.send_message(mto=to, mbody=text, mtype=message['type'])