feat: 1-to-1 message render + web data-lake backend

This commit is contained in:
hh
2026-05-31 01:45:05 +02:00
parent f0afb7ec5b
commit 75425d1bee
110 changed files with 10199 additions and 54 deletions
+18
View File
@@ -1,16 +1,20 @@
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from pathlib import Path
import asyncpg
from dishka.integrations.fastapi import DishkaRoute, FromDishka, setup_dishka
from fastapi import FastAPI
from fastapi.responses import FileResponse
from fastmcp.utilities.lifespan import combine_lifespans
from starlette.applications import Starlette
from api.auth import BearerAuthMiddleware
from api.mcp.server import mcp
from api.routers import (
accounts,
annotations,
avatars,
backfill,
chats,
folders,
@@ -54,12 +58,14 @@ async def health(pool: FromDishka[asyncpg.Pool]) -> dict[str, bool]:
return {"db": db_ok, "timescaledb": bool(timescale_ok)}
app.include_router(accounts.router)
app.include_router(policy.router)
app.include_router(folders.router)
app.include_router(backfill.router)
app.include_router(search.router)
app.include_router(chats.router)
app.include_router(media.router)
app.include_router(avatars.router)
app.include_router(social.router)
app.include_router(presence.router)
app.include_router(peers.router)
@@ -68,6 +74,18 @@ app.include_router(watches.router)
app.mount("/mcp", mcp_app)
_spa_dir = Path(env.api.static_dir).resolve()
if _spa_dir.is_dir():
_spa_index = _spa_dir / "index.html"
@app.get("/{spa_path:path}")
async def serve_spa(spa_path: str) -> FileResponse:
candidate = (_spa_dir / spa_path).resolve()
if spa_path and candidate.is_relative_to(_spa_dir) and candidate.is_file():
return FileResponse(candidate)
return FileResponse(_spa_index)
app.add_middleware(BearerAuthMiddleware, token=_token)
setup_dishka(container, app)
+13
View File
@@ -0,0 +1,13 @@
import asyncpg
from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter
from utils.read import accounts
from utils.read.models import AccountView
router = APIRouter(prefix="/api", tags=["accounts"], route_class=DishkaRoute)
@router.get("/accounts")
async def list_accounts(pool: FromDishka[asyncpg.Pool]) -> list[AccountView]:
return await accounts.list_accounts(pool)
+40
View File
@@ -0,0 +1,40 @@
from typing import Annotated
import asyncpg
from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import FileResponse
from utils.jobs import enqueue
from utils.read.avatars import current_avatar
from utils.storage import ContentAddressedStorage
router = APIRouter(prefix="/api/avatars", tags=["avatars"], route_class=DishkaRoute)
@router.get("/{owner_kind}/{owner_id}")
async def serve_avatar(
pool: FromDishka[asyncpg.Pool],
storage: FromDishka[ContentAddressedStorage],
owner_kind: str,
owner_id: int,
account_id: Annotated[int, Query()],
) -> FileResponse:
avatar = await current_avatar(pool, account_id, owner_kind, owner_id)
if avatar is None:
raise HTTPException(status_code=404, detail="avatar not found")
if not avatar.downloaded or avatar.storage_key is None:
await enqueue(
pool,
account_id,
"fetch_avatar",
{
"owner_kind": owner_kind,
"owner_id": owner_id,
"unique_id": avatar.unique_id,
},
)
raise HTTPException(status_code=409, detail="avatar not downloaded; fetching")
return FileResponse(
storage.url(avatar.storage_key), media_type=avatar.mime or "image/jpeg"
)
+15
View File
@@ -3,7 +3,9 @@ from typing import Annotated
import asyncpg
from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, Query
from pydantic import BaseModel
from utils.jobs import enqueue
from utils.read import chats
from utils.read.models import (
DEFAULT_LIMIT,
@@ -15,6 +17,11 @@ from utils.read.models import (
router = APIRouter(prefix="/api", tags=["chats"], route_class=DishkaRoute)
class EnrichRequest(BaseModel):
account_id: int
AccountId = Annotated[int, Query()]
Limit = Annotated[int, Query()]
Offset = Annotated[int, Query()]
@@ -48,6 +55,14 @@ async def chat_history(
)
@router.post("/chats/{chat_id}/enrich")
async def enrich_chat(
pool: FromDishka[asyncpg.Pool], chat_id: int, body: EnrichRequest
) -> dict[str, int]:
job_id = await enqueue(pool, body.account_id, "enrich_chat", {"chat_id": chat_id})
return {"job_id": job_id}
@router.get("/chats/{chat_id}/messages/{message_id}/versions")
async def message_versions(
pool: FromDishka[asyncpg.Pool], chat_id: int, message_id: int, account_id: AccountId
+48 -3
View File
@@ -1,10 +1,17 @@
from typing import Annotated
import asyncpg
from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import FileResponse
from utils.read.media import get_media
from utils.read.models import MediaView
from utils.read.media import (
get_media,
get_media_version,
get_media_versions,
get_message_media,
)
from utils.read.models import MediaVersionView, MediaView
from utils.storage import ContentAddressedStorage
router = APIRouter(prefix="/api/media", tags=["media"], route_class=DishkaRoute)
@@ -18,6 +25,44 @@ async def media_meta(pool: FromDishka[asyncpg.Pool], media_id: int) -> MediaView
return media
@router.get("/versions/{chat_id}/{message_id}")
async def message_media_versions(
pool: FromDishka[asyncpg.Pool],
chat_id: int,
message_id: int,
account_id: Annotated[int, Query()],
) -> list[MediaVersionView]:
return await get_media_versions(pool, account_id, chat_id, message_id)
@router.get("/version/{version_id}")
async def serve_media_version(
pool: FromDishka[asyncpg.Pool],
storage: FromDishka[ContentAddressedStorage],
version_id: int,
) -> FileResponse:
version = await get_media_version(pool, version_id)
if version is None:
raise HTTPException(status_code=404, detail="media version not found")
return FileResponse(
storage.url(version.storage_key),
media_type=version.mime or "application/octet-stream",
)
@router.get("/message/{chat_id}/{message_id}")
async def message_media(
pool: FromDishka[asyncpg.Pool],
chat_id: int,
message_id: int,
account_id: Annotated[int, Query()],
) -> MediaView:
media = await get_message_media(pool, account_id, chat_id, message_id)
if media is None:
raise HTTPException(status_code=404, detail="media not found")
return media
@router.get("/{media_id}")
async def serve_media(
pool: FromDishka[asyncpg.Pool],
+8
View File
@@ -12,6 +12,14 @@ router = APIRouter(prefix="/api", tags=["peers"], route_class=DishkaRoute)
AccountId = Annotated[int, Query()]
@router.get("/peers/batch")
async def get_peers(
pool: FromDishka[asyncpg.Pool], account_id: AccountId, ids: Annotated[str, Query()]
) -> list[PeerView]:
parsed = [int(part) for part in ids.split(",") if part.strip()]
return await peers.get_peers(pool, account_id, parsed)
@router.get("/peers/{peer_id}")
async def get_peer(
pool: FromDishka[asyncpg.Pool], peer_id: int, account_id: AccountId