comparison sat/plugins/plugin_misc_text_commands.py @ 3216:8418e0c83ed7

plugin text commands: handles coroutines + better command parsing: - async coroutine can now be used for text commands (in addition to Deferred and regular callbacks) - isidentifier is now used to detect a command, allowing to use "_" in command names - if message starts with a single `/`, it is never sent (previously it could be sent is some corner cases) - don't use OrderedDict anymore as dict now keep insertion order by default
author Goffi <goffi@goffi.org>
date Fri, 13 Mar 2020 17:50:08 +0100
parents 0318802dfe28
children be6d91572633
comparison
equal deleted inserted replaced
3215:bfa1bde97f48 3216:8418e0c83ed7
15 # GNU Affero General Public License for more details. 15 # GNU Affero General Public License for more details.
16 16
17 # You should have received a copy of the GNU Affero General Public License 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/>. 18 # along with this program. If not, see <http://www.gnu.org/licenses/>.
19 19
20 from twisted.words.protocols.jabber import jid
21 from twisted.internet import defer
22 from twisted.python import failure
20 from sat.core.i18n import _ 23 from sat.core.i18n import _
21 from sat.core.constants import Const as C 24 from sat.core.constants import Const as C
22 from sat.core import exceptions 25 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 from sat.core.log import getLogger
27 from sat.tools import utils
28
26 29
27 log = getLogger(__name__) 30 log = getLogger(__name__)
28 from twisted.python import failure
29 from collections import OrderedDict
30 31
31 PLUGIN_INFO = { 32 PLUGIN_INFO = {
32 C.PI_NAME: "Text commands", 33 C.PI_NAME: "Text commands",
33 C.PI_IMPORT_NAME: C.TEXT_CMDS, 34 C.PI_IMPORT_NAME: C.TEXT_CMDS,
34 C.PI_TYPE: "Misc", 35 C.PI_TYPE: "Misc",
79 - @command keyword can be used, 80 - @command keyword can be used,
80 see http://wiki.goffi.org/wiki/Coding_style/en for documentation 81 see http://wiki.goffi.org/wiki/Coding_style/en for documentation
81 @return (dict): dictionary with parsed data where key can be: 82 @return (dict): dictionary with parsed data where key can be:
82 - "doc_short_help" (default: ""): the untranslated short documentation 83 - "doc_short_help" (default: ""): the untranslated short documentation
83 - "type" (default "all"): the command type as specified in documentation 84 - "type" (default "all"): the command type as specified in documentation
84 - "args" (default: ""): the arguments available, using syntax specified in documentation. 85 - "args" (default: ""): the arguments available, using syntax specified in
86 documentation.
85 - "doc_arg_[name]": the doc of [name] argument 87 - "doc_arg_[name]": the doc of [name] argument
86 """ 88 """
87 data = OrderedDict([("doc_short_help", ""), ("type", "all"), ("args", "")]) 89 data = {
90 "doc_short_help": "",
91 "type": "all",
92 "args": "",
93 }
88 docstring = cmd.__doc__ 94 docstring = cmd.__doc__
89 if docstring is None: 95 if docstring is None:
90 log.warning("No docstring found for command {}".format(cmd_name)) 96 log.warning("No docstring found for command {}".format(cmd_name))
91 docstring = "" 97 docstring = ""
92 98
167 _( 173 _(
168 "Conflict for command [{old_name}], renaming it to [{new_name}]" 174 "Conflict for command [{old_name}], renaming it to [{new_name}]"
169 ).format(old_name=cmd_name, new_name=new_name) 175 ).format(old_name=cmd_name, new_name=new_name)
170 ) 176 )
171 cmd_name = new_name 177 cmd_name = new_name
172 self._commands[cmd_name] = cmd_data = OrderedDict( 178 self._commands[cmd_name] = cmd_data = {"callback": cmd}
173 {"callback": cmd}
174 ) # We use an Ordered dict to keep documenation order
175 cmd_data.update(self._parseDocString(cmd, cmd_name)) 179 cmd_data.update(self._parseDocString(cmd, cmd_name))
176 log.info(_("Registered text command [%s]") % cmd_name) 180 log.info(_("Registered text command [%s]") % cmd_name)
177 181
178 def addWhoIsCb(self, callback, priority=0): 182 def addWhoIsCb(self, callback, priority=0):
179 """Add a callback which give information to the /whois command 183 """Add a callback which give information to the /whois command
231 except IndexError: 235 except IndexError:
232 return mess_data 236 return mess_data
233 237
234 # we have a command 238 # we have a command
235 d = None 239 d = None
236 command = msg[1:].partition(" ")[0].lower() 240 command = msg[1:].partition(" ")[0].lower().strip()
237 if command.isalpha(): 241 if not command.isidentifier():
238 # looks like an actual command, we try to call the corresponding method 242 self.feedBack(
239 def retHandling(ret): 243 client,
240 """ Handle command return value: 244 _("Invalid command /%s. ") % command + self.HELP_SUGGESTION,
241 if ret is True, normally send message (possibly modified by command) 245 mess_data,
242 else, abord message sending 246 )
243 """ 247 raise failure.Failure(exceptions.CancelError())
244 if ret: 248
245 return mess_data 249 # looks like an actual command, we try to call the corresponding method
246 else: 250 def retHandling(ret):
247 log.debug("text command detected ({})".format(command)) 251 """ Handle command return value:
248 raise failure.Failure(exceptions.CancelError()) 252 if ret is True, normally send message (possibly modified by command)
249 253 else, abord message sending
250 def genericErrback(failure): 254 """
251 try: 255 if ret:
252 msg = "with condition {}".format(failure.value.condition) 256 return mess_data
253 except AttributeError: 257 else:
254 msg = "with error {}".format(failure.value) 258 log.debug("text command detected ({})".format(command))
255 self.feedBack(client, "Command failed {}".format(msg), mess_data) 259 raise failure.Failure(exceptions.CancelError())
256 return False 260
257 261 def genericErrback(failure):
258 mess_data["unparsed"] = msg[
259 1 + len(command) :
260 ] # part not yet parsed of the message
261 try: 262 try:
262 cmd_data = self._commands[command] 263 msg = "with condition {}".format(failure.value.condition)
263 except KeyError: 264 except AttributeError:
265 msg = "with error {}".format(failure.value)
266 self.feedBack(client, "Command failed {}".format(msg), mess_data)
267 return False
268
269 mess_data["unparsed"] = msg[
270 1 + len(command) :
271 ] # part not yet parsed of the message
272 try:
273 cmd_data = self._commands[command]
274 except KeyError:
275 self.feedBack(
276 client,
277 _("Unknown command /%s. ") % command + self.HELP_SUGGESTION,
278 mess_data,
279 )
280 log.debug("text command help message")
281 raise failure.Failure(exceptions.CancelError())
282 else:
283 if not self._contextValid(mess_data, cmd_data):
284 # The command is not launched in the right context, we throw a message with help instructions
285 context_txt = (
286 _("group discussions")
287 if cmd_data["type"] == "group"
288 else _("one to one discussions")
289 )
290 feedback = _("/{command} command only applies in {context}.").format(
291 command=command, context=context_txt
292 )
264 self.feedBack( 293 self.feedBack(
265 client, 294 client, "{} {}".format(feedback, self.HELP_SUGGESTION), mess_data
266 _("Unknown command /%s. ") % command + self.HELP_SUGGESTION, 295 )
267 mess_data, 296 log.debug("text command invalid message")
268 )
269 log.debug("text command help message")
270 raise failure.Failure(exceptions.CancelError()) 297 raise failure.Failure(exceptions.CancelError())
271 else: 298 else:
272 if not self._contextValid(mess_data, cmd_data): 299 d = utils.asDeferred(cmd_data["callback"], client, mess_data)
273 # The command is not launched in the right context, we throw a message with help instructions 300 d.addErrback(genericErrback)
274 context_txt = ( 301 d.addCallback(retHandling)
275 _("group discussions") 302
276 if cmd_data["type"] == "group" 303 return d
277 else _("one to one discussions")
278 )
279 feedback = _("/{command} command only applies in {context}.").format(
280 command=command, context=context_txt
281 )
282 self.feedBack(
283 client, "{} {}".format(feedback, self.HELP_SUGGESTION), mess_data
284 )
285 log.debug("text command invalid message")
286 raise failure.Failure(exceptions.CancelError())
287 else:
288 d = defer.maybeDeferred(cmd_data["callback"], client, mess_data)
289 d.addErrback(genericErrback)
290 d.addCallback(retHandling)
291
292 return (
293 d or mess_data
294 ) # if a command is detected, we should have a deferred, else we send the message normally
295 304
296 def _contextValid(self, mess_data, cmd_data): 305 def _contextValid(self, mess_data, cmd_data):
297 """Tell if a command can be used in the given context 306 """Tell if a command can be used in the given context
298 307
299 @param mess_data(dict): message data as given in sendMessage trigger 308 @param mess_data(dict): message data as given in sendMessage trigger