Newer
Older
tgclient-mcp / backend / app / tg / calls.py
"""Звонки Telegram с сигналингом MTProto (raw-запросы phone.*).

Граница возможностей (важно, в докстринге тулов тоже): Telethon — без
аудио-движка (tgcalls/libtgvoip), поэтому со стороны tgclient голос не
передаётся. Реализовано:
- исходящий вызов: phone.requestCall — у абонента настоящий входящий экран
  звонка; отклонил/не ответил (missed) — состояния declined/missed;
- когда абонент принял: DH-ключ (messages.getDhConfig; g_a = g^a mod p,
  commit SHA256(g_a); fingerprint = первые 8 байт SHA1(key) LE),
  phone.confirmCall и сразу phone.discardCall — медиа передавать нечем,
  держать «немое» соединение нечестно;
- статус/сброс по call_id (random_id → call_id из PhoneCallWaiting);
- входящие звонки агент не трогает (нет микрофона) — только факт в статусе
  не пишется (звонок звенит на всех живых сессиях владельца).

Канон core.telegram.org/api/end-to-end/voice-calls: числа DH — little-endian
байты; fingerprint — lower 64 bits of SHA1(key). Состояния живут в RAM
процесса (_calls): после рестарта активные звонки Telegram сам скинет
(ringing у абонента живёт на сервере ТГ, не у нас).
"""

import hashlib
import secrets
from datetime import datetime, timezone

from telethon import events, functions
from telethon.tl.types import (
    InputPhoneCall,
    PhoneCallAccepted,
    PhoneCallDiscardReasonHangup,
    PhoneCallProtocol,
    UpdatePhoneCall,
)

MIN_LAYER = 92
MAX_LAYER = 127
LIBRARY_VERSIONS = ["tgclient-signaling"]
TTL_STATUS_KEEP = 100  # сколько последних звонков держим в RAM

_calls: dict[int, dict] = {}          # call_id → публичное+приватное состояние
_dh_cache: dict[int, tuple[int, int, int]] = {}  # account_id → (version, g, p_int)


def _now() -> str:
    return datetime.now(timezone.utc).isoformat()


def _track(call_id: int, st: dict) -> None:
    _calls[call_id] = st
    for oldest in list(_calls)[: max(0, len(_calls) - TTL_STATUS_KEEP)]:
        _calls.pop(oldest, None)


# --- DH-конфиг ----------------------------------------------------------------

async def _dh_params(client, account_id: int) -> tuple[int, int, int]:
    """(version, g, p) из messages.getDhConfig (тот же DH, что у секретных
    чатов); кеш по версии account_id — getDhConfig(версией) вернёт
    DhConfigNotModified, если конфиг не менялся."""
    version, g, p = _dh_cache.get(account_id, (0, 2, 0))
    if p:
        return version, g, p
    result = await client(functions.messages.GetDhConfigRequest(
        version=version, random_length=1024,
    ))
    kind = type(result).__name__
    if kind == "DhConfig":
        params = (int(result.version), int(result.g), int.from_bytes(result.p, "little"))
        _dh_cache[account_id] = params
        return params
    if kind == "DhConfigNotModified" and p:
        return version, g, p
    raise RuntimeError(f"unexpected messages.getDhConfig reply: {kind}")


# --- Raw-хендлер: подтверждение при ответе абонента + финальные статусы --------

def register_call_listener(client, account_id: int) -> None:
    """Вешается на клиента аккаунта (AccountManager.get_client, вместе с
    пуш-событиями сообщений). Только исходящие вызовы (_calls по call_id)."""

    async def _on_call(update) -> None:
        call = getattr(update, "phone_call", None)
        if call is None:
            return
        st = _calls.get(getattr(call, "id", 0))
        if st is None:
            return  # не наш звонок (в т.ч. входящий) — не трогаем
        kind = type(call).__name__
        st["updated_at"] = _now()
        if kind == "PhoneCallAccepted":
            st["state"] = "answered"
            try:
                await _confirm_and_hangup(client, account_id, call, st)
            except Exception as exc:  # noqa: BLE001 — честный статус в данных
                st["state"], st["detail"] = "error", f"{type(exc).__name__}: {exc}"
        elif kind == "PhoneCallDiscarded":
            reason = type(getattr(call, "reason", None)).__name__
            st["state"] = {
                "PhoneCallDiscardReasonMissed": "missed",
                "PhoneCallDiscardReasonHangup": "declined",
                "PhoneCallDiscardReasonBusy": "busy",
                "PhoneCallDiscardReasonDisconnect": "disconnected",
            }.get(reason, "discarded")

    client.add_event_handler(_on_call, events.Raw(UpdatePhoneCall))


