Phase 9: Telegram bot management with chat discovery
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:
2026-03-19 17:45:58 +03:00
parent 5dee7c55ca
commit cf987cbfb4
9 changed files with 440 additions and 10 deletions

View File

@@ -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",

View File

@@ -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": "Определите, на какие события и файлы реагировать",

View File

@@ -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: '◇' },
];

View File

@@ -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 -->

View 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}

View File

@@ -0,0 +1,181 @@
"""Telegram bot management API routes."""
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
import aiohttp
from immich_watcher_core.telegram.media import TELEGRAM_API_BASE_URL
from ..auth.dependencies import get_current_user
from ..database.engine import get_session
from ..database.models import TelegramBot, User
router = APIRouter(prefix="/api/telegram-bots", tags=["telegram-bots"])
class BotCreate(BaseModel):
name: str
token: str
class BotUpdate(BaseModel):
name: str | None = None
@router.get("")
async def list_bots(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""List all registered Telegram bots."""
result = await session.exec(
select(TelegramBot).where(TelegramBot.user_id == user.id)
)
return [_bot_response(b) for b in result.all()]
@router.post("", status_code=status.HTTP_201_CREATED)
async def create_bot(
body: BotCreate,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Register a new Telegram bot (validates token via getMe)."""
# Validate token by calling getMe
bot_info = await _get_me(body.token)
if not bot_info:
raise HTTPException(status_code=400, detail="Invalid bot token")
bot = TelegramBot(
user_id=user.id,
name=body.name,
token=body.token,
bot_username=bot_info.get("username", ""),
bot_id=bot_info.get("id", 0),
)
session.add(bot)
await session.commit()
await session.refresh(bot)
return _bot_response(bot)
@router.put("/{bot_id}")
async def update_bot(
bot_id: int,
body: BotUpdate,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Update a bot's display name."""
bot = await _get_user_bot(session, bot_id, user.id)
if body.name is not None:
bot.name = body.name
session.add(bot)
await session.commit()
await session.refresh(bot)
return _bot_response(bot)
@router.delete("/{bot_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_bot(
bot_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Delete a registered bot."""
bot = await _get_user_bot(session, bot_id, user.id)
await session.delete(bot)
await session.commit()
@router.get("/{bot_id}/token")
async def get_bot_token(
bot_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Get the full bot token (for internal use by targets)."""
bot = await _get_user_bot(session, bot_id, user.id)
return {"token": bot.token}
@router.get("/{bot_id}/chats")
async def list_bot_chats(
bot_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Discover active chats for a bot via getUpdates.
Returns unique chats the bot has received messages from.
Note: Telegram only keeps updates for 24 hours, so this shows
recently active chats. For groups, the bot must have received
at least one message.
"""
bot = await _get_user_bot(session, bot_id, user.id)
chats = await _discover_chats(bot.token)
return chats
# --- Helpers ---
async def _get_me(token: str) -> dict | None:
"""Call Telegram getMe to validate token and get bot info."""
try:
async with aiohttp.ClientSession() as http:
async with http.get(f"{TELEGRAM_API_BASE_URL}{token}/getMe") as resp:
data = await resp.json()
if data.get("ok"):
return data.get("result", {})
except aiohttp.ClientError:
pass
return None
async def _discover_chats(token: str) -> list[dict]:
"""Discover chats by fetching recent updates from Telegram."""
seen: dict[int, dict] = {}
try:
async with aiohttp.ClientSession() as http:
async with http.get(
f"{TELEGRAM_API_BASE_URL}{token}/getUpdates",
params={"limit": 100, "allowed_updates": '["message"]'},
) as resp:
data = await resp.json()
if not data.get("ok"):
return []
for update in data.get("result", []):
msg = update.get("message", {})
chat = msg.get("chat", {})
chat_id = chat.get("id")
if chat_id and chat_id not in seen:
seen[chat_id] = {
"id": chat_id,
"title": chat.get("title") or chat.get("first_name", "") + (" " + chat.get("last_name", "")).strip(),
"type": chat.get("type", "private"),
"username": chat.get("username", ""),
}
except aiohttp.ClientError:
pass
return list(seen.values())
def _bot_response(b: TelegramBot) -> dict:
return {
"id": b.id,
"name": b.name,
"bot_username": b.bot_username,
"bot_id": b.bot_id,
"token_preview": f"{b.token[:8]}...{b.token[-4:]}" if len(b.token) > 12 else "***",
"created_at": b.created_at.isoformat(),
}
async def _get_user_bot(session: AsyncSession, bot_id: int, user_id: int) -> TelegramBot:
bot = await session.get(TelegramBot, bot_id)
if not bot or bot.user_id != user_id:
raise HTTPException(status_code=404, detail="Bot not found")
return bot

View File

@@ -7,6 +7,7 @@ from .models import (
EventLog,
ImmichServer,
NotificationTarget,
TelegramBot,
TemplateConfig,
TrackingConfig,
User,

View File

@@ -36,6 +36,20 @@ class ImmichServer(SQLModel, table=True):
created_at: datetime = Field(default_factory=_utcnow)
class TelegramBot(SQLModel, table=True):
"""Registered Telegram bot."""
__tablename__ = "telegram_bot"
id: int | None = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="user.id")
name: str # User-given display name
token: str # Bot API token
bot_username: str = Field(default="") # @username from getMe
bot_id: int = Field(default=0) # Numeric bot ID from getMe
created_at: datetime = Field(default_factory=_utcnow)
class TrackingConfig(SQLModel, table=True):
"""Tracking configuration: what events/assets to react to and scheduled modes."""

View File

@@ -23,6 +23,7 @@ from .api.targets import router as targets_router
from .api.users import router as users_router
from .api.status import router as status_router
from .api.sync import router as sync_router
from .api.telegram_bots import router as telegram_bots_router
from .ai.telegram_webhook import router as telegram_ai_router
logging.basicConfig(
@@ -74,6 +75,7 @@ app.include_router(targets_router)
app.include_router(users_router)
app.include_router(status_router)
app.include_router(sync_router)
app.include_router(telegram_bots_router)
app.include_router(telegram_ai_router)
# Serve frontend static files if available