feat(watcher,api,frontend): archive who writes to watched bots over mtproto

This commit is contained in:
hh committed 2026-09-18 15:05:01 +02:00
1 parent 089a31db8d
commit 4bd031f21e
30 files changed
+1863 -10

No files matched your search

@@ -1,6 +1,7 @@
<script lang="ts">
import AlertsPanel from "$lib/components/alerts/AlertsPanel.svelte";
import AnnotationsPanel from "$lib/components/annotations/AnnotationsPanel.svelte";
import BotsPanel from "$lib/components/bots/BotsPanel.svelte";
import JobsPanel from "$lib/components/jobs/JobsPanel.svelte";
import PolicyEditor from "$lib/components/policy/PolicyEditor.svelte";
import AnalyticsPanel from "$lib/components/presence/AnalyticsPanel.svelte";
@@ -33,6 +34,7 @@
watches: "Отслеживания",
alerts: "Алерты",
shares: "Файлы по ссылке",
bots: "Боты под наблюдением",
};
</script>
@@ -78,6 +80,8 @@
<AnnotationsPanel />
{:else if ui.rightPanel === "shares"}
<SharesPanel />
{:else if ui.rightPanel === "bots"}
<BotsPanel />
{/if}
</div>
@@ -0,0 +1,234 @@
<script lang="ts">
import { botStats, botUpdates } from "$lib/api/endpoints";
import type { BotSender, BotStats, BotUpdate } from "$lib/api/types";
import BotTimeline from "$lib/components/bots/BotTimeline.svelte";
import BotUpdateList from "$lib/components/bots/BotUpdateList.svelte";
import Button from "$lib/components/ui/Button.svelte";
import Spinner from "$lib/components/ui/Spinner.svelte";
import { formatListDate } from "$lib/format/datetime";
interface Props {
botId: number;
}
const { botId }: Props = $props();
let stats = $state<BotStats | null>(null);
let updates = $state<BotUpdate[]>([]);
let sender = $state<number | null>(null);
let loading = $state(true);
$effect(() => {
loading = true;
botStats(botId)
.then((result) => {
stats = result;
})
.finally(() => {
loading = false;
});
});
$effect(() => {
botUpdates(botId, { limit: 50, senderId: sender ?? undefined }).then(
(result) => {
updates = result;
}
);
});
function senderLabel(item: BotSender): string {
return (
item.name ??
(item.username ? `@${item.username}` : String(item.sender_id))
);
}
function toggleSender(id: number): void {
sender = sender === id ? null : id;
}
</script>
{#if loading && stats === null}
<Spinner />
{:else if stats}
<div class="stats">
<div class="tiles">
<div class="tile">
<span class="value">{stats.summary.incoming}</span>
<span class="label">входящих</span>
</div>
<div class="tile">
<span class="value">{stats.summary.senders}</span>
<span class="label">отправителей</span>
</div>
<div class="tile">
<span class="value">{stats.summary.chats}</span>
<span class="label">чатов</span>
</div>
<div class="tile">
<span class="value">{stats.summary.outgoing}</span>
<span class="label">ответов бота</span>
</div>
</div>
{#if stats.timeline.length > 0}
<section>
<h3>По дням</h3>
<BotTimeline timeline={stats.timeline} />
</section>
{/if}
{#if stats.media.length > 0}
<section>
<h3>Вложения</h3>
<ul class="counts">
{#each stats.media as item (item.key)}
<li><span>{item.key}</span><b>{item.incoming}</b></li>
{/each}
</ul>
</section>
{/if}
<section>
<h3>Кто пишет</h3>
{#if stats.senders.length === 0}
<p class="empty">Пока никто.</p>
{:else}
<ul class="senders">
{#each stats.senders as item (item.sender_id)}
<li>
<button
type="button"
class="sender"
class:active={sender === item.sender_id}
onclick={() => toggleSender(item.sender_id)}
>
<span class="name">{senderLabel(item)}</span>
<span class="meta">
{item.incoming}
· {formatListDate(item.last_ts)}
</span>
</button>
</li>
{/each}
</ul>
{/if}
</section>
<section>
<div class="section-head">
<h3>Лента</h3>
{#if sender !== null}
<Button variant="translucent" smaller onclick={() => (sender = null)}>
Все
</Button>
{/if}
</div>
{#if updates.length === 0}
<p class="empty">Ничего не пришло.</p>
{:else}
<BotUpdateList {updates} />
{/if}
</section>
</div>
{/if}
<style lang="scss">
.stats {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.tiles {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.5rem;
}
.tile {
display: flex;
flex-direction: column;
gap: 0.125rem;
align-items: center;
padding: 0.5rem 0.25rem;
border-radius: var(--border-radius-default);
background-color: var(--color-background-secondary);
}
.value {
font-size: 1.125rem;
font-weight: var(--font-weight-medium);
}
.label {
font-size: 0.6875rem;
color: var(--color-text-secondary);
text-align: center;
}
h3 {
margin: 0 0 0.5rem;
font-size: 0.875rem;
font-weight: var(--font-weight-medium);
color: var(--color-primary);
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.counts,
.senders {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin: 0;
padding: 0;
list-style: none;
}
.counts li {
display: flex;
justify-content: space-between;
font-size: 0.875rem;
}
.sender {
display: flex;
align-items: baseline;
justify-content: space-between;
width: 100%;
padding: 0.375rem 0.5rem;
border: none;
border-radius: var(--border-radius-default);
font-size: 0.875rem;
color: inherit;
text-align: left;
background: none;
cursor: pointer;
&:hover,
&.active {
background-color: var(--color-background-secondary);
}
}
.meta {
font-size: 0.75rem;
color: var(--color-text-secondary);
}
.empty {
margin: 0;
color: var(--color-text-secondary);
}
</style>
@@ -0,0 +1,43 @@
<script lang="ts">
import type { BotBucket } from "$lib/api/types";
interface Props {
timeline: BotBucket[];
}
const { timeline }: Props = $props();
const max = $derived(
Math.max(1, ...timeline.map((bucket) => bucket.incoming))
);
function barTitle(bucket: BotBucket): string {
return `${bucket.bucket.slice(0, 10)} — ${bucket.incoming} входящих от ${bucket.senders}`;
}
</script>
<div class="bars">
{#each timeline as bucket (bucket.bucket)}
<div
class="bar"
style:height={`${(bucket.incoming / max) * 100}%`}
title={barTitle(bucket)}
></div>
{/each}
</div>
<style lang="scss">
.bars {
display: flex;
align-items: flex-end;
gap: 1px;
height: 7.5rem;
}
.bar {
flex: 1;
min-height: 2px;
border-radius: 2px 2px 0 0;
background-color: var(--color-primary);
}
</style>
@@ -0,0 +1,106 @@
<script lang="ts">
import type { BotUpdate } from "$lib/api/types";
import { formatFull } from "$lib/format/datetime";
import { mediaKindLabel } from "$lib/format/media";
interface Props {
updates: BotUpdate[];
}
const { updates }: Props = $props();
const kindLabels: Record<string, string> = {
message: "сообщение",
callback_query: "кнопка",
member: "статус",
};
function senderName(update: BotUpdate): string {
if (update.sender_name) {
return update.sender_name;
}
if (update.sender_username) {
return `@${update.sender_username}`;
}
return update.sender_id === null ? "неизвестно" : String(update.sender_id);
}
function body(update: BotUpdate): string {
const media = mediaKindLabel(update.media_kind);
const text = update.text ?? "";
if (media && text) {
return `[${media}] ${text}`;
}
return media ? `[${media}]` : text;
}
</script>
<ul class="updates">
{#each updates as update (`${update.kind}:${update.chat_id}:${update.item_id}:${update.ts}`)}
<li class="update">
<div class="head">
<span class="sender">{senderName(update)}</span>
{#if update.sender_username && update.sender_name}
<span class="username">@{update.sender_username}</span>
{/if}
<span class="kind">{kindLabels[update.kind] ?? update.kind}</span>
{#if update.source === "history"}
<span class="kind">история</span>
{/if}
<time>{formatFull(update.ts)}</time>
</div>
{#if body(update)}
<p class="body">{body(update)}</p>
{/if}
{#if update.chat_title && update.chat_type !== "private"}
<div class="chat">{update.chat_title}</div>
{/if}
</li>
{/each}
</ul>
<style lang="scss">
.updates {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin: 0;
padding: 0;
list-style: none;
}
.update {
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--color-borders);
}
.head {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.5rem;
font-size: 0.8125rem;
}
.sender {
font-weight: var(--font-weight-medium);
}
.username,
.kind,
time {
color: var(--color-text-secondary);
}
.body {
overflow-wrap: anywhere;
margin: 0.25rem 0 0;
}
.chat {
margin-top: 0.25rem;
font-size: 0.75rem;
color: var(--color-text-secondary);
}
</style>
@@ -0,0 +1,268 @@
<script lang="ts">
import {
addWatchedBot,
listWatchedBots,
removeWatchedBot,
} from "$lib/api/endpoints";
import type { WatchedBot } from "$lib/api/types";
import BotStatsView from "$lib/components/bots/BotStatsView.svelte";
import Button from "$lib/components/ui/Button.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import Spinner from "$lib/components/ui/Spinner.svelte";
import { formatListDate } from "$lib/format/datetime";
import { toasts } from "$lib/stores/toasts.svelte";
let bots = $state<WatchedBot[]>([]);
let loading = $state(true);
let token = $state("");
let adding = $state(false);
let selected = $state<number | null>(null);
const statusLabels: Record<string, string> = {
pending: "подключается",
ok: "слушает",
unauthorized: "токен отклонён",
error: "ошибка",
removed: "отключён",
};
async function load() {
loading = true;
try {
bots = await listWatchedBots();
} finally {
loading = false;
}
}
$effect(() => {
load();
});
async function add() {
if (!token.trim()) {
return;
}
adding = true;
try {
await addWatchedBot(token.trim());
token = "";
await load();
} catch {
toasts.error("Не похоже на токен бота");
} finally {
adding = false;
}
}
async function remove(bot: WatchedBot) {
await removeWatchedBot(bot.bot_id);
if (selected === bot.bot_id) {
selected = null;
}
await load();
}
function botName(bot: WatchedBot): string {
return bot.username
? `@${bot.username}`
: (bot.title ?? String(bot.bot_id));
}
const current = $derived(bots.find((bot) => bot.bot_id === selected) ?? null);
</script>
<div class="panel-content">
{#if current}
<header class="bot-header">
<Button
variant="translucent"
round
smaller
onclick={() => (selected = null)}
aria-label="Назад"
>
<Icon name="arrow-left" />
</Button>
<div>
<div class="name">{botName(current)}</div>
<div class="meta">
{statusLabels[current.status] ?? current.status}
{#if current.backfilled_at === null && current.status === "ok"}
· история загружается
{/if}
</div>
</div>
</header>
{#if current.status_detail}
<p class="detail">{current.status_detail}</p>
{/if}
<BotStatsView botId={current.bot_id} />
{:else}
{#if loading}
<Spinner />
{:else if bots.length === 0}
<p class="empty">Ботов пока нет.</p>
{:else}
<ul class="bots">
{#each bots as bot (bot.bot_id)}
<li class="bot">
<button
type="button"
class="open"
onclick={() => (selected = bot.bot_id)}
>
<span class="name">{botName(bot)}</span>
<span class="meta">
{statusLabels[bot.status] ?? bot.status}
· {bot.incoming} от {bot.senders}
{#if bot.last_ts}
· {formatListDate(bot.last_ts)}
{/if}
</span>
</button>
{#if bot.enabled}
<Button
variant="translucent"
round
smaller
onclick={() => remove(bot)}
aria-label="Отключить"
>
<Icon name="delete" />
</Button>
{/if}
</li>
{/each}
</ul>
{/if}
<div class="add">
<label class="field">
<span>Токен бота от @BotFather</span>
<input type="password" bind:value={token} placeholder="123456:AA…">
</label>
<Button fluid loading={adding} onclick={add}>Добавить</Button>
</div>
<p class="note">
Бот логинится по MTProto, отдельной авторизацией. Это не забирает
getUpdates у того, кто держит того же бота через Bot API, и не требует
вебхука. Сколько прошлой переписки удастся вытащить, решает сервер
Telegram: watcher просит историю диалогов бота и берёт, что дают.
</p>
<p class="note">
В личке бот видит каждого, кто ему написал. В группах ему доставляют всё
только при выключенном privacy mode, иначе — команды и ответы на его
сообщения. Отключение бота убирает токен и сессию, но архив остаётся.
</p>
{/if}
</div>
<style lang="scss">
.panel-content {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
}
.bot-header {
display: flex;
align-items: center;
gap: 0.75rem;
}
.bots {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin: 0;
padding: 0;
list-style: none;
}
.bot {
display: flex;
align-items: center;
gap: 0.25rem;
}
.open {
display: flex;
flex: 1;
flex-direction: column;
gap: 0.125rem;
padding: 0.5rem;
border: none;
border-radius: var(--border-radius-default);
color: inherit;
text-align: left;
background: none;
cursor: pointer;
&:hover {
background-color: var(--color-background-secondary);
}
}
.name {
font-weight: var(--font-weight-medium);
}
.meta {
font-size: 0.75rem;
color: var(--color-text-secondary);
}
.detail {
margin: 0;
font-size: 0.8125rem;
color: var(--color-text-secondary);
}
.add {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding-top: 1rem;
border-top: 1px solid var(--color-borders);
}
.field {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.8125rem;
color: var(--color-text-secondary);
input {
padding: 0.5rem;
border: 1px solid var(--color-borders);
border-radius: var(--border-radius-default);
font-size: 0.875rem;
color: var(--color-text);
background-color: var(--color-background);
}
}
.note {
margin: 0;
font-size: 0.75rem;
line-height: 1.4;
color: var(--color-text-secondary);
}
.empty {
margin: 0;
color: var(--color-text-secondary);
text-align: center;
}
</style>
@@ -57,6 +57,11 @@
label="Файлы по ссылке"
onclick={() => ui.openPanel("shares")}
/>
<SettingsItem
icon="bots"
label="Боты под наблюдением"
onclick={() => ui.openPanel("bots")}
/>
<SettingsItem
icon="eye"
label="Отслеживания"