Exemple #1
0
    def test_multi_flag(self):
        flag1 = Flag(
            name=["flag", "f"],
            description="some flag description",
        )
        flag2 = Flag(
            name=["Flag", "F"],
            description="some flag description",
        )

        bot_username = "******"
        command_line = '/command --{}{}'.format(flag1.names[1], flag2.names[1])
        expected_args = [
            flag1,
            flag2,
        ]

        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)
class ChoreCommandHandler(GrocyCommandHandler):

    def command_handlers(self):
        return [
            CommandHandler(COMMAND_CHORES,
                           filters=(~ Filters.reply) & (~ Filters.forwarded),
                           callback=self._chores_callback)
        ]

    @command(
        name=COMMAND_CHORES,
        description="List overdue chores.",
        arguments=[
            Flag(name=["all", "a"], description="Show all chores")
        ],
        permissions=CONFIG_ADMINS
    )
    @COMMAND_TIME_CHORES.time()
    def _chores_callback(self, update: Update, context: CallbackContext, all: bool) -> None:
        """
        Show a list of all chores
        :param update: the chat update object
        :param context: telegram context
        """
        bot = context.bot
        chat_id = update.effective_chat.id

        chores = self._grocy.chores(True)
        chores = sorted(
            chores,
            key=lambda
                x: datetime.now().astimezone() if x.next_estimated_execution_time is None else x.next_estimated_execution_time
        )

        overdue_chores = filter_overdue_chores(chores)
        other = [item for item in chores if item not in overdue_chores]

        overdue_item_texts = list(map(chore_to_str, overdue_chores))
        other_item_texts = list(map(chore_to_str, other))

        lines = ["*=> Chores <=*"]
        if all and len(other_item_texts) > 0:
            lines.extend([
                "",
                *other_item_texts
            ])

        if len(overdue_item_texts) > 0:
            lines.extend([
                "",
                "*Overdue:*",
                *overdue_item_texts
            ])

        text = "\n".join(lines).strip()
        send_message(bot, chat_id, text, parse_mode=ParseMode.MARKDOWN)
Exemple #3
0
    def test_flag_missing(self):
        flag1 = Flag(
            name="flag",
            description="some flag description",
        )

        bot_username = "******"
        command_line = '/command'.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 False)
Exemple #4
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))
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))
Exemple #6
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)
Exemple #7
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))