Poll: Initial commit with basic features.

This commit is contained in:
Valentin Lorentz 2021-06-19 16:39:23 +02:00
parent 3b25a94b46
commit d919e2133d
9 changed files with 523 additions and 4 deletions

View File

@ -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):

58
plugins/Poll/README.rst Normal file
View File

@ -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 [<channel>] <question> <answer1> [<answer2> [<answer3> [...]]]
Creates a new poll with the specified <question> and answers on the <channel>. The first word of each answer is used as its id to vote, so each answer should start with a different word. <channel> is only necessary if this command is run in private, and defaults to the current channel otherwise.
.. _command-poll-close:
close [<channel>] <poll_id>
Closes the specified poll.
.. _command-poll-results:
results [<channel>] <poll_id>
Returns the results of the specified poll.
.. _command-poll-vote:
vote [<channel>] <poll_id> <answer_id>
Registers your vote on the poll <poll_id> as being the answer identified by <answer_id> (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.

72
plugins/Poll/__init__.py Normal file
View File

@ -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:

72
plugins/Poll/config.py Normal file
View File

@ -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.
"""
),
),
)

View File

@ -0,0 +1 @@
# Stub so local is a module, used for third-party modules

191
plugins/Poll/plugin.py Normal file
View File

@ -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):
"""[<channel>] <question> <answer1> [<answer2> [<answer3> [...]]]
Creates a new poll with the specified <question> and answers
on the <channel>.
The first word of each answer is used as its id to vote,
so each answer should start with a different word.
<channel> 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):
"""[<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):
"""[<channel>] <poll_id> <answer_id>
Registers your vote on the poll <poll_id> as being the answer
identified by <answer_id> (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):
"""[<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_

124
plugins/Poll/test.py Normal file
View File

@ -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",
)

View File

@ -1,4 +1,4 @@
[tool.black]
line-length = 79
include = 'plugins/(Autocomplete|Fediverse)/.*\.pyi?$'
include = 'plugins/(Autocomplete|Fediverse|Poll)/.*\.pyi?$'

View File

@ -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 = []