feat: add api and mcp

This commit is contained in:
h
2026-05-30 01:32:35 +02:00
parent 6a5cde6ae4
commit c40e720163
30 changed files with 2354 additions and 31 deletions
+66
View File
@@ -410,3 +410,69 @@ class Link(SQLModel, table=True):
raw: dict[str, Any] = Field(
default_factory=dict, sa_column=Column(JSONB, nullable=False)
)
class Annotation(SQLModel, table=True):
__tablename__ = "annotations"
id: int | None = Field(default=None, primary_key=True)
account_id: int
chat_id: int = Field(sa_column=Column(BigInteger, nullable=False, index=True))
message_id: int = Field(sa_column=Column(BigInteger, nullable=False))
text: str
created_at: datetime = Field(
sa_column=Column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
)
updated_at: datetime = Field(
sa_column=Column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
onupdate=func.now(),
)
)
class Watch(SQLModel, table=True):
__tablename__ = "watches"
id: int | None = Field(default=None, primary_key=True)
account_id: int
kind: str
params: dict[str, Any] = Field(
default_factory=dict, sa_column=Column(JSONB, nullable=False)
)
enabled: bool = True
created_at: datetime = Field(
sa_column=Column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
)
updated_at: datetime = Field(
sa_column=Column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
onupdate=func.now(),
)
)
class Alert(SQLModel, table=True):
__tablename__ = "alerts"
id: int | None = Field(default=None, primary_key=True)
account_id: int
watch_id: int = Field(foreign_key="watches.id")
ts: datetime = Field(sa_column=Column(DateTime(timezone=True), nullable=False))
payload: dict[str, Any] = Field(
default_factory=dict, sa_column=Column(JSONB, nullable=False)
)
seen: bool = False
created_at: datetime = Field(
sa_column=Column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
)
+5
View File
@@ -36,6 +36,10 @@ class ApiSettings(BaseSettings):
port: int = 8080
class AuthSettings(BaseSettings):
token: SecretStr | None = None
class StorageSettings(BaseSettings):
root: str = "storage"
shard_depth: int = 2
@@ -52,6 +56,7 @@ class Settings(BaseSettings):
db: DatabaseSettings = Field(default_factory=DatabaseSettings)
tg: TelegramSettings = Field(default_factory=TelegramSettings)
api: ApiSettings = Field(default_factory=ApiSettings)
auth: AuthSettings = Field(default_factory=AuthSettings)
storage: StorageSettings = Field(default_factory=StorageSettings)
log: LogSettings = Field(default_factory=LogSettings)
+20
View File
@@ -0,0 +1,20 @@
import json
from typing import Any
import asyncpg
JOBS_CHANGED_CHANNEL = "jobs_changed"
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
View File
+72
View File
@@ -0,0 +1,72 @@
import asyncpg
from utils.read.models import AnnotationView, Page
_COLS = "id, account_id, chat_id, message_id, text, created_at, updated_at"
async def list_annotations(
pool: asyncpg.Pool,
account_id: int,
page: Page,
*,
chat_id: int | None = None,
message_id: int | None = None,
) -> list[AnnotationView]:
params: list[object] = [account_id]
where = "account_id = $1"
if chat_id is not None:
params.append(chat_id)
where += f" AND chat_id = ${len(params)}"
if message_id is not None:
params.append(message_id)
where += f" AND message_id = ${len(params)}"
params.append(page.capped_limit)
params.append(page.offset)
rows = await pool.fetch(
f"SELECT {_COLS} FROM annotations WHERE {where} " # noqa: S608
f"ORDER BY created_at DESC LIMIT ${len(params) - 1} OFFSET ${len(params)}",
*params,
)
return [AnnotationView(**dict(row)) for row in rows]
async def get_annotation(
pool: asyncpg.Pool, annotation_id: int
) -> AnnotationView | None:
row = await pool.fetchrow(
f"SELECT {_COLS} FROM annotations WHERE id = $1", # noqa: S608
annotation_id,
)
return AnnotationView(**dict(row)) if row else None
async def create_annotation(
pool: asyncpg.Pool, account_id: int, chat_id: int, message_id: int, text: str
) -> AnnotationView:
row = await pool.fetchrow(
"INSERT INTO annotations (account_id, chat_id, message_id, text) " # noqa: S608
f"VALUES ($1, $2, $3, $4) RETURNING {_COLS}",
account_id,
chat_id,
message_id,
text,
)
return AnnotationView(**dict(row))
async def update_annotation(
pool: asyncpg.Pool, annotation_id: int, text: str
) -> AnnotationView | None:
row = await pool.fetchrow(
"UPDATE annotations SET text = $2, updated_at = now() " # noqa: S608
f"WHERE id = $1 RETURNING {_COLS}",
annotation_id,
text,
)
return AnnotationView(**dict(row)) if row else None
async def delete_annotation(pool: asyncpg.Pool, annotation_id: int) -> bool:
result = await pool.execute("DELETE FROM annotations WHERE id = $1", annotation_id)
return result.endswith("1")
+105
View File
@@ -0,0 +1,105 @@
import asyncpg
from utils.read.models import ChatListItem, MessageVersionView, MessageView, Page
_MESSAGE_COLS = (
"chat_id, message_id, date, sender_id, text, "
"has_media, is_self_destruct, edited_at, deleted_at"
)
def _peer_title(
first: str | None, last: str | None, username: str | None
) -> str | None:
name = " ".join(part for part in (first, last) if part)
return name or username
async def list_chats(
pool: asyncpg.Pool, account_id: int, page: Page
) -> list[ChatListItem]:
rows = await pool.fetch(
"SELECT m.chat_id, count(*) AS message_count, max(m.date) AS last_date, "
"(SELECT p.first_name FROM peers p "
"WHERE p.account_id = $1 AND p.peer_id = m.chat_id) AS first_name, "
"(SELECT p.last_name FROM peers p "
"WHERE p.account_id = $1 AND p.peer_id = m.chat_id) AS last_name, "
"(SELECT p.username FROM peers p "
"WHERE p.account_id = $1 AND p.peer_id = m.chat_id) AS username, "
"(SELECT ch.title FROM chat_history ch "
"WHERE ch.account_id = $1 AND ch.chat_id = m.chat_id "
"AND ch.title IS NOT NULL ORDER BY ch.ts DESC LIMIT 1) AS group_title "
"FROM messages m WHERE m.account_id = $1 "
"GROUP BY m.chat_id ORDER BY last_date DESC LIMIT $2 OFFSET $3",
account_id,
page.capped_limit,
page.offset,
)
items = []
for row in rows:
title = row["group_title"] or _peer_title(
row["first_name"], row["last_name"], row["username"]
)
items.append(
ChatListItem(
chat_id=row["chat_id"],
title=title,
message_count=row["message_count"],
last_date=row["last_date"],
)
)
return items
async def get_chat_history(
pool: asyncpg.Pool,
account_id: int,
chat_id: int,
page: Page,
*,
include_deleted: bool = True,
) -> list[MessageView]:
where = "account_id = $1 AND chat_id = $2"
if not include_deleted:
where += " AND deleted_at IS NULL"
rows = await pool.fetch(
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,
)
return [MessageView(**dict(row)) for row in rows]
async def get_deleted_messages(
pool: asyncpg.Pool, account_id: int, page: Page, *, chat_id: int | None = None
) -> list[MessageView]:
params: list[object] = [account_id]
where = "account_id = $1 AND deleted_at IS NOT NULL"
if chat_id is not None:
params.append(chat_id)
where += f" AND chat_id = ${len(params)}"
params.append(page.capped_limit)
params.append(page.offset)
rows = await pool.fetch(
f"SELECT {_MESSAGE_COLS} FROM messages WHERE {where} " # noqa: S608
f"ORDER BY deleted_at DESC LIMIT ${len(params) - 1} OFFSET ${len(params)}",
*params,
)
return [MessageView(**dict(row)) for row in rows]
async def get_message_versions(
pool: asyncpg.Pool, account_id: int, chat_id: int, message_id: int
) -> list[MessageVersionView]:
rows = await pool.fetch(
"SELECT observed_at, edit_date, text FROM message_versions "
"WHERE account_id = $1 AND chat_id = $2 AND message_id = $3 "
"ORDER BY observed_at",
account_id,
chat_id,
message_id,
)
return [MessageVersionView(**dict(row)) for row in rows]
+29
View File
@@ -0,0 +1,29 @@
import asyncpg
from utils.read.models import MediaView
_MEDIA_COLS = (
"id, account_id, chat_id, message_id, kind, storage_key, file_size, "
"mime, ttl_seconds, downloaded, extracted_text, created_at"
)
async def get_media(pool: asyncpg.Pool, media_id: int) -> MediaView | None:
row = await pool.fetchrow(
f"SELECT {_MEDIA_COLS} FROM media WHERE id = $1", # noqa: S608
media_id,
)
return MediaView(**dict(row)) if row else None
async def get_message_media(
pool: asyncpg.Pool, account_id: int, chat_id: int, message_id: int
) -> MediaView | None:
row = await pool.fetchrow(
f"SELECT {_MEDIA_COLS} FROM media " # noqa: S608
"WHERE account_id = $1 AND chat_id = $2 AND message_id = $3",
account_id,
chat_id,
message_id,
)
return MediaView(**dict(row)) if row else None
+160
View File
@@ -0,0 +1,160 @@
from datetime import datetime
from typing import Any
from pydantic import BaseModel
DEFAULT_LIMIT = 50
MAX_LIMIT = 500
class Page(BaseModel):
limit: int = DEFAULT_LIMIT
offset: int = 0
@property
def capped_limit(self) -> int:
return min(self.limit, MAX_LIMIT)
class ChatListItem(BaseModel):
chat_id: int
title: str | None
message_count: int
last_date: datetime | None
class MessageView(BaseModel):
chat_id: int
message_id: int
date: datetime
sender_id: int | None
text: str | None
has_media: bool
is_self_destruct: bool
edited_at: datetime | None
deleted_at: datetime | None
class MessageVersionView(BaseModel):
observed_at: datetime
edit_date: datetime | None
text: str | None
class MediaView(BaseModel):
id: int
account_id: int
chat_id: int
message_id: int
kind: str
storage_key: str | None
file_size: int | None
mime: str | None
ttl_seconds: int | None
downloaded: bool
extracted_text: str | None
created_at: datetime
class CallbackView(BaseModel):
position: int
label: str | None
data: str | None
class ReactionView(BaseModel):
peer_id: int
reaction: str
added_at: datetime
removed_at: datetime | None
class LinkView(BaseModel):
position: int
url: str
kind: str
web_url: str | None
web_title: str | None
web_description: str | None
web_site_name: str | None
class PresenceSample(BaseModel):
peer_id: int
ts: datetime
status: str
last_online_date: datetime | None
next_offline_date: datetime | None
class PresenceHourly(BaseModel):
peer_id: int
bucket: datetime
samples: int
online_samples: int
last_seen: datetime | None
class PeerView(BaseModel):
peer_id: int
first_name: str | None
last_name: str | None
username: str | None
phone: str | None
photo_unique_id: str | None
is_deleted_account: bool
updated_at: datetime
class PeerHistoryView(BaseModel):
observed_at: datetime
first_name: str | None
last_name: str | None
username: str | None
phone: str | None
photo_unique_id: str | None
is_deleted_account: bool
class StoryView(BaseModel):
peer_id: int
story_id: int
date: datetime | None
expire_date: datetime | None
caption: str | None
media_kind: str | None
storage_key: str | None
downloaded: bool
views: int | None
pinned: bool
deleted: bool
class AnnotationView(BaseModel):
id: int
account_id: int
chat_id: int
message_id: int
text: str
created_at: datetime
updated_at: datetime
class WatchView(BaseModel):
id: int
account_id: int
kind: str
params: dict[str, Any]
enabled: bool
created_at: datetime
updated_at: datetime
class AlertView(BaseModel):
id: int
account_id: int
watch_id: int
ts: datetime
payload: dict[str, Any]
seen: bool
created_at: datetime
+50
View File
@@ -0,0 +1,50 @@
import asyncpg
from utils.read.models import Page, PeerHistoryView, PeerView, StoryView
async def get_peer(
pool: asyncpg.Pool, account_id: int, peer_id: int
) -> PeerView | None:
row = await pool.fetchrow(
"SELECT peer_id, first_name, last_name, username, phone, "
"photo_unique_id, is_deleted_account, updated_at FROM peers "
"WHERE account_id = $1 AND peer_id = $2",
account_id,
peer_id,
)
return PeerView(**dict(row)) if row else None
async def get_peer_history(
pool: asyncpg.Pool, account_id: int, peer_id: int
) -> list[PeerHistoryView]:
rows = await pool.fetch(
"SELECT observed_at, first_name, last_name, username, phone, "
"photo_unique_id, is_deleted_account FROM peer_history "
"WHERE account_id = $1 AND peer_id = $2 ORDER BY observed_at DESC",
account_id,
peer_id,
)
return [PeerHistoryView(**dict(row)) for row in rows]
async def get_stories(
pool: asyncpg.Pool, account_id: int, page: Page, *, peer_id: int | None = None
) -> list[StoryView]:
params: list[object] = [account_id]
where = "account_id = $1"
if peer_id is not None:
params.append(peer_id)
where += f" AND peer_id = ${len(params)}"
params.append(page.capped_limit)
params.append(page.offset)
rows = await pool.fetch(
"SELECT peer_id, story_id, date, expire_date, caption, media_kind, " # noqa: S608
"storage_key, downloaded, views, pinned, deleted FROM stories "
f"WHERE {where} "
f"ORDER BY date DESC NULLS LAST LIMIT ${len(params) - 1} "
f"OFFSET ${len(params)}",
*params,
)
return [StoryView(**dict(row)) for row in rows]
+57
View File
@@ -0,0 +1,57 @@
from datetime import datetime
import asyncpg
from utils.read.models import Page, PresenceHourly, PresenceSample
async def presence_history( # noqa: PLR0913
pool: asyncpg.Pool,
account_id: int,
peer_id: int,
page: Page,
*,
date_from: datetime | None = None,
date_to: datetime | None = None,
) -> list[PresenceSample]:
params: list[object] = [account_id, peer_id]
where = "account_id = $1 AND peer_id = $2"
if date_from is not None:
params.append(date_from)
where += f" AND ts >= ${len(params)}"
if date_to is not None:
params.append(date_to)
where += f" AND ts <= ${len(params)}"
params.append(page.capped_limit)
params.append(page.offset)
rows = await pool.fetch(
"SELECT peer_id, ts, status, last_online_date, next_offline_date " # noqa: S608
f"FROM presence WHERE {where} "
f"ORDER BY ts DESC LIMIT ${len(params) - 1} OFFSET ${len(params)}",
*params,
)
return [PresenceSample(**dict(row)) for row in rows]
async def presence_hourly(
pool: asyncpg.Pool,
account_id: int,
peer_id: int,
*,
date_from: datetime | None = None,
date_to: datetime | None = None,
) -> list[PresenceHourly]:
params: list[object] = [account_id, peer_id]
where = "account_id = $1 AND peer_id = $2"
if date_from is not None:
params.append(date_from)
where += f" AND bucket >= ${len(params)}"
if date_to is not None:
params.append(date_to)
where += f" AND bucket <= ${len(params)}"
rows = await pool.fetch(
"SELECT peer_id, bucket, samples, online_samples, last_seen " # noqa: S608
f"FROM presence_hourly WHERE {where} ORDER BY bucket DESC",
*params,
)
return [PresenceHourly(**dict(row)) for row in rows]
+53
View File
@@ -0,0 +1,53 @@
import asyncpg
from utils.read.models import CallbackView, LinkView, ReactionView
async def get_callbacks(
pool: asyncpg.Pool, account_id: int, chat_id: int, message_id: int
) -> list[CallbackView]:
rows = await pool.fetch(
"SELECT position, label, data FROM callbacks "
"WHERE account_id = $1 AND chat_id = $2 AND message_id = $3 "
"ORDER BY position",
account_id,
chat_id,
message_id,
)
return [
CallbackView(
position=row["position"],
label=row["label"],
data=row["data"].hex() if row["data"] is not None else None,
)
for row in rows
]
async def get_reactions(
pool: asyncpg.Pool, account_id: int, chat_id: int, message_id: int
) -> list[ReactionView]:
rows = await pool.fetch(
"SELECT peer_id, reaction, added_at, removed_at FROM reactions "
"WHERE account_id = $1 AND chat_id = $2 AND message_id = $3 "
"ORDER BY added_at",
account_id,
chat_id,
message_id,
)
return [ReactionView(**dict(row)) for row in rows]
async def get_links(
pool: asyncpg.Pool, account_id: int, chat_id: int, message_id: int
) -> list[LinkView]:
rows = await pool.fetch(
"SELECT position, url, kind, web_url, web_title, web_description, "
"web_site_name FROM links "
"WHERE account_id = $1 AND chat_id = $2 AND message_id = $3 "
"ORDER BY position",
account_id,
chat_id,
message_id,
)
return [LinkView(**dict(row)) for row in rows]
+113
View File
@@ -0,0 +1,113 @@
import json
from datetime import UTC, datetime
from typing import Any
import asyncpg
from utils.read.models import AlertView, Page, WatchView
_WATCH_COLS = "id, account_id, kind, params, enabled, created_at, updated_at"
_ALERT_COLS = "id, account_id, watch_id, ts, payload, seen, created_at"
def _to_watch(row: asyncpg.Record) -> WatchView:
data = dict(row)
data["params"] = json.loads(data["params"])
return WatchView(**data)
def _to_alert(row: asyncpg.Record) -> AlertView:
data = dict(row)
data["payload"] = json.loads(data["payload"])
return AlertView(**data)
async def list_watches(pool: asyncpg.Pool, account_id: int) -> list[WatchView]:
rows = await pool.fetch(
f"SELECT {_WATCH_COLS} FROM watches WHERE account_id = $1 " # noqa: S608
"ORDER BY id DESC",
account_id,
)
return [_to_watch(row) for row in rows]
async def get_watch(pool: asyncpg.Pool, watch_id: int) -> WatchView | None:
row = await pool.fetchrow(
f"SELECT {_WATCH_COLS} FROM watches WHERE id = $1", # noqa: S608
watch_id,
)
return _to_watch(row) if row else None
async def create_watch(
pool: asyncpg.Pool,
account_id: int,
kind: str,
params: dict[str, Any],
*,
enabled: bool = True,
) -> WatchView:
row = await pool.fetchrow(
"INSERT INTO watches (account_id, kind, params, enabled) " # noqa: S608
f"VALUES ($1, $2, $3::jsonb, $4) RETURNING {_WATCH_COLS}",
account_id,
kind,
json.dumps(params),
enabled,
)
return _to_watch(row)
async def update_watch(
pool: asyncpg.Pool, watch_id: int, params: dict[str, Any], *, enabled: bool
) -> WatchView | None:
row = await pool.fetchrow(
"UPDATE watches SET params = $2::jsonb, enabled = $3, updated_at = now() " # noqa: S608
f"WHERE id = $1 RETURNING {_WATCH_COLS}",
watch_id,
json.dumps(params),
enabled,
)
return _to_watch(row) if row else None
async def delete_watch(pool: asyncpg.Pool, watch_id: int) -> bool:
result = await pool.execute("DELETE FROM watches WHERE id = $1", watch_id)
return result.endswith("1")
async def list_alerts(
pool: asyncpg.Pool, account_id: int, page: Page, *, seen: bool | None = None
) -> list[AlertView]:
params: list[object] = [account_id]
where = "account_id = $1"
if seen is not None:
params.append(seen)
where += f" AND seen = ${len(params)}"
params.append(page.capped_limit)
params.append(page.offset)
rows = await pool.fetch(
f"SELECT {_ALERT_COLS} FROM alerts WHERE {where} " # noqa: S608
f"ORDER BY ts DESC LIMIT ${len(params) - 1} OFFSET ${len(params)}",
*params,
)
return [_to_alert(row) for row in rows]
async def insert_alert(
pool: asyncpg.Pool, account_id: int, watch_id: int, payload: dict[str, Any]
) -> AlertView:
row = await pool.fetchrow(
"INSERT INTO alerts (account_id, watch_id, ts, payload) " # noqa: S608
f"VALUES ($1, $2, $3, $4::jsonb) RETURNING {_ALERT_COLS}",
account_id,
watch_id,
datetime.now(UTC),
json.dumps(payload),
)
return _to_alert(row)
async def mark_alert_seen(pool: asyncpg.Pool, alert_id: int) -> bool:
result = await pool.execute("UPDATE alerts SET seen = true WHERE id = $1", alert_id)
return result.endswith("1")