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)