Librellium 3 сар өмнө
commit
c05a0db00d

+ 219 - 0
.gitignore

@@ -0,0 +1,219 @@
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[codz]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+share/python-wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# PyInstaller
+#   Usually these files are written by a python script from a template
+#   before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.nox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+*.py.cover
+.hypothesis/
+.pytest_cache/
+cover/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+db.sqlite3
+db.sqlite3-journal
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+.pybuilder/
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# IPython
+profile_default/
+ipython_config.py
+
+# pyenv
+#   For a library or package, you might want to ignore these files since the code is
+#   intended to run in multiple environments; otherwise, check them in:
+# .python-version
+
+# pipenv
+#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
+#   However, in case of collaboration, if having platform-specific dependencies or dependencies
+#   having no cross-platform support, pipenv may install dependencies that don't work, or not
+#   install all needed dependencies.
+# Pipfile.lock
+
+# UV
+#   Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
+#   This is especially recommended for binary packages to ensure reproducibility, and is more
+#   commonly ignored for libraries.
+# uv.lock
+
+# poetry
+#   Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
+#   This is especially recommended for binary packages to ensure reproducibility, and is more
+#   commonly ignored for libraries.
+#   https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
+# poetry.lock
+# poetry.toml
+
+# pdm
+#   Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
+#   pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
+#   https://pdm-project.org/en/latest/usage/project/#working-with-version-control
+# pdm.lock
+# pdm.toml
+.pdm-python
+.pdm-build/
+
+# pixi
+#   Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
+# pixi.lock
+#   Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
+#   in the .venv directory. It is recommended not to include this directory in version control.
+.pixi
+
+# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
+__pypackages__/
+
+# Celery stuff
+celerybeat-schedule
+celerybeat.pid
+
+# Redis
+*.rdb
+*.aof
+*.pid
+
+# RabbitMQ
+mnesia/
+rabbitmq/
+rabbitmq-data/
+
+# ActiveMQ
+activemq-data/
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.envrc
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+.dmypy.json
+dmypy.json
+
+# Pyre type checker
+.pyre/
+
+# pytype static type analyzer
+.pytype/
+
+# Cython debug symbols
+cython_debug/
+
+# PyCharm
+#   JetBrains specific template is maintained in a separate JetBrains.gitignore that can
+#   be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
+#   and can be added to the global gitignore or merged into this file.  For a more nuclear
+#   option (not recommended) you can uncomment the following to ignore the entire idea folder.
+# .idea/
+
+# Abstra
+#   Abstra is an AI-powered process automation framework.
+#   Ignore directories containing user credentials, local state, and settings.
+#   Learn more at https://abstra.io/docs
+.abstra/
+
+# Visual Studio Code
+#   Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore 
+#   that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
+#   and can be added to the global gitignore or merged into this file. However, if you prefer, 
+#   you could uncomment the following to ignore the entire vscode folder
+# .vscode/
+
+# Ruff stuff:
+.ruff_cache/
+
+# PyPI configuration file
+.pypirc
+
+# Marimo
+marimo/_static/
+marimo/_lsp/
+__marimo__/
+
+# Streamlit
+.streamlit/secrets.toml
+
+# Config
+config.yml

+ 10 - 0
Dockerfile

@@ -0,0 +1,10 @@
+FROM python:3.11.14-slim
+
+WORKDIR /app
+
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+COPY . .
+
+CMD ["python", "-m", "simpleforward"]

+ 18 - 0
LICENSE

@@ -0,0 +1,18 @@
+Copyright (c) 2025 - Librellium
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this
+software and associated documentation files (the "Software"), to deal in the Software
+without restriction, including without limitation the rights to use, copy, modify,
+merge, publish, distribute, sublicense, and/or sell copies of the Software,
+and to permit persons to whom the Software is furnished to do so, subject to the
+following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies
+or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
+PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
+OR OTHER DEALINGS IN THE SOFTWARE.

+ 46 - 0
README.md

@@ -0,0 +1,46 @@
+# SimpleForward
+
+**SimpleForward** — это простой и прозрачный Telegram-бот формата «анонимные сообщения». Он пересылает контент от пользователей в целевой чат, не сохраняя и не раскрывая их личные данные. Необходимые данные для работы бота находятся только в оперативной памяти.
+
+Проект полностью открыт и распространяется под лицензией [MIT](LICENSE).
+
+---
+
+## Возможности
+
+* Полностью анонимная пересылка сообщений.
+* Гибкая конфигурация.
+* Поддержка:
+  * текста
+  * фото
+  * видео
+
+---
+
+## Настройка
+
+Для конфигурации используется `config.yml`.
+Создайте его вручную, используя **config.yml.example**:
+
+```bash
+cp config.yml.example config.yml
+```
+
+Заполните необходимые поля — пример содержит описание всех параметров.
+
+---
+
+## Запуск
+
+### Локально (Python)
+
+```bash
+pip install -r requirements.txt
+python -m simpleforward
+```
+
+### Через docker-compose
+
+```bash
+docker compose up --build
+```

+ 14 - 0
config.yml.example

@@ -0,0 +1,14 @@
+bot:
+  token:
+  timeout: 10
+forwarding:
+  target_chat_id:
+  message_template: "🔔 У вас новое сообщение!\n\n<blockquote>{text}</blockquote>"
+  types:
+    - text
+    - photo
+    - video
+logging:
+  level: "INFO"
+  fmt: "%(asctime)s.%(msecs)03d %(levelname)s [%(name)s] %(message)s"
+  date_fmt: "%Y-%m-%d %H:%M:%S"

+ 7 - 0
docker-compose.yml

@@ -0,0 +1,7 @@
+services:
+  simpleforward:
+    build: .
+    labels:
+      description: "Telegram-бот для анонимных сообщений"
+    volumes:
+      - ./config.yml:/app/config.yml

+ 19 - 0
requirements.txt

@@ -0,0 +1,19 @@
+aiofiles==24.1.0
+aiogram==3.22.0
+aiohappyeyeballs==2.6.1
+aiohttp==3.12.15
+aiosignal==1.4.0
+annotated-types==0.7.0
+attrs==25.4.0
+certifi==2025.11.12
+frozenlist==1.8.0
+idna==3.11
+magic-filter==1.0.12
+multidict==6.7.0
+propcache==0.4.1
+pydantic==2.11.10
+pydantic_core==2.33.2
+PyYAML==6.0.3
+typing-inspection==0.4.2
+typing_extensions==4.15.0
+yarl==1.22.0

+ 2 - 0
simpleforward/__init__.py

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

+ 30 - 0
simpleforward/__main__.py

@@ -0,0 +1,30 @@
+import asyncio
+import logging
+
+from aiogram import Bot, Dispatcher
+
+from simpleforward.bot import MessageManager, build
+from simpleforward.config import Config
+
+from . import paths
+
+
+async def main():
+    config = Config.load(paths.CONFIG_FILE)
+
+    message_manager = MessageManager()
+
+    logging.basicConfig(format=config.logging.fmt,
+                        datefmt=config.logging.date_fmt,
+                        level=config.logging.level)
+
+    bot = Bot(token=config.bot.token.get_secret_value())
+    dispatcher = Dispatcher()
+
+    dispatcher.include_router(build(config.forwarding,
+                                    message_manager))
+
+    await dispatcher.start_polling(bot)
+
+if __name__ == "__main__":
+    asyncio.run(main())

+ 4 - 0
simpleforward/bot/__init__.py

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

+ 23 - 0
simpleforward/bot/main.py

@@ -0,0 +1,23 @@
+from aiogram import Router
+
+from simpleforward.config import models
+
+from .message_manager import MessageManager
+from .routers.photo import PhotoRouter
+from .routers.start import StartRouter
+from .routers.text import TextRouter
+
+
+def build(forwarding_config: models.Forwarding,
+          message_manager: MessageManager):
+    main_router = Router()
+
+    main_router.include_routers(
+        StartRouter(),
+        TextRouter(forwarding_config,
+                   message_manager),
+        PhotoRouter(forwarding_config,
+                    message_manager)
+    )
+
+    return main_router

+ 21 - 0
simpleforward/bot/message_manager.py

@@ -0,0 +1,21 @@
+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)

+ 114 - 0
simpleforward/bot/routers/photo.py

