init
This commit is contained in:
+110
@@ -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)
|
||||
@@ -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
@@ -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
@@ -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
|
||||
Reference in New Issue
Block a user