diff --git a/plugins/Autocomplete/README.md b/plugins/Autocomplete/README.md new file mode 100644 index 000000000..e8ac3ceb9 --- /dev/null +++ b/plugins/Autocomplete/README.md @@ -0,0 +1,9 @@ +Provides command completion for IRC clients that support it. + +This plugin implements an early draft of the IRCv3 autocompletion client tags. +As this is not yet a released specification, it does nothing unless +`supybot.protocols.irc.experimentalExtensions` is set to True (keep it set to +False unless you know what you are doing). + +If you are interested in this feature, please contribute to +[the discussion](https://github.com/ircv3/ircv3-specifications/pull/415>) diff --git a/plugins/Autocomplete/__init__.py b/plugins/Autocomplete/__init__.py new file mode 100644 index 000000000..1dbb0d94a --- /dev/null +++ b/plugins/Autocomplete/__init__.py @@ -0,0 +1,72 @@ +### +# Copyright (c) 2020, Valentin Lorentz +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +### + +""" +Autocomplete: Provides command autocompletion for IRC clients that support it. +""" + +import sys +import supybot +from supybot import world + +# Use this for the version of this plugin. +__version__ = "" + +# XXX Replace this with an appropriate author or supybot.Author instance. +__author__ = supybot.authors.unknown + +# This is a dictionary mapping supybot.Author instances to lists of +# contributions. +__contributors__ = {} + +# This is a url where the most recent plugin package can be downloaded. +__url__ = "" + +from . import config +from . import plugin + +if sys.version_info >= (3, 4): + from importlib import reload +else: + from imp import reload +# In case we're being reloaded. +reload(config) +reload(plugin) +# Add more reloads here if you add third-party modules and want them to be +# reloaded when this plugin is reloaded. Don't forget to import them as well! + +if world.testing: + from . import test + +Class = plugin.Class +configure = config.configure + + +# vim:set shiftwidth=4 tabstop=4 expandtab textwidth=79: diff --git a/plugins/Autocomplete/config.py b/plugins/Autocomplete/config.py new file mode 100644 index 000000000..078185c4c --- /dev/null +++ b/plugins/Autocomplete/config.py @@ -0,0 +1,70 @@ +### +# Copyright (c) 2020, Valentin Lorentz +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +### + +from supybot import conf, registry + +try: + from supybot.i18n import PluginInternationalization + + _ = PluginInternationalization("Autocomplete") +except: + # Placeholder that allows to run the plugin on a bot + # without the i18n module + _ = lambda x: x + + +def configure(advanced): + # This will be called by supybot to configure this module. advanced is + # a bool that specifies whether the user identified themself as an advanced + # user or not. You should effect your configuration by manipulating the + # registry as appropriate. + from supybot.questions import expect, anything, something, yn + + conf.registerPlugin("Autocomplete", True) + + +Autocomplete = conf.registerPlugin("Autocomplete") +# This is where your configuration variables (if any) should go. For example: +# conf.registerGlobalValue(Autocomplete, 'someConfigVariableName', +# registry.Boolean(False, _("""Help for someConfigVariableName."""))) +conf.registerChannelValue( + Autocomplete, + "enabled", + registry.Boolean( + False, + _( + """Whether the bot should reply to autocomplete + requests from clients.""" + ), + ), +) + + +# vim:set shiftwidth=4 tabstop=4 expandtab textwidth=79: diff --git a/plugins/Autocomplete/local/__init__.py b/plugins/Autocomplete/local/__init__.py new file mode 100644 index 000000000..e86e97b86 --- /dev/null +++ b/plugins/Autocomplete/local/__init__.py @@ -0,0 +1 @@ +# Stub so local is a module, used for third-party modules diff --git a/plugins/Autocomplete/plugin.py b/plugins/Autocomplete/plugin.py new file mode 100644 index 000000000..d2325c4b3 --- /dev/null +++ b/plugins/Autocomplete/plugin.py @@ -0,0 +1,144 @@ +### +# Copyright (c) 2020, Valentin Lorentz +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +### + +from supybot import conf, utils, plugins, ircutils, ircmsgs, callbacks +from supybot.commands import * + +try: + from supybot.i18n import PluginInternationalization + + _ = PluginInternationalization("Autocomplete") +except ImportError: + # Placeholder that allows to run the plugin on a bot + # without the i18n module + _ = lambda x: x + + +REQUEST_TAG = "+draft/autocomplete-request" +RESPONSE_TAG = "+draft/autocomplete" + + +def _getAutocompleteResponse(irc, msg, payload): + """Returns the value of the +draft/autocomplete tag for the given + +draft/autocomplete-request payload.""" + tokens = callbacks.tokenize(payload, channel=msg.channel, network=irc.network) + normalized_payload = " ".join(tokens) + + candidates = _getCandidates(irc, normalized_payload) + + if not candidates: + return "" + + # strip what the user already typed + assert all(candidate.startswith(normalized_payload) for candidate in candidates) + normalized_payload_length = len(normalized_payload) + response_items = [candidate[normalized_payload_length:] for candidate in candidates] + response_items.sort() + return "\t".join(response_items) + + +def _getCandidates(irc, normalized_payload): + """Returns a list of commands starting with the normalized_payload.""" + candidates = set() + for cb in irc.callbacks: + cb_commands = cb.listCommands() + + # copy them with the plugin name (optional when calling a command) + # at the beginning + plugin_name = cb.canonicalName() + cb_commands += [plugin_name + " " + command for command in cb_commands] + + candidates |= { + command for command in cb_commands if command.startswith(normalized_payload) + } + + return candidates + + +class Autocomplete(callbacks.Plugin): + """Provides command completion for IRC clients that support it.""" + + def _enabled(self, irc, msg): + return conf.supybot.protocols.irc.experimentalExtensions() and self.registryValue( + "enabled", msg.channel, irc.network + ) + + def doTagmsg(self, irc, msg): + if REQUEST_TAG not in msg.server_tags: + return + if not "msgid" in msg.server_tags: + return + if not self._enabled(irc, msg): + return + + msgid = msg.server_tags["msgid"] + + text = msg.server_tags[REQUEST_TAG] + + # using callbacks._addressed instead of callbacks.addressed, as + # callbacks.addressed would tag the m + payload = callbacks._addressed(irc, msg, payload=text) + + if not payload: + # not addressed + return + + # marks used by '_addressed' are usually prefixes (char, string, + # nick), but may also be suffixes (with + # supybot.reply.whenAddressedBy.nick.atEnd); but there is no way to + # have it in the middle of the message AFAIK. + assert payload in text + + if not text.endswith(payload): + # If there is a suffix, it means the end of the text is used to + # address the bot, so it can't be a method to be completed. + return + + autocomplete_response = _getAutocompleteResponse(irc, msg, payload) + if not autocomplete_response: + return + + target = msg.channel or ircutils.nickFromHostmask(msg.prefix) + irc.queueMsg( + ircmsgs.IrcMsg( + server_tags={ + "+draft/reply": msgid, + "+draft/autocomplete": autocomplete_response, + }, + command="TAGMSG", + args=[target], + ) + ) + + +Class = Autocomplete + + +# vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79: diff --git a/plugins/Autocomplete/test.py b/plugins/Autocomplete/test.py new file mode 100644 index 000000000..0b3d29ca9 --- /dev/null +++ b/plugins/Autocomplete/test.py @@ -0,0 +1,127 @@ +### +# Copyright (c) 2020, Valentin Lorentz +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +### + +from supybot import conf, ircmsgs +from supybot.test import * + + +class AutocompleteTestCase(PluginTestCase): + plugins = ("Autocomplete", "Later", "Misc") + # Later and Misc both have a 'tell' command, this allows checking + # deduplication. + + def _sendRequest(self, request): + self.irc.feedMsg( + ircmsgs.IrcMsg( + prefix="foo!bar@baz", + server_tags={"msgid": "1234", "+draft/autocomplete-request": request}, + command="TAGMSG", + args=[self.nick], + ) + ) + + def _assertAutocompleteResponse(self, request, expectedResponse): + self._sendRequest(request) + m = self.irc.takeMsg() + self.assertEqual( + m, + ircmsgs.IrcMsg( + server_tags={ + "+draft/reply": "1234", + "+draft/autocomplete": expectedResponse, + }, + command="TAGMSG", + args=["foo"], + ), + ) + + def testResponse(self): + with conf.supybot.protocols.irc.experimentalExtensions.context(True): + with conf.supybot.plugins.Autocomplete.enabled.context(True): + self._assertAutocompleteResponse("apro", "pos") + self._assertAutocompleteResponse("apr", "opos") + self._assertAutocompleteResponse("te", "ll\tstplugin eval") + self._assertAutocompleteResponse("tel", "l") + self._assertAutocompleteResponse("misc t", "ell") + self._assertAutocompleteResponse("misc c", "learmores\tompletenick") + + def testNoResponse(self): + with conf.supybot.protocols.irc.experimentalExtensions.context(True): + self._sendRequest("apro") + self.assertIsNone(self.irc.takeMsg()) + + with conf.supybot.plugins.Autocomplete.enabled.context(True): + self._sendRequest("apro") + self.assertIsNone(self.irc.takeMsg()) + + +class AutocompleteChannelTestCase(ChannelPluginTestCase): + plugins = ("Autocomplete", "Later", "Misc") + + def _sendRequest(self, request): + self.irc.feedMsg( + ircmsgs.IrcMsg( + prefix="foo!bar@baz", + server_tags={"msgid": "1234", "+draft/autocomplete-request": request}, + command="TAGMSG", + args=[self.channel], + ) + ) + + def _assertAutocompleteResponse(self, request, expectedResponse): + self._sendRequest(request) + m = self.irc.takeMsg() + self.assertEqual( + m, + ircmsgs.IrcMsg( + server_tags={ + "+draft/reply": "1234", + "+draft/autocomplete": expectedResponse, + }, + command="TAGMSG", + args=[self.channel], + ), + ) + def testResponse(self): + with conf.supybot.protocols.irc.experimentalExtensions.context(True): + with conf.supybot.plugins.Autocomplete.enabled.context(True): + self._assertAutocompleteResponse("@apro", "pos") + + def testNoResponse(self): + with conf.supybot.protocols.irc.experimentalExtensions.context(True): + self._sendRequest("@apro") + self.assertIsNone(self.irc.takeMsg()) + + with conf.supybot.plugins.Autocomplete.enabled.context(True): + self._sendRequest("@apro") + self.assertIsNone(self.irc.takeMsg()) + + +# vim:set shiftwidth=4 tabstop=4 expandtab textwidth=79: diff --git a/src/callbacks.py b/src/callbacks.py index ddacf9a7f..a9458f1bb 100644 --- a/src/callbacks.py +++ b/src/callbacks.py @@ -51,7 +51,15 @@ _ = PluginInternationalization() def _addressed(irc, msg, prefixChars=None, nicks=None, prefixStrings=None, whenAddressedByNick=None, - whenAddressedByNickAtEnd=None): + whenAddressedByNickAtEnd=None, payload=None): + """Determines whether this message is a command to the bot (because of a + prefix char/string, or because the bot's nick is used as prefix, or because + it's a private message, etc.). + Returns the actual content of the command (ie. the content of the message, + stripped of the prefix that was used to determine if it's addressed). + + If 'payload' is not None, its value is used instead of msg.args[1] as the + content of the message.""" if isinstance(irc, str): warnings.warn( "callbacks.addressed's first argument should now be be the Irc " @@ -71,9 +79,10 @@ def _addressed(irc, msg, prefixChars=None, nicks=None, payload = payload[len(prefixString):].lstrip() return payload - assert msg.command == 'PRIVMSG' + assert msg.command in ('PRIVMSG', 'TAGMSG') target = msg.channel or msg.args[0] - payload = msg.args[1] + if not payload: + payload = msg.args[1] if not payload: return '' if prefixChars is None: