Example #1
0
    def test_bool_argument():
        arg = Argument(name="boolean_arg",
                       description="boolean description",
                       type=bool,
                       example="0")

        assert not arg.parse_arg_value("0")
    def test_bool_argument(self):
        arg = Argument(name="boolean_arg",
                       description="boolean description",
                       type=bool,
                       example="0")

        self.assertFalse(arg.parse_arg_value("0"))
Example #3
0
    def test_named_argument(self):
        arg1 = Argument(name="int_arg",
                        description="str description",
                        type=int,
                        example="5")
        arg2 = Argument(name="str_arg",
                        description="str description",
                        example="text")
        arg3 = Argument(name="float_arg",
                        description="str description",
                        type=float,
                        example="1.23",
                        optional=True,
                        default=12.5)

        bot_username = "******"
        command_line = '/command 12345 --{} "two words"'.format(arg2.name)
        expected_args = [arg1, arg2, arg3]

        command, parsed_args = parse_telegram_command(bot_username,
                                                      command_line,
                                                      expected_args)

        self.assertEqual(command, "command")
        self.assertEqual(len(parsed_args), len(expected_args))
        self.assertEqual(parsed_args[arg1.name], 12345)
        self.assertEqual(parsed_args[arg2.name], "two words")
        self.assertEqual(parsed_args[arg3.name], arg3.default)
Example #4
0
    def test_named_argument():
        arg1 = Argument(name="int_arg",
                        description="str description",
                        type=int,
                        example="5")
        arg2 = Argument(name="str_arg",
                        description="str description",
                        example="text")
        arg3 = Argument(name="float_arg",
                        description="str description",
                        type=float,
                        example="1.23",
                        optional=True,
                        default=12.5)

        bot_username = "******"
        command_line = '/command 12345 --{} "two words"'.format(arg2.name)
        expected_args = [arg1, arg2, arg3]

        command, parsed_args = parse_telegram_command(bot_username,
                                                      command_line,
                                                      expected_args)

        def test(str_arg, int_arg, float_arg):
            assert int_arg == 12345
            assert str_arg == "two words"
            assert float_arg == arg3.default
            return True

        assert command == "command"
        assert len(parsed_args) == len(expected_args)
        assert test(**parsed_args)
Example #5
0
    def test_int_argument():
        arg = Argument(name="int_arg",
                       description="int description",
                       type=int,
                       example="0")

        assert arg.parse_arg_value("0") == 0
        assert arg.parse_arg_value("01") == 1
        assert arg.parse_arg_value("10") == 10
    def test_int_argument(self):
        arg = Argument(name="int_arg",
                       description="int description",
                       type=int,
                       example="0")

        self.assertEqual(arg.parse_arg_value("0"), 0)
        self.assertEqual(arg.parse_arg_value("01"), 1)
        self.assertEqual(arg.parse_arg_value("10"), 10)
Example #7
0
    def test_naming_prefix_within_quote(self):
        arg1 = Argument(name="a", description="str description", example="v")
        arg_val = "--a"

        bot_username = "******"
        command_line = '/command "{}"'.format(arg_val)
        expected_args = [arg1]

        command, parsed_args = parse_telegram_command(bot_username,
                                                      command_line,
                                                      expected_args)

        self.assertEqual(parsed_args[arg1.name], arg_val)
Example #8
0
    def test_excess_named_args(self):
        flag1 = Argument(name="flag",
                         description="some flag description",
                         flag=True,
                         example="")

        bot_username = "******"
        command_line = '/command hello --{} --fail 123'.format(flag1.name)
        expected_args = [
            flag1,
        ]

        self.assertRaises(ValueError, parse_telegram_command, bot_username,
                          command_line, expected_args)
    def test_float_argument(self):
        arg = Argument(name="float_arg",
                       description="float description",
                       type=float,
                       example="1.2")

        self.assertEqual(arg.parse_arg_value("0"), 0.0)
        self.assertEqual(arg.parse_arg_value("01"), 1.0)
        self.assertEqual(arg.parse_arg_value("10"), 10.0)
        self.assertEqual(arg.parse_arg_value("10.2"), 10.2)
        self.assertEqual(arg.parse_arg_value("3%"), 0.03)
Example #10
0
    def test_float_argument():
        arg = Argument(name="float_arg",
                       description="float description",
                       type=float,
                       example="1.2")

        assert arg.parse_arg_value("0") == 0.0
        assert arg.parse_arg_value("01") == 1.0
        assert arg.parse_arg_value("10") == 10.0
        assert arg.parse_arg_value("10.2") == 10.2
        assert arg.parse_arg_value("3%") == 0.03
Example #11
0
    def test_escape_char(self):
        arg1 = Argument(name="a", description="str description", example="v")

        arg_values = [{
            "in": 'te\\"st',
            "out": 'te"st'
        }, {
            "in": 'test\\"',
            "out": 'test"'
        }]

        bot_username = "******"
        expected_args = [arg1]

        for arg_val in arg_values:
            command_line = '/command "{}"'.format(arg_val["in"])
            command, parsed_args = parse_telegram_command(
                bot_username, command_line, expected_args)
            self.assertEqual(parsed_args[arg1.name], arg_val["out"])
Example #12
0
    def test_excess_floating_args(self):
        flag1 = Argument(name="flag",
                         description="some flag description",
                         flag=True,
                         example="")

        bot_username = "******"
        command_line = '/command --{} 123 haha'.format(flag1.name)
        expected_args = [
            flag1,
        ]

        command, parsed_args = parse_telegram_command(bot_username,
                                                      command_line,
                                                      expected_args)

        self.assertEqual(command, "command")
        self.assertEqual(len(parsed_args), len(expected_args))
        self.assertTrue("flag" in parsed_args)
        self.assertTrue(parsed_args["flag"] is True)
