Răsfoiți Sursa

Merge pull request #28 from librellium/feature/keyboards

feature/keyboards
Librellium 2 zile în urmă
părinte
comite
ffd438d992
36 a modificat fișierele cu 525 adăugiri și 263 ștergeri
  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"]

+ 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,
     UserContextMiddleware,
     UserLanguageMiddleware,
@@ -13,6 +17,7 @@ from anonflow.services import ModeratorService, UserService
 
 def build_middlewares(
     config: Config,
+    dispatcher: Dispatcher,
     responses_router: ResponsesRouter,
     user_service: UserService,
     moderator_service: ModeratorService,
@@ -21,7 +26,9 @@ def build_middlewares(
 
     middlewares.append(UserContextMiddleware(user_service=user_service))
 
-    middlewares.append(UserLanguageMiddleware())
+    middlewares.append(
+        UserLanguageMiddleware(fallback_language=config.app.fallback_language)
+    )
 
     middlewares.append(
         UserBannedMiddleware(
@@ -29,22 +36,31 @@ def build_middlewares(
         )
     )
 
-    if config.behavior.subscription_requirement.enabled:
+    if config.bot.behavior.subscription_requirement.enabled:
         middlewares.append(
             UserSubscriptionMiddleware(
                 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))
 
-    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(
             UserThrottlingMiddleware(
                 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 anonflow.bot.routers import MediaRouter, StartRouter, TextRouter
+from anonflow.bot.routers import MediaRouter, PostRouter, StartRouter, TextRouter
 from anonflow.bot.transport import ResponsesRouter
 from anonflow.config import Config
 from anonflow.moderation import ModerationService
@@ -17,17 +17,25 @@ def build_routers(
     main_router = Router()
 
     routers = [
-        StartRouter(responses_port=responses_router, user_service=user_service),
+        StartRouter(
+            responses_port=responses_router,
+            user_service=user_service
+        ),
         TextRouter(
             responses_port=responses_router,
             moderation_service=moderation_service,
-            forwarding_types=config.forwarding.types,
+            forwarding_types=config.bot.forwarding.types,
         ),
         MediaRouter(
             responses_port=responses_router,
             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:

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

@@ -2,7 +2,7 @@ import logging
 from typing import Optional
 
 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 anonflow import __version_str__, paths
@@ -87,7 +87,11 @@ class Application:
             self._dispatcher = Dispatcher(storage=MemoryStorage())
 
     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):
         with require(self, "_bot", "_config", "_translator") as (
@@ -96,8 +100,8 @@ class Application:
             translator,
         ):
             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),
                 translator=translator,
             )
@@ -169,9 +173,16 @@ class Application:
             "_responses_router",
             "_user_service",
             "_moderator_service",
-        ) as (dispatcher, config, responses_router, user_service, moderator_service):
+        ) as (
+            dispatcher,
+            config,
+            responses_router,
+            user_service,
+            moderator_service
+        ):
             middlewares = build_middlewares(
                 config=config,
+                dispatcher=dispatcher,
                 responses_router=responses_router,
                 user_service=user_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.services import ModeratorService
 
+from .utils import extract_message, extract_user
+
 
 class UserBannedMiddleware(BaseMiddleware):
     def __init__(
@@ -16,9 +18,10 @@ class UserBannedMiddleware(BaseMiddleware):
         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):
+        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(
                     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.types import Message
 
 from anonflow.services import UserService
 
+from .utils import extract_user
+
 
 class UserContextMiddleware(BaseMiddleware):
     def __init__(self, user_service: UserService):
@@ -13,8 +14,8 @@ class UserContextMiddleware(BaseMiddleware):
     async def __call__(self, handler, event, data):
         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)

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

@@ -1,19 +1,22 @@
 from aiogram import BaseMiddleware
-from aiogram.types import Message
+
+from .utils import extract_user
 
 
 class UserLanguageMiddleware(BaseMiddleware):
-    def __init__(self):
+    def __init__(self, fallback_language: str):
         super().__init__()
 
+        self._fallback_language = fallback_language
+
     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")
             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)

+ 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.interfaces import UserResponsesPort
 
+from .utils import extract_message
+
 
 class UserNotRegisteredMiddleware(BaseMiddleware):
     def __init__(self, responses_port: UserResponsesPort):
@@ -13,7 +15,7 @@ class UserNotRegisteredMiddleware(BaseMiddleware):
         self._responses_port = responses_port
 
     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:
             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.interfaces import UserResponsesPort
 
+from .utils import extract_message, extract_user
+
 
 class UserSubscriptionMiddleware(BaseMiddleware):
     def __init__(
@@ -18,16 +20,16 @@ class UserSubscriptionMiddleware(BaseMiddleware):
         self._channel_ids = channel_ids
 
     async def __call__(self, handler, event, data):
-        message = getattr(event, "message", None)
+        message = extract_message(event)
+        from_user = extract_user(event)
         if (
             isinstance(message, Message)
+            and from_user is not None
+            and message.bot is not None
             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:
-                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):
                     await self._responses_port.user_subscription_required(
                         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 aiogram import BaseMiddleware
+from aiogram.enums import ChatType
 from aiogram.types import ChatIdUnion, Message
 
 from anonflow.bot.transport.types import RequestContext
 from anonflow.interfaces import UserResponsesPort
 
+from .utils import extract_message
+
 
 class UserThrottlingMiddleware(BaseMiddleware):
     def __init__(
         self,
         responses_port: UserResponsesPort,
         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__()
 
         self._responses_port = responses_port
         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_locks: Dict[int, asyncio.Lock] = {}
@@ -28,13 +33,14 @@ class UserThrottlingMiddleware(BaseMiddleware):
         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
+        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 ""
-            if not text.startswith("/"):
+            if not text.startswith(self._ignored_commands):
                 async with self._lock:
                     user_lock = self._user_locks.setdefault(
                         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 .start import StartRouter
 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 contextlib import suppress
 from io import BytesIO
-from typing import Dict, FrozenSet, List
+from typing import Dict, List, Set
 
 from aiogram import F, Router
 from aiogram.enums import ChatType
 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.interfaces import PostResponsesPort
 from anonflow.moderation import ModerationService
-from anonflow.bot.transport.content import ContentGroup, ContentMediaItem, MediaType
-from anonflow.bot.transport.types import RequestContext
 
 
 class MediaRouter(Router):
@@ -21,7 +25,7 @@ class MediaRouter(Router):
         self,
         responses_port: PostResponsesPort,
         moderation_service: ModerationService,
-        forwarding_types: FrozenSet[ForwardingType],
+        forwarding_types: Set[ForwardingType],
     ):
         super().__init__()
 
@@ -57,7 +61,7 @@ class MediaRouter(Router):
         elif message.video and "video" in self._forwarding_types:
             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:
             return
 
@@ -94,7 +98,7 @@ class MediaRouter(Router):
                     messages = self.media_groups.pop(media_group_id, [])  # 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:
             async with self._media_groups_lock:
@@ -109,7 +113,7 @@ class MediaRouter(Router):
                 )
             return
 
-        await self._process_messages([message], user_language)
+        asyncio.create_task(self._process([message], user_language))
 
     def setup(self):
         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.types import Message
 
-from anonflow.services import UserService
-from anonflow.interfaces import UserResponsesPort
 from anonflow.bot.transport.types import RequestContext
+from anonflow.interfaces import UserResponsesPort
+from anonflow.services import UserService
 
 
 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.enums import ChatType
 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.interfaces import PostResponsesPort
 from anonflow.moderation import ModerationService
-from anonflow.bot.transport.content import ContentTextItem
-from anonflow.bot.transport.types import RequestContext
 
 
 class TextRouter(Router):
@@ -16,7 +17,7 @@ class TextRouter(Router):
         self,
         responses_port: PostResponsesPort,
         moderation_service: ModerationService,
-        forwarding_types: FrozenSet[ForwardingType],
+        forwarding_types: Set[ForwardingType],
     ):
         super().__init__()
 
@@ -24,15 +25,18 @@ class TextRouter(Router):
         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)
+    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):
         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.client.bot import Default
+from aiogram.client.default import Default
 from aiogram.types import (
     ChatIdUnion,
     InputMediaPhoto,
@@ -32,9 +33,26 @@ class DeliveryService:
         else:
             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):
         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(
         self,
         chat_id: ChatIdUnion,
@@ -101,3 +119,16 @@ class DeliveryService:
         return await self._bot.send_message(
             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 anonflow.interfaces import PostResponsesPort, UserResponsesPort
+from anonflow.bot.keyboards import Keyboards
+from anonflow.interfaces import (
+    ModeratorResponsesPort,
+    PostResponsesPort,
+    UserResponsesPort,
+)
 from anonflow.translator import Translator
 
 from .content import ContentGroup, ContentItem
@@ -11,50 +15,83 @@ from .delivery import DeliveryService
 from .types import RequestContext
 
 
-class ResponsesRouter(PostResponsesPort, UserResponsesPort):
+class ResponsesRouter(ModeratorResponsesPort, PostResponsesPort, UserResponsesPort):
     def __init__(
         self,
-        moderation_chat_ids: Tuple[ChatIdUnion],
+        moderation_chat_id: ChatIdUnion,
         publication_channel_ids: Tuple[ChatIdUnion],
         delivery_service: DeliveryService,
         translator: Translator,
     ):
-        self._moderation_chat_ids = moderation_chat_ids
+        self._moderation_chat_id = moderation_chat_id
         self._publication_channel_ids = publication_channel_ids
         self._delivery_service = delivery_service
         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(
         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(
-                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):
-        _ = 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(
@@ -63,54 +100,48 @@ class ResponsesRouter(PostResponsesPort, UserResponsesPort):
         content: Union[ContentItem, ContentGroup],
         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:
-            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):
-        _ = 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):
-        _ = 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(
-            context.chat_id, _("messages.user.not_registered")
+            context.chat_id, t_user("user.not_registered")
         )
 
     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):
-        _ = 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(
-            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):
-        _ = 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(
             context.chat_id,
-            _(
-                "messages.user.throttled",
+            t_user(
+                "user.throttled",
                 n=remaining_time,
                 remaining_time=remaining_time,
             ),

+ 8 - 10
anonflow/config/config.py

@@ -3,21 +3,19 @@ from string import Template
 
 import yaml
 from dotenv import dotenv_values
-from pydantic import BaseModel, SecretStr
+from pydantic import BaseModel, Field, SecretStr
 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):
-    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):
         password = None

+ 22 - 37
anonflow/config/models.py

@@ -1,5 +1,5 @@
 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
 
@@ -8,63 +8,51 @@ ModerationBackend: TypeAlias = Literal["omni", "gpt"]
 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
     delay: float = 120
-    model_config = {"frozen": True}
 
 
-class BehaviorSubscriptionRequirement(BaseModel):
+class BotBehaviorSubscriptionRequirement(BaseModel):
     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):
     backend: str = "sqlite"
-    model_config = {"frozen": True}
 
 
 class Database(BaseModel):
     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
     port: Optional[int] = None
     username: Optional[str] = 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):
@@ -73,18 +61,15 @@ class OpenAI(BaseModel):
     proxy: Optional[HttpUrl] = None
     timeout: int = 10
     max_retries: int = 0
-    model_config = {"frozen": True}
 
 
 class Moderation(BaseModel):
     enabled: bool = True
     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):
     level: LoggingLevel = "INFO"
     fmt: Optional[str] = "%(asctime)s.%(msecs)03d %(levelname)s [%(name)s] %(message)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)
 
-    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_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 .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):
     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_started(self, context: RequestContext): ...  # fmt: skip

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

@@ -4,7 +4,7 @@ from enum import Enum
 
 @dataclass
 class ModeratorPermissions:
-    can_approve_posts: bool = False
+    can_manage_posts: bool = False
     can_manage_bans: bool = False
     can_manage_moderators: bool = False
 
@@ -13,6 +13,6 @@ class ModeratorPermissions:
 
 
 class ModeratorPermission(str, Enum):
-    APPROVE_POSTS = "can_approve_posts"
+    MANAGE_POSTS = "can_manage_posts"
     MANAGE_BANS = "can_manage_bans"
     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):
         if actor_user_id == user_id:
             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):
@@ -40,10 +40,10 @@ class ModeratorService:
                     await self._moderator_repository.add(session, user_id)
                 else:
                     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:
-            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 with self._database.begin_session() as session:
@@ -52,7 +52,7 @@ class ModeratorService:
                 await self._ban_repository.ban(session, actor_user_id, user_id)
             else:
                 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(
@@ -70,7 +70,7 @@ class ModeratorService:
 
     async def can(self, actor_user_id: int, permission: ModeratorPermission):
         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 with self._database.get_session() as session:
@@ -115,10 +115,10 @@ class ModeratorService:
                     await self._moderator_repository.remove(session, user_id)
                 else:
                     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:
-            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 with self._database.begin_session() as session:
@@ -127,7 +127,7 @@ class ModeratorService:
                 await self._ban_repository.unban(session, actor_user_id, user_id)
             else:
                 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):
