Persist Telegram chats in DB, auto-save from webhooks, click-to-copy
All checks were successful
Validate / Hassfest (push) Successful in 4s

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 21:45:20 +03:00
parent e6ff0a423a
commit 482f54d620
6 changed files with 206 additions and 30 deletions

View File

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