Compare commits

...
4 Commits
11 changed files with 192 additions and 30 deletions

No files matched your search

+1 -1
View File
@@ -1,4 +1,4 @@
_commit: v0.3.3 _commit: v0.4.0
_src_path: https://git.kotikot.com/templates/backend-python.git _src_path: https://git.kotikot.com/templates/backend-python.git
author_email: h@kotikot.com author_email: h@kotikot.com
author_name: h author_name: h
+1 -2
View File
@@ -1,6 +1,5 @@
_commit: v0.3.1 _commit: v0.4.2
_src_path: https://git.kotikot.com/templates/frontend-svelte.git _src_path: https://git.kotikot.com/templates/frontend-svelte.git
include_impeccable: false
install_deps: false install_deps: false
layout: part layout: part
project_name: beavergram project_name: beavergram
+4 -1
View File
@@ -1,7 +1,10 @@
_commit: v0.4.0 _commit: v0.5.0
_src_path: https://git.kotikot.com/templates/infra.git _src_path: https://git.kotikot.com/templates/infra.git
init_git: false init_git: false
layout: parts layout: parts
mcp_servers:
- context7
- svelte
parts: parts:
- backend - backend
- frontend - frontend
+3
View File
@@ -6,3 +6,6 @@ docker-compose.override.yml
.idea .idea
docs docs
storage storage
# Local playground
t/
+4 -4
View File
@@ -1,12 +1,12 @@
{ {
"mcpServers": { "mcpServers": {
"svelte": {
"type": "http",
"url": "https://mcp.svelte.dev/mcp"
},
"context7": { "context7": {
"type": "http", "type": "http",
"url": "https://mcp.context7.com/mcp" "url": "https://mcp.context7.com/mcp"
},
"svelte": {
"type": "http",
"url": "https://mcp.svelte.dev/mcp"
} }
} }
} }
+11 -2
View File
@@ -3,8 +3,17 @@ PARTS := $(patsubst %/Makefile,%,$(wildcard */Makefile))
.PHONY: env recreate rebuild restart logs down deploy fmt check build pre-deploy post-deploy .PHONY: env recreate rebuild restart logs down deploy fmt check build pre-deploy post-deploy
env: env:
@test -f .env || awk 'FNR == 1 && NR > 1 { print "" } 1' .env.example $(addsuffix /.env.example,$(PARTS)) > .env @fresh=; test -f .env || { awk 'FNR == 1 && NR > 1 { print "" } 1' .env.example $(addsuffix /.env.example,$(PARTS)) > .env; fresh=1; }; \
@for part in $(PARTS); do test -f $$part/docker-compose.override.yml || cp $$part/docker-compose.override.yml.example $$part/docker-compose.override.yml; done for part in $(PARTS); do \
override=$$part/docker-compose.override.yml; \
test -f $$override && continue; \
cp $$override.example $$override; \
for port in $$(sed -n 's/.*127\.0\.0\.1:\([0-9]*\):.*/\1/p' $$override | sort -u); do \
random=$$((20000 + $$(od -An -N2 -tu2 /dev/urandom) % 40000)); \
sed "s/127\.0\.0\.1:$$port:/127.0.0.1:$$random:/" $$override > $$override.tmp && mv $$override.tmp $$override; \
test -z "$$fresh" || { sed "s|localhost:$$port/|localhost:$$random/|" .env > .env.tmp && mv .env.tmp .env; }; \
done; \
done
recreate: recreate:
docker compose up -d --force-recreate docker compose up -d --force-recreate
+4 -2
View File
@@ -5,10 +5,12 @@ services:
caddy: caddy:
aliases: aliases:
- beavergram-api - beavergram-api
# ports:
# - "127.0.0.1:8080:${API__PORT}"
postgres: postgres:
ports: # ports:
- "127.0.0.1:${DB__PORT:-5432}:5432" # - "127.0.0.1:5432:${DB__PORT}"
networks: networks:
caddy: caddy:
+1
View File
@@ -87,6 +87,7 @@ services:
POSTGRES_USER: ${DB__USER:-beavergram} POSTGRES_USER: ${DB__USER:-beavergram}
POSTGRES_PASSWORD: ${DB__PASSWORD:-beavergram} POSTGRES_PASSWORD: ${DB__PASSWORD:-beavergram}
POSTGRES_DB: ${DB__DB_NAME:-beavergram} POSTGRES_DB: ${DB__DB_NAME:-beavergram}
PGPORT: ${DB__PORT:-5432}
volumes: volumes:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data
- ./backend/migrations/init:/docker-entrypoint-initdb.d:ro - ./backend/migrations/init:/docker-entrypoint-initdb.d:ro
+113
View File
@@ -0,0 +1,113 @@
import asyncio
from pathlib import Path
from typing import Any
import asyncpg
from fastmcp.exceptions import ToolError
from fastmcp.server.dependencies import get_http_request
from fastmcp.utilities.types import Image
from mcp.types import TextContent
from utils.files import expiry_from_seconds
from utils.jobs import enqueue
from utils.read import chats, media, shares
from utils.read.models import FileShareView, MediaView
from utils.storage import ContentAddressedStorage
LINK_TTL_SECONDS = 24 * 3600
MAX_WAIT_SECONDS = 120
_POLL_SECONDS = 1.0
_INLINE_IMAGE_BYTES = 5 * 1024 * 1024
_INLINE_TEXT_BYTES = 256 * 1024
_INLINE_IMAGE_MIMES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
_TEXT_MIMES = {"application/json", "application/xml", "application/x-yaml"}
_FAILED_JOB = ("failed", "canceled")
async def _job_error(pool: asyncpg.Pool, job_id: int) -> str | None:
row = await pool.fetchrow("SELECT status, error FROM jobs WHERE id = $1", job_id)
if row is None or row["status"] in _FAILED_JOB:
return (row and row["error"]) or "download job is gone"
return None
async def fetch_media(
pool: asyncpg.Pool,
account_id: int,
chat_id: int,
message_id: int,
wait_seconds: float,
) -> MediaView | None:
item = await media.get_message_media(pool, account_id, chat_id, message_id)
if item is None:
msg = f"message {chat_id}/{message_id} has no media"
raise ToolError(msg)
if item.downloaded and item.storage_key:
return item
job_id = await enqueue(
pool, account_id, "fetch_media", {"chat_id": chat_id, "message_id": message_id}
)
loop = asyncio.get_running_loop()
deadline = loop.time() + wait_seconds
while loop.time() < deadline:
await asyncio.sleep(_POLL_SECONDS)
item = await media.get_message_media(pool, account_id, chat_id, message_id)
if item is not None and item.downloaded and item.storage_key:
return item
error = await _job_error(pool, job_id)
if error is not None:
msg = f"download failed: {error}"
raise ToolError(msg)
return None
def _origin() -> str:
request = get_http_request()
proto = request.headers.get("x-forwarded-proto", request.url.scheme)
return f"{proto}://{request.url.netloc}"
async def share_media(pool: asyncpg.Pool, item: MediaView, mime: str) -> FileShareView:
storage_key = item.storage_key or ""
share = await shares.find_active_share(pool, item.account_id, storage_key)
if share is None:
chat = await chats.get_chat(pool, item.account_id, item.chat_id)
share = await shares.create_share(
pool,
item.account_id,
"media",
storage_key,
item.file_name or f"media_{item.id}",
mime=mime,
file_size=item.file_size,
title=chat.title if chat else None,
chat_id=item.chat_id,
message_id=item.message_id,
expires_at=expiry_from_seconds(LINK_TTL_SECONDS),
)
return share
def _is_text(mime: str) -> bool:
return mime.startswith("text/") or mime in _TEXT_MIMES
def inline_content(storage: ContentAddressedStorage, key: str, mime: str) -> list[Any]:
size = Path(storage.url(key)).stat().st_size
if mime in _INLINE_IMAGE_MIMES and size <= _INLINE_IMAGE_BYTES:
return [Image(data=storage.get(key), format=mime.rsplit("/", maxsplit=1)[-1])]
if _is_text(mime) and size <= _INLINE_TEXT_BYTES:
text = storage.get(key).decode("utf-8", errors="replace")
return [TextContent(type="text", text=text)]
return []
def describe(item: MediaView, mime: str, share: FileShareView) -> str:
size = f"{item.file_size} bytes" if item.file_size else "size unknown"
expires = share.expires_at.isoformat() if share.expires_at else "never"
return (
f"{item.file_name} ({item.kind}, {mime}, {size})\n"
f"Download: {_origin()}/f/{share.token}/{share.url_name}\n"
f"Public link, expires {expires}; fetch it with curl/wget or any HTTP "
"client, no auth needed."
)
+48 -18
View File
@@ -7,12 +7,19 @@ from fastmcp import FastMCP
from fastmcp.exceptions import ToolError from fastmcp.exceptions import ToolError
from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext
from fastmcp.tools.base import ToolResult from fastmcp.tools.base import ToolResult
from mcp.types import CallToolRequestParams from mcp.types import CallToolRequestParams, TextContent
from pydantic import BaseModel 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 api.mcp.format import build_transcript, load_notes, load_photos, resolve_names
from dependencies.container import container from dependencies.container import container
from utils.jobs import enqueue from utils.files import resolve_mime
from utils.read import annotations, chats, media, peers, presence, social, watches from utils.read import annotations, chats, media, peers, presence, social, watches
from utils.read.accounts import self_user_id, unknown_account_hint from utils.read.accounts import self_user_id, unknown_account_hint
from utils.read.models import DEFAULT_LIMIT, Page from utils.read.models import DEFAULT_LIMIT, Page
@@ -44,7 +51,7 @@ Typical flows:
`before_id` (first id you saw), to newer with `after_id` (last id you saw); `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 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 with its `#message_id`, and photos show as `[photo #message_id]` — pass that
id to `get_media` (with `fetch=True` to download) or to annotation tools. id to `download_media` or to annotation tools.
Lines marked Lines marked
"📝 [your private note, NOT in Telegram]" are the user's own annotations "📝 [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 attached locally via the web UI; they never existed in Telegram. Treat them
@@ -53,8 +60,11 @@ Typical flows:
transcripts; supports chat/sender/date filters and regex). transcripts; supports chat/sender/date filters and regex).
- Forensics: `get_deleted_messages` and `get_message_versions` recover content - Forensics: `get_deleted_messages` and `get_message_versions` recover content
removed or edited in Telegram but kept in the archive. removed or edited in Telegram but kept in the archive.
- Media: `get_media` returns metadata; pass `fetch=True` to enqueue a lazy - Files: `download_media` gets any message attachment (photo, video, voice,
download if the file isn't stored yet. 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, `list_watches` lists rules, and - Monitoring: `set_watch` creates a local rule, `list_watches` lists rules, and
`list_alerts` reads what those rules fired. `list_alerts` reads what those rules fired.
@@ -63,7 +73,8 @@ Watched bots:
own `account_id` and its chats, messages and media read through the same own `account_id` and its chats, messages and media read through the same
tools as a user account. Nothing bot-specific to call. tools as a user account. Nothing bot-specific to call.
Writes: everything is read-only except `set_watch`, the only allowed write. 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`. Paging: list/search tools accept `limit` and `offset`.
""" """
@@ -239,21 +250,40 @@ async def get_message_versions(
@mcp.tool @mcp.tool
async def get_media( async def get_media(
account_id: int, chat_id: int, message_id: int, fetch: bool = False account_id: int, chat_id: int, message_id: int
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
"""Get media metadata for a message; set fetch=True to enqueue lazy download.""" """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() pool = await _pool()
item = await media.get_message_media(pool, account_id, chat_id, message_id) wait = min(max(wait_seconds, 0), MAX_WAIT_SECONDS)
if item is None: item = await fetch_media(pool, account_id, chat_id, message_id, wait)
return None if item is None or item.storage_key is None:
if fetch and not item.downloaded: return [
await enqueue( TextContent(
pool, type="text",
account_id, text="Still downloading from Telegram; call download_media again.",
"fetch_media",
{"chat_id": chat_id, "message_id": message_id},
) )
return item.model_dump(mode="json") ]
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 @mcp.tool
@@ -5,6 +5,8 @@ services:
caddy: caddy:
aliases: aliases:
- beavergram-frontend - beavergram-frontend
# ports:
# - "127.0.0.1:3000:3000"
networks: networks:
caddy: caddy: