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)
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)
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))
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)
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))