From 1bdaa756689683d99424f96f00e53995d8fa18e1 Mon Sep 17 00:00:00 2001 From: Alexey Skobkin Date: Mon, 2 May 2022 02:43:04 +0300 Subject: [PATCH 01/19] #7 package draft. --- telegram/__init__.py | 0 telegram/command_processor.py | 25 +++++++++++++++++++++++++ telegram/notifier.py | 11 +++++++++++ 3 files changed, 36 insertions(+) create mode 100644 telegram/__init__.py create mode 100644 telegram/command_processor.py create mode 100644 telegram/notifier.py diff --git a/telegram/__init__.py b/telegram/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/telegram/command_processor.py b/telegram/command_processor.py new file mode 100644 index 0000000..bd5f162 --- /dev/null +++ b/telegram/command_processor.py @@ -0,0 +1,25 @@ +import telebot +from telebot.types import Message + + +class CommandProcessor: + bot: telebot.TeleBot + + def __init__(self, token: str): + self.bot = telebot.TeleBot(token) + + def run(self): + self.bot.register_message_handler(commands=['help', 'start'], 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 __add_feed(self, message: Message): + self.bot.reply_to(message, 'Feed added (stub)') + + def __list_feeds(self, message: Message): + self.bot.reply_to(message, 'Your feeds: (stub)') + + def __delete_feed(self, message: Message): + self.bot.reply_to(message, 'Feed deleted: (stub)') diff --git a/telegram/notifier.py b/telegram/notifier.py new file mode 100644 index 0000000..1eb2b85 --- /dev/null +++ b/telegram/notifier.py @@ -0,0 +1,11 @@ +import telebot + + +class Notifier: + bot: telebot.TeleBot + + def __init__(self, token: str): + self.bot = telebot.TeleBot(token) + + def notify(self, chat_id: int): + self.bot.send_message(chat_id=chat_id, text="Notification stub") -- 2.43.5 From aaaed01f9dad5be7533dae5badc229c828c68532 Mon Sep 17 00:00:00 2001 From: Alexey Skobkin Date: Mon, 2 May 2022 17:53:53 +0300 Subject: [PATCH 02/19] #7 Adding docstrings to classes. --- telegram/command_processor.py | 4 ++++ telegram/notifier.py | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/telegram/command_processor.py b/telegram/command_processor.py index bd5f162..bc5d2d2 100644 --- a/telegram/command_processor.py +++ b/telegram/command_processor.py @@ -3,6 +3,10 @@ from telebot.types import Message class CommandProcessor: + """ + Processes user input and dispatches the data to other services. + """ + bot: telebot.TeleBot def __init__(self, token: str): diff --git a/telegram/notifier.py b/telegram/notifier.py index 1eb2b85..633f901 100644 --- a/telegram/notifier.py +++ b/telegram/notifier.py @@ -2,6 +2,10 @@ import telebot class Notifier: + """ + Sends notifications to users about new RSS feed items. + """ + bot: telebot.TeleBot def __init__(self, token: str): -- 2.43.5 From 5628f9a39644546c7d0fc71bfe31594505a7b377 Mon Sep 17 00:00:00 2001 From: Alexey Skobkin Date: Mon, 2 May 2022 18:11:20 +0300 Subject: [PATCH 03/19] #7 Some pylama tweaks. --- pylama.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pylama.ini b/pylama.ini index b0a5d70..a17cf17 100644 --- a/pylama.ini +++ b/pylama.ini @@ -3,7 +3,7 @@ format = pylint skip = .venv/* linters = pyflakes,pylint,pycodestyle -#ignore = F0401,C0111,E731 +ignore = F0401,C0114,R0903 [pylama:pylint] max_line_length = 120 -- 2.43.5 From db7369af638b835e3a5c44c35a3c33f5ef1380d5 Mon Sep 17 00:00:00 2001 From: Alexey Skobkin Date: Mon, 2 May 2022 18:18:13 +0300 Subject: [PATCH 04/19] #7 Registering handlers in CommandProcessor. Some docstrings added. --- telegram/command_processor.py | 9 ++++++--- telegram/notifier.py | 5 ++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/telegram/command_processor.py b/telegram/command_processor.py index bc5d2d2..6129ded 100644 --- a/telegram/command_processor.py +++ b/telegram/command_processor.py @@ -3,9 +3,7 @@ from telebot.types import Message class CommandProcessor: - """ - Processes user input and dispatches the data to other services. - """ + """Processes user input and dispatches the data to other services.""" bot: telebot.TeleBot @@ -13,7 +11,12 @@ class CommandProcessor: self.bot = telebot.TeleBot(token) def run(self): + """Runs a bot and polls for new messages indefinitely.""" self.bot.register_message_handler(commands=['help', 'start'], callback=self.__command_help) + 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.infinity_polling() def __command_help(self, message: Message): diff --git a/telegram/notifier.py b/telegram/notifier.py index 633f901..9abdc55 100644 --- a/telegram/notifier.py +++ b/telegram/notifier.py @@ -2,9 +2,7 @@ import telebot class Notifier: - """ - Sends notifications to users about new RSS feed items. - """ + """Sends notifications to users about new RSS feed items.""" bot: telebot.TeleBot @@ -12,4 +10,5 @@ class Notifier: self.bot = telebot.TeleBot(token) def notify(self, chat_id: int): + """Send notification to the user""" self.bot.send_message(chat_id=chat_id, text="Notification stub") -- 2.43.5 From 8f359847bf105deab2d31612416339f41f1d4656 Mon Sep 17 00:00:00 2001 From: Alexey Skobkin Date: Mon, 2 May 2022 18:30:21 +0300 Subject: [PATCH 05/19] #11 pylama. Hiding pylama installation in Drone CI logs. --- .drone.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.drone.yml b/.drone.yml index 4e296fa..2a18ece 100644 --- a/.drone.yml +++ b/.drone.yml @@ -24,7 +24,7 @@ steps: commands: - python -m venv .venv - source ./.venv/bin/activate - - 'pip install pylama pylama\[all\]' + - 'pip install pylama pylama\[all\] > /dev/null' - pylama when: event: -- 2.43.5 From 5509053bb00c976de4e8caeb4e9dd7f9d50aa01f Mon Sep 17 00:00:00 2001 From: Alexey Skobkin Date: Mon, 2 May 2022 19:14:12 +0300 Subject: [PATCH 06/19] #7 Implementing Telegram bot run script. --- README.md | 7 +++++++ bot.py | 13 +++++++++++++ requirements.txt | 1 + telegram/command_processor.py | 3 ++- 4 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 bot.py diff --git a/README.md b/README.md index b19ac07..f90e6dc 100644 --- a/README.md +++ b/README.md @@ -29,3 +29,10 @@ pip freeze > requirements.txt **Do not forget** to install the latest dependencies before adding new dependencies and rewriting the `requirements.txt` file. Otherwise old dependencies could be lost. + +## Running the bot + +```shell +export TELEGRAM_TOKEN=xxx +python bot.py +``` diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..b3de037 --- /dev/null +++ b/bot.py @@ -0,0 +1,13 @@ +import logging +import os +from telegram.command_processor import CommandProcessor + +from dotenv import load_dotenv + +load_dotenv() + +token = os.getenv('TELEGRAM_TOKEN') + +bot = CommandProcessor(token) +logging.info("Starting Telegram bot") +bot.run() diff --git a/requirements.txt b/requirements.txt index 5a8b4cb..e650209 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,7 @@ certifi==2021.10.8 charset-normalizer==2.0.12 idna==3.3 pyTelegramBotAPI==4.5.0 +python-dotenv==0.20.0 requests==2.27.1 urllib3==1.26.9 feedparser==6.0.2 \ No newline at end of file diff --git a/telegram/command_processor.py b/telegram/command_processor.py index 6129ded..7856ac6 100644 --- a/telegram/command_processor.py +++ b/telegram/command_processor.py @@ -1,13 +1,14 @@ import telebot from telebot.types import Message - class CommandProcessor: """Processes user input and dispatches the data to other services.""" bot: telebot.TeleBot def __init__(self, token: str): + if token is None or len(token) == 0: + raise ValueError("Token should not be empty") self.bot = telebot.TeleBot(token) def run(self): -- 2.43.5 From c0148c2c91b46e26875d6e4cd2f053307d391528 Mon Sep 17 00:00:00 2001 From: Alexey Skobkin Date: Mon, 2 May 2022 23:41:17 +0300 Subject: [PATCH 07/19] #7 Making more detailed logic of notification. Slight refactoring. --- telegram/notifier.py | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/telegram/notifier.py b/telegram/notifier.py index 9abdc55..d80dfc9 100644 --- a/telegram/notifier.py +++ b/telegram/notifier.py @@ -9,6 +9,26 @@ class Notifier: def __init__(self, token: str): self.bot = telebot.TeleBot(token) - def notify(self, chat_id: int): - """Send notification to the user""" - self.bot.send_message(chat_id=chat_id, text="Notification stub") + def send_updates(self, chat_id: int, updates: list): + """Send notification about new items to the user""" + for update in updates: + self.__send_update(chat_id, update) + + def __send_update(self, chat_id: int, update): + self.bot.send_message( + chat_id=chat_id, + text=self.__format_message(), + parse_mode='MarkdownV2' + ) + + def __format_message(self) -> str: + update = { + 'title': 'Item Title', + 'text': 'Short item text here...', + 'url': 'https://fediland.github.io/', + } + + return ( + f"**[{update['title']}]({update['url']})**\n\n" + f"{update['text']}" + ) -- 2.43.5 From dc314b4599d6dbc6b8ca9076832101127ea74d1f Mon Sep 17 00:00:00 2001 From: Alexey Skobkin Date: Mon, 2 May 2022 23:44:16 +0300 Subject: [PATCH 08/19] #7 Code style changes. --- bot.py | 3 ++- telegram/command_processor.py | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/bot.py b/bot.py index b3de037..22aff3f 100644 --- a/bot.py +++ b/bot.py @@ -1,8 +1,9 @@ import logging import os +from dotenv import load_dotenv + from telegram.command_processor import CommandProcessor -from dotenv import load_dotenv load_dotenv() diff --git a/telegram/command_processor.py b/telegram/command_processor.py index 7856ac6..1081f99 100644 --- a/telegram/command_processor.py +++ b/telegram/command_processor.py @@ -1,6 +1,7 @@ import telebot from telebot.types import Message + class CommandProcessor: """Processes user input and dispatches the data to other services.""" -- 2.43.5 From 305f1afa3ceaeb6eb34bcb1f83d0ac1294c82133 Mon Sep 17 00:00:00 2001 From: Alexey Skobkin Date: Mon, 30 May 2022 00:33:36 +0300 Subject: [PATCH 09/19] #7 Moving from package to module. --- bot.py | 2 +- telegram/command_processor.py => telegram.py | 33 +++++++++++++++++++ telegram/__init__.py | 0 telegram/notifier.py | 34 -------------------- 4 files changed, 34 insertions(+), 35 deletions(-) rename telegram/command_processor.py => telegram.py (58%) delete mode 100644 telegram/__init__.py delete mode 100644 telegram/notifier.py diff --git a/bot.py b/bot.py index 22aff3f..3f79158 100644 --- a/bot.py +++ b/bot.py @@ -2,7 +2,7 @@ import logging import os from dotenv import load_dotenv -from telegram.command_processor import CommandProcessor +from telegram import CommandProcessor load_dotenv() diff --git a/telegram/command_processor.py b/telegram.py similarity index 58% rename from telegram/command_processor.py rename to telegram.py index 1081f99..b89075c 100644 --- a/telegram/command_processor.py +++ b/telegram.py @@ -32,3 +32,36 @@ class CommandProcessor: def __delete_feed(self, message: Message): self.bot.reply_to(message, 'Feed deleted: (stub)') + + +class Notifier: + """Sends notifications to users about new RSS feed items.""" + + bot: telebot.TeleBot + + def __init__(self, token: str): + self.bot = telebot.TeleBot(token) + + def send_updates(self, chat_id: int, updates: list): + """Send notification about new items to the user""" + for update in updates: + self.__send_update(chat_id, update) + + def __send_update(self, chat_id: int, update): + self.bot.send_message( + chat_id=chat_id, + text=self.__format_message(), + parse_mode='MarkdownV2' + ) + + def __format_message(self) -> str: + update = { + 'title': 'Item Title', + 'text': 'Short item text here...', + 'url': 'https://fediland.github.io/', + } + + return ( + f"**[{update['title']}]({update['url']})**\n\n" + f"{update['text']}" + ) diff --git a/telegram/__init__.py b/telegram/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/telegram/notifier.py b/telegram/notifier.py deleted file mode 100644 index d80dfc9..0000000 --- a/telegram/notifier.py +++ /dev/null @@ -1,34 +0,0 @@ -import telebot - - -class Notifier: - """Sends notifications to users about new RSS feed items.""" - - bot: telebot.TeleBot - - def __init__(self, token: str): - self.bot = telebot.TeleBot(token) - - def send_updates(self, chat_id: int, updates: list): - """Send notification about new items to the user""" - for update in updates: - self.__send_update(chat_id, update) - - def __send_update(self, chat_id: int, update): - self.bot.send_message( - chat_id=chat_id, - text=self.__format_message(), - parse_mode='MarkdownV2' - ) - - def __format_message(self) -> str: - update = { - 'title': 'Item Title', - 'text': 'Short item text here...', - 'url': 'https://fediland.github.io/', - } - - return ( - f"**[{update['title']}]({update['url']})**\n\n" - f"{update['text']}" - ) -- 2.43.5 From dbdb256359f94c5b6561115074b2c00aa5baa61d Mon Sep 17 00:00:00 2001 From: Alexey Skobkin Date: Mon, 30 May 2022 02:59:40 +0300 Subject: [PATCH 10/19] #6 #7 Adding feed operation methods to the Database class. Creating custom DisplayableException for future usage in CommandProcessor. Some PEP-8 code style changes. --- database.py | 104 ++++++++++++++++++++++++++++++++++++++++++-------- exceptions.py | 3 ++ 2 files changed, 92 insertions(+), 15 deletions(-) create mode 100644 exceptions.py diff --git a/database.py b/database.py index f8f4542..6c74564 100644 --- a/database.py +++ b/database.py @@ -1,28 +1,25 @@ import sqlite3 -""" -Classes: -Database - implement intercaction with the database. -""" -class Database(): - """Implement intercaction with the database.""" +from exceptions import DisplayableException + + +class Database: + """Implement interaction with the database.""" def __init__(self, path: str) -> None: """Create a database file if not exists.""" - self.conn = sqlite3.connect(path) + # TODO: think about removing check_same_thread=False + self.conn = sqlite3.connect(path, check_same_thread=False) self.cur = self.conn.cursor() - self.cur.execute('CREATE TABLE IF NOT EXISTS users (id INTEGER, telegram_id NUMERIC NOT NULL UNIQUE, PRIMARY KEY(id))') - self.cur.execute('CREATE TABLE IF NOT EXISTS feeds (id INTEGER, url TEXT NOT NULL UNIQUE, PRIMARY KEY(id))') - self.cur.execute('CREATE TABLE IF NOT EXISTS subscriptions (user_id INTEGER, feed_id INTEGER, UNIQUE (user_id, feed_id), FOREIGN KEY(user_id) REFERENCES users(id), FOREIGN KEY(feed_id) REFERENCES feeds(id))') - self.cur.execute('CREATE TABLE IF NOT EXISTS feeds_last_items (feed_id INTEGER, url TEXT NOT NULL UNIQUE, title TEXT, description TEXT, date NUMERIC, FOREIGN KEY(feed_id) REFERENCES feeds(id))') + self.__init_schema() - def add_user(self, telegram_id: str) -> int: + def add_user(self, telegram_id: int) -> int: """Add a user's telegram id to the database and return its database id.""" self.cur.execute('INSERT INTO users (telegram_id) VALUES (?)', [telegram_id]) self.conn.commit() return self.cur.lastrowid - def find_user(self, telegram_id: str) -> int | None: + def find_user(self, telegram_id: int) -> int | None: """Get a user's telegram id and return its database id.""" self.cur.execute('SELECT id FROM users WHERE telegram_id = ?', [telegram_id]) row = self.cur.fetchone() @@ -36,21 +33,70 @@ class Database(): self.conn.commit() return self.cur.lastrowid + def find_feed_by_url(self, url: str) -> int | None: + """Find feed ID by url.""" + self.cur.execute('SELECT id FROM feeds WHERE url = ?', [url]) + row = self.cur.fetchone() + if row is None: + return None + return row[0] + + def subscribe_user_by_url(self, user_id: int, url: str) -> None: + """Subscribe user to the feed creating it if does not exist yet.""" + feed_id = self.find_feed_by_url(url) + if feed_id is None: + feed_id = self.add_feed(url) + + if self.is_user_subscribed(user_id, feed_id): + raise DisplayableException('Already subscribed') + + self.subscribe_user(user_id, feed_id) + def subscribe_user(self, user_id: int, feed_id: int) -> None: """Subscribe a user to the feed.""" self.cur.execute('INSERT INTO subscriptions (user_id, feed_id) VALUES (?, ?)', [user_id, feed_id]) self.conn.commit() + def unsubscribe_user_by_url(self, user_id: int, url: str) -> None: + feed_id = self.find_feed_by_url(url) + if feed_id is None: + raise DisplayableException('Feed does not exist') + + if not self.is_user_subscribed(user_id, feed_id): + raise DisplayableException('Not subscribed') + + self.unsubscribe_user(user_id, feed_id) + + if self.get_feed_subscribers_count(feed_id) == 0: + # Feed is not used anymore. Removing. + self.delete_feed(feed_id) + def unsubscribe_user(self, user_id: int, feed_id: int) -> None: """Unsubscribe a user from the feed.""" self.cur.execute('DELETE FROM subscriptions WHERE feed_id = ? AND user_id = ?', [feed_id, user_id]) self.conn.commit() + def is_user_subscribed(self, user_id: int, feed_id: int) -> bool: + """Check if user subscribed to specific feed.""" + self.cur.execute('SELECT 1 FROM subscriptions WHERE user_id = ? AND feed_id = ?', [user_id, feed_id]) + row = self.cur.fetchone() + if row is None: + return False + return True + def delete_feed(self, feed_id: int) -> None: """Delete a feed.""" self.cur.execute('DELETE FROM feeds WHERE id = ?', [feed_id]) self.conn.commit() + def get_feed_subscribers_count(self, feed_id: int) -> int: + """Count feed subscribers.""" + self.cur.execute('SELECT COUNT(user_id) FROM subscriptions WHERE feed_id = ?', [feed_id]) + row = self.cur.fetchone() + if row is None: + return 0 + return int(row[0]) + def find_feeds(self) -> list: """Get a list of feeds.""" self.cur.execute('SELECT * FROM feeds') @@ -58,7 +104,8 @@ class Database(): def find_user_feeds(self, user_id: int) -> list: """Return a list of feeds the user is subscribed to.""" - self.cur.execute('SELECT * FROM feeds WHERE id IN (SELECT feed_id FROM subscriptions WHERE user_id = ?)', [user_id]) + self.cur.execute('SELECT * FROM feeds WHERE id IN (SELECT feed_id FROM subscriptions WHERE user_id = ?)', + [user_id]) return self.cur.fetchall() def find_feed_items(self, feed_id: int) -> list: @@ -72,5 +119,32 @@ class Database(): new_items[i] = (feed_id,) + new_items[i] self.cur.execute('DELETE FROM feeds_last_items WHERE feed_id = ?', [feed_id]) - self.cur.executemany('INSERT INTO feeds_last_items (feed_id, url, title, description, date) VALUES (?, ?, ?, ?, ?)', new_items) + self.cur.executemany( + 'INSERT INTO feeds_last_items (feed_id, url, title, description, date) VALUES (?, ?, ?, ?, ?)', new_items) self.conn.commit() + + def __init_schema(self): + # TODO: Move to migrations + self.cur.execute( + 'CREATE TABLE IF NOT EXISTS users (id INTEGER, telegram_id INTEGER NOT NULL UNIQUE, PRIMARY KEY(id))' + ) + self.cur.execute('CREATE TABLE IF NOT EXISTS feeds (id INTEGER, url TEXT NOT NULL UNIQUE, PRIMARY KEY(id))') + self.cur.execute( + 'CREATE TABLE IF NOT EXISTS subscriptions (' + ' user_id INTEGER,' + ' feed_id INTEGER,' + ' UNIQUE (user_id, feed_id),' + ' FOREIGN KEY(user_id) REFERENCES users(id),' + ' FOREIGN KEY(feed_id) REFERENCES feeds(id)' + ')' + ) + self.cur.execute( + 'CREATE TABLE IF NOT EXISTS feeds_last_items (' + ' feed_id INTEGER,' + ' url TEXT NOT NULL UNIQUE,' + ' title TEXT,' + ' description TEXT,' + ' date NUMERIC,' + ' FOREIGN KEY(feed_id) REFERENCES feeds(id)' + ')' + ) diff --git a/exceptions.py b/exceptions.py new file mode 100644 index 0000000..5072879 --- /dev/null +++ b/exceptions.py @@ -0,0 +1,3 @@ +class DisplayableException(Exception): + """Exception which could be safely displayed to the end-user.""" + pass -- 2.43.5 From 93610e40849e18526a8475ddb72a38401dd752f9 Mon Sep 17 00:00:00 2001 From: Alexey Skobkin Date: Mon, 30 May 2022 03:02:02 +0300 Subject: [PATCH 11/19] #7 Database integration. All basic commands implemented in CommandProcessor. Two telegram update middlewares implemented (UserAuthMiddleware, ExceptionHandlerMiddleware). --- bot.py | 6 ++- requirements.txt | 5 +- telegram.py | 128 ++++++++++++++++++++++++++++++++++++++++------- 3 files changed, 119 insertions(+), 20 deletions(-) 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).') -- 2.43.5 From 54cded15e55b258f22d984a5e05c0d11b30996ee Mon Sep 17 00:00:00 2001 From: Alexey Skobkin Date: Mon, 30 May 2022 03:17:11 +0300 Subject: [PATCH 12/19] #7 Code style changes recommended by pylint. --- telegram.py | 45 +++++++++++++++++++++------------------------ 1 file changed, 21 insertions(+), 24 deletions(-) diff --git a/telegram.py b/telegram.py index 5f12374..f419e57 100644 --- a/telegram.py +++ b/telegram.py @@ -16,7 +16,7 @@ class CommandProcessor: 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 + self.database: Database = database def run(self): """Run a bot and poll for new messages indefinitely.""" @@ -28,7 +28,7 @@ class CommandProcessor: self.bot.infinity_polling() - def __command_help(self, message: Message, data: dict): + def __command_help(self, message: Message): self.bot.reply_to( message, 'Supported commands:\n' @@ -47,12 +47,12 @@ class CommandProcessor: if not self.__is_url_valid(url): raise DisplayableException('Invalid feed URL') - self.db.subscribe_user_by_url(data['user_id'], url) + self.database.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']) + feeds = self.database.find_user_feeds(data['user_id']) feed_list = '' for feed in feeds: @@ -69,15 +69,16 @@ class CommandProcessor: if not self.__is_url_valid(url): raise DisplayableException('Invalid feed URL') - self.db.unsubscribe_user_by_url(data['user_id'], url) + self.database.unsubscribe_user_by_url(data['user_id'], url) self.bot.reply_to(message, 'Unsubscribed.') - def __is_url_valid(self, url: str) -> bool: + @staticmethod + def __is_url_valid(url: str) -> bool: if not validators.url(url): return False - """For security reasons we should not allow anything except HTTP/HTTPS.""" + # For security reasons we should not allow anything except HTTP/HTTPS. if not url.startswith(('http://', 'https://')): return False return True @@ -101,13 +102,8 @@ class Notifier: parse_mode='MarkdownV2' ) - def __format_message(self) -> str: - update = { - 'title': 'Item Title', - 'text': 'Short item text here...', - 'url': 'https://fediland.github.io/', - } - + @staticmethod + def __format_message(item) -> str: return ( f"**[{update['title']}]({update['url']})**\n\n" f"{update['text']}" @@ -117,25 +113,24 @@ class Notifier: class UserAuthMiddleware(BaseMiddleware): """Transparently authenticates and registers the user if needed.""" - def __init__(self, db: Database): + def __init__(self, database: Database): super().__init__() self.update_types = ['message'] - self.db: Database = db + self.database: Database = database def pre_process(self, message: Message, data: dict): - """Pre-process the message, find user and add it's ID to the handler data dictionary.""" + """Pre-process update, 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 + """Post-process update.""" def __find_or_register_user(self, message: Message) -> int: telegram_id = message.from_user.id - user_id = self.db.find_user(telegram_id) + user_id = self.database.find_user(telegram_id) if user_id is None: - return self.db.add_user(telegram_id) + return self.database.add_user(telegram_id) return user_id @@ -147,13 +142,15 @@ class ExceptionHandlerMiddleware(BaseMiddleware): self.update_types = ['message'] self.bot: TeleBot = bot - def pre_process(self, message, data): - pass + def pre_process(self, message: Message, data: dict): + """Pre-process update.""" + # pylint: disable=W0613 def post_process(self, message: Message, data: dict, exception: Exception | None): + """Post-process update. Send user an error notification.""" if exception is None: return - elif type(exception) is DisplayableException: + if isinstance(exception, DisplayableException): self.bot.reply_to(message, 'Error: ' + str(exception)) else: self.bot.reply_to(message, 'Something went wrong. Please try again (maybe later).') -- 2.43.5 From 882b4df5e197f242855f62ee77c7f4883d19028e Mon Sep 17 00:00:00 2001 From: Alexey Skobkin Date: Mon, 30 May 2022 03:33:04 +0300 Subject: [PATCH 13/19] #7 Notifier is now able to process real FeedItem objects. --- telegram.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/telegram.py b/telegram.py index f419e57..b456060 100644 --- a/telegram.py +++ b/telegram.py @@ -5,6 +5,7 @@ import validators from database import Database from exceptions import DisplayableException +from rss import FeedItem class CommandProcessor: @@ -90,23 +91,23 @@ class Notifier: def __init__(self, token: str): self.bot: TeleBot = TeleBot(token) - def send_updates(self, chat_id: int, updates: list): + def send_updates(self, chat_id: int, updates: list[FeedItem]): """Send notification about new items to the user""" for update in updates: self.__send_update(chat_id, update) - def __send_update(self, chat_id: int, update): + def __send_update(self, telegram_id: int, update: FeedItem): self.bot.send_message( - chat_id=chat_id, - text=self.__format_message(), + chat_id=telegram_id, + text=self.__format_message(update), parse_mode='MarkdownV2' ) @staticmethod - def __format_message(item) -> str: + def __format_message(item: FeedItem) -> str: return ( - f"**[{update['title']}]({update['url']})**\n\n" - f"{update['text']}" + f"**[{item.title}]({item.url})**\n\n" + f"{item.description}" ) -- 2.43.5 From af73458e29649fd3e766734ad5fc324eaf5a3eab Mon Sep 17 00:00:00 2001 From: Alexey Skobkin Date: Mon, 30 May 2022 03:35:16 +0300 Subject: [PATCH 14/19] #7 Removing unnecessary statement. --- exceptions.py | 1 - 1 file changed, 1 deletion(-) diff --git a/exceptions.py b/exceptions.py index 5072879..a1effe0 100644 --- a/exceptions.py +++ b/exceptions.py @@ -1,3 +1,2 @@ class DisplayableException(Exception): """Exception which could be safely displayed to the end-user.""" - pass -- 2.43.5 From a6aa04b2cac2ca89578160089805d66bad688ac8 Mon Sep 17 00:00:00 2001 From: Alexey Skobkin Date: Mon, 30 May 2022 22:58:19 +0300 Subject: [PATCH 15/19] #7 Removing trailing space. --- telegram.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/telegram.py b/telegram.py index b456060..13acdd6 100644 --- a/telegram.py +++ b/telegram.py @@ -33,7 +33,7 @@ class CommandProcessor: self.bot.reply_to( message, 'Supported commands:\n' - ' /add - Add new feed\n ' + ' /add - Add new feed\n' ' /list - List currently added feeds\n' ' /del - Remove feed\n' ' /help - Get this help message' -- 2.43.5 From 3bffd32df08ad5670275f8b82af8f0cf44c34510 Mon Sep 17 00:00:00 2001 From: Alexey Skobkin Date: Mon, 30 May 2022 22:58:50 +0300 Subject: [PATCH 16/19] #7 Notifier now sends messages in HTML. --- telegram.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/telegram.py b/telegram.py index 13acdd6..38646b7 100644 --- a/telegram.py +++ b/telegram.py @@ -100,13 +100,13 @@ class Notifier: self.bot.send_message( chat_id=telegram_id, text=self.__format_message(update), - parse_mode='MarkdownV2' + parse_mode='HTML' ) @staticmethod def __format_message(item: FeedItem) -> str: return ( - f"**[{item.title}]({item.url})**\n\n" + f"{item.title}\n\n" f"{item.description}" ) -- 2.43.5 From f9ffdac38a6baf56cff1892be5beea0cdbff8aa0 Mon Sep 17 00:00:00 2001 From: Alexey Skobkin Date: Mon, 30 May 2022 23:22:01 +0300 Subject: [PATCH 17/19] #7 Notifier is now considering Telegram API sendMessage() rate limit. --- telegram.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/telegram.py b/telegram.py index 38646b7..6b909db 100644 --- a/telegram.py +++ b/telegram.py @@ -1,3 +1,5 @@ +import time + from telebot import TeleBot from telebot.handler_backends import BaseMiddleware from telebot.types import Message @@ -88,13 +90,22 @@ class CommandProcessor: class Notifier: """Sends notifications to users about new RSS feed items.""" + BATCH_LIMIT: int = 30 + + sent_counter: int = 0 + def __init__(self, token: str): self.bot: TeleBot = TeleBot(token) - def send_updates(self, chat_id: int, updates: list[FeedItem]): + def send_updates(self, chat_ids: list[int], updates: list[FeedItem]): """Send notification about new items to the user""" - for update in updates: - self.__send_update(chat_id, update) + for chat_id in chat_ids: + for update in updates: + self.__send_update(chat_id, update) + self.sent_counter += 1 + if self.sent_counter >= self.BATCH_LIMIT: + # TODO: probably implement better later + time.sleep(1) def __send_update(self, telegram_id: int, update: FeedItem): self.bot.send_message( -- 2.43.5 From 8554689fefc30a7f1a50a233c3f24173a067f570 Mon Sep 17 00:00:00 2001 From: Alexey Skobkin Date: Mon, 30 May 2022 23:23:09 +0300 Subject: [PATCH 18/19] #7 Notifier counter reset fix. --- telegram.py | 1 + 1 file changed, 1 insertion(+) diff --git a/telegram.py b/telegram.py index 6b909db..a4b0bea 100644 --- a/telegram.py +++ b/telegram.py @@ -106,6 +106,7 @@ class Notifier: if self.sent_counter >= self.BATCH_LIMIT: # TODO: probably implement better later time.sleep(1) + self.sent_counter = 0 def __send_update(self, telegram_id: int, update: FeedItem): self.bot.send_message( -- 2.43.5 From 54cdb3f368a4417259ff7293ddf3d7d0654866be Mon Sep 17 00:00:00 2001 From: Alexey Skobkin Date: Mon, 30 May 2022 23:29:18 +0300 Subject: [PATCH 19/19] #7 Notifier inconsistent naming fix. --- telegram.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/telegram.py b/telegram.py index a4b0bea..bf39403 100644 --- a/telegram.py +++ b/telegram.py @@ -108,9 +108,9 @@ class Notifier: time.sleep(1) self.sent_counter = 0 - def __send_update(self, telegram_id: int, update: FeedItem): + def __send_update(self, chat_id: int, update: FeedItem): self.bot.send_message( - chat_id=telegram_id, + chat_id=chat_id, text=self.__format_message(update), parse_mode='HTML' ) -- 2.43.5