diff --git a/README.md b/README.md index b6b1a07..2078051 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # beavergram -A read-only Telegram client that keeps everything. A userbot on your own account logs every incoming message into Postgres, including the ones later deleted or edited, along with media, reactions, presence and stories. A web client reads that archive back, and an MCP server hands it to agents. +A Telegram client that keeps everything and writes nowhere but your own Saved Messages. A userbot on your own account logs every incoming message into Postgres, including the ones later deleted or edited, along with media, reactions, presence and stories. A web client reads that archive back, and an MCP server hands it to agents. Useful on its own: a Telegram where nothing disappears. In the [beaver](https://git.kotikot.com/beaver) setup it is one of the agent's hands - the dispatcher reads chat history by the `chat_id` stored in a person's card. @@ -8,7 +8,7 @@ Useful on its own: a Telegram where nothing disappears. In the [beaver](https:// - **Messages** with edits as versions and deletions as flags, so a message removed in Telegram is still in the archive with its history. - **Media** on disk, content-addressed, with the versions of a replaced photo. -- **Voice** transcribed through Telegram's own transcription (Premium), queued when the quota runs out. +- **Voice** transcribed through Telegram's own transcription (Premium), queued when the quota runs out: your own and the played ones by themselves, any voice or round video on request. - **Presence** as a time series (TimescaleDB), read receipts, stories. - **Capture policy** from your Telegram folders with per-chat overrides: channels nothing, groups messages only, private chats everything, by default. - **Annotations and watches**: your notes on messages, with `#tags` and one list of them all, and rules that fire alerts, both yours to write in the web client. @@ -24,7 +24,11 @@ Nothing is ever deleted from the archive. ## MCP -Read-only: `search_messages_tool`, `list_chats`, `get_chat_history` (a readable transcript, deleted messages included) and `get_chat_history_raw` (the same as JSON), `get_deleted_messages`, `get_feed` (deleted and edited messages and profile changes across chats), `get_message_versions`, `get_media`, `get_callbacks`, `get_read_info`, `presence_history`, `presence_stats`, `get_peer_history`, `get_person`, `get_stories`, `get_annotations`, `list_watches`, `list_alerts`. Two tools write: `set_watch`, and `download_media`, which pulls any attachment and leaves a public link to it for a day, visible and revocable in the web client. There are no send, edit or delete tools and there will not be. +Read: `search_messages_tool`, `list_chats`, `get_chat_history` (a readable transcript, deleted messages included) and `get_chat_history_raw` (the same as JSON), `get_deleted_messages`, `get_feed` (deleted and edited messages and profile changes across chats), `get_message_versions`, `get_media`, `get_callbacks`, `get_read_info`, `presence_history`, `presence_stats`, `get_peer_history`, `get_person`, `get_stories`, `get_annotations`, `list_watches`, `list_alerts`. + +Act: `transcribe_message` asks Telegram for the transcript of any voice or round video, `set_watch` writes a rule, `download_media` pulls any attachment and leaves a public link to it for a day, visible and revocable in the web client. `send_to_saved` is the one thing that sends to Telegram, and only to your own Saved Messages: text, a file by URL (as a voice message when asked), or an archived message, forwarded while it lives and copied from the archive once it is gone. Send any OGG/Opus there as a voice and `transcribe_message` turns Telegram Premium into a free transcriber. There is no tool that edits, deletes or sends anywhere else. + +The same over HTTP: `POST /api/media/transcribe` (`wait` seconds to hold for the text), `GET /api/media/lookup?sha256=` (where these exact bytes appear in the archive), `POST /api/saved` (multipart `account_id`, `text`, `file`, `voice`) and `POST /api/saved/forward`. ## Run diff --git a/backend/src/api/app.py b/backend/src/api/app.py index 0803b64..ee2d4e1 100644 --- a/backend/src/api/app.py +++ b/backend/src/api/app.py @@ -34,6 +34,7 @@ from api.routers import ( policy, presence, profile, + saved, search, shares, social, @@ -113,6 +114,7 @@ app.include_router(annotations.router) app.include_router(watches.router) app.include_router(webhooks.router) app.include_router(shares.router) +app.include_router(saved.router) app.include_router(storage.router) app.include_router(files.router) app.include_router(capture.router) diff --git a/backend/src/api/mcp/server.py b/backend/src/api/mcp/server.py index ed42120..a4810ea 100644 --- a/backend/src/api/mcp/server.py +++ b/backend/src/api/mcp/server.py @@ -19,6 +19,7 @@ from api.mcp.download import ( share_media, ) from api.mcp.format import build_transcript, load_notes, load_photos, resolve_names +from api.saved import FetchError, fetch_file, forward, send_new from api.transcribe import request_transcript from dependencies.container import container from utils.files import resolve_mime @@ -46,7 +47,8 @@ from utils.watch_rules import WatchKind, WatchParamsError, clean_name, normalize INSTRUCTIONS = """\ beavergram archives Telegram data (chats, messages, media, presence, stories, -peer history) into Postgres and exposes it read-only over these tools. +peer history) into Postgres and exposes it over these tools, read-only except +for the few writes listed at the end. Account scoping: - Every tool takes `account_id`. It selects which archived Telegram account to @@ -101,8 +103,10 @@ 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` and the share links -`download_media` creates (visible and revocable in the web UI). +Writes: everything is read-only except `set_watch`, the share links +`download_media` creates (visible and revocable in the web UI), and +`send_to_saved`, the one tool that sends to Telegram: only to the user's own +Saved Messages. Paging: list tools accept `limit` and `offset`; `search_messages_tool` returns `next_cursor` to pass back as `cursor`. @@ -392,6 +396,49 @@ async def transcribe_message( return result.model_dump(mode="json", exclude_none=True) +@mcp.tool +async def send_to_saved( + account_id: int, + text: str | None = None, + url: str | None = None, + voice: bool = False, + chat_id: int | None = None, + message_id: int | None = None, +) -> dict[str, Any]: + """Send something to the account's own Saved Messages. + + The only tool that writes to Telegram, and only to the user's own chat. + - `chat_id` + `message_id`: an archived message. It is forwarded while it + still exists in Telegram; a deleted, self-destructing or unforwardable + one goes as a copy from the archive. `mode` says which. + - `text` and/or `url`: a new message. The server fetches `url` (http or + https, up to 200 MB) and sends it as a file with `text` as the caption; + `voice=true` sends it as a voice message exactly as given, so pass + OGG/Opus. A local file can skip the URL: `POST /api/saved` (multipart + `account_id`, `text`, `voice`, `file`) with this server's bearer. + Returns `status` (`done`, `pending`, `failed` with `error`) and the + `chat_id`/`message_id` of the new message. Any audio can be transcribed + through Telegram Premium: send it with `voice=true`, then call + `transcribe_message` on the returned ids. + """ + pool = await _pool() + if chat_id is not None and message_id is not None: + result = await forward(pool, account_id, chat_id, message_id) + return result.model_dump(mode="json", exclude_none=True) + upload = None + if url is not None: + try: + upload = await fetch_file(url) + except FetchError as exc: + raise ToolError(str(exc)) from exc + if upload is None and not text: + msg = "pass text, url, or chat_id with message_id" + raise ToolError(msg) + storage = await container.get(ContentAddressedStorage) + result = await send_new(pool, storage, account_id, text, upload, voice=voice) + return result.model_dump(mode="json", exclude_none=True) + + @mcp.tool async def get_callbacks( account_id: int, chat_id: int, message_id: int diff --git a/backend/src/api/routers/saved.py b/backend/src/api/routers/saved.py new file mode 100644 index 0000000..2958d0a --- /dev/null +++ b/backend/src/api/routers/saved.py @@ -0,0 +1,44 @@ +from typing import Annotated + +import asyncpg +from dishka.integrations.fastapi import DishkaRoute, FromDishka +from fastapi import APIRouter, File, Form, HTTPException, UploadFile, status +from pydantic import BaseModel + +from api.saved import MAX_UPLOAD_BYTES, SavedResult, forward, send_new +from utils.storage import ContentAddressedStorage + +router = APIRouter(prefix="/api/saved", tags=["saved"], route_class=DishkaRoute) + + +class ForwardRequest(BaseModel): + account_id: int + chat_id: int + message_id: int + + +@router.post("") +async def send_to_saved( + pool: FromDishka[asyncpg.Pool], + storage: FromDishka[ContentAddressedStorage], + account_id: Annotated[int, Form()], + text: Annotated[str | None, Form()] = None, + voice: Annotated[bool, Form()] = False, + file: Annotated[UploadFile | None, File()] = None, +) -> SavedResult: + upload = None + if file is not None: + if file.size is not None and file.size > MAX_UPLOAD_BYTES: + raise HTTPException(status.HTTP_413_CONTENT_TOO_LARGE, "file too large") + data = await file.read() + upload = (data, file.filename or "file", file.content_type) + if upload is None and not text: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "text or file is required") + return await send_new(pool, storage, account_id, text, upload, voice=voice) + + +@router.post("/forward") +async def forward_to_saved( + pool: FromDishka[asyncpg.Pool], body: ForwardRequest +) -> SavedResult: + return await forward(pool, body.account_id, body.chat_id, body.message_id) diff --git a/backend/src/api/saved.py b/backend/src/api/saved.py new file mode 100644 index 0000000..cc6e2ce --- /dev/null +++ b/backend/src/api/saved.py @@ -0,0 +1,131 @@ +import asyncio +import json +from pathlib import Path +from typing import Any, Literal +from urllib.parse import urlsplit + +import asyncpg +import httpx2 +from pydantic import BaseModel + +from utils.jobs import enqueue +from utils.read import accounts +from utils.storage import ContentAddressedStorage + +MAX_UPLOAD_BYTES = 200 * 1024 * 1024 +WAIT_SECONDS = 90.0 +FETCH_TIMEOUT_SECONDS = 120.0 +_POLL_SECONDS = 0.5 + +SavedMode = Literal["sent", "forwarded", "copied"] + + +class SavedResult(BaseModel): + status: Literal["done", "pending", "failed"] + chat_id: int | None = None + message_id: int | None = None + mode: SavedMode | None = None + error: str | None = None + job_id: int | None = None + + +class FetchError(Exception): + pass + + +async def fetch_file(url: str) -> tuple[bytes, str, str | None]: + if urlsplit(url).scheme not in ("http", "https"): + msg = "url must be http or https" + raise FetchError(msg) + chunks: list[bytes] = [] + size = 0 + try: + async with ( + httpx2.AsyncClient( + timeout=FETCH_TIMEOUT_SECONDS, follow_redirects=True + ) as client, + client.stream("GET", url) as response, + ): + response.raise_for_status() + async for chunk in response.aiter_bytes(): + size += len(chunk) + if size > MAX_UPLOAD_BYTES: + msg = "file is over 200 MB" + raise FetchError(msg) + chunks.append(chunk) + except httpx2.HTTPError as exc: + msg = f"could not fetch {url}: {exc}" + raise FetchError(msg) from exc + name = Path(urlsplit(str(response.url)).path).name or "file" + mime = response.headers.get("content-type", "").split(";")[0].strip() or None + return b"".join(chunks), name, mime + + +async def _refusal(pool: asyncpg.Pool, account_id: int) -> SavedResult | None: + account = await accounts.get_account(pool, account_id) + if account is None or account.is_bot: + return SavedResult(status="failed", error="bot_account") + return None + + +async def _outcome(pool: asyncpg.Pool, job_id: int, mode: SavedMode) -> SavedResult: + loop = asyncio.get_running_loop() + deadline = loop.time() + WAIT_SECONDS + while loop.time() < deadline: + await asyncio.sleep(_POLL_SECONDS) + row = await pool.fetchrow( + "SELECT status, error, progress FROM jobs WHERE id = $1", job_id + ) + if row is None or row["status"] in ("failed", "canceled"): + error = (row and row["error"]) or "failed" + return SavedResult(status="failed", error=error, job_id=job_id) + if row["status"] == "done": + sent: dict[str, Any] = json.loads(row["progress"]) + return SavedResult( + status="done", + chat_id=sent.get("chat_id"), + message_id=sent.get("message_id"), + mode=sent.get("mode", mode), + job_id=job_id, + ) + return SavedResult(status="pending", job_id=job_id) + + +async def send_new( # noqa: PLR0913 + pool: asyncpg.Pool, + storage: ContentAddressedStorage, + account_id: int, + text: str | None, + file: tuple[bytes, str, str | None] | None, + *, + voice: bool = False, +) -> SavedResult: + refusal = await _refusal(pool, account_id) + if refusal is not None: + return refusal + params: dict[str, Any] = {"text": text} + if file is not None: + data, name, mime = file + params |= { + "upload": storage.stage(data), + "file_name": name, + "mime": mime, + "voice": voice, + } + job_id = await enqueue(pool, account_id, "send_saved", params) + return await _outcome(pool, job_id, "sent") + + +async def forward( + pool: asyncpg.Pool, account_id: int, chat_id: int, message_id: int +) -> SavedResult: + refusal = await _refusal(pool, account_id) + if refusal is not None: + return refusal + job_id = await enqueue( + pool, + account_id, + "forward_saved", + {"chat_id": chat_id, "message_id": message_id}, + ) + return await _outcome(pool, job_id, "forwarded") diff --git a/backend/src/userbot/handlers/messages.py b/backend/src/userbot/handlers/messages.py index 854a574..5d781d9 100644 --- a/backend/src/userbot/handlers/messages.py +++ b/backend/src/userbot/handlers/messages.py @@ -1,12 +1,7 @@ from pyrogram.types import Message -from userbot.modules.capture import capture_message -from userbot.modules.capture.chat_meta import meta_from_chat +from userbot.modules.capture.ingest import ingest_message from userbot.modules.client import PyroClient -from userbot.modules.stt import is_transcribable -from userbot.modules.stt.gate import safe_transcribe -from userbot.modules.watches.seen import seen_message -from utils.events import notify_bg_event @PyroClient.on_message() @@ -20,21 +15,7 @@ async def on_message(client: PyroClient, message: Message) -> None: or message.date is None ): return - meta = meta_from_chat(message.chat, ctx.contacts.ids) - await ctx.watches.on_message(seen_message(meta.chat_id, message)) - toggles = ctx.resolve(meta) - if not toggles.messages: - return - await capture_message(client, message, ctx, toggles) - await notify_bg_event( - ctx.pool, "message", ctx.account_id, chat_id=meta.chat_id, message_id=message.id - ) - if ( - toggles.stt - and is_transcribable(message) - and (message.outgoing or message.unread_media is False) - ): - await safe_transcribe(client, ctx, meta.chat_id, message.id) + await ingest_message(client, ctx, message) handlers = on_message.handlers # ty: ignore[unresolved-attribute] diff --git a/backend/src/userbot/modules/capture/ingest.py b/backend/src/userbot/modules/capture/ingest.py new file mode 100644 index 0000000..20b6e9b --- /dev/null +++ b/backend/src/userbot/modules/capture/ingest.py @@ -0,0 +1,30 @@ +from pyrogram import Client +from pyrogram.types import Message + +from userbot.modules.capture.chat_meta import meta_from_chat +from userbot.modules.capture.context import CaptureContext +from userbot.modules.capture.message import capture_message +from userbot.modules.stt import is_transcribable +from userbot.modules.stt.gate import safe_transcribe +from userbot.modules.watches.seen import seen_message +from utils.events import notify_bg_event + + +async def ingest_message(client: Client, ctx: CaptureContext, message: Message) -> None: + if message.chat is None: + return + meta = meta_from_chat(message.chat, ctx.contacts.ids) + await ctx.watches.on_message(seen_message(meta.chat_id, message)) + toggles = ctx.resolve(meta) + if not toggles.messages: + return + await capture_message(client, message, ctx, toggles) + await notify_bg_event( + ctx.pool, "message", ctx.account_id, chat_id=meta.chat_id, message_id=message.id + ) + if ( + toggles.stt + and is_transcribable(message) + and (message.outgoing or message.unread_media is False) + ): + await safe_transcribe(client, ctx, meta.chat_id, message.id) diff --git a/backend/src/userbot/modules/jobs/handlers/__init__.py b/backend/src/userbot/modules/jobs/handlers/__init__.py index a3fe7ca..cb01c09 100644 --- a/backend/src/userbot/modules/jobs/handlers/__init__.py +++ b/backend/src/userbot/modules/jobs/handlers/__init__.py @@ -7,6 +7,7 @@ from userbot.modules.jobs.handlers import ( fetch_media, fetch_read_info, search_peers, + send_saved, sync_contacts, sync_dialogs, transcribe, @@ -21,6 +22,7 @@ __all__ = [ "fetch_media", "fetch_read_info", "search_peers", + "send_saved", "sync_contacts", "sync_dialogs", "transcribe", diff --git a/backend/src/userbot/modules/jobs/handlers/send_saved.py b/backend/src/userbot/modules/jobs/handlers/send_saved.py new file mode 100644 index 0000000..8a2e4c8 --- /dev/null +++ b/backend/src/userbot/modules/jobs/handlers/send_saved.py @@ -0,0 +1,32 @@ +from userbot.modules.jobs.context import JobContext +from userbot.modules.jobs.registry import register +from userbot.modules.saved import forward, send_new + + +@register("send_saved") +async def send_saved(ctx: JobContext) -> None: + client = ctx.client + capture = getattr(client, "capture", None) + if client is None or capture is None: + return + sent = await send_new(client, capture, ctx.job.params) + await ctx.report_progress( + {"chat_id": sent.chat.id if sent.chat else None, "message_id": sent.id} + ) + + +@register("forward_saved") +async def forward_saved(ctx: JobContext) -> None: + client = ctx.client + capture = getattr(client, "capture", None) + if client is None or capture is None: + return + params = ctx.job.params + sent, mode = await forward(client, capture, params["chat_id"], params["message_id"]) + await ctx.report_progress( + { + "chat_id": sent.chat.id if sent.chat else None, + "message_id": sent.id, + "mode": mode, + } + ) diff --git a/backend/src/userbot/modules/saved.py b/backend/src/userbot/modules/saved.py new file mode 100644 index 0000000..60c4e0d --- /dev/null +++ b/backend/src/userbot/modules/saved.py @@ -0,0 +1,158 @@ +from io import BytesIO +from typing import Any + +from pyrogram import Client, enums +from pyrogram.errors import FloodPremiumWait, FloodWait, RPCError +from pyrogram.types import Message, MessageEntity + +from userbot.modules.capture.context import CaptureContext +from userbot.modules.capture.ingest import ingest_message +from utils.files import media_file_name +from utils.logging import logger +from utils.read import chats, media +from utils.read.models import EntityView, MessageView + +SAVED = "me" +PLAIN = enums.ParseMode.DISABLED +PHOTO_MIMES = ("image/jpeg", "image/png", "image/webp") + + +class SavedError(Exception): + pass + + +def _file(data: bytes, name: str) -> BytesIO: + file = BytesIO(data) + file.name = name + return file + + +def _entity(view: EntityView) -> MessageEntity | None: + kind = enums.MessageEntityType.__members__.get(view.type.upper()) + if kind is None or kind is enums.MessageEntityType.TEXT_MENTION: + return None + return MessageEntity( + type=kind, + offset=view.offset, + length=view.length, + url=view.url, + language=view.language, + custom_emoji_id=view.custom_emoji_id, + ) + + +def _entities(view: MessageView) -> list[MessageEntity] | None: + found = [entity for entity in map(_entity, view.entities) if entity is not None] + return found or None + + +async def _send_file( # noqa: PLR0911 + client: Client, + kind: str, + file: BytesIO, + caption: str | None, + entities: list[MessageEntity] | None, +) -> Message | None: + text: dict[str, Any] = { + "caption": caption or "", + "caption_entities": entities, + "parse_mode": PLAIN, + } + match kind: + case "photo": + return await client.send_photo(SAVED, file, **text) + case "video": + return await client.send_video(SAVED, file, **text) + case "voice": + return await client.send_voice(SAVED, file, **text) + case "audio": + return await client.send_audio(SAVED, file, **text) + case "animation": + return await client.send_animation(SAVED, file, **text) + case "video_note": + return await client.send_video_note(SAVED, file) + case "sticker": + return await client.send_sticker(SAVED, file) + return await client.send_document(SAVED, file, file_name=file.name, **text) + + +def _upload_kind(mime: str | None, *, voice: bool) -> str: + if voice: + return "voice" + if mime in PHOTO_MIMES: + return "photo" + if mime and mime.startswith("video/"): + return "video" + return "document" + + +async def send_new( + client: Client, ctx: CaptureContext, params: dict[str, Any] +) -> Message: + text = params.get("text") or None + upload = params.get("upload") + if upload is None: + if not text: + msg = "nothing_to_send" + raise SavedError(msg) + sent = await client.send_message(SAVED, text, parse_mode=PLAIN) + else: + data = ctx.storage.staged(upload).read_bytes() + kind = _upload_kind(params.get("mime"), voice=bool(params.get("voice"))) + sent = await _send_file( + client, kind, _file(data, params.get("file_name") or upload), text, None + ) + ctx.storage.unstage(upload) + return await _keep(client, ctx, sent) + + +async def _copy( + client: Client, ctx: CaptureContext, view: MessageView +) -> Message | None: + entities = _entities(view) + item = None + if view.has_media and view.web_page is None: + item = await media.get_message_media( + ctx.pool, ctx.account_id, view.chat_id, view.message_id + ) + if item is None: + if not view.text: + msg = "nothing_to_copy" + raise SavedError(msg) + return await client.send_message( + SAVED, view.text, entities=entities, parse_mode=PLAIN + ) + key = item.storage_key + if not (item.downloaded and key and ctx.storage.exists(key)): + msg = "media_missing" + raise SavedError(msg) + name = media_file_name(item.kind, item.mime, view.message_id, item.file_name) + return await _send_file( + client, item.kind, _file(ctx.storage.get(key), name), view.text, entities + ) + + +async def forward( + client: Client, ctx: CaptureContext, chat_id: int, message_id: int +) -> tuple[Message, str]: + view = await chats.get_message(ctx.pool, ctx.account_id, chat_id, message_id) + if view is None: + msg = "not_archived" + raise SavedError(msg) + if view.deleted_at is None and not view.is_self_destruct: + try: + sent = await client.forward_messages(SAVED, chat_id, message_id) + return await _keep(client, ctx, sent), "forwarded" + except (FloodWait, FloodPremiumWait): + raise + except RPCError as exc: + logger.info(f"[yellow]Forward {chat_id}/{message_id} refused: {exc}[/]") + return await _keep(client, ctx, await _copy(client, ctx, view)), "copied" + + +async def _keep(client: Client, ctx: CaptureContext, sent: Message | None) -> Message: + if sent is None: + msg = "not_sent" + raise SavedError(msg) + await ingest_message(client, ctx, sent) + return sent diff --git a/backend/src/utils/storage.py b/backend/src/utils/storage.py index 8e5d650..5ddaee0 100644 --- a/backend/src/utils/storage.py +++ b/backend/src/utils/storage.py @@ -1,7 +1,9 @@ import hashlib from pathlib import Path +from uuid import uuid4 TRASH_DIR = ".trash" +UPLOADS_DIR = ".uploads" class ContentAddressedStorage: @@ -34,6 +36,19 @@ class ContentAddressedStorage: def url(self, key: str) -> str: return str(self._path(key)) + def staged(self, name: str) -> Path: + return self._root / UPLOADS_DIR / Path(name).name + + def stage(self, data: bytes) -> str: + name = uuid4().hex + path = self.staged(name) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + return name + + def unstage(self, name: str) -> None: + self.staged(name).unlink(missing_ok=True) + def _trash_path(self, key: str) -> Path: return self._root / TRASH_DIR / key diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index 891c6b7..7a3a5c3 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -95,6 +95,18 @@ export async function request( return response.json() as Promise; } +export async function requestForm(path: string, form: FormData): Promise { + const response = await fetch(`${BASE}${path}`, { + body: form, + headers: authHeaders(), + method: "POST", + }); + if (!response.ok) { + return handleError(response); + } + return response.json() as Promise; +} + function streamUrl(path: string): string { const query = auth.token ? `?token=${encodeURIComponent(auth.token)}` : ""; return `${BASE}${path}${query}`; diff --git a/frontend/src/lib/api/endpoints.ts b/frontend/src/lib/api/endpoints.ts index cbd7c98..7741815 100644 --- a/frontend/src/lib/api/endpoints.ts +++ b/frontend/src/lib/api/endpoints.ts @@ -1,4 +1,4 @@ -import { request } from "#lib/api/client.js"; +import { request, requestForm } from "#lib/api/client.js"; import type { Account, Annotation, @@ -597,6 +597,47 @@ export function transcribeMedia( }); } +export interface SavedResult { + chat_id: number | null; + error: string | null; + message_id: number | null; + mode: "copied" | "forwarded" | "sent" | null; + status: "done" | "failed" | "pending"; +} + +export interface SavedDraft { + file?: File; + text?: string; + voice?: boolean; +} + +export function sendToSaved(draft: SavedDraft): Promise { + const form = new FormData(); + form.set("account_id", String(accounts.selectedId)); + if (draft.text) { + form.set("text", draft.text); + } + if (draft.file) { + form.set("file", draft.file); + form.set("voice", String(Boolean(draft.voice))); + } + return requestForm("/saved", form); +} + +export function forwardToSaved( + chatId: number, + messageId: number +): Promise { + return request("/saved/forward", { + body: { + account_id: accounts.selectedId, + chat_id: chatId, + message_id: messageId, + }, + method: "POST", + }); +} + export function listAnnotations( options: { chatId?: number; messageId?: number } = {} ): Promise { diff --git a/frontend/src/lib/components/MessageBubble.svelte b/frontend/src/lib/components/MessageBubble.svelte index 340ba99..462d30c 100644 --- a/frontend/src/lib/components/MessageBubble.svelte +++ b/frontend/src/lib/components/MessageBubble.svelte @@ -25,6 +25,7 @@ import { chats } from "#lib/stores/chats.svelte.js"; import { notes } from "#lib/stores/notes.svelte.js"; import { peers } from "#lib/stores/peers.svelte.js"; + import { savedUi } from "#lib/stores/saved.svelte.js"; import { ui } from "#lib/stores/ui.svelte.js"; import { appear } from "#lib/transitions/appear.js"; import { goto } from "$app/navigation"; @@ -331,6 +332,15 @@ >{t("thread.toReply")} {/if} + {#if savedUi.available && !savedUi.isSavedChat(message.chat_id)} + savedUi.forward(message.chat_id, message.message_id)} + >{message.deleted_at || message.is_self_destruct + ? t("saved.copy") + : t("saved.forward")} + {/if} + import { Dialog } from "bits-ui"; + import Button from "#lib/components/ui/Button.svelte"; + import Icon from "#lib/components/ui/Icon.svelte"; + import { formatBytes } from "#lib/format/media.js"; + import { t } from "#lib/i18n/locale.svelte.js"; + import { savedUi } from "#lib/stores/saved.svelte.js"; + + const VOICE_NAME = /\.(ogg|oga|opus)$/i; + const MAX_INPUT_HEIGHT = 240; + + let text = $state(""); + let sending = $state(false); + let file = $state(null); + let caption = $state(""); + let voice = $state(false); + let picker = $state(); + let input = $state(); + + const dialogOpen = $derived(file !== null); + const isAudio = $derived( + file !== null && + (file.type.startsWith("audio/") || VOICE_NAME.test(file.name)) + ); + const preview = $derived( + file?.type.startsWith("image/") ? URL.createObjectURL(file) : null + ); + + $effect(() => { + const url = preview; + return () => { + if (url) { + URL.revokeObjectURL(url); + } + }; + }); + + function fit() { + if (!input) { + return; + } + input.style.height = "auto"; + input.style.height = `${Math.min(input.scrollHeight, MAX_INPUT_HEIGHT)}px`; + } + + async function sendText() { + const message = text.trim(); + if (!message || sending) { + return; + } + sending = true; + if (await savedUi.send({ text: message })) { + text = ""; + requestAnimationFrame(fit); + } + sending = false; + } + + async function sendFile() { + if (!file || sending) { + return; + } + sending = true; + const sent = await savedUi.send({ + file, + text: caption.trim(), + voice: isAudio && voice, + }); + sending = false; + if (sent) { + file = null; + caption = ""; + } + } + + function onkeydown(event: KeyboardEvent) { + if (event.key === "Enter" && !event.shiftKey && !event.isComposing) { + event.preventDefault(); + sendText(); + } + } + + function onpick() { + const picked = picker?.files?.[0] ?? null; + if (picker) { + picker.value = ""; + } + if (!picked) { + return; + } + file = picked; + caption = text.trim(); + text = ""; + voice = VOICE_NAME.test(picked.name) || picked.type === "audio/ogg"; + requestAnimationFrame(fit); + } + + function onOpenChange(open: boolean) { + if (!(open || sending)) { + if (!text) { + text = caption; + } + file = null; + caption = ""; + requestAnimationFrame(fit); + } + } + + +
+
+ + + +
+ +
+ + + + + +
+ {t("saved.sendFile")} + + + +
+
+ {#if preview} + {file?.name} + {/if} +
+ + + + + {file?.name} + {formatBytes(file?.size ?? null)} + +
+ + {#if isAudio} + + {/if} +
+
+ + {#snippet child({ + props, + })} + + {/snippet} + + +
+
+
+
+ + diff --git a/frontend/src/lib/components/media/AudioFile.svelte b/frontend/src/lib/components/media/AudioFile.svelte index 96da391..9df6a9f 100644 --- a/frontend/src/lib/components/media/AudioFile.svelte +++ b/frontend/src/lib/components/media/AudioFile.svelte @@ -88,7 +88,7 @@ padding: 0.1875rem 0; &.own { - --active: var(--color-white); + --active: var(--color-accent-own); --toggle-fg: var(--color-background-own); } } diff --git a/frontend/src/lib/components/media/VideoNote.svelte b/frontend/src/lib/components/media/VideoNote.svelte index 0ee4e54..3d35197 100644 --- a/frontend/src/lib/components/media/VideoNote.svelte +++ b/frontend/src/lib/components/media/VideoNote.svelte @@ -106,8 +106,8 @@ {/if} {:else} - - + + {/if} @@ -246,6 +246,13 @@ color: var(--color-white); filter: drop-shadow(0 1px 4px rgba(0, 0, 0, 0.5)); + + &.fetch { + padding: 0.875rem; + border-radius: 50%; + background-color: rgba(0, 0, 0, 0.45); + filter: none; + } } .badge { diff --git a/frontend/src/lib/components/media/VoiceMessage.svelte b/frontend/src/lib/components/media/VoiceMessage.svelte index 269c4a6..0653305 100644 --- a/frontend/src/lib/components/media/VoiceMessage.svelte +++ b/frontend/src/lib/components/media/VoiceMessage.svelte @@ -169,11 +169,11 @@ --transcribe-bg: var(--color-primary-tint); &.own { - --active: var(--color-white); - --inactive: rgba(255, 255, 255, 0.45); + --active: var(--color-accent-own); + --inactive: color-mix(in srgb, var(--color-accent-own) 40%, transparent); --toggle-fg: var(--color-background-own); - --voice-meta: rgba(255, 255, 255, 0.75); - --transcribe-bg: rgba(255, 255, 255, 0.2); + --voice-meta: var(--color-message-meta-own); + --transcribe-bg: var(--color-voice-transcribe-button-own); } } diff --git a/frontend/src/lib/i18n/messages.ts b/frontend/src/lib/i18n/messages.ts index 7e4023e..56dd333 100644 --- a/frontend/src/lib/i18n/messages.ts +++ b/frontend/src/lib/i18n/messages.ts @@ -15,6 +15,7 @@ import { policy } from "#lib/i18n/messages/policy.js"; import { presence } from "#lib/i18n/messages/presence.js"; import { profile } from "#lib/i18n/messages/profile.js"; import { read } from "#lib/i18n/messages/read.js"; +import { saved } from "#lib/i18n/messages/saved.js"; import { search } from "#lib/i18n/messages/search.js"; import { service } from "#lib/i18n/messages/service.js"; import { settings } from "#lib/i18n/messages/settings.js"; @@ -45,6 +46,7 @@ export const messages = { ...presence, ...profile, ...read, + ...saved, ...search, ...service, ...settings, diff --git a/frontend/src/lib/i18n/messages/saved.ts b/frontend/src/lib/i18n/messages/saved.ts new file mode 100644 index 0000000..22b6e9c --- /dev/null +++ b/frontend/src/lib/i18n/messages/saved.ts @@ -0,0 +1,36 @@ +import type { Messages } from "#lib/i18n/types.js"; + +export const saved = { + "saved.asVoice": { en: "As a voice message", ru: "Голосовым сообщением" }, + "saved.asVoiceHint": { + en: "Sent exactly as is, so give it OGG/Opus", + ru: "Уходит как есть, нужен OGG/Opus", + }, + "saved.attach": { en: "Attach a file", ru: "Прикрепить файл" }, + "saved.caption": { en: "Caption", ru: "Подпись" }, + "saved.copied": { + en: "Copy sent to Saved Messages", + ru: "Копия в Избранном", + }, + "saved.copy": { en: "Copy to Saved Messages", ru: "Копию в Избранное" }, + "saved.failed": { + en: "Could not send to Saved Messages", + ru: "Не удалось отправить в Избранное", + }, + "saved.forward": { en: "To Saved Messages", ru: "В Избранное" }, + "saved.forwarded": { + en: "Forwarded to Saved Messages", + ru: "Переслано в Избранное", + }, + "saved.mediaMissing": { + en: "The file is not in the archive, there is nothing to copy", + ru: "Файла нет в архиве, копировать нечего", + }, + "saved.message": { en: "Message", ru: "Сообщение" }, + "saved.pending": { + en: "Telegram is still sending, it will show up shortly", + ru: "Telegram ещё отправляет, сообщение скоро появится", + }, + "saved.send": { en: "Send", ru: "Отправить" }, + "saved.sendFile": { en: "Send a file", ru: "Отправить файл" }, +} satisfies Messages; diff --git a/frontend/src/lib/stores/saved.svelte.ts b/frontend/src/lib/stores/saved.svelte.ts new file mode 100644 index 0000000..df16e4f --- /dev/null +++ b/frontend/src/lib/stores/saved.svelte.ts @@ -0,0 +1,59 @@ +import { + forwardToSaved, + type SavedDraft, + type SavedResult, + sendToSaved, +} from "#lib/api/endpoints.js"; +import { t } from "#lib/i18n/locale.svelte.js"; +import type { MessageKey } from "#lib/i18n/messages.js"; +import { accounts } from "#lib/stores/accounts.svelte.js"; +import { toasts } from "#lib/stores/toasts.svelte.js"; + +const REFUSALS: Record = { + media_missing: "saved.mediaMissing", +}; + +function report(result: SavedResult): boolean { + if (result.status === "done") { + return true; + } + if (result.status === "pending") { + toasts.show(t("saved.pending")); + return true; + } + toasts.error(t(REFUSALS[result.error ?? ""] ?? "saved.failed")); + return false; +} + +export const savedUi = { + get available(): boolean { + const account = accounts.selected; + return account !== null && !account.is_bot && account.tg_user_id !== null; + }, + + async forward(chatId: number, messageId: number): Promise { + try { + const result = await forwardToSaved(chatId, messageId); + if (report(result) && result.status === "done") { + toasts.show( + t(result.mode === "copied" ? "saved.copied" : "saved.forwarded") + ); + } + } catch { + toasts.error(t("saved.failed")); + } + }, + + isSavedChat(chatId: number): boolean { + return this.available && accounts.selected?.tg_user_id === chatId; + }, + + async send(draft: SavedDraft): Promise { + try { + return report(await sendToSaved(draft)); + } catch { + toasts.error(t("saved.failed")); + return false; + } + }, +}; diff --git a/frontend/src/routes/app/[chatId]/+page.svelte b/frontend/src/routes/app/[chatId]/+page.svelte index 3488169..4a3806d 100644 --- a/frontend/src/routes/app/[chatId]/+page.svelte +++ b/frontend/src/routes/app/[chatId]/+page.svelte @@ -2,9 +2,11 @@ import { untrack } from "svelte"; import ChatHeader from "#lib/components/ChatHeader.svelte"; import MessageList from "#lib/components/MessageList.svelte"; + import SavedComposer from "#lib/components/SavedComposer.svelte"; import { accounts } from "#lib/stores/accounts.svelte.js"; import { chats } from "#lib/stores/chats.svelte.js"; import { MARK_PARAM, withoutMessage } from "#lib/stores/feed.svelte.js"; + import { savedUi } from "#lib/stores/saved.svelte.js"; import { ui } from "#lib/stores/ui.svelte.js"; import { goto } from "$app/navigation"; import { page } from "$app/state"; @@ -44,6 +46,9 @@
+ {#if savedUi.isSavedChat(chatId)} + + {/if} {/key}