feat: add annotations, user profiles, watchers, stories, search and more
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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"),
|
||||
)
|
||||
@@ -1,6 +1,6 @@
|
||||
import asyncpg
|
||||
|
||||
from utils.read.models import AvatarRef
|
||||
from utils.read.models import AvatarHistoryView, AvatarRef
|
||||
|
||||
_PEER_UNIQUE_ID = """
|
||||
SELECT photo_unique_id FROM peers
|
||||
@@ -18,6 +18,12 @@ SELECT unique_id, storage_key, downloaded, mime FROM avatars
|
||||
WHERE account_id = $1 AND owner_id = $2 AND unique_id = $3
|
||||
"""
|
||||
|
||||
_AVATAR_HISTORY = """
|
||||
SELECT unique_id, first_seen_at, downloaded FROM avatars
|
||||
WHERE account_id = $1 AND owner_id = $2
|
||||
ORDER BY first_seen_at DESC
|
||||
"""
|
||||
|
||||
|
||||
async def current_avatar(
|
||||
pool: asyncpg.Pool, account_id: int, owner_kind: str, owner_id: int
|
||||
@@ -28,3 +34,17 @@ async def current_avatar(
|
||||
return None
|
||||
row = await pool.fetchrow(_AVATAR, account_id, owner_id, unique_id)
|
||||
return AvatarRef(**dict(row)) if row else None
|
||||
|
||||
|
||||
async def avatar_by_unique_id(
|
||||
pool: asyncpg.Pool, account_id: int, owner_id: int, unique_id: str
|
||||
) -> AvatarRef | None:
|
||||
row = await pool.fetchrow(_AVATAR, account_id, owner_id, unique_id)
|
||||
return AvatarRef(**dict(row)) if row else None
|
||||
|
||||
|
||||
async def avatar_history(
|
||||
pool: asyncpg.Pool, account_id: int, owner_id: int
|
||||
) -> list[AvatarHistoryView]:
|
||||
rows = await pool.fetch(_AVATAR_HISTORY, account_id, owner_id)
|
||||
return [AvatarHistoryView(**dict(row)) for row in rows]
|
||||
|
||||
@@ -118,25 +118,39 @@ async def list_chats(
|
||||
return items
|
||||
|
||||
|
||||
async def get_chat_history(
|
||||
async def get_chat_history( # noqa: PLR0913
|
||||
pool: asyncpg.Pool,
|
||||
account_id: int,
|
||||
chat_id: int,
|
||||
page: Page,
|
||||
*,
|
||||
include_deleted: bool = True,
|
||||
before_id: int | None = None,
|
||||
after_id: int | None = None,
|
||||
) -> list[MessageView]:
|
||||
where = "account_id = $1 AND chat_id = $2"
|
||||
if not include_deleted:
|
||||
where += " AND deleted_at IS NULL"
|
||||
rows = await pool.fetch(
|
||||
params: list[object] = [account_id, chat_id]
|
||||
if after_id is not None:
|
||||
params.append(after_id)
|
||||
where += f" AND message_id > ${len(params)}"
|
||||
order = "date ASC, message_id ASC"
|
||||
elif before_id is not None:
|
||||
params.append(before_id)
|
||||
where += f" AND message_id < ${len(params)}"
|
||||
order = "date DESC, message_id DESC"
|
||||
else:
|
||||
order = "date DESC, message_id DESC"
|
||||
params.append(page.capped_limit)
|
||||
query = (
|
||||
f"SELECT {_MESSAGE_COLS} FROM messages WHERE {where} " # noqa: S608
|
||||
"ORDER BY date DESC, message_id DESC LIMIT $3 OFFSET $4",
|
||||
account_id,
|
||||
chat_id,
|
||||
page.capped_limit,
|
||||
page.offset,
|
||||
f"ORDER BY {order} LIMIT ${len(params)}"
|
||||
)
|
||||
if before_id is None and after_id is None:
|
||||
params.append(page.offset)
|
||||
query += f" OFFSET ${len(params)}"
|
||||
rows = await pool.fetch(query, *params)
|
||||
media_by_key = await _media_map(pool, account_id, rows)
|
||||
parsed = [(row, load_raw(row["raw"])) for row in rows]
|
||||
views: list[MessageView] = []
|
||||
|
||||
@@ -225,6 +225,12 @@ class AvatarRef(BaseModel):
|
||||
mime: str | None
|
||||
|
||||
|
||||
class AvatarHistoryView(BaseModel):
|
||||
unique_id: str
|
||||
first_seen_at: datetime
|
||||
downloaded: bool
|
||||
|
||||
|
||||
class CustomEmojiRef(BaseModel):
|
||||
storage_key: str | None
|
||||
downloaded: bool
|
||||
@@ -307,6 +313,27 @@ class PeerHistoryView(BaseModel):
|
||||
is_deleted_account: bool
|
||||
|
||||
|
||||
class ChatLinkView(BaseModel):
|
||||
message_id: int
|
||||
date: datetime | None
|
||||
url: str
|
||||
kind: str
|
||||
web_url: str | None
|
||||
web_title: str | None
|
||||
web_site_name: str | None
|
||||
|
||||
|
||||
class DayCount(BaseModel):
|
||||
day: datetime
|
||||
count: int
|
||||
outgoing: int
|
||||
|
||||
|
||||
class MessageAt(BaseModel):
|
||||
message_id: int
|
||||
date: datetime
|
||||
|
||||
|
||||
class StoryView(BaseModel):
|
||||
peer_id: int
|
||||
story_id: int
|
||||
|
||||
@@ -68,3 +68,17 @@ async def get_stories(
|
||||
*params,
|
||||
)
|
||||
return [StoryView(**dict(row)) for row in rows]
|
||||
|
||||
|
||||
async def get_story(
|
||||
pool: asyncpg.Pool, account_id: int, peer_id: int, story_id: int
|
||||
) -> StoryView | None:
|
||||
row = await pool.fetchrow(
|
||||
"SELECT peer_id, story_id, date, expire_date, caption, media_kind, "
|
||||
"storage_key, downloaded, views, pinned, deleted FROM stories "
|
||||
"WHERE account_id = $1 AND peer_id = $2 AND story_id = $3",
|
||||
account_id,
|
||||
peer_id,
|
||||
story_id,
|
||||
)
|
||||
return StoryView(**dict(row)) if row else None
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import asyncpg
|
||||
|
||||
from utils.read.accounts import self_user_id
|
||||
from utils.read.models import ChatLinkView, DayCount, MediaView, MessageAt, Page
|
||||
|
||||
_MEDIA_COLS = (
|
||||
"id, account_id, chat_id, message_id, kind, storage_key, file_size, "
|
||||
"mime, ttl_seconds, downloaded, extracted_text, created_at"
|
||||
)
|
||||
|
||||
|
||||
async def chat_media(
|
||||
pool: asyncpg.Pool, account_id: int, chat_id: int, kinds: list[str], page: Page
|
||||
) -> list[MediaView]:
|
||||
rows = await pool.fetch(
|
||||
f"SELECT {_MEDIA_COLS} FROM media " # noqa: S608
|
||||
"WHERE account_id = $1 AND chat_id = $2 AND kind = ANY($3) "
|
||||
"ORDER BY message_id DESC LIMIT $4 OFFSET $5",
|
||||
account_id,
|
||||
chat_id,
|
||||
kinds,
|
||||
page.capped_limit,
|
||||
page.offset,
|
||||
)
|
||||
return [MediaView(**dict(row)) for row in rows]
|
||||
|
||||
|
||||
async def chat_links(
|
||||
pool: asyncpg.Pool, account_id: int, chat_id: int, page: Page
|
||||
) -> list[ChatLinkView]:
|
||||
rows = await pool.fetch(
|
||||
"SELECT l.message_id, m.date, l.url, l.kind, l.web_url, "
|
||||
"l.web_title, l.web_site_name FROM links l "
|
||||
"LEFT JOIN messages m ON m.account_id = l.account_id "
|
||||
"AND m.chat_id = l.chat_id AND m.message_id = l.message_id "
|
||||
"WHERE l.account_id = $1 AND l.chat_id = $2 "
|
||||
"ORDER BY l.message_id DESC, l.position LIMIT $3 OFFSET $4",
|
||||
account_id,
|
||||
chat_id,
|
||||
page.capped_limit,
|
||||
page.offset,
|
||||
)
|
||||
return [ChatLinkView(**dict(row)) for row in rows]
|
||||
|
||||
|
||||
async def daily_counts(
|
||||
pool: asyncpg.Pool, account_id: int, chat_id: int
|
||||
) -> list[DayCount]:
|
||||
self_id = await self_user_id(pool, account_id)
|
||||
rows = await pool.fetch(
|
||||
"SELECT date_trunc('day', date) AS day, count(*) AS count, "
|
||||
"count(*) FILTER (WHERE sender_id = $3) AS outgoing FROM messages "
|
||||
"WHERE account_id = $1 AND chat_id = $2 "
|
||||
"GROUP BY day ORDER BY day",
|
||||
account_id,
|
||||
chat_id,
|
||||
self_id,
|
||||
)
|
||||
return [DayCount(**dict(row)) for row in rows]
|
||||
|
||||
|
||||
async def first_message_on_day(
|
||||
pool: asyncpg.Pool, account_id: int, chat_id: int, day: datetime
|
||||
) -> MessageAt | None:
|
||||
row = await pool.fetchrow(
|
||||
"SELECT message_id, date FROM messages "
|
||||
"WHERE account_id = $1 AND chat_id = $2 AND date >= $3 AND date < $4 "
|
||||
"ORDER BY date, message_id LIMIT 1",
|
||||
account_id,
|
||||
chat_id,
|
||||
day,
|
||||
day + timedelta(days=1),
|
||||
)
|
||||
return MessageAt(**dict(row)) if row else None
|
||||
Reference in New Issue
Block a user