From 482f54d6204dcf0f744479567f5863cc6dda3a80 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Thu, 19 Mar 2026 21:45:20 +0300 Subject: [PATCH] Persist Telegram chats in DB, auto-save from webhooks, click-to-copy - Add TelegramChat model (bot_id, chat_id, title, type, username) - Chats auto-saved when bot receives webhook messages - New API: GET/DELETE chats, POST discover (merges from getUpdates) - Cascade delete chats when bot is deleted - Frontend: click chat row to copy chat ID to clipboard - Frontend: delete button per chat, "Discover chats" sync button - Add 4 i18n keys (EN/RU) for chat management Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/lib/i18n/en.json | 6 +- frontend/src/lib/i18n/ru.json | 6 +- .../src/routes/telegram-bots/+page.svelte | 58 +++++-- .../ai/telegram_webhook.py | 9 ++ .../api/telegram_bots.py | 143 ++++++++++++++++-- .../immich_watcher_server/database/models.py | 14 ++ 6 files changed, 206 insertions(+), 30 deletions(-) diff --git a/frontend/src/lib/i18n/en.json b/frontend/src/lib/i18n/en.json index 907abb9..a1b5875 100644 --- a/frontend/src/lib/i18n/en.json +++ b/frontend/src/lib/i18n/en.json @@ -194,7 +194,11 @@ "rateSearch": "Search cooldown", "rateFind": "Find cooldown", "rateDefault": "Default cooldown", - "syncCommands": "Sync to Telegram" + "syncCommands": "Sync to Telegram", + "discoverChats": "Discover chats from Telegram", + "clickToCopy": "Click to copy chat ID", + "chatsDiscovered": "Chats discovered", + "chatDeleted": "Chat removed" }, "trackingConfig": { "title": "Tracking Configs", diff --git a/frontend/src/lib/i18n/ru.json b/frontend/src/lib/i18n/ru.json index 21662fe..a4c579a 100644 --- a/frontend/src/lib/i18n/ru.json +++ b/frontend/src/lib/i18n/ru.json @@ -194,7 +194,11 @@ "rateSearch": "Кулдаун поиска", "rateFind": "Кулдаун поиска файлов", "rateDefault": "Кулдаун по умолчанию", - "syncCommands": "Синхронизировать с Telegram" + "syncCommands": "Синхронизировать с Telegram", + "discoverChats": "Обнаружить чаты из Telegram", + "clickToCopy": "Нажмите, чтобы скопировать ID чата", + "chatsDiscovered": "Чаты обнаружены", + "chatDeleted": "Чат удалён" }, "trackingConfig": { "title": "Конфигурации отслеживания", diff --git a/frontend/src/routes/telegram-bots/+page.svelte b/frontend/src/routes/telegram-bots/+page.svelte index 9a71c53..fe57087 100644 --- a/frontend/src/routes/telegram-bots/+page.svelte +++ b/frontend/src/routes/telegram-bots/+page.svelte @@ -11,7 +11,7 @@ import ConfirmModal from '$lib/components/ConfirmModal.svelte'; import IconButton from '$lib/components/IconButton.svelte'; import Hint from '$lib/components/Hint.svelte'; - import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte'; + import { snackSuccess, snackError, snackInfo } from '$lib/stores/snackbar.svelte'; const ALL_COMMANDS = [ 'status', 'albums', 'events', 'summary', 'latest', @@ -82,11 +82,7 @@ expandedSection[botId] = section; if (section === 'chats') { - chatsLoading[botId] = true; - api(`/telegram-bots/${botId}/chats`) - .then((data: any) => chats[botId] = data) - .catch(() => chats[botId] = []) - .finally(() => chatsLoading[botId] = false); + loadChats(botId); } if (section === 'commands') { @@ -95,6 +91,35 @@ } } + async function loadChats(botId: number) { + chatsLoading[botId] = true; + try { chats[botId] = await api(`/telegram-bots/${botId}/chats`); } + catch { chats[botId] = []; } + chatsLoading[botId] = false; + } + + async function discoverChats(botId: number) { + chatsLoading[botId] = true; + try { + chats[botId] = await api(`/telegram-bots/${botId}/chats/discover`, { method: 'POST' }); + snackSuccess(t('telegramBot.chatsDiscovered')); + } catch (err: any) { snackError(err.message); } + chatsLoading[botId] = false; + } + + async function deleteChat(botId: number, chatDbId: number) { + try { + await api(`/telegram-bots/${botId}/chats/${chatDbId}`, { method: 'DELETE' }); + chats[botId] = (chats[botId] || []).filter((c: any) => c.id !== chatDbId); + snackSuccess(t('telegramBot.chatDeleted')); + } catch (err: any) { snackError(err.message); } + } + + function copyChatId(chatId: string) { + navigator.clipboard.writeText(chatId); + snackInfo(t('snack.copied') + ': ' + chatId); + } + function toggleCommand(botId: number, cmd: string) { const cfg = editingConfig[botId]; if (!cfg) return; @@ -211,19 +236,24 @@ {:else}
{#each chats[bot.id] as chat} -
-
+
+
- {chat.id} + {chatTypeLabel(chat.type)} + {chat.chat_id} + + deleteChat(bot.id, chat.id)} variant="danger" />
{/each}
{/if} -
{/if} diff --git a/packages/server/src/immich_watcher_server/ai/telegram_webhook.py b/packages/server/src/immich_watcher_server/ai/telegram_webhook.py index bd361e7..123f04a 100644 --- a/packages/server/src/immich_watcher_server/ai/telegram_webhook.py +++ b/packages/server/src/immich_watcher_server/ai/telegram_webhook.py @@ -16,6 +16,7 @@ from ..auth.dependencies import get_current_user from ..config import settings from ..database.engine import get_session from ..database.models import AlbumTracker, EventLog, ImmichServer, NotificationTarget, TelegramBot, User +from ..api.telegram_bots import save_chat_from_webhook from .commands import handle_command, send_media_group from .service import chat, is_ai_enabled, summarize_albums @@ -70,6 +71,14 @@ async def telegram_webhook( if not chat_id or not text: return {"ok": True, "skipped": "empty"} + # Auto-persist chat from incoming message + if bot: + try: + await save_chat_from_webhook(session, bot.id, chat_info) + await session.commit() + except Exception: + _LOGGER.debug("Failed to auto-save chat %s", chat_id) + # Try bot commands first (if bot is registered) if bot and text.startswith("/"): cmd_response = await handle_command(bot, chat_id, text, session) diff --git a/packages/server/src/immich_watcher_server/api/telegram_bots.py b/packages/server/src/immich_watcher_server/api/telegram_bots.py index 601be5b..a4b2656 100644 --- a/packages/server/src/immich_watcher_server/api/telegram_bots.py +++ b/packages/server/src/immich_watcher_server/api/telegram_bots.py @@ -12,7 +12,7 @@ from immich_watcher_core.telegram.media import TELEGRAM_API_BASE_URL from ..ai.commands import register_commands_with_telegram from ..auth.dependencies import get_current_user from ..database.engine import get_session -from ..database.models import TelegramBot, User +from ..database.models import TelegramBot, TelegramChat, User router = APIRouter(prefix="/api/telegram-bots", tags=["telegram-bots"]) @@ -46,7 +46,6 @@ async def create_bot( 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") @@ -89,8 +88,12 @@ async def delete_bot( user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session), ): - """Delete a registered bot.""" + """Delete a registered bot and its chats.""" bot = await _get_user_bot(session, bot_id, user.id) + # Delete associated chats + result = await session.exec(select(TelegramChat).where(TelegramChat.bot_id == bot_id)) + for chat in result.all(): + await session.delete(chat) await session.delete(bot) await session.commit() @@ -103,27 +106,91 @@ async def get_bot_token( ): """Get the full bot token (used by frontend to construct target config).""" bot = await _get_user_bot(session, bot_id, user.id) - # Token is returned only to the authenticated owner return {"token": bot.token} +# --- Chat management --- + @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. + """List persisted chats for a bot.""" + await _get_user_bot(session, bot_id, user.id) # Auth check + result = await session.exec( + select(TelegramChat).where(TelegramChat.bot_id == bot_id) + ) + return [_chat_response(c) for c in result.all()] - 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. + +@router.post("/{bot_id}/chats/discover") +async def discover_chats( + bot_id: int, + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + """Discover new chats via Telegram getUpdates and persist them. + + Merges newly discovered chats with existing ones (no duplicates). + Returns the full updated chat list. """ bot = await _get_user_bot(session, bot_id, user.id) - chats = await _discover_chats(bot.token) - return chats + discovered = await _fetch_chats_from_telegram(bot.token) + # Load existing chats to avoid duplicates + result = await session.exec( + select(TelegramChat).where(TelegramChat.bot_id == bot_id) + ) + existing = {c.chat_id: c for c in result.all()} + + new_count = 0 + for chat_data in discovered: + cid = str(chat_data["id"]) + if cid in existing: + # Update title/username if changed + existing_chat = existing[cid] + existing_chat.title = chat_data.get("title", existing_chat.title) + existing_chat.username = chat_data.get("username", existing_chat.username) + session.add(existing_chat) + else: + new_chat = TelegramChat( + bot_id=bot_id, + chat_id=cid, + title=chat_data.get("title", ""), + chat_type=chat_data.get("type", "private"), + username=chat_data.get("username", ""), + ) + session.add(new_chat) + new_count += 1 + + await session.commit() + + # Return full list + result = await session.exec( + select(TelegramChat).where(TelegramChat.bot_id == bot_id) + ) + return [_chat_response(c) for c in result.all()] + + +@router.delete("/{bot_id}/chats/{chat_db_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_chat( + bot_id: int, + chat_db_id: int, + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + """Delete a persisted chat entry.""" + await _get_user_bot(session, bot_id, user.id) # Auth check + chat = await session.get(TelegramChat, chat_db_id) + if not chat or chat.bot_id != bot_id: + raise HTTPException(status_code=404, detail="Chat not found") + await session.delete(chat) + await session.commit() + + +# --- Commands --- @router.post("/{bot_id}/sync-commands") async def sync_commands( @@ -154,8 +221,8 @@ async def _get_me(token: str) -> dict | None: return None -async def _discover_chats(token: str) -> list[dict]: - """Discover chats by fetching recent updates from Telegram.""" +async def _fetch_chats_from_telegram(token: str) -> list[dict]: + """Fetch chats from Telegram getUpdates API.""" seen: dict[int, dict] = {} try: async with aiohttp.ClientSession() as http: @@ -173,7 +240,7 @@ async def _discover_chats(token: str) -> list[dict]: 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(), + "title": chat.get("title") or (chat.get("first_name", "") + (" " + chat.get("last_name", "")).strip()), "type": chat.get("type", "private"), "username": chat.get("username", ""), } @@ -182,6 +249,17 @@ async def _discover_chats(token: str) -> list[dict]: return list(seen.values()) +def _chat_response(c: TelegramChat) -> dict: + return { + "id": c.id, + "chat_id": c.chat_id, + "title": c.title, + "type": c.chat_type, + "username": c.username, + "discovered_at": c.discovered_at.isoformat(), + } + + def _bot_response(b: TelegramBot) -> dict: return { "id": b.id, @@ -199,3 +277,40 @@ async def _get_user_bot(session: AsyncSession, bot_id: int, user_id: int) -> Tel if not bot or bot.user_id != user_id: raise HTTPException(status_code=404, detail="Bot not found") return bot + + +async def save_chat_from_webhook( + session: AsyncSession, bot_id: int, chat_data: dict +) -> None: + """Save or update a chat entry from an incoming webhook message. + + Called by the webhook handler to auto-persist chats. + """ + chat_id = str(chat_data.get("id", "")) + if not chat_id: + return + + result = await session.exec( + select(TelegramChat).where( + TelegramChat.bot_id == bot_id, + TelegramChat.chat_id == chat_id, + ) + ) + existing = result.first() + + title = chat_data.get("title") or ( + chat_data.get("first_name", "") + (" " + chat_data.get("last_name", "")).strip() + ) + + if existing: + existing.title = title + existing.username = chat_data.get("username", existing.username) + session.add(existing) + else: + session.add(TelegramChat( + bot_id=bot_id, + chat_id=chat_id, + title=title, + chat_type=chat_data.get("type", "private"), + username=chat_data.get("username", ""), + )) diff --git a/packages/server/src/immich_watcher_server/database/models.py b/packages/server/src/immich_watcher_server/database/models.py index 6e9788b..633f91b 100644 --- a/packages/server/src/immich_watcher_server/database/models.py +++ b/packages/server/src/immich_watcher_server/database/models.py @@ -66,6 +66,20 @@ class TelegramBot(SQLModel, table=True): created_at: datetime = Field(default_factory=_utcnow) +class TelegramChat(SQLModel, table=True): + """Discovered Telegram chat associated with a bot.""" + + __tablename__ = "telegram_chat" + + id: int | None = Field(default=None, primary_key=True) + bot_id: int = Field(foreign_key="telegram_bot.id") + chat_id: str # Telegram chat ID (can be negative for groups) + title: str = Field(default="") + chat_type: str = Field(default="private") # private/group/supergroup/channel + username: str = Field(default="") + discovered_at: datetime = Field(default_factory=_utcnow) + + class TrackingConfig(SQLModel, table=True): """Tracking configuration: what events/assets to react to and scheduled modes."""