Переглянути джерело

Merge pull request #28 from librellium/feature/keyboards

feature/keyboards
Librellium 2 днів тому
батько
коміт
ffd438d992
36 змінених файлів з 525 додано та 263 видалено
  1. 1 1
      anonflow/app/__init__.py
  2. 23 7
      anonflow/app/builders/middlewares.py
  3. 12 4
      anonflow/app/builders/routers.py
  4. 16 5
      anonflow/app/main.py
  5. 3 0
      anonflow/bot/keyboards/__init__.py
  6. 7 0
      anonflow/bot/keyboards/callbacks.py
  7. 22 0
      anonflow/bot/keyboards/keyboards.py
  8. 17 0
      anonflow/bot/middlewares/__init__.py
  9. 6 3
      anonflow/bot/middlewares/user/banned.py
  10. 5 4
      anonflow/bot/middlewares/user/context.py
  11. 9 6
      anonflow/bot/middlewares/user/language.py
  12. 3 1
      anonflow/bot/middlewares/user/not_registered.py
  13. 7 5
      anonflow/bot/middlewares/user/subscription.py
  14. 13 7
      anonflow/bot/middlewares/user/throttling.py
  15. 16 0
      anonflow/bot/middlewares/user/utils.py
  16. 2 1
      anonflow/bot/routers/__init__.py
  17. 3 0
      anonflow/bot/routers/callbacks/__init__.py
  18. 40 0
      anonflow/bot/routers/callbacks/post.py
  19. 11 7
      anonflow/bot/routers/media.py
  20. 2 2
      anonflow/bot/routers/start.py
  21. 15 11
      anonflow/bot/routers/text.py
  22. 33 2
      anonflow/bot/transport/delivery.py
  23. 89 58
      anonflow/bot/transport/router.py
  24. 8 10
      anonflow/config/config.py
  25. 22 37
      anonflow/config/models.py
  26. 1 1
      anonflow/database/orm.py
  27. 2 1
      anonflow/interfaces/__init__.py
  28. 7 0
      anonflow/interfaces/moderator.py
  29. 1 0
      anonflow/interfaces/post.py
  30. 2 2
      anonflow/services/moderator/permissions.py
  31. 12 12
      anonflow/services/moderator/service.py
  32. 3 3
      anonflow/services/user/service.py
  33. 9 5
      anonflow/translator/translator.py
  34. 43 42
      config.yml.example
  35. 29 0
      translations/ru/LC_MESSAGES/keyboards.po
  36. 31 26
      translations/ru/LC_MESSAGES/messages.po

+ 1 - 1
anonflow/app/__init__.py

@@ -1,3 +1,3 @@
-from .app import Application
+from .main import Application
 
 
 __all__ = ["Application"]
 __all__ = ["Application"]

+ 23 - 7
anonflow/app/builders/middlewares.py