Example #13
0
class InfiniteWisdomBot:
    """
    The main entry class of the InfiniteWisdom telegram bot
    """

    def __init__(self, config: AppConfig, persistence: ImageDataPersistence, image_analysers: [ImageAnalyser]):
        """
        Creates an instance.
        :param config: configuration object
        :param persistence: image persistence
        :param image_analysers: list of image analysers
        """
        self._config = config
        self._persistence = persistence
        self._image_analysers = image_analysers

        self._updater = Updater(token=self._config.TELEGRAM_BOT_TOKEN.value, use_context=True)
        LOGGER.debug("Using bot id '{}' ({})".format(self._updater.bot.id, self._updater.bot.name))

        self._dispatcher = self._updater.dispatcher

        handlers = [
            CommandHandler(COMMAND_START,
                           filters=(~ Filters.reply) & (~ Filters.forwarded),
                           callback=self._start_callback),
            CommandHandler(COMMAND_INSPIRE,
                           filters=(~ Filters.reply) & (~ Filters.forwarded),
                           callback=self._inspire_callback),
            CommandHandler(COMMAND_FORCE_ANALYSIS,
                           filters=(~ Filters.forwarded),
                           callback=self._forceanalysis_callback),
            CommandHandler(COMMAND_STATS,
                           filters=(~ Filters.reply) & (~ Filters.forwarded),
                           callback=self._stats_callback),
            CommandHandler(REPLY_COMMAND_INFO,
                           filters=Filters.reply & (~ Filters.forwarded),
                           callback=self._reply_info_command_callback),
            CommandHandler(REPLY_COMMAND_TEXT,
                           filters=Filters.reply & (~ Filters.forwarded),
                           callback=self._reply_text_command_callback),
            CommandHandler(REPLY_COMMAND_DELETE,
                           filters=Filters.reply & (~ Filters.forwarded),
                           callback=self._reply_delete_command_callback),
            CommandHandler(COMMAND_VERSION,
                           filters=(~ Filters.reply) & (~ Filters.forwarded),
                           callback=self._version_command_callback),
            CommandHandler(COMMAND_CONFIG,
                           filters=(~ Filters.reply) & (~ Filters.forwarded),
                           callback=self._config_command_callback),
            CommandHandler(COMMAND_COMMANDS,
                           filters=(~ Filters.reply) & (~ Filters.forwarded),
                           callback=self._commands_command_callback),
            # unknown command handler
            MessageHandler(
                filters=Filters.command & (~ Filters.forwarded),
                callback=self._unknown_command_callback),
            InlineQueryHandler(self._inline_query_callback),
            ChosenInlineResultHandler(self._inline_result_chosen_callback)
        ]

        for handler in handlers:
            self._updater.dispatcher.add_handler(handler)

    @property
    def bot(self):
        return self._updater.bot

    def start(self):
        """
        Starts up the bot.
        This means filling the url pool and listening for messages.
        """
        self._updater.start_polling()
        self._updater.idle()

    def stop(self):
        """
        Shuts down the bot.
        """
        self._updater.stop()

    @START_TIME.time()
    def _start_callback(self, update: Update, context: CallbackContext) -> None:
        """
        Welcomes a new user with an example image and a greeting message
        :param update: the chat update object
        :param context: telegram context
        """
        bot = context.bot
        self._send_random_quote(update, context)
        greeting_message = self._config.TELEGRAM_GREETING_MESSAGE.value
        if greeting_message is not None and len(greeting_message) > 0:
            send_message(bot=bot, chat_id=update.message.chat_id, message=greeting_message)

    @command(
        name=COMMAND_INSPIRE,
        description="Get inspired by a random quote of infinite wisdom."
    )
    @INSPIRE_TIME.time()
    def _inspire_callback(self, update: Update, context: CallbackContext) -> None:
        """
        /inspire command handler
        :param update: the chat update object
        :param context: telegram context
        """
        self._send_random_quote(update, context)

    @command(
        name=COMMAND_FORCE_ANALYSIS,
        description="Force a re-analysis of an existing image.",
        arguments=[
            Argument(
                name="image_hash",
                description="The hash of the image to reset.",
                example="d41d8cd98f00b204e9800998ecf8427e",
                optional=True
            )
        ],
        permissions=CONFIG_ADMINS
    )
    def _forceanalysis_callback(self, update: Update, context: CallbackContext, image_hash: str or None) -> None:
        """
        /forceanalysis command handler (with an argument)
        :param update: the chat update object
        :param context: telegram context
        """
        bot = context.bot
        message = update.effective_message
        chat_id = update.effective_chat.id

        with _session_scope() as session:
            if image_hash is not None:
                entity = self._persistence.find_by_image_hash(session, image_hash)
            elif message.reply_to_message is not None:
                reply_to_message = message.reply_to_message

                entity = self._find_entity_for_message(session, bot.id, reply_to_message)
            else:
                send_message(bot, chat_id,
                             ":exclamation: Missing image reply or image hash argument".format(image_hash),
                             reply_to=message.message_id)
                return

            if entity is None:
                send_message(bot, chat_id,
                             ":exclamation: Image entity not found".format(image_hash),
                             reply_to=message.message_id)
                return

            entity.analyser = None
            entity.analyser_quality = None
            self._persistence.update(session, entity)
            send_message(bot, chat_id,
                         ":wrench: Reset analyser data for image with hash: {})".format(entity.image_hash),
                         reply_to=message.message_id)

    def _find_entity_for_message(self, session, bot_id, message):
        """
        Tries to find an entity for a given message
        :param bot_id: the id of this bot
        :param message: the message
        :return: image entity or None if no entity was found
        """
        if message is None:
            return None

        entity = None
        if (message.from_user is None
                or message.from_user.id != bot_id
                or message.effective_attachment is None):
            return None

        for attachment in message.effective_attachment:
            telegram_file_id = attachment.file_id
            entity = self._persistence.find_by_telegram_file_id(session, telegram_file_id)
            if entity is not None:
                break

        return entity

    @command(
        name=COMMAND_STATS,
        description="List statistics of this bot.",
        permissions=CONFIG_ADMINS
    )
    def _stats_callback(self, update: Update, context: CallbackContext) -> None:
        """
        /stats command handler
        :param update: the chat update object
        :param context: telegram context
        """
        bot = context.bot
        message = update.effective_message
        chat_id = update.effective_chat.id

        text = format_metrics()

        send_message(bot, chat_id, text, reply_to=message.message_id)

    @command(
        name=COMMAND_VERSION,
        description="Show the version of this bot.",
        permissions=CONFIG_ADMINS
    )
    def _version_command_callback(self, update: Update, context: CallbackContext) -> None:
        """
        /stats command handler
        :param update: the chat update object
        :param context: telegram context
        """
        bot = context.bot
        message = update.effective_message
        chat_id = update.effective_chat.id

        from infinitewisdom.const import __version__
        text = "{}".format(__version__)
        send_message(bot, chat_id, text, reply_to=message.message_id)

    @command(
        name=COMMAND_CONFIG,
        description="Show current application configuration.",
        permissions=PRIVATE_CHAT & CONFIG_ADMINS
    )
    def _config_command_callback(self, update: Update, context: CallbackContext):
        """
        /config command handler
        :param update: the chat update object
        :param context: telegram context
        """
        from container_app_conf.formatter.toml import TomlFormatter

        bot = context.bot
        chat_id = update.effective_message.chat_id
        message_id = update.effective_message.message_id

        text = self._config.print(formatter=TomlFormatter())
        text = "```\n{}\n```".format(text)
        send_message(bot, chat_id, text, parse_mode=ParseMode.MARKDOWN, reply_to=message_id)

    @command(
        name=REPLY_COMMAND_INFO,
        description="Show information of the image that is referenced via message reply.",
        permissions=CONFIG_ADMINS
    )
    @requires_image_reply
    def _reply_info_command_callback(self, update: Update, context: CallbackContext,
                                     entity_of_reply: Image or None) -> None:
        """
        /info reply command handler
        :param update: the chat update object
        :param context: telegram context
        """
        bot = context.bot
        message = update.effective_message
        chat_id = update.effective_chat.id

        send_message(bot, chat_id, "{}".format(entity_of_reply),
                     parse_mode=ParseMode.MARKDOWN,
                     reply_to=message.message_id)

    @command(
        name=REPLY_COMMAND_TEXT,
        description="Set the text of the image that is referenced via message reply.",
        arguments=[
            Argument(
                name="text",
                description="The text to set.",
                example="This is a very inspirational quote.",
                validator=lambda x: x and x.strip()
            )
        ],
        permissions=CONFIG_ADMINS
    )
    @requires_image_reply
    def _reply_text_command_callback(self, update: Update, context: CallbackContext,
                                     entity_of_reply: Image or None, text: str) -> None:
        """
        /text reply command handler
        :param update: the chat update object
        :param context: telegram context
        """
        bot = context.bot
        message = update.effective_message
        chat_id = update.effective_chat.id

        entity_of_reply.analyser = IMAGE_ANALYSIS_TYPE_HUMAN
        entity_of_reply.analyser_quality = 1.0
        entity_of_reply.text = text
        with _session_scope() as session:
            self._persistence.update(session, entity_of_reply)
        send_message(bot, chat_id,
                     ":wrench: Updated text for referenced image to '{}' (Hash: {})".format(entity_of_reply.text,
                                                                                            entity_of_reply.image_hash),
                     reply_to=message.message_id)

    @command(
        name=REPLY_COMMAND_DELETE,
        description="Delete the image that is referenced via message reply.",
        permissions=CONFIG_ADMINS
    )
    @requires_image_reply
    def _reply_delete_command_callback(self, update: Update, context: CallbackContext,
                                       entity_of_reply: Image or None) -> None:
        """
        /text reply command handler
        :param update: the chat update object
        :param context: telegram context
        """
        bot = context.bot
        message = update.effective_message
        chat_id = update.effective_chat.id
        is_edit = hasattr(message, 'edited_message') and message.edited_message is not None

        if is_edit:
            LOGGER.debug("Ignoring edited delete command")
            return

        with _session_scope() as session:
            self._persistence.delete(session, entity_of_reply)
            send_message(bot, chat_id,
                         "Deleted referenced image from persistence (Hash: {})".format(entity_of_reply.image_hash),
                         reply_to=message.message_id)

    @command(
        name=COMMAND_COMMANDS,
        description="List commands supported by this bot.",
        permissions=CONFIG_ADMINS
    )
    def _commands_command_callback(self, update: Update, context: CallbackContext):
        bot = context.bot
        message = update.effective_message
        chat_id = update.effective_chat.id

        from telegram_click import generate_command_list
        text = generate_command_list(update, context)
        send_message(bot, chat_id, text,
                     parse_mode=ParseMode.MARKDOWN,
                     reply_to=message.message_id)

    def _unknown_command_callback(self, update: Update, context: CallbackContext) -> None:
        """
        Handles unknown commands send by a user
        :param update: the chat update object
        :param context: telegram context
        """
        message = update.effective_message
        username = "******"
        if update.effective_user is not None:
            username = update.effective_user.username

        user_is_admin = username in self._config.TELEGRAM_ADMIN_USERNAMES.value
        if user_is_admin:
            self._commands_command_callback(update, context)
            return
        else:
            self._inspire_callback(update, context)

    @INLINE_TIME.time()
    def _inline_query_callback(self, update: Update, context: CallbackContext) -> None:
        """
        Responds to an inline client request with a list of 16 randomly chosen images
        :param update: the chat update object
        :param context: telegram context
        """
        LOGGER.debug('Inline query')

        query = update.inline_query.query
        offset = update.inline_query.offset
        if not offset:
            offset = 0
        else:
            offset = int(offset)
        badge_size = self._config.TELEGRAM_INLINE_BADGE_SIZE.value

        with _session_scope() as session:
            if len(query) > 0:
                entities = self._persistence.find_by_text(session, query, badge_size, offset)
            else:
                entities = self._persistence.get_random(session, page_size=badge_size)

            results = list(map(lambda x: self._entity_to_inline_query_result(x), entities))
        
        LOGGER.debug('Inline query "{}": {}+{} results'.format(query, len(results), offset))
        if len(results) > 0:
            new_offset = offset + badge_size
        else:
            new_offset = ''

        update.inline_query.answer(
            results,
            next_offset=new_offset
        )

    @staticmethod
    def _inline_result_chosen_callback(update: Update, context: CallbackContext):
        """
        Called when an inline result is chosen by a user
        :param update: the chat update object
        :param context: telegram context
        """
        CHOSEN_INLINE_RESULTS.inc()

    def _send_random_quote(self, update: Update, context: CallbackContext) -> None:
        """
        Sends a quote from the pool to the requesting chat
        :param update: the chat update object
        :param context: telegram context
        """
        bot = context.bot
        chat_id = update.effective_chat.id
        bot.send_chat_action(chat_id=chat_id, action=ChatAction.TYPING)

        with _session_scope() as session:

            entity = self._persistence.get_random(session)
            LOGGER.debug("Sending random quote '{}' to chat id: {}".format(entity.image_hash, chat_id))

            caption = None
            if self._config.TELEGRAM_CAPTION_IMAGES_WITH_TEXT.value:
                caption = entity.text

            telegram_file_ids_for_current_bot = self.find_telegram_file_ids_for_current_bot(bot.token, entity)
            if len(telegram_file_ids_for_current_bot) > 0:
                file_ids = send_photo(bot=bot, chat_id=chat_id, file_id=telegram_file_ids_for_current_bot[0].id,
                                      caption=caption)
                bot_token = self._persistence.get_bot_token(session, bot.token)
                for file_id in file_ids:
                    entity.add_file_id(bot_token, file_id)
                self._persistence.update(session, entity)
                return

            image_bytes = self._persistence.get_image_data(entity)
            file_ids = send_photo(bot=bot, chat_id=chat_id, image_data=image_bytes, caption=caption)
            bot_token = self._persistence.get_bot_token(session, bot.token)
            for file_id in file_ids:
                entity.add_file_id(bot_token, file_id)
            self._persistence.update(session, entity, image_bytes)

    def _entity_to_inline_query_result(self, entity: Image):
        """
        Creates a telegram inline query result object for the given entity
        :param entity: the entity to use
        :return: inline result object
        """
        telegram_file_ids_for_current_bot = self.find_telegram_file_ids_for_current_bot(self.bot.token, entity)
        if len(telegram_file_ids_for_current_bot) > 0:
            return InlineQueryResultCachedPhoto(
                id=entity.image_hash,
                photo_file_id=str(telegram_file_ids_for_current_bot[0].id),
            )
        else:
            return InlineQueryResultPhoto(
                id=entity.image_hash,
                photo_url=entity.url,
                thumb_url=entity.url,
                photo_height=50,
                photo_width=50
            )

    @staticmethod
    def find_telegram_file_ids_for_current_bot(token: str, entity: Image) -> []:
        """
        Filters all telegram file ids of an image for the current bot
        :param token: the bot token
        :param entity: the image entity
        :return: list of matching telegram file ids
        """
        hashed_bot_token = cryptographic_hash(token)
        result = []
        for file_id in entity.telegram_file_ids:
            if hashed_bot_token in list(map(lambda x: x.hashed_token, file_id.bot_tokens)):
                result.append(file_id)
        return result
