Persist Telegram chats in DB, auto-save from webhooks, click-to-copy
All checks were successful
Validate / Hassfest (push) Successful in 4s
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:
@@ -194,7 +194,11 @@
|
|||||||
"rateSearch": "Search cooldown",
|
"rateSearch": "Search cooldown",
|
||||||
"rateFind": "Find cooldown",
|
"rateFind": "Find cooldown",
|
||||||
"rateDefault": "Default 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": {
|
"trackingConfig": {
|
||||||
"title": "Tracking Configs",
|
"title": "Tracking Configs",
|
||||||
|
|||||||
@@ -194,7 +194,11 @@
|
|||||||
"rateSearch": "Кулдаун поиска",
|
"rateSearch": "Кулдаун поиска",
|
||||||
"rateFind": "Кулдаун поиска файлов",
|
"rateFind": "Кулдаун поиска файлов",
|
||||||
"rateDefault": "Кулдаун по умолчанию",
|
"rateDefault": "Кулдаун по умолчанию",
|
||||||
"syncCommands": "Синхронизировать с Telegram"
|
"syncCommands": "Синхронизировать с Telegram",
|
||||||
|
"discoverChats": "Обнаружить чаты из Telegram",
|
||||||
|
"clickToCopy": "Нажмите, чтобы скопировать ID чата",
|
||||||
|
"chatsDiscovered": "Чаты обнаружены",
|
||||||
|
"chatDeleted": "Чат удалён"
|
||||||
},
|
},
|
||||||
"trackingConfig": {
|
"trackingConfig": {
|
||||||
"title": "Конфигурации отслеживания",
|
"title": "Конфигурации отслеживания",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||||
import IconButton from '$lib/components/IconButton.svelte';
|
import IconButton from '$lib/components/IconButton.svelte';
|
||||||
import Hint from '$lib/components/Hint.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 = [
|
const ALL_COMMANDS = [
|
||||||
'status', 'albums', 'events', 'summary', 'latest',
|
'status', 'albums', 'events', 'summary', 'latest',
|
||||||
@@ -82,11 +82,7 @@
|
|||||||
expandedSection[botId] = section;
|
expandedSection[botId] = section;
|
||||||
|
|
||||||
if (section === 'chats') {
|
if (section === 'chats') {
|
||||||
chatsLoading[botId] = true;
|
loadChats(botId);
|
||||||
api(`/telegram-bots/${botId}/chats`)
|
|
||||||
.then((data: any) => chats[botId] = data)
|
|
||||||
.catch(() => chats[botId] = [])
|
|
||||||
.finally(() => chatsLoading[botId] = false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (section === 'commands') {
|
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) {
|
function toggleCommand(botId: number, cmd: string) {
|
||||||
const cfg = editingConfig[botId];
|
const cfg = editingConfig[botId];
|
||||||
if (!cfg) return;
|
if (!cfg) return;
|
||||||
@@ -211,19 +236,24 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
{#each chats[bot.id] as chat}
|
{#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 class="flex items-center justify-between text-sm px-2 py-1 rounded hover:bg-[var(--color-muted)] group">
|
||||||
<div>
|
<button class="flex items-center gap-2 text-left cursor-pointer"
|
||||||
|
onclick={() => copyChatId(chat.chat_id)}
|
||||||
|
title={t('telegramBot.clickToCopy')}>
|
||||||
<span class="font-medium">{chat.title || chat.username || 'Unknown'}</span>
|
<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>
|
<span class="text-xs 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.chat_id}</span>
|
||||||
<span class="text-xs text-[var(--color-muted-foreground)] font-mono">{chat.id}</span>
|
</button>
|
||||||
|
<IconButton icon="mdiDelete" title={t('common.delete')} size={14}
|
||||||
|
onclick={() => deleteChat(bot.id, chat.id)} variant="danger" />
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<button onclick={() => toggleSection(bot.id, 'chats')}
|
<button onclick={() => discoverChats(bot.id)}
|
||||||
class="text-xs text-[var(--color-muted-foreground)] hover:underline mt-2">
|
class="text-xs text-[var(--color-primary)] hover:underline mt-2 flex items-center gap-1">
|
||||||
{t('telegramBot.refreshChats')}
|
<MdiIcon name="mdiSync" size={14} />
|
||||||
|
{t('telegramBot.discoverChats')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from ..auth.dependencies import get_current_user
|
|||||||
from ..config import settings
|
from ..config import settings
|
||||||
from ..database.engine import get_session
|
from ..database.engine import get_session
|
||||||
from ..database.models import AlbumTracker, EventLog, ImmichServer, NotificationTarget, TelegramBot, User
|
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 .commands import handle_command, send_media_group
|
||||||
from .service import chat, is_ai_enabled, summarize_albums
|
from .service import chat, is_ai_enabled, summarize_albums
|
||||||
|
|
||||||
@@ -70,6 +71,14 @@ async def telegram_webhook(
|
|||||||
if not chat_id or not text:
|
if not chat_id or not text:
|
||||||
return {"ok": True, "skipped": "empty"}
|
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)
|
# Try bot commands first (if bot is registered)
|
||||||
if bot and text.startswith("/"):
|
if bot and text.startswith("/"):
|
||||||
cmd_response = await handle_command(bot, chat_id, text, session)
|
cmd_response = await handle_command(bot, chat_id, text, session)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from immich_watcher_core.telegram.media import TELEGRAM_API_BASE_URL
|
|||||||
from ..ai.commands import register_commands_with_telegram
|
from ..ai.commands import register_commands_with_telegram
|
||||||
from ..auth.dependencies import get_current_user
|
from ..auth.dependencies import get_current_user
|
||||||
from ..database.engine import get_session
|
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"])
|
router = APIRouter(prefix="/api/telegram-bots", tags=["telegram-bots"])
|
||||||
|
|
||||||
@@ -46,7 +46,6 @@ async def create_bot(
|
|||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
"""Register a new Telegram bot (validates token via getMe)."""
|
"""Register a new Telegram bot (validates token via getMe)."""
|
||||||
# Validate token by calling getMe
|
|
||||||
bot_info = await _get_me(body.token)
|
bot_info = await _get_me(body.token)
|
||||||
if not bot_info:
|
if not bot_info:
|
||||||
raise HTTPException(status_code=400, detail="Invalid bot token")
|
raise HTTPException(status_code=400, detail="Invalid bot token")
|
||||||
@@ -89,8 +88,12 @@ async def delete_bot(
|
|||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
session: AsyncSession = Depends(get_session),
|
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)
|
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.delete(bot)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
@@ -103,27 +106,91 @@ async def get_bot_token(
|
|||||||
):
|
):
|
||||||
"""Get the full bot token (used by frontend to construct target config)."""
|
"""Get the full bot token (used by frontend to construct target config)."""
|
||||||
bot = await _get_user_bot(session, bot_id, user.id)
|
bot = await _get_user_bot(session, bot_id, user.id)
|
||||||
# Token is returned only to the authenticated owner
|
|
||||||
return {"token": bot.token}
|
return {"token": bot.token}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Chat management ---
|
||||||
|
|
||||||
@router.get("/{bot_id}/chats")
|
@router.get("/{bot_id}/chats")
|
||||||
async def list_bot_chats(
|
async def list_bot_chats(
|
||||||
bot_id: int,
|
bot_id: int,
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
session: AsyncSession = Depends(get_session),
|
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
|
@router.post("/{bot_id}/chats/discover")
|
||||||
recently active chats. For groups, the bot must have received
|
async def discover_chats(
|
||||||
at least one message.
|
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)
|
bot = await _get_user_bot(session, bot_id, user.id)
|
||||||
chats = await _discover_chats(bot.token)
|
discovered = await _fetch_chats_from_telegram(bot.token)
|
||||||
return chats
|
|
||||||
|
|
||||||
|
# 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")
|
@router.post("/{bot_id}/sync-commands")
|
||||||
async def sync_commands(
|
async def sync_commands(
|
||||||
@@ -154,8 +221,8 @@ async def _get_me(token: str) -> dict | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def _discover_chats(token: str) -> list[dict]:
|
async def _fetch_chats_from_telegram(token: str) -> list[dict]:
|
||||||
"""Discover chats by fetching recent updates from Telegram."""
|
"""Fetch chats from Telegram getUpdates API."""
|
||||||
seen: dict[int, dict] = {}
|
seen: dict[int, dict] = {}
|
||||||
try:
|
try:
|
||||||
async with aiohttp.ClientSession() as http:
|
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:
|
if chat_id and chat_id not in seen:
|
||||||
seen[chat_id] = {
|
seen[chat_id] = {
|
||||||
"id": 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"),
|
"type": chat.get("type", "private"),
|
||||||
"username": chat.get("username", ""),
|
"username": chat.get("username", ""),
|
||||||
}
|
}
|
||||||
@@ -182,6 +249,17 @@ async def _discover_chats(token: str) -> list[dict]:
|
|||||||
return list(seen.values())
|
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:
|
def _bot_response(b: TelegramBot) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": b.id,
|
"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:
|
if not bot or bot.user_id != user_id:
|
||||||
raise HTTPException(status_code=404, detail="Bot not found")
|
raise HTTPException(status_code=404, detail="Bot not found")
|
||||||
return bot
|
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", ""),
|
||||||
|
))
|
||||||
|
|||||||
@@ -66,6 +66,20 @@ class TelegramBot(SQLModel, table=True):
|
|||||||
created_at: datetime = Field(default_factory=_utcnow)
|
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):
|
class TrackingConfig(SQLModel, table=True):
|
||||||
"""Tracking configuration: what events/assets to react to and scheduled modes."""
|
"""Tracking configuration: what events/assets to react to and scheduled modes."""
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user