feat: add api and mcp

This commit is contained in:
hh
2026-05-30 01:32:35 +02:00
parent 6a5cde6ae4
commit c40e720163
30 changed files with 2354 additions and 31 deletions
+41 -3
View File
@@ -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)
+36
View File
@@ -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})
View File
+233
View File
@@ -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
)
)
+77
View File
@@ -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")
+4 -18
View File
@@ -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",
+68
View File
@@ -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
)
+38
View File
@@ -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",
)
+42
View File
@@ -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
)
+49
View File
@@ -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
)
+17
View File
@@ -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)
+37
View File
@@ -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)
+82
View File
@@ -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")