12
CHANGELOG.md
@ -1,6 +1,18 @@
|
|||||||
# Change Log
|
# Change Log
|
||||||
All notable changes to this project will be documented in this file.
|
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
|
## [0.1.0] - 06.10.2021
|
||||||
### Features
|
### Features
|
||||||
- `/join` command
|
- `/join` command
|
||||||
|
137
README.md
@ -5,23 +5,97 @@
|
|||||||
# Contents
|
# Contents
|
||||||
|
|
||||||
* [Description](#description)
|
* [Description](#description)
|
||||||
* [Getting started.](#getting-started)
|
|
||||||
* [Requirements](#requirements)
|
|
||||||
* [Installation](#installation)
|
|
||||||
* [Logs](#logs)
|
|
||||||
* [Env files](#env-files)
|
|
||||||
* [Commands](#commands)
|
* [Commands](#commands)
|
||||||
* [`/join`](#join)
|
* [`/join`](#join)
|
||||||
* [`/leave`](#leave)
|
* [`/leave`](#leave)
|
||||||
* [`/everyone`](#everyone)
|
* [`/everyone`](#everyone)
|
||||||
* [`/groups`](#groups)
|
* [`/groups`](#groups)
|
||||||
* [`/silent`](#silent)
|
|
||||||
* [`/start`](#start)
|
* [`/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
|
## Description
|
||||||
Everyone Mention Bot is simple, but useful telegram bot to gather group members attention.
|
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.
|
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
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
with group name
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### `/leave`
|
||||||
|
Leaves the group (and deletes if no members are left).
|
||||||
|
```
|
||||||
|
/leave {group_name}
|
||||||
|
```
|
||||||
|
*Examples*
|
||||||
|
|
||||||
|
without group name
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
with group name
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### `/everyone`
|
||||||
|
Mention everyone that joined requested group.
|
||||||
|
```
|
||||||
|
/everyone {group-name}
|
||||||
|
```
|
||||||
|
|
||||||
|
*Examples*
|
||||||
|
|
||||||
|
without group name
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
with group name
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|
### `/groups`
|
||||||
|
Will display available groups for this chat as well with members count.
|
||||||
|
|
||||||
|

|
||||||
|
### `/start`
|
||||||
|
Start & Help message
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Example command flow
|
||||||
|

|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Type in your group name (you can leave it blank for `default` group name), then click on one of the three following options.
|
||||||
|
|
||||||
|

|
||||||
## Getting started
|
## Getting started
|
||||||
### Requirements
|
### Requirements
|
||||||
- `docker-compose` in version `1.25.0`
|
- `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
|
- copy environment files and fulfill empty values
|
||||||
```bash
|
```bash
|
||||||
cp .env.local .env
|
cp .env.local .env
|
||||||
cp docker/config/app.dist.env docker/config/app.env
|
cp docker/config/app.env.local docker/config/app.env
|
||||||
cp docker/config/database.dist.env 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)
|
- start the project (`-d` flag will run containers in detached mode)
|
||||||
```bash
|
```bash
|
||||||
@ -62,6 +136,7 @@ docker/logs <container>
|
|||||||
- `MONGODB_PASSWORD` - MongoDB password
|
- `MONGODB_PASSWORD` - MongoDB password
|
||||||
- `MONGODB_HOSTNAME` - MongoDB host (default `database` - container name)
|
- `MONGODB_HOSTNAME` - MongoDB host (default `database` - container name)
|
||||||
- `MONGODB_PORT` - MongoDB port (default `27017` - given in docker-compose configuration)
|
- `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`
|
- `database.env`
|
||||||
- `MONGO_INITDB_ROOT_USERNAME` - conf from `app.env`
|
- `MONGO_INITDB_ROOT_USERNAME` - conf from `app.env`
|
||||||
@ -69,49 +144,3 @@ docker/logs <container>
|
|||||||
- `MONGO_INITDB_DATABASE` - conf from `app.env`
|
- `MONGO_INITDB_DATABASE` - conf from `app.env`
|
||||||
- `MONGODB_DATA_DIR` - directory to store MongoDB documents (inside a container)
|
- `MONGODB_DATA_DIR` - directory to store MongoDB documents (inside a container)
|
||||||
- `MONDODB_LOG_DIR` - path to logs storage
|
- `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)
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
### `/leave`
|
|
||||||
```
|
|
||||||
/leave <group_name>
|
|
||||||
```
|
|
||||||
|
|
||||||
Leaves the group given in message (`default` if not given)
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
### `/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
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
### `/groups`
|
|
||||||
Will display available groups for this chat as well with members count.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
### `/silent`
|
|
||||||
```
|
|
||||||
/silent <group_name>
|
|
||||||
```
|
|
||||||
|
|
||||||
Will display all every member of given group (`default` if not given) but without notyfing them.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
### `/start`
|
|
||||||
Start & Help message
|
|
||||||
|
|
||||||

|
|
||||||
|
@ -22,7 +22,6 @@ services:
|
|||||||
- ./docker/config/app.env
|
- ./docker/config/app.env
|
||||||
volumes:
|
volumes:
|
||||||
- ./src:/src
|
- ./src:/src
|
||||||
- ./logs:/var/log/bot
|
|
||||||
ports:
|
ports:
|
||||||
- $APP_EXPOSED_PORT:$APP_INTERNAL_PORT
|
- $APP_EXPOSED_PORT:$APP_INTERNAL_PORT
|
||||||
depends_on:
|
depends_on:
|
||||||
|
@ -7,3 +7,5 @@ MONGODB_USERNAME=
|
|||||||
MONGODB_PASSWORD=
|
MONGODB_PASSWORD=
|
||||||
MONGODB_HOSTNAME=localhost
|
MONGODB_HOSTNAME=localhost
|
||||||
MONGODB_PORT=27017
|
MONGODB_PORT=27017
|
||||||
|
|
||||||
|
BANNED_USERS=
|
8
docker/mongosh
Executable 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
|
Before Width: | Height: | Size: 33 KiB |
BIN
docs/everyone_default.png
Normal file
After Width: | Height: | Size: 19 KiB |
BIN
docs/everyone_group.png
Normal file
After Width: | Height: | Size: 20 KiB |
BIN
docs/flow_command.png
Normal file
After Width: | Height: | Size: 62 KiB |
BIN
docs/groups.png
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 16 KiB |
BIN
docs/inline_mode_1.png
Normal file
After Width: | Height: | Size: 14 KiB |
BIN
docs/inline_mode_2.png
Normal file
After Width: | Height: | Size: 13 KiB |
BIN
docs/join.png
Before Width: | Height: | Size: 41 KiB |
BIN
docs/join_default.png
Normal file
After Width: | Height: | Size: 19 KiB |
BIN
docs/join_group.png
Normal file
After Width: | Height: | Size: 20 KiB |
BIN
docs/leave.png
Before Width: | Height: | Size: 32 KiB |
BIN
docs/leave_default.png
Normal file
After Width: | Height: | Size: 19 KiB |
BIN
docs/leave_group.png
Normal file
After Width: | Height: | Size: 20 KiB |
BIN
docs/silent.png
Before Width: | Height: | Size: 27 KiB |
BIN
docs/start.png
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 77 KiB |
26
src/app.py
@ -1,27 +1,21 @@
|
|||||||
from logging import Logger
|
|
||||||
import logging
|
|
||||||
from telegram.ext import Updater
|
from telegram.ext import Updater
|
||||||
from telegram.ext.dispatcher import Dispatcher
|
from telegram.ext.dispatcher import Dispatcher
|
||||||
|
|
||||||
from logger import Logger
|
from bot.handler import *
|
||||||
from config.credentials import BOT_TOKEN, PORT, WEBHOOK_URL
|
|
||||||
from bot.handler import (groupsHandler, joinHandler, mentionHandler, leaveHandler,
|
|
||||||
silentMentionHandler, startHandler)
|
|
||||||
from bot.handler.abstractHandler import AbstractHandler
|
from bot.handler.abstractHandler import AbstractHandler
|
||||||
|
from config.envs import BOT_TOKEN, PORT, WEBHOOK_URL
|
||||||
|
from logger import Logger
|
||||||
|
|
||||||
|
|
||||||
class App:
|
class App:
|
||||||
updater: Updater
|
updater: Updater
|
||||||
dispatcher: Dispatcher
|
dispatcher: Dispatcher
|
||||||
|
|
||||||
log_file: str = '/var/log/bot.log'
|
|
||||||
log_format: str = '%(levelname)s-%(asctime)s: %(message)s'
|
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.updater = Updater(BOT_TOKEN)
|
self.updater = Updater(BOT_TOKEN)
|
||||||
|
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
self.setup_logging()
|
Logger.register()
|
||||||
self.register_handlers()
|
self.register_handlers()
|
||||||
self.register_webhook()
|
self.register_webhook()
|
||||||
|
|
||||||
@ -29,9 +23,7 @@ class App:
|
|||||||
|
|
||||||
def register_handlers(self) -> None:
|
def register_handlers(self) -> None:
|
||||||
for handler in AbstractHandler.__subclasses__():
|
for handler in AbstractHandler.__subclasses__():
|
||||||
self.updater.dispatcher.add_handler(
|
self.updater.dispatcher.add_handler(handler().bot_handler)
|
||||||
handler().get_bot_handler()
|
|
||||||
)
|
|
||||||
|
|
||||||
def register_webhook(self) -> None:
|
def register_webhook(self) -> None:
|
||||||
self.updater.start_webhook(
|
self.updater.start_webhook(
|
||||||
@ -41,15 +33,9 @@ class App:
|
|||||||
webhook_url="/".join([WEBHOOK_URL, BOT_TOKEN])
|
webhook_url="/".join([WEBHOOK_URL, BOT_TOKEN])
|
||||||
)
|
)
|
||||||
|
|
||||||
Logger.get_logger(Logger.action_logger).info(
|
Logger.info(f'Webhook configured, listening on {WEBHOOK_URL}/<bot-token>')
|
||||||
f'Webhook configured, listening on {WEBHOOK_URL}/<bot-token>'
|
|
||||||
)
|
|
||||||
|
|
||||||
def setup_logging(self) -> None:
|
|
||||||
logger = Logger()
|
|
||||||
logger.setup()
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
app = App()
|
app = App()
|
||||||
|
|
||||||
app.run()
|
app.run()
|
||||||
|
5
src/bot/handler/__init__.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
__all__ = [
|
||||||
|
'abstractHandler', 'everyoneHandler', 'groupsHandler',
|
||||||
|
'inlineQueryHandler', 'joinHandler', 'leaveHandler',
|
||||||
|
'startHandler'
|
||||||
|
]
|
@ -1,37 +1,37 @@
|
|||||||
from abc import abstractmethod
|
from abc import abstractmethod
|
||||||
|
|
||||||
from bot.message.messageData import MessageData
|
from telegram.ext import Handler
|
||||||
from logger import Logger
|
|
||||||
from telegram.ext.callbackcontext import CallbackContext
|
from telegram.ext.callbackcontext import CallbackContext
|
||||||
from telegram.ext.handler import Handler
|
|
||||||
from telegram.update import Update
|
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:
|
class AbstractHandler:
|
||||||
@abstractmethod
|
bot_handler: Handler
|
||||||
def get_bot_handler(self) -> Handler: raise Exception('get_bot_handler method is not implemented')
|
inbound: InboundMessage
|
||||||
|
action: str
|
||||||
|
|
||||||
@abstractmethod
|
@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 wrap(self, update: Update, context: CallbackContext) -> None:
|
||||||
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:
|
|
||||||
try:
|
try:
|
||||||
update.effective_message.reply_markdown_v2(text=message)
|
group_specific = self.is_group_specific()
|
||||||
except Exception as err:
|
|
||||||
Logger.error(str(err))
|
|
||||||
|
|
||||||
def reply_html(self, update: Update, html: str) -> None:
|
self.inbound = InboundMessage.create(update, context, group_specific)
|
||||||
try:
|
self.handle(update, context)
|
||||||
update.effective_message.reply_html(text=html)
|
Logger.action(self.inbound, self.action)
|
||||||
except Exception as err:
|
except (InvalidActionException, InvalidArgumentException, ActionNotAllowedException) as e:
|
||||||
Logger.error(str(err))
|
Replier.markdown(update, str(e))
|
||||||
|
except Exception as e:
|
||||||
|
Logger.exception(e)
|
||||||
|
|
||||||
|
def is_group_specific(self) -> bool:
|
||||||
|
return True
|
||||||
|
32
src/bot/handler/everyoneHandler.py
Executable 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
|
@ -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.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 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):
|
class GroupsHandler(AbstractHandler):
|
||||||
bot_handler: CommandHandler
|
bot_handler: CommandHandler
|
||||||
group_repository: GroupRepository
|
chat_repository: ChatRepository
|
||||||
|
action: str = 'groups'
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.bot_handler = CommandHandler('groups', self.handle)
|
self.bot_handler = CommandHandler(self.action, self.wrap)
|
||||||
self.group_repository = GroupRepository()
|
self.chat_repository = ChatRepository()
|
||||||
|
|
||||||
def handle(self, update: Update, context: CallbackContext) -> None:
|
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:
|
def is_group_specific(self) -> bool:
|
||||||
self.reply_html(update, self.build_groups_message(groups))
|
return False
|
||||||
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>'
|
|
||||||
|
51
src/bot/handler/inlineQueryHandler.py
Normal 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)
|
@ -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.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 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):
|
class JoinHandler(AbstractHandler):
|
||||||
bot_handler: CommandHandler
|
bot_handler: CommandHandler
|
||||||
user_repository: UserRepository
|
user_repository: UserRepository
|
||||||
|
action: str = 'join'
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.bot_handler = CommandHandler('join', self.handle)
|
self.bot_handler = CommandHandler(self.action, self.wrap)
|
||||||
self.user_repository = UserRepository()
|
self.user_repository = UserRepository()
|
||||||
|
self.chat_repository = ChatRepository()
|
||||||
|
|
||||||
def handle(self, update: Update, context: CallbackContext) -> None:
|
def handle(self, update: Update, context: CallbackContext) -> None:
|
||||||
try:
|
user = self.user_repository.provide(self.inbound)
|
||||||
message_data = MessageData.create_from_arguments(update, context)
|
chat = self.chat_repository.provide(self.inbound)
|
||||||
except InvalidArgumentException as e:
|
users = chat.groups.get(self.inbound.group_name)
|
||||||
return self.reply_markdown(update, str(e))
|
|
||||||
|
|
||||||
try:
|
if user.user_id in users:
|
||||||
user = self.user_repository.get_by_id(message_data.user_id)
|
raise InvalidActionException(Replier.interpolate(not_joined, self.inbound))
|
||||||
|
|
||||||
if user.is_in_chat(message_data.chat_id):
|
users.append(user.user_id)
|
||||||
return self.reply_markdown(update, self.interpolate_reply(not_joined, message_data))
|
self.chat_repository.save(chat)
|
||||||
|
|
||||||
user.add_to_chat(message_data.chat_id)
|
Replier.markdown(update, Replier.interpolate(joined, self.inbound))
|
||||||
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}')
|
|
||||||
|
@ -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.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 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):
|
class LeaveHandler(AbstractHandler):
|
||||||
bot_handler: CommandHandler
|
bot_handler: CommandHandler
|
||||||
user_repository: UserRepository
|
user_repository: UserRepository
|
||||||
|
chat_repository: ChatRepository
|
||||||
|
action: str = 'leave'
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.bot_handler = CommandHandler('leave', self.handle)
|
self.bot_handler = CommandHandler(self.action, self.wrap)
|
||||||
self.user_repository = UserRepository()
|
self.user_repository = UserRepository()
|
||||||
|
self.chat_repository = ChatRepository()
|
||||||
|
|
||||||
def handle(self, update: Update, context: CallbackContext) -> None:
|
def handle(self, update: Update, context: CallbackContext) -> None:
|
||||||
try:
|
user = self.user_repository.provide(self.inbound)
|
||||||
message_data = MessageData.create_from_arguments(update, context)
|
chat = self.chat_repository.provide(self.inbound)
|
||||||
except InvalidArgumentException as e:
|
group = chat.groups.get(self.inbound.group_name)
|
||||||
return self.reply_markdown(update, str(e))
|
|
||||||
|
|
||||||
try:
|
if user.user_id not in group:
|
||||||
user = self.user_repository.get_by_id(message_data.user_id)
|
raise InvalidActionException(Replier.interpolate(not_left, self.inbound))
|
||||||
|
|
||||||
if not user.is_in_chat(message_data.chat_id):
|
group.remove(user.user_id)
|
||||||
raise NotFoundException()
|
if not group:
|
||||||
except NotFoundException:
|
chat.groups.pop(self.inbound.group_name)
|
||||||
return self.reply_markdown(update, self.interpolate_reply(not_left, message_data))
|
|
||||||
|
|
||||||
user.remove_from_chat(message_data.chat_id)
|
self.chat_repository.save(chat)
|
||||||
self.user_repository.save(user)
|
|
||||||
|
|
||||||
self.reply_markdown(update, self.interpolate_reply(left, message_data))
|
Replier.markdown(update, Replier.interpolate(left, self.inbound))
|
||||||
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}')
|
|
||||||
|
@ -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])
|
|
||||||
|
|
@ -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}')
|
|
@ -1,25 +1,35 @@
|
|||||||
from config.contents import start_text
|
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
|
||||||
from logger import Logger
|
|
||||||
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 bot.handler.abstractHandler import AbstractHandler
|
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):
|
class StartHandler(AbstractHandler):
|
||||||
bot_handler: CommandHandler
|
bot_handler: CommandHandler
|
||||||
|
action: str = 'start'
|
||||||
|
|
||||||
def __init__(self) -> None:
|
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:
|
def handle(self, update: Update, context: CallbackContext) -> None:
|
||||||
self.reply_markdown(update, start_text)
|
markup = InlineKeyboardMarkup(
|
||||||
self.log_action(MessageData.create_from_arguments(update, context))
|
[
|
||||||
|
[
|
||||||
|
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:
|
Replier.html(update, start_text, markup)
|
||||||
return self.bot_handler
|
|
||||||
|
|
||||||
def log_action(self, message_data: MessageData) -> None:
|
def is_group_specific(self) -> bool:
|
||||||
Logger.info(f'User {message_data.username} called /start for {message_data.chat_id}')
|
return False
|
||||||
|
40
src/bot/message/inboundMessage.py
Normal 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)
|
@ -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)
|
|
31
src/bot/message/replier.py
Normal 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))
|
@ -1,3 +1,4 @@
|
|||||||
|
# markdownv2 python-telegram-bot specific
|
||||||
joined = '{} joined group `{}`'
|
joined = '{} joined group `{}`'
|
||||||
not_joined = '{} is already in group `{}`'
|
not_joined = '{} is already in group `{}`'
|
||||||
left = '{} left group `{}`'
|
left = '{} left group `{}`'
|
||||||
@ -5,28 +6,36 @@ not_left = '{} did not join group `{}` before'
|
|||||||
mention_failed = 'There are no users to mention'
|
mention_failed = 'There are no users to mention'
|
||||||
no_groups = 'There are no groups for this chat'
|
no_groups = 'There are no groups for this chat'
|
||||||
|
|
||||||
|
# html python-telegram-bot specific
|
||||||
start_text = """
|
start_text = """
|
||||||
Hello there
|
Hello!
|
||||||
I am @everyone\_mention\_bot
|
@everyone_mention_bot here.
|
||||||
I am here to help you with mass notifies
|
|
||||||
|
|
||||||
Please take a look at available commands
|
<b>Description</b>:
|
||||||
`<group-name>` is not required, if not given, it is set to `default`
|
I <b>do not</b> have access to your messages!
|
||||||
|
I am here to help you with multiple user mentions.
|
||||||
|
|
||||||
To join group:
|
<b>Usage</b>:
|
||||||
`/join <group-name>`
|
Users that joined the group by <code>/join</code> command, can be mentioned after calling <code>/everyone</code> command.
|
||||||
for example: `/join games`
|
|
||||||
|
|
||||||
To leave group:
|
<b>Commands</b>:
|
||||||
`/leave <group-name>`
|
<pre>/join {group-name}</pre>
|
||||||
|
Joins (or creates if group did not exist before) group.
|
||||||
|
|
||||||
To gather everyone attention use:
|
<pre>/leave {group-name}</pre>
|
||||||
`/everyone <group-name>`
|
Leaves (or deletes if no other users are left) the group
|
||||||
|
|
||||||
To see all available groups use:
|
<pre>/everyone {group-name}</pre>
|
||||||
`/groups`
|
Mentions everyone that joined the group.
|
||||||
|
|
||||||
To display all members in a group:
|
<pre>/groups</pre>
|
||||||
`/silent <group-name>`
|
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.
|
||||||
"""
|
"""
|
@ -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
@ -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 []
|
@ -1,24 +1,25 @@
|
|||||||
from urllib.parse import quote_plus
|
from __future__ import annotations
|
||||||
|
|
||||||
from config.credentials import (MONGODB_DATABASE, MONGODB_HOSTNAME,
|
|
||||||
MONGODB_PASSWORD, MONGODB_PORT,
|
|
||||||
MONGODB_USERNAME)
|
|
||||||
from pymongo import MongoClient
|
from pymongo import MongoClient
|
||||||
from pymongo.database import Database
|
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
|
mongo_client: MongoClient
|
||||||
database: Database
|
database: Database
|
||||||
|
|
||||||
def __init__(self) -> None:
|
# allow only 10 minutes on idle, close connection after
|
||||||
uri = "mongodb://%s:%s@%s:%s/%s?authSource=admin" % (
|
max_idle_time: int = 10 * (60 * 1000)
|
||||||
MONGODB_USERNAME, quote_plus(MONGODB_PASSWORD),
|
|
||||||
MONGODB_HOSTNAME, MONGODB_PORT, MONGODB_DATABASE
|
|
||||||
)
|
|
||||||
|
|
||||||
self.mongo_client = MongoClient(uri)
|
def __init__(self) -> None:
|
||||||
self.database = self.mongo_client[MONGODB_DATABASE]
|
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:
|
def insert_one(self, collection: str, data: dict) -> None:
|
||||||
self.database.get_collection(collection).insert_one(data)
|
self.database.get_collection(collection).insert_one(data)
|
||||||
@ -29,10 +30,11 @@ class Client():
|
|||||||
def find_many(self, collection: str, filter: dict) -> dict:
|
def find_many(self, collection: str, filter: dict) -> dict:
|
||||||
return self.database.get_collection(collection).find(filter)
|
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(
|
self.database.get_collection(collection).update_one(
|
||||||
filter,
|
filter,
|
||||||
{ "$set" : data }
|
{"$set": data},
|
||||||
|
upsert=True
|
||||||
)
|
)
|
||||||
|
|
||||||
def aggregate(self, collection, pipeline: list):
|
def aggregate(self, collection, pipeline: list):
|
||||||
|
8
src/decorators/singleton.py
Normal 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
@ -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: []})
|
@ -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'
|
|
@ -1,40 +1,31 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Iterable
|
|
||||||
|
from bot.message.inboundMessage import InboundMessage
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class User():
|
class User:
|
||||||
user_id: str
|
user_id: str
|
||||||
username: str
|
username: str
|
||||||
chats: Iterable[str]
|
|
||||||
|
|
||||||
collection: str = 'users'
|
mongo_user_id_index: str = '_id'
|
||||||
id_index: str = '_id'
|
mongo_username_index: str = 'username'
|
||||||
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)
|
|
||||||
|
|
||||||
def to_mongo_document(self) -> dict:
|
def to_mongo_document(self) -> dict:
|
||||||
return {
|
return {
|
||||||
self.username_index: self.username,
|
self.mongo_user_id_index: self.user_id,
|
||||||
self.chats_index: self.chats
|
self.mongo_username_index: self.username
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_mongo_document(mongo_document: dict) -> User:
|
def from_mongo_document(mongo_document: dict) -> User:
|
||||||
return User(
|
return User(
|
||||||
mongo_document[User.id_index],
|
mongo_document[User.mongo_user_id_index],
|
||||||
mongo_document[User.username_index],
|
mongo_document[User.mongo_username_index]
|
||||||
mongo_document[User.chats_index]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_inbound_message(inbound: InboundMessage) -> User:
|
||||||
|
return User(inbound.user_id, inbound.username)
|
||||||
|
2
src/exception/actionNotAllowedException.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
class ActionNotAllowedException(Exception):
|
||||||
|
pass
|
2
src/exception/invalidActionException.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
class InvalidActionException(Exception):
|
||||||
|
pass
|
@ -3,7 +3,10 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
from bot.message.inboundMessage import InboundMessage
|
||||||
|
|
||||||
|
|
||||||
|
# noinspection SpellCheckingInspection
|
||||||
class Logger:
|
class Logger:
|
||||||
action_logger: str = 'action-logger'
|
action_logger: str = 'action-logger'
|
||||||
action_logger_file: str = '/var/log/bot/action.log'
|
action_logger_file: str = '/var/log/bot/action.log'
|
||||||
@ -11,9 +14,7 @@ class Logger:
|
|||||||
main_logger: str = 'main-logger'
|
main_logger: str = 'main-logger'
|
||||||
main_logger_file: str = '/var/log/bot/app.log'
|
main_logger_file: str = '/var/log/bot/app.log'
|
||||||
|
|
||||||
formatter: str = logging.Formatter('%(asctime)s[%(levelname)s]: %(message)s')
|
def __init__(self):
|
||||||
|
|
||||||
def setup(self) -> None:
|
|
||||||
self.configure(self.action_logger, self.action_logger_file, logging.INFO)
|
self.configure(self.action_logger, self.action_logger_file, logging.INFO)
|
||||||
self.configure(self.main_logger, self.main_logger_file, logging.ERROR)
|
self.configure(self.main_logger, self.main_logger_file, logging.ERROR)
|
||||||
|
|
||||||
@ -23,21 +24,38 @@ class Logger:
|
|||||||
os.makedirs(directory)
|
os.makedirs(directory)
|
||||||
|
|
||||||
logger = logging.getLogger(logger_name)
|
logger = logging.getLogger(logger_name)
|
||||||
|
logger.propagate = False
|
||||||
|
|
||||||
file_handler = logging.FileHandler(log_file, mode='w')
|
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 = logging.StreamHandler()
|
||||||
stream_handler.setFormatter(self.formatter)
|
stream_handler.setFormatter(formatter)
|
||||||
|
|
||||||
logger.setLevel(level)
|
logger.setLevel(level)
|
||||||
logger.addHandler(file_handler)
|
logger.addHandler(file_handler)
|
||||||
logger.addHandler(stream_handler)
|
logger.addHandler(stream_handler)
|
||||||
|
|
||||||
@staticmethod
|
@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)
|
return logging.getLogger(logger_name)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
def info(message: str) -> None:
|
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:
|
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})')
|
||||||
|
9
src/repository/abstractRepository.py
Normal 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()
|
55
src/repository/chatRepository.py
Normal 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()
|
||||||
|
)
|
||||||
|
|
@ -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
|
|
@ -1,63 +1,43 @@
|
|||||||
from typing import Iterable, Optional
|
from bot.message.inboundMessage import InboundMessage
|
||||||
|
|
||||||
from bot.message.messageData import MessageData
|
|
||||||
from database.client import Client
|
|
||||||
from entity.user import User
|
from entity.user import User
|
||||||
from exception.notFoundException import NotFoundException
|
from exception.notFoundException import NotFoundException
|
||||||
|
from repository.abstractRepository import AbstractRepository
|
||||||
|
|
||||||
|
|
||||||
class UserRepository():
|
class UserRepository(AbstractRepository):
|
||||||
client: Client
|
collection_name: str = 'users'
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self):
|
||||||
self.client = Client()
|
super().__init__()
|
||||||
|
|
||||||
def get_by_id(self, id: str) -> User:
|
def provide(self, inbound: InboundMessage) -> User:
|
||||||
user = self.client.find_one(
|
user = User.from_inbound_message(inbound)
|
||||||
User.collection,
|
|
||||||
|
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:
|
if not user:
|
||||||
raise NotFoundException(f'Could not find user with "{id}" id')
|
raise NotFoundException
|
||||||
|
|
||||||
return User(
|
return User.from_mongo_document(user)
|
||||||
user[User.id_index],
|
|
||||||
user[User.username_index],
|
|
||||||
user[User.chats_index]
|
|
||||||
)
|
|
||||||
|
|
||||||
def save(self, user: User) -> None:
|
def save(self, user: User) -> None:
|
||||||
self.client.update_one(
|
self.database_client.save(
|
||||||
User.collection,
|
self.collection_name,
|
||||||
{ User.id_index: user.user_id },
|
{User.mongo_user_id_index: user.user_id},
|
||||||
user.to_mongo_document()
|
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
|
|
||||||
|
21
src/utils/messageBuilder.py
Normal 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])
|
10
src/validator/accessValidator.py
Normal 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')
|
17
src/validator/groupNameValidator.py
Normal 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}.'))
|