@@ -1,4 +1,8 @@
-from anonflow.bot.middlewares.user import (
+from itertools import chain
+
+from aiogram import Dispatcher
+
+from anonflow.bot.middlewares import (
     UserBannedMiddleware,
     UserBannedMiddleware,
     UserContextMiddleware,
     UserContextMiddleware,
     UserLanguageMiddleware,
     UserLanguageMiddleware,
@@ -13,6 +17,7 @@ from anonflow.services import ModeratorService, UserService
 
 
 def build_middlewares(
 def build_middlewares(
     config: Config,
     config: Config,
+    dispatcher: Dispatcher,
     responses_router: ResponsesRouter,
     responses_router: ResponsesRouter,
     user_service: UserService,
     user_service: UserService,
     moderator_service: ModeratorService,
     moderator_service: ModeratorService,
@@ -21,7 +26,9 @@ def build_middlewares(
 
 
     middlewares.append(UserContextMiddleware(user_service=user_service))
     middlewares.append(UserContextMiddleware(user_service=user_service))
 
 
-    middlewares.append(UserLanguageMiddleware())
+    middlewares.append(
+        UserLanguageMiddleware(fallback_language=config.app.fallback_language)
+    )
 
 
     middlewares.append(
     middlewares.append(
         UserBannedMiddleware(
         UserBannedMiddleware(
@@ -29,22 +36,31 @@ def build_middlewares(
         )
         )
     )
     )
 
 
-    if config.behavior.subscription_requirement.enabled:
+    if config.bot.behavior.subscription_requirement.enabled:
         middlewares.append(
         middlewares.append(
             UserSubscriptionMiddleware(
             UserSubscriptionMiddleware(
                 responses_port=responses_router,
                 responses_port=responses_router,
-                channel_ids=config.behavior.subscription_requirement.channel_ids,
+                channel_ids=config.bot.behavior.subscription_requirement.channel_ids,
             )
             )
         )
         )
 
 
     middlewares.append(UserNotRegisteredMiddleware(responses_port=responses_router))
     middlewares.append(UserNotRegisteredMiddleware(responses_port=responses_router))
 
 
-    if config.behavior.throttling.enabled:
+    if config.bot.behavior.throttling.enabled:
+        ignored_commands = ("/" + c for c in chain.from_iterable(
+            getattr(command, "commands", [])
+            for dp_router in dispatcher.sub_routers
+            for router in dp_router.sub_routers
+            for handler in router.message.handlers
+            for command in handler.flags.get("commands", [])
+        ))
+
         middlewares.append(
         middlewares.append(
             UserThrottlingMiddleware(
             UserThrottlingMiddleware(
                 responses_port=responses_router,
                 responses_port=responses_router,
-                delay=config.behavior.throttling.delay,
-                allowed_chat_ids=config.forwarding.moderation_chat_ids,
+                delay=config.bot.behavior.throttling.delay,
+                ignored_chat_ids=[config.bot.forwarding.moderation_chat_id],
+                ignored_commands=ignored_commands
             )
             )
         )
         )
 
 

+ 12 - 4
anonflow/app/builders/routers.py

@@ -1,6 +1,6 @@
 from aiogram import Router
 from aiogram import Router
 
 
-from anonflow.bot.routers import MediaRouter, StartRouter, TextRouter
+from anonflow.bot.routers import MediaRouter, PostRouter, StartRouter, TextRouter
 from anonflow.bot.transport import ResponsesRouter
 from anonflow.bot.transport import ResponsesRouter
 from anonflow.config import Config
 from anonflow.config import Config
 from anonflow.moderation import ModerationService
 from anonflow.moderation import ModerationService
@@ -17,17 +17,25 @@ def build_routers(
     main_router = Router()
     main_router = Router()
 
 
     routers = [
     routers = [
-        StartRouter(responses_port=responses_router, user_service=user_service),
+        StartRouter(
+            responses_port=responses_router,
+            user_service=user_service
+        ),
         TextRouter(
         TextRouter(
             responses_port=responses_router,
             responses_port=responses_router,
             moderation_service=moderation_service,
             moderation_service=moderation_service,
-            forwarding_types=config.forwarding.types,
+            forwarding_types=config.bot.forwarding.types,
         ),
         ),
         MediaRouter(
         MediaRouter(
             responses_port=responses_router,
             responses_port=responses_router,
             moderation_service=moderation_service,
             moderation_service=moderation_service,
-            forwarding_types=config.forwarding.types,
+            forwarding_types=config.bot.forwarding.types,
         ),
         ),
+        PostRouter(
+            post_responses_port=responses_router,
+            moderator_responses_port=responses_router,
+            moderator_service=moderator_service
+        )
     ]
     ]
 
 
     for router in routers:
     for router in routers:

+ 16 - 5
anonflow/app/app.py → anonflow/app/main.py

@@ -2,7 +2,7 @@ import logging
 from typing import Optional
 from typing import Optional
 
 
 from aiogram import Bot, Dispatcher
 from aiogram import Bot, Dispatcher
-from aiogram.client.bot import DefaultBotProperties
+from aiogram.client.default import DefaultBotProperties
 from aiogram.fsm.storage.memory import MemoryStorage
 from aiogram.fsm.storage.memory import MemoryStorage
 
 
 from anonflow import __version_str__, paths
 from anonflow import __version_str__, paths
@@ -87,7 +87,11 @@ class Application:
             self._dispatcher = Dispatcher(storage=MemoryStorage())
             self._dispatcher = Dispatcher(storage=MemoryStorage())
 
 
     def _init_translator(self):
     def _init_translator(self):
-        self._translator = Translator(translations_dir=paths.TRANSLATIONS_DIR)
+        with require(self, "_config") as config:
+            self._translator = Translator(
+                translations_dir=paths.TRANSLATIONS_DIR,
+                default_language=config.app.language
+            )
 
 
     def _init_transport(self):
     def _init_transport(self):
         with require(self, "_bot", "_config", "_translator") as (
         with require(self, "_bot", "_config", "_translator") as (
@@ -96,8 +100,8 @@ class Application:
             translator,
             translator,
         ):
         ):
             self._responses_router = ResponsesRouter(
             self._responses_router = ResponsesRouter(
-                moderation_chat_ids=config.forwarding.moderation_chat_ids,
-                publication_channel_ids=config.forwarding.publication_channel_ids,
+                moderation_chat_id=config.bot.forwarding.moderation_chat_id,
+                publication_channel_ids=config.bot.forwarding.publication_channel_ids,
                 delivery_service=DeliveryService(bot),
                 delivery_service=DeliveryService(bot),
                 translator=translator,
                 translator=translator,
             )
             )
@@ -169,9 +173,16 @@ class Application:
             "_responses_router",
             "_responses_router",
             "_user_service",
             "_user_service",
             "_moderator_service",
             "_moderator_service",
-        ) as (dispatcher, config, responses_router, user_service, moderator_service):
+        ) as (
+            dispatcher,
+            config,
+            responses_router,
+            user_service,
+            moderator_service
+        ):
             middlewares = build_middlewares(
             middlewares = build_middlewares(
                 config=config,
                 config=config,
+                dispatcher=dispatcher,
                 responses_router=responses_router,
                 responses_router=responses_router,
                 user_service=user_service,
                 user_service=user_service,
                 moderator_service=moderator_service,
                 moderator_service=moderator_service,

+ 3 - 0
anonflow/bot/keyboards/__init__.py

@@ -0,0 +1,3 @@
+from .keyboards import Keyboards
+
+__all__ = ["Keyboards"]

+ 7 - 0
anonflow/bot/keyboards/callbacks.py

@@ -0,0 +1,7 @@
+from typing import Literal
+
+from aiogram.filters.callback_data import CallbackData
+
+
+class PostCallbackData(CallbackData, prefix="post"):
+    action: Literal["approve", "reject"]

+ 22 - 0
anonflow/bot/keyboards/keyboards.py

@@ -0,0 +1,22 @@
+from aiogram.utils.keyboard import InlineKeyboardBuilder
+
+from .callbacks import PostCallbackData
+
+
+class Keyboards:
+    @staticmethod
+    def get_post_markup(t_kb):
+        builder = InlineKeyboardBuilder()
+
+        builder.button(
+            text=t_kb("post.approve"),
+            callback_data=PostCallbackData(action="approve")
+        )
+        builder.button(
+            text=t_kb("post.reject"),
+            callback_data=PostCallbackData(action="reject")
+        )
+
+        builder.adjust(2)
+
+        return builder.as_markup()

+ 17 - 0
anonflow/bot/middlewares/__init__.py

@@ -0,0 +1,17 @@
+from .user import (
+    UserBannedMiddleware,
+    UserContextMiddleware,
+    UserLanguageMiddleware,
+    UserNotRegisteredMiddleware,
+    UserSubscriptionMiddleware,
+    UserThrottlingMiddleware
+)
+
+__all__ = [
+    "UserBannedMiddleware",
+    "UserContextMiddleware",
+    "UserLanguageMiddleware",
+    "UserNotRegisteredMiddleware",
+    "UserSubscriptionMiddleware",
+    "UserThrottlingMiddleware"
+]

+ 6 - 3
anonflow/bot/middlewares/user/banned.py

@@ -5,6 +5,8 @@ from anonflow.bot.transport.types import RequestContext
 from anonflow.interfaces import UserResponsesPort
 from anonflow.interfaces import UserResponsesPort
 from anonflow.services import ModeratorService
 from anonflow.services import ModeratorService
 
 
+from .utils import extract_message, extract_user
+
 
 
 class UserBannedMiddleware(BaseMiddleware):
 class UserBannedMiddleware(BaseMiddleware):
     def __init__(
     def __init__(
@@ -16,9 +18,10 @@ class UserBannedMiddleware(BaseMiddleware):
         self._moderator_service = moderator_service
         self._moderator_service = moderator_service
 
 
     async def __call__(self, handler, event, data):
     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):
+        message = extract_message(event)
+        from_user = extract_user(event)
+        if isinstance(message, Message) and from_user:
+            if await self._moderator_service.is_banned(from_user.id):
                 await self._responses_port.user_banned(
                 await self._responses_port.user_banned(
                     RequestContext(message.chat.id, data["user_language"])
                     RequestContext(message.chat.id, data["user_language"])
                 )
                 )

+ 5 - 4
anonflow/bot/middlewares/user/context.py

@@ -1,8 +1,9 @@
 from aiogram import BaseMiddleware
 from aiogram import BaseMiddleware
-from aiogram.types import Message
 
 
 from anonflow.services import UserService
 from anonflow.services import UserService
 
 
+from .utils import extract_user
+
 
 
 class UserContextMiddleware(BaseMiddleware):
 class UserContextMiddleware(BaseMiddleware):
     def __init__(self, user_service: UserService):
     def __init__(self, user_service: UserService):
@@ -13,8 +14,8 @@ class UserContextMiddleware(BaseMiddleware):
     async def __call__(self, handler, event, data):
     async def __call__(self, handler, event, data):
         data["user"] = None
         data["user"] = None
 
 
-        message = getattr(event, "message", None)
-        if isinstance(message, Message) and message.from_user:
-            data["user"] = await self._user_service.get(message.from_user.id)
+        from_user = extract_user(event)
+        if from_user:
+            data["user"] = await self._user_service.get(from_user.id)
 
 
         return await handler(event, data)
         return await handler(event, data)

+ 9 - 6
anonflow/bot/middlewares/user/language.py

@@ -1,19 +1,22 @@
 from aiogram import BaseMiddleware
 from aiogram import BaseMiddleware
-from aiogram.types import Message
+
+from .utils import extract_user
 
 
 
 
 class UserLanguageMiddleware(BaseMiddleware):
 class UserLanguageMiddleware(BaseMiddleware):
-    def __init__(self):
+    def __init__(self, fallback_language: str):
         super().__init__()
         super().__init__()
 
 
+        self._fallback_language = fallback_language
+
     async def __call__(self, handler, event, data):
     async def __call__(self, handler, event, data):
-        data["user_language"] = None
+        data["user_language"] = self._fallback_language
 
 
-        message = getattr(event, "message", None)
-        if isinstance(message, Message) and message.from_user:
+        from_user = extract_user(event)
+        if from_user:
             user = data.get("user")
             user = data.get("user")
             data["user_language"] = (
             data["user_language"] = (
-                user.language if user else message.from_user.language_code
+                user.language if user else from_user.language_code
             )
             )
 
 
         return await handler(event, data)
         return await handler(event, data)

+ 3 - 1
anonflow/bot/middlewares/user/not_registered.py

@@ -5,6 +5,8 @@ from aiogram.types import Message
 from anonflow.bot.transport.types import RequestContext
 from anonflow.bot.transport.types import RequestContext
 from anonflow.interfaces import UserResponsesPort
 from anonflow.interfaces import UserResponsesPort
 
 
+from .utils import extract_message
+
 
 
 class UserNotRegisteredMiddleware(BaseMiddleware):
 class UserNotRegisteredMiddleware(BaseMiddleware):
     def __init__(self, responses_port: UserResponsesPort):
     def __init__(self, responses_port: UserResponsesPort):
@@ -13,7 +15,7 @@ class UserNotRegisteredMiddleware(BaseMiddleware):
         self._responses_port = responses_port
         self._responses_port = responses_port
 
 
     async def __call__(self, handler, event, data):
     async def __call__(self, handler, event, data):
-        message = getattr(event, "message", None)
+        message = extract_message(event)
         if isinstance(message, Message) and message.chat.type == ChatType.PRIVATE:
         if isinstance(message, Message) and message.chat.type == ChatType.PRIVATE:
             text = message.text or message.caption or ""
             text = message.text or message.caption or ""
 
 

+ 7 - 5
anonflow/bot/middlewares/user/subscription.py

@@ -7,6 +7,8 @@ from aiogram.types import ChatIdUnion, Message
 from anonflow.bot.transport.types import RequestContext
 from anonflow.bot.transport.types import RequestContext
 from anonflow.interfaces import UserResponsesPort
 from anonflow.interfaces import UserResponsesPort
 
 
+from .utils import extract_message, extract_user
+
 
 
 class UserSubscriptionMiddleware(BaseMiddleware):
 class UserSubscriptionMiddleware(BaseMiddleware):
     def __init__(
     def __init__(
@@ -18,16 +20,16 @@ class UserSubscriptionMiddleware(BaseMiddleware):
         self._channel_ids = channel_ids
         self._channel_ids = channel_ids
 
 
     async def __call__(self, handler, event, data):
     async def __call__(self, handler, event, data):
-        message = getattr(event, "message", None)
+        message = extract_message(event)
+        from_user = extract_user(event)
         if (
         if (
             isinstance(message, Message)
             isinstance(message, Message)
+            and from_user is not None
+            and message.bot is not None
             and message.chat.type == ChatType.PRIVATE
             and message.chat.type == ChatType.PRIVATE
-            and message.from_user
-            and message.bot
         ):
         ):
-            user_id = message.from_user.id
             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)
+                member = await message.bot.get_chat_member(channel_id, from_user.id)
                 if member.status in (ChatMemberStatus.KICKED, ChatMemberStatus.LEFT):
                 if member.status in (ChatMemberStatus.KICKED, ChatMemberStatus.LEFT):
                     await self._responses_port.user_subscription_required(
                     await self._responses_port.user_subscription_required(
                         RequestContext(message.chat.id, data["user_language"])
                         RequestContext(message.chat.id, data["user_language"])

+ 13 - 7
anonflow/bot/middlewares/user/throttling.py

@@ -3,24 +3,29 @@ import time
 from typing import Dict, Iterable, Optional
 from typing import Dict, Iterable, Optional
 
 
 from aiogram import BaseMiddleware
 from aiogram import BaseMiddleware
+from aiogram.enums import ChatType
 from aiogram.types import ChatIdUnion, Message
 from aiogram.types import ChatIdUnion, Message
 
 
 from anonflow.bot.transport.types import RequestContext
 from anonflow.bot.transport.types import RequestContext
 from anonflow.interfaces import UserResponsesPort
 from anonflow.interfaces import UserResponsesPort
 
 
+from .utils import extract_message
+
 
 
 class UserThrottlingMiddleware(BaseMiddleware):
 class UserThrottlingMiddleware(BaseMiddleware):
     def __init__(
     def __init__(
         self,
         self,
         responses_port: UserResponsesPort,
         responses_port: UserResponsesPort,
         delay: float,
         delay: float,
-        allowed_chat_ids: Optional[Iterable[ChatIdUnion]] = None,
+        ignored_chat_ids: Optional[Iterable[Optional[ChatIdUnion]]] = None,
+        ignored_commands: Optional[Iterable[str]] = None
     ):
     ):
         super().__init__()
         super().__init__()
 
 
         self._responses_port = responses_port
         self._responses_port = responses_port
         self._delay = delay
         self._delay = delay
-        self._allowed_chat_ids = allowed_chat_ids
+        self._ignored_chat_ids = tuple(ignored_chat_ids or ())
+        self._ignored_commands = tuple(ignored_commands or ())
 
 
         self._user_times: Dict[int, float] = {}
         self._user_times: Dict[int, float] = {}
         self._user_locks: Dict[int, asyncio.Lock] = {}
         self._user_locks: Dict[int, asyncio.Lock] = {}
@@ -28,13 +33,14 @@ class UserThrottlingMiddleware(BaseMiddleware):
         self._lock = asyncio.Lock()
         self._lock = asyncio.Lock()
 
 
     async def __call__(self, handler, event, data):
     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
+        message = extract_message(event)
+        if (
+            isinstance(message, Message)
+            and message.chat.id not in self._ignored_chat_ids
+            and message.chat.type == ChatType.PRIVATE
         ):
         ):
             text = message.text or message.caption or ""
             text = message.text or message.caption or ""
-            if not text.startswith("/"):
+            if not text.startswith(self._ignored_commands):
                 async with self._lock:
                 async with self._lock:
                     user_lock = self._user_locks.setdefault(
                     user_lock = self._user_locks.setdefault(
                         message.chat.id, asyncio.Lock()
                         message.chat.id, asyncio.Lock()

+ 16 - 0
anonflow/bot/middlewares/user/utils.py

@@ -0,0 +1,16 @@
+from typing import Optional
+
+from aiogram.types import Message, User
+
+
+def extract_message(event) -> Optional[Message]:
+    if message := getattr(event, "message", None):
+        return message
+    if callback_query := getattr(event, "callback_query", None):
+        return callback_query.message
+
+def extract_user(event) -> Optional[User]:
+    if message := getattr(event, "message", None):
+        return message.from_user
+    if callback_query := getattr(event, "callback_query", None):
+        return callback_query.from_user

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

@@ -1,5 +1,6 @@
+from .callbacks import PostRouter
 from .media import MediaRouter
 from .media import MediaRouter
 from .start import StartRouter
 from .start import StartRouter
 from .text import TextRouter
 from .text import TextRouter
 
 
-__all__ = ["MediaRouter", "StartRouter", "TextRouter"]
+__all__ = ["PostRouter", "MediaRouter", "StartRouter", "TextRouter"]

+ 3 - 0
anonflow/bot/routers/callbacks/__init__.py

@@ -0,0 +1,3 @@
+from .post import PostRouter
+
+__all__ = ["PostRouter"]

+ 40 - 0
anonflow/bot/routers/callbacks/post.py

@@ -0,0 +1,40 @@
+from aiogram import Router
+from aiogram.types import CallbackQuery
+
+from anonflow.bot.keyboards.callbacks import PostCallbackData
+from anonflow.bot.transport.types import RequestContext
+from anonflow.interfaces import PostResponsesPort, ModeratorResponsesPort
+from anonflow.services import ModeratorService
+from anonflow.services.moderator.permissions import ModeratorPermission
+
+
+class PostRouter(Router):
+    def __init__(
+        self,
+        post_responses_port: PostResponsesPort,
+        moderator_responses_port: ModeratorResponsesPort,
+        moderator_service: ModeratorService
+    ):
+        super().__init__()
+
+        self._post_responses_port = post_responses_port
+        self._moderator_responses_port = moderator_responses_port
+        self._moderator_service = moderator_service
+
+    async def _on_post_callback_query(self, query: CallbackQuery, callback_data: PostCallbackData, user_language: str):
+        message = query.message
+
+        if message:
+            if await self._moderator_service.can(query.from_user.id, ModeratorPermission.MANAGE_POSTS):
+                await self._post_responses_port.post_moderators_decision(
+                    RequestContext(message.chat.id, user_language),
+                    True if callback_data.action == "approve" else False,
+                    message.message_id
+                )
+            else:
+                await self._moderator_responses_port.moderator_permission_error(
+                    RequestContext(message.chat.id, user_language), query.id
+                )
+
+    def setup(self):
+        self.callback_query.register(self._on_post_callback_query, PostCallbackData.filter())

+ 11 - 7
anonflow/bot/routers/media.py

@@ -3,17 +3,21 @@ import base64
 from asyncio import CancelledError
 from asyncio import CancelledError
 from contextlib import suppress
 from contextlib import suppress
 from io import BytesIO
 from io import BytesIO
-from typing import Dict, FrozenSet, List
+from typing import Dict, List, Set
 
 
 from aiogram import F, Router
 from aiogram import F, Router
 from aiogram.enums import ChatType
 from aiogram.enums import ChatType
 from aiogram.types import Message
 from aiogram.types import Message
 
 
+from anonflow.bot.transport.content import (
+    ContentGroup,
+    ContentMediaItem,
+    MediaType
+)
+from anonflow.bot.transport.types import RequestContext
 from anonflow.config.models import ForwardingType
 from anonflow.config.models import ForwardingType
 from anonflow.interfaces import PostResponsesPort
 from anonflow.interfaces import PostResponsesPort
 from anonflow.moderation import ModerationService
 from anonflow.moderation import ModerationService
-from anonflow.bot.transport.content import ContentGroup, ContentMediaItem, MediaType
-from anonflow.bot.transport.types import RequestContext
 
 
 
 
 class MediaRouter(Router):
 class MediaRouter(Router):
@@ -21,7 +25,7 @@ class MediaRouter(Router):
         self,
         self,
         responses_port: PostResponsesPort,
         responses_port: PostResponsesPort,
         moderation_service: ModerationService,
         moderation_service: ModerationService,
-        forwarding_types: FrozenSet[ForwardingType],
+        forwarding_types: Set[ForwardingType],
     ):
     ):
         super().__init__()
         super().__init__()
 
 
@@ -57,7 +61,7 @@ class MediaRouter(Router):
         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}
             return {"type": MediaType.VIDEO, "file_id": message.video.file_id}
 
 
-    async def _process_messages(self, messages: List[Message], user_language: str):
+    async def _process(self, messages: List[Message], user_language: str):
         if not messages:
         if not messages:
             return
             return
 
 
@@ -94,7 +98,7 @@ class MediaRouter(Router):
                     messages = self.media_groups.pop(media_group_id, [])  # type: ignore
                     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.pop(media_group_id, None)  # type: ignore
 
 
-                await self._process_messages(messages, user_language)
+                asyncio.create_task(self._process(messages, user_language))
 
 
         if media_group_id:
         if media_group_id:
             async with self._media_groups_lock:
             async with self._media_groups_lock:
@@ -109,7 +113,7 @@ class MediaRouter(Router):
                 )
                 )
             return
             return
 
 
-        await self._process_messages([message], user_language)
+        asyncio.create_task(self._process([message], user_language))
 
 
     def setup(self):
     def setup(self):
         self.message.register(self._on_media, F.photo | F.video)
         self.message.register(self._on_media, F.photo | F.video)

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

@@ -2,9 +2,9 @@ from aiogram import Router
 from aiogram.filters import CommandStart
 from aiogram.filters import CommandStart
 from aiogram.types import Message
 from aiogram.types import Message
 
 
-from anonflow.services import UserService
-from anonflow.interfaces import UserResponsesPort
 from anonflow.bot.transport.types import RequestContext
 from anonflow.bot.transport.types import RequestContext
+from anonflow.interfaces import UserResponsesPort
+from anonflow.services import UserService
 
 
 
 
 class StartRouter(Router):
 class StartRouter(Router):

+ 15 - 11
anonflow/bot/routers/text.py

@@ -1,14 +1,15 @@
-from typing import FrozenSet
+import asyncio
+from typing import Set
 
 
 from aiogram import F, Router
 from aiogram import F, Router
 from aiogram.enums import ChatType
 from aiogram.enums import ChatType
 from aiogram.types import Message
 from aiogram.types import Message
 
 
+from anonflow.bot.transport.content import ContentTextItem
+from anonflow.bot.transport.types import RequestContext
 from anonflow.config.models import ForwardingType
 from anonflow.config.models import ForwardingType
 from anonflow.interfaces import PostResponsesPort
 from anonflow.interfaces import PostResponsesPort
 from anonflow.moderation import ModerationService
 from anonflow.moderation import ModerationService
-from anonflow.bot.transport.content import ContentTextItem
-from anonflow.bot.transport.types import RequestContext
 
 
 
 
 class TextRouter(Router):
 class TextRouter(Router):
@@ -16,7 +17,7 @@ class TextRouter(Router):
         self,
         self,
         responses_port: PostResponsesPort,
         responses_port: PostResponsesPort,
         moderation_service: ModerationService,
         moderation_service: ModerationService,
-        forwarding_types: FrozenSet[ForwardingType],
+        forwarding_types: Set[ForwardingType],
     ):
     ):
         super().__init__()
         super().__init__()
 
 
@@ -24,15 +25,18 @@ class TextRouter(Router):
         self._moderation_service = moderation_service
         self._moderation_service = moderation_service
         self._forwarding_types = forwarding_types
         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)
+    async def _process(self, message: Message, user_language: str):
+        context = RequestContext(message.chat.id, user_language)
+
+        is_approved = await self._moderation_service.process(context, message.text)
 
 
-            is_approved = await self._moderation_service.process(context, message.text)
+        await self._responses_port.post_prepared(
+            context, ContentTextItem(message.text or ""), is_approved
+        )
 
 
-            await self._responses_port.post_prepared(
-                context, ContentTextItem(message.text or ""), is_approved
-            )
+    async def _on_text(self, message: Message, user_language: str):
+        if message.chat.type == ChatType.PRIVATE and "text" in self._forwarding_types:
+            asyncio.create_task(self._process(message, user_language))
 
 
     def setup(self):
     def setup(self):
         self.message.register(self._on_text, F.text)
         self.message.register(self._on_text, F.text)

+ 33 - 2
anonflow/bot/transport/delivery.py

@@ -1,7 +1,8 @@
-from typing import Optional, List, Union
+import asyncio
+from typing import Callable, Optional, List, Union
 
 
 from aiogram import Bot
 from aiogram import Bot
-from aiogram.client.bot import Default
+from aiogram.client.default import Default
 from aiogram.types import (
 from aiogram.types import (
     ChatIdUnion,
     ChatIdUnion,
     InputMediaPhoto,
     InputMediaPhoto,
@@ -32,9 +33,26 @@ class DeliveryService:
         else:
         else:
             raise ValueError("Media item type is invalid.")
             raise ValueError("Media item type is invalid.")
 
 
+    async def answer_callback_query(self, callback_query_id: str, text: str):
+        return await self._bot.answer_callback_query(callback_query_id, text)
+
+    async def copy(
+        self, chat_id: ChatIdUnion, from_chat_id: ChatIdUnion, message_id: int
+    ):
+        return await self._bot.copy_message(chat_id, from_chat_id, message_id)
+
     async def delete(self, chat_id: ChatIdUnion, message_id: int):
     async def delete(self, chat_id: ChatIdUnion, message_id: int):
         return await self._bot.delete_message(chat_id, message_id)
         return await self._bot.delete_message(chat_id, message_id)
 
 
+    async def delete_with_delay(self, chat_id: ChatIdUnion, message_id: int, delay: float):
+        await asyncio.sleep(delay)
+        return await self.delete(chat_id, message_id)
+
+    async def remove_reply_markup(self, chat_id: ChatIdUnion, message_id: int):
+        return await self._bot.edit_message_reply_markup(
+            chat_id=chat_id, message_id=message_id
+        )
+
     async def send_content(
     async def send_content(
         self,
         self,
         chat_id: ChatIdUnion,
         chat_id: ChatIdUnion,
@@ -101,3 +119,16 @@ class DeliveryService:
         return await self._bot.send_message(
         return await self._bot.send_message(
             chat_id=chat_id, text=text, parse_mode=parse_mode, reply_markup=reply_markup
             chat_id=chat_id, text=text, parse_mode=parse_mode, reply_markup=reply_markup
         )
         )
+
+    async def send_with_delete(
+        self,
+        delay: float,
+        func: Callable,
+        *args,
+        **kwargs
+    ):
+        message = await func(*args, **kwargs)
+        asyncio.create_task(
+            self.delete_with_delay(message.chat.id, message.message_id, delay)
+        )
+        return message

+ 89 - 58
anonflow/bot/transport/router.py

@@ -1,9 +1,13 @@
-from itertools import chain
-from typing import Tuple, Union
+from typing import Optional, Tuple, Union
 
 
 from aiogram.types import ChatIdUnion
 from aiogram.types import ChatIdUnion
 
 
-from anonflow.interfaces import PostResponsesPort, UserResponsesPort
+from anonflow.bot.keyboards import Keyboards
+from anonflow.interfaces import (
+    ModeratorResponsesPort,
+    PostResponsesPort,
+    UserResponsesPort,
+)
 from anonflow.translator import Translator
 from anonflow.translator import Translator
 
 
 from .content import ContentGroup, ContentItem
 from .content import ContentGroup, ContentItem
@@ -11,50 +15,83 @@ from .delivery import DeliveryService
 from .types import RequestContext
 from .types import RequestContext
 
 
 
 
-class ResponsesRouter(PostResponsesPort, UserResponsesPort):
+class ResponsesRouter(ModeratorResponsesPort, PostResponsesPort, UserResponsesPort):
     def __init__(
     def __init__(
         self,
         self,
-        moderation_chat_ids: Tuple[ChatIdUnion],
+        moderation_chat_id: ChatIdUnion,
         publication_channel_ids: Tuple[ChatIdUnion],
         publication_channel_ids: Tuple[ChatIdUnion],
         delivery_service: DeliveryService,
         delivery_service: DeliveryService,
         translator: Translator,
         translator: Translator,
     ):
     ):
-        self._moderation_chat_ids = moderation_chat_ids
+        self._moderation_chat_id = moderation_chat_id
         self._publication_channel_ids = publication_channel_ids
         self._publication_channel_ids = publication_channel_ids
         self._delivery_service = delivery_service
         self._delivery_service = delivery_service
         self._translator = translator
         self._translator = translator
 
 
+    async def _get_translators(self, user_language: str, keyboards_from_user: bool = False):
+        return (
+            await self._translator.get(),
+            await self._translator.get(
+                user_language
+            ),
+            await self._translator.get(
+                user_language if keyboards_from_user else None, domain="keyboards"
+            )
+        )
+
+    async def moderator_permission_error(self, context: RequestContext, callback_query_id: Optional[str] = None):
+        t_app, t_user, t_kb = await self._get_translators(context.user_language)
+        if callback_query_id:
+            await self._delivery_service.answer_callback_query(
+                callback_query_id, t_user("moderator.permission_error")
+            )
+        else:
+            await self._delivery_service.send_text(
+                context.chat_id, t_user("moderator.permission_error")
+            )
+
+    async def post_moderators_decision(
+        self, context: RequestContext, is_approved: bool, message_id: int
+    ):
+        await self._delivery_service.remove_reply_markup(context.chat_id, message_id)
+        if is_approved:
+            for chat_id in self._publication_channel_ids:
+                await self._delivery_service.copy(chat_id, context.chat_id, message_id)
+
     async def post_moderation_decision(
     async def post_moderation_decision(
         self, context: RequestContext, is_approved: bool, reason: str
         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",
-                        reason=reason,
-                    ),
-                )
-            else:
-                await self._delivery_service.send_text(
-                    chat_id,
-                    _(
-                        "messages.staff.moderation_rejected",
-                        reason=reason,
-                    ),
-                )
-
-        if not is_approved:
+        t_app, t_user, t_kb = await self._get_translators(context.user_language)
+        if is_approved:
             await self._delivery_service.send_text(
             await self._delivery_service.send_text(
-                context.chat_id, _("messages.user.moderation_rejected")
+                self._moderation_chat_id,
+                t_app(
+                    "moderator.moderation_approved",
+                    reason=reason,
+                ),
+            )
+        else:
+            await self._delivery_service.send_text(
+                self._moderation_chat_id,
+                t_app(
+                    "moderator.moderation_rejected",
+                    reason=reason,
+                ),
+            )
+
+        if is_approved:
+            await self._delivery_service.send_text(
+                context.chat_id, t_user("user.moderation_approved")
+            )
+        else:
+            await self._delivery_service.send_text(
+                context.chat_id, t_user("user.moderation_rejected")
             )
             )
 
 
     async def post_moderation_started(self, context: RequestContext):
     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")
+        t_app, t_user, t_kb = await self._get_translators(context.user_language)
+        await self._delivery_service.send_with_delete(
+            5, self._delivery_service.send_text, context.chat_id, t_user("user.moderation_started")
         )
         )
 
 
     async def post_prepared(
     async def post_prepared(
@@ -63,54 +100,48 @@ class ResponsesRouter(PostResponsesPort, UserResponsesPort):
         content: Union[ContentItem, ContentGroup],
         content: Union[ContentItem, ContentGroup],
         is_approved: bool,
         is_approved: bool,
     ):
     ):
-        _ = await self._translator.get(context.user_language)
-
-        chat_ids = (
-            chain(self._moderation_chat_ids, self._publication_channel_ids)
-            if is_approved
-            else iter(self._moderation_chat_ids)
-        )
+        t_app, t_user, t_kb = await self._get_translators(context.user_language)
 
 
-        content.translate(lambda t: _("messages.channel.post", text=t))
+        content.translate(lambda t: t_app("channel.post", text=t))
 
 
-        for chat_id in chat_ids:
-            await self._delivery_service.send_content(chat_id, content)
+        await self._delivery_service.send_content(
+            self._moderation_chat_id,
+            content,
+            reply_markup=(
+                Keyboards.get_post_markup(t_kb) if not is_approved else None
+            ),
+        )
 
 
         if is_approved:
         if is_approved:
-            await self._delivery_service.send_text(
-                context.chat_id, _("messages.user.moderation_approved")
-            )
+            for chat_id in self._publication_channel_ids:
+                await self._delivery_service.send_content(chat_id, content)
 
 
     async def user_banned(self, context: RequestContext):
     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")
-        )
+        t_app, t_user, t_kb = await self._get_translators(context.user_language)
+        await self._delivery_service.send_text(context.chat_id, t_user("user.banned"))
 
 
     async def user_not_registered(self, context: RequestContext):
     async def user_not_registered(self, context: RequestContext):
-        _ = await self._translator.get(context.user_language)
+        t_app, t_user, t_kb = await self._get_translators(context.user_language)
         await self._delivery_service.send_text(
         await self._delivery_service.send_text(
-            context.chat_id, _("messages.user.not_registered")
+            context.chat_id, t_user("user.not_registered")
         )
         )
 
 
     async def user_start(self, context: RequestContext):
     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")
-        )
+        t_app, t_user, t_kb = await self._get_translators(context.user_language)
+        await self._delivery_service.send_text(context.chat_id, t_user("user.command_start"))
 
 
     async def user_subscription_required(self, context: RequestContext):
     async def user_subscription_required(self, context: RequestContext):
-        _ = await self._translator.get(context.user_language)
+        t_app, t_user, t_kb = await self._get_translators(context.user_language)
         await self._delivery_service.send_text(
         await self._delivery_service.send_text(
-            context.chat_id, _("messages.user.subscription_required")
+            context.chat_id, t_user("user.subscription_required")
         )
         )
 
 
     async def user_throttled(self, context: RequestContext, remaining_time: int):
     async def user_throttled(self, context: RequestContext, remaining_time: int):
-        _ = await self._translator.get(context.user_language)
+        t_app, t_user, t_kb = await self._get_translators(context.user_language)
         await self._delivery_service.send_text(
         await self._delivery_service.send_text(
             context.chat_id,
             context.chat_id,
-            _(
-                "messages.user.throttled",
+            t_user(
+                "user.throttled",
                 n=remaining_time,
                 n=remaining_time,
                 remaining_time=remaining_time,
                 remaining_time=remaining_time,
             ),
             ),

+ 8 - 10
anonflow/config/config.py

@@ -3,21 +3,19 @@ from string import Template
 
 
 import yaml
 import yaml
 from dotenv import dotenv_values
 from dotenv import dotenv_values
-from pydantic import BaseModel, SecretStr
+from pydantic import BaseModel, Field, SecretStr
 from sqlalchemy.engine import URL
 from sqlalchemy.engine import URL
 
 
-from .models import Behavior, Bot, Database, Forwarding, Logging, Moderation, OpenAI
+from .models import App, Bot, Database, Logging, Moderation, OpenAI
 
 
 
 
 class Config(BaseModel):
 class Config(BaseModel):
-    bot: Bot = Bot()
-    behavior: Behavior = Behavior()
-    database: Database = Database()
-    forwarding: Forwarding = Forwarding()
-    openai: OpenAI = OpenAI()
-    moderation: Moderation = Moderation()
-    logging: Logging = Logging()
-    model_config = {"frozen": True}
+    app: App = Field(default_factory=App)
+    bot: Bot = Field(default_factory=Bot)
+    database: Database = Field(default_factory=Database)
+    openai: OpenAI = Field(default_factory=OpenAI)
+    moderation: Moderation = Field(default_factory=Moderation)
+    logging: Logging = Field(default_factory=Logging)
 
 
     def get_database_url(self):
     def get_database_url(self):
         password = None
         password = None

+ 22 - 37
anonflow/config/models.py

@@ -1,5 +1,5 @@
 from pathlib import Path
 from pathlib import Path
-from typing import FrozenSet, Literal, Optional, Tuple, TypeAlias, Union
+from typing import List, Literal, Optional, Set, TypeAlias, Union
 
 
 from pydantic import BaseModel, Field, HttpUrl, SecretStr
 from pydantic import BaseModel, Field, HttpUrl, SecretStr
 
 
@@ -8,63 +8,51 @@ ModerationBackend: TypeAlias = Literal["omni", "gpt"]
 LoggingLevel: TypeAlias = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
 LoggingLevel: TypeAlias = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
 
 
 
 
-class Bot(BaseModel):
-    token: Optional[SecretStr] = None
-    timeout: int = 10
-    model_config = {"frozen": True}
+class App(BaseModel):
+    language: str = "ru"
+    fallback_language: str = "ru"
 
 
 
 
-class BehaviorThrottling(BaseModel):
+class BotBehaviorThrottling(BaseModel):
     enabled: bool = True
     enabled: bool = True
     delay: float = 120
     delay: float = 120
-    model_config = {"frozen": True}
 
 
 
 
-class BehaviorSubscriptionRequirement(BaseModel):
+class BotBehaviorSubscriptionRequirement(BaseModel):
     enabled: bool = True
     enabled: bool = True
-    channel_ids: Tuple[int, ...] = Field(default_factory=tuple)
-    model_config = {"frozen": True}
+    channel_ids: List[int] = Field(default_factory=list)
 
 
 
 
-class Behavior(BaseModel):
-    throttling: BehaviorThrottling = BehaviorThrottling()
-    subscription_requirement: BehaviorSubscriptionRequirement = BehaviorSubscriptionRequirement()  # fmt: skip
-    model_config = {"frozen": True}
+class BotBehavior(BaseModel):
+    throttling: BotBehaviorThrottling = Field(default_factory=BotBehaviorThrottling)
+    subscription_requirement: BotBehaviorSubscriptionRequirement = Field(default_factory=BotBehaviorSubscriptionRequirement)  # fmt: skip
 
 
 
 
-class DatabaseRepositoriesUser(BaseModel):
-    cache_size: int = 1024
-    cache_ttl: int = 60
-    model_config = {"frozen": True}
+class BotForwarding(BaseModel):
+    moderation_chat_id: Optional[int] = None
+    publication_channel_ids: List[int] = Field(default_factory=list)
+    types: Set[ForwardingType] = Field(default_factory=lambda: {"text", "photo", "video"})
 
 
 
 
-class DatabaseRepositories(BaseModel):
-    user: DatabaseRepositoriesUser = DatabaseRepositoriesUser()
-    model_config = {"frozen": True}
+class Bot(BaseModel):
+    token: Optional[SecretStr] = None
+    timeout: int = 10
+    behavior: BotBehavior = Field(default_factory=BotBehavior)
+    forwarding: BotForwarding = Field(default_factory=BotForwarding)
 
 
 
 
 class DatabaseMigrations(BaseModel):
 class DatabaseMigrations(BaseModel):
     backend: str = "sqlite"
     backend: str = "sqlite"
-    model_config = {"frozen": True}
 
 
 
 
 class Database(BaseModel):
 class Database(BaseModel):
     backend: str = "sqlite+aiosqlite"
     backend: str = "sqlite+aiosqlite"
-    name_or_path: Optional[Union[str, Path]] = None
+    name_or_path: Optional[Union[str, Path]] = "anonflow.db"
     host: Optional[str] = None
     host: Optional[str] = None
     port: Optional[int] = None
     port: Optional[int] = None
     username: Optional[str] = None
     username: Optional[str] = None
     password: Optional[SecretStr] = None
     password: Optional[SecretStr] = None
-    repositories: DatabaseRepositories = DatabaseRepositories()
-    migrations: DatabaseMigrations = DatabaseMigrations()
-    model_config = {"frozen": True}
-
-
-class Forwarding(BaseModel):
-    moderation_chat_ids: Tuple[int, ...] = Field(default_factory=tuple)
-    publication_channel_ids: Tuple[int, ...] = Field(default_factory=tuple)
-    types: FrozenSet[ForwardingType] = frozenset(["text", "photo", "video"])
-    model_config = {"frozen": True}
+    migrations: DatabaseMigrations = Field(default_factory=DatabaseMigrations)
 
 
 
 
 class OpenAI(BaseModel):
 class OpenAI(BaseModel):
@@ -73,18 +61,15 @@ class OpenAI(BaseModel):
     proxy: Optional[HttpUrl] = None
     proxy: Optional[HttpUrl] = None
     timeout: int = 10
     timeout: int = 10
     max_retries: int = 0
     max_retries: int = 0
-    model_config = {"frozen": True}
 
 
 
 
 class Moderation(BaseModel):
 class Moderation(BaseModel):
     enabled: bool = True
     enabled: bool = True
     model: str = "gpt-5-mini"
     model: str = "gpt-5-mini"
-    backends: FrozenSet[ModerationBackend] = frozenset(["omni", "gpt"])
-    model_config = {"frozen": True}
+    backends: Set[ModerationBackend] = Field(default_factory=lambda: {"omni", "gpt"})
 
 
 
 
 class Logging(BaseModel):
 class Logging(BaseModel):
     level: LoggingLevel = "INFO"
     level: LoggingLevel = "INFO"
     fmt: Optional[str] = "%(asctime)s.%(msecs)03d %(levelname)s [%(name)s] %(message)s"
     fmt: Optional[str] = "%(asctime)s.%(msecs)03d %(levelname)s [%(name)s] %(message)s"
     date_fmt: Optional[str] = "%Y-%m-%d %H:%M:%S"
     date_fmt: Optional[str] = "%Y-%m-%d %H:%M:%S"
-    model_config = {"frozen": True}

+ 1 - 1
anonflow/database/orm.py

@@ -37,7 +37,7 @@ class Moderator(Base):
 
 
     user_id = Column(Integer, ForeignKey("users.user_id"), primary_key=True)
     user_id = Column(Integer, ForeignKey("users.user_id"), primary_key=True)
 
 
-    can_approve_posts = Column(Boolean, nullable=False, default=False)
+    can_manage_posts = Column(Boolean, nullable=False, default=False)
     can_manage_bans = Column(Boolean, nullable=False, default=False)
     can_manage_bans = Column(Boolean, nullable=False, default=False)
     can_manage_moderators = Column(Boolean, nullable=False, default=False)
     can_manage_moderators = Column(Boolean, nullable=False, default=False)
 
 

+ 2 - 1
anonflow/interfaces/__init__.py

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

+ 7 - 0
anonflow/interfaces/moderator.py

@@ -0,0 +1,7 @@
+from typing import Optional, Protocol
+
+from anonflow.bot.transport.types import RequestContext
+
+
+class ModeratorResponsesPort(Protocol):
+    async def moderator_permission_error(self, context: RequestContext, callback_query_id: Optional[str] = None): ...

+ 1 - 0
anonflow/interfaces/post.py

@@ -6,5 +6,6 @@ from anonflow.bot.transport.types import RequestContext
 
 
 class PostResponsesPort(Protocol):
 class PostResponsesPort(Protocol):
     async def post_prepared(self, context: RequestContext, content: Union[ContentItem, ContentGroup], is_approved: bool): ...  # fmt: skip
     async def post_prepared(self, context: RequestContext, content: Union[ContentItem, ContentGroup], is_approved: bool): ...  # fmt: skip
+    async def post_moderators_decision(self, context: RequestContext, is_approved: bool, message_id: int): ... # fmt: skip
     async def post_moderation_decision(self, context: RequestContext, is_approved: bool, reason: str): ...  # fmt: skip
     async def post_moderation_decision(self, context: RequestContext, is_approved: bool, reason: str): ...  # fmt: skip
     async def post_moderation_started(self, context: RequestContext): ...  # fmt: skip
     async def post_moderation_started(self, context: RequestContext): ...  # fmt: skip

+ 2 - 2
anonflow/services/moderator/permissions.py

@@ -4,7 +4,7 @@ from enum import Enum
 
 
 @dataclass
 @dataclass
 class ModeratorPermissions:
 class ModeratorPermissions:
-    can_approve_posts: bool = False
+    can_manage_posts: bool = False
     can_manage_bans: bool = False
     can_manage_bans: bool = False
     can_manage_moderators: bool = False
     can_manage_moderators: bool = False
 
 
@@ -13,6 +13,6 @@ class ModeratorPermissions:
 
 
 
 
 class ModeratorPermission(str, Enum):
 class ModeratorPermission(str, Enum):
-    APPROVE_POSTS = "can_approve_posts"
+    MANAGE_POSTS = "can_manage_posts"
     MANAGE_BANS = "can_manage_bans"
     MANAGE_BANS = "can_manage_bans"
     MANAGE_MODERATORS = "can_manage_moderators"
     MANAGE_MODERATORS = "can_manage_moderators"

+ 12 - 12
anonflow/services/moderator/service.py

@@ -27,7 +27,7 @@ class ModeratorService:
     def _assert_not_self(actor_user_id: int, user_id: int):
     def _assert_not_self(actor_user_id: int, user_id: int):
         if actor_user_id == user_id:
         if actor_user_id == user_id:
             raise SelfActionError(
             raise SelfActionError(
-                f"Moderator user_id={actor_user_id} cannot perform this action on themselves. Target user_id={user_id}."
+                f"Moderator user_id={actor_user_id} cannot perform this action on themselves, target user_id={user_id}"
             )
             )
 
 
     async def add(self, actor_user_id: int, user_id: int):
     async def add(self, actor_user_id: int, user_id: int):
@@ -40,10 +40,10 @@ class ModeratorService:
                     await self._moderator_repository.add(session, user_id)
                     await self._moderator_repository.add(session, user_id)
                 else:
                 else:
                     raise ModeratorPermissionError(
                     raise ModeratorPermissionError(
-                        f"Moderator user_id={actor_user_id} does not have permission to perform 'add'."
+                        f"Moderator user_id={actor_user_id} does not have permission to perform 'add'"
                     )
                     )
         except IntegrityError:
         except IntegrityError:
-            self._logger.warning("Failed to add moderator user_id=%s", user_id)
+            self._logger.warning("Failed to add moderator user_id=%s by moderator user_id=%s", user_id, actor_user_id)
 
 
     async def ban(self, actor_user_id: int, user_id: int):
     async def ban(self, actor_user_id: int, user_id: int):
         async with self._database.begin_session() as session:
         async with self._database.begin_session() as session:
@@ -52,7 +52,7 @@ class ModeratorService:
                 await self._ban_repository.ban(session, actor_user_id, user_id)
                 await self._ban_repository.ban(session, actor_user_id, user_id)
             else:
             else:
                 raise ModeratorPermissionError(
                 raise ModeratorPermissionError(
-                    f"Moderator user_id={actor_user_id} does not have permission to perform 'ban'."
+                    f"Moderator user_id={actor_user_id} does not have permission to perform 'ban'"
                 )
                 )
 
 
     async def _can(
     async def _can(
@@ -70,7 +70,7 @@ class ModeratorService:
 
 
     async def can(self, actor_user_id: int, permission: ModeratorPermission):
     async def can(self, actor_user_id: int, permission: ModeratorPermission):
         async with self._database.get_session() as session:
         async with self._database.get_session() as session:
-            return self._can(session, actor_user_id, permission)
+            return await self._can(session, actor_user_id, permission)
 
 
     async def get(self, user_id: int):
     async def get(self, user_id: int):
         async with self._database.get_session() as session:
         async with self._database.get_session() as session:
@@ -115,10 +115,10 @@ class ModeratorService:
                     await self._moderator_repository.remove(session, user_id)
                     await self._moderator_repository.remove(session, user_id)
                 else:
                 else:
                     raise ModeratorPermissionError(
                     raise ModeratorPermissionError(
-                        f"Moderator user_id={actor_user_id} does not have permission to perform 'remove'."
+                        f"Moderator user_id={actor_user_id} does not have permission to perform 'remove'"
                     )
                     )
         except IntegrityError:
         except IntegrityError:
-            self._logger.warning("Failed to remove moderator user_id=%s", user_id)
+            self._logger.warning("Failed to remove moderator user_id=%s by moderator user_id=%s", user_id, actor_user_id)
 
 
     async def unban(self, actor_user_id: int, user_id: int):
     async def unban(self, actor_user_id: int, user_id: int):
         async with self._database.begin_session() as session:
         async with self._database.begin_session() as session:
@@ -127,7 +127,7 @@ class ModeratorService:
                 await self._ban_repository.unban(session, actor_user_id, user_id)
                 await self._ban_repository.unban(session, actor_user_id, user_id)
             else:
             else:
                 raise ModeratorPermissionError(
                 raise ModeratorPermissionError(
-                    f"Moderator user_id={actor_user_id} does not have permission to perform 'unban'."
+                    f"Moderator user_id={actor_user_id} does not have permission to perform 'unban'"
                 )
                 )
 
 
     async def update(self, actor_user_id: int, user_id: int, **fields):
     async def update(self, actor_user_id: int, user_id: int, **fields):
@@ -140,10 +140,10 @@ class ModeratorService:
                     await self._moderator_repository.update(session, user_id, **fields)
                     await self._moderator_repository.update(session, user_id, **fields)
                 else:
                 else:
                     raise ModeratorPermissionError(
                     raise ModeratorPermissionError(
-                        f"Moderator user_id={actor_user_id} does not have permission to perform 'update'."
+                        f"Moderator user_id={actor_user_id} does not have permission to perform 'update'"
                     )
                     )
         except IntegrityError:
         except IntegrityError:
-            self._logger.warning("Failed to update moderator user_id=%s", user_id)
+            self._logger.exception("Failed to update moderator user_id=%s by moderator user_id=%s", user_id, actor_user_id)
 
 
     async def update_permissions(
     async def update_permissions(
         self, actor_user_id: int, user_id: int, permissions: ModeratorPermissions
         self, actor_user_id: int, user_id: int, permissions: ModeratorPermissions
@@ -159,7 +159,7 @@ class ModeratorService:
                     )
                     )
                 else:
                 else:
                     raise ModeratorPermissionError(
                     raise ModeratorPermissionError(
-                        f"Moderator user_id={actor_user_id} does not have permission to perform 'update_permissions'."
+                        f"Moderator user_id={actor_user_id} does not have permission to perform 'update_permissions'"
                     )
                     )
         except IntegrityError:
         except IntegrityError:
-            self._logger.warning("Failed to update moderator user_id=%s", user_id)
+            self._logger.exception("Failed to update moderator user_id=%s by moderator user_id=%s", user_id, actor_user_id)

+ 3 - 3
anonflow/services/user/service.py

@@ -17,7 +17,7 @@ class UserService:
             async with self._database.begin_session() as session:
             async with self._database.begin_session() as session:
                 await self._user_repository.add(session, user_id)
                 await self._user_repository.add(session, user_id)
         except IntegrityError:
         except IntegrityError:
-            self._logger.warning("Failed to add user user_id=%s", user_id)
+            self._logger.warning("Failed to add user, possibly already exists")
 
 
     async def get(self, user_id: int):
     async def get(self, user_id: int):
         async with self._database.get_session() as session:
         async with self._database.get_session() as session:
@@ -32,11 +32,11 @@ class UserService:
             async with self._database.begin_session() as session:
             async with self._database.begin_session() as session:
                 await self._user_repository.remove(session, user_id)
                 await self._user_repository.remove(session, user_id)
         except IntegrityError:
         except IntegrityError:
-            self._logger.warning("Failed to remove user user_id=%s", user_id)
+            self._logger.exception("Failed to remove user")
 
 
     async def update(self, user_id: int, **fields):
     async def update(self, user_id: int, **fields):
         try:
         try:
             async with self._database.begin_session() as session:
             async with self._database.begin_session() as session:
                 await self._user_repository.update(session, user_id, **fields)
                 await self._user_repository.update(session, user_id, **fields)
         except IntegrityError:
         except IntegrityError:
-            self._logger.warning("Failed to update user user_id=%s", user_id)
+            self._logger.exception("Failed to update user")

+ 9 - 5
anonflow/translator/translator.py

@@ -7,14 +7,15 @@ from typing import Optional
 
 
 
 
 class Translator:
 class Translator:
-    def __init__(self, translations_dir: Path):
+    def __init__(self, translations_dir: Path, default_language: str):
         self._translations_dir = translations_dir
         self._translations_dir = translations_dir
+        self._default_language = default_language
 
 
     @staticmethod
     @staticmethod
     @lru_cache
     @lru_cache
-    def _get_translation(lang: str, domain: str, translations_dir: Path):
+    def _get_translation(language: str, domain: str, translations_dir: Path):
         translation = gettext.translation(
         translation = gettext.translation(
-            domain, translations_dir, languages=[lang], fallback=True
+            domain, translations_dir, languages=[language], fallback=True
         )
         )
         return translation
         return translation
 
 
@@ -22,9 +23,12 @@ class Translator:
     def _format(s: str, **context):
     def _format(s: str, **context):
         return s.format_map(defaultdict(str, context))
         return s.format_map(defaultdict(str, context))
 
 
-    async def get(self, lang: str = "ru", domain: str = "messages"):
+    async def get(self, language: Optional[str] = None, domain: str = "messages"):
         translator = await asyncio.to_thread(
         translator = await asyncio.to_thread(
-            self._get_translation, lang, domain, self._translations_dir
+            self._get_translation,
+            language or self._default_language,
+            domain,
+            self._translations_dir
         )
         )
 
 
         def _(
         def _(

+ 43 - 42
config.yml.example

@@ -1,3 +1,12 @@
+app:
+  # Primary bot language.
+  # Used by the bot in moderation chats by default.
+  language: ru
+
+  # Language used when a user is not found in the database
+  # and their preferred language cannot be determined.
+  fallback_language: ru
+
 bot:
 bot:
   # Telegram bot token, passed via environment variable BOT_TOKEN.
   # Telegram bot token, passed via environment variable BOT_TOKEN.
   # Used by aiogram to connect to the Telegram Bot API.
   # Used by aiogram to connect to the Telegram Bot API.
@@ -8,23 +17,40 @@ bot:
   # only defines how long a single HTTP request may take before timing out.
   # only defines how long a single HTTP request may take before timing out.
   timeout: 15
   timeout: 15
 
 
-behavior:
-  throttling:
-    # Enable/disable throttling for user-submitted posts.
-    # When enabled, each user must wait `delay` seconds between submissions.
-    enabled: true
-
-    # Minimal delay (in seconds) between two post submissions from the same user.
-    # The delay is measured between the moments when the user's requests are processed by the bot.
-    delay: 120
-
-  subscription_requirement:
-    # Require users to be subscribed to specific Telegram channels before they can use the bot.
-    # If enabled, the bot checks subscription status for each user action.
-    enabled: false
-
-    # List of Telegram chat_ids (channels) that the user must be subscribed to.
-    channel_ids: []
+  behavior:
+    throttling:
+      # Enable/disable throttling for user-submitted posts.
+      # When enabled, each user must wait `delay` seconds between submissions.
+      enabled: true
+
+      # Minimal delay (in seconds) between two post submissions from the same user.
+      # The delay is measured between the moments when the user's requests are processed by the bot.
+      delay: 120
+
+    subscription_requirement:
+      # Require users to be subscribed to specific Telegram channels before they can use the bot.
+      # If enabled, the bot checks subscription status for each user action.
+      enabled: false
+
+      # List of Telegram chat_ids (channels) that the user must be subscribed to.
+      channel_ids: []
+
+  forwarding:
+    # Telegram chat_id where decisions of the moderation module are sent.
+    # Typically this is a chat for moderators that receive moderation results.
+    moderation_chat_id: null
+
+    # Telegram chat_ids where approved posts are published.
+    # Once approved, messages are forwarded to all configured publication channels.
+    publication_channel_ids: []
+
+    # List of Telegram message types that are handled and can be forwarded
+    # by the bot: text messages, photos, videos, etc.
+    # Types correspond to Telegram message content types.
+    types:
+      - text
+      - photo
+      - video
 
 
 database:
 database:
   # SQLAlchemy database backend/driver.
   # SQLAlchemy database backend/driver.
@@ -58,31 +84,6 @@ database:
     # Alembic is configured to work with the same SQLAlchemy URL.
     # Alembic is configured to work with the same SQLAlchemy URL.
     backend: ${DB_MIGRATIONS_BACKEND}
     backend: ${DB_MIGRATIONS_BACKEND}
 
 
-  repositories:
-    user:
-      # Maximum number of user records cached in memory.
-      cache_size: 1024
-
-      # Time-to-live for cached user objects (in seconds).
-      cache_ttl: 60
-
-forwarding:
-  # Telegram chat_ids where decisions of the moderation module are sent.
-  # Typically these are chats for moderators that receive moderation results.
-  moderation_chat_ids: []
-
-  # Telegram chat_ids where approved posts are published.
-  # Once approved, messages are forwarded to all configured publication channels.
-  publication_channel_ids: []
-
-  # List of Telegram message types that are handled and can be forwarded
-  # by the bot: text messages, photos, videos, etc.
-  # Types correspond to Telegram message content types.
-  types:
-    - text
-    - photo
-    - video
-
 openai:
 openai:
   # OpenAI API key used by the client.
   # OpenAI API key used by the client.
   # Can be provided via OPENAI_API_KEY environment variable.
   # Can be provided via OPENAI_API_KEY environment variable.

+ 29 - 0
translations/ru/LC_MESSAGES/keyboards.po

@@ -0,0 +1,29 @@
+# Russian translations for anonflow.
+# Copyright (C) 2026 Librellium
+# This file is distributed under the same license as the anonflow project.
+# Librellium support@librellium.space, 2026.
+#
+msgid ""
+msgstr ""
+"Project-Id-Version:  0.5.0\n"
+"Report-Msgid-Bugs-To: support@librellium.space\n"
+"POT-Creation-Date: 2026-03-09 00:03+0500\n"
+"PO-Revision-Date: 2026-03-09 00:05+0500\n"
+"Last-Translator: Librellium support@librellium.space\n"
+"Language-Team: Russian <Librellium>, https://librellium.space\n"
+"Language: ru\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && "
+"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
+"Generated-By: Babel 2.17.0\n"
+
+#: anonflow/bot/keyboards/keyboards.py:12
+msgid "post.approve"
+msgstr "Отправить"
+
+#: anonflow/bot/keyboards/keyboards.py:16
+msgid "post.reject"
+msgstr "Отклонить"
+

+ 31 - 26
translations/ru/LC_MESSAGES/messages.po

@@ -1,14 +1,14 @@
 # Russian translations for anonflow.
 # Russian translations for anonflow.
 # Copyright (C) 2026 Librellium
 # Copyright (C) 2026 Librellium
 # This file is distributed under the same license as the anonflow project.
 # This file is distributed under the same license as the anonflow project.
-# Librellium 246878136+librellium@users.noreply.github.com, 2026.
+# Librellium support@librellium.space, 2026.
 #
 #
 msgid ""
 msgid ""
 msgstr ""
 msgstr ""
 "Project-Id-Version:  0.5.0\n"
 "Project-Id-Version:  0.5.0\n"
 "Report-Msgid-Bugs-To: support@librellium.space\n"
 "Report-Msgid-Bugs-To: support@librellium.space\n"
 "POT-Creation-Date: 2026-02-22 12:26+0500\n"
 "POT-Creation-Date: 2026-02-22 12:26+0500\n"
-"PO-Revision-Date: 2026-02-22 12:28+0500\n"
+"PO-Revision-Date: 2026-03-09 00:07+0500\n"
 "Last-Translator: Librellium support@librellium.space\n"
 "Last-Translator: Librellium support@librellium.space\n"
 "Language-Team: Russian <Librellium>, https://librellium.space\n"
 "Language-Team: Russian <Librellium>, https://librellium.space\n"
 "Language: ru\n"
 "Language: ru\n"
@@ -19,62 +19,67 @@ msgstr ""
 "n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
 "n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
 "Generated-By: Babel 2.17.0\n"
 "Generated-By: Babel 2.17.0\n"
 
 
-#: anonflow/services/transport/router.py:39
-msgid "messages.staff.moderation_approved"
+#: anonflow/services/transport/router.py:46
+msgid "moderator.permission_error"
+msgstr ""
+"У вас недостаточно прав, чтобы выполнить это действие!"
+
+#: anonflow/services/transport/router.py:70
+msgid "moderator.moderation_approved"
 msgstr ""
 msgstr ""
 "<b>Сообщение ниже было отправлено.</b>\n"
 "<b>Сообщение ниже было отправлено.</b>\n"
 "\n"
 "\n"
 "Причина: {reason}"
 "Причина: {reason}"
 
 
-#: anonflow/services/transport/router.py:47
-msgid "messages.staff.moderation_rejected"
+#: anonflow/services/transport/router.py:78
+msgid "moderator.moderation_rejected"
 msgstr ""
 msgstr ""
 "<b>Сообщение ниже было отклонено.</b>\n"
 "<b>Сообщение ниже было отклонено.</b>\n"
 "\n"
 "\n"
 "Причина: {reason}"
 "Причина: {reason}"
 
 
-#: anonflow/services/transport/router.py:55
-msgid "messages.user.moderation_rejected"
+#: anonflow/services/transport/router.py:85
+msgid "user.moderation_approved"
+msgstr "Сообщение успешно отправлено!"
+
+#: anonflow/services/transport/router.py:89
+msgid "user.moderation_rejected"
 msgstr ""
 msgstr ""
 "Извините, но сообщение не прошло модерацию. "
 "Извините, но сообщение не прошло модерацию. "
 "Оно было отправлено на ручную проверку."
 "Оно было отправлено на ручную проверку."
 
 
-#: anonflow/services/transport/router.py:62
-msgid "messages.user.moderation_started"
+#: anonflow/services/transport/router.py:95
+msgid "user.moderation_started"
 msgstr "Сообщение отправлено на модерацию, ожидайте..."
 msgstr "Сообщение отправлено на модерацию, ожидайте..."
 
 
-#: anonflow/services/transport/router.py:79
-msgid "messages.channel.post"
+#: anonflow/services/transport/router.py:106
+msgid "channel.post"
 msgstr ""
 msgstr ""
 "<b>Сообщение из предложки</b>\n"
 "<b>Сообщение из предложки</b>\n"
 "\n"
 "\n"
 "<blockquote>{text}</blockquote>"
 "<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"
+#: anonflow/services/transport/router.py:123
+msgid "user.banned"
 msgstr "Извините, но вы были заблокированы. Отправка сообщений недоступна."
 msgstr "Извините, но вы были заблокированы. Отправка сообщений недоступна."
 
 
-#: anonflow/services/transport/router.py:106
-msgid "messages.user.not_registered"
+#: anonflow/services/transport/router.py:128
+msgid "user.not_registered"
 msgstr "Для продолжения пропишите /start."
 msgstr "Для продолжения пропишите /start."
 
 
-#: anonflow/services/transport/router.py:113
-msgid "messages.user.command_start"
+#: anonflow/services/transport/router.py:133
+msgid "user.command_start"
 msgstr ""
 msgstr ""
 "<b>Привет!</b>\n"
 "<b>Привет!</b>\n"
 "Ты можешь отправить мне сообщение, и я передам его в прикрепленный "
 "Ты можешь отправить мне сообщение, и я передам его в прикрепленный "
 "канал, не раскрывая твою личность."
 "канал, не раскрывая твою личность."
 
 
-#: anonflow/services/transport/router.py:120
-msgid "messages.user.subscription_required"
+#: anonflow/services/transport/router.py:138
+msgid "user.subscription_required"
 msgstr "Вам нужно быть участником канала, чтобы отправлять сообщения!"
 msgstr "Вам нужно быть участником канала, чтобы отправлять сообщения!"
 
 
-#: anonflow/services/transport/router.py:128
-msgid "messages.user.throttled"
+#: anonflow/services/transport/router.py:146
+msgid "user.throttled"
 msgid_plural "messages.user.throttled"
 msgid_plural "messages.user.throttled"
 msgstr[0] "Вы уже недавно отправляли сообщение! Пожалуйста, подождите {remaining_time} секунду перед следующей попыткой."
 msgstr[0] "Вы уже недавно отправляли сообщение! Пожалуйста, подождите {remaining_time} секунду перед следующей попыткой."
 msgstr[1] "Вы уже недавно отправляли сообщение! Пожалуйста, подождите {remaining_time} секунды перед следующей попыткой."
 msgstr[1] "Вы уже недавно отправляли сообщение! Пожалуйста, подождите {remaining_time} секунды перед следующей попыткой."