feat: 1-to-1 message render + web data-lake backend

This commit is contained in:
hh committed 2026-05-31 01:45:05 +02:00
1 parent f0afb7ec5b
commit 75425d1bee
110 files changed
+10199 -54

No files matched your search

+74
View File
@@ -0,0 +1,74 @@
import { accounts } from "$lib/stores/accounts.svelte";
import { auth } from "$lib/stores/auth.svelte";
const BASE = import.meta.env.VITE_API_BASE ?? "/api";
const RETRY_DELAY = 2500;
export type AvatarKind = "peer" | "chat";
const ready = new Map<string, string>();
const missing = new Set<string>();
const inflight = new Map<string, Promise<string | null>>();
function cacheKey(account: number, kind: AvatarKind, id: number): string {
return `${account}:${kind}:${id}`;
}
function authHeaders(): Record<string, string> {
return auth.token ? { Authorization: `Bearer ${auth.token}` } : {};
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
async function fetchAvatar(
account: number,
kind: AvatarKind,
id: number,
key: string,
retry: boolean
): Promise<string | null> {
const url = `${BASE}/avatars/${kind}/${id}?account_id=${account}`;
const response = await fetch(url, { headers: authHeaders() });
if (response.ok) {
const objectUrl = URL.createObjectURL(await response.blob());
ready.set(key, objectUrl);
return objectUrl;
}
if (response.status === 409 && retry) {
await delay(RETRY_DELAY);
return fetchAvatar(account, kind, id, key, false);
}
missing.add(key);
return null;
}
export function loadAvatar(
kind: AvatarKind,
id: number
): Promise<string | null> {
const account = accounts.selectedId;
if (account === null) {
return Promise.resolve(null);
}
const key = cacheKey(account, kind, id);
const cached = ready.get(key);
if (cached) {
return Promise.resolve(cached);
}
if (missing.has(key)) {
return Promise.resolve(null);
}
const existing = inflight.get(key);
if (existing) {
return existing;
}
const promise = fetchAvatar(account, kind, id, key, true).finally(() => {
inflight.delete(key);
});
inflight.set(key, promise);
return promise;
}
+141
View File
@@ -0,0 +1,141 @@
import { goto } from "$app/navigation";
import { accounts } from "$lib/stores/accounts.svelte";
import { auth } from "$lib/stores/auth.svelte";
const BASE = import.meta.env.VITE_API_BASE ?? "/api";
const MAX_LIMIT = 500;
export class ApiError extends Error {
status: number;
detail: string;
constructor(status: number, detail: string) {
super(detail);
this.name = "ApiError";
this.status = status;
this.detail = detail;
}
}
type QueryValue = string | number | boolean | null | undefined;
interface RequestOptions {
account?: boolean;
body?: unknown;
method?: string;
query?: Record<string, QueryValue>;
}
function buildQuery(
query: Record<string, QueryValue> | undefined,
withAccount: boolean
): string {
const params = new URLSearchParams();
if (withAccount && accounts.selectedId !== null) {
params.set("account_id", String(accounts.selectedId));
}
if (query) {
for (const [key, value] of Object.entries(query)) {
if (value === null || value === undefined) {
continue;
}
const clamped =
key === "limit" && typeof value === "number"
? Math.min(value, MAX_LIMIT)
: value;
params.set(key, String(clamped));
}
}
const text = params.toString();
return text ? `?${text}` : "";
}
function authHeaders(): Record<string, string> {
return auth.token ? { Authorization: `Bearer ${auth.token}` } : {};
}
async function handleError(response: Response): Promise<never> {
if (response.status === 401) {
auth.logout();
await goto("/login");
}
let detail = response.statusText;
try {
const data = await response.json();
if (data && typeof data.detail === "string") {
detail = data.detail;
}
} catch {
detail = response.statusText;
}
throw new ApiError(response.status, detail);
}
export async function request<T>(
path: string,
options: RequestOptions = {}
): Promise<T> {
const { method = "GET", query, body, account = false } = options;
const headers: Record<string, string> = authHeaders();
if (body !== undefined) {
headers["Content-Type"] = "application/json";
}
const response = await fetch(`${BASE}${path}${buildQuery(query, account)}`, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
if (!response.ok) {
return handleError(response);
}
if (response.status === 204) {
return undefined as T;
}
return response.json() as Promise<T>;
}
export type MediaResult =
| { state: "ready"; url: string; mime: string | null }
| { state: "not-downloaded" }
| { state: "missing" };
export async function requestMedia(mediaId: number): Promise<MediaResult> {
const response = await fetch(`${BASE}/media/${mediaId}`, {
headers: authHeaders(),
});
if (response.status === 409) {
return { state: "not-downloaded" };
}
if (response.status === 404) {
return { state: "missing" };
}
if (!response.ok) {
return handleError(response);
}
const blob = await response.blob();
return {
state: "ready",
url: URL.createObjectURL(blob),
mime: response.headers.get("Content-Type"),
};
}
export async function requestMediaVersion(
versionId: number
): Promise<MediaResult> {
const response = await fetch(`${BASE}/media/version/${versionId}`, {
headers: authHeaders(),
});
if (response.status === 404) {
return { state: "missing" };
}
if (!response.ok) {
return handleError(response);
}
const blob = await response.blob();
return {
state: "ready",
url: URL.createObjectURL(blob),
mime: response.headers.get("Content-Type"),
};
}
+120
View File
@@ -0,0 +1,120 @@
import { request } from "$lib/api/client";
import type {
Account,
Chat,
Folder,
JobView,
MediaVersion,
MediaView,
MessageVersion,
MessageView,
PeerView,
} from "$lib/api/types";
import { accounts } from "$lib/stores/accounts.svelte";
interface Page {
limit?: number;
offset?: number;
}
export function listAccounts(): Promise<Account[]> {
return request<Account[]>("/accounts");
}
export function listChats(page: Page = {}): Promise<Chat[]> {
return request<Chat[]>("/chats", { account: true, query: { ...page } });
}
export function listFolders(): Promise<Folder[]> {
return request<Folder[]>("/folders", { account: true });
}
export function listMessages(
chatId: number,
options: Page & { include_deleted?: boolean } = {}
): Promise<MessageView[]> {
return request<MessageView[]>(`/chats/${chatId}/messages`, {
account: true,
query: { ...options },
});
}
export function listMessageVersions(
chatId: number,
messageId: number
): Promise<MessageVersion[]> {
return request<MessageVersion[]>(
`/chats/${chatId}/messages/${messageId}/versions`,
{ account: true }
);
}
export function listDeleted(
options: Page & { chat_id?: number } = {}
): Promise<MessageView[]> {
return request<MessageView[]>("/deleted", {
account: true,
query: { ...options },
});
}
export function getPeer(peerId: number): Promise<PeerView> {
return request<PeerView>(`/peers/${peerId}`, { account: true });
}
export function getPeers(ids: number[]): Promise<PeerView[]> {
if (ids.length === 0) {
return Promise.resolve([]);
}
return request<PeerView[]>("/peers/batch", {
account: true,
query: { ids: ids.join(",") },
});
}
export function enrichChat(chatId: number): Promise<{ job_id: number }> {
return request<{ job_id: number }>(`/chats/${chatId}/enrich`, {
method: "POST",
body: { account_id: accounts.selectedId },
});
}
export function getJob(jobId: number): Promise<JobView> {
return request<JobView>(`/jobs/${jobId}`, { account: true });
}
export function getMediaVersions(
chatId: number,
messageId: number
): Promise<MediaVersion[]> {
return request<MediaVersion[]>(`/media/versions/${chatId}/${messageId}`, {
account: true,
});
}
export function getMediaMeta(mediaId: number): Promise<MediaView> {
return request<MediaView>(`/media/${mediaId}/meta`);
}
export function getMessageMedia(
chatId: number,
messageId: number
): Promise<MediaView> {
return request<MediaView>(`/media/message/${chatId}/${messageId}`, {
account: true,
});
}
export function fetchMedia(
chatId: number,
messageId: number
): Promise<{ job_id: number }> {
return request<{ job_id: number }>("/media/fetch", {
method: "POST",
body: {
account_id: accounts.selectedId,
chat_id: chatId,
message_id: messageId,
},
});
}
+175
View File
@@ -0,0 +1,175 @@
import { requestMedia } from "$lib/api/client";
import { getMessageMedia } from "$lib/api/endpoints";
import type { MediaRef } from "$lib/api/types";
import { accounts } from "$lib/stores/accounts.svelte";
export type InlineMedia =
| {
state: "ready";
mediaId: number;
kind: string;
mime: string | null;
url: string;
transcript: string | null;
}
| { state: "not-downloaded"; mediaId: number; kind: string }
| { state: "missing" };
export interface ViewerItem {
downloaded: boolean;
kind: string;
mediaId: number | null;
messageId: number;
}
export function viewerItemsFrom(
messageId: number,
media: MediaRef[]
): ViewerItem[] {
if (media.length === 0) {
return [{ messageId, mediaId: null, kind: "", downloaded: false }];
}
return media.map((item) => ({
messageId: item.message_id,
mediaId: item.id,
kind: item.kind,
downloaded: item.downloaded,
}));
}
export type VisualKind = "image" | "video" | "other";
const VIDEO_KINDS = new Set(["video", "video_note", "animation", "gif"]);
export function visualKind(kind: string): VisualKind {
if (kind === "photo") {
return "image";
}
if (VIDEO_KINDS.has(kind)) {
return "video";
}
return "other";
}
const ready = new Map<string, InlineMedia>();
const inflight = new Map<string, Promise<InlineMedia>>();
function cacheKey(account: number, chatId: number, messageId: number): string {
return `${account}:${chatId}:${messageId}`;
}
async function resolve(
chatId: number,
messageId: number
): Promise<InlineMedia> {
let meta: Awaited<ReturnType<typeof getMessageMedia>>;
try {
meta = await getMessageMedia(chatId, messageId);
} catch {
return { state: "missing" };
}
if (!meta.downloaded) {
return { state: "not-downloaded", mediaId: meta.id, kind: meta.kind };
}
const blob = await requestMedia(meta.id);
if (blob.state === "ready") {
return {
state: "ready",
mediaId: meta.id,
kind: meta.kind,
mime: blob.mime,
url: blob.url,
transcript: meta.extracted_text,
};
}
if (blob.state === "not-downloaded") {
return { state: "not-downloaded", mediaId: meta.id, kind: meta.kind };
}
return { state: "missing" };
}
const byId = new Map<string, InlineMedia>();
const byIdInflight = new Map<string, Promise<InlineMedia>>();
async function resolveById(media: MediaRef): Promise<InlineMedia> {
if (media.id === null) {
return { state: "missing" };
}
if (!media.downloaded) {
return { state: "not-downloaded", mediaId: media.id, kind: media.kind };
}
const blob = await requestMedia(media.id);
if (blob.state === "ready") {
return {
state: "ready",
mediaId: media.id,
kind: media.kind,
mime: blob.mime,
url: blob.url,
transcript: null,
};
}
if (blob.state === "not-downloaded") {
return { state: "not-downloaded", mediaId: media.id, kind: media.kind };
}
return { state: "missing" };
}
export function loadMediaItem(media: MediaRef): Promise<InlineMedia> {
const account = accounts.selectedId;
if (account === null || media.id === null) {
return Promise.resolve<InlineMedia>({ state: "missing" });
}
const key = `${account}:${media.id}`;
const cached = byId.get(key);
if (cached) {
return Promise.resolve(cached);
}
const existing = byIdInflight.get(key);
if (existing) {
return existing;
}
const promise = resolveById(media)
.then((result) => {
if (result.state === "ready") {
byId.set(key, result);
}
return result;
})
.finally(() => {
byIdInflight.delete(key);
});
byIdInflight.set(key, promise);
return promise;
}
export function loadInlineMedia(
chatId: number,
messageId: number
): Promise<InlineMedia> {
const account = accounts.selectedId;
if (account === null) {
return Promise.resolve<InlineMedia>({ state: "missing" });
}
const key = cacheKey(account, chatId, messageId);
const cached = ready.get(key);
if (cached) {
return Promise.resolve(cached);
}
const existing = inflight.get(key);
if (existing) {
return existing;
}
const promise = resolve(chatId, messageId)
.then((result) => {
if (result.state === "ready") {
ready.set(key, result);
}
return result;
})
.finally(() => {
inflight.delete(key);
});
inflight.set(key, promise);
return promise;
}
+375
View File
@@ -0,0 +1,375 @@
export type ChatKind = "private" | "group";
export type PolicyScopeType =
| "default_dm"
| "default_group"
| "default_channel"
| "folder"
| "chat";
export type SearchSource = "text" | "stt";
export type JobStatus =
| "pending"
| "running"
| "done"
| "failed"
| "canceled"
| "paused";
export interface Account {
account_id: number;
is_active: boolean;
label: string | null;
phone: string | null;
tg_user_id: number | null;
}
export interface Chat {
chat_id: number;
has_avatar: boolean;
is_bot: boolean;
is_broadcast: boolean;
is_contact: boolean;
kind: ChatKind;
last_date: string | null;
last_sender_id: number | null;
last_text: string | null;
message_count: number;
title: string | null;
}
export interface EntityView {
custom_emoji_id: string | null;
language: string | null;
length: number;
offset: number;
type: string;
url: string | null;
}
export interface ReplyView {
media_kind: string | null;
message_id: number | null;
sender_id: number | null;
sender_name: string | null;
text: string | null;
}
export interface ForwardView {
chat_id: number | null;
chat_title: string | null;
date: string | null;
from_id: number | null;
from_name: string | null;
kind: "channel" | "hidden" | "user";
message_id: number | null;
signature: string | null;
}
export interface MediaRef {
downloaded: boolean;
duration: number | null;
file_size: number | null;
height: number | null;
id: number | null;
kind: string;
message_id: number;
mime: string | null;
ttl_seconds: number | null;
width: number | null;
}
export interface ReactionCount {
chosen: boolean;
count: number;
custom_emoji_id: string | null;
emoji: string | null;
}
export interface InlineButton {
data: string | null;
kind: "callback" | "other" | "switch" | "url";
text: string;
url: string | null;
}
export interface WebPageView {
description: string | null;
display_url: string | null;
has_photo: boolean;
site_name: string | null;
title: string | null;
type: string | null;
url: string;
}
export interface PollOption {
correct: boolean | null;
text: string;
vote_percentage: number;
voter_count: number;
}
export interface PollView {
anonymous: boolean;
closed: boolean;
multiple: boolean;
options: PollOption[];
question: string;
quiz: boolean;
total_voter_count: number;
}
export interface ContactView {
first_name: string | null;
last_name: string | null;
phone_number: string | null;
user_id: number | null;
}
export interface LocationView {
address: string | null;
latitude: number | null;
longitude: number | null;
title: string | null;
}
export interface ServiceView {
duration: number | null;
kind: string;
member_ids: number[] | null;
pinned_message_id: number | null;
}
export interface StickerView {
emoji: string | null;
height: number | null;
is_animated: boolean;
is_video: boolean;
mime: string | null;
set_name: string | null;
width: number | null;
}
export interface MessageView {
chat_id: number;
contact: ContactView | null;
date: string;
deleted_at: string | null;
edited_at: string | null;
entities: EntityView[];
forward: ForwardView | null;
has_media: boolean;
inline_buttons: InlineButton[][];
is_animated_emoji: boolean;
is_self_destruct: boolean;
is_sticker: boolean;
location: LocationView | null;
media: MediaRef[];
media_group_id: string | null;
message_id: number;
poll: PollView | null;
quote: string | null;
reactions: ReactionCount[];
reply: ReplyView | null;
sender_id: number | null;
service: ServiceView | null;
sticker: StickerView | null;
text: string | null;
via_bot_id: number | null;
web_page: WebPageView | null;
}
export interface MessageVersion {
edit_date: string | null;
observed_at: string;
text: string | null;
}
export interface MediaVersion {
file_size: number | null;
id: number;
kind: string;
mime: string | null;
observed_at: string;
storage_key: string;
}
export interface SearchHit {
chat_id: number;
date: string;
extracted_text: string | null;
message_id: number;
sender_id: number | null;
source: SearchSource;
text: string | null;
}
export interface MediaView {
account_id: number;
chat_id: number;
created_at: string;
downloaded: boolean;
extracted_text: string | null;
file_size: number | null;
id: number;
kind: string;
message_id: number;
mime: string | null;
storage_key: string | null;
ttl_seconds: number | null;
}
export interface Callback {
data: string | null;
label: string | null;
position: number;
}
export interface Reaction {
added_at: string;
peer_id: number;
reaction: string;
removed_at: string | null;
}
export interface LinkPreview {
kind: string;
position: number;
url: string;
web_description: string | null;
web_site_name: string | null;
web_title: string | null;
web_url: string | null;
}
export interface PresenceSample {
last_online_date: string | null;
next_offline_date: string | null;
peer_id: number;
status: string;
ts: string;
}
export interface PresenceHourly {
bucket: string;
last_seen: string | null;
online_samples: number;
peer_id: number;
samples: number;
}
export interface PeerView {
first_name: string | null;
has_avatar: boolean;
is_deleted_account: boolean;
last_name: string | null;
peer_id: number;
phone: string | null;
photo_unique_id: string | null;
updated_at: string;
username: string | null;
}
export interface PeerHistoryView {
first_name: string | null;
is_deleted_account: boolean;
last_name: string | null;
observed_at: string;
phone: string | null;
photo_unique_id: string | null;
username: string | null;
}
export interface StoryView {
caption: string | null;
date: string | null;
deleted: boolean;
downloaded: boolean;
expire_date: string | null;
media_kind: string | null;
peer_id: number;
pinned: boolean;
storage_key: string | null;
story_id: number;
views: number | null;
}
export interface Annotation {
account_id: number;
chat_id: number;
created_at: string;
id: number;
message_id: number;
text: string;
updated_at: string;
}
export interface CaptureToggles {
backfill: boolean;
media: boolean;
messages: boolean;
presence: boolean;
profile_history: boolean;
reactions: boolean;
self_destruct_media: boolean;
stories: boolean;
stt: boolean;
track_edits_deletes: boolean;
}
export interface PolicyRecord extends CaptureToggles {
account_id: number | null;
id: number;
scope_id: number | null;
scope_type: PolicyScopeType;
}
export interface Folder {
bots: boolean;
broadcasts: boolean;
contacts: boolean;
exclude_ids: number[];
folder_id: number;
groups: boolean;
include_ids: number[];
is_chatlist: boolean;
non_contacts: boolean;
order_index: number;
pinned_ids: number[];
title: string;
}
export interface Watch {
account_id: number;
created_at: string;
enabled: boolean;
id: number;
kind: string;
params: Record<string, unknown>;
updated_at: string;
}
export interface Alert {
account_id: number;
created_at: string;
id: number;
payload: Record<string, unknown>;
seen: boolean;
ts: string;
watch_id: number;
}
export interface JobView {
account_id: number;
attempts: number;
created_at: string;
cursor: Record<string, unknown> | null;
error: string | null;
finished_at: string | null;
flood_waits: number;
id: number;
kind: string;
params: Record<string, unknown>;
progress: Record<string, unknown>;
started_at: string | null;
status: JobStatus;
}