Parcourir la source

Merge pull request #2 from librellium/refactor/major

Refactor/major
Librellium il y a 2 mois
Parent
commit
b135c8963a

+ 68 - 79
anonflow/app.py

@@ -5,7 +5,6 @@ from aiogram.client.bot import DefaultBotProperties
 
 from anonflow.bot import (
     EventHandler,
-    MessageManager,
     GlobalSlowmodeMiddleware,
     SubscriptionMiddleware,
     UserSlowmodeMiddleware,
@@ -23,51 +22,32 @@ from . import paths
 
 
 class NotInitializedError(Exception): ...
-class BotNotInitializedError(NotInitializedError): ...
-class ConfigNotInitializedError(NotInitializedError): ...
-class TranslatorNotInitializedError(NotInitializedError): ...
-class ModerationNotInitializedError(NotInitializedError): ...
 
 
 class Application:
     def __init__(self):
         self.bot = None
         self.dispatcher = None
-        self.message_manager = None
         self.config = None
         self.translator = None
         self.executor = None
         self.event_handler = None
 
-    def get_bot(self):
-        if self.bot is None:
-            raise BotNotInitializedError()
-
-        return self.bot
-
-    def get_dispatcher(self):
-        if self.dispatcher is None:
-            raise BotNotInitializedError()
-
-        return self.dispatcher
-
-    def get_message_manager(self):
-        if self.message_manager is None:
-            raise BotNotInitializedError()
-
-        return self.message_manager
-
-    def get_config(self):
-        if self.config is None:
-            raise ConfigNotInitializedError()
-
-        return self.config
-
-    def get_translator(self):
-        if self.translator is None:
-            raise TranslatorNotInitializedError()
-
-        return self.translator
+    _essential_components = frozenset({
+        "bot",
+        "dispatcher",
+        "config",
+        "translator",
+        "event_handler"
+    })
+    def __getattribute__(self, name: str, /):
+        if name in object.__getattribute__(self, "_essential_components"):
+            obj = object.__getattribute__(self, name)
+            if obj is None:
+                raise NotInitializedError(name)
+            return obj
+
+        return object.__getattribute__(self, name)
 
     def _init_config(self):
         config_filepath = paths.CONFIG_FILEPATH
@@ -78,103 +58,112 @@ class Application:
         self.config = Config.load(config_filepath)
 
     def _init_logging(self):
-        config = self.get_config()
+        config = self.config
 
         logging.basicConfig(
-            format=config.logging.fmt,
-            datefmt=config.logging.date_fmt,
-            level=config.logging.level,
+            format=config.logging.fmt, # type: ignore
+            datefmt=config.logging.date_fmt, # type: ignore
+            level=config.logging.level, # type: ignore
         )
 
     def _init_bot(self):
-        config = self.get_config()
+        config = self.config
 
-        if not config.bot.token:
-            raise ValueError()
+        bot_token = config.bot.token # type: ignore
+        if not bot_token:
+            raise ValueError("bot.token is required and cannot be empty")
 
         self.bot = Bot(
-            token=config.bot.token.get_secret_value(),
+            token=bot_token.get_secret_value(),
             default=DefaultBotProperties(parse_mode="HTML")
         )
         self.dispatcher = Dispatcher()
 
-        self.message_manager = MessageManager()
+    async def _init_translator(self):
+        self.translator = Translator()
+        await self.translator.init(self.bot)
 
     def _postinit_bot(self):
         dispatcher, config, translator = (
-            self.get_dispatcher(),
-            self.get_config(),
-            self.get_translator()
+            self.dispatcher,
+            self.config,
+            self.translator
         )
 
-        if config.behavior.subscription_requirement.enabled:
-            dispatcher.update.middleware(
+        if config.behavior.subscription_requirement.enabled: # type: ignore
+            dispatcher.update.middleware( # type: ignore
                 SubscriptionMiddleware(
-                    channel_ids=config.forwarding.publication_channel_ids,
-                    translator=translator
+                    channel_ids=config.forwarding.publication_channel_ids, # type: ignore
+                    translator=translator # type: ignore
                 )
             )
 
-        if config.behavior.slowmode.enabled:
+        if config.behavior.slowmode.enabled: # type: ignore
             slowmode_map = {
                 "global": GlobalSlowmodeMiddleware,
                 "user": UserSlowmodeMiddleware
             }
-            dispatcher.update.middleware(
-                slowmode_map[config.behavior.slowmode.mode](
-                    delay=config.behavior.slowmode.delay,
+            dispatcher.update.middleware( # type: ignore
+                slowmode_map[config.behavior.slowmode.mode]( # type: ignore
+                    delay=config.behavior.slowmode.delay, # type: ignore
                     translator=translator,
-                    allowed_chat_ids=config.forwarding.moderation_chat_ids
+                    allowed_chat_ids=config.forwarding.moderation_chat_ids # type: ignore
                 )
             )
 
-    async def _init_translator(self):
-        self.translator = Translator()
-        await self.translator.init(self.bot)
+    def _init_event_handler(self):
+        bot, config, translator = (
+            self.bot,
+            self.config,
+            self.translator
+        )
+
+        self.event_handler = EventHandler(bot=bot, config=config, translator=translator) # type: ignore
 
     def _init_moderation(self):
-        bot = self.get_bot()
-        config = self.get_config()
-        translator = self.get_translator()
+        bot, config = (
+            self.bot,
+            self.config
+        )
 
-        if config.moderation.enabled:
+        if config.moderation.enabled: # type: ignore
             self.rule_manager = RuleManager(rules_dir=paths.RULES_DIR)
             self.rule_manager.reload()
 
-            self.planner = ModerationPlanner(config=config, rule_manager=self.rule_manager)
+            self.planner = ModerationPlanner(config=config, rule_manager=self.rule_manager) # type: ignore
             self.executor = ModerationExecutor(
-                config=config,
-                bot=bot,
+                config=config, # type: ignore
+                bot=bot, # type: ignore
                 planner=self.planner
             )
-            self.event_handler = EventHandler(bot=bot, config=config, translator=translator)
 
     async def init(self):
         self._init_config()
         self._init_logging()
         self._init_bot()
         await self._init_translator()
+        self._postinit_bot()
+        self._init_event_handler()
         self._init_moderation()
 
     async def run(self):
         await self.init()
 
-        bot, dispatcher, config, translator, message_manager = (
-            self.get_bot(),
-            self.get_dispatcher(),
-            self.get_config(),
-            self.get_translator(),
-            self.get_message_manager()
+        bot, dispatcher, config, translator, event_handler = (
+            self.bot,
+            self.dispatcher,
+            self.config,
+            self.translator,
+            self.event_handler
         )
 
-        dispatcher.include_router(
+        dispatcher.include_router( # type: ignore
             build(
-                config=config,
-                message_manager=message_manager,
-                translator=translator,
+                config=config, # type: ignore
+                translator=translator, # type: ignore
+                event_handler=event_handler, # type: ignore
                 executor=self.executor,
-                event_handler=self.event_handler
             )
         )
 
-        await dispatcher.start_polling(bot)
+        await dispatcher.start_polling(bot) # type: ignore

+ 1 - 2
anonflow/bot/__init__.py

@@ -1,11 +1,10 @@
 from .builder import build
+from .events import EventHandler
 from .middleware import GlobalSlowmodeMiddleware, SubscriptionMiddleware, UserSlowmodeMiddleware
-from .utils import EventHandler, MessageManager
 
 __all__ = [
     "build",
     "EventHandler",
-    "MessageManager",
     "GlobalSlowmodeMiddleware",
     "SubscriptionMiddleware",
     "UserSlowmodeMiddleware",

+ 4 - 7
anonflow/bot/builder.py

@@ -6,16 +6,15 @@ from anonflow.config import Config
 from anonflow.moderation import ModerationExecutor
 from anonflow.translator import Translator
 
+from .events import EventHandler
 from .routers import InfoRouter, MediaRouter, StartRouter, TextRouter
-from .utils import EventHandler, MessageManager
 
 
 def build(
     config: Config,
-    message_manager: MessageManager,
     translator: Translator,
+    event_handler: EventHandler,
     executor: Optional[ModerationExecutor] = None,
-    event_handler: Optional[EventHandler] = None
 ):
     main_router = Router()
 
@@ -24,17 +23,15 @@ def build(
         InfoRouter(translator=translator),
         TextRouter(
             config=config,
-            message_manager=message_manager,
             translator=translator,
-            moderation_executor=executor,
             event_handler=event_handler,
+            moderation_executor=executor,
         ),
         MediaRouter(
             config=config,
-            message_manager=message_manager,
             translator=translator,
-            moderation_executor=executor,
             event_handler=event_handler,
+            moderation_executor=executor,
         ),
     )
 

+ 5 - 0
anonflow/bot/events/__init__.py

@@ -0,0 +1,5 @@
+from .event_handler import EventHandler
+from .models import (BotMessagePreparedEvent, Events, ExecutorDeletionEvent,
+                     ModerationDecisionEvent, ModerationStartedEvent)
+
+__all__ = ["EventHandler", "BotMessagePreparedEvent", "Events", "ExecutorDeletionEvent", "ModerationDecisionEvent", "ModerationStartedEvent"]

+ 43 - 11
anonflow/bot/utils/event_handler.py → anonflow/bot/events/event_handler.py

@@ -1,17 +1,20 @@
-from aiogram import Bot
-from aiogram.types import ChatIdUnion, Message
-from aiogram.exceptions import TelegramBadRequest
 from contextlib import suppress
 from typing import Dict
 
+from aiogram import Bot
+from aiogram.exceptions import TelegramBadRequest
+from aiogram.types import ChatIdUnion, Message, InputMediaPhoto, InputMediaVideo
+
 from anonflow.config import Config
-from anonflow.moderation.models import (
+from anonflow.translator import Translator
+
+from .models import (
+    BotMessagePreparedEvent,
     Events,
     ExecutorDeletionEvent,
     ModerationDecisionEvent,
-    ModerationStartedEvent,
+    ModerationStartedEvent
 )
-from anonflow.translator import Translator
 
 
 class EventHandler:
@@ -23,7 +26,8 @@ class EventHandler:
         self._messages: Dict[ChatIdUnion, Message] = {}
 
     async def handle(self, event: Events, message: Message):
-        moderation_chat_ids = self.config.forwarding.moderation_chat_ids
+        moderation_chat_ids = self.config.forwarding.moderation_chat_ids or ()
+        publication_channel_ids = self.config.forwarding.publication_channel_ids or ()
 
         _ = self.translator.get()
 
@@ -34,7 +38,7 @@ class EventHandler:
         elif isinstance(event, ModerationDecisionEvent):
             for chat_id in moderation_chat_ids:
                 if event.approved:
-                    await message.bot.send_message(
+                    await self.bot.send_message(
                         chat_id,
                         _(
                             "messages.staff.moderation_approved",
@@ -43,7 +47,7 @@ class EventHandler:
                         )
                     )
                 else:
-                    await message.bot.send_message(
+                    await self.bot.send_message(
                         chat_id,
                         _(
                             "messages.staff.moderation_rejected",
@@ -61,10 +65,38 @@ class EventHandler:
                 await message.answer(_("messages.user.send_success", message=message))
             else:
                 await message.answer(_("messages.user.moderation_rejected", message=message))
+        elif isinstance(event, BotMessagePreparedEvent) and publication_channel_ids is not None:
+            for chat_id in publication_channel_ids + moderation_chat_ids:
+                content = event.content
+                if isinstance(content, str):
+                    await self.bot.send_message(
+                        chat_id,
+                        _("messages.channel.text", message=message)
+                    )
+                if isinstance(content, list):
+                    if len(content) > 1:
+                        await self.bot.send_media_group(
+                            chat_id,
+                            content
+                        )
+                    else:
+                        input_media = content[0]
+                        if isinstance(input_media, InputMediaPhoto):
+                            await self.bot.send_photo(
+                                chat_id,
+                                input_media.media,
+                                caption=input_media.caption
+                            )
+                        elif isinstance(input_media, InputMediaVideo):
+                            await self.bot.send_video(
+                                chat_id,
+                                input_media.media,
+                                caption=input_media.caption
+                            )
         elif isinstance(event, ExecutorDeletionEvent) and moderation_chat_ids:
             for chat_id in moderation_chat_ids:
                 if event.success:
-                    await message.bot.send_message(
+                    await self.bot.send_message(
                         chat_id,
                         _(
                             "messages.staff.deletion_success",
@@ -73,7 +105,7 @@ class EventHandler:
                         )
                     )
                 else:
-                    await message.bot.send_message(
+                    await self.bot.send_message(
                         chat_id,
                         _(
                             "messages.staff.deletion_failure",

+ 28 - 0
anonflow/bot/events/models.py

@@ -0,0 +1,28 @@
+from dataclasses import dataclass
+from typing import List, Optional, Union
+
+from aiogram.types import ChatIdUnion, MediaUnion
+
+
+@dataclass
+class BotMessagePreparedEvent:
+    content: Union[str, List[MediaUnion]]
+
+@dataclass
+class ExecutorDeletionEvent:
+    success: bool
+    message_id: Optional[ChatIdUnion] = None
+
+
+@dataclass
+class ModerationDecisionEvent:
+    approved: bool
+    explanation: str
+
+
+@dataclass
+class ModerationStartedEvent:
+    pass
+
+
+Events = Union[BotMessagePreparedEvent, ExecutorDeletionEvent, ModerationDecisionEvent, ModerationStartedEvent]

+ 4 - 4
anonflow/bot/middleware/subscription.py

@@ -1,7 +1,7 @@
 from typing import List
 
 from aiogram import BaseMiddleware
-from aiogram.enums import ChatMemberStatus
+from aiogram.enums import ChatMemberStatus, ChatType
 from aiogram.types import ChatIdUnion, Message
 
 from anonflow.translator import Translator
@@ -21,10 +21,10 @@ class SubscriptionMiddleware(BaseMiddleware):
 
         message = extract_message(event)
 
-        if isinstance(message, Message):
-            user_id = message.from_user.id
+        if isinstance(message, Message) and message.chat.type == ChatType.PRIVATE:
+            user_id = message.from_user.id # type: ignore
             for channel_id in self.channel_ids:
-                member = await message.bot.get_chat_member(channel_id, user_id)
+                member = await message.bot.get_chat_member(channel_id, user_id) # type: ignore
                 if member.status in (ChatMemberStatus.KICKED, ChatMemberStatus.LEFT):
                     await message.answer(_("messages.user.subscription_required", message=message))
                     return

+ 22 - 90
anonflow/bot/routers/media.py

@@ -2,15 +2,14 @@ import asyncio
 from asyncio import CancelledError
 from typing import Dict, List, Optional
 
-from aiogram import Bot, F, Router
+from aiogram import F, Router
 from aiogram.enums import ChatType
-from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
 from aiogram.types import InputMediaPhoto, InputMediaVideo, Message
 
-from anonflow.bot.utils.event_handler import EventHandler
-from anonflow.bot.utils.message_manager import MessageManager
+from anonflow.bot.events.models import BotMessagePreparedEvent, ModerationDecisionEvent
+from anonflow.bot.events.event_handler import EventHandler
 from anonflow.config import Config
-from anonflow.moderation import ModerationDecisionEvent, ModerationExecutor
+from anonflow.moderation import ModerationExecutor
 from anonflow.translator import Translator
 
 
@@ -18,18 +17,16 @@ class MediaRouter(Router):
     def __init__(
         self,
         config: Config,
-        message_manager: MessageManager,
         translator: Translator,
+        event_handler: EventHandler,
         moderation_executor: Optional[ModerationExecutor] = None,
-        event_handler: Optional[EventHandler] = None,
     ):
         super().__init__()
 
         self.config = config
-        self.message_manager = message_manager
         self.translator = translator
-        self.executor = moderation_executor
         self.event_handler = event_handler
+        self.executor = moderation_executor
 
         self.media_groups: Dict[str, List[Message]] = {}
         self.media_groups_tasks: Dict[str, asyncio.Task] = {}
@@ -39,7 +36,7 @@ class MediaRouter(Router):
 
     def _register_handlers(self):
         @self.message(F.photo | F.video)
-        async def on_photo(message: Message, bot: Bot):
+        async def on_photo(message: Message):
             if message.chat.type != ChatType.PRIVATE:
                 return
 
@@ -67,89 +64,24 @@ class MediaRouter(Router):
                 if not messages:
                     return
 
-                moderation_chat_ids = self.config.forwarding.moderation_chat_ids
-                publication_channel_ids = self.config.forwarding.publication_channel_ids
-
                 _ = self.translator.get()
 
-                reply_to_message_id = messages[0].message_id
+                if can_send_media(messages):
+                    moderation = self.config.moderation.enabled
+                    moderation_passed = not moderation
 
-                try:
-                    if can_send_media(messages):
-                        moderation = self.config.moderation.enabled
-                        moderation_passed = not moderation
-
-                        group_message_id = None
-
-                        targets = {}
-                        if moderation_chat_ids:
-                            for chat_id in moderation_chat_ids:
-                                targets[chat_id] = True
-
-                        if len(messages) > 1:
-                            media = []
-                            for msg in messages:
-                                if moderation and msg.caption:
-                                    async for event in self.executor.process_message(msg):
-                                        if isinstance(event, ModerationDecisionEvent):
-                                            moderation_passed = event.approved
-                                        await self.event_handler.handle(event, message)
-
-                                media.append(await get_media(msg))
-
-                            if publication_channel_ids and moderation_passed:
-                                for channel_id in publication_channel_ids:
-                                    targets[channel_id] = False
-
-                            for target, save_message_id in targets.items():
-                                messages = await bot.send_media_group(target, media)
-
-                                if save_message_id:
-                                    group_message_id = messages[0].message_id
-                        elif len(messages) == 1:
-                            msg = messages[0]
-                            caption = msg.caption
-
-                            if moderation and caption:
-                                async for event in self.executor.process_message(msg):
-                                    if isinstance(event, ModerationDecisionEvent):
-                                        moderation_passed = event.approved
-                                    await self.event_handler.handle(event, message)
-
-                            if publication_channel_ids and moderation_passed:
-                                for channel_id in publication_channel_ids:
-                                    targets[channel_id] = False
-
-                            func = bot.send_photo if msg.photo else bot.send_video
-                            file_id = (
-                                msg.photo[-1].file_id
-                                if msg.photo
-                                else msg.video.file_id
-                            )
-
-                            for target, save_message_id in targets.items():
-                                msg_id = (
-                                    await func(
-                                        target,
-                                        file_id,
-                                        caption=_("messages.channel.media", message=msg),
-                                    )
-                                ).message_id
-
-                                if save_message_id:
-                                    group_message_id = msg_id
-
-                        self.message_manager.add(
-                            reply_to_message_id, group_message_id, message.chat.id
-                        )
-                except (TelegramBadRequest, TelegramForbiddenError) as e:
-                    await message.answer(
-                        _(
-                            "messages.user.send_failure",
-                            message=message,
-                            exception=e,
-                        )
-                    )
+                    media = []
+                    for msg in messages:
+                        if moderation and msg.caption:
+                            async for event in self.executor.process_message(msg):
+                                if isinstance(event, ModerationDecisionEvent):
+                                    moderation_passed = event.approved
+                                await self.event_handler.handle(event, message)
+
+                        media.append(await get_media(msg))
+
+                    if moderation_passed:
+                        await self.event_handler.handle(BotMessagePreparedEvent(media), messages[0])
 
             media_group_id = message.media_group_id
 

+ 17 - 78
anonflow/bot/routers/text.py

@@ -1,14 +1,13 @@
 from typing import Optional
 
-from aiogram import Bot, F, Router
+from aiogram import F, Router
 from aiogram.enums import ChatType
-from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
 from aiogram.types import Message
 
-from anonflow.bot.utils.event_handler import EventHandler
-from anonflow.bot.utils.message_manager import MessageManager
+from anonflow.bot.events.models import BotMessagePreparedEvent
+from anonflow.bot.events.event_handler import EventHandler, ModerationDecisionEvent
 from anonflow.config import Config
-from anonflow.moderation import ModerationDecisionEvent, ModerationExecutor
+from anonflow.moderation import ModerationExecutor
 from anonflow.translator import Translator
 
 
@@ -16,98 +15,38 @@ class TextRouter(Router):
     def __init__(
         self,
         config: Config,
-        message_manager: MessageManager,
         translator: Translator,
+        event_handler: EventHandler,
         moderation_executor: Optional[ModerationExecutor] = None,
-        event_handler: Optional[EventHandler] = None,
     ):
         super().__init__()
 
         self.config = config
-        self.message_manager = message_manager
         self.translator = translator
-        self.executor = moderation_executor
         self.event_handler = event_handler
+        self.executor = moderation_executor
 
         self._register_handlers()
 
     def _register_handlers(self):
         @self.message(F.text)
-        async def on_text(message: Message, bot: Bot):
-            reply_to_message = message.reply_to_message
+        async def on_text(message: Message):
 
             _ = self.translator.get()
 
-            moderation = self.config.moderation.enabled
+            moderation = self.config.moderation.enabled and isinstance(self.executor, ModerationExecutor)
             moderation_passed = not moderation
 
-            moderation_chat_ids = self.config.forwarding.moderation_chat_ids
-            publication_channel_ids = self.config.forwarding.publication_channel_ids
-
             if (
-                message.chat.id in moderation_chat_ids
-                and reply_to_message
-                and reply_to_message.from_user.is_bot
-            ):
-                result = self.message_manager.get(reply_to_message.message_id)
-
-                if result:
-                    reply_to_message_id, chat_id = result
-                    try:
-                        await bot.send_message(
-                            chat_id,
-                            message.text,
-                            reply_to_message_id=reply_to_message_id,
-                        )
-                        await message.answer(_("messages.user.send_success", message=message))
-                    except (TelegramBadRequest, TelegramForbiddenError) as e:
-                        await message.answer(
-                            _(
-                                "messages.user.send_failure",
-                                message=message,
-                                exception=e,
-                            )
-                        )
-            elif (
                 message.chat.type == ChatType.PRIVATE
                 and "text" in self.config.forwarding.types
             ):
-                try:
-                    group_message_id = None
-
-                    targets = {}
-                    if moderation_chat_ids:
-                        for chat_id in moderation_chat_ids:
-                            targets[chat_id] = True
-
-                    if moderation:
-                        async for event in self.executor.process_message(message):
-                            if isinstance(event, ModerationDecisionEvent):
-                                moderation_passed = event.approved
-                            await self.event_handler.handle(event, message)
-
-                    if publication_channel_ids and moderation_passed:
-                        for channel_id in publication_channel_ids:
-                            targets[channel_id] = False
-
-                    for target, save_message_id in targets.items():
-                        reply_to_message_id = message.message_id
-                        msg = await bot.send_message(
-                            target,
-                            _("messages.channel.text", message=message)
-                        )
-
-                        if save_message_id:
-                            group_message_id = msg.message_id
-
-                    self.message_manager.add(
-                        reply_to_message_id, group_message_id, message.chat.id
-                    )
-                except (TelegramBadRequest, TelegramForbiddenError) as e:
-                    await message.answer(
-                        _(
-                            "messages.user.send_failure",
-                            message=message,
-                            exception=e,
-                        )
-                    )
+                if moderation:
+                    assert self.executor is not None
+                    async for event in self.executor.process_message(message):
+                        if isinstance(event, ModerationDecisionEvent):
+                            moderation_passed = event.approved
+                        await self.event_handler.handle(event, message)
+
+                if moderation_passed:
+                    await self.event_handler.handle(BotMessagePreparedEvent(_("messages.channel.text", message=message)), message)

+ 0 - 4
anonflow/bot/utils/__init__.py

@@ -1,4 +0,0 @@
-from .event_handler import EventHandler
-from .message_manager import MessageManager
-
-__all__ = ["EventHandler", "MessageManager"]

+ 0 - 21
anonflow/bot/utils/message_manager.py

@@ -1,21 +0,0 @@
-from typing import Dict, List, Tuple
-
-
-class MessageManager:
-    def __init__(self, max_messages_per_chat_id: int = 5):
-        self._message_ids_map: Dict[int, Tuple[int, int]] = {}
-        self._chat_ids_map: Dict[int, List[int]] = {}
-
-        self._max_messages = max_messages_per_chat_id
-
-    def add(self, reply_to_message_id: int, group_message_id: int, chat_id: int) -> None:
-        chat_id_messages = self._chat_ids_map.setdefault(chat_id, [])
-        if len(chat_id_messages) >= self._max_messages:
-            old_message_id = chat_id_messages.pop(0)
-            self._message_ids_map.pop(old_message_id)
-
-        chat_id_messages.append(group_message_id)
-        self._message_ids_map[group_message_id] = (reply_to_message_id, chat_id)
-
-    def get(self, message_id: int) -> tuple[int, int] | None:
-        return self._message_ids_map.get(message_id)

+ 11 - 13
anonflow/config/config.py

@@ -3,19 +3,17 @@ from pathlib import Path
 import yaml
 from pydantic import BaseModel, SecretStr
 
-from .models import Config as MainConfig
+from .models import Behavior, Bot, Forwarding, Logging, Moderation, OpenAI
 
 
 class Config(BaseModel):
-    config: MainConfig = MainConfig()
-
-    def __getattr__(self, name: str, /):
-        config = object.__getattribute__(self, "config")
-
-        if name in config.model_fields:
-            return object.__getattribute__(config, name)
-
-        return object.__getattribute__(self, name)
+    bot: Bot = Bot()
+    behavior: Behavior = Behavior()
+    forwarding: Forwarding = Forwarding()
+    openai: OpenAI = OpenAI()
+    moderation: Moderation = Moderation()
+    logging: Logging = Logging()
+    model_config = {"frozen": True}
 
     @classmethod
     def _serialize(cls, obj):
@@ -36,16 +34,16 @@ class Config(BaseModel):
         if filepath.exists():
             with filepath.open(encoding="utf-8") as f:
                 data = yaml.safe_load(f) or {}
-            return cls(config=MainConfig(**data))
+            return cls(**data) # type: ignore
 
-        return cls(config=MainConfig())
+        return cls()
 
     def save(self, filepath: Path):
         filepath.parent.mkdir(parents=True, exist_ok=True)
 
         with filepath.open("w", encoding="utf-8") as config_file:
             yaml.dump(
-                self._serialize(self.config.model_dump()),
+                self._serialize(self.model_dump()),
                 config_file,
                 width=float("inf"),
                 sort_keys=False,

+ 14 - 15
anonflow/config/models.py

@@ -1,4 +1,4 @@
-from typing import List, Literal, Optional, TypeAlias
+from typing import Literal, Optional, Tuple, TypeAlias
 
 from pydantic import BaseModel, SecretStr
 
@@ -11,52 +11,51 @@ LoggingLevel: TypeAlias = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL
 class Bot(BaseModel):
     token: Optional[SecretStr] = None
     timeout: int = 10
+    model_config = {"frozen": True}
 
 
 class BehaviorSlowmode(BaseModel):
     enabled: bool = True
     mode: SlowmodeMode = "user"
     delay: float = 120
+    model_config = {"frozen": True}
 
 
 class BehaviorSubscriptionRequirement(BaseModel):
     enabled: bool = True
-    channel_ids: Optional[List[int]] = None
+    channel_ids: Optional[Tuple[int]] = None
+    model_config = {"frozen": True}
 
 
 class Behavior(BaseModel):
     slowmode: BehaviorSlowmode = BehaviorSlowmode()
     subscription_requirement: BehaviorSubscriptionRequirement = BehaviorSubscriptionRequirement()
+    model_config = {"frozen": True}
 
 
 class Forwarding(BaseModel):
-    moderation_chat_ids: Optional[List[int]] = None
-    publication_channel_ids: Optional[List[int]] = None
-    types: List[ForwardingType] = ["text", "photo", "video"]
+    moderation_chat_ids: Optional[Tuple[int]] = None
+    publication_channel_ids: Optional[Tuple[int]] = None
+    types: Tuple[ForwardingType, ...] = ("text", "photo", "video")
+    model_config = {"frozen": True}
 
 
 class OpenAI(BaseModel):
     api_key: Optional[SecretStr] = None
     timeout: int = 10
     max_retries: int = 0
+    model_config = {"frozen": True}
 
 
 class Moderation(BaseModel):
     enabled: bool = True
     model: str = "gpt-5-mini"
-    types: List[ModerationType] = ["omni", "gpt"]
+    types: Tuple[ModerationType, ...] = ("omni", "gpt")
+    model_config = {"frozen": True}
 
 
 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"
-
-
-class Config(BaseModel):
-    bot: Bot = Bot()
-    behavior: Behavior = Behavior()
-    forwarding: Forwarding = Forwarding()
-    openai: OpenAI = OpenAI()
-    moderation: Moderation = Moderation()
-    logging: Logging = Logging()
+    model_config = {"frozen": True}

+ 0 - 6
anonflow/moderation/__init__.py

@@ -1,13 +1,7 @@
 from .executor import ModerationExecutor, ModerationPlanner
-from .models import (Events, ExecutorDeletionEvent, ModerationDecisionEvent,
-                     ModerationStartedEvent)
 from .rule_manager import RuleManager
 
 __all__ = [
-    "Events",
-    "ExecutorDeletionEvent",
-    "ModerationDecisionEvent",
-    "ModerationStartedEvent",
     "ModerationExecutor",
     "ModerationPlanner",
     "RuleManager",

+ 11 - 9
anonflow/moderation/executor.py

@@ -8,7 +8,7 @@ from yarl import URL
 
 from anonflow.config import Config
 
-from .models import Events, ExecutorDeletionEvent, ModerationDecisionEvent, ModerationStartedEvent
+from anonflow.bot.events.models import Events, ExecutorDeletionEvent, ModerationDecisionEvent, ModerationStartedEvent
 from .planner import ModerationPlanner
 
 
@@ -36,20 +36,20 @@ class ModerationExecutor:
         parsed_url = URL(message_link)
         parsed_path = parsed_url.path.strip("/").split("/")
 
-        publication_chat_id = self.config.forwarding.publication_chat_id
+        publication_channel_ids = self.config.forwarding.publication_channel_ids
 
-        if not publication_chat_id:
+        if not publication_channel_ids:
             return ExecutorDeletionEvent(success=False)
 
         if (
             len(parsed_path) == 3
             and parsed_path[0] == "c"
-            and parsed_path[1].replace("-100", "")
-            == str(publication_chat_id).replace("-100", "")
+            and parsed_path[1] in publication_channel_ids
         ):
+            channel_id = parsed_path[1]
             message_id = parsed_path[2]
             try:
-                await self.bot.delete_message(publication_chat_id, message_id=message_id)
+                await self.bot.delete_message(channel_id, message_id=message_id)
                 return ExecutorDeletionEvent(success=True, message_id=message_id)
             except TelegramBadRequest:
                 return ExecutorDeletionEvent(success=False, message_id=message_id)
@@ -82,10 +82,12 @@ class ModerationExecutor:
             if hasattr(self, func_name) and func_name in function_names:
                 try:
                     self._logger.info(
-                        f"Executing {func_name}({', '.join(map(str, func.get('args')))})"
+                        f"Executing {func_name}({', '.join(map(str, func.get('args', [])))})"
                     )
-                    yield await getattr(self, func_name)(*func.get("args"))
+                    yield await getattr(self, func_name)(*func.get("args", []))
                 except Exception:
                     self._logger.exception(
-                        f"Failed to execute {func_name}({', '.join(map(str, func.get('args')))})"
+                        f"Failed to execute {func_name}({', '.join(map(str, func.get('args', [])))})"
                     )
+            else:
+                self._logger.warning("Function %s not found, skipping.", func_name)

+ 0 - 24
anonflow/moderation/models.py

@@ -1,24 +0,0 @@
-from dataclasses import dataclass
-from typing import Optional, Union
-
-from aiogram.types import ChatIdUnion
-
-
-@dataclass
-class ExecutorDeletionEvent:
-    success: bool
-    message_id: Optional[ChatIdUnion] = None
-
-
-@dataclass
-class ModerationDecisionEvent:
-    approved: bool
-    explanation: str
-
-
-@dataclass
-class ModerationStartedEvent:
-    pass
-
-
-Events = Union[ExecutorDeletionEvent, ModerationDecisionEvent, ModerationStartedEvent]

+ 5 - 1
anonflow/moderation/planner.py

@@ -18,8 +18,12 @@ class ModerationPlanner:
         self.config = config
         self.rule_manager = rule_manager
 
+        api_key = self.config.openai.api_key
+        if not api_key:
+            raise ValueError("openai.api_key is required and cannot be empty")
+
         self._client = AsyncOpenAI(
-            api_key=self.config.openai.api_key.get_secret_value(),
+            api_key=api_key.get_secret_value(),
             timeout=self.config.openai.timeout,
             max_retries=self.config.openai.max_retries,
         )

+ 14 - 14
anonflow/translator/translator.py

@@ -12,9 +12,16 @@ class Translator:
     def __init__(self):
         self.bot = None
 
-    async def init(self, bot: Optional[Bot]):
-        if bot:
-            self.bot = await bot.get_me()
+    @staticmethod
+    @lru_cache
+    def _get_translator(lang: str):
+        translator = gettext.translation(
+            "messages",
+            paths.TRANSLATIONS_DIR,
+            languages=[lang],
+            fallback=True
+        )
+        return translator
 
     def format(self, text: str, message: Optional[Message], **extra):
         bot = self.bot
@@ -39,17 +46,7 @@ class Translator:
         )
 
     def get(self, lang: Literal["ru"] = "ru"):
-        @lru_cache
-        def get_translator(lang: str):
-            translator = gettext.translation(
-                "messages",
-                paths.TRANSLATIONS_DIR,
-                languages=[lang],
-                fallback=True
-            )
-            return translator
-
-        translator = get_translator(lang)
+        translator = self._get_translator(lang)
 
         def _(msgid: str, message: Optional[Message], **extra):
             return self.format(
@@ -59,3 +56,6 @@ class Translator:
             )
 
         return _
+
+    async def init(self, bot: Optional[Bot]):
+        if bot: self.bot = await bot.get_me()