Example #14
0
    def test_str_argument():
        arg = Argument(name="str_arg",
                       description="str description",
                       example="text")

        assert arg.parse_arg_value("sample") == "sample"
Example #15
0
class MyBot:
    name = None
    child_count = None

    def __init__(self):
        self._updater = Updater(token=os.environ.get("TELEGRAM_BOT_KEY"),
                                use_context=True)

        handler_groups = {
            1: [
                CommandHandler(['help', 'h'],
                               filters=(~Filters.forwarded) & (~Filters.reply),
                               callback=self._commands_command_callback),
                CommandHandler('start',
                               filters=(~Filters.forwarded) & (~Filters.reply),
                               callback=self._start_command_callback),
                CommandHandler(['name', 'n'],
                               filters=(~Filters.forwarded) & (~Filters.reply),
                               callback=self._name_command_callback),
                CommandHandler(['age', 'a'],
                               filters=(~Filters.forwarded) & (~Filters.reply),
                               callback=self._age_command_callback),
                CommandHandler(['children', 'c'],
                               filters=(~Filters.forwarded) & (~Filters.reply),
                               callback=self._children_command_callback),
                # Unknown command handler
                MessageHandler(Filters.command,
                               callback=self._unknown_command_callback)
            ]
        }

        for group, handlers in handler_groups.items():
            for handler in handlers:
                self._updater.dispatcher.add_handler(handler, group=group)

    def start(self):
        """
        Starts up the bot.
        This means filling the url pool and listening for messages.
        """
        self._updater.start_polling(clean=True)
        self._updater.idle()

    def _unknown_command_callback(self, update: Update,
                                  context: CallbackContext):
        self._send_command_list(update, context)

    # Optionally specify this command to list all available commands
    @command(name=['help', 'h'],
             description='List commands supported by this bot.')
    def _commands_command_callback(self, update: Update,
                                   context: CallbackContext):
        self._send_command_list(update, context)

    @command(name='start', description='Start bot interaction')
    def _start_command_callback(self, update: Update,
                                context: CallbackContext):
        self._send_command_list(update, context)

    @staticmethod
    def _send_command_list(update: Update, context: CallbackContext):
        bot = context.bot
        chat_id = update.effective_message.chat_id
        text = generate_command_list(update, context)
        bot.send_message(chat_id, text, parse_mode=ParseMode.MARKDOWN)

    @command(
        name=['name', 'n'],
        description='Get/Set a name',
        arguments=[
            Argument(name=['name', 'n'],
                     description='The new name',
                     validator=lambda x: x.strip(),
                     optional=True,
                     example='Markus'),
            Flag(name=['flag', 'f'],
                 description="Some flag that changes the command behaviour.")
        ])
    def _name_command_callback(self, update: Update, context: CallbackContext,
                               name: str or None, flag: bool):
        chat_id = update.effective_chat.id
        if name is None:
            message = 'Current: {}'.format(self.name)
        else:
            self.name = name
            message = 'New: {}'.format(self.name)
        message += '\n' + 'Flag is: {}'.format(flag)
        context.bot.send_message(chat_id, message)

    @command(name=['age', 'a'],
             description='Set age',
             arguments=[
                 Argument(name=['age', 'a'],
                          description='The new age',
                          type=int,
                          validator=lambda x: x > 0,
                          example='25')
             ],
             permissions=MyPermission() & ~GROUP_ADMIN &
             (USER_NAME('markusressel') | USER_ID(123456)))
    def _age_command_callback(self, update: Update, context: CallbackContext,
                              age: int):
        context.bot.send_message(update.effective_chat.id,
                                 'New age: {}'.format(age))

    @command(name=['children', 'c'],
             description='Set children amount',
             arguments=[
                 Argument(name=['amount', 'a'],
                          description='The new amount',
                          type=float,
                          validator=lambda x: x >= 0,
                          example='1.57')
             ])
    def _children_command_callback(self, update: Update,
                                   context: CallbackContext, amount: float
                                   or None):
        chat_id = update.effective_chat.id
        if amount is None:
            context.bot.send_message(chat_id,
                                     'Current: {}'.format(self.child_count))
        else:
            self.child_count = amount
            context.bot.send_message(chat_id, 'New: {}'.format(amount))
