feat(backend,frontend,mcp): saved messages take text, files, voices and archived messages, the one write to telegram

This commit is contained in:
hh committed 2026-10-28 01:53:48 +00:00
1 parent 2dd4e589c3
commit 58b4346166
22 files changed
+1091 -35

No files matched your search

+7 -3
View File
@@ -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
+2
View File
@@ -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)
+50 -3
View File
@@ -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
+44
View File
@@ -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)
+131
View File
@@ -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")
+2 -21
View File
@@ -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]
@@ -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)
@@ -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",
@@ -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,
}
)
+158
View File
@@ -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
+15
View File
@@ -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
+12
View File
@@ -95,6 +95,18 @@ export async function request<T>(
return response.json() as Promise<T>;
}
export async function requestForm<T>(path: string, form: FormData): Promise<T> {
const response = await fetch(`${BASE}${path}`, {
body: form,
headers: authHeaders(),
method: "POST",
});
if (!response.ok) {
return handleError(response);
}
return response.json() as Promise<T>;
}
function streamUrl(path: string): string {
const query = auth.token ? `?token=${encodeURIComponent(auth.token)}` : "";
return `${BASE}${path}${query}`;
+42 -1
View File
@@ -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<SavedResult> {
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<SavedResult>("/saved", form);
}
export function forwardToSaved(
chatId: number,
messageId: number
): Promise<SavedResult> {
return request<SavedResult>("/saved/forward", {
body: {
account_id: accounts.selectedId,
chat_id: chatId,
message_id: messageId,
},
method: "POST",
});
}
export function listAnnotations(
options: { chatId?: number; messageId?: number } = {}
): Promise<Annotation[]> {
@@ -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")}</ContextMenuItem
>
{/if}
{#if savedUi.available && !savedUi.isSavedChat(message.chat_id)}
<ContextMenuItem
icon="forward"
onselect={() => savedUi.forward(message.chat_id, message.message_id)}
>{message.deleted_at || message.is_self_destruct
? t("saved.copy")
: t("saved.forward")}</ContextMenuItem
>
{/if}
<CopyItem
done={t("chat.menu.linkCopied")}
icon="link"
@@ -0,0 +1,438 @@
<script lang="ts">
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<File | null>(null);
let caption = $state("");
let voice = $state(false);
let picker = $state<HTMLInputElement>();
let input = $state<HTMLTextAreaElement>();
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);
}
}
</script>
<div class="SavedComposer">
<div class="field">
<textarea
aria-label={t("saved.message")}
oninput={fit}
{onkeydown}
placeholder={t("saved.message")}
rows="1"
bind:this={input}
bind:value={text}
></textarea>
<button
aria-label={t("saved.attach")}
class="attach"
onclick={() => picker?.click()}
type="button"
>
<Icon name="attach" size="1.5rem" />
</button>
<input hidden onchange={onpick} type="file" bind:this={picker}>
</div>
<Button
aria-label={t("saved.send")}
class="send"
disabled={!text.trim()}
loading={sending && !file}
onclick={sendText}
round
>
<Icon name="send" size="1.5rem" />
</Button>
</div>
<Dialog.Root {onOpenChange} open={dialogOpen}>
<Dialog.Portal>
<Dialog.Overlay class="dialog-overlay" />
<Dialog.Content class="dialog-content">
<header class="dialog-head">
<Dialog.Title class="dialog-title">{t("saved.sendFile")}</Dialog.Title>
<Dialog.Close aria-label={t("common.close")} class="dialog-close">
<Icon name="close" size="1.25rem" />
</Dialog.Close>
</header>
<div class="dialog-body">
{#if preview}
<img alt={file?.name} class="preview" src={preview}>
{/if}
<div class="file">
<span class="tile">
<Icon name={isAudio ? "microphone" : "document"} size="1.5rem" />
</span>
<span class="meta">
<span class="name">{file?.name}</span>
<span class="size">{formatBytes(file?.size ?? null)}</span>
</span>
</div>
<textarea
aria-label={t("saved.caption")}
class="caption"
placeholder={t("saved.caption")}
rows="2"
bind:value={caption}
></textarea>
{#if isAudio}
<button
aria-checked={voice}
class="toggle-row"
onclick={() => {
voice = !voice;
}}
role="switch"
type="button"
>
<span class="label">
<span>{t("saved.asVoice")}</span>
<small>{t("saved.asVoiceHint")}</small>
</span>
<span class="switch" class:on={voice}>
<span class="knob"></span>
</span>
</button>
{/if}
</div>
<div class="dialog-actions">
<Dialog.Close>
{#snippet child({
props,
})}
<Button {...props} pill variant="text">
{t("common.cancel")}
</Button>
{/snippet}
</Dialog.Close>
<Button disabled={sending} loading={sending} onclick={sendFile} pill>
{t("saved.send")}
</Button>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<style lang="scss">
.SavedComposer {
display: flex;
flex-shrink: 0;
align-items: flex-end;
gap: 0.5rem;
padding: 0.5rem max(1rem, calc((100% - var(--messages-container-width)) / 2))
1.25rem;
:global(.send) {
flex-shrink: 0;
width: 3.5rem;
height: 3.5rem;
}
}
.field {
display: flex;
flex: 1;
align-items: flex-end;
min-width: 0;
min-height: 3.5rem;
padding: 0 0.5rem 0 1rem;
border-radius: var(--border-radius-messages);
background-color: var(--color-background);
box-shadow: 0 1px 2px var(--color-default-shadow);
}
textarea {
resize: none;
flex: 1;
min-width: 0;
margin: 0;
padding: 1rem 0;
border: 0;
font: inherit;
font-size: 1rem;
line-height: 1.375;
color: var(--color-text);
background: transparent;
outline: none;
&::placeholder {
color: var(--color-placeholders);
}
}
.attach {
cursor: pointer;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
margin-bottom: 0.5rem;
border: 0;
border-radius: 50%;
color: var(--color-composer-button);
background: transparent;
transition: color 0.15s ease;
&:focus-visible {
outline: 2px solid var(--color-primary);
}
@media (hover: hover) {
&:hover {
color: var(--color-primary);
}
}
}
.preview {
display: block;
max-width: 100%;
max-height: 16rem;
margin: 0 auto 1rem;
border-radius: var(--border-radius-default-small);
object-fit: contain;
}
.file {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
}
.tile {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
border-radius: 50%;
color: var(--color-white);
background-color: var(--color-primary);
}
.meta {
display: flex;
flex-direction: column;
min-width: 0;
}
.name {
overflow: hidden;
font-size: 0.9375rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.size {
font-size: 0.875rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
}
.caption {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid var(--color-borders-input);
border-radius: var(--border-radius-default);
&:focus {
border-color: var(--color-primary);
box-shadow: inset 0 0 0 1px var(--color-primary);
}
}
.toggle-row {
cursor: pointer;
display: flex;
align-items: center;
gap: 1rem;
width: calc(100% + 2.5rem);
margin: 0.75rem -1.25rem 0;
padding: 0.5rem 1.25rem;
border: 0;
font-size: 0.9375rem;
text-align: start;
color: var(--color-text);
background-color: transparent;
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
}
.label {
display: flex;
flex: 1;
flex-direction: column;
small {
font-size: 0.8125rem;
color: var(--color-text-secondary);
}
}
.switch {
flex-shrink: 0;
display: flex;
align-items: center;
width: 2.25rem;
height: 1.375rem;
padding: 0.125rem;
border-radius: 0.6875rem;
background-color: var(--color-borders);
transition: background-color 0.15s ease;
&.on {
background-color: var(--color-primary);
}
}
.knob {
width: 1.125rem;
height: 1.125rem;
border-radius: 50%;
background-color: var(--color-white);
transition: transform 0.15s ease;
.on & {
transform: translateX(0.875rem);
}
}
@media (pointer: coarse) {
.SavedComposer {
padding-bottom: max(0.75rem, env(safe-area-inset-bottom));
}
}
</style>
@@ -88,7 +88,7 @@
padding: 0.1875rem 0;
&.own {
--active: var(--color-white);
--active: var(--color-accent-own);
--toggle-fg: var(--color-background-own);
}
}
@@ -106,8 +106,8 @@
{/if}
{:else}
<span class="blank"></span>
<span class="play">
<Icon name={waiting ? "timer" : "download"} size="2.25rem" />
<span class="play fetch">
<Icon name={waiting ? "timer" : "download"} size="1.75rem" />
</span>
{/if}
<span class="badge">
@@ -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 {
@@ -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);
}
}
+2
View File
@@ -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,
+36
View File
@@ -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;
+59
View File
@@ -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<string, MessageKey> = {
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<void> {
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<boolean> {
try {
return report(await sendToSaved(draft));
} catch {
toasts.error(t("saved.failed"));
return false;
}
},
};
@@ -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 @@
<div class="chat-body">
<MessageList {chatId} />
</div>
{#if savedUi.isSavedChat(chatId)}
<SavedComposer />
{/if}
{/key}
</div>