diff --git a/.gitignore b/.gitignore index 61f81b2..e7d0448 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,6 @@ test-data/ node_modules/ frontend/build/ frontend/.svelte-kit/ + +# Logs +*.log diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..84b6bef --- /dev/null +++ b/frontend/src/lib/api.ts @@ -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 | null = null; + +async function refreshAccessToken(): Promise { + if (refreshPromise) return refreshPromise; + refreshPromise = doRefreshAccessToken().finally(() => { + refreshPromise = null; + }); + return refreshPromise; +} + +async function doRefreshAccessToken(): Promise { + 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( + path: string, + options: RequestInit = {} +): Promise { + const token = getToken(); + const headers: Record = { + 'Content-Type': 'application/json', + ...(options.headers as Record) + }; + 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(); +} diff --git a/frontend/src/lib/components/AuthLayout.svelte b/frontend/src/lib/components/AuthLayout.svelte new file mode 100644 index 0000000..2002813 --- /dev/null +++ b/frontend/src/lib/components/AuthLayout.svelte @@ -0,0 +1,191 @@ + + +
+ +
+
+ + +
+
+ {@render children()} +
+
+
+ + diff --git a/frontend/src/lib/components/IconPicker.svelte b/frontend/src/lib/components/IconPicker.svelte index b0714b8..1ae2961 100644 --- a/frontend/src/lib/components/IconPicker.svelte +++ b/frontend/src/lib/components/IconPicker.svelte @@ -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 @@ - {#each filtered() as iconName} + {#each filtered as iconName} @@ -268,10 +265,7 @@
@@ -379,10 +358,7 @@
{#if collapsed} @@ -400,19 +376,13 @@
@@ -472,10 +442,7 @@

{pwdMsg}

{/if} @@ -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; diff --git a/frontend/src/routes/bots/+page.svelte b/frontend/src/routes/bots/+page.svelte index 06c2c37..4c4e870 100644 --- a/frontend/src/routes/bots/+page.svelte +++ b/frontend/src/routes/bots/+page.svelte @@ -1,47 +1,25 @@ {#if !loaded}{:else} {#if activeTab === 'telegram'} - - - - -{#if showForm} - - {#if error}
{error}
{/if} -
-
- -
- form.icon = v} /> - -
-
- {#if !editing} -
- - -
- {/if} - -
-
+ {/if} -{#if bots.length === 0 && !showForm} - - - -{:else} -
- {#each bots as bot} - -
-
-
- -

{bot.name}

- {#if bot.bot_username} - @{bot.bot_username} - {/if} - - - {bot.update_mode === 'webhook' ? t('telegramBot.webhook') : t('telegramBot.polling')} - -
-

{bot.token_preview}

-
-
- editBot(bot)} /> - - - - remove(bot.id)} variant="danger" /> -
-
- - - {#if expandedSection[bot.id] === 'chats'} -
- {#if chatsLoading[bot.id]} -

{t('common.loading')}

- {:else if (chats[bot.id] || []).length === 0} -

{t('telegramBot.noChats')}

- {:else} -
- {#each chats[bot.id] as chat} -
copyChatId(e, chat.chat_id)} - title={t('telegramBot.clickToCopy')} - role="button" tabindex="0"> -
- {chat.title || chat.username || 'Unknown'} - {chatTypeLabel(chat.type)} - {chat.chat_id} -
-
- testChat(e, bot.id, chat.chat_id)} - disabled={chatTesting[`${bot.id}_${chat.chat_id}`]} /> - { e.stopPropagation(); deleteChat(bot.id, chat.id); }} variant="danger" /> -
-
- {/each} -
- {/if} - -
- {/if} - - {#if expandedSection[bot.id] === 'listeners'} -
- {#if botListenerLoading[bot.id]} -

{t('common.loading')}

- {:else if (botListenerStatus[bot.id] || []).length === 0} -

{t('commandTracker.noListeners')}

- {:else} -
- {/if} - - -
-

{t('telegramBot.updateMode')}

-
-
- - -
- - {#if bot.update_mode === 'polling'} - - - {t('telegramBot.pollingActive')} - - {/if} - - {#if bot.update_mode === 'webhook'} - - - {#if webhookStatus[bot.id]} - {@const ws = webhookStatus[bot.id]} - - {ws.url ? t('telegramBot.webhookActive') : t('telegramBot.webhookNotSet')} - {#if ws.pending_update_count > 0} - ({ws.pending_update_count} {t('telegramBot.pendingUpdates')}) - {/if} - - {#if ws.last_error_message} - {t('telegramBot.webhookError')}: {ws.last_error_message} - {/if} - {:else} - - {/if} - {/if} - - {#if !settings.external_url && bot.update_mode === 'webhook'} - - - {t('telegramBot.noExternalDomain')} - - {/if} -
-
-
- {/if} - - {/each} -
-{/if} - - confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} /> -{/if} - - {#if activeTab === 'email'} - - - - -{#if showEmailForm} - - {#if error}
{error}
{/if} -
-
- -
- emailForm.icon = v} /> - -
-
-
- - -
-
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
- - -
-
+ {/if} -{#if emailBots.length === 0 && !showEmailForm} - - - -{:else} -
- {#each emailBots as bot} - -
-
-
- -

{bot.name}

-
-
- {bot.email} - {bot.smtp_host}:{bot.smtp_port} - {#if bot.smtp_use_tls} - TLS - {/if} -
-
-
- testEmailBot(bot.id)} disabled={emailTesting[bot.id]} /> - editEmailBot(bot)} /> - removeEmail(bot.id)} variant="danger" /> -
-
-
- {/each} -
-{/if} - - confirmDeleteEmail?.onconfirm()} oncancel={() => confirmDeleteEmail = null} /> -{/if} - - {#if activeTab === 'matrix'} - - - - -{#if showMatrixForm} - - {#if error}
{error}
{/if} -
-
- -
- matrixForm.icon = v} /> - -
-
-
- - -
-
- - -
-
- - -
- -
-
-{/if} - -{#if matrixBots.length === 0 && !showMatrixForm} - - - -{:else} -
- {#each matrixBots as bot} - -
-
-
- -

{bot.name}

-
-
- {bot.homeserver_url} - {#if bot.display_name} - {bot.display_name} - {/if} -
-
-
- testMatrixBot(bot.id)} disabled={matrixTesting[bot.id]} /> - editMatrixBot(bot)} /> - removeMatrix(bot.id)} variant="danger" /> -
-
-
- {/each} -
-{/if} - - confirmDeleteMatrix?.onconfirm()} oncancel={() => confirmDeleteMatrix = null} /> + {/if} {/if} diff --git a/frontend/src/routes/bots/EmailBotTab.svelte b/frontend/src/routes/bots/EmailBotTab.svelte new file mode 100644 index 0000000..c642ad2 --- /dev/null +++ b/frontend/src/routes/bots/EmailBotTab.svelte @@ -0,0 +1,175 @@ + + + + + + +{#if showEmailForm} + + {#if error}
{error}
{/if} +
+
+ +
+ emailForm.icon = v} /> + +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+{/if} + +{#if emailBots.length === 0 && !showEmailForm} + + + +{:else} +
+ {#each emailBots as bot} + +
+
+
+ +

{bot.name}

+
+
+ {bot.email} + {bot.smtp_host}:{bot.smtp_port} + {#if bot.smtp_use_tls} + TLS + {/if} +
+
+
+ testEmailBot(bot.id)} disabled={emailTesting[bot.id]} /> + editEmailBot(bot)} /> + removeEmail(bot.id)} variant="danger" /> +
+
+
+ {/each} +
+{/if} + + confirmDeleteEmail?.onconfirm()} oncancel={() => confirmDeleteEmail = null} /> diff --git a/frontend/src/routes/bots/MatrixBotTab.svelte b/frontend/src/routes/bots/MatrixBotTab.svelte new file mode 100644 index 0000000..8ddfb34 --- /dev/null +++ b/frontend/src/routes/bots/MatrixBotTab.svelte @@ -0,0 +1,156 @@ + + + + + + +{#if showMatrixForm} + + {#if error}
{error}
{/if} +
+
+ +
+ matrixForm.icon = v} /> + +
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+{/if} + +{#if matrixBots.length === 0 && !showMatrixForm} + + + +{:else} +
+ {#each matrixBots as bot} + +
+
+
+ +

{bot.name}

+
+
+ {bot.homeserver_url} + {#if bot.display_name} + {bot.display_name} + {/if} +
+
+
+ testMatrixBot(bot.id)} disabled={matrixTesting[bot.id]} /> + editMatrixBot(bot)} /> + removeMatrix(bot.id)} variant="danger" /> +
+
+
+ {/each} +
+{/if} + + confirmDeleteMatrix?.onconfirm()} oncancel={() => confirmDeleteMatrix = null} /> diff --git a/frontend/src/routes/bots/TelegramBotTab.svelte b/frontend/src/routes/bots/TelegramBotTab.svelte new file mode 100644 index 0000000..f0594ca --- /dev/null +++ b/frontend/src/routes/bots/TelegramBotTab.svelte @@ -0,0 +1,419 @@ + + + + + + +{#if showForm} + + {#if error}
{error}
{/if} +
+
+ +
+ form.icon = v} /> + +
+
+ {#if !editing} +
+ + +
+ {/if} + +
+
+{/if} + +{#if bots.length === 0 && !showForm} + + + +{:else} +
+ {#each bots as bot} + +
+
+
+ +

{bot.name}

+ {#if bot.bot_username} + @{bot.bot_username} + {/if} + + + {bot.update_mode === 'webhook' ? t('telegramBot.webhook') : t('telegramBot.polling')} + +
+

{bot.token_preview}

+
+
+ editBot(bot)} /> + + + + remove(bot.id)} variant="danger" /> +
+
+ + + {#if expandedSection[bot.id] === 'chats'} +
+ {#if chatsLoading[bot.id]} +

{t('common.loading')}

+ {:else if (chats[bot.id] || []).length === 0} +

{t('telegramBot.noChats')}

+ {:else} +
+ {#each chats[bot.id] as chat} +
copyChatId(e, chat.chat_id)} + title={t('telegramBot.clickToCopy')} + role="button" tabindex="0"> +
+ {chat.title || chat.username || 'Unknown'} + {chatTypeLabel(chat.type)} + {chat.chat_id} +
+
+ testChat(e, bot.id, chat.chat_id)} + disabled={chatTesting[`${bot.id}_${chat.chat_id}`]} /> + { e.stopPropagation(); deleteChat(bot.id, chat.id); }} variant="danger" /> +
+
+ {/each} +
+ {/if} + +
+ {/if} + + {#if expandedSection[bot.id] === 'listeners'} +
+ {#if botListenerLoading[bot.id]} +

{t('common.loading')}

+ {:else if (botListenerStatus[bot.id] || []).length === 0} +

{t('commandTracker.noListeners')}

+ {:else} +
+ {#each botListenerStatus[bot.id] as trk} +
+
+ + {trk.name} + + {trk.enabled ? t('commandTracker.enabled') : t('commandTracker.disabled')} + +
+ + {t('common.edit')} + +
+ {/each} +
+ {/if} + + +
+

{t('telegramBot.updateMode')}

+
+
+ + +
+ + {#if bot.update_mode === 'polling'} + + + {t('telegramBot.pollingActive')} + + {/if} + + {#if bot.update_mode === 'webhook'} + + + {#if webhookStatus[bot.id]} + {@const ws = webhookStatus[bot.id]} + + {ws.url ? t('telegramBot.webhookActive') : t('telegramBot.webhookNotSet')} + {#if ws.pending_update_count > 0} + ({ws.pending_update_count} {t('telegramBot.pendingUpdates')}) + {/if} + + {#if ws.last_error_message} + {t('telegramBot.webhookError')}: {ws.last_error_message} + {/if} + {:else} + + {/if} + {/if} + + {#if !settings.external_url && bot.update_mode === 'webhook'} + + + {t('telegramBot.noExternalDomain')} + + {/if} +
+
+
+ {/if} +
+ {/each} +
+{/if} + + confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} /> diff --git a/frontend/src/routes/command-trackers/+page.svelte b/frontend/src/routes/command-trackers/+page.svelte index f10f5b0..5b6dd4e 100644 --- a/frontend/src/routes/command-trackers/+page.svelte +++ b/frontend/src/routes/command-trackers/+page.svelte @@ -263,7 +263,7 @@
- + {listener.listener_type}
-
- -
-
- - -
-
- -
- - -
- - -
-
- -
-

- Notify Bridge -

-

{t('auth.signInTitle')}

-
- - {#if error} -
- - {error} -
- {/if} - -
-
- - -
-
- - -
- -
-
+ + +
+ +
-
- +
+
+ + +
+
+ + +
+ +
+ diff --git a/frontend/src/routes/notification-trackers/+page.svelte b/frontend/src/routes/notification-trackers/+page.svelte index 961bb45..376cc1e 100644 --- a/frontend/src/routes/notification-trackers/+page.svelte +++ b/frontend/src/routes/notification-trackers/+page.svelte @@ -1,24 +1,24 @@ @@ -317,58 +330,19 @@
{loadError}
{:else if showForm} -
- - {#if error}
{error}
{/if} -
-
- -
- form.icon = v} /> - -
-
-
- - -
- {#if collections.length > 0} -
- - -
- {#each collections.filter(a => !collectionFilter || (a.albumName || a.name || '').toLowerCase().includes(collectionFilter.toLowerCase())) as col} - - {/each} -
-
- {/if} -
-
- - -
-
- - -
-
- - -
-
-
+ {/if} {#if loaded && !loadError} @@ -388,7 +362,7 @@ {tracker.enabled ? t('notificationTracker.active') : t('notificationTracker.paused')} -t +

{(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

- {#if expandedTracker === tracker.id} -
- {#if (tracker.tracker_targets || []).length === 0} -

{t('notificationTracker.noLinkedTargets')}

- {:else} - {#each tracker.tracker_targets as tt} -
-
- - {tt.target_name || `Target #${tt.target_id}`} - {tt.target_type} - {#if !tt.enabled} - {t('notificationTracker.paused')} - {/if} -
-
- - -
- openTestMenu(tt.id, e)} - disabled={Object.keys(ttTesting).some(k => k.startsWith(`${tt.id}_`) && ttTesting[k])} /> -
- updateTargetLink(tracker.id, tt, 'enabled', !tt.enabled)} /> - removeTargetLink(tracker.id, tt.id)} variant="danger" /> -
-
- {/each} - {/if} - - - {#if getUnlinkedTargets(tracker).length > 0} -
- - - - -
- {/if} -
+ 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} {/each} @@ -483,61 +407,22 @@ t -
testMenuOpen = null} - onkeydown={(e) => { if (e.key === 'Escape') testMenuOpen = null; }}> -
-
- {#each testTypes as tt} - {@const trackerId = notificationTrackers.find(t => t.tracker_targets?.some((x: any) => String(x.id) === testMenuOpen))?.id} - - {/each} -
-{/if} + testMenuOpen = null} +/> - { linkWarning = null; }}> - {#if linkWarning} -

- {t('notificationTracker.missingLinksDesc')} -

-
- {#each linkWarning.albums as album} -
- {album.name} - - {album.issue === 'expired' ? t('notificationTracker.expired') : album.issue === 'password-protected' ? t('notificationTracker.passwordProtected') : t('notificationTracker.noLink')} - -
- {/each} -
-

- {t('notificationTracker.linksNote')} -

-
- - {#if linkWarning.albums.some(a => a.issue === 'missing')} - - {/if} -
- {/if} -
+ { linkWarning = null; }} + onautoCreate={autoCreateLinks} + ondismiss={dismissLinkWarning} +/> + 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; + 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(); + + +
+ {#if (tracker.tracker_targets || []).length === 0} +

{t('notificationTracker.noLinkedTargets')}

+ {:else} + {#each tracker.tracker_targets as tt} +
+
+ + {tt.target_name || `Target #${tt.target_id}`} + {tt.target_type} + {#if !tt.enabled} + {t('notificationTracker.paused')} + {/if} +
+
+ + +
+ onopenTestMenu(tt.id, e)} + disabled={Object.keys(ttTesting).some(k => k.startsWith(`${tt.id}_`) && ttTesting[k])} /> +
+ onupdateLink(tt, 'enabled', !tt.enabled)} /> + onremoveLink(tt.id)} variant="danger" /> +
+
+ {/each} + {/if} + + + {#if unlinkedTargets.length > 0} +
+ + + + +
+ {/if} +
diff --git a/frontend/src/routes/notification-trackers/SharedLinkModal.svelte b/frontend/src/routes/notification-trackers/SharedLinkModal.svelte new file mode 100644 index 0000000..1b7c558 --- /dev/null +++ b/frontend/src/routes/notification-trackers/SharedLinkModal.svelte @@ -0,0 +1,48 @@ + + + + {#if linkWarning} +

+ {t('notificationTracker.missingLinksDesc')} +

+
+ {#each linkWarning.albums as album} +
+ {album.name} + + {album.issue === 'expired' ? t('notificationTracker.expired') : album.issue === 'password-protected' ? t('notificationTracker.passwordProtected') : t('notificationTracker.noLink')} + +
+ {/each} +
+

+ {t('notificationTracker.linksNote')} +

+
+ + {#if linkWarning.albums.some(a => a.issue === 'missing')} + + {/if} +
+ {/if} +
diff --git a/frontend/src/routes/notification-trackers/TestMenu.svelte b/frontend/src/routes/notification-trackers/TestMenu.svelte new file mode 100644 index 0000000..79142d1 --- /dev/null +++ b/frontend/src/routes/notification-trackers/TestMenu.svelte @@ -0,0 +1,37 @@ + + +{#if testMenuOpen} + +
{ if (e.key === 'Escape') onclose(); }}> +
+
+ {#each testTypes as tt} + + {/each} +
+{/if} diff --git a/frontend/src/routes/notification-trackers/TrackerForm.svelte b/frontend/src/routes/notification-trackers/TrackerForm.svelte new file mode 100644 index 0000000..e75ff75 --- /dev/null +++ b/frontend/src/routes/notification-trackers/TrackerForm.svelte @@ -0,0 +1,96 @@ + + +
+ + {#if error}
{error}
{/if} +
+
+ +
+ form.icon = v} /> + +
+
+
+ + +
+ {#if collections.length > 0} +
+ + +
+ {#each collections.filter(a => !collectionFilter || (a.albumName || a.name || '').toLowerCase().includes(collectionFilter.toLowerCase())) as col} + + {/each} +
+
+ {/if} +
+
+ + +
+
+ + +
+
+ + +
+
+
diff --git a/frontend/src/routes/setup/+page.svelte b/frontend/src/routes/setup/+page.svelte index 332a91e..1b3a258 100644 --- a/frontend/src/routes/setup/+page.svelte +++ b/frontend/src/routes/setup/+page.svelte @@ -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 @@ } -
-
-
- -
-
-
-
- -
-

- Notify Bridge -

-

{t('auth.setupDescription')}

-
- - {#if error} -
- - {error} -
- {/if} - -
-
- - -
-
- - -
-
- - -
- -
+ +
+
+
+

+ Notify Bridge +

+

{t('auth.setupDescription')}

-
- +
+
+ + +
+
+ + +
+
+ + +
+ +
+ diff --git a/frontend/src/routes/targets/+page.svelte b/frontend/src/routes/targets/+page.svelte index 1947f84..b69ed78 100644 --- a/frontend/src/routes/targets/+page.svelte +++ b/frontend/src/routes/targets/+page.svelte @@ -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 = { @@ -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>({}); + + // ── Target form state ── + let showForm = $state(false); let editing = $state(null); let formType = $state('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(null); + // ── Receiver inline form state ── + + let addingReceiverForTarget = $state(null); + let receiverForm = $state>({}); + let receiverSubmitting = $state(false); + let receiverBotChats = $state>({}); + let receiverHeadersError = $state(''); + let confirmDeleteReceiver = $state<{ targetId: number; receiver: TargetReceiver } | null>(null); + let receiverTesting = $state>({}); + + // ── 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 }; } } @@ -258,32 +397,10 @@ {#if telegramBots.length === 0} -

{t('telegramBot.noBots')}

+

{t('telegramBot.noBots')}

{/if}
- {#if form.bot_id} -
- - {#if (botChats[form.bot_id] || []).length > 0} - -

- -

- {:else} - -

{t('telegramBot.noChats')}

- {/if} -
- {/if} -
{/if}
- {:else if formType === 'webhook'} -
- - -
-
- - - {#if headersError}

{headersError}

{/if} -
{:else if formType === 'discord' || formType === 'slack'} -
- - -
-
- - -
{t('targets.selectEmailBot')} {#if emailBots.length === 0} -

{t('emailBot.noBots')}

+

{t('emailBot.noBots')}

{/if}
-
- - -
{:else if formType === 'matrix'}
{#if matrixBots.length === 0} -

{t('matrixBot.noBots')}

+

{t('matrixBot.noBots')}

{/if}
-
- - -
{/if} {#if formType === 'telegram'} @@ -398,35 +485,18 @@ {:else}
- {#each targets as target} + {#each targets as target (target.id)} +

{target.name}

{#if !activeType}{target.type}{/if} - {#if target.receiver_count}{target.receiver_count} receiver(s){/if} + {#if (target.receivers || []).length > 0}{(target.receivers || []).length} receiver(s){/if} {#if getBotName(target)}{/if}
-

- {#if target.type === 'telegram'} - Chat: {#if target.chat_name}{target.chat_name} ({target.config?.chat_id}){:else}{target.config?.chat_id || '***'}{/if} - {#if target.config?.chat_action} - {target.config.chat_action} - {/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} -

edit(target)} /> @@ -434,6 +504,109 @@ confirmDelete = target} variant="danger" />
+ + +
+
+

{t('targets.receivers')}

+
+ + {#if (target.receivers || []).length === 0 && addingReceiverForTarget !== target.id} +

{t('targets.noReceivers')}

+ {/if} + + {#each target.receivers || [] as recv (recv.id)} +
+
+ + {receiverLabel(target, recv)} +
+
+ testReceiver(target.id, recv.id)} + disabled={receiverTesting[recv.id]} size={16} /> + toggleReceiver(target.id, recv)} + size={16} + /> + confirmDeleteReceiver = { targetId: target.id, receiver: recv }} + variant="danger" + size={16} + /> +
+
+ {/each} + + + {#if addingReceiverForTarget === target.id} +
+ {#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} + + {:else} + + {/if} + {#if botId} + + {/if} + {:else if target.type === 'email'} + + {:else if target.type === 'webhook'} + + + {#if receiverHeadersError}

{receiverHeadersError}

{/if} + {:else if target.type === 'discord' || target.type === 'slack'} + + {:else if target.type === 'ntfy'} + + {:else if target.type === 'matrix'} + + {/if} + +
+ + +
+
+ {:else} + + {/if} +
{/each}
@@ -447,3 +620,10 @@ onconfirm={() => { if (confirmDelete) { remove(confirmDelete.id); confirmDelete = null; } }} oncancel={() => confirmDelete = null} /> + + { if (confirmDeleteReceiver) { removeReceiver(confirmDeleteReceiver.targetId, confirmDeleteReceiver.receiver.id); confirmDeleteReceiver = null; } }} + oncancel={() => confirmDeleteReceiver = null} +/> diff --git a/frontend/src/routes/template-configs/+page.svelte b/frontend/src/routes/template-configs/+page.svelte index ccd5a23..d5d3c1d 100644 --- a/frontend/src/routes/template-configs/+page.svelte +++ b/frontend/src/routes/template-configs/+page.svelte @@ -190,18 +190,41 @@ } function sanitizePreview(html: string): string { - // Allow only Telegram-safe HTML tags, escape everything else - return html - .replace(/&/g, '&') - .replace(//g, '>') - // Restore allowed tags — only http(s) URLs for to prevent javascript: XSS - .replace(/<a href="(https?:\/\/[^"]*)">/g, '') - .replace(/<\/a>/g, '') - .replace(/<b>/g, '').replace(/<\/b>/g, '') - .replace(/<i>/g, '').replace(/<\/i>/g, '') - .replace(/<code>/g, '').replace(/<\/code>/g, '') - .replace(/<pre>/g, '
').replace(/<\/pre>/g, '
'); + // 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) { diff --git a/packages/core/src/notify_bridge_core/notifications/dispatcher.py b/packages/core/src/notify_bridge_core/notifications/dispatcher.py index 079d08c..4ce2a46 100644 --- a/packages/core/src/notify_bridge_core/notifications/dispatcher.py +++ b/packages/core/src/notify_bridge_core/notifications/dispatcher.py @@ -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: diff --git a/packages/server/src/notify_bridge_server/api/app_settings.py b/packages/server/src/notify_bridge_server/api/app_settings.py index dba03f4..d98eb48 100644 --- a/packages/server/src/notify_bridge_server/api/app_settings.py +++ b/packages/server/src/notify_bridge_server/api/app_settings.py @@ -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.""" diff --git a/packages/server/src/notify_bridge_server/api/command_configs.py b/packages/server/src/notify_bridge_server/api/command_configs.py index cd56a87..846a94e 100644 --- a/packages/server/src/notify_bridge_server/api/command_configs.py +++ b/packages/server/src/notify_bridge_server/api/command_configs.py @@ -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 diff --git a/packages/server/src/notify_bridge_server/api/delete_protection.py b/packages/server/src/notify_bridge_server/api/delete_protection.py index 44ae35a..45d2f0e 100644 --- a/packages/server/src/notify_bridge_server/api/delete_protection.py +++ b/packages/server/src/notify_bridge_server/api/delete_protection.py @@ -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( diff --git a/packages/server/src/notify_bridge_server/api/notification_trackers.py b/packages/server/src/notify_bridge_server/api/notification_trackers.py index 77ed1ec..9eb58d1 100644 --- a/packages/server/src/notify_bridge_server/api/notification_trackers.py +++ b/packages/server/src/notify_bridge_server/api/notification_trackers.py @@ -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) diff --git a/packages/server/src/notify_bridge_server/api/target_receivers.py b/packages/server/src/notify_bridge_server/api/target_receivers.py index c0f19b9..8581a2f 100644 --- a/packages/server/src/notify_bridge_server/api/target_receivers.py +++ b/packages/server/src/notify_bridge_server/api/target_receivers.py @@ -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, diff --git a/packages/server/src/notify_bridge_server/api/targets.py b/packages/server/src/notify_bridge_server/api/targets.py index fedb224..2680760 100644 --- a/packages/server/src/notify_bridge_server/api/targets.py +++ b/packages/server/src/notify_bridge_server/api/targets.py @@ -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 diff --git a/packages/server/src/notify_bridge_server/auth/routes.py b/packages/server/src/notify_bridge_server/auth/routes.py index 74e66bd..8256ce2 100644 --- a/packages/server/src/notify_bridge_server/auth/routes.py +++ b/packages/server/src/notify_bridge_server/auth/routes.py @@ -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() diff --git a/packages/server/src/notify_bridge_server/commands/handler.py b/packages/server/src/notify_bridge_server/commands/handler.py index 542a7e4..1983065 100644 --- a/packages/server/src/notify_bridge_server/commands/handler.py +++ b/packages/server/src/notify_bridge_server/commands/handler.py @@ -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: diff --git a/packages/server/src/notify_bridge_server/commands/webhook.py b/packages/server/src/notify_bridge_server/commands/webhook.py index 90e16a9..45f8c56 100644 --- a/packages/server/src/notify_bridge_server/commands/webhook.py +++ b/packages/server/src/notify_bridge_server/commands/webhook.py @@ -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: diff --git a/packages/server/src/notify_bridge_server/config.py b/packages/server/src/notify_bridge_server/config.py index c83c3f9..b31159a 100644 --- a/packages/server/src/notify_bridge_server/config.py +++ b/packages/server/src/notify_bridge_server/config.py @@ -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." ) diff --git a/packages/server/src/notify_bridge_server/database/migrations.py b/packages/server/src/notify_bridge_server/database/migrations.py index 9df96a2..332c994 100644 --- a/packages/server/src/notify_bridge_server/database/migrations.py +++ b/packages/server/src/notify_bridge_server/database/migrations.py @@ -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 = ''" + )) diff --git a/packages/server/src/notify_bridge_server/database/models.py b/packages/server/src/notify_bridge_server/database/models.py index 36be5f4..380157b 100644 --- a/packages/server/src/notify_bridge_server/database/models.py +++ b/packages/server/src/notify_bridge_server/database/models.py @@ -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) diff --git a/packages/server/src/notify_bridge_server/main.py b/packages/server/src/notify_bridge_server/main.py index 39db644..09046d9 100644 --- a/packages/server/src/notify_bridge_server/main.py +++ b/packages/server/src/notify_bridge_server/main.py @@ -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 diff --git a/packages/server/src/notify_bridge_server/services/notifier.py b/packages/server/src/notify_bridge_server/services/notifier.py index 01ef536..56ec596 100644 --- a/packages/server/src/notify_bridge_server/services/notifier.py +++ b/packages/server/src/notify_bridge_server/services/notifier.py @@ -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) diff --git a/packages/server/src/notify_bridge_server/services/telegram_poller.py b/packages/server/src/notify_bridge_server/services/telegram_poller.py index 94a2dee..1cf978c 100644 --- a/packages/server/src/notify_bridge_server/services/telegram_poller.py +++ b/packages/server/src/notify_bridge_server/services/telegram_poller.py @@ -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)