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:
@@ -53,3 +53,6 @@ test-data/
|
||||
node_modules/
|
||||
frontend/build/
|
||||
frontend/.svelte-kit/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
@@ -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' }) },
|
||||
|
||||
@@ -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') },
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
// Restore allowed tags — only http(s) URLs for <a> to prevent javascript: XSS
|
||||
.replace(/<a href="(https?:\/\/[^"]*)">/g, '<a href="$1" target="_blank" rel="noopener noreferrer">')
|
||||
.replace(/<\/a>/g, '</a>')
|
||||
.replace(/<b>/g, '<b>').replace(/<\/b>/g, '</b>')
|
||||
.replace(/<i>/g, '<i>').replace(/<\/i>/g, '</i>')
|
||||
.replace(/<code>/g, '<code>').replace(/<\/code>/g, '</code>')
|
||||
.replace(/<pre>/g, '<pre>').replace(/<\/pre>/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)
|
||||
|
||||
Reference in New Issue
Block a user