commit 9c15227cbf26894baae98fe1e1f6078be83c91f9 Author: Miłosz Guglas <32432158+miloszowi@users.noreply.github.com> Date: Sat Sep 18 15:30:56 2021 +0200 Initial commit diff --git a/.env.local b/.env.local new file mode 100644 index 0000000..a0bec50 --- /dev/null +++ b/.env.local @@ -0,0 +1,8 @@ +bot_token= +firebase_apiKey= +firebase_authDomain= +firebase_databaseURL= +firebase_projectId= +firebase_storageBucket= +app_url= +PORT= \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b6e4761 --- /dev/null +++ b/.gitignore @@ -0,0 +1,129 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..beeb482 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Miłosz Guglas + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Procfile b/Procfile new file mode 100644 index 0000000..fe54d30 --- /dev/null +++ b/Procfile @@ -0,0 +1 @@ +web: python entrypoint.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..2d254aa --- /dev/null +++ b/README.md @@ -0,0 +1,67 @@ +#

[everyone-mention-telegram-bot](http://t.me/everyone_mention_bot) +

+

simple, but useful telegram bot to gather all of group members attention! + + +## Contents + +* [Getting started.](#getting-started) + * [Installation](#installation) + * [Requirements](#requirements) + * [Env file](#env-file) +* [Commands](#commands) + * [`/in`](#in) + * [`/out`](#out) + * [`/everyone`](#everyone) + +### Getting started +#### Installation +```bash +git clone https://github.com/miloszowi/everyone-mention-telegram-bot.git +pip install -r requirements.txt +python entrypoint.py +``` + +#### Requirements +- `python` with version specified in `runtime.txt` +- `pip` with version `20.0.2` + +#### Env file +```bash +cp .env.local .env +``` +and then fulfill copied `.env` file with required values +- `bot_token` - your telegram bot token from [BotFather](https://telegram.me/BotFather) +- `firebase_*` - all of those values you can find in firebase console +- `app_url` - your app url for retrieving webhooks +- `PORT` - port for your app + +### Commands +#### `/in` +Will sign you in for everyone-mentions. + +![in command example](docs/in_command.png) + +If you have already opted-in before, alternative reply will be displayed. + +![in command when someone already opted in example](docs/in_command_already_opted_in.png) + +#### `/out` +Will sign you off for everyone-mentions. + +![out command example](docs/out_command.png) + +If you haven't opted-in before, alternative reply will be displayed. + +![out command when someone did not opt in example](docs/out_command_did_not_opt_in_before.png) + +#### `/everone` +Will mention everyone that opted-in for everyone-mentions separated by spaces. + +If user does not contain nickname, his ID will be present instead of nickname. + +![everybody command example](docs/everyone_command.png) + +If there are no users that opted-in for mentioning, alternative reply will be displayed. + +![everybone noone to mention example](docs/everyone_noone_to_mention.png) diff --git a/docs/commands.png b/docs/commands.png new file mode 100644 index 0000000..24caf9d Binary files /dev/null and b/docs/commands.png differ diff --git a/docs/everyone_command.png b/docs/everyone_command.png new file mode 100644 index 0000000..37c75c9 Binary files /dev/null and b/docs/everyone_command.png differ diff --git a/docs/everyone_noone_to_mention.png b/docs/everyone_noone_to_mention.png new file mode 100644 index 0000000..88200a1 Binary files /dev/null and b/docs/everyone_noone_to_mention.png differ diff --git a/docs/in_command.png b/docs/in_command.png new file mode 100644 index 0000000..fc08bcf Binary files /dev/null and b/docs/in_command.png differ diff --git a/docs/in_command_already_opted_in.png b/docs/in_command_already_opted_in.png new file mode 100644 index 0000000..4370a08 Binary files /dev/null and b/docs/in_command_already_opted_in.png differ diff --git a/docs/logo.png b/docs/logo.png new file mode 100644 index 0000000..5e76b51 Binary files /dev/null and b/docs/logo.png differ diff --git a/docs/out_command.png b/docs/out_command.png new file mode 100644 index 0000000..062b913 Binary files /dev/null and b/docs/out_command.png differ diff --git a/docs/out_command_did_not_opt_in_before.png b/docs/out_command_did_not_opt_in_before.png new file mode 100644 index 0000000..f7a7732 Binary files /dev/null and b/docs/out_command_did_not_opt_in_before.png differ diff --git a/entrypoint.py b/entrypoint.py new file mode 100644 index 0000000..2e270f8 --- /dev/null +++ b/entrypoint.py @@ -0,0 +1,6 @@ +from src.app import App + +if __name__ == "__main__": + app = App() + + app.run() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..af1197a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +python-dotenv==0.19.0 +python-telegram-bot==13.7 +Pyrebase==3.0.27 diff --git a/runtime.txt b/runtime.txt new file mode 100644 index 0000000..a48890e --- /dev/null +++ b/runtime.txt @@ -0,0 +1 @@ +python-3.8.10 diff --git a/src/app.py b/src/app.py new file mode 100644 index 0000000..42eb3d2 --- /dev/null +++ b/src/app.py @@ -0,0 +1,34 @@ +from .config.credentials import bot_token, app_url, port +from .config.handlers import handlers +from .handlers.handlerInterface import HandlerInterface +from telegram.ext.dispatcher import Dispatcher +from telegram.ext import Updater + + +class App: + updater: Updater + dispatcher: Dispatcher + + def __init__(self): + self.updater = Updater(bot_token) + + def run(self) -> None: + self.registerHandlers() + self.registerWebhook() + + self.updater.idle() + + def registerHandlers(self) -> None: + for handler in handlers: + if not isinstance(handler, HandlerInterface): + raise Exception('Invalid list of handlers provided. Handler must implement HandlerInterface') + + self.updater.dispatcher.add_handler(handler.getBotHandler()) + + def registerWebhook(self) -> None: + self.updater.start_webhook( + listen="0.0.0.0", + port=int(port), + url_path=bot_token, + webhook_url=f'{app_url}/{bot_token}' + ) diff --git a/src/config/contents.py b/src/config/contents.py new file mode 100644 index 0000000..d36c2fc --- /dev/null +++ b/src/config/contents.py @@ -0,0 +1,8 @@ +import re + +# These are MarkdownV2 python-telegram-bot specific +opted_in_successfully = re.escape('You have opted-in for everyone-mentions.') +opted_in_failed = re.escape('You already opted-in for everyone-mentions.') +opted_off_successfully = 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.') diff --git a/src/config/credentials.py b/src/config/credentials.py new file mode 100644 index 0000000..8cb7c00 --- /dev/null +++ b/src/config/credentials.py @@ -0,0 +1,16 @@ +import os +from dotenv import load_dotenv + +load_dotenv() + +bot_token = os.environ['bot_token'] +app_url = os.environ['app_url'] +port = os.environ['PORT'] + +firebaseConfig = { + "apiKey": os.environ['firebase_apiKey'], + "authDomain": os.environ['firebase_authDomain'], + "databaseURL": os.environ['firebase_databaseURL'], + "projectId": os.environ['firebase_projectId'], + "storageBucket": os.environ['firebase_storageBucket'], +} diff --git a/src/config/handlers.py b/src/config/handlers.py new file mode 100644 index 0000000..a7d81d6 --- /dev/null +++ b/src/config/handlers.py @@ -0,0 +1,9 @@ +from ..handlers.inHandler import InHandler +from ..handlers.outHandler import OutHandler +from ..handlers.mentionHandler import MentionHandler + +handlers = [ + InHandler(), + OutHandler(), + MentionHandler() +] diff --git a/src/firebaseProxy.py b/src/firebaseProxy.py new file mode 100644 index 0000000..aa8db8c --- /dev/null +++ b/src/firebaseProxy.py @@ -0,0 +1,34 @@ +import pyrebase +from pyrebase.pyrebase import Database as FirebaseDB +from .config.credentials import firebaseConfig + + +class FirebaseProxy(): + db: FirebaseDB + + # Group specific values + group_index: str = 'groups' + + # User specific values + id_index: str = 'id' + name_index: str = 'name' + + def __init__(self) -> None: + firebase = pyrebase.pyrebase.initialize_app(firebaseConfig) + self.db = firebase.database() + + def getChilds(self, *childs: str) -> FirebaseDB: + current = self.db + + for child_index in childs: + current = current.child(child_index) + + return current + + @staticmethod + def getGroupPath(groupId: int) -> str: + return f'{FirebaseProxy.group_index}/{groupId}' + + @staticmethod + def getUserPath(userId: int, groupId: int) -> str: + return f'{groupId}_{userId}' diff --git a/src/handlers/handlerInterface.py b/src/handlers/handlerInterface.py new file mode 100644 index 0000000..3cad7ce --- /dev/null +++ b/src/handlers/handlerInterface.py @@ -0,0 +1,18 @@ +from abc import abstractmethod +from telegram.ext.callbackcontext import CallbackContext +from telegram.ext.handler import Handler +from telegram.update import Update + + +class HandlerInterface: + def __init__(self) -> None: + pass + + @abstractmethod + def getBotHandler(self) -> Handler: raise Exception('getBotHandler method is not implemented') + + @abstractmethod + def handle(self, update: Update, context: CallbackContext) -> None: raise Exception('handle method is not implemented') + + @abstractmethod + def getCommandName(self) -> str: raise Exception('getCommandName method is not implemented') diff --git a/src/handlers/inHandler.py b/src/handlers/inHandler.py new file mode 100644 index 0000000..5f55e1c --- /dev/null +++ b/src/handlers/inHandler.py @@ -0,0 +1,39 @@ +from ..config.contents import opted_in_successfully, opted_in_failed +from ..repositories.userRepository import UserRepository +from ..firebaseProxy import FirebaseProxy +from .handlerInterface import HandlerInterface +from telegram.ext.callbackcontext import CallbackContext +from telegram.ext.commandhandler import CommandHandler +from telegram.update import Update + + +class InHandler(HandlerInterface): + botHandler: CommandHandler + commandName: str = 'in' + + def __init__(self) -> None: + self.botHandler = CommandHandler( + self.getCommandName(), + self.handle + ) + + def handle(self, update: Update, context: CallbackContext) -> None: + groupId = update.effective_chat.id + userData = { + FirebaseProxy.id_index: update.effective_user.id, + FirebaseProxy.name_index: update.effective_user.username + } + userRepository = UserRepository() + + if userRepository.isPresentInGroup(userData.get(FirebaseProxy.id_index), groupId): + update.message.reply_markdown_v2(text=opted_in_failed) + return + + userRepository.addForGroup(userData, groupId) + update.message.reply_markdown_v2(text=opted_in_successfully) + + def getBotHandler(self) -> CommandHandler: + return self.botHandler + + def getCommandName(self) -> str: + return self.commandName diff --git a/src/handlers/mentionHandler.py b/src/handlers/mentionHandler.py new file mode 100644 index 0000000..cef012c --- /dev/null +++ b/src/handlers/mentionHandler.py @@ -0,0 +1,42 @@ +from ..config.contents import mention_failed +from ..firebaseProxy import FirebaseProxy +from ..repositories.groupRepository import GroupRepository +from .handlerInterface import HandlerInterface +from telegram.ext.callbackcontext import CallbackContext +from telegram.ext.commandhandler import CommandHandler +from telegram.update import Update + + +class MentionHandler(HandlerInterface): + botHandler: CommandHandler + commandName: str = 'everyone' + + def __init__(self) -> None: + self.botHandler = CommandHandler( + self.getCommandName(), + self.handle + ) + + def handle(self, update: Update, context: CallbackContext) -> None: + groupId = update.effective_chat.id + groupRepository = GroupRepository() + mentionMessage = self.buildMentionMessage(groupRepository.get(id=groupId)) + + update.message.reply_markdown_v2(text=mentionMessage) + + def getBotHandler(self) -> CommandHandler: + return self.botHandler + + def getCommandName(self) -> str: + return self.commandName + + def buildMentionMessage(self, usersData: dict) -> str: + result = '' + + for userData in usersData: + userId = str(userData.get(FirebaseProxy.id_index)) + username = userData.get(FirebaseProxy.name_index) or userId + + result += "*[%s](tg://user?id=%s)* " % (username, userId) + + return result or mention_failed diff --git a/src/handlers/outHandler.py b/src/handlers/outHandler.py new file mode 100644 index 0000000..e102829 --- /dev/null +++ b/src/handlers/outHandler.py @@ -0,0 +1,39 @@ +from ..config.contents import opted_off_successfully, opted_off_failed +from ..repositories.userRepository import UserRepository +from .handlerInterface import HandlerInterface +from telegram.ext.callbackcontext import CallbackContext +from telegram.ext.commandhandler import CommandHandler +from telegram.update import Update + + +class OutHandler(HandlerInterface): + botHandler: CommandHandler + commandName: str = 'out' + + def __init__(self) -> None: + self.botHandler = CommandHandler( + self.getCommandName(), + self.handle + ) + + def handle(self, update: Update, context: CallbackContext) -> None: + groupId = update.effective_chat.id + userData = { + 'id': update.effective_user.id, + 'name': update.effective_user.username + } + + userRepository = UserRepository() + if not userRepository.isPresentInGroup(userData.get('id'), groupId): + update.message.reply_markdown_v2(text=opted_off_failed) + return + + userRepository.removeForGroup(userId=userData.get('id'), groupId=groupId) + + update.message.reply_markdown_v2(text=opted_off_successfully) + + def getBotHandler(self) -> CommandHandler: + return self.botHandler + + def getCommandName(self) -> str: + return self.commandName diff --git a/src/repositories/groupRepository.py b/src/repositories/groupRepository.py new file mode 100644 index 0000000..9747ab4 --- /dev/null +++ b/src/repositories/groupRepository.py @@ -0,0 +1,18 @@ +from ..firebaseProxy import FirebaseProxy + + +class GroupRepository(): + firebase: FirebaseProxy + + def __init__(self) -> None: + self.firebase = FirebaseProxy() + + def get(self, id: int) -> dict: + result = [] + groupData = self.firebase.getChilds(FirebaseProxy.group_index, id).get() + + if groupData.each(): + for user_root in groupData.each(): + result.append(user_root.val()) + + return result diff --git a/src/repositories/userRepository.py b/src/repositories/userRepository.py new file mode 100644 index 0000000..d1d472b --- /dev/null +++ b/src/repositories/userRepository.py @@ -0,0 +1,30 @@ +from ..firebaseProxy import FirebaseProxy + + +class UserRepository(): + firebaseProxy: FirebaseProxy + + def __init__(self) -> None: + self.firebaseProxy = FirebaseProxy() + + def addForGroup(self, userData: dict, groupId: int) -> None: + self.firebaseProxy.getChilds(FirebaseProxy.getGroupPath(groupId)).update({ + f'{groupId}_{userData.get("id")}': { + FirebaseProxy.id_index: userData.get("id"), + FirebaseProxy.name_index: userData.get("name") + } + }) + + def removeForGroup(self, userId: int, groupId: int) -> None: + self.firebaseProxy.getChilds(FirebaseProxy.getGroupPath(groupId)).update({ + FirebaseProxy.getUserPath(userId, groupId): {} + }) + + def isPresentInGroup(self, userId: int, groupId: int) -> bool: + user = self.firebaseProxy.getChilds( + FirebaseProxy.getGroupPath(groupId), + FirebaseProxy.getUserPath(userId, groupId) + ).get().val() + + return bool(user) + \ No newline at end of file