diff --git a/.gitignore b/.gitignore index 08d0bd2..b117770 100755 --- a/.gitignore +++ b/.gitignore @@ -129,3 +129,6 @@ dmypy.json # Pyre type checker .pyre/ + +# logs +logs/ \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 1677662..32d7996 100755 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,6 +22,7 @@ services: - ./docker/config/app.env volumes: - ./src:/src + - ./logs:/var/log/bot ports: - $APP_EXPOSED_PORT:$APP_INTERNAL_PORT depends_on: diff --git a/src/app.py b/src/app.py index d52bb87..f31204e 100755 --- a/src/app.py +++ b/src/app.py @@ -1,19 +1,27 @@ +from logging import Logger +import logging from telegram.ext import Updater from telegram.ext.dispatcher import Dispatcher +from logger import Logger from config.credentials import BOT_TOKEN, PORT, WEBHOOK_URL +from handler import (groupsHandler, inHandler, mentionHandler, outHandler, + silentMentionHandler, startHandler) from handler.abstractHandler import AbstractHandler -from handler import (inHandler, mentionHandler, outHandler, silentMentionHandler, groupsHandler) class App: updater: Updater dispatcher: Dispatcher + log_file: str = '/var/log/bot.log' + log_format: str = '%(levelname)s-%(asctime)s: %(message)s' + def __init__(self): self.updater = Updater(BOT_TOKEN) def run(self) -> None: + self.setup_logging() self.register_handlers() self.register_webhook() @@ -33,6 +41,14 @@ class App: webhook_url="/".join([WEBHOOK_URL, BOT_TOKEN]) ) + Logger.get_logger(Logger.action_logger).info( + f'Webhook configured, listening on {WEBHOOK_URL}/' + ) + + def setup_logging(self) -> None: + logger = Logger() + logger.setup() + if __name__ == "__main__": app = App() diff --git a/src/config/contents.py b/src/config/contents.py index 4a929d5..a898669 100755 --- a/src/config/contents.py +++ b/src/config/contents.py @@ -7,3 +7,31 @@ opted_off = re.escape('You have opted-off for everyone-mentions.') opted_off_failed = re.escape('You need to opt-in first before processing this command.') mention_failed = re.escape('There are no users to mention.') no_groups = re.escape('There are no groups for this chat.') + + +start_text = re.escape(""" +Hello there. +I am `@everyone_mention_bot`. +I am here to help you with mass notifies. + +Please take a look at available commands. +Parameter `` is not required, if not given, I will assign you to `default` group. + +To opt-in for everyone-mentions use: +`/in ` +for example: `/in gaming` + +To opt-off for everyone mentions use: +`/out ` + +To gather everyone attention use: +`/everyone ` + +To see all available groups use: +`/groups` + +To display all users that opted-in for everyone-mentions use: +`/silent ` + +In case questions regarding my usage please reach out to @miloszowi +""") \ No newline at end of file diff --git a/src/handler/abstractHandler.py b/src/handler/abstractHandler.py index 1efa14c..c0cddab 100755 --- a/src/handler/abstractHandler.py +++ b/src/handler/abstractHandler.py @@ -1,5 +1,6 @@ from abc import abstractmethod +from logger import Logger from telegram.ext.callbackcontext import CallbackContext from telegram.ext.handler import Handler from telegram.update import Update @@ -14,14 +15,20 @@ class AbstractHandler: @abstractmethod def handle(self, update: Update, context: CallbackContext) -> None: raise Exception('handle method is not implemented') + @abstractmethod + def log_action(self, update_data: UpdateData) -> None: raise Exception('log_action method is not implemented') + def get_update_data(self, update: Update, context: CallbackContext) -> UpdateData: return UpdateData.create_from_arguments(update, context) - def reply(self, update: Update, text: str) -> None: - update.effective_message.reply(text=text) - def reply_markdown(self, update: Update, message: str) -> None: - update.effective_message.reply_markdown_v2(text=message) + try: + update.effective_message.reply_markdown_v2(text=message) + except Exception as err: + Logger.error(str(err)) def reply_html(self, update: Update, html: str) -> None: - update.effective_message.reply_html(text=html) + try: + update.effective_message.reply_html(text=html) + except Exception as err: + Logger.error(str(err)) diff --git a/src/handler/groupsHandler.py b/src/handler/groupsHandler.py index c6bd580..8e679fe 100644 --- a/src/handler/groupsHandler.py +++ b/src/handler/groupsHandler.py @@ -3,6 +3,8 @@ from typing import Iterable import prettytable as pt from config.contents import no_groups from entity.group import Group +from handler.vo.updateData import UpdateData +from logger import Logger from repository.groupRepository import GroupRepository from telegram.ext.callbackcontext import CallbackContext from telegram.ext.commandhandler import CommandHandler @@ -20,18 +22,22 @@ class GroupsHandler(AbstractHandler): self.group_repository = GroupRepository() def handle(self, update: Update, context: CallbackContext) -> None: - real_chat_id = str(update.effective_chat.id) + update_data = UpdateData.create_from_arguments(update, context, False) - groups = self.group_repository.get_by_chat_id(real_chat_id) + groups = self.group_repository.get_by_chat_id(update_data.chat_id) if groups: - return self.reply_html(update, self.build_groups_message(groups)) + self.reply_html(update, self.build_groups_message(groups)) + return self.log_action(update_data) self.reply_markdown(update, no_groups) def get_bot_handler(self) -> CommandHandler: return self.bot_handler + def log_action(self, update_data: UpdateData) -> None: + Logger.info(f'User {update_data.username} called /groups for {update_data.chat_id}') + def build_groups_message(self, groups: Iterable[Group]) -> str: resultTable = pt.PrettyTable(['Name', 'Members']) diff --git a/src/handler/inHandler.py b/src/handler/inHandler.py index 8fff3ef..b2745e5 100755 --- a/src/handler/inHandler.py +++ b/src/handler/inHandler.py @@ -1,3 +1,5 @@ +from handler.vo.updateData import UpdateData +from logger import Logger from config.contents import opted_in, opted_in_failed from exception.invalidArgumentException import InvalidArgumentException from exception.notFoundException import NotFoundException @@ -31,11 +33,14 @@ class InHandler(AbstractHandler): user.add_to_chat(update_data.chat_id) self.user_repository.save(user) - except NotFoundException: self.user_repository.save_by_update_data(update_data) self.reply_markdown(update, opted_in) + self.log_action(update_data) def get_bot_handler(self) -> CommandHandler: return self.bot_handler + + def log_action(self, update_data: UpdateData) -> None: + Logger.info(f'User {update_data.username} joined {update_data.chat_id}') diff --git a/src/handler/mentionHandler.py b/src/handler/mentionHandler.py index 9735b9e..e6687de 100755 --- a/src/handler/mentionHandler.py +++ b/src/handler/mentionHandler.py @@ -3,6 +3,8 @@ from typing import Iterable from config.contents import mention_failed from entity.user import User from exception.invalidArgumentException import InvalidArgumentException +from handler.vo.updateData import UpdateData +from logger import Logger from repository.userRepository import UserRepository from telegram.ext.callbackcontext import CallbackContext from telegram.ext.commandhandler import CommandHandler @@ -21,20 +23,24 @@ class MentionHandler(AbstractHandler): def handle(self, update: Update, context: CallbackContext) -> None: try: - updateData = self.get_update_data(update, context) + update_data = self.get_update_data(update, context) except InvalidArgumentException as e: return self.reply_markdown(update, str(e)) - users = self.user_repository.get_all_for_chat(updateData.chat_id) + users = self.user_repository.get_all_for_chat(update_data.chat_id) if users: - return self.reply_markdown(update, self.build_mention_message(users)) + self.reply_markdown(update, self.build_mention_message(users)) + return self.log_action(update_data) self.reply_markdown(update, mention_failed) def get_bot_handler(self) -> CommandHandler: return self.bot_handler + def log_action(self, update_data: UpdateData) -> None: + Logger.info(f'User {update_data.username} called /everyone for {update_data.chat_id}') + def build_mention_message(self, users: Iterable[User]) -> str: result = '' diff --git a/src/handler/outHandler.py b/src/handler/outHandler.py index dc200a4..736e38a 100755 --- a/src/handler/outHandler.py +++ b/src/handler/outHandler.py @@ -1,6 +1,8 @@ from config.contents import opted_off, opted_off_failed from exception.invalidArgumentException import InvalidArgumentException from exception.notFoundException import NotFoundException +from handler.vo.updateData import UpdateData +from logger import Logger from repository.userRepository import UserRepository from telegram.ext.callbackcontext import CallbackContext from telegram.ext.commandhandler import CommandHandler @@ -19,22 +21,26 @@ class OutHandler(AbstractHandler): def handle(self, update: Update, context: CallbackContext) -> None: try: - updateData = self.get_update_data(update, context) + update_data = self.get_update_data(update, context) except InvalidArgumentException as e: return self.reply_markdown(update, str(e)) try: - user = self.user_repository.get_by_id(updateData.user_id) + user = self.user_repository.get_by_id(update_data.user_id) - if not user.is_in_chat(updateData.chat_id): + if not user.is_in_chat(update_data.chat_id): raise NotFoundException() except NotFoundException: return self.reply_markdown(update, opted_off_failed) - user.remove_from_chat(updateData.chat_id) + user.remove_from_chat(update_data.chat_id) self.user_repository.save(user) self.reply_markdown(update, opted_off) + self.log_action(update_data) def get_bot_handler(self) -> CommandHandler: return self.bot_handler + + def log_action(self, update_data: UpdateData) -> None: + Logger.info(f'User {update_data.username} left {update_data.chat_id}') diff --git a/src/handler/silentMentionHandler.py b/src/handler/silentMentionHandler.py index e9ff22a..19be822 100644 --- a/src/handler/silentMentionHandler.py +++ b/src/handler/silentMentionHandler.py @@ -1,10 +1,12 @@ from typing import Iterable from entity.user import User +from logger import Logger from telegram.ext.commandhandler import CommandHandler from handler.abstractHandler import AbstractHandler from handler.mentionHandler import MentionHandler +from handler.vo.updateData import UpdateData class MentionHandler(MentionHandler, AbstractHandler): @@ -19,3 +21,6 @@ class MentionHandler(MentionHandler, AbstractHandler): result += f'*{user.username}\({user.user_id}\)*\n' return result + + def log_action(self, update_data: UpdateData) -> None: + Logger.info(f'User {update_data.username} called /silent for {update_data.chat_id}') diff --git a/src/handler/startHandler.py b/src/handler/startHandler.py new file mode 100644 index 0000000..ee32f7a --- /dev/null +++ b/src/handler/startHandler.py @@ -0,0 +1,25 @@ +from config.contents import start_text +from logger import Logger +from telegram.ext.callbackcontext import CallbackContext +from telegram.ext.commandhandler import CommandHandler +from telegram.update import Update + +from handler.abstractHandler import AbstractHandler +from handler.vo.updateData import UpdateData + + +class StartHandler(AbstractHandler): + bot_handler: CommandHandler + + def __init__(self) -> None: + self.bot_handler = CommandHandler('start', self.handle) + + def handle(self, update: Update, context: CallbackContext) -> None: + self.reply_markdown(update, start_text) + self.log_action(UpdateData.create_from_arguments(update, context)) + + def get_bot_handler(self) -> CommandHandler: + return self.bot_handler + + def log_action(self, update_data: UpdateData) -> None: + Logger.info(f'User {update_data.username} called /start for {update_data.chat_id}') diff --git a/src/handler/vo/updateData.py b/src/handler/vo/updateData.py index 194d62e..a43f433 100644 --- a/src/handler/vo/updateData.py +++ b/src/handler/vo/updateData.py @@ -18,21 +18,21 @@ class UpdateData(): username: str @staticmethod - def create_from_arguments(update: Update, context: CallbackContext) -> UpdateData: + def create_from_arguments(update: Update, context: CallbackContext, include_group: bool = True) -> UpdateData: chat_id = str(update.effective_chat.id) - if context.args and context.args[0]: - group_name = str(context.args[0]) - if not re.match(r"^[A-Za-z]+$", context.args[0]): + if context.args and context.args[0] and include_group: + group_name = str(context.args[0]).lower() + if not re.match(r"^[A-Za-z]+$", group_name): raise InvalidArgumentException(re.escape('Group name must contain only letters.')) - if context.args[0] == Group.default_name: + if group_name == Group.default_name: raise InvalidArgumentException(re.escape(f'Group can not be `{Group.default_name}`.')) - if len(context.args[0]) > 20: + if len(group_name) > 20: raise InvalidArgumentException(re.escape(f'Group name length can not be greater than 20.')) - chat_id += f'~{context.args[0]}'.lower() + chat_id += f'~{group_name}' user_id = str(update.effective_user.id) diff --git a/src/logger.py b/src/logger.py new file mode 100644 index 0000000..4d0bbba --- /dev/null +++ b/src/logger.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import logging +import os + + +class Logger: + action_logger: str = 'action-logger' + action_logger_file: str = '/var/log/bot/action.log' + + main_logger: str = 'main-logger' + main_logger_file: str = '/var/log/bot/app.log' + + formatter: str = logging.Formatter('%(asctime)s[%(levelname)s]: %(message)s') + + def setup(self) -> None: + self.configure(self.action_logger, self.action_logger_file, logging.INFO) + self.configure(self.main_logger, self.main_logger_file, logging.ERROR) + + def configure(self, logger_name, log_file, level) -> None: + directory = os.path.dirname(log_file) + if not os.path.exists(directory): + os.makedirs(directory) + + logger = logging.getLogger(logger_name) + file_handler = logging.FileHandler(log_file, mode='w') + file_handler.setFormatter(self.formatter) + stream_handler = logging.StreamHandler() + stream_handler.setFormatter(self.formatter) + + logger.setLevel(level) + logger.addHandler(file_handler) + logger.addHandler(stream_handler) + + @staticmethod + def get_logger(logger_name) -> logging.Logger: + return logging.getLogger(logger_name) + + def info(message: str) -> None: + Logger.get_logger(Logger.action_logger).info(message) + + def error(message: str) -> None: + Logger.get_logger(Logger.main_logger).error(message) \ No newline at end of file