diff --git a/backend/app/mcp/tools.py b/backend/app/mcp/tools.py index 0e1eb8f..013fdbb 100644 --- a/backend/app/mcp/tools.py +++ b/backend/app/mcp/tools.py @@ -312,8 +312,9 @@ async def _messages_history(account_id, dialog_id, limit, offset_id, min_id) -> dict: client, _row, entity = await _client_for_dialog(get_db(), account_id, dialog_id) limit = _limit(limit, 30, HISTORY_CAP) + # offset_id/min_id — int, TL-сериализация не принимает None; 0 = «без» msgs = await client.get_messages( - entity, limit=limit, offset_id=offset_id or None, min_id=min_id or None, + entity, limit=limit, offset_id=int(offset_id), min_id=int(min_id), ) return {"messages": [message_dict(m) for m in msgs]} diff --git a/backend/app/tg/calls.py b/backend/app/tg/calls.py index acfb9d5..384f7ea 100644 --- a/backend/app/tg/calls.py +++ b/backend/app/tg/calls.py @@ -137,7 +137,7 @@ 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: + if type(pc).__name__ == "PhoneCallWaiting" and bool(getattr(pc, "video", False)) == video: call = pc break if call is None: diff --git a/backend/tests/test_mcp_tools.py b/backend/tests/test_mcp_tools.py new file mode 100644 index 0000000..277ec6d --- /dev/null +++ b/backend/tests/test_mcp_tools.py @@ -0,0 +1,407 @@ +"""Runtime-прогон всех core/media MCP-тулов против фейкового Telethon-клиента. + +Дополнение к test_login_flow (там только логин): здесь проверяется, что каждый +тул строит валидные вызовы Telethon (TL-запросы/kwargs из реальной версии), +сериализаторы справляются с настоящими TL-объектами, а ошибки возвращаются +данными ({"error": …}), а не падают. По мотивам Production-бага: в `_tool` +стоял не существующий класс errors.EntityNotFoundError (AttributeError в +except-клаузе), а messages_history шёл offset_id=None — TL-сериализация не +принимает None для int. +""" + +import asyncio +import base64 +import sys +import types +from datetime import datetime, timezone +from pathlib import Path + +import pytest +import telethon.errors +from telethon.tl.types import ( + Chat, ChatPhotoEmpty, Dialog, Document, DocumentAttributeAudio, + DocumentAttributeFilename, InputPeerChat, InputPeerUser, Message, + MessageMediaDocument, MessageMediaPhoto, PeerChat, PeerUser, + PhoneCallProtocol, PhoneCallWaiting, Photo, PhotoSize, UpdatePhoneCall, User, +) + +BACKEND = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BACKEND)) + +from app.config import get_settings +from app.db import get_db, init_db +from app.mcp import tools +from app.tg.calls import _calls + +NOW = datetime.now(timezone.utc) + + +class DummyMCP: + """Ловит тулерегистрацию register_tools: тулы по имени, без FastMCP.""" + + def __init__(self): + self.registered = {} + + def tool(self): + def deco(fn): + self.registered[fn.__name__] = fn + return fn + return deco + + +_TOOLS_CACHE = {} + + +def get_tools(module=None): + """register_tools → dict name → функция (кэш на модуль tools).""" + dm = DummyMCP() + _T = dm + module.register_tools(_T) + return _T.registered + + +class FakeManager: + def __init__(self, clients): + self.clients = clients # account_id → client + self.saved = [] + + async def get_client(self, account_id): + return self.clients[account_id] + + async def save_session(self, account_id): + self.saved.append(account_id) + + def pending_client(self, login_id): + return None + + +class FakeClient: + """Подмножество Telethon-клиента, которого хватает тулам.""" + + def __init__(self, *, me=None, dialogs=None, by_id=None, contacts=None, + entities=None): + self.me = me or User(id=42, first_name="Тест", last_name="Юзер", + username="tester", phone="+79991234567") + self.dialogs = dialogs or [] + self.by_id = by_id or {} # raw peer id (InputPeerChat.chat_id) → list[Message] + self.contacts = contacts or [] + # маркированный dialog_id → InputPeer (как в пуле manager) + self.input_entities = entities or {777: InputPeerChat(chat_id=777)} + + async def get_me(self): + return self.me + + async def get_input_entity(self, peer): + ent = self.input_entities.get(peer) + if ent is None: + raise ValueError(f"Could not find the input entity for {peer}") + return ent + + async def get_entity(self, entity): + return entity + + def iter_dialogs(self, *, limit=100): + async def it(): + for d in self.dialogs[:limit]: + yield d + return it() + + @staticmethod + def _peer_key(entity): + return getattr(entity, "chat_id", getattr(entity, "user_id", 777)) + + async def get_messages(self, entity, *, limit=100, offset_id=0, min_id=0, + ids=None, search=None, from_user=None): + pool = self.by_id.get(self._peer_key(entity), []) + if ids: + found = {m.id: m for m in pool} + return [found.get(i) for i in ids] + if search is not None: + pool = [m for m in pool if search.lower() in (m.message or "").lower()] + return list(reversed(pool))[:limit] # новые первыми + + async def send_message(self, entity, text, **kwargs): + return Message(id=max(m.id for m in self.by_id.get(777, [Message(id=0, peer_id=None, date=NOW)])) + 1, + peer_id=PeerChat(chat_id=777), date=NOW, message=text, out=True) + + async def send_file(self, entity, *a, **kw): + mid = max(m.id for m in self.by_id.get(777, [Message(id=0, peer_id=None, date=NOW)])) + 1 + doc = Document(id=1, access_hash=2, file_reference=b"x", date=NOW, + mime_type=kw.get("mime_type", "application/octet-stream"), + size=64, dc_id=1, attributes=kw.get("attributes", [])) + return Message(id=mid, peer_id=PeerChat(chat_id=777), date=NOW, message="", + media=MessageMediaDocument(document=doc), out=True) + + async def edit_message(self, entity, mid, text): + return Message(id=mid, peer_id=PeerChat(chat_id=777), date=NOW, message=text, out=True) + + async def delete_messages(self, entity, ids, *, revoke=True): + self.by_id[777] = [m for m in self.by_id.get(777, []) if m.id not in ids] + return True + + async def download_media(self, message, target): + return b"mediabytes" + + def get_participants(self, entity, *, limit=100): + async def none_(): + return [] + return none_() + + async def __call__(self, request): + kind = type(request).__name__ + if kind == "GetFullChatRequest": + return types.SimpleNamespace(full_chat=None, users=self.contacts) + if kind == "GetContactsRequest": + return types.SimpleNamespace(users=self.contacts) + if kind == "SearchGlobalRequest": + request._bytes() # сериализация не должна падать (None-args регресс) + msgs = [m for pool in self.by_id.values() for m in pool] + return types.SimpleNamespace(messages=msgs) + if kind == "GetDhConfigRequest": + from telethon.tl.types import messages as tl_messages + return tl_messages.DhConfig(g=3, p=b"\xfd" * 256, version=1, random=[b"\x00"]) + if kind == "RequestCallRequest": + proto = PhoneCallProtocol(min_layer=92, max_layer=127, udp_p2p=True, + udp_reflector=True, library_versions=["x"]) + waiting = PhoneCallWaiting(id=555, access_hash=9, date=NOW, + admin_id=42, participant_id=7, protocol=proto) + return types.SimpleNamespace(updates=[UpdatePhoneCall(phone_call=waiting)]) + if kind == "DiscardCallRequest": + request._bytes() + return types.SimpleNamespace() + if kind == "ConfirmCallRequest": + return types.SimpleNamespace() + raise AssertionError(f"fake client: unexpected request {kind}") + + +async def setup(tmp_path, monkeypatch, *, user="mcp"): + monkeypatch.setenv("TGCLIENT_DB_PATH", str(tmp_path / "t.db")) + monkeypatch.setenv("TGCLIENT_API_ID", "1") + monkeypatch.setenv("TGCLIENT_API_HASH", "hash") + monkeypatch.setenv("TGCLIENT_AUTH_CLIENT_ID", "") + get_settings.cache_clear() + await init_db() + conn = get_db() + await conn.execute( + "INSERT OR IGNORE INTO users (user_id, email, created_at, updated_at)" + " VALUES (?, 'test@example.com', ?, ?)", (user, NOW.isoformat(), NOW.isoformat())) + await conn.commit() + return conn + + +async def insert_account(conn, account_id=1, user="mcp", status="active"): + await conn.execute( + "INSERT OR REPLACE INTO accounts" + " (id, user_id, phone, label, tg_user_id, username, display_name," + " session_data, status, created_at, updated_at, last_used_at)" + " VALUES (?, ?, ?, '', 42, 'tester', 'Тест', 'sess', ?, ?, ?, ?)", + (account_id, user, "+79991234567", status, NOW.isoformat(), NOW.isoformat(), None)) + await conn.commit() + + +def make_manager(monkeypatch, client): + manager = FakeManager({1: client}) + import app.main as appmain + import app.tg.manager as mgr + monkeypatch.setattr(appmain, "get_account_manager", lambda: manager, raising=False) + monkeypatch.setattr(mgr, "report", lambda *a, **k: None) + return manager + + +def voice_message(mid=10): + doc = Document(id=1, access_hash=2, file_reference=b"x", date=NOW, + mime_type="audio/ogg", size=1234, dc_id=1, + attributes=[DocumentAttributeAudio(duration=5, voice=True, waveform=b"\x82\x82"), + DocumentAttributeFilename("voice.ogg")]) + return Message(id=mid, peer_id=PeerChat(chat_id=777), date=NOW, message="голосовое", + media=MessageMediaDocument(document=doc), out=True) + + +def photo_message(mid=11, raw=778): + photo = Photo(id=1, access_hash=2, file_reference=b"", date=NOW, + sizes=[PhotoSize(type="x", w=10, h=10, size=555)], dc_id=1) + return Message(id=mid, peer_id=PeerChat(chat_id=raw), date=NOW, message="", + media=MessageMediaPhoto(photo=photo)) + + +def text_message(mid=12, text="привет"): + return Message(id=mid, peer_id=PeerChat(chat_id=777), date=NOW, message=text, out=True) + + +pytestmark = pytest.mark.asyncio + + +@pytest.mark.asyncio +async def test_core_tools_smoke(monkeypatch, tmp_path): + conn = await setup(tmp_path, monkeypatch) + await insert_account(conn) + g = Chat(id=777, title="Группа", photo=ChatPhotoEmpty(), participants_count=2, + date=NOW, version=0) + # iter_dialogs отдаёт custom Dialog (не TL) — как в реальном Telethon + raw_dlg = Dialog(peer=PeerChat(chat_id=777), top_message=12, read_inbox_max_id=12, + read_outbox_max_id=0, unread_count=1, unread_mentions_count=0, + unread_reactions_count=0, unread_poll_votes_count=0, draft=None, + notify_settings=None, folder_id=None) + from telethon.tl.custom.dialog import Dialog as CustomDialog + last = text_message(12) + dlg = CustomDialog(client=None, dialog=raw_dlg, entities={-777: g}, message=last) + client = FakeClient( + dialogs=[dlg], by_id={777: [voice_message(10), photo_message(11), text_message(12)]}, + contacts=[User(id=9, first_name="Друг")], + entities={777: InputPeerChat(chat_id=777), -777: InputPeerChat(chat_id=777), + 42: InputPeerUser(user_id=42, access_hash=1)}) + T = get_tools(tools) + make_manager(monkeypatch, client) + + out = await T["accounts_list"]() + assert out["accounts"][0]["status"] == "active" and out["accounts"][0]["id"] == 1 + + me = await T["me_get"](1) + assert me["account_id"] == 1 and me["is_bot"] is False + + dl_out = await T["dialogs_list"](1, 10) + d = dl_out["dialogs"][0] + assert d["id"] == -777 and d["unread_count"] == 1 and d["last_message"]["id"] == 12 + + hist = await T["messages_history"](1, -777, 10, 0, 0) + assert [m["id"] for m in hist["messages"]] == [12, 11, 10] + + read = await T["messages_read"](1, -777, 10) + assert read["media"]["voice"] is True and read["media"]["waveform"] + + ph = await T["messages_read"](1, -777, 11) + assert ph["media"]["size"] == 555 and "voice" not in ph["media"] + + sr = await T["messages_search"](1, -777, "привет", "", 10) + assert [m["id"] for m in sr["messages"]] == [12] + glob = await T["messages_search"](1, 0, "голосовое", "", 10) + assert glob["messages"] + + ci = await T["chat_info"](1, -777) + assert ci["id"] == -777 and ci["type"] == "chat" + + ct = await T["contacts_list"](1, 10) + assert ct["contacts"][0]["id"] == 9 + + # не-свой dialog_id → 404 данными + nf = await T["messages_history"](1, -888, 10, 0, 0) + assert nf["error"] == 404 + + +@pytest.mark.asyncio +async def test_mutation_tools_smoke(monkeypatch, tmp_path): + conn = await setup(tmp_path, monkeypatch) + await insert_account(conn) + client = FakeClient( + by_id={777: [voice_message(10), text_message(12)]}, + entities={-777: InputPeerChat(chat_id=777), 42: InputPeerUser(user_id=42, access_hash=1)}) + T = get_tools(tools) + make_manager(monkeypatch, client) + + sent = await T["message_send"](1, -777, "текст", 0, False) + assert sent["text"] == "текст" and sent["out"] is True + + rep = await T["message_reply"](1, -777, 12, "ответ") + assert rep["text"] == "ответ" + + ed = await T["message_edit"](1, -777, 12, "правка") + assert ed["text"] == "правка" + + de = await T["message_delete"](1, -777, [10], True) + assert de == {"status": "ok", "deleted": [10], "revoke": True} + + # пустой text / пустые ids → 422 данными + assert (await T["message_send"](1, -777, " ", 0, False))["error"] == 422 + assert (await T["message_delete"](1, -777, [], True))["error"] == 422 + + # чужой dialog → 404 (не найден на этом аккаунте) + nf = await T["message_send"](1, -999, "x", 0, False) + assert nf["error"] == 404 + + +@pytest.mark.asyncio +async def test_media_tools_smoke(monkeypatch, tmp_path): + conn = await setup(tmp_path, monkeypatch) + await insert_account(conn) + client = FakeClient( + by_id={777: [voice_message(10), text_message(12)], 778: [photo_message(11)]}, + entities={-777: InputPeerChat(chat_id=777), -778: InputPeerChat(chat_id=778)}) + T = get_tools(tools) + make_manager(monkeypatch, client) + + dm = await T["download_media"](1, -777, 10, 0) + assert dm["is_voice"] is True and dm["file_name"] == "voice.ogg" + assert dm["data_base64"] == base64.b64encode(b"mediabytes").decode("ascii") + + ph_dl = await T["download_media"](1, -778, 11, 0) + assert ph_dl["is_round"] is False and ph_dl["data_base64"] + + # файл больше лимита → 413 + small = await T["download_media"](1, -778, 11, 8) + assert small["error"] == 413 + + # сообщение без медиа → 404 + nomed = await T["download_media"](1, -777, 12, 0) + assert nomed["error"] == 404 + + up = await T["upload_file"](1, -777, base64.b64encode(b"doc").decode(), "", + "doc.txt", "подпись", False) + assert "media" in up + + voc = await T["upload_voice"](1, -777, base64.b64encode(b"ogg").decode(), "", 5, None) + assert voc["media"]["voice"] is True + + rnd = await T["upload_round"](1, -777, base64.b64encode(b"mp4").decode(), "", 10, 512) + assert rnd["media"]["round"] is True + + toolong = await T["upload_round"](1, -777, base64.b64encode(b"mp4").decode(), "", 61, 0) + assert toolong["error"] == 422 + + +@pytest.mark.asyncio +async def test_call_tools_smoke(monkeypatch, tmp_path): + conn = await setup(tmp_path, monkeypatch) + await insert_account(conn) + _calls.clear() + client = FakeClient( + entities={42: InputPeerUser(user_id=42, access_hash=1), + -777: InputPeerChat(chat_id=777)}) + T = get_tools(tools) + make_manager(monkeypatch, client) + + out = await T["call_start"](1, 42, False) + assert out["call_id"] == 555 and out["state"] == "ringing" + + st = await T["call_status"](555) + assert st["state"] == "ringing" + + disc = await T["call_discard"](555) + assert disc["state"] == "cancelled" + _calls.clear() + + # юзер не найден → 404 данными + nf = await T["call_start"](1, 999, False) + assert nf["error"] == 404 + # группе звонить нельзя → 422 + grp = await T["call_start"](1, -777, False) + assert grp["error"] == 422 + # неизвестный статус → 404 + unk = await T["call_status"](1) + assert unk["error"] == 404 + + +@pytest.mark.asyncio +async def test_entity_not_found_boundary(monkeypatch, tmp_path): + """«Диалог не найден» от Telethon (ValueError из кеша) → 404-данные.""" + conn = await setup(tmp_path, monkeypatch) + await insert_account(conn) + client = FakeClient(entities={}) + T = get_tools(tools) + make_manager(monkeypatch, client) + + async def get_input_entity_raises(x): + raise ValueError("Could not find the input entity for PeerChat") + + client.get_input_entity = get_input_entity_raises + out = await T["messages_history"](1, -888, 10, 0, 0) + assert out["error"] == 404 \ No newline at end of file