aboutsummaryrefslogtreecommitdiff
path: root/velascobot2
diff options
context:
space:
mode:
authorGuillermo Serrahima <dev@vylion.com>2026-02-02 02:43:14 +0100
committerGuillermo Serrahima <dev@vylion.com>2026-02-02 02:43:14 +0100
commit512915dd4587491dad19c13cde92daada5756d72 (patch)
tree3fbf32282066486b59b3f1c8120a073e304a7a7e /velascobot2
parent91a1bed08873ad7698f6f5e25324a69677dbcdac (diff)
downloadvelascobot2-512915dd4587491dad19c13cde92daada5756d72.tar.xz
velascobot2-512915dd4587491dad19c13cde92daada5756d72.zip
Initial commit. Achieved minimum functionality
Diffstat (limited to 'velascobot2')
-rw-r--r--velascobot2/bot.py606
-rw-r--r--velascobot2/database.py257
-rw-r--r--velascobot2/messenger.py140
-rw-r--r--velascobot2/parrot.py135
-rw-r--r--velascobot2/utils.py48
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: