505 lines
18 KiB
Python
505 lines
18 KiB
Python
from collections.abc import Sequence
|
|
from datetime import datetime
|
|
from typing import Any
|
|
|
|
import asyncpg
|
|
from fastmcp import FastMCP
|
|
from fastmcp.exceptions import ToolError
|
|
from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext
|
|
from fastmcp.tools.base import ToolResult
|
|
from mcp.types import CallToolRequestParams, TextContent
|
|
from pydantic import BaseModel
|
|
|
|
from api.mcp.download import (
|
|
MAX_WAIT_SECONDS,
|
|
describe,
|
|
fetch_media,
|
|
inline_content,
|
|
share_media,
|
|
)
|
|
from api.mcp.format import build_transcript, load_notes, load_photos, resolve_names
|
|
from dependencies.container import container
|
|
from utils.files import resolve_mime
|
|
from utils.read import (
|
|
annotations,
|
|
chats,
|
|
media,
|
|
peers,
|
|
people,
|
|
presence,
|
|
presence_stats,
|
|
social,
|
|
watches,
|
|
)
|
|
from utils.read.accounts import self_user_id, unknown_account_hint
|
|
from utils.read.models import DEFAULT_LIMIT, Page
|
|
from utils.read.period import DEFAULT_TZ, MAX_DAYS, known_timezone
|
|
from utils.read.read_receipts import read_info
|
|
from utils.search.models import SearchFilters
|
|
from utils.search.repository import search_messages
|
|
from utils.storage import ContentAddressedStorage
|
|
from utils.watch_rules import WatchKind, WatchParamsError, clean_name, normalize_params
|
|
|
|
INSTRUCTIONS = """\
|
|
beavergram archives Telegram data (chats, messages, media, presence, stories,
|
|
peer history) into Postgres and exposes it read-only over these tools.
|
|
|
|
Account scoping:
|
|
- Every tool takes `account_id`. It selects which archived Telegram account to
|
|
read. Unless the user names a different one, always pass `account_id=1`.
|
|
- `account_id` is beavergram's own small serial (1, 2, ...), never the archived
|
|
account's Telegram user id. Passing a Telegram id returns an error naming the
|
|
right `account_id`.
|
|
|
|
Identifiers:
|
|
- `chat_id` and `peer_id` are Telegram IDs (negative for groups/channels,
|
|
positive for users/bots). Discover them with `list_chats` before calling
|
|
tools that need a specific chat.
|
|
|
|
Typical flows:
|
|
- Read a chat: `list_chats` -> pick a `chat_id` -> `get_chat_history` for a
|
|
human-readable transcript (names, timestamps, voice STT, inline photos), or
|
|
`get_chat_history_raw` for structured JSON. Both return messages oldest
|
|
first; default gives the latest `limit`. Page to older messages with
|
|
`before_id` (first id you saw), to newer with `after_id` (last id you saw);
|
|
begin a full forward walk with `after_id=0`. Each transcript line starts
|
|
with its `#message_id`, and photos show as `[photo #message_id]` — pass that
|
|
id to `download_media` or to annotation tools.
|
|
Lines marked
|
|
"📝 [your private note, NOT in Telegram]" are the user's own annotations
|
|
attached locally via the web UI; they never existed in Telegram. Treat them
|
|
as private notes from the user to you, not as chat content.
|
|
- Find something: `search_messages_tool` (full-text over message text and STT
|
|
transcripts; supports chat/sender/date filters and regex).
|
|
- People: `get_person` gives one person in full: profile changes, shared groups,
|
|
joins and leaves, deleted message count, the private chat.
|
|
- Forensics: `get_deleted_messages` and `get_message_versions` recover content
|
|
removed or edited in Telegram but kept in the archive.
|
|
- Files: `download_media` gets any message attachment (photo, video, voice,
|
|
document, ...). It pulls the file from Telegram if the archive lacks it and
|
|
returns a public link valid 24h that needs no auth (`curl -O <link>`), plus
|
|
the content itself for small images and text files. `get_media` returns
|
|
metadata only.
|
|
- Monitoring: `set_watch` creates a local rule (kinds `keyword`,
|
|
`new_message`, `message_deleted`, `message_edited`, `peer_online`; params in
|
|
its description), `list_watches` lists rules, and `list_alerts` reads what
|
|
those rules fired, with chat, message preview and a web client `path`.
|
|
|
|
Watched bots:
|
|
- a bot whose token the user added is just another archived account: it has its
|
|
own `account_id` and its chats, messages and media read through the same
|
|
tools as a user account. Nothing bot-specific to call.
|
|
|
|
Writes: everything is read-only except `set_watch` and the share links
|
|
`download_media` creates (visible and revocable in the web UI).
|
|
|
|
Paging: list/search tools accept `limit` and `offset`.
|
|
"""
|
|
|
|
mcp: FastMCP = FastMCP("beavergram", instructions=INSTRUCTIONS)
|
|
|
|
|
|
async def _pool() -> asyncpg.Pool:
|
|
return await container.get(asyncpg.Pool)
|
|
|
|
|
|
class AccountGuard(Middleware):
|
|
"""Reject tool calls scoped to an account_id that does not exist."""
|
|
|
|
async def on_call_tool(
|
|
self,
|
|
context: MiddlewareContext[CallToolRequestParams],
|
|
call_next: CallNext[CallToolRequestParams, ToolResult],
|
|
) -> ToolResult:
|
|
account_id = (context.message.arguments or {}).get("account_id")
|
|
if isinstance(account_id, int) and not isinstance(account_id, bool):
|
|
hint = await unknown_account_hint(await _pool(), account_id)
|
|
if hint is not None:
|
|
raise ToolError(hint)
|
|
return await call_next(context)
|
|
|
|
|
|
mcp.add_middleware(AccountGuard())
|
|
|
|
|
|
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(output_schema=None)
|
|
async def get_chat_history(
|
|
account_id: int,
|
|
chat_id: int,
|
|
limit: int = DEFAULT_LIMIT,
|
|
before_id: int | None = None,
|
|
after_id: int | None = None,
|
|
include_deleted: bool = True,
|
|
include_images: bool = True,
|
|
max_images: int = 20,
|
|
) -> list[Any]:
|
|
"""Read a chat as a readable transcript, oldest first.
|
|
|
|
Renders "Name (time): text", voice notes as
|
|
"(Voice message, STT Content: ...)", and inlines downloaded photos as
|
|
images. Default returns the latest `limit` messages; page to older
|
|
messages with `before_id` (the first id you saw) or to newer ones with
|
|
`after_id` (the last id you saw).
|
|
"""
|
|
pool = await _pool()
|
|
views = await chats.get_chat_history(
|
|
pool,
|
|
account_id,
|
|
chat_id,
|
|
Page(limit=limit),
|
|
include_deleted=include_deleted,
|
|
before_id=before_id,
|
|
after_id=after_id,
|
|
)
|
|
if after_id is None:
|
|
views = list(reversed(views))
|
|
names = await resolve_names(pool, account_id, views)
|
|
notes = await load_notes(pool, account_id, chat_id, views)
|
|
self_id = await self_user_id(pool, account_id)
|
|
photos: list[tuple[int, bytes, str]] = []
|
|
truncated = False
|
|
if include_images:
|
|
storage = await container.get(ContentAddressedStorage)
|
|
photos, truncated = await load_photos(
|
|
pool, storage, account_id, views, limit=max_images
|
|
)
|
|
return build_transcript(
|
|
views, names, self_id, photos, notes=notes, truncated=truncated
|
|
)
|
|
|
|
|
|
@mcp.tool
|
|
async def get_chat_history_raw(
|
|
account_id: int,
|
|
chat_id: int,
|
|
limit: int = DEFAULT_LIMIT,
|
|
before_id: int | None = None,
|
|
after_id: int | None = None,
|
|
include_deleted: bool = True,
|
|
) -> list[dict[str, Any]]:
|
|
"""Structured chat messages as JSON, oldest first.
|
|
|
|
Default returns the latest `limit` messages. Walk the whole chat forward
|
|
with `after_id` set to the last returned message_id (begin at
|
|
`after_id=0`), or backward with `before_id` set to the first returned
|
|
message_id.
|
|
"""
|
|
views = await chats.get_chat_history(
|
|
await _pool(),
|
|
account_id,
|
|
chat_id,
|
|
Page(limit=limit),
|
|
include_deleted=include_deleted,
|
|
before_id=before_id,
|
|
after_id=after_id,
|
|
)
|
|
if after_id is None:
|
|
views = list(reversed(views))
|
|
return _dump(views)
|
|
|
|
|
|
@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
|
|
) -> dict[str, Any] | None:
|
|
"""Get media metadata for a message; use download_media to get the file."""
|
|
item = await media.get_message_media(await _pool(), account_id, chat_id, message_id)
|
|
return item.model_dump(mode="json") if item else None
|
|
|
|
|
|
@mcp.tool(output_schema=None)
|
|
async def download_media(
|
|
account_id: int, chat_id: int, message_id: int, wait_seconds: int = 45
|
|
) -> list[Any]:
|
|
"""Download the file attached to a message: any photo, video, voice, document.
|
|
|
|
Pulls it from Telegram first if the archive doesn't have it yet, waiting up
|
|
to `wait_seconds`. Returns a public download link (valid 24h, no auth) and
|
|
inlines small images and text files.
|
|
"""
|
|
pool = await _pool()
|
|
wait = min(max(wait_seconds, 0), MAX_WAIT_SECONDS)
|
|
item = await fetch_media(pool, account_id, chat_id, message_id, wait)
|
|
if item is None or item.storage_key is None:
|
|
return [
|
|
TextContent(
|
|
type="text",
|
|
text="Still downloading from Telegram; call download_media again.",
|
|
)
|
|
]
|
|
mime = resolve_mime(item.kind, item.mime, item.file_name)
|
|
share = await share_media(pool, item, mime)
|
|
storage = await container.get(ContentAddressedStorage)
|
|
return [
|
|
TextContent(type="text", text=describe(item, mime, share)),
|
|
*inline_content(storage, item.storage_key, mime),
|
|
]
|
|
|
|
|
|
@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 get_read_info(
|
|
account_id: int, chat_id: int, message_id: int
|
|
) -> dict[str, Any] | None:
|
|
"""When the user's own message was read, from the archive only.
|
|
|
|
`observed_at` is when the archive saw the chat read up to this message,
|
|
`exact_at` the read date Telegram reported for a private chat, `readers`
|
|
who read it in a group and when, `listened_at` when a voice or round
|
|
video was played. `outcome` is the last Telegram answer: ok, not_read_yet,
|
|
user_privacy (they hide read time), your_privacy (the user hides theirs),
|
|
chat_too_big, too_old, unavailable, flood or error.
|
|
"""
|
|
info = await read_info(await _pool(), account_id, chat_id, message_id)
|
|
return info.model_dump(mode="json") if info else None
|
|
|
|
|
|
@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(name="presence_stats")
|
|
async def presence_stats_tool(
|
|
account_id: int,
|
|
peer_id: int,
|
|
days: int = 30,
|
|
tz: str = DEFAULT_TZ,
|
|
limit: int = DEFAULT_LIMIT,
|
|
offset: int = 0,
|
|
) -> dict[str, Any]:
|
|
"""Time a peer spent online, from status pushes turned into online sessions.
|
|
|
|
`tz` is an IANA zone name; days and hours are counted in it. `week` holds
|
|
the average online minutes for each weekday (0 is Monday) and hour, null
|
|
where the archive has no data for that hour. `days` gives online seconds
|
|
per day, `gaps` the stretches when capture was down, `sessions` the latest
|
|
sessions (online stretches with breaks under 5 minutes), paged by
|
|
`limit`/`offset`. `hidden` means the peer hides their last seen time.
|
|
"""
|
|
pool = await _pool()
|
|
if not 1 <= days <= MAX_DAYS:
|
|
msg = f"days must be between 1 and {MAX_DAYS}"
|
|
raise ToolError(msg)
|
|
if not await known_timezone(pool, tz):
|
|
msg = f"Unknown time zone: {tz}"
|
|
raise ToolError(msg)
|
|
stats = await presence_stats.presence_stats(
|
|
pool, account_id, peer_id, Page(limit=limit, offset=offset), days=days, tz=tz
|
|
)
|
|
return stats.model_dump(mode="json")
|
|
|
|
|
|
@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_person(account_id: int, peer_id: int) -> dict[str, Any]:
|
|
"""Everything the archive knows about one person.
|
|
|
|
Current profile, name/username/phone changes with times (newest first),
|
|
avatar history, groups shared in the archive with their message counts and
|
|
last message, joins and leaves with who added or removed them, the number
|
|
of their deleted messages, and the private chat if there is one.
|
|
"""
|
|
pool = await _pool()
|
|
overview = await people.get_overview(pool, account_id, peer_id)
|
|
groups = await people.get_groups(pool, account_id, peer_id)
|
|
return {**overview.model_dump(mode="json"), **groups.model_dump(mode="json")}
|
|
|
|
|
|
@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: WatchKind,
|
|
params: dict[str, Any] | None = None,
|
|
name: str | None = None,
|
|
enabled: bool = True,
|
|
) -> dict[str, Any]:
|
|
"""Create a local watch rule (the only local write MCP is allowed).
|
|
|
|
A watch fires an alert into the web client (live counter and list) and to
|
|
the user's webhooks. The user's own messages never fire. Kinds and params:
|
|
|
|
- `keyword`: `{"pattern": str, "regex": bool = false, "chat_id"?: int,
|
|
"sender_id"?: int}`. Case-insensitive substring, or a Python regex when
|
|
`regex` is true (at most 200 characters, no nested repeats like
|
|
`(a+)+`). Checked on new messages, on edits (the new text) and on voice
|
|
transcripts. Without `chat_id` it watches every chat.
|
|
- `new_message`: `{"chat_id"?: int, "sender_id"?: int}`, at least one: any
|
|
new message in that chat, or from that person anywhere, or both.
|
|
- `message_deleted`: `{"chat_id"?: int}`. A stored message deleted in that
|
|
chat; without `chat_id`, in any private chat.
|
|
- `message_edited`: `{"chat_id"?: int}`. Same scopes as `message_deleted`.
|
|
- `peer_online`: `{"peer_id": int}`. The person comes online after at
|
|
least 15 minutes away.
|
|
|
|
`name` is an optional human label shown in the list and in alerts.
|
|
"""
|
|
try:
|
|
clean = normalize_params(kind, params or {})
|
|
except WatchParamsError as exc:
|
|
raise ToolError(str(exc)) from exc
|
|
watch = await watches.create_watch(
|
|
await _pool(), account_id, kind, clean, name=clean_name(name), 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,
|
|
before: int | None = None,
|
|
limit: int = DEFAULT_LIMIT,
|
|
) -> dict[str, Any]:
|
|
"""List fired alerts from watch rules, newest first.
|
|
|
|
Each alert names its watch (id, kind, name), the event (`message`, `edit`,
|
|
`transcript`, `delete`, `online`), the chat, the message preview with its
|
|
sender and deleted/edited flags, the peer for `peer_online`, and `path`,
|
|
the web client link. `seen=false` keeps unseen ones. Page older with
|
|
`before` set to `next_before` from the previous call; `unseen` is the
|
|
account's unseen count.
|
|
"""
|
|
page = await watches.list_alerts(
|
|
await _pool(), account_id, seen=seen, before=before, limit=limit
|
|
)
|
|
return page.model_dump(mode="json")
|