Merge pull request #4 from miloszowi/release/0.2.0

0.2.0 Release
This commit is contained in:
Miłosz Guglas 2021-10-26 17:53:32 +02:00 committed by miloszowi
commit f9643f052e
53 changed files with 663 additions and 507 deletions

View File

@ -1,6 +1,18 @@
# Change Log
All notable changes to this project will be documented in this file.
## [0.2.0] - 26.10.2021
### Added
- Inline Mode for `join`, `leave` & `everyone`
- Banned users environment variable
- Buttons for `start` message
### Changed
- Code quality improvements
- `start` text
- mongodb data structure
- group name max length to 40
### Deleted
- `/silent` command
## [0.1.0] - 06.10.2021
### Features
- `/join` command

137
README.md
View File

@ -5,23 +5,97 @@
# Contents
* [Description](#description)
* [Getting started.](#getting-started)
* [Requirements](#requirements)
* [Installation](#installation)
* [Logs](#logs)
* [Env files](#env-files)
* [Commands](#commands)
* [`/join`](#join)
* [`/leave`](#leave)
* [`/everyone`](#everyone)
* [`/groups`](#groups)
* [`/silent`](#silent)
* [`/start`](#start)
* [Example command flow](#example-command-flow)
* [Inline Mode](#inline-mode)
* [Usage](#usage)
* [Getting started.](#getting-started)
* [Requirements](#requirements)
* [Installation](#installation)
* [Logs](#logs)
* [Env files](#env-files)
## Description
Everyone Mention Bot is simple, but useful telegram bot to gather group members attention.
You can create groups per chat to mention every user that joined the group by calling one command instead of mentioning them one by one.
## Commands
*Important*: `{group-name}` is not required, if not given, it will be set to `default`.
### `/join`
Joins the group (if group did not exist before, it will be created).
```
/join {group_name}
```
*Examples*
without group name
![join default command example](docs/join_default.png)
with group name
![join group command example](docs/join_group.png)
### `/leave`
Leaves the group (and deletes if no members are left).
```
/leave {group_name}
```
*Examples*
without group name
![leave default command example](docs/leave_default.png)
with group name
![leave group command example](docs/leave_group.png)
### `/everyone`
Mention everyone that joined requested group.
```
/everyone {group-name}
```
*Examples*
without group name
![everyone default command example](docs/everyone_default.png)
with group name
![everyone group command example](docs/everyone_group.png)
### `/groups`
Will display available groups for this chat as well with members count.
![groups command example](docs/groups.png)
### `/start`
Start & Help message
![start command example](docs/start.png)
### Example command flow
![example command flow](docs/flow_command.png)
## Inline Mode
Using Inline Mode is recommended because policy of bots with privacy mode enabled (https://core.telegram.org/bots/faq#what-messages-will-my-bot-get) says that command trigger is sent (without mentioning the bot) only to the last mentioned bot. So if you do have multiple bots in current chat, I might not receive your command!
### Usage
To use inline mode, type `@everyone_mention_bot` in telegram message input or click on the `Inline Mode` button from `/start` command.
![inline popup](docs/inline_mode_1.png)
Type in your group name (you can leave it blank for `default` group name), then click on one of the three following options.
![inline answer](docs/inline_mode_2.png)
## Getting started
### Requirements
- `docker-compose` in version `1.25.0`
@ -35,8 +109,8 @@ git clone https://github.com/miloszowi/everyone-mention-telegram-bot.git
- copy environment files and fulfill empty values
```bash
cp .env.local .env
cp docker/config/app.dist.env docker/config/app.env
cp docker/config/database.dist.env docker/config/app.env
cp docker/config/app.env.local docker/config/app.env
cp docker/config/database.env.local docker/config/app.env
```
- start the project (`-d` flag will run containers in detached mode)
```bash
@ -62,6 +136,7 @@ docker/logs <container>
- `MONGODB_PASSWORD` - MongoDB password
- `MONGODB_HOSTNAME` - MongoDB host (default `database` - container name)
- `MONGODB_PORT` - MongoDB port (default `27017` - given in docker-compose configuration)
- `BANNED_USERS` - user ids separated by comma that are not allowed to use the bot
- `database.env`
- `MONGO_INITDB_ROOT_USERNAME` - conf from `app.env`
@ -69,49 +144,3 @@ docker/logs <container>
- `MONGO_INITDB_DATABASE` - conf from `app.env`
- `MONGODB_DATA_DIR` - directory to store MongoDB documents (inside a container)
- `MONDODB_LOG_DIR` - path to logs storage
## Commands
### `/join`
```
/join <group_name>
```
Joins the group (and create if it did not exist before) given in message (`default` if not given)
![join command example](docs/join.png)
### `/leave`
```
/leave <group_name>
```
Leaves the group given in message (`default` if not given)
![leave command example](docs/leave.png)
### `/everyone`
```
/everyone <group_id>
```
Will mention every member of given group (`default` if not given).
If user does not have nickname, it will first try to assign his firstname, then random firstname from `names` python library
![everyone command example](docs/everyone.png)
### `/groups`
Will display available groups for this chat as well with members count.
![groups command example](docs/groups.png)
### `/silent`
```
/silent <group_name>
```
Will display all every member of given group (`default` if not given) but without notyfing them.
![silent command example](docs/silent.png)
### `/start`
Start & Help message
![start command example](docs/start.png)

View File

@ -22,7 +22,6 @@ services:
- ./docker/config/app.env
volumes:
- ./src:/src
- ./logs:/var/log/bot
ports:
- $APP_EXPOSED_PORT:$APP_INTERNAL_PORT
depends_on:

View File

@ -7,3 +7,5 @@ MONGODB_USERNAME=
MONGODB_PASSWORD=
MONGODB_HOSTNAME=localhost
MONGODB_PORT=27017
BANNED_USERS=

8
docker/mongosh Executable file
View File

@ -0,0 +1,8 @@
#!/usr/bin/env bash
if [ -f docker/config/app.env ]
then
source docker/config/app.env
fi
docker-compose exec database mongosh --port $MONGODB_PORT --host $MONGODB_HOSTNAME --username $MONGODB_USERNAME --password $MONGODB_PASSWORD --authenticationDatabase admin $MONGODB_DATABASE

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

BIN
docs/everyone_default.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
docs/everyone_group.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
docs/flow_command.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 16 KiB

BIN
docs/inline_mode_1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
docs/inline_mode_2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

BIN
docs/join_default.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
docs/join_group.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

BIN
docs/leave_default.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
docs/leave_group.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 77 KiB

View File

@ -1,27 +1,21 @@
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 bot.handler import (groupsHandler, joinHandler, mentionHandler, leaveHandler,
silentMentionHandler, startHandler)
from bot.handler import *
from bot.handler.abstractHandler import AbstractHandler
from config.envs import BOT_TOKEN, PORT, WEBHOOK_URL
from logger import Logger
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()
Logger.register()
self.register_handlers()
self.register_webhook()
@ -29,9 +23,7 @@ class App:
def register_handlers(self) -> None:
for handler in AbstractHandler.__subclasses__():
self.updater.dispatcher.add_handler(
handler().get_bot_handler()
)
self.updater.dispatcher.add_handler(handler().bot_handler)
def register_webhook(self) -> None:
self.updater.start_webhook(
@ -41,15 +33,9 @@ class App:
webhook_url="/".join([WEBHOOK_URL, BOT_TOKEN])
)
Logger.get_logger(Logger.action_logger).info(
f'Webhook configured, listening on {WEBHOOK_URL}/<bot-token>'
)
Logger.info(f'Webhook configured, listening on {WEBHOOK_URL}/<bot-token>')
def setup_logging(self) -> None:
logger = Logger()
logger.setup()
if __name__ == "__main__":
app = App()
app.run()

View File

@ -0,0 +1,5 @@
__all__ = [
'abstractHandler', 'everyoneHandler', 'groupsHandler',
'inlineQueryHandler', 'joinHandler', 'leaveHandler',
'startHandler'
]

View File

@ -1,37 +1,37 @@
from abc import abstractmethod
from bot.message.messageData import MessageData
from logger import Logger
from telegram.ext import Handler
from telegram.ext.callbackcontext import CallbackContext
from telegram.ext.handler import Handler
from telegram.update import Update
from telegram.utils.helpers import mention_markdown
from bot.message.inboundMessage import InboundMessage
from bot.message.replier import Replier
from exception.actionNotAllowedException import ActionNotAllowedException
from exception.invalidActionException import InvalidActionException
from exception.invalidArgumentException import InvalidArgumentException
from logger import Logger
class AbstractHandler:
@abstractmethod
def get_bot_handler(self) -> Handler: raise Exception('get_bot_handler method is not implemented')
bot_handler: Handler
inbound: InboundMessage
action: str
@abstractmethod
def handle(self, update: Update, context: CallbackContext) -> None: raise Exception('handle method is not implemented')
def handle(self, update: Update, context: CallbackContext) -> None:
raise Exception('handle method is not implemented')
@abstractmethod
def log_action(self, message_data: MessageData) -> None: raise Exception('log_action method is not implemented')
def interpolate_reply(self, reply: str, message_data: MessageData):
return reply.format(
mention_markdown(message_data.user_id, message_data.username),
message_data.group_name
)
def reply_markdown(self, update: Update, message: str) -> None:
def wrap(self, update: Update, context: CallbackContext) -> None:
try:
update.effective_message.reply_markdown_v2(text=message)
except Exception as err:
Logger.error(str(err))
group_specific = self.is_group_specific()
def reply_html(self, update: Update, html: str) -> None:
try:
update.effective_message.reply_html(text=html)
except Exception as err:
Logger.error(str(err))
self.inbound = InboundMessage.create(update, context, group_specific)
self.handle(update, context)
Logger.action(self.inbound, self.action)
except (InvalidActionException, InvalidArgumentException, ActionNotAllowedException) as e:
Replier.markdown(update, str(e))
except Exception as e:
Logger.exception(e)
def is_group_specific(self) -> bool:
return True

View File

@ -0,0 +1,32 @@
from telegram.ext.callbackcontext import CallbackContext
from telegram.ext.commandhandler import CommandHandler
from telegram.update import Update
from bot.handler.abstractHandler import AbstractHandler
from bot.message.replier import Replier
from config.contents import mention_failed
from exception.invalidActionException import InvalidActionException
from exception.notFoundException import NotFoundException
from repository.chatRepository import ChatRepository
from repository.userRepository import UserRepository
from utils.messageBuilder import MessageBuilder
class EveryoneHandler(AbstractHandler):
bot_handler: CommandHandler
chat_repository: ChatRepository
user_repository: UserRepository
action: str = 'everyone'
def __init__(self) -> None:
self.bot_handler = CommandHandler(self.action, self.wrap)
self.chat_repository = ChatRepository()
self.user_repository = UserRepository()
def handle(self, update: Update, context: CallbackContext) -> None:
try:
users = self.chat_repository.get_users_for_group(self.inbound.chat_id, self.inbound.group_name)
Replier.markdown(update, MessageBuilder.mention_message(users))
except NotFoundException as e:
raise InvalidActionException(mention_failed) from e

View File

@ -1,45 +1,34 @@
from typing import Iterable
import prettytable as pt
from bot.handler.abstractHandler import AbstractHandler
from bot.message.messageData import MessageData
from config.contents import no_groups
from entity.group import Group
from logger import Logger
from repository.groupRepository import GroupRepository
from telegram.ext.callbackcontext import CallbackContext
from telegram.ext.commandhandler import CommandHandler
from telegram.update import Update
from bot.handler.abstractHandler import AbstractHandler
from bot.message.replier import Replier
from config.contents import no_groups
from exception.invalidActionException import InvalidActionException
from exception.notFoundException import NotFoundException
from repository.chatRepository import ChatRepository
from utils.messageBuilder import MessageBuilder
class GroupsHandler(AbstractHandler):
bot_handler: CommandHandler
group_repository: GroupRepository
chat_repository: ChatRepository
action: str = 'groups'
def __init__(self) -> None:
self.bot_handler = CommandHandler('groups', self.handle)
self.group_repository = GroupRepository()
self.bot_handler = CommandHandler(self.action, self.wrap)
self.chat_repository = ChatRepository()
def handle(self, update: Update, context: CallbackContext) -> None:
message_data = MessageData.create_from_arguments(update, context, False)
try:
chat = self.chat_repository.get(self.inbound.chat_id)
if not chat.groups:
raise NotFoundException
groups = self.group_repository.get_by_chat_id(message_data.chat_id)
Replier.html(update, MessageBuilder.group_message(chat.groups))
except NotFoundException:
raise InvalidActionException(no_groups)
if groups:
self.reply_html(update, self.build_groups_message(groups))
return self.log_action(message_data)
self.reply_markdown(update, no_groups)
def get_bot_handler(self) -> CommandHandler:
return self.bot_handler
def log_action(self, message_data: MessageData) -> None:
Logger.info(f'User {message_data.username} called /groups for {message_data.chat_id}')
def build_groups_message(self, groups: Iterable[Group]) -> str:
resultTable = pt.PrettyTable(['Name', 'Members'])
resultTable.add_rows([[record.group_name, record.users_count] for record in groups])
return f'<pre>{str(resultTable)}</pre>'
def is_group_specific(self) -> bool:
return False

View File

@ -0,0 +1,51 @@
from telegram import InlineQueryResultArticle
from telegram.ext.callbackcontext import CallbackContext
from telegram.ext.inlinequeryhandler import \
InlineQueryHandler as CoreInlineQueryHandler
from telegram.inline.inputtextmessagecontent import InputTextMessageContent
from telegram.update import Update
from bot.handler.abstractHandler import AbstractHandler
from bot.message.inboundMessage import InboundMessage
from exception.actionNotAllowedException import ActionNotAllowedException
from validator.accessValidator import AccessValidator
class InlineQueryHandler(AbstractHandler):
bot_handler: CoreInlineQueryHandler
def __init__(self) -> None:
self.bot_handler = CoreInlineQueryHandler(self.handle)
def handle(self, update: Update, context: CallbackContext) -> None:
try:
AccessValidator.validate(str(update.effective_user.id))
except ActionNotAllowedException:
update.inline_query.answer([])
return
group_display = update.inline_query.query or InboundMessage.default_group
group = '' if group_display == InboundMessage.default_group else group_display
results = [
InlineQueryResultArticle(
id='everyone',
title='MENTION',
description=f'Mention members in group "{group_display}"',
input_message_content=InputTextMessageContent(f'/everyone {group}')
),
InlineQueryResultArticle(
id='join',
title='JOIN',
description=f'Joins group "{group_display}"',
input_message_content=InputTextMessageContent(f'/join {group}')
),
InlineQueryResultArticle(
id='leave',
title='LEAVE',
description=f'Leaves group "{group_display}"',
input_message_content=InputTextMessageContent(f'/leave {group}')
)
]
update.inline_query.answer(results, cache_time=4800)

View File

@ -1,46 +1,34 @@
from telegram.utils.helpers import mention_markdown
from bot.handler.abstractHandler import AbstractHandler
from bot.message.messageData import MessageData
from config.contents import joined, not_joined
from exception.invalidArgumentException import InvalidArgumentException
from exception.notFoundException import NotFoundException
from logger import Logger
from repository.userRepository import UserRepository
from telegram.ext.callbackcontext import CallbackContext
from telegram.ext.commandhandler import CommandHandler
from telegram.update import Update
from bot.handler.abstractHandler import AbstractHandler
from bot.message.replier import Replier
from config.contents import joined, not_joined
from exception.invalidActionException import InvalidActionException
from repository.chatRepository import ChatRepository
from repository.userRepository import UserRepository
class JoinHandler(AbstractHandler):
bot_handler: CommandHandler
user_repository: UserRepository
action: str = 'join'
def __init__(self) -> None:
self.bot_handler = CommandHandler('join', self.handle)
self.bot_handler = CommandHandler(self.action, self.wrap)
self.user_repository = UserRepository()
self.chat_repository = ChatRepository()
def handle(self, update: Update, context: CallbackContext) -> None:
try:
message_data = MessageData.create_from_arguments(update, context)
except InvalidArgumentException as e:
return self.reply_markdown(update, str(e))
user = self.user_repository.provide(self.inbound)
chat = self.chat_repository.provide(self.inbound)
users = chat.groups.get(self.inbound.group_name)
try:
user = self.user_repository.get_by_id(message_data.user_id)
if user.user_id in users:
raise InvalidActionException(Replier.interpolate(not_joined, self.inbound))
if user.is_in_chat(message_data.chat_id):
return self.reply_markdown(update, self.interpolate_reply(not_joined, message_data))
users.append(user.user_id)
self.chat_repository.save(chat)
user.add_to_chat(message_data.chat_id)
self.user_repository.save(user)
except NotFoundException:
self.user_repository.save_by_message_data(message_data)
self.reply_markdown(update, self.interpolate_reply(joined, message_data))
self.log_action(message_data)
def get_bot_handler(self) -> CommandHandler:
return self.bot_handler
def log_action(self, message_data: MessageData) -> None:
Logger.info(f'User {message_data.username} joined {message_data.chat_id}')
Replier.markdown(update, Replier.interpolate(joined, self.inbound))

View File

@ -1,45 +1,38 @@
from bot.handler.abstractHandler import AbstractHandler
from bot.message.messageData import MessageData
from config.contents import left, not_left
from exception.invalidArgumentException import InvalidArgumentException
from exception.notFoundException import NotFoundException
from logger import Logger
from repository.userRepository import UserRepository
from telegram.ext.callbackcontext import CallbackContext
from telegram.ext.commandhandler import CommandHandler
from telegram.update import Update
from bot.handler.abstractHandler import AbstractHandler
from bot.message.replier import Replier
from config.contents import left, not_left
from exception.invalidActionException import InvalidActionException
from repository.userRepository import UserRepository
from repository.chatRepository import ChatRepository
class LeaveHandler(AbstractHandler):
bot_handler: CommandHandler
user_repository: UserRepository
chat_repository: ChatRepository
action: str = 'leave'
def __init__(self) -> None:
self.bot_handler = CommandHandler('leave', self.handle)
self.bot_handler = CommandHandler(self.action, self.wrap)
self.user_repository = UserRepository()
self.chat_repository = ChatRepository()
def handle(self, update: Update, context: CallbackContext) -> None:
try:
message_data = MessageData.create_from_arguments(update, context)
except InvalidArgumentException as e:
return self.reply_markdown(update, str(e))
user = self.user_repository.provide(self.inbound)
chat = self.chat_repository.provide(self.inbound)
group = chat.groups.get(self.inbound.group_name)
try:
user = self.user_repository.get_by_id(message_data.user_id)
if user.user_id not in group:
raise InvalidActionException(Replier.interpolate(not_left, self.inbound))
if not user.is_in_chat(message_data.chat_id):
raise NotFoundException()
except NotFoundException:
return self.reply_markdown(update, self.interpolate_reply(not_left, message_data))
group.remove(user.user_id)
if not group:
chat.groups.pop(self.inbound.group_name)
user.remove_from_chat(message_data.chat_id)
self.user_repository.save(user)
self.chat_repository.save(chat)
self.reply_markdown(update, self.interpolate_reply(left, message_data))
self.log_action(message_data)
def get_bot_handler(self) -> CommandHandler:
return self.bot_handler
def log_action(self, message_data: MessageData) -> None:
Logger.info(f'User {message_data.username} left {message_data.chat_id}')
Replier.markdown(update, Replier.interpolate(left, self.inbound))

View File

@ -1,47 +0,0 @@
from typing import Iterable
from telegram.utils.helpers import mention_markdown
from bot.handler.abstractHandler import AbstractHandler
from bot.message.messageData import MessageData
from config.contents import mention_failed
from entity.user import User
from exception.invalidArgumentException import InvalidArgumentException
from logger import Logger
from repository.userRepository import UserRepository
from telegram.ext.callbackcontext import CallbackContext
from telegram.ext.commandhandler import CommandHandler
from telegram.update import Update
class MentionHandler(AbstractHandler):
bot_handler: CommandHandler
user_repository: UserRepository
def __init__(self) -> None:
self.bot_handler = CommandHandler('everyone', self.handle)
self.user_repository = UserRepository()
def handle(self, update: Update, context: CallbackContext) -> None:
try:
message_data = MessageData.create_from_arguments(update, context)
except InvalidArgumentException as e:
return self.reply_markdown(update, str(e))
users = self.user_repository.get_all_for_chat(message_data.chat_id)
if users:
self.reply_markdown(update, self.build_mention_message(users))
return self.log_action(message_data)
self.reply_markdown(update, mention_failed)
def get_bot_handler(self) -> CommandHandler:
return self.bot_handler
def log_action(self, message_data: MessageData) -> None:
Logger.info(f'User {message_data.username} called /everyone for {message_data.chat_id}')
def build_mention_message(self, users: Iterable[User]) -> str:
return ' '.join([mention_markdown(user.user_id, user.username) for user in users])

View File

@ -1,21 +0,0 @@
from typing import Iterable
from entity.user import User
from logger import Logger
from telegram.ext.commandhandler import CommandHandler
from bot.handler.abstractHandler import AbstractHandler
from bot.handler.mentionHandler import MentionHandler
from bot.message.messageData import MessageData
class MentionHandler(MentionHandler, AbstractHandler):
def __init__(self) -> None:
super().__init__()
self.bot_handler = CommandHandler('silent', self.handle)
def build_mention_message(self, users: Iterable[User]) -> str:
return ' '.join([user.username for user in users])
def log_action(self, message_data: MessageData) -> None:
Logger.info(f'User {message_data.username} called /silent for {message_data.chat_id}')

View File

@ -1,25 +1,35 @@
from config.contents import start_text
from logger import Logger
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
from telegram.ext.callbackcontext import CallbackContext
from telegram.ext.commandhandler import CommandHandler
from telegram.update import Update
from bot.handler.abstractHandler import AbstractHandler
from bot.message.messageData import MessageData
from bot.message.replier import Replier
from config.contents import start_text
from logger import Logger
class StartHandler(AbstractHandler):
bot_handler: CommandHandler
action: str = 'start'
def __init__(self) -> None:
self.bot_handler = CommandHandler('start', self.handle)
self.bot_handler = CommandHandler(self.action, self.wrap)
def handle(self, update: Update, context: CallbackContext) -> None:
self.reply_markdown(update, start_text)
self.log_action(MessageData.create_from_arguments(update, context))
markup = InlineKeyboardMarkup(
[
[
InlineKeyboardButton('Inline Mode', switch_inline_query_current_chat='example'),
],
[
InlineKeyboardButton('GitHub', url='https://github.com/miloszowi/everyone-mention-telegram-bot'),
InlineKeyboardButton('Creator', url='https://t.me/miloszowi')
]
]
)
def get_bot_handler(self) -> CommandHandler:
return self.bot_handler
Replier.html(update, start_text, markup)
def log_action(self, message_data: MessageData) -> None:
Logger.info(f'User {message_data.username} called /start for {message_data.chat_id}')
def is_group_specific(self) -> bool:
return False

View File

@ -0,0 +1,40 @@
from __future__ import annotations
from dataclasses import dataclass
import names
from telegram.ext.callbackcontext import CallbackContext
from telegram.update import Update
from validator.accessValidator import AccessValidator
from validator.groupNameValidator import GroupNameValidator
@dataclass
class InboundMessage:
user_id: str
chat_id: str
group_name: str
username: str
default_group: str = 'default'
@staticmethod
def create(update: Update, context: CallbackContext, group_specific: bool) -> InboundMessage:
user_id = str(update.effective_user.id)
AccessValidator.validate(user_id)
chat_id = str(update.effective_chat.id)
group_name = InboundMessage.default_group
if context.args and context.args[0] and group_specific:
group_name = str(context.args[0]).lower()
GroupNameValidator.validate(group_name)
username = update.effective_user.username or update.effective_user.first_name
if not username:
username = names.get_first_name()
return InboundMessage(user_id, chat_id, group_name, username)

View File

@ -1,45 +0,0 @@
from __future__ import annotations
import re
from dataclasses import dataclass
import names
from entity.group import Group
from exception.invalidArgumentException import InvalidArgumentException
from telegram.ext.callbackcontext import CallbackContext
from telegram.update import Update
@dataclass
class MessageData():
user_id: str
chat_id: str
group_name: str
username: str
@staticmethod
def create_from_arguments(update: Update, context: CallbackContext, include_group: bool = True) -> MessageData:
chat_id = str(update.effective_chat.id)
group_name = Group.default_name
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 group_name == Group.default_name:
raise InvalidArgumentException(re.escape(f'Group can not be `{Group.default_name}`.'))
if len(group_name) > 20:
raise InvalidArgumentException(re.escape(f'Group name length can not be greater than 20.'))
chat_id += f'~{group_name}'
user_id = str(update.effective_user.id)
username = update.effective_user.username or update.effective_user.first_name
if not username:
username = names.get_first_name()
return MessageData(user_id, chat_id, group_name, username)

View File

@ -0,0 +1,31 @@
from typing import Optional
from telegram import InlineKeyboardMarkup, Update
from telegram.utils.helpers import mention_markdown
from bot.message.inboundMessage import InboundMessage
from logger import Logger
class Replier:
@staticmethod
def interpolate(content: str, inbound_message: InboundMessage):
return content.format(
mention_markdown(inbound_message.user_id, inbound_message.username),
inbound_message.group_name
)
@staticmethod
def markdown(update: Update, message: str, reply_markup: Optional[InlineKeyboardMarkup] = None) -> None:
try:
update.effective_message.reply_markdown_v2(message, reply_markup=reply_markup)
except Exception as err:
Logger.error(str(err))
@staticmethod
def html(update: Update, html: str, reply_markup: Optional[InlineKeyboardMarkup] = None) -> None:
try:
update.effective_message.reply_html(html, reply_markup=reply_markup, disable_web_page_preview=True)
except Exception as err:
Logger.error(str(err))

View File

@ -1,3 +1,4 @@
# markdownv2 python-telegram-bot specific
joined = '{} joined group `{}`'
not_joined = '{} is already in group `{}`'
left = '{} left group `{}`'
@ -5,28 +6,36 @@ not_left = '{} did not join group `{}` before'
mention_failed = 'There are no users to mention'
no_groups = 'There are no groups for this chat'
# html python-telegram-bot specific
start_text = """
Hello there
I am @everyone\_mention\_bot
I am here to help you with mass notifies
Hello!
@everyone_mention_bot here.
Please take a look at available commands
`<group-name>` is not required, if not given, it is set to `default`
<b>Description</b>:
I <b>do not</b> have access to your messages!
I am here to help you with multiple user mentions.
To join group:
`/join <group-name>`
for example: `/join games`
<b>Usage</b>:
Users that joined the group by <code>/join</code> command, can be mentioned after calling <code>/everyone</code> command.
To leave group:
`/leave <group-name>`
<b>Commands</b>:
<pre>/join {group-name}</pre>
Joins (or creates if group did not exist before) group.
To gather everyone attention use:
`/everyone <group-name>`
<pre>/leave {group-name}</pre>
Leaves (or deletes if no other users are left) the group
To see all available groups use:
`/groups`
<pre>/everyone {group-name}</pre>
Mentions everyone that joined the group.
To display all members in a group:
`/silent <group-name>`
<pre>/groups</pre>
Show all created groups in this chat.
<pre>/start</pre>
Show start & help text
<b>Please note</b>
<code>{group-name}</code> is not required, <code>default</code> if not given.
If your chat does have multiple bots <b>I might not receive your command</b> according to <a href="https://core.telegram.org/bots/faq#what-messages-will-my-bot-get">policy of bots with privacy mode enabled</a> - use <code>Inline Mode</code> to avoid this.
"""

View File

@ -1,15 +0,0 @@
import os
from dotenv import load_dotenv
load_dotenv()
BOT_TOKEN = os.environ['BOT_TOKEN']
WEBHOOK_URL = os.environ['WEBHOOK_URL']
PORT = os.environ['PORT']
MONGODB_DATABASE = os.environ['MONGODB_DATABASE']
MONGODB_USERNAME = os.environ['MONGODB_USERNAME']
MONGODB_PASSWORD = os.environ['MONGODB_PASSWORD']
MONGODB_HOSTNAME = os.environ['MONGODB_HOSTNAME']
MONGODB_PORT = os.environ['MONGODB_PORT']

18
src/config/envs.py Executable file
View File

@ -0,0 +1,18 @@
import os
from urllib.parse import quote_plus
from dotenv import load_dotenv
load_dotenv()
BOT_TOKEN = os.environ['BOT_TOKEN']
WEBHOOK_URL = os.environ['WEBHOOK_URL']
PORT = os.environ['PORT']
MONGO_DATABASE = os.environ['MONGODB_DATABASE']
MONGO_CONNECTION_STRING = "mongodb://%s:%s@%s:%s/%s?authSource=admin" % (
os.environ['MONGODB_USERNAME'], quote_plus(os.environ['MONGODB_PASSWORD']),
os.environ['MONGODB_HOSTNAME'], os.environ['MONGODB_PORT'], MONGO_DATABASE
)
BANNED_USERS = os.environ['BANNED_USERS'].split(',') or []

View File

@ -1,24 +1,25 @@
from urllib.parse import quote_plus
from config.credentials import (MONGODB_DATABASE, MONGODB_HOSTNAME,
MONGODB_PASSWORD, MONGODB_PORT,
MONGODB_USERNAME)
from __future__ import annotations
from pymongo import MongoClient
from pymongo.database import Database
from config.envs import MONGO_CONNECTION_STRING, MONGO_DATABASE
from decorators.singleton import Singleton
class Client():
class Client(metaclass=Singleton):
mongo_client: 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
)
# allow only 10 minutes on idle, close connection after
max_idle_time: int = 10 * (60 * 1000)
self.mongo_client = MongoClient(uri)
self.database = self.mongo_client[MONGODB_DATABASE]
def __init__(self) -> None:
self.mongo_client = MongoClient(
MONGO_CONNECTION_STRING,
connect=False,
maxIdleTimeMS=self.max_idle_time
)
self.database = self.mongo_client[MONGO_DATABASE]
def insert_one(self, collection: str, data: dict) -> None:
self.database.get_collection(collection).insert_one(data)
@ -29,10 +30,11 @@ class Client():
def find_many(self, collection: str, filter: dict) -> dict:
return self.database.get_collection(collection).find(filter)
def update_one(self, collection: str, filter: dict, data: dict) -> None:
def save(self, collection: str, filter: dict, data: dict) -> None:
self.database.get_collection(collection).update_one(
filter,
{ "$set" : data }
{"$set": data},
upsert=True
)
def aggregate(self, collection, pipeline: list):

View File

@ -0,0 +1,8 @@
class Singleton(type):
_instances: dict = {}
def __call__(cls, *args, **kwargs):
if cls not in cls._instances:
cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs)
return cls._instances[cls]

32
src/entity/chat.py Normal file
View File

@ -0,0 +1,32 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Iterable
from bot.message.inboundMessage import InboundMessage
@dataclass
class Chat:
chat_id: str
groups: dict
mongo_chat_id_index: str = '_id'
mongo_groups_index: str = 'groups'
def to_mongo_document(self) -> dict:
return {
self.mongo_chat_id_index: self.chat_id,
self.mongo_groups_index: self.groups
}
@staticmethod
def from_mongo_document(mongo_document: dict) -> Chat:
return Chat(
mongo_document[Chat.mongo_chat_id_index],
mongo_document[Chat.mongo_groups_index]
)
@staticmethod
def from_inbound_message(inbound: InboundMessage) -> Chat:
return Chat(inbound.chat_id, {inbound.group_name: []})

View File

@ -1,12 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass
class Group():
chat_id: str
group_name: str
users_count: int
default_name: str = 'default'

View File

@ -1,40 +1,31 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Iterable
from bot.message.inboundMessage import InboundMessage
@dataclass
class User():
class User:
user_id: str
username: str
chats: Iterable[str]
collection: str = 'users'
id_index: str = '_id'
chats_index: str = 'chats'
username_index: str = 'username'
def is_in_chat(self, chat_id: str) -> bool:
return chat_id in self.chats
def add_to_chat(self, chat_id: str) -> None:
self.chats.append(chat_id)
def remove_from_chat(self, chat_id: str) -> None:
if chat_id in self.chats:
self.chats.remove(chat_id)
mongo_user_id_index: str = '_id'
mongo_username_index: str = 'username'
def to_mongo_document(self) -> dict:
return {
self.username_index: self.username,
self.chats_index: self.chats
self.mongo_user_id_index: self.user_id,
self.mongo_username_index: self.username
}
@staticmethod
def from_mongo_document(mongo_document: dict) -> User:
return User(
mongo_document[User.id_index],
mongo_document[User.username_index],
mongo_document[User.chats_index]
mongo_document[User.mongo_user_id_index],
mongo_document[User.mongo_username_index]
)
@staticmethod
def from_inbound_message(inbound: InboundMessage) -> User:
return User(inbound.user_id, inbound.username)

View File

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

View File

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

View File

@ -3,7 +3,10 @@ from __future__ import annotations
import logging
import os
from bot.message.inboundMessage import InboundMessage
# noinspection SpellCheckingInspection
class Logger:
action_logger: str = 'action-logger'
action_logger_file: str = '/var/log/bot/action.log'
@ -11,9 +14,7 @@ class Logger:
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:
def __init__(self):
self.configure(self.action_logger, self.action_logger_file, logging.INFO)
self.configure(self.main_logger, self.main_logger_file, logging.ERROR)
@ -23,21 +24,38 @@ class Logger:
os.makedirs(directory)
logger = logging.getLogger(logger_name)
logger.propagate = False
file_handler = logging.FileHandler(log_file, mode='w')
file_handler.setFormatter(self.formatter)
formatter = logging.Formatter('%(asctime)s [%(levelname)s]: %(message)s', datefmt='%H:%M:%S %Y/%m/%d')
file_handler.setFormatter(formatter)
stream_handler = logging.StreamHandler()
stream_handler.setFormatter(self.formatter)
stream_handler.setFormatter(formatter)
logger.setLevel(level)
logger.addHandler(file_handler)
logger.addHandler(stream_handler)
@staticmethod
def get_logger(logger_name) -> logging.Logger:
def register() -> None:
Logger()
@staticmethod
def get(logger_name: str) -> logging.Logger:
return logging.getLogger(logger_name)
@staticmethod
def info(message: str) -> None:
Logger.get_logger(Logger.action_logger).info(message)
Logger.get(Logger.action_logger).info(message)
@staticmethod
def error(message: str) -> None:
Logger.get_logger(Logger.main_logger).error(message)
Logger.get(Logger.main_logger).error(message)
@staticmethod
def exception(exception: Exception) -> None:
Logger.get(Logger.main_logger).exception(exception)
@staticmethod
def action(inbound: InboundMessage, action: str) -> None:
Logger.info(f'User {inbound.username}({inbound.user_id}) called {action.upper()} for {inbound.chat_id}({inbound.group_name})')

View File

@ -0,0 +1,9 @@
from database.client import Client
class AbstractRepository:
collection_name: str
database_client: Client
def __init__(self):
self.database_client = Client()

View File

@ -0,0 +1,55 @@
from typing import Iterable
from bot.message.inboundMessage import InboundMessage
from entity.chat import Chat
from entity.user import User
from exception.notFoundException import NotFoundException
from repository.abstractRepository import AbstractRepository
from repository.userRepository import UserRepository
class ChatRepository(AbstractRepository):
collection_name: str = 'chats'
user_repository: UserRepository
def __init__(self):
super().__init__()
self.user_repository = UserRepository()
def provide(self, inbound: InboundMessage) -> Chat:
try:
chat = self.get(inbound.chat_id)
if not chat.groups.get(inbound.group_name):
chat.groups[inbound.group_name] = []
except NotFoundException:
chat = Chat.from_inbound_message(inbound)
return chat
def get(self, chat_id: str) -> Chat:
chat = self.database_client.find_one(
self.collection_name,
{
Chat.mongo_chat_id_index: chat_id
}
)
if not chat:
raise NotFoundException
return Chat.from_mongo_document(chat)
def get_users_for_group(self, chat_id: str, group: str) -> Iterable[User]:
chat = self.get(chat_id)
if not chat.groups.get(group):
raise NotFoundException
return [self.user_repository.get(user_id) for user_id in chat.groups.get(group)]
def save(self, chat: Chat) -> None:
self.database_client.save(
self.collection_name,
{Chat.mongo_chat_id_index: chat.chat_id},
chat.to_mongo_document()
)

View File

@ -1,53 +0,0 @@
import itertools
import re
from typing import Iterable
from database.client import Client
from entity.group import Group
from entity.user import User
class GroupRepository():
client: Client
count: str = 'count'
def __init__(self) -> None:
self.client = Client()
def get_by_chat_id(self, chat_id: str) -> Iterable[Group]:
groups = self.client.aggregate(
User.collection,
[
{ "$unwind": f'${User.chats_index}' },
{
"$match": {
User.chats_index: { "$regex": re.compile(f'^{chat_id}.*$') },
},
},
{
"$group": {
"_id": {
"$last": { "$split": [f'${User.chats_index}', "~"] },
},
self.count: { "$count": {} },
},
},
{
"$sort": { '_id': 1 }
}
]
)
result = []
for group in groups:
group_name = group['_id']
if group_name == chat_id:
group_name = Group.default_name
result.append(
Group(chat_id, group_name, group[self.count])
)
return result

View File

@ -1,63 +1,43 @@
from typing import Iterable, Optional
from bot.message.messageData import MessageData
from database.client import Client
from bot.message.inboundMessage import InboundMessage
from entity.user import User
from exception.notFoundException import NotFoundException
from repository.abstractRepository import AbstractRepository
class UserRepository():
client: Client
class UserRepository(AbstractRepository):
collection_name: str = 'users'
def __init__(self) -> None:
self.client = Client()
def __init__(self):
super().__init__()
def get_by_id(self, id: str) -> User:
user = self.client.find_one(
User.collection,
def provide(self, inbound: InboundMessage) -> User:
user = User.from_inbound_message(inbound)
try:
entity = self.get(user.user_id)
if entity != user:
self.save(user)
except NotFoundException:
self.save(user)
return user
def get(self, user_id: str) -> User:
user = self.database_client.find_one(
self.collection_name,
{
User.id_index: id
User.mongo_user_id_index: user_id
}
)
if not user:
raise NotFoundException(f'Could not find user with "{id}" id')
raise NotFoundException
return User(
user[User.id_index],
user[User.username_index],
user[User.chats_index]
)
return User.from_mongo_document(user)
def save(self, user: User) -> None:
self.client.update_one(
User.collection,
{ User.id_index: user.user_id },
self.database_client.save(
self.collection_name,
{User.mongo_user_id_index: user.user_id},
user.to_mongo_document()
)
def save_by_message_data(self, data: MessageData) -> None:
self.client.insert_one(
User.collection,
{
User.id_index: data.user_id,
User.username_index: data.username,
User.chats_index: [data.chat_id]
}
)
def get_all_for_chat(self, chat_id: str) -> Iterable[User]:
result = []
users = self.client.find_many(
User.collection,
{
User.chats_index: {
"$in" : [chat_id]
}
}
)
for record in users:
result.append(User.from_mongo_document(record))
return result

View File

@ -0,0 +1,21 @@
from typing import Iterable
from prettytable import prettytable
from telegram.utils.helpers import mention_markdown
from entity.user import User
class MessageBuilder:
@staticmethod
def group_message(groups: dict) -> str:
table = prettytable.PrettyTable(['Name', 'Members'])
for group in groups:
table.add_row([group, len(groups[group])])
return f'<pre>{str(table)}</pre>'
@staticmethod
def mention_message(users: Iterable[User]) -> str:
return ' '.join([mention_markdown(user.user_id, user.username) for user in users])

View File

@ -0,0 +1,10 @@
from config.envs import BANNED_USERS
from exception.actionNotAllowedException import ActionNotAllowedException
class AccessValidator:
@staticmethod
def validate(user_id: str) -> None:
if user_id in BANNED_USERS:
raise ActionNotAllowedException('User is banned')

View File

@ -0,0 +1,17 @@
import re
from exception.invalidArgumentException import InvalidArgumentException
class GroupNameValidator:
MAX_GROUP_NAME_LENGTH: int = 40
@staticmethod
def validate(group: str) -> None:
group = group.lower()
if len(group) > 0 and not re.match('^\w+$', group):
raise InvalidArgumentException(re.escape('Special characters are not allowed.'))
if len(group) > GroupNameValidator.MAX_GROUP_NAME_LENGTH:
raise InvalidArgumentException(re.escape(f'Group name length can not be greater than {GroupNameValidator.MAX_GROUP_NAME_LENGTH}.'))