Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
283eab1eb0 | ||
|
|
7f4d500302 | ||
|
|
cf48d2db3e | ||
|
|
114058e6ae |
No files matched your search
+1
-1
@@ -1,4 +1,4 @@
|
||||
_commit: v0.3.3
|
||||
_commit: v0.4.0
|
||||
_src_path: https://git.kotikot.com/templates/backend-python.git
|
||||
author_email: h@kotikot.com
|
||||
author_name: h
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
_commit: v0.3.1
|
||||
_commit: v0.4.2
|
||||
_src_path: https://git.kotikot.com/templates/frontend-svelte.git
|
||||
include_impeccable: false
|
||||
install_deps: false
|
||||
layout: part
|
||||
project_name: beavergram
|
||||
|
||||
+4
-1
@@ -1,7 +1,10 @@
|
||||
_commit: v0.4.0
|
||||
_commit: v0.5.0
|
||||
_src_path: https://git.kotikot.com/templates/infra.git
|
||||
init_git: false
|
||||
layout: parts
|
||||
mcp_servers:
|
||||
- context7
|
||||
- svelte
|
||||
parts:
|
||||
- backend
|
||||
- frontend
|
||||
|
||||
@@ -6,3 +6,6 @@ docker-compose.override.yml
|
||||
.idea
|
||||
docs
|
||||
storage
|
||||
|
||||
# Local playground
|
||||
t/
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"svelte": {
|
||||
"type": "http",
|
||||
"url": "https://mcp.svelte.dev/mcp"
|
||||
},
|
||||
"context7": {
|
||||
"type": "http",
|
||||
"url": "https://mcp.context7.com/mcp"
|
||||
}
|
||||
}
|
||||
"mcpServers": {
|
||||
"context7": {
|
||||
"type": "http",
|
||||
"url": "https://mcp.context7.com/mcp"
|
||||
},
|
||||
"svelte": {
|
||||
"type": "http",
|
||||
"url": "https://mcp.svelte.dev/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,17 @@ PARTS := $(patsubst %/Makefile,%,$(wildcard */Makefile))
|
||||
.PHONY: env recreate rebuild restart logs down deploy fmt check build pre-deploy post-deploy
|
||||
|
||||
env:
|
||||
@test -f .env || awk 'FNR == 1 && NR > 1 { print "" } 1' .env.example $(addsuffix /.env.example,$(PARTS)) > .env
|
||||
@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
|
||||
@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 \
|
||||
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:
|
||||
docker compose up -d --force-recreate
|
||||
|
||||
@@ -5,10 +5,12 @@ services:
|
||||
caddy:
|
||||
aliases:
|
||||
- beavergram-api
|
||||
# ports:
|
||||
# - "127.0.0.1:8080:${API__PORT}"
|
||||
|
||||
postgres:
|
||||
ports:
|
||||
- "127.0.0.1:${DB__PORT:-5432}:5432"
|
||||
# ports:
|
||||
# - "127.0.0.1:5432:${DB__PORT}"
|
||||
|
||||
networks:
|
||||
caddy:
|
||||
|
||||
@@ -87,6 +87,7 @@ services:
|
||||
POSTGRES_USER: ${DB__USER:-beavergram}
|
||||
POSTGRES_PASSWORD: ${DB__PASSWORD:-beavergram}
|
||||
POSTGRES_DB: ${DB__DB_NAME:-beavergram}
|
||||
PGPORT: ${DB__PORT:-5432}
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
- ./backend/migrations/init:/docker-entrypoint-initdb.d:ro
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
@@ -7,12 +7,19 @@ 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
|
||||
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.jobs import enqueue
|
||||
from utils.files import resolve_mime
|
||||
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.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);
|
||||
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 `get_media` (with `fetch=True` to download) or to annotation tools.
|
||||
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
|
||||
@@ -53,8 +60,11 @@ Typical flows:
|
||||
transcripts; supports chat/sender/date filters and regex).
|
||||
- Forensics: `get_deleted_messages` and `get_message_versions` recover content
|
||||
removed or edited in Telegram but kept in the archive.
|
||||
- Media: `get_media` returns metadata; pass `fetch=True` to enqueue a lazy
|
||||
download if the file isn't stored yet.
|
||||
- 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, `list_watches` lists rules, and
|
||||
`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
|
||||
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`.
|
||||
"""
|
||||
@@ -239,21 +250,40 @@ async def get_message_versions(
|
||||
|
||||
@mcp.tool
|
||||
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:
|
||||
"""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()
|
||||
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")
|
||||
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
|
||||
|
||||
@@ -5,6 +5,8 @@ services:
|
||||
caddy:
|
||||
aliases:
|
||||
- beavergram-frontend
|
||||
# ports:
|
||||
# - "127.0.0.1:3000:3000"
|
||||
|
||||
networks:
|
||||
caddy:
|
||||
|
||||
Reference in new issue
Block a user