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"))
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)
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)
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)
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)
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)
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
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"])
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)
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
def test_str_argument(): arg = Argument(name="str_arg", description="str description", example="text") assert arg.parse_arg_value("sample") == "sample"
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 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)
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))
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)