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
+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", ""))