Ver Fonte

Merge pull request #25 from librellium/refactor/major

Refactor/major
Librellium há 1 semana atrás
pai
commit
4086eaea1c
43 ficheiros alterados com 715 adições e 679 exclusões
  1. 1 1
      anonflow/__init__.py
  2. 88 85
      anonflow/app.py
  3. 15 8
      anonflow/bot/builders/middlewares.py
  4. 10 12
      anonflow/bot/builders/routers.py
  5. 0 22
      anonflow/bot/middleware/banned.py
  6. 0 67
      anonflow/bot/middleware/throttling.py
  7. 2 0
      anonflow/bot/middlewares/__init__.py
  8. 23 0
      anonflow/bot/middlewares/banned.py
  9. 24 0
      anonflow/bot/middlewares/language.py
  10. 8 7
      anonflow/bot/middlewares/not_registered.py
  11. 7 7
      anonflow/bot/middlewares/subscription.py
  12. 71 0
      anonflow/bot/middlewares/throttling.py
  13. 0 2
      anonflow/bot/routers/__init__.py
  14. 0 17
      anonflow/bot/routers/info.py
  15. 69 70
      anonflow/bot/routers/media.py
  16. 13 13
      anonflow/bot/routers/start.py
  17. 25 32
      anonflow/bot/routers/text.py
  18. 3 1
      anonflow/database/repositories/user.py
  19. 4 0
      anonflow/interfaces/__init__.py
  20. 13 0
      anonflow/interfaces/post.py
  21. 11 0
      anonflow/interfaces/user.py
  22. 4 1
      anonflow/moderation/__init__.py
  23. 15 0
      anonflow/moderation/events.py
  24. 13 18
      anonflow/moderation/executor.py
  25. 15 8
      anonflow/moderation/planner.py
  26. 6 6
      anonflow/moderation/rule_manager.py
  27. 28 0
      anonflow/moderation/service.py
  28. 5 6
      anonflow/services/__init__.py
  29. 0 10
      anonflow/services/accounts/__init__.py
  30. 0 0
      anonflow/services/moderator/__init__.py
  31. 0 0
      anonflow/services/moderator/exceptions.py
  32. 1 1
      anonflow/services/moderator/permissions.py
  33. 0 0
      anonflow/services/moderator/service.py
  34. 2 2
      anonflow/services/transport/__init__.py
  35. 22 14
      anonflow/services/transport/content.py
  36. 52 16
      anonflow/services/transport/delivery.py
  37. 0 59
      anonflow/services/transport/results.py
  38. 96 99
      anonflow/services/transport/router.py
  39. 9 0
      anonflow/services/transport/types.py
  40. 3 0
      anonflow/services/user/__init__.py
  41. 0 0
      anonflow/services/user/service.py
  42. 18 48
      anonflow/translator/translator.py
  43. 39 47
      translations/ru/LC_MESSAGES/messages.po

+ 1 - 1
anonflow/__init__.py

@@ -1,2 +1,2 @@
-__version__ = (0, 4, 0)
+__version__ = (0, 5, 0)
 __version_str__ = ".".join(map(str, __version__))

+ 88 - 85
anonflow/app.py

@@ -7,7 +7,7 @@ from aiogram.client.bot import DefaultBotProperties
 from aiogram.fsm.storage.memory import MemoryStorage
 
 from anonflow import __version_str__
-from anonflow.bot.builders.middleware import build as build_middleware
+from anonflow.bot.builders.middlewares import build as build_middlewares
 from anonflow.bot.builders.routers import build as build_routers
 from anonflow.config import Config
 from anonflow.database import (
@@ -19,12 +19,13 @@ from anonflow.database import (
 from anonflow.moderation import (
     ModerationExecutor,
     ModerationPlanner,
+    ModerationService,
     RuleManager
 )
 from anonflow.services import (
     DeliveryService,
-    MessageRouter,
     ModeratorService,
+    ResponsesRouter,
     UserService
 )
 from anonflow.translator import Translator
@@ -52,16 +53,18 @@ class Application:
     def __init__(self):
         self._logger = logging.getLogger(__name__)
 
-        self.bot: Optional[Bot] = None
-        self.dispatcher: Optional[Dispatcher] = None
-        self.config: Optional[Config] = None
-        self.database: Optional[Database] = None
-        self.moderator_service: Optional[ModeratorService] = None
-        self.user_service: Optional[UserService] = None
-        self.translator: Optional[Translator] = None
-        self.moderation_planner: Optional[ModerationPlanner] = None
-        self.moderation_executor: Optional[ModerationExecutor] = None
-        self.message_router: Optional[MessageRouter] = None
+        self._bot: Optional[Bot] = None
+        self._dispatcher: Optional[Dispatcher] = None
+        self._config: Optional[Config] = None
+        self._database: Optional[Database] = None
+        self._moderator_service: Optional[ModeratorService] = None
+        self._user_service: Optional[UserService] = None
+        self._translator: Optional[Translator] = None
+        self._rule_manager: Optional[RuleManager] = None
+        self._moderation_planner: Optional[ModerationPlanner] = None
+        self._moderation_executor: Optional[ModerationExecutor] = None
+        self._moderation_service: Optional[ModerationService] = None
+        self._responses_router: Optional[ResponsesRouter] = None
 
     def _init_config(self):
         config_filepath = paths.CONFIG_FILEPATH
@@ -70,10 +73,10 @@ class Application:
             Config().save(config_filepath)
             raise RuntimeError("Config file was just created. Please fill it out and restart the application.")
 
-        self.config = Config.load(config_filepath)
+        self._config = Config.load(config_filepath)
 
     def _init_logging(self):
-        with require(self, "config") as config:
+        with require(self, "_config") as config:
             logging.basicConfig(
                 format=config.logging.fmt,
                 datefmt=config.logging.date_fmt,
@@ -81,70 +84,51 @@ class Application:
             )
 
     async def _init_database(self):
-        with require(self, "config") as config:
-            self.database = Database(config.get_database_url())
-            await self.database.init()
+        with require(self, "_config") as config:
+            self._database = Database(config.get_database_url())
+            await self._database.init()
 
-            self.moderator_service = ModeratorService(
-                self.database,
+            self._moderator_service = ModeratorService(
+                self._database,
                 BanRepository(),
                 ModeratorRepository()
             )
-            await self.moderator_service.init()
-            self.user_service = UserService(
-                self.database,
+            await self._moderator_service.init()
+            self._user_service = UserService(
+                self._database,
                 UserRepository()
             )
 
     def _init_bot(self):
-        with require(self, "config") as config:
+        with require(self, "_config") as config:
             bot_token = config.bot.token
             if not bot_token:
                 raise ValueError("bot.token is required and cannot be empty")
 
-            self.bot = Bot(
+            self._bot = Bot(
                 token=bot_token.get_secret_value(),
                 default=DefaultBotProperties(parse_mode="HTML")
             )
-            self.dispatcher = Dispatcher(storage=MemoryStorage())
+            self._dispatcher = Dispatcher(storage=MemoryStorage())
 
-    async def _init_translator(self):
-        self.translator = Translator(translations_dir=paths.TRANSLATIONS_DIR)
-        await self.translator.init(self.bot)
+    def _init_translator(self):
+        self._translator = Translator(translations_dir=paths.TRANSLATIONS_DIR)
 
     def _init_transport(self):
         with require(
-            self, "bot", "config", "translator"
+            self, "_bot", "_config", "_translator"
         ) as (bot, config, translator):
-            self.message_router = MessageRouter(
+            self._responses_router = ResponsesRouter(
                 moderation_chat_ids=config.forwarding.moderation_chat_ids,
                 publication_channel_ids=config.forwarding.publication_channel_ids,
                 delivery_service=DeliveryService(bot),
                 translator=translator
             )
 
-    def _init_middleware(self):
-        with require(
-            self, "dispatcher", "config", "message_router", "user_service", "moderator_service"
-        ) as (dispatcher, config, message_router, user_service, moderator_service):
-            middlewares = build_middleware(
-                message_router=message_router,
-                user_service=user_service,
-                moderator_service=moderator_service,
-                subscription_requirement=config.behavior.subscription_requirement.enabled,
-                subscription_channel_ids=config.behavior.subscription_requirement.channel_ids,
-                throttling=config.behavior.throttling.enabled,
-                throttling_delay=config.behavior.throttling.delay,
-                throttling_allowed_chat_ids=config.forwarding.moderation_chat_ids
-            )
-
-            for middleware in middlewares:
-                dispatcher.update.middleware(middleware)
-
     def _init_moderation(self):
-        with require(self, "config") as config:
-            self.rule_manager = RuleManager(rules_dir=paths.RULES_DIR)
-            self.rule_manager.reload()
+        with require(self, "_config", "_responses_router") as (config, responses_router):
+            self._rule_manager = RuleManager(rules_dir=paths.RULES_DIR)
+            self._rule_manager.reload()
 
             api_key = config.openai.api_key
             if not api_key and config.moderation.enabled:
@@ -153,65 +137,84 @@ class Application:
             base_url = config.openai.base_url
             proxy = config.openai.proxy
 
-            self.moderation_planner = ModerationPlanner(
+            self._moderation_planner = ModerationPlanner(
                 api_key=api_key.get_secret_value() if api_key else None,
                 gpt_model=config.moderation.model,
                 backends=config.moderation.backends,
-                rule_manager=self.rule_manager,
+                rule_manager=self._rule_manager,
                 base_url=str(base_url) if base_url else None,
                 proxy=str(proxy) if proxy else None,
                 timeout=config.openai.timeout,
                 max_retries=config.openai.max_retries
             )
-            self.moderation_planner.set_enabled(config.moderation.enabled)
-            self.moderation_executor = ModerationExecutor(planner=self.moderation_planner)
+            self._moderation_planner.set_enabled(config.moderation.enabled)
+            self._moderation_executor = ModerationExecutor(self._moderation_planner)
+
+            self._moderation_service = ModerationService(
+                responses_router,
+                self._moderation_executor
+            )
+
+    def _init_routers(self):
+        with require(
+            self, "_dispatcher", "_config", "_responses_router", "_user_service", "_moderator_service", "_moderation_service"
+        ) as (dispatcher, config, responses_router, user_service, moderator_service, moderation_service):
+            dispatcher.include_router(
+                build_routers(
+                    config=config,
+                    responses_router=responses_router,
+                    user_service=user_service,
+                    moderator_service=moderator_service,
+                    moderation_service=moderation_service,
+                )
+            )
+
+    def _init_middleware(self):
+        with require(
+            self, "_dispatcher", "_config", "_responses_router", "_user_service", "_moderator_service"
+        ) as (dispatcher, config, responses_router, user_service, moderator_service):
+            middlewares = build_middlewares(
+                responses_router=responses_router,
+                user_service=user_service,
+                moderator_service=moderator_service,
+                subscription_requirement=config.behavior.subscription_requirement.enabled,
+                subscription_channel_ids=config.behavior.subscription_requirement.channel_ids,
+                throttling=config.behavior.throttling.enabled,
+                throttling_delay=config.behavior.throttling.delay,
+                throttling_allowed_chat_ids=config.forwarding.moderation_chat_ids
+            )
+
+            for middleware in middlewares:
+                dispatcher.update.middleware(middleware)
 
     async def init(self):
         self._init_config()
         self._init_logging()
         await self._init_database()
         self._init_bot()
-        await self._init_translator()
+        self._init_translator()
         self._init_transport()
-        self._init_middleware()
         self._init_moderation()
+        self._init_routers()
+        self._init_middleware()
 
     async def run(self):
         try:
             await self.init()
-        except Exception:
-            if self.bot:
-                await self.bot.session.close()
-            if self.database:
-                await self.database.close()
-            if self.moderation_planner:
-                await self.moderation_planner.close()
+        except:
+            if self._bot:
+                await self._bot.session.close()
+            if self._database:
+                await self._database.close()
+            if self._moderation_planner:
+                await self._moderation_planner.close()
             raise
 
         self._logger.info(f"Anonflow v{__version_str__} has been successfully initialized.")
 
         with require(
-            self,
-            "bot", "dispatcher", "config",
-            "database", "message_router",
-            "user_service", "moderator_service",
-            "moderation_planner", "moderation_executor"
-        ) as (
-            bot, dispatcher, config,
-            database, message_router,
-            user_service, moderator_service,
-            moderation_planner, moderation_executor
-        ):
-            dispatcher.include_router(
-                build_routers(
-                    config=config,
-                    message_router=message_router,
-                    user_service=user_service,
-                    moderator_service=moderator_service,
-                    moderation_executor=moderation_executor,
-                )
-            )
-
+            self, "_bot", "_dispatcher", "_database", "_moderation_planner"
+        ) as (bot, dispatcher, database, moderation_planner):
             try:
                 await dispatcher.start_polling(bot)
             finally:

+ 15 - 8
anonflow/bot/builders/middleware.py → anonflow/bot/builders/middlewares.py

@@ -3,20 +3,21 @@ from typing import Tuple
 from aiogram.types import ChatIdUnion
 
 from anonflow.services import (
-    MessageRouter,
     ModeratorService,
+    ResponsesRouter,
     UserService
 )
 
-from anonflow.bot.middleware import (
+from anonflow.bot.middlewares import (
     BannedMiddleware,
+    LanguageMiddleware,
     NotRegisteredMiddleware,
     SubscriptionMiddleware,
     ThrottlingMiddleware
 )
 
 def build(
-    message_router: MessageRouter,
+    responses_router: ResponsesRouter,
     user_service: UserService,
     moderator_service: ModeratorService,
 
@@ -29,9 +30,15 @@ def build(
 ):
     middlewares = []
 
+    middlewares.append(
+        LanguageMiddleware(
+            user_service=user_service
+        )
+    )
+
     middlewares.append(
         BannedMiddleware(
-            message_router=message_router,
+            responses_port=responses_router,
             moderator_service=moderator_service
         )
     )
@@ -39,14 +46,14 @@ def build(
     if subscription_requirement:
         middlewares.append(
             SubscriptionMiddleware(
-                channel_ids=subscription_channel_ids,
-                message_router=message_router
+                responses_port=responses_router,
+                channel_ids=subscription_channel_ids
             )
         )
 
     middlewares.append(
         NotRegisteredMiddleware(
-            message_router=message_router,
+            responses_port=responses_router,
             user_service=user_service
         )
     )
@@ -54,7 +61,7 @@ def build(
     if throttling:
         middlewares.append(
             ThrottlingMiddleware(
-                message_router=message_router,
+                responses_port=responses_router,
                 delay=throttling_delay,
                 allowed_chat_ids=throttling_allowed_chat_ids
             )

+ 10 - 12
anonflow/bot/builders/routers.py

@@ -1,11 +1,10 @@
 from aiogram import Router
 
 from anonflow.config import Config
-from anonflow.moderation import ModerationExecutor
-from anonflow.services import MessageRouter, ModeratorService, UserService
+from anonflow.moderation import ModerationService
+from anonflow.services import ModeratorService, ResponsesRouter, UserService
 
 from anonflow.bot.routers import (
-    InfoRouter,
     MediaRouter,
     StartRouter,
     TextRouter
@@ -13,28 +12,27 @@ from anonflow.bot.routers import (
 
 def build(
     config: Config,
-    message_router: MessageRouter,
+    responses_router: ResponsesRouter,
     user_service: UserService,
     moderator_service: ModeratorService,
-    moderation_executor: ModerationExecutor,
+    moderation_service: ModerationService,
 ) -> Router:
     main_router = Router()
 
     routers = [
         StartRouter(
-            message_router=message_router,
+            responses_port=responses_router,
             user_service=user_service
         ),
-        InfoRouter(message_router=message_router),
         TextRouter(
-            message_router=message_router,
-            forwarding_types=config.forwarding.types,
-            moderation_executor=moderation_executor
+            responses_port=responses_router,
+            moderation_service=moderation_service,
+            forwarding_types=config.forwarding.types
         ),
         MediaRouter(
-            message_router=message_router,
+            responses_port=responses_router,
+            moderation_service=moderation_service,
             forwarding_types=config.forwarding.types,
-            moderation_executor=moderation_executor
         ),
     ]
 

+ 0 - 22
anonflow/bot/middleware/banned.py

@@ -1,22 +0,0 @@
-from aiogram import BaseMiddleware
-from aiogram.types import Message
-
-from anonflow.services import MessageRouter, ModeratorService
-from anonflow.services.transport.results import UserBannedResult
-
-
-class BannedMiddleware(BaseMiddleware):
-    def __init__(self, message_router: MessageRouter, moderator_service: ModeratorService):
-        super().__init__()
-
-        self.message_router = message_router
-        self.moderator_service = moderator_service
-
-    async def __call__(self, handler, event, data):
-        message = getattr(event, "message", None)
-        if isinstance(message, Message):
-            if await self.moderator_service.is_banned(message.chat.id):
-                await self.message_router.dispatch(UserBannedResult(), message)
-                return
-
-        return await handler(event, data)

+ 0 - 67
anonflow/bot/middleware/throttling.py

@@ -1,67 +0,0 @@
-import asyncio
-import time
-from typing import Dict, Iterable, Optional
-
-from aiogram import BaseMiddleware
-from aiogram.types import ChatIdUnion, Message
-
-from anonflow.services import MessageRouter
-from anonflow.services.transport.results import UserThrottledResult
-
-
-class ThrottlingMiddleware(BaseMiddleware):
-    def __init__(
-        self,
-        message_router: MessageRouter,
-        delay: float,
-        allowed_chat_ids: Optional[Iterable[ChatIdUnion]]
-    ):
-        super().__init__()
-
-        self.message_router = message_router
-        self.delay = delay
-        self.allowed_chat_ids = allowed_chat_ids
-
-        self.user_times: Dict[int, float] = {}
-        self.user_locks: Dict[int, asyncio.Lock] = {}
-
-        self.lock = asyncio.Lock()
-
-    async def __call__(self, handler, event, data):
-        message = getattr(event, "message", None)
-        if isinstance(message, Message) and message.chat.id not in self.allowed_chat_ids:
-            text = message.text or message.caption or ""
-            if not text.startswith("/"):
-                async with self.lock:
-                    user_lock = self.user_locks.setdefault(message.chat.id, asyncio.Lock())
-
-                if user_lock.locked():
-                    start_time = self.user_times.get(message.chat.id) or 0
-                    current_time = time.monotonic()
-
-                    await self.message_router.dispatch(
-                        UserThrottledResult(
-                            remaining_time=(
-                                round(self.delay - (current_time - start_time))
-                                if start_time else 0
-                            )
-                        ),
-                        message
-                    )
-                    return
-
-                async with user_lock:
-                    start_time = time.monotonic()
-                    self.user_times[message.chat.id] = start_time
-
-                    result = await handler(event, data)
-
-                    elapsed_time = time.monotonic() - start_time
-                    await asyncio.sleep(max(0, self.delay - elapsed_time))
-
-                async with self.lock:
-                    self.user_locks.pop(message.chat.id)
-
-                return result
-
-        return await handler(event, data)

+ 2 - 0
anonflow/bot/middleware/__init__.py → anonflow/bot/middlewares/__init__.py

@@ -1,10 +1,12 @@
 from .banned import BannedMiddleware
+from .language import LanguageMiddleware
 from .not_registered import NotRegisteredMiddleware
 from .subscription import SubscriptionMiddleware
 from .throttling import ThrottlingMiddleware
 
 __all__ = [
     "BannedMiddleware",
+    "LanguageMiddleware",
     "NotRegisteredMiddleware",
     "SubscriptionMiddleware",
     "ThrottlingMiddleware"

+ 23 - 0
anonflow/bot/middlewares/banned.py

@@ -0,0 +1,23 @@
+from aiogram import BaseMiddleware
+from aiogram.types import Message
+
+from anonflow.interfaces import UserResponsesPort
+from anonflow.services import ModeratorService
+from anonflow.services.transport.types import RequestContext
+
+
+class BannedMiddleware(BaseMiddleware):
+    def __init__(self, responses_port: UserResponsesPort, moderator_service: ModeratorService):
+        super().__init__()
+
+        self._responses_port = responses_port
+        self._moderator_service = moderator_service
+
+    async def __call__(self, handler, event, data):
+        message = getattr(event, "message", None)
+        if isinstance(message, Message):
+            if await self._moderator_service.is_banned(message.chat.id):
+                await self._responses_port.user_banned(RequestContext(message.chat.id, data["user_language"]))
+                return
+
+        return await handler(event, data)

+ 24 - 0
anonflow/bot/middlewares/language.py

@@ -0,0 +1,24 @@
+from aiogram import BaseMiddleware
+from aiogram.types import Message
+
+from anonflow.services import UserService
+
+
+class LanguageMiddleware(BaseMiddleware):
+    def __init__(self, user_service: UserService):
+        super().__init__()
+
+        self._user_service = user_service
+
+    async def __call__(self, handler, event, data):
+        data["user_language"] = None
+
+        message = getattr(event, "message", None)
+        if isinstance(message, Message) and message.from_user:
+            user = await self._user_service.get(message.from_user.id)
+            data["user_language"] = (
+                user.language
+                if user else message.from_user.language_code
+            )
+
+        return await handler(event, data)

+ 8 - 7
anonflow/bot/middleware/not_registered.py → anonflow/bot/middlewares/not_registered.py

@@ -2,25 +2,26 @@ from aiogram import BaseMiddleware
 from aiogram.enums import ChatType
 from aiogram.types import Message
 
-from anonflow.services import MessageRouter, UserService
-from anonflow.services.transport.results import UserNotRegisteredResult
+from anonflow.interfaces import UserResponsesPort
+from anonflow.services import UserService
+from anonflow.services.transport.types import RequestContext
 
 
 class NotRegisteredMiddleware(BaseMiddleware):
-    def __init__(self, message_router: MessageRouter, user_service: UserService):
+    def __init__(self, responses_port: UserResponsesPort, user_service: UserService):
         super().__init__()
 
-        self.message_router = message_router
-        self.user_service = user_service
+        self._responses_port = responses_port
+        self._user_service = user_service
 
     async def __call__(self, handler, event, data):
         message = getattr(event, "message", None)
         if isinstance(message, Message) and message.chat.type == ChatType.PRIVATE:
             text = message.text or message.caption or ""
 
-            is_user_exists = await self.user_service.has(message.chat.id)
+            is_user_exists = await self._user_service.has(message.chat.id)
             if not is_user_exists and not text.startswith("/start"):
-                await self.message_router.dispatch(UserNotRegisteredResult(), message)
+                await self._responses_port.user_not_registered(RequestContext(message.chat.id, data["user_language"]))
                 return
 
         return await handler(event, data)

+ 7 - 7
anonflow/bot/middleware/subscription.py → anonflow/bot/middlewares/subscription.py

@@ -4,25 +4,25 @@ from aiogram import BaseMiddleware
 from aiogram.enums import ChatMemberStatus, ChatType
 from aiogram.types import ChatIdUnion, Message
 
-from anonflow.services import MessageRouter
-from anonflow.services.transport.results import UserSubscriptionRequiredResult
+from anonflow.interfaces import UserResponsesPort
+from anonflow.services.transport.types import RequestContext
 
 
 class SubscriptionMiddleware(BaseMiddleware):
-    def __init__(self, channel_ids: Tuple[ChatIdUnion], message_router: MessageRouter):
+    def __init__(self, responses_port: UserResponsesPort, channel_ids: Tuple[ChatIdUnion]):
         super().__init__()
 
-        self.channel_ids = channel_ids
-        self.message_router = message_router
+        self._responses_port = responses_port
+        self._channel_ids = channel_ids
 
     async def __call__(self, handler, event, data):
         message = getattr(event, "message", None)
         if isinstance(message, Message) and message.chat.type == ChatType.PRIVATE:
             user_id = message.from_user.id # type: ignore
-            for channel_id in self.channel_ids:
+            for channel_id in self._channel_ids:
                 member = await message.bot.get_chat_member(channel_id, user_id) # type: ignore
                 if member.status in (ChatMemberStatus.KICKED, ChatMemberStatus.LEFT):
-                    await self.message_router.dispatch(UserSubscriptionRequiredResult(), message)
+                    await self._responses_port.user_subscription_required(RequestContext(message.chat.id, data["user_language"]))
                     return
 
         return await handler(event, data)

+ 71 - 0
anonflow/bot/middlewares/throttling.py

@@ -0,0 +1,71 @@
+import asyncio
+import time
+from typing import Dict, Iterable, Optional
+
+from aiogram import BaseMiddleware
+from aiogram.types import ChatIdUnion, Message
+
+from anonflow.interfaces import UserResponsesPort
+from anonflow.services.transport.types import RequestContext
+
+
+class ThrottlingMiddleware(BaseMiddleware):
+    def __init__(
+        self,
+        responses_port: UserResponsesPort,
+        delay: float,
+        allowed_chat_ids: Optional[Iterable[ChatIdUnion]]
+    ):
+        super().__init__()
+
+        self._responses_port = responses_port
+        self._delay = delay
+        self._allowed_chat_ids = allowed_chat_ids
+
+        self._user_times: Dict[int, float] = {}
+        self._user_locks: Dict[int, asyncio.Lock] = {}
+
+        self._lock = asyncio.Lock()
+
+    async def __call__(self, handler, event, data):
+        message = getattr(event, "message", None)
+        if (
+            isinstance(message, Message)
+            and (
+                self._allowed_chat_ids is not None
+                and message.chat.id not in self._allowed_chat_ids
+            )
+        ):
+            text = message.text or message.caption or ""
+            if not text.startswith("/"):
+                async with self._lock:
+                    user_lock = self._user_locks.setdefault(message.chat.id, asyncio.Lock())
+
+                if user_lock.locked():
+                    start_time = self._user_times.get(message.chat.id) or 0
+                    current_time = time.monotonic()
+
+                    await self._responses_port.user_throttled(
+                        RequestContext(message.chat.id, data["user_language"]),
+                        remaining_time=(
+                            round(self._delay - (current_time - start_time))
+                            if start_time else 0
+                        )
+                    )
+                    return
+
+                async with user_lock:
+                    start_time = time.monotonic()
+                    self._user_times[message.chat.id] = start_time
+
+                    result = await handler(event, data)
+
+                    elapsed_time = time.monotonic() - start_time
+                    await asyncio.sleep(max(0, self._delay - elapsed_time))
+
+                async with self._lock:
+                    self._user_locks.pop(message.chat.id)
+
+                return result
+
+        return await handler(event, data)

+ 0 - 2
anonflow/bot/routers/__init__.py

@@ -1,10 +1,8 @@
-from .info import InfoRouter
 from .media import MediaRouter
 from .start import StartRouter
 from .text import TextRouter
 
 __all__ = [
-    "InfoRouter",
     "MediaRouter",
     "StartRouter",
     "TextRouter"

+ 0 - 17
anonflow/bot/routers/info.py

@@ -1,17 +0,0 @@
-from aiogram import Router
-from aiogram.filters import Command
-from aiogram.types import Message
-
-from anonflow.services import MessageRouter
-from anonflow.services.transport.results import CommandInfoResult
-
-
-class InfoRouter(Router):
-    def __init__(self, message_router: MessageRouter):
-        super().__init__()
-        self.message_router = message_router
-
-    def setup(self):
-        @self.message(Command("info"))
-        async def on_info(message: Message):
-            await self.message_router.dispatch(CommandInfoResult(), message)

+ 69 - 70
anonflow/bot/routers/media.py

@@ -10,35 +10,32 @@ from aiogram.enums import ChatType
 from aiogram.types import Message
 
 from anonflow.config.models import ForwardingType
-from anonflow.moderation import ModerationExecutor
-from anonflow.services.transport import MessageRouter
+from anonflow.interfaces import PostResponsesPort
+from anonflow.moderation import ModerationService
 from anonflow.services.transport.content import (
-    ContentMediaGroup,
+    ContentGroup,
     ContentMediaItem,
     MediaType
 )
-from anonflow.services.transport.results import (
-    ModerationDecisionResult,
-    PostPreparedResult
-)
+from anonflow.services.transport.types import RequestContext
 
 
 class MediaRouter(Router):
     def __init__(
         self,
-        message_router: MessageRouter,
-        forwarding_types: FrozenSet[ForwardingType],
-        moderation_executor: ModerationExecutor,
+        responses_port: PostResponsesPort,
+        moderation_service: ModerationService,
+        forwarding_types: FrozenSet[ForwardingType]
     ):
         super().__init__()
 
-        self.message_router = message_router
-        self.forwarding_types = forwarding_types
-        self.moderation_executor = moderation_executor
+        self._responses_port = responses_port
+        self._moderation_service = moderation_service
+        self._forwarding_types = forwarding_types
 
-        self.media_groups: Dict[str, List[Message]] = {}
-        self.media_groups_tasks: Dict[str, asyncio.Task] = {}
-        self.media_groups_lock = asyncio.Lock()
+        self._media_groups: Dict[str, List[Message]] = {}
+        self._media_groups_tasks: Dict[str, asyncio.Task] = {}
+        self._media_groups_lock = asyncio.Lock()
 
     @staticmethod
     async def get_b64image(message: Message):
@@ -53,72 +50,74 @@ class MediaRouter(Router):
 
     def _can_send_media(self, msgs: List[Message]):
         return any(
-            (msg.photo and "photo" in self.forwarding_types) or
-            (msg.video and "video" in self.forwarding_types)
+            (msg.photo and "photo" in self._forwarding_types) or
+            (msg.video and "video" in self._forwarding_types)
             for msg in msgs
         )
 
     def _get_media(self, message: Message):
-        if message.photo and "photo" in self.forwarding_types:
+        if message.photo and "photo" in self._forwarding_types:
             return {"type": MediaType.PHOTO, "file_id": message.photo[-1].file_id}
-        elif message.video and "video" in self.forwarding_types:
+        elif message.video and "video" in self._forwarding_types:
             return {"type": MediaType.VIDEO, "file_id": message.video.file_id}
 
-    def setup(self):
-        async def process_messages(messages: List[Message]):
-            if not messages:
-                return
-
-            if self._can_send_media(messages):
-                moderation_approved = False
-
-                content_group = ContentMediaGroup()
-                caption = next((msg.caption for msg in messages if msg.caption), "")
-                for message in messages:
-                    async for result in self.moderation_executor.process(
-                        message.caption,
-                        await self.get_b64image(message)
-                    ):
-                        if isinstance(result, ModerationDecisionResult):
-                            moderation_approved = result.is_approved
-                        await self.message_router.dispatch(result, message)
-
-                    media = self._get_media(message)
-                    if media:
-                        content_group.items.append(ContentMediaItem(**media, caption=caption))
-
-                await self.message_router.dispatch(
-                    PostPreparedResult(content_group, moderation_approved),
-                    messages[0]
-                )
+    async def _process_messages(self, messages: List[Message], user_language: str):
+        if not messages:
+            return
+
+        if not self._can_send_media(messages):
+            return
 
-        @self.message(F.photo | F.video)
-        async def on_photo(message: Message):
-            if message.chat.type != ChatType.PRIVATE:
-                return
+        context = RequestContext(messages[0].chat.id, user_language)
+        is_approved = False
 
-            media_group_id = message.media_group_id
+        content_group = ContentGroup()
+        caption = next((msg.caption for msg in messages if msg.caption), "")
 
-            async def await_media_group():
-                with suppress(CancelledError):
-                    await asyncio.sleep(2)
-                    async with self.media_groups_lock:
-                        messages = self.media_groups.pop(media_group_id, []) # type: ignore
-                        self.media_groups_tasks.pop(media_group_id, None) # type: ignore
+        for message in messages:
+            is_approved = await self._moderation_service.process(
+                context,
+                message.caption,
+                await self.get_b64image(message)
+            )
+
+            media = self._get_media(message)
+            if media:
+                content_group.append(ContentMediaItem(**media, caption=caption))
+
+        await self._responses_port.post_prepared(
+            context, content_group, is_approved
+        )
 
-                    await process_messages(messages)
+    async def _on_media(self, message: Message, user_language: str):
+        if message.chat.type != ChatType.PRIVATE:
+            return
 
-            if media_group_id:
-                async with self.media_groups_lock:
-                    self.media_groups.setdefault(media_group_id, []).append(message)
+        media_group_id = message.media_group_id
 
-                    task = self.media_groups_tasks.get(media_group_id)
-                    if task:
-                        task.cancel()
+        async def await_media_group():
+            with suppress(CancelledError):
+                await asyncio.sleep(2)
+                async with self._media_groups_lock:
+                    messages = self.media_groups.pop(media_group_id, []) # type: ignore
+                    self.media_groups_tasks.pop(media_group_id, None) # type: ignore
 
-                    self.media_groups_tasks[media_group_id] = asyncio.create_task(
-                        await_media_group()
-                    )
-                return
+                await self._process_messages(messages, user_language)
 
-            await process_messages([message])
+        if media_group_id:
+            async with self._media_groups_lock:
+                self._media_groups.setdefault(media_group_id, []).append(message)
+
+                task = self._media_groups_tasks.get(media_group_id)
+                if task:
+                    task.cancel()
+
+                self._media_groups_tasks[media_group_id] = asyncio.create_task(
+                    await_media_group()
+                )
+            return
+
+        await self._process_messages([message], user_language)
+
+    def setup(self):
+        self.message.register(self._on_media, F.photo | F.video)

+ 13 - 13
anonflow/bot/routers/start.py

@@ -2,22 +2,22 @@ from aiogram import Router
 from aiogram.filters import CommandStart
 from aiogram.types import Message
 
-from anonflow.services import MessageRouter, UserService
-from anonflow.services.transport.results import CommandStartResult
+from anonflow.services import UserService
+from anonflow.interfaces import UserResponsesPort
+from anonflow.services.transport.types import RequestContext
 
 
 class StartRouter(Router):
-    def __init__(self, message_router: MessageRouter, user_service: UserService):
+    def __init__(self, responses_port: UserResponsesPort, user_service: UserService):
         super().__init__()
-        self.message_router = message_router
-        self.user_service = user_service
+
+        self._responses_port = responses_port
+        self._user_service = user_service
+
+    async def _on_start(self, message: Message, user_language: str):
+        if message.from_user:
+            await self._user_service.add(message.from_user.id)
+        await self._responses_port.user_start(RequestContext(message.chat.id, user_language))
 
     def setup(self):
-        @self.message(CommandStart())
-        async def on_start(message: Message):
-            if message.from_user:
-                await self.user_service.add(message.from_user.id)
-            await self.message_router.dispatch(
-                CommandStartResult(),
-                message
-            )
+        self.message.register(self._on_start, CommandStart())

+ 25 - 32
anonflow/bot/routers/text.py

@@ -5,46 +5,39 @@ from aiogram.enums import ChatType
 from aiogram.types import Message
 
 from anonflow.config.models import ForwardingType
-from anonflow.moderation import ModerationExecutor
-from anonflow.services.transport import MessageRouter
+from anonflow.interfaces import PostResponsesPort
+from anonflow.moderation import ModerationService
 from anonflow.services.transport.content import ContentTextItem
-from anonflow.services.transport.results import (
-    ModerationDecisionResult,
-    PostPreparedResult
-)
+from anonflow.services.transport.types import RequestContext
 
 
 class TextRouter(Router):
     def __init__(
         self,
-        message_router: MessageRouter,
-        forwarding_types: FrozenSet[ForwardingType],
-        moderation_executor: ModerationExecutor,
+        responses_port: PostResponsesPort,
+        moderation_service: ModerationService,
+        forwarding_types: FrozenSet[ForwardingType]
     ):
         super().__init__()
 
-        self.message_router = message_router
-        self.forwarding_types = forwarding_types
-        self.moderation_executor = moderation_executor
+        self._responses_port = responses_port
+        self._moderation_service = moderation_service
+        self._forwarding_types = forwarding_types
+
+    async def _on_text(self, message: Message, user_language: str):
+        if (
+            message.chat.type == ChatType.PRIVATE
+            and "text" in self._forwarding_types
+        ):
+            context = RequestContext(message.chat.id, user_language)
+
+            is_approved = await self._moderation_service.process(
+                context, message.text
+            )
+
+            await self._responses_port.post_prepared(
+                context, ContentTextItem(message.text or ""), is_approved
+            )
 
     def setup(self):
-        @self.message(F.text)
-        async def on_text(message: Message):
-            if (
-                message.chat.type == ChatType.PRIVATE
-                and "text" in self.forwarding_types
-            ):
-                moderation_approved = False
-
-                async for result in self.moderation_executor.process(message.text):
-                    if isinstance(result, ModerationDecisionResult):
-                        moderation_approved = result.is_approved
-                    await self.message_router.dispatch(result, message)
-
-                await self.message_router.dispatch(
-                    PostPreparedResult(
-                        ContentTextItem(message.text or ""),
-                        moderation_approved
-                    ),
-                    message
-                )
+        self.message.register(self._on_text, F.text)

+ 3 - 1
anonflow/database/repositories/user.py

@@ -1,3 +1,5 @@
+from typing import Optional
+
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import joinedload, selectinload
 
@@ -15,7 +17,7 @@ class UserRepository(BaseRepository):
             model_args={"user_id": user_id}
         )
 
-    async def get(self, session: AsyncSession, user_id: int):
+    async def get(self, session: AsyncSession, user_id: int) -> Optional[User]:
         return await super()._get(
             session,
             filters={"user_id": user_id},

+ 4 - 0
anonflow/interfaces/__init__.py

@@ -0,0 +1,4 @@
+from .post import PostResponsesPort
+from .user import UserResponsesPort
+
+__all__ = ["PostResponsesPort", "UserResponsesPort"]

+ 13 - 0
anonflow/interfaces/post.py

@@ -0,0 +1,13 @@
+from typing import Protocol, Union
+
+from anonflow.services.transport.content import (
+    ContentGroup,
+    ContentItem
+)
+from anonflow.services.transport.types import RequestContext
+
+
+class PostResponsesPort(Protocol):
+    async def post_prepared(self, context: RequestContext, content: Union[ContentItem, ContentGroup], is_approved: bool): ...
+    async def post_moderation_decision(self, context: RequestContext, is_approved: bool, reason: str): ...
+    async def post_moderation_started(self, context: RequestContext): ...

+ 11 - 0
anonflow/interfaces/user.py

@@ -0,0 +1,11 @@
+from typing import Protocol
+
+from anonflow.services.transport.types import RequestContext
+
+
+class UserResponsesPort(Protocol):
+    async def user_banned(self, context: RequestContext): ...
+    async def user_not_registered(self, context: RequestContext): ...
+    async def user_start(self, context: RequestContext): ...
+    async def user_subscription_required(self, context: RequestContext): ...
+    async def user_throttled(self, context: RequestContext, remaining_time: int): ...

+ 4 - 1
anonflow/moderation/__init__.py

@@ -1,8 +1,11 @@
-from .executor import ModerationExecutor, ModerationPlanner
+from .executor import ModerationExecutor
+from .planner import ModerationPlanner
 from .rule_manager import RuleManager
+from .service import ModerationService
 
 __all__ = [
     "ModerationExecutor",
     "ModerationPlanner",
     "RuleManager",
+    "ModerationService"
 ]

+ 15 - 0
anonflow/moderation/events.py

@@ -0,0 +1,15 @@
+from dataclasses import dataclass
+
+
+@dataclass(frozen=True)
+class Event:
+    pass
+
+@dataclass(frozen=True)
+class ModerationDecisionEvent(Event):
+    is_approved: bool
+    reason: str
+
+@dataclass(frozen=True)
+class ModerationStartedEvent(Event):
+    pass

+ 13 - 18
anonflow/moderation/executor.py

@@ -1,27 +1,22 @@
 import asyncio
 import logging
 import textwrap
-from typing import AsyncGenerator, Literal, Optional
-
-from anonflow.services.transport.results import (
-    Results,
-    ModerationDecisionResult,
-    ModerationStartedResult
-)
+from typing import AsyncGenerator, Optional, Literal
 
+from .events import Event, ModerationDecisionEvent, ModerationStartedEvent
 from .planner import ModerationPlanner
 
 
 class ModerationExecutor:
-    def __init__(self, planner: ModerationPlanner):
+    def __init__(self, moderation_planner: ModerationPlanner):
         self._logger = logging.getLogger(__name__)
 
-        self.planner = planner
-        self.planner.set_functions(self.moderation_decision)
+        self._moderation_planner = moderation_planner
+        self._moderation_planner.set_functions(self.moderation_decision)
 
     def moderation_decision(self, status: Literal["approve", "reject"], reason: str):
         moderation_map = {"approve": True, "reject": False}
-        return ModerationDecisionResult(is_approved=moderation_map.get(status.lower(), False), reason=reason)
+        return ModerationDecisionEvent(is_approved=moderation_map.get(status.lower(), False), reason=reason)
     moderation_decision.description = textwrap.dedent( # type: ignore
         """
         Processes a message with a moderation decision by status and reason.
@@ -30,15 +25,15 @@ class ModerationExecutor:
         """
     ).strip()
 
-    async def process(self, text: Optional[str] = None, image: Optional[str] = None) -> AsyncGenerator[Results, None]:
-        yield ModerationStartedResult()
+    async def process(self, text: Optional[str] = None, image: Optional[str] = None) -> AsyncGenerator[Event, None]:
+        yield ModerationStartedEvent()
 
-        functions = await self.planner.plan(text, image)
-        function_names = self.planner.get_function_names()
+        functions = await self._moderation_planner.plan(text, image)
+        function_names = self._moderation_planner.get_function_names()
 
-        for func in functions:
-            func_name = func.get("name", "")
-            func_args = func.get("args", {})
+        for function in functions:
+            func_name = function.get("name", "")
+            func_args = function.get("args", {})
 
             method = getattr(self, func_name, None)
 

+ 15 - 8
anonflow/moderation/planner.py

@@ -49,7 +49,7 @@ class ModerationPlanner:
             "http_client": self._client
         }
 
-        self.rule_manager = rule_manager
+        self._rule_manager = rule_manager
 
         self._enabled = False
         self._functions: List[Dict[str, Any]] = []
@@ -111,6 +111,9 @@ class ModerationPlanner:
 
         return "\n".join(lines)
 
+    def _build_rules(self):
+        return "\n\n".join(self._rule_manager.get_rules())
+
     async def _run_omni(self, text: Optional[str] = None, image: Optional[str] = None):
         if self.is_backend_enabled("omni"):
             content = self._build_content(text, image)
@@ -130,6 +133,7 @@ class ModerationPlanner:
 
         if self.is_backend_enabled("gpt"):
             functions_prompt = self._build_functions_prompt(self._functions)
+            rules_prompt = self._build_rules()
 
             output = None
             for attempt in range(self._max_retries + 1):
@@ -153,13 +157,12 @@ class ModerationPlanner:
                                     - Do not omit required arguments.
                                     Available functions:
                                     {functions_prompt}
+
+                                    **RULES:**
+                                    {rules_prompt}
                                     '''
                                 ).strip(),
                             },
-                            {
-                                "role": "system",
-                                "content": "\n\n".join(self.rule_manager.get_rules())
-                            },
                             {
                                 "role": "user",
                                 "content": text
@@ -216,8 +219,8 @@ class ModerationPlanner:
             return
 
         self._functions.clear()
-        for func in functions:
-            sig = inspect.signature(func)
+        for function in functions:
+            sig = inspect.signature(function)
             args = {
                 name: (
                     getattr(param.annotation, "__name__", str(param.annotation))
@@ -227,7 +230,11 @@ class ModerationPlanner:
             }
 
             self._functions.append(
-                {"name": func.__name__, "args": args, "description": func.description or ""}
+                {
+                    "name": function.__name__,
+                    "args": args,
+                    "description": function.description or ""
+                }
             )
 
         function_names = self.get_function_names()

+ 6 - 6
anonflow/moderation/rule_manager.py

@@ -8,16 +8,16 @@ class RuleManager:
     def __init__(self, rules_dir: Path):
         self._logger = logging.getLogger(__name__)
 
-        self.rules_dir = rules_dir
+        self._rules_dir = rules_dir
         self._rules: List[str] = []
 
     def reload(self):
-        if not self.rules_dir.exists():
-            self.rules_dir.mkdir(parents=True, exist_ok=True)
+        if not self._rules_dir.exists():
+            self._rules_dir.mkdir(parents=True, exist_ok=True)
 
         self._rules.clear()
-        for rule_filename in listdir(self.rules_dir):
-            rule_filepath = Path(self.rules_dir / rule_filename).resolve()
+        for rule_filename in listdir(self._rules_dir):
+            rule_filepath = Path(self._rules_dir / rule_filename).resolve()
             with rule_filepath.open(encoding="utf-8") as rule_file:
                 rule = rule_file.read()
                 if rule:
@@ -26,4 +26,4 @@ class RuleManager:
         self._logger.info("Rules loaded. Total=%d", len(self._rules))
 
     def get_rules(self):
-        return self._rules
+        return self._rules.copy()

+ 28 - 0
anonflow/moderation/service.py

@@ -0,0 +1,28 @@
+from typing import Optional
+
+from anonflow.interfaces import PostResponsesPort
+from anonflow.services.transport.types import RequestContext
+
+from .events import ModerationDecisionEvent, ModerationStartedEvent
+from .executor import ModerationExecutor
+
+
+class ModerationService:
+    def __init__(
+        self,
+        responses_port: PostResponsesPort,
+        moderation_executor: ModerationExecutor
+    ):
+        self._responses_port = responses_port
+        self._moderation_executor = moderation_executor
+
+    async def process(self, context: RequestContext, text: Optional[str] = None, image: Optional[str] = None):
+        is_approved = False
+        async for event in self._moderation_executor.process(text, image):
+            if isinstance(event, ModerationDecisionEvent):
+                is_approved = event.is_approved
+                await self._responses_port.post_moderation_decision(context, event.is_approved, event.reason)
+            elif isinstance(event, ModerationStartedEvent):
+                await self._responses_port.post_moderation_started(context)
+
+        return is_approved

+ 5 - 6
anonflow/services/__init__.py

@@ -1,11 +1,10 @@
-from .accounts.moderator import ModeratorService
-from .accounts.user import UserService
-from .transport.delivery import DeliveryService
-from .transport.router import MessageRouter
+from .moderator import ModeratorService
+from .transport import DeliveryService, ResponsesRouter
+from .user import UserService
 
 __all__ = [
     "ModeratorService",
-    "UserService",
     "DeliveryService",
-    "MessageRouter",
+    "ResponsesRouter",
+    "UserService",
 ]

+ 0 - 10
anonflow/services/accounts/__init__.py

@@ -1,10 +0,0 @@
-from .moderator import ModeratorService
-from .moderator.exceptions import ModeratorPermissionError, SelfActionError
-from .user import UserService
-
-__all__ = [
-    "ModeratorService",
-    "ModeratorPermissionError",
-    "SelfActionError",
-    "UserService"
-]

+ 0 - 0
anonflow/services/accounts/moderator/__init__.py → anonflow/services/moderator/__init__.py


+ 0 - 0
anonflow/services/accounts/moderator/exceptions.py → anonflow/services/moderator/exceptions.py


+ 1 - 1
anonflow/services/accounts/moderator/permissions.py → anonflow/services/moderator/permissions.py

@@ -2,7 +2,7 @@ from dataclasses import dataclass, asdict
 from enum import Enum
 
 
-@dataclass(frozen=True)
+@dataclass
 class ModeratorPermissions:
     can_approve_posts: bool = False
     can_manage_bans: bool = False

+ 0 - 0
anonflow/services/accounts/moderator/service.py → anonflow/services/moderator/service.py


+ 2 - 2
anonflow/services/transport/__init__.py

@@ -1,7 +1,7 @@
 from .delivery import DeliveryService
-from .router import MessageRouter
+from .router import ResponsesRouter
 
 __all__ = [
     "DeliveryService",
-    "MessageRouter"
+    "ResponsesRouter"
 ]

+ 22 - 14
anonflow/services/transport/content.py

@@ -1,30 +1,38 @@
-from dataclasses import dataclass, field
+from abc import ABC, abstractmethod
+from dataclasses import dataclass
 from enum import Enum
-from typing import List, Optional
+from typing import Callable, Iterable, Optional
 
 
 class MediaType(str, Enum):
     PHOTO = "photo"
     VIDEO = "video"
 
-@dataclass(frozen=True)
-class ContentItem:
-    pass
+@dataclass
+class ContentItem(ABC):
+    @abstractmethod
+    def translate(self, translator: Callable): ...
 
-@dataclass(frozen=True)
-class ContentGroup:
-    pass
-
-@dataclass(frozen=True)
+@dataclass
 class ContentTextItem(ContentItem):
     text: str
 
-@dataclass(frozen=True)
+    def translate(self, translator: Callable):
+        self.text = translator(self.text)
+
+@dataclass
 class ContentMediaItem(ContentItem):
     type: MediaType
     file_id: str
     caption: Optional[str] = None
 
-@dataclass(frozen=True)
-class ContentMediaGroup(ContentGroup):
-    items: List[ContentMediaItem] = field(default_factory=list)
+    def translate(self, translator: Callable):
+        self.caption = translator(self.caption)
+
+class ContentGroup(list):
+    def __init__(self, items: Optional[Iterable[ContentItem]] = None):
+        return super().__init__(items or [])
+
+    def translate(self, translator: Callable):
+        for item in self:
+            item.translate(translator)

+ 52 - 16
anonflow/services/transport/delivery.py

@@ -1,4 +1,4 @@
-from typing import Optional, Union
+from typing import Optional, List, Union
 
 from aiogram import Bot
 from aiogram.client.bot import Default
@@ -6,10 +6,16 @@ from aiogram.types import (
     ChatIdUnion,
     InputMediaPhoto,
     InputMediaVideo,
+    MediaUnion,
     ReplyMarkupUnion
 )
 
-from .content import ContentMediaGroup, ContentMediaItem, MediaType
+from .content import (
+    ContentGroup,
+    ContentItem,
+    ContentTextItem,
+    MediaType
+)
 
 
 class DeliveryService:
@@ -17,24 +23,57 @@ class DeliveryService:
         self._bot = bot
 
     @staticmethod
-    def _wrap_media(item: ContentMediaItem):
+    def _wrap_content_item(item, parse_mode: Optional[Union[str, Default]] = Default("parse_mode")):
         if item.type == MediaType.PHOTO:
-            return InputMediaPhoto(media=item.file_id, caption=item.caption)
+            return InputMediaPhoto(media=item.file_id, caption=item.caption, parse_mode=parse_mode)
         elif item.type == MediaType.VIDEO:
-            return InputMediaVideo(media=item.file_id, caption=item.caption)
+            return InputMediaVideo(media=item.file_id, caption=item.caption, parse_mode=parse_mode)
         else:
             raise ValueError("Media item type is invalid.")
 
+    async def delete(self, chat_id: ChatIdUnion, message_id: int):
+        return await self._bot.delete_message(chat_id, message_id)
+
+    async def send_content(
+        self,
+        chat_id: ChatIdUnion,
+        content: Union[ContentItem, ContentGroup],
+        parse_mode: Optional[Union[str, Default]] = Default("parse_mode"),
+        reply_markup: Optional[ReplyMarkupUnion] = None
+    ):
+        if isinstance(content, ContentTextItem):
+            await self.send_text(
+                chat_id,
+                content.text,
+                parse_mode=parse_mode,
+                reply_markup=reply_markup
+            )
+        elif isinstance(content, ContentGroup):
+            if len(content) > 1:
+                await self.send_media_group(
+                    chat_id,
+                    [
+                        self._wrap_content_item(item, parse_mode)
+                        for item in content
+                    ]
+                )
+            elif len(content) == 1:
+                await self.send_media(
+                    chat_id,
+                    self._wrap_content_item(content[0]),
+                    parse_mode=parse_mode,
+                    reply_markup=reply_markup
+                )
+
     async def send_media(
         self,
         chat_id: ChatIdUnion,
-        media_item: ContentMediaItem,
+        media: MediaUnion,
         parse_mode: Optional[Union[str, Default]] = Default("parse_mode"),
         reply_markup: Optional[ReplyMarkupUnion] = None
     ):
-        media = self._wrap_media(media_item)
         if isinstance(media, InputMediaPhoto):
-            await self._bot.send_photo(
+            return await self._bot.send_photo(
                 chat_id,
                 media.media,
                 caption=media.caption,
@@ -42,7 +81,7 @@ class DeliveryService:
                 reply_markup=reply_markup
             )
         elif isinstance(media, InputMediaVideo):
-            await self._bot.send_video(
+            return await self._bot.send_video(
                 chat_id,
                 media.media,
                 caption=media.caption,
@@ -53,14 +92,11 @@ class DeliveryService:
     async def send_media_group(
         self,
         chat_id: ChatIdUnion,
-        media_group: ContentMediaGroup,
+        media_group: List[MediaUnion],
     ):
-        await self._bot.send_media_group(
+        return await self._bot.send_media_group(
             chat_id=chat_id,
-            media=[
-                self._wrap_media(item)
-                for item in media_group.items
-            ]
+            media=media_group
         )
 
     async def send_text(
@@ -70,7 +106,7 @@ class DeliveryService:
         parse_mode: Optional[Union[str, Default]] = Default("parse_mode"),
         reply_markup: Optional[ReplyMarkupUnion] = None
     ):
-        await self._bot.send_message(
+        return await self._bot.send_message(
             chat_id=chat_id,
             text=text,
             parse_mode=parse_mode,

+ 0 - 59
anonflow/services/transport/results.py

@@ -1,59 +0,0 @@
-from dataclasses import dataclass
-from typing import TypeAlias, Union
-
-from .content import ContentMediaGroup, ContentMediaItem, ContentTextItem
-
-
-@dataclass(frozen=True)
-class Result:
-    pass
-
-@dataclass(frozen=True)
-class CommandInfoResult(Result):
-    pass
-
-@dataclass(frozen=True)
-class CommandStartResult(Result):
-    pass
-
-@dataclass(frozen=True)
-class PostPreparedResult(Result):
-    content: Union[ContentTextItem, ContentMediaItem, ContentMediaGroup]
-    moderation_approved: bool
-
-@dataclass(frozen=True)
-class ModerationDecisionResult(Result):
-    is_approved: bool
-    reason: str
-
-@dataclass(frozen=True)
-class ModerationStartedResult(Result):
-    pass
-
-@dataclass(frozen=True)
-class UserBannedResult(Result):
-    pass
-
-@dataclass(frozen=True)
-class UserSubscriptionRequiredResult(Result):
-    pass
-
-@dataclass(frozen=True)
-class UserThrottledResult(Result):
-    remaining_time: int
-
-@dataclass(frozen=True)
-class UserNotRegisteredResult(Result):
-    pass
-
-Results: TypeAlias = Union[
-    CommandInfoResult,
-    CommandStartResult,
-    PostPreparedResult,
-    ModerationDecisionResult,
-    ModerationStartedResult,
-    UserBannedResult,
-    UserSubscriptionRequiredResult,
-    UserThrottledResult,
-    UserNotRegisteredResult
-]

+ 96 - 99
anonflow/services/transport/router.py

@@ -1,27 +1,17 @@
 from itertools import chain
-from typing import Any, Callable, Dict, Tuple
+from typing import Tuple, Union
 
-from aiogram.types import ChatIdUnion, Message
+from aiogram.types import ChatIdUnion
 
+from anonflow.interfaces import PostResponsesPort, UserResponsesPort
 from anonflow.translator import Translator
 
-from .content import ContentMediaGroup, ContentTextItem
+from .content import ContentGroup, ContentItem
 from .delivery import DeliveryService
-from .results import (
-    Results,
-    CommandInfoResult,
-    CommandStartResult,
-    ModerationDecisionResult,
-    ModerationStartedResult,
-    PostPreparedResult,
-    UserBannedResult,
-    UserNotRegisteredResult,
-    UserSubscriptionRequiredResult,
-    UserThrottledResult
-)
-
-
-class MessageRouter:
+from .types import RequestContext
+
+
+class ResponsesRouter(PostResponsesPort, UserResponsesPort):
     def __init__(
         self,
         moderation_chat_ids: Tuple[ChatIdUnion],
@@ -29,107 +19,114 @@ class MessageRouter:
         delivery_service: DeliveryService,
         translator: Translator
     ):
-        self.moderation_chat_ids = moderation_chat_ids
-        self.publication_channel_ids = publication_channel_ids
-        self.delivery_service = delivery_service
-        self.translator = translator
-
-        self._handlers: Dict[Any, Callable] = {
-            CommandInfoResult: self._handle_command_info,
-            CommandStartResult: self._handle_command_start,
-            PostPreparedResult: self._handle_post_prepared,
-            ModerationStartedResult: self._handle_moderation_started,
-            ModerationDecisionResult: self._handle_moderation_decision,
-            UserBannedResult: self._handle_user_banned,
-            UserNotRegisteredResult: self._handle_user_not_registered,
-            UserSubscriptionRequiredResult: self._handle_user_subscription_required,
-            UserThrottledResult: self._handle_user_throttled
-        }
-
-    async def _handle_command_info(self, result: CommandInfoResult, message: Message, _):
-        await self.delivery_service.send_text(message.chat.id, _("messages.user.command_info", message=message))
-
-    async def _handle_command_start(self, result: CommandStartResult, message: Message, _):
-        await self.delivery_service.send_text(message.chat.id, _("messages.user.command_start", message=message))
-
-    async def _handle_post_prepared(self, result: PostPreparedResult, message: Message, _):
-        chat_ids = (
-            chain(self.moderation_chat_ids, self.publication_channel_ids)
-            if result.moderation_approved
-            else iter(self.moderation_chat_ids)
-        )
+        self._moderation_chat_ids = moderation_chat_ids
+        self._publication_channel_ids = publication_channel_ids
+        self._delivery_service = delivery_service
+        self._translator = translator
 
-        content = result.content
-        for chat_id in chat_ids:
-            if isinstance(content, ContentTextItem):
-                await self.delivery_service.send_text(chat_id, _("messages.channel.text", text=content.text))
-            elif isinstance(content, ContentMediaGroup):
-                items = content.items
-                if len(items) > 1:
-                    await self.delivery_service.send_media_group(chat_id, content)
-                elif len(items) == 1:
-                    await self.delivery_service.send_media(chat_id, items[0])
-
-        if result.moderation_approved:
-            await message.answer(_("messages.user.moderation_approved", message=message))
-
-    async def _handle_moderation_started(self, result: ModerationStartedResult, message: Message, _):
-        await self.delivery_service.send_text(
-            message.chat.id,
-            _("messages.user.moderation_started", message=message)
-        )
-
-    async def _handle_moderation_decision(self, result: ModerationDecisionResult, message: Message, _):
-        for chat_id in self.moderation_chat_ids:
-            if result.is_approved:
-                await self.delivery_service.send_text(
+    async def post_moderation_decision(
+        self,
+        context: RequestContext,
+        is_approved: bool,
+        reason: str
+    ):
+        _ = await self._translator.get(context.user_language)
+        for chat_id in self._moderation_chat_ids:
+            if is_approved:
+                await self._delivery_service.send_text(
                     chat_id,
                     _(
                         "messages.staff.moderation_approved",
-                        message=message,
-                        explanation=result.reason,
+                        reason=reason,
                     )
                 )
             else:
-                await self.delivery_service.send_text(
+                await self._delivery_service.send_text(
                     chat_id,
                     _(
                         "messages.staff.moderation_rejected",
-                        message=message,
-                        explanation=result.reason,
+                        reason=reason,
                     )
                 )
 
-        if not result.is_approved:
-            await self.delivery_service.send_text(
-                message.chat.id,
-                _("messages.user.moderation_rejected", message=message)
+        if not is_approved:
+            await self._delivery_service.send_text(
+                context.chat_id,
+                _("messages.user.moderation_rejected")
             )
 
-    async def _handle_user_banned(self, result: UserBannedResult, message: Message, _):
-        await self.delivery_service.send_text(message.chat.id, _("messages.user.banned", message))
+    async def post_moderation_started(self, context: RequestContext):
+        _ = await self._translator.get(context.user_language)
+        await self._delivery_service.send_text(
+            context.chat_id,
+            _("messages.user.moderation_started")
+        )
 
-    async def _handle_user_not_registered(self, result: UserNotRegisteredResult, message: Message, _):
-        await self.delivery_service.send_text(message.chat.id, _("messages.user.not_registered", message))
+    async def post_prepared(
+        self,
+        context: RequestContext,
+        content: Union[ContentItem, ContentGroup],
+        is_approved: bool
+    ):
+        _ = await self._translator.get(context.user_language)
 
-    async def _handle_user_subscription_required(self, result: UserSubscriptionRequiredResult, message: Message, _):
-        await self.delivery_service.send_text(message.chat.id, _("messages.user.subscription_required", message))
+        chat_ids = (
+            chain(self._moderation_chat_ids, self._publication_channel_ids)
+            if is_approved else iter(self._moderation_chat_ids)
+        )
 
-    async def _handle_user_throttled(self, result: UserThrottledResult, message: Message, _):
-        await self.delivery_service.send_text(
-            message.chat.id,
-            _(
-                "messages.user.throttled",
-                message,
-                remaining=result.remaining_time
+        translator = lambda t: _(
+            "messages.channel.post",
+            text=t
+        )
+        content.translate(translator)
+
+        for chat_id in chat_ids:
+            await self._delivery_service.send_content(
+                chat_id, content
             )
+
+        if is_approved:
+            await self._delivery_service.send_text(
+                context.chat_id,
+                _("messages.user.moderation_approved")
+            )
+
+    async def user_banned(self, context: RequestContext):
+        _ = await self._translator.get(context.user_language)
+        await self._delivery_service.send_text(
+            context.chat_id,
+            _("messages.user.banned")
         )
 
-    async def dispatch(self, result: Results, message: Message):
-        _ = self.translator.get()
+    async def user_not_registered(self, context: RequestContext):
+        _ = await self._translator.get(context.user_language)
+        await self._delivery_service.send_text(
+            context.chat_id,
+            _("messages.user.not_registered")
+        )
 
-        handler = self._handlers.get(type(result))
-        if handler is None:
-            return
+    async def user_start(self, context: RequestContext):
+        _ = await self._translator.get(context.user_language)
+        await self._delivery_service.send_text(
+            context.chat_id,
+            _("messages.user.command_start")
+        )
+
+    async def user_subscription_required(self, context: RequestContext):
+        _ = await self._translator.get(context.user_language)
+        await self._delivery_service.send_text(
+            context.chat_id,
+            _("messages.user.subscription_required")
+        )
 
-        await handler(result, message, _)
+    async def user_throttled(self, context: RequestContext, remaining_time: int):
+        _ = await self._translator.get(context.user_language)
+        await self._delivery_service.send_text(
+            context.chat_id,
+            _(
+                "messages.user.throttled",
+                n=remaining_time,
+                remaining_time=remaining_time
+            )
+        )

+ 9 - 0
anonflow/services/transport/types.py

@@ -0,0 +1,9 @@
+from dataclasses import dataclass
+
+from aiogram.types import ChatIdUnion
+
+
+@dataclass(frozen=True)
+class RequestContext:
+    chat_id: ChatIdUnion
+    user_language: str

+ 3 - 0
anonflow/services/user/__init__.py

@@ -0,0 +1,3 @@
+from .service import UserService
+
+__all__ = ["UserService"]

+ 0 - 0
anonflow/services/accounts/user.py → anonflow/services/user/service.py


+ 18 - 48
anonflow/translator/translator.py

@@ -1,75 +1,45 @@
+import asyncio
 import gettext
 from collections import defaultdict
 from functools import lru_cache
 from pathlib import Path
-from typing import Literal, Optional
-
-from aiogram import Bot
-from aiogram.types import Message
-
-from anonflow import __version_str__
+from typing import Optional
 
 
 class Translator:
     def __init__(self, translations_dir: Path):
-        self.translations_dir = translations_dir
-
-        self.bot = None
+        self._translations_dir = translations_dir
 
     @staticmethod
     @lru_cache
-    def _get_translator(lang: str, translations_dir: Path):
-        translator = gettext.translation(
+    def _get_translation(lang: str, translations_dir: Path):
+        translation = gettext.translation(
             "messages",
             translations_dir,
             languages=[lang],
             fallback=True
         )
-        return translator
-
-    def format(self, s: str, message: Optional[Message], **extra):
-        bot = self.bot
-
-        msg_context = {}
-        if isinstance(message, Message):
-            user = message.from_user
-            chat = message.chat
-
-            first_name = getattr(user, "first_name", "")
-            last_name = getattr(user, "last_name", "")
-
-            msg_context = {
-                "chat_id": getattr(chat, "id", ""),
-                "user_id": getattr(user, "id", ""),
-                "first_name": first_name,
-                "last_name": last_name,
-                "full_name": " ".join(filter(None, (first_name, last_name))),
-                "username": getattr(user, "username", ""),
-                "bot_first_name": getattr(bot, "first_name", ""),
-                "bot_last_name": getattr(bot, "last_name", ""),
-                "bot_username": getattr(bot, "username", ""),
-                "bot_version": __version_str__
-            }
+        return translation
 
+    @staticmethod
+    def _format(s: str, **context):
         return s.format_map(
             defaultdict(
                 str,
-                msg_context | extra
+                context
             )
         )
 
-    def get(self, lang: Literal["ru"] = "ru"):
-        translator = self._get_translator(lang, self.translations_dir)
+    async def get(self, lang: str = "ru"):
+        translator = await asyncio.to_thread(self._get_translation, lang, self._translations_dir)
 
-        def _(msgid: str, message: Optional[Message] = None, **extra):
-            return self.format(
-                translator.gettext(msgid),
-                message=message,
-                **extra
+        def _(msgid1: str, msgid2: Optional[str] = None, n: Optional[int] = None, **context):
+            return self._format(
+                (
+                    translator.ngettext(msgid1, msgid2 if msgid2 is not None else msgid1, n)
+                    if n is not None else translator.gettext(msgid1)
+                ),
+                **context
             )
 
         return _
-
-    async def init(self, bot: Optional[Bot]):
-        if bot:
-            self.bot = await bot.get_me()

+ 39 - 47
translations/ru/LC_MESSAGES/messages.po

@@ -6,10 +6,10 @@
 msgid ""
 msgstr ""
 "Project-Id-Version:  0.5.0\n"
-"Report-Msgid-Bugs-To: 246878136+librellium@users.noreply.github.com\n"
+"Report-Msgid-Bugs-To: support@librellium.space\n"
 "POT-Creation-Date: 2026-02-22 12:26+0500\n"
 "PO-Revision-Date: 2026-02-22 12:28+0500\n"
-"Last-Translator: Librellium 246878136+librellium@users.noreply.github.com\n"
+"Last-Translator: Librellium support@librellium.space\n"
 "Language-Team: Russian <Librellium>, https://librellium.space\n"
 "Language: ru\n"
 "MIME-Version: 1.0\n"
@@ -19,72 +19,64 @@ msgstr ""
 "n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
 "Generated-By: Babel 2.17.0\n"
 
-#: anonflow/services/transport/router.py:50
-msgid "messages.user.command_info"
-msgstr ""
-"<b>Anonflow</b> v{bot_version}\n"
-"<b>Имя бота</b>: {bot_username}\n"
-"<b>Ваш ID</b>: {chat_id} / {user_id}\n"
-"<b>Ваше имя</b>: {full_name}\n"
-"<b>Ваш username</b>: {username}"
-
-#: anonflow/services/transport/router.py:53
-msgid "messages.user.command_start"
-msgstr ""
-"<b>Привет {username}!</b>\n"
-"Ты можешь отправить мне сообщение, и я передам его в прикрепленный "
-"канал, не раскрывая твою личность."
-
-#: anonflow/services/transport/router.py:65
-msgid "messages.channel.text"
-msgstr ""
-"<b>Сообщение из предложки</b>\n"
-"\n"
-"<blockquote>{text}</blockquote>"
-
-#: anonflow/services/transport/router.py:74
-msgid "messages.user.moderation_approved"
-msgstr "Сообщение успешно отправлено!"
-
-#: anonflow/services/transport/router.py:79
-msgid "messages.user.moderation_started"
-msgstr "Сообщение отправлено на модерацию, ожидайте..."
-
-#: anonflow/services/transport/router.py:88
+#: anonflow/services/transport/router.py:39
 msgid "messages.staff.moderation_approved"
 msgstr ""
-"Сообщение ниже было отправлено.\n"
+"<b>Сообщение ниже было отправлено.</b>\n"
 "\n"
-"Объяснение: {explanation}"
+"Причина: {reason}"
 
-#: anonflow/services/transport/router.py:97
+#: anonflow/services/transport/router.py:47
 msgid "messages.staff.moderation_rejected"
 msgstr ""
-"Сообщение ниже было отклонено.\n"
+"<b>Сообщение ниже было отклонено.</b>\n"
 "\n"
-"Объяснение: {explanation}"
+"Причина: {reason}"
 
-#: anonflow/services/transport/router.py:106
+#: anonflow/services/transport/router.py:55
 msgid "messages.user.moderation_rejected"
 msgstr ""
 "Извините, но сообщение не прошло модерацию. "
 "Оно было отправлено на ручную проверку."
 
-#: anonflow/services/transport/router.py:110
+#: anonflow/services/transport/router.py:62
+msgid "messages.user.moderation_started"
+msgstr "Сообщение отправлено на модерацию, ожидайте..."
+
+#: anonflow/services/transport/router.py:79
+msgid "messages.channel.post"
+msgstr ""
+"<b>Сообщение из предложки</b>\n"
+"\n"
+"<blockquote>{text}</blockquote>"
+
+#: anonflow/services/transport/router.py:92
+msgid "messages.user.moderation_approved"
+msgstr "Сообщение успешно отправлено!"
+
+#: anonflow/services/transport/router.py:99
 msgid "messages.user.banned"
 msgstr "Извините, но вы были заблокированы. Отправка сообщений недоступна."
 
-#: anonflow/services/transport/router.py:113
+#: anonflow/services/transport/router.py:106
 msgid "messages.user.not_registered"
 msgstr "Для продолжения пропишите /start."
 
-#: anonflow/services/transport/router.py:116
+#: anonflow/services/transport/router.py:113
+msgid "messages.user.command_start"
+msgstr ""
+"<b>Привет!</b>\n"
+"Ты можешь отправить мне сообщение, и я передам его в прикрепленный "
+"канал, не раскрывая твою личность."
+
+#: anonflow/services/transport/router.py:120
 msgid "messages.user.subscription_required"
 msgstr "Вам нужно быть участником канала, чтобы отправлять сообщения!"
 
-#: anonflow/services/transport/router.py:122
+#: anonflow/services/transport/router.py:128
 msgid "messages.user.throttled"
-msgstr ""
-"Вы уже недавно отправляли сообщение! Пожалуйста, подождите {remaining} "
-"секунд перед следующей попыткой."
+msgid_plural "messages.user.throttled"
+msgstr[0] "Вы уже недавно отправляли сообщение! Пожалуйста, подождите {remaining_time} секунду перед следующей попыткой."
+msgstr[1] "Вы уже недавно отправляли сообщение! Пожалуйста, подождите {remaining_time} секунды перед следующей попыткой."
+msgstr[2] "Вы уже недавно отправляли сообщение! Пожалуйста, подождите {remaining_time} секунд перед следующей попыткой."