changed mongoDb collection structure, removed group entity & repository, updated README.md, changed folder names to singular forms

This commit is contained in:
miloszowi 2021-09-25 16:49:11 +02:00
parent baa8a78cad
commit cd8e3507ca
27 changed files with 347 additions and 393 deletions

View File

@ -3,53 +3,61 @@
<p align="center"> simple, but useful telegram bot to gather all of group members attention!
<!-- Icon made by https://www.freepik.com from https://www.flaticon.com/ -->
## Contents
# Contents
* [Getting started.](#getting-started)
* [Installation](#installation)
* [Requirements](#requirements)
* [Env file](#env-file)
* [Installation](#installation)
* [Logs](#logs)
* [Env files](#env-files)
* [Commands](#commands)
* [`/in`](#in)
* [`/out`](#out)
* [`/everyone`](#everyone)
### Getting started
#### Installation
## Getting started
### Requirements
- `docker-compose` in version `1.25.0`
- `docker` in version `20.10.7`
### 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 files
First, copy env files for database and app containers
after that, you need to copy env files and fulfill it with correct values
```bash
cp docker/config/app/app.dist.env docker/config/app/app.env
cp docker/config/database/database.dist.env docker/config/app/app.env
cp docker/config/app.dist.env docker/config/app.env
cp docker/config/database.dist.env docker/config/app.env
```
and then fulfill copied `.env` files with required values
and finally, you can run the bot by launching docker containers
```bash
docker-compose up -d
```
(`-d` flag will run containers in detached mode)
### Logs
You can use
```bash
docker/logs <container>
```
to check container logs
### Env files
app.env
- `bot_token` - your telegram bot token from [BotFather](https://telegram.me/BotFather)
- `BOT_TOKEN` - your telegram bot token from [BotFather](https://telegram.me/BotFather)
- `MONGODB_DATABASE` - MongoDB database name
- `MONGODB_USERNAME` - MongoDB username
- `MONGODB_PASSWORD` - MongoDB password
- `MONGODB_HOSTNAME` - MongoDB host (default `database` - container name)
- `MONGODB_PORT` - MongoDB port (default `port` - given in docker-compose configuration)
- `MONGODB_PORT` - MongoDB port (default `27017` - given in docker-compose configuration)
database.env
- `MONGO_INITDB_ROOT_USERNAME` - conf from `app.env`
- `MONGO_INITDB_ROOT_PASSWORD` - conf from `app.env`
- `MONGO_INITDB_DATABASE` - conf from `app.env`
- `MONGODB_DATA_DIR` - directory to store MongoDB documents (inside a container)
- `MONDODB_LOG_DIR` - log file
### Commands
#### `/in`
- `MONDODB_LOG_DIR` - path to logs storage
## Commands
### `/in`
Will sign you in for everyone-mentions.
![in command example](docs/in_command.png)
@ -58,7 +66,7 @@ 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`
### `/out`
Will sign you off for everyone-mentions.
![out command example](docs/out_command.png)
@ -67,10 +75,10 @@ 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`
### `/everyone`
Will mention everyone that opted-in for everyone-mentions separated by spaces.
If user does not have nickname, it will assign random name from `names` python library to his ID
If user does not have nickname, it will first try to assign his firstname, then random firstname from `names` python library
![everybody command example](docs/everyone_command.png)

View File

@ -6,7 +6,7 @@ services:
image: mongo:4.0.8
restart: unless-stopped
env_file:
- ./docker/config/database/database.env
- ./docker/config/database.env
volumes:
- db-data:/data/db
ports:
@ -18,7 +18,7 @@ services:
build: .
command: python app.py
env_file:
- ./docker/config/app/app.env
- ./docker/config/app.env
volumes:
- ./src:/src
ports:

View File

@ -1,15 +1,17 @@
from config.credentials import bot_token
from config.handlers import handlers
from handlers.handlerInterface import HandlerInterface
from telegram.ext.dispatcher import Dispatcher
from telegram.ext import Updater
from telegram.ext.dispatcher import Dispatcher
from config.credentials import BOT_TOKEN
from handler.abstractHandler import AbstractHandler
from handler import (inHandler, mentionHandler, outHandler)
class App:
updater: Updater
dispatcher: Dispatcher
def __init__(self):
self.updater = Updater(bot_token)
self.updater = Updater(BOT_TOKEN)
def run(self) -> None:
self.registerHandlers()
@ -18,11 +20,10 @@ class App:
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())
for handler in AbstractHandler.__subclasses__():
self.updater.dispatcher.add_handler(
handler().getBotHandler()
)
if __name__ == "__main__":

View File

@ -1,8 +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 = 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 = 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.')

View File

@ -1,9 +1,10 @@
import os
from dotenv import load_dotenv
load_dotenv()
bot_token = os.environ['bot_token']
BOT_TOKEN = os.environ['BOT_TOKEN']
MONGODB_DATABASE=os.environ['MONGODB_DATABASE']
MONGODB_USERNAME=os.environ['MONGODB_USERNAME']

View File

@ -1,9 +0,0 @@
from handlers.inHandler import InHandler
from handlers.outHandler import OutHandler
from handlers.mentionHandler import MentionHandler
handlers = [
InHandler(),
OutHandler(),
MentionHandler()
]

View File

@ -1,10 +1,13 @@
from pymongo.errors import ServerSelectionTimeoutError
from config.credentials import MONGODB_USERNAME, MONGODB_PASSWORD, MONGODB_DATABASE, MONGODB_HOSTNAME, MONGODB_PORT
from pymongo import MongoClient
from pymongo.database import Database
from urllib.parse import quote_plus
class DatabaseClient():
from config.credentials import (MONGODB_DATABASE, MONGODB_HOSTNAME,
MONGODB_PASSWORD, MONGODB_PORT,
MONGODB_USERNAME)
from pymongo import MongoClient
from pymongo.database import Database
class Client():
mongoClient: MongoClient
database: Database
@ -17,14 +20,17 @@ class DatabaseClient():
self.mongoClient = MongoClient(uri)
self.database = self.mongoClient[MONGODB_DATABASE]
def insert(self, collection: str, data: dict) -> None:
def insertOne(self, collection: str, data: dict) -> None:
self.database.get_collection(collection).insert_one(data)
def find(self, collection: str, query: dict) -> dict:
return self.database.get_collection(collection).find(query)
def findOne(self, collection: str, query: dict) -> dict:
return self.database.get_collection(collection).find_one(query)
def remove(self, collection: str, data: dict) -> None:
self.database.get_collection(collection).remove(data)
def findMany(self, collection: str, filter: dict) -> dict:
return self.database.get_collection(collection).find(filter)
def updateOne(self, collection: str, filter: dict, data: dict) -> None:
self.database.get_collection(collection).update_one(
filter,
{ "$set" : data }
)

View File

@ -1,28 +0,0 @@
from __future__ import annotations
from typing import Optional
class Chat():
id: str
def __init__(self, id: str) -> None:
self.id = id
def getId(self) -> str:
return self.id
def toDict(self) -> dict:
return {
'_id': self.id
}
@staticmethod
def getMongoRoot() -> str:
return 'chat'
@staticmethod
def fromDocument(document: Optional[dict]) -> Optional[Chat]:
if not document:
return None
return Chat(document['_id'])

View File

@ -1,34 +0,0 @@
from __future__ import annotations
from typing import Optional
class ChatPerson():
chat_id: str
person_id: str
def __init__(self, chatId: str, personId: str) -> None:
self.chat_id = chatId
self.person_id = personId
def getChatId(self) -> str:
return self.chat_id
def getPersonId(self) -> str:
return self.person_id
def toDict(self) -> dict:
return {
'_id': f'{self.chat_id}-{self.person_id}',
'chat_id': self.chat_id,
'person_id': self.person_id
}
@staticmethod
def getMongoRoot() -> str:
return 'chat_person'
@staticmethod
def fromDocument(document: Optional[dict]) -> Optional[ChatPerson]:
if not document:
return None
return ChatPerson(document['chat_id'], document['person_id'])

View File

@ -1,44 +0,0 @@
from __future__ import annotations
from abc import abstractmethod
from typing import Optional
import names
class Person():
id: str
username: str
def __init__(self, id: str, username: Optional[str] = None) -> None:
self.id = id
if not username:
self.username = names.get_first_name()
else:
self.username = username
def getId(self) -> str:
return self.id
def getUsername(self) -> str:
return self.username
def toDict(self, withUsername: bool = True) -> dict:
result = {
'_id': self.id
}
if withUsername:
result['username'] = self.username
return result
@staticmethod
def getMongoRoot() -> str:
return 'person'
@staticmethod
def fromDocument(document: Optional[dict]) -> Optional[Person]:
if not document:
return None
return Person(document['_id'], document['username'])

52
src/entity/user.py Normal file
View File

@ -0,0 +1,52 @@
from __future__ import annotations
from typing import Iterable
class User():
collection: str = 'users'
idIndex: str = '_id'
chatsIndex: str = 'chats'
usernameIndex: str = 'username'
userId: str
username: str
chats: Iterable[str]
def __init__(self, userId, username, chats) -> None:
self.userId = userId
self.username = username
self.chats = chats
def getUserId(self) -> str:
return self.userId
def getUsername(self) -> str:
return self.username
def getChats(self) -> Iterable[str]:
return self.chats
def isInChat(self, chatId: str) -> bool:
return chatId in self.getChats()
def addToChat(self, chatId: str) -> None:
self.chats.append(chatId)
def removeFromChat(self, chatId: str) -> None:
if chatId in self.getChats():
self.chats.remove(chatId)
def toMongoDocument(self) -> dict:
return {
self.usernameIndex: self.getUsername(),
self.chatsIndex: self.getChats()
}
@staticmethod
def fromMongoDocument(mongoDocument: dict) -> User:
return User(
mongoDocument[User.idIndex],
mongoDocument[User.usernameIndex],
mongoDocument[User.chatsIndex]
)

View File

@ -0,0 +1,2 @@
class AlreadyExistsException(Exception):
pass

View File

@ -0,0 +1,2 @@
class NotFoundException(Exception):
pass

View File

@ -1,10 +1,13 @@
from abc import abstractmethod
from telegram.ext.callbackcontext import CallbackContext
from telegram.ext.handler import Handler
from telegram.update import Update
from handler.vo.updateData import UpdateData
class HandlerInterface:
class AbstractHandler:
def __init__(self) -> None:
pass
@ -14,8 +17,8 @@ class HandlerInterface:
@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')
def getUpdateData(self, update: Update) -> UpdateData:
return UpdateData.createFromUpdate(update)
def reply(self, update: Update, message: str) -> None:
update.effective_message.reply_markdown_v2(text=message)

38
src/handler/inHandler.py Executable file
View File

@ -0,0 +1,38 @@
from config.contents import opted_in, opted_in_failed
from exception.notFoundException import NotFoundException
from repository.userRepository import UserRepository
from telegram.ext.callbackcontext import CallbackContext
from telegram.ext.commandhandler import CommandHandler
from telegram.update import Update
from handler.abstractHandler import AbstractHandler
class InHandler(AbstractHandler):
botHandler: CommandHandler
userRepository: UserRepository
def __init__(self) -> None:
self.botHandler = CommandHandler('in', self.handle)
self.userRepository = UserRepository()
def handle(self, update: Update, context: CallbackContext) -> None:
updateData = self.getUpdateData(update)
try:
user = self.userRepository.getById(updateData.getUserId())
if user.isInChat(updateData.getChatId()):
self.reply(update, opted_in_failed)
return
user.addToChat(updateData.getChatId())
self.userRepository.save(user)
except NotFoundException:
self.userRepository.saveByUpdateData(updateData)
self.reply(update, opted_in)
def getBotHandler(self) -> CommandHandler:
return self.botHandler

40
src/handler/mentionHandler.py Executable file
View File

@ -0,0 +1,40 @@
from typing import Iterable
from config.contents import mention_failed
from entity.user import User
from repository.userRepository import UserRepository
from telegram.ext.callbackcontext import CallbackContext
from telegram.ext.commandhandler import CommandHandler
from telegram.update import Update
from handler.abstractHandler import AbstractHandler
class MentionHandler(AbstractHandler):
botHandler: CommandHandler
userRepository: UserRepository
def __init__(self) -> None:
self.botHandler = CommandHandler('everyone', self.handle)
self.userRepository = UserRepository()
def handle(self, update: Update, context: CallbackContext) -> None:
updateData = self.getUpdateData(update)
users = self.userRepository.getAllForChat(updateData.getChatId())
if users:
self.reply(update, self.buildMentionMessage(users))
return
self.reply(update, mention_failed)
def getBotHandler(self) -> CommandHandler:
return self.botHandler
def buildMentionMessage(self, users: Iterable[User]) -> str:
result = ''
for user in users:
result += f'*[{user.getUsername()}](tg://user?id={user.getUserId()})* '
return result

36
src/handler/outHandler.py Executable file
View File

@ -0,0 +1,36 @@
from config.contents import opted_off, opted_off_failed
from exception.notFoundException import NotFoundException
from repository.userRepository import UserRepository
from telegram.ext.callbackcontext import CallbackContext
from telegram.ext.commandhandler import CommandHandler
from telegram.update import Update
from handler.abstractHandler import AbstractHandler
class OutHandler(AbstractHandler):
botHandler: CommandHandler
userRepository: UserRepository
def __init__(self) -> None:
self.botHandler = CommandHandler('out', self.handle)
self.userRepository = UserRepository()
def handle(self, update: Update, context: CallbackContext) -> None:
updateData = self.getUpdateData(update)
try:
user = self.userRepository.getById(updateData.getUserId())
if not user.isInChat(updateData.getChatId()):
raise NotFoundException()
except NotFoundException:
self.reply(update, opted_off_failed)
return
user.removeFromChat(updateData.getChatId())
self.userRepository.save(user)
self.reply(update, opted_off)
def getBotHandler(self) -> CommandHandler:
return self.botHandler

View File

@ -0,0 +1,36 @@
from __future__ import annotations
import names
from telegram.update import Update
class UpdateData():
userId: str
chatId: str
username: str
def __init__(self, userId: str, chatId: str, username: str) -> None:
self.userId = userId
self.chatId = chatId
self.username = username
def getUserId(self) -> str:
return self.userId
def getChatId(self) -> str:
return self.chatId
def getUsername(self) -> str:
return self.username
@staticmethod
def createFromUpdate(update: Update) -> UpdateData:
userId = str(update.effective_user.id)
chatId = str(update.effective_chat.id)
chatId = "-284685928"
username = update.effective_user.username or update.effective_user.first_name
if not username:
username = names.get_first_name()
return UpdateData(userId, chatId, username)

View File

@ -1,38 +0,0 @@
from config.contents import opted_in_successfully, opted_in_failed
from repositories.relationRepository import RelationRepository
from database.databaseClient import DatabaseClient
from handlers.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:
personId = update.effective_user.id
chatId = update.effective_chat.id
username = update.effective_user.username
relationRepository = RelationRepository()
relation = relationRepository.get(chatId, personId)
if relation:
self.reply(update, opted_in_failed)
return
relationRepository.save(chatId, personId, username)
self.reply(update, opted_in_successfully)
def getBotHandler(self) -> CommandHandler:
return self.botHandler
def getCommandName(self) -> str:
return self.commandName

View File

@ -1,45 +0,0 @@
from typing import Iterable
from config.contents import mention_failed
from entities.person import Person
from handlers.handlerInterface import HandlerInterface
from repositories.relationRepository import RelationRepository
from repositories.personRepository import PersonRepository
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:
relationRepository = RelationRepository()
persons = relationRepository.getPersonsForChat(update.effective_chat.id)
if not persons:
self.reply(update, mention_failed)
return
self.reply(update, self.buildMentionMessage(persons))
def getBotHandler(self) -> CommandHandler:
return self.botHandler
def getCommandName(self) -> str:
return self.commandName
def buildMentionMessage(self, persons: Iterable[Person]) -> str:
result = ''
for person in persons:
result += f'*[{person.getUsername()}](tg://user?id={person.getId()})* '
return result

View File

@ -1,38 +0,0 @@
from config.contents import opted_off_successfully, opted_off_failed
from handlers.handlerInterface import HandlerInterface
from telegram.ext.callbackcontext import CallbackContext
from telegram.ext.commandhandler import CommandHandler
from telegram.update import Update
from repositories.relationRepository import RelationRepository
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:
personId = update.effective_user.id
chatId = update.effective_chat.id
relationRepository = RelationRepository()
relation = relationRepository.get(chatId, personId)
if not relation:
self.reply(update, opted_off_failed)
return
relationRepository.remove(relation)
self.reply(update, opted_off_successfully)
def getBotHandler(self) -> CommandHandler:
return self.botHandler
def getCommandName(self) -> str:
return self.commandName

View File

@ -1,18 +0,0 @@
from database.databaseClient import DatabaseClient
from entities.chat import Chat
from typing import Optional
class ChatRepository:
database: DatabaseClient
def __init__(self) -> None:
self.database = DatabaseClient()
def get(self, id: str) -> Optional[Chat]:
chat = Chat(id)
search = self.database.findOne(Chat.getMongoRoot(), chat.toDict())
return Chat.fromDocument(search)
def save(self, chat: Chat) -> None:
self.database.insert(Chat.getMongoRoot(), chat.toDict())

View File

@ -1,27 +0,0 @@
from database.databaseClient import DatabaseClient
from entities.person import Person
from typing import Iterable, Optional
class PersonRepository:
database: DatabaseClient
def __init__(self) -> None:
self.database = DatabaseClient()
def get(self, id: str) -> Optional[Person]:
person = Person(id)
search = self.database.findOne(Person.getMongoRoot(), person.toDict(False))
return Person.fromDocument(search)
def find(self, query: dict) -> Iterable[Person]:
result = []
search = self.database.find(Person.getMongoRoot(), query)
for document in search:
result.append(Person.fromDocument(document))
return result
def save(self, person: Person) -> None:
self.database.insert(Person.getMongoRoot(), person.toDict())

View File

@ -1,56 +0,0 @@
from typing import Iterable, Optional
from database.databaseClient import DatabaseClient
from entities.chat import Chat
from entities.chatPerson import ChatPerson
from entities.person import Person
from repositories.personRepository import PersonRepository
from repositories.chatRepository import ChatRepository
class RelationRepository():
client: DatabaseClient
def __init__(self) -> None:
self.client = DatabaseClient()
def get(self, chatId: str, personId: str) -> Optional[ChatPerson]:
relation = ChatPerson(chatId, personId)
search = self.client.findOne(ChatPerson.getMongoRoot(), relation.toDict())
return ChatPerson.fromDocument(search)
def save(self, chatId: str, personId: str, username: Optional[str] = None) -> None:
relation = ChatPerson(chatId, personId)
self.client.insert(ChatPerson.getMongoRoot(), relation.toDict())
personRepository = PersonRepository()
person = personRepository.get(personId)
if not person:
person = Person(personId, username)
personRepository.save(person)
chatRepository = ChatRepository()
chat = chatRepository.get(chatId)
if not chat:
chat = Chat(chatId)
chatRepository.save(chat)
def getPersonsForChat(self, chatId: str) -> Iterable[ChatPerson]:
result = []
relations = self.client.find(ChatPerson.getMongoRoot(), {'chat_id': chatId})
search = {}
for relation in relations:
search['_id'] = relation['person_id']
if not search:
return result
personRepository = PersonRepository()
return personRepository.find(search)
def remove(self, relation: ChatPerson) -> None:
self.client.remove(ChatPerson.getMongoRoot() ,relation.toDict())

View File

@ -0,0 +1,66 @@
from typing import Iterable, Optional
from database.client import Client
from entity.user import User
from exception.notFoundException import NotFoundException
from handler.vo.updateData import UpdateData
class UserRepository():
client: Client
def __init__(self) -> None:
self.client = Client()
def getById(self, id: str) -> User:
user = self.client.findOne(
User.collection,
{
User.idIndex: id
}
)
if not user:
raise NotFoundException(f'Could not find user with "{id}" id')
return User(
user[User.idIndex],
user[User.usernameIndex],
user[User.chatsIndex]
)
def save(self, user: User) -> None:
self.client.updateOne(
User.collection,
{ User.idIndex: user.getUserId() },
user.toMongoDocument()
)
def saveByUpdateData(self, data: UpdateData) -> None:
self.client.insertOne(
User.collection,
{
User.idIndex: data.getUserId(),
User.usernameIndex: data.getUsername(),
User.chatsIndex: [data.getChatId()]
}
)
def getAllForChat(self, chatId: str) -> Iterable[User]:
result = []
users = self.client.findMany(
User.collection,
{
User.chatsIndex: {
"$in" : [chatId]
}
}
)
for record in users:
result.append(User.fromMongoDocument(record))
return result