diff --git a/frontend/src/lib/i18n/en.json b/frontend/src/lib/i18n/en.json index e0d12de..1b1092b 100644 --- a/frontend/src/lib/i18n/en.json +++ b/frontend/src/lib/i18n/en.json @@ -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", diff --git a/frontend/src/lib/i18n/ru.json b/frontend/src/lib/i18n/ru.json index 295d048..d95dc17 100644 --- a/frontend/src/lib/i18n/ru.json +++ b/frontend/src/lib/i18n/ru.json @@ -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": "Определите, на какие события и файлы реагировать", diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 59aae5d..a816595 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -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: '◇' }, ]; diff --git a/frontend/src/routes/targets/+page.svelte b/frontend/src/routes/targets/+page.svelte index 13f4139..3a9306a 100644 --- a/frontend/src/routes/targets/+page.svelte +++ b/frontend/src/routes/targets/+page.svelte @@ -9,10 +9,12 @@ let targets = $state([]); let trackingConfigs = $state([]); let templateConfigs = $state([]); + let bots = $state([]); + let botChats = $state>({}); let showForm = $state(false); let editing = $state(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 @@ {#if formType === 'telegram'} +
- - + +
+ + {#if !form.bot_id} + +
+ + +
+ {/if} + +
- - + + {#if form.bot_id && (botChats[form.bot_id] || []).length > 0} + +

+ +

+ {:else} + + {#if form.bot_id} +

{t('telegramBot.noChats')}

+ {/if} + {/if}
diff --git a/frontend/src/routes/telegram-bots/+page.svelte b/frontend/src/routes/telegram-bots/+page.svelte new file mode 100644 index 0000000..0d668c5 --- /dev/null +++ b/frontend/src/routes/telegram-bots/+page.svelte @@ -0,0 +1,145 @@ + + + + + + +{#if !loaded}{:else} + +{#if showForm} + + {#if error}
{error}
{/if} +
+
+ + +
+
+ + +
+ +
+
+{/if} + +{#if bots.length === 0 && !showForm} +

{t('telegramBot.noBots')}

+{:else} +
+ {#each bots as bot} + +
+
+
+

{bot.name}

+ {#if bot.bot_username} + @{bot.bot_username} + {/if} +
+

{bot.token_preview}

+
+
+ + +
+
+ + {#if expandedBot === bot.id} +
+ {#if chatsLoading[bot.id]} +

{t('common.loading')}

+ {:else if (chats[bot.id] || []).length === 0} +

{t('telegramBot.noChats')}

+ {:else} +
+ {#each chats[bot.id] as chat} +
+
+ {chat.title || chat.username || 'Unknown'} + {chatTypeLabel(chat.type)} +
+ {chat.id} +
+ {/each} +
+ {/if} + +
+ {/if} +
+ {/each} +
+{/if} + +{/if} diff --git a/packages/server/src/immich_watcher_server/api/telegram_bots.py b/packages/server/src/immich_watcher_server/api/telegram_bots.py new file mode 100644 index 0000000..029d1c7 --- /dev/null +++ b/packages/server/src/immich_watcher_server/api/telegram_bots.py @@ -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 diff --git a/packages/server/src/immich_watcher_server/database/__init__.py b/packages/server/src/immich_watcher_server/database/__init__.py index 2fe3a3b..a9b3378 100644 --- a/packages/server/src/immich_watcher_server/database/__init__.py +++ b/packages/server/src/immich_watcher_server/database/__init__.py @@ -7,6 +7,7 @@ from .models import ( EventLog, ImmichServer, NotificationTarget, + TelegramBot, TemplateConfig, TrackingConfig, User, diff --git a/packages/server/src/immich_watcher_server/database/models.py b/packages/server/src/immich_watcher_server/database/models.py index 43f73b8..fad54f4 100644 --- a/packages/server/src/immich_watcher_server/database/models.py +++ b/packages/server/src/immich_watcher_server/database/models.py @@ -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.""" diff --git a/packages/server/src/immich_watcher_server/main.py b/packages/server/src/immich_watcher_server/main.py index df0700a..57f4f7d 100644 --- a/packages/server/src/immich_watcher_server/main.py +++ b/packages/server/src/immich_watcher_server/main.py @@ -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