Limnoria/plugins/Poll/plugin.py

255 lines
8.3 KiB
Python

###
# 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
For example, this creates a poll::
<admin> @poll add "Is this a test?" "Yes" "No" "Maybe"
<bot> The operation succeeded. Poll # 42 created.
Creates a poll that can be voted on in this way::
<citizen1> @vote 42 Yes
<citizen2> @vote 42 No
<citizen3> @vote 42 No
And results::
<admin> @poll results
<bot> 2 votes for No, 1 vote for Yes, and 0 votes for Maybe
Longer answers are possible, and voters only need to use the first
word of each answer to vote. For example, this creates a poll that
can be voted on in the same way::
<admin> @poll add "Is this a test?" "Yes totally" "No no no" "Maybe"
<bot> The operation succeeded. Poll # 43 created.
You can also add a number or letter at the beginning of each question to
make it easier::
<admin> @poll add "Who is the best captain?" "1 James T Kirk" "2 Jean-Luc Picard" "3 Benjamin Sisko" "4 Kathryn Janeway"
<bot> The operation succeeded. Poll # 44 created.
<trekkie1> @vote 42 1
<trekkie2> @vote 42 4
<trekkie3> @vote 42 4
"""
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].casefold(), 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=ircutils.IrcDict(),
open=True,
)
irc.replySuccess(_("Poll # %d created.") % poll_id)
@wrap(["channel", "positiveInt"])
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", "positiveInt", "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)
answer_id = answer_id.casefold()
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", "positiveInt"])
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")), poll.answers[k].split()[0])
for (k, v) in counts.most_common()
]
irc.replies(results)
@wrap(["channel"])
def list(self, irc, msg, args, channel):
"""[<channel>]
Lists open polls in the <channel>."""
results = [
format(
_("%i: %s (%n)"),
poll_id,
poll.question,
(len(poll.votes), _("vote")),
)
for (poll_id, poll) in self._polls[(irc.network, channel)].items()
if poll.open
]
if results:
irc.replies(results)
else:
irc.reply(_("There are no open polls."))
Class = Poll_