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
|
# 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.
|
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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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
|
## 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
|
## Run
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ from api.routers import (
|
|||||||
policy,
|
policy,
|
||||||
presence,
|
presence,
|
||||||
profile,
|
profile,
|
||||||
|
saved,
|
||||||
search,
|
search,
|
||||||
shares,
|
shares,
|
||||||
social,
|
social,
|
||||||
@@ -113,6 +114,7 @@ app.include_router(annotations.router)
|
|||||||
app.include_router(watches.router)
|
app.include_router(watches.router)
|
||||||
app.include_router(webhooks.router)
|
app.include_router(webhooks.router)
|
||||||
app.include_router(shares.router)
|
app.include_router(shares.router)
|
||||||
|
app.include_router(saved.router)
|
||||||
app.include_router(storage.router)
|
app.include_router(storage.router)
|
||||||
app.include_router(files.router)
|
app.include_router(files.router)
|
||||||
app.include_router(capture.router)
|
app.include_router(capture.router)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from api.mcp.download import (
|
|||||||
share_media,
|
share_media,
|
||||||
)
|
)
|
||||||
from api.mcp.format import build_transcript, load_notes, load_photos, resolve_names
|
from api.mcp.format import build_transcript, load_notes, load_photos, resolve_names
|
||||||
|
from api.saved import FetchError, fetch_file, forward, send_new
|
||||||
from api.transcribe import request_transcript
|
from api.transcribe import request_transcript
|
||||||
from dependencies.container import container
|
from dependencies.container import container
|
||||||
from utils.files import resolve_mime
|
from utils.files import resolve_mime
|
||||||
@@ -46,7 +47,8 @@ from utils.watch_rules import WatchKind, WatchParamsError, clean_name, normalize
|
|||||||
|
|
||||||
INSTRUCTIONS = """\
|
INSTRUCTIONS = """\
|
||||||
beavergram archives Telegram data (chats, messages, media, presence, stories,
|
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:
|
Account scoping:
|
||||||
- Every tool takes `account_id`. It selects which archived Telegram account to
|
- 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
|
own `account_id` and its chats, messages and media read through the same
|
||||||
tools as a user account. Nothing bot-specific to call.
|
tools as a user account. Nothing bot-specific to call.
|
||||||
|
|
||||||
Writes: everything is read-only except `set_watch` and the share links
|
Writes: everything is read-only except `set_watch`, the share links
|
||||||
`download_media` creates (visible and revocable in the web UI).
|
`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
|
Paging: list tools accept `limit` and `offset`; `search_messages_tool` returns
|
||||||
`next_cursor` to pass back as `cursor`.
|
`next_cursor` to pass back as `cursor`.
|
||||||
@@ -392,6 +396,49 @@ async def transcribe_message(
|
|||||||
return result.model_dump(mode="json", exclude_none=True)
|
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
|
@mcp.tool
|
||||||
async def get_callbacks(
|
async def get_callbacks(
|
||||||
account_id: int, chat_id: int, message_id: int
|
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 pyrogram.types import Message
|
||||||
|
|
||||||
from userbot.modules.capture import capture_message
|
from userbot.modules.capture.ingest import ingest_message
|
||||||
from userbot.modules.capture.chat_meta import meta_from_chat
|
|
||||||
from userbot.modules.client import PyroClient
|
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()
|
@PyroClient.on_message()
|
||||||
@@ -20,21 +15,7 @@ async def on_message(client: PyroClient, message: Message) -> None:
|
|||||||
or message.date is None
|
or message.date is None
|
||||||
):
|
):
|
||||||
return
|
return
|
||||||
meta = meta_from_chat(message.chat, ctx.contacts.ids)
|
await ingest_message(client, ctx, message)
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
handlers = on_message.handlers # ty: ignore[unresolved-attribute]
|
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_media,
|
||||||
fetch_read_info,
|
fetch_read_info,
|
||||||
search_peers,
|
search_peers,
|
||||||
|
send_saved,
|
||||||
sync_contacts,
|
sync_contacts,
|
||||||
sync_dialogs,
|
sync_dialogs,
|
||||||
transcribe,
|
transcribe,
|
||||||
@@ -21,6 +22,7 @@ __all__ = [
|
|||||||
"fetch_media",
|
"fetch_media",
|
||||||
"fetch_read_info",
|
"fetch_read_info",
|
||||||
"search_peers",
|
"search_peers",
|
||||||
|
"send_saved",
|
||||||
"sync_contacts",
|
"sync_contacts",
|
||||||
"sync_dialogs",
|
"sync_dialogs",
|
||||||
"transcribe",
|
"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
|
import hashlib
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
TRASH_DIR = ".trash"
|
TRASH_DIR = ".trash"
|
||||||
|
UPLOADS_DIR = ".uploads"
|
||||||
|
|
||||||
|
|
||||||
class ContentAddressedStorage:
|
class ContentAddressedStorage:
|
||||||
@@ -34,6 +36,19 @@ class ContentAddressedStorage:
|
|||||||
def url(self, key: str) -> str:
|
def url(self, key: str) -> str:
|
||||||
return str(self._path(key))
|
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:
|
def _trash_path(self, key: str) -> Path:
|
||||||
return self._root / TRASH_DIR / key
|
return self._root / TRASH_DIR / key
|
||||||
|
|
||||||
|
|||||||
@@ -95,6 +95,18 @@ export async function request<T>(
|
|||||||
return response.json() as Promise<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 {
|
function streamUrl(path: string): string {
|
||||||
const query = auth.token ? `?token=${encodeURIComponent(auth.token)}` : "";
|
const query = auth.token ? `?token=${encodeURIComponent(auth.token)}` : "";
|
||||||
return `${BASE}${path}${query}`;
|
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 {
|
import type {
|
||||||
Account,
|
Account,
|
||||||
Annotation,
|
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(
|
export function listAnnotations(
|
||||||
options: { chatId?: number; messageId?: number } = {}
|
options: { chatId?: number; messageId?: number } = {}
|
||||||
): Promise<Annotation[]> {
|
): Promise<Annotation[]> {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
import { chats } from "#lib/stores/chats.svelte.js";
|
import { chats } from "#lib/stores/chats.svelte.js";
|
||||||
import { notes } from "#lib/stores/notes.svelte.js";
|
import { notes } from "#lib/stores/notes.svelte.js";
|
||||||
import { peers } from "#lib/stores/peers.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 { ui } from "#lib/stores/ui.svelte.js";
|
||||||
import { appear } from "#lib/transitions/appear.js";
|
import { appear } from "#lib/transitions/appear.js";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
@@ -331,6 +332,15 @@
|
|||||||
>{t("thread.toReply")}</ContextMenuItem
|
>{t("thread.toReply")}</ContextMenuItem
|
||||||
>
|
>
|
||||||
{/if}
|
{/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
|
<CopyItem
|
||||||
done={t("chat.menu.linkCopied")}
|
done={t("chat.menu.linkCopied")}
|
||||||
icon="link"
|
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;
|
padding: 0.1875rem 0;
|
||||||
|
|
||||||
&.own {
|
&.own {
|
||||||
--active: var(--color-white);
|
--active: var(--color-accent-own);
|
||||||
--toggle-fg: var(--color-background-own);
|
--toggle-fg: var(--color-background-own);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,8 +106,8 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<span class="blank"></span>
|
<span class="blank"></span>
|
||||||
<span class="play">
|
<span class="play fetch">
|
||||||
<Icon name={waiting ? "timer" : "download"} size="2.25rem" />
|
<Icon name={waiting ? "timer" : "download"} size="1.75rem" />
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="badge">
|
<span class="badge">
|
||||||
@@ -246,6 +246,13 @@
|
|||||||
color: var(--color-white);
|
color: var(--color-white);
|
||||||
|
|
||||||
filter: drop-shadow(0 1px 4px rgba(0, 0, 0, 0.5));
|
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 {
|
.badge {
|
||||||
|
|||||||
@@ -169,11 +169,11 @@
|
|||||||
--transcribe-bg: var(--color-primary-tint);
|
--transcribe-bg: var(--color-primary-tint);
|
||||||
|
|
||||||
&.own {
|
&.own {
|
||||||
--active: var(--color-white);
|
--active: var(--color-accent-own);
|
||||||
--inactive: rgba(255, 255, 255, 0.45);
|
--inactive: color-mix(in srgb, var(--color-accent-own) 40%, transparent);
|
||||||
--toggle-fg: var(--color-background-own);
|
--toggle-fg: var(--color-background-own);
|
||||||
--voice-meta: rgba(255, 255, 255, 0.75);
|
--voice-meta: var(--color-message-meta-own);
|
||||||
--transcribe-bg: rgba(255, 255, 255, 0.2);
|
--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 { presence } from "#lib/i18n/messages/presence.js";
|
||||||
import { profile } from "#lib/i18n/messages/profile.js";
|
import { profile } from "#lib/i18n/messages/profile.js";
|
||||||
import { read } from "#lib/i18n/messages/read.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 { search } from "#lib/i18n/messages/search.js";
|
||||||
import { service } from "#lib/i18n/messages/service.js";
|
import { service } from "#lib/i18n/messages/service.js";
|
||||||
import { settings } from "#lib/i18n/messages/settings.js";
|
import { settings } from "#lib/i18n/messages/settings.js";
|
||||||
@@ -45,6 +46,7 @@ export const messages = {
|
|||||||
...presence,
|
...presence,
|
||||||
...profile,
|
...profile,
|
||||||
...read,
|
...read,
|
||||||
|
...saved,
|
||||||
...search,
|
...search,
|
||||||
...service,
|
...service,
|
||||||
...settings,
|
...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 { untrack } from "svelte";
|
||||||
import ChatHeader from "#lib/components/ChatHeader.svelte";
|
import ChatHeader from "#lib/components/ChatHeader.svelte";
|
||||||
import MessageList from "#lib/components/MessageList.svelte";
|
import MessageList from "#lib/components/MessageList.svelte";
|
||||||
|
import SavedComposer from "#lib/components/SavedComposer.svelte";
|
||||||
import { accounts } from "#lib/stores/accounts.svelte.js";
|
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||||
import { chats } from "#lib/stores/chats.svelte.js";
|
import { chats } from "#lib/stores/chats.svelte.js";
|
||||||
import { MARK_PARAM, withoutMessage } from "#lib/stores/feed.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 { ui } from "#lib/stores/ui.svelte.js";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
@@ -44,6 +46,9 @@
|
|||||||
<div class="chat-body">
|
<div class="chat-body">
|
||||||
<MessageList {chatId} />
|
<MessageList {chatId} />
|
||||||
</div>
|
</div>
|
||||||
|
{#if savedUi.isSavedChat(chatId)}
|
||||||
|
<SavedComposer />
|
||||||
|
{/if}
|
||||||
{/key}
|
{/key}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user