diff --git a/bot.py b/bot.py index 3f79158..6cc7d6b 100644 --- a/bot.py +++ b/bot.py @@ -2,13 +2,17 @@ import logging import os from dotenv import load_dotenv +from database import Database from telegram import CommandProcessor load_dotenv() token = os.getenv('TELEGRAM_TOKEN') +db_path = os.getenv('DATABASE_PATH') + +db = Database(db_path) +bot = CommandProcessor(token, db) -bot = CommandProcessor(token) logging.info("Starting Telegram bot") bot.run() diff --git a/requirements.txt b/requirements.txt index e650209..46d6009 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,11 @@ certifi==2021.10.8 charset-normalizer==2.0.12 +decorator==5.1.1 +feedparser==6.0.2 idna==3.3 pyTelegramBotAPI==4.5.0 python-dotenv==0.20.0 requests==2.27.1 +sgmllib3k==1.0.0 urllib3==1.26.9 -feedparser==6.0.2 \ No newline at end of file +validators==0.19.0 diff --git a/telegram.py b/telegram.py index b89075c..5f12374 100644 --- a/telegram.py +++ b/telegram.py @@ -1,46 +1,93 @@ -import telebot +from telebot import TeleBot +from telebot.handler_backends import BaseMiddleware from telebot.types import Message +import validators + +from database import Database +from exceptions import DisplayableException class CommandProcessor: """Processes user input and dispatches the data to other services.""" - bot: telebot.TeleBot - - def __init__(self, token: str): + def __init__(self, token: str, database: Database): if token is None or len(token) == 0: raise ValueError("Token should not be empty") - self.bot = telebot.TeleBot(token) + self.bot: TeleBot = TeleBot(token, use_class_middlewares=True) + self.bot.setup_middleware(UserAuthMiddleware(database)) + self.bot.setup_middleware(ExceptionHandlerMiddleware(self.bot)) + self.db: Database = database def run(self): - """Runs a bot and polls for new messages indefinitely.""" - self.bot.register_message_handler(commands=['help', 'start'], callback=self.__command_help) + """Run a bot and poll for new messages indefinitely.""" self.bot.register_message_handler(commands=['add'], callback=self.__add_feed) self.bot.register_message_handler(commands=['list'], callback=self.__list_feeds) self.bot.register_message_handler(commands=['del'], callback=self.__delete_feed) + self.bot.register_message_handler(commands=['help', 'start'], callback=self.__command_help) + self.bot.register_message_handler(callback=self.__command_help) self.bot.infinity_polling() - def __command_help(self, message: Message): - self.bot.reply_to(message, 'Commands: "/add", "/list", "/del", "/help".') + def __command_help(self, message: Message, data: dict): + self.bot.reply_to( + message, + 'Supported commands:\n' + ' /add - Add new feed\n ' + ' /list - List currently added feeds\n' + ' /del - Remove feed\n' + ' /help - Get this help message' + ) - def __add_feed(self, message: Message): - self.bot.reply_to(message, 'Feed added (stub)') + def __add_feed(self, message: Message, data: dict): + args = message.text.split() + if len(args) < 2: + raise DisplayableException('Feed URL should be specified') - def __list_feeds(self, message: Message): - self.bot.reply_to(message, 'Your feeds: (stub)') + url = str(args[1]) + if not self.__is_url_valid(url): + raise DisplayableException('Invalid feed URL') - def __delete_feed(self, message: Message): - self.bot.reply_to(message, 'Feed deleted: (stub)') + self.db.subscribe_user_by_url(data['user_id'], url) + + self.bot.reply_to(message, 'Successfully subscribed to feed.') + + def __list_feeds(self, message: Message, data: dict): + feeds = self.db.find_user_feeds(data['user_id']) + + feed_list = '' + for feed in feeds: + feed_list += '* ' + str(feed[0]) + ': ' + feed[1] + '\n' + + self.bot.reply_to(message, 'Your feeds:\n' + feed_list) + + def __delete_feed(self, message: Message, data: dict): + args = message.text.split() + if len(args) < 2: + raise DisplayableException('Feed URL should be specified') + + url = str(args[1]) + if not self.__is_url_valid(url): + raise DisplayableException('Invalid feed URL') + + self.db.unsubscribe_user_by_url(data['user_id'], url) + + self.bot.reply_to(message, 'Unsubscribed.') + + def __is_url_valid(self, url: str) -> bool: + if not validators.url(url): + return False + + """For security reasons we should not allow anything except HTTP/HTTPS.""" + if not url.startswith(('http://', 'https://')): + return False + return True class Notifier: """Sends notifications to users about new RSS feed items.""" - bot: telebot.TeleBot - def __init__(self, token: str): - self.bot = telebot.TeleBot(token) + self.bot: TeleBot = TeleBot(token) def send_updates(self, chat_id: int, updates: list): """Send notification about new items to the user""" @@ -65,3 +112,48 @@ class Notifier: f"**[{update['title']}]({update['url']})**\n\n" f"{update['text']}" ) + + +class UserAuthMiddleware(BaseMiddleware): + """Transparently authenticates and registers the user if needed.""" + + def __init__(self, db: Database): + super().__init__() + self.update_types = ['message'] + self.db: Database = db + + def pre_process(self, message: Message, data: dict): + """Pre-process the message, find user and add it's ID to the handler data dictionary.""" + data['user_id'] = self.__find_or_register_user(message) + + def post_process(self, message: Message, data: dict, exception): + """Post-process the message.""" + pass + + def __find_or_register_user(self, message: Message) -> int: + telegram_id = message.from_user.id + + user_id = self.db.find_user(telegram_id) + if user_id is None: + return self.db.add_user(telegram_id) + return user_id + + +class ExceptionHandlerMiddleware(BaseMiddleware): + """Sends messages to the user on exception.""" + + def __init__(self, bot: TeleBot): + super().__init__() + self.update_types = ['message'] + self.bot: TeleBot = bot + + def pre_process(self, message, data): + pass + + def post_process(self, message: Message, data: dict, exception: Exception | None): + if exception is None: + return + elif type(exception) is DisplayableException: + self.bot.reply_to(message, 'Error: ' + str(exception)) + else: + self.bot.reply_to(message, 'Something went wrong. Please try again (maybe later).')