diff --git a/inkbox_codex/__init__.py b/inkbox_codex/__init__.py index d37641c..02b4e89 100644 --- a/inkbox_codex/__init__.py +++ b/inkbox_codex/__init__.py @@ -1,3 +1,3 @@ """Inkbox bridge for Codex — email, SMS, iMessage, and voice.""" -__version__ = "0.1.0" +__version__ = "0.2.5" diff --git a/inkbox_codex/config.py b/inkbox_codex/config.py index 9de7c9b..2a257ad 100644 --- a/inkbox_codex/config.py +++ b/inkbox_codex/config.py @@ -2,8 +2,10 @@ from __future__ import annotations +import importlib.metadata import os from dataclasses import dataclass, field +from functools import lru_cache from pathlib import Path from typing import Any, Dict, List @@ -16,6 +18,9 @@ # Empty means "do not override"; the Inkbox SDK owns its API default. INKBOX_BASE_URL_DEFAULT = "" INKBOX_WS_PATH = "/phone/media/ws" + +USER_AGENT_NAME = "inkbox-codex" +DISTRIBUTION_NAME = "codex-plugin" DEFAULT_HOST = "0.0.0.0" DEFAULT_PORT = 8767 DEFAULT_WEBHOOK_PATH = "/webhook" @@ -99,8 +104,22 @@ def inkbox_base_url_kwargs(base_url: str | None = None) -> Dict[str, str]: return {"base_url": normalized} if normalized else {} +@lru_cache(maxsize=1) +def plugin_user_agent() -> str: + """Identifies this plugin ahead of the SDK's own ``User-Agent`` token.""" + try: + version = importlib.metadata.version(DISTRIBUTION_NAME) + except importlib.metadata.PackageNotFoundError: + version = "unknown" + return f"{USER_AGENT_NAME}/{version}" + + def inkbox_client_kwargs(api_key: str, base_url: str | None = None) -> Dict[str, str]: - return {"api_key": api_key, **inkbox_base_url_kwargs(base_url)} + return { + "api_key": api_key, + "user_agent_prefix": plugin_user_agent(), + **inkbox_base_url_kwargs(base_url), + } def _read_realtime_config() -> RealtimeConfig: diff --git a/inkbox_codex/gateway.py b/inkbox_codex/gateway.py index f099421..0ef786b 100644 --- a/inkbox_codex/gateway.py +++ b/inkbox_codex/gateway.py @@ -93,6 +93,27 @@ logger = logging.getLogger(__name__) +def _webhook_mail_body(message: Dict[str, Any]) -> str: + """Body text carried by the webhook, with a notice when it is a prefix. + + Falls back to the 200-char snippet for payloads that carry no body. + """ + body = str(message.get("body") or "") + if not body.strip(): + return str(message.get("snippet") or "") + if str(message.get("body_state") or "") != "truncated": + return body + total = message.get("body_total_chars") + included = message.get("body_included_chars") + msg_id = str(message.get("id") or "") + counts = f"{included} of {total} characters" if total and included else "part" + return ( + f"{body}\n\n[inkbox: this email was too long to deliver in full. " + f"You are seeing {counts}." + + (f" Fetch email {msg_id} to read the rest.]" if msg_id else "]") + ) + + def _format_transcript(transcript: Any, limit: int = 30) -> str: """Render the last ``limit`` (role, text) turns as plain lines.""" rows = list(transcript or [])[-limit:] @@ -1418,7 +1439,11 @@ async def _fetch_mail_attachments(self, message: Dict[str, Any]) -> List[Dict[st return await download_media(items, prefix=f"mail-{msg_id}") def _fetch_mail_body(self, message: Dict[str, Any]) -> str: - # The webhook only carries a snippet; pull the full body when we can. + # message.received carries the body, so the common case needs no + # round-trip; only a truncated or absent body is worth fetching. + body = str(message.get("body") or "") + if body.strip() and str(message.get("body_state") or "") != "truncated": + return body try: detail = self._identity.get_message(str(message.get("id"))) for attr in ("body_text", "text_body", "body"): @@ -1426,8 +1451,8 @@ def _fetch_mail_body(self, message: Dict[str, Any]) -> str: if value: return str(value) except Exception: - logger.debug("[bridge] full-body fetch failed; using snippet", exc_info=True) - return str(message.get("snippet") or "") + logger.debug("[bridge] full-body fetch failed; using the webhook body", exc_info=True) + return _webhook_mail_body(message) async def _lookup_text_conversation_summary(self, conversation_id: str) -> Any: if not conversation_id: diff --git a/pyproject.toml b/pyproject.toml index bb2b7a1..ac34321 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "codex-plugin" -version = "0.1.4" +version = "0.2.5" description = "Inkbox bridge for Codex — talk to your coding agent over email, SMS, iMessage, and voice" requires-python = ">=3.11" dependencies = [ diff --git a/tests/test_gateway_inbound_mail_body.py b/tests/test_gateway_inbound_mail_body.py new file mode 100644 index 0000000..59c35ae --- /dev/null +++ b/tests/test_gateway_inbound_mail_body.py @@ -0,0 +1,185 @@ +"""Inbound email turns carry the full body, not the 200-char snippet. + +``message.received`` ships the body alongside the snippet, self-describing +when it had to abbreviate. A whole body needs no round-trip; a truncated or +absent one falls back to a fetch, and the snippet is the last resort. +""" + +import asyncio +import json +import types + +import pytest + +from inkbox_codex import gateway +from inkbox_codex.config import BridgeConfig + + +LONG_BODY = "Pricing details follow. " * 40 +SNIPPET = LONG_BODY[:200] + + +@pytest.fixture(autouse=True) +def fake_web(monkeypatch): + def json_response(payload): + return types.SimpleNamespace(text=json.dumps(payload), payload=payload) + + monkeypatch.setattr(gateway, "web", types.SimpleNamespace(json_response=json_response)) + + +class _FakeSession: + def __init__(self): + self.inbound = [] + + async def handle_inbound(self, text, mode, meta): + self.inbound.append((text, mode, meta)) + + +class _FakeSessions: + def __init__(self): + self.by_id = {} + + def get(self, chat_id): + return self.by_id.setdefault(chat_id, _FakeSession()) + + +class _RecordingIdentity: + """Stands in for the SDK identity; records full-body fetches.""" + + def __init__(self, detail=None, fails=False): + self.calls = [] + self._detail = detail + self._fails = fails + + def get_message(self, message_id): + self.calls.append(message_id) + if self._fails: + raise RuntimeError("unreachable") + return self._detail + + +def _gw(identity=None): + gw = gateway.InkboxGateway(BridgeConfig(require_signature=False, allow_all_users=True)) + gw.sessions = _FakeSessions() + gw._identity = identity or _RecordingIdentity() + return gw + + +def _envelope(**message_overrides): + message = { + "id": "mail-in-1", + "thread_id": "thread-1", + "from_address": "atlas@inkboxmail.com", + "subject": "Coordinating", + "snippet": SNIPPET, + "direction": "inbound", + } + message.update(message_overrides) + return {"data": {"message": message}} + + +# ── helper ─────────────────────────────────────────────────────────────── + + +def test_complete_body_is_used_verbatim(): + message = {"body": LONG_BODY, "body_state": "complete", "snippet": SNIPPET} + + assert gateway._webhook_mail_body(message) == LONG_BODY + + +def test_truncated_body_appends_a_notice_with_the_message_id(): + message = { + "id": "mail-in-9", + "body": LONG_BODY, + "body_state": "truncated", + "body_truncated": True, + "body_total_chars": 40_000, + "body_included_chars": len(LONG_BODY), + "snippet": SNIPPET, + } + + result = gateway._webhook_mail_body(message) + + assert result.startswith(LONG_BODY) + assert "too long to deliver in full" in result + assert f"{len(LONG_BODY)} of 40000 characters" in result + assert "mail-in-9" in result + + +def test_missing_body_falls_back_to_the_snippet(): + assert gateway._webhook_mail_body({"snippet": SNIPPET}) == SNIPPET + + +def test_unavailable_body_falls_back_to_the_snippet(): + message = {"body": "", "body_state": "unavailable", "snippet": SNIPPET} + + assert gateway._webhook_mail_body(message) == SNIPPET + + +# ── fetch policy ───────────────────────────────────────────────────────── + + +def test_complete_body_skips_the_fetch(): + identity = _RecordingIdentity() + gw = _gw(identity) + + asyncio.run(gw._on_mail_received(_envelope(body=LONG_BODY, body_state="complete"))) + + assert identity.calls == [] + body, mode, _ = gw.sessions.by_id[next(iter(gw.sessions.by_id))].inbound[0] + assert mode == "email" + assert LONG_BODY in body + + +def test_truncated_body_fetches_the_remainder(): + full = LONG_BODY + "and the rest of it." + identity = _RecordingIdentity(detail=types.SimpleNamespace(body_text=full)) + gw = _gw(identity) + + asyncio.run( + gw._on_mail_received( + _envelope( + body=LONG_BODY, + body_state="truncated", + body_truncated=True, + body_total_chars=len(full), + body_included_chars=len(LONG_BODY), + ) + ) + ) + + assert identity.calls == ["mail-in-1"] + body, _, _ = gw.sessions.by_id[next(iter(gw.sessions.by_id))].inbound[0] + assert body == full + + +def test_failed_fetch_keeps_the_truncated_body_and_notes_it(): + identity = _RecordingIdentity(fails=True) + gw = _gw(identity) + + asyncio.run( + gw._on_mail_received( + _envelope( + body=LONG_BODY, + body_state="truncated", + body_truncated=True, + body_total_chars=40_000, + body_included_chars=len(LONG_BODY), + ) + ) + ) + + body, _, _ = gw.sessions.by_id[next(iter(gw.sessions.by_id))].inbound[0] + assert LONG_BODY in body + assert "too long to deliver in full" in body + + +def test_absent_body_falls_back_to_the_fetch_then_the_snippet(): + identity = _RecordingIdentity(fails=True) + gw = _gw(identity) + + asyncio.run(gw._on_mail_received(_envelope())) + + assert identity.calls == ["mail-in-1"] + body, _, _ = gw.sessions.by_id[next(iter(gw.sessions.by_id))].inbound[0] + assert body == SNIPPET diff --git a/tests/test_user_agent.py b/tests/test_user_agent.py new file mode 100644 index 0000000..67ad48d --- /dev/null +++ b/tests/test_user_agent.py @@ -0,0 +1,33 @@ +"""The plugin identifies itself and its version in the SDK User-Agent.""" + +from inkbox_codex import config as config_mod + + +def test_user_agent_names_the_plugin_and_its_version(): + config_mod.plugin_user_agent.cache_clear() + + ua = config_mod.plugin_user_agent() + + assert ua.startswith("inkbox-codex/") + assert ua.split("/", 1)[1] + + +def test_client_kwargs_carry_the_prefix(): + kwargs = config_mod.inkbox_client_kwargs("ak_test") + + assert kwargs["api_key"] == "ak_test" + assert kwargs["user_agent_prefix"] == config_mod.plugin_user_agent() + + +def test_unknown_distribution_still_yields_a_token(monkeypatch): + import importlib.metadata + + def _missing(_name): + raise importlib.metadata.PackageNotFoundError + + monkeypatch.setattr(importlib.metadata, "version", _missing) + config_mod.plugin_user_agent.cache_clear() + + assert config_mod.plugin_user_agent() == "inkbox-codex/unknown" + + config_mod.plugin_user_agent.cache_clear()