Example #1
0
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")
Example #2
0
    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
Example #3
0
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')
Example #4
0
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
Example #5
0
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")
Example #6
0
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')
Example #7
0
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
Example #8
0
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()
Example #9
0
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')
Example #10
0
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
Example #11
0
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
Example #12
0
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()