Firebase to MongoDB change, updated README.md, removed entrypoint.py and heroku-specific files

Author: miloszowi<miloszweb@gmail.com>
This commit is contained in:
miloszowi 2021-09-25 16:49:11 +02:00
parent c29b0dabc5
commit baa8a78cad
41 changed files with 390 additions and 226 deletions

View File

@ -1,6 +0,0 @@
bot_token=
firebase_apiKey=
firebase_authDomain=
firebase_databaseURL=
firebase_projectId=
firebase_storageBucket=

2
.gitignore vendored Normal file → Executable file
View File

@ -102,6 +102,8 @@ celerybeat.pid
*.sage.py *.sage.py
# Environments # Environments
app.env
database.env
.env .env
.venv .venv
env/ env/

7
Dockerfile Executable file
View File

@ -0,0 +1,7 @@
FROM python:3.8-buster
WORKDIR /src
COPY ./src/requirements.txt /src
RUN pip install -r ./requirements.txt

0
LICENSE Normal file → Executable file
View File

View File

@ -1 +0,0 @@
web: python entrypoint.py

26
README.md Normal file → Executable file
View File

@ -26,14 +26,28 @@ python entrypoint.py
- `python` with version specified in `runtime.txt` - `python` with version specified in `runtime.txt`
- `pip` with version `20.0.2` - `pip` with version `20.0.2`
#### Env file #### Env files
First, copy env files for database and app containers
```bash ```bash
cp .env.local .env cp docker/config/app/app.dist.env docker/config/app/app.env
cp docker/config/database/database.dist.env docker/config/app/app.env
``` ```
and then fulfill copied `.env` file with required values and then fulfill copied `.env` files 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.env
- `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)
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 ### Commands
#### `/in` #### `/in`
Will sign you in for everyone-mentions. Will sign you in for everyone-mentions.
@ -56,7 +70,7 @@ If you haven't opted-in before, alternative reply will be displayed.
#### `/everone` #### `/everone`
Will mention everyone that opted-in for everyone-mentions separated by spaces. 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. If user does not have nickname, it will assign random name from `names` python library to his ID
![everybody command example](docs/everyone_command.png) ![everybody command example](docs/everyone_command.png)

37
docker-compose.yml Executable file
View File

@ -0,0 +1,37 @@
version: "3.6"
services:
database:
image: mongo:4.0.8
restart: unless-stopped
env_file:
- ./docker/config/database/database.env
volumes:
- db-data:/data/db
ports:
- 27017:27017
networks:
- web
app:
build: .
command: python app.py
env_file:
- ./docker/config/app/app.env
volumes:
- ./src:/src
ports:
- 9000:9000
depends_on:
- database
networks:
- web
restart: on-failure
networks:
web:
driver: bridge
volumes:
db-data:

7
docker/config/app/app.dist.env Executable file
View File

@ -0,0 +1,7 @@
BOT_TOKEN=
MONGODB_DATABASE=
MONGODB_USERNAME=
MONGODB_PASSWORD=
MONGODB_HOSTNAME=localhost
MONGODB_PORT=27017

View File

@ -0,0 +1,5 @@
MONGO_INITDB_ROOT_USERNAME=
MONGO_INITDB_ROOT_PASSWORD=
MONGO_INITDB_DATABASE=
MONGODB_DATA_DIR=/mongo/database
MONDODB_LOG_DIR=

5
docker/logs Executable file
View File

@ -0,0 +1,5 @@
#!/usr/bin/env bash
[ -z "$1" ] && printf "\nPlease specify service name (ex. app)\n\n" && exit
docker-compose logs -f "$@"

0
docs/commands.png Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

0
docs/everyone_command.png Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

0
docs/everyone_noone_to_mention.png Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 8.7 KiB

0
docs/in_command.png Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

0
docs/in_command_already_opted_in.png Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

0
docs/logo.png Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

0
docs/out_command.png Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

0
docs/out_command_did_not_opt_in_before.png Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 9.7 KiB

After

Width:  |  Height:  |  Size: 9.7 KiB

View File

@ -1,6 +0,0 @@
from src.app import App
if __name__ == "__main__":
app = App()
app.run()

View File

@ -1 +0,0 @@
python-3.8.10

14
src/app.py Normal file → Executable file
View File

@ -1,10 +1,9 @@
from .config.credentials import bot_token from config.credentials import bot_token
from .config.handlers import handlers from config.handlers import handlers
from .handlers.handlerInterface import HandlerInterface from handlers.handlerInterface import HandlerInterface
from telegram.ext.dispatcher import Dispatcher from telegram.ext.dispatcher import Dispatcher
from telegram.ext import Updater from telegram.ext import Updater
class App: class App:
updater: Updater updater: Updater
dispatcher: Dispatcher dispatcher: Dispatcher
@ -14,6 +13,7 @@ class App:
def run(self) -> None: def run(self) -> None:
self.registerHandlers() self.registerHandlers()
self.updater.start_polling() self.updater.start_polling()
self.updater.idle() self.updater.idle()
@ -23,3 +23,9 @@ class App:
raise Exception('Invalid list of handlers provided. Handler must implement HandlerInterface') raise Exception('Invalid list of handlers provided. Handler must implement HandlerInterface')
self.updater.dispatcher.add_handler(handler.getBotHandler()) self.updater.dispatcher.add_handler(handler.getBotHandler())
if __name__ == "__main__":
app = App()
app.run()

0
src/config/contents.py Normal file → Executable file
View File

12
src/config/credentials.py Normal file → Executable file
View File

@ -5,10 +5,8 @@ load_dotenv()
bot_token = os.environ['bot_token'] bot_token = os.environ['bot_token']
firebaseConfig = { MONGODB_DATABASE=os.environ['MONGODB_DATABASE']
"apiKey": os.environ['firebase_apiKey'], MONGODB_USERNAME=os.environ['MONGODB_USERNAME']
"authDomain": os.environ['firebase_authDomain'], MONGODB_PASSWORD=os.environ['MONGODB_PASSWORD']
"databaseURL": os.environ['firebase_databaseURL'], MONGODB_HOSTNAME=os.environ['MONGODB_HOSTNAME']
"projectId": os.environ['firebase_projectId'], MONGODB_PORT=os.environ['MONGODB_PORT']
"storageBucket": os.environ['firebase_storageBucket'],
}

6
src/config/handlers.py Normal file → Executable file
View File

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

30
src/database/databaseClient.py Executable file
View File

@ -0,0 +1,30 @@
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():
mongoClient: MongoClient
database: Database
def __init__(self) -> None:
uri = "mongodb://%s:%s@%s:%s/%s?authSource=admin" % (
MONGODB_USERNAME, quote_plus(MONGODB_PASSWORD),
MONGODB_HOSTNAME, MONGODB_PORT, MONGODB_DATABASE
)
self.mongoClient = MongoClient(uri)
self.database = self.mongoClient[MONGODB_DATABASE]
def insert(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)

28
src/entities/chat.py Executable file
View File

@ -0,0 +1,28 @@
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'])

34
src/entities/chatPerson.py Executable file
View File

@ -0,0 +1,34 @@
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,34 +0,0 @@
from typing import Iterable
from .user import User
class Group():
__id: int
__users: Iterable[User] = []
def __init__(self, id: int) -> None:
self.__id = id
def getId(self) -> int:
return self.__id
def setUsers(self, users: Iterable[User]) -> None:
self.__users = users
def addUser(self, user: User) -> None:
self.__users.append(user)
def removeUser(self, user: User) -> None:
for index, groupUser in enumerate(self.__users):
if groupUser.getId() == user.getId():
del self.__users[index]
def getUsers(self) -> Iterable[User]:
return self.__users
def hasUser(self, user: User) -> bool:
userIds = [int(groupUser.getId()) for groupUser in self.getUsers()]
if user.getId() in userIds:
return True
return False

44
src/entities/person.py Executable file
View File

@ -0,0 +1,44 @@
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'])

View File

@ -1,19 +0,0 @@
from typing import Optional
class User():
__id: int
__username: Optional[str]
__groupId: int
def __init__(self, id: int, username: Optional[str]) -> None:
self.__id = id
self.__username = username
def getId(self) -> int:
return self.__id
def getUsername(self) -> Optional[str]:
return self.__username
def getGroupId(self) -> int:
return self.__groupId

View File

@ -1,34 +0,0 @@
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}'

3
src/handlers/handlerInterface.py Normal file → Executable file
View File

@ -16,3 +16,6 @@ class HandlerInterface:
@abstractmethod @abstractmethod
def getCommandName(self) -> str: raise Exception('getCommandName method is not implemented') def getCommandName(self) -> str: raise Exception('getCommandName method is not implemented')
def reply(self, update: Update, message: str) -> None:
update.effective_message.reply_markdown_v2(text=message)

30
src/handlers/inHandler.py Normal file → Executable file
View File

@ -1,12 +1,11 @@
from ..config.contents import opted_in_successfully, opted_in_failed from config.contents import opted_in_successfully, opted_in_failed
from ..entities.user import User from repositories.relationRepository import RelationRepository
from ..repositories.groupRepository import GroupRepository from database.databaseClient import DatabaseClient
from .handlerInterface import HandlerInterface from handlers.handlerInterface import HandlerInterface
from telegram.ext.callbackcontext import CallbackContext from telegram.ext.callbackcontext import CallbackContext
from telegram.ext.commandhandler import CommandHandler from telegram.ext.commandhandler import CommandHandler
from telegram.update import Update from telegram.update import Update
class InHandler(HandlerInterface): class InHandler(HandlerInterface):
botHandler: CommandHandler botHandler: CommandHandler
commandName: str = 'in' commandName: str = 'in'
@ -18,18 +17,19 @@ class InHandler(HandlerInterface):
) )
def handle(self, update: Update, context: CallbackContext) -> None: def handle(self, update: Update, context: CallbackContext) -> None:
groupRepository = GroupRepository() personId = update.effective_user.id
group = groupRepository.get(update.effective_chat.id) chatId = update.effective_chat.id
user = User(update.effective_user.id, update.effective_user.username) username = update.effective_user.username
if group.hasUser(user): relationRepository = RelationRepository()
update.message.reply_markdown_v2(text=opted_in_failed) relation = relationRepository.get(chatId, personId)
if relation:
self.reply(update, opted_in_failed)
return return
group.addUser(user) relationRepository.save(chatId, personId, username)
groupRepository.save(group) self.reply(update, opted_in_successfully)
update.message.reply_markdown_v2(text=opted_in_successfully)
def getBotHandler(self) -> CommandHandler: def getBotHandler(self) -> CommandHandler:
return self.botHandler return self.botHandler

33
src/handlers/mentionHandler.py Normal file → Executable file
View File

@ -1,9 +1,9 @@
from ..config.contents import mention_failed from typing import Iterable
from ..entities.group import Group from config.contents import mention_failed
from ..entities.user import User from entities.person import Person
from ..firebaseProxy import FirebaseProxy from handlers.handlerInterface import HandlerInterface
from ..repositories.groupRepository import GroupRepository from repositories.relationRepository import RelationRepository
from .handlerInterface import HandlerInterface from repositories.personRepository import PersonRepository
from telegram.ext.callbackcontext import CallbackContext from telegram.ext.callbackcontext import CallbackContext
from telegram.ext.commandhandler import CommandHandler from telegram.ext.commandhandler import CommandHandler
from telegram.update import Update from telegram.update import Update
@ -20,11 +20,15 @@ class MentionHandler(HandlerInterface):
) )
def handle(self, update: Update, context: CallbackContext) -> None: def handle(self, update: Update, context: CallbackContext) -> None:
groupId = update.effective_chat.id relationRepository = RelationRepository()
groupRepository = GroupRepository() persons = relationRepository.getPersonsForChat(update.effective_chat.id)
mentionMessage = self.buildMentionMessage(groupRepository.get(id=groupId))
if not persons:
self.reply(update, mention_failed)
return
self.reply(update, self.buildMentionMessage(persons))
update.message.reply_markdown_v2(text=mentionMessage)
def getBotHandler(self) -> CommandHandler: def getBotHandler(self) -> CommandHandler:
return self.botHandler return self.botHandler
@ -32,11 +36,10 @@ class MentionHandler(HandlerInterface):
def getCommandName(self) -> str: def getCommandName(self) -> str:
return self.commandName return self.commandName
def buildMentionMessage(self, group: Group) -> str: def buildMentionMessage(self, persons: Iterable[Person]) -> str:
result = '' result = ''
for user in group.getUsers(): for person in persons:
username = user.getUsername() or user.getId() result += f'*[{person.getUsername()}](tg://user?id={person.getId()})* '
result += f'*[{username}](tg://user?id={user.getId()})* '
return result or mention_failed return result

28
src/handlers/outHandler.py Normal file → Executable file
View File

@ -1,11 +1,11 @@
from ..config.contents import opted_off_successfully, opted_off_failed from config.contents import opted_off_successfully, opted_off_failed
from ..entities.user import User from handlers.handlerInterface import HandlerInterface
from ..repositories.groupRepository import GroupRepository
from .handlerInterface import HandlerInterface
from telegram.ext.callbackcontext import CallbackContext from telegram.ext.callbackcontext import CallbackContext
from telegram.ext.commandhandler import CommandHandler from telegram.ext.commandhandler import CommandHandler
from telegram.update import Update from telegram.update import Update
from repositories.relationRepository import RelationRepository
class OutHandler(HandlerInterface): class OutHandler(HandlerInterface):
botHandler: CommandHandler botHandler: CommandHandler
@ -18,18 +18,18 @@ class OutHandler(HandlerInterface):
) )
def handle(self, update: Update, context: CallbackContext) -> None: def handle(self, update: Update, context: CallbackContext) -> None:
groupRepository = GroupRepository() personId = update.effective_user.id
group = groupRepository.get(update.effective_chat.id) chatId = update.effective_chat.id
user = User(update.effective_user.id, update.effective_user.username)
if group.hasUser(user): relationRepository = RelationRepository()
group.removeUser(user) relation = relationRepository.get(chatId, personId)
groupRepository.save(group)
if not relation:
update.message.reply_markdown_v2(text=opted_off_successfully) self.reply(update, opted_off_failed)
return return
update.message.reply_markdown_v2(text=opted_off_failed) relationRepository.remove(relation)
self.reply(update, opted_off_successfully)
def getBotHandler(self) -> CommandHandler: def getBotHandler(self) -> CommandHandler:
return self.botHandler return self.botHandler

View File

@ -0,0 +1,18 @@
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,48 +0,0 @@
from ..entities.group import Group
from ..entities.user import User
from ..firebaseProxy import FirebaseProxy
class GroupRepository():
firebase: FirebaseProxy
def __init__(self) -> None:
self.firebase = FirebaseProxy()
def get(self, id: int) -> Group:
group = Group(id)
fbData = self.firebase.getChilds(FirebaseProxy.group_index, id).get()
users = []
for userData in fbData.each() or []:
userData = userData.val()
users.append(
User(
userData.get(FirebaseProxy.id_index),
userData.get(FirebaseProxy.name_index)
)
)
group.setUsers(users)
return group
def save(self, group: Group) -> None:
users = {}
if not group.getUsers():
self.remove(group)
for user in group.getUsers():
users[user.getId()] = {
FirebaseProxy.id_index: user.getId(),
FirebaseProxy.name_index: user.getUsername()
}
self.firebase.getChilds(
FirebaseProxy.group_index,
group.getId()
).update(users)
def remove(self, group: Group) -> None:
self.firebase.getChilds(FirebaseProxy.group_index, group.getId()).remove()

View File

@ -0,0 +1,27 @@
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

@ -0,0 +1,56 @@
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

@ -1,12 +0,0 @@
from ..firebaseProxy import FirebaseProxy
class UserRepository():
firebaseProxy: FirebaseProxy
def __init__(self) -> None:
self.firebaseProxy = FirebaseProxy()
# TODO : this repository needs to handle user save/deletion/update
# right now, all of those above is handled by GroupRepository

3
requirements.txt → src/requirements.txt Normal file → Executable file
View File

@ -1,3 +1,4 @@
python-dotenv==0.19.0 python-dotenv==0.19.0
python-telegram-bot==13.7 python-telegram-bot==13.7
Pyrebase==3.0.27 pymongo==3.12.0
names==0.3.0