Files
notify-bridge/frontend/src/routes/bots/TelegramBotTab.svelte
T
alexei.dolgolyov 82e400ddcd feat: chat language display, disabled EntitySelect items, dev scripts
Chat language:
- Added language_code field to TelegramChat model + migration
- Saved from message.from.language_code on webhook/polling
- Displayed as badge on bot chat cards and target receiver items
- Resolved from DB in target API response (works for existing receivers)
- Shown in chat picker dropdown (desc includes language)

EntitySelect improvements:
- Tracker-target link selector shows all targets, already-linked ones
  appear disabled with "Already linked" hint
- Receiver chat picker shows already-added chats as disabled

Dev scripts:
- scripts/restart-backend.sh and restart-frontend.sh
- Updated .claude/docs/dev-servers.md to reference scripts
2026-03-22 23:39:52 +03:00

433 lines
19 KiB
Svelte

<script lang="ts">
import { slide } from 'svelte/transition';
import { api } from '$lib/api';
import { t, getLocale } from '$lib/i18n';
import { telegramBotsCache } from '$lib/stores/caches.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte';
import IconPicker from '$lib/components/IconPicker.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import { snackSuccess, snackError, snackInfo } from '$lib/stores/snackbar.svelte';
import type { TelegramChat } from '$lib/types';
let { settings, onreload }: { settings: any; onreload: () => Promise<void> } = $props();
let bots = $derived(telegramBotsCache.items);
let showForm = $state(false);
let editing = $state<number | null>(null);
let form = $state({ name: '', icon: '', token: '' });
let error = $state('');
let submitting = $state(false);
let confirmDelete = $state<any>(null);
// Per-bot expandable sections
let chats = $state<Record<number, TelegramChat[]>>({});
let chatsLoading = $state<Record<number, boolean>>({});
let expandedSection = $state<Record<number, string>>({});
// Webhook status per bot
let webhookStatus = $state<Record<number, any>>({});
let chatTesting = $state<Record<string, boolean>>({});
let modeChanging = $state<Record<number, boolean>>({});
// Listener status: command trackers using this bot
let botListenerStatus = $state<Record<number, any[]>>({});
let botListenerLoading = $state<Record<number, boolean>>({});
function openNew() { form = { name: '', icon: '', token: '' }; editing = null; showForm = true; }
function editBot(bot: any) { form = { name: bot.name, icon: bot.icon || '', token: '' }; editing = bot.id; showForm = true; }
async function saveBot(e: SubmitEvent) {
e.preventDefault(); error = ''; submitting = true;
try {
if (editing) {
await api(`/telegram-bots/${editing}`, { method: 'PUT', body: JSON.stringify({ name: form.name, icon: form.icon }) });
snackSuccess(t('snack.botUpdated'));
} else {
await api('/telegram-bots', { method: 'POST', body: JSON.stringify(form) });
snackSuccess(t('snack.botRegistered'));
}
form = { name: '', icon: '', token: '' }; showForm = false; editing = null; await onreload();
} catch (err: any) { error = err.message; snackError(err.message); }
finally { submitting = false; }
}
function remove(id: number) {
confirmDelete = {
id,
onconfirm: async () => {
try { await api(`/telegram-bots/${id}`, { method: 'DELETE' }); await onreload(); snackSuccess(t('snack.botDeleted')); }
catch (err: any) { error = err.message; snackError(err.message); }
finally { confirmDelete = null; }
}
};
}
function toggleSection(botId: number, section: string) {
if (expandedSection[botId] === section) {
expandedSection = { ...expandedSection, [botId]: '' };
return;
}
expandedSection = { ...expandedSection, [botId]: section };
if (section === 'chats') loadChats(botId);
}
async function loadChats(botId: number) {
chatsLoading = { ...chatsLoading, [botId]: true };
try { chats = { ...chats, [botId]: await api(`/telegram-bots/${botId}/chats`) }; } catch { chats = { ...chats, [botId]: [] }; }
chatsLoading = { ...chatsLoading, [botId]: false };
}
async function discoverChats(botId: number) {
chatsLoading = { ...chatsLoading, [botId]: true };
try {
chats = { ...chats, [botId]: await api(`/telegram-bots/${botId}/chats/discover`, { method: 'POST' }) };
snackSuccess(t('telegramBot.chatsDiscovered'));
} catch (err: any) { snackError(err.message); }
chatsLoading = { ...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); }
}
async function loadListenerStatus(botId: number) {
botListenerLoading = { ...botListenerLoading, [botId]: true };
try {
const trackers = await api('/command-trackers');
const matched: any[] = [];
for (const trk of trackers) {
try {
const listeners = await api(`/command-trackers/${trk.id}/listeners`);
const hasBot = listeners.some((l: any) => l.listener_type === 'telegram_bot' && l.listener_id === botId);
if (hasBot) matched.push(trk);
} catch { /* ignore */ }
}
botListenerStatus = { ...botListenerStatus, [botId]: matched };
} catch { botListenerStatus = { ...botListenerStatus, [botId]: [] }; }
botListenerLoading = { ...botListenerLoading, [botId]: false };
}
async function syncCommands(botId: number) {
modeChanging = { ...modeChanging, [botId]: true };
try {
const res = await api(`/telegram-bots/${botId}/sync-commands`, { method: 'POST' });
if (res.success) snackSuccess(t('telegramBot.commandsSynced'));
else snackError(res.error || 'Failed');
} catch (err: any) { snackError(err.message); }
modeChanging = { ...modeChanging, [botId]: false };
}
async function switchMode(botId: number, mode: string) {
modeChanging = { ...modeChanging, [botId]: true };
try {
await api(`/telegram-bots/${botId}`, { method: 'PUT', body: JSON.stringify({ update_mode: mode }) });
await onreload();
if (mode === 'webhook') {
await loadWebhookStatus(botId);
}
snackSuccess(t('snack.botUpdated'));
} catch (err: any) { snackError(err.message); }
modeChanging = { ...modeChanging, [botId]: false };
}
async function loadWebhookStatus(botId: number) {
try {
webhookStatus = { ...webhookStatus, [botId]: await api(`/telegram-bots/${botId}/webhook/status`) };
} catch { webhookStatus = { ...webhookStatus, [botId]: null }; }
}
async function registerWebhook(botId: number) {
modeChanging = { ...modeChanging, [botId]: true };
try {
const res = await api(`/telegram-bots/${botId}/webhook/register`, { method: 'POST' });
if (res.success) {
snackSuccess(res.verified ? t('telegramBot.webhookVerified') : t('telegramBot.webhookRegistered'));
await loadWebhookStatus(botId);
} else {
snackError(res.error || 'Failed to register webhook');
}
} catch (err: any) { snackError(err.message); }
modeChanging = { ...modeChanging, [botId]: false };
}
async function unregisterWebhook(botId: number) {
modeChanging = { ...modeChanging, [botId]: true };
try {
const res = await api(`/telegram-bots/${botId}/webhook/unregister`, { method: 'POST' });
if (res.success) { snackSuccess(t('telegramBot.webhookUnregistered')); await loadWebhookStatus(botId); }
else snackError(res.error || 'Failed');
} catch (err: any) { snackError(err.message); }
modeChanging = { ...modeChanging, [botId]: false };
}
function copyChatId(e: Event, chatId: string) {
e.stopPropagation();
if (navigator.clipboard?.writeText) {
navigator.clipboard.writeText(chatId);
} else {
// Fallback for non-HTTPS contexts
const ta = document.createElement('textarea');
ta.value = chatId;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
}
snackInfo(`${t('snack.copied')}: ${chatId}`);
}
async function testChat(e: Event, botId: number, chatId: string) {
e.stopPropagation();
const key = `${botId}_${chatId}`;
if (chatTesting[key]) return;
chatTesting = { ...chatTesting, [key]: true };
try {
const res = await api(`/telegram-bots/${botId}/chats/${chatId}/test?locale=${getLocale()}`, { method: 'POST' });
if (res.success) snackSuccess(t('snack.targetTestSent'));
else snackError(res.error || 'Failed');
} catch (err: any) { snackError(err.message); }
chatTesting = { ...chatTesting, [key]: 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, editing = null) : openNew(); }}
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 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={saveBot} class="space-y-3">
<div>
<label for="bot-name" class="block text-sm font-medium mb-1">{t('telegramBot.name')}</label>
<div class="flex gap-2">
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
<input id="bot-name" bind:value={form.name} required placeholder={t('telegramBot.namePlaceholder')}
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
</div>
{#if !editing}
<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>
{/if}
<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') : (editing ? t('common.save') : t('telegramBot.addBot'))}
</button>
</form>
</Card>
{/if}
{#if bots.length === 0 && !showForm}
<Card>
<EmptyState icon="mdiRobot" message={t('telegramBot.noBots')} />
</Card>
{:else}
<div class="space-y-3 stagger-children">
{#each bots as bot}
<Card hover entityId={bot.id}>
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2">
<span style="color: var(--color-primary);"><MdiIcon name={bot.icon || 'mdiRobot'} size={20} /></span>
<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}
<!-- Mode badge -->
<span class="text-xs px-1.5 py-0.5 rounded font-mono {bot.update_mode === 'webhook'
? 'bg-blue-500/10 text-blue-500'
: 'bg-emerald-500/10 text-emerald-500'}">
{bot.update_mode === 'webhook' ? t('telegramBot.webhook') : t('telegramBot.polling')}
</span>
</div>
<p class="text-xs text-[var(--color-muted-foreground)] font-mono">{bot.token_preview}</p>
</div>
<div class="flex items-center gap-1">
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => editBot(bot)} />
<button onclick={() => toggleSection(bot.id, 'chats')}
class="text-xs text-[var(--color-muted-foreground)] hover:underline px-2 py-1">
{t('telegramBot.chats')} {expandedSection[bot.id] === 'chats' ? '▲' : '▼'}
</button>
<button onclick={() => { toggleSection(bot.id, 'listeners'); if (expandedSection[bot.id] === 'listeners') loadListenerStatus(bot.id); }}
class="text-xs text-[var(--color-muted-foreground)] hover:underline px-2 py-1">
{t('commandTracker.listeners')} {expandedSection[bot.id] === 'listeners' ? '▲' : '▼'}
</button>
<button onclick={() => syncCommands(bot.id)} disabled={modeChanging[bot.id]}
class="text-xs text-[var(--color-primary)] hover:underline px-2 py-1 flex items-center gap-1">
<MdiIcon name="mdiSync" size={14} />
{t('telegramBot.syncCommands')}
</button>
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(bot.id)} variant="danger" />
</div>
</div>
<!-- Chats section -->
{#if expandedSection[bot.id] === 'chats'}
<div class="mt-3 border-t border-[var(--color-border)] pt-3" in:slide>
{#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)] cursor-pointer"
onclick={(e: MouseEvent) => copyChatId(e, chat.chat_id)}
title={t('telegramBot.clickToCopy')}
role="button" tabindex="0">
<div class="flex items-center gap-2">
<span class="font-medium">{chat.title || chat.username || 'Unknown'}</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>
{#if chat.language_code}<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{chat.language_code.toUpperCase()}</span>{/if}
<span class="text-xs text-[var(--color-muted-foreground)] font-mono">{chat.chat_id}</span>
</div>
<div class="flex items-center gap-1">
<IconButton icon="mdiSend" title="Test message" size={14}
onclick={(e: MouseEvent) => testChat(e, bot.id, chat.chat_id)}
disabled={chatTesting[`${bot.id}_${chat.chat_id}`]} />
<IconButton icon="mdiDelete" title={t('common.delete')} size={14}
onclick={(e: MouseEvent) => { e.stopPropagation(); deleteChat(bot.id, chat.id); }} variant="danger" />
</div>
</div>
{/each}
</div>
{/if}
<button onclick={() => discoverChats(bot.id)}
class="text-xs text-[var(--color-primary)] hover:underline mt-2 flex items-center gap-1">
<MdiIcon name="mdiSync" size={14} />
{t('telegramBot.discoverChats')}
</button>
</div>
{/if}
<!-- Listener Status section -->
{#if expandedSection[bot.id] === 'listeners'}
<div class="mt-3 border-t border-[var(--color-border)] pt-3 space-y-3" in:slide>
{#if botListenerLoading[bot.id]}
<p class="text-xs text-[var(--color-muted-foreground)]">{t('common.loading')}</p>
{:else if (botListenerStatus[bot.id] || []).length === 0}
<p class="text-xs text-[var(--color-muted-foreground)]">{t('commandTracker.noListeners')}</p>
{:else}
<div class="space-y-1">
{#each botListenerStatus[bot.id] as trk}
<div class="flex items-center justify-between text-sm px-2 py-1 rounded hover:bg-[var(--color-muted)]">
<div class="flex items-center gap-2">
<MdiIcon name={trk.icon || 'mdiConsoleLine'} size={14} />
<span class="font-medium">{trk.name}</span>
<span class="text-xs px-1.5 py-0.5 rounded font-mono {trk.enabled
? 'bg-emerald-500/10 text-emerald-500'
: 'bg-red-500/10 text-red-500'}">
{trk.enabled ? t('commandTracker.enabled') : t('commandTracker.disabled')}
</span>
</div>
<a href="/command-trackers" class="text-xs text-[var(--color-primary)] hover:underline">
{t('common.edit')}
</a>
</div>
{/each}
</div>
{/if}
<!-- Update mode -->
<div class="border-t border-[var(--color-border)] pt-3">
<p class="text-xs font-medium mb-2">{t('telegramBot.updateMode')}</p>
<div class="flex items-center gap-3 flex-wrap">
<div class="flex items-center rounded-md border border-[var(--color-border)] overflow-hidden">
<button onclick={() => switchMode(bot.id, 'polling')}
disabled={modeChanging[bot.id] || bot.update_mode === 'polling'}
class="px-3 py-1 text-xs transition-colors {bot.update_mode === 'polling'
? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]'
: 'hover:bg-[var(--color-muted)]'} disabled:opacity-70">
<MdiIcon name="mdiSync" size={14} />
{t('telegramBot.polling')}
</button>
<button onclick={() => switchMode(bot.id, 'webhook')}
disabled={modeChanging[bot.id] || bot.update_mode === 'webhook'}
class="px-3 py-1 text-xs transition-colors {bot.update_mode === 'webhook'
? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]'
: 'hover:bg-[var(--color-muted)]'} disabled:opacity-70">
<MdiIcon name="mdiWebhook" size={14} />
{t('telegramBot.webhook')}
</button>
</div>
{#if bot.update_mode === 'polling'}
<span class="text-xs text-emerald-500 flex items-center gap-1">
<MdiIcon name="mdiCheckCircle" size={14} />
{t('telegramBot.pollingActive')}
</span>
{/if}
{#if bot.update_mode === 'webhook'}
<button onclick={() => registerWebhook(bot.id)} disabled={modeChanging[bot.id]}
class="px-2 py-1 text-xs border border-[var(--color-border)] rounded-md hover:bg-[var(--color-muted)] disabled:opacity-50">
{t('telegramBot.registerWebhook')}
</button>
<button onclick={() => unregisterWebhook(bot.id)} disabled={modeChanging[bot.id]}
class="px-2 py-1 text-xs text-[var(--color-muted-foreground)] hover:underline disabled:opacity-50">
{t('telegramBot.unregisterWebhook')}
</button>
{#if webhookStatus[bot.id]}
{@const ws = webhookStatus[bot.id]}
<span class="text-xs font-mono {ws.url ? 'text-blue-500' : 'text-[var(--color-muted-foreground)]'}">
{ws.url ? t('telegramBot.webhookActive') : t('telegramBot.webhookNotSet')}
{#if ws.pending_update_count > 0}
({ws.pending_update_count} {t('telegramBot.pendingUpdates')})
{/if}
</span>
{#if ws.last_error_message}
<span class="text-xs text-red-500">{t('telegramBot.webhookError')}: {ws.last_error_message}</span>
{/if}
{:else}
<button onclick={() => loadWebhookStatus(bot.id)}
class="text-xs text-[var(--color-primary)] hover:underline">
{t('telegramBot.webhookStatus')}
</button>
{/if}
{/if}
{#if !settings.external_url && bot.update_mode === 'webhook'}
<span class="text-xs text-amber-500 flex items-center gap-1">
<MdiIcon name="mdiAlert" size={14} />
{t('telegramBot.noExternalDomain')}
</span>
{/if}
</div>
</div>
</div>
{/if}
</Card>
{/each}
</div>
{/if}
<ConfirmModal open={confirmDelete !== null} message={t('telegramBot.confirmDelete')}
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />