init: TTS Telegram Bot (edge-tts, Dmitry voice, admin panel)

This commit is contained in:
vrubel 2026-06-03 18:39:05 +00:00
commit ed3c1cf0ce
9 changed files with 498 additions and 0 deletions

3
.env Normal file
View file

@ -0,0 +1,3 @@
USER_BOT_TOKEN=8567080481:AAHDn6D-JQf2tuLD_S3Md9w0j34REktVsWE
ADMIN_BOT_TOKEN=8808539314:AAFydKdcal6HJpgirkFHO2fUdetECTwgxxI
TTS_VOICE=ru-RU-DmitryNeural

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
__pycache__/
*.pyc
.venv/
venv/

149
ARCHITECTURE.md Normal file
View file

@ -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
```

101
README.md Normal file
View file

@ -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<br/>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
```

10
app/Dockerfile Normal file
View file

@ -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"]

216
app/bot.py Normal file
View file

@ -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: <code>{admin_chat_id}</code>\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'📊 <b>Статистика бота</b>\n\n'
f'👥 Уникальных пользователей: <b>{unique}</b>\n'
f'🎤 Озвучено сообщений: <b>{total}</b>',
parse_mode='HTML'
)
# ────────────────────────── USER BOT ───────────────────────────
async def user_start(update: Update, context):
await update.message.reply_text(
'👋 Привет! Отправь мне текст, и я озвучу его '
'голосом <b>Дмитрия</b> (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'👤 <b>{user.full_name}</b>\n'
f'🆔 <code>{user.id}</code>\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...")

3
app/requirements.txt Normal file
View file

@ -0,0 +1,3 @@
python-telegram-bot>=21,<22
edge-tts>=7,<8
mutagen>=1.47,<2

1
data/stats.json Normal file
View file

@ -0,0 +1 @@
{"users": [20814732, 639276829, 1594297548], "total_messages": 8}

11
docker-compose.yml Normal file
View file

@ -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