From ed3c1cf0ce9a12010409f53d52d93105db9884c4 Mon Sep 17 00:00:00 2001 From: vrubel Date: Wed, 3 Jun 2026 18:39:05 +0000 Subject: [PATCH] init: TTS Telegram Bot (edge-tts, Dmitry voice, admin panel) --- .env | 3 + .gitignore | 4 + ARCHITECTURE.md | 149 +++++++++++++++++++++++++++++ README.md | 101 ++++++++++++++++++++ app/Dockerfile | 10 ++ app/bot.py | 216 +++++++++++++++++++++++++++++++++++++++++++ app/requirements.txt | 3 + data/stats.json | 1 + docker-compose.yml | 11 +++ 9 files changed, 498 insertions(+) create mode 100644 .env create mode 100644 .gitignore create mode 100644 ARCHITECTURE.md create mode 100644 README.md create mode 100644 app/Dockerfile create mode 100644 app/bot.py create mode 100644 app/requirements.txt create mode 100644 data/stats.json create mode 100644 docker-compose.yml diff --git a/.env b/.env new file mode 100644 index 0000000..888385d --- /dev/null +++ b/.env @@ -0,0 +1,3 @@ +USER_BOT_TOKEN=8567080481:AAHDn6D-JQf2tuLD_S3Md9w0j34REktVsWE +ADMIN_BOT_TOKEN=8808539314:AAFydKdcal6HJpgirkFHO2fUdetECTwgxxI +TTS_VOICE=ru-RU-DmitryNeural diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ad03bc7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +.venv/ +venv/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..a73c60b --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,149 @@ +# Архитектура сервиса T2S Telegram Bot + +## Описание + +Сервис реализует двухбота — один для пользователей (озвучка текста в голос), второй для администратора (логи и копии озвучек). Оба бота запускаются в одном Docker-контейнере в рамках одного asyncio-процесса. + +## Компоненты + +``` +┌─────────────────────────────────────────────────────┐ +│ Docker Container │ +│ ┌──────────────────────────────────────────────┐ │ +│ │ Python 3.12 процесс │ │ +│ │ │ │ +│ │ ┌─────────────────┐ ┌─────────────────┐ │ │ +│ │ │ User Bot App │ │ Admin Bot App │ │ │ +│ │ │ (Application) │ │ (Application) │ │ │ +│ │ │ │ │ │ │ │ +│ │ │ Token: │ │ Token: │ │ │ +│ │ │ USER_BOT_TOKEN │ │ ADMIN_BOT_TOKEN│ │ │ +│ │ └───────┬─────────┘ └────────┬────────┘ │ │ +│ │ │ │ │ │ +│ │ │ Admin Bot │ │ │ +│ │ │ Instance │ │ │ +│ │ └──────┬───────────────┘ │ │ +│ │ │ (Bot token=ADMIN_BOT_TOKEN) │ │ +│ │ ▼ │ │ +│ │ ┌────────────────────────────┐ │ │ +│ │ │ edge-tts (TTS) │ │ │ +│ │ │ Microsoft TTS Service │ │ │ +│ │ │ via websocket/HTTP │ │ │ +│ │ │ Voice: ru-RU-DmitryNeural │ │ │ +│ │ └────────────────────────────┘ │ │ +│ └──────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────┘ +``` + +## Потоки данных + +### Пользовательский поток + +``` +Пользователь ──текст──▶ User Bot App ──TTS──▶ edge-tts + │ + User Bot App ◀──audio.ogg────────────┘ + │ + голосовое сообщение + │ + ▼ + Пользователь +``` + +### Администраторский поток + +``` +User Bot App ──текст + user info──▶ Admin Bot (прямой Bot-клиент) + │ + голосовое + подпись + │ + ▼ + Администратор +``` + +## Детали реализации + +### `bot.py` — точка входа + +```python +async def main(): + admin_app = Application.builder().token(ADMIN_TOKEN).build() + user_app = Application.builder().token(USER_TOKEN).build() + # Оба запускаются внутри вложенных async with +``` + +Ключевые решения: + +1. **Один процесс** — два `Application` внутри вложенных `async with`. Это позволяет разделять память (переменная `admin_chat_id`) и не усложнять инфраструктуру. + +2. **Отдельный Bot-клиент для админа** — `admin_bot = Bot(token=ADMIN_TOKEN)` создаётся как экземпляр `telegram.Bot`, а не `Application`. Это лёгкий HTTP-клиент для отправки сообщений без полного цикла поллинга. + +3. **Временные файлы** — TTS генерируется в `NamedTemporaryFile(suffix='.ogg')`, файл открывается для чтения, отправляется, затем удаляется в `finally`. Никакого накопления мусора. + +### Озвучка (edge-tts) + +- Библиотека `edge-tts` эмулирует запрос к Microsoft Edge TTS API +- Не требует API-ключа, бесплатно +- Голос кешируется при первом вызове (скачивается в `~/.cache/edge-tts/`) +- Формат вывода — Ogg Opus (нативно поддерживается Telegram как голосовое сообщение) +- Асинхронный вызов: `await edge_tts.Communicate(text, voice).save(path)` + +### Telegram Bot API + +- Используется `python-telegram-bot` v21+ (синтаксис `Application`, `async with`) +- Long-polling (без webhook — не требует публичного HTTPS-адреса) +- `send_action(action='record_voice')` показывает индикатор "запись голоса" + +## Обработка ошибок + +| Сценарий | Реакция | +|---|---| +| edge-tts недоступен | Исключение в `communicate.save()` → ошибка в лог | +| Админ не запустил `/start` | `admin_chat_id is None` → лог `warning` + без уведомления | +| Ошибка отправки админу | `try/except` → лог `error`, пользователь всё равно получает ответ | +| Временный файл не удалился | `try/unlink` в `finally` — silent ignore | + +## Сетевая модель + +``` +Container ──443/tcp──▶ api.telegram.org (боты) +Container ──443/tcp──▶ speech.microsoft.com (edge-tts) +``` + +Исходящие HTTPS-соединения. Входящих нет — только long-polling к Telegram API. + +## Зависимости + +- `python:3.12-slim` — base image (~130 MB) +- `python-telegram-bot >=21, <22` — Telegram API +- `edge-tts >=6, <7` — Microsoft TTS +- `httpx` — HTTP-клиент PTB +- `aiohttp` — HTTP-клиент edge-tts + +## Конфигурация + +Вся конфигурация через переменные окружения (файл `.env`): + +```env +USER_BOT_TOKEN=*** # Токен пользовательского бота (Telegram BotFather) +ADMIN_BOT_TOKEN=*** # Токен админского бота +TTS_VOICE=ru-RU-DmitryNeural # Голос edge-tts (опционально) +``` + +## Docker + +```yaml +# docker-compose.yml +services: + t2s: + build: ./app + container_name: t2s-telegram-bot + restart: always + env_file: .env +``` + +Сборка при заблокированном Docker Hub: + +```bash +DOCKER_BUILDKIT=0 docker build --network=host -t t2s-telegram-bot ./app +``` diff --git a/README.md b/README.md new file mode 100644 index 0000000..38c6c55 --- /dev/null +++ b/README.md @@ -0,0 +1,101 @@ +# T2S Telegram Bot — Text-to-Speech + +Сервис озвучивания текста через Telegram. Пользователь отправляет текст — получает голосовое сообщение голосом Дмитрия (Microsoft edge-tts). Администратор получает копию каждой озвучки с информацией об отправителе. + +## Быстрый старт + +```bash +# Запустить сервис +docker compose up -d + +# Посмотреть логи +docker compose logs -f + +# Остановить +docker compose down +``` + +### Первичная настройка + +1. Напиши `/start` **админ-боту** (токен `ADMIN_BOT_TOKEN`) — он запомнит твой `chat_id` +2. После этого все сообщения пользователей будут дублироваться тебе с аудио + информацией +3. Любой пользователь может писать текст **пользовательскому боту** (токен `USER_BOT_TOKEN`) и получать озвучку + +## Архитектура + +```mermaid +graph LR + User[Пользователь Telegram] -->|текст| UBot[Пользовательский бот] + UBot -->|TTS запрос| edge[edge-tts
Microsoft TTS] + edge -->|audio.ogg| UBot + UBot -->|голосовое сообщение| User + UBot -->|копия + информация| ABot[Админ-бот] + ABot -->|голосовое + информация| Admin[Администратор] +``` + +- **Один процесс** запускает два экземпляра `python-telegram-bot` Application +- **Озвучка** — Microsoft edge-tts, голос `ru-RU-DmitryNeural` (бесплатно, без API-ключа) +- **Аудио** — временные `.ogg` файлы, удаляются сразу после отправки + +## Структура проекта + +``` +. +├── .env # Токены ботов + настройки +├── .gitignore +├── README.md +├── docker-compose.yml # Оркестрация +└── app/ + ├── Dockerfile + ├── requirements.txt + └── bot.py # Логика ботов +``` + +## Конфигурация (.env) + +| Переменная | Описание | Пример | +|---|---|---| +| `USER_BOT_TOKEN` | Токен пользовательского бота | `856708...` | +| `ADMIN_BOT_TOKEN` | Токен админского бота | `880853...` | +| `TTS_VOICE` | Голос edge-tts (опционально) | `ru-RU-DmitryNeural` | + +### Доступные русские голоса + +| Имя | Пол | +|---|---| +| `ru-RU-DmitryNeural` | Мужской (по умолчанию) | +| `ru-RU-SvetlanaNeural` | Женский | +| `ru-RU-DariyaNeural` | Женский | + +## Использование + +Пользователь пишет любое текстовое сообщение пользовательскому боту: + +``` +User: Привет, как дела? +Bot: 🎵 (голосовое сообщение с озвучкой) +``` + +Администратору приходит: + +``` +👤 Иван Иванов +🆔 123456789 +📝 Привет, как дела? +⏰ 2026-06-03 18:09:00 +🎵 (то же голосовое сообщение) +``` + +## Требования + +- Docker + Docker Compose +- Доступ к api.telegram.org (боты) +- Доступ к Microsoft edge-tts CDN (первый запрос кеширует голос) + +## Особенности сети + +При использовании с роутером GLiNet (DPI, блокировка Docker Hub) используйте для сборки: + +```bash +DOCKER_BUILDKIT=0 docker build --network=host -t t2s-telegram-bot ./app +``` diff --git a/app/Dockerfile b/app/Dockerfile new file mode 100644 index 0000000..6410ca4 --- /dev/null +++ b/app/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY bot.py . + +CMD ["python", "bot.py"] diff --git a/app/bot.py b/app/bot.py new file mode 100644 index 0000000..4ebbe9d --- /dev/null +++ b/app/bot.py @@ -0,0 +1,216 @@ +import asyncio +import json +import logging +import os +import tempfile +from datetime import datetime + +import edge_tts +from mutagen.mp3 import MP3 +from telegram import Bot, Update +from telegram.ext import Application, CommandHandler, MessageHandler, filters + +logging.basicConfig( + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + level=logging.INFO +) +logger = logging.getLogger(__name__) + +USER_TOKEN = os.environ['USER_BOT_TOKEN'] +ADMIN_TOKEN = os.environ['ADMIN_BOT_TOKEN'] +TTS_VOICE = os.environ.get('TTS_VOICE', 'ru-RU-DmitryNeural') +DATA_DIR = os.environ.get('DATA_DIR', '/app/data') +os.makedirs(DATA_DIR, exist_ok=True) + +ADMIN_CHAT_ID_FILE = os.path.join(DATA_DIR, 'admin_chat_id.txt') +STATS_FILE = os.path.join(DATA_DIR, 'stats.json') + + +def load_admin_chat_id(): + try: + with open(ADMIN_CHAT_ID_FILE) as f: + return int(f.read().strip()) + except (FileNotFoundError, ValueError): + return None + + +def save_admin_chat_id(chat_id): + with open(ADMIN_CHAT_ID_FILE, 'w') as f: + f.write(str(chat_id)) + + +def load_stats(): + try: + with open(STATS_FILE) as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return {"users": [], "total_messages": 0} + + +def save_stats(stats): + with open(STATS_FILE, 'w') as f: + json.dump(stats, f) + + +def get_ogg_duration(path: str) -> int: + """Read audio duration in seconds (MP3 from edge-tts v7).""" + try: + audio = MP3(path) + return int(audio.info.length) + except Exception: + logger.warning(f"Could not read duration from {path}") + return 0 + + +admin_chat_id = load_admin_chat_id() + +# Separate Bot instance for sending admin notifications +admin_bot = Bot(token=ADMIN_TOKEN) + +# ────────────────────────── ADMIN BOT ────────────────────────── + +async def admin_start(update: Update, context): + """Capture admin chat ID on /start and persist.""" + global admin_chat_id + admin_chat_id = update.effective_chat.id + save_admin_chat_id(admin_chat_id) + await update.message.reply_text( + f'✅ Админ-панель готова.\n' + f'Твой chat_id: {admin_chat_id}\n\n' + f'Сюда будут приходить все озвучки пользователей.\n' + f'Используй /stat для статистики.', + parse_mode='HTML' + ) + logger.info(f"Admin registered: chat_id={admin_chat_id}") + + +async def admin_stat(update: Update, context): + """Show bot usage statistics.""" + s = load_stats() + unique = len(s['users']) + total = s['total_messages'] + await update.message.reply_text( + f'📊 Статистика бота\n\n' + f'👥 Уникальных пользователей: {unique}\n' + f'🎤 Озвучено сообщений: {total}', + parse_mode='HTML' + ) + + +# ────────────────────────── USER BOT ─────────────────────────── + +async def user_start(update: Update, context): + await update.message.reply_text( + '👋 Привет! Отправь мне текст, и я озвучу его ' + 'голосом Дмитрия (ru-RU).\n\n' + 'Просто пришли любое текстовое сообщение.', + parse_mode='HTML' + ) + + +async def handle_user_message(update: Update, context): + """Receive text → TTS → reply voice → forward to admin.""" + global admin_chat_id + user = update.effective_user + text = update.message.text + + logger.info(f"User {user.full_name} (id={user.id}): {text[:80]}") + + # Track stats + stats = load_stats() + if user.id not in stats['users']: + stats['users'].append(user.id) + stats['total_messages'] = stats.get('total_messages', 0) + 1 + save_stats(stats) + + # Tell Telegram we're recording audio + await update.message.chat.send_action(action='record_voice') + + # Generate TTS with edge-tts (Microsoft, free, offline-capable) + tmp = tempfile.NamedTemporaryFile(suffix='.ogg', delete=False) + tmp_path = tmp.name + tmp.close() + + try: + communicate = edge_tts.Communicate(text, voice=TTS_VOICE) + await communicate.save(tmp_path) + + # ── 1. Send voice back to user ── + duration = get_ogg_duration(tmp_path) + with open(tmp_path, 'rb') as f: + await update.message.reply_voice(voice=f, duration=duration) + + # ── 2. Forward to admin ── + if admin_chat_id: + caption = ( + f'👤 {user.full_name}\n' + f'🆔 {user.id}\n' + f'📝 {text}\n' + f'⏰ {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}\n' + f'🔊 Длительность: {duration // 60}:{duration % 60:02d}' + ) + try: + with open(tmp_path, 'rb') as f: + await admin_bot.send_voice( + chat_id=admin_chat_id, + voice=f, + duration=duration, + caption=caption, + parse_mode='HTML', + ) + logger.info(f"Admin notified: {user.full_name} → {text[:40]}") + except Exception as e: + logger.error(f"Failed to notify admin: {e}") + else: + logger.warning("Admin not registered — run /start in admin bot") + + finally: + try: + os.unlink(tmp_path) + except OSError: + pass + + +# ────────────────────────── SETUP ────────────────────────────── + +def build_admin_app() -> Application: + app = Application.builder().token(ADMIN_TOKEN).build() + app.add_handler(CommandHandler('start', admin_start)) + app.add_handler(CommandHandler('stat', admin_stat)) + return app + + +def build_user_app() -> Application: + app = Application.builder().token(USER_TOKEN).build() + app.add_handler(CommandHandler('start', user_start)) + app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_user_message)) + return app + + +async def main(): + admin_app = build_admin_app() + user_app = build_user_app() + + # Run both bots concurrently + async with admin_app: + await admin_app.initialize() + await admin_app.start() + await admin_app.updater.start_polling() + logger.info("Admin bot polling started") + + async with user_app: + await user_app.initialize() + await user_app.start() + await user_app.updater.start_polling() + logger.info("User bot polling started") + + # Keep alive + while True: + await asyncio.sleep(3600) + + +if __name__ == '__main__': + try: + asyncio.run(main()) + except (KeyboardInterrupt, SystemExit): + logger.info("Shutting down gracefully...") diff --git a/app/requirements.txt b/app/requirements.txt new file mode 100644 index 0000000..f58b560 --- /dev/null +++ b/app/requirements.txt @@ -0,0 +1,3 @@ +python-telegram-bot>=21,<22 +edge-tts>=7,<8 +mutagen>=1.47,<2 diff --git a/data/stats.json b/data/stats.json new file mode 100644 index 0000000..e8ff50a --- /dev/null +++ b/data/stats.json @@ -0,0 +1 @@ +{"users": [20814732, 639276829, 1594297548], "total_messages": 8} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..378eec8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,11 @@ +version: "3.9" + +services: + t2s: + build: ./app + container_name: t2s-telegram-bot + restart: always + network_mode: "host" + env_file: .env + volumes: + - ./data:/app/data