Example #16
0
class DeineMuddaBot:

    def __init__(self, config: AppConfig, persistence: Persistence):
        self._antispam = AntiSpam(config, persistence)
        self._config = config
        self._persistence = persistence
        self._response_manager = ResponseManager(self._persistence)

        self._updater = Updater(token=self._config.TELEGRAM_BOT_TOKEN.value,
                                use_context=True)

        handler_groups = {
            0: [MessageHandler(filters=None, callback=self._any_message_callback)],
            1: [CommandHandler(
                COMMAND_HELP,
                    filters=(~ Filters.forwarded) & (~ Filters.reply),
                callback=self._help_command_callback),
                CommandHandler(
                    COMMAND_VERSION,
                    filters=(~ Filters.forwarded) & (~ Filters.reply),
                    callback=self._version_command_callback),
                CommandHandler(
                    COMMAND_CONFIG,
                    filters=(~ Filters.forwarded) & (~ Filters.reply),
                    callback=self._config_command_callback),
                CommandHandler(
                    COMMAND_STATS,
                    filters=(~ Filters.forwarded) & (~ Filters.reply),
                    callback=self._stats_command_callback),
                CommandHandler(
                    COMMAND_MUDDA,
                    filters=(~ Filters.forwarded),
                    callback=self._mudda_command_callback),
                CommandHandler(
                    COMMAND_LIST_USERS,
                    filters=(~ Filters.forwarded) & (~ Filters.reply),
                    callback=self._list_users_command_callback),
                CommandHandler(
                    COMMAND_BAN,
                    filters=(~ Filters.forwarded) & (~ Filters.reply),
                    callback=self._ban_command_callback),
                CommandHandler(
                    COMMAND_UNBAN,
                    filters=(~ Filters.forwarded) & (~ Filters.reply),
                    callback=self._unban_command_callback),
                CommandHandler(
                    COMMAND_GET_SETTINGS,
                    filters=(~ Filters.forwarded) & (~ Filters.reply),
                    callback=self._get_settings_command_callback),
                CommandHandler(
                    COMMAND_SET_ANTISPAM,
                    filters=(~ Filters.forwarded) & (~ Filters.reply),
                    callback=self._set_antispam_command_callback),
                CommandHandler(
                    COMMAND_SET_CHANCE,
                    filters=(~ Filters.forwarded) & (~ Filters.reply),
                    callback=self._set_chance_command_callback),
                MessageHandler(Filters.text, callback=self._message_callback),
                MessageHandler(
                    filters=Filters.command & ~ Filters.reply,
                    callback=self._help_command_callback)],
            2: [MessageHandler(
                filters=Filters.group & (~ Filters.reply) & (~ Filters.forwarded),
                callback=self._group_message_callback)],
        }

        for group, handlers in handler_groups.items():
            for handler in handlers:
                self._updater.dispatcher.add_handler(handler, group=group)

    def start(self):
        """
        Starts up the bot.
        This means filling the url pool and listening for messages.
        """
        self._updater.start_polling(clean=True)
        self._updater.idle()

    def stop(self):
        """
        Shuts down the bot.
        """
        self._updater.stop()

    def _shout(self, bot: Bot, message, text: str, reply: bool or int = True):
        """
        Shouts a message into the given chat
        :param bot: bot object
        :param message: message object
        :param text: text to shout
        :param reply: True to reply to the message's user, int to reply to a specific message, False is no reply
        """
        shouting_text = "<b>{}!!!</b>".format(text.upper())

        reply_to_message_id = None
        if reply:
            if isinstance(reply, bool):
                reply_to_message_id = message.message_id
            elif isinstance(reply, int):
                reply_to_message_id = reply
            else:
                raise AttributeError(f"Unsupported reply parameter: {reply}")

        send_message(bot, message.chat_id,
                     message=shouting_text,
                     parse_mode=ParseMode.HTML,
                     reply_to=reply_to_message_id)

    def _any_message_callback(self, update: Update, context: CallbackContext):
        chat_id = update.effective_message.chat_id
        chat_type = update.effective_chat.type

        chat = self._persistence.get_chat(chat_id)
        from_user = update.effective_message.from_user
        if chat is None:
            # make sure we know about this chat in persistence
            chat = Chat(id=chat_id, type=chat_type)
            chat.set_setting(SETTINGS_ANTISPAM_ENABLED_KEY, SETTINGS_ANTISPAM_ENABLED_DEFAULT)
            chat.set_setting(SETTINGS_TRIGGER_PROBABILITY_KEY, SETTINGS_TRIGGER_PROBABILITY_DEFAULT)
            self._persistence.add_or_update_chat(chat)

        # remember chat user
        chat = self._persistence.get_chat(chat_id)
        self._persistence.add_or_update_chat_member(chat, from_user)

    @MESSAGE_TIME.time()
    def _message_callback(self, update: Update, context: CallbackContext):
        bot = context.bot
        chat_id = update.effective_message.chat_id

        from_user = update.message.from_user
        chat = self._persistence.get_chat(chat_id)

        if len(update.message.text) not in self._config.CHAR_COUNT_RANGE.value:
            return

        if len(update.message.text.split()) not in self._config.WORD_COUNT_RANGE.value:
            return

        response_message = self._response_manager.find_matching_rule(chat, from_user.first_name, update.message.text)
        if response_message:
            self._shout(bot, update.message, response_message)

        MESSAGES_COUNT.labels(chat_id=chat_id).inc()

    def _group_message_callback(self, update: Update, context: CallbackContext):
        bot = context.bot
        effective_message = update.effective_message
        chat_id = effective_message.chat_id
        chat_type = update.effective_chat.type

        my_id = bot.get_me().id

        if effective_message.new_chat_members:
            for member in effective_message.new_chat_members:
                if member.id == my_id:
                    LOGGER.debug("Bot was added to group: {}".format(chat_id))
                    chat_entity = Chat(id=chat_id, type=chat_type)
                    self._persistence.add_or_update_chat(chat_entity)
                else:
                    LOGGER.debug("{} ({}) joined group {}".format(member.full_name, member.id, chat_id))
                    chat = self._persistence.get_chat(chat_id)
                    # remember chat user
                    self._persistence.add_or_update_chat_member(chat, member)

        if effective_message.left_chat_member:
            member = effective_message.left_chat_member
            if member.id == my_id:
                LOGGER.debug("Bot was removed from group: {}".format(chat_id))
                self._persistence.delete_chat(chat_id)
            else:
                LOGGER.debug("{} ({}) left group {}".format(member.full_name, member.id, chat_id))
                chat = self._persistence.get_chat(chat_id)
                chat.users = list(filter(lambda x: x.id != member.id, chat.users))
                self._persistence.add_or_update_chat(chat)

    @command(
        name=COMMAND_HELP,
        description="List commands supported by this bot.",
        permissions=PRIVATE_CHAT | GROUP_CREATOR | GROUP_ADMIN | CONFIG_ADMINS
    )
    def _help_command_callback(self, update: Update, context: CallbackContext):
        bot = context.bot
        chat_id = update.effective_message.chat_id
        text = generate_command_list(update, context)
        send_message(bot, chat_id, text, parse_mode=ParseMode.MARKDOWN)

    @command(
        name=COMMAND_VERSION,
        description="Show application version.",
        permissions=PRIVATE_CHAT | GROUP_CREATOR | GROUP_ADMIN | CONFIG_ADMINS
    )
    def _version_command_callback(self, update: Update, context: CallbackContext):
        bot = context.bot
        chat_id = update.effective_message.chat_id
        message_id = update.effective_message.message_id
        text = "Version: `{}`".format(DEINE_MUDDA_VERSION)
        send_message(bot, chat_id, text, parse_mode=ParseMode.MARKDOWN, reply_to=message_id)

    @command(
        name=COMMAND_CONFIG,
        description="Show current application configuration.",
        permissions=PRIVATE_CHAT & CONFIG_ADMINS
    )
    def _config_command_callback(self, update: Update, context: CallbackContext):
        from container_app_conf.formatter.toml import TomlFormatter

        bot = context.bot
        chat_id = update.effective_message.chat_id
        message_id = update.effective_message.message_id

        text = self._config.print(formatter=TomlFormatter())
        text = "```\n{}\n```".format(text)
        send_message(bot, chat_id, text, parse_mode=ParseMode.MARKDOWN, reply_to=message_id)

    @command(
        name=COMMAND_STATS,
        description="List bot statistics.",
        permissions=PRIVATE_CHAT | GROUP_CREATOR | GROUP_ADMIN | CONFIG_ADMINS
    )
    def _stats_command_callback(self, update: Update, context: CallbackContext) -> None:
        """
        /stats command handler
        :param update: the chat update object
        :param context: telegram context
        """
        bot = context.bot
        message_id = update.effective_message.message_id
        chat_id = update.effective_message.chat_id

        text = format_metrics()

        send_message(bot, chat_id, text, reply_to=message_id)

    @command(
        name=COMMAND_MUDDA,
        description="Trigger the bot manually.",
        permissions=PRIVATE_CHAT | GROUP_CREATOR | GROUP_ADMIN | CONFIG_ADMINS
    )
    def _mudda_command_callback(self, update: Update, context: CallbackContext):
        bot = context.bot
        if self._antispam.process_message(update, context):
            return

        text = "deine mudda"

        reply_to_message = update.message.reply_to_message
        if reply_to_message is not None:
            if not reply_to_message.from_user.is_bot \
                    and reply_to_message.from_user.id != update.message.from_user.id:
                reply = reply_to_message.message_id
            else:
                # ignore reply
                LOGGER.debug(
                    f"Ignoring /mudda call on reply to message {reply_to_message.message_id}: {reply_to_message.text}")
                return
        else:
            reply = False

        self._shout(bot, update.message, text, reply=reply)

    @command(
        name=COMMAND_GET_SETTINGS,
        description="Show settings for the current chat.",
        permissions=PRIVATE_CHAT | GROUP_CREATOR | GROUP_ADMIN | CONFIG_ADMINS
    )
    def _get_settings_command_callback(self, update: Update, context: CallbackContext):
        bot = context.bot
        chat_id = update.effective_message.chat_id
        message_id = update.effective_message.message_id

        chat = self._persistence.get_chat(chat_id)

        lines = []
        for setting in chat.settings:
            lines.append("{}: {}".format(setting.key, setting.value))

        message = "\n".join(lines)
        if message:
            send_message(bot, chat_id, message, reply_to=message_id)
        else:
            # TODO: would be better if this could display default values to give at least some information
            send_message(bot, chat_id, "No chat specific settings set", reply_to=message_id)

    @command(
        name=COMMAND_SET_CHANCE,
        description="Set the trigger probability to a specific value.",
        arguments=[
            Argument(
                name=["probability", "p"],
                example="0.13",
                type=float,
                converter=lambda x: float(x),
                description="The probability to set",
                validator=(lambda x: 0 <= x <= 1)
            )
        ],
        permissions=PRIVATE_CHAT | GROUP_CREATOR | GROUP_ADMIN | CONFIG_ADMINS
    )
    def _set_chance_command_callback(self, update: Update, context: CallbackContext, probability):
        bot = context.bot
        chat_id = update.effective_message.chat_id
        message_id = update.effective_message.message_id

        chat = self._persistence.get_chat(chat_id)
        chat.set_setting(SETTINGS_TRIGGER_PROBABILITY_KEY, str(probability))
        self._persistence.add_or_update_chat(chat)

        send_message(bot, chat_id, message="TriggerChance: {}%".format(probability * 100), reply_to=message_id)

    @command(
        name=COMMAND_SET_ANTISPAM,
        description="Turn antispam feature on/off",
        arguments=[
            Selection(
                name=["state", "s"],
                description="The new state",
                allowed_values=["on", "off"]
            )
        ],
        permissions=PRIVATE_CHAT | GROUP_CREATOR | GROUP_ADMIN | CONFIG_ADMINS
    )
    def _set_antispam_command_callback(self, update: Update, context: CallbackContext, new_state: str):
        bot = context.bot
        chat_id = update.effective_message.chat_id
        message_id = update.effective_message.message_id

        chat = self._persistence.get_chat(chat_id)
        chat.set_setting(SETTINGS_ANTISPAM_ENABLED_KEY, new_state)
        self._persistence.add_or_update_chat(chat)

        send_message(bot, chat_id, message="Antispam: {}".format(new_state), reply_to=message_id)

    @command(
        name=COMMAND_LIST_USERS,
        description="List all known users in this chat",
        permissions=PRIVATE_CHAT | GROUP_CREATOR | GROUP_ADMIN | CONFIG_ADMINS
    )
    def _list_users_command_callback(self, update: Update, context: CallbackContext):
        bot = context.bot
        chat_id = update.effective_message.chat_id
        message_id = update.effective_message.message_id

        chat = self._persistence.get_chat(chat_id)
        message = "\n".join(
            list(map(lambda x: f"{x.id}: {x.username}" + (" (BANNED)" if x.is_banned else ""), chat.users)))

        send_message(bot, chat_id, message=message, reply_to=message_id)

    @command(
        name=COMMAND_BAN,
        description="Ban a user",
        arguments=[
            Argument(
                name=["user", "u"],
                description="Username or user id",
                type=str,
                example="123456789",
            )
        ],
        permissions=PRIVATE_CHAT | GROUP_CREATOR | GROUP_ADMIN | CONFIG_ADMINS
    )
    def _ban_command_callback(self, update: Update, context: CallbackContext, user: str):
        bot = context.bot
        chat_id = update.effective_message.chat_id
        message_id = update.effective_message.message_id

        try:
            user_id = int(user)
            user_entity = self._persistence.get_user(user_id)
        except:
            user_entity = self._persistence.get_user_by_username(user)

        if user_entity is None:
            send_message(bot, chat_id, message=f"User {user} is unknown", reply_to=message_id)
            return

        user_id = user_entity.id
        username = user_entity.username
        user_entity.is_banned = True
        self._persistence.add_or_update_user(user_entity)

        send_message(bot, chat_id, message=f"Banned user: {username} ({user_id})",
                     reply_to=message_id)

    @command(
        name=COMMAND_UNBAN,
        description="Unban a banned user",
        arguments=[
            Argument(
                name=["user", "u"],
                description="Username or user id",
                type=str,
                example="123456789",
            )
        ],
        permissions=PRIVATE_CHAT | GROUP_CREATOR | GROUP_ADMIN | CONFIG_ADMINS
    )
    def _unban_command_callback(self, update: Update, context: CallbackContext, user: str):
        bot = context.bot
        chat_id = update.effective_message.chat_id
        message_id = update.effective_message.message_id

        try:
            user_id = int(user)
            user_entity = self._persistence.get_user(user_id)
        except:
            user_entity = self._persistence.get_user_by_username(user)

        if user_entity is None:
            send_message(bot, chat_id, message=f"User {user} is unknown", reply_to=message_id)
            return

        user_id = user_entity.id
        username = user_entity.username
        user_entity.is_banned = False
        self._persistence.add_or_update_user(user_entity)

        send_message(bot, chat_id,
                     message=f"Unbanned user: {username} ({user_id})",
                     reply_to=message_id)
