"""Звонки 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"),
}