# --- Исходящий вызов -------------------------------------------------------------

def _protocol() -> "PhoneCallProtocol":
    return PhoneCallProtocol(
        min_layer=MIN_LAYER, max_layer=MAX_LAYER,
        udp_p2p=True, udp_reflector=True, library_versions=LIBRARY_VERSIONS,
    )


def _export(int_value: int, p: int) -> bytes:
    """DH-число в little-endian байты размером с модуль (канон voice-calls)."""
    size = (p.bit_length() + 7) // 8
    return int_value.to_bytes(size, "little")


async def call_start(client, account_id: int, input_user, *, video: bool = False) -> dict:
    """phone.requestCall: настоящий ринг абонента (его ТГ-клиенты звонят).
    Возвращает {call_id, state}. Приватный экспонент a остаётся только в RAM."""
    version, g, p = await _dh_params(client, account_id)
    a = secrets.randbits(2048)
    g_a_int = pow(g, a, p)
    result = await client(functions.phone.RequestCallRequest(
        user_id=input_user,
        random_id=secrets.randbits(31),
        g_a_hash=hashlib.sha256(_export(g_a_int, p)).digest(),
        protocol=_protocol(),
        video=video,
    ))
    call = None
    for upd in getattr(result, "updates", []) or []:
        pc = getattr(upd, "phone_call", None)
        if type(pc).__name__ == "PhoneCallWaiting" and getattr(pc, "video", False) == video:
            call = pc
            break
    if call is None:
        raise RuntimeError("telegram did not return PhoneCallWaiting — check protocol layers")
    st = {
        "account_id": account_id,
        "call_id": call.id,
        "access_hash": call.access_hash,
        "a": a,
        "g": version and g or 2,
        "p": p,
        "state": "ringing",
        "video": video,
        "detail": "",
        "updated_at": _now(),
    }
    _track(call.id, st)
    return _out(st)


async def _confirm_and_hangup(client, account_id: int, call, st: dict) -> None:
    """Абонент поднял: key = g_b^a mod p (LE), fingerprint = SHA1(key)[:8] LE →
    confirm → немедленный hangup (медиа нечем передавать)."""
    version, g, p = await _dh_params(client, account_id)
    key = _export(pow(int.from_bytes(call.g_b, "little"), st["a"], p), p)
    key_fingerprint = int.from_bytes(hashlib.sha1(key).digest()[:8], "little")
    peer = InputPhoneCall(id=call.id, access_hash=call.access_hash)
    await client(functions.phone.ConfirmCallRequest(
        peer=peer,
        g_a=_export(pow(g, st["a"], p), p),
        key_fingerprint=key_fingerprint,
        protocol=_protocol(),
    ))
    await client(functions.phone.DiscardCallRequest(
        peer=peer, duration=0, reason=PhoneCallDiscardReasonHangup(), connection_id=0,
    ))
    st["state"], st["detail"] = "hangup_after_answer", "медиа не передаётся — вызов завершён"


async def call_status(call_id: int) -> dict | None:
    """Публичное состояние звонка (None — неизвестно/убрано из RAM)."""
    st = _calls.get(call_id)
    return _out(st) if st else None


async def call_discard(client, call_id: int, reason: str = "hangup") -> None:
    """Отменить свой вызов до ответа абонента."""
    st = _calls.get(call_id)
    if st is None:
        raise KeyError(call_id)
    # DiscardCallRequest до confirm: reason только hangup/busy/missed уместны
    await client(functions.phone.DiscardCallRequest(
        peer=InputPhoneCall(id=call_id, access_hash=st["access_hash"]),
        duration=0, reason=PhoneCallDiscardReasonHangup(), connection_id=0,
    ))
    st["state"], st["updated_at"] = "cancelled", _now()


def _out(st: dict) -> dict:
    """Публичное состояние: без приватного экспонента и access_hash."""
    return {
        "call_id": st.get("call_id"),
        "account_id": st.get("account_id"),
        "state": st.get("state"),
        "video": st.get("video", False),
        "detail": st.get("detail", ""),
        "updated_at": st.get("updated_at"),
    }