Phase 9: Telegram bot management with chat discovery
Some checks failed
Validate / Hassfest (push) Has been cancelled
Some checks failed
Validate / Hassfest (push) Has been cancelled
New entity + API:
- TelegramBot model (name, token, bot_username, bot_id)
- CRUD at /api/telegram-bots with token validation via getMe
- GET /{id}/chats: discover active chats via getUpdates
- GET /{id}/token: retrieve full token (for target config)
Frontend:
- /telegram-bots page: register bots, view discovered chats
per bot (expandable), refresh on demand
- Targets page: select from registered bots (dropdown) instead
of raw token input. Chat selector shows discovered chats
when bot is selected, falls back to manual input.
Bot token resolved server-side on save.
Nav icon uses monochrome symbol for consistency.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@
|
||||
"trackers": "Trackers",
|
||||
"trackingConfigs": "Tracking",
|
||||
"templateConfigs": "Templates",
|
||||
"telegramBots": "Bots",
|
||||
"targets": "Targets",
|
||||
"users": "Users",
|
||||
"logout": "Logout"
|
||||
@@ -159,6 +160,25 @@
|
||||
"confirmDelete": "Delete this user?",
|
||||
"joined": "joined"
|
||||
},
|
||||
"telegramBot": {
|
||||
"title": "Telegram Bots",
|
||||
"description": "Register and manage Telegram bots",
|
||||
"addBot": "Add Bot",
|
||||
"name": "Display name",
|
||||
"namePlaceholder": "Family notifications bot",
|
||||
"token": "Bot Token",
|
||||
"tokenPlaceholder": "123456:ABC-DEF...",
|
||||
"noBots": "No bots registered yet.",
|
||||
"chats": "Chats",
|
||||
"noChats": "No chats found. Send a message to the bot first.",
|
||||
"refreshChats": "Refresh",
|
||||
"selectBot": "Select bot",
|
||||
"selectChat": "Select chat",
|
||||
"private": "Private",
|
||||
"group": "Group",
|
||||
"supergroup": "Supergroup",
|
||||
"channel": "Channel"
|
||||
},
|
||||
"trackingConfig": {
|
||||
"title": "Tracking Configs",
|
||||
"description": "Define what events and assets to react to",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"trackers": "Трекеры",
|
||||
"trackingConfigs": "Отслеживание",
|
||||
"templateConfigs": "Шаблоны",
|
||||
"telegramBots": "Боты",
|
||||
"targets": "Получатели",
|
||||
"users": "Пользователи",
|
||||
"logout": "Выход"
|
||||
@@ -159,6 +160,25 @@
|
||||
"confirmDelete": "Удалить этого пользователя?",
|
||||
"joined": "зарегистрирован"
|
||||
},
|
||||
"telegramBot": {
|
||||
"title": "Telegram боты",
|
||||
"description": "Регистрация и управление Telegram ботами",
|
||||
"addBot": "Добавить бота",
|
||||
"name": "Отображаемое имя",
|
||||
"namePlaceholder": "Бот семейных уведомлений",
|
||||
"token": "Токен бота",
|
||||
"tokenPlaceholder": "123456:ABC-DEF...",
|
||||
"noBots": "Ботов пока нет.",
|
||||
"chats": "Чаты",
|
||||
"noChats": "Чатов не найдено. Сначала отправьте сообщение боту.",
|
||||
"refreshChats": "Обновить",
|
||||
"selectBot": "Выберите бота",
|
||||
"selectChat": "Выберите чат",
|
||||
"private": "Личный",
|
||||
"group": "Группа",
|
||||
"supergroup": "Супергруппа",
|
||||
"channel": "Канал"
|
||||
},
|
||||
"trackingConfig": {
|
||||
"title": "Конфигурации отслеживания",
|
||||
"description": "Определите, на какие события и файлы реагировать",
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
{ href: '/trackers', key: 'nav.trackers', icon: '◎' },
|
||||
{ href: '/tracking-configs', key: 'nav.trackingConfigs', icon: '⚙' },
|
||||
{ href: '/template-configs', key: 'nav.templateConfigs', icon: '⎘' },
|
||||
{ href: '/telegram-bots', key: 'nav.telegramBots', icon: '⊡' },
|
||||
{ href: '/targets', key: 'nav.targets', icon: '◇' },
|
||||
];
|
||||
|
||||
|
||||
@@ -9,10 +9,12 @@
|
||||
let targets = $state<any[]>([]);
|
||||
let trackingConfigs = $state<any[]>([]);
|
||||
let templateConfigs = $state<any[]>([]);
|
||||
let bots = $state<any[]>([]);
|
||||
let botChats = $state<Record<number, any[]>>({});
|
||||
let showForm = $state(false);
|
||||
let editing = $state<number | null>(null);
|
||||
let formType = $state<'telegram' | 'webhook'>('telegram');
|
||||
const defaultForm = () => ({ name: '', bot_token: '', chat_id: '', url: '', headers: '',
|
||||
const defaultForm = () => ({ name: '', bot_id: 0, chat_id: '', bot_token: '', url: '', headers: '',
|
||||
max_media_to_send: 50, max_media_per_group: 10, media_delay: 500, max_asset_size: 50,
|
||||
disable_url_preview: false, send_large_photos_as_documents: false, ai_captions: false,
|
||||
tracking_config_id: 0, template_config_id: 0 });
|
||||
@@ -24,18 +26,23 @@
|
||||
onMount(load);
|
||||
async function load() {
|
||||
try {
|
||||
[targets, trackingConfigs, templateConfigs] = await Promise.all([
|
||||
api('/targets'), api('/tracking-configs'), api('/template-configs')
|
||||
[targets, trackingConfigs, templateConfigs, bots] = await Promise.all([
|
||||
api('/targets'), api('/tracking-configs'), api('/template-configs'), api('/telegram-bots')
|
||||
]);
|
||||
} catch {} finally { loaded = true; }
|
||||
}
|
||||
|
||||
async function loadBotChats() {
|
||||
if (!form.bot_id) return;
|
||||
try { botChats[form.bot_id] = await api(`/telegram-bots/${form.bot_id}/chats`); } catch {}
|
||||
}
|
||||
|
||||
function openNew() { form = defaultForm(); formType = 'telegram'; editing = null; showForm = true; }
|
||||
function edit(tgt: any) {
|
||||
async function edit(tgt: any) {
|
||||
formType = tgt.type;
|
||||
const c = tgt.config || {};
|
||||
form = {
|
||||
name: tgt.name, bot_token: '', chat_id: c.chat_id || '', url: c.url || '', headers: '',
|
||||
name: tgt.name, bot_id: c.bot_id || 0, bot_token: '', chat_id: c.chat_id || '', url: c.url || '', headers: '',
|
||||
max_media_to_send: c.max_media_to_send ?? 50, max_media_per_group: c.max_media_per_group ?? 10,
|
||||
media_delay: c.media_delay ?? 500, max_asset_size: c.max_asset_size ?? 50,
|
||||
disable_url_preview: c.disable_url_preview ?? false, send_large_photos_as_documents: c.send_large_photos_as_documents ?? false,
|
||||
@@ -49,8 +56,15 @@
|
||||
async function save(e: SubmitEvent) {
|
||||
e.preventDefault(); error = '';
|
||||
try {
|
||||
let botToken = form.bot_token;
|
||||
// Resolve token from registered bot if selected
|
||||
if (formType === 'telegram' && form.bot_id && !botToken) {
|
||||
const tokenRes = await api(`/telegram-bots/${form.bot_id}/token`);
|
||||
botToken = tokenRes.token;
|
||||
}
|
||||
const config = formType === 'telegram'
|
||||
? { ...(form.bot_token ? { bot_token: form.bot_token } : {}), chat_id: form.chat_id,
|
||||
? { ...(botToken ? { bot_token: botToken } : {}), chat_id: form.chat_id,
|
||||
bot_id: form.bot_id || undefined,
|
||||
max_media_to_send: form.max_media_to_send, max_media_per_group: form.max_media_per_group,
|
||||
media_delay: form.media_delay, max_asset_size: form.max_asset_size,
|
||||
disable_url_preview: form.disable_url_preview, send_large_photos_as_documents: form.send_large_photos_as_documents,
|
||||
@@ -107,13 +121,45 @@
|
||||
<input id="tgt-name" bind:value={form.name} required placeholder={t('targets.namePlaceholder')} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
{#if formType === 'telegram'}
|
||||
<!-- Bot selector -->
|
||||
<div>
|
||||
<label for="tgt-token" class="block text-sm font-medium mb-1">{t('targets.botToken')}</label>
|
||||
<input id="tgt-token" bind:value={form.bot_token} required type="password" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
<label for="tgt-bot" class="block text-sm font-medium mb-1">{t('telegramBot.selectBot')}</label>
|
||||
<select id="tgt-bot" bind:value={form.bot_id} onchange={loadBotChats}
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
||||
<option value={0}>— Manual token —</option>
|
||||
{#each bots as bot}<option value={bot.id}>{bot.name} (@{bot.bot_username})</option>{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if !form.bot_id}
|
||||
<!-- Manual token input (fallback) -->
|
||||
<div>
|
||||
<label for="tgt-token" class="block text-sm font-medium mb-1">{t('targets.botToken')}</label>
|
||||
<input id="tgt-token" bind:value={form.bot_token} required type="password" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Chat selector -->
|
||||
<div>
|
||||
<label for="tgt-chat" class="block text-sm font-medium mb-1">{t('targets.chatId')}</label>
|
||||
<input id="tgt-chat" bind:value={form.chat_id} required class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
<label for="tgt-chat" class="block text-sm font-medium mb-1">{t('telegramBot.selectChat')}</label>
|
||||
{#if form.bot_id && (botChats[form.bot_id] || []).length > 0}
|
||||
<select id="tgt-chat" bind:value={form.chat_id}
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
||||
<option value="">— {t('telegramBot.selectChat')} —</option>
|
||||
{#each botChats[form.bot_id] as chat}
|
||||
<option value={String(chat.id)}>{chat.title || chat.username || 'Unknown'} ({chat.type}) [{chat.id}]</option>
|
||||
{/each}
|
||||
</select>
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">
|
||||
<button type="button" onclick={loadBotChats} class="hover:underline">{t('telegramBot.refreshChats')}</button>
|
||||
</p>
|
||||
{:else}
|
||||
<input id="tgt-chat" bind:value={form.chat_id} required placeholder="Chat ID"
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
{#if form.bot_id}
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('telegramBot.noChats')}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Telegram media settings -->
|
||||
|
||||
145
frontend/src/routes/telegram-bots/+page.svelte
Normal file
145
frontend/src/routes/telegram-bots/+page.svelte
Normal file
@@ -0,0 +1,145 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import Card from '$lib/components/Card.svelte';
|
||||
import Loading from '$lib/components/Loading.svelte';
|
||||
|
||||
let bots = $state<any[]>([]);
|
||||
let loaded = $state(false);
|
||||
let showForm = $state(false);
|
||||
let form = $state({ name: '', token: '' });
|
||||
let error = $state('');
|
||||
let submitting = $state(false);
|
||||
|
||||
// Per-bot chat lists
|
||||
let chats = $state<Record<number, any[]>>({});
|
||||
let chatsLoading = $state<Record<number, boolean>>({});
|
||||
let expandedBot = $state<number | null>(null);
|
||||
|
||||
onMount(load);
|
||||
async function load() { try { bots = await api('/telegram-bots'); } catch {} finally { loaded = true; } }
|
||||
|
||||
async function create(e: SubmitEvent) {
|
||||
e.preventDefault(); error = ''; submitting = true;
|
||||
try {
|
||||
await api('/telegram-bots', { method: 'POST', body: JSON.stringify(form) });
|
||||
form = { name: '', token: '' }; showForm = false; await load();
|
||||
} catch (err: any) { error = err.message; }
|
||||
submitting = false;
|
||||
}
|
||||
|
||||
async function remove(id: number) {
|
||||
if (!confirm(t('common.delete') + '?')) return;
|
||||
try { await api(`/telegram-bots/${id}`, { method: 'DELETE' }); await load(); } catch (err: any) { error = err.message; }
|
||||
}
|
||||
|
||||
async function loadChats(botId: number) {
|
||||
if (expandedBot === botId) { expandedBot = null; return; }
|
||||
expandedBot = botId;
|
||||
chatsLoading[botId] = true;
|
||||
try { chats[botId] = await api(`/telegram-bots/${botId}/chats`); }
|
||||
catch { chats[botId] = []; }
|
||||
chatsLoading[botId] = false;
|
||||
}
|
||||
|
||||
function chatTypeLabel(type: string): string {
|
||||
const map: Record<string, string> = {
|
||||
private: t('telegramBot.private'),
|
||||
group: t('telegramBot.group'),
|
||||
supergroup: t('telegramBot.supergroup'),
|
||||
channel: t('telegramBot.channel'),
|
||||
};
|
||||
return map[type] || type;
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageHeader title={t('telegramBot.title')} description={t('telegramBot.description')}>
|
||||
<button onclick={() => { showForm ? (showForm = false) : (showForm = true, form = { name: '', token: '' }); }}
|
||||
class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
||||
{showForm ? t('common.cancel') : t('telegramBot.addBot')}
|
||||
</button>
|
||||
</PageHeader>
|
||||
|
||||
{#if !loaded}<Loading />{:else}
|
||||
|
||||
{#if showForm}
|
||||
<Card class="mb-6">
|
||||
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
|
||||
<form onsubmit={create} class="space-y-3">
|
||||
<div>
|
||||
<label for="bot-name" class="block text-sm font-medium mb-1">{t('telegramBot.name')}</label>
|
||||
<input id="bot-name" bind:value={form.name} required placeholder={t('telegramBot.namePlaceholder')}
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="bot-token" class="block text-sm font-medium mb-1">{t('telegramBot.token')}</label>
|
||||
<input id="bot-token" bind:value={form.token} required placeholder={t('telegramBot.tokenPlaceholder')}
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)] font-mono" />
|
||||
</div>
|
||||
<button type="submit" disabled={submitting}
|
||||
class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50">
|
||||
{submitting ? t('common.loading') : t('telegramBot.addBot')}
|
||||
</button>
|
||||
</form>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
{#if bots.length === 0 && !showForm}
|
||||
<Card><p class="text-sm text-[var(--color-muted-foreground)]">{t('telegramBot.noBots')}</p></Card>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each bots as bot}
|
||||
<Card>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="font-medium">{bot.name}</p>
|
||||
{#if bot.bot_username}
|
||||
<span class="text-xs text-[var(--color-muted-foreground)]">@{bot.bot_username}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] font-mono">{bot.token_preview}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button onclick={() => loadChats(bot.id)}
|
||||
class="text-xs text-[var(--color-muted-foreground)] hover:underline">
|
||||
{t('telegramBot.chats')} {expandedBot === bot.id ? '▲' : '▼'}
|
||||
</button>
|
||||
<button onclick={() => remove(bot.id)}
|
||||
class="text-xs text-[var(--color-destructive)] hover:underline">{t('common.delete')}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if expandedBot === bot.id}
|
||||
<div class="mt-3 border-t border-[var(--color-border)] pt-3">
|
||||
{#if chatsLoading[bot.id]}
|
||||
<p class="text-xs text-[var(--color-muted-foreground)]">{t('common.loading')}</p>
|
||||
{:else if (chats[bot.id] || []).length === 0}
|
||||
<p class="text-xs text-[var(--color-muted-foreground)]">{t('telegramBot.noChats')}</p>
|
||||
{:else}
|
||||
<div class="space-y-1">
|
||||
{#each chats[bot.id] as chat}
|
||||
<div class="flex items-center justify-between text-sm px-2 py-1 rounded hover:bg-[var(--color-muted)]">
|
||||
<div>
|
||||
<span class="font-medium">{chat.title || chat.username || 'Unknown'}</span>
|
||||
<span class="text-xs ml-2 px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{chatTypeLabel(chat.type)}</span>
|
||||
</div>
|
||||
<span class="text-xs text-[var(--color-muted-foreground)] font-mono">{chat.id}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<button onclick={() => loadChats(bot.id)}
|
||||
class="text-xs text-[var(--color-muted-foreground)] hover:underline mt-2">
|
||||
{t('telegramBot.refreshChats')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{/if}
|
||||
Reference in New Issue
Block a user