|
| 1 | +# See readme.md for instructions on running this code. |
| 2 | + |
| 3 | +from __future__ import absolute_import |
| 4 | +from __future__ import print_function |
| 5 | +from six.moves import range |
| 6 | + |
| 7 | +from collections import OrderedDict, namedtuple |
| 8 | + |
| 9 | +def input_from_message_content(message_content): |
| 10 | + lines = message_content.split('\n') |
| 11 | + main_line = lines[0].split(' ') |
| 12 | + command = main_line[0] |
| 13 | + options = main_line[1:] |
| 14 | + title = "" |
| 15 | + if len(lines) > 1: |
| 16 | + title = lines[1] |
| 17 | + vote_options = [] |
| 18 | + if len(lines) > 2: |
| 19 | + vote_options = lines[2:] |
| 20 | + vote_options = [v for v in vote_options if len(v) > 0] |
| 21 | + Input = namedtuple('Input', ['command', 'options', 'title', 'vote_options']) |
| 22 | + return Input(command, options, title, vote_options) |
| 23 | + |
| 24 | +class PollHandler(object): |
| 25 | + def usage(self): |
| 26 | + return ''' |
| 27 | + This bot maintains up to one poll per user, per topic, in streams only. |
| 28 | + It currently keeps a running count of the votes, as they are made, with one |
| 29 | + mesage in the stream being updated to show the current status. |
| 30 | + Message the bot privately, appending the stream and topic, or mention it |
| 31 | + within a topic (for new, vote and end commands); if the stream or |
| 32 | + topic contain spaces use a '+' where the space would be. |
| 33 | + ''' |
| 34 | + |
| 35 | + def handle_message(self, message, bot_handler, state_handler): |
| 36 | + |
| 37 | + help_msg = OrderedDict([ |
| 38 | + ('about', "gives a simple summary of this bot."), |
| 39 | + ('help', "produces this help."), |
| 40 | + ('commands', "a concise form of help, listing the supported commands."), |
| 41 | + ('new', ("start a new poll: specify a title on the following line " |
| 42 | + "and at least two options on subsequent lines.")), |
| 43 | + ('vote', ("vote in an ongoing poll: specify a poll id given in the poll message " |
| 44 | + "followed by the number of the option to vote for.")), |
| 45 | + ('end', ("end your own ongoing poll.")), |
| 46 | + ]) |
| 47 | + stream_topic_notgiven = "\nPlease specify a stream & topic if messaging the bot privately." |
| 48 | + space_equivalent = "+" |
| 49 | + |
| 50 | + sender = message["sender_email"] |
| 51 | + sender_id = message["sender_id"] |
| 52 | + |
| 53 | + def private_reply(text): |
| 54 | + bot_handler.send_message(dict(type='private', to=sender, content=text)) |
| 55 | + |
| 56 | + # Break down the text supplied into potential input sets |
| 57 | + inp = input_from_message_content(message['content']) |
| 58 | + |
| 59 | + # Updates the poll text, based on the current data in the poll (may modify poll) |
| 60 | + # Return True/False, depending if message was successfully posted |
| 61 | + def update_poll_text(poll, stream_topic, force_end=False): |
| 62 | + # Construct initial/updated poll text |
| 63 | + msg = ("Poll by {} (id: {})\n{}\n" |
| 64 | + .format(poll['creator'], stream_topic[2], poll['title'])) |
| 65 | + for i in range(poll['n']): |
| 66 | + msg += ("{}. [{}] {}\n" |
| 67 | + .format(i+1, len(poll['tallies'][i]), poll[i])) |
| 68 | + if force_end: |
| 69 | + msg += "**This poll has ended**\n" |
| 70 | + # Set/update poll text |
| 71 | + if poll['msg_id'] is None: |
| 72 | + result = bot_handler.send_message(dict(type='stream', |
| 73 | + to=stream_topic[0], |
| 74 | + subject=stream_topic[1], |
| 75 | + content=msg)) |
| 76 | + if result['result'] == 'success': |
| 77 | + poll['msg_id'] = result['id'] |
| 78 | + return (msg, True) |
| 79 | + else: |
| 80 | + return (msg, False) |
| 81 | + else: |
| 82 | + result = bot_handler.update_message(dict(message_id = poll['msg_id'], |
| 83 | + content = msg)) |
| 84 | + if result['result'] == 'success': |
| 85 | + return (msg, True) |
| 86 | + else: |
| 87 | + return (msg, False) # FIXME Cannot always update a message; should poll end? |
| 88 | + # FIXME Should add handling of return value |
| 89 | + |
| 90 | + # Quickly return for simple help response cases |
| 91 | + if inp.command == "" or inp.command == "help": |
| 92 | + txt = ("{}\n\nIt supports the following commands:\n" |
| 93 | + .format(" ".join(self.usage().split()))) |
| 94 | + for k, v in help_msg.items(): |
| 95 | + txt += "\n**{}** : {}".format(k, v) |
| 96 | + private_reply(txt) |
| 97 | + return |
| 98 | + elif inp.command == "about": |
| 99 | + private_reply(" ".join(self.usage().split())) |
| 100 | + return |
| 101 | + elif inp.command == "commands": |
| 102 | + private_reply("Commands: " + ", ".join((k for k in help_msg))) |
| 103 | + return |
| 104 | + |
| 105 | + src_is_private = (message['type'] == 'private') |
| 106 | + |
| 107 | + # Ensure we have some state |
| 108 | + active_polls = state_handler.get_state() |
| 109 | + if active_polls is None: |
| 110 | + active_polls = {} |
| 111 | + |
| 112 | + if inp.command == "new": |
| 113 | + # Check where the poll will be -> obtain stream_topic |
| 114 | + stream_topic = None |
| 115 | + if src_is_private: |
| 116 | + if len(inp.options) != 2: |
| 117 | + private_reply(stream_topic_notgiven) |
| 118 | + return |
| 119 | + else: |
| 120 | + stream = inp.options[0].replace(space_equivalent, " ") |
| 121 | + topic = inp.options[1].replace(space_equivalent, " ") |
| 122 | + stream_topic = (stream, topic, sender_id) |
| 123 | + else: |
| 124 | + stream_topic = (message['display_recipient'], message['subject'], sender_id) |
| 125 | + # Check if a poll is already active with this id |
| 126 | + if stream_topic in active_polls: |
| 127 | + private_reply("Already have a poll with this id; end it explicitly first") |
| 128 | + return |
| 129 | + # Check we have at least a poll title and 2(+) options |
| 130 | + if inp.title == "" or len(inp.vote_options) < 2: |
| 131 | + private_reply("To " + help_msg['new']) |
| 132 | + return |
| 133 | + # Create new poll data |
| 134 | + new_poll = { |
| 135 | + 'title': inp.title, # Poll title |
| 136 | + 'tallies': [], # List of list of sender_id's who voted |
| 137 | + 'msg_id': None, # Message id containing poll text |
| 138 | + 'n': len(inp.vote_options), # How many voting options |
| 139 | + 'creator': message['sender_full_name'] # Name of poll creator |
| 140 | + } |
| 141 | + for i, v in enumerate(inp.vote_options): # Set text & tallies for each vote_option |
| 142 | + new_poll[i] = v |
| 143 | + new_poll['tallies'].append([]) |
| 144 | + # Try to send initial poll message to stream/topic, and alert user of result |
| 145 | + (update_msg, success) = update_poll_text(new_poll, stream_topic) |
| 146 | + if success: |
| 147 | + # Insert the new poll and update the state |
| 148 | + active_polls[stream_topic] = new_poll |
| 149 | + state_handler.set_state(active_polls) |
| 150 | + # Generate private message to creator, to indicate successful creation |
| 151 | + msg = ("Poll created in stream '#{}' with topic '{}':\n{}" |
| 152 | + .format(stream_topic[0], stream_topic[1], update_msg)) |
| 153 | + else: |
| 154 | + msg = ("Could not create poll in stream '#{}' with topic '{}'" |
| 155 | + .format(stream_topic[0], stream_topic[1])) |
| 156 | + private_reply(msg) |
| 157 | + elif inp.command == "vote": |
| 158 | + # Translate options into requested poll and vote |
| 159 | + requested_poll_id = 0 |
| 160 | + requested_vote_option = "" |
| 161 | + stream_topic = None |
| 162 | + if src_is_private: |
| 163 | + if len(inp.options) != 4: |
| 164 | + private_reply("To " + help_msg['vote'] + stream_topic_notgiven) |
| 165 | + return |
| 166 | + else: |
| 167 | + requested_poll_id = inp.options[2] |
| 168 | + requested_vote_option = inp.options[3] |
| 169 | + stream = inp.options[0].replace(space_equivalent, " ") |
| 170 | + topic = inp.options[1].replace(space_equivalent, " ") |
| 171 | + stream_topic = (stream, topic, requested_poll_id) |
| 172 | + else: |
| 173 | + if len(inp.options) != 2: |
| 174 | + private_reply("To " + help_msg['vote']) |
| 175 | + return |
| 176 | + else: |
| 177 | + requested_poll_id = inp.options[0] |
| 178 | + requested_vote_option = inp.options[1] |
| 179 | + stream_topic = (message['display_recipient'], |
| 180 | + message['subject'], requested_poll_id) |
| 181 | + # Validate requested_poll_id before setting stream_topic & poll |
| 182 | + try: |
| 183 | + requested_poll_id = int(requested_poll_id) |
| 184 | + except ValueError: |
| 185 | + private_reply("To " + help_msg['vote']) |
| 186 | + return |
| 187 | + stream_topic = (stream_topic[0], stream_topic[1], requested_poll_id) |
| 188 | + if stream_topic not in active_polls: |
| 189 | + private_reply("To " + help_msg['vote']) |
| 190 | + return |
| 191 | + poll = active_polls[stream_topic] |
| 192 | + # Check if this user has voted for any option already |
| 193 | + context_txt = (" in the poll on stream '#{}' (topic '{}') titled: '{}'" |
| 194 | + .format(stream_topic[0], stream_topic[1], poll['title'])) |
| 195 | + for i, tally in enumerate(poll['tallies']): |
| 196 | + if sender_id in tally: |
| 197 | + msg = ("You have already voted{}\n(You voted for {}: {})" |
| 198 | + .format(context_txt, i+1, poll[i])) |
| 199 | + private_reply(msg) |
| 200 | + return |
| 201 | + # Check that vote index is within expected bounds |
| 202 | + vote_index = 0 |
| 203 | + try: |
| 204 | + vote_index = int(requested_vote_option) |
| 205 | + except ValueError: |
| 206 | + private_reply("Please select one number to vote for{}".format(context_txt)) |
| 207 | + return |
| 208 | + max_vote_index = poll['n'] |
| 209 | + if 0 < vote_index <= max_vote_index: # Indexed from 1 |
| 210 | + # Use the vote |
| 211 | + poll['tallies'][vote_index-1].append(sender_id) |
| 212 | + (update_msg, success) = update_poll_text(poll, stream_topic) |
| 213 | + if success: |
| 214 | + state_handler.set_state(active_polls) |
| 215 | + msg = ("You just voted{}\n(You voted for {}: {})" |
| 216 | + .format(context_txt, vote_index, poll[vote_index-1])) |
| 217 | + private_reply(msg) |
| 218 | + else: |
| 219 | + private_reply("Could not update the poll with your vote.") |
| 220 | + # FIXME Should we end the poll automatically here? |
| 221 | + else: |
| 222 | + private_reply(("Please select a number to vote for, between 1-{},{}" |
| 223 | + .format(max_vote_index, context_txt))) |
| 224 | + return |
| 225 | + elif inp.command == "end": |
| 226 | + # Translate options into stream & topic |
| 227 | + stream_topic = None |
| 228 | + if src_is_private: |
| 229 | + if len(inp.options) != 2: |
| 230 | + private_reply(stream_topic_notgiven) |
| 231 | + return |
| 232 | + else: |
| 233 | + stream = inp.options[0].replace(space_equivalent, " ") |
| 234 | + topic = inp.options[1].replace(space_equivalent, " ") |
| 235 | + stream_topic = (stream, topic, sender_id) |
| 236 | + else: |
| 237 | + stream_topic = (message['display_recipient'], message['subject'], sender_id) |
| 238 | + # End the poll if present |
| 239 | + if stream_topic in active_polls: |
| 240 | + (update_msg, success) = update_poll_text(active_polls[stream_topic], |
| 241 | + stream_topic, force_end=True) |
| 242 | + private_reply((("Ending your poll in '#{}' and topic '{}', " |
| 243 | + "final results were:\n\n{}") |
| 244 | + .format(stream, topic, update_msg))) |
| 245 | + if not success: |
| 246 | + private_reply("NOTE: Your poll ended, but the poll message could not be updated.") |
| 247 | + del active_polls[stream_topic] |
| 248 | + state_handler.set_state(active_polls) |
| 249 | + else: |
| 250 | + private_reply(("You do not have a poll in '#{}' and topic '{}'" |
| 251 | + .format(stream_topic[0], stream_topic[1]))) |
| 252 | + else: |
| 253 | + msg = "Unsupported command." |
| 254 | + private_reply(msg) |
| 255 | + |
| 256 | + |
| 257 | +handler_class = PollHandler |
0 commit comments