diff --git a/src/familiar/comms.py b/src/familiar/comms.py new file mode 100644 index 0000000..3f7a2fe --- /dev/null +++ b/src/familiar/comms.py @@ -0,0 +1,124 @@ +"""Mail and calendar: read tools plus guarded write actions. + +Reads (mail headers, calendar events) follow the per-character pattern. Writes +(send mail, mark mail read, respond to an event) go through :func:`writes.guard`: +they require an explicit character, return a dry-run preview unless +``confirm=True``, and only execute when writes are enabled. +""" + +from __future__ import annotations + +from typing import Any + +from fastmcp import FastMCP + +from . import auth, writes +from .charutil import run_per_character, summarize +from .esi import client + + +# --------------------------------------------------------------------------- # +# Mail +# --------------------------------------------------------------------------- # +async def get_mail(character: str | int | None = None, limit: int = 20) -> dict[str, Any]: + """Recent mail headers for one character or all.""" + + async def fetch(token: str, cid: int) -> dict[str, Any]: + headers = await client().get(f"/characters/{cid}/mail/", token=token) or [] + return summarize(headers, limit) + + return await run_per_character(character, fetch) + + +async def read_mail(character: str | int, mail_id: int) -> dict[str, Any]: + """Full body of a single mail for the given character.""" + cid = auth.resolve_character(character) + token, _ = await auth.get_auth(cid) + return await client().get(f"/characters/{cid}/mail/{mail_id}/", token=token) + + +async def send_mail( + character: str | int, + recipients: list[dict[str, Any]], + subject: str, + body: str, + confirm: bool = False, +) -> dict[str, Any]: + """Send an EVE mail from a character. WRITE — guarded by write-safety. + + ``recipients`` is a list of ESI recipient dicts, e.g. + ``[{"recipient_id": 123, "recipient_type": "character"}]``. + """ + cid = auth.resolve_character(character) + payload = {"recipients": recipients, "subject": subject, "body": body} + + async def execute() -> Any: + token, _ = await auth.get_auth(cid) + return await client().post(f"/characters/{cid}/mail/", json=payload, token=token) + + return await writes.guard( + "send_mail", + {"character_id": cid, "subject": subject, "recipients": recipients}, + confirm, + execute, + ) + + +async def set_mail_read( + character: str | int, mail_id: int, read: bool = True, confirm: bool = False +) -> dict[str, Any]: + """Mark a mail read/unread. WRITE — guarded by write-safety.""" + cid = auth.resolve_character(character) + + async def execute() -> Any: + token, _ = await auth.get_auth(cid) + return await client().put( + f"/characters/{cid}/mail/{mail_id}/", json={"read": read}, token=token + ) + + return await writes.guard( + "set_mail_read", {"character_id": cid, "mail_id": mail_id, "read": read}, confirm, execute + ) + + +# --------------------------------------------------------------------------- # +# Calendar +# --------------------------------------------------------------------------- # +async def get_calendar(character: str | int | None = None, limit: int = 20) -> dict[str, Any]: + """Upcoming calendar events for one character or all.""" + + async def fetch(token: str, cid: int) -> dict[str, Any]: + events = await client().get(f"/characters/{cid}/calendar/", token=token) or [] + return summarize(events, limit) + + return await run_per_character(character, fetch) + + +async def respond_calendar_event( + character: str | int, event_id: int, response: str, confirm: bool = False +) -> dict[str, Any]: + """Respond to a calendar event (accepted/declined/tentative). WRITE.""" + cid = auth.resolve_character(character) + response = response if response in {"accepted", "declined", "tentative"} else "tentative" + + async def execute() -> Any: + token, _ = await auth.get_auth(cid) + return await client().put( + f"/characters/{cid}/calendar/{event_id}/", json={"response": response}, token=token + ) + + return await writes.guard( + "respond_calendar_event", + {"character_id": cid, "event_id": event_id, "response": response}, + confirm, + execute, + ) + + +TOOLS = [get_mail, read_mail, send_mail, set_mail_read, get_calendar, respond_calendar_event] + + +def register(mcp: FastMCP) -> None: + """Register the mail/calendar tools onto the given server.""" + for fn in TOOLS: + mcp.tool(fn) diff --git a/src/familiar/esi.py b/src/familiar/esi.py index 699391a..63f99ec 100644 --- a/src/familiar/esi.py +++ b/src/familiar/esi.py @@ -63,6 +63,18 @@ class EsiClient: ) -> Any: return await self._request("POST", path, params=params, json=json, token=token) + async def put( + self, + path: str, + json: Any | None = None, + params: dict[str, Any] | None = None, + token: str | None = None, + ) -> Any: + return await self._request("PUT", path, params=params, json=json, token=token) + + async def delete(self, path: str, token: str | None = None) -> Any: + return await self._request("DELETE", path, token=token) + async def get_all_pages( self, path: str, params: dict[str, Any] | None = None, token: str | None = None ) -> list[Any]: diff --git a/src/familiar/server.py b/src/familiar/server.py index af9d2ed..ab6788b 100644 --- a/src/familiar/server.py +++ b/src/familiar/server.py @@ -14,6 +14,7 @@ from fastmcp import FastMCP from . import ( __version__, account, + comms, corp, corp_economy, industry, @@ -83,3 +84,6 @@ corp_economy.register(mcp) # Phase 4: faction warfare, projects, freelance, activities, access (read-only). misc.register(mcp) + +# Phase 4: mail & calendar (read + guarded writes). +comms.register(mcp) diff --git a/tests/test_comms.py b/tests/test_comms.py new file mode 100644 index 0000000..46d910c --- /dev/null +++ b/tests/test_comms.py @@ -0,0 +1,77 @@ +"""Mail/calendar read tools and guarded write actions.""" + +import asyncio + +import httpx +import pytest + +from conftest import route +from familiar import auth, comms, writes +from familiar.server import mcp + +CHARS = [{"character_id": 99, "character_name": "Alpha", "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) + monkeypatch.setattr(auth, "resolve_character", lambda i: 99) + return CHARS + + +@pytest.fixture +def writes_on(tmp_path, monkeypatch): + monkeypatch.setenv("FAMILIAR_DATA_DIR", str(tmp_path)) + monkeypatch.setenv("FAMILIAR_ENABLE_WRITES", "true") + + +def test_get_mail_read(mock_esi, fake_auth): + mock_esi(lambda r: route(r, {"/mail/": [{"mail_id": 1, "subject": "hi"}]})) + res = asyncio.run(comms.get_mail()) + assert res["characters"][0]["count"] == 1 + + +def test_send_mail_dry_run_does_not_post(mock_esi, fake_auth): + posted = [] + mock_esi(lambda r: (posted.append(r), httpx.Response(201, json=1))[1]) + res = asyncio.run(comms.send_mail(99, [{"recipient_id": 1}], "s", "b")) + assert res["dry_run"] is True + assert not posted + + +def test_send_mail_disabled_raises(mock_esi, fake_auth, tmp_path, monkeypatch): + monkeypatch.setenv("FAMILIAR_DATA_DIR", str(tmp_path)) + monkeypatch.delenv("FAMILIAR_ENABLE_WRITES", raising=False) + mock_esi(lambda r: httpx.Response(201, json=1)) + with pytest.raises(writes.WriteDisabled): + asyncio.run(comms.send_mail(99, [{"recipient_id": 1}], "s", "b", confirm=True)) + + +def test_send_mail_executes_when_enabled(mock_esi, fake_auth, writes_on): + mock_esi(lambda r: httpx.Response(201, json=987654)) + res = asyncio.run(comms.send_mail(99, [{"recipient_id": 1}], "s", "b", confirm=True)) + assert res["executed"] is True + assert res["result"] == 987654 + + +def test_respond_calendar_validates_response(mock_esi, fake_auth, writes_on): + mock_esi(lambda r: httpx.Response(204, content=b"")) + res = asyncio.run(comms.respond_calendar_event(99, 5, "banana", confirm=True)) + assert res["action"] == "respond_calendar_event" + assert res["executed"] is True + + +def test_comms_tools_registered(): + tools = {t.name for t in asyncio.run(mcp.list_tools())} + assert { + "get_mail", + "send_mail", + "set_mail_read", + "get_calendar", + "respond_calendar_event", + } <= tools