Mercurial > libervia-backend
comparison sat/plugins/plugin_misc_text_commands.py @ 2562:26edcf3a30eb
core, setup: huge cleaning:
- moved directories from src and frontends/src to sat and sat_frontends, which is the recommanded naming convention
- move twisted directory to root
- removed all hacks from setup.py, and added missing dependencies, it is now clean
- use https URL for website in setup.py
- removed "Environment :: X11 Applications :: GTK", as wix is deprecated and removed
- renamed sat.sh to sat and fixed its installation
- added python_requires to specify Python version needed
- replaced glib2reactor which use deprecated code by gtk3reactor
sat can now be installed directly from virtualenv without using --system-site-packages anymore \o/
author | Goffi <goffi@goffi.org> |
---|---|
date | Mon, 02 Apr 2018 19:44:50 +0200 |
parents | src/plugins/plugin_misc_text_commands.py@0046283a285d |
children | 56f94936df1e |
comparison
equal
deleted
inserted
replaced
2561:bd30dc3ffe5a | 2562:26edcf3a30eb |
---|---|
1 #!/usr/bin/env python2 | |
2 # -*- coding: utf-8 -*- | |
3 | |
4 # SàT plugin for managing text commands | |
5 # Copyright (C) 2009-2018 Jérôme Poisson (goffi@goffi.org) | |
6 | |
7 # This program is free software: you can redistribute it and/or modify | |
8 # it under the terms of the GNU Affero General Public License as published by | |
9 # the Free Software Foundation, either version 3 of the License, or | |
10 # (at your option) any later version. | |
11 | |
12 # This program is distributed in the hope that it will be useful, | |
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of | |
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
15 # GNU Affero General Public License for more details. | |
16 | |
17 # You should have received a copy of the GNU Affero General Public License | |
18 # along with this program. If not, see <http://www.gnu.org/licenses/>. | |
19 | |
20 from sat.core.i18n import _ | |
21 from sat.core.constants import Const as C | |
22 from sat.core import exceptions | |
23 from twisted.words.protocols.jabber import jid | |
24 from twisted.internet import defer | |
25 from sat.core.log import getLogger | |
26 log = getLogger(__name__) | |
27 from twisted.python import failure | |
28 from collections import OrderedDict | |
29 | |
30 PLUGIN_INFO = { | |
31 C.PI_NAME: "Text commands", | |
32 C.PI_IMPORT_NAME: C.TEXT_CMDS, | |
33 C.PI_TYPE: "Misc", | |
34 C.PI_PROTOCOLS: ["XEP-0245"], | |
35 C.PI_DEPENDENCIES: [], | |
36 C.PI_MAIN: "TextCommands", | |
37 C.PI_HANDLER: "no", | |
38 C.PI_DESCRIPTION: _("""IRC like text commands""") | |
39 } | |
40 | |
41 | |
42 class InvalidCommandSyntax(Exception): | |
43 """Throwed while parsing @command in docstring if syntax is invalid""" | |
44 pass | |
45 | |
46 | |
47 CMD_KEY = "@command" | |
48 CMD_TYPES = ('group', 'one2one', 'all') | |
49 FEEDBACK_INFO_TYPE = "TEXT_CMD" | |
50 | |
51 | |
52 class TextCommands(object): | |
53 #FIXME: doc strings for commands have to be translatable | |
54 # plugins need a dynamic translation system (translation | |
55 # should be downloadable independently) | |
56 | |
57 HELP_SUGGESTION = _("Type '/help' to get a list of the available commands. If you didn't want to use a command, please start your message with '//' to escape the slash.") | |
58 | |
59 def __init__(self, host): | |
60 log.info(_("Text commands initialization")) | |
61 self.host = host | |
62 # this is internal command, so we set high priority | |
63 host.trigger.add("sendMessage", self.sendMessageTrigger, priority=1000000) | |
64 self._commands = {} | |
65 self._whois = [] | |
66 self.registerTextCommands(self) | |
67 | |
68 def _parseDocString(self, cmd, cmd_name): | |
69 """Parse a docstring to get text command data | |
70 | |
71 @param cmd: function or method callback for the command, | |
72 its docstring will be used for self documentation in the following way: | |
73 - first line is the command short documentation, shown with /help | |
74 - @command keyword can be used, see http://wiki.goffi.org/wiki/Coding_style/en for documentation | |
75 @return (dict): dictionary with parsed data where key can be: | |
76 - "doc_short_help" (default: ""): the untranslated short documentation | |
77 - "type" (default "all"): the command type as specified in documentation | |
78 - "args" (default: ""): the arguments available, using syntax specified in documentation. | |
79 - "doc_arg_[name]": the doc of [name] argument | |
80 """ | |
81 data = OrderedDict([('doc_short_help', ""), | |
82 ('type', 'all'), | |
83 ('args', '')]) | |
84 docstring = cmd.__doc__ | |
85 if docstring is None: | |
86 log.warning(u"Not docstring found for command {}".format(cmd_name)) | |
87 docstring = "" | |
88 | |
89 doc_data = docstring.split("\n") | |
90 data["doc_short_help"] = doc_data[0] | |
91 | |
92 try: | |
93 cmd_indent = 0 # >0 when @command is found are we are parsing it | |
94 | |
95 for line in doc_data: | |
96 stripped = line.strip() | |
97 if cmd_indent and line[cmd_indent:cmd_indent+5] == " -": | |
98 colon_idx = line.find(":") | |
99 if colon_idx == -1: | |
100 raise InvalidCommandSyntax("No colon found in argument description") | |
101 arg_name = line[cmd_indent+6:colon_idx].strip() | |
102 if not arg_name: | |
103 raise InvalidCommandSyntax("No name found in argument description") | |
104 arg_help = line[colon_idx+1:].strip() | |
105 data["doc_arg_{}".format(arg_name)] = arg_help | |
106 elif cmd_indent: | |
107 # we are parsing command and indent level is not good, it's finished | |
108 break | |
109 elif stripped.startswith(CMD_KEY): | |
110 cmd_indent = line.find(CMD_KEY) | |
111 | |
112 # type | |
113 colon_idx = stripped.find(":") | |
114 if colon_idx == -1: | |
115 raise InvalidCommandSyntax("missing colon") | |
116 type_data = stripped[len(CMD_KEY):colon_idx].strip() | |
117 if len(type_data) == 0: | |
118 type_data="(all)" | |
119 elif len(type_data) <= 2 or type_data[0] != '(' or type_data[-1] != ')': | |
120 raise InvalidCommandSyntax("Bad type data syntax") | |
121 type_ = type_data[1:-1] | |
122 if type_ not in CMD_TYPES: | |
123 raise InvalidCommandSyntax("Unknown type {}".format(type_)) | |
124 data["type"] = type_ | |
125 | |
126 #args | |
127 data["args"] = stripped[colon_idx+1:].strip() | |
128 except InvalidCommandSyntax as e: | |
129 log.warning(u"Invalid command syntax for command {command}: {message}".format(command=cmd_name, message=e.message)) | |
130 | |
131 return data | |
132 | |
133 | |
134 def registerTextCommands(self, instance): | |
135 """ Add a text command | |
136 | |
137 @param instance: instance of a class containing text commands | |
138 """ | |
139 for attr in dir(instance): | |
140 if attr.startswith('cmd_'): | |
141 cmd = getattr(instance, attr) | |
142 if not callable(cmd): | |
143 log.warning(_(u"Skipping not callable [%s] attribute") % attr) | |
144 continue | |
145 cmd_name = attr[4:] | |
146 if not cmd_name: | |
147 log.warning(_("Skipping cmd_ method")) | |
148 if cmd_name in self._commands: | |
149 suff=2 | |
150 while (cmd_name + str(suff)) in self._commands: | |
151 suff+=1 | |
152 new_name = cmd_name + str(suff) | |
153 log.warning(_(u"Conflict for command [{old_name}], renaming it to [{new_name}]").format(old_name=cmd_name, new_name=new_name)) | |
154 cmd_name = new_name | |
155 self._commands[cmd_name] = cmd_data = OrderedDict({'callback':cmd}) # We use an Ordered dict to keep documenation order | |
156 cmd_data.update(self._parseDocString(cmd, cmd_name)) | |
157 log.info(_("Registered text command [%s]") % cmd_name) | |
158 | |
159 def addWhoIsCb(self, callback, priority=0): | |
160 """Add a callback which give information to the /whois command | |
161 | |
162 @param callback: a callback which will be called with the following arguments | |
163 - whois_msg: list of information strings to display, callback need to append its own strings to it | |
164 - target_jid: full jid from whom we want information | |
165 - profile: %(doc_profile)s | |
166 @param priority: priority of the information to show (the highest priority will be displayed first) | |
167 """ | |
168 self._whois.append((priority, callback)) | |
169 self._whois.sort(key=lambda item: item[0], reverse=True) | |
170 | |
171 def sendMessageTrigger(self, client, mess_data, pre_xml_treatments, post_xml_treatments): | |
172 """Install SendMessage command hook """ | |
173 pre_xml_treatments.addCallback(self._sendMessageCmdHook, client) | |
174 return True | |
175 | |
176 def _sendMessageCmdHook(self, mess_data, client): | |
177 """ Check text commands in message, and react consequently | |
178 | |
179 msg starting with / are potential command. If a command is found, it is executed, else and help message is sent | |
180 msg starting with // are escaped: they are sent with a single / | |
181 commands can abord message sending (if they return anything evaluating to False), or continue it (if they return True), eventually after modifying the message | |
182 an "unparsed" key is added to message, containing part of the message not yet parsed | |
183 commands can be deferred or not | |
184 @param mess_data(dict): data comming from sendMessage trigger | |
185 @param profile: %(doc_profile)s | |
186 """ | |
187 try: | |
188 msg = mess_data["message"][''] | |
189 msg_lang = '' | |
190 except KeyError: | |
191 try: | |
192 # we have not default message, we try to take the first found | |
193 msg_lang, msg = mess_data["message"].iteritems().next() | |
194 except StopIteration: | |
195 log.debug(u"No message found, skipping text commands") | |
196 return mess_data | |
197 | |
198 try: | |
199 if msg[:2] == '//': | |
200 # we have a double '/', it's the escape sequence | |
201 mess_data["message"][msg_lang] = msg[1:] | |
202 return mess_data | |
203 if msg[0] != '/': | |
204 return mess_data | |
205 except IndexError: | |
206 return mess_data | |
207 | |
208 # we have a command | |
209 d = None | |
210 command = msg[1:].partition(' ')[0].lower() | |
211 if command.isalpha(): | |
212 # looks like an actual command, we try to call the corresponding method | |
213 def retHandling(ret): | |
214 """ Handle command return value: | |
215 if ret is True, normally send message (possibly modified by command) | |
216 else, abord message sending | |
217 """ | |
218 if ret: | |
219 return mess_data | |
220 else: | |
221 log.debug(u"text command detected ({})".format(command)) | |
222 raise failure.Failure(exceptions.CancelError()) | |
223 | |
224 def genericErrback(failure): | |
225 try: | |
226 msg = u"with condition {}".format(failure.value.condition) | |
227 except AttributeError: | |
228 msg = u"with error {}".format(failure.value) | |
229 self.feedBack(client, u"Command failed {}".format(msg), mess_data) | |
230 return False | |
231 | |
232 mess_data["unparsed"] = msg[1 + len(command):] # part not yet parsed of the message | |
233 try: | |
234 cmd_data = self._commands[command] | |
235 except KeyError: | |
236 self.feedBack(client, _("Unknown command /%s. ") % command + self.HELP_SUGGESTION, mess_data) | |
237 log.debug("text command help message") | |
238 raise failure.Failure(exceptions.CancelError()) | |
239 else: | |
240 if not self._contextValid(mess_data, cmd_data): | |
241 # The command is not launched in the right context, we throw a message with help instructions | |
242 context_txt = _("group discussions") if cmd_data["type"] == "group" else _("one to one discussions") | |
243 feedback = _("/{command} command only applies in {context}.").format(command=command, context=context_txt) | |
244 self.feedBack(client, u"{} {}".format(feedback, self.HELP_SUGGESTION), mess_data) | |
245 log.debug("text command invalid message") | |
246 raise failure.Failure(exceptions.CancelError()) | |
247 else: | |
248 d = defer.maybeDeferred(cmd_data["callback"], client, mess_data) | |
249 d.addErrback(genericErrback) | |
250 d.addCallback(retHandling) | |
251 | |
252 return d or mess_data # if a command is detected, we should have a deferred, else we send the message normally | |
253 | |
254 def _contextValid(self, mess_data, cmd_data): | |
255 """Tell if a command can be used in the given context | |
256 | |
257 @param mess_data(dict): message data as given in sendMessage trigger | |
258 @param cmd_data(dict): command data as returned by self._parseDocString | |
259 @return (bool): True if command can be used in this context | |
260 """ | |
261 if ((cmd_data['type'] == "group" and mess_data["type"] != "groupchat") or | |
262 (cmd_data['type'] == 'one2one' and mess_data["type"] == "groupchat")): | |
263 return False | |
264 return True | |
265 | |
266 def getRoomJID(self, arg, service_jid): | |
267 """Return a room jid with a shortcut | |
268 | |
269 @param arg: argument: can be a full room jid (e.g.: sat@chat.jabberfr.org) | |
270 or a shortcut (e.g.: sat or sat@ for sat on current service) | |
271 @param service_jid: jid of the current service (e.g.: chat.jabberfr.org) | |
272 """ | |
273 nb_arobas = arg.count('@') | |
274 if nb_arobas == 1: | |
275 if arg[-1] != '@': | |
276 return jid.JID(arg) | |
277 return jid.JID(arg + service_jid) | |
278 return jid.JID(u"%s@%s" % (arg, service_jid)) | |
279 | |
280 def feedBack(self, client, message, mess_data, info_type=FEEDBACK_INFO_TYPE): | |
281 """Give a message back to the user""" | |
282 if mess_data["type"] == 'groupchat': | |
283 to_ = mess_data["to"].userhostJID() | |
284 else: | |
285 to_ = client.jid | |
286 | |
287 # we need to invert send message back, so sender need to original destinee | |
288 mess_data["from"] = mess_data["to"] | |
289 mess_data["to"] = to_ | |
290 mess_data["type"] = C.MESS_TYPE_INFO | |
291 mess_data["message"] = {'': message} | |
292 mess_data["extra"]["info_type"] = info_type | |
293 client.messageSendToBridge(mess_data) | |
294 | |
295 def cmd_whois(self, client, mess_data): | |
296 """show informations on entity | |
297 | |
298 @command: [JID|ROOM_NICK] | |
299 - JID: entity to request | |
300 - ROOM_NICK: nick of the room to request | |
301 """ | |
302 log.debug("Catched whois command") | |
303 | |
304 entity = mess_data["unparsed"].strip() | |
305 | |
306 if mess_data['type'] == "groupchat": | |
307 room = mess_data["to"].userhostJID() | |
308 try: | |
309 if self.host.plugins["XEP-0045"].isNickInRoom(client, room, entity): | |
310 entity = u"%s/%s" % (room, entity) | |
311 except KeyError: | |
312 log.warning("plugin XEP-0045 is not present") | |
313 | |
314 if not entity: | |
315 target_jid = mess_data["to"] | |
316 else: | |
317 try: | |
318 target_jid = jid.JID(entity) | |
319 if not target_jid.user or not target_jid.host: | |
320 raise jid.InvalidFormat | |
321 except (RuntimeError, jid.InvalidFormat, AttributeError): | |
322 self.feedBack(client, _("Invalid jid, can't whois"), mess_data) | |
323 return False | |
324 | |
325 if not target_jid.resource: | |
326 target_jid.resource = self.host.memory.getMainResource(client, target_jid) | |
327 | |
328 whois_msg = [_(u"whois for %(jid)s") % {'jid': target_jid}] | |
329 | |
330 d = defer.succeed(None) | |
331 for ignore, callback in self._whois: | |
332 d.addCallback(lambda ignore: callback(client, whois_msg, mess_data, target_jid)) | |
333 | |
334 def feedBack(ignore): | |
335 self.feedBack(client, u"\n".join(whois_msg), mess_data) | |
336 return False | |
337 | |
338 d.addCallback(feedBack) | |
339 return d | |
340 | |
341 def _getArgsHelp(self, cmd_data): | |
342 """Return help string for args of cmd_name, according to docstring data | |
343 | |
344 @param cmd_data: command data | |
345 @return (list[unicode]): help strings | |
346 """ | |
347 strings = [] | |
348 for doc_name, doc_help in cmd_data.iteritems(): | |
349 if doc_name.startswith("doc_arg_"): | |
350 arg_name = doc_name[8:] | |
351 strings.append(u"- {name}: {doc_help}".format(name=arg_name, doc_help=_(doc_help))) | |
352 | |
353 return strings | |
354 | |
355 def cmd_me(self, client, mess_data): | |
356 """display a message at third person | |
357 | |
358 @command (all): message | |
359 - message: message to show at third person | |
360 e.g.: "/me clenches his fist" will give "[YOUR_NICK] clenches his fist" | |
361 """ | |
362 # We just ignore the command as the match is done on receiption by clients | |
363 return True | |
364 | |
365 def cmd_whoami(self, client, mess_data): | |
366 """give your own jid""" | |
367 self.feedBack(client, client.jid.full(), mess_data) | |
368 | |
369 def cmd_help(self, client, mess_data): | |
370 """show help on available commands | |
371 | |
372 @command: [cmd_name] | |
373 - cmd_name: name of the command for detailed help | |
374 """ | |
375 cmd_name = mess_data["unparsed"].strip() | |
376 if cmd_name and cmd_name[0] == "/": | |
377 cmd_name = cmd_name[1:] | |
378 if cmd_name and cmd_name not in self._commands: | |
379 self.feedBack(client, _(u"Invalid command name [{}]\n".format(cmd_name)), mess_data) | |
380 cmd_name = "" | |
381 if not cmd_name: | |
382 # we show the global help | |
383 longuest = max([len(command) for command in self._commands]) | |
384 help_cmds = [] | |
385 | |
386 for command in sorted(self._commands): | |
387 cmd_data = self._commands[command] | |
388 if not self._contextValid(mess_data, cmd_data): | |
389 continue | |
390 spaces = (longuest - len(command)) * ' ' | |
391 help_cmds.append(" /{command}: {spaces} {short_help}".format( | |
392 command=command, | |
393 spaces=spaces, | |
394 short_help=cmd_data["doc_short_help"] | |
395 )) | |
396 | |
397 help_mess = _(u"Text commands available:\n%s") % (u'\n'.join(help_cmds), ) | |
398 else: | |
399 # we show detailled help for a command | |
400 cmd_data = self._commands[cmd_name] | |
401 syntax = cmd_data["args"] | |
402 help_mess = _(u"/{name}: {short_help}\n{syntax}{args_help}").format( | |
403 name=cmd_name, | |
404 short_help=cmd_data['doc_short_help'], | |
405 syntax=_(" "*4+"syntax: {}\n").format(syntax) if syntax else "", | |
406 args_help=u'\n'.join([u" "*8+"{}".format(line) for line in self._getArgsHelp(cmd_data)])) | |
407 | |
408 self.feedBack(client, help_mess, mess_data) |