diff --git a/cogs/config.py b/cogs/config.py index 4148555..8cef470 100644 --- a/cogs/config.py +++ b/cogs/config.py @@ -1,3 +1,5 @@ +import logging + import discord from discord.ext import commands @@ -74,4 +76,6 @@ class Config: await self.bot.say(f'Server role `{role}` not found.') def setup(bot): + global logger + logger = logging.getLogger('bot') bot.add_cog(Config(bot)) \ No newline at end of file diff --git a/cogs/db_api.py b/cogs/db_api.py new file mode 100644 index 0000000..cb9cb4b --- /dev/null +++ b/cogs/db_api.py @@ -0,0 +1,33 @@ +import dbl +import asyncio +import logging + +from essentials.settings import SETTINGS + + +class DiscordBotsOrgAPI: + """Handles interactions with the discordbots.org API""" + + def __init__(self, bot): + self.bot = bot + self.token = SETTINGS.dbl_token + self.dblpy = dbl.Client(self.bot, self.token) + self.bot.loop.create_task(self.update_stats()) + + async def update_stats(self): + """This function runs every 30 minutes to automatically update your server count""" + + while True: + logger.info('attempting to post server count') + try: + #await self.dblpy.post_server_count() + logger.info('posted server count ({})'.format(len(self.bot.servers))) + except Exception as e: + logger.exception('Failed to post server count\n{}: {}'.format(type(e).__name__, e)) + await asyncio.sleep(1800) + + +def setup(bot): + global logger + logger = logging.getLogger('bot') + bot.add_cog(DiscordBotsOrgAPI(bot)) \ No newline at end of file diff --git a/cogs/help.py b/cogs/help.py new file mode 100644 index 0000000..eca79be --- /dev/null +++ b/cogs/help.py @@ -0,0 +1,215 @@ +import logging + +import discord +from discord.ext import commands + +from essentials.multi_server import get_server_pre, ask_for_server +from essentials.settings import SETTINGS + + +class Help: + + def __init__(self, bot): + self.bot = bot + self.pages = ['šŸ ', 'šŸ†•', 'šŸ”', 'šŸ•¹', 'šŸ› ', 'šŸ’–'] + + async def embed_list_reaction_handler(self, page, pre, msg=None): + embed = self.get_help_embed(page, pre) + + if msg is None: + msg = await self.bot.say(embed=embed) + # add reactions + for emoji in self.pages: + await self.bot.add_reaction(msg, emoji) + else: + await self.bot.edit_message(msg, embed=embed) + + # wait for reactions (2 minutes) + def check(reaction, user): + return reaction.emoji if user != self.bot.user else False + + res = await self.bot.wait_for_reaction(emoji=self.pages, message=msg, timeout=180, check=check) + + # redirect on reaction + if res is None: + await self.bot.delete_message(msg) + return None + else: + await self.bot.remove_reaction(res.reaction.message, res.reaction.emoji, res.user) + return res + + def get_help_embed(self, page, pre): + + title = f' Pollmaster Help - React with an emoji to learn more about this topic!' + embed = discord.Embed(title='', description='', colour=SETTINGS.color) + embed.set_author(name=title, icon_url=SETTINGS.title_icon) + embed.set_footer(text='Use reactions to navigate the help. This message will self-destruct in 3 minutes.') + + if page == 'šŸ ': + ## POLL CREATION SHORT + embed.add_field(name='šŸ†• Making New Polls', + value='There are 3 ways to create a new poll.', inline=False) + embed.add_field(name='Commands', value=f'`{pre}quick` | `{pre}new` | `{pre}prepared`', inline=False) + embed.add_field(name='Arguments', value=f'Arguments: `` (optional)', inline=False) + embed.add_field(name='Examples', value=f'Examples: `{pre}new` | `{pre}quick What is the greenest color?`', + inline=False) + + ## POLL CONTROLS + embed.add_field(name='šŸ” Show Polls', + value='Commands to list and display polls.', inline=False) + embed.add_field(name='Command', value=f'`{pre}show`', inline=False) + embed.add_field(name='Arguments', value=f'Arguments: `open` (default) | `closed` | `prepared` | ' + f'`` (optional)', inline=False) + embed.add_field(name='Examples', value=f'Examples: `{pre}show` | `{pre}show closed` | `{pre}show mascot`', + inline=False) + + ## POLL CONTROLS + embed.add_field(name='šŸ•¹ Poll Controls', + value='You can use these commands to interact with polls.', inline=False) + embed.add_field(name='Commands', value=f'`{pre}close` | `{pre}export` | `{pre}delete` | `{pre}activate` ', + inline=False) + embed.add_field(name='Arguments', value=f'Arguments: (required)', inline=False) + embed.add_field(name='Examples', value=f'Examples: `{pre}close mascot` | `{pre}export proposal`', + inline=False) + + ## POLL CONTROLS + embed.add_field(name='šŸ›  Configuration', + value='Various Commands to personalize Pollmaster for this server.', inline=False) + embed.add_field(name='Commands', + value=f'`{pre}userrole ` | `{pre}adminrole ` | `{pre}prefix ` ', + inline=False) + + ## ABOUT + embed.add_field(name='šŸ’– About Pollmaster', + value='More infos about Pollmaster, the developer, where to go for further help and how you can support us.', + inline=False) + + elif page == 'šŸ†•': + embed.add_field(name='šŸ†• Making New Polls', + value='There are three ways to create a new poll. For all three commands you can either just ' + 'type the command or type the command followed by the question to skip that first step.' + 'Your Members need the or role to use these commands. More in šŸ›  Configuration.', + inline=False) + embed.add_field(name='šŸ”¹ **Quick Poll:** `!quick ` (optional)', + value='If you just need a quick poll, this is the way to go. All you have to specify is the ' + 'question and your answers; the rest will be set to default values.', + inline=False) + embed.add_field(name='šŸ”¹ **All Features:** `!new ` (optional)', + value='This command gives you full control over your poll. A step by step wizard will guide ' + 'you through the process and you can specify options such as Multiple Choice, ' + 'Anonymous Voting, Role Restriction, Role Weights and Deadline.', + inline=False) + embed.add_field(name='šŸ”¹ **Prepare and Schedule:** `!prepare ` (optional)', + value='Similar to `!new`, this gives you all the options. But additionally, the poll will ' + 'be set to \'inactive\'. You can specify if the poll should activate at a certain time ' + 'and/or if you would like to manually `!activate` it. ' + 'Perfect if you are preparing for a team meeting!', + inline=False) + elif page == 'šŸ”': + embed.add_field(name='šŸ” Show Polls', + value='All users can display and list polls, with the exception of prepared polls. ' + 'Voting is done simply by using the reactions below the poll.', + inline=False) + embed.add_field(name='šŸ”¹ **Show a Poll:** `!show `', + value='This command will refresh and display a poll. The votes in the message will always ' + 'be up to date and accurate. The number of reactions can be different for a number ' + 'of reasons and you can safely disregard them.', + inline=False) + embed.add_field(name='šŸ”¹ **List Polls:** `!show <> | open | closed | prepared`', + value='If you just type `!show` without an argument it will default to `!show open`.' + 'These commands will print a list of open, closed or prepared polls that exist on ' + 'the server. The first word in bold is the label of the poll and after the colon, ' + 'you can read the question. These lists are paginated and you can use the arrow ' + 'reactions to navigate larger lists.', + inline=False) + elif page == 'šŸ•¹': + embed.add_field(name='šŸ•¹ Poll Controls', + value='All these commands can only be used by an or by the author of the poll. ' + 'Go to šŸ›  Configuration for more info on the permissions.', + inline=False) + embed.add_field(name='šŸ”¹ **Close** `!close `', + value='Polls will close automatically when their deadline is reached. But you can always ' + 'close them manually by using this command. A closed poll will lock in the votes so ' + 'users can no longer change, add or remove votes. Once closed, you can export a poll.', + inline=False) + embed.add_field(name='šŸ”¹ **Delete** `!delete `', + value='This will *permanently and irreversibly* delete a poll from the database. ' + 'Once done, the label is freed up and can be assigned again.', + inline=False) + embed.add_field(name='šŸ”¹ **Export** `!export `', + value='You can use this command or react with šŸ“Ž to a closed poll to generate a report. ' + 'The report will then be sent to you in discord via the bot. This utf8-textfile ' + '(make sure to open it in an utf8-ready editor) will contain all the infos about the ' + 'poll, including a detailed list of participants and their votes (just a list of names ' + 'for anonymous polls).', + inline=False) + embed.add_field(name='šŸ”¹ **Activate** `!activate `', + value='To see how you can prepare inactive polls read the `!prepare` command under Making ' + 'New Polls. This command is used to manually activate a prepared poll.', + inline=False) + + elif page == 'šŸ› ': + embed.add_field(name='šŸ›  Configuration', + value='To run any of these commands you need the **\"Manage Server\"** permisson.', + inline=False) + embed.add_field(name='šŸ”¹ **Poll Admins** `!adminrole (optional)`', + value='This gives the rights to create polls and to control ALL polls on the server. ' + 'To see the current role for poll admin, run the command without an argument: `!adminrole`\n' + 'If you want to change the admin role to any other role, use the name of the new role ' + 'as the argument: !adminrole moderators', + inline=False) + embed.add_field(name='šŸ”¹ **Poll Users** `!userrole (optional)`', + value='Everything here is identical to the admin role, except that Poll Users can only ' + 'control the polls which were created by themselves.', + inline=False) + embed.add_field(name='šŸ”¹ **Change Prefix** `!prefix `', + value='This will change the bot prefix for your server. If you want to use a trailing ' + 'whitespace, use "\w" instead of " " (discord deletes trailing whitespaces).', + inline=False) + + elif page == 'šŸ’–': + embed.add_field(name='šŸ’– Pollmaster šŸ’–', + value='If you enjoy the bot, you can show your appreciation by giving him an upvote on Discordbots.', + inline=False) + embed.add_field(name='šŸ”¹ **Developer**', + value='Pollmaster is developed by Newti#0654', + inline=False) + embed.add_field(name='šŸ”¹ **Support**', + value='You can support Pollmaster by sending an upvote his way or by clicking the donate link ' + 'on the discordbots page:\n https://discordbots.org/bot/444514223075360800', + inline=False) + embed.add_field(name='šŸ”¹ **Support Server**', + value='If you need help with pollmaster, want to try him out or would like to give feedback ' + 'to the developer, feel free to join the support server: ', + inline=False) + embed.add_field(name='šŸ”¹ **Github**', + value='The full python source code is on my Github: https://github.com/matnad/pollmaster', + inline=False) + embed.add_field(name='**Thanks for using Pollmaster!** šŸ’—', value='Newti', inline=False) + else: + return None + + return embed + + @commands.command(pass_context=True) + async def help(self, ctx, *, topic=None): + server = await ask_for_server(self.bot, ctx.message) + pre = await get_server_pre(self.bot, server) + res = 1 + while res is not None: + if res == 1: + page = 'šŸ ' + msg = None + else: + page = res.reaction.emoji + msg = res.reaction.message + res = await self.embed_list_reaction_handler(page, pre, msg) + # print(res.user, res.reaction, res.reaction.emoji) + # cleanup + await self.bot.delete_message(ctx.message) + + +def setup(bot): + global logger + logger = logging.getLogger('bot') + bot.add_cog(Help(bot)) diff --git a/cogs/poll.py b/cogs/poll.py index 7f295b1..6d32b11 100644 --- a/cogs/poll.py +++ b/cogs/poll.py @@ -1,18 +1,23 @@ import codecs import datetime +import logging import os -import time -from math import ceil +import re from uuid import uuid4 -from string import ascii_lowercase +from string import ascii_lowercase, printable +import dateparser +import pytz from matplotlib import rcParams from matplotlib.afm import AFM from unidecode import unidecode import discord -from cogs.utils import get_pre -from utils.poll_name_generator import generate_word +from essentials.multi_server import get_pre +from essentials.exceptions import * +from essentials.settings import SETTINGS + +logger = logging.getLogger('bot') ## Helvetica is the closest font to Whitney (discord uses Whitney) in afm ## This is used to estimate text width and adjust the layout of the embeds''' @@ -23,14 +28,11 @@ with open(afm_fname, 'rb') as fh: ## A-Z Emojis for Discord AZ_EMOJIS = [(b'\\U0001f1a'.replace(b'a', bytes(hex(224 + (6 + i))[2:], "utf-8"))).decode("unicode-escape") for i in range(26)] - - class Poll: def __init__(self, bot, ctx=None, server=None, channel=None, load=False): self.bot = bot - self.color = discord.Colour(int('7289da', 16)) self.cursor_pos = 0 if not load and ctx: @@ -41,46 +43,57 @@ class Poll: channel = ctx.message.channel self.author = ctx.message.author - self.stopped = False self.server = server self.channel = channel self.name = "Quick Poll" - self.short = str(uuid4()) + self.short = str(uuid4())[0:23] self.anonymous = False self.reaction = True self.multiple_choice = False self.options_reaction = ['yes', 'no'] self.options_reaction_default = False - self.options_traditional = [] + # self.options_traditional = [] # self.options_traditional_default = False self.roles = ['@everyone'] self.weights_roles = [] self.weights_numbers = [] self.duration = 0 - self.time_created = time.time() + self.duration_tz = 'UTC' + self.time_created = datetime.datetime.utcnow().replace(tzinfo=pytz.utc) self.open = True - self.inactive = False + self.active = True + self.activation = 0 + self.activation_tz = 'UTC' self.votes = {} - def is_open(self): - if open and self.duration > 0 and self.time_created + self.duration * 60 < time.time(): + async def is_open(self, update_db=True): + if open and self.duration != 0 \ + and datetime.datetime.utcnow().replace(tzinfo=pytz.utc) > self.duration.replace(tzinfo=pytz.utc): self.open = False + if update_db: + await self.save_to_db() return self.open + async def is_active(self, update_db=True): + if not self.active and self.activation != 0 \ + and datetime.datetime.utcnow().replace(tzinfo=pytz.utc) > self.activation.replace(tzinfo=pytz.utc): + self.active = True + if update_db: + await self.save_to_db() + return self.active + async def wizard_says(self, text, footer=True): - embed = discord.Embed(title="Poll creation Wizard", description=text, - color=self.color) + embed = discord.Embed(title="Poll creation Wizard", description=text, color=SETTINGS.color) if footer: embed.set_footer(text="Type `stop` to cancel the wizard.") return await self.bot.say(embed=embed) async def wizard_says_edit(self, message, text, add=False): if add and message.embeds.__len__() > 0: text = message.embeds[0]['description'] + text - embed = discord.Embed(title="Poll creation Wizard", description=text, - color=self.color) + embed = discord.Embed(title="Poll creation Wizard", description=text, color=SETTINGS.color) embed.set_footer(text="Type `stop` to cancel the wizard.") return await self.bot.edit_message(message, embed=embed) @@ -90,439 +103,609 @@ class Poll: text = message.embeds[0]['description'] + '\n\n:exclamation: ' + error return await self.wizard_says_edit(message, text) - def check_reply(self, msg): - if msg and msg.content: - if msg.content.lower() == 'stop': - self.stopped = True - return msg - elif msg.content.__len__() > 0: - return msg - else: - return None + async def add_vaild(self, message, string): + text = '' + if message.embeds.__len__() > 0: + text = message.embeds[0]['description'] + '\n\nāœ… ' + string + return await self.wizard_says_edit(message, text) async def get_user_reply(self): - reply = await self.bot.wait_for_message(author=self.author, check=self.check_reply) - if self.stopped: - await self.wizard_says('Poll Wizard stopped.', footer=False) - if reply.content.startswith(await get_pre(self.bot, reply)): - await self.wizard_says(f'You can\'t use bot commands during the Poll Creation Wizard.\n' - f'Stopping the Wizard and then executing the command:\n`{reply.content}`', - footer=False) - self.stopped = True - return 'stop' - return reply.content + """Pre-parse user input for wizard""" + reply = await self.bot.wait_for_message(author=self.author) + if reply and reply.content: + if reply.content.startswith(await get_pre(self.bot, reply)): + await self.wizard_says(f'You can\'t use bot commands during the Poll Creation Wizard.\n' + f'Stopping the Wizard and then executing the command:\n`{reply.content}`', + footer=False) + raise StopWizard + elif reply.content.lower() == 'stop': + await self.wizard_says('Poll Wizard stopped.', footer=False) + raise StopWizard + + else: + return reply.content + else: + raise InvalidInput + + def sanitize_string(self, string): + """Sanitize user input for wizard""" + # sanitize input + if string is None: + raise InvalidInput + string = re.sub("[^{}]+".format(printable), "", string) + if set(string).issubset(set(' ')): + raise InvalidInput + return string async def set_name(self, force=None): - if self.stopped: return - if force is not None: - self.name = force + """Set the Question / Name of the Poll.""" + async def get_valid(in_reply): + if not in_reply: + raise InvalidInput + min_len = 3 + max_len = 200 + in_reply = self.sanitize_string(in_reply) + if not in_reply: + raise InvalidInput + elif min_len <= in_reply.__len__() <= max_len: + return in_reply + else: + raise InvalidInput + + try: + self.name = await get_valid(force) return - text = """I will guide you step by step through the creation of your new poll. - We can do this in a text channel or you can PM me to keep the server clean. - **How would you like your poll to be called?** - Try to be descriptive without writing more than one sentence.""" + except InputError: + pass + + text = ("I will guide you step by step through the creation of your new poll." + "We can do this in a text channel or you can PM me to keep the server clean.\n" + "**How would you like your poll to be called?**\n" + "Try to be descriptive without writing more than one sentence.") message = await self.wizard_says(text) - reply = '' - while reply.__len__() < 2 or reply.__len__() > 200: - if reply != '': - await self.add_error(message, '**Keep the name between 3 and 200 letters**') - reply = await self.get_user_reply() - if self.stopped: break - self.name = reply - return self.name + while True: + try: + reply = await self.get_user_reply() + self.name = await get_valid(reply) + await self.add_vaild(message, self.name) + break + except InvalidInput: + await self.add_error(message, '**Keep the name between 3 and 200 valid characters**') async def set_short(self, force=None): - if self.stopped: return - if force is not None: - self.short = force + """Set the label of the Poll.""" + async def get_valid(in_reply): + if not in_reply: + raise InvalidInput + min_len = 2 + max_len = 25 + in_reply = self.sanitize_string(in_reply) + if not in_reply: + raise InvalidInput + elif in_reply in ['open', 'closed', 'prepared']: + raise ReservedInput + elif await self.bot.db.polls.find_one({'server_id': str(self.server.id), 'short': in_reply}) is not None: + raise DuplicateInput + elif min_len <= in_reply.__len__() <= max_len and in_reply.split(" ").__len__() == 1: + return in_reply + else: + raise InvalidInput + + try: + self.short = await get_valid(force) return + except InputError: + pass + text = """Great. **Now type a unique one word identifier, a label, for your poll.** This label will be used to refer to the poll. Keep it short and significant.""" message = await self.wizard_says(text) - reply = '' - while reply.__len__() < 3 or reply.__len__() > 20 or reply.split(" ").__len__() != 1: - if reply != '': - await self.add_error(message, '**Only one word between 3 and 20 letters!**') - reply = await self.get_user_reply() - if self.stopped: break - if await self.bot.db.polls.find_one({'server_id': str(self.server.id), 'short': reply}) is not None: + while True: + try: + reply = await self.get_user_reply() + self.short = await get_valid(reply) + await self.add_vaild(message, self.short) + break + except InvalidInput: + await self.add_error(message, '**Only one word between 2 and 25 valid characters!**') + except ReservedInput: + await self.add_error(message, '**Can\'t use reserved words (open, closed, prepared) as label!**') + except DuplicateInput: await self.add_error(message, f'**The label `{reply}` is not unique on this server. Choose a different one!**') - reply = '' - if reply in ['open', 'closed', 'prepared']: - await self.add_error(message, '**Can\'t use reserved words (open, closed, prepared) as label!**') - reply = '' - self.short = reply - return self.short + + async def set_preparation(self, force=None): + """Set the preparation conditions for the Poll.""" + async def get_valid(in_reply): + if not in_reply: + raise InvalidInput + in_reply = self.sanitize_string(in_reply) + if not in_reply: + raise InvalidInput + elif in_reply == '0': + return 0 + + dt = dateparser.parse(in_reply) + if not isinstance(dt, datetime.datetime): + raise InvalidInput + + if dt.tzinfo is None or dt.tzinfo.utcoffset(dt) is None: + dt = dt.astimezone(pytz.utc) + + now = datetime.datetime.utcnow().astimezone(pytz.utc) + + # print(now, now.tzinfo) + # print("orig",dt , dt.tzinfo) + # dt = dt.astimezone(pytz.utc) + # print("converted", dt, dt.tzinfo) + + if dt < now: + raise DateOutOfRange(dt) + return dt + + try: + dt = await get_valid(force) + self.activation = dt + if self.activation != 0: + self.activation_tz = dt.tzinfo.tzname(dt) + self.active = False + return + except InputError: + pass + + text = ("This poll will we created inactive. You can either schedule activation at a certain date or activate " + "it manually. **Type `0` to activate it manually or tell me when you want to activate it** by " + "typing an absolute or relative date. You can specify a timezone if you want.\n" + "Examples: `in 2 days`, `next week CET`, `may 3rd 2019`, `9.11.2019 9pm EST` ") + message = await self.wizard_says(text) + + while True: + try: + reply = await self.get_user_reply() + dt = await get_valid(reply) + self.activation = dt + if self.activation == 0: + await self.add_vaild(message, 'manually activated') + else: + self.activation_tz = dt.tzinfo.tzname(dt) + await self.add_vaild(message, self.activation.strftime('%d-%b-%Y %H:%M %Z')) + self.active = False + break + except InvalidInput: + await self.add_error(message, '**I could not understand that format.**') + except TypeError: + await self.add_error(message, '**Type Error.**') + except DateOutOfRange as e: + await self.add_error(message, f'**{e.date.strftime("%d-%b-%Y %H:%M")} is in the past.**') async def set_anonymous(self, force=None): - if self.stopped: return - if force is not None: - self.anonymous = force + """Determine if poll is anonymous.""" + async def get_valid(in_reply): + if not in_reply: + raise InvalidInput + is_true = ['yes', '1'] + is_false = ['no', '0'] + in_reply = self.sanitize_string(in_reply) + if not in_reply: + raise InvalidInput + elif in_reply.lower() in is_true: + return True + elif in_reply.lower() in is_false: + return False + else: + raise InvalidInput + + try: + self.anonymous = await get_valid(force) return - text = """Next you need to decide: **Do you want your poll to be anonymous?** - - `0 - No` - `1 - Yes` - - An anonymous poll has the following effects: - :small_blue_diamond: You will never see who voted for which option - :small_blue_diamond: Once the poll is closed, you will see who participated (but not their choice)""" + except InputError: + pass + + text = ("Next you need to decide: **Do you want your poll to be anonymous?**\n" + "\n" + "`0 - No`\n" + "`1 - Yes`\n" + "\n" + "An anonymous poll has the following effects:\n" + "šŸ”¹ You will never see who voted for which option\n" + "šŸ”¹ Once the poll is closed, you will see who participated (but not their choice)") message = await self.wizard_says(text) - reply = '' - while reply not in ['yes', 'no', '1', '0']: - if reply != '': + while True: + try: + reply = await self.get_user_reply() + self.anonymous = await get_valid(reply) + await self.add_vaild(message, f'{"Yes" if self.anonymous else "No"}') + break + except InvalidInput: await self.add_error(message, '**You can only answer with `yes` | `1` or `no` | `0`!**') - reply = await self.get_user_reply() - if self.stopped: break - if isinstance(reply, str): - reply = reply.lower() - self.anonymous = reply in ['yes', '1'] - return self.anonymous - async def set_reaction(self, force=None): - ''' Currently everything is reaction, this is not needed''' - if force is not None: - self.reaction = force - return - if self.stopped: return - text = """**Do you want your users to vote by adding reactions to the poll? Type `yes` or `no`.** - Reaction voting typically has the following properties: - :small_blue_diamond: Voting is quick and painless (no typing required) - :small_blue_diamond: Multiple votes are possible - :small_blue_diamond: Not suited for a large number of options - :small_blue_diamond: Not suited for long running polls""" - message = await self.wizard_says(text) - - reply = '' - while reply not in ['yes', 'no']: - if reply != '': - await self.add_error(message, '**You can only answer with `yes` or `no`!**') - reply = await self.get_user_reply() - if self.stopped: break - if isinstance(reply, str): reply = reply.lower() - - self.reaction = reply == 'yes' - return self.reaction + # async def set_reaction(self, force=None): + # ''' Currently everything is reaction, this is not needed''' + # if force is not None and force in [True, False]: + # self.reaction = force + # return + # if self.stopped: return + # text = """**Do you want your users to vote by adding reactions to the poll? Type `yes` or `no`.** + # Reaction voting typically has the following properties: + # :small_blue_diamond: Voting is quick and painless (no typing required) + # :small_blue_diamond: Multiple votes are possible + # :small_blue_diamond: Not suited for a large number of options + # :small_blue_diamond: Not suited for long running polls""" + # message = await self.wizard_says(text) + # + # reply = '' + # while reply not in ['yes', 'no']: + # if reply != '': + # await self.add_error(message, '**You can only answer with `yes` or `no`!**') + # reply = await self.get_user_reply() + # if self.stopped: break + # if isinstance(reply, str): reply = reply.lower() + # + # self.reaction = reply == 'yes' + # return self.reaction async def set_multiple_choice(self, force=None): - if self.stopped: return - if force is not None: - self.multiple_choice = force + """Determine if poll is multiple choice.""" + async def get_valid(in_reply): + if not in_reply: + raise InvalidInput + is_true = ['yes', '1'] + is_false = ['no', '0'] + in_reply = self.sanitize_string(in_reply) + if not in_reply: + raise InvalidInput + elif in_reply.lower() in is_true: + return True + elif in_reply.lower() in is_false: + return False + else: + raise InvalidInput + + try: + self.multiple_choice = await get_valid(force) return - text = """**Should users be able to vote for multiple options?** - - `0 - No` - `1 - Yes` - - If you type `0` or `no`, a new vote will override the old vote. Otherwise the users can vote for as many options as they like.""" + except InputError: + pass + + text = ("**Should users be able to vote for multiple options?**\n" + "\n" + "`0 - No`\n" + "`1 - Yes`\n" + "\n" + "If you type `0` or `no`, a new vote will override the old vote. " + "Otherwise the users can vote for as many options as they like.") message = await self.wizard_says(text) - reply = '' - while reply not in ['yes', 'no', '1', '0']: - if reply != '': + while True: + try: + reply = await self.get_user_reply() + self.multiple_choice = await get_valid(reply) + await self.add_vaild(message, f'{"Yes" if self.multiple_choice else "No"}') + break + except InvalidInput: await self.add_error(message, '**You can only answer with `yes` | `1` or `no` | `0`!**') - reply = await self.get_user_reply() - if self.stopped: break - if isinstance(reply, str): - reply = reply.lower() - self.multiple_choice = reply in ['yes', '1'] - return self.multiple_choice async def set_options_reaction(self, force=None): - if self.stopped: return - if force is not None: - self.options_reaction = force - return - text = """**Choose the options/answers for your poll.** - Either type the corresponding number to a predefined option set, - or type your own options, separated by commas. + """Set the answers / options of the Poll.""" + async def get_valid(in_reply): + if not in_reply: + raise InvalidInput + preset = ['1','2','3','4'] + split = [r.strip() for r in in_reply.split(",")] - **1** - :white_check_mark: :negative_squared_cross_mark: - **2** - :thumbsup: :zipper_mouth: :thumbsdown: - **3** - :heart_eyes: :thumbsup: :zipper_mouth: :thumbsdown: :nauseated_face: + if split.__len__() == 1: + if split[0] in preset: + return int(split[0]) + else: + raise WrongNumberOfArguments + elif 1 < split.__len__() <= 26: + split = [self.sanitize_string(o) for o in split] + if any([len(o) < 1 for o in split]): + raise InvalidInput + else: + return split + else: + raise WrongNumberOfArguments - Example for custom options: - **apple juice, banana ice cream, kiwi slices** """ - message = await self.wizard_says(text) + def get_preset_options(number): + if number == 1: + return ['āœ…', 'āŽ'] + elif number == 2: + return ['šŸ‘', '🤐', 'šŸ‘Ž'] + elif number == 3: + return ['šŸ˜', 'šŸ‘', '🤐', 'šŸ‘Ž', '🤢'] + elif number == 4: + return ['in favour', 'against', 'abstain'] - reply = '' - while reply == '' or (reply.split(",").__len__() < 2 and reply not in ['1', '2', '3']) \ - or (reply.split(",").__len__() > 26 and reply not in ['1', '2', '3']): - if reply != '': - await self.add_error(message, - '**Invalid entry. Type `1`, `2` or `3` or a comma separated list (max. 26 options).**') - reply = await self.get_user_reply() - if self.stopped: break - if reply == '1': - self.options_reaction = ['āœ…', 'āŽ'] - self.options_reaction_default = True - elif reply == '2': - self.options_reaction = ['šŸ‘', '🤐', 'šŸ‘Ž'] - self.options_reaction_default = True - elif reply == '3': - self.options_reaction = ['šŸ˜', 'šŸ‘', '🤐', 'šŸ‘Ž', '🤢'] - self.options_reaction_default = True - else: - self.options_reaction = [r.strip() for r in reply.split(",")] + try: + options = await get_valid(force) self.options_reaction_default = False - return self.options_reaction - - async def set_options_traditional(self, force=None): - '''Currently not used as everything is reaction based''' - if force is not None: - self.options_traditional = force + if isinstance(options, int): + self.options_reaction = get_preset_options(options) + if options <= 3: + self.options_reaction_default = True + else: + self.options_reaction = options return - if self.stopped: return - text = """**Next you chose from the possible set of options/answers for your poll.** - Type the corresponding number or type your own options, separated by commas. + except InputError: + pass - **1** - yes, no - **2** - in favour, against, abstain - **3** - love it, like it, don't care, meh, hate it - - If you write your own options they will be listed and can be voted for. - To use your custom options type them like this: - **apple juice, banana ice cream, kiwi slices**""" + text = ("**Choose the options/answers for your poll.**\n" + "Either chose a preset of options or type your own options, separated by commas.\n" + "\n" + "**1** - :white_check_mark: :negative_squared_cross_mark:\n" + "**2** - :thumbsup: :zipper_mouth: :thumbsdown:\n" + "**3** - :heart_eyes: :thumbsup: :zipper_mouth: :thumbsdown: :nauseated_face:\n" + "**4** - in favour, against, abstain\n" + "\n" + "Example for custom options:\n" + "**apple juice, banana ice cream, kiwi slices** ") message = await self.wizard_says(text) - reply = '' - while reply == '' or (reply.split(",").__len__() < 2 and reply not in ['1', '2', '3']) \ - or (reply.split(",").__len__() > 99 and reply not in ['1', '2', '3']): - if reply != '': + while True: + try: + reply = await self.get_user_reply() + options = await get_valid(reply) + self.options_reaction_default = False + if isinstance(options, int): + self.options_reaction = get_preset_options(options) + if options <= 3: + self.options_reaction_default = True + else: + self.options_reaction = options + await self.add_vaild(message, f'{", ".join(self.options_reaction)}') + break + except InvalidInput: await self.add_error(message, - '**Invalid entry. Type `1` `2` or `3` or a comma separated list (max. 99 options).**') - reply = await self.get_user_reply() - if self.stopped: break + '**Invalid entry. Type `1`, `2`, `3` or `4` or a comma separated list of ' + 'up to 26 options.**') + except WrongNumberOfArguments: + await self.add_error(message, + '**You need more than 1 option! Type them in a comma separated list.**') - if reply == '1': - self.options_traditional = ['yes, no'] - elif reply == '2': - self.options_traditional = ['in favour', 'against', 'abstain'] - elif reply == '3': - self.options_traditional = ['love it', 'like it', 'don\'t care', 'meh', 'hate it'] - else: - self.options_traditional = [r.strip() for r in reply.split(",")] - return self.options_traditional async def set_roles(self, force=None): - if self.stopped: return - if force is not None: - self.roles = force - return - n_roles = self.server.roles.__len__() - if n_roles == 0: - text = "No roles found on this server, skipping to the next step." - self.roles = ['@everyone'] - elif n_roles <= 15: - text = """**Choose which roles are allowed to vote.** - Type `0`, `all` or `everyone` to have no restrictions. - If you want multiple roles to be able to vote, separate the numbers with a comma. - """ - text += f'\n`{0} - no restrictions`' - i = 1 - for role in [r.name for r in self.server.roles]: - text += f'\n`{i} - {role}`' - i += 1 - text += """ + """Set role restrictions for the Poll.""" + async def get_valid(in_reply, roles): + n_roles = roles.__len__() + if not in_reply: + raise InvalidInput - Example: `2, 3` - """ - message = await self.wizard_says(text) + split = [self.sanitize_string(r.strip()) for r in in_reply.split(",")] + if split.__len__() == 1 and split[0] in ['0', 'all', 'everyone']: + return ['@everyone'] - reply = '' - while reply == '' or reply == 'error' or reply == 'toolarge': - if reply == 'error': - await self.add_error(message, f'**Only positive numbers separated by commas are allowed!**') - elif reply == 'toolarge': - await self.add_error(message, f'**Only use valid numbers......**') + if n_roles <= 20: + if not all([r.isdigit() for r in split]): + raise ExpectedInteger + elif any([int(r) > n_roles for r in split]): + raise OutOfRange - reply = await self.get_user_reply() - if self.stopped: break - - if reply in ['0', 'all', 'everyone']: - break - if not all([r.strip().isdigit() for r in reply.split(",")]): - reply = 'error' - continue - elif any([int(r.strip()) > n_roles for r in reply.split(",")]): - reply = 'toolarge' - continue - if reply in ['0', 'all', 'everyone', 'stop']: - self.roles = ['@everyone'] + role_names = [r.name for r in roles] + return [role_names[i - 1] for i in [int(r) for r in split]] else: - role_names = [r.name for r in self.server.roles] - self.roles = [role_names[i - 1] for i in [int(r.strip()) for r in reply.split(",")]] - - else: - text = """**Choose which roles are allowed to vote.** - Type `0`, `all` or `everyone` to have no restrictions. - Type out the role names, separated by a comma, to restrict voting to specific roles: - `moderators, Editors, vips` (hint: role names are case sensitive!) - """ - message = await self.wizard_says(text) - - reply = '' - reply_roles = [] - while reply == '': - reply = await self.get_user_reply() - if self.stopped: break - if reply in ['0', 'all', 'everyone']: - break - reply_roles = [r.strip() for r in reply.split(",")] invalid_roles = [] - for r in reply_roles: - if not any([r == sr for sr in [x.name for x in self.server.roles]]): + for r in split: + if not any([r == sr for sr in [x.name for x in roles]]): invalid_roles.append(r) if invalid_roles.__len__() > 0: - await self.add_error(message, - f'**Invalid roles found: {", ".join(invalid_roles)}. Roles are case-sensitive!**') - reply = '' - continue + raise InvalidRoles(", ".join(invalid_roles)) + else: + return split - if reply in ['0', 'all', 'everyone']: - self.roles = ['@everyone'] - else: - self.roles = reply_roles - return self.roles + roles = self.server.roles + n_roles = roles.__len__() + try: + self.roles = await get_valid(force, roles) + return + except InputError: + pass + + if n_roles <= 20: + text = ("**Choose which roles are allowed to vote.**\n" + "Type `0`, `all` or `everyone` to have no restrictions.\n" + "If you want multiple roles to be able to vote, separate the numbers with a comma.\n") + text += f'\n`{0} - no restrictions`' + + for i, role in enumerate([r.name for r in self.server.roles]): + text += f'\n`{i+1} - {role}`' + text += ("\n" + "\n" + " Example: `2, 3` \n") + else: + text = ("**Choose which roles are allowed to vote.**\n" + "Type `0`, `all` or `everyone` to have no restrictions.\n" + "Type out the role names, separated by a comma, to restrict voting to specific roles:\n" + "`moderators, Editors, vips` (hint: role names are case sensitive!)\n") + message = await self.wizard_says(text) + + while True: + try: + reply = await self.get_user_reply() + self.roles = await get_valid(reply, roles) + await self.add_vaild(message, f'{", ".join(self.roles)}') + break + except InvalidInput: + await self.add_error(message, '**I can\'t read this input.**') + except ExpectedInteger: + await self.add_error(message, '**Only type positive numbers separated by a comma.**') + except OutOfRange: + await self.add_error(message, '**Only type numbers you can see in the list.**') + except InvalidRoles as e: + await self.add_error(message, f'**The following roles are invalid: {e.roles}**') + + # async def set_options_traditional(self, force=None): + # '''Currently not used as everything is reaction based''' + # if force is not None: + # self.options_traditional = force + # return + # if self.stopped: return + # text = """**Next you chose from the possible set of options/answers for your poll.** + # Type the corresponding number or type your own options, separated by commas. + # + # **1** - yes, no + # **2** - in favour, against, abstain + # **3** - love it, like it, don't care, meh, hate it + # + # If you write your own options they will be listed and can be voted for. + # To use your custom options type them like this: + # **apple juice, banana ice cream, kiwi slices**""" + # message = await self.wizard_says(text) + # + # reply = '' + # while reply == '' or (reply.split(",").__len__() < 2 and reply not in ['1', '2', '3']) \ + # or (reply.split(",").__len__() > 99 and reply not in ['1', '2', '3']): + # if reply != '': + # await self.add_error(message, + # '**Invalid entry. Type `1` `2` or `3` or a comma separated list (max. 99 options).**') + # reply = await self.get_user_reply() + # if self.stopped: break + # + # if reply == '1': + # self.options_traditional = ['yes, no'] + # elif reply == '2': + # self.options_traditional = ['in favour', 'against', 'abstain'] + # elif reply == '3': + # self.options_traditional = ['love it', 'like it', 'don\'t care', 'meh', 'hate it'] + # else: + # self.options_traditional = [r.strip() for r in reply.split(",")] + # return self.options_traditional async def set_weights(self, force=None): - if self.stopped: return - if force is not None and len(force) == 2: - self.weights_roles = force[0] - self.weights_numbers = force[1] - return - text = """Almost done. - **Weights allow you to give certain roles more or less effective votes. - Type `0` or `none` if you don't need any weights.** - A weight for the role `moderator` of `2` for example will automatically count the votes of all the moderators twice. - To assign weights type the role, followed by a colon, followed by the weight like this: - `moderator: 2, newbie: 0.5`""" - message = await self.wizard_says(text) - - status = 'start' - roles = [] - weights = [] - while status != 'ok': - if status != 'start': - await self.add_error(message, status) - status = 'ok' - reply = await self.get_user_reply() - if self.stopped: break + """Set role weights for the poll.""" + async def get_valid(in_reply, server_roles): + if not in_reply: + raise InvalidInput + no_weights = ['0', 'none'] + pairs = [self.sanitize_string(p.strip()) for p in in_reply.split(",")] + if pairs.__len__() == 1 and pairs[0] in no_weights: + return [[], []] + if not all([":" in p for p in pairs]): + raise ExpectedSeparator(":") roles = [] weights = [] - if reply.lower() in ['0', 'none']: - break - for e in [r.strip() for r in reply.split(",")]: - if ':' in e: - c = [x.strip() for x in e.rsplit(":", 1)] - if not any([c[0] == sr for sr in [x.name for x in self.server.roles]]): - status = f'**Role `{c[0]}` not found.**' - break - else: - try: - c[1] = float(c[1]) - except ValueError: - status = f'**Weight `{c[1]}` for `{c[0]}` is not a number.**' - break - c[1] = int(c[1]) if c[1].is_integer() else c[1] - roles.append(c[0]) - weights.append(c[1]) - if len(roles) > len(set(roles)): - status = f'**Only assign a weight to a role once. `{c[0]}` is assigned twice.**' - else: - status = f'**No colon found in `{e}`. Use `:` to separate role and weight.**' - break + for e in pairs: + c = [x.strip() for x in e.rsplit(":", 1)] + if not any([c[0] == sr for sr in [x.name for x in server_roles]]): + raise InvalidRoles(c[0]) - self.weights_roles = roles - self.weights_numbers = weights - return [self.weights_roles, self.weights_numbers] + c[1] = float(c[1]) # Catch ValueError + c[1] = int(c[1]) if c[1].is_integer() else c[1] - async def set_duration(self, force=None): - if self.stopped: return - if force is not None and isinstance(force, float): - self.duration = force + roles.append(c[0]) + weights.append(c[1]) + if len(roles) > len(set(roles)): + raise WrongNumberOfArguments + + return [roles, weights] + + try: + w_n = await get_valid(force, self.server.roles) + self.weights_roles = w_n[0] + self.weights_numbers = w_n[1] return - text = """Last step. - **How long should your poll be active?** - Type a number followed by `m`inutes, `h`ours or `d`ays. - If you want the poll to last indefinitely (until you close it), type `0`. - You can also type a UTC closing date in this format: dd.mm.yyyy hh:mm - - Examples: `5m` or `1 h` or `7d` or `15.8.2019 12:15`""" + except InputError: + pass + + text = ("Almost done.\n" + "**Weights allow you to give certain roles more or less effective votes.\n" + "Type `0` or `none` if you don't need any weights.**\n" + "A weight for the role `moderator` of `2` for example will automatically count the votes of all the moderators twice.\n" + "To assign weights type the role, followed by a colon, followed by the weight like this:\n" + "`moderator: 2, newbie: 0.5`") message = await self.wizard_says(text) - status = 'start' - duration = 0.0 - while status != 'ok': - if status != 'start': - await self.add_error(message, status) - status = 'ok' - reply = await self.get_user_reply() - if self.stopped: break - if reply == '0': break + while True: + try: + reply = await self.get_user_reply() + w_n = await get_valid(reply, self.server.roles) + self.weights_roles = w_n[0] + self.weights_numbers = w_n[1] + weights = [] + for r, n in zip(self.weights_roles, self.weights_numbers): + weights.append(f'{r}: {n}') + await self.add_vaild(message, ", ".join(weights)) + break + except InvalidInput: + await self.add_error(message, '**Can\'t read this input.**') + except ExpectedSeparator as e: + await self.add_error(message, f'**Expected roles and weights to be separated by {e.separator}**') + except InvalidRoles as e: + await self.add_error(message, f'**Invalid role found: {e.roles}**') + except ValueError: + await self.add_error(message, f'**Weights must be numbers.**') + except WrongNumberOfArguments: + await self.add_error(message, f'**Not every role has a weight assigned.**') - date = self.convert_user_date(reply) - if isinstance(date, float): - date = float(ceil(date)) - if date < 0: - status = f'**The entered date is in the past. Use a date in the future.**' - else: - print(date) - duration = date - else: - unit = reply[-1].lower() - if unit not in ['m', 'h', 'd']: - status = f'**{reply} is not a valid format.**' + async def set_duration(self, force=None): + """Set the duration /deadline for the Poll.""" + async def get_valid(in_reply): + if not in_reply: + raise InvalidInput + in_reply = self.sanitize_string(in_reply) + if not in_reply: + raise InvalidInput + elif in_reply == '0': + return 0 - duration = reply[:-1].strip() - try: - duration = float(duration) - except ValueError: - status = f'**{reply} is not a valid format.**' + dt = dateparser.parse(in_reply) + if not isinstance(dt, datetime.datetime): + raise InvalidInput - if unit == 'h': - duration *= 60 - elif unit == 'd': - duration *= 60 * 24 + if dt.tzinfo is None or dt.tzinfo.utcoffset(dt) is None: + dt = dt.astimezone(pytz.utc) - self.duration = duration - return self.duration + now = datetime.datetime.utcnow().astimezone(pytz.utc) + + if dt < now: + raise DateOutOfRange(dt) + return dt - def convert_user_date(self, date): try: - dt = datetime.datetime.strptime(date, '%d.%m.%Y %H:%M') - except ValueError: - return False + self.duration = await get_valid(force) + return + except InputError: + pass - print(dt) - print(datetime.datetime.utcnow()) - return float((dt - datetime.datetime.utcnow()).total_seconds() / 60.0) + text = ("Last step.\n" + "**When should the poll be closed?**\n" + "If you want the poll to last indefinitely (until you close it), type `0`." + "Otherwise tell me when the poll should close in relative or absolute terms. " + "You can specify a timezone if you want.\n" + "\n" + "Examples: `in 6 hours` or `next week CET` or `aug 15th 5:10` or `15.8.2019 11pm EST`") + message = await self.wizard_says(text) + + while True: + try: + reply = await self.get_user_reply() + dt = await get_valid(reply) + self.duration = dt + if self.duration == 0: + await self.add_vaild(message, 'until closed manually') + else: + self.duration_tz = dt.tzinfo.tzname(dt) + await self.add_vaild(message, self.duration.strftime('%d-%b-%Y %H:%M %Z')) + break + except InvalidInput: + await self.add_error(message, '**I could not understand that format.**') + except TypeError: + await self.add_error(message, '**Type Error.**') + except DateOutOfRange as e: + await self.add_error(message, f'**{e.date.strftime("%d-%b-%Y %H:%M")} is in the past.**') def finalize(self): - self.time_created = time.time() + self.time_created = datetime.datetime.utcnow().replace(tzinfo=pytz.utc) - # def __str__(self): - # poll_string = f'Poll by user {self.author}\n' - # poll_string += f'Name of the poll: {self.name}\n' - # poll_string += f'Short of the poll: {self.short}\n' - # poll_string += f'Short of the poll: {str(self.anonymous)}\n' - # poll_string += f'Options reaction: {",".join(self.options_reaction)}\n' - # poll_string += f'Options traditional: {",".join(self.options_traditional)}\n' - # poll_string += f'Roles: {",".join(self.roles)}\n' - # poll_string += f'WR: {",".join(self.weights_roles)}\n' - # poll_string += f'WN: {",".join([str(x) for x in self.weights_numbers])}\n' - # poll_string += f'duration: {self.duration} minutes\n' - # return poll_string - def to_dict(self): + async def to_dict(self): return { 'server_id': str(self.server.id), 'channel_id': str(self.channel.id), @@ -534,19 +717,22 @@ class Poll: 'multiple_choice': self.multiple_choice, 'options_reaction': self.options_reaction, 'reaction_default': self.options_reaction_default, - 'options_traditional': self.options_traditional, + #'options_traditional': self.options_traditional, 'roles': self.roles, 'weights_roles': self.weights_roles, 'weights_numbers': self.weights_numbers, 'duration': self.duration, + 'duration_tz': self.duration_tz, 'time_created': self.time_created, - 'open': self.open, + 'open': await self.is_open(update_db=False), + 'active': await self.is_active(update_db=False), + 'activation': self.activation, + 'activation_tz': self.activation_tz, 'votes': self.votes } - def to_export(self): - # export to txt file - + async def to_export(self): + """Create report and return string""" # build string for weights weight_str = 'No weights' if self.weights_roles.__len__() > 0: @@ -565,13 +751,13 @@ class Poll: winning_votes = votes elif votes == winning_votes: winning_options.append(o) - + deadline_str = await self.get_deadline(string=True) export = (f'--------------------------------------------\n' f'POLLMASTER DISCORD EXPORT\n' f'--------------------------------------------\n' f'Server name (ID): {self.server.name} ({self.server.id})\n' f'Owner of the poll: {self.author.name}\n' - f'Time of creation: {datetime.datetime.utcfromtimestamp(self.time_created).strftime("%d-%b-%Y %H:%M") + " UTC"}\n' + f'Time of creation: {self.time_created.strftime("%d-%b-%Y %H:%M %Z")}\n' f'--------------------------------------------\n' f'POLL SETTINGS\n' f'--------------------------------------------\n' @@ -582,7 +768,7 @@ class Poll: f'Answer options: {", ".join(self.options_reaction)}\n' f'Allowed roles: {", ".join(self.roles) if self.roles.__len__() > 0 else "@everyone"}\n' f'Weights for roles: {weight_str}\n' - f'Deadline: {self.get_deadline(string=True)}\n' + f'Deadline: {deadline_str}\n' f'--------------------------------------------\n' f'POLL RESULTS\n' f'--------------------------------------------\n' @@ -631,11 +817,12 @@ class Poll: '--------------------------------------------\n') return export - def export(self): + async def export(self): + """Create export file and return path""" if not self.open: fn = 'export/' + str(self.server.id) + '_' + str(self.short) + '.txt' with codecs.open(fn, 'w', 'utf-8') as outfile: - outfile.write(self.to_export()) + outfile.write(await self.to_export()) return fn else: return None @@ -651,24 +838,31 @@ class Poll: self.multiple_choice = d['multiple_choice'] self.options_reaction = d['options_reaction'] self.options_reaction_default = d['reaction_default'] - self.options_traditional = d['options_traditional'] + # self.options_traditional = d['options_traditional'] self.roles = d['roles'] self.weights_roles = d['weights_roles'] self.weights_numbers = d['weights_numbers'] self.duration = d['duration'] + self.duration_tz = d['duration_tz'] self.time_created = d['time_created'] + + self.activation = d['activation'] + self.activation_tz = d['activation_tz'] + self.active = d['active'] self.open = d['open'] - self.open = self.is_open() # ckeck for deadline since last call + self.cursor_pos = 0 self.votes = d['votes'] + self.open = await self.is_open() + self.active = await self.is_active() + async def save_to_db(self): await self.bot.db.polls.update_one({'server_id': str(self.server.id), 'short': str(self.short)}, - {'$set': self.to_dict()}, upsert=True) + {'$set': await self.to_dict()}, upsert=True) @staticmethod async def load_from_db(bot, server_id, short, ctx=None, ): - # query = await bot.db.polls.find_one({'server_id': str(server_id), 'short': str(short)}) query = await bot.db.polls.find_one({'server_id': str(server_id), 'short': str(short)}) if query is not None: p = Poll(bot, ctx, load=True) @@ -678,10 +872,10 @@ class Poll: return None async def add_field_custom(self, name, value, embed): - ## this is used to estimate the width of text and add empty embed fields for a cleaner report - ## cursor_pos is used to track if we are at the start of a new line in the report. Each line has max 2 slots for info. - ## If the line is short, we can fit a second field, if it is too long, we get an automatic linebreak. - ## If it is in between, we create an empty field to prevent the inline from looking ugly + """this is used to estimate the width of text and add empty embed fields for a cleaner report + cursor_pos is used to track if we are at the start of a new line in the report. Each line has max 2 slots for info. + If the line is short, we can fit a second field, if it is too long, we get an automatic linebreak. + If it is in between, we create an empty field to prevent the inline from looking ugly""" name = str(name) value = str(value) @@ -693,21 +887,29 @@ class Poll: embed.add_field(name=name, value=value, inline=False if w > 12500 and self.cursor_pos % 2 == 1 else True) self.cursor_pos += 1 - ## create an empty field if we are at the second slot and the width of the first slot is between the critical values - if self.cursor_pos % 2 == 1 and w > 11600 and w < 20000: + # create an empty field if we are at the second slot and the width of the first slot is between the critical values + if self.cursor_pos % 2 == 1 and 11600 < w < 20000: embed.add_field(name='\u200b', value='\u200b', inline=True) self.cursor_pos += 1 return embed async def generate_embed(self): + """Generate Discord Report""" self.cursor_pos = 0 - embed = discord.Embed(title='', colour=self.color) # f'Status: {"Open" if self.is_open() else "Closed"}' + embed = discord.Embed(title='', colour=SETTINGS.color) # f'Status: {"Open" if self.is_open() else "Closed"}' embed.set_author(name=f' >> {self.short} ', - icon_url="http://mnadler.ch/img/donat-chart-32.png") - embed.set_thumbnail(url="http://mnadler.ch/img/poll-topic-64.png") + icon_url=SETTINGS.author_icon) + embed.set_thumbnail(url=SETTINGS.report_icon) # ## adding fields with custom, length sensitive function + if not await self.is_active(): + embed = await self.add_field_custom(name='**INACTIVE**', + value=f'This poll is inactive until ' + f'{self.get_activation_date(string=True)}.', + embed=embed + ) + embed = await self.add_field_custom(name='**Poll Question**', value=self.name, embed=embed) embed = await self.add_field_custom(name='**Roles**', value=', '.join(self.roles), embed=embed) @@ -720,25 +922,25 @@ class Poll: embed = await self.add_field_custom(name='**Anonymous**', value=self.anonymous, embed=embed) # embed = await self.add_field_custom(name='**Multiple Choice**', value=self.multiple_choice, embed=embed) - embed = await self.add_field_custom(name='**Deadline**', value=self.get_poll_status(), embed=embed) + embed = await self.add_field_custom(name='**Deadline**', value=await self.get_poll_status(), embed=embed) embed = await self.add_field_custom(name='**Author**', value=self.author.name, embed=embed) if self.reaction: if self.options_reaction_default: - if self.is_open(): - text = f'*Vote by adding reactions to the poll*. ' - text += '*You can vote for multiple options.*' if self.multiple_choice \ - else '*You have 1 vote, but can change it.*' + if await self.is_open(): + text = f'**Score** ' + text += '*(Multiple Choice)*' if self.multiple_choice \ + else '*(Single Choice)*' else: - text = f'*Final Results of the {"multiple choice" if self.multiple_choice else "single choice"} Poll.*' + text = f'**Final Score**' vote_display = [] for i, r in enumerate(self.options_reaction): vote_display.append(f'{r} {self.count_votes(i)}') - embed = await self.add_field_custom(name='**Score**', value=' '.join(vote_display), embed=embed) + embed = await self.add_field_custom(name=text, value=' '.join(vote_display), embed=embed) else: embed.add_field(name='\u200b', value='\u200b', inline=False) - if self.is_open(): + if await self.is_open(): text = f'*Vote by adding reactions to the poll*. ' text += '*You can vote for multiple options.*' if self.multiple_choice \ else '*You have 1 vote, but can change it.*' @@ -751,16 +953,19 @@ class Poll: value=r, embed=embed ) - else: - embed = await self.add_field_custom(name='**Options**', value=', '.join(self.get_options()), embed=embed) + # else: + # embed = await self.add_field_custom(name='**Options**', value=', '.join(self.get_options()), embed=embed) embed.set_footer(text='bot is in development') return embed - async def post_embed(self, ctx): - msg = await self.bot.say(embed=await self.generate_embed()) - if self.reaction and self.is_open(): + async def post_embed(self, destination=None): + if destination is None: + msg = await self.bot.say(embed=await self.generate_embed()) + else: + msg = await self.bot.send_message(destination=destination, embed= await self.generate_embed()) + if self.reaction and await self.is_open() and await self.is_active(): if self.options_reaction_default: for r in self.options_reaction: await self.bot.add_reaction( @@ -775,28 +980,54 @@ class Poll: AZ_EMOJIS[i] ) return msg + elif not await self.is_open(): + await self.bot.add_reaction(msg, 'šŸ“Ž') else: return msg - def get_options(self): - if self.reaction: - return self.options_reaction - else: - return self.options_traditional + # def get_options(self): + # if self.reaction: + # return self.options_reaction + # else: + # return self.options_traditional - def get_deadline(self, string=False): - deadline = float(self.time_created) + float(self.duration) * 60 - if string: - if self.duration == 0: + async def get_deadline(self, string=False): + if self.duration == 0: + if string: return 'No deadline' else: - return datetime.datetime.utcfromtimestamp(deadline).strftime('%d-%b-%Y %H:%M') + ' UTC' + return 0 else: - return deadline + deadline = self.duration + if deadline.tzinfo is None or deadline.tzinfo.utcoffset(deadline) is None: + deadline = pytz.utc.localize(deadline) + tz = pytz.timezone(self.duration_tz) + deadline = deadline.astimezone(tz) + if string: + return deadline.strftime('%d-%b-%Y %H:%M %Z') + else: + return deadline - def get_poll_status(self): - if self.is_open(): - return self.get_deadline(string=True) + def get_activation_date(self, string=False): + if self.activation == 0: + if string: + return 'manually activated' + else: + return 0 + else: + activation_date = self.activation + if activation_date.tzinfo is None or activation_date.tzinfo.utcoffset(activation_date) is None: + activation_date = pytz.utc.localize(activation_date) + tz = pytz.timezone(self.activation_tz) + activation_date = activation_date.astimezone(tz) + if string: + return activation_date.strftime('%d-%b-%Y %H:%M %Z') + else: + return activation_date + + async def get_poll_status(self): + if await self.is_open(): + return await self.get_deadline(string=True) else: return 'Poll is closed.' @@ -807,23 +1038,15 @@ class Poll: else: return sum([1 for c in [u for u in self.votes] if option in self.votes[c]['choices']]) - # async def has_voted(self, user_id): - # query = await self.bot.db.polls.find_one({'server_id': str(self.server.id), 'short': self.short}) - # if query is None: - # return False - # else: - # votes = query['votes'] - # if user_id in votes: - # return votes[user_id] - # else: - # return False async def vote(self, user, option, message): - if not self.is_open(): + if not await self.is_open(): # refresh to show closed poll await self.bot.edit_message(message, embed=await self.generate_embed()) await self.bot.clear_reactions(message) return + elif not await self.is_active(): + return choice = 'invalid' already_voted = False @@ -877,11 +1100,13 @@ class Poll: pass async def unvote(self, user, option, message): - if not self.is_open(): + if not await self.is_open(): # refresh to show closed poll await self.bot.edit_message(message, embed=await self.generate_embed()) await self.bot.clear_reactions(message) return + elif not await self.is_active(): + return if str(user.id) not in self.votes: return diff --git a/cogs/poll_controls.py b/cogs/poll_controls.py index 835b699..9ca2ef0 100644 --- a/cogs/poll_controls.py +++ b/cogs/poll_controls.py @@ -1,22 +1,21 @@ import copy -import pprint -import time - +import logging import discord -from settings import * + from discord.ext import commands from .poll import Poll -from .utils import ask_for_server, ask_for_channel, get_server_pre -from .utils import SETTINGS +from utils.paginator import embed_list_paginated +from essentials.multi_server import get_server_pre, ask_for_server, ask_for_channel +from essentials.settings import SETTINGS from utils.poll_name_generator import generate_word - +from essentials.exceptions import StopWizard class PollControls: def __init__(self, bot): self.bot = bot - ## General Methods + # General Methods async def is_admin_or_creator(self, ctx, server, owner_id, error_msg=None): member = server.get_member(ctx.message.author.id) if member.id == owner_id: @@ -44,7 +43,47 @@ class PollControls: embed.set_footer(text=footer_text) await self.bot.say(embed=embed) - ## Commands + # Commands + @commands.command(pass_context=True) + async def activate(self, ctx, *, short=None): + """Activate a prepared poll. Parameter: