diff options
| author | Guillermo Serrahima <dev@vylion.com> | 2026-02-02 02:43:14 +0100 |
|---|---|---|
| committer | Guillermo Serrahima <dev@vylion.com> | 2026-02-02 02:43:14 +0100 |
| commit | 512915dd4587491dad19c13cde92daada5756d72 (patch) | |
| tree | 3fbf32282066486b59b3f1c8120a073e304a7a7e /velascobot2 | |
| parent | 91a1bed08873ad7698f6f5e25324a69677dbcdac (diff) | |
| download | velascobot2-512915dd4587491dad19c13cde92daada5756d72.tar.xz velascobot2-512915dd4587491dad19c13cde92daada5756d72.zip | |
Initial commit. Achieved minimum functionality
Diffstat (limited to 'velascobot2')
| -rw-r--r-- | velascobot2/bot.py | 606 | ||||
| -rw-r--r-- | velascobot2/database.py | 257 | ||||
| -rw-r--r-- | velascobot2/messenger.py | 140 | ||||
| -rw-r--r-- | velascobot2/parrot.py | 135 | ||||
| -rw-r--r-- | velascobot2/utils.py | 48 |
5 files changed, 1186 insertions, 0 deletions
diff --git a/velascobot2/bot.py b/velascobot2/bot.py new file mode 100644 index 0000000..5914906 --- /dev/null +++ b/velascobot2/bot.py @@ -0,0 +1,606 @@ +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! +/where - Tell 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" + " save my vocabulary in the form of a json dictionary, so no logs are" + " kept.\n\nMy default period in private chats is one message of mine from" + " each 2 messages received, and in group chats it's 10 messages I read for" + " each message I send." +) + + +def static_reply(text: str, format=None): + async def reply(update: Update, context: ContextTypes.DEFAULT_TYPE): + assert update.message + await update.message.reply_text(text, parse_mode=format) + + 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 + await update.message.reply_text("Pong!") + + +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("where", static_reply("Not implemented")) + ) + app.add_handler( + MessageHandler( + ( + filters.TEXT + | filters.Sticker.ALL + | filters.ANIMATION + | filters.VIDEO + ), + self.read, + ) + ) + + if self.testing: + 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) + chat_data = self.messenger.get_chat_data(chat_id) + if chat_data is None: + await update.message.reply_text( + "I've processed no messages so far." + ) + return + + num = ( + str(chat_data.processed_messages) + if chat_data.processed_messages > 0 + else "no" + ) + await update.message.reply_text( + "I've processed {} messages so far.".format(num) + ) + + 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: + await update.message.reply_text( + f"The current speech period is {chat_data.message_period}" + ) + 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): + await update.message.reply_text( + "You do not have permissions to do that." + ) + 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 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: + await update.message.reply_text( + f"The current answer chance is {chat_data.answer_chance}" + ) + 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): + await update.message.reply_text( + "You do not have permissions to do that." + ) + 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 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: + await update.message.reply_text("That only works in groups.") + 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): + await update.message.reply_text( + "You do not have permissions to do that." + ) + return + self.messenger.update_set_restricted(chat_id, not chat_data.restricted) + allowed = ( + "let only admins" if not chat_data.restricted else "let everyone" + ) + await update.message.reply_text(f"I will {allowed} configure me now.") + + 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: + await update.message.reply_text("That only works in groups.") + 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): + await update.message.reply_text( + "You do not have permissions to do that." + ) + return + self.messenger.update_set_silenced(chat_id, not chat_data.silenced) + allowed = "avoid mentioning" if not chat_data.silenced else "mention" + await update.message.reply_text(f"I will {allowed} people now.") + + 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) + ) + await bot.send_message(chat_id, content, **kwargs) + 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) + ) + + 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}") + + 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: + await self.send(chat.id, Response.TEXT, announcement) + logger.info( + "Sending announcement to chat {}".format(chat.id) + ) + except Exception: + pass + + async def wake_up(self, exclusive: bool = False) -> None: + 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) + 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: + 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.""" + chat = update.message.chat + chat_id = str(chat.id) + chat_data = self.messenger.get_chat_data(chat_id) + + if chat_data is None: + return + + if not self.testing and chat_data.restricted: + user = 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:])) + if chat_data.processed_messages > 0: + await self.speak(chat_id, reply_to_message_id=rid) + else: + 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=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" + + answer = ( + f"You're messaging in the chat of saved title" + f"__{chat_data.name}__, with id `{chat_data.id}`, message count " + f"{chat_data.processed_messages}, period " + f"{chat_data.message_period}, and answer probability " + f"{chat_data.answer_chance}.\n\nThis chat is {permissions}." + ) + + await msg.reply_markdown(answer) diff --git a/velascobot2/database.py b/velascobot2/database.py new file mode 100644 index 0000000..582495f --- /dev/null +++ b/velascobot2/database.py @@ -0,0 +1,257 @@ +import sqlite3 +import traceback +from typing import Any + +from velascobot2.utils import ChatData + + +class _SQLiteContextMgr(object): + def __init__( + self, + database: str = "sqlitedb", + isolation_level: str = "", + ignore_exc: bool = False, + ): + self.database = database + self.isolation_level = isolation_level + self.ignore_exc = ignore_exc + self.connection = None + self.cursor = None + + def __enter__(self): + try: + self.connection = sqlite3.connect( + database=self.database, isolation_level=self.isolation_level + ) + self.cursor = self.connection.cursor() + return self.cursor + except Exception as ex: + traceback.print_exc() + raise ex + + def __exit__(self, exc_type, exc_val, exc_tb): + try: + if exc_type is not None: + self.connection.rollback() + return self.ignore_exc + else: + self.connection.commit() + except Exception as ex: + traceback.print_exc() + raise ex + finally: + self.cursor.close() + self.connection.close() + + +class Database: + def __init__(self, bot_name: str): + self.db_name = f"{bot_name}.sqlite" + self._init_database() + + def _init_database(self): + """creates schema on first startup""" + with _SQLiteContextMgr(database=self.db_name) as db: + db.execute(""" + CREATE TABLE IF NOT EXISTS chats ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, + name TEXT NOT NULL, + processed_messages INTEGER DEFAULT 0, + message_period INTEGER DEFAULT 10, + answer_chance REAL DEFAULT 0.5, + restricted INTEGER DEFAULT 0, + silenced INTEGER DEFAULT 0 + ) + """) + + db.execute(""" + CREATE TABLE IF NOT EXISTS vocab ( + chat_id TEXT NOT NULL, + key TEXT NOT NULL, + word TEXT NOT NULL, + count INTEGER DEFAULT 1, + PRIMARY KEY (chat_id, key, word), + FOREIGN KEY (chat_id) REFERENCES chats(id) + ) + """) + + db.execute(""" + CREATE INDEX IF NOT EXISTS vocab_idx + ON vocab(chat_id, key) + """) + + db.execute(""" + CREATE TABLE IF NOT EXISTS graphics ( + chat_id TEXT NOT NULL, + type TEXT NOT NULL, + graph_id TEXT NOT NULL, + count INTEGER DEFAULT 1, + PRIMARY KEY (chat_id, graph_id), + FOREIGN KEY (chat_id) REFERENCES chats(id) + ) + """) + + db.execute(""" + CREATE INDEX IF NOT EXISTS graphics_idx + ON graphics(chat_id) + """) + + def ensure_chat_exists( + self, chat_id: str, chat_type: str, chat_name: str + ) -> bool: + """ + checks if the chat exists, stores it with defaults if not. + returns True if newly created, False if already existed + """ + with _SQLiteContextMgr(database=self.db_name) as db: + # check existence + result = db.execute( + "SELECT id FROM chats WHERE id = ?", (chat_id,) + ).fetchone() + + if result: + return False + + # create new chat config entry + db.execute( + "INSERT INTO chats (id, type, name) VALUES (?, ?, ?)", + (chat_id, chat_type, chat_name), + ) + + return True + + def add_words(self, chat_id: str, key: str, word: str, count: int): + """adds or updates a word ocurrence""" + with _SQLiteContextMgr(database=self.db_name) as db: + db.execute( + "INSERT INTO vocab (chat_id, key, word, count) VALUES (?, ?, ?," + " ?) ON CONFLICT(chat_id, key, word) DO UPDATE SET count =" + " count + ?", + (chat_id, key, word, count, count), + ) + + def add_graphic(self, chat_id: str, graph_type: str, graph_id: str): + """adds or updates a graphic ocurrence""" + with _SQLiteContextMgr(database=self.db_name) as db: + db.execute( + "INSERT INTO graphics (chat_id, type, graph_id, count) VALUES" + " (?, ?, ?, ?) ON CONFLICT(chat_id, graph_id) DO UPDATE" + " SET count = count + ?", + (chat_id, graph_type, graph_id, 1, 1), + ) + + def get_next_words(self, chat_id: str, key: str) -> list[tuple[str, int]]: + """returns list of possible words to follow the key n-gram""" + with _SQLiteContextMgr(database=self.db_name) as db: + rows = db.execute( + "SELECT word, count FROM vocab WHERE chat_id = ? AND key = ?", + (chat_id, key), + ).fetchall() + + if not rows: + return [] + + return rows + + def get_graphics(self, chat_id: str): + with _SQLiteContextMgr(database=self.db_name) as db: + rows = db.execute( + "SELECT type, graph_id, count FROM graphics WHERE chat_id = ?", + (chat_id,), + ).fetchall() + + if not rows: + return [] + + return rows + + def _update_chat(self, chat_id: str, field: str, value: Any): + """updates a field in the chats table""" + with _SQLiteContextMgr(database=self.db_name) as db: + db.execute( + f"UPDATE chats SET {field} = ? WHERE id = ?", (value, chat_id) + ) + + def update_chat_name(self, chat_id: str, chat_name: str): + """updates a field in the chats table""" + with _SQLiteContextMgr(database=self.db_name) as db: + db.execute( + "UPDATE chats SET name = ? WHERE id = ?", (chat_name, chat_id) + ) + + def update_chat_answer_chance(self, chat_id: str, answer_chance: float): + with _SQLiteContextMgr(database=self.db_name) as db: + db.execute( + "UPDATE chats SET answer_chance = ? WHERE id = ?", + (answer_chance, chat_id), + ) + + def update_chat_message_period(self, chat_id: str, period: int): + with _SQLiteContextMgr(database=self.db_name) as db: + db.execute( + "UPDATE chats SET message_period = ? WHERE id = ?", + (period, chat_id), + ) + + def update_chat_set_restricted(self, chat_id: str, restricted: bool): + with _SQLiteContextMgr(database=self.db_name) as db: + db.execute( + "UPDATE chats SET restricted = ? WHERE id = ?", + (int(restricted), chat_id), + ) + + def update_chat_set_silenced(self, chat_id: str, silenced: bool): + with _SQLiteContextMgr(database=self.db_name) as db: + db.execute( + "UPDATE chats SET silenced = ? WHERE id = ?", + (int(silenced), chat_id), + ) + + def incr_processed_messages(self, chat_id: str): + with _SQLiteContextMgr(database=self.db_name) as db: + db.execute( + "UPDATE chats SET processed_messages = processed_messages + ?" + " WHERE id = ?", + (1, chat_id), + ) + + def count_graphics(self, chat_id: str) -> int: + """Returns the number of graphics stored for a given chat id.""" + with _SQLiteContextMgr(database=self.db_name) as db: + count = ( + db.execute( + "SELECT SUM(count) FROM graphics WHERE chat_id = ?", + (chat_id,), + ).fetchone()[0] + or 0 + ) + return count + + def count_words(self, chat_id: str, key: str) -> int: + """Returns the number of words that follow a given key for a given chat id.""" + with _SQLiteContextMgr(database=self.db_name) as db: + count = ( + db.execute( + "SELECT SUM(count) FROM vocab WHERE chat_id = ? AND" + " key = ?", + (chat_id, key), + ).fetchone()[0] + or 0 + ) + return count + + def get_all_chats(self) -> list[ChatData]: + with _SQLiteContextMgr(database=self.db_name) as db: + rows = db.execute("SELECT * FROM chats").fetchall() + return [ChatData.from_sql_row(row) for row in rows] + + def get_chat_data(self, chat_id: str) -> ChatData | None: + with _SQLiteContextMgr(database=self.db_name) as db: + result = db.execute( + "SELECT * FROM chats WHERE id = ?", (chat_id,) + ).fetchone() + + if result: + return ChatData.from_sql_row(result) + return None diff --git a/velascobot2/messenger.py b/velascobot2/messenger.py new file mode 100644 index 0000000..7298cac --- /dev/null +++ b/velascobot2/messenger.py @@ -0,0 +1,140 @@ +import time +import logging +from enum import Enum + +from velascobot2.utils import ChatData, GraphicType, Response +from velascobot2.parrot import Parrot + +logger = logging.getLogger(__name__) + + +class ReplyMode(Enum): + FIXED = 1 + CHANCE = 2 + + +class Messenger: + def __init__( + self, + bot_name: str, + bosses: list[str] = [], + nicknames: list[str] = [], + reply: float = 0.1, + repeat: float = 0.05, + wake_up: bool = False, + mode: ReplyMode = ReplyMode.FIXED, + mute_time: int = 60, + max_len: int = 50, + min_period: int = 1, + max_period: int = 100000, + ): + # List of nicknames (other than the username) that the bot can react to + self.names = nicknames + # Mute time for Telegram network errors + self.mute_time = mute_time + # Last mute timestamp + self.mute_timer = None + # The bot's username, "@" included. Updated on Bot creation + self.username = "@" + bot_name + # Wakeup flag that determines if it should send a wakeup message to + # stored groupchats + self.wake_up = wake_up + # Chance of sending messages as replies + self.reply = reply + # Chance of sending 2 messages in a row + self.repeat = repeat + # Bot bosses ID + self.bosses = bosses + # Max word length for a message + self.max_len = max_len + # Whether a message is guaranteed after period_messages, or if it's a + # random chance + self.mode = mode + # minimum value allowed for message_period + self.min_period = min_period + # maximum value allowed for message_period + self.max_period = max_period + + self._parrot = Parrot(bot_name) + + def get_all_chats(self) -> list[ChatData]: + return self._parrot.get_all_chats() + + def mentioned(self, text: str) -> bool: + """Returns True if the bot's username or any of its nicknames is + mentioned in the text, as long as it's not some other user's + username""" + if self.username in text: + return True + for name in self.names: + if name in text and "@{}".format(name) not in text: + return True + return False + + def is_mute(self) -> bool: + """Returns True if not enough time has passed since the last time the + bot got muted.""" + current_time = int(time.perf_counter()) + return ( + self.mute_timer is not None + and (current_time - self.mute_timer) < self.mute_time + ) + + def give_response( + self, chat_id: str + ) -> tuple[Response, str | tuple[GraphicType, str]]: + response = self._parrot.choose_response_type(chat_id) + if response == Response.TEXT: + return response, self._parrot.give_text(chat_id) + elif response == Response.GRAPHIC: |
