feat: add api and mcp
This commit is contained in:
@@ -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()
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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]
|
||||
@@ -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]
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user