@@ -140,10 +140,10 @@ class ModeratorService:
                     await self._moderator_repository.update(session, user_id, **fields)
                 else:
                     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:
-            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(
         self, actor_user_id: int, user_id: int, permissions: ModeratorPermissions
@@ -159,7 +159,7 @@ class ModeratorService:
                     )
                 else:
                     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:
-            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:
                 await self._user_repository.add(session, user_id)
         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 with self._database.get_session() as session:
@@ -32,11 +32,11 @@ class UserService:
             async with self._database.begin_session() as session:
                 await self._user_repository.remove(session, user_id)
         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):
         try:
             async with self._database.begin_session() as session:
                 await self._user_repository.update(session, user_id, **fields)
         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:
-    def __init__(self, translations_dir: Path):
+    def __init__(self, translations_dir: Path, default_language: str):
         self._translations_dir = translations_dir
+        self._default_language = default_language
 
     @staticmethod
     @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(
-            domain, translations_dir, languages=[lang], fallback=True
+            domain, translations_dir, languages=[language], fallback=True
         )
         return translation
 
@@ -22,9 +23,12 @@ class Translator:
     def _format(s: 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(
-            self._get_translation, lang, domain, self._translations_dir
+            self._get_translation,
+            language or self._default_language,
+            domain,
+            self._translations_dir
         )
 
         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:
   # Telegram bot token, passed via environment variable BOT_TOKEN.
   # 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.
   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:
   # SQLAlchemy database backend/driver.
@@ -58,31 +84,6 @@ database:
     # Alembic is configured to work with the same SQLAlchemy URL.
     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 API key used by the client.
   # 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.
 # Copyright (C) 2026 Librellium
 # 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 ""
 msgstr ""
 "Project-Id-Version:  0.5.0\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"
+"PO-Revision-Date: 2026-03-09 00:07+0500\n"
 "Last-Translator: Librellium support@librellium.space\n"
 "Language-Team: Russian <Librellium>, https://librellium.space\n"
 "Language: ru\n"
@@ -19,62 +19,67 @@ 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: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 ""
 "<b>Сообщение ниже было отправлено.</b>\n"
 "\n"
 "Причина: {reason}"
 
-#: anonflow/services/transport/router.py:47
-msgid "messages.staff.moderation_rejected"
+#: anonflow/services/transport/router.py:78
+msgid "moderator.moderation_rejected"
 msgstr ""
 "<b>Сообщение ниже было отклонено.</b>\n"
 "\n"
 "Причина: {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 ""
 "Извините, но сообщение не прошло модерацию. "
 "Оно было отправлено на ручную проверку."
 
-#: anonflow/services/transport/router.py:62
-msgid "messages.user.moderation_started"
+#: anonflow/services/transport/router.py:95
+msgid "user.moderation_started"
 msgstr "Сообщение отправлено на модерацию, ожидайте..."
 
-#: anonflow/services/transport/router.py:79
-msgid "messages.channel.post"
+#: anonflow/services/transport/router.py:106
+msgid "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"
+#: anonflow/services/transport/router.py:123
+msgid "user.banned"
 msgstr "Извините, но вы были заблокированы. Отправка сообщений недоступна."
 
-#: anonflow/services/transport/router.py:106
-msgid "messages.user.not_registered"
+#: anonflow/services/transport/router.py:128
+msgid "user.not_registered"
 msgstr "Для продолжения пропишите /start."
 
-#: anonflow/services/transport/router.py:113
-msgid "messages.user.command_start"
+#: anonflow/services/transport/router.py:133
+msgid "user.command_start"
 msgstr ""
 "<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 "Вам нужно быть участником канала, чтобы отправлять сообщения!"
 
-#: anonflow/services/transport/router.py:128
-msgid "messages.user.throttled"
+#: anonflow/services/transport/router.py:146
+msgid "user.throttled"
 msgid_plural "messages.user.throttled"
 msgstr[0] "Вы уже недавно отправляли сообщение! Пожалуйста, подождите {remaining_time} секунду перед следующей попыткой."
 msgstr[1] "Вы уже недавно отправляли сообщение! Пожалуйста, подождите {remaining_time} секунды перед следующей попыткой."