feat: add annotations, user profiles, watchers, stories, search and more

This commit is contained in:
hh
2026-06-01 17:15:09 +02:00
parent ed469ba8dd
commit 2465bcd184
47 changed files with 5009 additions and 242 deletions
+4
View File
@@ -26,8 +26,10 @@ from api.routers import (
peers,
policy,
presence,
profile,
search,
social,
stories,
watches,
)
from dependencies.container import container
@@ -77,6 +79,8 @@ app.include_router(avatars.router)
app.include_router(custom_emoji.router)
app.include_router(social.router)
app.include_router(presence.router)
app.include_router(stories.router)
app.include_router(profile.router)
app.include_router(events.router)
app.include_router(peers.router)
app.include_router(annotations.router)
+18 -2
View File
@@ -6,12 +6,23 @@ 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.read.avatars import avatar_by_unique_id, avatar_history, current_avatar
from utils.read.models import AvatarHistoryView
from utils.storage import ContentAddressedStorage
router = APIRouter(prefix="/api/avatars", tags=["avatars"], route_class=DishkaRoute)
@router.get("/{owner_kind}/{owner_id}/history")
async def serve_avatar_history(
pool: FromDishka[asyncpg.Pool],
owner_kind: str, # noqa: ARG001
owner_id: int,
account_id: Annotated[int, Query()],
) -> list[AvatarHistoryView]:
return await avatar_history(pool, account_id, owner_id)
@router.get("/{owner_kind}/{owner_id}")
async def serve_avatar(
pool: FromDishka[asyncpg.Pool],
@@ -19,8 +30,13 @@ async def serve_avatar(
owner_kind: str,
owner_id: int,
account_id: Annotated[int, Query()],
unique_id: Annotated[str | None, Query()] = None,
) -> FileResponse:
avatar = await current_avatar(pool, account_id, owner_kind, owner_id)
avatar = (
await avatar_by_unique_id(pool, account_id, owner_id, unique_id)
if unique_id is not None
else 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:
+4
View File
@@ -47,6 +47,8 @@ async def chat_history(
limit: Limit = DEFAULT_LIMIT,
offset: Offset = 0,
include_deleted: Annotated[bool, Query()] = True,
before_id: Annotated[int | None, Query()] = None,
after_id: Annotated[int | None, Query()] = None,
) -> list[MessageView]:
return await chats.get_chat_history(
pool,
@@ -54,6 +56,8 @@ async def chat_history(
chat_id,
Page(limit=limit, offset=offset),
include_deleted=include_deleted,
before_id=before_id,
after_id=after_id,
)
+1 -14
View File
@@ -5,7 +5,7 @@ from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, HTTPException, Query
from utils.read import peers
from utils.read.models import DEFAULT_LIMIT, Page, PeerHistoryView, PeerView, StoryView
from utils.read.models import PeerHistoryView, PeerView
router = APIRouter(prefix="/api", tags=["peers"], route_class=DishkaRoute)
@@ -35,16 +35,3 @@ async def peer_history(
pool: FromDishka[asyncpg.Pool], peer_id: int, account_id: AccountId
) -> list[PeerHistoryView]:
return await peers.get_peer_history(pool, account_id, peer_id)
@router.get("/stories")
async def stories(
pool: FromDishka[asyncpg.Pool],
account_id: AccountId,
peer_id: Annotated[int | None, Query()] = None,
limit: Annotated[int, Query()] = DEFAULT_LIMIT,
offset: Annotated[int, Query()] = 0,
) -> list[StoryView]:
return await peers.get_stories(
pool, account_id, Page(limit=limit, offset=offset), peer_id=peer_id
)
+70
View File
@@ -0,0 +1,70 @@
from datetime import datetime
from typing import Annotated
import asyncpg
from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, HTTPException, Query
from utils.read import profile
from utils.read.models import (
DEFAULT_LIMIT,
ChatLinkView,
DayCount,
MediaView,
MessageAt,
Page,
)
router = APIRouter(
prefix="/api/chats/{chat_id}", tags=["profile"], route_class=DishkaRoute
)
AccountId = Annotated[int, Query()]
@router.get("/media")
async def chat_media(
pool: FromDishka[asyncpg.Pool],
chat_id: int,
account_id: AccountId,
kinds: Annotated[str, Query()],
limit: Annotated[int, Query()] = DEFAULT_LIMIT,
offset: Annotated[int, Query()] = 0,
) -> list[MediaView]:
parsed = [part for part in kinds.split(",") if part.strip()]
return await profile.chat_media(
pool, account_id, chat_id, parsed, Page(limit=limit, offset=offset)
)
@router.get("/links")
async def chat_links(
pool: FromDishka[asyncpg.Pool],
chat_id: int,
account_id: AccountId,
limit: Annotated[int, Query()] = DEFAULT_LIMIT,
offset: Annotated[int, Query()] = 0,
) -> list[ChatLinkView]:
return await profile.chat_links(
pool, account_id, chat_id, Page(limit=limit, offset=offset)
)
@router.get("/calendar")
async def chat_calendar(
pool: FromDishka[asyncpg.Pool], chat_id: int, account_id: AccountId
) -> list[DayCount]:
return await profile.daily_counts(pool, account_id, chat_id)
@router.get("/message-at")
async def message_at(
pool: FromDishka[asyncpg.Pool],
chat_id: int,
account_id: AccountId,
date: Annotated[datetime, Query()],
) -> MessageAt:
found = await profile.first_message_on_day(pool, account_id, chat_id, date)
if found is None:
raise HTTPException(status_code=404, detail="no message on day")
return found
+48
View File
@@ -0,0 +1,48 @@
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.read import peers
from utils.read.models import DEFAULT_LIMIT, Page, StoryView
from utils.storage import ContentAddressedStorage
router = APIRouter(prefix="/api", tags=["stories"], route_class=DishkaRoute)
AccountId = Annotated[int, Query()]
_STORY_MIME = {"photo": "image/jpeg", "video": "video/mp4"}
@router.get("/stories")
async def list_stories(
pool: FromDishka[asyncpg.Pool],
account_id: AccountId,
peer_id: Annotated[int | None, Query()] = None,
limit: Annotated[int, Query()] = DEFAULT_LIMIT,
offset: Annotated[int, Query()] = 0,
) -> list[StoryView]:
return await peers.get_stories(
pool, account_id, Page(limit=limit, offset=offset), peer_id=peer_id
)
@router.get("/stories/{peer_id}/{story_id}/media")
async def serve_story_media(
pool: FromDishka[asyncpg.Pool],
storage: FromDishka[ContentAddressedStorage],
peer_id: int,
story_id: int,
account_id: AccountId,
) -> FileResponse:
story = await peers.get_story(pool, account_id, peer_id, story_id)
if story is None:
raise HTTPException(status_code=404, detail="story not found")
if not story.downloaded or story.storage_key is None:
raise HTTPException(status_code=409, detail="story media not downloaded")
return FileResponse(
storage.url(story.storage_key),
media_type=_STORY_MIME.get(story.media_kind or "", "application/octet-stream"),
)