Phase 9: Telegram bot management with chat discovery
Some checks failed
Validate / Hassfest (push) Has been cancelled
Some checks failed
Validate / Hassfest (push) Has been cancelled
New entity + API:
- TelegramBot model (name, token, bot_username, bot_id)
- CRUD at /api/telegram-bots with token validation via getMe
- GET /{id}/chats: discover active chats via getUpdates
- GET /{id}/token: retrieve full token (for target config)
Frontend:
- /telegram-bots page: register bots, view discovered chats
per bot (expandable), refresh on demand
- Targets page: select from registered bots (dropdown) instead
of raw token input. Chat selector shows discovered chats
when bot is selected, falls back to manual input.
Bot token resolved server-side on save.
Nav icon uses monochrome symbol for consistency.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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": "Определите, на какие события и файлы реагировать",
|
||||
|
||||
@@ -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: '◇' },
|
||||
];
|
||||
|
||||
|
||||
@@ -9,10 +9,12 @@
|
||||
let targets = $state<any[]>([]);
|
||||
let trackingConfigs = $state<any[]>([]);
|
||||
let templateConfigs = $state<any[]>([]);
|
||||
let bots = $state<any[]>([]);
|
||||
let botChats = $state<Record<number, any[]>>({});
|
||||
let showForm = $state(false);
|
||||
let editing = $state<number | null>(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 @@
|
||||
<input id="tgt-name" bind:value={form.name} required placeholder={t('targets.namePlaceholder')} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
{#if formType === 'telegram'}
|
||||
<!-- Bot selector -->
|
||||
<div>
|
||||
<label for="tgt-token" class="block text-sm font-medium mb-1">{t('targets.botToken')}</label>
|
||||
<input id="tgt-token" bind:value={form.bot_token} required type="password" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
<label for="tgt-bot" class="block text-sm font-medium mb-1">{t('telegramBot.selectBot')}</label>
|
||||
<select id="tgt-bot" bind:value={form.bot_id} onchange={loadBotChats}
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
||||
<option value={0}>— Manual token —</option>
|
||||
{#each bots as bot}<option value={bot.id}>{bot.name} (@{bot.bot_username})</option>{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if !form.bot_id}
|
||||
<!-- Manual token input (fallback) -->
|
||||
<div>
|
||||
<label for="tgt-token" class="block text-sm font-medium mb-1">{t('targets.botToken')}</label>
|
||||
<input id="tgt-token" bind:value={form.bot_token} required type="password" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Chat selector -->
|
||||
<div>
|
||||
<label for="tgt-chat" class="block text-sm font-medium mb-1">{t('targets.chatId')}</label>
|
||||
<input id="tgt-chat" bind:value={form.chat_id} required class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
<label for="tgt-chat" class="block text-sm font-medium mb-1">{t('telegramBot.selectChat')}</label>
|
||||
{#if form.bot_id && (botChats[form.bot_id] || []).length > 0}
|
||||
<select id="tgt-chat" bind:value={form.chat_id}
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
||||
<option value="">— {t('telegramBot.selectChat')} —</option>
|
||||
{#each botChats[form.bot_id] as chat}
|
||||
<option value={String(chat.id)}>{chat.title || chat.username || 'Unknown'} ({chat.type}) [{chat.id}]</option>
|
||||
{/each}
|
||||
</select>
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">
|
||||
<button type="button" onclick={loadBotChats} class="hover:underline">{t('telegramBot.refreshChats')}</button>
|
||||
</p>
|
||||
{:else}
|
||||
<input id="tgt-chat" bind:value={form.chat_id} required placeholder="Chat ID"
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
{#if form.bot_id}
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('telegramBot.noChats')}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Telegram media settings -->
|
||||
|
||||
145
frontend/src/routes/telegram-bots/+page.svelte
Normal file
145
frontend/src/routes/telegram-bots/+page.svelte
Normal file
@@ -0,0 +1,145 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import Card from '$lib/components/Card.svelte';
|
||||
import Loading from '$lib/components/Loading.svelte';
|
||||
|
||||
let bots = $state<any[]>([]);
|
||||
let loaded = $state(false);
|
||||
let showForm = $state(false);
|
||||
let form = $state({ name: '', token: '' });
|
||||
let error = $state('');
|
||||
let submitting = $state(false);
|
||||
|
||||
// Per-bot chat lists
|
||||
let chats = $state<Record<number, any[]>>({});
|
||||
let chatsLoading = $state<Record<number, boolean>>({});
|
||||
let expandedBot = $state<number | null>(null);
|
||||
|
||||
onMount(load);
|
||||
async function load() { try { bots = await api('/telegram-bots'); } catch {} finally { loaded = true; } }
|
||||
|
||||
async function create(e: SubmitEvent) {
|
||||
e.preventDefault(); error = ''; submitting = true;
|
||||
try {
|
||||
await api('/telegram-bots', { method: 'POST', body: JSON.stringify(form) });
|
||||
form = { name: '', token: '' }; showForm = false; await load();
|
||||
} catch (err: any) { error = err.message; }
|
||||
submitting = false;
|
||||
}
|
||||
|
||||
async function remove(id: number) {
|
||||
if (!confirm(t('common.delete') + '?')) return;
|
||||
try { await api(`/telegram-bots/${id}`, { method: 'DELETE' }); await load(); } catch (err: any) { error = err.message; }
|
||||
}
|
||||
|
||||
async function loadChats(botId: number) {
|
||||
if (expandedBot === botId) { expandedBot = null; return; }
|
||||
expandedBot = botId;
|
||||
chatsLoading[botId] = true;
|
||||
try { chats[botId] = await api(`/telegram-bots/${botId}/chats`); }
|
||||
catch { chats[botId] = []; }
|
||||
chatsLoading[botId] = false;
|
||||
}
|
||||
|
||||
function chatTypeLabel(type: string): string {
|
||||
const map: Record<string, string> = {
|
||||
private: t('telegramBot.private'),
|
||||
group: t('telegramBot.group'),
|
||||
supergroup: t('telegramBot.supergroup'),
|
||||
channel: t('telegramBot.channel'),
|
||||
};
|
||||
return map[type] || type;
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageHeader title={t('telegramBot.title')} description={t('telegramBot.description')}>
|
||||
<button onclick={() => { showForm ? (showForm = false) : (showForm = true, form = { name: '', token: '' }); }}
|
||||
class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
||||
{showForm ? t('common.cancel') : t('telegramBot.addBot')}
|
||||
</button>
|
||||
</PageHeader>
|
||||
|
||||
{#if !loaded}<Loading />{:else}
|
||||
|
||||
{#if showForm}
|
||||
<Card class="mb-6">
|
||||
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
|
||||
<form onsubmit={create} class="space-y-3">
|
||||
<div>
|
||||
<label for="bot-name" class="block text-sm font-medium mb-1">{t('telegramBot.name')}</label>
|
||||
<input id="bot-name" bind:value={form.name} required placeholder={t('telegramBot.namePlaceholder')}
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="bot-token" class="block text-sm font-medium mb-1">{t('telegramBot.token')}</label>
|
||||
<input id="bot-token" bind:value={form.token} required placeholder={t('telegramBot.tokenPlaceholder')}
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)] font-mono" />
|
||||
</div>
|
||||
<button type="submit" disabled={submitting}
|
||||
class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50">
|
||||
{submitting ? t('common.loading') : t('telegramBot.addBot')}
|
||||
</button>
|
||||
</form>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
{#if bots.length === 0 && !showForm}
|
||||
<Card><p class="text-sm text-[var(--color-muted-foreground)]">{t('telegramBot.noBots')}</p></Card>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each bots as bot}
|
||||
<Card>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="font-medium">{bot.name}</p>
|
||||
{#if bot.bot_username}
|
||||
<span class="text-xs text-[var(--color-muted-foreground)]">@{bot.bot_username}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] font-mono">{bot.token_preview}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button onclick={() => loadChats(bot.id)}
|
||||
class="text-xs text-[var(--color-muted-foreground)] hover:underline">
|
||||
{t('telegramBot.chats')} {expandedBot === bot.id ? '▲' : '▼'}
|
||||
</button>
|
||||
<button onclick={() => remove(bot.id)}
|
||||
class="text-xs text-[var(--color-destructive)] hover:underline">{t('common.delete')}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if expandedBot === bot.id}
|
||||
<div class="mt-3 border-t border-[var(--color-border)] pt-3">
|
||||
{#if chatsLoading[bot.id]}
|
||||
<p class="text-xs text-[var(--color-muted-foreground)]">{t('common.loading')}</p>
|
||||
{:else if (chats[bot.id] || []).length === 0}
|
||||
<p class="text-xs text-[var(--color-muted-foreground)]">{t('telegramBot.noChats')}</p>
|
||||
{:else}
|
||||
<div class="space-y-1">
|
||||
{#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>
|
||||
<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>
|
||||
</div>
|
||||
<span class="text-xs text-[var(--color-muted-foreground)] font-mono">{chat.id}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<button onclick={() => loadChats(bot.id)}
|
||||
class="text-xs text-[var(--color-muted-foreground)] hover:underline mt-2">
|
||||
{t('telegramBot.refreshChats')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{/if}
|
||||
181
packages/server/src/immich_watcher_server/api/telegram_bots.py
Normal file
181
packages/server/src/immich_watcher_server/api/telegram_bots.py
Normal file
@@ -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
|
||||
@@ -7,6 +7,7 @@ from .models import (
|
||||
EventLog,
|
||||
ImmichServer,
|
||||
NotificationTarget,
|
||||
TelegramBot,
|
||||
TemplateConfig,
|
||||
TrackingConfig,
|
||||
User,
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user