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