Compare commits

...

24 Commits
0.1.0 ... main

Author SHA1 Message Date
miloszowi
44ecc307e3 another fix for reaction 2024-03-05 09:49:35 +01:00
miloszowi
fca6dd6f80 fixed issue when reacting to message triggered dynamic mention 2024-03-05 09:47:47 +01:00
miloszowi
eeb9fe5c3a fixed character escaping in replier 2024-03-05 08:32:48 +00:00
miloszowi
12a1c08866 fixed backslash escape in replier 2024-02-28 13:18:22 +00:00
miloszowi
e44f4b75a5 0.3.2 - fixed a bug with missing + in replier 2024-02-28 11:32:57 +01:00
miloszowi
e242584974 0.3.1 - Fixed telegram restricted characters 2024-02-28 11:27:07 +01:00
miloszowi
878091deae fixed bug when /join@everyone_mention_bot was treated as a message handler action 2023-11-25 14:58:58 +00:00
Miłosz Guglas
a6b441b197
Merge pull request #5 from miloszowi/develop
0.3.0 Release
2022-03-10 16:08:02 +01:00
miloszowi
4140098594 fixed default group mention if there was a @ sign in any command argument 2022-03-10 16:04:38 +01:00
miloszowi
a02c09d19d fixed problem upon editing message with dynamic mentions causing mentioning default group 2021-11-24 14:28:09 +01:00
miloszowi
df374e31ba fixed NoneType upon telegram message edit 2021-11-15 09:20:43 +01:00
miloszowi
48ceab008d fixed commands with bot mention causing invalid group name 2021-11-12 14:20:51 +01:00
miloszowi
568ac15b5e dynamic mentioning group name to lowercase, updated CHANGELOG 2021-11-12 13:15:00 +01:00
miloszowi
6790426ba2 Dynamic mentioning added, removed some unwanted properties, updated README 2021-11-12 12:23:54 +01:00
Miłosz Guglas
f9643f052e Merge pull request #4 from miloszowi/release/0.2.0
0.2.0 Release
2021-10-26 17:55:25 +02:00
miloszowi
6150faeee0 Group name length validation increased, updated contents, readme, changelog 2021-10-26 17:52:51 +02:00
miloszowi
cd0712a07b fixed logging twice same action and added group to log message 2021-10-14 20:40:35 +02:00
miloszowi
e0916441b9 mongodb new structure, added new collection chats 2021-10-14 20:24:58 +02:00
miloszowi
9c8f5795f8 Singleton mongo client, added mongosh easy enter command, better logging, added idle time to mongo connection 2021-10-13 18:40:24 +02:00
miloszowi
ea2fddff40 AbstractHandler wrap method addition, updated start text content and added buttons, changed MessageData to InboundMessage, better logging, code quality improvements, changed env files naming 2021-10-11 17:20:39 +02:00
miloszowi
fb223556cb group name validation changed to accept all alphanumeric characters, fixed no groups reply 2021-10-08 17:35:03 +02:00
miloszowi
b451569ddb removed /silent from start help text 2021-10-08 15:54:17 +02:00
miloszowi
431b004284 Banned users env, access validator, removed silent command, code quality improvements 2021-10-08 15:25:47 +02:00
miloszowi
d05d0c0904 Added inline query handler, updated CHANGELOG, added group name validator 2021-10-07 19:15:53 +02:00
54 changed files with 762 additions and 508 deletions

View File

