From 4edb1f4b87575a8ccd9581329572442042089fa4 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 19 Jul 2026 21:24:04 +0000 Subject: [PATCH] init --- .env.example | 22 +++++++++ .gitignore | 5 ++ Dockerfile | 22 +++++++++ README.md | 113 ++++++++++++++++++++++++++++++++++++++++++++ app/__init__.py | 0 app/calling.py | 110 ++++++++++++++++++++++++++++++++++++++++++ app/config.py | 47 ++++++++++++++++++ app/main.py | 58 +++++++++++++++++++++++ app/tts.py | 32 +++++++++++++ docker-compose.yml | 14 ++++++ requirements.txt | 9 ++++ scripts/__init__.py | 0 scripts/login.py | 31 ++++++++++++ 13 files changed, 463 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/calling.py create mode 100644 app/config.py create mode 100644 app/main.py create mode 100644 app/tts.py create mode 100644 docker-compose.yml create mode 100644 requirements.txt create mode 100644 scripts/__init__.py create mode 100644 scripts/login.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..cee48c1 --- /dev/null +++ b/.env.example @@ -0,0 +1,22 @@ +# my.telegram.org -> API development tools +API_ID=1234567 +API_HASH=your_api_hash_here + +# Номер телефона аккаунта, который будет ЗВОНИТЬ (с кодом страны, без пробелов) +PHONE_NUMBER=+79991234567 + +# Имя session-файла (без расширения), будет лежать в /data/sessions +SESSION_NAME=caller + +# Кому звонить по умолчанию: @username или numeric user id — реальный +# 1-на-1 звонок (p2p, с гудком и ожиданием ответа). +CALL_TARGET=@monster1025 + +# Сколько секунд ждать ответа (гудка), прежде чем считать, что не дозвонились +CALL_RING_TIMEOUT=45 + +# Язык TTS-озвучки (gTTS): ru, en, ... +TTS_LANG=ru + +# Опциональный bearer-токен для защиты REST-ручки. Если пусто — без авторизации. +API_TOKEN=change-me diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..51baaf5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.env +sessions/ +__pycache__/ +*.pyc +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2595f81 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM python:3.11-slim + +# ffmpeg нужен pytgcalls для декодирования/кодирования аудио потока +RUN apt-get update \ + && apt-get install -y --no-install-recommends ffmpeg \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +ENV PYTHONPATH=/app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app ./app +COPY scripts ./scripts + +# Сюда монтируется volume с session-файлом (см. docker-compose.yml) +RUN mkdir -p /data/sessions + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..f6c57fc --- /dev/null +++ b/README.md @@ -0,0 +1,113 @@ +# Telegram Call Service + +REST-сервис: `POST /call` → делает настоящий **1-на-1 звонок** (p2p call, +такой же, как обычный звонок в приложении Telegram — с гудком и ожиданием +ответа) указанному пользователю под вашим аккаунтом и озвучивает переданный +текст через TTS сразу после того, как вызываемый принял звонок. + +## Как это устроено + +Telegram Bot API **не умеет** звонить — это принципиальное ограничение платформы, +никакой код это не обойдёт. Настоящий голосовой звонок доступен только через +MTProto-аккаунт обычного пользователя (Pyrogram-форк `pyrofork` + `py-tgcalls`, +низкоуровневый биндинг `ntgcalls`). + +Библиотека `py-tgcalls` делает это через `phone.requestCall` / `phone.acceptCall` +с DH key exchange — ровно тот же путь, которым идёт обычный звонок из +приложения. `play()` блокирует выполнение, пока собеседник не ответит (или не +истечёт `CALL_RING_TIMEOUT`), бросает `TimedOutAnswer` / `CallDeclined` / +`CallBusy`, если не дозвонились. + +## ⚠️ Важно понимать риски + +- Это автоматизация **личного аккаунта**, а не бота. Telegram может ограничить + или заблокировать аккаунт за автоматизированные действия, особенно при + частом/массовом использовании. Используйте выделенный номер, не основной. +- API_ID/API_HASH и session-файл дают полный доступ к аккаунту — храните + `.env` и `sessions/` так же бережно, как пароль. +- Не используйте это для звонков посторонним людям без их согласия. +- Звонок реально дозвонится только если у вызываемого аккаунта в + **Settings → Privacy and Security → Calls** разрешены звонки от вашего + аккаунта (например, "Everybody", или вы у него в контактах). Иначе Telegram + тихо отклонит попытку на уровне privacy — это ограничение платформы, не бага + сервиса. + +## Настройка + +### 1. Получить API_ID / API_HASH + +https://my.telegram.org → API development tools → создать приложение. + +### 2. Настроить .env + +```bash +cp .env.example .env +# заполнить API_ID, API_HASH, PHONE_NUMBER, CALL_TARGET, API_TOKEN +``` + +`CALL_TARGET` — кому звонить по умолчанию: `@username` вызываемого аккаунта +(или его numeric user id). Убедитесь, что у вызывающего аккаунта этот +пользователь виден (например, есть в контактах) — иначе `resolve_peer` не +сможет найти адресата по username при первом обращении. + +### 3. Один раз залогиниться (интерактивно, вне обычного запуска) + +```bash +docker compose build +docker compose run --rm callsvc python scripts/login.py +``` + +Введите код из Telegram (и пароль 2FA, если включён). Session-файл сохранится +в `./sessions/` на хосте и будет переиспользоваться при обычном запуске. + +### 4. Запуск сервиса + +```bash +docker compose up -d +``` + +## API + +### `GET /health` + +Проверка живости. + +### `POST /call` + +```bash +curl -X POST http://localhost:8000/call \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $API_TOKEN" \ + -d '{"text": "Внимание! Сработал алерт на проде."}' +``` + +Поля тела запроса: + +| поле | обязательное | описание | +|--------|--------------|---------------------------------------------------------------------------| +| text | да | текст, который будет озвучен (TTS, до 2000 символов) | +| target | нет | `@username` или numeric user id вызываемого. Если не задан — берётся `CALL_TARGET` из `.env` | + +Если `API_TOKEN` в `.env` не задан — заголовок `Authorization` не требуется. + +Ответ (после того, как собеседник принял звонок и TTS проигрался): + +```json +{"status": "called", "target": "@monster1025", "duration": 4.2} +``` + +Ошибки: + +- `409` — не дозвонились: не ответили за `CALL_RING_TIMEOUT` секунд, отклонили, + заняты, звонок сброшен, либо звонок на эту цель уже идёт, либо `target` + резолвится в группу/канал (сервис звонит только пользователям). +- `401` — неверный/отсутствующий Bearer-токен (если `API_TOKEN` задан). +- `500` — прочие ошибки (см. логи `docker compose logs -f`). + +## Ограничения текущей версии + +- Один одновременный звонок на цель (защищено локом), параллельные запросы + на разные `target` обрабатываются независимо. +- Озвучка через gTTS требует исходящего доступа в интернет из контейнера. +- `POST /call` синхронно ждёт ответа на звонок (до `CALL_RING_TIMEOUT` сек) — + учитывайте это в таймауте клиента, который дёргает ручку. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/calling.py b/app/calling.py new file mode 100644 index 0000000..61c4c2c --- /dev/null +++ b/app/calling.py @@ -0,0 +1,110 @@ +import asyncio +import logging +import os + +from pyrogram import Client +from pytgcalls import PyTgCalls, filters as pfl +from pytgcalls.exceptions import CallBusy, CallDeclined, CallDiscarded, TimedOutAnswer +from pytgcalls.types import CallConfig, MediaStream, StreamEnded + +from app.config import settings +from app.tts import synthesize + +logger = logging.getLogger("callsvc.calling") + +pyro_client = Client( + settings.session_name, + api_id=settings.api_id, + api_hash=settings.api_hash, + phone_number=settings.phone_number, + workdir=settings.sessions_dir, +) + +call_py = PyTgCalls(pyro_client) + +# per-цель "stream finished" события, заполняются on_stream_end хендлером +_stream_finished: dict[int, asyncio.Event] = {} +# один вызов на цель одновременно, чтобы не пересекались звонки +_locks: dict[str, asyncio.Lock] = {} + + +def _lock_for(key: str) -> asyncio.Lock: + if key not in _locks: + _locks[key] = asyncio.Lock() + return _locks[key] + + +@call_py.on_update(pfl.stream_end) +async def _on_stream_end(_: PyTgCalls, update: StreamEnded): + event = _stream_finished.get(update.chat_id) + if event is not None: + event.set() + + +async def start(): + await pyro_client.start() + await call_py.start() + logger.info("Telegram user client + PyTgCalls started") + + +async def stop(): + # PyTgCalls не имеет отдельного stop() — он поднимает/использует тот же + # pyrogram-клиент, поэтому останавливаем только клиент. + await pyro_client.stop() + logger.info("Telegram user client + PyTgCalls stopped") + + +async def make_call(text: str, target: int | str | None = None) -> dict: + """Звонит пользователю настоящим p2p-звонком (с гудком и ожиданием ответа) + и озвучивает текст (TTS) сразу после того, как собеседник ответил. + """ + target = target if target is not None else settings.call_target + lock = _lock_for(str(target)) + + if lock.locked(): + raise RuntimeError(f"Call to {target} is already in progress") + + async with lock: + audio_path, duration = synthesize(text) + resolved_id = await call_py.resolve_chat_id(target) + if resolved_id <= 0: + raise RuntimeError( + f"{target} resolves to a group/channel id ({resolved_id}). " + "This service only makes direct p2p calls to users." + ) + event = asyncio.Event() + _stream_finished[resolved_id] = event + + try: + config = CallConfig(timeout=settings.call_ring_timeout) + try: + logger.info("Calling %s (resolved id %s)...", target, resolved_id) + await call_py.play(resolved_id, MediaStream(audio_path), config=config) + except TimedOutAnswer as exc: + raise RuntimeError(f"{target} did not answer the call in time") from exc + except CallDeclined as exc: + raise RuntimeError(f"{target} declined the call") from exc + except CallBusy as exc: + raise RuntimeError(f"{target} is busy") from exc + except CallDiscarded as exc: + raise RuntimeError(f"Call to {target} was discarded") from exc + + logger.info("Call answered, playing TTS (%.1fs) to %s", duration, target) + + # ждём сигнал о завершении стрима, но не дольше duration + запас + timeout = duration + 10 + try: + await asyncio.wait_for(event.wait(), timeout=timeout) + except asyncio.TimeoutError: + logger.warning( + "Stream end event not received for %s within %.1fs, hanging up anyway", + target, + timeout, + ) + + await call_py.leave_call(resolved_id) + return {"target": target, "duration": duration} + finally: + _stream_finished.pop(resolved_id, None) + if os.path.exists(audio_path): + os.remove(audio_path) diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..89fa148 --- /dev/null +++ b/app/config.py @@ -0,0 +1,47 @@ +import os +from dataclasses import dataclass + + +def _require_int(name: str) -> int: + val = os.environ.get(name) + if not val: + raise RuntimeError(f"Env var {name} is required") + return int(val) + + +def _require_str(name: str) -> str: + val = os.environ.get(name) + if not val: + raise RuntimeError(f"Env var {name} is required") + return val + + +@dataclass(frozen=True) +class Settings: + api_id: int + api_hash: str + phone_number: str + session_name: str + sessions_dir: str + # Цель звонка по умолчанию: @username или numeric user id — прямой p2p звонок. + call_target: str + call_ring_timeout: int + tts_lang: str + api_token: str | None + + +def load_settings() -> Settings: + return Settings( + api_id=_require_int("API_ID"), + api_hash=_require_str("API_HASH"), + phone_number=_require_str("PHONE_NUMBER"), + session_name=os.environ.get("SESSION_NAME", "caller"), + sessions_dir=os.environ.get("SESSIONS_DIR", "/data/sessions"), + call_target=_require_str("CALL_TARGET"), + call_ring_timeout=int(os.environ.get("CALL_RING_TIMEOUT", "45")), + tts_lang=os.environ.get("TTS_LANG", "ru"), + api_token=os.environ.get("API_TOKEN") or None, + ) + + +settings = load_settings() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..c8316f1 --- /dev/null +++ b/app/main.py @@ -0,0 +1,58 @@ +import logging +from contextlib import asynccontextmanager + +from fastapi import Depends, FastAPI, HTTPException, Header +from pydantic import BaseModel, Field + +from app import calling +from app.config import settings + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger("callsvc.main") + + +@asynccontextmanager +async def lifespan(_: FastAPI): + await calling.start() + yield + await calling.stop() + + +app = FastAPI(title="Telegram Call Service", lifespan=lifespan) + + +class CallRequest(BaseModel): + text: str = Field(..., min_length=1, max_length=2000, description="Текст для озвучки TTS") + target: int | str | None = Field( + default=None, + description=( + "Кому звонить: @username или numeric user id. " + "Если не задан — берётся CALL_TARGET из .env" + ), + ) + + +def check_auth(authorization: str | None = Header(default=None)): + if not settings.api_token: + return + expected = f"Bearer {settings.api_token}" + if authorization != expected: + raise HTTPException(status_code=401, detail="Invalid or missing bearer token") + + +@app.get("/health") +async def health(): + return {"status": "ok"} + + +@app.post("/call", dependencies=[Depends(check_auth)]) +async def call(req: CallRequest): + try: + result = await calling.make_call(req.text, req.target) + except RuntimeError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except Exception as exc: # noqa: BLE001 + logger.exception("Call failed") + raise HTTPException(status_code=500, detail=str(exc)) from exc + + return {"status": "called", **result} diff --git a/app/tts.py b/app/tts.py new file mode 100644 index 0000000..d86716f --- /dev/null +++ b/app/tts.py @@ -0,0 +1,32 @@ +import logging +import os +import tempfile +import uuid + +from gtts import gTTS +from mutagen.mp3 import MP3 + +from app.config import settings + +logger = logging.getLogger("callsvc.tts") + + +def synthesize(text: str) -> tuple[str, float]: + """Генерирует mp3 из текста через gTTS. + + Возвращает (путь_к_файлу, длительность_в_секундах). + Вызывающий код отвечает за удаление файла после использования. + """ + if not text or not text.strip(): + raise ValueError("text must not be empty") + + path = os.path.join(tempfile.gettempdir(), f"call-{uuid.uuid4().hex}.mp3") + gTTS(text=text, lang=settings.tts_lang).save(path) + + try: + duration = MP3(path).info.length + except Exception: # noqa: BLE001 - на всякий случай не роняем звонок из-за метаданных + logger.warning("Could not read mp3 duration for %s, defaulting to 30s", path) + duration = 30.0 + + return path, duration diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0460c2e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +services: + callsvc: + build: . + container_name: telegram-call-service + restart: unless-stopped + env_file: + - .env + ports: + - "8200:8000" + volumes: + - ./sessions:/data/sessions + # для первого логина: docker compose run --rm callsvc python scripts/login.py + stdin_open: true + tty: true diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6b3debe --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +fastapi==0.115.0 +uvicorn[standard]==0.30.6 +pyrofork==2.3.69 +tgcrypto-pyrofork==1.2.6 +py-tgcalls[pyrogram]==2.3.3 +gTTS==2.5.3 +mutagen==1.47.0 +pydantic==2.9.2 +python-dotenv==1.0.1 diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/login.py b/scripts/login.py new file mode 100644 index 0000000..a0b0f40 --- /dev/null +++ b/scripts/login.py @@ -0,0 +1,31 @@ +""" +Одноразовый интерактивный логин user-аккаунта Telegram. + +Запуск: + docker compose run --rm callsvc python scripts/login.py + +Введёт код подтверждения (и пароль 2FA, если включён) и сохранит +session-файл в /data/sessions/.session — том смонтирован +в docker-compose.yml, так что сессия переживёт пересоздание контейнера. +""" + +from pyrogram import Client + +from app.config import settings + + +def main(): + client = Client( + settings.session_name, + api_id=settings.api_id, + api_hash=settings.api_hash, + phone_number=settings.phone_number, + workdir=settings.sessions_dir, + ) + with client: + me = client.get_me() + print(f"Logged in as {me.first_name} (id={me.id}). Session saved to {settings.sessions_dir}.") + + +if __name__ == "__main__": + main()