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."""