feat: comprehensive code review fixes + receivers-only architecture

Security:
- Refuse startup with default secret_key in production (was just logging)
- Settings endpoint now requires admin role
- Password validation on initial setup
- DOM-based HTML sanitizer replaces regex in template previews
- Add *.log to .gitignore

Performance & reliability:
- Token refresh deduplication prevents race condition on concurrent 401s
- Theme media query listener registered once (no leak)
- IconPicker uses $derived instead of function call per render
- Snackbar uses single-batch state update instead of while loop
- Replace 11 inline hover handlers with CSS :hover in layout

Architecture - receivers-only:
- Delivery endpoints (chat_id, email, url, room_id, topic) now stored
  exclusively in TargetReceiver rows, never in target.config
- Migration extracts existing delivery fields to receiver rows
- Notifier and dispatcher remove all config fallbacks
- Frontend targets page shows receivers list per target with
  add/remove/toggle/test per receiver
- Single-receiver test endpoint: POST /targets/{id}/receivers/{id}/test

Code quality:
- Extract AuthLayout.svelte from login/setup (150 lines CSS dedup)
- Split telegram-bots page (754→51 lines + 3 tab components)
- Split notification-trackers page (547→432 lines + 4 components)
- Deduplicate _send_reply into shared handler.send_reply()
- Add locale column to template models, replace name-based detection
- Fix delete_notification_tracker dead protection check
- Fix check_telegram_bot query (filter by type, remove bogus OR)
- Add graceful scheduler shutdown in lifespan
- Consistent /bots?tab=telegram URLs across all nav links

