feat: add api and mcp
This commit is contained in:
+41
-3
@@ -4,18 +4,44 @@ from contextlib import asynccontextmanager
|
||||
import asyncpg
|
||||
from dishka.integrations.fastapi import DishkaRoute, FromDishka, setup_dishka
|
||||
from fastapi import FastAPI
|
||||
from fastmcp.utilities.lifespan import combine_lifespans
|
||||
from starlette.applications import Starlette
|
||||
|
||||
from api.routers import backfill, folders, policy
|
||||
from api.auth import BearerAuthMiddleware
|
||||
from api.mcp.server import mcp
|
||||
from api.routers import (
|
||||
annotations,
|
||||
backfill,
|
||||
chats,
|
||||
folders,
|
||||
media,
|
||||
peers,
|
||||
policy,
|
||||
presence,
|
||||
search,
|
||||
social,
|
||||
watches,
|
||||
)
|
||||
from dependencies.container import container
|
||||
from utils.env import env
|
||||
|
||||
if env.auth.token is None:
|
||||
msg = "AUTH__TOKEN is required for the api process"
|
||||
raise RuntimeError(msg)
|
||||
_token = env.auth.token.get_secret_value()
|
||||
|
||||
mcp_app = mcp.http_app(path="/")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app_: FastAPI) -> AsyncGenerator[None]:
|
||||
async def lifespan(app_: Starlette) -> AsyncGenerator[None]:
|
||||
yield
|
||||
await app_.state.dishka_container.close()
|
||||
|
||||
|
||||
app = FastAPI(title="beavergram API", lifespan=lifespan)
|
||||
app = FastAPI(
|
||||
title="beavergram API", lifespan=combine_lifespans(lifespan, mcp_app.lifespan)
|
||||
)
|
||||
app.router.route_class = DishkaRoute
|
||||
|
||||
|
||||
@@ -31,5 +57,17 @@ async def health(pool: FromDishka[asyncpg.Pool]) -> dict[str, bool]:
|
||||
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(social.router)
|
||||
app.include_router(presence.router)
|
||||
app.include_router(peers.router)
|
||||
app.include_router(annotations.router)
|
||||
app.include_router(watches.router)
|
||||
|
||||
app.mount("/mcp", mcp_app)
|
||||
|
||||
app.add_middleware(BearerAuthMiddleware, token=_token)
|
||||
|
||||
setup_dishka(container, app)
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||
|
||||
PROTECTED_PREFIXES = ("/api", "/mcp")
|
||||
_UNAUTHORIZED = b'{"detail":"unauthorized"}'
|
||||
|
||||
|
||||
class BearerAuthMiddleware:
|
||||
def __init__(self, app: ASGIApp, token: str) -> None:
|
||||
self.app = app
|
||||
self.token = token
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if scope["type"] != "http":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
if scope["method"] == "OPTIONS" or not scope["path"].startswith(
|
||||
PROTECTED_PREFIXES
|
||||
):
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
headers = dict(scope["headers"])
|
||||
authorization = headers.get(b"authorization", b"").decode()
|
||||
if authorization == f"Bearer {self.token}":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
await send(
|
||||
{
|
||||
"type": "http.response.start",
|
||||
"status": 401,
|
||||
"headers": [
|
||||
(b"content-type", b"application/json"),
|
||||
(b"www-authenticate", b"Bearer"),
|
||||
],
|
||||
}
|
||||
)
|
||||
await send({"type": "http.response.body", "body": _UNAUTHORIZED})
|
||||
@@ -0,0 +1,233 @@
|
||||
from collections.abc import Sequence
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import asyncpg
|
||||
from fastmcp import FastMCP
|
||||
from pydantic import BaseModel
|
||||
|
||||
from dependencies.container import container
|
||||
from utils.jobs import enqueue
|
||||
from utils.read import annotations, chats, media, peers, presence, social, watches
|
||||
from utils.read.models import DEFAULT_LIMIT, Page
|
||||
from utils.search.models import SearchFilters
|
||||
from utils.search.repository import search_messages
|
||||
|
||||
mcp: FastMCP = FastMCP("beavergram")
|
||||
|
||||
|
||||
async def _pool() -> asyncpg.Pool:
|
||||
return await container.get(asyncpg.Pool)
|
||||
|
||||
|
||||
def _dump(items: Sequence[BaseModel]) -> list[dict[str, Any]]:
|
||||
return [item.model_dump(mode="json") for item in items]
|
||||
|
||||
|
||||
@mcp.tool
|
||||
async def search_messages_tool(
|
||||
account_id: int,
|
||||
query: str | None = None,
|
||||
chat_id: int | None = None,
|
||||
sender_id: int | None = None,
|
||||
has_media: bool | None = None,
|
||||
date_from: datetime | None = None,
|
||||
date_to: datetime | None = None,
|
||||
regex: str | None = None,
|
||||
limit: int = DEFAULT_LIMIT,
|
||||
offset: int = 0,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Full-text search over message text and STT transcripts."""
|
||||
filters = SearchFilters(
|
||||
account_id=account_id,
|
||||
query=query,
|
||||
chat_id=chat_id,
|
||||
sender_id=sender_id,
|
||||
has_media=has_media,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
regex=regex,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return _dump(await search_messages(await _pool(), filters))
|
||||
|
||||
|
||||
@mcp.tool
|
||||
async def list_chats(
|
||||
account_id: int, limit: int = DEFAULT_LIMIT, offset: int = 0
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List archived chats with message counts and last activity."""
|
||||
page = Page(limit=limit, offset=offset)
|
||||
return _dump(await chats.list_chats(await _pool(), account_id, page))
|
||||
|
||||
|
||||
@mcp.tool
|
||||
async def get_chat_history(
|
||||
account_id: int,
|
||||
chat_id: int,
|
||||
limit: int = DEFAULT_LIMIT,
|
||||
offset: int = 0,
|
||||
include_deleted: bool = True,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Read archived messages of a chat, newest first."""
|
||||
return _dump(
|
||||
await chats.get_chat_history(
|
||||
await _pool(),
|
||||
account_id,
|
||||
chat_id,
|
||||
Page(limit=limit, offset=offset),
|
||||
include_deleted=include_deleted,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool
|
||||
async def get_deleted_messages(
|
||||
account_id: int,
|
||||
chat_id: int | None = None,
|
||||
limit: int = DEFAULT_LIMIT,
|
||||
offset: int = 0,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List messages that were deleted in Telegram but kept in the archive."""
|
||||
return _dump(
|
||||
await chats.get_deleted_messages(
|
||||
await _pool(), account_id, Page(limit=limit, offset=offset), chat_id=chat_id
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool
|
||||
async def get_message_versions(
|
||||
account_id: int, chat_id: int, message_id: int
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Get the edit history of a message."""
|
||||
return _dump(
|
||||
await chats.get_message_versions(await _pool(), account_id, chat_id, message_id)
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool
|
||||
async def get_media(
|
||||
account_id: int, chat_id: int, message_id: int, fetch: bool = False
|
||||
) -> dict[str, Any] | None:
|
||||
"""Get media metadata for a message; set fetch=True to enqueue lazy download."""
|
||||
pool = await _pool()
|
||||
item = await media.get_message_media(pool, account_id, chat_id, message_id)
|
||||
if item is None:
|
||||
return None
|
||||
if fetch and not item.downloaded:
|
||||
await enqueue(
|
||||
pool,
|
||||
account_id,
|
||||
"fetch_media",
|
||||
{"chat_id": chat_id, "message_id": message_id},
|
||||
)
|
||||
return item.model_dump(mode="json")
|
||||
|
||||
|
||||
@mcp.tool
|
||||
async def get_callbacks(
|
||||
account_id: int, chat_id: int, message_id: int
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Get bot inline-button callback data (hex) for a message."""
|
||||
items = await social.get_callbacks(await _pool(), account_id, chat_id, message_id)
|
||||
return _dump(items)
|
||||
|
||||
|
||||
@mcp.tool
|
||||
async def presence_history(
|
||||
account_id: int,
|
||||
peer_id: int,
|
||||
date_from: datetime | None = None,
|
||||
date_to: datetime | None = None,
|
||||
limit: int = DEFAULT_LIMIT,
|
||||
offset: int = 0,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Get online/offline status history of a peer."""
|
||||
return _dump(
|
||||
await presence.presence_history(
|
||||
await _pool(),
|
||||
account_id,
|
||||
peer_id,
|
||||
Page(limit=limit, offset=offset),
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool
|
||||
async def get_peer_history(account_id: int, peer_id: int) -> list[dict[str, Any]]:
|
||||
"""Get name/username/avatar change history of a contact."""
|
||||
return _dump(await peers.get_peer_history(await _pool(), account_id, peer_id))
|
||||
|
||||
|
||||
@mcp.tool
|
||||
async def get_stories(
|
||||
account_id: int,
|
||||
peer_id: int | None = None,
|
||||
limit: int = DEFAULT_LIMIT,
|
||||
offset: int = 0,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List archived stories of contacts."""
|
||||
return _dump(
|
||||
await peers.get_stories(
|
||||
await _pool(), account_id, Page(limit=limit, offset=offset), peer_id=peer_id
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool
|
||||
async def get_annotations(
|
||||
account_id: int,
|
||||
chat_id: int | None = None,
|
||||
message_id: int | None = None,
|
||||
limit: int = DEFAULT_LIMIT,
|
||||
offset: int = 0,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Read user annotations on messages (read-only via MCP)."""
|
||||
return _dump(
|
||||
await annotations.list_annotations(
|
||||
await _pool(),
|
||||
account_id,
|
||||
Page(limit=limit, offset=offset),
|
||||
chat_id=chat_id,
|
||||
message_id=message_id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool
|
||||
async def set_watch(
|
||||
account_id: int,
|
||||
kind: str,
|
||||
params: dict[str, Any] | None = None,
|
||||
enabled: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a local watch rule (the only local write MCP is allowed)."""
|
||||
watch = await watches.create_watch(
|
||||
await _pool(), account_id, kind, params or {}, enabled=enabled
|
||||
)
|
||||
return watch.model_dump(mode="json")
|
||||
|
||||
|
||||
@mcp.tool
|
||||
async def list_watches(account_id: int) -> list[dict[str, Any]]:
|
||||
"""List local watch rules."""
|
||||
return _dump(await watches.list_watches(await _pool(), account_id))
|
||||
|
||||
|
||||
@mcp.tool
|
||||
async def list_alerts(
|
||||
account_id: int,
|
||||
seen: bool | None = None,
|
||||
limit: int = DEFAULT_LIMIT,
|
||||
offset: int = 0,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List fired alerts from watch rules."""
|
||||
return _dump(
|
||||
await watches.list_alerts(
|
||||
await _pool(), account_id, Page(limit=limit, offset=offset), seen=seen
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,77 @@
|
||||
from typing import Annotated
|
||||
|
||||
import asyncpg
|
||||
from dishka.integrations.fastapi import DishkaRoute, FromDishka
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
from utils.read import annotations
|
||||
from utils.read.models import DEFAULT_LIMIT, AnnotationView, Page
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/annotations", tags=["annotations"], route_class=DishkaRoute
|
||||
)
|
||||
|
||||
|
||||
class AnnotationCreate(BaseModel):
|
||||
account_id: int
|
||||
chat_id: int
|
||||
message_id: int
|
||||
text: str
|
||||
|
||||
|
||||
class AnnotationUpdate(BaseModel):
|
||||
text: str
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_annotations(
|
||||
pool: FromDishka[asyncpg.Pool],
|
||||
account_id: Annotated[int, Query()],
|
||||
chat_id: Annotated[int | None, Query()] = None,
|
||||
message_id: Annotated[int | None, Query()] = None,
|
||||
limit: Annotated[int, Query()] = DEFAULT_LIMIT,
|
||||
offset: Annotated[int, Query()] = 0,
|
||||
) -> list[AnnotationView]:
|
||||
return await annotations.list_annotations(
|
||||
pool,
|
||||
account_id,
|
||||
Page(limit=limit, offset=offset),
|
||||
chat_id=chat_id,
|
||||
message_id=message_id,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_annotation(
|
||||
pool: FromDishka[asyncpg.Pool], body: AnnotationCreate
|
||||
) -> AnnotationView:
|
||||
return await annotations.create_annotation(
|
||||
pool, body.account_id, body.chat_id, body.message_id, body.text
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{annotation_id}")
|
||||
async def get_annotation(
|
||||
pool: FromDishka[asyncpg.Pool], annotation_id: int
|
||||
) -> AnnotationView:
|
||||
annotation = await annotations.get_annotation(pool, annotation_id)
|
||||
if annotation is None:
|
||||
raise HTTPException(status_code=404, detail="annotation not found")
|
||||
return annotation
|
||||
|
||||
|
||||
@router.put("/{annotation_id}")
|
||||
async def update_annotation(
|
||||
pool: FromDishka[asyncpg.Pool], annotation_id: int, body: AnnotationUpdate
|
||||
) -> AnnotationView:
|
||||
annotation = await annotations.update_annotation(pool, annotation_id, body.text)
|
||||
if annotation is None:
|
||||
raise HTTPException(status_code=404, detail="annotation not found")
|
||||
return annotation
|
||||
|
||||
|
||||
@router.delete("/{annotation_id}", status_code=204)
|
||||
async def delete_annotation(pool: FromDishka[asyncpg.Pool], annotation_id: int) -> None:
|
||||
if not await annotations.delete_annotation(pool, annotation_id):
|
||||
raise HTTPException(status_code=404, detail="annotation not found")
|
||||
@@ -7,9 +7,9 @@ from dishka.integrations.fastapi import DishkaRoute, FromDishka
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["backfill"], route_class=DishkaRoute)
|
||||
from utils.jobs import enqueue
|
||||
|
||||
JOBS_CHANGED_CHANNEL = "jobs_changed"
|
||||
router = APIRouter(prefix="/api", tags=["backfill"], route_class=DishkaRoute)
|
||||
|
||||
|
||||
class BackfillRequest(BaseModel):
|
||||
@@ -52,25 +52,11 @@ def _to_view(row: asyncpg.Record) -> JobView:
|
||||
return JobView(**data)
|
||||
|
||||
|
||||
async def _enqueue(
|
||||
pool: asyncpg.Pool, account_id: int, kind: str, params: dict[str, Any]
|
||||
) -> int:
|
||||
job_id = await pool.fetchval(
|
||||
"INSERT INTO jobs (account_id, kind, params) "
|
||||
"VALUES ($1, $2, $3::jsonb) RETURNING id",
|
||||
account_id,
|
||||
kind,
|
||||
json.dumps(params),
|
||||
)
|
||||
await pool.execute(f"NOTIFY {JOBS_CHANGED_CHANNEL}")
|
||||
return job_id
|
||||
|
||||
|
||||
@router.post("/backfill", status_code=201)
|
||||
async def enqueue_backfill(
|
||||
pool: FromDishka[asyncpg.Pool], body: BackfillRequest
|
||||
) -> EnqueueResponse:
|
||||
job_id = await _enqueue(
|
||||
job_id = await enqueue(
|
||||
pool,
|
||||
body.account_id,
|
||||
"backfill",
|
||||
@@ -83,7 +69,7 @@ async def enqueue_backfill(
|
||||
async def enqueue_fetch_media(
|
||||
pool: FromDishka[asyncpg.Pool], body: FetchMediaRequest
|
||||
) -> EnqueueResponse:
|
||||
job_id = await _enqueue(
|
||||
job_id = await enqueue(
|
||||
pool,
|
||||
body.account_id,
|
||||
"fetch_media",
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
from typing import Annotated
|
||||
|
||||
import asyncpg
|
||||
from dishka.integrations.fastapi import DishkaRoute, FromDishka
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from utils.read import chats
|
||||
from utils.read.models import (
|
||||
DEFAULT_LIMIT,
|
||||
ChatListItem,
|
||||
MessageVersionView,
|
||||
MessageView,
|
||||
Page,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["chats"], route_class=DishkaRoute)
|
||||
|
||||
AccountId = Annotated[int, Query()]
|
||||
Limit = Annotated[int, Query()]
|
||||
Offset = Annotated[int, Query()]
|
||||
|
||||
|
||||
@router.get("/chats")
|
||||
async def list_chats(
|
||||
pool: FromDishka[asyncpg.Pool],
|
||||
account_id: AccountId,
|
||||
limit: Limit = DEFAULT_LIMIT,
|
||||
offset: Offset = 0,
|
||||
) -> list[ChatListItem]:
|
||||
return await chats.list_chats(pool, account_id, Page(limit=limit, offset=offset))
|
||||
|
||||
|
||||
@router.get("/chats/{chat_id}/messages")
|
||||
async def chat_history(
|
||||
pool: FromDishka[asyncpg.Pool],
|
||||
chat_id: int,
|
||||
account_id: AccountId,
|
||||
limit: Limit = DEFAULT_LIMIT,
|
||||
offset: Offset = 0,
|
||||
include_deleted: Annotated[bool, Query()] = True,
|
||||
) -> list[MessageView]:
|
||||
return await chats.get_chat_history(
|
||||
pool,
|
||||
account_id,
|
||||
chat_id,
|
||||
Page(limit=limit, offset=offset),
|
||||
include_deleted=include_deleted,
|
||||
)
|
||||
|
||||
|
||||
@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
|
||||
) -> list[MessageVersionView]:
|
||||
return await chats.get_message_versions(pool, account_id, chat_id, message_id)
|
||||
|
||||
|
||||
@router.get("/deleted")
|
||||
async def deleted_messages(
|
||||
pool: FromDishka[asyncpg.Pool],
|
||||
account_id: AccountId,
|
||||
chat_id: Annotated[int | None, Query()] = None,
|
||||
limit: Limit = DEFAULT_LIMIT,
|
||||
offset: Offset = 0,
|
||||
) -> list[MessageView]:
|
||||
return await chats.get_deleted_messages(
|
||||
pool, account_id, Page(limit=limit, offset=offset), chat_id=chat_id
|
||||
)
|
||||
@@ -0,0 +1,38 @@
|
||||
import asyncpg
|
||||
from dishka.integrations.fastapi import DishkaRoute, FromDishka
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from utils.read.media import get_media
|
||||
from utils.read.models import MediaView
|
||||
from utils.storage import ContentAddressedStorage
|
||||
|
||||
router = APIRouter(prefix="/api/media", tags=["media"], route_class=DishkaRoute)
|
||||
|
||||
|
||||
@router.get("/{media_id}/meta")
|
||||
async def media_meta(pool: FromDishka[asyncpg.Pool], media_id: int) -> MediaView:
|
||||
media = await get_media(pool, media_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],
|
||||
storage: FromDishka[ContentAddressedStorage],
|
||||
media_id: int,
|
||||
) -> FileResponse:
|
||||
media = await get_media(pool, media_id)
|
||||
if media is None:
|
||||
raise HTTPException(status_code=404, detail="media not found")
|
||||
if not media.downloaded or media.storage_key is None:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="media not downloaded; enqueue fetch via POST /api/media/fetch",
|
||||
)
|
||||
return FileResponse(
|
||||
storage.url(media.storage_key),
|
||||
media_type=media.mime or "application/octet-stream",
|
||||
)
|
||||
@@ -0,0 +1,42 @@
|
||||
from typing import Annotated
|
||||
|
||||
import asyncpg
|
||||
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
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["peers"], route_class=DishkaRoute)
|
||||
|
||||
AccountId = Annotated[int, Query()]
|
||||
|
||||
|
||||
@router.get("/peers/{peer_id}")
|
||||
async def get_peer(
|
||||
pool: FromDishka[asyncpg.Pool], peer_id: int, account_id: AccountId
|
||||
) -> PeerView:
|
||||
peer = await peers.get_peer(pool, account_id, peer_id)
|
||||
if peer is None:
|
||||
raise HTTPException(status_code=404, detail="peer not found")
|
||||
return peer
|
||||
|
||||
|
||||
@router.get("/peers/{peer_id}/history")
|
||||
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,49 @@
|
||||
from datetime import datetime
|
||||
from typing import Annotated
|
||||
|
||||
import asyncpg
|
||||
from dishka.integrations.fastapi import DishkaRoute, FromDishka
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from utils.read import presence
|
||||
from utils.read.models import DEFAULT_LIMIT, Page, PresenceHourly, PresenceSample
|
||||
|
||||
router = APIRouter(prefix="/api/presence", tags=["presence"], route_class=DishkaRoute)
|
||||
|
||||
AccountId = Annotated[int, Query()]
|
||||
PeerId = Annotated[int, Query()]
|
||||
DateFrom = Annotated[datetime | None, Query()]
|
||||
DateTo = Annotated[datetime | None, Query()]
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def presence_history(
|
||||
pool: FromDishka[asyncpg.Pool],
|
||||
account_id: AccountId,
|
||||
peer_id: PeerId,
|
||||
date_from: DateFrom = None,
|
||||
date_to: DateTo = None,
|
||||
limit: Annotated[int, Query()] = DEFAULT_LIMIT,
|
||||
offset: Annotated[int, Query()] = 0,
|
||||
) -> list[PresenceSample]:
|
||||
return await presence.presence_history(
|
||||
pool,
|
||||
account_id,
|
||||
peer_id,
|
||||
Page(limit=limit, offset=offset),
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/hourly")
|
||||
async def presence_hourly(
|
||||
pool: FromDishka[asyncpg.Pool],
|
||||
account_id: AccountId,
|
||||
peer_id: PeerId,
|
||||
date_from: DateFrom = None,
|
||||
date_to: DateTo = None,
|
||||
) -> list[PresenceHourly]:
|
||||
return await presence.presence_hourly(
|
||||
pool, account_id, peer_id, date_from=date_from, date_to=date_to
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
from typing import Annotated
|
||||
|
||||
import asyncpg
|
||||
from dishka.integrations.fastapi import DishkaRoute, FromDishka
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from utils.search.models import SearchFilters, SearchHit
|
||||
from utils.search.repository import search_messages
|
||||
|
||||
router = APIRouter(prefix="/api/search", tags=["search"], route_class=DishkaRoute)
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def search(
|
||||
pool: FromDishka[asyncpg.Pool], filters: Annotated[SearchFilters, Query()]
|
||||
) -> list[SearchHit]:
|
||||
return await search_messages(pool, filters)
|
||||
@@ -0,0 +1,37 @@
|
||||
from typing import Annotated
|
||||
|
||||
import asyncpg
|
||||
from dishka.integrations.fastapi import DishkaRoute, FromDishka
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from utils.read import social
|
||||
from utils.read.models import CallbackView, LinkView, ReactionView
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/messages/{chat_id}/{message_id}",
|
||||
tags=["social"],
|
||||
route_class=DishkaRoute,
|
||||
)
|
||||
|
||||
AccountId = Annotated[int, Query()]
|
||||
|
||||
|
||||
@router.get("/callbacks")
|
||||
async def message_callbacks(
|
||||
pool: FromDishka[asyncpg.Pool], chat_id: int, message_id: int, account_id: AccountId
|
||||
) -> list[CallbackView]:
|
||||
return await social.get_callbacks(pool, account_id, chat_id, message_id)
|
||||
|
||||
|
||||
@router.get("/reactions")
|
||||
async def message_reactions(
|
||||
pool: FromDishka[asyncpg.Pool], chat_id: int, message_id: int, account_id: AccountId
|
||||
) -> list[ReactionView]:
|
||||
return await social.get_reactions(pool, account_id, chat_id, message_id)
|
||||
|
||||
|
||||
@router.get("/links")
|
||||
async def message_links(
|
||||
pool: FromDishka[asyncpg.Pool], chat_id: int, message_id: int, account_id: AccountId
|
||||
) -> list[LinkView]:
|
||||
return await social.get_links(pool, account_id, chat_id, message_id)
|
||||
@@ -0,0 +1,82 @@
|
||||
from typing import Annotated, Any
|
||||
|
||||
import asyncpg
|
||||
from dishka.integrations.fastapi import DishkaRoute, FromDishka
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
from utils.read import watches
|
||||
from utils.read.models import DEFAULT_LIMIT, AlertView, Page, WatchView
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["watches"], route_class=DishkaRoute)
|
||||
|
||||
|
||||
class WatchCreate(BaseModel):
|
||||
account_id: int
|
||||
kind: str
|
||||
params: dict[str, Any] = {}
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class WatchUpdate(BaseModel):
|
||||
params: dict[str, Any] = {}
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
@router.get("/watches")
|
||||
async def list_watches(
|
||||
pool: FromDishka[asyncpg.Pool], account_id: Annotated[int, Query()]
|
||||
) -> list[WatchView]:
|
||||
return await watches.list_watches(pool, account_id)
|
||||
|
||||
|
||||
@router.post("/watches", status_code=201)
|
||||
async def create_watch(pool: FromDishka[asyncpg.Pool], body: WatchCreate) -> WatchView:
|
||||
return await watches.create_watch(
|
||||
pool, body.account_id, body.kind, body.params, enabled=body.enabled
|
||||
)
|
||||
|
||||
|
||||
@router.get("/watches/{watch_id}")
|
||||
async def get_watch(pool: FromDishka[asyncpg.Pool], watch_id: int) -> WatchView:
|
||||
watch = await watches.get_watch(pool, watch_id)
|
||||
if watch is None:
|
||||
raise HTTPException(status_code=404, detail="watch not found")
|
||||
return watch
|
||||
|
||||
|
||||
@router.put("/watches/{watch_id}")
|
||||
async def update_watch(
|
||||
pool: FromDishka[asyncpg.Pool], watch_id: int, body: WatchUpdate
|
||||
) -> WatchView:
|
||||
watch = await watches.update_watch(
|
||||
pool, watch_id, body.params, enabled=body.enabled
|
||||
)
|
||||
if watch is None:
|
||||
raise HTTPException(status_code=404, detail="watch not found")
|
||||
return watch
|
||||
|
||||
|
||||
@router.delete("/watches/{watch_id}", status_code=204)
|
||||
async def delete_watch(pool: FromDishka[asyncpg.Pool], watch_id: int) -> None:
|
||||
if not await watches.delete_watch(pool, watch_id):
|
||||
raise HTTPException(status_code=404, detail="watch not found")
|
||||
|
||||
|
||||
@router.get("/alerts")
|
||||
async def list_alerts(
|
||||
pool: FromDishka[asyncpg.Pool],
|
||||
account_id: Annotated[int, Query()],
|
||||
seen: Annotated[bool | None, Query()] = None,
|
||||
limit: Annotated[int, Query()] = DEFAULT_LIMIT,
|
||||
offset: Annotated[int, Query()] = 0,
|
||||
) -> list[AlertView]:
|
||||
return await watches.list_alerts(
|
||||
pool, account_id, Page(limit=limit, offset=offset), seen=seen
|
||||
)
|
||||
|
||||
|
||||
@router.post("/alerts/{alert_id}/seen", status_code=204)
|
||||
async def mark_alert_seen(pool: FromDishka[asyncpg.Pool], alert_id: int) -> None:
|
||||
if not await watches.mark_alert_seen(pool, alert_id):
|
||||
raise HTTPException(status_code=404, detail="alert not found")
|
||||
Reference in New Issue
Block a user