Example #17
0
class InventoryCommandHandler(GrocyCommandHandler):
    def command_handlers(self):
        return [
            CommandHandler(COMMAND_INVENTORY,
                           filters=(~Filters.reply) & (~Filters.forwarded),
                           callback=self._inventory_callback),
            CommandHandler(COMMAND_INVENTORY_ADD,
                           filters=(~Filters.reply) & (~Filters.forwarded),
                           callback=self._inventory_add_callback),
            CommandHandler(COMMAND_INVENTORY_REMOVE,
                           filters=(~Filters.reply) & (~Filters.forwarded),
                           callback=self._inventory_remove_callback),
        ]

    @command(name=COMMAND_INVENTORY,
             description="List product inventory.",
             arguments=[
                 Flag(name=["missing", "m"],
                      description="Show missing products"),
             ],
             permissions=CONFIG_ADMINS)
    @COMMAND_TIME_INVENTORY.time()
    def _inventory_callback(self, update: Update, context: CallbackContext,
                            missing: bool) -> None:
        """
        Show a list of all products in the inventory
        :param update: the chat update object
        :param context: telegram context
        """
        bot = context.bot
        chat_id = update.effective_chat.id

        products = self._grocy.get_all_products()
        if missing:
            products = list(filter(lambda x: x.amount == 0, products))

        products = sorted(products, key=lambda x: x.name.lower())

        item_texts = list(list(map(product_to_str, products)))
        text = "\n".join([
            "*=> Inventory <=*",
            *item_texts,
        ]).strip()

        send_message(bot, chat_id, text, parse_mode=ParseMode.MARKDOWN)

    @command(name=COMMAND_INVENTORY_REMOVE,
             description="Remove a product from inventory.",
             arguments=[
                 Argument(name=["name"],
                          description="Product name",
                          example="Banana"),
                 Argument(name=["amount"],
                          description="Product amount",
                          type=int,
                          example="2",
                          validator=lambda x: x > 0,
                          optional=True,
                          default=1),
             ],
             permissions=CONFIG_ADMINS)
    @COMMAND_TIME_INVENTORY.time()
    def _inventory_remove_callback(self, update: Update,
                                   context: CallbackContext, name: str,
                                   amount: int) -> None:
        """
        Add a product to the inventory
        :param update: the chat update object
        :param context: telegram context
        """
        products = self._grocy.get_all_products()
        self._reply_keyboard_handler.await_user_selection(
            update,
            context,
            name,
            choices=products,
            key=lambda x: x.name,
            callback=self._remove_product_keyboard_response_callback,
            callback_data={
                "product_name": name,
                "amount": amount,
            })

    @command(name=COMMAND_INVENTORY_ADD,
             description="Add a product to inventory.",
             arguments=[
                 Argument(name=["name"],
                          description="Product name",
                          example="Banana"),
                 Argument(name=["amount"],
                          description="Product amount",
                          type=int,
                          example="2",
                          validator=lambda x: x > 0,
                          optional=True,
                          default=1),
                 Argument(name=["exp"],
                          description="Expiration date or duration",
                          example="20.01.2020",
                          optional=True,
                          default="Never"),
                 Argument(name=["price"],
                          description="Product price",
                          type=float,
                          example="2.80",
                          validator=lambda x: x > 0,
                          optional=True),
             ],
             permissions=CONFIG_ADMINS)
    @COMMAND_TIME_INVENTORY.time()
    def _inventory_add_callback(self, update: Update, context: CallbackContext,
                                name: str, amount: int, exp: str, price: float
                                or None) -> None:
        """
        Add a product to the inventory
        :param update: the chat update object
        :param context: telegram context
        """
        # parse the expiration date input
        if exp.casefold() == "never".casefold():
            exp = NEVER_EXPIRES_DATE
        else:
            try:
                from dateutil import parser
                exp = parser.parse(exp)
            except:
                from pytimeparse import parse
                parsed = parse(exp)
                if parsed is None:
                    raise ValueError(
                        "Cannot parse the given time format: {}".format(exp))
                exp = datetime.now() + timedelta(seconds=parsed)

        products = self._grocy.get_all_products()
        self._reply_keyboard_handler.await_user_selection(
            update,
            context,
            name,
            choices=products,
            key=lambda x: x.name,
            callback=self._add_product_keyboard_response_callback,
            callback_data={
                "product_name": name,
                "amount": amount,
                "exp": exp,
                "price": price
            })

    def _add_product_keyboard_response_callback(self, update: Update,
                                                context: CallbackContext,
                                                product: Product, data: dict):
        """
        Called when the user has selected a product to add to the inventory
        :param update: the chat update object
        :param context: telegram context
        :param product: the selected product
        :param data: callback data
        """
        amount = data["amount"]
        exp = data["exp"]
        price = data["price"]

        self._inventory_add_execute(update, context, product, amount, exp,
                                    price)

    @timing
    def _remove_product_keyboard_response_callback(self, update: Update,
                                                   context: CallbackContext,
                                                   product: Product,
                                                   data: dict):
        """
        Called when the user has selected a product to remove from to the inventory
        :param update: the chat update object
        :param context: telegram context
        :param product: the selected product
        :param data: callback data
        """
        bot = context.bot
        chat_id = update.effective_chat.id
        message_id = update.effective_message.message_id
        amount = data["amount"]

        self._grocy.add_product(product_id=product.id,
                                amount=-amount,
                                price=None)

        text = "Removed {}x {}".format(amount, product.name)
        send_message(bot,
                     chat_id,
                     text,
                     parse_mode=ParseMode.MARKDOWN,
                     reply_to=message_id,
                     menu=ReplyKeyboardRemove(selective=True))

    def _inventory_add_execute(self, update: Update, context: CallbackContext,
                               product: Product, amount: int, exp: datetime,
                               price: float):
        """
        Adds a product to the inventory
        :param update: the chat update object
        :param context: telegram context
        :param product: product entity
        :param amount: amount
        :param exp: expiration date
        :param price: price
        """
        bot = context.bot
        chat_id = update.effective_chat.id
        message_id = update.effective_message.message_id
        self._grocy.add_product(product_id=product.id,
                                amount=amount,
                                price=price,
                                best_before_date=exp)

        text = "Added {}x {} (Exp: {}, Price: {})".format(
            amount, product.name,
            "Never" if exp == NEVER_EXPIRES_DATE else exp, price)
        send_message(bot,
                     chat_id,
                     text,
                     parse_mode=ParseMode.MARKDOWN,
                     reply_to=message_id,
                     menu=ReplyKeyboardRemove(selective=True))
    def test_str_argument(self):
        arg = Argument(name="str_arg",
                       description="str description",
                       example="text")

        self.assertEqual(arg.parse_arg_value("sample"), "sample")