i18n:
- Error page, chat actions, target types, provider types internationalized
- All new receiver UI strings in EN + RU
This commit is contained in:
2026-03-22 02:19:31 +03:00
parent b525e3e7f4
commit 751097b347
43 changed files with 2584 additions and 1685 deletions
+3
View File
@@ -53,3 +53,6 @@ test-data/
node_modules/
frontend/build/
frontend/.svelte-kit/
# Logs
*.log
+98
View File
@@ -0,0 +1,98 @@
/**
* API client with JWT auth for the Notify Bridge backend.
*/
const API_BASE = '/api';
function getToken(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem('access_token');
}
export function setTokens(access: string, refresh: string) {
localStorage.setItem('access_token', access);
localStorage.setItem('refresh_token', refresh);
}
export function clearTokens() {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
}
export function isAuthenticated(): boolean {
return !!getToken();
}
let refreshPromise: Promise<boolean> | null = null;
async function refreshAccessToken(): Promise<boolean> {
if (refreshPromise) return refreshPromise;
refreshPromise = doRefreshAccessToken().finally(() => {
refreshPromise = null;
});
return refreshPromise;
}
async function doRefreshAccessToken(): Promise<boolean> {
if (typeof window === 'undefined') return false;
const refreshToken = localStorage.getItem('refresh_token');
if (!refreshToken) return false;
try {
const res = await fetch(`${API_BASE}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: refreshToken })
});
if (res.ok) {
const data = await res.json();
setTokens(data.access_token, data.refresh_token);
return true;
}
} catch {
// ignore
}
return false;
}
export async function api<T = any>(
path: string,
options: RequestInit = {}
): Promise<T> {
const token = getToken();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string>)
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
let res = await fetch(`${API_BASE}${path}`, { ...options, headers });
// Try token refresh on 401
if (res.status === 401 && token) {
const refreshed = await refreshAccessToken();
if (refreshed) {
headers['Authorization'] = `Bearer ${getToken()}`;
res = await fetch(`${API_BASE}${path}`, { ...options, headers });
}
}
if (res.status === 401) {
clearTokens();
if (typeof window !== 'undefined') {
window.location.href = '/login';
}
throw new Error('Unauthorized');
}
if (res.status === 204) return undefined as T;
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail || `HTTP ${res.status}`);
}
return res.json();
}
@@ -0,0 +1,191 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let { visible = false, children }: { visible: boolean; children: Snippet } = $props();
</script>
<div class="auth-page">
<!-- Animated gradient mesh background -->
<div class="auth-bg"></div>
<div class="auth-grid"></div>
<!-- Card -->
<div class="auth-card-wrapper" class:visible>
<div class="auth-card">
{@render children()}
</div>
</div>
</div>
<style>
.auth-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
background: var(--color-background);
}
.auth-bg {
position: absolute;
inset: 0;
z-index: 0;
background:
radial-gradient(ellipse 80% 60% at 20% 30%, var(--color-glow-strong), transparent 60%),
radial-gradient(ellipse 60% 80% at 80% 70%, rgba(99, 102, 241, 0.08), transparent 60%),
radial-gradient(ellipse 50% 50% at 50% 50%, var(--color-glow), transparent 70%);
animation: gradientShift 12s ease-in-out infinite;
background-size: 200% 200%;
}
.auth-grid {
position: absolute;
inset: 0;
z-index: 0;
opacity: 0.3;
background-image: radial-gradient(circle at 1px 1px, var(--color-border) 0.5px, transparent 0);
background-size: 32px 32px;
}
.auth-card-wrapper {
position: relative;
z-index: 1;
width: 100%;
max-width: 24rem;
padding: 1rem;
opacity: 0;
transform: translateY(16px) scale(0.98);
transition: opacity 0.5s ease-out, transform 0.5s ease-out;
}
.auth-card-wrapper.visible {
opacity: 1;
transform: translateY(0) scale(1);
}
.auth-card {
background: var(--color-card);
border: 1px solid var(--color-border);
border-radius: 1rem;
padding: 2rem;
box-shadow:
0 4px 24px rgba(0, 0, 0, 0.08),
0 0 0 1px rgba(255, 255, 255, 0.05) inset;
backdrop-filter: blur(8px);
}
:global([data-theme="dark"]) .auth-card {
box-shadow:
0 4px 24px rgba(0, 0, 0, 0.3),
0 0 48px var(--color-glow),
0 0 0 1px rgba(255, 255, 255, 0.03) inset;
}
:global(.auth-logo-icon) {
display: inline-flex;
align-items: center;
justify-content: center;
width: 3.5rem;
height: 3.5rem;
border-radius: 1rem;
background: var(--color-primary);
color: var(--color-primary-foreground);
box-shadow: 0 0 24px var(--color-glow-strong);
}
:global(.auth-control-btn) {
display: flex;
align-items: center;
justify-content: center;
padding: 0.25rem 0.625rem;
border-radius: 0.5rem;
font-size: 0.7rem;
font-weight: 500;
background: var(--color-muted);
color: var(--color-muted-foreground);
border: none;
cursor: pointer;
transition: all 0.2s ease;
}
:global(.auth-control-btn:hover) {
color: var(--color-foreground);
box-shadow: 0 0 8px var(--color-glow);
}
:global(.auth-label) {
display: block;
font-size: 0.8rem;
font-weight: 500;
margin-bottom: 0.375rem;
color: var(--color-foreground);
}
:global(.auth-input) {
width: 100%;
padding: 0.625rem 0.875rem;
border: 1px solid var(--color-border);
border-radius: 0.625rem;
font-size: 0.875rem;
background: var(--color-background);
color: var(--color-foreground);
transition: border-color 0.2s, box-shadow 0.2s;
}
:global(.auth-input:focus) {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-glow), 0 0 16px var(--color-glow);
}
:global(.auth-error) {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-radius: 0.625rem;
font-size: 0.8rem;
margin-bottom: 1rem;
background: var(--color-error-bg);
color: var(--color-error-fg);
}
:global(.auth-submit) {
width: 100%;
padding: 0.625rem;
border-radius: 0.625rem;
font-size: 0.875rem;
font-weight: 600;
background: var(--color-primary);
color: var(--color-primary-foreground);
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
transition: all 0.2s ease;
}
:global(.auth-submit:hover:not(:disabled)) {
box-shadow: 0 0 24px var(--color-glow-strong);
transform: translateY(-1px);
}
:global(.auth-submit:active:not(:disabled)) {
transform: translateY(0);
}
:global(.auth-submit:disabled) {
opacity: 0.6;
cursor: not-allowed;
}
@keyframes gradientShift {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
</style>
@@ -24,12 +24,12 @@
'mdiFileDocument', 'mdiEmail', 'mdiPhone', 'mdiChat', 'mdiShare',
];
function filtered(): string[] {
const filtered = $derived.by(() => {
const allIcons = getAllMdiNames();
if (!search) return popular.filter(p => allIcons.includes(p));
const q = search.toLowerCase();
return allIcons.filter(k => k.toLowerCase().includes(q)).slice(0, 60);
}
});
function toggleOpen() {
if (!open && buttonEl) {
@@ -81,7 +81,7 @@
<button type="button" onclick={() => select('')}
class="flex items-center justify-center aspect-square rounded hover:bg-[var(--color-muted)] text-xs text-[var(--color-muted-foreground)]"
title="No icon"></button>
{#each filtered() as iconName}
{#each filtered as iconName}
<button type="button" onclick={() => select(iconName)}
class="flex items-center justify-center aspect-square rounded hover:bg-[var(--color-muted)] {value === iconName ? 'bg-[var(--color-accent)]' : ''}"
title={iconName.replace('mdi', '')}>
@@ -51,7 +51,7 @@
mapFn: (e: any) => ({ detail: e.provider_type, icon: e.icon || 'mdiFileDocumentEdit' }) },
{ key: 'targets', label: 'nav.targets', icon: 'mdiTarget', href: '/targets',
mapFn: (e: any) => ({ detail: e.type, icon: e.icon || 'mdiTarget' }) },
{ key: 'telegram_bots', label: 'nav.telegram', icon: 'mdiSendCircle', href: '/bots',
{ key: 'telegram_bots', label: 'nav.telegram', icon: 'mdiSendCircle', href: '/bots?tab=telegram',
mapFn: (e: any) => ({ detail: `@${e.bot_username || ''}`, icon: e.icon || 'mdiRobot' }) },
{ key: 'email_bots', label: 'nav.email', icon: 'mdiEmailOutline', href: '/bots?tab=email',
mapFn: (e: any) => ({ detail: e.email || '', icon: e.icon || 'mdiEmailOutline' }) },
+10 -10
View File
@@ -79,24 +79,24 @@ export const sortFilterItems = (): GridItem[] => [
// --- Chat action (Telegram targets) ---
export const chatActionItems = (): GridItem[] => [
{ value: '', icon: 'mdiMinus', label: 'None' },
{ value: 'typing', icon: 'mdiKeyboard', label: 'Typing' },
{ value: 'upload_photo', icon: 'mdiImagePlus', label: 'Upload Photo' },
{ value: 'upload_video', icon: 'mdiVideoPlus', label: 'Upload Video' },
{ value: 'upload_document', icon: 'mdiFileUpload', label: 'Upload Doc' },
{ value: 'record_video', icon: 'mdiVideo', label: 'Record Video' },
{ value: 'record_voice', icon: 'mdiMicrophone', label: 'Record Voice' },
{ value: '', icon: 'mdiMinus', label: t('targets.chatActionNone') },
{ value: 'typing', icon: 'mdiKeyboard', label: t('targets.chatActionTyping') },
{ value: 'upload_photo', icon: 'mdiImagePlus', label: t('targets.chatActionUploadPhoto') },
{ value: 'upload_video', icon: 'mdiVideoPlus', label: t('targets.chatActionUploadVideo') },
{ value: 'upload_document', icon: 'mdiFileUpload', label: t('targets.chatActionUploadDoc') },
{ value: 'record_video', icon: 'mdiVideo', label: t('targets.chatActionRecordVideo') },
{ value: 'record_voice', icon: 'mdiMicrophone', label: t('targets.chatActionRecordVoice') },
];
// --- Preview target type ---
export const previewTargetTypeItems = (): GridItem[] => [
{ value: 'telegram', icon: 'mdiSend', label: 'Telegram' },
{ value: 'webhook', icon: 'mdiWebhook', label: 'Webhook' },
{ value: 'telegram', icon: 'mdiSend', label: t('targets.typeTelegram') },
{ value: 'webhook', icon: 'mdiWebhook', label: t('targets.typeWebhook') },
];
// --- Provider type ---
export const providerTypeItems = (): GridItem[] => [
{ value: 'immich', icon: 'mdiCamera', label: 'Immich' },
{ value: 'immich', icon: 'mdiCamera', label: t('providers.typeImmich') },
];
+23 -1
View File
@@ -105,6 +105,7 @@
"online": "Online",
"offline": "Offline",
"checking": "Checking...",
"typeImmich": "Immich",
"loadError": "Failed to load providers.",
"externalDomain": "External Domain",
"optional": "optional",
@@ -228,6 +229,14 @@
"sendLargeAsDocuments": "Send large photos as documents",
"chatAction": "Chat action",
"chatActionNone": "None (no action)",
"chatActionTyping": "Typing",
"chatActionUploadPhoto": "Upload Photo",
"chatActionUploadVideo": "Upload Video",
"chatActionUploadDoc": "Upload Doc",
"chatActionRecordVideo": "Record Video",
"chatActionRecordVoice": "Record Voice",
"typeTelegram": "Telegram",
"typeWebhook": "Webhook",
"overrideUsername": "Override bot username",
"ntfyServer": "ntfy Server URL",
"ntfyTopic": "Topic",
@@ -236,7 +245,16 @@
"selectEmailBot": "Select Email Bot",
"selectMatrixBot": "Select Matrix Bot",
"recipientEmail": "Recipient Email",
"matrixRoomId": "Room ID"
"matrixRoomId": "Room ID",
"receivers": "Receivers",
"noReceivers": "No receivers yet",
"addReceiver": "Add Receiver",
"receiverAdded": "Receiver added",
"receiverDeleted": "Receiver deleted",
"receiverUpdated": "Receiver updated",
"confirmDeleteReceiver": "Delete this receiver?",
"receiverEnabled": "Receiver enabled",
"receiverDisabled": "Receiver disabled"
},
"users": {
"title": "Users",
@@ -702,6 +720,10 @@
"line": "line",
"add": "Add"
},
"error": {
"notFound": "Page not found",
"goHome": "Go home"
},
"searchPalette": {
"placeholder": "Search entities...",
"noResults": "No results found",
+23 -1
View File
@@ -105,6 +105,7 @@
"online": "В сети",
"offline": "Не в сети",
"checking": "Проверка...",
"typeImmich": "Immich",
"loadError": "Не удалось загрузить провайдеры.",
"externalDomain": "Внешний домен",
"optional": "необязательно",
@@ -228,6 +229,14 @@
"sendLargeAsDocuments": "Отправлять большие фото как документы",
"chatAction": "Действие в чате",
"chatActionNone": "Нет (без действия)",
"chatActionTyping": "Печатает",
"chatActionUploadPhoto": "Загрузка фото",
"chatActionUploadVideo": "Загрузка видео",
"chatActionUploadDoc": "Загрузка документа",
"chatActionRecordVideo": "Запись видео",
"chatActionRecordVoice": "Запись голоса",
"typeTelegram": "Telegram",
"typeWebhook": "Вебхук",
"overrideUsername": "Переопределить имя бота",
"ntfyServer": "URL сервера ntfy",
"ntfyTopic": "Тема",
@@ -236,7 +245,16 @@
"selectEmailBot": "Выберите Email бот",
"selectMatrixBot": "Выберите Matrix бот",
"recipientEmail": "Email получателя",
"matrixRoomId": "ID комнаты"
"matrixRoomId": "ID комнаты",
"receivers": "Получатели",
"noReceivers": "Нет получателей",
"addReceiver": "Добавить получателя",
"receiverAdded": "Получатель добавлен",
"receiverDeleted": "Получатель удалён",
"receiverUpdated": "Получатель обновлён",
"confirmDeleteReceiver": "Удалить этого получателя?",
"receiverEnabled": "Получатель включён",
"receiverDisabled": "Получатель отключён"
},
"users": {
"title": "Пользователи",
@@ -702,6 +720,10 @@
"line": "строка",
"add": "Добавить"
},
"error": {
"notFound": "Страница не найдена",
"goHome": "На главную"
},
"searchPalette": {
"placeholder": "Поиск объектов...",
"noResults": "Ничего не найдено",
@@ -0,0 +1,85 @@
export type SnackType = 'success' | 'error' | 'info' | 'warning';
export interface Snack {
id: number;
type: SnackType;
message: string;
detail?: string;
timeout: number;
}
const DEFAULT_TIMEOUTS: Record<SnackType, number> = {
success: 3000,
info: 3000,
warning: 4000,
error: 5000,
};
const MAX_VISIBLE = 3;
let nextId = 1;
let snacks = $state<Snack[]>([]);
const timers = new Map<number, ReturnType<typeof setTimeout>>();
export function getSnacks(): Snack[] {
return snacks;
}
export function addSnack(
type: SnackType,
message: string,
options?: { detail?: string; timeout?: number },
): void {
const id = nextId++;
const timeout = options?.timeout ?? DEFAULT_TIMEOUTS[type];
const snack: Snack = { id, type, message, detail: options?.detail, timeout };
snacks = [snack, ...snacks];
// Enforce max visible — single batch removal
if (snacks.length > MAX_VISIBLE) {
const overflow = snacks.slice(MAX_VISIBLE);
for (const s of overflow) {
const timer = timers.get(s.id);
if (timer) {
clearTimeout(timer);
timers.delete(s.id);
}
}
snacks = snacks.slice(0, MAX_VISIBLE);
}
// Auto-dismiss
if (timeout > 0) {
timers.set(
id,
setTimeout(() => removeSnack(id), timeout),
);
}
}
export function removeSnack(id: number): void {
const timer = timers.get(id);
if (timer) {
clearTimeout(timer);
timers.delete(id);
}
snacks = snacks.filter((s) => s.id !== id);
}
// Convenience functions
export function snackSuccess(message: string): void {
addSnack('success', message);
}
export function snackError(message: string, detail?: string): void {
addSnack('error', message, { detail });
}
export function snackInfo(message: string): void {
addSnack('info', message);
}
export function snackWarning(message: string): void {
addSnack('warning', message);
}
+56
View File
@@ -0,0 +1,56 @@
/**
* Theme management with Svelte 5 runes.
* Supports light, dark, and system preference.
*/
export type Theme = 'light' | 'dark' | 'system';
let theme = $state<Theme>('system');
let resolved = $state<'light' | 'dark'>('light');
let listenerRegistered = false;
export function getTheme() {
return {
get current() { return theme; },
get resolved() { return resolved; },
get isDark() { return resolved === 'dark'; },
};
}
export function setTheme(newTheme: Theme) {
theme = newTheme;
if (typeof localStorage !== 'undefined') {
localStorage.setItem('theme', newTheme);
}
applyTheme();
}
export function initTheme() {
if (typeof localStorage !== 'undefined') {
const saved = localStorage.getItem('theme') as Theme | null;
if (saved && ['light', 'dark', 'system'].includes(saved)) {
theme = saved;
}
}
applyTheme();
// Listen for system preference changes (register only once)
if (typeof window !== 'undefined' && !listenerRegistered) {
listenerRegistered = true;
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (theme === 'system') applyTheme();
});
}
}
function applyTheme() {
if (typeof document === 'undefined') return;
if (theme === 'system') {
resolved = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
} else {
resolved = theme;
}
document.documentElement.setAttribute('data-theme', resolved);
}
+13
View File
@@ -81,6 +81,17 @@ export interface Tracker {
created_at: string;
}
export interface TargetReceiver {
id: number;
target_id: number;
name: string;
config: Record<string, any>;
receiver_key: string;
enabled: boolean;
chat_name?: string;
created_at?: string;
}
export interface NotificationTarget {
id: number;
type: string;
@@ -88,6 +99,8 @@ export interface NotificationTarget {
icon: string;
config: Record<string, any>;
chat_name?: string;
receiver_count: number;
receivers: TargetReceiver[];
created_at: string;
}
+3 -2
View File
@@ -1,13 +1,14 @@
<script lang="ts">
import { page } from '$app/state';
import { t } from '$lib/i18n';
</script>
<div class="min-h-screen flex items-center justify-center">
<div class="text-center animate-fade-slide-in">
<h1 class="text-6xl font-bold text-[var(--color-muted-foreground)] mb-4">{page.status}</h1>
<p class="text-lg text-[var(--color-muted-foreground)] mb-8">{page.error?.message || 'Page not found'}</p>
<p class="text-lg text-[var(--color-muted-foreground)] mb-8">{page.error?.message || t('error.notFound')}</p>
<a href="/" class="px-6 py-3 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-[var(--radius)] font-medium hover:opacity-90 transition-opacity">
Go home
{t('error.goHome')}
</a>
</div>
</div>
+80 -46
View File
@@ -83,7 +83,7 @@
{
key: 'nav.bots', icon: 'mdiRobot',
children: [
{ href: '/bots', key: 'nav.telegram', icon: 'mdiSendCircle', countKey: 'telegram_bots' },
{ href: '/bots?tab=telegram', key: 'nav.telegram', icon: 'mdiSendCircle', countKey: 'telegram_bots' },
{ href: '/bots?tab=email', key: 'nav.email', icon: 'mdiEmailOutline', countKey: 'email_bots' },
{ href: '/bots?tab=matrix', key: 'nav.matrix', icon: 'mdiMatrix', countKey: 'matrix_bots' },
],
@@ -139,7 +139,7 @@
{ href: '/notification-trackers', key: 'nav.notification', icon: 'mdiBellOutline' },
{ href: '/command-trackers', key: 'nav.commands', icon: 'mdiConsoleLine' },
{ href: '/targets', key: 'nav.targets', icon: 'mdiTarget' },
{ href: '/bots', key: 'nav.bots', icon: 'mdiRobot' },
{ href: '/bots?tab=telegram', key: 'nav.bots', icon: 'mdiRobot' },
]);
const isAuthPage = $derived(
@@ -256,10 +256,7 @@
</div>
{/if}
<button onclick={toggleSidebar}
class="flex items-center justify-center w-8 h-8 rounded-lg transition-all duration-200"
style="color: var(--color-muted-foreground); background: transparent;"
onmouseenter={(e) => { e.currentTarget.style.background = 'var(--color-muted)'; e.currentTarget.style.color = 'var(--color-foreground)'; }}
onmouseleave={(e) => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--color-muted-foreground)'; }}
class="sidebar-icon-btn flex items-center justify-center w-8 h-8 rounded-lg transition-all duration-200"
title={collapsed ? t('common.expand') : t('common.collapse')}>
<MdiIcon name={collapsed ? 'mdiChevronRight' : 'mdiChevronLeft'} size={18} />
</button>
@@ -268,10 +265,7 @@
<!-- Search button -->
<div class="{collapsed ? 'px-2 py-1.5' : 'px-3 py-1.5'}" style="border-bottom: 1px solid var(--color-border);">
<button onclick={() => openSearch?.()}
class="flex items-center gap-2 w-full {collapsed ? 'justify-center px-2' : 'px-2.5'} py-1.5 rounded-lg text-sm transition-all duration-200"
style="background: var(--color-muted); color: var(--color-muted-foreground); border: 1px solid var(--color-border);"
onmouseenter={(e) => { e.currentTarget.style.borderColor = 'var(--color-primary)'; e.currentTarget.style.color = 'var(--color-foreground)'; }}
onmouseleave={(e) => { e.currentTarget.style.borderColor = 'var(--color-border)'; e.currentTarget.style.color = 'var(--color-muted-foreground)'; }}
class="search-btn flex items-center gap-2 w-full {collapsed ? 'justify-center px-2' : 'px-2.5'} py-1.5 rounded-lg text-sm transition-all duration-200"
title={t('searchPalette.placeholder')}>
<MdiIcon name="mdiMagnify" size={16} />
{#if !collapsed}
@@ -288,10 +282,7 @@
<!-- Group header -->
<button
onclick={() => collapsed ? null : toggleGroup(entry.key)}
class="nav-item group flex items-center gap-2.5 {collapsed ? 'justify-center px-2' : 'px-3'} py-2 rounded-lg text-sm transition-all duration-200 w-full text-left relative"
style="color: {isGroupActive(entry) ? 'var(--color-primary)' : 'var(--color-muted-foreground)'}; background: {isGroupActive(entry) && !expandedGroups[entry.key] ? 'var(--color-sidebar-active)' : 'transparent'}; font-weight: {isGroupActive(entry) ? '500' : '400'};"
onmouseenter={(e) => { if (!isGroupActive(entry)) { e.currentTarget.style.background = 'var(--color-muted)'; e.currentTarget.style.color = 'var(--color-foreground)'; } }}
onmouseleave={(e) => { if (!isGroupActive(entry)) { e.currentTarget.style.background = isGroupActive(entry) && !expandedGroups[entry.key] ? 'var(--color-sidebar-active)' : 'transparent'; e.currentTarget.style.color = isGroupActive(entry) ? 'var(--color-primary)' : 'var(--color-muted-foreground)'; } }}
class="nav-link group flex items-center gap-2.5 {collapsed ? 'justify-center px-2' : 'px-3'} py-2 rounded-lg text-sm transition-all duration-200 w-full text-left relative {isGroupActive(entry) ? 'active' : ''} {isGroupActive(entry) && !expandedGroups[entry.key] ? 'active-bg' : ''}"
title={collapsed ? t(entry.key) : ''}
>
{#if isGroupActive(entry) && !expandedGroups[entry.key]}
@@ -311,10 +302,7 @@
{#each entry.children as child}
<a
href={child.href}
class="nav-item group flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-sm transition-all duration-200 relative"
style="color: {isActive(child.href) ? 'var(--color-primary)' : 'var(--color-muted-foreground)'}; background: {isActive(child.href) ? 'var(--color-sidebar-active)' : 'transparent'}; font-weight: {isActive(child.href) ? '500' : '400'};"
onmouseenter={(e) => { if (!isActive(child.href)) { e.currentTarget.style.background = 'var(--color-muted)'; e.currentTarget.style.color = 'var(--color-foreground)'; } }}
onmouseleave={(e) => { if (!isActive(child.href)) { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--color-muted-foreground)'; } }}
class="nav-link nav-link-child group flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-sm transition-all duration-200 relative {isActive(child.href) ? 'active' : ''}"
>
{#if isActive(child.href)}
<div class="active-indicator" style="position: absolute; left: -13px; top: 50%; transform: translateY(-50%); width: 3px; height: 60%; border-radius: 0 3px 3px 0; background: var(--color-primary); box-shadow: 0 0 8px var(--color-glow-strong);"></div>
@@ -332,10 +320,7 @@
<!-- Top-level item -->
<a
href={entry.href}
class="nav-item group flex items-center gap-2.5 {collapsed ? 'justify-center px-2' : 'px-3'} py-2 rounded-lg text-sm transition-all duration-200 relative"
style="color: {isActive(entry.href) ? 'var(--color-primary)' : 'var(--color-muted-foreground)'}; background: {isActive(entry.href) ? 'var(--color-sidebar-active)' : 'transparent'}; font-weight: {isActive(entry.href) ? '500' : '400'};"
onmouseenter={(e) => { if (!isActive(entry.href)) { e.currentTarget.style.background = 'var(--color-muted)'; e.currentTarget.style.color = 'var(--color-foreground)'; } }}
onmouseleave={(e) => { if (!isActive(entry.href)) { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--color-muted-foreground)'; } }}
class="nav-link group flex items-center gap-2.5 {collapsed ? 'justify-center px-2' : 'px-3'} py-2 rounded-lg text-sm transition-all duration-200 relative {isActive(entry.href) ? 'active' : ''}"
title={collapsed ? t(entry.key) : ''}
>
{#if isActive(entry.href)}
@@ -358,18 +343,12 @@
<!-- Theme & Language -->
<div class="flex {collapsed ? 'flex-col items-center gap-1 p-2' : 'gap-1.5 px-4 py-2.5'}">
<button onclick={toggleLocale}
class="flex items-center justify-center {collapsed ? 'w-8 h-8' : 'px-2.5 py-1'} rounded-lg text-xs font-medium transition-all duration-200"
style="background: var(--color-muted); color: var(--color-muted-foreground);"
onmouseenter={(e) => { e.currentTarget.style.color = 'var(--color-foreground)'; e.currentTarget.style.boxShadow = '0 0 8px var(--color-glow)'; }}
onmouseleave={(e) => { e.currentTarget.style.color = 'var(--color-muted-foreground)'; e.currentTarget.style.boxShadow = 'none'; }}
class="footer-pill flex items-center justify-center {collapsed ? 'w-8 h-8' : 'px-2.5 py-1'} rounded-lg text-xs font-medium transition-all duration-200"
title={t('common.language')}>
{getLocale().toUpperCase()}
</button>
<button onclick={cycleTheme}
class="flex items-center justify-center {collapsed ? 'w-8 h-8' : 'px-2.5 py-1'} rounded-lg text-xs transition-all duration-200"
style="background: var(--color-muted); color: var(--color-muted-foreground);"
onmouseenter={(e) => { e.currentTarget.style.color = 'var(--color-foreground)'; e.currentTarget.style.boxShadow = '0 0 8px var(--color-glow)'; }}
onmouseleave={(e) => { e.currentTarget.style.color = 'var(--color-muted-foreground)'; e.currentTarget.style.boxShadow = 'none'; }}
class="footer-pill flex items-center justify-center {collapsed ? 'w-8 h-8' : 'px-2.5 py-1'} rounded-lg text-xs transition-all duration-200"
title={t('common.theme')}>
<MdiIcon name={theme.resolved === 'dark' ? 'mdiWeatherNight' : theme.current === 'system' ? 'mdiDesktopTowerMonitor' : 'mdiWeatherSunny'} size={14} />
</button>
@@ -379,10 +358,7 @@
<div class="p-2.5" style="border-top: 1px solid var(--color-border);">
{#if collapsed}
<button onclick={logout}
class="w-full flex justify-center py-2 rounded-lg transition-all duration-200"
style="color: var(--color-muted-foreground);"
onmouseenter={(e) => { e.currentTarget.style.color = 'var(--color-foreground)'; e.currentTarget.style.background = 'var(--color-muted)'; }}
onmouseleave={(e) => { e.currentTarget.style.color = 'var(--color-muted-foreground)'; e.currentTarget.style.background = 'transparent'; }}
class="sidebar-icon-btn w-full flex justify-center py-2 rounded-lg transition-all duration-200"
title={t('nav.logout')}>
<MdiIcon name="mdiLogout" size={16} />
</button>
@@ -400,19 +376,13 @@
</div>
</div>
<button onclick={logout}
class="p-1.5 rounded-lg transition-all duration-200"
style="color: var(--color-muted-foreground);"
onmouseenter={(e) => { e.currentTarget.style.color = 'var(--color-foreground)'; e.currentTarget.style.background = 'var(--color-muted)'; }}
onmouseleave={(e) => { e.currentTarget.style.color = 'var(--color-muted-foreground)'; e.currentTarget.style.background = 'transparent'; }}
class="sidebar-icon-btn p-1.5 rounded-lg transition-all duration-200"
title={t('nav.logout')}>
<MdiIcon name="mdiLogout" size={15} />
</button>
</div>
<button onclick={() => showPasswordForm = true}
class="text-[0.7rem] mt-1.5 transition-colors duration-200 flex items-center gap-1"
style="color: var(--color-muted-foreground);"
onmouseenter={(e) => { e.currentTarget.style.color = 'var(--color-primary)'; }}
onmouseleave={(e) => { e.currentTarget.style.color = 'var(--color-muted-foreground)'; }}>
class="change-pwd-link text-[0.7rem] mt-1.5 transition-colors duration-200 flex items-center gap-1">
<MdiIcon name="mdiKeyVariant" size={12} />
{t('common.changePassword')}
</button>
@@ -472,10 +442,7 @@
<p class="text-sm" style="color: var({pwdSuccess ? '--color-success-fg' : '--color-error-fg'});">{pwdMsg}</p>
{/if}
<button type="submit"
class="w-full py-2.5 rounded-lg text-sm font-medium transition-all duration-200"
style="background: var(--color-primary); color: var(--color-primary-foreground);"
onmouseenter={(e) => { e.currentTarget.style.boxShadow = '0 0 16px var(--color-glow-strong)'; }}
onmouseleave={(e) => { e.currentTarget.style.boxShadow = 'none'; }}>
class="primary-btn w-full py-2.5 rounded-lg text-sm font-medium transition-all duration-200">
{t('common.save')}
</button>
</form>
@@ -488,6 +455,73 @@
@media (max-width: 767px) {
.mobile-nav { display: flex !important; }
}
/* Sidebar icon button (toggle, logout) */
.sidebar-icon-btn {
color: var(--color-muted-foreground);
background: transparent;
}
.sidebar-icon-btn:hover {
background: var(--color-muted);
color: var(--color-foreground);
}
/* Search button */
.search-btn {
background: var(--color-muted);
color: var(--color-muted-foreground);
border: 1px solid var(--color-border);
}
.search-btn:hover {
border-color: var(--color-primary);
color: var(--color-foreground);
}
/* Nav links (top-level items, group headers, group children) */
.nav-link {
color: var(--color-muted-foreground);
background: transparent;
font-weight: 400;
}
.nav-link:not(.active):hover {
background: var(--color-muted);
color: var(--color-foreground);
}
.nav-link.active {
color: var(--color-primary);
font-weight: 500;
}
.nav-link.active-bg {
background: var(--color-sidebar-active);
}
/* Footer pill buttons (locale, theme) */
.footer-pill {
background: var(--color-muted);
color: var(--color-muted-foreground);
}
.footer-pill:hover {
color: var(--color-foreground);
box-shadow: 0 0 8px var(--color-glow);
}
/* Change password link */
.change-pwd-link {
color: var(--color-muted-foreground);
}
.change-pwd-link:hover {
color: var(--color-primary);
}
/* Primary action button (password form submit) */
.primary-btn {
background: var(--color-primary);
color: var(--color-primary-foreground);
}
.primary-btn:hover {
box-shadow: 0 0 16px var(--color-glow-strong);
}
.nav-badge {
font-size: 0.6rem;
font-weight: 600;
+8 -711
View File
@@ -1,47 +1,25 @@
<script lang="ts">
import { onMount } from 'svelte';
import { slide } from 'svelte/transition';
import { page } from '$app/state';
import { api } from '$lib/api';
import { t, getLocale } from '$lib/i18n';
import { t } from '$lib/i18n';
import { telegramBotsCache, emailBotsCache, matrixBotsCache } from '$lib/stores/caches.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.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 { snackError } from '$lib/stores/snackbar.svelte';
import { highlightFromUrl } from '$lib/highlight';
import type { TelegramBot, TelegramChat, EmailBot, MatrixBot } from '$lib/types';
import TelegramBotTab from './TelegramBotTab.svelte';
import EmailBotTab from './EmailBotTab.svelte';
import MatrixBotTab from './MatrixBotTab.svelte';
type BotTab = 'telegram' | 'email' | 'matrix';
let activeTab = $derived((page.url.searchParams.get('tab') as BotTab | null) || 'telegram');
let bots = $derived(telegramBotsCache.items);
let emailBots = $derived(emailBotsCache.items);
let matrixBots = $derived(matrixBotsCache.items);
let loaded = $state(false);
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);
// Global settings (loaded for webhook mode checks)
let settings = $state<any>({});
// 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>>({});
onMount(load);
async function load() {
try {
@@ -54,701 +32,20 @@
} catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
finally { loaded = true; highlightFromUrl(); }
}
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 load();
} 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 load(); 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); }
}
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>>({});
async function loadListenerStatus(botId: number) {
botListenerLoading = { ...botListenerLoading, [botId]: true };
try {
// Load all command trackers and filter for ones that have this bot as a listener
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 {
const res = await api(`/telegram-bots/${botId}`, { method: 'PUT', body: JSON.stringify({ update_mode: mode }) });
await load();
if (mode === 'webhook') {
// Load webhook status after switching
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();
navigator.clipboard.writeText(chatId);
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;
}
// --- Email Bot state ---
let showEmailForm = $state(false);
let editingEmail = $state<number | null>(null);
let emailSubmitting = $state(false);
let emailTesting = $state<Record<number, boolean>>({});
let confirmDeleteEmail = $state<any>(null);
const defaultEmailForm = () => ({
name: '', icon: '', email: '', smtp_host: '', smtp_port: 587,
smtp_username: '', smtp_password: '', smtp_use_tls: true,
});
let emailForm = $state(defaultEmailForm());
function openNewEmail() { emailForm = defaultEmailForm(); editingEmail = null; showEmailForm = true; }
function editEmailBot(bot: EmailBot) {
emailForm = {
name: bot.name, icon: bot.icon || '', email: bot.email,
smtp_host: bot.smtp_host, smtp_port: bot.smtp_port,
smtp_username: bot.smtp_username, smtp_password: '',
smtp_use_tls: bot.smtp_use_tls,
};
editingEmail = bot.id; showEmailForm = true;
}
async function saveEmailBot(e: SubmitEvent) {
e.preventDefault(); error = ''; emailSubmitting = true;
try {
const body = { ...emailForm };
if (editingEmail) {
// Don't send empty password on update (means "keep current")
if (!body.smtp_password) delete (body as any).smtp_password;
await api(`/email-bots/${editingEmail}`, { method: 'PUT', body: JSON.stringify(body) });
snackSuccess(t('snack.emailBotUpdated'));
} else {
await api('/email-bots', { method: 'POST', body: JSON.stringify(body) });
snackSuccess(t('snack.emailBotCreated'));
}
emailForm = defaultEmailForm(); showEmailForm = false; editingEmail = null; await load();
} catch (err: any) { error = err.message; snackError(err.message); }
finally { emailSubmitting = false; }
}
function removeEmail(id: number) {
confirmDeleteEmail = {
id,
onconfirm: async () => {
try { await api(`/email-bots/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.emailBotDeleted')); }
catch (err: any) { error = err.message; snackError(err.message); }
finally { confirmDeleteEmail = null; }
}
};
}
async function testEmailBot(botId: number) {
emailTesting = { ...emailTesting, [botId]: true };
try {
const res = await api(`/email-bots/${botId}/test`, { method: 'POST' });
if (res.success) snackSuccess(t('snack.emailBotTestSent'));
else snackError(res.error || 'Failed');
} catch (err: any) { snackError(err.message); }
emailTesting = { ...emailTesting, [botId]: false };
}
// --- Matrix Bot state ---
let showMatrixForm = $state(false);
let editingMatrix = $state<number | null>(null);
let matrixSubmitting = $state(false);
let matrixTesting = $state<Record<number, boolean>>({});
let confirmDeleteMatrix = $state<any>(null);
const defaultMatrixForm = () => ({
name: '', icon: '', homeserver_url: '', access_token: '', display_name: '',
});
let matrixForm = $state(defaultMatrixForm());
function openNewMatrix() { matrixForm = defaultMatrixForm(); editingMatrix = null; showMatrixForm = true; }
function editMatrixBot(bot: MatrixBot) {
matrixForm = {
name: bot.name, icon: bot.icon || '',
homeserver_url: bot.homeserver_url, access_token: '',
display_name: bot.display_name || '',
};
editingMatrix = bot.id; showMatrixForm = true;
}
async function saveMatrixBot(e: SubmitEvent) {
e.preventDefault(); error = ''; matrixSubmitting = true;
try {
const body = { ...matrixForm };
if (editingMatrix && !body.access_token) delete (body as any).access_token;
if (editingMatrix) {
await api(`/matrix-bots/${editingMatrix}`, { method: 'PUT', body: JSON.stringify(body) });
snackSuccess(t('snack.matrixBotUpdated'));
} else {
await api('/matrix-bots', { method: 'POST', body: JSON.stringify(body) });
snackSuccess(t('snack.matrixBotCreated'));
}
matrixForm = defaultMatrixForm(); showMatrixForm = false; editingMatrix = null; await load();
} catch (err: any) { error = err.message; snackError(err.message); }
finally { matrixSubmitting = false; }
}
function removeMatrix(id: number) {
confirmDeleteMatrix = {
id,
onconfirm: async () => {
try { await api(`/matrix-bots/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.matrixBotDeleted')); }
catch (err: any) { error = err.message; snackError(err.message); }
finally { confirmDeleteMatrix = null; }
}
};
}
async function testMatrixBot(botId: number) {
matrixTesting = { ...matrixTesting, [botId]: true };
try {
const res = await api(`/matrix-bots/${botId}/test`, { method: 'POST' });
if (res.success) snackSuccess(t('snack.matrixBotTestOk'));
else snackError(res.error || 'Failed');
} catch (err: any) { snackError(err.message); }
matrixTesting = { ...matrixTesting, [botId]: false };
}
</script>
{#if !loaded}<Loading />{:else}
{#if activeTab === 'telegram'}
<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>
<TelegramBotTab {settings} onreload={load} />
{/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>
<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} />
{/if}
<!-- ======= Email Bots Section ======= -->
{#if activeTab === 'email'}
<PageHeader title={t('emailBot.title')} description={t('emailBot.description')}>
<button onclick={() => { showEmailForm ? (showEmailForm = false, editingEmail = null) : openNewEmail(); }}
class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
{showEmailForm ? t('common.cancel') : t('emailBot.addBot')}
</button>
</PageHeader>
{#if showEmailForm}
<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={saveEmailBot} class="space-y-3">
<div>
<label for="ebot-name" class="block text-sm font-medium mb-1">{t('emailBot.name')}</label>
<div class="flex gap-2">
<IconPicker value={emailForm.icon} onselect={(v: string) => emailForm.icon = v} />
<input id="ebot-name" bind:value={emailForm.name} required placeholder={t('emailBot.namePlaceholder')}
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
</div>
<div>
<label for="ebot-email" class="block text-sm font-medium mb-1">{t('emailBot.email')}</label>
<input id="ebot-email" bind:value={emailForm.email} required type="email" placeholder="notify@example.com"
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label for="ebot-host" class="block text-sm font-medium mb-1">{t('emailBot.smtpHost')}</label>
<input id="ebot-host" bind:value={emailForm.smtp_host} required placeholder="smtp.gmail.com"
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="ebot-port" class="block text-sm font-medium mb-1">{t('emailBot.smtpPort')}</label>
<input id="ebot-port" bind:value={emailForm.smtp_port} type="number" min="1" max="65535"
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label for="ebot-user" class="block text-sm font-medium mb-1">{t('emailBot.smtpUsername')}</label>
<input id="ebot-user" bind:value={emailForm.smtp_username} placeholder={t('emailBot.smtpUsernamePlaceholder')}
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="ebot-pass" class="block text-sm font-medium mb-1">{t('emailBot.smtpPassword')}</label>
<input id="ebot-pass" bind:value={emailForm.smtp_password} type="password" placeholder={editingEmail ? t('emailBot.passwordUnchanged') : ''}
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
</div>
<label class="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" bind:checked={emailForm.smtp_use_tls} />
{t('emailBot.useTls')}
</label>
<button type="submit" disabled={emailSubmitting}
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">
{emailSubmitting ? t('common.loading') : (editingEmail ? t('common.save') : t('emailBot.addBot'))}
</button>
</form>
</Card>
<EmailBotTab onreload={load} />
{/if}
{#if emailBots.length === 0 && !showEmailForm}
<Card>
<EmptyState icon="mdiEmailOutline" message={t('emailBot.noBots')} />
</Card>
{:else}
<div class="space-y-3 stagger-children">
{#each emailBots 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 || 'mdiEmailOutline'} size={20} /></span>
<p class="font-medium">{bot.name}</p>
</div>
<div class="flex items-center gap-2 mt-1 flex-wrap">
<span class="text-xs text-[var(--color-muted-foreground)] font-mono">{bot.email}</span>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{bot.smtp_host}:{bot.smtp_port}</span>
{#if bot.smtp_use_tls}
<span class="text-xs px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-500">TLS</span>
{/if}
</div>
</div>
<div class="flex items-center gap-1">
<IconButton icon="mdiEmailSend" title={t('emailBot.testConnection')} onclick={() => testEmailBot(bot.id)} disabled={emailTesting[bot.id]} />
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => editEmailBot(bot)} />
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => removeEmail(bot.id)} variant="danger" />
</div>
</div>
</Card>
{/each}
</div>
{/if}
<ConfirmModal open={confirmDeleteEmail !== null} message={t('emailBot.confirmDelete')}
onconfirm={() => confirmDeleteEmail?.onconfirm()} oncancel={() => confirmDeleteEmail = null} />
{/if}
<!-- ======= Matrix Bots Section ======= -->
{#if activeTab === 'matrix'}
<PageHeader title={t('matrixBot.title')} description={t('matrixBot.description')}>
<button onclick={() => { showMatrixForm ? (showMatrixForm = false, editingMatrix = null) : openNewMatrix(); }}
class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
{showMatrixForm ? t('common.cancel') : t('matrixBot.addBot')}
</button>
</PageHeader>
{#if showMatrixForm}
<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={saveMatrixBot} class="space-y-3">
<div>
<label for="mbot-name" class="block text-sm font-medium mb-1">{t('matrixBot.name')}</label>
<div class="flex gap-2">
<IconPicker value={matrixForm.icon} onselect={(v: string) => matrixForm.icon = v} />
<input id="mbot-name" bind:value={matrixForm.name} required placeholder={t('matrixBot.namePlaceholder')}
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
</div>
<div>
<label for="mbot-hs" class="block text-sm font-medium mb-1">{t('matrixBot.homeserverUrl')}</label>
<input id="mbot-hs" bind:value={matrixForm.homeserver_url} required placeholder="https://matrix.org"
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="mbot-token" class="block text-sm font-medium mb-1">{t('matrixBot.accessToken')}</label>
<input id="mbot-token" bind:value={matrixForm.access_token} type="password"
required={!editingMatrix}
placeholder={editingMatrix ? t('matrixBot.tokenUnchanged') : t('matrixBot.tokenPlaceholder')}
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)] font-mono" />
</div>
<div>
<label for="mbot-display" class="block text-sm font-medium mb-1">{t('matrixBot.displayName')}</label>
<input id="mbot-display" bind:value={matrixForm.display_name} placeholder="Notify Bridge"
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<button type="submit" disabled={matrixSubmitting}
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">
{matrixSubmitting ? t('common.loading') : (editingMatrix ? t('common.save') : t('matrixBot.addBot'))}
</button>
</form>
</Card>
{/if}
{#if matrixBots.length === 0 && !showMatrixForm}
<Card>
<EmptyState icon="mdiMatrix" message={t('matrixBot.noBots')} />
</Card>
{:else}
<div class="space-y-3 stagger-children">
{#each matrixBots 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 || 'mdiMatrix'} size={20} /></span>
<p class="font-medium">{bot.name}</p>
</div>
<div class="flex items-center gap-2 mt-1 flex-wrap">
<span class="text-xs text-[var(--color-muted-foreground)] font-mono">{bot.homeserver_url}</span>
{#if bot.display_name}
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{bot.display_name}</span>
{/if}
</div>
</div>
<div class="flex items-center gap-1">
<IconButton icon="mdiConnection" title={t('matrixBot.testConnection')} onclick={() => testMatrixBot(bot.id)} disabled={matrixTesting[bot.id]} />
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => editMatrixBot(bot)} />
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => removeMatrix(bot.id)} variant="danger" />
</div>
</div>
</Card>
{/each}
</div>
{/if}
<ConfirmModal open={confirmDeleteMatrix !== null} message={t('matrixBot.confirmDelete')}
onconfirm={() => confirmDeleteMatrix?.onconfirm()} oncancel={() => confirmDeleteMatrix = null} />
<MatrixBotTab onreload={load} />
{/if}
{/if}
+175
View File
@@ -0,0 +1,175 @@
<script lang="ts">
import { api } from '$lib/api';
import { t } from '$lib/i18n';
import { emailBotsCache } 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 } from '$lib/stores/snackbar.svelte';
import type { EmailBot } from '$lib/types';
let { onreload }: { onreload: () => Promise<void> } = $props();
let emailBots = $derived(emailBotsCache.items);
let showEmailForm = $state(false);
let editingEmail = $state<number | null>(null);
let emailSubmitting = $state(false);
let emailTesting = $state<Record<number, boolean>>({});
let confirmDeleteEmail = $state<any>(null);
let error = $state('');
const defaultEmailForm = () => ({
name: '', icon: '', email: '', smtp_host: '', smtp_port: 587,
smtp_username: '', smtp_password: '', smtp_use_tls: true,
});
let emailForm = $state(defaultEmailForm());
function openNewEmail() { emailForm = defaultEmailForm(); editingEmail = null; showEmailForm = true; }
function editEmailBot(bot: EmailBot) {
emailForm = {
name: bot.name, icon: bot.icon || '', email: bot.email,
smtp_host: bot.smtp_host, smtp_port: bot.smtp_port,
smtp_username: bot.smtp_username, smtp_password: '',
smtp_use_tls: bot.smtp_use_tls,
};
editingEmail = bot.id; showEmailForm = true;
}
async function saveEmailBot(e: SubmitEvent) {
e.preventDefault(); error = ''; emailSubmitting = true;
try {
const body = { ...emailForm };
if (editingEmail) {
if (!body.smtp_password) delete (body as any).smtp_password;
await api(`/email-bots/${editingEmail}`, { method: 'PUT', body: JSON.stringify(body) });
snackSuccess(t('snack.emailBotUpdated'));
} else {
await api('/email-bots', { method: 'POST', body: JSON.stringify(body) });
snackSuccess(t('snack.emailBotCreated'));
}
emailForm = defaultEmailForm(); showEmailForm = false; editingEmail = null; await onreload();
} catch (err: any) { error = err.message; snackError(err.message); }
finally { emailSubmitting = false; }
}
function removeEmail(id: number) {
confirmDeleteEmail = {
id,
onconfirm: async () => {
try { await api(`/email-bots/${id}`, { method: 'DELETE' }); await onreload(); snackSuccess(t('snack.emailBotDeleted')); }
catch (err: any) { error = err.message; snackError(err.message); }
finally { confirmDeleteEmail = null; }
}
};
}
async function testEmailBot(botId: number) {
emailTesting = { ...emailTesting, [botId]: true };
try {
const res = await api(`/email-bots/${botId}/test`, { method: 'POST' });
if (res.success) snackSuccess(t('snack.emailBotTestSent'));
else snackError(res.error || 'Failed');
} catch (err: any) { snackError(err.message); }
emailTesting = { ...emailTesting, [botId]: false };
}
</script>
<PageHeader title={t('emailBot.title')} description={t('emailBot.description')}>
<button onclick={() => { showEmailForm ? (showEmailForm = false, editingEmail = null) : openNewEmail(); }}
class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
{showEmailForm ? t('common.cancel') : t('emailBot.addBot')}
</button>
</PageHeader>
{#if showEmailForm}
<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={saveEmailBot} class="space-y-3">
<div>
<label for="ebot-name" class="block text-sm font-medium mb-1">{t('emailBot.name')}</label>
<div class="flex gap-2">
<IconPicker value={emailForm.icon} onselect={(v: string) => emailForm.icon = v} />
<input id="ebot-name" bind:value={emailForm.name} required placeholder={t('emailBot.namePlaceholder')}
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
</div>
<div>
<label for="ebot-email" class="block text-sm font-medium mb-1">{t('emailBot.email')}</label>
<input id="ebot-email" bind:value={emailForm.email} required type="email" placeholder="notify@example.com"
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label for="ebot-host" class="block text-sm font-medium mb-1">{t('emailBot.smtpHost')}</label>
<input id="ebot-host" bind:value={emailForm.smtp_host} required placeholder="smtp.gmail.com"
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="ebot-port" class="block text-sm font-medium mb-1">{t('emailBot.smtpPort')}</label>
<input id="ebot-port" bind:value={emailForm.smtp_port} type="number" min="1" max="65535"
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label for="ebot-user" class="block text-sm font-medium mb-1">{t('emailBot.smtpUsername')}</label>
<input id="ebot-user" bind:value={emailForm.smtp_username} placeholder={t('emailBot.smtpUsernamePlaceholder')}
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="ebot-pass" class="block text-sm font-medium mb-1">{t('emailBot.smtpPassword')}</label>
<input id="ebot-pass" bind:value={emailForm.smtp_password} type="password" placeholder={editingEmail ? t('emailBot.passwordUnchanged') : ''}
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
</div>
<label class="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" bind:checked={emailForm.smtp_use_tls} />
{t('emailBot.useTls')}
</label>
<button type="submit" disabled={emailSubmitting}
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">
{emailSubmitting ? t('common.loading') : (editingEmail ? t('common.save') : t('emailBot.addBot'))}
</button>
</form>
</Card>
{/if}
{#if emailBots.length === 0 && !showEmailForm}
<Card>
<EmptyState icon="mdiEmailOutline" message={t('emailBot.noBots')} />
</Card>
{:else}
<div class="space-y-3 stagger-children">
{#each emailBots 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 || 'mdiEmailOutline'} size={20} /></span>
<p class="font-medium">{bot.name}</p>
</div>
<div class="flex items-center gap-2 mt-1 flex-wrap">
<span class="text-xs text-[var(--color-muted-foreground)] font-mono">{bot.email}</span>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{bot.smtp_host}:{bot.smtp_port}</span>
{#if bot.smtp_use_tls}
<span class="text-xs px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-500">TLS</span>
{/if}
</div>
</div>
<div class="flex items-center gap-1">
<IconButton icon="mdiEmailSend" title={t('emailBot.testConnection')} onclick={() => testEmailBot(bot.id)} disabled={emailTesting[bot.id]} />
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => editEmailBot(bot)} />
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => removeEmail(bot.id)} variant="danger" />
</div>
</div>
</Card>
{/each}
</div>
{/if}
<ConfirmModal open={confirmDeleteEmail !== null} message={t('emailBot.confirmDelete')}
onconfirm={() => confirmDeleteEmail?.onconfirm()} oncancel={() => confirmDeleteEmail = null} />
@@ -0,0 +1,156 @@
<script lang="ts">
import { api } from '$lib/api';
import { t } from '$lib/i18n';
import { matrixBotsCache } 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 } from '$lib/stores/snackbar.svelte';
import type { MatrixBot } from '$lib/types';
let { onreload }: { onreload: () => Promise<void> } = $props();
let matrixBots = $derived(matrixBotsCache.items);
let showMatrixForm = $state(false);
let editingMatrix = $state<number | null>(null);
let matrixSubmitting = $state(false);
let matrixTesting = $state<Record<number, boolean>>({});
let confirmDeleteMatrix = $state<any>(null);
let error = $state('');
const defaultMatrixForm = () => ({
name: '', icon: '', homeserver_url: '', access_token: '', display_name: '',
});
let matrixForm = $state(defaultMatrixForm());
function openNewMatrix() { matrixForm = defaultMatrixForm(); editingMatrix = null; showMatrixForm = true; }
function editMatrixBot(bot: MatrixBot) {
matrixForm = {
name: bot.name, icon: bot.icon || '',
homeserver_url: bot.homeserver_url, access_token: '',
display_name: bot.display_name || '',
};
editingMatrix = bot.id; showMatrixForm = true;
}
async function saveMatrixBot(e: SubmitEvent) {
e.preventDefault(); error = ''; matrixSubmitting = true;
try {
const body = { ...matrixForm };
if (editingMatrix && !body.access_token) delete (body as any).access_token;
if (editingMatrix) {
await api(`/matrix-bots/${editingMatrix}`, { method: 'PUT', body: JSON.stringify(body) });
snackSuccess(t('snack.matrixBotUpdated'));
} else {
await api('/matrix-bots', { method: 'POST', body: JSON.stringify(body) });
snackSuccess(t('snack.matrixBotCreated'));
}
matrixForm = defaultMatrixForm(); showMatrixForm = false; editingMatrix = null; await onreload();
} catch (err: any) { error = err.message; snackError(err.message); }
finally { matrixSubmitting = false; }
}
function removeMatrix(id: number) {
confirmDeleteMatrix = {
id,
onconfirm: async () => {
try { await api(`/matrix-bots/${id}`, { method: 'DELETE' }); await onreload(); snackSuccess(t('snack.matrixBotDeleted')); }
catch (err: any) { error = err.message; snackError(err.message); }
finally { confirmDeleteMatrix = null; }
}
};
}
async function testMatrixBot(botId: number) {
matrixTesting = { ...matrixTesting, [botId]: true };
try {
const res = await api(`/matrix-bots/${botId}/test`, { method: 'POST' });
if (res.success) snackSuccess(t('snack.matrixBotTestOk'));
else snackError(res.error || 'Failed');
} catch (err: any) { snackError(err.message); }
matrixTesting = { ...matrixTesting, [botId]: false };
}
</script>
<PageHeader title={t('matrixBot.title')} description={t('matrixBot.description')}>
<button onclick={() => { showMatrixForm ? (showMatrixForm = false, editingMatrix = null) : openNewMatrix(); }}
class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
{showMatrixForm ? t('common.cancel') : t('matrixBot.addBot')}
</button>
</PageHeader>
{#if showMatrixForm}
<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={saveMatrixBot} class="space-y-3">
<div>
<label for="mbot-name" class="block text-sm font-medium mb-1">{t('matrixBot.name')}</label>
<div class="flex gap-2">
<IconPicker value={matrixForm.icon} onselect={(v: string) => matrixForm.icon = v} />
<input id="mbot-name" bind:value={matrixForm.name} required placeholder={t('matrixBot.namePlaceholder')}
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
</div>
<div>
<label for="mbot-hs" class="block text-sm font-medium mb-1">{t('matrixBot.homeserverUrl')}</label>
<input id="mbot-hs" bind:value={matrixForm.homeserver_url} required placeholder="https://matrix.org"
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="mbot-token" class="block text-sm font-medium mb-1">{t('matrixBot.accessToken')}</label>
<input id="mbot-token" bind:value={matrixForm.access_token} type="password"
required={!editingMatrix}
placeholder={editingMatrix ? t('matrixBot.tokenUnchanged') : t('matrixBot.tokenPlaceholder')}
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)] font-mono" />
</div>
<div>
<label for="mbot-display" class="block text-sm font-medium mb-1">{t('matrixBot.displayName')}</label>
<input id="mbot-display" bind:value={matrixForm.display_name} placeholder="Notify Bridge"
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<button type="submit" disabled={matrixSubmitting}
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">
{matrixSubmitting ? t('common.loading') : (editingMatrix ? t('common.save') : t('matrixBot.addBot'))}
</button>
</form>
</Card>
{/if}
{#if matrixBots.length === 0 && !showMatrixForm}
<Card>
<EmptyState icon="mdiMatrix" message={t('matrixBot.noBots')} />
</Card>
{:else}
<div class="space-y-3 stagger-children">
{#each matrixBots 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 || 'mdiMatrix'} size={20} /></span>
<p class="font-medium">{bot.name}</p>
</div>
<div class="flex items-center gap-2 mt-1 flex-wrap">
<span class="text-xs text-[var(--color-muted-foreground)] font-mono">{bot.homeserver_url}</span>
{#if bot.display_name}
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{bot.display_name}</span>
{/if}
</div>
</div>
<div class="flex items-center gap-1">
<IconButton icon="mdiConnection" title={t('matrixBot.testConnection')} onclick={() => testMatrixBot(bot.id)} disabled={matrixTesting[bot.id]} />
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => editMatrixBot(bot)} />
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => removeMatrix(bot.id)} variant="danger" />
</div>
</div>
</Card>
{/each}
</div>
{/if}
<ConfirmModal open={confirmDeleteMatrix !== null} message={t('matrixBot.confirmDelete')}
onconfirm={() => confirmDeleteMatrix?.onconfirm()} oncancel={() => confirmDeleteMatrix = null} />
@@ -0,0 +1,419 @@
<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();
navigator.clipboard.writeText(chatId);
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>
<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} />
@@ -263,7 +263,7 @@
<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="mdiRobot" size={14} />
<CrossLink href="/bots" icon="mdiRobot" label={listener.name || listener.listener_type} entityId={listener.listener_id} />
<CrossLink href="/bots?tab=telegram" icon="mdiRobot" label={listener.name || listener.listener_type} entityId={listener.listener_id} />
<span class="text-xs px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-500 font-mono">{listener.listener_type}</span>
</div>
<IconButton icon="mdiClose" title={t('commandTracker.removeListener')} size={14}
+47 -228
View File
@@ -6,6 +6,7 @@
import { t, getLocale, setLocale } from '$lib/i18n';
import { initTheme, getTheme, setTheme, type Theme } from '$lib/theme.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import AuthLayout from '$lib/components/AuthLayout.svelte';
const theme = getTheme();
let username = $state('');
@@ -37,235 +38,53 @@
}
</script>
<div class="auth-page">
<!-- Animated gradient mesh background -->
<div class="auth-bg"></div>
<div class="auth-grid"></div>
<!-- Login card -->
<div class="auth-card-wrapper" class:visible={mounted}>
<div class="auth-card">
<!-- Controls -->
<div class="flex justify-end gap-1.5 mb-6">
<button onclick={() => { setLocale(getLocale() === 'en' ? 'ru' : 'en'); }}
class="auth-control-btn">
{getLocale().toUpperCase()}
</button>
<button onclick={() => { const o: Theme[] = ['light','dark','system']; setTheme(o[(o.indexOf(theme.current)+1)%3]); }}
class="auth-control-btn">
<MdiIcon name={theme.resolved === 'dark' ? 'mdiWeatherNight' : 'mdiWeatherSunny'} size={13} />
</button>
</div>
<!-- Logo / title -->
<div class="text-center mb-8">
<div class="auth-logo-icon">
<MdiIcon name="mdiLan" size={28} />
</div>
<h1 class="text-xl font-semibold mt-4 tracking-tight">
<span style="color: var(--color-primary);">Notify</span> Bridge
</h1>
<p class="text-sm mt-1" style="color: var(--color-muted-foreground);">{t('auth.signInTitle')}</p>
</div>
{#if error}
<div class="auth-error animate-fade-slide-in">
<MdiIcon name="mdiAlertCircle" size={16} />
{error}
</div>
{/if}
<form onsubmit={handleSubmit} class="space-y-4">
<div>
<label for="username" class="auth-label">{t('auth.username')}</label>
<input id="username" type="text" bind:value={username} required
class="auth-input" placeholder="admin" />
</div>
<div>
<label for="password" class="auth-label">{t('auth.password')}</label>
<input id="password" type="password" bind:value={password} required
class="auth-input" />
</div>
<button type="submit" disabled={submitting} class="auth-submit">
{#if submitting}
<div class="w-4 h-4 rounded-full border-2 border-current border-t-transparent animate-spin"></div>
{/if}
{submitting ? t('auth.signingIn') : t('auth.signIn')}
</button>
</form>
</div>
<AuthLayout visible={mounted}>
<!-- Controls -->
<div class="flex justify-end gap-1.5 mb-6">
<button onclick={() => { setLocale(getLocale() === 'en' ? 'ru' : 'en'); }}
class="auth-control-btn">
{getLocale().toUpperCase()}
</button>
<button onclick={() => { const o: Theme[] = ['light','dark','system']; setTheme(o[(o.indexOf(theme.current)+1)%3]); }}
class="auth-control-btn">
<MdiIcon name={theme.resolved === 'dark' ? 'mdiWeatherNight' : 'mdiWeatherSunny'} size={13} />
</button>
</div>
</div>
<style>
.auth-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
background: var(--color-background);
}
<!-- Logo / title -->
<div class="text-center mb-8">
<div class="auth-logo-icon">
<MdiIcon name="mdiLan" size={28} />
</div>
<h1 class="text-xl font-semibold mt-4 tracking-tight">
<span style="color: var(--color-primary);">Notify</span> Bridge
</h1>
<p class="text-sm mt-1" style="color: var(--color-muted-foreground);">{t('auth.signInTitle')}</p>
</div>
.auth-bg {
position: absolute;
inset: 0;
z-index: 0;
background:
radial-gradient(ellipse 80% 60% at 20% 30%, var(--color-glow-strong), transparent 60%),
radial-gradient(ellipse 60% 80% at 80% 70%, rgba(99, 102, 241, 0.08), transparent 60%),
radial-gradient(ellipse 50% 50% at 50% 50%, var(--color-glow), transparent 70%);
animation: gradientShift 12s ease-in-out infinite;
background-size: 200% 200%;
}
{#if error}
<div class="auth-error animate-fade-slide-in">
<MdiIcon name="mdiAlertCircle" size={16} />
{error}
</div>
{/if}
.auth-grid {
position: absolute;
inset: 0;
z-index: 0;
opacity: 0.3;
background-image: radial-gradient(circle at 1px 1px, var(--color-border) 0.5px, transparent 0);
background-size: 32px 32px;
}
.auth-card-wrapper {
position: relative;
z-index: 1;
width: 100%;
max-width: 24rem;
padding: 1rem;
opacity: 0;
transform: translateY(16px) scale(0.98);
transition: opacity 0.5s ease-out, transform 0.5s ease-out;
}
.auth-card-wrapper.visible {
opacity: 1;
transform: translateY(0) scale(1);
}
.auth-card {
background: var(--color-card);
border: 1px solid var(--color-border);
border-radius: 1rem;
padding: 2rem;
box-shadow:
0 4px 24px rgba(0, 0, 0, 0.08),
0 0 0 1px rgba(255, 255, 255, 0.05) inset;
backdrop-filter: blur(8px);
}
:global([data-theme="dark"]) .auth-card {
box-shadow:
0 4px 24px rgba(0, 0, 0, 0.3),
0 0 48px var(--color-glow),
0 0 0 1px rgba(255, 255, 255, 0.03) inset;
}
.auth-logo-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 3.5rem;
height: 3.5rem;
border-radius: 1rem;
background: var(--color-primary);
color: var(--color-primary-foreground);
box-shadow: 0 0 24px var(--color-glow-strong);
}
.auth-control-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 0.25rem 0.625rem;
border-radius: 0.5rem;
font-size: 0.7rem;
font-weight: 500;
background: var(--color-muted);
color: var(--color-muted-foreground);
border: none;
cursor: pointer;
transition: all 0.2s ease;
}
.auth-control-btn:hover {
color: var(--color-foreground);
box-shadow: 0 0 8px var(--color-glow);
}
.auth-label {
display: block;
font-size: 0.8rem;
font-weight: 500;
margin-bottom: 0.375rem;
color: var(--color-foreground);
}
.auth-input {
width: 100%;
padding: 0.625rem 0.875rem;
border: 1px solid var(--color-border);
border-radius: 0.625rem;
font-size: 0.875rem;
background: var(--color-background);
color: var(--color-foreground);
transition: border-color 0.2s, box-shadow 0.2s;
}
.auth-input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-glow), 0 0 16px var(--color-glow);
}
.auth-error {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-radius: 0.625rem;
font-size: 0.8rem;
margin-bottom: 1rem;
background: var(--color-error-bg);
color: var(--color-error-fg);
}
.auth-submit {
width: 100%;
padding: 0.625rem;
border-radius: 0.625rem;
font-size: 0.875rem;
font-weight: 600;
background: var(--color-primary);
color: var(--color-primary-foreground);
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
transition: all 0.2s ease;
}
.auth-submit:hover:not(:disabled) {
box-shadow: 0 0 24px var(--color-glow-strong);
transform: translateY(-1px);
}
.auth-submit:active:not(:disabled) {
transform: translateY(0);
}
.auth-submit:disabled {
opacity: 0.6;
cursor: not-allowed;
}
@keyframes gradientShift {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
</style>
<form onsubmit={handleSubmit} class="space-y-4">
<div>
<label for="username" class="auth-label">{t('auth.username')}</label>
<input id="username" type="text" bind:value={username} required
class="auth-input" placeholder="admin" />
</div>
<div>
<label for="password" class="auth-label">{t('auth.password')}</label>
<input id="password" type="password" bind:value={password} required
class="auth-input" />
</div>
<button type="submit" disabled={submitting} class="auth-submit">
{#if submitting}
<div class="w-4 h-4 rounded-full border-2 border-current border-t-transparent animate-spin"></div>
{/if}
{submitting ? t('auth.signingIn') : t('auth.signIn')}
</button>
</form>
</AuthLayout>
@@ -1,24 +1,24 @@
<script lang="ts">
import { onMount } from 'svelte';
import { slide } from 'svelte/transition';
import { api } from '$lib/api';
import { t, getLocale } from '$lib/i18n';
import { providersCache, targetsCache, trackingConfigsCache, templateConfigsCache } from '$lib/stores/caches.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte';
import IconPicker from '$lib/components/IconPicker.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import Modal from '$lib/components/Modal.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import Hint from '$lib/components/Hint.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import CrossLink from '$lib/components/CrossLink.svelte';
import EntitySelect from '$lib/components/EntitySelect.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { highlightFromUrl } from '$lib/highlight';
import type { Tracker, ServiceProvider, NotificationTarget, TrackingConfig, TemplateConfig } from '$lib/types';
import type { Tracker, TrackingConfig, TemplateConfig } from '$lib/types';
import TrackerForm from './TrackerForm.svelte';
import LinkedTargetsSection from './LinkedTargetsSection.svelte';
import SharedLinkModal from './SharedLinkModal.svelte';
import TestMenu from './TestMenu.svelte';
let loaded = $state(false);
let loadError = $state('');
@@ -35,7 +35,6 @@
let submitting = $state(false);
let confirmDelete = $state<Tracker | null>(null);
let toggling = $state<Record<number, boolean>>({});
// Per tracker-target test state (keyed by `${ttId}_${testType}`)
let ttTesting = $state<Record<string, string>>({});
// Shared link validation
@@ -52,15 +51,26 @@
let form = $state(defaultForm());
let error = $state('');
// Linked targets management (inline in tracker detail)
// Linked targets management
let expandedTracker = $state<number | null>(null);
let linkedTargets = $state<Record<number, any[]>>({});
let addingTarget = $state<Record<number, boolean>>({});
let newLinkTargetId = $state<Record<number, number>>({});
let newLinkTrackingConfigId = $state<Record<number, number>>({});
let newLinkTemplateConfigId = $state<Record<number, number>>({});
// Test menu
let testMenuOpen = $state<string | null>(null);
let testMenuStyle = $state('');
const testTypes = [
{ key: 'basic', icon: 'mdiSend', labelKey: 'notificationTracker.testBasic' },
{ key: 'periodic', icon: 'mdiCalendarClock', labelKey: 'notificationTracker.testPeriodic' },
{ key: 'scheduled', icon: 'mdiImageMultiple', labelKey: 'notificationTracker.testScheduled' },
{ key: 'memory', icon: 'mdiHistory', labelKey: 'notificationTracker.testMemory' },
];
onMount(load);
async function load() {
loadError = '';
try {
@@ -74,12 +84,12 @@
snackError(loadError);
} finally { loaded = true; highlightFromUrl(); }
}
async function loadCollections() {
if (!form.provider_id) return;
try { collections = await api(`/providers/${form.provider_id}/collections`); } catch { collections = []; }
}
// Auto-load collections when provider changes via EntitySelect
let _prevProviderId = 0;
$effect(() => {
if (showForm && form.provider_id && form.provider_id !== _prevProviderId) {
@@ -89,6 +99,7 @@
});
function openNew() { form = defaultForm(); editing = null; showForm = true; collections = []; previousCollectionIds = []; }
async function edit(trk: any) {
form = {
name: trk.name, icon: trk.icon || '', provider_id: trk.provider_id,
@@ -104,7 +115,6 @@
e.preventDefault(); error = '';
if (submitting) return;
// Check shared links for newly added albums
const newAlbumIds = form.collection_ids.filter(id => !previousCollectionIds.includes(id));
if (newAlbumIds.length > 0 && form.provider_id) {
linkCheckLoading = true;
@@ -128,7 +138,7 @@
if (missingAlbums.length > 0) {
linkWarning = { albums: missingAlbums, providerId: form.provider_id };
linkCheckLoading = false;
return; // Show warning, don't save yet
return;
}
} catch { /* Proceed if check fails */ }
linkCheckLoading = false;
@@ -175,6 +185,7 @@
linkWarning = null;
await doSave();
}
async function toggle(tracker: any) {
if (toggling[tracker.id]) return;
toggling = { ...toggling, [tracker.id]: true };
@@ -184,7 +195,9 @@
snackSuccess(tracker.enabled ? t('snack.trackerPaused') : t('snack.trackerResumed'));
} catch (err: any) { snackError(err.message); } finally { toggling = { ...toggling, [tracker.id]: false }; }
}
function startDelete(tracker: any) { confirmDelete = tracker; }
async function doDelete() {
if (!confirmDelete) return;
try {
@@ -194,39 +207,13 @@
} catch (err: any) { error = err.message; snackError(err.message); }
confirmDelete = null;
}
let testMenuOpen = $state<string | null>(null);
let testMenuStyle = $state('');
const testTypes = [
{ key: 'basic', icon: 'mdiSend', labelKey: 'notificationTracker.testBasic' },
{ key: 'periodic', icon: 'mdiCalendarClock', labelKey: 'notificationTracker.testPeriodic' },
{ key: 'scheduled', icon: 'mdiImageMultiple', labelKey: 'notificationTracker.testScheduled' },
{ key: 'memory', icon: 'mdiHistory', labelKey: 'notificationTracker.testMemory' },
];
async function testTrackerTarget(trackerId: number, ttId: number, testType: string) {
testMenuOpen = null;
const key = `${ttId}_${testType}`;
if (ttTesting[key]) return;
ttTesting = { ...ttTesting, [key]: testType };
try {
await api(`/notification-trackers/${trackerId}/targets/${ttId}/test/${testType}?locale=${getLocale()}`, { method: 'POST' });
snackSuccess(t('snack.targetTestSent'));
} catch (err: any) {
snackError(err.message);
} finally {
ttTesting = { ...ttTesting, [key]: '' };
}
function toggleCollection(collectionId: string) {
form.collection_ids = form.collection_ids.includes(collectionId)
? form.collection_ids.filter(id => id !== collectionId)
: [...form.collection_ids, collectionId];
}
function openTestMenu(ttId: number, event: MouseEvent) {
const btn = event.currentTarget as HTMLElement;
const rect = btn.getBoundingClientRect();
testMenuStyle = `position:fixed; z-index:9999; top:${rect.bottom + 4}px; right:${window.innerWidth - rect.right}px;`;
testMenuOpen = testMenuOpen === String(ttId) ? null : String(ttId);
}
function toggleCollection(collectionId: string) { form.collection_ids = form.collection_ids.includes(collectionId) ? form.collection_ids.filter(id => id !== collectionId) : [...form.collection_ids, collectionId]; }
function formatDate(dateStr: string): string {
if (!dateStr) return '';
try {
@@ -235,11 +222,10 @@
} catch { return ''; }
}
// --- Linked Targets Management ---
// --- Linked Targets helpers ---
function toggleExpand(trackerId: number) {
if (expandedTracker === trackerId) { expandedTracker = null; return; }
expandedTracker = trackerId;
// tracker_targets already loaded in tracker response
}
function getProviderType(tracker: any): string {
@@ -301,6 +287,33 @@
await load();
} catch (err: any) { snackError(err.message); }
}
async function testTrackerTarget(trackerId: number, ttId: number, testType: string) {
testMenuOpen = null;
const key = `${ttId}_${testType}`;
if (ttTesting[key]) return;
ttTesting = { ...ttTesting, [key]: testType };
try {
await api(`/notification-trackers/${trackerId}/targets/${ttId}/test/${testType}?locale=${getLocale()}`, { method: 'POST' });
snackSuccess(t('snack.targetTestSent'));
} catch (err: any) {
snackError(err.message);
} finally {
ttTesting = { ...ttTesting, [key]: '' };
}
}
function openTestMenu(ttId: number, event: MouseEvent) {
const btn = event.currentTarget as HTMLElement;
const rect = btn.getBoundingClientRect();
testMenuStyle = `position:fixed; z-index:9999; top:${rect.bottom + 4}px; right:${window.innerWidth - rect.right}px;`;
testMenuOpen = testMenuOpen === String(ttId) ? null : String(ttId);
}
function handleTestFromMenu(ttId: number, testType: string) {
const trackerId = notificationTrackers.find(t => t.tracker_targets?.some((x: any) => String(x.id) === String(ttId)))?.id;
if (trackerId) testTrackerTarget(trackerId, ttId, testType);
}
</script>
<PageHeader title={t('notificationTracker.title')} description={t('notificationTracker.description')}>
@@ -317,58 +330,19 @@
<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3">{loadError}</div>
</Card>
{:else if showForm}
<div in:slide={{ duration: 200 }}>
<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={save} class="space-y-4">
<div>
<label for="trk-name" class="block text-sm font-medium mb-1">{t('notificationTracker.name')}</label>
<div class="flex gap-2">
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
<input id="trk-name" bind:value={form.name} required placeholder={t('notificationTracker.namePlaceholder')} class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
</div>
<div>
<label class="block text-sm font-medium mb-1">{t('notificationTracker.server')}</label>
<EntitySelect items={providerItems} bind:value={form.provider_id} placeholder={t('notificationTracker.selectServer')} />
</div>
{#if collections.length > 0}
<div>
<label class="block text-sm font-medium mb-1">{t('notificationTracker.albums')} ({collections.length})</label>
<input type="text" bind:value={collectionFilter} placeholder="Filter..."
class="w-full px-3 py-1.5 mb-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
<div class="max-h-56 overflow-y-auto border border-[var(--color-border)] rounded-md p-2 space-y-1">
{#each collections.filter(a => !collectionFilter || (a.albumName || a.name || '').toLowerCase().includes(collectionFilter.toLowerCase())) as col}
<label class="flex items-center justify-between text-sm cursor-pointer hover:bg-[var(--color-muted)] px-2 py-1 rounded">
<span class="flex items-center gap-2">
<input type="checkbox" checked={form.collection_ids.includes(col.id)} onchange={() => toggleCollection(col.id)} />
{col.albumName || col.name} <span class="text-[var(--color-muted-foreground)]">({col.assetCount ?? col.asset_count ?? 0})</span>
</span>
{#if col.updatedAt || col.updated_at}
<span class="text-xs text-[var(--color-muted-foreground)] whitespace-nowrap ml-2">{formatDate(col.updatedAt || col.updated_at)}</span>
{/if}
</label>
{/each}
</div>
</div>
{/if}
<div class="grid grid-cols-2 gap-3">
<div>
<label for="trk-interval" class="block text-sm font-medium mb-1">{t('notificationTracker.scanInterval')}<Hint text={t('hints.scanInterval')} /></label>
<input id="trk-interval" type="number" bind:value={form.scan_interval} min="10" max="3600" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="trk-batch" class="block text-sm font-medium mb-1">{t('notificationTracker.batchDuration')}<Hint text={t('hints.batchDuration')} /></label>
<input id="trk-batch" type="number" bind:value={form.batch_duration} min="0" max="3600" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
</div>
<button type="submit" disabled={submitting || linkCheckLoading} 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">
{#if linkCheckLoading}{t('notificationTracker.checkingLinks')}{:else}{editing ? t('common.save') : t('notificationTracker.createTracker')}{/if}
</button>
</form>
</Card>
</div>
<TrackerForm
bind:form
{providerItems}
{collections}
bind:collectionFilter
{editing}
{submitting}
{linkCheckLoading}
{error}
onsave={save}
ontoggleCollection={toggleCollection}
{formatDate}
/>
{/if}
{#if loaded && !loadError}
@@ -388,7 +362,7 @@
<span class="text-xs px-1.5 py-0.5 rounded {tracker.enabled ? 'bg-[var(--color-success-bg)] text-[var(--color-success-fg)]' : 'bg-[var(--color-muted)] text-[var(--color-muted-foreground)]'}">
{tracker.enabled ? t('notificationTracker.active') : t('notificationTracker.paused')}
</span>
t <CrossLink href="/providers" icon="mdiServer" label={getProviderName(tracker.provider_id)} entityId={tracker.provider_id} />
<CrossLink href="/providers" icon="mdiServer" label={getProviderName(tracker.provider_id)} entityId={tracker.provider_id} />
</div>
<p class="text-sm text-[var(--color-muted-foreground)]">
{(tracker.collection_ids || []).length} {t('notificationTracker.albums_count')} · {t('notificationTracker.every')} {tracker.scan_interval}s · {(tracker.tracker_targets || []).length} {t('notificationTracker.linkedTargets')}
@@ -406,76 +380,26 @@ t <CrossLink href="/providers" icon="mdiServer" label={getProviderName(trac
</div>
</div>
<!-- Linked Targets Section -->
{#if expandedTracker === tracker.id}
<div class="mt-3 border-t border-[var(--color-border)] pt-3 space-y-2" in:slide>
{#if (tracker.tracker_targets || []).length === 0}
<p class="text-xs text-[var(--color-muted-foreground)]">{t('notificationTracker.noLinkedTargets')}</p>
{:else}
{#each tracker.tracker_targets as tt}
<div class="flex items-center justify-between text-sm px-2 py-1.5 rounded bg-[var(--color-muted)]/30">
<div class="flex items-center gap-2">
<span style="color: var(--color-primary);"><MdiIcon name={tt.target_icon || (tt.target_type === 'telegram' ? 'mdiSend' : 'mdiWebhook')} size={16} /></span>
<span class="font-medium">{tt.target_name || `Target #${tt.target_id}`}</span>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{tt.target_type}</span>
{#if !tt.enabled}
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-warning-bg)] text-[var(--color-warning-fg)]">{t('notificationTracker.paused')}</span>
{/if}
</div>
<div class="flex items-center gap-2 flex-wrap justify-end">
<select value={tt.tracking_config_id || 0}
onchange={(e: Event) => updateTargetLink(tracker.id, tt, 'tracking_config_id', Number((e.target as HTMLSelectElement).value) || null)}
class="text-xs px-2 py-1 border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
<option value={0}> {t('trackingConfig.title')} —</option>
{#each configsForTracker(tracker, trackingConfigs) as tc}<option value={tc.id}>{tc.name}</option>{/each}
</select>
<select value={tt.template_config_id || 0}
onchange={(e: Event) => updateTargetLink(tracker.id, tt, 'template_config_id', Number((e.target as HTMLSelectElement).value) || null)}
class="text-xs px-2 py-1 border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
<option value={0}> {t('templateConfig.title')} —</option>
{#each configsForTracker(tracker, templateConfigs) as tc}<option value={tc.id}>{tc.name}</option>{/each}
</select>
<div class="relative">
<IconButton icon="mdiDotsVertical" size={14} title={t('common.test')}
onclick={(e: MouseEvent) => openTestMenu(tt.id, e)}
disabled={Object.keys(ttTesting).some(k => k.startsWith(`${tt.id}_`) && ttTesting[k])} />
</div>
<IconButton icon={tt.enabled ? 'mdiPause' : 'mdiPlay'} size={14}
title={tt.enabled ? t('notificationTracker.pause') : t('notificationTracker.resume')}
onclick={() => updateTargetLink(tracker.id, tt, 'enabled', !tt.enabled)} />
<IconButton icon="mdiClose" size={14} title={t('common.delete')}
onclick={() => removeTargetLink(tracker.id, tt.id)} variant="danger" />
</div>
</div>
{/each}
{/if}
<!-- Add target link -->
{#if getUnlinkedTargets(tracker).length > 0}
<div class="flex items-center gap-2 mt-2">
<select bind:value={newLinkTargetId[tracker.id]}
class="text-xs px-2 py-1 border border-[var(--color-border)] rounded-md bg-[var(--color-background)] flex-1">
<option value={0}> {t('notificationTracker.addTarget')} —</option>
{#each getUnlinkedTargets(tracker) as tgt}<option value={tgt.id}>{tgt.name} ({tgt.type})</option>{/each}
</select>
<select bind:value={newLinkTrackingConfigId[tracker.id]}
class="text-xs px-2 py-1 border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
<option value={0}> {t('trackingConfig.title')} —</option>
{#each configsForTracker(tracker, trackingConfigs) as tc}<option value={tc.id}>{tc.name}</option>{/each}
</select>
<select bind:value={newLinkTemplateConfigId[tracker.id]}
class="text-xs px-2 py-1 border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
<option value={0}> {t('templateConfig.title')} —</option>
{#each configsForTracker(tracker, templateConfigs) as tc}<option value={tc.id}>{tc.name}</option>{/each}
</select>
<button onclick={() => addTargetLink(tracker.id)}
disabled={!newLinkTargetId[tracker.id] || addingTarget[tracker.id]}
class="text-xs px-3 py-1 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded hover:opacity-90 disabled:opacity-50">
{t('common.add')}
</button>
</div>
{/if}
</div>
<LinkedTargetsSection
{tracker}
{trackingConfigs}
{templateConfigs}
unlinkedTargets={getUnlinkedTargets(tracker)}
newLinkTargetId={newLinkTargetId[tracker.id] || 0}
newLinkTrackingConfigId={newLinkTrackingConfigId[tracker.id] || 0}
newLinkTemplateConfigId={newLinkTemplateConfigId[tracker.id] || 0}
addingTarget={addingTarget[tracker.id] || false}
{ttTesting}
configsForTracker={(configs) => configsForTracker(tracker, configs)}
onupdateLink={(tt, field, value) => updateTargetLink(tracker.id, tt, field, value)}
onremoveLink={(ttId) => removeTargetLink(tracker.id, ttId)}
onaddLink={() => addTargetLink(tracker.id)}
onopenTestMenu={openTestMenu}
onchangeNewTarget={(v) => newLinkTargetId = { ...newLinkTargetId, [tracker.id]: v }}
onchangeNewTrackingConfig={(v) => newLinkTrackingConfigId = { ...newLinkTrackingConfigId, [tracker.id]: v }}
onchangeNewTemplateConfig={(v) => newLinkTemplateConfigId = { ...newLinkTemplateConfigId, [tracker.id]: v }}
/>
{/if}
</Card>
{/each}
@@ -483,61 +407,22 @@ t <CrossLink href="/providers" icon="mdiServer" label={getProviderName(trac
{/if}
{/if}
{#if testMenuOpen}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div style="position:fixed; top:0; left:0; right:0; bottom:0; z-index:9998;"
onclick={() => testMenuOpen = null}
onkeydown={(e) => { if (e.key === 'Escape') testMenuOpen = null; }}>
</div>
<div style="{testMenuStyle} background:var(--color-card); border:1px solid var(--color-border); border-radius:0.5rem; box-shadow:0 10px 25px rgba(0,0,0,0.3); padding:0.25rem; min-width:10rem;">
{#each testTypes as tt}
{@const trackerId = notificationTrackers.find(t => t.tracker_targets?.some((x: any) => String(x.id) === testMenuOpen))?.id}
<button
onclick={() => trackerId && testTrackerTarget(trackerId, Number(testMenuOpen), tt.key)}
disabled={!!ttTesting[`${testMenuOpen}_${tt.key}`]}
class="flex items-center gap-2 w-full px-3 py-1.5 text-sm rounded hover:bg-[var(--color-muted)] transition-colors disabled:opacity-50 text-left">
<MdiIcon name={tt.icon} size={14} />
{t(tt.labelKey)}
{#if ttTesting[`${testMenuOpen}_${tt.key}`]}
<span class="ml-auto text-xs text-[var(--color-muted-foreground)]">...</span>
{/if}
</button>
{/each}
</div>
{/if}
<TestMenu
{testMenuOpen}
{testMenuStyle}
{ttTesting}
{testTypes}
ontest={handleTestFromMenu}
onclose={() => testMenuOpen = null}
/>
<Modal open={linkWarning !== null} title={t('notificationTracker.missingLinksTitle')} onclose={() => { linkWarning = null; }}>
{#if linkWarning}
<p class="text-sm mb-3" style="color: var(--color-muted-foreground);">
{t('notificationTracker.missingLinksDesc')}
</p>
<div class="space-y-1.5 mb-4 max-h-40 overflow-y-auto">
{#each linkWarning.albums as album}
<div class="flex items-center justify-between text-sm px-2 py-1.5 rounded bg-[var(--color-muted)]/30">
<span class="font-medium">{album.name}</span>
<span class="text-xs px-1.5 py-0.5 rounded {album.issue === 'expired' ? 'bg-[var(--color-error-bg)] text-[var(--color-error-fg)]' : album.issue === 'password-protected' ? 'bg-[var(--color-warning-bg)] text-[var(--color-warning-fg)]' : 'bg-[var(--color-muted)] text-[var(--color-muted-foreground)]'}">
{album.issue === 'expired' ? t('notificationTracker.expired') : album.issue === 'password-protected' ? t('notificationTracker.passwordProtected') : t('notificationTracker.noLink')}
</span>
</div>
{/each}
</div>
<p class="text-xs mb-4" style="color: var(--color-muted-foreground);">
<MdiIcon name="mdiInformation" size={14} /> {t('notificationTracker.linksNote')}
</p>
<div class="flex items-center gap-2 justify-end">
<button onclick={dismissLinkWarning}
class="px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md hover:bg-[var(--color-muted)]">
{t('notificationTracker.saveWithoutLinks')}
</button>
{#if linkWarning.albums.some(a => a.issue === 'missing')}
<button onclick={autoCreateLinks} disabled={linkCreating}
class="px-3 py-1.5 text-sm bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md hover:opacity-90 disabled:opacity-50">
{linkCreating ? t('common.loading') : t('notificationTracker.createLinks').replace('{count}', String(linkWarning.albums.filter(a => a.issue === 'missing').length))}
</button>
{/if}
</div>
{/if}
</Modal>
<SharedLinkModal
{linkWarning}
{linkCreating}
onclose={() => { linkWarning = null; }}
onautoCreate={autoCreateLinks}
ondismiss={dismissLinkWarning}
/>
<ConfirmModal
open={!!confirmDelete}
@@ -0,0 +1,119 @@
<script lang="ts">
import { slide } from 'svelte/transition';
import { t } from '$lib/i18n';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import type { Tracker, NotificationTarget, TrackingConfig, TemplateConfig } from '$lib/types';
interface Props {
tracker: Tracker;
trackingConfigs: TrackingConfig[];
templateConfigs: TemplateConfig[];
unlinkedTargets: NotificationTarget[];
newLinkTargetId: number;
newLinkTrackingConfigId: number;
newLinkTemplateConfigId: number;
addingTarget: boolean;
ttTesting: Record<string, string>;
configsForTracker: (configs: (TrackingConfig | TemplateConfig)[]) => any[];
onupdateLink: (tt: any, field: string, value: any) => void;
onremoveLink: (ttId: number) => void;
onaddLink: () => void;
onopenTestMenu: (ttId: number, event: MouseEvent) => void;
onchangeNewTarget: (value: number) => void;
onchangeNewTrackingConfig: (value: number) => void;
onchangeNewTemplateConfig: (value: number) => void;
}
let {
tracker,
trackingConfigs,
templateConfigs,
unlinkedTargets,
newLinkTargetId,
newLinkTrackingConfigId,
newLinkTemplateConfigId,
addingTarget,
ttTesting,
configsForTracker,
onupdateLink,
onremoveLink,
onaddLink,
onopenTestMenu,
onchangeNewTarget,
onchangeNewTrackingConfig,
onchangeNewTemplateConfig,
}: Props = $props();
</script>
<div class="mt-3 border-t border-[var(--color-border)] pt-3 space-y-2" in:slide>
{#if (tracker.tracker_targets || []).length === 0}
<p class="text-xs text-[var(--color-muted-foreground)]">{t('notificationTracker.noLinkedTargets')}</p>
{:else}
{#each tracker.tracker_targets as tt}
<div class="flex items-center justify-between text-sm px-2 py-1.5 rounded bg-[var(--color-muted)]/30">
<div class="flex items-center gap-2">
<span style="color: var(--color-primary);"><MdiIcon name={tt.target_icon || (tt.target_type === 'telegram' ? 'mdiSend' : 'mdiWebhook')} size={16} /></span>
<span class="font-medium">{tt.target_name || `Target #${tt.target_id}`}</span>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{tt.target_type}</span>
{#if !tt.enabled}
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-warning-bg)] text-[var(--color-warning-fg)]">{t('notificationTracker.paused')}</span>
{/if}
</div>
<div class="flex items-center gap-2 flex-wrap justify-end">
<select value={tt.tracking_config_id || 0}
onchange={(e: Event) => onupdateLink(tt, 'tracking_config_id', Number((e.target as HTMLSelectElement).value) || null)}
class="text-xs px-2 py-1 border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
<option value={0}> {t('trackingConfig.title')} —</option>
{#each configsForTracker(trackingConfigs) as tc}<option value={tc.id}>{tc.name}</option>{/each}
</select>
<select value={tt.template_config_id || 0}
onchange={(e: Event) => onupdateLink(tt, 'template_config_id', Number((e.target as HTMLSelectElement).value) || null)}
class="text-xs px-2 py-1 border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
<option value={0}> {t('templateConfig.title')} —</option>
{#each configsForTracker(templateConfigs) as tc}<option value={tc.id}>{tc.name}</option>{/each}
</select>
<div class="relative">
<IconButton icon="mdiDotsVertical" size={14} title={t('common.test')}
onclick={(e: MouseEvent) => onopenTestMenu(tt.id, e)}
disabled={Object.keys(ttTesting).some(k => k.startsWith(`${tt.id}_`) && ttTesting[k])} />
</div>
<IconButton icon={tt.enabled ? 'mdiPause' : 'mdiPlay'} size={14}
title={tt.enabled ? t('notificationTracker.pause') : t('notificationTracker.resume')}
onclick={() => onupdateLink(tt, 'enabled', !tt.enabled)} />
<IconButton icon="mdiClose" size={14} title={t('common.delete')}
onclick={() => onremoveLink(tt.id)} variant="danger" />
</div>
</div>
{/each}
{/if}
<!-- Add target link -->
{#if unlinkedTargets.length > 0}
<div class="flex items-center gap-2 mt-2">
<select value={newLinkTargetId}
onchange={(e: Event) => onchangeNewTarget(Number((e.target as HTMLSelectElement).value))}
class="text-xs px-2 py-1 border border-[var(--color-border)] rounded-md bg-[var(--color-background)] flex-1">
<option value={0}> {t('notificationTracker.addTarget')} —</option>
{#each unlinkedTargets as tgt}<option value={tgt.id}>{tgt.name} ({tgt.type})</option>{/each}
</select>
<select value={newLinkTrackingConfigId}
onchange={(e: Event) => onchangeNewTrackingConfig(Number((e.target as HTMLSelectElement).value))}
class="text-xs px-2 py-1 border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
<option value={0}> {t('trackingConfig.title')} —</option>
{#each configsForTracker(trackingConfigs) as tc}<option value={tc.id}>{tc.name}</option>{/each}
</select>
<select value={newLinkTemplateConfigId}
onchange={(e: Event) => onchangeNewTemplateConfig(Number((e.target as HTMLSelectElement).value))}
class="text-xs px-2 py-1 border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
<option value={0}> {t('templateConfig.title')} —</option>
{#each configsForTracker(templateConfigs) as tc}<option value={tc.id}>{tc.name}</option>{/each}
</select>
<button onclick={onaddLink}
disabled={!newLinkTargetId || addingTarget}
class="text-xs px-3 py-1 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded hover:opacity-90 disabled:opacity-50">
{t('common.add')}
</button>
</div>
{/if}
</div>
@@ -0,0 +1,48 @@
<script lang="ts">
import { t } from '$lib/i18n';
import Modal from '$lib/components/Modal.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
interface Props {
linkWarning: { albums: any[]; providerId: number } | null;
linkCreating: boolean;
onclose: () => void;
onautoCreate: () => void;
ondismiss: () => void;
}
let { linkWarning, linkCreating, onclose, onautoCreate, ondismiss }: Props = $props();
</script>
<Modal open={linkWarning !== null} title={t('notificationTracker.missingLinksTitle')} onclose={onclose}>
{#if linkWarning}
<p class="text-sm mb-3" style="color: var(--color-muted-foreground);">
{t('notificationTracker.missingLinksDesc')}
</p>
<div class="space-y-1.5 mb-4 max-h-40 overflow-y-auto">
{#each linkWarning.albums as album}
<div class="flex items-center justify-between text-sm px-2 py-1.5 rounded bg-[var(--color-muted)]/30">
<span class="font-medium">{album.name}</span>
<span class="text-xs px-1.5 py-0.5 rounded {album.issue === 'expired' ? 'bg-[var(--color-error-bg)] text-[var(--color-error-fg)]' : album.issue === 'password-protected' ? 'bg-[var(--color-warning-bg)] text-[var(--color-warning-fg)]' : 'bg-[var(--color-muted)] text-[var(--color-muted-foreground)]'}">
{album.issue === 'expired' ? t('notificationTracker.expired') : album.issue === 'password-protected' ? t('notificationTracker.passwordProtected') : t('notificationTracker.noLink')}
</span>
</div>
{/each}
</div>
<p class="text-xs mb-4" style="color: var(--color-muted-foreground);">
<MdiIcon name="mdiInformation" size={14} /> {t('notificationTracker.linksNote')}
</p>
<div class="flex items-center gap-2 justify-end">
<button onclick={ondismiss}
class="px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md hover:bg-[var(--color-muted)]">
{t('notificationTracker.saveWithoutLinks')}
</button>
{#if linkWarning.albums.some(a => a.issue === 'missing')}
<button onclick={onautoCreate} disabled={linkCreating}
class="px-3 py-1.5 text-sm bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md hover:opacity-90 disabled:opacity-50">
{linkCreating ? t('common.loading') : t('notificationTracker.createLinks').replace('{count}', String(linkWarning.albums.filter(a => a.issue === 'missing').length))}
</button>
{/if}
</div>
{/if}
</Modal>
@@ -0,0 +1,37 @@
<script lang="ts">
import { t } from '$lib/i18n';
import MdiIcon from '$lib/components/MdiIcon.svelte';
interface Props {
testMenuOpen: string | null;
testMenuStyle: string;
ttTesting: Record<string, string>;
testTypes: { key: string; icon: string; labelKey: string }[];
ontest: (ttId: number, testType: string) => void;
onclose: () => void;
}
let { testMenuOpen, testMenuStyle, ttTesting, testTypes, ontest, onclose }: Props = $props();
</script>
{#if testMenuOpen}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div style="position:fixed; top:0; left:0; right:0; bottom:0; z-index:9998;"
onclick={onclose}
onkeydown={(e) => { if (e.key === 'Escape') onclose(); }}>
</div>
<div style="{testMenuStyle} background:var(--color-card); border:1px solid var(--color-border); border-radius:0.5rem; box-shadow:0 10px 25px rgba(0,0,0,0.3); padding:0.25rem; min-width:10rem;">
{#each testTypes as tt}
<button
onclick={() => ontest(Number(testMenuOpen), tt.key)}
disabled={!!ttTesting[`${testMenuOpen}_${tt.key}`]}
class="flex items-center gap-2 w-full px-3 py-1.5 text-sm rounded hover:bg-[var(--color-muted)] transition-colors disabled:opacity-50 text-left">
<MdiIcon name={tt.icon} size={14} />
{t(tt.labelKey)}
{#if ttTesting[`${testMenuOpen}_${tt.key}`]}
<span class="ml-auto text-xs text-[var(--color-muted-foreground)]">...</span>
{/if}
</button>
{/each}
</div>
{/if}
@@ -0,0 +1,96 @@
<script lang="ts">
import { slide } from 'svelte/transition';
import { t } from '$lib/i18n';
import Card from '$lib/components/Card.svelte';
import IconPicker from '$lib/components/IconPicker.svelte';
import Hint from '$lib/components/Hint.svelte';
import EntitySelect from '$lib/components/EntitySelect.svelte';
interface Props {
form: {
name: string;
icon: string;
provider_id: number;
collection_ids: string[];
scan_interval: number;
batch_duration: number;
};
providerItems: { value: number; label: string; icon: string; desc: string }[];
collections: any[];
collectionFilter: string;
editing: number | null;
submitting: boolean;
linkCheckLoading: boolean;
error: string;
onsave: (e: SubmitEvent) => void;
ontoggleCollection: (collectionId: string) => void;
formatDate: (dateStr: string) => string;
}
let {
form = $bindable(),
providerItems,
collections,
collectionFilter = $bindable(),
editing,
submitting,
linkCheckLoading,
error,
onsave,
ontoggleCollection,
formatDate,
}: Props = $props();
</script>
<div in:slide={{ duration: 200 }}>
<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={onsave} class="space-y-4">
<div>
<label for="trk-name" class="block text-sm font-medium mb-1">{t('notificationTracker.name')}</label>
<div class="flex gap-2">
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
<input id="trk-name" bind:value={form.name} required placeholder={t('notificationTracker.namePlaceholder')} class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
</div>
<div>
<label class="block text-sm font-medium mb-1">{t('notificationTracker.server')}</label>
<EntitySelect items={providerItems} bind:value={form.provider_id} placeholder={t('notificationTracker.selectServer')} />
</div>
{#if collections.length > 0}
<div>
<label class="block text-sm font-medium mb-1">{t('notificationTracker.albums')} ({collections.length})</label>
<input type="text" bind:value={collectionFilter} placeholder="Filter..."
class="w-full px-3 py-1.5 mb-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
<div class="max-h-56 overflow-y-auto border border-[var(--color-border)] rounded-md p-2 space-y-1">
{#each collections.filter(a => !collectionFilter || (a.albumName || a.name || '').toLowerCase().includes(collectionFilter.toLowerCase())) as col}
<label class="flex items-center justify-between text-sm cursor-pointer hover:bg-[var(--color-muted)] px-2 py-1 rounded">
<span class="flex items-center gap-2">
<input type="checkbox" checked={form.collection_ids.includes(col.id)} onchange={() => ontoggleCollection(col.id)} />
{col.albumName || col.name} <span class="text-[var(--color-muted-foreground)]">({col.assetCount ?? col.asset_count ?? 0})</span>
</span>
{#if col.updatedAt || col.updated_at}
<span class="text-xs text-[var(--color-muted-foreground)] whitespace-nowrap ml-2">{formatDate(col.updatedAt || col.updated_at)}</span>
{/if}
</label>
{/each}
</div>
</div>
{/if}
<div class="grid grid-cols-2 gap-3">
<div>
<label for="trk-interval" class="block text-sm font-medium mb-1">{t('notificationTracker.scanInterval')}<Hint text={t('hints.scanInterval')} /></label>
<input id="trk-interval" type="number" bind:value={form.scan_interval} min="10" max="3600" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="trk-batch" class="block text-sm font-medium mb-1">{t('notificationTracker.batchDuration')}<Hint text={t('hints.batchDuration')} /></label>
<input id="trk-batch" type="number" bind:value={form.batch_duration} min="0" max="3600" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
</div>
<button type="submit" disabled={submitting || linkCheckLoading} 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">
{#if linkCheckLoading}{t('notificationTracker.checkingLinks')}{:else}{editing ? t('common.save') : t('notificationTracker.createTracker')}{/if}
</button>
</form>
</Card>
</div>
+36 -194
View File
@@ -5,6 +5,7 @@
import { t } from '$lib/i18n';
import { initTheme } from '$lib/theme.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import AuthLayout from '$lib/components/AuthLayout.svelte';
let username = $state('admin');
let password = $state('');
@@ -29,201 +30,42 @@
}
</script>
<div class="auth-page">
<div class="auth-bg"></div>
<div class="auth-grid"></div>
<div class="auth-card-wrapper" class:visible={mounted}>
<div class="auth-card">
<div class="text-center mb-8">
<div class="auth-logo-icon">
<MdiIcon name="mdiShieldAccount" size={28} />
</div>
<h1 class="text-xl font-semibold mt-4 tracking-tight">
<span style="color: var(--color-primary);">Notify</span> Bridge
</h1>
<p class="text-sm mt-1" style="color: var(--color-muted-foreground);">{t('auth.setupDescription')}</p>
</div>
{#if error}
<div class="auth-error animate-fade-slide-in">
<MdiIcon name="mdiAlertCircle" size={16} />
{error}
</div>
{/if}
<form onsubmit={handleSubmit} class="space-y-4">
<div>
<label for="username" class="auth-label">{t('auth.username')}</label>
<input id="username" type="text" bind:value={username} required class="auth-input" />
</div>
<div>
<label for="password" class="auth-label">{t('auth.password')}</label>
<input id="password" type="password" bind:value={password} required class="auth-input" />
</div>
<div>
<label for="confirm" class="auth-label">{t('auth.confirmPassword')}</label>
<input id="confirm" type="password" bind:value={confirmPassword} required class="auth-input" />
</div>
<button type="submit" disabled={submitting} class="auth-submit">
{#if submitting}
<div class="w-4 h-4 rounded-full border-2 border-current border-t-transparent animate-spin"></div>
{/if}
{submitting ? t('auth.creatingAccount') : t('auth.createAccount')}
</button>
</form>
<AuthLayout visible={mounted}>
<div class="text-center mb-8">
<div class="auth-logo-icon">
<MdiIcon name="mdiShieldAccount" size={28} />
</div>
<h1 class="text-xl font-semibold mt-4 tracking-tight">
<span style="color: var(--color-primary);">Notify</span> Bridge
</h1>
<p class="text-sm mt-1" style="color: var(--color-muted-foreground);">{t('auth.setupDescription')}</p>
</div>
</div>
<style>
.auth-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
background: var(--color-background);
}
{#if error}
<div class="auth-error animate-fade-slide-in">
<MdiIcon name="mdiAlertCircle" size={16} />
{error}
</div>
{/if}
.auth-bg {
position: absolute;
inset: 0;
z-index: 0;
background:
radial-gradient(ellipse 80% 60% at 20% 30%, var(--color-glow-strong), transparent 60%),
radial-gradient(ellipse 60% 80% at 80% 70%, rgba(99, 102, 241, 0.08), transparent 60%),
radial-gradient(ellipse 50% 50% at 50% 50%, var(--color-glow), transparent 70%);
animation: gradientShift 12s ease-in-out infinite;
background-size: 200% 200%;
}
.auth-grid {
position: absolute;
inset: 0;
z-index: 0;
opacity: 0.3;
background-image: radial-gradient(circle at 1px 1px, var(--color-border) 0.5px, transparent 0);
background-size: 32px 32px;
}
.auth-card-wrapper {
position: relative;
z-index: 1;
width: 100%;
max-width: 24rem;
padding: 1rem;
opacity: 0;
transform: translateY(16px) scale(0.98);
transition: opacity 0.5s ease-out, transform 0.5s ease-out;
}
.auth-card-wrapper.visible {
opacity: 1;
transform: translateY(0) scale(1);
}
.auth-card {
background: var(--color-card);
border: 1px solid var(--color-border);
border-radius: 1rem;
padding: 2rem;
box-shadow:
0 4px 24px rgba(0, 0, 0, 0.08),
0 0 0 1px rgba(255, 255, 255, 0.05) inset;
}
:global([data-theme="dark"]) .auth-card {
box-shadow:
0 4px 24px rgba(0, 0, 0, 0.3),
0 0 48px var(--color-glow),
0 0 0 1px rgba(255, 255, 255, 0.03) inset;
}
.auth-logo-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 3.5rem;
height: 3.5rem;
border-radius: 1rem;
background: var(--color-primary);
color: var(--color-primary-foreground);
box-shadow: 0 0 24px var(--color-glow-strong);
}
.auth-label {
display: block;
font-size: 0.8rem;
font-weight: 500;
margin-bottom: 0.375rem;
color: var(--color-foreground);
}
.auth-input {
width: 100%;
padding: 0.625rem 0.875rem;
border: 1px solid var(--color-border);
border-radius: 0.625rem;
font-size: 0.875rem;
background: var(--color-background);
color: var(--color-foreground);
transition: border-color 0.2s, box-shadow 0.2s;
}
.auth-input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-glow), 0 0 16px var(--color-glow);
}
.auth-error {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-radius: 0.625rem;
font-size: 0.8rem;
margin-bottom: 1rem;
background: var(--color-error-bg);
color: var(--color-error-fg);
}
.auth-submit {
width: 100%;
padding: 0.625rem;
border-radius: 0.625rem;
font-size: 0.875rem;
font-weight: 600;
background: var(--color-primary);
color: var(--color-primary-foreground);
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
transition: all 0.2s ease;
}
.auth-submit:hover:not(:disabled) {
box-shadow: 0 0 24px var(--color-glow-strong);
transform: translateY(-1px);
}
.auth-submit:active:not(:disabled) {
transform: translateY(0);
}
.auth-submit:disabled {
opacity: 0.6;
cursor: not-allowed;
}
@keyframes gradientShift {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
</style>
<form onsubmit={handleSubmit} class="space-y-4">
<div>
<label for="username" class="auth-label">{t('auth.username')}</label>
<input id="username" type="text" bind:value={username} required class="auth-input" />
</div>
<div>
<label for="password" class="auth-label">{t('auth.password')}</label>
<input id="password" type="password" bind:value={password} required class="auth-input" />
</div>
<div>
<label for="confirm" class="auth-label">{t('auth.confirmPassword')}</label>
<input id="confirm" type="password" bind:value={confirmPassword} required class="auth-input" />
</div>
<button type="submit" disabled={submitting} class="auth-submit">
{#if submitting}
<div class="w-4 h-4 rounded-full border-2 border-current border-t-transparent animate-spin"></div>
{/if}
{submitting ? t('auth.creatingAccount') : t('auth.createAccount')}
</button>
</form>
</AuthLayout>
+313 -133
View File
@@ -20,7 +20,9 @@
import { chatActionItems } from '$lib/grid-items';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { highlightFromUrl } from '$lib/highlight';
import type { NotificationTarget, TelegramBot, TelegramChat, EmailBot, MatrixBot } from '$lib/types';
import type { NotificationTarget, TargetReceiver, TelegramChat } from '$lib/types';
// ── Helpers ──
function getBotName(target: any): string | null {
if (target.type === 'telegram' && target.config?.bot_id) {
@@ -39,10 +41,10 @@
}
function getBotHref(target: any): string {
if (target.type === 'telegram') return '/bots';
if (target.type === 'telegram') return '/bots?tab=telegram';
if (target.type === 'email') return '/bots?tab=email';
if (target.type === 'matrix') return '/bots?tab=matrix';
return '/bots';
return '/bots?tab=telegram';
}
function getBotEntityId(target: any): number | null {
@@ -52,6 +54,24 @@
return null;
}
function receiverLabel(target: NotificationTarget, recv: TargetReceiver): string {
const c = recv.config || {};
if (target.type === 'telegram') {
return (recv as any).chat_name || c.chat_id || recv.receiver_key || '?';
}
if (target.type === 'email') return c.email || recv.receiver_key || '?';
if (target.type === 'webhook') return c.url || recv.receiver_key || '?';
if (target.type === 'discord' || target.type === 'slack') {
const url = c.webhook_url || recv.receiver_key || '';
return url.length > 50 ? url.substring(0, 50) + '...' : url || '?';
}
if (target.type === 'ntfy') return c.topic || recv.receiver_key || '?';
if (target.type === 'matrix') return c.room_id || recv.receiver_key || '?';
return recv.receiver_key || '?';
}
// ── Constants ──
const ALL_TYPES = ['telegram', 'webhook', 'email', 'discord', 'slack', 'ntfy', 'matrix'] as const;
type TargetType = typeof ALL_TYPES[number];
const TYPE_ICONS: Record<string, string> = {
@@ -69,6 +89,8 @@
label: tt.charAt(0).toUpperCase() + tt.slice(1),
})));
// ── Derived state ──
let allTargets = $derived(targetsCache.items);
let activeType = $derived(page.url.searchParams.get('type') as TargetType | null);
let targets = $derived(activeType ? allTargets.filter(t => t.type === activeType) : allTargets);
@@ -78,39 +100,56 @@
const telegramBotItems = $derived(telegramBots.map(b => ({ value: b.id, label: b.name, icon: b.icon || 'mdiRobot', desc: b.bot_username ? `@${b.bot_username}` : '' })));
const emailBotItems = $derived(emailBots.map(b => ({ value: b.id, label: b.name, icon: b.icon || 'mdiEmailOutline', desc: b.email })));
const matrixBotItems = $derived(matrixBots.map(b => ({ value: b.id, label: b.name, icon: b.icon || 'mdiMatrix', desc: b.display_name || b.homeserver_url })));
let botChats = $state<Record<number, TelegramChat[]>>({});
// ── Target form state ──
let showForm = $state(false);
let editing = $state<number | null>(null);
let formType = $state<TargetType>('telegram');
const defaultForm = () => ({ name: '', icon: '', bot_id: 0, chat_id: '', bot_token: '', url: '', headers: '',
const defaultForm = () => ({
name: '', icon: '', bot_id: 0, bot_token: '',
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, chat_action: 'typing',
// Discord/Slack
webhook_url: '', username: '',
// ntfy
server_url: 'https://ntfy.sh', topic: '', auth_token: '', priority: 3,
// Discord/Slack shared settings
username: '',
// ntfy shared settings
server_url: 'https://ntfy.sh', auth_token: '',
// Matrix
matrix_bot_id: 0, room_id: '',
matrix_bot_id: 0,
// Email
email_bot_id: 0, email: '',
email_bot_id: 0,
});
let form = $state(defaultForm());
let error = $state('');
let headersError = $state('');
let loaded = $state(false);
let submitting = $state(false);
let loadError = $state('');
let showTelegramSettings = $state(false);
let confirmDelete = $state<NotificationTarget | null>(null);
// ── Receiver inline form state ──
let addingReceiverForTarget = $state<number | null>(null);
let receiverForm = $state<Record<string, any>>({});
let receiverSubmitting = $state(false);
let receiverBotChats = $state<Record<number, TelegramChat[]>>({});
let receiverHeadersError = $state('');
let confirmDeleteReceiver = $state<{ targetId: number; receiver: TargetReceiver } | null>(null);
let receiverTesting = $state<Record<number, boolean>>({});
// ── Effects ──
// Reset form when switching target type tabs
$effect(() => {
activeType; // track
showForm = false;
editing = null;
error = '';
addingReceiverForTarget = null;
});
// ── Data loading ──
onMount(load);
async function load() {
try {
@@ -119,53 +158,59 @@
emailBotsCache.fetch(), matrixBotsCache.fetch(),
]);
loadError = '';
} catch (err: any) { loadError = err.message || t('common.loadError'); snackError(loadError); } finally { loaded = true; highlightFromUrl(); }
}
async function loadBotChats() {
if (!form.bot_id) return;
try { botChats[form.bot_id] = await api(`/telegram-bots/${form.bot_id}/chats`); } catch {}
}
// Auto-load chats when bot changes via EntitySelect
let _prevBotId = 0;
$effect(() => {
if (showForm && form.bot_id && form.bot_id !== _prevBotId) {
_prevBotId = form.bot_id;
loadBotChats();
} catch (err: any) {
loadError = err.message || t('common.loadError');
snackError(loadError);
} finally {
loaded = true;
highlightFromUrl();
}
});
}
function openNew() { form = defaultForm(); formType = activeType || 'telegram'; editing = null; showTelegramSettings = false; showForm = true; }
async function edit(tgt: any) {
formType = tgt.type;
async function loadReceiverBotChats(botId: number) {
if (!botId) return;
try { receiverBotChats[botId] = await api(`/telegram-bots/${botId}/chats`); } catch {}
}
// ── Target CRUD ──
function openNew() {
form = defaultForm();
formType = activeType || 'telegram';
editing = null;
showTelegramSettings = false;
showForm = true;
}
async function edit(tgt: NotificationTarget) {
formType = tgt.type as TargetType;
const c = tgt.config || {};
form = {
name: tgt.name, icon: tgt.icon || '',
// telegram
bot_id: c.bot_id || 0, bot_token: '', chat_id: c.chat_id || '',
bot_id: c.bot_id || 0, bot_token: '',
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,
ai_captions: c.ai_captions ?? false, chat_action: c.chat_action ?? 'typing',
// webhook
url: c.url || '', headers: '',
// discord/slack
webhook_url: c.webhook_url || '', username: c.username || '',
username: c.username || '',
// ntfy
server_url: c.server_url || 'https://ntfy.sh', topic: c.topic || '',
auth_token: c.auth_token || '', priority: c.priority ?? 3,
server_url: c.server_url || 'https://ntfy.sh',
auth_token: c.auth_token || '',
// email
email_bot_id: c.email_bot_id || 0, email: c.email || '',
email_bot_id: c.email_bot_id || 0,
// matrix
matrix_bot_id: c.matrix_bot_id || 0, room_id: c.room_id || '',
matrix_bot_id: c.matrix_bot_id || 0,
};
editing = tgt.id; showTelegramSettings = false; showForm = true;
if (form.bot_id) await loadBotChats();
editing = tgt.id;
showTelegramSettings = false;
showForm = true;
}
async function save(e: SubmitEvent) {
e.preventDefault(); error = ''; headersError = '';
e.preventDefault();
error = '';
if (submitting) return;
submitting = true;
try {
@@ -177,38 +222,43 @@
const tokenRes = await api(`/telegram-bots/${form.bot_id}/token`);
botToken = tokenRes.token;
}
config = { ...(botToken ? { bot_token: botToken } : {}), chat_id: form.chat_id,
config = {
...(botToken ? { bot_token: botToken } : {}),
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,
ai_captions: form.ai_captions, chat_action: form.chat_action || undefined };
ai_captions: form.ai_captions, chat_action: form.chat_action || undefined,
};
} else if (formType === 'webhook') {
let parsedHeaders = {};
if (form.headers) {
try { parsedHeaders = JSON.parse(form.headers); }
catch { headersError = t('common.headersInvalid'); return; }
}
config = { url: form.url, headers: parsedHeaders, ai_captions: form.ai_captions };
config = { ai_captions: form.ai_captions };
} else if (formType === 'discord' || formType === 'slack') {
config = { webhook_url: form.webhook_url, username: form.username || undefined };
config = { username: form.username || undefined };
} else if (formType === 'ntfy') {
config = { server_url: form.server_url, topic: form.topic, auth_token: form.auth_token || undefined };
config = { server_url: form.server_url, auth_token: form.auth_token || undefined };
} else if (formType === 'email') {
config = { email_bot_id: form.email_bot_id, email: form.email };
config = { email_bot_id: form.email_bot_id };
} else if (formType === 'matrix') {
config = { matrix_bot_id: form.matrix_bot_id, room_id: form.room_id };
config = { matrix_bot_id: form.matrix_bot_id };
}
if (editing) {
await api(`/targets/${editing}`, { method: 'PUT', body: JSON.stringify({ name: form.name, icon: form.icon, config }) });
} else {
await api('/targets', { method: 'POST', body: JSON.stringify({ type: formType, name: form.name, icon: form.icon, config }) });
}
showForm = false; editing = null; await load();
showForm = false;
editing = null;
await load();
snackSuccess(t('snack.targetSaved'));
} catch (err: any) { error = err.message; snackError(err.message); }
finally { submitting = false; }
} catch (err: any) {
error = err.message;
snackError(err.message);
} finally {
submitting = false;
}
}
async function test(id: number) {
try {
const res = await api(`/targets/${id}/test?locale=${getLocale()}`, { method: 'POST' });
@@ -216,9 +266,98 @@
else snackError(`Failed: ${res.error}`);
} catch (err: any) { snackError(err.message); }
}
async function remove(id: number) {
try { await api(`/targets/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.targetDeleted')); }
catch (err: any) { error = err.message; snackError(err.message); }
try {
await api(`/targets/${id}`, { method: 'DELETE' });
await load();
snackSuccess(t('snack.targetDeleted'));
} catch (err: any) {
error = err.message;
snackError(err.message);
}
}
// ── Receiver CRUD ──
function openReceiverForm(targetId: number, targetType: string) {
addingReceiverForTarget = targetId;
receiverHeadersError = '';
if (targetType === 'telegram') {
receiverForm = { chat_id: '' };
// Load bot chats for the target's bot
const tgt = allTargets.find(t => t.id === targetId);
const botId = tgt?.config?.bot_id;
if (botId && !receiverBotChats[botId]) loadReceiverBotChats(botId);
} else if (targetType === 'email') {
receiverForm = { email: '' };
} else if (targetType === 'webhook') {
receiverForm = { url: '', headers: '' };
} else if (targetType === 'discord' || targetType === 'slack') {
receiverForm = { webhook_url: '' };
} else if (targetType === 'ntfy') {
receiverForm = { topic: '' };
} else if (targetType === 'matrix') {
receiverForm = { room_id: '' };
}
}
async function saveReceiver(targetId: number) {
if (receiverSubmitting) return;
receiverSubmitting = true;
receiverHeadersError = '';
try {
const config = { ...receiverForm };
// Parse headers JSON for webhook
if ('headers' in config && typeof config.headers === 'string') {
if (config.headers) {
try { config.headers = JSON.parse(config.headers); }
catch { receiverHeadersError = t('common.headersInvalid'); return; }
} else {
delete config.headers;
}
}
await api(`/targets/${targetId}/receivers`, {
method: 'POST',
body: JSON.stringify({ name: '', config }),
});
addingReceiverForTarget = null;
await load();
snackSuccess(t('targets.receiverAdded'));
} catch (err: any) {
snackError(err.message);
} finally {
receiverSubmitting = false;
}
}
async function toggleReceiver(targetId: number, receiver: TargetReceiver) {
try {
await api(`/targets/${targetId}/receivers/${receiver.id}`, {
method: 'PUT',
body: JSON.stringify({ enabled: !receiver.enabled }),
});
await load();
snackSuccess(receiver.enabled ? t('targets.receiverDisabled') : t('targets.receiverEnabled'));
} catch (err: any) { snackError(err.message); }
}
async function removeReceiver(targetId: number, receiverId: number) {
try {
await api(`/targets/${targetId}/receivers/${receiverId}`, { method: 'DELETE' });
await load();
snackSuccess(t('targets.receiverDeleted'));
} catch (err: any) { snackError(err.message); }
}
async function testReceiver(targetId: number, receiverId: number) {
receiverTesting = { ...receiverTesting, [receiverId]: true };
try {
const res = await api(`/targets/${targetId}/receivers/${receiverId}/test?locale=${getLocale()}`, { method: 'POST' });
if (res.success) snackSuccess(t('snack.targetTestSent'));
else snackError(`Failed: ${res.error}`);
} catch (err: any) { snackError(err.message); }
finally { receiverTesting = { ...receiverTesting, [receiverId]: false }; }
}
</script>
@@ -258,32 +397,10 @@
<label class="block text-sm font-medium mb-1">{t('telegramBot.selectBot')}</label>
<EntitySelect items={telegramBotItems} bind:value={form.bot_id} placeholder={t('telegramBot.selectBot')} />
{#if telegramBots.length === 0}
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('telegramBot.noBots')} <a href="/bots" class="underline"></a></p>
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('telegramBot.noBots')} <a href="/bots?tab=telegram" class="underline"></a></p>
{/if}
</div>
{#if form.bot_id}
<div>
<label for="tgt-chat" class="block text-sm font-medium mb-1">{t('telegramBot.selectChat')}</label>
{#if (botChats[form.bot_id] || []).length > 0}
<select 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)]">
<option value="">{t('telegramBot.selectChat')}</option>
{#each botChats[form.bot_id] as chat}
<option value={chat.chat_id}>{chat.title || chat.username || 'Unknown'} ({chat.type}) [{chat.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)]" />
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('telegramBot.noChats')}</p>
{/if}
</div>
{/if}
<div class="border border-[var(--color-border)] rounded-md p-3">
<button type="button" onclick={() => showTelegramSettings = !showTelegramSettings}
class="text-sm font-medium cursor-pointer w-full text-left flex items-center justify-between">
@@ -317,22 +434,7 @@
</div>
{/if}
</div>
{:else if formType === 'webhook'}
<div>
<label for="tgt-url" class="block text-sm font-medium mb-1">{t('targets.webhookUrl')}</label>
<input id="tgt-url" bind:value={form.url} required placeholder="https://..." class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="tgt-headers" class="block text-sm font-medium mb-1">Headers (JSON)</label>
<input id="tgt-headers" bind:value={form.headers} placeholder={'{"Authorization": "Bearer ..."}'} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" style={headersError ? 'border-color: var(--color-error-fg)' : ''} />
{#if headersError}<p class="text-xs text-[var(--color-error-fg)] mt-1">{headersError}</p>{/if}
</div>
{:else if formType === 'discord' || formType === 'slack'}
<div>
<label for="tgt-wh" class="block text-sm font-medium mb-1">{formType === 'discord' ? 'Discord' : 'Slack'} Webhook URL</label>
<input id="tgt-wh" bind:value={form.webhook_url} required placeholder={formType === 'discord' ? 'https://discord.com/api/webhooks/...' : 'https://hooks.slack.com/services/...'}
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="tgt-user" class="block text-sm font-medium mb-1">{t('targets.overrideUsername')}</label>
<input id="tgt-user" bind:value={form.username} placeholder="Notify Bridge"
@@ -344,11 +446,6 @@
<input id="tgt-ntfy-server" bind:value={form.server_url} required placeholder="https://ntfy.sh"
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="tgt-ntfy-topic" class="block text-sm font-medium mb-1">{t('targets.ntfyTopic')}</label>
<input id="tgt-ntfy-topic" bind:value={form.topic} required placeholder="my-notifications"
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="tgt-ntfy-token" class="block text-sm font-medium mb-1">{t('targets.ntfyToken')}</label>
<input id="tgt-ntfy-token" bind:value={form.auth_token} placeholder={t('targets.ntfyTokenPlaceholder')}
@@ -359,27 +456,17 @@
<label class="block text-sm font-medium mb-1">{t('targets.selectEmailBot')}</label>
<EntitySelect items={emailBotItems} bind:value={form.email_bot_id} placeholder={t('targets.selectEmailBot')} />
{#if emailBots.length === 0}
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('emailBot.noBots')} <a href="/bots" class="underline"></a></p>
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('emailBot.noBots')} <a href="/bots?tab=email" class="underline"></a></p>
{/if}
</div>
<div>
<label for="tgt-email" class="block text-sm font-medium mb-1">{t('targets.recipientEmail')}</label>
<input id="tgt-email" bind:value={form.email} required type="email" placeholder="recipient@example.com"
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
{:else if formType === 'matrix'}
<div>
<label class="block text-sm font-medium mb-1">{t('targets.selectMatrixBot')}</label>
<EntitySelect items={matrixBotItems} bind:value={form.matrix_bot_id} placeholder={t('targets.selectMatrixBot')} />
{#if matrixBots.length === 0}
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('matrixBot.noBots')} <a href="/bots" class="underline"></a></p>
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('matrixBot.noBots')} <a href="/bots?tab=matrix" class="underline"></a></p>
{/if}
</div>
<div>
<label for="tgt-room" class="block text-sm font-medium mb-1">{t('targets.matrixRoomId')}</label>
<input id="tgt-room" bind:value={form.room_id} required placeholder="!abc123:matrix.org"
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)] font-mono" />
</div>
{/if}
{#if formType === 'telegram'}
@@ -398,35 +485,18 @@
</Card>
{:else}
<div class="space-y-3 stagger-children">
{#each targets as target}
{#each targets as target (target.id)}
<Card hover entityId={target.id}>
<!-- Target header -->
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2">
<span style="color: var(--color-primary);"><MdiIcon name={target.icon || TYPE_ICONS[target.type] || 'mdiTarget'} size={20} /></span>
<p class="font-medium">{target.name}</p>
{#if !activeType}<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{target.type}</span>{/if}
{#if target.receiver_count}<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{target.receiver_count} receiver(s)</span>{/if}
{#if (target.receivers || []).length > 0}<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{(target.receivers || []).length} receiver(s)</span>{/if}
{#if getBotName(target)}<CrossLink href={getBotHref(target)} icon="mdiRobot" label={getBotName(target)} entityId={getBotEntityId(target)} />{/if}
</div>
<p class="text-sm text-[var(--color-muted-foreground)]">
{#if target.type === 'telegram'}
Chat: {#if target.chat_name}{target.chat_name} <span class="font-mono text-xs">({target.config?.chat_id})</span>{:else}{target.config?.chat_id || '***'}{/if}
{#if target.config?.chat_action}
<span class="text-xs px-1.5 py-0.5 ml-1 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{target.config.chat_action}</span>
{/if}
{:else if target.type === 'webhook'}
{target.config?.url || ''}
{:else if target.type === 'discord' || target.type === 'slack'}
{target.config?.webhook_url ? target.config.webhook_url.substring(0, 50) + '...' : ''}
{:else if target.type === 'ntfy'}
{target.config?.server_url || 'ntfy.sh'} / {target.config?.topic || ''}
{:else if target.type === 'email'}
{target.config?.email || ''}
{:else if target.type === 'matrix'}
{target.config?.room_id || ''}
{/if}
</p>
</div>
<div class="flex items-center gap-1">
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(target)} />
@@ -434,6 +504,109 @@
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => confirmDelete = target} variant="danger" />
</div>
</div>
<!-- Receivers list -->
<div class="mt-3 pt-3 border-t border-[var(--color-border)]">
<div class="flex items-center justify-between mb-2">
<p class="text-xs font-medium text-[var(--color-muted-foreground)] uppercase tracking-wide">{t('targets.receivers')}</p>
</div>
{#if (target.receivers || []).length === 0 && addingReceiverForTarget !== target.id}
<p class="text-xs text-[var(--color-muted-foreground)] italic mb-2">{t('targets.noReceivers')}</p>
{/if}
{#each target.receivers || [] as recv (recv.id)}
<div class="flex items-center justify-between py-1.5 px-2 rounded-md hover:bg-[var(--color-muted)]" class:opacity-50={!recv.enabled}>
<div class="flex items-center gap-2 min-w-0">
<MdiIcon name={TYPE_ICONS[target.type] || 'mdiTarget'} size={14} />
<span class="text-sm truncate">{receiverLabel(target, recv)}</span>
</div>
<div class="flex items-center gap-1">
<IconButton icon="mdiSend" title={t('targets.test')}
onclick={() => testReceiver(target.id, recv.id)}
disabled={receiverTesting[recv.id]} size={16} />
<IconButton
icon={recv.enabled ? 'mdiToggleSwitch' : 'mdiToggleSwitchOff'}
title={recv.enabled ? t('targets.receiverDisabled') : t('targets.receiverEnabled')}
onclick={() => toggleReceiver(target.id, recv)}
size={16}
/>
<IconButton
icon="mdiDelete"
title={t('common.delete')}
onclick={() => confirmDeleteReceiver = { targetId: target.id, receiver: recv }}
variant="danger"
size={16}
/>
</div>
</div>
{/each}
<!-- Inline add-receiver form -->
{#if addingReceiverForTarget === target.id}
<div in:slide={{ duration: 150 }} class="mt-2 p-2 rounded-md border border-[var(--color-border)] bg-[var(--color-background)]">
{#if target.type === 'telegram'}
{@const botId = target.config?.bot_id}
{@const chatItems = (receiverBotChats[botId] || []).map((c: TelegramChat) => ({
value: c.chat_id,
label: c.title || c.username || c.chat_id,
icon: c.type === 'private' ? 'mdiAccount' : c.type === 'channel' ? 'mdiBullhorn' : 'mdiAccountGroup',
desc: `${c.type} · ${c.chat_id}`,
}))}
{#if chatItems.length > 0}
<EntitySelect items={chatItems} bind:value={receiverForm.chat_id} placeholder={t('telegramBot.selectChat')} />
{:else}
<input bind:value={receiverForm.chat_id} placeholder="Chat ID"
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
{/if}
{#if botId}
<button type="button" onclick={() => loadReceiverBotChats(botId)}
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>
{/if}
{:else if target.type === 'email'}
<input bind:value={receiverForm.email} type="email" placeholder="recipient@example.com"
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
{:else if target.type === 'webhook'}
<input bind:value={receiverForm.url} placeholder="https://..."
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)] mb-2" />
<input bind:value={receiverForm.headers} placeholder={'{"Authorization": "Bearer ..."}'}
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]"
style={receiverHeadersError ? 'border-color: var(--color-error-fg)' : ''} />
{#if receiverHeadersError}<p class="text-xs text-[var(--color-error-fg)] mt-1">{receiverHeadersError}</p>{/if}
{:else if target.type === 'discord' || target.type === 'slack'}
<input bind:value={receiverForm.webhook_url}
placeholder={target.type === 'discord' ? 'https://discord.com/api/webhooks/...' : 'https://hooks.slack.com/services/...'}
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
{:else if target.type === 'ntfy'}
<input bind:value={receiverForm.topic} placeholder="my-notifications"
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
{:else if target.type === 'matrix'}
<input bind:value={receiverForm.room_id} placeholder="!abc123:matrix.org"
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)] font-mono" />
{/if}
<div class="flex gap-2 mt-2">
<button type="button" onclick={() => saveReceiver(target.id)} disabled={receiverSubmitting}
class="px-3 py-1 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-xs font-medium hover:opacity-90 disabled:opacity-50">
{receiverSubmitting ? t('common.loading') : t('common.save')}
</button>
<button type="button" onclick={() => addingReceiverForTarget = null}
class="px-3 py-1 border border-[var(--color-border)] rounded-md text-xs hover:bg-[var(--color-muted)]">
{t('targets.cancel')}
</button>
</div>
</div>
{:else}
<button type="button" onclick={() => openReceiverForm(target.id, target.type)}
class="mt-1 flex items-center gap-1 text-xs text-[var(--color-primary)] hover:underline cursor-pointer">
<MdiIcon name="mdiPlus" size={14} />
{t('targets.addReceiver')}
</button>
{/if}
</div>
</Card>
{/each}
</div>
@@ -447,3 +620,10 @@
onconfirm={() => { if (confirmDelete) { remove(confirmDelete.id); confirmDelete = null; } }}
oncancel={() => confirmDelete = null}
/>
<ConfirmModal
open={!!confirmDeleteReceiver}
message={t('targets.confirmDeleteReceiver')}
onconfirm={() => { if (confirmDeleteReceiver) { removeReceiver(confirmDeleteReceiver.targetId, confirmDeleteReceiver.receiver.id); confirmDeleteReceiver = null; } }}
oncancel={() => confirmDeleteReceiver = null}
/>
@@ -190,18 +190,41 @@
}
function sanitizePreview(html: string): string {
// Allow only Telegram-safe HTML tags, escape everything else
return html
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
// Restore allowed tags — only http(s) URLs for <a> to prevent javascript: XSS
.replace(/&lt;a href="(https?:\/\/[^"]*)"&gt;/g, '<a href="$1" target="_blank" rel="noopener noreferrer">')
.replace(/&lt;\/a&gt;/g, '</a>')
.replace(/&lt;b&gt;/g, '<b>').replace(/&lt;\/b&gt;/g, '</b>')
.replace(/&lt;i&gt;/g, '<i>').replace(/&lt;\/i&gt;/g, '</i>')
.replace(/&lt;code&gt;/g, '<code>').replace(/&lt;\/code&gt;/g, '</code>')
.replace(/&lt;pre&gt;/g, '<pre>').replace(/&lt;\/pre&gt;/g, '</pre>');
// DOM-based sanitizer: parse HTML, walk tree, keep only safe elements
const ALLOWED_TAGS = new Set(['B', 'I', 'CODE', 'PRE', 'A', 'BR']);
const doc = new DOMParser().parseFromString(html, 'text/html');
const fragment = document.createDocumentFragment();
function walkNodes(parent: Node, target: Node) {
for (const node of Array.from(parent.childNodes)) {
if (node.nodeType === Node.TEXT_NODE) {
target.appendChild(document.createTextNode(node.textContent || ''));
} else if (node.nodeType === Node.ELEMENT_NODE) {
const el = node as Element;
if (ALLOWED_TAGS.has(el.tagName)) {
const safe = document.createElement(el.tagName);
if (el.tagName === 'A') {
const href = el.getAttribute('href') || '';
if (/^https?:\/\//i.test(href)) {
safe.setAttribute('href', href);
safe.setAttribute('target', '_blank');
safe.setAttribute('rel', 'noopener noreferrer');
}
}
walkNodes(el, safe);
target.appendChild(safe);
} else {
// Unwrap: keep text content of disallowed tags
walkNodes(el, target);
}
}
}
}
walkNodes(doc.body, fragment);
const wrapper = document.createElement('div');
wrapper.appendChild(fragment);
return wrapper.innerHTML;
}
function remove(id: number) {
@@ -116,8 +116,9 @@ class NotificationDispatcher:
if not bot_token:
return {"success": False, "error": "Missing bot_token"}
# Resolve receivers — broadcast to each, or fall back to legacy chat_id in config
receivers = target.receivers or [{"chat_id": target.config.get("chat_id")}]
if not target.receivers:
return {"success": False, "error": "No receivers configured"}
receivers = target.receivers
# Prepare assets list once (shared across receivers)
provider_urls = []
@@ -182,8 +183,9 @@ class NotificationDispatcher:
async def _send_webhook(
self, target: TargetConfig, message: str, event: ServiceEvent
) -> dict[str, Any]:
# Resolve receivers — broadcast to each, or fall back to legacy url in config
receivers = target.receivers or [{"url": target.config.get("url"), "headers": target.config.get("headers", {})}]
if not target.receivers:
return {"success": False, "error": "No receivers configured"}
receivers = target.receivers
payload = {
"message": message,
@@ -226,8 +228,9 @@ class NotificationDispatcher:
use_tls=smtp_cfg.get("use_tls", True),
))
# Resolve receivers
receivers = target.receivers or [{"email": target.config.get("email", "")}]
if not target.receivers:
return {"success": False, "error": "No receivers configured"}
receivers = target.receivers
subject = f"[Notify Bridge] {event.event_type.value}: {event.collection_name}"
results: list[dict[str, Any]] = []
@@ -251,7 +254,9 @@ class NotificationDispatcher:
) -> dict[str, Any]:
from .discord.client import DiscordClient
receivers = target.receivers or [{"webhook_url": target.config.get("webhook_url", "")}]
if not target.receivers:
return {"success": False, "error": "No receivers configured"}
receivers = target.receivers
username = target.config.get("username")
results: list[dict[str, Any]] = []
@@ -271,7 +276,9 @@ class NotificationDispatcher:
) -> dict[str, Any]:
from .slack.client import SlackClient
receivers = target.receivers or [{"webhook_url": target.config.get("webhook_url", "")}]
if not target.receivers:
return {"success": False, "error": "No receivers configured"}
receivers = target.receivers
username = target.config.get("username")
results: list[dict[str, Any]] = []
@@ -293,7 +300,9 @@ class NotificationDispatcher:
server_url = target.config.get("server_url", "https://ntfy.sh")
auth_token = target.config.get("auth_token")
receivers = target.receivers or [{"topic": target.config.get("topic", "")}]
if not target.receivers:
return {"success": False, "error": "No receivers configured"}
receivers = target.receivers
title = f"{event.event_type.value}: {event.collection_name}"
@@ -323,7 +332,9 @@ class NotificationDispatcher:
if not homeserver or not access_token:
return {"success": False, "error": "Missing Matrix homeserver_url or access_token"}
receivers = target.receivers or [{"room_id": target.config.get("room_id", "")}]
if not target.receivers:
return {"success": False, "error": "No receivers configured"}
receivers = target.receivers
results: list[dict[str, Any]] = []
async with aiohttp.ClientSession() as session:
@@ -8,7 +8,7 @@ from pydantic import BaseModel
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from ..auth.dependencies import get_current_user
from ..auth.dependencies import require_admin
from ..database.engine import get_session
from ..database.models import AppSetting, TelegramBot, User
@@ -51,7 +51,7 @@ class SettingsUpdate(BaseModel):
@router.get("")
async def get_settings(
user: User = Depends(get_current_user),
user: User = Depends(require_admin),
session: AsyncSession = Depends(get_session),
):
"""Return all app settings."""
@@ -64,7 +64,7 @@ async def get_settings(
@router.put("")
async def update_settings(
body: SettingsUpdate,
user: User = Depends(get_current_user),
user: User = Depends(require_admin),
session: AsyncSession = Depends(get_session),
):
"""Update app settings (admin). Re-registers webhooks when base URL changes."""
@@ -173,7 +173,11 @@ async def _find_system_default_template(
)
)
templates = result.all()
# Match by locale suffix in name, e.g. "(EN)" or "(RU)"
# Match by locale column first, fall back to name suffix
locale_lower = locale_upper.lower()
for tpl in templates:
if tpl.locale == locale_lower:
return tpl
for tpl in templates:
if f"({locale_upper})" in tpl.name:
return tpl
@@ -47,12 +47,12 @@ async def check_telegram_bot(session: AsyncSession, bot_id: int) -> list[str]:
"""Check if a TelegramBot is used by any targets or command listeners."""
consumers = []
# Check notification targets with this bot in config
result = await session.exec(select(NotificationTarget))
result = await session.exec(
select(NotificationTarget).where(NotificationTarget.type == "telegram")
)
for t in result.all():
if t.config.get("bot_id") == bot_id or t.config.get("bot_token"):
# Need to verify it's actually this bot
if t.config.get("bot_id") == bot_id:
consumers.append(f"Target: {t.name}")
if t.config.get("bot_id") == bot_id:
consumers.append(f"Target: {t.name}")
# Check command tracker listeners
result = await session.exec(
select(CommandTrackerListener).where(
@@ -111,9 +111,7 @@ async def delete_notification_tracker(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
from .delete_protection import check_notification_tracker, raise_if_used
tracker = await _get_user_tracker(session, tracker_id, user.id)
raise_if_used(await check_notification_tracker(session, tracker.id), tracker.name)
# Delete associated tracker-target links
result = await session.exec(
select(NotificationTrackerTarget).where(NotificationTrackerTarget.tracker_id == tracker_id)
@@ -3,7 +3,7 @@
import logging
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
@@ -11,6 +11,7 @@ from sqlmodel.ext.asyncio.session import AsyncSession
from ..auth.dependencies import get_current_user
from ..database.engine import get_session
from ..database.models import NotificationTarget, TargetReceiver, User
from ..services.notifier import send_to_receiver
_LOGGER = logging.getLogger(__name__)
@@ -117,6 +118,25 @@ async def update_receiver(
return _response(receiver)
@router.post("/{receiver_id}/test")
async def test_receiver(
target_id: int,
receiver_id: int,
locale: str = Query("en"),
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Send a test notification to a single receiver."""
target = await _get_user_target(session, target_id, user.id)
receiver = await session.get(TargetReceiver, receiver_id)
if not receiver or receiver.target_id != target_id:
raise HTTPException(status_code=404, detail="Receiver not found")
from ..services.notifier import _get_test_message
message = _get_test_message(locale, target.type)
return await send_to_receiver(target, dict(receiver.config), message)
@router.delete("/{receiver_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_receiver(
target_id: int,
@@ -12,11 +12,46 @@ from ..auth.dependencies import get_current_user
from ..database.engine import get_session
from ..database.models import NotificationTarget, NotificationTrackerTarget, TargetReceiver, TelegramBot, TelegramChat, User
from ..services.notifier import send_test_notification
from .target_receivers import _receiver_key
_LOGGER = logging.getLogger(__name__)
router = APIRouter(prefix="/api/targets", tags=["targets"])
# Delivery fields that belong in TargetReceiver, NOT in target.config
_DELIVERY_FIELDS: dict[str, str] = {
"telegram": "chat_id",
"webhook": "url",
"email": "email",
"discord": "webhook_url",
"slack": "webhook_url",
"ntfy": "topic",
"matrix": "room_id",
}
def _extract_delivery_fields(target_type: str, config: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
"""Split config into (clean_config, receiver_config).
Returns the target config with delivery fields removed,
and a receiver config dict (empty if no delivery field found).
"""
field = _DELIVERY_FIELDS.get(target_type)
if not field:
return dict(config), {}
clean = dict(config)
receiver_cfg: dict[str, Any] = {}
value = clean.pop(field, None)
if value:
receiver_cfg[field] = value
# For webhook, also move headers to receiver config
if target_type == "webhook" and "headers" in clean:
receiver_cfg["headers"] = clean.pop("headers")
return clean, receiver_cfg
class TargetCreate(BaseModel):
type: str # "telegram" or "webhook"
@@ -44,32 +79,38 @@ async def list_targets(
)
targets = result.all()
# Resolve chat names for telegram targets
chat_names: dict[str, str] = {}
for tgt in targets:
if tgt.type == "telegram" and tgt.config.get("chat_id"):
bot_id = tgt.config.get("bot_id")
chat_id = str(tgt.config["chat_id"])
if bot_id:
chat_result = await session.exec(
select(TelegramChat).where(
TelegramChat.bot_id == bot_id,
TelegramChat.chat_id == chat_id,
)
)
chat = chat_result.first()
if chat:
chat_names[f"{bot_id}_{chat_id}"] = chat.title or chat.username or ""
# Load receiver counts
receiver_counts: dict[int, int] = {}
# Load receivers for each target
target_receivers: dict[int, list[TargetReceiver]] = {}
for tgt in targets:
recv_result = await session.exec(
select(TargetReceiver).where(TargetReceiver.target_id == tgt.id)
)
receiver_counts[tgt.id] = len(recv_result.all())
target_receivers[tgt.id] = list(recv_result.all())
return [_target_response(t, chat_names, receiver_counts.get(t.id, 0)) for t in targets]
# Resolve chat names from receivers for telegram targets
chat_names: dict[str, str] = {}
for tgt in targets:
if tgt.type == "telegram":
bot_id = tgt.config.get("bot_id")
if not bot_id:
continue
for recv in target_receivers.get(tgt.id, []):
chat_id = str(recv.config.get("chat_id", ""))
if chat_id:
chat_result = await session.exec(
select(TelegramChat).where(
TelegramChat.bot_id == bot_id,
TelegramChat.chat_id == chat_id,
)
)
chat = chat_result.first()
if chat:
chat_names[f"{bot_id}_{chat_id}"] = chat.title or chat.username or ""
return [
_target_response(t, chat_names, target_receivers.get(t.id, []))
for t in targets
]
@router.post("", status_code=status.HTTP_201_CREATED)
@@ -85,15 +126,33 @@ async def create_target(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Type must be one of: {', '.join(valid_types)}",
)
# Extract delivery fields from config — they go into a TargetReceiver
clean_config, receiver_cfg = _extract_delivery_fields(body.type, body.config)
target = NotificationTarget(
user_id=user.id,
type=body.type,
name=body.name,
icon=body.icon,
config=body.config,
config=clean_config,
chat_action=body.chat_action,
)
session.add(target)
await session.flush() # get target.id
# Auto-create a receiver if delivery fields were present
if receiver_cfg:
key = _receiver_key(body.type, receiver_cfg)
receiver = TargetReceiver(
target_id=target.id,
name=body.name,
config=receiver_cfg,
receiver_key=key,
enabled=True,
)
session.add(receiver)
await session.commit()
await session.refresh(target)
return {"id": target.id, "type": target.type, "name": target.name}
@@ -107,7 +166,11 @@ async def get_target(
):
"""Get a specific notification target."""
target = await _get_user_target(session, target_id, user.id)
return _target_response(target)
recv_result = await session.exec(
select(TargetReceiver).where(TargetReceiver.target_id == target.id)
)
receivers = list(recv_result.all())
return _target_response(target, receivers=receivers)
@router.put("/{target_id}")
@@ -119,8 +182,38 @@ async def update_target(
):
"""Update a notification target."""
target = await _get_user_target(session, target_id, user.id)
for field, value in body.model_dump(exclude_unset=True).items():
setattr(target, field, value)
updates = body.model_dump(exclude_unset=True)
# If config is being updated, extract any delivery fields
if "config" in updates and updates["config"] is not None:
clean_config, receiver_cfg = _extract_delivery_fields(target.type, updates["config"])
updates["config"] = clean_config
# Update or create receiver if delivery fields were present
if receiver_cfg:
key = _receiver_key(target.type, receiver_cfg)
existing_result = await session.exec(
select(TargetReceiver).where(
TargetReceiver.target_id == target.id,
TargetReceiver.receiver_key == key,
)
)
existing_recv = existing_result.first()
if existing_recv:
existing_recv.config = receiver_cfg
session.add(existing_recv)
else:
receiver = TargetReceiver(
target_id=target.id,
name=target.name,
config=receiver_cfg,
receiver_key=key,
enabled=True,
)
session.add(receiver)
for field_name, value in updates.items():
setattr(target, field_name, value)
session.add(target)
await session.commit()
await session.refresh(target)
@@ -160,7 +253,12 @@ async def test_target(
return result
def _target_response(target: NotificationTarget, chat_names: dict[str, str] | None = None, receiver_count: int = 0) -> dict:
def _target_response(
target: NotificationTarget,
chat_names: dict[str, str] | None = None,
receivers: list[TargetReceiver] | None = None,
) -> dict:
recv_list = receivers or []
resp = {
"id": target.id,
"type": target.type,
@@ -168,16 +266,27 @@ def _target_response(target: NotificationTarget, chat_names: dict[str, str] | No
"icon": target.icon,
"config": _safe_config(target),
"chat_action": target.chat_action,
"receiver_count": receiver_count,
"receiver_count": len(recv_list),
"receivers": [
{
"id": r.id,
"name": r.name,
"config": dict(r.config),
"receiver_key": r.receiver_key,
"enabled": r.enabled,
}
for r in recv_list
],
"created_at": target.created_at.isoformat(),
}
# Attach resolved chat name for telegram targets
# Attach resolved chat names from receivers for telegram targets
if target.type == "telegram" and chat_names:
bot_id = target.config.get("bot_id")
chat_id = str(target.config.get("chat_id", ""))
key = f"{bot_id}_{chat_id}"
if key in chat_names:
resp["chat_name"] = chat_names[key]
for recv_resp in resp["receivers"]:
chat_id = str(recv_resp["config"].get("chat_id", ""))
key = f"{bot_id}_{chat_id}"
if key in chat_names:
recv_resp["chat_name"] = chat_names[key]
return resp
@@ -56,6 +56,8 @@ async def setup(body: SetupRequest, session: AsyncSession = Depends(get_session)
if count > 0:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Setup already completed.")
if len(body.password) < 6:
raise HTTPException(status_code=400, detail="Password must be at least 6 characters")
user = User(username=body.username, hashed_password=_hash_password(body.password), role="admin")
session.add(user)
await session.commit()
@@ -541,6 +541,25 @@ def _format_assets(
})
async def send_reply(bot_token: str, chat_id: str, text: str) -> None:
"""Send a text reply via Telegram Bot API, retrying without HTML on parse failure."""
async with aiohttp.ClientSession() as http:
url = f"{TELEGRAM_API_BASE_URL}{bot_token}/sendMessage"
payload: dict[str, Any] = {"chat_id": chat_id, "text": text, "parse_mode": "HTML"}
try:
async with http.post(url, json=payload) as resp:
if resp.status != 200:
result = await resp.json()
_LOGGER.debug("Telegram reply failed: %s", result.get("description"))
if "parse" in str(result.get("description", "")).lower():
payload.pop("parse_mode", None)
async with http.post(url, json=payload) as retry_resp:
if retry_resp.status != 200:
_LOGGER.warning("Telegram reply failed on retry")
except aiohttp.ClientError as err:
_LOGGER.error("Failed to send Telegram reply: %s", err)
async def send_media_group(
bot_token: str, chat_id: str, media_items: list[dict[str, Any]],
) -> None:
@@ -15,7 +15,7 @@ from notify_bridge_core.notifications.telegram.media import TELEGRAM_API_BASE_UR
from ..database.engine import get_session
from ..database.models import TelegramBot
from ..services.telegram import save_chat_from_webhook
from .handler import handle_command, send_media_group
from .handler import handle_command, send_media_group, send_reply
_LOGGER = logging.getLogger(__name__)
@@ -81,32 +81,12 @@ async def telegram_webhook(
if isinstance(cmd_response, list):
await send_media_group(bot.token, chat_id, cmd_response)
else:
await _send_reply(bot.token, chat_id, cmd_response)
await send_reply(bot.token, chat_id, cmd_response)
return {"ok": True}
return {"ok": True, "skipped": "not_a_command"}
async def _send_reply(bot_token: str, chat_id: str, text: str) -> None:
"""Send a text reply via Telegram Bot API."""
async with aiohttp.ClientSession() as http_session:
url = f"{TELEGRAM_API_BASE_URL}{bot_token}/sendMessage"
payload: dict[str, Any] = {"chat_id": chat_id, "text": text, "parse_mode": "HTML"}
try:
async with http_session.post(url, json=payload) as resp:
if resp.status != 200:
result = await resp.json()
_LOGGER.debug("Telegram reply failed: %s", result.get("description"))
# Retry without parse_mode if HTML fails
if "parse" in str(result.get("description", "")).lower():
payload.pop("parse_mode", None)
async with http_session.post(url, json=payload) as retry_resp:
if retry_resp.status != 200:
_LOGGER.warning("Telegram reply failed on retry")
except aiohttp.ClientError as err:
_LOGGER.error("Failed to send Telegram reply: %s", err)
async def register_webhook(bot_token: str, webhook_url: str, secret: str | None = None) -> dict:
"""Register webhook URL with Telegram Bot API."""
async with aiohttp.ClientSession() as http:
@@ -15,9 +15,8 @@ class Settings(BaseSettings):
def model_post_init(self, __context: Any) -> None:
if self.secret_key == "change-me-in-production" and not self.debug:
import logging
logging.getLogger(__name__).critical(
"SECURITY: Using default secret_key! "
raise ValueError(
"SECURITY: Cannot start with default secret_key in production. "
"Set NOTIFY_BRIDGE_SECRET_KEY environment variable."
)
@@ -6,6 +6,7 @@ and the Phase 1 entity refactor (tracker → notification_tracker, etc.).
import json
import logging
from typing import Any
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncEngine
@@ -713,3 +714,139 @@ async def migrate_target_receivers(engine: AsyncEngine) -> None:
if migrated:
logger.info("Migrated %d target receivers from legacy config", migrated)
async def migrate_receivers_from_config(engine: AsyncEngine) -> None:
"""Extract delivery endpoint fields from target.config into TargetReceiver rows.
For each NotificationTarget that still has a delivery field (chat_id, url,
webhook_url, email, topic, room_id) in its config JSON:
1. Create a TargetReceiver row (if one with the same key doesn't exist)
2. Remove the delivery field(s) from the config JSON
Idempotent: checks for existing receiver before creating; only strips fields
that are still present in config.
"""
# Mapping: target_type -> (delivery field in config, receiver config builder)
_DELIVERY_FIELDS: dict[str, dict[str, str]] = {
"telegram": {"chat_id": "chat_id"},
"webhook": {"url": "url"},
"email": {"email": "email"},
"discord": {"webhook_url": "webhook_url"},
"slack": {"webhook_url": "webhook_url"},
"ntfy": {"topic": "topic"},
"matrix": {"room_id": "room_id"},
}
async with engine.begin() as conn:
if not await _has_table(conn, "notification_target"):
return
if not await _has_table(conn, "target_receiver"):
return
targets = (await conn.execute(
text("SELECT id, type, config FROM notification_target")
)).fetchall()
created = 0
cleaned = 0
for row in targets:
target_id, target_type, raw_config = row[0], row[1], row[2]
try:
cfg = json.loads(raw_config) if isinstance(raw_config, str) else (raw_config or {})
except (json.JSONDecodeError, TypeError):
cfg = {}
field_map = _DELIVERY_FIELDS.get(target_type, {})
if not field_map:
continue
# Check if any delivery field is present in config
delivery_field = list(field_map.keys())[0] # e.g. "chat_id", "url"
delivery_value = cfg.get(delivery_field)
if not delivery_value:
continue
# Build receiver config
receiver_config: dict[str, Any] = {delivery_field: delivery_value}
# For webhook, also move headers to receiver config
if target_type == "webhook" and "headers" in cfg:
receiver_config["headers"] = cfg["headers"]
receiver_key = str(delivery_value)
# Check if receiver already exists
existing = (await conn.execute(
text(
"SELECT id FROM target_receiver "
"WHERE target_id = :tid AND receiver_key = :rk"
),
{"tid": target_id, "rk": receiver_key},
)).fetchone()
if not existing:
# Derive a name for the receiver
if target_type == "telegram":
name = f"Chat {delivery_value}"
elif target_type == "webhook":
name = str(delivery_value)[:50]
elif target_type == "email":
name = str(delivery_value)
else:
name = str(delivery_value)[:50]
await conn.execute(
text(
"INSERT INTO target_receiver "
"(target_id, name, config, receiver_key, enabled, created_at) "
"VALUES (:tid, :name, :cfg, :rk, 1, CURRENT_TIMESTAMP)"
),
{
"tid": target_id,
"name": name,
"cfg": json.dumps(receiver_config),
"rk": receiver_key,
},
)
created += 1
# Remove delivery fields from config
new_cfg = dict(cfg)
new_cfg.pop(delivery_field, None)
# For webhook, also remove headers (moved to receiver)
if target_type == "webhook":
new_cfg.pop("headers", None)
if new_cfg != cfg:
await conn.execute(
text(
"UPDATE notification_target SET config = :cfg WHERE id = :tid"
),
{"cfg": json.dumps(new_cfg), "tid": target_id},
)
cleaned += 1
if created:
logger.info("Created %d receiver rows from target config delivery fields", created)
if cleaned:
logger.info("Cleaned delivery fields from %d target configs", cleaned)
async def migrate_template_locale(engine: AsyncEngine) -> None:
"""Add locale column to template_config and command_template_config.
Backfill locale from name: "(RU)" -> "ru", else "en" for system-owned rows.
"""
async with engine.begin() as conn:
for table in ("template_config", "command_template_config"):
if await _has_column(conn, table, "locale"):
continue
logger.info("Adding locale column to %s", table)
await conn.execute(text(f"ALTER TABLE {table} ADD COLUMN locale TEXT DEFAULT ''"))
# Backfill system-owned rows
await conn.execute(text(
f"UPDATE {table} SET locale = 'ru' WHERE user_id = 0 AND name LIKE '%(RU)%'"
))
await conn.execute(text(
f"UPDATE {table} SET locale = 'en' WHERE user_id = 0 AND locale = ''"
))
@@ -170,6 +170,7 @@ class TemplateConfig(SQLModel, table=True):
name: str
description: str = Field(default="")
icon: str = Field(default="")
locale: str = Field(default="") # e.g. "en", "ru"; empty = unspecified
date_format: str = Field(default="%d.%m.%Y, %H:%M UTC")
date_only_format: str = Field(default="%d.%m.%Y")
@@ -330,6 +331,7 @@ class CommandTemplateConfig(SQLModel, table=True):
name: str
description: str = Field(default="")
icon: str = Field(default="")
locale: str = Field(default="") # e.g. "en", "ru"; empty = unspecified
created_at: datetime = Field(default_factory=_utcnow)
@@ -39,13 +39,15 @@ async def lifespan(app: FastAPI):
await init_db()
# Run data migrations (idempotent)
from .database.engine import get_engine
from .database.migrations import migrate_schema, migrate_tracker_targets, migrate_entity_refactor, migrate_template_slots, migrate_target_receivers
from .database.migrations import migrate_schema, migrate_tracker_targets, migrate_entity_refactor, migrate_template_slots, migrate_target_receivers, migrate_template_locale, migrate_receivers_from_config
engine = get_engine()
await migrate_schema(engine)
await migrate_tracker_targets(engine)
await migrate_entity_refactor(engine)
await migrate_template_slots(engine)
await migrate_target_receivers(engine)
await migrate_template_locale(engine)
await migrate_receivers_from_config(engine)
await _seed_default_templates()
await _seed_default_command_templates()
# Configure webhook secret from DB setting (falls back to env var)
@@ -54,9 +56,13 @@ async def lifespan(app: FastAPI):
async with _AS(engine) as _session:
_secret = await _get_setting(_session, "telegram_webhook_secret")
set_webhook_secret(_secret or None)
from .services.scheduler import start_scheduler
from .services.scheduler import start_scheduler, get_scheduler
await start_scheduler()
yield
# Graceful shutdown
scheduler = get_scheduler()
if scheduler.running:
scheduler.shutdown()
app = FastAPI(title="Notify Bridge", version="0.1.0", lifespan=lifespan)
@@ -108,7 +114,8 @@ async def _seed_default_templates():
)
system_configs = result.all()
existing_locales = {
"ru" if "(RU)" in c.name else "en": c for c in system_configs
(c.locale if c.locale else ("ru" if "(RU)" in c.name else "en")): c
for c in system_configs
}
for locale in ("en", "ru"):
@@ -144,6 +151,8 @@ async def _seed_default_templates():
values[col] = "%d.%m.%Y, %H:%M UTC"
elif col == "date_only_format":
values[col] = "%d.%m.%Y"
elif col == "locale":
values[col] = locale
else:
values[col] = "" # empty string for legacy columns
cols_str = ", ".join(values.keys())
@@ -211,6 +220,7 @@ async def _seed_default_command_templates():
provider_type="immich",
name=name,
description=f"Default Immich command templates ({locale.upper()})",
locale=locale,
)
session.add(config)
await session.flush()
@@ -227,7 +237,7 @@ async def _seed_default_command_templates():
)
system_configs = result.all()
for config in system_configs:
locale = "ru" if "(RU)" in config.name else "en"
locale = config.locale if config.locale else ("ru" if "(RU)" in config.name else "en")
slots = load_default_command_templates(locale)
if not slots:
continue
@@ -86,13 +86,8 @@ async def _send_telegram_broadcast(target: NotificationTarget, message: str, rec
if not bot_token:
return {"success": False, "error": "Missing bot_token"}
# Fall back to legacy chat_id if no receivers
if not receivers:
chat_id = target.config.get("chat_id")
if chat_id:
receivers = [{"chat_id": str(chat_id)}]
else:
return {"success": False, "error": "No receivers configured"}
return {"success": False, "error": "No receivers configured"}
results: list[dict] = []
async with aiohttp.ClientSession() as session:
@@ -121,14 +116,8 @@ async def _send_telegram_broadcast(target: NotificationTarget, message: str, rec
async def _send_webhook_broadcast(target: NotificationTarget, message: str, receivers: list[dict]) -> dict:
from notify_bridge_core.notifications.webhook.client import WebhookClient
# Fall back to legacy url if no receivers
if not receivers:
url = target.config.get("url")
headers = target.config.get("headers", {})
if url:
receivers = [{"url": url, "headers": headers}]
else:
return {"success": False, "error": "No receivers configured"}
return {"success": False, "error": "No receivers configured"}
results: list[dict] = []
async with aiohttp.ClientSession() as session:
@@ -206,11 +195,7 @@ async def _send_email_broadcast(target: NotificationTarget, message: str, receiv
async def _send_webhook_like_broadcast(target: NotificationTarget, message: str, receivers: list[dict]) -> dict:
"""Broadcast for Discord and Slack — both use webhook URLs as receivers."""
if not receivers:
webhook_url = target.config.get("webhook_url")
if webhook_url:
receivers = [{"webhook_url": webhook_url}]
else:
return {"success": False, "error": "No receivers configured"}
return {"success": False, "error": "No receivers configured"}
results: list[dict] = []
async with aiohttp.ClientSession() as session:
@@ -238,11 +223,7 @@ async def _send_ntfy_broadcast(target: NotificationTarget, message: str, receive
auth_token = target.config.get("auth_token")
if not receivers:
topic = target.config.get("topic")
if topic:
receivers = [{"topic": topic}]
else:
return {"success": False, "error": "No receivers configured"}
return {"success": False, "error": "No receivers configured"}
from notify_bridge_core.notifications.ntfy.client import NtfyClient
results: list[dict] = []
@@ -307,6 +288,26 @@ def _aggregate(results: list[dict]) -> dict:
# --- Public API used by routes ---
async def send_to_receiver(target: NotificationTarget, receiver_config: dict, message: str) -> dict:
"""Send a message to a single receiver of a target."""
try:
send_fn = {
"telegram": _send_telegram_broadcast,
"webhook": _send_webhook_broadcast,
"email": _send_email_broadcast,
"discord": _send_webhook_like_broadcast,
"slack": _send_webhook_like_broadcast,
"ntfy": _send_ntfy_broadcast,
"matrix": _send_matrix_broadcast,
}.get(target.type)
if send_fn:
return await send_fn(target, message, [receiver_config])
return {"success": False, "error": f"Unknown target type: {target.type}"}
except Exception as e:
_LOGGER.error("Send to receiver failed: %s", e)
return {"success": False, "error": str(e)}
async def send_test_notification(target: NotificationTarget, locale: str = "en") -> dict:
"""Send a simple test message."""
message = _get_test_message(locale, target.type)
@@ -180,7 +180,7 @@ async def _poll_bot(bot_id: int) -> None:
_last_update_id[bot_id] = updates[-1]["update_id"]
# Process each update
from ..commands.handler import handle_command, send_media_group
from ..commands.handler import handle_command, send_media_group, send_reply
for update in updates:
message = update.get("message")
@@ -210,22 +210,8 @@ async def _poll_bot(bot_id: int) -> None:
if isinstance(cmd_response, list):
await send_media_group(bot_token, chat_id, cmd_response)
else:
await _send_reply(bot_token, chat_id, cmd_response)
await send_reply(bot_token, chat_id, cmd_response)
except Exception:
_LOGGER.error("Error handling command from bot %d", bot_id, exc_info=True)
async def _send_reply(bot_token: str, chat_id: str, text: str) -> None:
"""Send a text reply via Telegram Bot API."""
async with aiohttp.ClientSession() as http:
url = f"{TELEGRAM_API_BASE_URL}{bot_token}/sendMessage"
payload: dict[str, Any] = {"chat_id": chat_id, "text": text, "parse_mode": "HTML"}
try:
async with http.post(url, json=payload) as resp:
if resp.status != 200:
result = await resp.json()
if "parse" in str(result.get("description", "")).lower():
payload.pop("parse_mode", None)
await http.post(url, json=payload)
except aiohttp.ClientError as err:
_LOGGER.error("Failed to send Telegram reply: %s", err)