feat(backend,frontend,mcp): saved messages take text, files, voices and archived messages, the one write to telegram
This commit is contained in:
1 parent
2dd4e589c3
commit
58b4346166
22 files changed
+1091
-35
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in new issue
Block a user