From ea3a7c0a61b285b066608c831b7022cad6d1313b Mon Sep 17 00:00:00 2001 From: h Date: Mon, 19 Oct 2026 02:41:42 +0000 Subject: [PATCH] feat(frontend): watches, webhooks and alerts panels read humanly, with chat picker, url filter, test send and delivery log --- frontend/src/lib/api/endpoints.ts | 42 -- frontend/src/lib/api/types.ts | 25 +- frontend/src/lib/api/watches.ts | 210 ++++++ .../src/lib/components/RightColumn.svelte | 15 +- .../lib/components/alerts/AlertList.svelte | 82 --- .../lib/components/alerts/AlertsPanel.svelte | 646 ++++++++++++++++- .../components/watches/ChatPickerField.svelte | 305 ++++++++ .../lib/components/watches/WatchEditor.svelte | 121 ---- .../lib/components/watches/WatchForm.svelte | 388 ++++++++++ .../lib/components/watches/WatchList.svelte | 111 --- .../components/watches/WatchesPanel.svelte | 473 +++++++++++- .../lib/components/watches/WebhookCard.svelte | 676 ++++++++++++++++++ .../components/watches/WebhooksPanel.svelte | 443 ++++++++++++ frontend/src/lib/format/watches.ts | 242 +++++++ frontend/src/lib/i18n/messages.ts | 4 + frontend/src/lib/i18n/messages/alerts.ts | 47 ++ frontend/src/lib/i18n/messages/watches.ts | 206 ++++++ frontend/src/lib/stores/alerts.svelte.ts | 120 ++++ frontend/src/lib/stores/ui.svelte.ts | 1 + 19 files changed, 3699 insertions(+), 458 deletions(-) create mode 100644 frontend/src/lib/api/watches.ts delete mode 100644 frontend/src/lib/components/alerts/AlertList.svelte create mode 100644 frontend/src/lib/components/watches/ChatPickerField.svelte delete mode 100644 frontend/src/lib/components/watches/WatchEditor.svelte create mode 100644 frontend/src/lib/components/watches/WatchForm.svelte delete mode 100644 frontend/src/lib/components/watches/WatchList.svelte create mode 100644 frontend/src/lib/components/watches/WebhookCard.svelte create mode 100644 frontend/src/lib/components/watches/WebhooksPanel.svelte create mode 100644 frontend/src/lib/format/watches.ts create mode 100644 frontend/src/lib/i18n/messages/alerts.ts create mode 100644 frontend/src/lib/i18n/messages/watches.ts create mode 100644 frontend/src/lib/stores/alerts.svelte.ts diff --git a/frontend/src/lib/api/endpoints.ts b/frontend/src/lib/api/endpoints.ts index 39cde84..6a684f2 100644 --- a/frontend/src/lib/api/endpoints.ts +++ b/frontend/src/lib/api/endpoints.ts @@ -1,7 +1,6 @@ import { request } from "#lib/api/client.js"; import type { Account, - Alert, Annotation, AvatarHistoryView, CallbackView, @@ -45,7 +44,6 @@ import type { TrashItem, TrashOutcome, VolumeBucket, - Watch, WatchedBot, } from "#lib/api/types.js"; import { accounts } from "#lib/stores/accounts.svelte.js"; @@ -547,46 +545,6 @@ export function transcribeMedia( }); } -export function listWatches(): Promise { - return request("/watches", { account: true }); -} - -export function createWatch( - kind: string, - params: Record, - enabled: boolean -): Promise { - return request("/watches", { - body: { account_id: accounts.selectedId, enabled, kind, params }, - method: "POST", - }); -} - -export function updateWatch( - id: number, - params: Record, - enabled: boolean -): Promise { - return request(`/watches/${id}`, { - body: { enabled, params }, - method: "PUT", - }); -} - -export function deleteWatch(id: number): Promise { - return request(`/watches/${id}`, { method: "DELETE" }); -} - -export function listAlerts( - options: Page & { seen?: boolean } = {} -): Promise { - return request("/alerts", { account: true, query: { ...options } }); -} - -export function markAlertSeen(id: number): Promise { - return request(`/alerts/${id}/seen`, { method: "POST" }); -} - export function listAnnotations( options: { chatId?: number; messageId?: number } = {} ): Promise { diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index 62d56d3..7a47e4f 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -1,3 +1,5 @@ +import type { LiveAlertEvent } from "#lib/api/watches.js"; + export type ChatKind = "private" | "group"; export type PolicyScopeType = | "default_dm" @@ -543,26 +545,6 @@ export interface Folder { title: string; } -export interface Watch { - account_id: number; - created_at: string; - enabled: boolean; - id: number; - kind: string; - params: Record; - updated_at: string; -} - -export interface Alert { - account_id: number; - created_at: string; - id: number; - payload: Record; - seen: boolean; - ts: string; - watch_id: number; -} - export interface JobView { account_id: number; attempts: number; @@ -607,7 +589,8 @@ export type LiveEvent = | LiveMessageEvent | LiveDeleteEvent | LivePresenceEvent - | LiveReceiptEvent; + | LiveReceiptEvent + | LiveAlertEvent; export interface FileShare { account_id: number; diff --git a/frontend/src/lib/api/watches.ts b/frontend/src/lib/api/watches.ts new file mode 100644 index 0000000..0ad4da9 --- /dev/null +++ b/frontend/src/lib/api/watches.ts @@ -0,0 +1,210 @@ +import { request } from "#lib/api/client.js"; +import { accounts } from "#lib/stores/accounts.svelte.js"; + +export type WatchKind = + | "keyword" + | "new_message" + | "message_deleted" + | "message_edited" + | "peer_online"; + +export interface WatchParams { + chat_id?: number; + pattern?: string; + peer_id?: number; + regex?: boolean; + sender_id?: number; +} + +export type StoredParams = WatchParams & { keyword?: string }; + +export interface ChatRef { + has_avatar: boolean; + id: number; + kind: "private" | "group" | "channel"; + title: string | null; +} + +export interface PersonRef { + has_avatar: boolean; + id: number; + name: string | null; +} + +export interface Watch { + account_id: number; + alert_count: number; + chat: ChatRef | null; + created_at: string; + enabled: boolean; + id: number; + kind: WatchKind; + last_alert_at: string | null; + name: string | null; + params: StoredParams; + person: PersonRef | null; + updated_at: string; +} + +export interface WatchDraft { + enabled: boolean; + kind: WatchKind; + name: string | null; + params: WatchParams; +} + +export type AlertEvent = + | "message" + | "edit" + | "transcript" + | "delete" + | "online"; + +export interface AlertMessage { + date: string | null; + deleted: boolean; + edited: boolean; + id: number; + sender: PersonRef | null; + text: string | null; +} + +export interface AlertItem { + account_id: number; + away_since: string | null; + chat: ChatRef | null; + event: AlertEvent; + id: number; + message: AlertMessage | null; + path: string; + peer: PersonRef | null; + seen: boolean; + ts: string; + watch: { + id: number; + kind: WatchKind; + name: string | null; + params: StoredParams; + }; +} + +export interface AlertPage { + items: AlertItem[]; + next_before: number | null; + unseen: number; +} + +export interface LiveAlertEvent { + alert: AlertItem | null; + type: "alert"; + unseen: number; +} + +export interface Webhook { + account_id: number; + created_at: string; + enabled: boolean; + id: number; + last_delivery: Delivery | null; + secret: string; + updated_at: string; + url: string; + watch_ids: number[] | null; +} + +export interface WebhookDraft { + enabled: boolean; + url: string; + watch_ids: number[] | null; +} + +export interface Delivery { + alert_id: number | null; + attempts: number; + created_at: string; + event: "alert" | "test"; + finished_at: string | null; + id: number; + last_error: string | null; + next_attempt_at: string; + response_code: number | null; + status: "pending" | "sending" | "delivered" | "failed"; + webhook_id: number; +} + +export function listWatches(): Promise { + return request("/watches", { account: true }); +} + +export function createWatch(draft: WatchDraft): Promise { + return request("/watches", { + body: { account_id: accounts.selectedId, ...draft }, + method: "POST", + }); +} + +export function updateWatch(id: number, draft: WatchDraft): Promise { + return request(`/watches/${id}`, { body: draft, method: "PUT" }); +} + +export function deleteWatch(id: number): Promise { + return request(`/watches/${id}`, { method: "DELETE" }); +} + +export function listAlerts( + options: { before?: number; limit?: number; seen?: boolean } = {} +): Promise { + return request("/alerts", { account: true, query: options }); +} + +export async function unseenAlerts(): Promise { + const { unseen } = await request<{ unseen: number }>("/alerts/unseen", { + account: true, + }); + return unseen; +} + +export function markAlertSeen(id: number): Promise { + return request(`/alerts/${id}/seen`, { method: "POST" }); +} + +export function markAllAlertsSeen(upTo?: number): Promise<{ marked: number }> { + return request<{ marked: number }>("/alerts/seen", { + body: { account_id: accounts.selectedId, up_to: upTo ?? null }, + method: "POST", + }); +} + +export function listWebhooks(): Promise { + return request("/webhooks", { account: true }); +} + +export function createWebhook(draft: WebhookDraft): Promise { + return request("/webhooks", { + body: { account_id: accounts.selectedId, ...draft }, + method: "POST", + }); +} + +export function updateWebhook( + id: number, + draft: WebhookDraft +): Promise { + return request(`/webhooks/${id}`, { body: draft, method: "PUT" }); +} + +export function deleteWebhook(id: number): Promise { + return request(`/webhooks/${id}`, { method: "DELETE" }); +} + +export function rotateWebhookSecret(id: number): Promise { + return request(`/webhooks/${id}/secret`, { method: "POST" }); +} + +export function testWebhook(id: number): Promise { + return request(`/webhooks/${id}/test`, { method: "POST" }); +} + +export function listDeliveries(id: number): Promise { + return request(`/webhooks/${id}/deliveries`); +} diff --git a/frontend/src/lib/components/RightColumn.svelte b/frontend/src/lib/components/RightColumn.svelte index cb6b1d1..2594447 100644 --- a/frontend/src/lib/components/RightColumn.svelte +++ b/frontend/src/lib/components/RightColumn.svelte @@ -19,6 +19,7 @@ import Button from "#lib/components/ui/Button.svelte"; import Icon from "#lib/components/ui/Icon.svelte"; import WatchesPanel from "#lib/components/watches/WatchesPanel.svelte"; + import WebhooksPanel from "#lib/components/watches/WebhooksPanel.svelte"; import { t } from "#lib/i18n/locale.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js"; import { chats } from "#lib/stores/chats.svelte.js"; @@ -27,7 +28,7 @@ import { type RightPanel, ui } from "#lib/stores/ui.svelte.js"; const titles: Record = $derived({ - alerts: "Алерты", + alerts: t("settings.alerts"), annotations: "Заметки", bots: "Боты", callbacks: "Callback-данные", @@ -46,12 +47,14 @@ stories: "Сторис", "stories-all": "Все сторис", versions: "Версии", - watches: "Отслеживания", + watches: t("settings.watches"), + webhooks: t("watches.webhooks"), }); const parents: Partial> = { "storage-chat": "storage", "storage-trash": "storage", + webhooks: "watches", }; const parent = $derived(ui.rightPanel ? parents[ui.rightPanel] : undefined); @@ -118,7 +121,13 @@ {:else if ui.rightPanel === "stories-all"} {:else if ui.rightPanel === "watches"} - + {#key accounts.selectedId} + + {/key} + {:else if ui.rightPanel === "webhooks"} + {#key accounts.selectedId} + + {/key} {:else if ui.rightPanel === "alerts"} {:else if ui.rightPanel === "annotations"} diff --git a/frontend/src/lib/components/alerts/AlertList.svelte b/frontend/src/lib/components/alerts/AlertList.svelte deleted file mode 100644 index 150e0b4..0000000 --- a/frontend/src/lib/components/alerts/AlertList.svelte +++ /dev/null @@ -1,82 +0,0 @@ - - -
    - {#each alerts as alert (alert.id)} -
  • -
    -

    {text(alert)}

    - -
    - {#if !alert.seen} - - {/if} -
  • - {/each} -
- - diff --git a/frontend/src/lib/components/alerts/AlertsPanel.svelte b/frontend/src/lib/components/alerts/AlertsPanel.svelte index c4a9de3..c743c10 100644 --- a/frontend/src/lib/components/alerts/AlertsPanel.svelte +++ b/frontend/src/lib/components/alerts/AlertsPanel.svelte @@ -1,72 +1,640 @@ -
- - - {#if loading} - - {:else if alerts.length === 0} -

Алертов нет.

- {:else} - - {/if} +
+ +
+{#if alerts.unseen > 0} + +{/if} + +{#if alerts.notifySupported} + + {#if notifyProblem} +

{t("alerts.notifyDenied")}

+ {/if} +{/if} + +{#if loading && items.length === 0} +
+{:else if failed && items.length === 0} +
+

{t("alerts.loadFailed")}

+ +
+{:else if items.length === 0} +
+ + {#if filter === "all"} + + {/if} +
+{:else} +
    + {#each items as alert (alert.id)} + {@const avatar = alertAvatar(alert)} + {@const preview = alertPreview(alert)} +
  • + + {#snippet children({ + props, + })} + + {/snippet} + {#snippet menu()} + openAlert(alert)} + > + {alert.message ? t("alerts.open") : t("alerts.openChat")} + + {#if !alert.seen} + seen(alert)}> + {t("alerts.markSeen")} + + {/if} + {/snippet} + +
  • + {/each} +
+ {#if nextBefore !== null} + + {/if} +{/if} + diff --git a/frontend/src/lib/components/watches/ChatPickerField.svelte b/frontend/src/lib/components/watches/ChatPickerField.svelte new file mode 100644 index 0000000..09b5540 --- /dev/null +++ b/frontend/src/lib/components/watches/ChatPickerField.svelte @@ -0,0 +1,305 @@ + + +
+
+ + {#if value && !required} + + {/if} +
+ {#if open} +
+ picker.search(query)} + placeholder={people ? t("watches.searchPeople") : t("watches.search")} + type="text" + bind:value={query} + {@attach focus} + > + {#each results as chat (chat.chat_id)} + + {:else} +

{t("watches.nothing")}

+ {/each} +
+ {/if} +
+ + diff --git a/frontend/src/lib/components/watches/WatchEditor.svelte b/frontend/src/lib/components/watches/WatchEditor.svelte deleted file mode 100644 index 5154bc7..0000000 --- a/frontend/src/lib/components/watches/WatchEditor.svelte +++ /dev/null @@ -1,121 +0,0 @@ - - -
{ - e.preventDefault(); - submit(); - }} -> - - - {#if isKeyword} - - {:else} - - {/if} - - - - -
- - diff --git a/frontend/src/lib/components/watches/WatchForm.svelte b/frontend/src/lib/components/watches/WatchForm.svelte new file mode 100644 index 0000000..7348513 --- /dev/null +++ b/frontend/src/lib/components/watches/WatchForm.svelte @@ -0,0 +1,388 @@ + + +
{ + event.preventDefault(); + save(); + }} +> + {#if !watch} +
{t("watches.kind")}
+ { + kind = value; + issue = null; + }} + options={kinds} + selected={kind} + /> + {/if} + +
+ {#if kind === "keyword"} +
+ + +
+ + {/if} +
+ + {#if kind === "peer_online"} + { + person = value; + issue = null; + }} + people + required + value={person} + /> + {:else} + { + chat = value; + issue = null; + }} + value={chat} + /> + {#if scoped} + { + person = value; + issue = null; + }} + people + value={person} + /> + {/if} + {/if} + +
+
+ + +
+
+ + {#if issue} +

{issue}

+ {/if} + +
+ + +
+ + + diff --git a/frontend/src/lib/components/watches/WatchList.svelte b/frontend/src/lib/components/watches/WatchList.svelte deleted file mode 100644 index 9efe4ce..0000000 --- a/frontend/src/lib/components/watches/WatchList.svelte +++ /dev/null @@ -1,111 +0,0 @@ - - -
    - {#each watches as watch (watch.id)} -
  • -
    - {watch.kind} - {summary(watch)} -
    -
    - - -
    -
  • - {/each} -
- - diff --git a/frontend/src/lib/components/watches/WatchesPanel.svelte b/frontend/src/lib/components/watches/WatchesPanel.svelte index d63199a..6b387cd 100644 --- a/frontend/src/lib/components/watches/WatchesPanel.svelte +++ b/frontend/src/lib/components/watches/WatchesPanel.svelte @@ -1,80 +1,475 @@ -
- +{#snippet lead( + watch: Watch +)} + {@const chatId = watch.params.chat_id} + {@const personId = watch.params.sender_id ?? watch.params.peer_id} + {#if chatId !== undefined} + 0 ? "peer" : "chat" }} + colorKey={chatId} + hasAvatar={watch.chat?.has_avatar ?? false} + name={chatTitle(watch.chat, chatId)} + size={2.625} + /> + {:else if personId !== undefined} + + {:else} + + {/if} +{/snippet} + +
+ + {#if editing === "new"} +
+ { + editing = null; + }} + onsaved={saved} + /> +
+ {/if} + + {#if problem} +

{problem}

+ {/if} {#if loading} - +
+ {:else if failed} +
+

{t("watches.loadFailed")}

+ +
{:else if watches.length === 0} -

Пока нет отслеживаний.

+
+ +
{:else} - +
    + {#each watches as watch (watch.id)} +
  • + {#if confirming === watch.id} +
    +

    {t("watches.deleteConfirm")}

    +
    + + +
    +
    + {:else} + + {#snippet children({ + props, + })} + + {/snippet} + {#snippet menu()} + edit(watch)}> + {t("watches.edit")} + + toggle(watch)} + > + {watch.enabled ? t("watches.disable") : t("watches.enable")} + + { + editing = null; + confirming = watch.id; + }} + > + {t("watches.delete")} + + {/snippet} + + {/if} + {#if editing === watch.id} +
    + { + editing = null; + }} + onsaved={saved} + {watch} + /> +
    + {/if} +
  • + {/each} +
{/if} + +
+ ui.openPanel("webhooks")} + value={hookCount > 0 ? String(hookCount) : undefined} + /> +
diff --git a/frontend/src/lib/components/watches/WebhookCard.svelte b/frontend/src/lib/components/watches/WebhookCard.svelte new file mode 100644 index 0000000..bd80678 --- /dev/null +++ b/frontend/src/lib/components/watches/WebhookCard.svelte @@ -0,0 +1,676 @@ + + +{#snippet choice( + label: string, + checked: boolean, + watchId: number | null +)} + +{/snippet} + +
+
+
+ + +
+ {#if dirty} +
+ + +
+ {/if} +
+ +
+
+ {t("webhooks.secret")} + {webhook.secret} +
+ +
+

+ {copied ? t("webhooks.copied") : t("webhooks.signature")} +

+
+ +
+ + + +
{t("webhooks.filter")}
+ {@render choice(t("webhooks.allWatches"), chosen.size === 0, null)} + {#each watches as watch (watch.id)} + {@render choice( + watch.name ?? describeWatch(watch), + chosen.has(watch.id), + watch.id + )} + {/each} + + {#if problem} +

{problem}

+ {/if} + + {#if confirming} +
+

{t("webhooks.deleteConfirm")}

+
+ + +
+
+ {:else} +
+ + +
+ {/if} + {#if result} +

+ {result.status === "delivered" + ? t("webhooks.testOk", { code: result.response_code ?? "" }) + : t("webhooks.testFailed", { + error: result.last_error ?? String(result.response_code ?? ""), + })} +

+ {/if} + +
{t("webhooks.log")}
+ {#if !logLoaded} +
+ {:else if log.length === 0} +

{t("webhooks.logEmpty")}

+ {:else} +
    + {#each log as delivery (delivery.id)} +
  • + + + + + + {t(`webhooks.event.${delivery.event}`)} + {#if delivery.alert_id !== null} + #{delivery.alert_id} + {/if} + + {detail(delivery)} + + +
  • + {/each} +
+ {/if} +
+ + diff --git a/frontend/src/lib/components/watches/WebhooksPanel.svelte b/frontend/src/lib/components/watches/WebhooksPanel.svelte new file mode 100644 index 0000000..cc0dd76 --- /dev/null +++ b/frontend/src/lib/components/watches/WebhooksPanel.svelte @@ -0,0 +1,443 @@ + + +
+ + {#if adding} +
{ + event.preventDefault(); + add(); + }} + > +
+ + +
+
+ + {#if newUrl.trim() !== ""} + + {/if} +
+
+ {/if} + + {#if problem} +

{problem}

+ {/if} + + {#if loading} +
+ {:else if failed} +
+

{t("watches.loadFailed")}

+ +
+ {:else if hooks.length === 0 && !adding} +
+ +
+ {:else} +
    + {#each hooks as hook (hook.id)} +
  • + + {#snippet children({ + props, + })} + + {/snippet} + {#snippet menu()} + open(hook, "test")}> + {t("webhooks.test")} + + toggle(hook)} + > + {hook.enabled ? t("watches.disable") : t("watches.enable")} + + open(hook, "delete")} + > + {t("watches.delete")} + + {/snippet} + + {#if expanded === hook.id} + {#key intent} +
    + remove(hook)} + {watches} + webhook={hook} + /> +
    + {/key} + {/if} +
  • + {/each} +
+ {/if} +
+ + diff --git a/frontend/src/lib/format/watches.ts b/frontend/src/lib/format/watches.ts new file mode 100644 index 0000000..673e841 --- /dev/null +++ b/frontend/src/lib/format/watches.ts @@ -0,0 +1,242 @@ +import type { + AlertEvent, + AlertItem, + ChatRef, + Delivery, + PersonRef, + StoredParams, + WatchKind, +} from "#lib/api/watches.js"; +import { formatTime } from "#lib/format/datetime.js"; +import { locale, t } from "#lib/i18n/locale.svelte.js"; + +export const KIND_ICONS: Record = { + keyword: "search", + message_deleted: "delete", + message_edited: "edit", + new_message: "message", + peer_online: "user-online", +}; + +export const EVENT_ICONS: Record = { + delete: "delete", + edit: "edit", + message: "message", + online: "user-online", + transcript: "transcribe", +}; + +export const WATCH_KINDS: WatchKind[] = [ + "keyword", + "new_message", + "message_deleted", + "message_edited", + "peer_online", +]; + +export interface Picked { + hasAvatar: boolean; + id: number; + title: string; +} + +interface Described { + chat: ChatRef | null; + kind: WatchKind; + params: StoredParams; + person: PersonRef | null; +} + +const MINUTE = 60_000; +const HOUR = 60 * MINUTE; +const DAY = 24 * HOUR; +const HOURS_AS_DAYS = 48; +const WHITESPACE = /\s+/g; + +export function chatTitle(chat: ChatRef | null, id?: number): string { + if (chat?.title) { + return chat.title; + } + return t("watches.unknownChat", { id: chat?.id ?? id ?? "" }); +} + +export function personName(person: PersonRef | null, id?: number): string { + if (person?.name) { + return person.name; + } + return t("watches.unknownPerson", { id: person?.id ?? id ?? "" }); +} + +export function patternOf(params: StoredParams): string { + return params.pattern ?? params.keyword ?? ""; +} + +function what(params: StoredParams): string { + const key = params.regex ? "watches.describe.regex" : "watches.describe.word"; + return t(key, { pattern: patternOf(params) }); +} + +function describeKeyword(watch: Described): string { + const { params } = watch; + const values = { + chat: chatTitle(watch.chat, params.chat_id), + person: personName(watch.person, params.sender_id), + what: what(params), + }; + if (params.chat_id !== undefined && params.sender_id !== undefined) { + return t("watches.describe.keywordFromIn", values); + } + if (params.chat_id !== undefined) { + return t("watches.describe.keywordIn", values); + } + if (params.sender_id !== undefined) { + return t("watches.describe.keywordFrom", values); + } + return t("watches.describe.keyword", values); +} + +function describeNew(watch: Described): string { + const { params } = watch; + const values = { + chat: chatTitle(watch.chat, params.chat_id), + person: personName(watch.person, params.sender_id), + }; + if (params.chat_id !== undefined && params.sender_id !== undefined) { + return t("watches.describe.newFromIn", values); + } + if (params.chat_id !== undefined) { + return t("watches.describe.newIn", values); + } + return t("watches.describe.newFrom", values); +} + +export function describeWatch(watch: Described): string { + const { params } = watch; + const chat = { chat: chatTitle(watch.chat, params.chat_id) }; + switch (watch.kind) { + case "keyword": + return describeKeyword(watch); + case "new_message": + return describeNew(watch); + case "message_deleted": + return params.chat_id === undefined + ? t("watches.describe.deletedDm") + : t("watches.describe.deletedIn", chat); + case "message_edited": + return params.chat_id === undefined + ? t("watches.describe.editedDm") + : t("watches.describe.editedIn", chat); + default: + return t("watches.describe.online", { + person: personName(watch.person, params.peer_id), + }); + } +} + +function unitFormat(unit: "minute" | "hour" | "day", value: number): string { + return new Intl.NumberFormat(locale.tag, { + style: "unit", + unit, + unitDisplay: "short", + }).format(value); +} + +export function awayFor(since: string, until: string): string { + const gap = Math.max( + 0, + new Date(until).getTime() - new Date(since).getTime() + ); + if (gap < HOUR) { + return unitFormat("minute", Math.max(1, Math.round(gap / MINUTE))); + } + if (gap < HOURS_AS_DAYS * HOUR) { + return unitFormat("hour", Math.round(gap / HOUR)); + } + return unitFormat("day", Math.round(gap / DAY)); +} + +export function alertTitle(alert: AlertItem): string { + if (alert.chat) { + return chatTitle(alert.chat); + } + return personName(alert.peer); +} + +export function alertLabel(alert: AlertItem): string { + if (alert.watch.name) { + return alert.watch.name; + } + if (alert.watch.kind === "keyword") { + return what(alert.watch.params); + } + return t(`alerts.event.${alert.event}`); +} + +export function alertPreview(alert: AlertItem): string { + if (alert.event === "online") { + return alert.away_since + ? t("alerts.onlineAfter", { away: awayFor(alert.away_since, alert.ts) }) + : ""; + } + const { message } = alert; + const text = message?.text?.replace(WHITESPACE, " ") || t("alerts.noText"); + const sender = message?.sender; + if ( + !sender || + sender.id === alert.chat?.id || + alert.chat?.kind === "private" + ) { + return text; + } + return `${personName(sender)}: ${text}`; +} + +export function alertLine(alert: AlertItem): string { + const preview = alertPreview(alert); + return preview ? `${alertLabel(alert)}: ${preview}` : alertLabel(alert); +} + +export function alertAvatar(alert: AlertItem): { + hasAvatar: boolean; + id: number; + kind: "chat" | "peer"; + name: string; +} { + if (alert.chat) { + return { + hasAvatar: alert.chat.has_avatar, + id: alert.chat.id, + kind: alert.chat.id > 0 ? "peer" : "chat", + name: chatTitle(alert.chat), + }; + } + return { + hasAvatar: alert.peer?.has_avatar ?? false, + id: alert.peer?.id ?? 0, + kind: "peer", + name: personName(alert.peer), + }; +} + +function deliveryReason(delivery: Delivery): string { + return delivery.response_code === null + ? (delivery.last_error ?? "") + : String(delivery.response_code); +} + +export function deliveryStatus(delivery: Delivery): string { + const waiting = + delivery.status === "sending" || + (delivery.status === "pending" && delivery.attempts === 0); + if (waiting) { + return t("webhooks.status.sending"); + } + const head = + delivery.status === "pending" + ? t("webhooks.status.pending", { + time: formatTime(delivery.next_attempt_at), + }) + : t(`webhooks.status.${delivery.status}`); + const reason = deliveryReason(delivery); + return reason ? `${head} · ${reason}` : head; +} diff --git a/frontend/src/lib/i18n/messages.ts b/frontend/src/lib/i18n/messages.ts index a4b5b99..2939b7a 100644 --- a/frontend/src/lib/i18n/messages.ts +++ b/frontend/src/lib/i18n/messages.ts @@ -1,15 +1,19 @@ import { account } from "#lib/i18n/messages/account.js"; +import { alerts } from "#lib/i18n/messages/alerts.js"; import { common } from "#lib/i18n/messages/common.js"; import { menu } from "#lib/i18n/messages/menu.js"; import { read } from "#lib/i18n/messages/read.js"; import { settings } from "#lib/i18n/messages/settings.js"; +import { watches } from "#lib/i18n/messages/watches.js"; export const messages = { ...account, + ...alerts, ...common, ...menu, ...read, ...settings, + ...watches, }; export type MessageKey = keyof typeof messages; diff --git a/frontend/src/lib/i18n/messages/alerts.ts b/frontend/src/lib/i18n/messages/alerts.ts new file mode 100644 index 0000000..462831a --- /dev/null +++ b/frontend/src/lib/i18n/messages/alerts.ts @@ -0,0 +1,47 @@ +import type { Messages } from "#lib/i18n/types.js"; + +export const alerts = { + "alerts.all": { en: "All", ru: "Все" }, + "alerts.badge": { + en: { one: "{count} unread alert", other: "{count} unread alerts" }, + ru: { + few: "{count} непрочитанных оповещения", + many: "{count} непрочитанных оповещений", + one: "{count} непрочитанное оповещение", + other: "{count} непрочитанных оповещения", + }, + }, + "alerts.empty.description": { + en: "They show up here when a watch fires", + ru: "Они появятся здесь, когда сработает наблюдение", + }, + "alerts.empty.title": { en: "No alerts", ru: "Оповещений нет" }, + "alerts.emptyUnseen.title": { en: "All read", ru: "Всё прочитано" }, + "alerts.event.delete": { en: "Deleted", ru: "Удалено" }, + "alerts.event.edit": { en: "Edited", ru: "Изменено" }, + "alerts.event.message": { en: "New", ru: "Новое" }, + "alerts.event.online": { en: "Online", ru: "В сети" }, + "alerts.event.transcript": { en: "In a voice message", ru: "В голосовом" }, + "alerts.loadFailed": { + en: "Couldn't load alerts", + ru: "Не удалось загрузить оповещения", + }, + "alerts.markAll": { en: "Mark all as read", ru: "Отметить все прочитанными" }, + "alerts.markSeen": { en: "Mark as read", ru: "Отметить прочитанным" }, + "alerts.more": { en: "Show more", ru: "Показать ещё" }, + "alerts.noText": { en: "No text", ru: "Без текста" }, + "alerts.notify": { + en: "Browser notifications", + ru: "Уведомления в браузере", + }, + "alerts.notifyDenied": { + en: "The browser blocked notifications for this site", + ru: "Браузер запретил уведомления для этого сайта", + }, + "alerts.onlineAfter": { en: "after {away} away", ru: "не было {away}" }, + "alerts.open": { en: "Go to message", ru: "Перейти к сообщению" }, + "alerts.openChat": { en: "Open chat", ru: "Открыть чат" }, + "alerts.retry": { en: "Retry", ru: "Повторить" }, + "alerts.setup": { en: "Set up watches", ru: "Настроить наблюдения" }, + "alerts.unseen": { en: "Unread", ru: "Непрочитанные" }, +} satisfies Messages; diff --git a/frontend/src/lib/i18n/messages/watches.ts b/frontend/src/lib/i18n/messages/watches.ts new file mode 100644 index 0000000..4c639de --- /dev/null +++ b/frontend/src/lib/i18n/messages/watches.ts @@ -0,0 +1,206 @@ +import type { Messages } from "#lib/i18n/types.js"; + +export const watches = { + "watches.add": { en: "New watch", ru: "Новое наблюдение" }, + "watches.anyChat": { en: "Any chat", ru: "Любой чат" }, + "watches.anyDm": { en: "Any private chat", ru: "Любая личка" }, + "watches.anySender": { en: "Anyone", ru: "Кто угодно" }, + "watches.chat": { en: "Chat", ru: "Чат" }, + "watches.choose": { en: "Choose", ru: "Выбрать" }, + "watches.clear": { en: "Clear", ru: "Убрать" }, + "watches.delete": { en: "Delete", ru: "Удалить" }, + "watches.deleteConfirm": { + en: "Delete the watch with its alerts?", + ru: "Удалить наблюдение вместе с оповещениями?", + }, + "watches.describe.deletedDm": { + en: "Deleted messages in private chats", + ru: "Удалённые сообщения в личке", + }, + "watches.describe.deletedIn": { + en: "Deleted messages in {chat}", + ru: "Удалённые сообщения в чате «{chat}»", + }, + "watches.describe.editedDm": { + en: "Edited messages in private chats", + ru: "Изменённые сообщения в личке", + }, + "watches.describe.editedIn": { + en: "Edited messages in {chat}", + ru: "Изменённые сообщения в чате «{chat}»", + }, + "watches.describe.keyword": { + en: "{what} in any chat", + ru: "{what} в любом чате", + }, + "watches.describe.keywordFrom": { + en: "{what} from {person}", + ru: "{what}, отправитель {person}", + }, + "watches.describe.keywordFromIn": { + en: "{what} from {person} in {chat}", + ru: "{what} в чате «{chat}», отправитель {person}", + }, + "watches.describe.keywordIn": { + en: "{what} in {chat}", + ru: "{what} в чате «{chat}»", + }, + "watches.describe.newFrom": { + en: "New messages from {person}", + ru: "Новые сообщения, отправитель {person}", + }, + "watches.describe.newFromIn": { + en: "New messages from {person} in {chat}", + ru: "Новые сообщения в чате «{chat}», отправитель {person}", + }, + "watches.describe.newIn": { + en: "New messages in {chat}", + ru: "Новые сообщения в чате «{chat}»", + }, + "watches.describe.online": { + en: "{person} is online", + ru: "{person} в сети", + }, + "watches.describe.regex": { + en: "Pattern “{pattern}”", + ru: "Шаблон «{pattern}»", + }, + "watches.describe.word": { en: "Word “{pattern}”", ru: "Слово «{pattern}»" }, + "watches.disable": { en: "Turn off", ru: "Выключить" }, + "watches.edit": { en: "Edit", ru: "Изменить" }, + "watches.empty.description": { + en: "A watch looks for a word, a person or deleted messages and raises an alert", + ru: "Наблюдение ищет слово, человека или удалённые сообщения и присылает оповещение", + }, + "watches.empty.title": { en: "No watches yet", ru: "Наблюдений пока нет" }, + "watches.enable": { en: "Turn on", ru: "Включить" }, + "watches.error.action": { + en: "That didn't work, try again", + ru: "Не получилось, попробуйте ещё раз", + }, + "watches.error.badRegex": { + en: "Can't read the expression: {detail}", + ru: "Не получилось разобрать выражение: {detail}", + }, + "watches.error.failed": { + en: "Couldn't save: {detail}", + ru: "Не удалось сохранить: {detail}", + }, + "watches.error.needPattern": { + en: "Enter a word or a phrase", + ru: "Впишите слово или фразу", + }, + "watches.error.needPerson": { + en: "Choose a person", + ru: "Выберите человека", + }, + "watches.error.needScope": { + en: "Choose a chat or a sender", + ru: "Выберите чат или отправителя", + }, + "watches.error.slowRegex": { + en: "A repeat inside a repeat, like (a+)+, can hang. Make the expression simpler", + ru: "Повтор внутри повтора, вроде (a+)+, может зависнуть. Упростите выражение", + }, + "watches.expression": { en: "Expression", ru: "Выражение" }, + "watches.fired": { + en: { + one: "{count} alert, last {date}", + other: "{count} alerts, last {date}", + }, + ru: { + few: "{count} оповещения, последнее {date}", + many: "{count} оповещений, последнее {date}", + one: "{count} оповещение, последнее {date}", + other: "{count} оповещения, последнее {date}", + }, + }, + "watches.kind": { en: "What to watch", ru: "Что ищем" }, + "watches.kind.keyword": { en: "A word or a pattern", ru: "Слово или шаблон" }, + "watches.kind.message_deleted": { + en: "Deleted messages", + ru: "Удалённые сообщения", + }, + "watches.kind.message_edited": { + en: "Edited messages", + ru: "Изменённые сообщения", + }, + "watches.kind.new_message": { en: "New messages", ru: "Новые сообщения" }, + "watches.kind.peer_online": { en: "Coming online", ru: "Появление в сети" }, + "watches.loadFailed": { + en: "Couldn't load watches", + ru: "Не удалось загрузить наблюдения", + }, + "watches.name": { en: "Name, optional", ru: "Название, если нужно" }, + "watches.never": { en: "Hasn't fired yet", ru: "Ещё не срабатывало" }, + "watches.nothing": { en: "Nothing found", ru: "Ничего не найдено" }, + "watches.off": { en: "Off", ru: "Выключено" }, + "watches.pattern": { en: "Word or phrase", ru: "Слово или фраза" }, + "watches.person": { en: "Person", ru: "Человек" }, + "watches.regex": { en: "Regular expression", ru: "Регулярное выражение" }, + "watches.retry": { en: "Retry", ru: "Повторить" }, + "watches.search": { en: "Search chats", ru: "Поиск чата" }, + "watches.searchPeople": { en: "Search people", ru: "Поиск человека" }, + "watches.sender": { en: "Sender", ru: "Отправитель" }, + "watches.unknownChat": { en: "Chat {id}", ru: "Чат {id}" }, + "watches.unknownPerson": { en: "User {id}", ru: "Пользователь {id}" }, + "watches.webhooks": { en: "Webhooks", ru: "Вебхуки" }, + "webhooks.add": { en: "Add a webhook", ru: "Добавить вебхук" }, + "webhooks.allWatches": { en: "All watches", ru: "Все наблюдения" }, + "webhooks.attempts": { + en: { one: "{count} attempt", other: "{count} attempts" }, + ru: { + few: "{count} попытки", + many: "{count} попыток", + one: "{count} попытка", + other: "{count} попытки", + }, + }, + "webhooks.badUrl": { + en: "Needs an http or https address", + ru: "Нужен адрес http или https", + }, + "webhooks.copied": { en: "Copied", ru: "Скопировано" }, + "webhooks.copy": { en: "Copy", ru: "Скопировать" }, + "webhooks.deleteConfirm": { + en: "Delete the webhook?", + ru: "Удалить вебхук?", + }, + "webhooks.empty.description": { + en: "Every alert goes out as a signed POST to your address", + ru: "Каждое оповещение уйдёт POST-запросом с подписью на ваш адрес", + }, + "webhooks.empty.title": { en: "No webhooks", ru: "Вебхуков нет" }, + "webhooks.enabled": { en: "Send alerts", ru: "Отправлять оповещения" }, + "webhooks.event.alert": { en: "Alert", ru: "Оповещение" }, + "webhooks.event.test": { en: "Test", ru: "Проверка" }, + "webhooks.filter": { en: "Which watches", ru: "Какие наблюдения" }, + "webhooks.log": { en: "Recent deliveries", ru: "Последние доставки" }, + "webhooks.logEmpty": { + en: "Nothing sent yet", + ru: "Пока ничего не отправлено", + }, + "webhooks.never": { en: "Nothing sent yet", ru: "Ещё не отправлялся" }, + "webhooks.off": { en: "Off", ru: "Выключен" }, + "webhooks.rotate": { en: "New secret", ru: "Новый секрет" }, + "webhooks.secret": { en: "Signing secret", ru: "Секрет подписи" }, + "webhooks.signature": { + en: "Header X-Beavergram-Signature: sha256=HMAC of the body", + ru: "Заголовок X-Beavergram-Signature: sha256=HMAC тела", + }, + "webhooks.status.delivered": { en: "Delivered", ru: "Доставлено" }, + "webhooks.status.failed": { en: "Not delivered", ru: "Не доставлено" }, + "webhooks.status.pending": { en: "Retry at {time}", ru: "Повтор в {time}" }, + "webhooks.status.sending": { en: "Sending", ru: "Отправляется" }, + "webhooks.test": { en: "Send a test", ru: "Проверить" }, + "webhooks.testFailed": { + en: "Not delivered: {error}", + ru: "Не доставлено: {error}", + }, + "webhooks.testing": { en: "Sending…", ru: "Отправляю…" }, + "webhooks.testOk": { + en: "Delivered, response {code}", + ru: "Доставлено, ответ {code}", + }, + "webhooks.url": { en: "Address", ru: "Адрес" }, +} satisfies Messages; diff --git a/frontend/src/lib/stores/alerts.svelte.ts b/frontend/src/lib/stores/alerts.svelte.ts new file mode 100644 index 0000000..a707a79 --- /dev/null +++ b/frontend/src/lib/stores/alerts.svelte.ts @@ -0,0 +1,120 @@ +import { + type AlertItem, + type LiveAlertEvent, + unseenAlerts, +} from "#lib/api/watches.js"; +import { alertLine, alertTitle } from "#lib/format/watches.js"; +import { events } from "#lib/stores/events.svelte.js"; +import { browser } from "$app/env"; +import { goto } from "$app/navigation"; + +const NOTIFY_KEY = "bg.alerts.notify"; + +export const ALERTS_PARAM = "alerts"; + +type Listener = (alert: AlertItem) => void; + +function notifySupported(): boolean { + return browser && "Notification" in window; +} + +function createAlerts() { + const listeners = new Set(); + let unseen = $state(0); + let notify = $state( + notifySupported() && + localStorage.getItem(NOTIFY_KEY) === "1" && + Notification.permission === "granted" + ); + let account: number | null = null; + + async function refresh() { + if (account === null) { + return; + } + const current = account; + const count = await unseenAlerts().catch(() => null); + if (count !== null && current === account) { + unseen = count; + } + } + + function popUp(alert: AlertItem) { + if (!(notify && document.hidden)) { + return; + } + const popup = new Notification(alertTitle(alert), { + body: alertLine(alert), + tag: `bg-alert-${alert.id}`, + }); + popup.onclick = () => { + window.focus(); + goto(alert.path); + popup.close(); + }; + } + + function apply(event: LiveAlertEvent) { + ({ unseen } = event); + if (event.alert === null) { + return; + } + for (const listener of listeners) { + listener(event.alert); + } + popUp(event.alert); + } + + events.subscribe((event) => { + if (event.type === "alert") { + apply(event); + } + }); + events.onReconnect(() => { + refresh(); + }); + + return { + get notify(): boolean { + return notify; + }, + get notifySupported(): boolean { + return notifySupported(); + }, + onAlert(listener: Listener): () => void { + listeners.add(listener); + return () => listeners.delete(listener); + }, + read(count = 1) { + unseen = Math.max(0, unseen - count); + }, + readAll() { + unseen = 0; + }, + async setNotify(on: boolean): Promise { + if (!notifySupported()) { + return false; + } + const allowed = + on && + (Notification.permission === "granted" || + (await Notification.requestPermission()) === "granted"); + notify = allowed; + localStorage.setItem(NOTIFY_KEY, allowed ? "1" : "0"); + return allowed === on; + }, + track(accountId: number | null) { + if (accountId === account) { + return; + } + account = accountId; + unseen = 0; + refresh(); + }, + get unseen(): number { + return unseen; + }, + }; +} + +export const alerts = createAlerts(); diff --git a/frontend/src/lib/stores/ui.svelte.ts b/frontend/src/lib/stores/ui.svelte.ts index 608906c..c0ec9fe 100644 --- a/frontend/src/lib/stores/ui.svelte.ts +++ b/frontend/src/lib/stores/ui.svelte.ts @@ -13,6 +13,7 @@ export type RightPanel = | "stories-all" | "policy" | "watches" + | "webhooks" | "alerts" | "shares" | "bots"