import random import logging import time import sys from telegram import Chat, ChatMember, Message, Update import telegram from telegram.ext import ( Application, CommandHandler, ContextTypes, MessageHandler, filters, ) from telegram.error import NetworkError from velascobot2.utils import ChatData, GraphicType, Response from velascobot2.messenger import Messenger logger = logging.getLogger(__name__) start_msg = ( "Hello there! Ask me for /help to see an overview of the available" " commands." ) wake_msg = "Good morning. I just woke up" help_msg = """I answer to the following commands: /start - I say hi. /about - What I'm about. /explain - I explain how I work. /help - I send this message. /count - I tell you how many messages from this chat I have processed. /speak - Forces me to speak (if you are allowed to). /period - Change the period of my messages. (Maximum of 100000) /answer - Change my chance to answer at will. (Decimal between 0 and 1) /restrict - Toggle restriction of configuration commands to admins only. /silence - Toggle restriction on mentions by the bot. /ping - Pong! /config - Show my configuration for this chat. """ about_msg = ( "I am yet another Markov Bot experiment. I read everything you type to me" " and then spit back nonsensical messages that look like yours.\n\nYou can" " send /explain if you want further explanation." ) explanation = ( "I decompose every message I read in groups of 3 consecutive words, so for" " each consecutive pair I save the word that can follow them. I then use" " this to make my own messages. At first I will only repeat your messages" " because for each 2 words I will have very few possible following" " words.\n\nI also separate my vocabulary by chats, so anything I learn in" " one chat I will only say in that chat. For privacy, you know. Also, I" " only save my learned word triplets in an SQLite database, so no logs are" " kept.\n\nMy default period in chats is one message of mine for each 10" " messages received." ) def static_reply(text: str, format=None): async def reply(update: Update, context: ContextTypes.DEFAULT_TYPE): assert update.message try: await update.message.reply_text(text, parse_mode=format) except ( telegram.error.Forbidden, telegram.error.BadRequest, telegram.error.NetworkError, ) as e: logger.error(str(e)) return reply async def stop(update: Update, context: ContextTypes.DEFAULT_TYPE): assert update.message chat = update.message.chat chat_id = str(chat.id) # TODO: remove chat from DB logger.warning( "I got blocked by {} [{}]".format(chat.effective_name, chat_id) ) # Legacy function from original velascobot, created before "effective_name" # was implemented in the telegram library. Kept to ensure consistency def get_chat_title(chat: Chat): """Returns group chat name or falls back to user name on a private chat.""" if chat.title is not None: return chat.title elif chat.first_name is not None: if chat.last_name is not None: return chat.first_name + " " + chat.last_name else: return chat.first_name else: return "" async def ping(update: Update, _context: ContextTypes.DEFAULT_TYPE) -> None: """Pongs back.""" assert update.message is not None try: await update.message.reply_text("Pong!") except ( telegram.error.Forbidden, telegram.error.BadRequest, telegram.error.NetworkError, ) as e: logger.error(str(e)) async def error_handler(update: object, context: ContextTypes.DEFAULT_TYPE): logger.error(context.error) sys.exit(1) class Bot: def __init__( self, messenger: Messenger, bot_token: str, motd_chats: list[str] = [], motd_list: list[str] = [], whitelist: list[str] | None = None, testing: bool = False, ) -> None: self.messenger = messenger self.motd_chats = motd_chats self.motd_list = motd_list self.whitelist = whitelist self.testing = testing self.countdown: dict[str, int] = {} if testing: assert ( whitelist is not None ), "Testing mode enabled but no whitelist was given." # Create the Application and pass it your bot's token. app = ( Application.builder() .token(bot_token) .concurrent_updates(True) .build() ) # on different commands - answer in Telegram app.add_handler(CommandHandler("start", static_reply(start_msg))) app.add_handler(CommandHandler("about", static_reply(about_msg))) app.add_handler(CommandHandler("explain", static_reply(explanation))) app.add_handler(CommandHandler("help", static_reply(help_msg))) app.add_handler(CommandHandler("stop", stop)) app.add_handler(CommandHandler("ping", ping)) app.add_handler(CommandHandler("count", self.get_processed_messages)) app.add_handler(CommandHandler("speak", self.speak_command)) app.add_handler(CommandHandler("period", self.period)) app.add_handler(CommandHandler("answer", self.answer)) app.add_handler(CommandHandler("restrict", self.restrict)) app.add_handler(CommandHandler("silence", self.silence)) app.add_handler(CommandHandler("config", self.show_config)) app.add_handler( MessageHandler( ( filters.TEXT | filters.Sticker.ALL | filters.ANIMATION | filters.VIDEO ), self.read, ) ) app.add_error_handler(error_handler) self.app = app async def get_processed_messages( self, update: Update, _context: ContextTypes.DEFAULT_TYPE ) -> None: assert update.message chat_id = str(update.message.chat.id) count = self.messenger.get_processed_messages(chat_id) try: await update.message.reply_text( f"I remember {count} messages." ) except ( telegram.error.Forbidden, telegram.error.BadRequest, telegram.error.NetworkError, ) as e: logger.error(str(e)) async def period( self, update: Update, _context: ContextTypes.DEFAULT_TYPE ) -> None: """Responds with the current message period or sets a new one.""" assert update.message chat = update.message.chat chat_id = str(chat.id) chat_data = self.messenger.get_chat_data(chat_id) assert chat_data assert update.message.text words = update.message.text.split() if len(words) <= 1: try: await update.message.reply_text( f"The current speech period is {chat_data.message_period}" ) except ( telegram.error.Forbidden, telegram.error.BadRequest, telegram.error.NetworkError, ) as e: logger.error(str(e)) return if chat_data.restricted: assert update.message.from_user user = await chat.get_member(update.message.from_user.id) if not self.superuser(user): try: await update.message.reply_text( "You do not have permissions to do that." ) except ( telegram.error.Forbidden, telegram.error.BadRequest, telegram.error.NetworkError, ) as e: logger.error(str(e)) return try: period = int(words[1]) self.messenger.update_message_period(chat_id, period) await update.message.reply_text( f"Period of speaking set to {period}." ) except ( telegram.error.Forbidden, telegram.error.BadRequest, telegram.error.NetworkError, ) as e: logger.error(str(e)) except Exception: await update.message.reply_text( "Format was confusing; period unchanged from" f" {chat_data.message_period}." ) async def answer( self, update: Update, _context: ContextTypes.DEFAULT_TYPE ) -> None: """Responds with the current answer chance or sets a new one.""" assert update.message chat = update.message.chat chat_id = str(chat.id) chat_data = self.messenger.get_chat_data(chat_id) assert chat_data assert update.message.text words = update.message.text.split() if len(words) <= 1: try: await update.message.reply_text( f"The current answer chance is {chat_data.answer_chance}" ) except ( telegram.error.Forbidden, telegram.error.BadRequest, telegram.error.NetworkError, ) as e: logger.error(str(e)) return if chat_data.restricted: assert update.message.from_user user = await chat.get_member(update.message.from_user.id) if not self.superuser(user): try: await update.message.reply_text( "You do not have permissions to do that." ) except ( telegram.error.Forbidden, telegram.error.BadRequest, telegram.error.NetworkError, ) as e: logger.error(str(e)) return try: chance = float(words[1]) self.messenger.update_answer_chance(chat_id, chance) await update.message.reply_text( f"Chance of answering set to {chance}." ) except ( telegram.error.Forbidden, telegram.error.BadRequest, telegram.error.NetworkError, ) as e: logger.error(str(e)) except Exception: await update.message.reply_text( "Format was confusing; answer chance unchanged from" f" {chat_data.answer_chance}." ) async def restrict( self, update: Update, _context: ContextTypes.DEFAULT_TYPE ) -> None: """Toggles the current restriction state.""" assert update.message if "group" not in update.message.chat.type: try: await update.message.reply_text("That only works in groups.") except ( telegram.error.Forbidden, telegram.error.BadRequest, telegram.error.NetworkError, ) as e: logger.error(str(e)) return chat = update.message.chat chat_id = str(chat.id) assert update.message.from_user user = await chat.get_member(update.message.from_user.id) chat_data = self.messenger.get_chat_data(chat_id) assert chat_data if chat_data.restricted: if not self.superuser(user): try: await update.message.reply_text( "You do not have permissions to do that." ) except ( telegram.error.Forbidden, telegram.error.BadRequest, telegram.error.NetworkError, ) as e: logger.error(str(e)) return self.messenger.update_set_restricted(chat_id, not chat_data.restricted) allowed = ( "let only admins" if not chat_data.restricted else "let everyone" ) try: await update.message.reply_text( f"I will {allowed} configure me now." ) except ( telegram.error.Forbidden, telegram.error.BadRequest, telegram.error.NetworkError, ) as e: logger.error(str(e)) async def silence( self, update: Update, _context: ContextTypes.DEFAULT_TYPE ) -> None: """Toggles the current silence state.""" assert update.message if "group" not in update.message.chat.type: try: await update.message.reply_text("That only works in groups.") except ( telegram.error.Forbidden, telegram.error.BadRequest, telegram.error.NetworkError, ) as e: logger.error(str(e)) return chat = update.message.chat chat_id = str(chat.id) assert update.message.from_user user = await chat.get_member(update.message.from_user.id) chat_data = self.messenger.get_chat_data(chat_id) assert chat_data if chat_data.silenced: if not self.superuser(user): try: await update.message.reply_text( "You do not have permissions to do that." ) except ( telegram.error.Forbidden, telegram.error.BadRequest, telegram.error.NetworkError, ) as e: logger.error(str(e)) return self.messenger.update_set_silenced(chat_id, not chat_data.silenced) allowed = "avoid mentioning" if not chat_data.silenced else "mention" try: await update.message.reply_text(f"I will {allowed} people now.") except ( telegram.error.Forbidden, telegram.error.BadRequest, telegram.error.NetworkError, ) as e: logger.error(str(e)) async def send( self, chat_id: int | str, message_type: Response, content: str | tuple[GraphicType, str], **kwargs, ): bot = self.app.bot if message_type == Response.TEXT: assert isinstance(content, str) if content == "": logger.warning("Generated empty text response.") return max_length = telegram.constants.MessageLimit.MAX_TEXT_LENGTH if len(content) > max_length: content = content[:max_length] msg = "reply" if (kwargs.get("reply_to_message_id")) else "message" logger.info( "Sending a {} to {}: '{}'".format(msg, chat_id, content) ) try: await bot.send_message(chat_id, content, **kwargs) except ( telegram.error.Forbidden, telegram.error.BadRequest, telegram.error.NetworkError, ) as e: logger.error(str(e)) else: assert isinstance(content, tuple) graph_type, graph_id = content if graph_id == "": logger.warning(f"Generated empty {graph_type} response.") return logger.info( 'Sending {} "{}" to {}'.format(graph_type, graph_id, chat_id) ) try: if graph_type == GraphicType.STICKER: sent = await bot.send_sticker( chat_id=chat_id, sticker=graph_id, **kwargs ) elif graph_type == GraphicType.ANIMATION: sent = await bot.send_animation( chat_id=chat_id, animation=graph_id, **kwargs ) elif graph_type == GraphicType.VIDEO: sent = await bot.send_video( chat_id=chat_id, video=graph_id, **kwargs ) else: raise Exception("Sending unkown graphic type") logger.debug(f"Sent graphic: {sent}") except ( telegram.error.Forbidden, telegram.error.BadRequest, telegram.error.NetworkError, ) as e: logger.error(str(e)) except Exception as e: raise e async def announce(self, announcement, check=(lambda _: True)) -> None: """Sends an announcement to all chats that pass a check.""" for chat in self.messenger.get_all_chats(): if check(chat): try: logger.info(f"Sending announcement to {chat.id}") await self.send(chat.id, Response.TEXT, announcement) time.sleep(1) except Exception: pass async def wake_up(self, exclusive: bool = False) -> None: # Make the bot mute itself for 1 minute while it catches up with chats # to avoid spamming with several messages in a row self.messenger.set_mute_timer(int(time.perf_counter())) if not self.messenger.wake_up: return if len(self.motd_list) == 0: return def valid_chat(chat: ChatData) -> bool: if self.whitelist is not None and chat.id not in self.whitelist: return False if self.motd_chats and exclusive: return chat.id in self.motd_chats if not self.motd_chats: return "group" in chat.type else: return "group" in chat.type or chat.id in self.motd_chats wake_text = random.choice(self.motd_list) logger.info(f"Printing message of the day: {wake_text}") await self.announce(wake_text, valid_chat) async def should_reply(self, message: Message, chat: ChatData) -> bool: """Returns True if the bot should reply to a message.""" if self.messenger.is_mute(): # Not if mute time hasn't finished return False if not self.testing and chat.restricted: # If we're not in testing mode and the chat is restricted assert message.from_user user = await message.chat.get_member(message.from_user.id) if not self.superuser(user): # ...And the user has no permissions, should not reply return False # otherwise (testing mode, or the chat is unrestricted, # or the user is a superuser) replied_message = message.reply_to_message text = message.text.casefold() if message.text else "" replies_to_me = ( replied_message is not None and replied_message.from_user and replied_message.from_user.name == self.messenger.username ) # Only if it's a reply to a message of ours or the bot is mentioned in # the message return replies_to_me or self.messenger.mentioned(text) def superuser(self, member: ChatMember) -> bool: """Returns True if the user is a group chat admin, creator or in the bosses list.""" logger.info( "user {} ({}) requesting a restricted action".format( str(member.user.id), member.user.name ) ) return ( (member.status == "creator") or (member.status == "administrator") or (member.user.id in self.messenger.bosses) ) async def speak(self, chat_id: str, **kwargs) -> None: """Send a new message.""" if self.whitelist is not None and chat_id not in self.whitelist: # Don't, if there's a whitelist and this chat is not in it return if self.messenger.is_mute(): # Don't, if mute time isn't over return try: response, content = self.messenger.give_response(chat_id) await self.send(chat_id, response, content, **kwargs) while random.random() <= self.messenger.repeat: if "reply_to_message_id" in kwargs: del kwargs["reply_to_message_id"] response, content = self.messenger.give_response(chat_id) await self.send(chat_id, response, content, **kwargs) # Consider any Network Error as a Telegram temporary ban, as I # couldn't find out in the documentation how error 429 is handled by # python-telegram-bot except NetworkError as e: logger.error("Sending a message caused network error:") logger.exception(e) logger.error( "Going mute for {} seconds.".format(self.messenger.mute_time) ) self.messenger.set_mute_timer(int(time.perf_counter())) except Exception as e: logger.error("Sending a message caused exception:") logger.exception(e) def extract_graphic(self, message: Message) -> tuple[GraphicType, str]: logger.debug(f"Sticker: {message.sticker}") logger.debug(f"Animation: {message.animation}") logger.debug(f"Sticker: {message.video}") if message.sticker is not None: return GraphicType.STICKER, message.sticker.file_id elif message.animation is not None: return GraphicType.ANIMATION, message.animation.file_id if message.video is not None: return GraphicType.VIDEO, message.video.file_id else: raise Exception("Unknown graphic type") # Rolls the chance for answering in this specific chat, # according to the answer probability def is_answering(self, answer_chance: float) -> bool: """Rolls the chance for answering to a message.""" if answer_chance == 1: return True elif answer_chance == 0: return False rand = random.random() return rand <= answer_chance async def read(self, update: Update, context: ContextTypes.DEFAULT_TYPE): """Reads a non-command message.""" # Ignore non-message updates if update.message is None: return message = update.message chat = message.chat chat_id = str(chat.id) chat_name = get_chat_title(chat) self.messenger.ensure_chat_exists(chat_id, chat.type, chat_name) chat_data = self.messenger.get_chat_data(chat_id) assert chat_data if message.text is not None: self.messenger.process_text(chat_id, message.text) else: graph_type, graph_id = self.extract_graphic(message) self.messenger.process_graphic(chat_id, graph_type, graph_id) # Update the ChatData name if it has changed if chat_name != chat_data.name: self.messenger.update_chat_name(chat_id, chat_name) # Check if it's a "replyable" message & roll the chance to do so should_reply = await self.should_reply(update.message, chat_data) if should_reply and self.is_answering(chat_data.answer_chance): await self.speak(chat_id, reply_to_message_id=message.message_id) return # Decrease the countdown for the chat, and send a message if it # reached 0 if chat_id not in self.countdown: self.countdown[chat_id] = chat_data.message_period self.countdown[chat_id] -= 1 if self.countdown[chat_id] < 0: self.countdown[chat_id] = chat_data.message_period await self.speak(chat_id) async def speak_command( self, update: Update, context: ContextTypes.DEFAULT_TYPE ): """Handles the /speak command.""" assert update.message chat = update.message.chat chat_id = str(chat.id) if self.messenger.get_processed_messages(chat_id) == 0: empty_gen_warning = "I haven't learned a single word yet." await self.send( chat_id, Response.TEXT, content=empty_gen_warning, reply_to_message_id=update.message.message_id, ) return chat_data = self.messenger.get_chat_data(chat_id) if chat_data is None: return if not self.testing and chat_data.restricted: assert update.message.from_user user = await update.message.chat.get_member( update.message.from_user.id ) if not self.superuser(user): # update.message.reply_text("You do not have permissions to do that.") return mid = str(update.message.message_id) replied = update.message.reply_to_message # Reply to the message that the command replies to, otherwise to the # command itself rid = replied.message_id if replied else mid # words = update.message.text.split() # if len(words) > 1: # self.messenger.process_text(chat_id, ' '.join(words[1:])) await self.speak(chat_id, reply_to_message_id=rid) async def show_config( self, update: Update, context: ContextTypes.DEFAULT_TYPE ): """Sends a summary of the chat configuration.""" msg = update.message assert msg chat = msg.chat chat_id = str(chat.id) chat_data = self.messenger.get_chat_data(chat_id) assert chat_data if chat_data.restricted and chat_data.silenced: permissions = "restricted and silenced" elif chat_data.restricted: permissions = "restricted but not silenced" elif chat_data.silenced: permissions = "not restricted but silenced" else: permissions = "neither restricted nor silenced" processed_messages = self.messenger.get_processed_messages(chat_id) answer = ( f"You're messaging in the chat of __{chat_data.name}__, with id " f"`{chat_data.id}`, message count " f"{processed_messages}, period " f"{chat_data.message_period}, and answer probability " f"{chat_data.answer_chance}.\n\nThis chat is {permissions}." ) try: await msg.reply_markdown(answer) except Exception: pass