@@ -0,0 +1,114 @@
+import asyncio
+from asyncio import CancelledError
+from typing import Dict, List
+
+from aiogram import Bot, F, Router
+from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
+from aiogram.types import InputMediaPhoto, InputMediaVideo, Message
+
+from simpleforward.bot.message_manager import MessageManager
+from simpleforward.config import models
+
+
+class PhotoRouter(Router):
+    def __init__(self,
+                 forwarding_config: models.Forwarding,
+                 message_manager: MessageManager):
+        super().__init__()
+
+        self.config = forwarding_config
+        self.message_manager = message_manager
+
+        self.media_groups: Dict[int, dict] = {}
+        self.media_groups_tasks: Dict[int, asyncio.Task] = {}
+        self.media_groups_lock = asyncio.Lock()
+
+        self._register_handlers()
+
+    def _register_handlers(self):
+        @self.message(F.photo | F.video)
+        async def on_photo(message: Message, bot: Bot):
+            if "photo" not in self.config.types and "video" not in self.config.types:
+                return
+
+            def can_send_media(msgs: List[Message]):
+                photos = len([msg for msg in msgs if msg.photo])
+                videos = len([msg for msg in msgs if msg.video])
+
+                if (photos and "photo" in self.config.types) or (videos and "video" in self.config.types):
+                    return True
+                else:
+                    return False
+
+            def get_media(msg: Message):
+                caption = self.config.message_template.format(text=msg.caption) if msg.caption else None
+                parse_mode = "HTML" if msg.caption else None
+
+                if msg.photo and "photo" in self.config.types:
+                    return InputMediaPhoto(
+                        media=msg.photo[-1].file_id,
+                        caption=caption,
+                        parse_mode=parse_mode
+                    )
+                elif msg.video and "video" in self.config.types:
+                    return InputMediaVideo(
+                        media=msg.video.file_id,
+                        caption=caption,
+                        parse_mode=parse_mode
+                    )
+
+            async def process_messages(messages: list[Message]):
+                if not messages: return
+
+                reply_to_message_id = messages[0].message_id
+
+                try:
+                    if can_send_media(messages):
+                        if len(messages) > 1:
+                            group_message_id = (await bot.send_media_group(
+                                self.config.target_chat_id,
+                                [
+                                    get_media(msg)
+                                    for msg in messages
+                                ]
+                            ))[0].message_id
+                        elif len(messages) == 1:
+                            msg = messages[0]
+
+                            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
+
+                            group_message_id = (await func(
+                                self.config.target_chat_id,
+                                file_id,
+                                caption=self.config.message_template.format(text=msg.caption or ""),
+                                parse_mode="HTML"
+                            )).message_id
+
+                        self.message_manager.add(reply_to_message_id, group_message_id, message.chat.id)
+                        await message.answer("✅ Сообщение успешно отправлено!")
+                except (TelegramBadRequest, TelegramForbiddenError) as e:
+                    await message.answer(f'❌ Не удалось отправить сообщение: "{e}"')
+
+            media_group_id = message.media_group_id
+
+            async def await_media_group():
+                try:
+                    await asyncio.sleep(2)
+                    async with self.media_groups_lock:
+                        messages = self.media_groups.pop(media_group_id, [])
+                        self.media_groups_tasks.pop(media_group_id, None)
+                    await process_messages(messages)
+                except CancelledError:
+                    pass
+
+            if media_group_id:
+                self.media_groups.setdefault(media_group_id, []).append(message)
+
+                task = self.media_groups_tasks.get(media_group_id)
+                if task: task.cancel()
+
+                self.media_groups_tasks[media_group_id] = asyncio.create_task(await_media_group())
+                return
+
+            await process_messages([message])

+ 19 - 0
simpleforward/bot/routers/start.py

@@ -0,0 +1,19 @@
+from aiogram import Router
+from aiogram.filters import CommandStart
+from aiogram.types import Message
+
+
+class StartRouter(Router):
+    def __init__(self):
+        super().__init__()
+        self._register_handlers()
+
+    def _register_handlers(self):
+        @self.message(CommandStart())
+        async def on_start(message: Message):
+            await message.answer(f"👋 Привет {message.from_user.username}!\nТы можешь отправить мне "
+                                 "сообщение, и я передам его в Подслушано 21 школы, не раскрывая твою личность.\n\n"
+                                 "[Исходный код](https://github.com/librellium/SimpleForward-podslv21/) "
+                                 "открыт и находится под лицензией "
+                                 "[MIT](https://github.com/librellium/SimpleForward-podslv21/blob/main/LICENSE)",
+                                 parse_mode="Markdown")

+ 49 - 0
simpleforward/bot/routers/text.py