class ShoppingListCommandHandler(GrocyCommandHandler):
    def command_handlers(self):
        return [
            CommandHandler(COMMAND_SHOPPING,
                           filters=(~Filters.reply) & (~Filters.forwarded),
                           callback=self._shopping_callback),
            CommandHandler(COMMAND_SHOPPING_LIST,
                           filters=(~Filters.reply) & (~Filters.forwarded),
                           callback=self._shopping_lists_callback),
            CommandHandler(COMMAND_SHOPPING_LIST_ADD,
                           filters=(~Filters.reply) & (~Filters.forwarded),
                           callback=self._shopping_list_add_callback),
        ]

    @command(name=COMMAND_SHOPPING_LIST_ADD,
             description="Add an item to a shopping list.",
             arguments=[
                 Argument(name=["name"],
                          description="Product name",
                          example="Banana"),
                 Argument(name=["amount"],
                          description="Product amount",
                          type=int,
                          example="2",
                          validator=lambda x: x > 0,
                          optional=True,
                          default=1),
                 Argument(name=["id"],
                          description="Shopping list id",
                          type=int,
                          example="1",
                          optional=True,
                          default=1),
             ],
             permissions=CONFIG_ADMINS)
    @COMMAND_TIME_SHOPPING_LIST_ADD.time()
    def _shopping_list_add_callback(self, update: Update,
                                    context: CallbackContext, name: str,
                                    amount: int, id: int) -> None:
        """
        Show a list of all shopping lists
        :param update: the chat update object
        :param context: telegram context
        :param name: product name
        :param amount: product amount
        :param id: shopping list id
        """
        products = self._grocy.get_all_products()
        self._reply_keyboard_handler.await_user_selection(
            update,
            context,
            name,
            choices=products,
            key=lambda x: x.name,
            callback=self._add_product_keyboard_response_callback,
            callback_data={
                "product_name": name,
                "amount": amount,
                "shopping_list_id": id
            })

    def _add_product_keyboard_response_callback(self, update: Update,
                                                context: CallbackContext,
                                                product: Product, data: dict):
        """
        Called when the user has selected a product to add to a shopping list
        :param update: the chat update object
        :param context: telegram context
        :param product: the selected product
        :param data: callback data
        """
        bot = context.bot
        chat_id = update.effective_chat.id
        message_id = update.effective_message.message_id

        amount = data["amount"]
        shopping_list_id = data["shopping_list_id"]
        self._grocy.add_product_to_shopping_list(product.id, shopping_list_id,
                                                 amount)

        text = "Added {}x {}".format(amount, product.name)
        send_message(bot,
                     chat_id,
                     text,
                     parse_mode=ParseMode.MARKDOWN,
                     reply_to=message_id,
                     menu=ReplyKeyboardRemove(selective=True))

    @command(
        name=COMMAND_SHOPPING,
        description="Print shopping list with buttons to check off items.",
        permissions=CONFIG_ADMINS)
    @COMMAND_TIME_SHOPPING.time()
    def _shopping_callback(self, update: Update,
                           context: CallbackContext) -> None:
        """
        Show a list of all shopping lists
        :param update: the chat update object
        :param context: telegram context
        """
        bot = context.bot
        chat_id = update.effective_chat.id

        shopping_list_items = self._grocy.shopping_list(True)
        # TODO: sort by parent product / product category
        # TODO: let the user specify the order of product categories, according to the grocery store of his choice
        shopping_list_items = sorted(shopping_list_items,
                                     key=lambda x: x.product.name.lower())

        # generate message
        text = "*=> Shopping List <=*"
        # generate keyboard
        initial_button_tuples = self._create_shopping_list_item_button_tuples(
            shopping_list_items)
        inline_keyboard_items = self._create_shopping_list_keyboard_items(
            initial_button_tuples)
        inline_keyboard_markup = self._inline_keyboard_handler.build_inline_keyboard(
            inline_keyboard_items)

        # send message
        result = send_message(bot,
                              chat_id,
                              text,
                              menu=inline_keyboard_markup,
                              parse_mode=ParseMode.MARKDOWN)
        # register callback for button presses
        self._inline_keyboard_handler.register_listener(
            chat_id=chat_id,
            message_id=result.message_id,
            command_id=ShoppingListItemButtonCallbackData.command_id,
            callback=self._shopping_button_pressed_callback,
            callback_data={
                "shopping_list_items": shopping_list_items,
                "initial_keyboard_items": initial_button_tuples
            })

    def _shopping_button_pressed_callback(self, update: Update,
                                          context: CallbackContext,
                                          button_data: str, data: Dict):
        button_data = ShoppingListItemButtonCallbackData.parse(button_data)
        query = update.callback_query
        query_id = query.id

        # TODO: there is currently no way to query shopping list ids, so this is hardcoded for now
        shopping_list_id = 1

        # retrieve shopping list from grocy
        # TODO: use list in store or from api?
        #  using old one for now since it should be way faster
        # shopping_list_items = self._grocy.shopping_list(True)
        shopping_list_items = data["shopping_list_items"]
        # find the matching item
        matching_items = list(
            filter(lambda x: x.id == button_data.shopping_list_item_id,
                   shopping_list_items))
        if len(matching_items) <= 0:
            # if the item is not on the shopping list anymore, show a message
            context.bot.answer_callback_query(
                query_id,
                text=f"The item is not on the shopping list anymore.")
            return

        # if the item is still on the shopping list, remove one item
        item = matching_items[0]
        response = self._grocy.remove_product_in_shopping_list(
            item.product_id, shopping_list_id, amount=1)
        if response is not None:
            response.raise_for_status()

        # add one item to the inventory
        product = item.product
        response = self._grocy.add_product(product_id=product.id,
                                           amount=1,
                                           price=None,
                                           best_before_date=NEVER_EXPIRES_DATE)
        if response is not None:
            response.raise_for_status()

        # then update the keyboard (list of buttons)
        stored_button_tuples = data["initial_keyboard_items"]
        # find the item in button tuples
        stored_item_title, stored_item_data = \
            list(filter(lambda x: x[1].shopping_list_item_id == item.id, stored_button_tuples.items()))[0]
        # increase its "clicked" counter
        stored_item_data.button_click_count += 1
        # generate the new button text
        stored_item_title_new = self._generate_button_title(
            item, stored_item_data)
        # remove the old data
        stored_button_tuples.pop(stored_item_title)
        if (stored_item_data.button_click_count >=
                stored_item_data.shopping_list_amount and
                self._config.BOT_SHOPPING_REMOVE_BUTTON_WHEN_COMPLETE.value):
            # if all items were checked off, we are done here
            pass
        else:
            # otherwise put the modified data back in the dictionary
            stored_button_tuples[stored_item_title_new] = stored_item_data

        # regenerate keyboard
        keyboard_items = self._create_shopping_list_keyboard_items(
            stored_button_tuples)
        # we need to re-sort since the items have changed
        keyboard_items = OrderedDict(
            sorted(keyboard_items.items(), key=lambda x: x[0].lower()))
        inline_keyboard_markup = self._inline_keyboard_handler.build_inline_keyboard(
            keyboard_items)

        query.edit_message_reply_markup(reply_markup=inline_keyboard_markup)
        answer_text = f"Checked off '{product.name}' ({stored_item_data.button_click_count}/{stored_item_data.shopping_list_amount})"
        context.bot.answer_callback_query(query_id, text=answer_text)

    @command(
        name=COMMAND_SHOPPING_LIST,
        description="List shopping list items.",
        arguments=[
            Argument(name=["id"],
                     description="Shopping list id",
                     type=int,
                     example="1",
                     optional=True,
                     default=1),
            Flag(
                name=["add_missing", "a"],
                description="Add items below minimum stock to the shopping list"
            )
        ],
        permissions=CONFIG_ADMINS)
    @COMMAND_TIME_SHOPPING_LIST.time()
    def _shopping_lists_callback(self, update: Update,
                                 context: CallbackContext, id: int,
                                 add_missing: bool or None) -> None:
        """
        Show a list of all shopping lists
        :param update: the chat update object
        :param context: telegram context
        :param id: shopping list id
        :param add_missing: whether to add missing products to the shopping list before displaying
        """
        bot = context.bot
        chat_id = update.effective_chat.id

        if add_missing:
            self._grocy.add_missing_product_to_shopping_list(
                shopping_list_id=id)

        # TODO: when supported, pass shopping list id here
        shopping_list_items = self._grocy.shopping_list(True)
        shopping_list_items = sorted(shopping_list_items,
                                     key=lambda x: x.product.name.lower())

        item_texts = list(
            list(map(shopping_list_item_to_str, shopping_list_items)))
        text = "\n".join([
            "*=> Shopping List <=*",
            *item_texts,
        ]).strip()

        send_message(bot, chat_id, text, parse_mode=ParseMode.MARKDOWN)

    def _create_shopping_list_keyboard_items(
        self,
        items: Dict[str,
                    ShoppingListItemButtonCallbackData]) -> Dict[str, str]:
        return OrderedDict(
            list(map(lambda x: (x[0], x[1].minify()), items.items())))

    def _create_shopping_list_item_button_tuples(
        self, items: List[ShoppingListProduct]
    ) -> Dict[str, ShoppingListItemButtonCallbackData]:
        """
        Creates the data required for generating a keyboard button from shopping list items
        :param items: shopping list items
        :return : button title, button callback data
        """
        return OrderedDict(
            list(map(self._create_shopping_list_item_button_tuple, items)))

    def _create_shopping_list_item_button_tuple(
        self, item: ShoppingListProduct
    ) -> Tuple[str, ShoppingListItemButtonCallbackData]:
        """
        Creates the data required for generating a keyboard button from a shopping list item
        :param item: shopping list item
        :return : button title, button callback data
        """
        button_data = self._create_shopping_list_item_button_data(item)
        button_title = self._generate_button_title(item, button_data)
        return button_title, button_data

    @staticmethod
    def _generate_button_title(item, stored_item_data) -> str:
        return f"{item.product.name} ({stored_item_data.button_click_count}/{int(item.amount)})"

    @staticmethod
    def _create_shopping_list_item_button_data(
            item: ShoppingListProduct,
            count: int = 0) -> ShoppingListItemButtonCallbackData:
        return ShoppingListItemButtonCallbackData(
            shopping_list_item_id=item.id,
            button_click_count=count,
            shopping_list_amount=int(item.amount))
