diff --git a/plugins/Autocomplete/plugin.py b/plugins/Autocomplete/plugin.py index 31cbcf8da..f3d11a75f 100644 --- a/plugins/Autocomplete/plugin.py +++ b/plugins/Autocomplete/plugin.py @@ -121,8 +121,9 @@ 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 + return ( + conf.supybot.protocols.irc.experimentalExtensions() + and self.registryValue("enabled", msg.channel, irc.network) ) def doTagmsg(self, irc, msg): diff --git a/plugins/Poll/README.rst b/plugins/Poll/README.rst new file mode 100644 index 000000000..fcd8d06e1 --- /dev/null +++ b/plugins/Poll/README.rst @@ -0,0 +1,58 @@ +.. _plugin-Poll: + +Documentation for the Poll plugin for Supybot +============================================= + +Purpose +------- +Poll: Provides a simple way to vote on answers to a question + +Usage +----- +Provides a simple way to vote on answers to a question + +.. _commands-Poll: + +Commands +-------- +.. _command-poll-add: + +add [] [ [ [...]]] + Creates a new poll with the specified and answers on the . The first word of each answer is used as its id to vote, so each answer should start with a different word. is only necessary if this command is run in private, and defaults to the current channel otherwise. + +.. _command-poll-close: + +close [] + Closes the specified poll. + +.. _command-poll-results: + +results [] + Returns the results of the specified poll. + +.. _command-poll-vote: + +vote [] + Registers your vote on the poll as being the answer identified by (which is the first word of each possible answer). + +.. _conf-Poll: + +Configuration +------------- + +.. _conf-supybot.plugins.Poll.public: + + +supybot.plugins.Poll.public + This config variable defaults to "True", is not network-specific, and is not channel-specific. + + Determines whether this plugin is publicly visible. + +.. _conf-supybot.plugins.Poll.requireManageCapability: + + +supybot.plugins.Poll.requireManageCapability + This config variable defaults to "channel,op; channel,halfop", is network-specific, and is channel-specific. + + Determines the capabilities required (if any) to open and close polls. Use 'channel,capab' for channel-level capabilities. Note that absence of an explicit anticapability means user has capability. + diff --git a/plugins/Poll/__init__.py b/plugins/Poll/__init__.py new file mode 100644 index 000000000..ca55dd13a --- /dev/null +++ b/plugins/Poll/__init__.py @@ -0,0 +1,72 @@ +### +# Copyright (c) 2021, 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. + +### + +""" +Poll: Provides a simple way to vote on answers to a question +""" + +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/Poll/config.py b/plugins/Poll/config.py new file mode 100644 index 000000000..f07f12041 --- /dev/null +++ b/plugins/Poll/config.py @@ -0,0 +1,72 @@ +### +# Copyright (c) 2021, 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("Poll") +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("Poll", True) + + +Poll = conf.registerPlugin("Poll") +# This is where your configuration variables (if any) should go. For example: +# conf.registerGlobalValue(Poll, 'someConfigVariableName', +# registry.Boolean(False, _("""Help for someConfigVariableName."""))) + +conf.registerChannelValue( + Poll, + "requireManageCapability", + registry.String( + "channel,op; channel,halfop", + _( + """Determines the capabilities required (if any) to open and + close polls. + Use 'channel,capab' for channel-level capabilities. + Note that absence of an explicit anticapability means user has + capability. + """ + ), + ), +) diff --git a/plugins/Poll/local/__init__.py b/plugins/Poll/local/__init__.py new file mode 100644 index 000000000..e86e97b86 --- /dev/null +++ b/plugins/Poll/local/__init__.py @@ -0,0 +1 @@ +# Stub so local is a module, used for third-party modules diff --git a/plugins/Poll/plugin.py b/plugins/Poll/plugin.py new file mode 100644 index 000000000..27a940cbb --- /dev/null +++ b/plugins/Poll/plugin.py @@ -0,0 +1,191 @@ +### +# Copyright (c) 2021, 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. + +### + +import collections +import re + +from supybot import utils, plugins, ircdb, ircutils, callbacks +from supybot.commands import * + +try: + from supybot.i18n import PluginInternationalization + + _ = PluginInternationalization("Poll") +except ImportError: + # Placeholder that allows to run the plugin on a bot + # without the i18n module + _ = lambda x: x + + +Poll = collections.namedtuple("Poll", "question answers votes open") + + +class Poll_(callbacks.Plugin): + """Provides a simple way to vote on answers to a question""" + + def __init__(self, irc): + super().__init__(irc) + + # {(network, channel): {id: Poll}} + self._polls = collections.defaultdict(dict) + + def name(self): + return "Poll" + + def _checkManageCapability(self, irc, msg, channel): + # Copy-pasted from Topic + capabilities = self.registryValue( + "requireManageCapability", channel, irc.network + ) + for capability in re.split(r"\s*;\s*", capabilities): + if capability.startswith("channel,"): + capability = ircdb.makeChannelCapability( + channel, capability[8:] + ) + if capability and ircdb.checkCapability(msg.prefix, capability): + return + irc.errorNoCapability(capabilities, Raise=True) + + def _getPoll(self, irc, channel, poll_id): + poll = self._polls[(irc.network, channel)].get(poll_id) + if poll is None: + irc.error( + _("A poll with this ID does not exist in this channel."), + Raise=True, + ) + return poll + + @wrap(["channel", "something", many("something")]) + def add(self, irc, msg, args, channel, question, answers): + """[] [ [ [...]]] + + Creates a new poll with the specified and answers + on the . + The first word of each answer is used as its id to vote, + so each answer should start with a different word. + + is only necessary if this command is run in private, + and defaults to the current channel otherwise.""" + self._checkManageCapability(irc, msg, channel) + + poll_id = max(self._polls[(irc.network, channel)], default=0) + 1 + + answers = [(answer.split()[0], answer) for answer in answers] + + answer_id_counts = collections.Counter(id_ for (id_, _) in answers).items() + duplicate_answer_ids = [ + answer_id for (answer_id, count) in answer_id_counts if count > 1 + ] + if duplicate_answer_ids: + irc.error( + format( + _("Duplicate answer identifier(s): %L"), duplicate_answer_ids + ), + Raise=True, + ) + + self._polls[(irc.network, channel)][poll_id] = Poll( + question=question, answers=dict(answers), votes={}, open=True + ) + + irc.replySuccess(_("Poll # %d created.") % poll_id) + + @wrap(["channel", "nonNegativeInt"]) + def close(self, irc, msg, args, channel, poll_id): + """[] + + Closes the specified poll.""" + self._checkManageCapability(irc, msg, channel) + + poll = self._getPoll(irc, channel, poll_id) + + if not poll.open: + irc.error(_("This poll was already closed."), Raise=True) + + poll = Poll( + question=poll.question, + answers=poll.answers, + votes=poll.votes, + open=False, + ) + self._polls[(irc.network, channel)][poll_id] = poll + irc.replySuccess() + + @wrap(["channel", "nonNegativeInt", "somethingWithoutSpaces"]) + def vote(self, irc, msg, args, channel, poll_id, answer_id): + """[] + + Registers your vote on the poll as being the answer + identified by (which is the first word of each possible + answer).""" + + poll = self._getPoll(irc, channel, poll_id) + + if not poll.open: + irc.error(_("This poll is closed."), Raise=True) + + if msg.nick in poll.votes: + irc.error(_("You already voted on this poll."), Raise=True) + + if answer_id not in poll.answers: + irc.error( + format( + _("Invalid answer ID. Valid answers are: %L"), + poll.answers, + ), + Raise=True, + ) + + poll.votes[msg.nick] = answer_id + + irc.replySuccess() + + @wrap(["channel", "nonNegativeInt"]) + def results(self, irc, msg, args, channel, poll_id): + """[] + + Returns the results of the specified poll.""" + + poll = self._getPoll(irc, channel, poll_id) + + counts = collections.Counter(poll.votes.values()) + + # Add answers with 0 votes + counts.update({answer_id: 0 for answer_id in poll.answers}) + + results = [ + format(_("%n for %s"), (v, "vote"), k) + for (k, v) in counts.most_common() + ] + + irc.replies(results) + + +Class = Poll_ diff --git a/plugins/Poll/test.py b/plugins/Poll/test.py new file mode 100644 index 000000000..a5dd12ad5 --- /dev/null +++ b/plugins/Poll/test.py @@ -0,0 +1,124 @@ +### +# Copyright (c) 2021, 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.test import * + + +class PollTestCase(ChannelPluginTestCase): + plugins = ("Poll",) + + def testBasics(self): + self.assertResponse( + 'poll add "Is this a test?" "Yes" "No" "Maybe"', + "The operation succeeded. Poll # 1 created.", + ) + + self.assertNotError("vote 1 Yes", frm="voter1!foo@bar") + self.assertNotError("vote 1 No", frm="voter2!foo@bar") + self.assertNotError("vote 1 No", frm="voter3!foo@bar") + + self.assertResponse( + "results 1", + "2 votes for No, 1 vote for Yes, and 0 votes for Maybe", + ) + + def testDoubleVoting(self): + self.assertResponse( + 'poll add "Is this a test?" "Yes" "No" "Maybe"', + "The operation succeeded. Poll # 1 created.", + ) + + self.assertNotError("vote 1 Yes", frm="voter1!foo@bar") + self.assertNotError("vote 1 No", frm="voter2!foo@bar") + self.assertResponse( + "vote 1 Yes", + "voter1: Error: You already voted on this poll.", + frm="voter1!foo@bar", + ) + + self.assertRegexp( + "results 1", + "1 vote for (Yes|No), 1 vote for (Yes|No), and 0 votes for Maybe", + ) + + def testClosed(self): + self.assertResponse( + 'poll add "Is this a test?" "Yes" "No" "Maybe"', + "The operation succeeded. Poll # 1 created.", + ) + + self.assertNotError("vote 1 Yes", frm="voter1!foo@bar") + self.assertNotError("vote 1 No", frm="voter2!foo@bar") + self.assertNotError("close 1") + self.assertResponse( + "vote 1 Yes", + "voter3: Error: This poll is closed.", + frm="voter3!foo@bar", + ) + self.assertRegexp("close 1", "already closed") + + self.assertRegexp( + "results 1", + "1 vote for (Yes|No), 1 vote for (Yes|No), and 0 votes for Maybe", + ) + + def testNonExisting(self): + self.assertResponse( + 'poll add "Is this a test?" "Yes" "No" "Maybe"', + "The operation succeeded. Poll # 1 created.", + ) + + self.assertRegexp("vote 2 Yes", "does not exist") + + def testLongAnswers(self): + self.assertResponse( + 'poll add "Is this a test?" "Yes totally" "No no no" "Maybe"', + "The operation succeeded. Poll # 1 created.", + ) + + self.assertNotError("vote 1 Yes", frm="voter1!foo@bar") + self.assertNotError("vote 1 No", frm="voter2!foo@bar") + self.assertNotError("vote 1 No", frm="voter3!foo@bar") + + self.assertResponse( + "results 1", + "2 votes for No, 1 vote for Yes, and 0 votes for Maybe", + ) + + def testDuplicateId(self): + self.assertResponse( + 'poll add "Is this a test?" "Yes" "Yes" "Maybe"', + "Error: Duplicate answer identifier(s): Yes", + ) + + self.assertResponse( + 'poll add "Is this a test?" "Yes totally" "Yes and no" "Maybe"', + "Error: Duplicate answer identifier(s): Yes", + ) diff --git a/pyproject.toml b/pyproject.toml index 599bce622..aefe4ade9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,4 +1,4 @@ [tool.black] line-length = 79 -include = 'plugins/(Autocomplete|Fediverse)/.*\.pyi?$' +include = 'plugins/(Autocomplete|Fediverse|Poll)/.*\.pyi?$' diff --git a/scripts/supybot-plugin-doc b/scripts/supybot-plugin-doc index d321402e5..320af12af 100644 --- a/scripts/supybot-plugin-doc +++ b/scripts/supybot-plugin-doc @@ -84,7 +84,7 @@ class PluginDoc(object): def __init__(self, mod, titleTemplate): self.mod = mod self.inst = self.mod.Class(None) - self.name = self.mod.Class.__name__ + self.name = self.inst.name() self.appendExtraBlankLine = False self.titleTemplate = string.Template(titleTemplate) self.lines = []