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
|
_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,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
@@ -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
|
||||||
|
|||||||
@@ -6,3 +6,6 @@ docker-compose.override.yml
|
|||||||
.idea
|
.idea
|
||||||
docs
|
docs
|
||||||
storage
|
storage
|
||||||
|
|
||||||
|
# Local playground
|
||||||
|
t/
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"svelte": {
|
"context7": {
|
||||||
"type": "http",
|
"type": "http",
|
||||||
"url": "https://mcp.svelte.dev/mcp"
|
"url": "https://mcp.context7.com/mcp"
|
||||||
},
|
},
|
||||||
"context7": {
|
"svelte": {
|
||||||
"type": "http",
|
"type": "http",
|
||||||
"url": "https://mcp.context7.com/mcp"
|
"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
|
.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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.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},
|
]
|
||||||
)
|
mime = resolve_mime(item.kind, item.mime, item.file_name)
|
||||||
return item.model_dump(mode="json")
|
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:
|
||||||
|
|||||||
Reference in new issue
Block a user