From ca393e3f86bd1554a6cafa365dba07a8dc276a3a Mon Sep 17 00:00:00 2001 From: Latte Date: Sat, 27 Jun 2026 19:53:23 +0200 Subject: [PATCH] Phase 4: character profile/social reads (#31) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add read-only tools: loyalty points, standings, medals, titles, agents research, jump fatigue, notifications, chat channels — per character or aggregated. Closes #31. 90 tests, coverage 92%. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/familiar/profile.py | 96 +++++++++++++++++++++++++++++++++++++++++ src/familiar/server.py | 4 ++ tests/test_profile.py | 80 ++++++++++++++++++++++++++++++++++ 3 files changed, 180 insertions(+) create mode 100644 src/familiar/profile.py create mode 100644 tests/test_profile.py diff --git a/src/familiar/profile.py b/src/familiar/profile.py new file mode 100644 index 0000000..525a5a2 --- /dev/null +++ b/src/familiar/profile.py @@ -0,0 +1,96 @@ +"""Read-only character profile/social tools. + +Loyalty, standings, medals, titles, agents research, jump fatigue, +notifications, and chat channels — per character or aggregated. +""" + +from __future__ import annotations + +from typing import Any + +from fastmcp import FastMCP + +from .charutil import run_per_character, summarize +from .esi import client + + +def _list_tool(path: str): + """Build a per-character fetcher returning a count + sample of a list endpoint.""" + + async def tool(character: str | int | None = None, limit: int = 20) -> dict[str, Any]: + async def fetch(token: str, cid: int) -> dict[str, Any]: + items = await client().get(f"/characters/{cid}/{path}", token=token) or [] + return summarize(items, limit) + + return await run_per_character(character, fetch) + + return tool + + +async def get_loyalty_points(character: str | int | None = None, limit: int = 20) -> dict[str, Any]: + """Loyalty points per NPC corporation, for one character or all.""" + return await _list_tool("loyalty/points/")(character, limit) + + +async def get_standings(character: str | int | None = None, limit: int = 20) -> dict[str, Any]: + """Standings toward NPC entities, for one character or all.""" + return await _list_tool("standings/")(character, limit) + + +async def get_medals(character: str | int | None = None, limit: int = 20) -> dict[str, Any]: + """Awarded medals, for one character or all.""" + return await _list_tool("medals/")(character, limit) + + +async def get_titles(character: str | int | None = None, limit: int = 20) -> dict[str, Any]: + """Corp titles held, for one character or all.""" + return await _list_tool("titles/")(character, limit) + + +async def get_agents_research( + character: str | int | None = None, limit: int = 20 +) -> dict[str, Any]: + """Active R&D agent research, for one character or all.""" + return await _list_tool("agents_research/")(character, limit) + + +async def get_notifications(character: str | int | None = None, limit: int = 20) -> dict[str, Any]: + """Recent notifications, for one character or all.""" + return await _list_tool("notifications/")(character, limit) + + +async def get_chat_channels(character: str | int | None = None, limit: int = 20) -> dict[str, Any]: + """Owned/operated chat channels, for one character or all.""" + return await _list_tool("chat_channels/")(character, limit) + + +async def get_jump_fatigue(character: str | int | None = None) -> dict[str, Any]: + """Jump fatigue / clone-jump timers, for one character or all.""" + + async def fetch(token: str, cid: int) -> dict[str, Any]: + data = await client().get(f"/characters/{cid}/fatigue/", token=token) or {} + return { + "jump_fatigue_expire_date": data.get("jump_fatigue_expire_date"), + "last_jump_date": data.get("last_jump_date"), + "last_update_date": data.get("last_update_date"), + } + + return await run_per_character(character, fetch) + + +TOOLS = [ + get_loyalty_points, + get_standings, + get_medals, + get_titles, + get_agents_research, + get_notifications, + get_chat_channels, + get_jump_fatigue, +] + + +def register(mcp: FastMCP) -> None: + """Register the character profile tools onto the given server.""" + for fn in TOOLS: + mcp.tool(fn) diff --git a/src/familiar/server.py b/src/familiar/server.py index d74cf2b..d520c0f 100644 --- a/src/familiar/server.py +++ b/src/familiar/server.py @@ -17,6 +17,7 @@ from . import ( industry, killmails, market, + profile, structures, trade, universe, @@ -67,3 +68,6 @@ writes.register(mcp) # Phase 4: killmails + zKillboard (read-only). killmails.register(mcp) + +# Phase 4: character profile / social (read-only). +profile.register(mcp) diff --git a/tests/test_profile.py b/tests/test_profile.py new file mode 100644 index 0000000..f292d85 --- /dev/null +++ b/tests/test_profile.py @@ -0,0 +1,80 @@ +"""Character profile/social tools (auth & ESI mocked).""" + +import asyncio + +import pytest + +from conftest import route +from familiar import auth, profile +from familiar.server import mcp + +CHARS = [ + {"character_id": 99, "character_name": "Alpha", "scopes": []}, + {"character_id": 100, "character_name": "Beta", "scopes": []}, +] + + +@pytest.fixture +def fake_auth(monkeypatch): + monkeypatch.setattr(auth, "list_characters", lambda: list(CHARS)) + + async def get_auth(cid, **kw): + return f"token-{cid}", cid + + monkeypatch.setattr(auth, "get_auth", get_auth) + + def resolve(identifier): + for c in CHARS: + if ( + str(c["character_id"]) == str(identifier) + or (c["character_name"] or "").lower() == str(identifier).lower() + ): + return c["character_id"] + raise auth.NotAuthenticated("no such character") + + monkeypatch.setattr(auth, "resolve_character", resolve) + return CHARS + + +def test_loyalty_points_all(mock_esi, fake_auth): + lp = [{"corporation_id": 1000, "loyalty_points": 5000}] + mock_esi(lambda r: route(r, {"/loyalty/points/": lp})) + res = asyncio.run(profile.get_loyalty_points()) + assert len(res["characters"]) == 2 + assert res["characters"][0]["sample"][0]["loyalty_points"] == 5000 + + +def test_standings_single(mock_esi, fake_auth): + st = [{"from_id": 500001, "standing": 5.0}] + mock_esi(lambda r: route(r, {"/standings/": st})) + res = asyncio.run(profile.get_standings(character="Alpha")) + assert res["characters"][0]["count"] == 1 + + +def test_jump_fatigue(mock_esi, fake_auth): + data = {"last_jump_date": "2026-06-01T00:00:00Z", "jump_fatigue_expire_date": "2026-06-02"} + mock_esi(lambda r: route(r, {"/fatigue/": data})) + res = asyncio.run(profile.get_jump_fatigue(character=99)) + assert res["characters"][0]["last_jump_date"] == "2026-06-01T00:00:00Z" + + +def test_notifications(mock_esi, fake_auth): + notes = [{"notification_id": 1, "type": "x"}, {"notification_id": 2, "type": "y"}] + mock_esi(lambda r: route(r, {"/notifications/": notes})) + res = asyncio.run(profile.get_notifications(character=99, limit=1)) + assert res["characters"][0]["count"] == 2 + assert len(res["characters"][0]["sample"]) == 1 + + +def test_profile_tools_registered(): + tools = {t.name for t in asyncio.run(mcp.list_tools())} + assert { + "get_loyalty_points", + "get_standings", + "get_medals", + "get_titles", + "get_agents_research", + "get_notifications", + "get_chat_channels", + "get_jump_fatigue", + } <= tools