This commit is contained in:
Your Name
2026-07-19 21:24:04 +00:00
commit 4edb1f4b87
13 changed files with 463 additions and 0 deletions
+22
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
.env
sessions/
__pycache__/
*.pyc
.DS_Store
+22
View File
@@ -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"]
+113
View File
@@ -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` сек) —
учитывайте это в таймауте клиента, который дёргает ручку.
View File
+110
View File
@@ -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)
+47
View File
@@ -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()
+58
View File
@@ -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}
+32
View File
@@ -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
+14
View File
@@ -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
+9
View File
@@ -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
View File
+31
View File
@@ -0,0 +1,31 @@
"""
Одноразовый интерактивный логин user-аккаунта Telegram.
Запуск:
docker compose run --rm callsvc python scripts/login.py
Введёт код подтверждения (и пароль 2FA, если включён) и сохранит
session-файл в /data/sessions/<SESSION_NAME>.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()