Example #20
0
class KeelTelegramBot:
    """
    The main entry class of the keel telegram bot
    """

    def __init__(self, config: Config, api_client: KeelApiClient):
        """
        Creates an instance.
        :param config: configuration object
        """
        self._config = config
        self._api_client = api_client

        self._response_handler = ReplyKeyboardHandler()

        self._updater = Updater(token=self._config.TELEGRAM_BOT_TOKEN.value, use_context=True)
        LOGGER.debug(f"Using bot id '{self._updater.bot.id}' ({self._updater.bot.name})")
        self._dispatcher = self._updater.dispatcher

        handler_groups = {
            0: [CallbackQueryHandler(callback=self._inline_keyboard_click_callback)],
            1: [
                CommandHandler(COMMAND_START,
                               filters=(~ Filters.reply) & (~ Filters.forwarded),
                               callback=self._start_callback),
                CommandHandler(COMMAND_LIST_APPROVALS,
                               filters=(~ Filters.reply) & (~ Filters.forwarded),
                               callback=self._list_approvals_callback),
                CommandHandler(COMMAND_APPROVE,
                               filters=(~ Filters.reply) & (~ Filters.forwarded),
                               callback=self._approve_callback),
                CommandHandler(COMMAND_REJECT,
                               filters=(~ Filters.reply) & (~ Filters.forwarded),
                               callback=self._reject_callback),
                CommandHandler(COMMAND_DELETE,
                               filters=(~ Filters.reply) & (~ Filters.forwarded),
                               callback=self._delete_callback),

                CommandHandler(COMMAND_HELP,
                               filters=(~ Filters.reply) & (~ Filters.forwarded),
                               callback=self._help_callback),
                CommandHandler(COMMAND_CONFIG,
                               filters=(~ Filters.reply) & (~ Filters.forwarded),
                               callback=self._config_callback),
                CommandHandler(COMMAND_VERSION,
                               filters=(~ Filters.reply) & (~ Filters.forwarded),
                               callback=self._version_callback),
                CommandHandler(CANCEL_KEYBOARD_COMMAND[1:],
                               filters=(~ Filters.reply) & (~ Filters.forwarded),
                               callback=self._response_handler.cancel_keyboard_callback),
                # unknown command handler
                MessageHandler(
                    filters=Filters.command & (~ Filters.forwarded),
                    callback=self._unknown_command_callback),
                MessageHandler(
                    filters=(~ Filters.forwarded),
                    callback=self._any_message_callback),
            ]
        }

        for group, handlers in handler_groups.items():
            for handler in handlers:
                self._updater.dispatcher.add_handler(handler, group=group)

    @property
    def bot(self):
        return self._updater.bot

    def start(self):
        """
        Starts up the bot.
        """
        self._updater.start_polling()

    def stop(self):
        """
        Shuts down the bot.
        """
        self._updater.stop()

    @COMMAND_TIME_START.time()
    def _start_callback(self, update: Update, context: CallbackContext) -> None:
        """
        Welcomes a new user with a greeting message
        :param update: the chat update object
        :param context: telegram context
        """
        bot = context.bot
        chat_id = update.effective_chat.id
        user_first_name = update.effective_user.first_name

        if not CONFIG_ADMINS.evaluate(update, context):
            send_message(bot, chat_id, "Sorry, you do not have permissions to use this bot.")
            return

        send_message(bot, chat_id,
                     f"Welcome {user_first_name},\nthis is your keel-telegram-bot instance, ready to go!")

    @COMMAND_TIME_LIST_APPROVALS.time()
    @command(name=COMMAND_LIST_APPROVALS,
             description="List pending approvals",
             arguments=[
                 Flag(name=["archived", "h"], description="Include archived items"),
                 Flag(name=["approved", "a"], description="Include approved items"),
                 Flag(name=["rejected", "r"], description="Include rejected items"),
             ],
             permissions=CONFIG_ADMINS)
    def _list_approvals_callback(self, update: Update, context: CallbackContext,
                                 archived: bool, approved: bool, rejected: bool) -> None:
        """
        List pending approvals
        """
        bot = context.bot
        message = update.effective_message
        chat_id = update.effective_chat.id

        items = self._api_client.get_approvals()

        rejected_items = list(filter(lambda x: x[KEY_REJECTED], items))
        archived_items = list(filter(lambda x: x[KEY_ARCHIVED], items))
        pending_items = list(filter(
            lambda x: x not in archived_items and x not in rejected_items
                      and x[KEY_VOTES_RECEIVED] < x[KEY_VOTES_REQUIRED], items))
        approved_items = list(
            filter(lambda x: x not in rejected_items and x not in archived_items and x not in pending_items, items))

        lines = []
        if archived:
            lines.append("\n".join([
                f"<b>=== Archived ({len(archived_items)}) ===</b>",
                "",
                "\n\n".join(list(map(lambda x: "> " + approval_to_str(x), archived_items)))
            ]).strip())

        if approved:
            lines.append("\n".join([
                f"<b>=== Approved ({len(approved_items)}) ===</b>",
                "",
                "\n\n".join(list(map(lambda x: "> " + approval_to_str(x), approved_items))),
            ]).strip())

        if rejected:
            lines.append("\n".join([
                f"<b>=== Rejected ({len(rejected_items)}) ===</b>",
                "",
                "\n\n".join(list(map(lambda x: "> " + approval_to_str(x), rejected_items))),
            ]).strip())

        lines.append("\n".join([
            f"<b>=== Pending ({len(pending_items)}) ===</b>",
            "",
            "\n\n".join(list(map(lambda x: "> " + approval_to_str(x), pending_items))),
        ]))

        text = "\n\n".join(lines).strip()
        send_message(bot, chat_id, text, reply_to=message.message_id, parse_mode=ParseMode.HTML)

    @COMMAND_TIME_APPROVE.time()
    @command(name=COMMAND_APPROVE,
             description="Approve a pending item",
             arguments=[
                 Argument(name=["identifier", "i"], description="Approval identifier or id",
                          example="default/myimage:1.5.5"),
                 Argument(name=["voter", "v"], description="Name of voter", example="john", optional=True),
             ],
             permissions=CONFIG_ADMINS)
    def _approve_callback(self, update: Update, context: CallbackContext,
                          identifier: str, voter: str or None) -> None:
        """
        Approve a pending item
        """
        if voter is None:
            voter = update.effective_user.full_name

        def execute(update: Update, context: CallbackContext, item: dict, data: dict):
            bot = context.bot
            message = update.effective_message
            chat_id = update.effective_chat.id

            self._api_client.approve(item["id"], item["identifier"], voter)
            text = f"Approved {item['identifier']}"
            send_message(bot, chat_id, text, reply_to=message.message_id, menu=ReplyKeyboardRemove(selective=True))

        items = self._api_client.get_approvals(rejected=False, archived=False)

        exact_matches = list(filter(lambda x: x["id"] == identifier, items))
        if len(exact_matches) > 0:
            execute(update, context, exact_matches[0], {})
            return

        self._response_handler.await_user_selection(
            update, context, identifier, choices=items, key=lambda x: x["identifier"],
            callback=execute,
        )

    @COMMAND_TIME_REJECT.time()
    @command(name=COMMAND_REJECT,
             description="Reject a pending item",
             arguments=[
                 Argument(name=["identifier", "i"], description="Approval identifier or id",
                          example="default/myimage:1.5.5"),
                 Argument(name=["voter", "v"], description="Name of voter", example="john", optional=True),
             ],
             permissions=CONFIG_ADMINS)
    def _reject_callback(self, update: Update, context: CallbackContext,
                         identifier: str, voter: str or None) -> None:
        """
        Reject a pending item
        """
        if voter is None:
            voter = update.effective_user.full_name

        def execute(update: Update, context: CallbackContext, item: dict, data: dict):
            bot = context.bot
            message = update.effective_message
            chat_id = update.effective_chat.id

            self._api_client.reject(item["id"], item["identifier"], voter)
            text = f"Rejected {item['identifier']}"
            send_message(bot, chat_id, text, reply_to=message.message_id, menu=ReplyKeyboardRemove(selective=True))

        items = self._api_client.get_approvals(rejected=False, archived=False)

        exact_matches = list(filter(lambda x: x["id"] == identifier, items))
        if len(exact_matches) > 0:
            execute(update, context, exact_matches[0], {})
            return

        self._response_handler.await_user_selection(
            update, context, identifier, choices=items, key=lambda x: x["identifier"],
            callback=execute,
        )

    @COMMAND_TIME_DELETE.time()
    @command(name=COMMAND_DELETE,
             description="Delete an approval item",
             arguments=[
                 Argument(name=["identifier", "i"], description="Approval identifier or id",
                          example="default/myimage:1.5.5"),
                 Argument(name=["voter", "v"], description="Name of voter", example="john", optional=True),
             ],
             permissions=CONFIG_ADMINS)
    def _delete_callback(self, update: Update, context: CallbackContext,
                         identifier: str, voter: str or None) -> None:
        """
        Delete an archived item
        """
        if voter is None:
            voter = update.effective_user.full_name

        def execute(update: Update, context: CallbackContext, item: dict, data: dict):
            bot = context.bot
            message = update.effective_message
            chat_id = update.effective_chat.id

            self._api_client.delete(item["id"], item["identifier"], voter)
            text = f"Deleted {item['identifier']}"
            send_message(bot, chat_id, text, reply_to=message.message_id, menu=ReplyKeyboardRemove(selective=True))

        items = self._api_client.get_approvals()

        exact_matches = list(filter(lambda x: x["id"] == identifier, items))
        if len(exact_matches) > 0:
            execute(update, context, exact_matches[0], {})
            return

        self._response_handler.await_user_selection(
            update, context, identifier, choices=items, key=lambda x: x["identifier"],
            callback=execute,
        )

    def on_notification(self, data: dict):
        """
        Handles incoming notifications (via Webhook)
        :param data: notification data
        """
        KEEL_NOTIFICATION_COUNTER.inc()

        identifier = data.get("identifier", None)
        title = data.get("name", None)
        type = data.get("type", None)
        level = data.get("level", None)  # success/failure
        message = data.get("message", None)

        text = "\n".join([
            f"<b>{title}: {level}</b>",
            f"{identifier}",
            f"{type}",
            f"{message}",
        ])

        for chat_id in self._config.TELEGRAM_CHAT_IDS.value:
            send_message(
                self.bot, chat_id,
                text, parse_mode=ParseMode.HTML,
                menu=None
            )

    def on_new_pending_approval(self, item: dict):
        """
        Handles new pending approvals by sending a message
        including an inline keyboard to all configured chat ids
        :param item: new pending approval
        """
        text = approval_to_str(item)

        keyboard_items = {
            "Approve": BUTTON_DATA_APPROVE,
            "Reject": BUTTON_DATA_REJECT
        }
        keyboard = self._build_inline_keyboard(keyboard_items)

        for chat_id in self._config.TELEGRAM_CHAT_IDS.value:
            send_message(
                self.bot, chat_id,
                text, parse_mode=ParseMode.HTML,
                menu=keyboard
            )

    @command(
        name=COMMAND_CONFIG,
        description="Print bot config.",
        permissions=CONFIG_ADMINS,
    )
    def _config_callback(self, update: Update, context: CallbackContext):
        bot = context.bot
        message = update.effective_message
        chat_id = update.effective_chat.id
        text = self._config.print(TomlFormatter())
        send_message(bot, chat_id, text, reply_to=message.message_id)

    @command(
        name=COMMAND_HELP,
        description="List commands supported by this bot.",
        permissions=CONFIG_ADMINS,
    )
    def _help_callback(self, update: Update, context: CallbackContext):
        bot = context.bot
        message = update.effective_message
        chat_id = update.effective_chat.id

        from telegram_click import generate_command_list
        text = generate_command_list(update, context)
        send_message(bot, chat_id, text,
                     parse_mode=ParseMode.MARKDOWN,
                     reply_to=message.message_id)

    @command(
        name=COMMAND_VERSION,
        description="Print bot version.",
        permissions=CONFIG_ADMINS,
    )
    def _version_callback(self, update: Update, context: CallbackContext):
        bot = context.bot
        message = update.effective_message
        chat_id = update.effective_chat.id

        from keel_telegram_bot import __version__
        text = __version__
        send_message(bot, chat_id, text, reply_to=message.message_id)

    def _unknown_command_callback(self, update: Update, context: CallbackContext) -> None:
        """
        Handles unknown commands send by a user
        :param update: the chat update object
        :param context: telegram context
        """
        message = update.effective_message
        username = "******"
        if update.effective_user is not None:
            username = update.effective_user.username

        user_is_admin = username in self._config.TELEGRAM_ADMIN_USERNAMES.value
        if user_is_admin:
            self._help_callback(update, context)
            return

    def _any_message_callback(self, update: Update, context: CallbackContext) -> None:
        """
        Used to respond to response keyboard entry selections
        :param update: the chat update object
        :param context: telegram context
        """
        self._response_handler.on_message(update, context)

    def _inline_keyboard_click_callback(self, update: Update, context: CallbackContext):
        """
        Handles inline keyboard button click callbacks
        :param update:
        :param context:
        """
        bot = context.bot
        from_user = update.callback_query.from_user

        message_text = update.effective_message.text
        query = update.callback_query
        query_id = query.id
        data = query.data

        if data == BUTTON_DATA_NOTHING:
            return

        try:
            matches = re.search(r"^Id: (.*)", message_text, flags=re.MULTILINE)
            approval_id = matches.group(1)
            matches = re.search(r"^Identifier: (.*)", message_text, flags=re.MULTILINE)
            approval_identifier = matches.group(1)

            if data == BUTTON_DATA_APPROVE:
                self._api_client.approve(approval_id, approval_identifier, from_user.full_name)
                answer_text = f"Approved '{approval_identifier}'"
                keyboard = self._build_inline_keyboard({"Approved": BUTTON_DATA_NOTHING})
                KEEL_APPROVAL_ACTION_COUNTER.labels(action="approve", identifier=approval_identifier).inc()
            elif data == BUTTON_DATA_REJECT:
                self._api_client.reject(approval_id, approval_identifier, from_user.full_name)
                answer_text = f"Rejected '{approval_identifier}'"
                keyboard = self._build_inline_keyboard({"Rejected": BUTTON_DATA_NOTHING})
                KEEL_APPROVAL_ACTION_COUNTER.labels(action="reject", identifier=approval_identifier).inc()
            else:
                bot.answer_callback_query(query_id, text="Unknown button")
                return

            # remove buttons
            query.edit_message_reply_markup(reply_markup=keyboard)
            context.bot.answer_callback_query(query_id, text=answer_text)
        except HTTPError as e:
            LOGGER.error(e)
            bot.answer_callback_query(query_id, text=f"{e.response.content.decode('utf-8')}")
        except Exception as e:
            LOGGER.error(e)
            bot.answer_callback_query(query_id, text=f"Unknwon error")

    @staticmethod
    def _build_inline_keyboard(items: Dict[str, str]) -> InlineKeyboardMarkup:
        """
        Builds an inline button menu
        :param items: dictionary of "button text" -> "callback data" items
        :return: reply markup
        """
        keyboard = list(map(lambda x: InlineKeyboardButton(x[0], callback_data=x[1]), items.items()))
        return InlineKeyboardMarkup.from_column(keyboard)