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
+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