def activate_notification(session, context, poll): """Show to vote type keyboard.""" user = context.user if user != poll.user: return "You aren't allowed to do this" message = context.query.message notification = (session.query(Notification).filter( Notification.select_message_id == message.message_id).one_or_none()) if notification is None: raise Exception( f"Got rogue notification board for poll {poll} and user {user}") existing_notification = (session.query(Notification).filter( Notification.poll == poll).filter( Notification.chat_id == message.chat_id).one_or_none()) # We already got a notification in this chat for this poll # Save the poll message id anyway if existing_notification is not None: session.delete(notification) existing_notification.poll_message_id = notification.poll_message_id else: notification.poll = poll session.commit() message.edit_text( i18n.t("external.notification.activated", locale=poll.locale)) increase_stat(session, "notifications")
def get_or_create(session, tg_user): """Get or create a new user.""" user = session.query(User).get(tg_user.id) if not user: user = User(tg_user.id, tg_user.username) session.add(user) try: session.commit() increase_stat(session, 'new_users') # Handle parallel user addition except IntegrityError as e: session.rollback() user = session.query(User).get(tg_user.id) if user is None: raise e # Allways update the username in case the username changed if tg_user.username is not None: user.username = tg_user.username.lower() # Allways update the name in case something changed name = User.get_name_from_tg_user(tg_user) user.name = name return user
def handle_vote(session, context): """Handle any clicks on vote buttons.""" # Remove the poll, in case it got deleted, but we didn't manage to kill all references option = session.query(PollOption).get(context.payload) if option is None: if context.query.message is not None: context.query.message.edit_text(i18n.t('deleted.polls', locale=context.user.locale)) else: context.bot.edit_message_text( i18n.t('deleted.polls', locale=context.user.locale), inline_message_id=context.query.inline_message_id, ) return poll = option.poll try: # Single vote if poll.poll_type == PollType.single_vote.name: update_poll = handle_single_vote(session, context, option) # Block vote elif poll.poll_type == PollType.block_vote.name: update_poll = handle_block_vote(session, context, option) # Limited vote elif poll.poll_type == PollType.limited_vote.name: update_poll = handle_limited_vote(session, context, option) # Cumulative vote elif poll.poll_type == PollType.cumulative_vote.name: update_poll = handle_cumulative_vote(session, context, option) elif poll.poll_type == PollType.count_vote.name: update_poll = handle_cumulative_vote(session, context, option, limited=False) elif poll.poll_type == PollType.doodle.name: update_poll = handle_doodle_vote(session, context, option) elif poll.poll_type == PollType.priority.name: update_poll = handle_priority_vote(session, context, option) else: raise Exception("Unknown poll type") except IntegrityError: # Double vote. Rollback the transaction and ignore the second vote session.rollback() return except ObjectDeletedError: # Vote on already removed vote. Rollback the transaction and ignore session.rollback() return except StaleDataError: # Try to edit a vote that has already been deleted. # This happens, if users spam the vote buttons. # Rollback the transaction and ignore session.rollback() return session.commit() if update_poll: update_poll_messages(session, context.bot, poll) increase_stat(session, 'votes')
def get_user(session, tg_user): """Get the user from the event.""" user = session.query(User).get(tg_user.id) if user is not None and user.banned: return user, None if user is None: user = User(tg_user.id, tg_user.username) session.add(user) try: session.commit() increase_stat(session, "new_users") # Handle race condition for parallel user addition # Return the user that has already been created # in another session except IntegrityError as e: session.rollback() user = session.query(User).get(tg_user.id) if user is None: raise e if tg_user.username is not None: user.username = tg_user.username.lower() name = get_name_from_tg_user(tg_user) user.name = name # Ensure user statistics exist for this user # We need to track at least some user activity, since there seem to be some users which # abuse the bot by creating polls and spamming up to 1 million votes per day. # # I really hate doing this, but I don't see another way to prevent DOS attacks # without tracking at least some numbers. user_statistic = session.query(UserStatistic).get((date.today(), user.id)) if user_statistic is None: user_statistic = UserStatistic(user) session.add(user_statistic) try: session.commit() # Handle race condition for parallel user statistic creation # Return the statistic that has already been created in another session except IntegrityError as e: session.rollback() user_statistic = session.query(UserStatistic).get( (date.today(), user.id)) if user_statistic is None: raise e return user, user_statistic
def create_poll(session, poll, user, chat, message=None): """Finish the poll creation.""" poll.created = True user.expected_input = None user.current_poll = None text = get_poll_text(session, poll) if len(text) > 4000: error_message = i18n.t("misc.over_4000", locale=user.locale) message = chat.send_message(error_message, parse_mode="markdown") session.delete(poll) return if message: message = message.edit_text( text, parse_mode="markdown", reply_markup=get_management_keyboard(poll), disable_web_page_preview=True, ) else: message = chat.send_message( text, parse_mode="markdown", reply_markup=get_management_keyboard(poll), disable_web_page_preview=True, ) if len(text) > 3000: error_message = i18n.t("misc.over_3000", locale=user.locale) message = chat.send_message(error_message, parse_mode="markdown") reference = Reference(poll, ReferenceType.admin.name, user=user, message_id=message.message_id) session.add(reference) session.flush() increase_stat(session, "created_polls") increase_user_stat(session, user, "created_polls")
def create_poll(session, poll, user, chat, message=None): """Finish the poll creation.""" poll.created = True user.expected_input = None user.current_poll = None text = get_poll_text(session, poll) if len(text) > 4000: error_message = i18n.t('misc.over_4000', locale=user.locale) message = chat.send_message(error_message, parse_mode='markdown') session.delete(poll) return if message: message = message.edit_text( text, parse_mode='markdown', reply_markup=get_management_keyboard(poll), disable_web_page_preview=True, ) else: message = chat.send_message( text, parse_mode='markdown', reply_markup=get_management_keyboard(poll), disable_web_page_preview=True, ) if len(text) > 3000: error_message = i18n.t('misc.over_3000', locale=user.locale) message = chat.send_message(error_message, parse_mode='markdown') reference = Reference(poll, admin_chat_id=chat.id, admin_message_id=message.message_id) session.add(reference) session.commit() increase_stat(session, 'created_polls')
def handle_callback_query(bot, update, session, user): """Handle synchronous callback queries. Some critical callbacks shouldn't be allowed to be asynchronous, since they tend to cause race conditions and integrity errors in the database schema. That's why some calls are restricted to be synchronous. """ context = get_context(bot, update, session, user) increase_user_stat(session, context.user, "callback_calls") session.commit() response = callback_mapping[context.callback_type](session, context) # Callback handler functions always return the callback answer # The only exception is the vote function, which is way too complicated and # implements its own callback query answer logic. if response is not None and context.callback_type != CallbackType.vote: context.query.answer(response) else: context.query.answer("") increase_stat(session, "callback_calls") return
def start(bot, update, session, user): """Send a start text.""" # Truncate the /start command text = update.message.text[6:].strip() user.started = True poll = None action = None try: poll_uuid = UUID(text.split("-")[0]) action = StartAction(int(text.split("-")[1])) poll = session.query(Poll).filter(Poll.uuid == poll_uuid).one() except: text = "" # We got an empty text, just send the start message if text == "": update.message.chat.send_message( i18n.t("misc.start", locale=user.locale), parse_mode="markdown", reply_markup=get_main_keyboard(user), disable_web_page_preview=True, ) return if poll is None: return "This poll no longer exists." if action == StartAction.new_option and poll.allow_new_options: # Update the expected input and set the current poll user.expected_input = ExpectedInput.new_user_option.name user.current_poll = poll session.commit() update.message.chat.send_message( i18n.t("creation.option.first", locale=poll.locale), parse_mode="markdown", reply_markup=get_external_add_option_keyboard(poll), ) elif action == StartAction.show_results: # Get all lines of the poll lines = compile_poll_text(session, poll) # Now split the text into chunks of max 4000 characters chunks = split_text(lines) for chunk in chunks: message = "\n".join(chunk) try: update.message.chat.send_message( message, parse_mode="markdown", disable_web_page_preview=True, ) # Retry for Timeout error (happens quite often when sending large messages) except TimeoutError: time.sleep(2) update.message.chat.send_message( message, parse_mode="markdown", disable_web_page_preview=True, ) time.sleep(1) update.message.chat.send_message( i18n.t("misc.start_after_results", locale=poll.locale), parse_mode="markdown", reply_markup=get_main_keyboard(user), ) increase_stat(session, "show_results") elif action == StartAction.share_poll and poll.allow_sharing: update.message.chat.send_message( i18n.t("external.share_poll", locale=poll.locale), reply_markup=get_external_share_keyboard(poll), ) increase_stat(session, "externally_shared") elif action == StartAction.vote: if not config["telegram"][ "allow_private_vote"] and not poll.is_priority(): return if poll.is_priority(): init_votes(session, poll, user) session.commit() text, keyboard = get_poll_text_and_vote_keyboard( session, poll, user=user, ) sent_message = update.message.chat.send_message( text, reply_markup=keyboard, parse_mode="markdown", disable_web_page_preview=True, ) reference = Reference( poll, ReferenceType.private_vote.name, user=user, message_id=sent_message.message_id, ) session.add(reference) session.commit()
def start(bot, update, session, user): """Send a start text.""" # Truncate the /start command text = "" if update.message is not None: text = update.message.text[6:].strip() user.started = True try: poll_uuid = UUID(text.split('-')[0]) action = StartAction(int(text.split('-')[1])) poll = session.query(Poll).filter(Poll.uuid == poll_uuid).one() except: text = '' # We got an empty text, just send the start message if text == '': update.message.chat.send_message( i18n.t('misc.start', locale=user.locale), parse_mode='markdown', reply_markup=get_main_keyboard(user), disable_web_page_preview=True, ) return if poll is None: return 'This poll no longer exists.' if action == StartAction.new_option: # Update the expected input and set the current poll user.expected_input = ExpectedInput.new_user_option.name user.current_poll = poll session.commit() update.message.chat.send_message( i18n.t('creation.option.first', locale=poll.locale), parse_mode='markdown', reply_markup=get_external_add_option_keyboard(poll)) elif action == StartAction.show_results: # Get all lines of the poll lines = compile_poll_text(session, poll) # Now split the text into chunks of max 4000 characters chunks = split_text(lines) for chunk in chunks: message = '\n'.join(chunk) try: update.message.chat.send_message( message, parse_mode='markdown', disable_web_page_preview=True, ) # Retry for Timeout error (happens quite often when sending large messages) except TimeoutError: time.sleep(2) update.message.chat.send_message( message, parse_mode='markdown', disable_web_page_preview=True, ) time.sleep(1) update.message.chat.send_message( i18n.t('misc.start_after_results', locale=poll.locale), parse_mode='markdown', reply_markup=get_main_keyboard(user), ) increase_stat(session, 'show_results') elif action == StartAction.share_poll: update.message.chat.send_message( i18n.t('external.share_poll', locale=poll.locale), reply_markup=get_external_share_keyboard(poll)) increase_stat(session, 'externally_shared')
def handle_callback_query(bot, update, session, user): """Handle callback queries from inline keyboards.""" context = CallbackContext(session, bot, update.callback_query, user) breadcrumbs.record( data={ 'query': update.callback_query, 'data': update.callback_query.data, 'user': user, 'callback_type': context.callback_type, 'callback_result': context.callback_result, 'poll': context.poll, }, category='callbacks', ) def ignore(session, context): context.query.answer( "This button doesn't do anything and is just for styling.") callback_functions = { # Creation CallbackType.show_poll_type_keyboard: show_poll_type_keyboard, CallbackType.change_poll_type: change_poll_type, CallbackType.toggle_anonymity: toggle_anonymity, CallbackType.all_options_entered: all_options_entered, CallbackType.toggle_results_visible: toggle_results_visible, CallbackType.open_creation_datepicker: open_creation_datepicker, CallbackType.close_creation_datepicker: close_creation_datepicker, CallbackType.pick_date_option: add_date, CallbackType.skip_description: skip_description, CallbackType.cancel_creation: cancel_creation, # Voting CallbackType.vote: handle_vote, # Menu CallbackType.menu_back: go_back, CallbackType.menu_vote: show_vote_menu, CallbackType.menu_option: show_settings, CallbackType.menu_delete: show_deletion_confirmation, CallbackType.menu_show: show_menu, CallbackType.menu_close: show_close_confirmation, # Poll management CallbackType.delete: delete_poll, CallbackType.delete_poll_with_messages: delete_poll_with_messages, CallbackType.close: close_poll, CallbackType.reopen: reopen_poll, CallbackType.reset: reset_poll, CallbackType.clone: clone_poll, # Settings CallbackType.settings_anonymization_confirmation: show_anonymization_confirmation, CallbackType.settings_anonymization: make_anonymous, CallbackType.settings_show_styling: show_styling_menu, CallbackType.settings_new_option: expect_new_option, CallbackType.settings_show_remove_option_menu: show_remove_options_menu, CallbackType.settings_remove_option: remove_option, CallbackType.settings_toggle_allow_new_options: toggle_allow_new_options, CallbackType.settings_open_add_option_datepicker: open_new_option_datepicker, CallbackType.settings_open_due_date_datepicker: open_due_date_datepicker, CallbackType.settings_pick_due_date: pick_due_date, CallbackType.settings_remove_due_date: remove_due_date, CallbackType.settings_open_language_picker: open_language_picker, CallbackType.settings_change_poll_language: change_poll_language, # Styling CallbackType.settings_toggle_percentage: toggle_percentage, CallbackType.settings_toggle_date_format: toggle_date_format, CallbackType.settings_toggle_summarization: toggle_summerization, CallbackType.settings_user_sorting: set_user_order, CallbackType.settings_option_sorting: set_option_order, CallbackType.settings_toggle_compact_buttons: toggle_compact_doodle_buttons, # User CallbackType.user_change_language: change_user_language, # Datepicker CallbackType.set_date: set_date, CallbackType.next_month: set_next_month, CallbackType.previous_month: set_previous_month, # External CallbackType.activate_notification: activate_notification, CallbackType.external_open_datepicker: open_external_datepicker, CallbackType.external_open_menu: open_external_menu, CallbackType.external_cancel: external_cancel, # Misc CallbackType.switch_help: switch_help, CallbackType.show_option_name: show_option_name, # Ignore CallbackType.ignore: ignore, } response = callback_functions[context.callback_type](session, context) # Callback handler functions always return the callback answer # The only exception is the vote function, which is way too complicated and # implements its own callback query answer logic. if response is not None and context.callback_type != CallbackType.vote: context.query.answer(response) else: context.query.answer('') increase_stat(session, 'callback_calls') return
def handle_async_callback_query(bot, update, session, user): """Handle asynchronous callback queries. Most callback queries are unproblematic in terms of causing race-conditions. Thereby they can be handled asynchronously. However, we do handle votes asynchronously as an edge-case, since we want those calls to be handled as fast as possible. The race condition handling for votes is handled in the respective `handle_vote` function. """ context = get_context(bot, update, session, user) # Vote logic needs some special handling if context.callback_type == CallbackType.vote: option = session.query(Option).get(context.payload) if option is None: return poll = option.poll # Ensure user statistics exist for this poll owner # We need to track at least some user activity, since there seem to be some users which # abuse the bot by creating polls and spamming up to 1 million votes per day. # # I really hate doing this, but I don't see another way to prevent DOS attacks # without tracking at least some numbers. user_statistic = session.query(UserStatistic).get( (date.today(), poll.user.id)) if user_statistic is None: user_statistic = UserStatistic(poll.user) session.add(user_statistic) try: session.commit() # Handle race condition for parallel user statistic creation # Return the statistic that has already been created in another session except IntegrityError as e: session.rollback() user_statistic = session.query(UserStatistic).get( (date.today(), poll.user.id)) if user_statistic is None: raise e # Increase stats before we do the voting logic # Otherwise the user might dos the bot by triggering flood exceptions # before actually being able to increase the stats increase_user_stat(session, context.user, "votes") increase_user_stat(session, poll.user, "poll_callback_calls") session.commit() response = handle_vote(session, context, option) else: increase_user_stat(session, context.user, "callback_calls") session.commit() response = async_callback_mapping[context.callback_type](session, context) # Callback handler functions always return the callback answer # The only exception is the vote function, which is way too complicated and # implements its own callback query answer logic. if response is not None and context.callback_type != CallbackType.vote: context.query.answer(response) else: context.query.answer("") increase_stat(session, "callback_calls") return
def handle_vote(session, context, option): """Handle any clicks on vote buttons.""" # Remove the poll, in case it got deleted, but we didn't manage to kill all references if option is None: if context.query.message is not None: context.query.message.edit_text( i18n.t("deleted.polls", locale=context.user.locale) ) else: context.bot.edit_message_text( i18n.t("deleted.polls", locale=context.user.locale), inline_message_id=context.query.inline_message_id, ) return poll = option.poll update_poll = False try: # Single vote if poll.poll_type == PollType.single_vote.name: update_poll = handle_single_vote(session, context, option) # Block vote elif poll.poll_type == PollType.block_vote.name: update_poll = handle_block_vote(session, context, option) # Limited vote elif poll.poll_type == PollType.limited_vote.name: update_poll = handle_limited_vote(session, context, option) # Cumulative vote elif poll.poll_type == PollType.cumulative_vote.name: update_poll = handle_cumulative_vote(session, context, option) elif poll.poll_type == PollType.count_vote.name: update_poll = handle_cumulative_vote( session, context, option, limited=False ) elif poll.poll_type == PollType.doodle.name: update_poll = handle_doodle_vote(session, context, option) elif poll.poll_type == PollType.priority.name: update_poll = handle_priority_vote(session, context, option) else: raise Exception("Unknown poll type") session.commit() except IntegrityError: # Double vote. Rollback the transaction and ignore the second vote session.rollback() return except ObjectDeletedError: # Vote on already removed vote. Rollback the transaction and ignore session.rollback() return except StaleDataError: # Try to edit a vote that has already been deleted. # This happens, if users spam the vote buttons. # Rollback the transaction and ignore session.rollback() return except OperationalError: # This happens, when a deadlock is created. # That can be caused by users spamming the vote button. session.rollback() return except NoResultFound: # This can happen if a user concurrently upvotes and downvotes an option. # -> Downvote deletes the Vote, upvote tries to change the Vote. session.rollback() return # Update the reference depending on message type message = context.query.message inline_message_id = context.query.inline_message_id if update_poll: if message is not None: update_poll_messages( session, context.bot, poll, message.message_id, context.user ) else: update_poll_messages( session, context.bot, poll, inline_message_id=inline_message_id ) increase_stat(session, "votes") session.commit()