@@ -0,0 +1,49 @@
+from aiogram import Bot, F, Router
+from aiogram.enums import ChatType
+from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
+from aiogram.types import Message
+
+from simpleforward.bot.message_manager import MessageManager
+from simpleforward.config import models
+
+
+class TextRouter(Router):
+    def __init__(self,
+                 forwarding_config: models.Forwarding,
+                 message_manager: MessageManager):
+        super().__init__()
+
+        self.config = forwarding_config
+        self.message_manager = message_manager
+
+        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
+
+            if message.chat.id == self.config.target_chat_id\
+                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("Ответ успешно отправлен!")
+                        except (TelegramBadRequest, TelegramForbiddenError) as e:
+                            await message.answer(f'Не удалось отправить ответ: "{e}"')
+            elif message.chat.type == ChatType.PRIVATE and "text" in self.config.types:
+                try:
+                    reply_to_message_id = message.message_id
+                    group_message_id = (await bot.send_message(
+                        self.config.target_chat_id,
+                        self.config.message_template.format(text=message.text),
+                        parse_mode="HTML"
+                    )).message_id
+
+                    self.message_manager.add(reply_to_message_id, group_message_id, message.chat.id)
+                    await message.answer("Сообщение успешно отправлено!")
+                except (TelegramBadRequest, TelegramForbiddenError) as e:
+                    await message.answer(f'Не удалось отправить сообщение: "{e}"')

+ 3 - 0
simpleforward/config/__init__.py

@@ -0,0 +1,3 @@
+from .config import Config
+
+__all__ = ["Config"]

+ 54 - 0
simpleforward/config/config.py

@@ -0,0 +1,54 @@
+from pathlib import Path
+from typing import Optional
+
+import yaml
+from pydantic import BaseModel, SecretStr
+
+from .models import *
+
+
+class Config(BaseModel):
+    bot: Bot = Bot()
+    forwarding: Forwarding = Forwarding()
+    logging: Logging = Logging()
+
+    @classmethod
+    def load(cls, cfg_path: Optional[Path] = None):
+        filepath = Path(cfg_path)
+        filepath.parent.mkdir(parents = True, exist_ok = True)
+
+        if not filepath.exists():
+            config = cls()
+        else:
+            with filepath.open(encoding = "utf-8") as config_file:
+                config = cls(**yaml.safe_load(config_file))
+
+        object.__setattr__(config, "filepath", filepath)
+
+        if not filepath.exists():
+            config.save()
+
+        return config
+
+    def save(self, cfg_path: Optional[Path] = None):
+        if not getattr(self, "filepath") and not cfg_path:
+            raise FileNotFoundError(f"Cannot find config file: {getattr(self.filepath) or cfg_path}")
+
+        if not getattr(self, "filepath") or (not isinstance(cfg_path, Path) and cfg_path is not None):
+            self.filepath = Path(cfg_path)
+
+        self.filepath.parent.mkdir(parents = True, exist_ok = True)
+
+        with open(self.filepath, "w", encoding = "utf-8") as config_file:
+            yaml.dump(self._serialize(self.model_dump()), config_file, width = float("inf"), sort_keys = False)
+
+    @classmethod
+    def _serialize(cls, obj):
+        if isinstance(obj, SecretStr):
+            return obj.get_secret_value()
+        elif isinstance(obj, dict):
+            return {key: cls._serialize(value) for key, value in obj.items()}
+        elif isinstance(obj, list):
+            return [cls._serialize(value) for value in obj]
+
+        return obj

+ 20 - 0
simpleforward/config/models.py

@@ -0,0 +1,20 @@
+from typing import List, Literal, Optional, TypeAlias
+
+from pydantic import BaseModel, SecretStr
+
+ForwardingType: TypeAlias = Literal["text", "photo", "video"]
+LoggingLevel: TypeAlias = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
+
+class Bot(BaseModel):
+    token: Optional[SecretStr] = None
+    timeout: int = 10
+
+class Forwarding(BaseModel):
+    target_chat_id: Optional[int] = None
+    message_template: str = "У вас новое сообщение!\n%s"
+    types: List[ForwardingType] = ["text"]
+
+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"

+ 6 - 0
simpleforward/paths.py

@@ -0,0 +1,6 @@
+from pathlib import Path
+
+ROOT_DIR = Path(__file__).resolve().parent.parent
+
+CONFIG_FILE = ROOT_DIR / "config.yml"
+CONFIG_EXAMPLE_FILE = ROOT_DIR / "config.yml.example"