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

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

No files matched your search

+12
View File
@@ -95,6 +95,18 @@ export async function request<T>(
return response.json() as Promise<T>;
}
export async function requestForm<T>(path: string, form: FormData): Promise<T> {
const response = await fetch(`${BASE}${path}`, {
body: form,
headers: authHeaders(),
method: "POST",
});
if (!response.ok) {
return handleError(response);
}
return response.json() as Promise<T>;
}
function streamUrl(path: string): string {
const query = auth.token ? `?token=${encodeURIComponent(auth.token)}` : "";
return `${BASE}${path}${query}`;
+42 -1
View File
@@ -1,4 +1,4 @@
import { request } from "#lib/api/client.js";
import { request, requestForm } from "#lib/api/client.js";
import type {
Account,
Annotation,
@@ -597,6 +597,47 @@ export function transcribeMedia(
});
}
export interface SavedResult {
chat_id: number | null;
error: string | null;
message_id: number | null;
mode: "copied" | "forwarded" | "sent" | null;
status: "done" | "failed" | "pending";
}
export interface SavedDraft {
file?: File;
text?: string;
voice?: boolean;
}
export function sendToSaved(draft: SavedDraft): Promise<SavedResult> {
const form = new FormData();
form.set("account_id", String(accounts.selectedId));
if (draft.text) {
form.set("text", draft.text);
}
if (draft.file) {
form.set("file", draft.file);
form.set("voice", String(Boolean(draft.voice)));
}
return requestForm<SavedResult>("/saved", form);
}
export function forwardToSaved(
chatId: number,
messageId: number
): Promise<SavedResult> {
return request<SavedResult>("/saved/forward", {
body: {
account_id: accounts.selectedId,
chat_id: chatId,
message_id: messageId,
},
method: "POST",
});
}
export function listAnnotations(
options: { chatId?: number; messageId?: number } = {}
): Promise<Annotation[]> {
@@ -25,6 +25,7 @@
import { chats } from "#lib/stores/chats.svelte.js";
import { notes } from "#lib/stores/notes.svelte.js";
import { peers } from "#lib/stores/peers.svelte.js";
import { savedUi } from "#lib/stores/saved.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js";
import { appear } from "#lib/transitions/appear.js";
import { goto } from "$app/navigation";
@@ -331,6 +332,15 @@
>{t("thread.toReply")}</ContextMenuItem
>
{/if}
{#if savedUi.available && !savedUi.isSavedChat(message.chat_id)}
<ContextMenuItem
icon="forward"
onselect={() => savedUi.forward(message.chat_id, message.message_id)}
>{message.deleted_at || message.is_self_destruct
? t("saved.copy")
: t("saved.forward")}</ContextMenuItem
>
{/if}
<CopyItem
done={t("chat.menu.linkCopied")}
icon="link"
@@ -0,0 +1,438 @@
<script lang="ts">
import { Dialog } from "bits-ui";
import Button from "#lib/components/ui/Button.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import { formatBytes } from "#lib/format/media.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { savedUi } from "#lib/stores/saved.svelte.js";
const VOICE_NAME = /\.(ogg|oga|opus)$/i;
const MAX_INPUT_HEIGHT = 240;
let text = $state("");
let sending = $state(false);
let file = $state<File | null>(null);
let caption = $state("");
let voice = $state(false);
let picker = $state<HTMLInputElement>();
let input = $state<HTMLTextAreaElement>();
const dialogOpen = $derived(file !== null);
const isAudio = $derived(
file !== null &&
(file.type.startsWith("audio/") || VOICE_NAME.test(file.name))
);
const preview = $derived(
file?.type.startsWith("image/") ? URL.createObjectURL(file) : null
);
$effect(() => {
const url = preview;
return () => {
if (url) {
URL.revokeObjectURL(url);
}
};
});
function fit() {
if (!input) {
return;
}
input.style.height = "auto";
input.style.height = `${Math.min(input.scrollHeight, MAX_INPUT_HEIGHT)}px`;
}
async function sendText() {
const message = text.trim();
if (!message || sending) {
return;
}
sending = true;
if (await savedUi.send({ text: message })) {
text = "";
requestAnimationFrame(fit);
}
sending = false;
}
async function sendFile() {
if (!file || sending) {
return;
}
sending = true;
const sent = await savedUi.send({
file,
text: caption.trim(),
voice: isAudio && voice,
});
sending = false;
if (sent) {
file = null;
caption = "";
}
}
function onkeydown(event: KeyboardEvent) {
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
event.preventDefault();
sendText();
}
}
function onpick() {
const picked = picker?.files?.[0] ?? null;
if (picker) {
picker.value = "";
}
if (!picked) {
return;
}
file = picked;
caption = text.trim();
text = "";
voice = VOICE_NAME.test(picked.name) || picked.type === "audio/ogg";
requestAnimationFrame(fit);
}
function onOpenChange(open: boolean) {
if (!(open || sending)) {
if (!text) {
text = caption;
}
file = null;
caption = "";
requestAnimationFrame(fit);
}
}
</script>
<div class="SavedComposer">
<div class="field">
<textarea
aria-label={t("saved.message")}
oninput={fit}
{onkeydown}
placeholder={t("saved.message")}
rows="1"
bind:this={input}
bind:value={text}
></textarea>
<button
aria-label={t("saved.attach")}
class="attach"
onclick={() => picker?.click()}
type="button"
>
<Icon name="attach" size="1.5rem" />
</button>
<input hidden onchange={onpick} type="file" bind:this={picker}>
</div>
<Button
aria-label={t("saved.send")}
class="send"
disabled={!text.trim()}
loading={sending && !file}
onclick={sendText}
round
>
<Icon name="send" size="1.5rem" />
</Button>
</div>
<Dialog.Root {onOpenChange} open={dialogOpen}>
<Dialog.Portal>
<Dialog.Overlay class="dialog-overlay" />
<Dialog.Content class="dialog-content">
<header class="dialog-head">
<Dialog.Title class="dialog-title">{t("saved.sendFile")}</Dialog.Title>
<Dialog.Close aria-label={t("common.close")} class="dialog-close">
<Icon name="close" size="1.25rem" />
</Dialog.Close>
</header>
<div class="dialog-body">
{#if preview}
<img alt={file?.name} class="preview" src={preview}>
{/if}
<div class="file">
<span class="tile">
<Icon name={isAudio ? "microphone" : "document"} size="1.5rem" />
</span>
<span class="meta">
<span class="name">{file?.name}</span>
<span class="size">{formatBytes(file?.size ?? null)}</span>
</span>
</div>
<textarea
aria-label={t("saved.caption")}
class="caption"
placeholder={t("saved.caption")}
rows="2"
bind:value={caption}
></textarea>
{#if isAudio}
<button
aria-checked={voice}
class="toggle-row"
onclick={() => {
voice = !voice;
}}
role="switch"
type="button"
>
<span class="label">
<span>{t("saved.asVoice")}</span>
<small>{t("saved.asVoiceHint")}</small>
</span>
<span class="switch" class:on={voice}>
<span class="knob"></span>
</span>
</button>
{/if}
</div>
<div class="dialog-actions">
<Dialog.Close>
{#snippet child({
props,
})}
<Button {...props} pill variant="text">
{t("common.cancel")}
</Button>
{/snippet}
</Dialog.Close>
<Button disabled={sending} loading={sending} onclick={sendFile} pill>
{t("saved.send")}
</Button>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<style lang="scss">
.SavedComposer {
display: flex;
flex-shrink: 0;
align-items: flex-end;
gap: 0.5rem;
padding: 0.5rem max(1rem, calc((100% - var(--messages-container-width)) / 2))
1.25rem;
:global(.send) {
flex-shrink: 0;
width: 3.5rem;
height: 3.5rem;
}
}
.field {
display: flex;
flex: 1;
align-items: flex-end;
min-width: 0;
min-height: 3.5rem;
padding: 0 0.5rem 0 1rem;
border-radius: var(--border-radius-messages);
background-color: var(--color-background);
box-shadow: 0 1px 2px var(--color-default-shadow);
}
textarea {
resize: none;
flex: 1;
min-width: 0;
margin: 0;
padding: 1rem 0;
border: 0;
font: inherit;
font-size: 1rem;
line-height: 1.375;
color: var(--color-text);
background: transparent;
outline: none;
&::placeholder {
color: var(--color-placeholders);
}
}
.attach {
cursor: pointer;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
margin-bottom: 0.5rem;
border: 0;
border-radius: 50%;
color: var(--color-composer-button);
background: transparent;
transition: color 0.15s ease;
&:focus-visible {
outline: 2px solid var(--color-primary);
}
@media (hover: hover) {
&:hover {
color: var(--color-primary);
}
}
}
.preview {
display: block;
max-width: 100%;
max-height: 16rem;
margin: 0 auto 1rem;
border-radius: var(--border-radius-default-small);
object-fit: contain;
}
.file {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
}
.tile {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
border-radius: 50%;
color: var(--color-white);
background-color: var(--color-primary);
}
.meta {
display: flex;
flex-direction: column;
min-width: 0;
}
.name {
overflow: hidden;
font-size: 0.9375rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.size {
font-size: 0.875rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
}
.caption {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid var(--color-borders-input);
border-radius: var(--border-radius-default);
&:focus {
border-color: var(--color-primary);
box-shadow: inset 0 0 0 1px var(--color-primary);
}
}
.toggle-row {
cursor: pointer;
display: flex;
align-items: center;
gap: 1rem;
width: calc(100% + 2.5rem);
margin: 0.75rem -1.25rem 0;
padding: 0.5rem 1.25rem;
border: 0;
font-size: 0.9375rem;
text-align: start;
color: var(--color-text);
background-color: transparent;
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
}
.label {
display: flex;
flex: 1;
flex-direction: column;
small {
font-size: 0.8125rem;
color: var(--color-text-secondary);
}
}
.switch {
flex-shrink: 0;
display: flex;
align-items: center;
width: 2.25rem;
height: 1.375rem;
padding: 0.125rem;
border-radius: 0.6875rem;
background-color: var(--color-borders);
transition: background-color 0.15s ease;
&.on {
background-color: var(--color-primary);
}
}
.knob {
width: 1.125rem;
height: 1.125rem;
border-radius: 50%;
background-color: var(--color-white);
transition: transform 0.15s ease;
.on & {
transform: translateX(0.875rem);
}
}
@media (pointer: coarse) {
.SavedComposer {
padding-bottom: max(0.75rem, env(safe-area-inset-bottom));
}
}
</style>
@@ -88,7 +88,7 @@
padding: 0.1875rem 0;
&.own {
--active: var(--color-white);
--active: var(--color-accent-own);
--toggle-fg: var(--color-background-own);
}
}
@@ -106,8 +106,8 @@
{/if}
{:else}
<span class="blank"></span>
<span class="play">
<Icon name={waiting ? "timer" : "download"} size="2.25rem" />
<span class="play fetch">
<Icon name={waiting ? "timer" : "download"} size="1.75rem" />
</span>
{/if}
<span class="badge">
@@ -246,6 +246,13 @@
color: var(--color-white);
filter: drop-shadow(0 1px 4px rgba(0, 0, 0, 0.5));
&.fetch {
padding: 0.875rem;
border-radius: 50%;
background-color: rgba(0, 0, 0, 0.45);
filter: none;
}
}
.badge {
@@ -169,11 +169,11 @@
--transcribe-bg: var(--color-primary-tint);
&.own {
--active: var(--color-white);
--inactive: rgba(255, 255, 255, 0.45);
--active: var(--color-accent-own);
--inactive: color-mix(in srgb, var(--color-accent-own) 40%, transparent);
--toggle-fg: var(--color-background-own);
--voice-meta: rgba(255, 255, 255, 0.75);
--transcribe-bg: rgba(255, 255, 255, 0.2);
--voice-meta: var(--color-message-meta-own);
--transcribe-bg: var(--color-voice-transcribe-button-own);
}
}
+2
View File
@@ -15,6 +15,7 @@ import { policy } from "#lib/i18n/messages/policy.js";
import { presence } from "#lib/i18n/messages/presence.js";
import { profile } from "#lib/i18n/messages/profile.js";
import { read } from "#lib/i18n/messages/read.js";
import { saved } from "#lib/i18n/messages/saved.js";
import { search } from "#lib/i18n/messages/search.js";
import { service } from "#lib/i18n/messages/service.js";
import { settings } from "#lib/i18n/messages/settings.js";
@@ -45,6 +46,7 @@ export const messages = {
...presence,
...profile,
...read,
...saved,
...search,
...service,
...settings,
+36
View File
@@ -0,0 +1,36 @@
import type { Messages } from "#lib/i18n/types.js";
export const saved = {
"saved.asVoice": { en: "As a voice message", ru: "Голосовым сообщением" },
"saved.asVoiceHint": {
en: "Sent exactly as is, so give it OGG/Opus",
ru: "Уходит как есть, нужен OGG/Opus",
},
"saved.attach": { en: "Attach a file", ru: "Прикрепить файл" },
"saved.caption": { en: "Caption", ru: "Подпись" },
"saved.copied": {
en: "Copy sent to Saved Messages",
ru: "Копия в Избранном",
},
"saved.copy": { en: "Copy to Saved Messages", ru: "Копию в Избранное" },
"saved.failed": {
en: "Could not send to Saved Messages",
ru: "Не удалось отправить в Избранное",
},
"saved.forward": { en: "To Saved Messages", ru: "В Избранное" },
"saved.forwarded": {
en: "Forwarded to Saved Messages",
ru: "Переслано в Избранное",
},
"saved.mediaMissing": {
en: "The file is not in the archive, there is nothing to copy",
ru: "Файла нет в архиве, копировать нечего",
},
"saved.message": { en: "Message", ru: "Сообщение" },
"saved.pending": {
en: "Telegram is still sending, it will show up shortly",
ru: "Telegram ещё отправляет, сообщение скоро появится",
},
"saved.send": { en: "Send", ru: "Отправить" },
"saved.sendFile": { en: "Send a file", ru: "Отправить файл" },
} satisfies Messages;
+59
View File
@@ -0,0 +1,59 @@
import {
forwardToSaved,
type SavedDraft,
type SavedResult,
sendToSaved,
} from "#lib/api/endpoints.js";
import { t } from "#lib/i18n/locale.svelte.js";
import type { MessageKey } from "#lib/i18n/messages.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js";
const REFUSALS: Record<string, MessageKey> = {
media_missing: "saved.mediaMissing",
};
function report(result: SavedResult): boolean {
if (result.status === "done") {
return true;
}
if (result.status === "pending") {
toasts.show(t("saved.pending"));
return true;
}
toasts.error(t(REFUSALS[result.error ?? ""] ?? "saved.failed"));
return false;
}
export const savedUi = {
get available(): boolean {
const account = accounts.selected;
return account !== null && !account.is_bot && account.tg_user_id !== null;
},
async forward(chatId: number, messageId: number): Promise<void> {
try {
const result = await forwardToSaved(chatId, messageId);
if (report(result) && result.status === "done") {
toasts.show(
t(result.mode === "copied" ? "saved.copied" : "saved.forwarded")
);
}
} catch {
toasts.error(t("saved.failed"));
}
},
isSavedChat(chatId: number): boolean {
return this.available && accounts.selected?.tg_user_id === chatId;
},
async send(draft: SavedDraft): Promise<boolean> {
try {
return report(await sendToSaved(draft));
} catch {
toasts.error(t("saved.failed"));
return false;
}
},
};
@@ -2,9 +2,11 @@
import { untrack } from "svelte";
import ChatHeader from "#lib/components/ChatHeader.svelte";
import MessageList from "#lib/components/MessageList.svelte";
import SavedComposer from "#lib/components/SavedComposer.svelte";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { chats } from "#lib/stores/chats.svelte.js";
import { MARK_PARAM, withoutMessage } from "#lib/stores/feed.svelte.js";
import { savedUi } from "#lib/stores/saved.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js";
import { goto } from "$app/navigation";
import { page } from "$app/state";
@@ -44,6 +46,9 @@
<div class="chat-body">
<MessageList {chatId} />
</div>
{#if savedUi.isSavedChat(chatId)}
<SavedComposer />
{/if}
{/key}
</div>