diff --git a/.gitignore b/.gitignore index cc3fcee..c0eb429 100644 --- a/.gitignore +++ b/.gitignore @@ -104,7 +104,7 @@ ipython_config.py # This is especially recommended for binary packages to ensure reproducibility, and is more # commonly ignored for libraries. # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock +poetry.lock # pdm # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. @@ -223,4 +223,8 @@ $RECYCLE.BIN/ # Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option) -.space +typings/ +poetry.lock +develop/ +/storage/* +*.zip diff --git a/.spaceignore b/.spaceignore deleted file mode 100644 index 6fdd640..0000000 --- a/.spaceignore +++ /dev/null @@ -1 +0,0 @@ -.mypy_cache \ No newline at end of file diff --git a/BotMicro/.gitignore b/BotMicro/.gitignore deleted file mode 100644 index 7fd4f16..0000000 --- a/BotMicro/.gitignore +++ /dev/null @@ -1,228 +0,0 @@ -# File created using '.gitignore Generator' for Visual Studio Code: https://bit.ly/vscode-gig -# Created by https://www.toptal.com/developers/gitignore/api/windows,visualstudiocode,python -# Edit at https://www.toptal.com/developers/gitignore?templates=windows,visualstudiocode,python - -### Python ### -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -poetry.lock - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/#use-with-ide -.pdm.toml - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ - -### Python Patch ### -# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration -poetry.toml - -# ruff -.ruff_cache/ - -# LSP config files -pyrightconfig.json - -### VisualStudioCode ### -.vscode/* -!.vscode/settings.json -!.vscode/tasks.json -!.vscode/launch.json -!.vscode/extensions.json -!.vscode/*.code-snippets - -# Local History for Visual Studio Code -.history/ - -# Built Visual Studio Code Extensions -*.vsix - -### VisualStudioCode Patch ### -# Ignore all local history of files -.history -.ionide - -### Windows ### -# Windows thumbnail cache files -Thumbs.db -Thumbs.db:encryptable -ehthumbs.db -ehthumbs_vista.db - -# Dump file -*.stackdump - -# Folder config file -[Dd]esktop.ini - -# Recycle Bin used on file shares -$RECYCLE.BIN/ - -# Windows Installer files -*.cab -*.msi -*.msix -*.msm -*.msp - -# Windows shortcuts -*.lnk - -# End of https://www.toptal.com/developers/gitignore/api/windows,visualstudiocode,python - -# Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option) - -typings/ -poetry.lock -develop/ diff --git a/BotMicro/analysis/__init__.py b/BotMicro/analysis/__init__.py deleted file mode 100644 index 9a4d344..0000000 --- a/BotMicro/analysis/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .checking import check_text - -__all__ = ['check_text'] \ No newline at end of file diff --git a/BotMicro/bot/factory.py b/BotMicro/bot/factory.py deleted file mode 100644 index 98e23c9..0000000 --- a/BotMicro/bot/factory.py +++ /dev/null @@ -1,52 +0,0 @@ -import asyncio -from os import getenv - -from aiogram import Bot, Dispatcher -from aiogram.enums.update_type import UpdateType -from aiogram.utils.callback_answer import CallbackAnswerMiddleware -from aiogram_deta.storage import DefaultKeyBuilder, DetaStorage -from bot.handlers import root_router as root_router -from bot.middlewares.callback_message import CallbackMessageMiddleware -from bot.middlewares.logging import LoggingMiddleware -from deta import Deta - - -def get_webhook_secret() -> str: - return getenv('DETA_SPACE_APP_MICRO_NAME', '') + getenv('DETA_PROJECT_KEY', '')[:4] - - -def create_bot(token: str) -> tuple[Bot, str]: - bot = Bot(token, parse_mode='HTML') - - webhook_url = getenv('DETA_SPACE_APP_HOSTNAME', '') + '/webhook' - webhook_secret = get_webhook_secret() - - loop = asyncio.get_event_loop() - loop.run_until_complete(bot.set_webhook( - url=webhook_url, - secret_token=webhook_secret, - allowed_updates=[item.value for item in UpdateType] - )) - - return bot, webhook_secret - - -def create_dispatcher(deta: Deta) -> Dispatcher: - base = deta.AsyncBase('fsm') # type: ignore - storage = DetaStorage(base, DefaultKeyBuilder( - with_destiny=True)) # type: ignore - - dispatcher = Dispatcher(storage=storage) - - dispatcher.include_router(root_router) - dispatcher.callback_query.middleware(CallbackMessageMiddleware()) - dispatcher.callback_query.middleware(CallbackAnswerMiddleware()) - - # if getenv('ENABLE_EVENTS_LOGS') == 'True': - # if getenv('LOGS_EXPIRE_IN') is not None: - # expire_in = int(getenv('LOGS_EXPIRE_IN')) - # dispatcher.update.middleware(LoggingMiddleware(expire_in)) - # else: - # dispatcher.update.middleware(LoggingMiddleware()) - - return dispatcher diff --git a/BotMicro/bot/handlers/__init__.py b/BotMicro/bot/handlers/__init__.py deleted file mode 100644 index 3afeeda..0000000 --- a/BotMicro/bot/handlers/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -from aiogram import Router - -from .error import router as error_router -from .groups import router as groups_router -from .private import router as private_router - -root_router = Router() -root_router.include_router(private_router) -root_router.include_router(groups_router) -root_router.include_router(error_router) diff --git a/BotMicro/bot/handlers/error.py b/BotMicro/bot/handlers/error.py deleted file mode 100644 index 1708db8..0000000 --- a/BotMicro/bot/handlers/error.py +++ /dev/null @@ -1,19 +0,0 @@ -from datetime import datetime - -from aiogram import Router -from aiogram.types.error_event import ErrorEvent -from deta import Base - -router = Router() - - -@router.errors() -async def errors_handler(event: ErrorEvent): - time = datetime.now() - - logging_base = Base('logs') - logging_base.put( - key=str(2 * 10**9 - time.timestamp()), - data={'time': time.isoformat(), 'update': event.update.json(), 'exception': repr(event.exception)}, - expire_in=60 * 60 * 2 # expire in two hours - ) diff --git a/BotMicro/bot/handlers/groups/__init__.py b/BotMicro/bot/handlers/groups/__init__.py deleted file mode 100644 index b73c2bc..0000000 --- a/BotMicro/bot/handlers/groups/__init__.py +++ /dev/null @@ -1,32 +0,0 @@ -from typing import Any, Awaitable, Callable, Dict - -from aiogram import Router -from aiogram.types import Message - -from bot.middlewares.active_group import ActiveGroupMiddleware - -from .group_message import router as group_message_router -from .new_group import router as new_group_router -from .new_member import router as new_member_router - -router = Router() -router.include_router(group_message_router) -router.include_router(new_group_router) -router.include_router(new_member_router) - -router.message.middleware(ActiveGroupMiddleware()) -router.edited_message.middleware(ActiveGroupMiddleware()) -router.chat_member.middleware(ActiveGroupMiddleware()) - - -@router.message.middleware() -async def group_chat_middleware( - handler: Callable[[Message, Dict[str, Any]], Awaitable[Any]], - event: Message, - data: Dict[str, Any] -) -> Any: - if event.from_user and event.from_user.full_name == 'Telegram': - return - - if event.chat.type in ('group', 'supergroup'): - return await handler(event, data) diff --git a/BotMicro/bot/handlers/groups/group_message.py b/BotMicro/bot/handlers/groups/group_message.py deleted file mode 100644 index 1f1968e..0000000 --- a/BotMicro/bot/handlers/groups/group_message.py +++ /dev/null @@ -1,93 +0,0 @@ -from os import getenv - -from aiogram import Bot, F, Router -from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, Message -from odetam.exceptions import ItemNotFound - -from analysis.checking import (check_full_words, check_partial_words, - check_profanity, check_regexps, check_substitution) -from analysis.normilize import get_normalized_text, remove_stop_words - -from bot.messages import PROFANITY_EVENT -from bot.utils.events import message_delete_event, profanity_filter_event -from bot.utils.spread import SendMessage, forward_messages, spread_messages -from bot.utils.message import get_full_text -from models import Dictionary, Group -from models.chat import Chat -from models.member import Member -from utils.logging import log - -router = Router() - - -@router.edited_message(F.from_user.is_bot == False) -@router.message(F.from_user.is_bot == False) -async def group_message_handler(message: Message, bot: Bot, group: Group) -> None: - member = await Member.get_or_none(str(message.from_user.id)) - if not member: - member = Member( - key=str(message.from_user.id), - strikes_count={group.key: 0} - ) - - member.messages_count.setdefault(group.key, 0) - member.messages_count[group.key] += 1 - await member.save() # type: ignore - - messages_threshold = int(getenv('MESSAGES_THRESHOLD', 5)) - if member.messages_count.get(group.key, 0) >= messages_threshold: - return {'messages_count': member.messages_count.get(group.key, 0)} - - - dictionary: Dictionary = await Dictionary.get(group.key) - - text = get_full_text(message) - text = get_normalized_text(text) - text = remove_stop_words(text, dictionary.stop_words) - - substitution_result = check_substitution(text) - if substitution_result: - await message_delete_event( - group, - member, - message, - f'замена букв в слове "{substitution_result}"', - bot, - ) - return {'result': substitution_result} - - full_check_result = check_full_words(text, dictionary.full_words) - if full_check_result: - await message_delete_event( - group, member, message, f'слово "{full_check_result}"', bot) - return {'result': full_check_result} - - partial_search_result = check_partial_words(text, dictionary.partial_words) - if partial_search_result: - word, part = partial_search_result - if part: - reason = f'часть "{part}" в слове "{word}"' - else: - reason = f'слово "{part}"' - - await message_delete_event( - group, member, message, reason, bot) - - return {'result': partial_search_result} - - regex_search_result = check_regexps(text, dictionary.regex_patterns) - if regex_search_result: - word, pattern = regex_search_result - await message_delete_event( - group, member, message, f'шаблон "{pattern}" в слове "{word}"', bot) - - return {'result': regex_search_result} - - profanity_check_result = check_profanity(text) - if profanity_check_result: - await profanity_filter_event( - group, member, message, profanity_check_result, bot) - - return {'result': profanity_check_result} - - return {'result': 'nothing found'} diff --git a/BotMicro/bot/handlers/groups/new_group.py b/BotMicro/bot/handlers/groups/new_group.py deleted file mode 100644 index 0f339a2..0000000 --- a/BotMicro/bot/handlers/groups/new_group.py +++ /dev/null @@ -1,29 +0,0 @@ -from aiogram import Router, F -from aiogram.filters import ChatMemberUpdatedFilter, IS_ADMIN -from aiogram.types import ChatMemberUpdated -from bot.handlers.private import ignored_users - -from models import Group, Dictionary - - -router = Router() - - -@router.my_chat_member(ChatMemberUpdatedFilter(IS_ADMIN)) -async def new_group_handler(event: ChatMemberUpdated): - group = Group( - key=str(event.chat.id), - title=event.chat.title or '', - active=True , - strike_mode=True, - strike_limit=3, - ignored_users=[] - ) - await group.save() - - # history = History(key=group.key, events=[]) - # await history.save() - - dictionary: Dictionary = await Dictionary.get('default') - dictionary.key = group.key - await dictionary.save() \ No newline at end of file diff --git a/BotMicro/bot/handlers/private/__init__.py b/BotMicro/bot/handlers/private/__init__.py deleted file mode 100644 index 5ecf927..0000000 --- a/BotMicro/bot/handlers/private/__init__.py +++ /dev/null @@ -1,33 +0,0 @@ -from typing import Any, Awaitable, Callable, Dict - -from aiogram import Router -from aiogram.types import Message - -from .edit_words import router as edit_words_router -from .event_message import router as event_message_router -from .groups import router as groups_router -from .ignored_users import router as ignored_users_router -from .list_words import router as list_words_router -from .start import router as start_router -from .strike_mode import router as strike_mode_router -from .profanity_filter import router as profanity_filter_router - -router = Router() -router.include_router(start_router) -router.include_router(groups_router) -router.include_router(edit_words_router) -router.include_router(list_words_router) -router.include_router(strike_mode_router) -router.include_router(ignored_users_router) -router.include_router(event_message_router) -router.include_router(profanity_filter_router) - - -@router.message.middleware() -async def private_chat_middleware( - handler: Callable[[Message, Dict[str, Any]], Awaitable[Any]], - event: Message, - data: Dict[str, Any] -) -> Any: - if event.chat.type == 'private': - return await handler(event, data) diff --git a/BotMicro/bot/middlewares/logging.py b/BotMicro/bot/middlewares/logging.py deleted file mode 100644 index 2816deb..0000000 --- a/BotMicro/bot/middlewares/logging.py +++ /dev/null @@ -1,31 +0,0 @@ -from datetime import datetime -from typing import Any, Awaitable, Callable, Dict - -from aiogram.dispatcher.middlewares.base import BaseMiddleware -from aiogram.types import TelegramObject -from deta import Base # type: ignore - - -EXPIRE_IN = 604800 # week - - -class LoggingMiddleware(BaseMiddleware): - def __init__(self, expire_in: int = EXPIRE_IN) -> None: - self.expire_in = expire_in - - async def __call__( - self, - handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], - event: TelegramObject, - data: Dict[str, Any] - ) -> Any: - time = datetime.now() - - logging_base = Base('logs') - logging_base.put( - key=str(2 * 10**9 - time.timestamp()), - data={'time': time.isoformat(), 'update': event.json()}, - expire_in=self.expire_in, - ) - - return await handler(event, data) diff --git a/BotMicro/bot/utils/chat_queries.py b/BotMicro/bot/utils/chat_queries.py deleted file mode 100644 index 449de55..0000000 --- a/BotMicro/bot/utils/chat_queries.py +++ /dev/null @@ -1,65 +0,0 @@ -from odetam.exceptions import ItemNotFound - -from models import Chat, Group, Dictionary - - -async def get_chat_groups(chat_id: int) -> list[Group]: - groups: list[Group] = [] - - try: - chat: Chat = await Chat.get(str(chat_id)) - except ItemNotFound: - return groups - - for group_key in chat.groups: - try: - group: Group = await Group.get(group_key) - except ItemNotFound: - continue - - groups.append(group) - - return groups - - -async def get_chat_groups_dictionaries(chat_id: int) -> list[Dictionary]: - dictionaries: list[Dictionary] = [] - - try: - chat: Chat = await Chat.get(str(chat_id)) - except ItemNotFound: - return dictionaries - - for group_key in chat.groups: - try: - dictionary: Dictionary = await Dictionary.get(group_key) - except ItemNotFound: - continue - - dictionaries.append(dictionary) - - return dictionaries - - -async def get_chat_groups_and_dictionaries(chat_id: int) -> list[tuple[Group, Dictionary]]: - groups_and_dicts: list[tuple[Group, Dictionary]] = [] - - try: - chat: Chat = await Chat.get(str(chat_id)) - except ItemNotFound: - return groups_and_dicts - - for group_key in chat.groups: - try: - group: Group = await Group.get(group_key) - except ItemNotFound: - continue - - try: - dictionary: Dictionary = await Dictionary.get(group_key) - except ItemNotFound: - continue - - groups_and_dicts.append((group, dictionary)) - - return groups_and_dicts diff --git a/BotMicro/logo.png b/BotMicro/logo.png deleted file mode 100644 index 082a707..0000000 Binary files a/BotMicro/logo.png and /dev/null differ diff --git a/BotMicro/main.py b/BotMicro/main.py deleted file mode 100644 index 729f44e..0000000 --- a/BotMicro/main.py +++ /dev/null @@ -1,24 +0,0 @@ -from os import getenv - -from deta import Deta - -from bot.factory import create_bot, create_dispatcher -from web.factory import create_app - - -BOT_TOKEN = getenv('BOT_TOKEN') -assert BOT_TOKEN - - -deta = Deta() - -bot, webhook_secret = create_bot(BOT_TOKEN) -dispatcher = create_dispatcher(deta) - - -app = create_app( - deta, - bot, - dispatcher, - webhook_secret -) diff --git a/BotMicro/models/__init__.py b/BotMicro/models/__init__.py deleted file mode 100644 index e5f3670..0000000 --- a/BotMicro/models/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -from .chat import Chat -from .dictionaries import Dictionary -from .events import StrikeMemberEvent, DeleteMessageEvent -from .group import Group -from .history import History -from .member import Member - -__all__ = ['Chat', 'Dictionary', 'StrikeMemberEvent', - 'DeleteMessageEvent', 'Group', 'History', 'Member'] diff --git a/BotMicro/models/chat.py b/BotMicro/models/chat.py deleted file mode 100644 index 4b90139..0000000 --- a/BotMicro/models/chat.py +++ /dev/null @@ -1,6 +0,0 @@ -from odetam.async_model import AsyncDetaModel - - -class Chat(AsyncDetaModel): - username: str - groups: list[str] diff --git a/BotMicro/models/history.py b/BotMicro/models/history.py deleted file mode 100644 index 90c298c..0000000 --- a/BotMicro/models/history.py +++ /dev/null @@ -1,7 +0,0 @@ -from odetam.async_model import AsyncDetaModel - -from models.events import Event - - -class History(AsyncDetaModel): - events: list[Event] diff --git a/BotMicro/pyproject.toml b/BotMicro/pyproject.toml deleted file mode 100644 index d1a5a0f..0000000 --- a/BotMicro/pyproject.toml +++ /dev/null @@ -1,19 +0,0 @@ -[tool.poetry] -name = "botmicro" -version = "0.1.0" -description = "" -authors = ["mamsdeveloper "] -readme = "README.md" - -[tool.poetry.dependencies] -python = "^3.9" -aiogram-deta = { git = "https://github.com/mamsdeveloper/deta.git", branch = "main" } -aiogram = "^3.0.0b1" -odetam = "^1.4.0" - -[tool.poetry.group.dev.dependencies] -autopep8 = "^2.0.2" - -[build-system] -requires = ["poetry-core"] -build-backend = "poetry.core.masonry.api" diff --git a/BotMicro/requirements.txt b/BotMicro/requirements.txt deleted file mode 100644 index 69da75d..0000000 --- a/BotMicro/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -aiogram>=3.0.0b1 -aiogram-deta @ git+https://github.com/mamsdeveloper/deta.git@main#egg=aiogram-deta -odetam diff --git a/BotMicro/utils/logging.py b/BotMicro/utils/logging.py deleted file mode 100644 index 9addb89..0000000 --- a/BotMicro/utils/logging.py +++ /dev/null @@ -1,16 +0,0 @@ -from datetime import datetime -from typing import Any, Optional - -from deta import Base - - -def log(data: dict[str, Any], expire_in: Optional[int] = 60 * 60 * 2) -> None: - time = datetime.now() - data.update({'time': time.isoformat()}) - - logging_base = Base('logs') - logging_base.put( - key=str(2 * 10**9 - time.timestamp()), - data=data, - expire_in=expire_in - ) diff --git a/BotMicro/vartrie.py b/BotMicro/vartrie.py deleted file mode 100644 index ba8d8ff..0000000 --- a/BotMicro/vartrie.py +++ /dev/null @@ -1,252 +0,0 @@ -"""VarTrie package. - -Provide prefix trie for words with letters that have variable forms. - -Example: - words = {'apple', 'banana', 'apricot'} - chars_table = {'a': {'á', '@-'}, 'e': {'e', 'é', 'É'}} - trie = VarTrie(chars_table, words) - - '@-pplÉ' in trie # True - 'apple' in trie # False (because 'a' is not in chars_table) - -Inspirations: - Search for words in different forms is common task in text processing. - For example, in a chat application, we may want to filter out bad words - in messages. However, some letters in bad words may be replaced with - similar-looking letters, such as 'a' with '@' or 'e' with 'é'. In this - case, we need to search for words in different forms. But if we have - a large number of words, it may be inefficient to search for each word - in all its forms. This is where VarTrie comes in handy. - - In one of my projects I process 47k words with about 20 forms each - with VarTrie in milliseconds. -""" - - -from collections import defaultdict -from email.policy import default -from typing import DefaultDict, Optional - - -class Node: - """Node of VarTrie. - - Consists of a dictionary of children nodes and a boolean value - indicating whether this node is the end of a word. - """ - - def __init__(self, is_end: bool = False): - """Initialize Node. - - Args: - is_end (bool): - Whether this node is the end of a word. Defaults to False. - """ - super().__init__() - self.is_end = is_end - self.children: DefaultDict[frozenset[str], Node] = defaultdict(Node) - - def __repr__(self) -> str: - """Return Node string representation as dict. - - Returns: - str: Node dictionary representation. - """ - return str(self.children) - - -class VarTrie: - """Prefix trie with letters that have variable forms. - - The trie is constructed using a set of words and a characters table, - which maps each letter to a set of its possible forms. - - Characters table rules: - If a letter is not in the characters table, it is assumed to have - only one form, itself. - - If letter in the characters table, but its forms do not include itself, - it will not be included in the trie. - - For chars_table = {'a': {'á', '@-'}, 'e': {'e', 'é', 'É'}} - 'a' will have three forms, 'á', '@-', but not 'a', - 'b' will have only one form, 'b', - 'e' will have three forms, 'e', 'é', 'É'. - - Example: - words = {'apple', 'banana', 'apricot'} - chars_table = {'a': {'á', '@-'}, 'e': {'e', 'é', 'É'}} - trie = VarTrie(chars_table, words) - - '@-pplÉ' in trie # True - 'apple' in trie # False (because 'a' is not in chars_table) - """ - - def __init__( - self, - chars_table: dict[str, set[str]], - words: Optional[set[str]] = None, - ): - """Initialize VarTrie from provided characters table and words. - - Args: - chars_table (dict[str, set[str]]): - Maps each letter to a set of its possible forms. - words (set[str]): - Words to be inserted into the trie. - Defaults empty trie created. - """ - self.root = Node() - self.chars_table = self._froze_chars_table(chars_table) - if words is not None: - self.insert_all(words) - - def insert(self, word: str) -> None: - """Insert word into the trie. - - Args: - word (str): Word to be inserted. - """ - node = self.root - while word: - char = word[0] - word = word[1:] - forms = self._get_char_forms(char) - node = node.children[forms] - - node.is_end = True - - def insert_all(self, words: set[str]) -> None: - """Insert all words into the trie. - - Args: - words (set[str]): Words to be inserted. - """ - for word in words: - self.insert(word) - - def search(self, word: str) -> bool: - """Return whether the word is in the trie. - - Args: - word (str): Word to be searched. - - Returns: - bool: Whether the word is in the trie. - """ - return self._search(self.root, word) - - def search_prefix(self, prefix: str) -> bool: - """Return whether the prefix is a prefix of a word in the trie. - - Args: - prefix (str): Prefix to be searched. - - Returns: - bool: Whether the prefix is a prefix of a word in the trie. - """ - return self._search_prefix(self.root, prefix) - - @classmethod - def _froze_chars_table( - cls, - chars_table: dict[str, set[str]], - ) -> dict[str, frozenset[str]]: - """Return frozenset version of chars_table. - - Args: - chars_table (dict[str, set[str]]): Characters table. - - Returns: - dict[str, frozenset[str]]: Frozenset version of chars_table. - """ - return {char: frozenset(forms) for char, forms in chars_table.items()} - - def _get_char_forms(self, char: str) -> frozenset[str]: - """Return set of forms of a character. - - If the character is not in the characters table, it is assumed to have - only one form, itself. - - Args: - char (str): Character to get forms of. - - Returns: - set[str]: Set of forms of the character. - """ - forms = self.chars_table.get(char) - if forms is None: - return frozenset((char, )) - - return forms - - def _get_descendants( - self, - node: Node, - word: str, - ) -> list[tuple[str, Node]]: - """Return list of descendants of node that match word. - - Find all forms that match word prefix and return their nodes. - - Args: - node (Node): Node to get descendants of. - word (str): Word to match descendants against. - - Returns: - list[tuple[str, Node]]: - List of descendants of node that match word. - """ - nodes = [] - for forms, forms_node in node.children.items(): - sorted_forms = sorted(forms, key=len, reverse=True) - for form in sorted_forms: - if word.startswith(form): - nodes.append((form, forms_node)) - - return nodes - - def _search(self, node: Node, word: str) -> bool: - """Return whether the word is in the trie. - - Args: - node (Node): Node to search word in. - word (str): Word to be searched. - - Returns: - bool: Whether the word is in the trie. - """ - descendants = self._get_descendants(node, word) - if not descendants: - return False - - if any(word == prefix and node.is_end for prefix, node in descendants): - return True - - return any( - self._search(node, word.removeprefix(prefix)) - for prefix, node in descendants - ) - - def _search_prefix(self, node: Node, prefix: str) -> bool: - """Return whether the prefix is a prefix of a word in the trie. - - Args: - node (Node): Node to search prefix in. - prefix (str): Prefix to be searched. - - Returns: - bool: Whether the prefix is a prefix of a word in the trie. - """ - descendants = self._get_descendants(node, prefix) - if not descendants: - return False - - if any(d_prefix == prefix for d_prefix, _ in descendants): - return True - - return any( - self._search_prefix(node, prefix.removeprefix(word)) - for word, node in descendants - ) diff --git a/BotMicro/web/factory.py b/BotMicro/web/factory.py deleted file mode 100644 index 084926f..0000000 --- a/BotMicro/web/factory.py +++ /dev/null @@ -1,18 +0,0 @@ -from aiogram import Bot, Dispatcher -from deta import Deta -from fastapi import FastAPI - -from web.routers import root_router -from web.stubs import BotStub, DispatcherStub, SecretStub - - -def create_app(deta: Deta, bot: Bot, dispatcher: Dispatcher, webhook_secret: str) -> FastAPI: - app = FastAPI(title='Bot') - app.dependency_overrides.update({ - BotStub: lambda: bot, - DispatcherStub: lambda: dispatcher, - SecretStub: lambda: webhook_secret, - }) - - app.include_router(root_router) - return app diff --git a/BotMicro/web/routers/__init__.py b/BotMicro/web/routers/__init__.py deleted file mode 100644 index abe87f8..0000000 --- a/BotMicro/web/routers/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -from fastapi import APIRouter - -from .develop import develop_router -from .webhook import webhook_router - -__all__ = ['root_router'] - - -root_router = APIRouter() -root_router.include_router(webhook_router) -root_router.include_router(develop_router) diff --git a/BotMicro/web/routers/develop.py b/BotMicro/web/routers/develop.py deleted file mode 100644 index 614676b..0000000 --- a/BotMicro/web/routers/develop.py +++ /dev/null @@ -1,15 +0,0 @@ -from aiogram import Bot -from fastapi import APIRouter, Depends - -from web.stubs import SecretStub, BotStub - -develop_router = APIRouter(prefix='/develop', tags=['Develop']) - - -@develop_router.get('') -async def get_meta_info( - expected_secret: str = Depends(SecretStub), - bot: Bot = Depends(BotStub), -): - webhook_info = await bot.get_webhook_info() - return {'secret_token': expected_secret, 'webhook_info': webhook_info} diff --git a/BotMicro/web/routers/webhook.py b/BotMicro/web/routers/webhook.py deleted file mode 100644 index a00cefa..0000000 --- a/BotMicro/web/routers/webhook.py +++ /dev/null @@ -1,27 +0,0 @@ -from aiogram import Bot, Dispatcher -from aiogram.types import Update -from aiogram.types.error_event import ErrorEvent -from fastapi import APIRouter, Depends, Header, HTTPException, status -from pydantic import SecretStr - -from web.stubs import BotStub, DispatcherStub, SecretStub - -webhook_router = APIRouter(prefix='/webhook', tags=['Webhook']) - - -@webhook_router.post('') -async def feed_update( - update: Update, - secret: SecretStr = Header(alias='X-Telegram-Bot-Api-Secret-Token'), - expected_secret: str = Depends(SecretStub), - bot: Bot = Depends(BotStub), - dispatcher: Dispatcher = Depends(DispatcherStub), -): - if secret.get_secret_value() != expected_secret: - raise HTTPException(detail='Invalid secret', status_code=status.HTTP_401_UNAUTHORIZED) - - result = await dispatcher.feed_update(bot, update=update) - if isinstance(result, ErrorEvent): - return {'ok': False, 'exception': result.exception, 'dispatcher': result} - - return {'ok': True, 'dispatcher': result} diff --git a/BotMicro/web/stubs.py b/BotMicro/web/stubs.py deleted file mode 100644 index d91f9eb..0000000 --- a/BotMicro/web/stubs.py +++ /dev/null @@ -1,10 +0,0 @@ -class BotStub: - pass - - -class DispatcherStub: - pass - - -class SecretStub: - pass diff --git a/LICENSE b/LICENSE index b195233..c18368c 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2023 mamsdeveloper +Copyright (c) 2024 Mikhail Butvin Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: diff --git a/Spacefile b/Spacefile deleted file mode 100644 index 0eae9dc..0000000 --- a/Spacefile +++ /dev/null @@ -1,27 +0,0 @@ -# Spacefile Docs: https://go.deta.dev/docs/spacefile/v0 -v: 0 -micros: - - name: BotMicro - src: BotMicro - engine: python3.9 - primary: true - public_routes: - - "/webhook" - - "/webhook/*" - - presets: - env: - - name: BOT_TOKEN - description: Secret token of telegram bot - - name: ENABLE_ERRORS_LOGS - description: Enable logging of errors. Logs are stored in the "logs" Deta Base. - default: "True" - - name: ENABLE_EVENTS_LOGS - description: Enable logging of each telegram event. Logs are stored in the "logs" Deta Base. - default: "True" - - name: LOGS_EXPIRE_IN - description: Time in seconds after which logs will be deleted. - default: "604800" - - name: MESSAGES_THRESHOLD - description: Number of messages to be sent after member will not be banned. - default: "5" diff --git a/antispambot/__init__.py b/antispambot/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/antispambot/analysis/__init__.py b/antispambot/analysis/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/BotMicro/analysis/checking.py b/antispambot/analysis/checking.py similarity index 76% rename from BotMicro/analysis/checking.py rename to antispambot/analysis/checking.py index 4b643c4..9ee56df 100644 --- a/BotMicro/analysis/checking.py +++ b/antispambot/analysis/checking.py @@ -1,11 +1,7 @@ -import pickle import re from typing import Optional -from deta import Drive - -from analysis.normilize import get_normalized_text, get_obfuscated_words -from vartrie import VarTrie +from antispambot.analysis.normilize import get_normalized_text def check_regex_inject(text: str) -> bool: @@ -48,20 +44,6 @@ def check_regexps(text: str, patterns: list[str]) -> Optional[tuple[str, str]]: return None -def check_profanity(text: str) -> Optional[str]: - drive = Drive('profanity') - profanity_trie_pkl = drive.get('trie.pkl') - profanity_trie: VarTrie = pickle.loads(profanity_trie_pkl.read()) - - text = get_normalized_text(text) - words = get_obfuscated_words(text) - for word in words: - if profanity_trie.search(word): - return word - - return None - - def check_text(text: str, full_words: list[str], partial_words: list[str]) -> Optional[str]: text = get_normalized_text(text) @@ -86,3 +68,16 @@ def check_substitution(text: str) -> Optional[str]: return match[0][0] return None + + +def check_emoji(text: str) -> bool: + """Check if text is spam consists of custom emojies.""" + if len(text) < 15: + # it is probable normal text + return False + + non_emoji = re.findall(r'[\w\d{re.escape(string.punctuation)}]', text) + if len(non_emoji) < 3: + return True + + return False diff --git a/BotMicro/analysis/normilize.py b/antispambot/analysis/normilize.py similarity index 95% rename from BotMicro/analysis/normilize.py rename to antispambot/analysis/normilize.py index b8d38ab..b9d24cc 100644 --- a/BotMicro/analysis/normilize.py +++ b/antispambot/analysis/normilize.py @@ -1,6 +1,5 @@ import re - analyzer = None diff --git a/BotMicro/bot/callbacks/event_message.py b/antispambot/bot/callbacks/event_message.py similarity index 100% rename from BotMicro/bot/callbacks/event_message.py rename to antispambot/bot/callbacks/event_message.py diff --git a/antispambot/bot/factory.py b/antispambot/bot/factory.py new file mode 100644 index 0000000..a10671c --- /dev/null +++ b/antispambot/bot/factory.py @@ -0,0 +1,26 @@ +from aiogram import Bot, Dispatcher +from aiogram.client.default import DefaultBotProperties +from aiogram.enums.parse_mode import ParseMode +from aiogram.fsm.storage.memory import MemoryStorage +from aiogram.utils.callback_answer import CallbackAnswerMiddleware + +from antispambot.bot.handlers import root_router as root_router +from antispambot.bot.middlewares.callback_message import ( + CallbackMessageMiddleware, +) +from antispambot.bot.middlewares.logging import LoggingMiddleware + + +def create_bot(token: str) -> Bot: + bot = Bot(token, default=DefaultBotProperties(parse_mode=ParseMode.HTML)) + return bot + + +def create_dispatcher() -> Dispatcher: + storage = MemoryStorage() + dispatcher = Dispatcher(storage=storage) + dispatcher.include_router(root_router) + dispatcher.callback_query.middleware(CallbackMessageMiddleware()) + dispatcher.callback_query.middleware(CallbackAnswerMiddleware()) + dispatcher.update.middleware(LoggingMiddleware()) + return dispatcher diff --git a/antispambot/bot/handlers/__init__.py b/antispambot/bot/handlers/__init__.py new file mode 100644 index 0000000..3b53a29 --- /dev/null +++ b/antispambot/bot/handlers/__init__.py @@ -0,0 +1,10 @@ +from aiogram import Router + +from antispambot.bot.handlers.error import router as error_router +from antispambot.bot.handlers.groups import router as groups_router +from antispambot.bot.handlers.private import router as private_router + +root_router = Router() +root_router.include_router(private_router) +root_router.include_router(groups_router) +root_router.include_router(error_router) diff --git a/antispambot/bot/handlers/error.py b/antispambot/bot/handlers/error.py new file mode 100644 index 0000000..acb8536 --- /dev/null +++ b/antispambot/bot/handlers/error.py @@ -0,0 +1,15 @@ +import logging +import traceback + +from aiogram import Router +from aiogram.types.error_event import ErrorEvent + +logger = logging.getLogger(__name__) + +router = Router() + + +@router.errors() +async def errors_handler(event: ErrorEvent): + logger.error(f'Error processing update [{event.update}]: {event.exception}') + logger.error(''.join(traceback.format_exception(event.exception))) diff --git a/antispambot/bot/handlers/groups/__init__.py b/antispambot/bot/handlers/groups/__init__.py new file mode 100644 index 0000000..e6c40e8 --- /dev/null +++ b/antispambot/bot/handlers/groups/__init__.py @@ -0,0 +1,38 @@ +from typing import Any, Awaitable, Callable, Dict + +from aiogram import Router +from aiogram.types import Message, TelegramObject + +from antispambot.bot.handlers.groups.group_message import ( + router as group_message_router, +) +from antispambot.bot.handlers.groups.new_group import ( + router as new_group_router, +) +from antispambot.bot.handlers.groups.new_member import ( + router as new_member_router, +) +from antispambot.bot.middlewares.active_group import ActiveGroupMiddleware + +router = Router() +router.include_router(group_message_router) +router.include_router(new_group_router) +router.include_router(new_member_router) + +router.message.middleware(ActiveGroupMiddleware()) +router.edited_message.middleware(ActiveGroupMiddleware()) +router.chat_member.middleware(ActiveGroupMiddleware()) + + +@router.message.middleware +async def group_chat_middleware( + handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], + event: TelegramObject, + data: Dict[str, Any] +) -> Any: + if isinstance(event, Message): + if event.from_user and event.from_user.full_name == 'Telegram': + return + + if event.chat.type in ('group', 'supergroup'): + return await handler(event, data) diff --git a/antispambot/bot/handlers/groups/group_message.py b/antispambot/bot/handlers/groups/group_message.py new file mode 100644 index 0000000..7e02ac9 --- /dev/null +++ b/antispambot/bot/handlers/groups/group_message.py @@ -0,0 +1,87 @@ +from os import getenv +from venv import logger + +from aiogram import Bot, F, Router +from aiogram.types import Message + +from antispambot.analysis.checking import ( + check_emoji, + check_full_words, + check_partial_words, + check_regexps, + check_substitution, +) +from antispambot.analysis.normilize import ( + get_normalized_text, + remove_stop_words, +) +from antispambot.bot.utils.events import message_delete_event +from antispambot.bot.utils.message import get_full_text +from antispambot.models.group import Group +from antispambot.models.member import Member +from antispambot.storage.storages import dictionary_storage, member_storage + +router = Router() + + +@router.edited_message(F.from_user.is_bot == False) +@router.message(F.from_user.is_bot == False) +async def group_message_handler(message: Message, bot: Bot, group: Group) -> None: + member = member_storage.get(str(message.from_user.id)) + if member is None: + member = Member( + key=str(message.from_user.id), + strikes_count={group.key: 0} + ) + + member.messages_count.setdefault(group.key, 0) + member.messages_count[group.key] += 1 + member_storage.save(member) + + messages_threshold = int(getenv('MESSAGES_THRESHOLD', 5)) + if member.messages_count.get(group.key, 0) >= messages_threshold: + logger.debug(f'User {member.key} has reached the messages threshold in group {group.key}') + return + + dictionary = dictionary_storage.get(group.key) + if dictionary is None: + logger.warning(f'Dictionary for group {group.key} is not found') + return + + text = get_full_text(message) + text = get_normalized_text(text) + text = remove_stop_words(text, dictionary.stop_words) + + substitution_result = check_substitution(text) + if substitution_result: + await message_delete_event(group, member, message, f'замена букв в слове "{substitution_result}"', bot) + return + + emoji_result = check_emoji(text) + if emoji_result: + await message_delete_event(group, member, message, 'сообщение содержит только эмодзи', bot) + return + + full_check_result = check_full_words(text, dictionary.full_words) + if full_check_result: + await message_delete_event(group, member, message, f'слово "{full_check_result}"', bot) + return + + partial_search_result = check_partial_words(text, dictionary.partial_words) + if partial_search_result: + word, part = partial_search_result + if part: + reason = f'часть "{part}" в слове "{word}"' + else: + reason = f'слово "{part}"' + + await message_delete_event(group, member, message, reason, bot) + return + + regex_search_result = check_regexps(text, dictionary.regex_patterns) + if regex_search_result: + word, pattern = regex_search_result + await message_delete_event(group, member, message, f'шаблон "{pattern}" в слове "{word}"', bot) + return + + return diff --git a/antispambot/bot/handlers/groups/new_group.py b/antispambot/bot/handlers/groups/new_group.py new file mode 100644 index 0000000..9976cd3 --- /dev/null +++ b/antispambot/bot/handlers/groups/new_group.py @@ -0,0 +1,29 @@ +from aiogram import Router +from aiogram.filters import IS_ADMIN, ChatMemberUpdatedFilter +from aiogram.types import ChatMemberUpdated + +from antispambot.models.dictionary import Dictionary +from antispambot.models.group import Group +from antispambot.storage.storages import dictionary_storage, group_storage + +router = Router() + + +@router.my_chat_member(ChatMemberUpdatedFilter(IS_ADMIN)) +async def new_group_handler(event: ChatMemberUpdated): + group = Group( + key=str(event.chat.id), + title=event.chat.title or '', + active=True, + strike_mode=True, + strike_limit=3, + ignored_users=[] + ) + group_storage.save(group) + + dictionary = dictionary_storage.get('default') + if dictionary is None: + dictionary = Dictionary(key='default', full_words=[], partial_words=[]) + + dictionary.key = group.key + dictionary_storage.save(dictionary) diff --git a/BotMicro/bot/handlers/groups/new_member.py b/antispambot/bot/handlers/groups/new_member.py similarity index 55% rename from BotMicro/bot/handlers/groups/new_member.py rename to antispambot/bot/handlers/groups/new_member.py index 75a6c7a..b1b636b 100644 --- a/BotMicro/bot/handlers/groups/new_member.py +++ b/antispambot/bot/handlers/groups/new_member.py @@ -1,29 +1,43 @@ +import logging import re from aiogram import Bot, Router -from aiogram.filters.chat_member_updated import (IS_MEMBER, IS_NOT_MEMBER, - ChatMemberUpdatedFilter) -from aiogram.types import (ChatMemberUpdated, InlineKeyboardButton, - InlineKeyboardMarkup) +from aiogram.filters.chat_member_updated import ( + IS_MEMBER, + IS_NOT_MEMBER, + ChatMemberUpdatedFilter, +) +from aiogram.types import ( + ChatMemberUpdated, + InlineKeyboardButton, + InlineKeyboardMarkup, +) -from bot import messages -from bot.callbacks.event_message import AllowNicknameCallback -from models.chat import Chat -from models.group import Group -from models.member import Member -from utils.logging import log +from antispambot.bot import messages +from antispambot.bot.callbacks.event_message import AllowNicknameCallback +from antispambot.models.chat import Chat +from antispambot.storage.storages import ( + chat_storage, + group_storage, + member_storage, +) + +logger = logging.getLogger(__name__) router = Router() @router.chat_member(ChatMemberUpdatedFilter(IS_NOT_MEMBER >> IS_MEMBER)) async def new_member_handler(event: ChatMemberUpdated, bot: Bot): - member = await Member.get_or_none(str(event.new_chat_member.user.id)) - if member and member.nickname_pass.get(str(event.chat.id)): - return + member = member_storage.get(str(event.new_chat_member.user.id)) + if member is not None: + if member.nickname_pass.get(str(event.chat.id)) is not None: + logger.info(f'User {event.new_chat_member.user.id} has nickname pass') + return - group = await Group.get_or_none(str(event.chat.id)) - if not group: + group = group_storage.get(str(event.chat.id)) + if group is None: + logger.warning(f'Group {event.chat.id} not found in storage') return fullname = event.new_chat_member.user.full_name @@ -34,14 +48,12 @@ async def new_member_handler(event: ChatMemberUpdated, bot: Bot): or len(re.sub(r'[^a-zа-я]', '', fullname)) <= 2 or re.search(r'[\u0600-\u06FF\u0530-\u058F\u4E00-\u9FFF]+', fullname) ): - result = await bot.ban_chat_member(event.chat.id, event.new_chat_member.user.id) - # log(data={ - # 'chat_id': event.chat.id, - # 'user_id': event.new_chat_member.user.id, - # 'ban_chat_member_result': result - # }) - - admins: list[Chat] = await Chat.query(Chat.groups.contains(group.key)) + await bot.ban_chat_member(event.chat.id, event.new_chat_member.user.id) + admins = [ + chat + for chat in chat_storage.get_all() + if group.key in chat.groups + ] for admin in admins: try: await send_ban_member_alert(bot, event, admin) diff --git a/antispambot/bot/handlers/private/__init__.py b/antispambot/bot/handlers/private/__init__.py new file mode 100644 index 0000000..b705643 --- /dev/null +++ b/antispambot/bot/handlers/private/__init__.py @@ -0,0 +1,46 @@ +from typing import Any, Awaitable, Callable, Dict + +from aiogram import Router +from aiogram.types import Message, TelegramObject + +from antispambot.bot.handlers.private.edit_words import ( + router as edit_words_router, +) +from antispambot.bot.handlers.private.event_message import ( + router as event_message_router, +) +from antispambot.bot.handlers.private.groups import router as groups_router +from antispambot.bot.handlers.private.ignored_users import ( + router as ignored_users_router, +) +from antispambot.bot.handlers.private.list_words import ( + router as list_words_router, +) +from antispambot.bot.handlers.private.profanity_filter import ( + router as profanity_filter_router, +) +from antispambot.bot.handlers.private.start import router as start_router +from antispambot.bot.handlers.private.strike_mode import ( + router as strike_mode_router, +) + +router = Router() +router.include_router(start_router) +router.include_router(groups_router) +router.include_router(edit_words_router) +router.include_router(list_words_router) +router.include_router(strike_mode_router) +router.include_router(ignored_users_router) +router.include_router(event_message_router) +router.include_router(profanity_filter_router) + + +@router.message.middleware +async def private_chat_middleware( + handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], + event: TelegramObject, + data: Dict[str, Any] +) -> Any: + if isinstance(event, Message): + if event.chat.type == 'private': + return await handler(event, data) diff --git a/BotMicro/bot/handlers/private/edit_words.py b/antispambot/bot/handlers/private/edit_words.py similarity index 82% rename from BotMicro/bot/handlers/private/edit_words.py rename to antispambot/bot/handlers/private/edit_words.py index ba08b6e..8e7e0cd 100644 --- a/BotMicro/bot/handlers/private/edit_words.py +++ b/antispambot/bot/handlers/private/edit_words.py @@ -1,12 +1,11 @@ -from aiogram import Router, F +from aiogram import F, Router from aiogram.fsm.context import FSMContext from aiogram.types import Message -from bot import messages -from bot.states.private import EditWords -from bot.utils.chat_queries import get_chat_groups_dictionaries -from models import Dictionary - +from antispambot.bot import messages +from antispambot.bot.states.private import EditWords +from antispambot.bot.utils.chat_queries import get_chat_groups_dictionaries +from antispambot.storage.storages import dictionary_storage router = Router() @@ -15,7 +14,7 @@ async def drop_words_handler(message: Message, state: FSMContext): await state.clear() - chat_dicts = await get_chat_groups_dictionaries(message.chat.id) + chat_dicts = get_chat_groups_dictionaries(message.chat.id) for dictionary in chat_dicts: if message.text == 'Убрать все полные слова': dictionary.full_words = [] @@ -24,7 +23,7 @@ async def drop_words_handler(message: Message, state: FSMContext): elif message.text == 'Убрать все шаблоны': dictionary.regex_patterns = [] - await dictionary.save() + dictionary_storage.save(dictionary) await message.answer(messages.SUCCESSFUL_DROP_WORDS) @@ -33,16 +32,18 @@ async def drop_words_handler(message: Message, state: FSMContext): async def repair_words_handler(message: Message, state: FSMContext): await state.clear() - default_dict: Dictionary = await Dictionary.get('default') + default_dict = dictionary_storage.get('default') + if default_dict is None: + return - chat_dicts = await get_chat_groups_dictionaries(message.chat.id) + chat_dicts = get_chat_groups_dictionaries(message.chat.id) for dictionary in chat_dicts: if message.text == 'Восстановить словарь полных слов': dictionary.full_words = default_dict.full_words elif message.text == 'Восстановить словарь частичных слов': dictionary.partial_words = default_dict.partial_words - await dictionary.save() + dictionary_storage.save(dictionary) await message.answer(messages.SUCCESSFUL_REPAIR_WORDS) @@ -76,7 +77,7 @@ async def words_handler(message: Message, state: FSMContext): action = data.get('command') await state.clear() - chat_dicts = await get_chat_groups_dictionaries(message.chat.id) + chat_dicts = get_chat_groups_dictionaries(message.chat.id) for dictionary in chat_dicts: if action == 'Добавить полные слова': dictionary.full_words.extend(words) @@ -95,6 +96,6 @@ async def words_handler(message: Message, state: FSMContext): elif action == 'Убрать пропуск слов': dictionary.stop_words = [word for word in dictionary.stop_words if word not in words] - await dictionary.save() + dictionary_storage.save(dictionary) await message.answer(messages.SUCCESSFUL_UPDATE_WORDS) \ No newline at end of file diff --git a/BotMicro/bot/handlers/private/event_message.py b/antispambot/bot/handlers/private/event_message.py similarity index 76% rename from BotMicro/bot/handlers/private/event_message.py rename to antispambot/bot/handlers/private/event_message.py index 649ab97..a464cce 100644 --- a/BotMicro/bot/handlers/private/event_message.py +++ b/antispambot/bot/handlers/private/event_message.py @@ -1,9 +1,14 @@ from aiogram import Bot, Router from aiogram.types import CallbackQuery, Message -from odetam.exceptions import ItemNotFound -from bot.callbacks.event_message import AllowNicknameCallback, BanMemberCallback, DeleteMessageCallback, UnbanMemberCallback -from models.member import Member +from antispambot.bot.callbacks.event_message import ( + AllowNicknameCallback, + BanMemberCallback, + DeleteMessageCallback, + UnbanMemberCallback, +) +from antispambot.models.member import Member +from antispambot.storage.storages import member_storage router = Router() @@ -19,14 +24,12 @@ async def unban_member_handler(query: CallbackQuery, message: Message, callback_ await bot.unban_chat_member(callback_data.chat_id, callback_data.user_id, only_if_banned=True) group_key = str(callback_data.chat_id) - try: - member: Member = await Member.get(str(callback_data.user_id)) - except ItemNotFound: + member = member_storage.get(str(callback_data.user_id)) + if member is None: member = Member(key=str(callback_data.user_id), strikes_count={}) member.strikes_count[group_key] = 0 - await member.save() - + member_storage.save(member) await message.edit_reply_markup(reply_markup=None) @@ -34,15 +37,13 @@ async def unban_member_handler(query: CallbackQuery, message: Message, callback_ async def allow_nickname_handler(query: CallbackQuery, message: Message, callback_data: AllowNicknameCallback, bot: Bot) -> None: await bot.unban_chat_member(callback_data.chat_id, callback_data.user_id, only_if_banned=True) - try: - member: Member = await Member.get(str(callback_data.user_id)) - except ItemNotFound: + member = member_storage.get(str(callback_data.user_id)) + if member is None: member = Member(key=str(callback_data.user_id), strikes_count={}) member.strikes_count[str(callback_data.chat_id)] = 0 member.nickname_pass[str(callback_data.chat_id)] = True - await member.save() - + member_storage.save(member) await message.edit_reply_markup(reply_markup=None) diff --git a/BotMicro/bot/handlers/private/groups.py b/antispambot/bot/handlers/private/groups.py similarity index 64% rename from BotMicro/bot/handlers/private/groups.py rename to antispambot/bot/handlers/private/groups.py index 08bb596..81a64fc 100644 --- a/BotMicro/bot/handlers/private/groups.py +++ b/antispambot/bot/handlers/private/groups.py @@ -1,10 +1,9 @@ -from aiogram import Router, F +from aiogram import F, Router from aiogram.fsm.context import FSMContext from aiogram.types import Message -from bot import messages -from bot.utils.chat_queries import get_chat_groups_and_dictionaries - +from antispambot.bot import messages +from antispambot.bot.utils.chat_queries import get_chat_groups_and_dictionaries router = Router() @@ -13,9 +12,8 @@ async def start_handler(message: Message, state: FSMContext) -> None: await state.clear() - groups_and_dicts = await get_chat_groups_and_dictionaries(message.chat.id) + groups_and_dicts = get_chat_groups_and_dictionaries(message.chat.id) if not groups_and_dicts: await message.answer(messages.NO_AVAILABLE_GROUPS) else: await message.answer(messages.build_groups_list(groups_and_dicts)) - \ No newline at end of file diff --git a/BotMicro/bot/handlers/private/ignored_users.py b/antispambot/bot/handlers/private/ignored_users.py similarity index 75% rename from BotMicro/bot/handlers/private/ignored_users.py rename to antispambot/bot/handlers/private/ignored_users.py index 14cac3a..5449772 100644 --- a/BotMicro/bot/handlers/private/ignored_users.py +++ b/antispambot/bot/handlers/private/ignored_users.py @@ -1,10 +1,11 @@ -from aiogram import Router, F +from aiogram import F, Router from aiogram.fsm.context import FSMContext from aiogram.types import Message -from bot.states.private import IgnoredUserState -from bot.utils.chat_queries import get_chat_groups -from bot import messages +from antispambot.bot import messages +from antispambot.bot.states.private import IgnoredUserState +from antispambot.bot.utils.chat_queries import get_chat_groups +from antispambot.storage.storages import group_storage router = Router() @@ -20,29 +21,28 @@ async def update_ignored_users_handler(message: Message, state: FSMContext): async def full_name_handler(message: Message, state: FSMContext): if not message.text: return - + data = await state.get_data() command = data['command'] await state.clear() - groups = await get_chat_groups(message.chat.id) + groups = get_chat_groups(message.chat.id) for group in groups: if command == 'Добавить исключение': group.ignored_users.append(message.text) elif command == 'Убрать исключение': if message.text in group.ignored_users: group.ignored_users.remove(message.text) - - await group.save() - + + group_storage.save(group) + await message.answer(messages.IGNORED_USERS_UPDATED) @router.message(F.text == 'Пользователи-исключения') async def list_ignored_users_handler(message: Message, state: FSMContext): await state.clear() - - groups = await get_chat_groups(message.chat.id) + + groups = get_chat_groups(message.chat.id) for group in groups: await message.answer(messages.build_ignored_users_list(group)) - diff --git a/BotMicro/bot/handlers/private/list_words.py b/antispambot/bot/handlers/private/list_words.py similarity index 66% rename from BotMicro/bot/handlers/private/list_words.py rename to antispambot/bot/handlers/private/list_words.py index 399af76..a5bec79 100644 --- a/BotMicro/bot/handlers/private/list_words.py +++ b/antispambot/bot/handlers/private/list_words.py @@ -1,12 +1,10 @@ -from aiogram import Router, F +from aiogram import F, Router from aiogram.fsm.context import FSMContext from aiogram.types import Message -from odetam.exceptions import ItemNotFound - -from bot import messages -from bot.utils.chat_queries import get_chat_groups_and_dictionaries -from models import Dictionary +from antispambot.bot import messages +from antispambot.bot.utils.chat_queries import get_chat_groups_and_dictionaries +from antispambot.storage.storages import dictionary_storage router = Router() @@ -15,12 +13,11 @@ async def list_words_handler(message: Message, state: FSMContext): await state.clear() - try: - default_dict: Dictionary = await Dictionary.get('default') - except ItemNotFound: + default_dict = dictionary_storage.get('default') + if default_dict is None: return - groups_and_dicts = await get_chat_groups_and_dictionaries(message.chat.id) + groups_and_dicts = get_chat_groups_and_dictionaries(message.chat.id) for group, dictionary in groups_and_dicts: full_words = sorted(set(dictionary.full_words)) partial_words = sorted(set(dictionary.partial_words)) diff --git a/BotMicro/bot/handlers/private/profanity_filter.py b/antispambot/bot/handlers/private/profanity_filter.py similarity index 64% rename from BotMicro/bot/handlers/private/profanity_filter.py rename to antispambot/bot/handlers/private/profanity_filter.py index ad1895a..c39543c 100644 --- a/BotMicro/bot/handlers/private/profanity_filter.py +++ b/antispambot/bot/handlers/private/profanity_filter.py @@ -2,8 +2,9 @@ from aiogram.fsm.context import FSMContext from aiogram.types import Message -from bot import messages -from bot.utils.chat_queries import get_chat_groups_dictionaries +from antispambot.bot import messages +from antispambot.bot.utils.chat_queries import get_chat_groups_dictionaries +from antispambot.storage.storages import dictionary_storage router = Router() @@ -12,10 +13,10 @@ async def activate_profanity_filter(message: Message, state: FSMContext): await state.clear() - chat_dicts = await get_chat_groups_dictionaries(message.chat.id) + chat_dicts = get_chat_groups_dictionaries(message.chat.id) for dictionary in chat_dicts: dictionary.profanity_filter = True - await dictionary.save() + dictionary_storage.save(dictionary) await message.answer(messages.SUCCESSFUL_ACTIVATE_FILTER) @@ -24,9 +25,9 @@ async def activate_profanity_filter(message: Message, state: FSMContext): async def deactivate_profanity_filter(message: Message, state: FSMContext): await state.clear() - chat_dicts = await get_chat_groups_dictionaries(message.chat.id) + chat_dicts = get_chat_groups_dictionaries(message.chat.id) for dictionary in chat_dicts: dictionary.profanity_filter = False - await dictionary.save() + dictionary_storage.save(dictionary) await message.answer(messages.SUCCESSFUL_DEACTIVATE_FILTER) diff --git a/BotMicro/bot/handlers/private/start.py b/antispambot/bot/handlers/private/start.py similarity index 86% rename from BotMicro/bot/handlers/private/start.py rename to antispambot/bot/handlers/private/start.py index 4f9e4dc..1cb7feb 100644 --- a/BotMicro/bot/handlers/private/start.py +++ b/antispambot/bot/handlers/private/start.py @@ -1,12 +1,11 @@ -from aiogram import Router, F +from aiogram import F, Router from aiogram.filters import CommandStart from aiogram.fsm.context import FSMContext -from aiogram.types import Message, KeyboardButton, ReplyKeyboardMarkup -from odetam.exceptions import ItemNotFound - -from bot import messages -from models import Chat +from aiogram.types import KeyboardButton, Message, ReplyKeyboardMarkup +from antispambot.bot import messages +from antispambot.models.chat import Chat +from antispambot.storage.storages import chat_storage router = Router() @@ -62,12 +61,11 @@ async def start_handler(message: Message, state: FSMContext) -> None: if not message.from_user: return - try: - chat: Chat = await Chat.get(str(message.chat.id)) - except ItemNotFound: + chat = chat_storage.get(str(message.chat.id)) + if chat is None: chat = Chat( key=str(message.chat.id), username=message.from_user.full_name, groups=[] ) - await chat.save() + chat_storage.save(chat) diff --git a/BotMicro/bot/handlers/private/strike_mode.py b/antispambot/bot/handlers/private/strike_mode.py similarity index 72% rename from BotMicro/bot/handlers/private/strike_mode.py rename to antispambot/bot/handlers/private/strike_mode.py index b579f17..b44a9d9 100644 --- a/BotMicro/bot/handlers/private/strike_mode.py +++ b/antispambot/bot/handlers/private/strike_mode.py @@ -1,11 +1,11 @@ -from aiogram import Router, F +from aiogram import F, Router from aiogram.fsm.context import FSMContext from aiogram.types import Message -from bot import messages -from bot.utils.chat_queries import get_chat_groups -from bot.states.private import StrikeLimitState - +from antispambot.bot import messages +from antispambot.bot.states.private import StrikeLimitState +from antispambot.bot.utils.chat_queries import get_chat_groups +from antispambot.storage.storages import group_storage router = Router() @@ -14,22 +14,22 @@ async def strike_mode_handler(message: Message, state: FSMContext): await state.clear() - chat_groups = await get_chat_groups(message.chat.id) + chat_groups = get_chat_groups(message.chat.id) for group in chat_groups: group.strike_mode = message.text == 'Включить баны' - await group.save() - + group_storage.save(group) + if message.text == 'Включить баны': await message.answer(messages.STRIKES_ENABLED) elif message.text == 'Отключить баны': await message.answer(messages.STRIKES_DISABLED) - + @router.message(F.text == 'Установить лимит бана') async def strike_limit_handler(message: Message, state: FSMContext): await message.answer(messages.ASK_STRIKE_LIMIT) await state.set_state(StrikeLimitState.limit) - + @router.message(StrikeLimitState.limit, F.text) async def strike_limit_number_handler(message: Message, state: FSMContext): @@ -39,13 +39,13 @@ async def strike_limit_number_handler(message: Message, state: FSMContext): if not message.text.isdigit(): await message.answer(messages.STRIKE_LIMIT_NOT_DIGIT) return - + strike_limit = int(message.text) - - chat_groups = await get_chat_groups(message.chat.id) + + chat_groups = get_chat_groups(message.chat.id) for group in chat_groups: group.strike_limit = strike_limit - await group.save() - + group_storage.save(group) + await message.answer(messages.STRIKE_LIMIT_UPDATED) - await state.clear() \ No newline at end of file + await state.clear() diff --git a/BotMicro/bot/messages.py b/antispambot/bot/messages.py similarity index 95% rename from BotMicro/bot/messages.py rename to antispambot/bot/messages.py index b58840a..af054c5 100644 --- a/BotMicro/bot/messages.py +++ b/antispambot/bot/messages.py @@ -1,6 +1,7 @@ from typing import Iterable -from models import Dictionary, Group +from antispambot.models.dictionary import Dictionary +from antispambot.models.group import Group GREETING = 'Привет! Я помогу тебе удалять сообщения со стоп-словами.' diff --git a/BotMicro/bot/middlewares/active_group.py b/antispambot/bot/middlewares/active_group.py similarity index 64% rename from BotMicro/bot/middlewares/active_group.py rename to antispambot/bot/middlewares/active_group.py index aca804c..261b516 100644 --- a/BotMicro/bot/middlewares/active_group.py +++ b/antispambot/bot/middlewares/active_group.py @@ -1,16 +1,10 @@ -from typing import Any, Callable, Awaitable - -from aiogram.types import Message -from odetam.exceptions import ItemNotFound - -from bot.utils.group_utils import is_user_admin - -from models import Group - from typing import Any, Awaitable, Callable, Dict from aiogram.dispatcher.middlewares.base import BaseMiddleware -from aiogram.types import TelegramObject +from aiogram.types import Message, TelegramObject + +from antispambot.bot.utils.group_utils import is_user_admin +from antispambot.storage.storages import group_storage class ActiveGroupMiddleware(BaseMiddleware): @@ -18,7 +12,7 @@ async def __call__( self, handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], event: TelegramObject, - data: Dict[str, Any] + data: Dict[str, Any], ) -> Any: if isinstance(event, Message): if not event.from_user: @@ -26,23 +20,25 @@ async def __call__( if event.from_user.full_name == 'Telegram': return - - is_admin = await is_user_admin(event.from_user, event.chat) + + if event.bot is None: + return + + is_admin = await is_user_admin(event.from_user, event.chat, event.bot) if is_admin: return - + chat_id = event.chat.id - try: - group: Group = await Group.get(str(chat_id)) - except ItemNotFound: + group = group_storage.get(str(chat_id)) + if group is None: return - + if not group.active: return - + if event.from_user.full_name in group.ignored_users: return - + data['group'] = group return await handler(event, data) diff --git a/BotMicro/bot/middlewares/callback_message.py b/antispambot/bot/middlewares/callback_message.py similarity index 100% rename from BotMicro/bot/middlewares/callback_message.py rename to antispambot/bot/middlewares/callback_message.py diff --git a/antispambot/bot/middlewares/logging.py b/antispambot/bot/middlewares/logging.py new file mode 100644 index 0000000..da06868 --- /dev/null +++ b/antispambot/bot/middlewares/logging.py @@ -0,0 +1,18 @@ +import logging +from typing import Any, Awaitable, Callable, Dict + +from aiogram.dispatcher.middlewares.base import BaseMiddleware +from aiogram.types import TelegramObject + +logger = logging.getLogger(__name__) + + +class LoggingMiddleware(BaseMiddleware): + async def __call__( + self, + handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], + event: TelegramObject, + data: Dict[str, Any] + ) -> Any: + logger.debug(event) + return await handler(event, data) diff --git a/BotMicro/bot/states/private.py b/antispambot/bot/states/private.py similarity index 100% rename from BotMicro/bot/states/private.py rename to antispambot/bot/states/private.py diff --git a/antispambot/bot/utils/chat_queries.py b/antispambot/bot/utils/chat_queries.py new file mode 100644 index 0000000..2c0389c --- /dev/null +++ b/antispambot/bot/utils/chat_queries.py @@ -0,0 +1,66 @@ +import logging + +from antispambot.models.dictionary import Dictionary +from antispambot.models.group import Group +from antispambot.storage.storages import ( + chat_storage, + dictionary_storage, + group_storage, +) + +logger = logging.getLogger(__name__) + + +def get_chat_groups(chat_id: int) -> list[Group]: + groups: list[Group] = [] + + chat = chat_storage.get(str(chat_id)) + if chat is None: + return groups + + for group_key in chat.groups: + group = group_storage.get(group_key) + if group is None: + continue + + groups.append(group) + + return groups + + +def get_chat_groups_dictionaries(chat_id: int) -> list[Dictionary]: + dictionaries: list[Dictionary] = [] + + chat = chat_storage.get(str(chat_id)) + if chat is None: + return dictionaries + + for group_key in chat.groups: + dictionary = dictionary_storage.get(group_key) + if dictionary is None: + continue + + dictionaries.append(dictionary) + + return dictionaries + + +def get_chat_groups_and_dictionaries(chat_id: int) -> list[tuple[Group, Dictionary]]: + groups_and_dicts: list[tuple[Group, Dictionary]] = [] + + chat = chat_storage.get(str(chat_id)) + if chat is None: + return groups_and_dicts + + for group_key in chat.groups: + group = group_storage.get(group_key) + if group is None: + continue + + dictionary = dictionary_storage.get(group_key) + if dictionary is None: + continue + + groups_and_dicts.append((group, dictionary)) + + return groups_and_dicts diff --git a/BotMicro/bot/utils/events.py b/antispambot/bot/utils/events.py similarity index 56% rename from BotMicro/bot/utils/events.py rename to antispambot/bot/utils/events.py index 9c10445..9a38e80 100644 --- a/BotMicro/bot/utils/events.py +++ b/antispambot/bot/utils/events.py @@ -1,17 +1,29 @@ +import logging from datetime import datetime from aiogram import Bot from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, Message -from bot.callbacks.event_message import (BanMemberCallback, - DeleteMessageCallback, - UnbanMemberCallback) -from bot.messages import (DELETE_MESSAGE_EVENT, DELETE_MESSAGE_REASON, - PROFANITY_EVENT, STRIKE_MEMBER_EVENT) -from bot.utils.spread import SendMessage, forward_messages, spread_messages -from models import (Chat, DeleteMessageEvent, Group, Member, - StrikeMemberEvent) -from models.events import Event, ProfanityFilterEvent +from antispambot.bot.callbacks.event_message import ( + BanMemberCallback, + UnbanMemberCallback, +) +from antispambot.bot.messages import ( + DELETE_MESSAGE_EVENT, + DELETE_MESSAGE_REASON, + STRIKE_MEMBER_EVENT, +) +from antispambot.bot.utils.spread import SendMessage, spread_messages +from antispambot.models.event import ( + DeleteMessageEvent, + Event, + StrikeMemberEvent, +) +from antispambot.models.group import Group +from antispambot.models.member import Member +from antispambot.storage.storages import chat_storage, member_storage + +logger = logging.getLogger(__name__) async def message_delete_event( @@ -21,6 +33,9 @@ async def message_delete_event( reason: str, bot: Bot ) -> Event: + assert message.from_user + logger.info(f'Deleting message {message.message_id} from {message.from_user.username} in group {group.key}: {reason}') + await message.delete() # send info to Recent Actions @@ -31,15 +46,13 @@ async def message_delete_event( # register event del_msg_event = DeleteMessageEvent( + key=str(message.message_id), username=message.from_user.username, full_name=message.from_user.full_name, message_text=message.text or 'error', reason=reason, time=datetime.now() ) - # history: History = await History.get(group.key) - # history.events.append(del_msg_event) - # await history.save() # type: ignore # send info to admins delete_event_message = SendMessage( @@ -71,7 +84,12 @@ async def message_delete_event( ] ]) ) - admins_chats: list[Chat] = await Chat.query(Chat.groups.contains(group.key)) + admins_chats = [ + chat + for chat in chat_storage.get_all() + if group.key in chat.groups + ] + logger.info(f'Admins chats: {[chat.username for chat in admins_chats]}') admins_chats_ids = [int(chat.key) for chat in admins_chats] await spread_messages(admins_chats_ids, [delete_event_message], bot) @@ -80,6 +98,7 @@ async def message_delete_event( if member_striked: await strike_member_event(group, member, message, bot) + logger.info(f'Success: {del_msg_event}') return del_msg_event @@ -89,6 +108,9 @@ async def strike_member_event( message: Message, bot: Bot ) -> Event: + assert message.from_user + logger.info(f'Striking member {message.from_user.username} in group {group.key}') + # send info to Recent Actions await send_to_recent_actions( message, @@ -101,15 +123,13 @@ async def strike_member_event( # register event strike_member_event = StrikeMemberEvent( + key=str(message.message_id), username=message.from_user.username, full_name=message.from_user.full_name, message_text=None, reason=None, time=datetime.now() ) - # history: History = await History.get(group.key) - # history.events.append(strike_member_event) - # await history.save() # type: ignore # send event to admins strike_event_message = SendMessage( @@ -123,77 +143,30 @@ async def strike_member_event( InlineKeyboardButton( text='Разбанить', callback_data=UnbanMemberCallback( - chat_id=group.key, + chat_id=int(group.key), user_id=message.from_user.id ).pack() ) ] ]) ) - admins_chats: list[Chat] = await Chat.query(Chat.groups.contains(group.key)) + admins_chats = [ + chat + for chat in chat_storage.get_all() + if group.key in chat.groups + ] + logger.info(f'Admins chats: {[chat.username for chat in admins_chats]}') admins_chats_ids = [int(chat.key) for chat in admins_chats] await spread_messages(admins_chats_ids, [strike_event_message], bot) + logger.info(f'Success: {strike_member_event}') return strike_member_event -async def profanity_filter_event( - group: Group, - member: Member, - message: Message, - word: str, - bot: Bot -) -> Event: - # register event - profanity_filter_event = ProfanityFilterEvent( - username=message.from_user.username, - full_name=message.from_user.full_name, - message_text=message.text or 'error', - reason=word, - time=datetime.now() - ) - # history: History = await History.get(group.key) - # history.events.append(profanity_filter_event) - # await history.save() # type: ignore - - # send message to admins - profanity_event_message = SendMessage( - text=PROFANITY_EVENT.format( - title=group.title, - word=profanity_filter_event.reason, - ), - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text='Удалить сообщение', - callback_data=DeleteMessageCallback( - chat_id=message.chat.id, - message_id=message.message_id - ).pack() - ), - InlineKeyboardButton( - text='Забанить', - callback_data=BanMemberCallback( - chat_id=message.chat.id, - user_id=message.from_user.id - ).pack() - ) - ] - ]) - ) - - admins_chats: list[Chat] = await Chat.query(Chat.groups.contains(group.key)) - admins_chats_ids = [int(chat.key) for chat in admins_chats] - await spread_messages(admins_chats_ids, [profanity_event_message], bot) - await forward_messages(admins_chats_ids, [message], bot) - - return profanity_filter_event - - async def update_member_strike(group: Group, member: Member) -> bool: member.strikes_count.setdefault(group.key, 0) member.strikes_count[group.key] += 1 - await member.save() + member_storage.save(member) if member.strikes_count[group.key] >= 3: return group.strike_mode diff --git a/BotMicro/bot/utils/group_utils.py b/antispambot/bot/utils/group_utils.py similarity index 72% rename from BotMicro/bot/utils/group_utils.py rename to antispambot/bot/utils/group_utils.py index 2a347f1..ec86476 100644 --- a/BotMicro/bot/utils/group_utils.py +++ b/antispambot/bot/utils/group_utils.py @@ -1,10 +1,9 @@ from aiogram import Bot from aiogram.exceptions import TelegramAPIError -from aiogram.types import User, Chat +from aiogram.types import Chat, User -async def is_user_admin(user: User, chat: Chat) -> bool: - bot = Bot.get_current() +async def is_user_admin(user: User, chat: Chat, bot: Bot) -> bool: if not bot: return False diff --git a/BotMicro/bot/utils/message.py b/antispambot/bot/utils/message.py similarity index 100% rename from BotMicro/bot/utils/message.py rename to antispambot/bot/utils/message.py diff --git a/BotMicro/bot/utils/spread.py b/antispambot/bot/utils/spread.py similarity index 80% rename from BotMicro/bot/utils/spread.py rename to antispambot/bot/utils/spread.py index 53b37a0..6c6c35e 100644 --- a/BotMicro/bot/utils/spread.py +++ b/antispambot/bot/utils/spread.py @@ -15,7 +15,7 @@ async def spread_messages( chat_ids: list[int], messages: list[SendMessage], bot: Bot -) -> list[Union[Exception, Message]]: +) -> list[Union[BaseException, Message]]: targets = [ bot.send_message( chat_id, @@ -25,7 +25,7 @@ async def spread_messages( for chat_id in chat_ids for message in messages ] - results: list[Union[Exception, Message]] = await gather( + results: list[Union[BaseException, Message]] = await gather( *targets, return_exceptions=True ) @@ -36,7 +36,7 @@ async def forward_messages( chat_ids: list[int], messages: list[Message], bot: Bot -) -> list[Union[Exception, Message]]: +) -> list[Union[BaseException, Message]]: targets = [ bot.forward_message( chat_id, @@ -46,7 +46,7 @@ async def forward_messages( for chat_id in chat_ids for message in messages ] - results: list[Union[Exception, Message]] = await gather( + results: list[Union[BaseException, Message]] = await gather( *targets, return_exceptions=True ) diff --git a/antispambot/models/__init__.py b/antispambot/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/antispambot/models/chat.py b/antispambot/models/chat.py new file mode 100644 index 0000000..4e49370 --- /dev/null +++ b/antispambot/models/chat.py @@ -0,0 +1,6 @@ +from antispambot.storage.base import BaseStorageModel + + +class Chat(BaseStorageModel): + username: str + groups: list[str] diff --git a/BotMicro/models/dictionaries.py b/antispambot/models/dictionary.py similarity index 57% rename from BotMicro/models/dictionaries.py rename to antispambot/models/dictionary.py index 7525988..af1a11f 100644 --- a/BotMicro/models/dictionaries.py +++ b/antispambot/models/dictionary.py @@ -1,14 +1,11 @@ -from typing import Optional -from odetam.async_model import AsyncDetaModel from pydantic import Field +from antispambot.storage.base import BaseStorageModel -class Dictionary(AsyncDetaModel): + +class Dictionary(BaseStorageModel): full_words: list[str] partial_words: list[str] regex_patterns: list[str] = Field(default_factory=list) stop_words: list[str] = Field(default_factory=list) profanity_filter: bool = False - - class Config: - table_name = 'dictionaries' diff --git a/BotMicro/models/events.py b/antispambot/models/event.py similarity index 53% rename from BotMicro/models/events.py rename to antispambot/models/event.py index c1e4d8c..f76c335 100644 --- a/BotMicro/models/events.py +++ b/antispambot/models/event.py @@ -1,11 +1,11 @@ from datetime import datetime from typing import Optional -from pydantic import BaseModel +from antispambot.storage.base import BaseStorageModel -class Event(BaseModel): - event: str = '' +class Event(BaseStorageModel): + event: str username: Optional[str] full_name: str time: datetime @@ -14,12 +14,8 @@ class Event(BaseModel): class StrikeMemberEvent(Event): - event = 'ban_user' + event: str = 'ban_user' class DeleteMessageEvent(Event): - event = 'delete_message' - - -class ProfanityFilterEvent(Event): - event = 'profanity_filter' + event: str = 'delete_message' diff --git a/BotMicro/models/group.py b/antispambot/models/group.py similarity index 53% rename from BotMicro/models/group.py rename to antispambot/models/group.py index fe90788..e9dde1a 100644 --- a/BotMicro/models/group.py +++ b/antispambot/models/group.py @@ -1,7 +1,7 @@ -from odetam.async_model import AsyncDetaModel +from antispambot.storage.base import BaseStorageModel -class Group(AsyncDetaModel): +class Group(BaseStorageModel): title: str active: bool strike_mode: bool diff --git a/BotMicro/models/member.py b/antispambot/models/member.py similarity index 66% rename from BotMicro/models/member.py rename to antispambot/models/member.py index 6b2696c..b670e9d 100644 --- a/BotMicro/models/member.py +++ b/antispambot/models/member.py @@ -1,8 +1,9 @@ -from odetam.async_model import AsyncDetaModel from pydantic import Field +from antispambot.storage.base import BaseStorageModel -class Member(AsyncDetaModel): + +class Member(BaseStorageModel): strikes_count: dict[str, int] messages_count: dict[str, int] = Field(default_factory=dict) nickname_pass: dict[str, bool] = Field(default_factory=dict) diff --git a/antispambot/storage/__init__.py b/antispambot/storage/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/antispambot/storage/base.py b/antispambot/storage/base.py new file mode 100644 index 0000000..bf94721 --- /dev/null +++ b/antispambot/storage/base.py @@ -0,0 +1,47 @@ +from pathlib import Path +from typing import Generic, Type, TypeAlias, TypeVar + +from pydantic import BaseModel, TypeAdapter + + +class BaseStorageModel(BaseModel): + key: str + + +T = TypeVar('T', bound=BaseStorageModel) +Models: TypeAlias = dict[str, T] + + +class JsonStorage(Generic[T]): + def __init__(self, model_type: Type[T], storage_path: Path) -> None: + self.storage_path = storage_path + self.model_type = model_type + self.models_adapter = TypeAdapter(Models[model_type]) + + def get(self, key: str) -> T | None: + models = self._load() + return models.get(key) + + def get_all(self) -> list[T]: + models = self._load() + return list(models.values()) + + def save(self, model: T) -> None: + models = self._load() + models[model.key] = model + self._dump(models) + + def delete(self, key: str) -> T | None: + models = self._load() + model = models.pop(key, None) + self._dump(models) + return model + + def _load(self) -> Models[T]: + if not self.storage_path.exists(): + return {} + + return self.models_adapter.validate_json(self.storage_path.read_bytes()) + + def _dump(self, models: Models) -> None: + self.storage_path.write_bytes(self.models_adapter.dump_json(models)) diff --git a/antispambot/storage/storages.py b/antispambot/storage/storages.py new file mode 100644 index 0000000..26dc8b0 --- /dev/null +++ b/antispambot/storage/storages.py @@ -0,0 +1,16 @@ +from pathlib import Path + +from antispambot.models.chat import Chat +from antispambot.models.dictionary import Dictionary +from antispambot.models.event import Event +from antispambot.models.group import Group +from antispambot.models.member import Member +from antispambot.storage.base import JsonStorage + +STORAGE_PATH = Path('./storage') + +chat_storage = JsonStorage(Chat, STORAGE_PATH.joinpath('chat.json')) +dictionary_storage = JsonStorage(Dictionary, STORAGE_PATH.joinpath('dictionary.json')) +event_storage = JsonStorage(Event, STORAGE_PATH.joinpath('event.json')) +group_storage = JsonStorage(Group, STORAGE_PATH.joinpath('group.json')) +member_storage = JsonStorage(Member, STORAGE_PATH.joinpath('member.json')) diff --git a/main.py b/main.py new file mode 100644 index 0000000..58c781a --- /dev/null +++ b/main.py @@ -0,0 +1,36 @@ +import asyncio +import logging +import sys + +from antispambot.bot.factory import create_bot, create_dispatcher + + +async def main(bot_token: str) -> None: + bot = create_bot(bot_token) + await bot.delete_webhook(drop_pending_updates=True) + dispatcher = create_dispatcher() + await dispatcher.start_polling(bot) + + +if __name__ == '__main__': + if len(sys.argv) < 2: + print('Usage: python main.py [--debug-log]') + exit(1) + + BOT_TOKEN = sys.argv[1] + + if '--debug-log' in sys.argv: + logging.basicConfig( + filename='logs.log', + level=logging.DEBUG, + format='%(asctime)s - %(levelname)s - %(message)s', + ) + else: + logging.basicConfig( + level=logging.INFO, + filename='logs.log', + format='%(asctime)s - %(levelname)s - %(message)s', + ) + logging.getLogger('aiogram').setLevel(logging.WARNING) + + asyncio.run(main(BOT_TOKEN)) diff --git a/predeploy.sh b/predeploy.sh new file mode 100755 index 0000000..f3afc07 --- /dev/null +++ b/predeploy.sh @@ -0,0 +1 @@ +zip -r antispambot.zip . -x ".venv/*" "**.mypy_cache/*" "**__pycache__/*" ".git/*" "./storage/*" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c97b8f4 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,45 @@ +aiofiles==24.1.0 +aiogram==3.13.1 +aiohappyeyeballs==2.4.0 +aiohttp==3.10.5 +aiosignal==1.3.1 +annotated-types==0.7.0 +anyio==4.6.0 +attrs==24.2.0 +autopep8==2.3.1 +certifi==2024.8.30 +click==8.1.7 +dnspython==2.6.1 +email_validator==2.2.0 +fastapi==0.115.0 +fastapi-cli==0.0.5 +frozenlist==1.4.1 +h11==0.14.0 +httpcore==1.0.5 +httptools==0.6.1 +httpx==0.27.2 +idna==3.10 +Jinja2==3.1.4 +magic-filter==1.0.12 +markdown-it-py==3.0.0 +MarkupSafe==2.1.5 +mdurl==0.1.2 +multidict==6.1.0 +pycodestyle==2.12.1 +pydantic==2.9.2 +pydantic_core==2.23.4 +Pygments==2.18.0 +python-dotenv==1.0.1 +python-multipart==0.0.10 +PyYAML==6.0.2 +rich==13.8.1 +shellingham==1.5.4 +sniffio==1.3.1 +starlette==0.38.5 +typer==0.12.5 +typing_extensions==4.12.2 +uvicorn==0.30.6 +uvloop==0.20.0 +watchfiles==0.24.0 +websockets==13.0.1 +yarl==1.11.1 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..f0e6383 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,7 @@ +[isort] +include_trailing_comma = true +use_parentheses = true +multi_line_output = 3 + +[pycodestyle] +max_line_length = 6969