feat: web UI chat render, panels, presence + analytics
This commit is contained in:
@@ -11,12 +11,16 @@ from starlette.applications import Starlette
|
||||
|
||||
from api.auth import BearerAuthMiddleware
|
||||
from api.mcp.server import mcp
|
||||
from api.realtime import hub
|
||||
from api.routers import (
|
||||
accounts,
|
||||
analytics,
|
||||
annotations,
|
||||
avatars,
|
||||
backfill,
|
||||
chats,
|
||||
custom_emoji,
|
||||
events,
|
||||
folders,
|
||||
media,
|
||||
peers,
|
||||
@@ -39,7 +43,10 @@ mcp_app = mcp.http_app(path="/")
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app_: Starlette) -> AsyncGenerator[None]:
|
||||
pool = await container.get(asyncpg.Pool)
|
||||
await hub.start(pool)
|
||||
yield
|
||||
await hub.stop()
|
||||
await app_.state.dishka_container.close()
|
||||
|
||||
|
||||
@@ -59,6 +66,7 @@ async def health(pool: FromDishka[asyncpg.Pool]) -> dict[str, bool]:
|
||||
|
||||
|
||||
app.include_router(accounts.router)
|
||||
app.include_router(analytics.router)
|
||||
app.include_router(policy.router)
|
||||
app.include_router(folders.router)
|
||||
app.include_router(backfill.router)
|
||||
@@ -66,8 +74,10 @@ app.include_router(search.router)
|
||||
app.include_router(chats.router)
|
||||
app.include_router(media.router)
|
||||
app.include_router(avatars.router)
|
||||
app.include_router(custom_emoji.router)
|
||||
app.include_router(social.router)
|
||||
app.include_router(presence.router)
|
||||
app.include_router(events.router)
|
||||
app.include_router(peers.router)
|
||||
app.include_router(annotations.router)
|
||||
app.include_router(watches.router)
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import asyncpg
|
||||
|
||||
from utils.env import env
|
||||
from utils.events import BG_EVENTS_CHANNEL
|
||||
from utils.read import chats as chats_read
|
||||
from utils.read import presence as presence_read
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
QUEUE_MAXSIZE = 256
|
||||
|
||||
|
||||
class Subscriber:
|
||||
def __init__(self, account_id: int, chat_id: int | None) -> None:
|
||||
self.account_id = account_id
|
||||
self.chat_id = chat_id
|
||||
self.queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=QUEUE_MAXSIZE)
|
||||
|
||||
|
||||
class EventHub:
|
||||
def __init__(self) -> None:
|
||||
self._subscribers: set[Subscriber] = set()
|
||||
self._pool: asyncpg.Pool | None = None
|
||||
self._conn: asyncpg.Connection | None = None
|
||||
self._tasks: set[asyncio.Task] = set()
|
||||
|
||||
def subscribe(self, account_id: int, chat_id: int | None) -> Subscriber:
|
||||
sub = Subscriber(account_id, chat_id)
|
||||
self._subscribers.add(sub)
|
||||
return sub
|
||||
|
||||
def unsubscribe(self, sub: Subscriber) -> None:
|
||||
self._subscribers.discard(sub)
|
||||
|
||||
async def start(self, pool: asyncpg.Pool) -> None:
|
||||
self._pool = pool
|
||||
conn = await asyncpg.connect(dsn=env.db.connection_url)
|
||||
await conn.add_listener(BG_EVENTS_CHANNEL, self._on_notify)
|
||||
self._conn = conn
|
||||
logger.info("Realtime hub listening on %s", BG_EVENTS_CHANNEL)
|
||||
|
||||
async def stop(self) -> None:
|
||||
for task in self._tasks:
|
||||
task.cancel()
|
||||
if self._conn is not None:
|
||||
await self._conn.close()
|
||||
self._conn = None
|
||||
|
||||
def _on_notify(
|
||||
self, _conn: asyncpg.Connection, _pid: int, _channel: str, payload: str
|
||||
) -> None:
|
||||
task = asyncio.create_task(self._dispatch(payload))
|
||||
self._tasks.add(task)
|
||||
task.add_done_callback(self._tasks.discard)
|
||||
|
||||
async def _dispatch(self, payload: str) -> None:
|
||||
try:
|
||||
event = json.loads(payload)
|
||||
except json.JSONDecodeError:
|
||||
return
|
||||
account_id = event.get("account_id")
|
||||
chat_id = event.get("chat_id")
|
||||
targets = [
|
||||
sub
|
||||
for sub in self._subscribers
|
||||
if sub.account_id == account_id
|
||||
and (sub.chat_id is None or sub.chat_id == chat_id)
|
||||
]
|
||||
if not targets:
|
||||
return
|
||||
frame = await self._build_frame(event)
|
||||
if frame is None:
|
||||
return
|
||||
for sub in targets:
|
||||
try:
|
||||
sub.queue.put_nowait(frame)
|
||||
except asyncio.QueueFull:
|
||||
logger.warning("Dropping event for slow subscriber")
|
||||
|
||||
async def _build_frame( # noqa: PLR0911
|
||||
self, event: dict[str, Any]
|
||||
) -> dict[str, Any] | None:
|
||||
if self._pool is None:
|
||||
return None
|
||||
kind = event.get("kind")
|
||||
account_id = event["account_id"]
|
||||
if kind in {"message", "edit", "reaction"}:
|
||||
view = await chats_read.get_message(
|
||||
self._pool, account_id, event["chat_id"], event["message_id"]
|
||||
)
|
||||
if view is None:
|
||||
return None
|
||||
return {"type": kind, "message": view.model_dump(mode="json")}
|
||||
if kind == "delete":
|
||||
return {
|
||||
"type": "delete",
|
||||
"chat_id": event.get("chat_id"),
|
||||
"message_ids": event.get("message_ids", []),
|
||||
}
|
||||
if kind == "presence":
|
||||
sample = await presence_read.current_presence(
|
||||
self._pool, account_id, event["chat_id"]
|
||||
)
|
||||
return {
|
||||
"type": "presence",
|
||||
"peer_id": event["chat_id"],
|
||||
"sample": sample.model_dump(mode="json") if sample else None,
|
||||
}
|
||||
if kind == "receipt":
|
||||
return {
|
||||
"type": "receipt",
|
||||
"chat_id": event["chat_id"],
|
||||
"read_up_to": event["message_id"],
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
hub = EventHub()
|
||||
@@ -0,0 +1,30 @@
|
||||
from typing import Annotated
|
||||
|
||||
import asyncpg
|
||||
from dishka.integrations.fastapi import DishkaRoute, FromDishka
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from utils.read import analytics
|
||||
from utils.read.models import ResponseStats, VolumeBucket
|
||||
|
||||
router = APIRouter(prefix="/api/analytics", tags=["analytics"], route_class=DishkaRoute)
|
||||
|
||||
AccountId = Annotated[int, Query()]
|
||||
ChatId = Annotated[int, Query()]
|
||||
|
||||
|
||||
@router.get("/volume")
|
||||
async def volume(
|
||||
pool: FromDishka[asyncpg.Pool],
|
||||
account_id: AccountId,
|
||||
chat_id: ChatId,
|
||||
days: Annotated[int, Query()] = 90,
|
||||
) -> list[VolumeBucket]:
|
||||
return await analytics.message_volume(pool, account_id, chat_id, days=days)
|
||||
|
||||
|
||||
@router.get("/response-time")
|
||||
async def response_time(
|
||||
pool: FromDishka[asyncpg.Pool], account_id: AccountId, chat_id: ChatId
|
||||
) -> ResponseStats:
|
||||
return await analytics.response_stats(pool, account_id, chat_id)
|
||||
@@ -24,6 +24,10 @@ class FetchMediaRequest(BaseModel):
|
||||
message_id: int
|
||||
|
||||
|
||||
class SyncDialogsRequest(BaseModel):
|
||||
account_id: int
|
||||
|
||||
|
||||
class EnqueueResponse(BaseModel):
|
||||
job_id: int
|
||||
|
||||
@@ -78,6 +82,14 @@ async def enqueue_fetch_media(
|
||||
return EnqueueResponse(job_id=job_id)
|
||||
|
||||
|
||||
@router.post("/dialogs/sync", status_code=201)
|
||||
async def enqueue_sync_dialogs(
|
||||
pool: FromDishka[asyncpg.Pool], body: SyncDialogsRequest
|
||||
) -> EnqueueResponse:
|
||||
job_id = await enqueue(pool, body.account_id, "sync_dialogs", {})
|
||||
return EnqueueResponse(job_id=job_id)
|
||||
|
||||
|
||||
@router.get("/jobs")
|
||||
async def list_jobs(
|
||||
pool: FromDishka[asyncpg.Pool],
|
||||
|
||||
@@ -13,7 +13,9 @@ from utils.read.models import (
|
||||
MessageVersionView,
|
||||
MessageView,
|
||||
Page,
|
||||
PinnedView,
|
||||
)
|
||||
from utils.read.pinned import get_pinned
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["chats"], route_class=DishkaRoute)
|
||||
|
||||
@@ -55,6 +57,13 @@ async def chat_history(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/chats/{chat_id}/pinned")
|
||||
async def chat_pinned(
|
||||
pool: FromDishka[asyncpg.Pool], chat_id: int, account_id: AccountId
|
||||
) -> PinnedView | None:
|
||||
return await get_pinned(pool, account_id, chat_id)
|
||||
|
||||
|
||||
@router.post("/chats/{chat_id}/enrich")
|
||||
async def enrich_chat(
|
||||
pool: FromDishka[asyncpg.Pool], chat_id: int, body: EnrichRequest
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
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.custom_emoji import current_custom_emoji
|
||||
from utils.storage import ContentAddressedStorage
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/custom-emoji", tags=["custom-emoji"], route_class=DishkaRoute
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{custom_emoji_id}")
|
||||
async def serve_custom_emoji(
|
||||
pool: FromDishka[asyncpg.Pool],
|
||||
storage: FromDishka[ContentAddressedStorage],
|
||||
custom_emoji_id: int,
|
||||
account_id: Annotated[int, Query()],
|
||||
) -> FileResponse:
|
||||
emoji = await current_custom_emoji(pool, custom_emoji_id)
|
||||
if emoji is None or not emoji.downloaded or emoji.storage_key is None:
|
||||
await enqueue(
|
||||
pool, account_id, "fetch_custom_emoji", {"custom_emoji_id": custom_emoji_id}
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=409, detail="custom emoji not downloaded; fetching"
|
||||
)
|
||||
return FileResponse(
|
||||
storage.url(emoji.storage_key),
|
||||
media_type=emoji.mime or "application/octet-stream",
|
||||
)
|
||||
@@ -0,0 +1,42 @@
|
||||
import asyncio
|
||||
import json
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from api.realtime import Subscriber, hub
|
||||
|
||||
router = APIRouter(prefix="/api/events", tags=["events"])
|
||||
|
||||
HEARTBEAT_SECONDS = 15
|
||||
|
||||
AccountId = Annotated[int, Query()]
|
||||
ChatId = Annotated[int | None, Query()]
|
||||
|
||||
|
||||
async def _stream(sub: Subscriber) -> AsyncGenerator[str]:
|
||||
try:
|
||||
yield ": connected\n\n"
|
||||
while True:
|
||||
try:
|
||||
frame = await asyncio.wait_for(
|
||||
sub.queue.get(), timeout=HEARTBEAT_SECONDS
|
||||
)
|
||||
except TimeoutError:
|
||||
yield ": keepalive\n\n"
|
||||
continue
|
||||
yield f"event: {frame['type']}\ndata: {json.dumps(frame)}\n\n"
|
||||
finally:
|
||||
hub.unsubscribe(sub)
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def events(account_id: AccountId, chat_id: ChatId = None) -> StreamingResponse:
|
||||
sub = hub.subscribe(account_id, chat_id)
|
||||
return StreamingResponse(
|
||||
_stream(sub),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
@@ -36,6 +36,13 @@ async def presence_history(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/current")
|
||||
async def current_presence(
|
||||
pool: FromDishka[asyncpg.Pool], account_id: AccountId, peer_id: PeerId
|
||||
) -> PresenceSample | None:
|
||||
return await presence.current_presence(pool, account_id, peer_id)
|
||||
|
||||
|
||||
@router.get("/hourly")
|
||||
async def presence_hourly(
|
||||
pool: FromDishka[asyncpg.Pool],
|
||||
|
||||
Reference in New Issue
Block a user