diff --git a/README.md b/README.md index c7f43da..1349561 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,10 @@ `messages_read`, `messages_search`, `message_send/reply/edit/delete`; - медиа: `download_media` (base64), `upload_file`; **голосовые** (`upload_voice` ogg-opus + waveform 63×5 бит) и **кружки** (`upload_round`, mp4-квадрат ≤60 с); + - **звонки**: `call_start/call_status/call_discard` — сигналинг MTProto + (`phone.requestCall` → ринг у абонента, DH + confirm по voice-calls-спеке); + **голос не передаётся** (Telethon без tgcalls): принявший вызов получает + моментальный hangup; статус в RAM (после рестарта сервиса — потерян); - справочники: `contacts_list`, `chat_info`, `chat_participants`; - текстовый логин: `account_login_start/code/password/status/cancel`. - **Ошибки — данные** (`{"error": code, "detail": ...}`), перс. ключи по канону diff --git a/backend/app/mcp/server.py b/backend/app/mcp/server.py index 6527fdb..1bb6d53 100644 --- a/backend/app/mcp/server.py +++ b/backend/app/mcp/server.py @@ -54,6 +54,7 @@ | «отправь файл/фото» | `upload_file` | | «отправь голосовое» | `upload_voice` (ogg-opus, duration+waveform) | | «отправь кружок» | `upload_round` (mp4 квадрат ≤60с) | +| «позвони ему» | `call_start` (сигналинг: ринг у абонента, без аудио) | | «добавь мой акк / залогинь» | `account_login_start` → `code` → [`password`] | ## Добавление аккаунта (текст-режим — интерактив с человеком) @@ -69,8 +70,12 @@ Лимиты: ≤3 параллельных логинов, ограничен поток кодов; код живёт ~10 минут. ## Правила -- **Мутации (send/edit/delete/upload и т.п.) — только по явной просьбе - пользователя.** Удаление и логаут аккаунта — необратимы. +- **Мутации (send/edit/delete/upload/call_start и т.п.) — только по явной + просьбе пользователя.** Удаление и логаут аккаунта — необратимы. +- **Звонки (`call_start`) — сигналинг без звука** (Telethon не умеет + аудио-движок tgcalls): абонент увидит настоящий входящий вызов; примет — + вызов тут же корректно завершится (hangup_after_answer). Предупреждай + пользователя об этом заранее; статусы — `call_status`/`call_discard`. - Ошибки — данные: `{"error": 403|404|409|413|422|429, "detail": "…"}` — прочти detail и скажи человеку нормально (или исправь вызов сам). - 403 по аккаунту = не твой аккаунт: не перебирай чужие id. diff --git a/backend/app/mcp/tools.py b/backend/app/mcp/tools.py index 884de37..2dcfdb4 100644 --- a/backend/app/mcp/tools.py +++ b/backend/app/mcp/tools.py @@ -610,6 +610,71 @@ return await _saved(client, row["id"], message) + # --- Звонки (сигналинг; см. app/tg/calls.py) ------------------------------ + + @mcp.tool() + async def call_start(account_id: int | None = None, dialog_id: int = 0, + video: bool = False) -> dict: + """📞 Позвонить в Telegram: настоящий входящий вызов у абонента (ring). + dialog_id — юзер (>0; каналам/группам звонить нельзя → 422). Аудио + не передаётся (Telethon без tgcalls): после ответа абонента вызов + корректно подтверждается и сразу завершается; отклонил → declined. + Статус — call_status(call_id).""" + return await _tool(lambda: _call_start(account_id, dialog_id, video)) + + + async def _call_start(account_id, dialog_id, video) -> dict: + db = get_db() + client, row, entity = await _client_for_dialog(db, account_id, dialog_id, writable=True) + kind = type(entity).__name__ # InputPeerUser / InputPeerChat / InputPeerChannel + if kind != "InputPeerUser": + raise DomainError(422, "звонить можно только юзерам (dialog_id > 0 из dialogs_list)") + if (limited := _write_guard(row["id"])) is not None: + return limited + from app.tg.calls import call_start + + out = await call_guard(_manager(), row["id"], + lambda: call_start(client, row["id"], entity, video=video)) + return out + + + @mcp.tool() + async def call_status(call_id: int) -> dict: + """❓ Статус звонка по call_id: ringing → answered → hangup_after_answer, + missed / declined / busy / cancelled. None после рестарта сервиса.""" + return await _tool(lambda: _call_status(call_id)) + + + async def _call_status(call_id) -> dict: + from app.tg.calls import call_status + + out = await call_status(int(call_id)) + if out is None: + raise DomainError(404, "звонок неизвестен (рестарт сервиса сбросил состояния; после таймаута Telegram сам завершает ringing)") + return out + + + @mcp.tool() + async def call_discard(call_id: int) -> dict: + """🚫 Отменить свой звонок до ответа абонента (hangup). Мутация.""" + return await _tool(lambda: _call_discard(call_id)) + + + async def _call_discard(call_id) -> dict: + db = get_db() + # call_id → состояние (RAM): аккаунт, к которому привязан звонок + from app.tg.calls import _calls, call_discard + + st = _calls.get(int(call_id)) + if st is None: + raise DomainError(404, "звонок неизвестен — после рестарта сервиса состояния в RAM теряются") + account_id = st["account_id"] + client, row = await _resolve_client(db, account_id, writable=True) + await call_guard(_manager(), row["id"], + lambda: call_discard(client, int(call_id))) + return {"status": "ok", "call_id": call_id, "state": "cancelled"} + + # --- Справочники --------------------------------------------------------- @mcp.tool() diff --git a/backend/app/tg/calls.py b/backend/app/tg/calls.py new file mode 100644 index 0000000..acfb9d5 --- /dev/null +++ b/backend/app/tg/calls.py @@ -0,0 +1,208 @@ +"""Звонки 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"), + } \ No newline at end of file diff --git a/backend/app/tg/manager.py b/backend/app/tg/manager.py index 56becea..299ff52 100644 --- a/backend/app/tg/manager.py +++ b/backend/app/tg/manager.py @@ -68,10 +68,12 @@ raise DomainError(409, f"account #{account_id} is not authorized — login again") self.clients[account_id] = client await self._touch(account_id) + from app.tg.calls import register_call_listener + from app.tg.events import register_listeners + + register_call_listener(client, account_id) if get_settings().notify_messages: # пуш-события сообщений → Synapse (агенты/скрипты подписаны в Synapse) - from app.tg.events import register_listeners - register_listeners(client, account_id) return client