feat(pheby): accept inbound chat attachments

This commit is contained in:
2026-09-23 09:01:28 +00:00
parent 18c4b34d08
commit e8c60a1001
7 changed files with 606 additions and 10 deletions
+1
View File
@@ -18,6 +18,7 @@ pheby-attachments/
pheby_conversations.json
# Editors / OS
.hermes/
.idea/
.vscode/
.DS_Store
+37 -3
View File
@@ -1,7 +1,7 @@
# Pheby Protocol v1 — Specification
JSON messages over WebSocket, plus one authenticated HTTPS endpoint for
attachment downloads. Every message (both directions) carries a `"type"`.
JSON messages over WebSocket, plus authenticated HTTPS endpoints for
attachment uploads and downloads. Every message (both directions) carries a `"type"`.
Client→server requests MAY carry a `"request_id"` (any client-chosen string);
the direct reply echoes it. Server→client events are broadcast to all
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" }
```
`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
```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
normal `MEDIA:` deliverable pipeline), the server copies it into
@@ -332,6 +365,7 @@ adapter-managed storage and broadcasts:
"size": 48213,
"kind": "image" | "voice" | "video" | "audio" | "document",
"inline_image": false,
"direction": "outbound",
"conversation_id": "a1b2…",
"message_id": "draft-8c1f…" | null,
"created_at": "2026-09-02T18:30:00+00:00",
+81 -1
View File
@@ -179,6 +179,7 @@ class AttachmentStore:
"kind": kind,
"conversation_id": conversation_id,
"message_id": message_id,
"direction": "outbound",
"created_at": protocol.now_iso(),
"created_epoch": time.time(),
"retention_days": self._retention_days,
@@ -197,6 +198,83 @@ class AttachmentStore:
attachment_id, kind, size, conversation_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 ────────────────────────────────────────────────
def describe(self, attachment_id: str) -> Optional[Dict[str, Any]]:
"""Public descriptor for an attachment (no server paths)."""
@@ -210,6 +288,7 @@ class AttachmentStore:
"size": int(meta.get("size", 0)),
"kind": meta.get("kind", "document"),
"inline_image": meta.get("kind") == "image",
"direction": meta.get("direction", "outbound"),
"conversation_id": meta.get("conversation_id"),
"message_id": meta.get("message_id"),
"created_at": meta.get("created_at"),
@@ -261,7 +340,8 @@ class AttachmentStore:
out = []
for aid in list(self._meta):
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)
return out
+85 -4
View File
@@ -225,6 +225,16 @@ def _iso(value: Any) -> Optional[str]:
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
) -> Tuple[List[Dict[str, Any]], bool]:
"""Load transcript rows for a conversation from Hermes state.db.
@@ -259,6 +269,8 @@ async def conversation_history(conversation_id: str, limit: int
continue
content = row.get("content")
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
# content; skip empties so the client transcript stays clean.
if not text.strip() and role == "assistant":
@@ -374,12 +386,18 @@ async def delete_conversation(conversation_id: str) -> bool:
# Chat runs
# ═══════════════════════════════════════════════════════════════════════════
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.
The gateway's full pipeline (auth, sessions, tools, approvals, clarify,
deliverables, streaming) runs on the adapter's message handler. Pheby
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()
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]
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
event = MessageEvent(
text=text,
message_type=MessageType.TEXT,
text=effective_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,
message_id=uuid.uuid4().hex[:12],
message_id=message_id,
metadata={"pheby_run_id": run_id},
media_urls=media_urls,
media_types=media_types,
media_text_inlined=media_text_inlined,
)
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().
try:
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:
with _RUN_LOCK:
current = _ACTIVE_RUNS.get(conversation_id)
+2
View File
@@ -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_FAILURE_LOCKOUT_SECONDS = 60.0 # repeated auth failures lock the source
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) ───────────────────────────────────────────
ERR_UNAUTHORIZED = "unauthorized"
+82 -2
View File
@@ -61,11 +61,13 @@ class PhebyServer:
async def start(self) -> bool:
from aiohttp import web as _web # local import keeps import light
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("/ws", self._handle_ws)
app.router.add_get("/attachments/{attachment_id}",
self._handle_attachment_download)
app.router.add_post("/attachments", self._handle_attachment_upload)
self._app = app
self._runner = web.AppRunner(app, access_log=None)
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 ────────────────────────────────────────────────
async def _handle_ws(self, request: web.Request) -> web.WebSocketResponse:
# Bind server/adapter identity to THIS task's context. ContextVars set
@@ -468,8 +521,35 @@ class PhebyServer:
proto.ERR_TOO_LARGE,
f"text exceeds {proto.MAX_TEXT_CHARS} chars", request_id))
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(
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):
conversation_id = str(message.get("conversation_id", ""))
+318
View File
@@ -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"