@ -1,6 +1,32 @@
# Change Log
All notable changes to this project will be documented in this file.
## [0.3.2] - 28.02.2023
### Changed
- fixed bug with missing '+' in replier
## [0.3.1] - 28.02.2023
### Changed
- fixed markdown replier to respect restricted characters provided in the [api docs](https://core.telegram.org/bots/api#markdownv2-style)
## [0.3.0] - 12.11.2021
### Added
- Dynamic mentioning by `@` character
### Changed
- `start` text
- Group name validation - those are forbidden now - `all`, `channel`, `chat`, `everyone`, `group`, `here`
### Deleted
- mentioning user that performed `/everyone` or dynamic mention in bot response
## [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

163
README.md
View File

@ -5,23 +5,121 @@
# Contents
* [Description](#description)
* [Getting started.](#getting-started)
* [Requirements](#requirements)
* [Installation](#installation)
* [Logs](#logs)
* [Env files](#env-files)
* [Usage](#usage)
* [Dynamic Mentioning](#dynamic-mentioning)
* [Commands](#commands)
* [`/join`](#join)
* [`/leave`](#leave)
* [`/everyone`](#everyone)
* [`/groups`](#groups)
* [`/silent`](#silent)
* [`/start`](#start)
* [Example command flow](#example-command-flow)
* [Inline Mode Usage](#inline-mode-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.
## Usage
First, users need to join the group to let mentioning them, to do that, they simply need to join specific group.
It can be done in 2 ways:
- Command [`/join`](#join)
- [Inline Mode](#inline-mode-usage)
Users that have joined the group can be mentioned in 3 ways:
- [Dynaminc Mentioning](#dynamic-mentioning) by `@`, for example `@everyone`
- Command [`/everyone`](#everyone)
- [Inline Mode](#inline-mode-usage)
To leave the group use one of the two ways:
- Command [`/leave`](#leave)
- [Inline Mode](#inline-mode-usage)
To display available groups:
- Command [`/groups`](#groups)
## Dynamic mentioning
You can use `@` character (as you would mention a user) to mention specific group.
All the below will mention users from `default` group.
`@all`, `@channel`, `@chat`, `@everyone`, `@group`, `@here`.
If you did create a group named `gaming`, you can simply use `@gaming` in your text to mention them all.
## 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 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 +133,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,56 +160,11 @@ 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`
- `MONGO_INITDB_ROOT_PASSWORD` - conf from `app.env`
- `MONGO_INITDB_DATABASE` - conf from `app.env`
- `MONGODB_DATA_DIR` - directory to store MongoDB documents (inside a container)
- `MONDODB_LOG_DIR` - 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)
- `MONDODB_LOG_DIR` - path to logs storage

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

@ -6,4 +6,6 @@ MONGODB_DATABASE=
MONGODB_USERNAME=
MONGODB_PASSWORD=
MONGODB_HOSTNAME=localhost
MONGODB_PORT=27017
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', 'dynamicMentionHandler'
]

View File

@ -1,37 +1,40 @@
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 exception.notFoundException import NotFoundException
from logger import Logger
class AbstractHandler:
@abstractmethod
def get_bot_handler(self) -> Handler: raise Exception('get_bot_handler method is not implemented')
class AbstractHandler:
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 NotFoundException:
pass # probably just mentioning user
except Exception as e:
Logger.exception(e)
def is_group_specific(self) -> bool:
return True

View File

@ -0,0 +1,31 @@
import re
from telegram.ext import Filters, MessageHandler
from telegram.ext.callbackcontext import CallbackContext
from telegram.update import Update
from bot.handler.abstractHandler import AbstractHandler
from bot.message.replier import Replier
from repository.chatRepository import ChatRepository
from utils.messageBuilder import MessageBuilder
class DynamicMentionHandler(AbstractHandler):
bot_handler: MessageHandler
chat_repository: ChatRepository
action: str = 'dynamic-mention'
def __init__(self) -> None:
self.bot_handler = MessageHandler(
Filters.regex(re.compile(r'@[^ ]')),
self.wrap
)
self.chat_repository = ChatRepository()
def handle(self, update: Update, context: CallbackContext) -> None:
if hasattr(update, 'message_reaction'):
return
users = self.chat_repository.get_users_for_group(self.inbound)
Replier.markdown(update, MessageBuilder.mention_message(users))

View File

@ -0,0 +1,29 @@
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 utils.messageBuilder import MessageBuilder
class EveryoneHandler(AbstractHandler):
bot_handler: CommandHandler
chat_repository: ChatRepository
action: str = 'everyone'
def __init__(self) -> None:
self.bot_handler = CommandHandler(self.action, self.wrap)
self.chat_repository = ChatRepository()
def handle(self, update: Update, context: CallbackContext) -> None:
try:
users = self.chat_repository.get_users_for_group(self.inbound)
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,53 @@
from __future__ import annotations
from dataclasses import dataclass
import names
import re
from telegram.ext.callbackcontext import CallbackContext
from telegram.update import Update
from exception.invalidActionException import InvalidActionException
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)
message_content = update.edited_message.text if update.edited_message else update.message.text
message_content = message_content.replace("\n", " ")
chat_id = str(update.effective_chat.id)
group_name = InboundMessage.default_group
# done upon resolving a command action
if context.args and context.args[0] and group_specific:
group_name = str(context.args[0]).lower()
GroupNameValidator.validate(group_name)
# done upon resolving a message handler action
if '@' in message_content and '@everyone_mention_bot' not in message_content:
searched_message_part = [part for part in message_content.split(' ') if '@' in part][0]
group_name = re.sub(r'\W+', '', searched_message_part).lower()
if group_name in GroupNameValidator.FORBIDDEN_GROUP_NAMES:
group_name = InboundMessage.default_group
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,37 @@
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):
formatted = content.format(
mention_markdown(inbound_message.user_id, inbound_message.username),
inbound_message.group_name
)
telegramRestrictionCharacters = ['_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!']
for character in telegramRestrictionCharacters:
formatted.replace(character, r'\{character}')
return formatted
@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("replier.markdown 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("replier.html 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,37 @@ 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.
I am here to help you with multiple user mentions.
Please take a look at available commands
`<group-name>` is not required, if not given, it is set to `default`
<b>Usage</b>:
Users that joined the group by <code>/join</code> command,
can be mentioned after typing one of those in your message:
<code>@all</code>, <code>@channel</code>, <code>@chat</code>, <code>@everyone</code>, <code>@group</code> or <code>@here</code>.
To join group:
`/join <group-name>`
for example: `/join games`
If you did create a group named <code>gaming</code>, simply use <code>@gaming</code> to call users from that group.
To leave group:
`/leave <group-name>`
You can also use <code>/everyone</code> command.
To gather everyone attention use:
`/everyone <group-name>`
<b>Commands</b>:
<pre>/join {group-name}</pre>
Joins (or creates if group did not exist before) group.
To see all available groups use:
`/groups`
<pre>/leave {group-name}</pre>
Leaves (or deletes if no other users are left) the group
To display all members in a group:
`/silent <group-name>`
"""
<pre>/everyone {group-name}</pre>
Mentions everyone that joined the group.
<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.
"""

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,60 @@
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, inbound: InboundMessage) -> Iterable[User]:
chat = self.get(inbound.chat_id)
if not chat.groups.get(inbound.group_name):
raise NotFoundException
users = [self.user_repository.get(user_id) for user_id in chat.groups.get(inbound.group_name) if user_id != inbound.user_id]
if not users:
raise NotFoundException
return users
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.from_mongo_document(user)
return User(
user[User.id_index],
user[User.username_index],
user[User.chats_index]
)
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,21 @@
import re
from exception.invalidArgumentException import InvalidArgumentException
class GroupNameValidator:
MAX_GROUP_NAME_LENGTH: int = 40
FORBIDDEN_GROUP_NAMES = ['all', 'channel', 'chat', 'everyone', 'group', 'here']
@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}.'))
if group in GroupNameValidator.FORBIDDEN_GROUP_NAMES:
raise InvalidArgumentException(re.escape(f'This group name is forbidden, please try with other name.'))