feat(pheby): accept inbound chat attachments
This commit is contained in:
@@ -18,6 +18,7 @@ pheby-attachments/
|
|||||||
pheby_conversations.json
|
pheby_conversations.json
|
||||||
|
|
||||||
# Editors / OS
|
# Editors / OS
|
||||||
|
.hermes/
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
+37
-3
@@ -1,7 +1,7 @@
|
|||||||
# Pheby Protocol v1 — Specification
|
# Pheby Protocol v1 — Specification
|
||||||
|
|
||||||
JSON messages over WebSocket, plus one authenticated HTTPS endpoint for
|
JSON messages over WebSocket, plus authenticated HTTPS endpoints for
|
||||||
attachment downloads. Every message (both directions) carries a `"type"`.
|
attachment uploads and downloads. Every message (both directions) carries a `"type"`.
|
||||||
Client→server requests MAY carry a `"request_id"` (any client-chosen string);
|
Client→server requests MAY carry a `"request_id"` (any client-chosen string);
|
||||||
the direct reply echoes it. Server→client events are broadcast to all
|
the direct reply echoes it. Server→client events are broadcast to all
|
||||||
authenticated connections (single-user app — typically one client).
|
authenticated connections (single-user app — typically one client).
|
||||||
@@ -145,6 +145,15 @@ message (the agent sees the whole transcript).
|
|||||||
→ { "type": "chat.send", "conversation_id": "a1b2…", "text": "What's the weather?", "request_id": "r6" }
|
→ { "type": "chat.send", "conversation_id": "a1b2…", "text": "What's the weather?", "request_id": "r6" }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`attachment_ids` is an optional list of up to 10 distinct inbound upload IDs
|
||||||
|
from `POST /attachments` in the **same conversation**. The adapter rejects
|
||||||
|
unknown, already-sent, or cross-conversation IDs. Upload first, then include
|
||||||
|
all IDs in the single `chat.send`; a rejected active run leaves them retryable.
|
||||||
|
The `text` field is still required and nonblank (for file-only sends, provide
|
||||||
|
a short caption). A successful send anchors the attachments to the user turn
|
||||||
|
and broadcasts `attachment.added`. Small text files are included in agent
|
||||||
|
context; other files remain available to the agent as local media paths.
|
||||||
|
|
||||||
### Server → Client run lifecycle
|
### Server → Client run lifecycle
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -316,7 +325,31 @@ metadata so it can be restored after the gateway restarts.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Attachments (agent → client deliverables)
|
## Attachments (both directions)
|
||||||
|
|
||||||
|
### Client → agent uploads
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /attachments?conversation_id=a1b2…&filename=notes.md
|
||||||
|
Authorization: Bearer <PHEBY_SECRET>
|
||||||
|
Content-Type: text/markdown
|
||||||
|
|
||||||
|
# Raw file bytes, not multipart or JSON
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns `201 {"attachment": {"attachment_id": "<32 hex chars>", ...}}`.
|
||||||
|
The descriptor contains `direction: "inbound"`, `message_id: null`, and the
|
||||||
|
same fields as agent deliverables below. An upload is **not** broadcast or
|
||||||
|
listed in conversation history until `chat.send` successfully claims it;
|
||||||
|
an unused upload expires with normal retention. Limit: 64 MiB per file.
|
||||||
|
Missing credentials → 401; bad conversation ID or empty body → 400;
|
||||||
|
oversize body → 413. The file picker may select up to 10 files per message.
|
||||||
|
The server keeps only registered copies in adapter-owned storage and never
|
||||||
|
trusts a client-provided path. Markdown and other small `text/*` files
|
||||||
|
(up to 100 KiB) are inlined into the agent's turn; the conversation history
|
||||||
|
returns just the user's original message text.
|
||||||
|
|
||||||
|
### Agent → client deliverables
|
||||||
|
|
||||||
When the agent produces a file (image, document, audio, video… via Hermes's
|
When the agent produces a file (image, document, audio, video… via Hermes's
|
||||||
normal `MEDIA:` deliverable pipeline), the server copies it into
|
normal `MEDIA:` deliverable pipeline), the server copies it into
|
||||||
@@ -332,6 +365,7 @@ adapter-managed storage and broadcasts:
|
|||||||
"size": 48213,
|
"size": 48213,
|
||||||
"kind": "image" | "voice" | "video" | "audio" | "document",
|
"kind": "image" | "voice" | "video" | "audio" | "document",
|
||||||
"inline_image": false,
|
"inline_image": false,
|
||||||
|
"direction": "outbound",
|
||||||
"conversation_id": "a1b2…",
|
"conversation_id": "a1b2…",
|
||||||
"message_id": "draft-8c1f…" | null,
|
"message_id": "draft-8c1f…" | null,
|
||||||
"created_at": "2026-09-02T18:30:00+00:00",
|
"created_at": "2026-09-02T18:30:00+00:00",
|
||||||
|
|||||||
@@ -179,6 +179,7 @@ class AttachmentStore:
|
|||||||
"kind": kind,
|
"kind": kind,
|
||||||
"conversation_id": conversation_id,
|
"conversation_id": conversation_id,
|
||||||
"message_id": message_id,
|
"message_id": message_id,
|
||||||
|
"direction": "outbound",
|
||||||
"created_at": protocol.now_iso(),
|
"created_at": protocol.now_iso(),
|
||||||
"created_epoch": time.time(),
|
"created_epoch": time.time(),
|
||||||
"retention_days": self._retention_days,
|
"retention_days": self._retention_days,
|
||||||
@@ -197,6 +198,83 @@ class AttachmentStore:
|
|||||||
attachment_id, kind, size, conversation_id)
|
attachment_id, kind, size, conversation_id)
|
||||||
return self.describe(attachment_id)
|
return self.describe(attachment_id)
|
||||||
|
|
||||||
|
async def register_bytes(
|
||||||
|
self,
|
||||||
|
data: bytes,
|
||||||
|
*,
|
||||||
|
conversation_id: str,
|
||||||
|
filename: str,
|
||||||
|
mime_type: Optional[str] = None,
|
||||||
|
message_id: Optional[str] = None,
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Register an *inbound* upload: raw client bytes → adapter storage.
|
||||||
|
|
||||||
|
Returns the attachment descriptor dict, or ``None`` on failure.
|
||||||
|
"""
|
||||||
|
fname = self._sanitize_filename(filename)
|
||||||
|
attachment_id = protocol.new_id()
|
||||||
|
mime = (mime_type or "").strip()
|
||||||
|
if mime in ("", "application/octet-stream"):
|
||||||
|
mime = guess_mime(fname)
|
||||||
|
if not isinstance(mime, str) or "/" not in mime or len(mime) > 120:
|
||||||
|
mime = "application/octet-stream"
|
||||||
|
is_image = mime.startswith(IMAGE_MIME_PREFIXES) or (
|
||||||
|
Path(fname).suffix.lower() in IMAGE_EXTS)
|
||||||
|
if Path(fname).suffix.lower() in VIDEO_EXTS:
|
||||||
|
kind = "video"
|
||||||
|
elif Path(fname).suffix.lower() in AUDIO_EXTS:
|
||||||
|
kind = "audio"
|
||||||
|
elif is_image:
|
||||||
|
kind = "image"
|
||||||
|
else:
|
||||||
|
kind = "document"
|
||||||
|
size = len(data)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with self._lock:
|
||||||
|
self._load_index()
|
||||||
|
blob = self._blob_path(attachment_id, fname)
|
||||||
|
blob.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
await asyncio.to_thread(blob.write_bytes, data)
|
||||||
|
meta: Dict[str, Any] = {
|
||||||
|
"attachment_id": attachment_id,
|
||||||
|
"filename": fname,
|
||||||
|
"mime_type": mime,
|
||||||
|
"size": size,
|
||||||
|
"kind": kind,
|
||||||
|
"conversation_id": conversation_id,
|
||||||
|
"message_id": message_id,
|
||||||
|
"direction": "inbound",
|
||||||
|
"created_at": protocol.now_iso(),
|
||||||
|
"created_epoch": time.time(),
|
||||||
|
"retention_days": self._retention_days,
|
||||||
|
"blob": blob.name,
|
||||||
|
"blob_subdir": blob.parent.name,
|
||||||
|
}
|
||||||
|
self._meta[attachment_id] = meta
|
||||||
|
self._save_index()
|
||||||
|
except Exception:
|
||||||
|
logger.error("[pheby] inbound attachment registration failed",
|
||||||
|
exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"[pheby] inbound attachment registered: id=%s kind=%s size=%d "
|
||||||
|
"conv=%s", attachment_id, kind, size, conversation_id)
|
||||||
|
return self.describe(attachment_id)
|
||||||
|
|
||||||
|
def anchor_message(self, attachment_id: str, message_id: str) -> None:
|
||||||
|
"""Attach *message_id* to a registered attachment (thread anchoring)."""
|
||||||
|
self._load_index()
|
||||||
|
meta = self._meta.get(attachment_id)
|
||||||
|
if meta is not None:
|
||||||
|
meta["message_id"] = str(message_id)
|
||||||
|
self._save_index()
|
||||||
|
|
||||||
|
def get_blob_path(self, attachment_id: str) -> Optional[Path]:
|
||||||
|
"""Filesystem path for an attachment (server-side use only)."""
|
||||||
|
return self.resolve_blob(attachment_id)
|
||||||
|
|
||||||
# ── lookup / download ────────────────────────────────────────────────
|
# ── lookup / download ────────────────────────────────────────────────
|
||||||
def describe(self, attachment_id: str) -> Optional[Dict[str, Any]]:
|
def describe(self, attachment_id: str) -> Optional[Dict[str, Any]]:
|
||||||
"""Public descriptor for an attachment (no server paths)."""
|
"""Public descriptor for an attachment (no server paths)."""
|
||||||
@@ -210,6 +288,7 @@ class AttachmentStore:
|
|||||||
"size": int(meta.get("size", 0)),
|
"size": int(meta.get("size", 0)),
|
||||||
"kind": meta.get("kind", "document"),
|
"kind": meta.get("kind", "document"),
|
||||||
"inline_image": meta.get("kind") == "image",
|
"inline_image": meta.get("kind") == "image",
|
||||||
|
"direction": meta.get("direction", "outbound"),
|
||||||
"conversation_id": meta.get("conversation_id"),
|
"conversation_id": meta.get("conversation_id"),
|
||||||
"message_id": meta.get("message_id"),
|
"message_id": meta.get("message_id"),
|
||||||
"created_at": meta.get("created_at"),
|
"created_at": meta.get("created_at"),
|
||||||
@@ -261,7 +340,8 @@ class AttachmentStore:
|
|||||||
out = []
|
out = []
|
||||||
for aid in list(self._meta):
|
for aid in list(self._meta):
|
||||||
desc = self.describe(aid)
|
desc = self.describe(aid)
|
||||||
if desc and desc.get("conversation_id") == conversation_id:
|
if desc and desc.get("conversation_id") == conversation_id and (
|
||||||
|
desc.get("direction") != "inbound" or desc.get("message_id")):
|
||||||
out.append(desc)
|
out.append(desc)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|||||||
@@ -225,6 +225,16 @@ def _iso(value: Any) -> Optional[str]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _display_user_text(text: str) -> str:
|
||||||
|
"""Hide agent-only inline file context from the user-facing transcript."""
|
||||||
|
start = "[Pheby user message]\n"
|
||||||
|
end = "\n[/Pheby user message]"
|
||||||
|
if start not in text:
|
||||||
|
return text
|
||||||
|
body = text.split(start, 1)[1]
|
||||||
|
return body.split(end, 1)[0] if end in body else text
|
||||||
|
|
||||||
|
|
||||||
async def conversation_history(conversation_id: str, limit: int
|
async def conversation_history(conversation_id: str, limit: int
|
||||||
) -> Tuple[List[Dict[str, Any]], bool]:
|
) -> Tuple[List[Dict[str, Any]], bool]:
|
||||||
"""Load transcript rows for a conversation from Hermes state.db.
|
"""Load transcript rows for a conversation from Hermes state.db.
|
||||||
@@ -259,6 +269,8 @@ async def conversation_history(conversation_id: str, limit: int
|
|||||||
continue
|
continue
|
||||||
content = row.get("content")
|
content = row.get("content")
|
||||||
text = content if isinstance(content, str) else str(content or "")
|
text = content if isinstance(content, str) else str(content or "")
|
||||||
|
if role == "user":
|
||||||
|
text = _display_user_text(text)
|
||||||
# Tool-call rows can surface as assistant rows with empty
|
# Tool-call rows can surface as assistant rows with empty
|
||||||
# content; skip empties so the client transcript stays clean.
|
# content; skip empties so the client transcript stays clean.
|
||||||
if not text.strip() and role == "assistant":
|
if not text.strip() and role == "assistant":
|
||||||
@@ -374,12 +386,18 @@ async def delete_conversation(conversation_id: str) -> bool:
|
|||||||
# Chat runs
|
# Chat runs
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
async def send_chat(server: Any, conversation_id: str, text: str,
|
async def send_chat(server: Any, conversation_id: str, text: str,
|
||||||
client: Any, request_id: Optional[str]) -> None:
|
client: Any, request_id: Optional[str],
|
||||||
|
attachment_ids: Optional[List[str]] = None) -> None:
|
||||||
"""Deliver a user message into the Hermes gateway for this conversation.
|
"""Deliver a user message into the Hermes gateway for this conversation.
|
||||||
|
|
||||||
The gateway's full pipeline (auth, sessions, tools, approvals, clarify,
|
The gateway's full pipeline (auth, sessions, tools, approvals, clarify,
|
||||||
deliverables, streaming) runs on the adapter's message handler. Pheby
|
deliverables, streaming) runs on the adapter's message handler. Pheby
|
||||||
adds nothing to the agent loop.
|
adds nothing to the agent loop.
|
||||||
|
|
||||||
|
*attachment_ids* (optional) reference inbound uploads already registered
|
||||||
|
in ``server.store``; they are anchored to the user message, handed to
|
||||||
|
Hermes as ``media_urls``/``media_types`` (tool-accessible local files),
|
||||||
|
and small text files are additionally inlined into the message text.
|
||||||
"""
|
"""
|
||||||
adapter = _current_adapter()
|
adapter = _current_adapter()
|
||||||
if adapter is None or not hasattr(adapter, "handle_message"):
|
if adapter is None or not hasattr(adapter, "handle_message"):
|
||||||
@@ -392,13 +410,68 @@ async def send_chat(server: Any, conversation_id: str, text: str,
|
|||||||
|
|
||||||
run_id = uuid.uuid4().hex[:16]
|
run_id = uuid.uuid4().hex[:16]
|
||||||
source = _source_for(conversation_id)
|
source = _source_for(conversation_id)
|
||||||
|
message_id = uuid.uuid4().hex[:12]
|
||||||
|
|
||||||
|
# ── inbound attachments ────────────────────────────────────────────────
|
||||||
|
media_urls: List[str] = []
|
||||||
|
media_types: List[str] = []
|
||||||
|
media_text_inlined: List[bool] = []
|
||||||
|
inline_blocks: List[str] = []
|
||||||
|
attachment_descs: List[Dict[str, Any]] = []
|
||||||
|
for aid in attachment_ids or []:
|
||||||
|
desc = server.store.describe(aid)
|
||||||
|
if desc is None or desc.get("conversation_id") != conversation_id or \
|
||||||
|
desc.get("direction") != "inbound" or desc.get("message_id"):
|
||||||
|
await client.send_json(proto.error_event(
|
||||||
|
proto.ERR_NOT_FOUND,
|
||||||
|
"Unknown or already sent attachment for this conversation", request_id))
|
||||||
|
return
|
||||||
|
attachment_descs.append(desc)
|
||||||
|
for desc in attachment_descs:
|
||||||
|
aid = desc["attachment_id"]
|
||||||
|
blob = server.store.get_blob_path(aid)
|
||||||
|
if blob is None:
|
||||||
|
await client.send_json(proto.error_event(
|
||||||
|
proto.ERR_NOT_FOUND, "Attachment unavailable", request_id))
|
||||||
|
return
|
||||||
|
media_urls.append(str(blob))
|
||||||
|
mime = str(desc.get("mime_type", "application/octet-stream"))
|
||||||
|
media_types.append(mime)
|
||||||
|
# Small text files become part of the message text so the model
|
||||||
|
# reads them directly without a tool round-trip.
|
||||||
|
fname = str(desc.get("filename", "file"))
|
||||||
|
inlined = False
|
||||||
|
if mime.startswith("text/") and int(desc.get("size", 0)) <= \
|
||||||
|
proto.INLINE_TEXT_BYTES:
|
||||||
|
try:
|
||||||
|
content = blob.read_text(encoding="utf-8", errors="replace")
|
||||||
|
inline_blocks.append(
|
||||||
|
f"Attached file: {fname}\n```{fname.rsplit('.', 1)[-1]}\n"
|
||||||
|
f"{content}\n```")
|
||||||
|
inlined = True
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
media_text_inlined.append(inlined)
|
||||||
|
|
||||||
|
effective_text = text
|
||||||
|
if attachment_descs:
|
||||||
|
effective_text = f"[Pheby user message]\n{text}\n[/Pheby user message]"
|
||||||
|
if inline_blocks:
|
||||||
|
effective_text += "\n\n" + "\n\n".join(inline_blocks)
|
||||||
|
|
||||||
from gateway.platforms.base import MessageEvent, MessageType
|
from gateway.platforms.base import MessageEvent, MessageType
|
||||||
event = MessageEvent(
|
event = MessageEvent(
|
||||||
text=text,
|
text=effective_text,
|
||||||
message_type=MessageType.TEXT,
|
message_type=(MessageType.PHOTO if media_types and
|
||||||
|
all(t.startswith("image/") for t in media_types)
|
||||||
|
else (MessageType.DOCUMENT if media_types
|
||||||
|
else MessageType.TEXT)),
|
||||||
source=source,
|
source=source,
|
||||||
message_id=uuid.uuid4().hex[:12],
|
message_id=message_id,
|
||||||
metadata={"pheby_run_id": run_id},
|
metadata={"pheby_run_id": run_id},
|
||||||
|
media_urls=media_urls,
|
||||||
|
media_types=media_types,
|
||||||
|
media_text_inlined=media_text_inlined,
|
||||||
)
|
)
|
||||||
|
|
||||||
with _RUN_LOCK:
|
with _RUN_LOCK:
|
||||||
@@ -438,6 +511,14 @@ async def send_chat(server: Any, conversation_id: str, text: str,
|
|||||||
# returns quickly; the eventual reply arrives through adapter.send().
|
# returns quickly; the eventual reply arrives through adapter.send().
|
||||||
try:
|
try:
|
||||||
await adapter.handle_message(event)
|
await adapter.handle_message(event)
|
||||||
|
for desc in attachment_descs:
|
||||||
|
aid = desc["attachment_id"]
|
||||||
|
server.store.anchor_message(aid, message_id)
|
||||||
|
await server.broadcast({
|
||||||
|
"type": proto.S_ATTACHMENT_ADDED,
|
||||||
|
"conversation_id": conversation_id,
|
||||||
|
"attachment": server.store.describe(aid),
|
||||||
|
})
|
||||||
except Exception:
|
except Exception:
|
||||||
with _RUN_LOCK:
|
with _RUN_LOCK:
|
||||||
current = _ACTIVE_RUNS.get(conversation_id)
|
current = _ACTIVE_RUNS.get(conversation_id)
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ MAX_HISTORY_MESSAGES = 500 # per conversation.open fetch cap
|
|||||||
AUTH_TIMEOUT_SECONDS = 10.0 # hello must arrive within this window
|
AUTH_TIMEOUT_SECONDS = 10.0 # hello must arrive within this window
|
||||||
AUTH_FAILURE_LOCKOUT_SECONDS = 60.0 # repeated auth failures lock the source
|
AUTH_FAILURE_LOCKOUT_SECONDS = 60.0 # repeated auth failures lock the source
|
||||||
AUTH_FAILURE_THRESHOLD = 5 # failures before lockout
|
AUTH_FAILURE_THRESHOLD = 5 # failures before lockout
|
||||||
|
MAX_UPLOAD_BYTES = 64 * 1024 * 1024 # inbound attachment upload cap
|
||||||
|
INLINE_TEXT_BYTES = 100 * 1024 # text/* files below this are inlined
|
||||||
|
|
||||||
# ── Error codes (machine-readable) ───────────────────────────────────────────
|
# ── Error codes (machine-readable) ───────────────────────────────────────────
|
||||||
ERR_UNAUTHORIZED = "unauthorized"
|
ERR_UNAUTHORIZED = "unauthorized"
|
||||||
|
|||||||
+82
-2
@@ -61,11 +61,13 @@ class PhebyServer:
|
|||||||
async def start(self) -> bool:
|
async def start(self) -> bool:
|
||||||
from aiohttp import web as _web # local import keeps import light
|
from aiohttp import web as _web # local import keeps import light
|
||||||
self.store.hydrate_legacy_meta()
|
self.store.hydrate_legacy_meta()
|
||||||
app = _web.Application(client_max_size=proto.MAX_WS_MESSAGE_BYTES)
|
app = _web.Application(client_max_size=max(proto.MAX_UPLOAD_BYTES,
|
||||||
|
proto.MAX_WS_MESSAGE_BYTES))
|
||||||
app.router.add_get("/health", self._handle_health)
|
app.router.add_get("/health", self._handle_health)
|
||||||
app.router.add_get("/ws", self._handle_ws)
|
app.router.add_get("/ws", self._handle_ws)
|
||||||
app.router.add_get("/attachments/{attachment_id}",
|
app.router.add_get("/attachments/{attachment_id}",
|
||||||
self._handle_attachment_download)
|
self._handle_attachment_download)
|
||||||
|
app.router.add_post("/attachments", self._handle_attachment_upload)
|
||||||
self._app = app
|
self._app = app
|
||||||
self._runner = web.AppRunner(app, access_log=None)
|
self._runner = web.AppRunner(app, access_log=None)
|
||||||
await self._runner.setup()
|
await self._runner.setup()
|
||||||
@@ -171,6 +173,57 @@ class PhebyServer:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def _handle_attachment_upload(
|
||||||
|
self, request: web.Request) -> web.Response:
|
||||||
|
"""Inbound attachment upload: raw body → adapter storage.
|
||||||
|
|
||||||
|
The descriptor is returned only to the uploader. Other clients see
|
||||||
|
the attachment after chat.send successfully claims it.
|
||||||
|
"""
|
||||||
|
if not self._check_http_secret(request):
|
||||||
|
return web.json_response(
|
||||||
|
{"error": {"code": proto.ERR_UNAUTHORIZED,
|
||||||
|
"message": "Authentication required"}},
|
||||||
|
status=401)
|
||||||
|
conversation_id = request.query.get("conversation_id", "")
|
||||||
|
if not ConversationRouter.is_valid_conversation_id(conversation_id):
|
||||||
|
return web.json_response(
|
||||||
|
{"error": {"code": proto.ERR_BAD_REQUEST,
|
||||||
|
"message": "Invalid conversation_id"}},
|
||||||
|
status=400)
|
||||||
|
declared = request.content_length
|
||||||
|
if declared is not None and declared > proto.MAX_UPLOAD_BYTES:
|
||||||
|
return web.json_response(
|
||||||
|
{"error": {"code": proto.ERR_TOO_LARGE,
|
||||||
|
"message": f"Upload exceeds "
|
||||||
|
f"{proto.MAX_UPLOAD_BYTES} bytes"}},
|
||||||
|
status=413)
|
||||||
|
data = await request.content.read(proto.MAX_UPLOAD_BYTES + 1)
|
||||||
|
if len(data) > proto.MAX_UPLOAD_BYTES:
|
||||||
|
return web.json_response(
|
||||||
|
{"error": {"code": proto.ERR_TOO_LARGE,
|
||||||
|
"message": f"Upload exceeds "
|
||||||
|
f"{proto.MAX_UPLOAD_BYTES} bytes"}},
|
||||||
|
status=413)
|
||||||
|
if not data:
|
||||||
|
return web.json_response(
|
||||||
|
{"error": {"code": proto.ERR_BAD_REQUEST,
|
||||||
|
"message": "Empty upload body"}},
|
||||||
|
status=400)
|
||||||
|
filename = request.query.get("filename") or "file.bin"
|
||||||
|
mime = request.headers.get("Content-Type", "").split(";")[0].strip()
|
||||||
|
desc = await self.store.register_bytes(
|
||||||
|
data, conversation_id=conversation_id, filename=filename,
|
||||||
|
mime_type=mime or None)
|
||||||
|
if desc is None:
|
||||||
|
return web.json_response(
|
||||||
|
{"error": {"code": proto.ERR_INTERNAL,
|
||||||
|
"message": "Attachment registration failed"}},
|
||||||
|
status=500)
|
||||||
|
logger.info("[pheby] attachment upload: id=%s bytes=%d conv=%s",
|
||||||
|
desc["attachment_id"], desc["size"], conversation_id)
|
||||||
|
return web.json_response({"attachment": desc}, status=201)
|
||||||
|
|
||||||
# ── WebSocket handler ────────────────────────────────────────────────
|
# ── WebSocket handler ────────────────────────────────────────────────
|
||||||
async def _handle_ws(self, request: web.Request) -> web.WebSocketResponse:
|
async def _handle_ws(self, request: web.Request) -> web.WebSocketResponse:
|
||||||
# Bind server/adapter identity to THIS task's context. ContextVars set
|
# Bind server/adapter identity to THIS task's context. ContextVars set
|
||||||
@@ -468,8 +521,35 @@ class PhebyServer:
|
|||||||
proto.ERR_TOO_LARGE,
|
proto.ERR_TOO_LARGE,
|
||||||
f"text exceeds {proto.MAX_TEXT_CHARS} chars", request_id))
|
f"text exceeds {proto.MAX_TEXT_CHARS} chars", request_id))
|
||||||
return
|
return
|
||||||
|
raw_ids = message.get("attachment_ids")
|
||||||
|
attachment_ids: List[str] = []
|
||||||
|
if raw_ids is not None:
|
||||||
|
if not isinstance(raw_ids, list) or \
|
||||||
|
not all(isinstance(x, str) for x in raw_ids) or \
|
||||||
|
len(raw_ids) > 10 or len(raw_ids) != len(set(raw_ids)):
|
||||||
|
await client.send_json(proto.error_event(
|
||||||
|
proto.ERR_BAD_REQUEST,
|
||||||
|
"attachment_ids must be at most 10 unique ids",
|
||||||
|
request_id))
|
||||||
|
return
|
||||||
|
for aid in raw_ids:
|
||||||
|
desc = self.store.describe(aid)
|
||||||
|
if desc is None or \
|
||||||
|
desc.get("conversation_id") != conversation_id or \
|
||||||
|
desc.get("direction") != "inbound":
|
||||||
|
await client.send_json(proto.error_event(
|
||||||
|
proto.ERR_NOT_FOUND,
|
||||||
|
"Unknown attachment for this conversation", request_id))
|
||||||
|
return
|
||||||
|
if desc.get("message_id"):
|
||||||
|
await client.send_json(proto.error_event(
|
||||||
|
proto.ERR_BAD_REQUEST,
|
||||||
|
"Attachment already belongs to a message", request_id))
|
||||||
|
return
|
||||||
|
attachment_ids.append(aid)
|
||||||
await self.bridge.send_chat(
|
await self.bridge.send_chat(
|
||||||
self, conversation_id, text, client, request_id)
|
self, conversation_id, text, client, request_id,
|
||||||
|
attachment_ids=attachment_ids)
|
||||||
|
|
||||||
async def _handle_run_cancel(self, client, message, request_id):
|
async def _handle_run_cancel(self, client, message, request_id):
|
||||||
conversation_id = str(message.get("conversation_id", ""))
|
conversation_id = str(message.get("conversation_id", ""))
|
||||||
|
|||||||
@@ -0,0 +1,318 @@
|
|||||||
|
"""Inbound attachment tests: upload route, anchoring, chat.send integration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from pheby import protocol as proto
|
||||||
|
from pheby.attachments import AttachmentStore
|
||||||
|
from pheby.server import PhebyServer
|
||||||
|
|
||||||
|
from test_pheby import FakeClientConnection, make_server
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear_bridge_run_state():
|
||||||
|
"""Reset hermes_bridge global run state between tests."""
|
||||||
|
from pheby import hermes_bridge as hb
|
||||||
|
yield
|
||||||
|
hb._ACTIVE_RUNS.clear()
|
||||||
|
hb._TOOL_EVENTS.clear()
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# Store: register_bytes / anchor / describe
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class TestRegisterBytes:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_registers_and_describes_inbound(self, tmp_path):
|
||||||
|
store = AttachmentStore(root=tmp_path / "att")
|
||||||
|
desc = await store.register_bytes(
|
||||||
|
b"# hello\nworld\n",
|
||||||
|
conversation_id="c1",
|
||||||
|
filename="notes.md",
|
||||||
|
mime_type="text/markdown",
|
||||||
|
)
|
||||||
|
assert desc is not None
|
||||||
|
assert desc["direction"] == "inbound"
|
||||||
|
assert desc["filename"] == "notes.md"
|
||||||
|
assert desc["kind"] == "document"
|
||||||
|
assert desc["mime_type"] == "text/markdown"
|
||||||
|
assert desc["size"] == len(b"# hello\nworld\n")
|
||||||
|
assert desc["message_id"] is None
|
||||||
|
# Blob is actually on disk with exact bytes.
|
||||||
|
blob = store.get_blob_path(desc["attachment_id"])
|
||||||
|
assert blob is not None and blob.read_bytes() == b"# hello\nworld\n"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_image_ext_becomes_image_kind(self, tmp_path):
|
||||||
|
store = AttachmentStore(root=tmp_path / "att")
|
||||||
|
desc = await store.register_bytes(
|
||||||
|
b"\x89PNG\r\n", conversation_id="c1", filename="pic.png")
|
||||||
|
assert desc["kind"] == "image"
|
||||||
|
assert desc["inline_image"] is True
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bad_mime_falls_back(self, tmp_path):
|
||||||
|
store = AttachmentStore(root=tmp_path / "att")
|
||||||
|
desc = await store.register_bytes(
|
||||||
|
b"x", conversation_id="c1", filename="f.bin",
|
||||||
|
mime_type="not-a-mime")
|
||||||
|
assert desc["mime_type"] == "application/octet-stream"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_generic_android_mime_uses_markdown_extension(self, tmp_path):
|
||||||
|
store = AttachmentStore(root=tmp_path / "att")
|
||||||
|
desc = await store.register_bytes(
|
||||||
|
b"# context\n", conversation_id="c1", filename="notes.md",
|
||||||
|
mime_type="application/octet-stream")
|
||||||
|
assert desc["mime_type"] == "text/markdown"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_anchor_persists(self, tmp_path):
|
||||||
|
root = tmp_path / "att"
|
||||||
|
store = AttachmentStore(root=root)
|
||||||
|
desc = await store.register_bytes(
|
||||||
|
b"x", conversation_id="c1", filename="f.bin")
|
||||||
|
store.anchor_message(desc["attachment_id"], "msg123")
|
||||||
|
store2 = AttachmentStore(root=root)
|
||||||
|
store2._loaded = False
|
||||||
|
store2._load_index()
|
||||||
|
d2 = store2.describe(desc["attachment_id"])
|
||||||
|
assert d2["message_id"] == "msg123"
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# HTTP upload route
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class TestUploadRoute:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_upload_requires_auth(self, tmp_path):
|
||||||
|
aiohttp = pytest.importorskip("aiohttp")
|
||||||
|
server = make_server(tmp_path)
|
||||||
|
server.config.port = 0
|
||||||
|
assert await server.start()
|
||||||
|
try:
|
||||||
|
port = server._site._server.sockets[0].getsockname()[1]
|
||||||
|
async with aiohttp.ClientSession() as http:
|
||||||
|
async with http.post(
|
||||||
|
f"http://127.0.0.1:{port}/attachments",
|
||||||
|
params={"conversation_id": "a" * 32},
|
||||||
|
data=b"hello") as resp:
|
||||||
|
assert resp.status == 401
|
||||||
|
finally:
|
||||||
|
await server.stop()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_upload_roundtrip_without_premature_broadcast(self, tmp_path):
|
||||||
|
aiohttp = pytest.importorskip("aiohttp")
|
||||||
|
server = make_server(tmp_path)
|
||||||
|
server.config.port = 0
|
||||||
|
client = FakeClientConnection()
|
||||||
|
client.authenticated = True
|
||||||
|
server._clients["t"] = client
|
||||||
|
cid = await server.router.new_conversation("Uploads")
|
||||||
|
assert await server.start()
|
||||||
|
try:
|
||||||
|
port = server._site._server.sockets[0].getsockname()[1]
|
||||||
|
headers = {"Authorization": "Bearer test-secret-abc123"}
|
||||||
|
async with aiohttp.ClientSession() as http:
|
||||||
|
async with http.post(
|
||||||
|
f"http://127.0.0.1:{port}/attachments",
|
||||||
|
params={"conversation_id": cid,
|
||||||
|
"filename": "notes.md"},
|
||||||
|
data=b"# title\nbody",
|
||||||
|
headers={**headers,
|
||||||
|
"Content-Type": "text/markdown"}) as resp:
|
||||||
|
assert resp.status == 201
|
||||||
|
body = await resp.json()
|
||||||
|
desc = body["attachment"]
|
||||||
|
assert desc["filename"] == "notes.md"
|
||||||
|
assert desc["direction"] == "inbound"
|
||||||
|
assert desc["conversation_id"] == cid
|
||||||
|
# Unsent uploads stay private and out of conversation history.
|
||||||
|
assert not [e for e in client.ws.events() if e["type"] == proto.S_ATTACHMENT_ADDED]
|
||||||
|
assert server.store.list_for_conversation(cid) == []
|
||||||
|
# download round-trips the bytes
|
||||||
|
async with aiohttp.ClientSession() as http:
|
||||||
|
async with http.get(
|
||||||
|
f"http://127.0.0.1:{port}{desc['download_path']}",
|
||||||
|
headers=headers) as resp:
|
||||||
|
assert resp.status == 200
|
||||||
|
assert await resp.read() == b"# title\nbody"
|
||||||
|
finally:
|
||||||
|
await server.stop()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_upload_empty_body_rejected(self, tmp_path):
|
||||||
|
aiohttp = pytest.importorskip("aiohttp")
|
||||||
|
server = make_server(tmp_path)
|
||||||
|
server.config.port = 0
|
||||||
|
cid = await server.router.new_conversation("U")
|
||||||
|
assert await server.start()
|
||||||
|
try:
|
||||||
|
port = server._site._server.sockets[0].getsockname()[1]
|
||||||
|
headers = {"Authorization": "Bearer test-secret-abc123"}
|
||||||
|
async with aiohttp.ClientSession() as http:
|
||||||
|
async with http.post(
|
||||||
|
f"http://127.0.0.1:{port}/attachments",
|
||||||
|
params={"conversation_id": cid},
|
||||||
|
data=b"", headers=headers) as resp:
|
||||||
|
assert resp.status == 400
|
||||||
|
finally:
|
||||||
|
await server.stop()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_upload_bad_conversation_format(self, tmp_path):
|
||||||
|
aiohttp = pytest.importorskip("aiohttp")
|
||||||
|
server = make_server(tmp_path)
|
||||||
|
server.config.port = 0
|
||||||
|
assert await server.start()
|
||||||
|
try:
|
||||||
|
port = server._site._server.sockets[0].getsockname()[1]
|
||||||
|
headers = {"Authorization": "Bearer test-secret-abc123"}
|
||||||
|
async with aiohttp.ClientSession() as http:
|
||||||
|
async with http.post(
|
||||||
|
f"http://127.0.0.1:{port}/attachments",
|
||||||
|
params={"conversation_id": "bad/id!"},
|
||||||
|
data=b"x", headers=headers) as resp:
|
||||||
|
assert resp.status == 400
|
||||||
|
finally:
|
||||||
|
await server.stop()
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# chat.send with attachment_ids
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class TestChatSendAttachments:
|
||||||
|
def test_user_history_hides_agent_file_context(self):
|
||||||
|
from pheby.hermes_bridge import _display_user_text
|
||||||
|
content = ("[The user sent a text document: 'plan.md'. Its content has been included below.]\n\n"
|
||||||
|
"[Pheby user message]\nreview this\n[/Pheby user message]\n\n"
|
||||||
|
"Attached file: plan.md\n```md\n# private context\n```")
|
||||||
|
assert _display_user_text(content) == "review this"
|
||||||
|
assert _display_user_text("ordinary message") == "ordinary message"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chat_send_with_text_file_inlines_and_anchors(
|
||||||
|
self, tmp_path):
|
||||||
|
server = make_server(tmp_path)
|
||||||
|
client = FakeClientConnection()
|
||||||
|
client.authenticated = True
|
||||||
|
server._clients["t"] = client
|
||||||
|
cid = "a" * 32
|
||||||
|
desc = await server.store.register_bytes(
|
||||||
|
b"# plan\n1. build\n",
|
||||||
|
conversation_id=cid, filename="plan.md",
|
||||||
|
mime_type="text/markdown")
|
||||||
|
from pheby import hermes_bridge as hb
|
||||||
|
await hb.send_chat(server, cid, "review this", client, "r1",
|
||||||
|
attachment_ids=[desc["attachment_id"]])
|
||||||
|
event = server.adapter.handled[0]
|
||||||
|
# Agent receives the file, while the history renderer can recover
|
||||||
|
# just the user's own text from the delimited context.
|
||||||
|
assert "```md" in event.text
|
||||||
|
assert "# plan" in event.text
|
||||||
|
assert event.text.startswith("[Pheby user message]\nreview this\n[/Pheby user message]")
|
||||||
|
# media handed to gateway
|
||||||
|
assert len(event.media_urls) == 1
|
||||||
|
assert Path(event.media_urls[0]).read_bytes() == b"# plan\n1. build\n"
|
||||||
|
assert event.media_types == ["text/markdown"]
|
||||||
|
assert event.media_text_inlined == [True]
|
||||||
|
# anchored to the message id
|
||||||
|
stored = server.store.describe(desc["attachment_id"])
|
||||||
|
assert stored["message_id"] == event.message_id
|
||||||
|
added = [e for e in client.ws.events() if e["type"] == proto.S_ATTACHMENT_ADDED]
|
||||||
|
assert added[-1]["attachment"]["message_id"] == event.message_id
|
||||||
|
assert server.store.list_for_conversation(cid) == [stored]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chat_send_image_sets_photo_type(self, tmp_path):
|
||||||
|
server = make_server(tmp_path)
|
||||||
|
client = FakeClientConnection()
|
||||||
|
client.authenticated = True
|
||||||
|
server._clients["t"] = client
|
||||||
|
cid = "b" * 32
|
||||||
|
desc = await server.store.register_bytes(
|
||||||
|
b"\x89PNG\r\n", conversation_id=cid, filename="pic.png",
|
||||||
|
mime_type="image/png")
|
||||||
|
from pheby import hermes_bridge as hb
|
||||||
|
await hb.send_chat(server, cid, "what is this?", client, "r1",
|
||||||
|
attachment_ids=[desc["attachment_id"]])
|
||||||
|
event = server.adapter.handled[0]
|
||||||
|
assert event.message_type.value == "photo"
|
||||||
|
assert event.media_types == ["image/png"]
|
||||||
|
assert event.media_text_inlined == [False]
|
||||||
|
assert "```" not in event.text
|
||||||
|
from pheby.hermes_bridge import _display_user_text
|
||||||
|
assert _display_user_text(event.text) == "what is this?"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chat_send_wrong_conversation_rejected(self, tmp_path):
|
||||||
|
server = make_server(tmp_path)
|
||||||
|
client = FakeClientConnection()
|
||||||
|
client.authenticated = True
|
||||||
|
server._clients["t"] = client
|
||||||
|
desc = await server.store.register_bytes(
|
||||||
|
b"x", conversation_id="c" * 32, filename="f.bin")
|
||||||
|
from pheby import hermes_bridge as hb
|
||||||
|
await hb.send_chat(server, "d" * 32, "hi", client, "r1",
|
||||||
|
attachment_ids=[desc["attachment_id"]])
|
||||||
|
events = client.ws.events()
|
||||||
|
assert events[0]["type"] == proto.S_ERROR
|
||||||
|
assert events[0]["error"]["code"] == proto.ERR_NOT_FOUND
|
||||||
|
assert server.adapter.handled == []
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chat_send_reused_attachment_rejected(self, tmp_path):
|
||||||
|
server = make_server(tmp_path)
|
||||||
|
client = FakeClientConnection()
|
||||||
|
client.authenticated = True
|
||||||
|
server._clients["t"] = client
|
||||||
|
cid = "e" * 32
|
||||||
|
desc = await server.store.register_bytes(
|
||||||
|
b"x", conversation_id=cid, filename="f.bin")
|
||||||
|
server.store.anchor_message(desc["attachment_id"], "existing-msg")
|
||||||
|
await server._handle_chat_send(client, {
|
||||||
|
"type": proto.C_CHAT_SEND,
|
||||||
|
"conversation_id": cid,
|
||||||
|
"text": "hi",
|
||||||
|
"attachment_ids": [desc["attachment_id"]],
|
||||||
|
}, "r9")
|
||||||
|
events = client.ws.events()
|
||||||
|
assert events[0]["type"] == proto.S_ERROR
|
||||||
|
assert events[0]["error"]["code"] == proto.ERR_BAD_REQUEST
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chat_send_attachment_shape_validation(self, tmp_path):
|
||||||
|
server = make_server(tmp_path)
|
||||||
|
client = FakeClientConnection()
|
||||||
|
client.authenticated = True
|
||||||
|
cid = "e" * 32
|
||||||
|
await server._handle_chat_send(client, {
|
||||||
|
"type": proto.C_CHAT_SEND,
|
||||||
|
"conversation_id": cid,
|
||||||
|
"text": "hi",
|
||||||
|
"attachment_ids": "not-a-list",
|
||||||
|
}, "r1")
|
||||||
|
ev = client.ws.events()
|
||||||
|
assert ev[0]["error"]["code"] == proto.ERR_BAD_REQUEST
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chat_send_no_attachments_unchanged(self, tmp_path):
|
||||||
|
server = make_server(tmp_path)
|
||||||
|
client = FakeClientConnection()
|
||||||
|
client.authenticated = True
|
||||||
|
server._clients["t"] = client
|
||||||
|
from pheby import hermes_bridge as hb
|
||||||
|
await hb.send_chat(server, "f" * 32, "plain", client, "r1")
|
||||||
|
event = server.adapter.handled[0]
|
||||||
|
assert event.text == "plain"
|
||||||
|
assert event.media_urls == []
|
||||||
|
assert event.message_type.value == "text"
|
||||||
Reference in New Issue
Block a user