feat: comprehensive code review fixes — security, performance, quality

Backend security:
- Reject Gitea webhooks when webhook_secret is empty (was silently skipping)
- Add slowapi rate limiting on login (5/min) and setup (3/min) endpoints
- Add CORS middleware with configurable origins
- Mask telegram_webhook_secret in settings API response
- Protect system-owned command template configs from regular user modification
- Increase minimum password length to 8 characters

Backend performance:
- Batch queries in _resolve_command_context (3 queries instead of 3N)
- Concurrent album fetching with asyncio.gather in immich commands
- Singleton Jinja2 SandboxedEnvironment (reuse instead of per-render creation)
- TTLCache for rate limits (bounded memory, auto-eviction)
- Optional aiohttp session reuse in send_reply/send_media_group

Backend code quality:
- Extract dispatch_helpers.py (shared link_data loading + event filtering)
- Extract database/seeds.py from main.py (490 lines → dedicated module)
- Split immich_handler.py (415 lines) into commands/immich/ subpackage
- Replace bare except blocks with logged warnings
- Add per-provider config validation (Pydantic models)
- Truncate command input to 512 chars
- Expose usage_* and desc_* slots in capabilities and variables API

Frontend security:
- CSS.escape() for user-controlled querySelector in highlight.ts
- Client-side password min 8 chars validation on setup and password change

Frontend code quality:
- Replace any types with proper interfaces across top files
- Decompose targets/+page.svelte into TargetForm + ReceiverSection
- Fix $derived.by usage, $state mutation patterns
- Add console.warn to empty catch blocks

Frontend UX:
- Auth redirect via goto() with "Redirecting..." state
- Platform-aware Ctrl/Cmd K keyboard hint
- Remove stat-card hover transform

Frontend accessibility:
- Modal: role=dialog, aria-modal, focus trap, restore focus
- EntitySelect/IconGridSelect: listbox/option roles, aria-selected/disabled
This commit is contained in:
2026-03-23 01:59:51 +03:00
parent 31584c5d31
commit e0bae394ee
78 changed files with 2855 additions and 1658 deletions
+1
View File
@@ -16,3 +16,4 @@ Detailed context is split into focused documents under `.claude/docs/`. Read the
2. **Overlays** MUST use `position: fixed` with inline styles and `z-index: 9999` — see [frontend-architecture.md](.claude/docs/frontend-architecture.md).
3. **Template variables** must be updated in 6 files simultaneously — see [template-system.md](.claude/docs/template-system.md).
4. **Entity cache** — shared entities use `$state`-based caches in `frontend/src/lib/stores/caches.svelte.ts`. Always use cache for cross-page data; invalidate after mutations — see [frontend-architecture.md](.claude/docs/frontend-architecture.md).
5. **Telegram API** — ALL Telegram Bot API calls (sendMessage, sendPhoto, sendMediaGroup, etc.) MUST go through `TelegramClient` in `packages/core/src/notify_bridge_core/notifications/telegram/client.py`. NEVER duplicate sending logic in command handlers, API routes, or services. If `TelegramClient` lacks a method you need, add it there.
+2 -2
View File
@@ -49,8 +49,8 @@ async function doRefreshAccessToken(): Promise<boolean> {
setTokens(data.access_token, data.refresh_token);
return true;
}
} catch {
// ignore
} catch (e) {
console.warn('Token refresh failed:', e);
}
return false;
}
+1 -6
View File
@@ -4,12 +4,7 @@
import { api, setTokens, clearTokens, isAuthenticated } from './api';
import { clearAllCaches } from './stores/caches.svelte';
interface User {
id: number;
username: string;
role: string;
}
import type { User } from './types';
let user = $state<User | null>(null);
let loading = $state(true);
@@ -0,0 +1,10 @@
<script lang="ts">
import MdiIcon from './MdiIcon.svelte';
let { icon = 'mdiInformation', message = '', size = 40 }: { icon?: string; message?: string; size?: number } = $props();
</script>
<div class="flex flex-col items-center py-8 gap-3" style="color: var(--color-muted-foreground);">
<div style="opacity: 0.4;"><MdiIcon name={icon} {size} /></div>
{#if message}<p class="text-sm">{message}</p>{/if}
</div>
@@ -6,6 +6,8 @@
label: string;
icon?: string;
desc?: string;
disabled?: boolean;
disabledHint?: string;
}
let {
@@ -62,6 +64,7 @@
}
function selectItem(item: EntityItem) {
if (item.disabled) return;
value = item.value || null;
onselect?.(value);
closePalette();
@@ -103,6 +106,8 @@
<!-- Trigger button -->
<button type="button" class="es-trigger" class:es-sm={size === 'sm'} onclick={openPalette}
aria-expanded={open}
aria-haspopup="listbox"
style="opacity: {disabled ? 0.5 : 1}; cursor: {disabled ? 'default' : 'pointer'};">
{#if selected}
{#if selected.icon}
@@ -135,15 +140,19 @@
<kbd class="ep-kbd">ESC</kbd>
</div>
<div class="ep-list" bind:this={listEl}>
<div class="ep-list" bind:this={listEl} role="listbox">
{#if filtered.length === 0}
<div class="ep-empty">No matches</div>
{:else}
{#each filtered as item, i}
<button
class="ep-item"
class:ep-highlight={i === highlightIdx}
class:ep-highlight={i === highlightIdx && !item.disabled}
class:ep-current={String(item.value) === String(value)}
class:ep-disabled={item.disabled}
role="option"
aria-selected={String(item.value) === String(value)}
aria-disabled={item.disabled || undefined}
onclick={() => selectItem(item)}
onmouseenter={() => highlightIdx = i}
type="button"
@@ -152,7 +161,9 @@
<span class="ep-item-icon"><MdiIcon name={item.icon} size={18} /></span>
{/if}
<span class="ep-item-label">{item.label}</span>
{#if item.desc}
{#if item.disabled && item.disabledHint}
<span class="ep-item-hint">{item.disabledHint}</span>
{:else if item.desc}
<span class="ep-item-desc">{item.desc}</span>
{/if}
</button>
@@ -292,6 +303,13 @@
.ep-item:hover, .ep-item.ep-highlight {
background: var(--color-muted);
}
.ep-item.ep-disabled {
opacity: 0.4;
cursor: default;
}
.ep-item.ep-disabled:hover {
background: transparent;
}
.ep-item.ep-current {
border-left-color: var(--color-primary);
}
@@ -319,4 +337,11 @@
text-overflow: ellipsis;
max-width: 40%;
}
.ep-item-hint {
font-size: 0.7rem;
font-style: italic;
color: var(--color-muted-foreground);
white-space: nowrap;
margin-left: auto;
}
</style>
@@ -0,0 +1,262 @@
<script lang="ts">
import { t } from '$lib/i18n';
import MdiIcon from './MdiIcon.svelte';
interface DayData {
date: string;
[eventType: string]: string | number;
}
let { days = [] }: { days: DayData[] } = $props();
const EVENT_TYPES = ['assets_added', 'assets_removed', 'collection_renamed', 'collection_deleted', 'sharing_changed'] as const;
const COLORS: Record<string, string> = {
assets_added: '#059669',
assets_removed: '#ef4444',
collection_renamed: '#6366f1',
collection_deleted: '#dc2626',
sharing_changed: '#f59e0b',
};
const LABELS: Record<string, string> = {
assets_added: 'dashboard.filterAssetsAdded',
assets_removed: 'dashboard.filterAssetsRemoved',
collection_renamed: 'dashboard.filterRenamed',
collection_deleted: 'dashboard.filterDeleted',
sharing_changed: 'dashboard.filterSharingChanged',
};
let tooltip = $state<{ x: number; y: number; text: string } | null>(null);
const maxValue = $derived.by(() => {
let max = 0;
for (const day of days) {
let sum = 0;
for (const et of EVENT_TYPES) {
sum += (day[et] as number) || 0;
}
if (sum > max) max = sum;
}
return Math.max(max, 1);
});
const hasData = $derived(days.some(d => EVENT_TYPES.some(et => (d[et] as number) > 0)));
// Active event types (ones that actually have data)
const activeTypes = $derived(EVENT_TYPES.filter(et => days.some(d => (d[et] as number) > 0)));
function formatDate(dateStr: string): string {
const d = new Date(dateStr + 'T00:00:00');
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
function showTooltip(e: MouseEvent, day: DayData) {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const parts: string[] = [];
for (const et of EVENT_TYPES) {
const v = (day[et] as number) || 0;
if (v > 0) parts.push(`${t(LABELS[et])}: ${v} ${v === 1 ? t('dashboard.event') : t('dashboard.events')}`);
}
if (parts.length === 0) parts.push(`0 ${t('dashboard.events')}`);
tooltip = {
x: rect.left + rect.width / 2,
y: rect.top,
text: `${formatDate(day.date)}\n${parts.join('\n')}`,
};
}
function hideTooltip() {
tooltip = null;
}
</script>
<div class="chart-wrapper">
<div class="chart-header">
<h4 class="chart-title">
<MdiIcon name="mdiChartBar" size={18} />
{t('dashboard.eventActivity')}
</h4>
<span class="chart-subtitle">{t('dashboard.last14days')}</span>
</div>
{#if !hasData}
<div class="chart-empty">
<MdiIcon name="mdiChartBoxOutline" size={32} />
<span>{t('dashboard.noChartData')}</span>
</div>
{:else}
<div class="chart-body">
<div class="chart-bars">
{#each days as day, i}
{@const total = EVENT_TYPES.reduce((s, et) => s + ((day[et] as number) || 0), 0)}
<div
class="bar-col"
role="img"
aria-label="{formatDate(day.date)}: {total} {t('dashboard.events')}"
onmouseenter={(e) => showTooltip(e, day)}
onmouseleave={hideTooltip}
>
<div class="bar-stack" style="--max: {maxValue}">
{#each EVENT_TYPES as et}
{@const v = (day[et] as number) || 0}
{#if v > 0}
<div
class="bar-segment"
style="height: {(v / maxValue) * 100}%; background: {COLORS[et]};"
></div>
{/if}
{/each}
</div>
<span class="bar-label">{i % 2 === 0 ? formatDate(day.date) : ''}</span>
</div>
{/each}
</div>
<!-- Legend -->
<div class="chart-legend">
{#each activeTypes as et}
<span class="legend-item">
<span class="legend-dot" style="background: {COLORS[et]};"></span>
{t(LABELS[et])}
</span>
{/each}
</div>
</div>
{/if}
</div>
{#if tooltip}
<div
class="chart-tooltip"
style="position: fixed; left: {tooltip.x}px; top: {tooltip.y}px; z-index: 9999; transform: translate(-50%, -100%) translateY(-8px);"
>
{#each tooltip.text.split('\n') as line}
<div>{line}</div>
{/each}
</div>
{/if}
<style>
.chart-wrapper {
background: var(--color-card);
border: 1px solid var(--color-border);
border-radius: 0.75rem;
padding: 1.25rem;
margin-bottom: 1.5rem;
transition: border-color 0.2s;
}
.chart-wrapper:hover {
border-color: var(--color-primary);
box-shadow: 0 0 16px var(--color-glow);
}
.chart-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.chart-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
font-weight: 600;
margin: 0;
}
.chart-subtitle {
font-size: 0.75rem;
color: var(--color-muted-foreground);
font-family: var(--font-mono);
}
.chart-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 2rem 0;
color: var(--color-muted-foreground);
opacity: 0.5;
font-size: 0.8rem;
}
.chart-body {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.chart-bars {
display: flex;
align-items: flex-end;
gap: 3px;
height: 120px;
}
.bar-col {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
height: 100%;
cursor: default;
}
.bar-stack {
flex: 1;
width: 100%;
display: flex;
flex-direction: column-reverse;
align-items: stretch;
justify-content: flex-start;
border-radius: 3px 3px 0 0;
overflow: hidden;
min-height: 0;
}
.bar-segment {
width: 100%;
min-height: 2px;
transition: height 0.5s ease, opacity 0.2s;
opacity: 0.85;
}
.bar-col:hover .bar-segment {
opacity: 1;
}
.bar-label {
font-size: 0.55rem;
color: var(--color-muted-foreground);
margin-top: 4px;
white-space: nowrap;
font-family: var(--font-mono);
}
.chart-legend {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
justify-content: center;
}
.legend-item {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.65rem;
color: var(--color-muted-foreground);
font-family: var(--font-mono);
text-transform: uppercase;
letter-spacing: 0.02em;
}
.legend-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.chart-tooltip {
background: var(--color-card);
border: 1px solid var(--color-border);
border-radius: 0.5rem;
padding: 0.5rem 0.75rem;
font-size: 0.7rem;
font-family: var(--font-mono);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
pointer-events: none;
white-space: nowrap;
line-height: 1.5;
}
</style>
+40
View File
@@ -0,0 +1,40 @@
<script lang="ts">
let { text = '' } = $props<{ text: string }>();
let visible = $state(false);
let tooltipStyle = $state('');
let btnEl: HTMLButtonElement;
function show() {
if (!btnEl) return;
visible = true;
const rect = btnEl.getBoundingClientRect();
const tooltipWidth = 272;
let left = rect.left + rect.width / 2 - tooltipWidth / 2;
if (left < 8) left = 8;
if (left + tooltipWidth > window.innerWidth - 8) left = window.innerWidth - tooltipWidth - 8;
tooltipStyle = `position:fixed; z-index:99999; bottom:${window.innerHeight - rect.top + 8}px; left:${left}px; width:${tooltipWidth}px;`;
}
function hide() {
visible = false;
}
</script>
<button type="button" bind:this={btnEl}
class="inline-flex items-center justify-center w-3.5 h-3.5 rounded-full text-[9px] font-bold leading-none
border border-[var(--color-border)] bg-[var(--color-muted)] text-[var(--color-muted-foreground)]
hover:bg-[var(--color-border)] hover:text-[var(--color-foreground)]
transition-colors cursor-help align-middle ml-2 flex-shrink-0"
onmouseenter={show}
onmouseleave={hide}
onfocus={show}
onblur={hide}
aria-label={text}
tabindex="0"
>?</button>
{#if visible}
<div role="tooltip" style="{tooltipStyle} background:var(--color-card); color:var(--color-foreground); border:1px solid var(--color-border); box-shadow:0 10px 30px rgba(0,0,0,0.3); padding:0.625rem 0.75rem; border-radius:0.5rem; font-size:0.75rem; white-space:normal; line-height:1.625; pointer-events:none;">
{text}
</div>
{/if}
@@ -0,0 +1,65 @@
<script lang="ts">
import MdiIcon from './MdiIcon.svelte';
let { icon, title = '', onclick, disabled = false, variant = 'default', size = 16, class: className = '' } = $props<{
icon: string;
title?: string;
onclick?: (e: MouseEvent) => void;
disabled?: boolean;
variant?: 'default' | 'danger' | 'success';
size?: number;
class?: string;
}>();
</script>
<button type="button" {title} {onclick} {disabled}
class="icon-btn icon-btn-{variant} {className}"
>
<MdiIcon name={icon} {size} />
</button>
<style>
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 0.5rem;
border: none;
background: transparent;
cursor: pointer;
transition: all 0.2s ease;
}
.icon-btn:disabled {
opacity: 0.4;
pointer-events: none;
}
.icon-btn-default {
color: var(--color-muted-foreground);
}
.icon-btn-default:hover {
color: var(--color-foreground);
background: var(--color-muted);
}
.icon-btn-danger {
color: var(--color-muted-foreground);
}
.icon-btn-danger:hover {
color: var(--color-destructive);
background: var(--color-error-bg);
box-shadow: 0 0 8px rgba(239, 68, 68, 0.15);
}
.icon-btn-success {
color: var(--color-muted-foreground);
}
.icon-btn-success:hover {
color: var(--color-success-fg);
background: var(--color-success-bg);
box-shadow: 0 0 8px rgba(5, 150, 105, 0.15);
}
</style>
@@ -76,6 +76,8 @@
<button type="button" bind:this={triggerEl} onclick={toggle}
class="icon-grid-trigger {compact ? 'icon-grid-compact' : ''}"
class:disabled
aria-expanded={open}
aria-haspopup="listbox"
style="opacity: {disabled ? 0.5 : 1}; cursor: {disabled ? 'default' : 'pointer'};">
{#if selected}
<span class="icon-grid-trigger-icon"><MdiIcon name={selected.icon} size={compact ? 14 : 18} /></span>
@@ -99,11 +101,13 @@
class="icon-grid-search" type="text" autocomplete="off"
onkeydown={handleKeydown} />
{/if}
<div class="icon-grid" style="grid-template-columns: repeat({columns}, 1fr);">
<div class="icon-grid" style="grid-template-columns: repeat({columns}, 1fr);" role="listbox">
{#each filtered as item}
<button type="button"
class="icon-grid-cell"
class:active={String(item.value) === String(value)}
role="option"
aria-selected={String(item.value) === String(value)}
onclick={() => select(item)}>
<span class="icon-grid-cell-icon"><MdiIcon name={item.icon} size={22} /></span>
<span class="icon-grid-cell-label">{item.label}</span>
@@ -0,0 +1,24 @@
<script lang="ts">
let { lines = 3 } = $props<{ lines?: number }>();
</script>
<div class="space-y-3">
{#each Array(lines) as _, i}
<div class="loading-bar" style="animation-delay: {i * 100}ms;"></div>
{/each}
</div>
<style>
.loading-bar {
height: 4rem;
border-radius: 0.75rem;
background: linear-gradient(90deg, var(--color-muted) 25%, var(--color-border) 50%, var(--color-muted) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
</style>
+158
View File
@@ -0,0 +1,158 @@
<script lang="ts">
import { onMount } from 'svelte';
import MdiIcon from './MdiIcon.svelte';
let { open = false, title = '', onclose, children } = $props<{
open: boolean;
title?: string;
onclose: () => void;
children: import('svelte').Snippet;
}>();
let visible = $state(false);
let panelEl: HTMLDivElement;
let previouslyFocused: HTMLElement | null = null;
const uniqueId = `modal-${Math.random().toString(36).slice(2, 9)}`;
$effect(() => {
if (open) {
previouslyFocused = document.activeElement as HTMLElement | null;
requestAnimationFrame(() => {
visible = true;
// Focus first focusable element inside the modal
requestAnimationFrame(() => {
const focusable = panelEl?.querySelector<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
focusable?.focus();
});
});
} else {
visible = false;
// Restore focus to the previously focused element
if (previouslyFocused && typeof previouslyFocused.focus === 'function') {
previouslyFocused.focus();
previouslyFocused = null;
}
}
});
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
onclose();
return;
}
// Focus trap: Tab / Shift+Tab
if (e.key === 'Tab' && panelEl) {
const focusableElements = panelEl.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (focusableElements.length === 0) return;
const first = focusableElements[0];
const last = focusableElements[focusableElements.length - 1];
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}
}
function handleBackdropKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') onclose();
}
</script>
<svelte:window onkeydown={open ? handleKeydown : undefined} />
{#if open}
<div
class="modal-backdrop"
class:visible
style="position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 9999; display: flex; align-items: center; justify-content: center;"
onclick={onclose}
onkeydown={handleBackdropKeydown}
role="presentation"
>
<div
bind:this={panelEl}
class="modal-panel"
class:visible
role="dialog"
aria-modal="true"
aria-labelledby="modal-title-{uniqueId}"
style="background: var(--color-card); border: 1px solid var(--color-border); border-radius: 1rem; width: 100%; max-width: 32rem; max-height: 80vh; margin: 1rem; display: flex; flex-direction: column;"
onclick={(e) => e.stopPropagation()}
>
<div style="display: flex; align-items: center; justify-content: space-between; padding: 1.5rem 1.5rem 1rem;">
<h3 id="modal-title-{uniqueId}" style="font-size: 1.125rem; font-weight: 600;">{title}</h3>
<button class="modal-close" onclick={onclose} aria-label="Close">
<MdiIcon name="mdiClose" size={18} />
</button>
</div>
<div style="padding: 0 1.5rem 1.5rem; overflow-y: auto;">
{@render children()}
</div>
</div>
</div>
{/if}
<style>
.modal-backdrop {
background: rgba(0, 0, 0, 0);
backdrop-filter: blur(0px);
transition: background 0.25s ease, backdrop-filter 0.25s ease;
}
.modal-backdrop.visible {
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
}
.modal-panel {
opacity: 0;
transform: translateY(12px) scale(0.97);
transition: opacity 0.25s ease, transform 0.25s ease;
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.12),
0 0 0 1px rgba(255, 255, 255, 0.05) inset;
}
:global([data-theme="dark"]) .modal-panel {
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.4),
0 0 48px var(--color-glow),
0 0 0 1px rgba(255, 255, 255, 0.03) inset;
}
.modal-panel.visible {
opacity: 1;
transform: translateY(0) scale(1);
}
.modal-close {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 0.5rem;
border: none;
background: transparent;
color: var(--color-muted-foreground);
cursor: pointer;
transition: all 0.2s ease;
}
.modal-close:hover {
background: var(--color-muted);
color: var(--color-foreground);
}
</style>
@@ -0,0 +1,21 @@
<script lang="ts">
let { title, description = '', children } = $props<{
title: string;
description?: string;
children?: import('svelte').Snippet;
}>();
</script>
<div class="flex items-center justify-between mb-8">
<div class="animate-fade-slide-in">
<h2 class="text-2xl font-semibold tracking-tight">{title}</h2>
{#if description}
<p class="text-sm mt-1.5" style="color: var(--color-muted-foreground);">{description}</p>
{/if}
</div>
{#if children}
<div class="animate-fade-slide-in" style="animation-delay: 60ms;">
{@render children()}
</div>
{/if}
</div>
@@ -40,32 +40,34 @@
}
/** All searchable entity groups. */
const GROUPS = [
{ key: 'providers', label: 'nav.providers', icon: 'mdiServer', href: '/providers',
mapFn: (e: any) => ({ detail: e.type, icon: e.icon || 'mdiServer' }) },
{ key: 'notification_trackers', label: 'nav.notification', icon: 'mdiRadar', href: '/notification-trackers',
mapFn: (e: any) => ({ detail: e.enabled ? 'enabled' : 'disabled', icon: e.icon || 'mdiRadar' }) },
{ key: 'tracking_configs', label: 'nav.trackingConfigs', icon: 'mdiCog', href: '/tracking-configs',
mapFn: (e: any) => ({ detail: e.provider_type, icon: e.icon || 'mdiCog' }) },
{ key: 'template_configs', label: 'nav.templateConfigs', icon: 'mdiFileDocumentEdit', href: '/template-configs',
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?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' }) },
{ key: 'matrix_bots', label: 'nav.matrix', icon: 'mdiMatrix', href: '/bots?tab=matrix',
mapFn: (e: any) => ({ detail: e.display_name || '', icon: e.icon || 'mdiMatrix' }) },
{ key: 'command_trackers', label: 'nav.commandTrackers', icon: 'mdiConsoleLine', href: '/command-trackers',
mapFn: (e: any) => ({ detail: e.enabled ? 'enabled' : 'disabled', icon: e.icon || 'mdiConsoleLine' }) },
{ key: 'command_configs', label: 'nav.commandConfigs', icon: 'mdiCog', href: '/command-configs',
mapFn: (e: any) => ({ detail: e.provider_type, icon: e.icon || 'mdiCog' }) },
{ key: 'command_template_configs', label: 'nav.cmdTemplateConfigs', icon: 'mdiCodeBracesBox', href: '/command-template-configs',
mapFn: (e: any) => ({ detail: e.provider_type, icon: e.icon || 'mdiCodeBracesBox' }) },
] as const;
type CacheEntity = Record<string, unknown> & { id: number; name: string };
const cacheMap: Record<string, { items: any[] }> = {
const GROUPS: readonly { key: string; label: string; icon: string; href: string; mapFn: (e: CacheEntity) => { detail: string; icon: string } }[] = [
{ key: 'providers', label: 'nav.providers', icon: 'mdiServer', href: '/providers',
mapFn: (e) => ({ detail: String(e.type || ''), icon: String(e.icon || 'mdiServer') }) },
{ key: 'notification_trackers', label: 'nav.notification', icon: 'mdiRadar', href: '/notification-trackers',
mapFn: (e) => ({ detail: e.enabled ? 'enabled' : 'disabled', icon: String(e.icon || 'mdiRadar') }) },
{ key: 'tracking_configs', label: 'nav.trackingConfigs', icon: 'mdiCog', href: '/tracking-configs',
mapFn: (e) => ({ detail: String(e.provider_type || ''), icon: String(e.icon || 'mdiCog') }) },
{ key: 'template_configs', label: 'nav.templateConfigs', icon: 'mdiFileDocumentEdit', href: '/template-configs',
mapFn: (e) => ({ detail: String(e.provider_type || ''), icon: String(e.icon || 'mdiFileDocumentEdit') }) },
{ key: 'targets', label: 'nav.targets', icon: 'mdiTarget', href: '/targets',
mapFn: (e) => ({ detail: String(e.type || ''), icon: String(e.icon || 'mdiTarget') }) },
{ key: 'telegram_bots', label: 'nav.telegram', icon: 'mdiSendCircle', href: '/bots?tab=telegram',
mapFn: (e) => ({ detail: `@${e.bot_username || ''}`, icon: String(e.icon || 'mdiRobot') }) },
{ key: 'email_bots', label: 'nav.email', icon: 'mdiEmailOutline', href: '/bots?tab=email',
mapFn: (e) => ({ detail: String(e.email || ''), icon: String(e.icon || 'mdiEmailOutline') }) },
{ key: 'matrix_bots', label: 'nav.matrix', icon: 'mdiMatrix', href: '/bots?tab=matrix',
mapFn: (e) => ({ detail: String(e.display_name || ''), icon: String(e.icon || 'mdiMatrix') }) },
{ key: 'command_trackers', label: 'nav.commandTrackers', icon: 'mdiConsoleLine', href: '/command-trackers',
mapFn: (e) => ({ detail: e.enabled ? 'enabled' : 'disabled', icon: String(e.icon || 'mdiConsoleLine') }) },
{ key: 'command_configs', label: 'nav.commandConfigs', icon: 'mdiCog', href: '/command-configs',
mapFn: (e) => ({ detail: String(e.provider_type || ''), icon: String(e.icon || 'mdiCog') }) },
{ key: 'command_template_configs', label: 'nav.cmdTemplateConfigs', icon: 'mdiCodeBracesBox', href: '/command-template-configs',
mapFn: (e) => ({ detail: String(e.provider_type || ''), icon: String(e.icon || 'mdiCodeBracesBox') }) },
];
const cacheMap = {
providers: providersCache,
notification_trackers: notificationTrackersCache,
tracking_configs: trackingConfigsCache,
@@ -77,7 +79,7 @@
command_trackers: commandTrackersCache,
command_configs: commandConfigsCache,
command_template_configs: commandTemplateConfigsCache,
};
} as unknown as Record<string, { items: { id: number; name: string; [k: string]: unknown }[] }>;
/** Build flat results from all caches, filtered by query. */
const results = $derived.by(() => {
+150
View File
@@ -0,0 +1,150 @@
<script lang="ts">
import { fly, fade } from 'svelte/transition';
import { getSnacks, removeSnack, type Snack } from '$lib/stores/snackbar.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import { t } from '$lib/i18n';
const snacks = $derived(getSnacks());
let expandedIds = $state<Set<number>>(new Set());
function toggleDetail(id: number) {
const next = new Set(expandedIds);
if (next.has(id)) next.delete(id);
else next.add(id);
expandedIds = next;
}
const iconMap: Record<string, string> = {
success: 'mdiCheckCircle',
error: 'mdiAlertCircle',
info: 'mdiInformation',
warning: 'mdiAlert',
};
const accentMap: Record<string, string> = {
success: '#059669',
error: '#ef4444',
info: '#3b82f6',
warning: '#f59e0b',
};
</script>
{#if snacks.length > 0}
<div
style="position: fixed; left: 50%; transform: translateX(-50%); z-index: 9999; display: flex; flex-direction: column; gap: 0.5rem; width: 90%; max-width: 26rem; pointer-events: none;"
class="snackbar-container"
>
{#each snacks as snack (snack.id)}
<div
in:fly={{ y: 40, duration: 300 }}
out:fade={{ duration: 200 }}
class="snack-item"
style="--snack-accent: {accentMap[snack.type]};"
>
<span class="snack-icon" style="color: {accentMap[snack.type]};">
<MdiIcon name={iconMap[snack.type]} size={18} />
</span>
<div style="flex: 1; min-width: 0;">
<p class="snack-message">{snack.message}</p>
{#if snack.detail}
<button class="snack-detail-toggle" onclick={() => toggleDetail(snack.id)}>
{expandedIds.has(snack.id) ? t('snackbar.hideDetails') : t('snackbar.showDetails')}
</button>
{#if expandedIds.has(snack.id)}
<pre class="snack-detail">{snack.detail}</pre>
{/if}
{/if}
</div>
<button class="snack-close" onclick={() => removeSnack(snack.id)} aria-label="Dismiss">
<MdiIcon name="mdiClose" size={14} />
</button>
</div>
{/each}
</div>
{/if}
<style>
.snackbar-container {
bottom: 5rem;
}
@media (min-width: 768px) {
.snackbar-container {
bottom: 1.5rem;
}
}
.snack-item {
pointer-events: auto;
display: flex;
align-items: flex-start;
gap: 0.625rem;
padding: 0.75rem 1rem;
border-radius: 0.75rem;
border-left: 3px solid var(--snack-accent);
background: var(--color-card);
border-top: 1px solid var(--color-border);
border-right: 1px solid var(--color-border);
border-bottom: 1px solid var(--color-border);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12), 0 0 0 1px rgba(255, 255, 255, 0.03) inset;
backdrop-filter: blur(12px);
}
:global([data-theme="dark"]) .snack-item {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4), 0 0 16px color-mix(in srgb, var(--snack-accent) 10%, transparent);
}
.snack-icon {
flex-shrink: 0;
margin-top: 1px;
}
.snack-message {
font-size: 0.8rem;
line-height: 1.4;
margin: 0;
color: var(--color-foreground);
}
.snack-detail-toggle {
font-size: 0.7rem;
color: var(--color-muted-foreground);
background: none;
border: none;
padding: 0;
margin-top: 0.25rem;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
.snack-detail-toggle:hover {
color: var(--color-foreground);
}
.snack-detail {
font-size: 0.7rem;
font-family: var(--font-mono);
color: var(--color-muted-foreground);
margin: 0.25rem 0 0;
white-space: pre-wrap;
word-break: break-word;
}
.snack-close {
flex-shrink: 0;
background: none;
border: none;
color: var(--color-muted-foreground);
cursor: pointer;
padding: 0.125rem;
border-radius: 0.25rem;
transition: all 0.15s ease;
line-height: 1;
}
.snack-close:hover {
color: var(--color-foreground);
background: var(--color-muted);
}
</style>
+2 -2
View File
@@ -42,7 +42,7 @@ export function highlightFromUrl(): void {
// Wait for DOM to render after loaded=true
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const card = document.querySelector(`[data-entity-id="${id}"]`);
const card = document.querySelector(`[data-entity-id="${CSS.escape(id)}"]`);
if (card) {
_highlightCard(card as HTMLElement);
} else {
@@ -89,7 +89,7 @@ function _waitForCard(id: string): void {
const start = Date.now();
const observer = new MutationObserver(() => {
const card = document.querySelector(`[data-entity-id="${id}"]`);
const card = document.querySelector(`[data-entity-id="${CSS.escape(id)}"]`);
if (card) {
observer.disconnect();
setTimeout(() => _highlightCard(card as HTMLElement), 50);
+3 -2
View File
@@ -47,7 +47,7 @@
"createAccount": "Create account",
"creatingAccount": "Creating account...",
"passwordMismatch": "Passwords do not match",
"passwordTooShort": "Password must be at least 6 characters",
"passwordTooShort": "Password must be at least 8 characters",
"or": "or"
},
"dashboard": {
@@ -753,7 +753,8 @@
"filterByName": "Filter by name...",
"allTypes": "All types",
"allProviders": "All providers",
"noFilterResults": "No items match the current filter."
"noFilterResults": "No items match the current filter.",
"redirecting": "Redirecting..."
},
"gridDesc": {
"sortNone": "No sorting applied",
+63
View File
@@ -0,0 +1,63 @@
/**
* Reactive i18n module using Svelte 5 $state rune.
* Locale changes automatically propagate to all components using t().
*/
import en from './en.json';
import ru from './ru.json';
export type Locale = 'en' | 'ru';
const translations: Record<Locale, Record<string, any>> = { en, ru };
function detectLocale(): Locale {
if (typeof localStorage !== 'undefined') {
const saved = localStorage.getItem('locale') as Locale | null;
if (saved && saved in translations) return saved;
}
if (typeof navigator !== 'undefined') {
const lang = navigator.language.slice(0, 2);
if (lang in translations) return lang as Locale;
}
return 'en';
}
let currentLocale = $state<Locale>(detectLocale());
export function getLocale(): Locale {
return currentLocale;
}
export function setLocale(locale: Locale) {
currentLocale = locale;
if (typeof localStorage !== 'undefined') {
localStorage.setItem('locale', locale);
}
}
export function initLocale() {
// No-op: locale is auto-detected at module load via $state.
// Kept for backward compatibility with existing onMount calls.
}
/**
* Get a translated string by dot-separated key.
* Falls back to English if key not found in current locale.
* Reactive: re-evaluates when currentLocale changes.
*/
export function t(key: string, fallback?: string): string {
return resolve(translations[currentLocale], key)
?? resolve(translations.en, key)
?? fallback
?? key;
}
function resolve(obj: any, path: string): string | undefined {
const parts = path.split('.');
let current = obj;
for (const part of parts) {
if (current == null || typeof current !== 'object') return undefined;
current = current[part];
}
return typeof current === 'string' ? current : undefined;
}
+2
View File
@@ -0,0 +1,2 @@
// Re-export from the .svelte.ts module which supports $state runes
export { t, getLocale, setLocale, initLocale, type Locale } from './index.svelte';
+3 -2
View File
@@ -47,7 +47,7 @@
"createAccount": "Создать аккаунт",
"creatingAccount": "Создание...",
"passwordMismatch": "Пароли не совпадают",
"passwordTooShort": "Пароль должен быть не менее 6 символов",
"passwordTooShort": "Пароль должен быть не менее 8 символов",
"or": "или"
},
"dashboard": {
@@ -753,7 +753,8 @@
"filterByName": "Фильтр по имени...",
"allTypes": "Все типы",
"allProviders": "Все провайдеры",
"noFilterResults": "Нет элементов, соответствующих фильтру."
"noFilterResults": "Нет элементов, соответствующих фильтру.",
"redirecting": "Перенаправление..."
},
"gridDesc": {
"sortNone": "Без сортировки",
+4
View File
@@ -41,6 +41,7 @@ export interface TelegramBot {
webhook_path_id: string;
commands_config: Record<string, any>;
token_preview: string;
update_mode?: string;
created_at: string;
}
@@ -50,6 +51,7 @@ export interface TelegramChat {
title: string;
type: string;
username: string;
language_code?: string;
discovered_at: string;
}
@@ -77,6 +79,7 @@ export interface Tracker {
scan_interval: number;
batch_duration: number;
enabled: boolean;
filters?: Record<string, any>;
tracker_targets: TrackerTarget[];
created_at: string;
}
@@ -222,4 +225,5 @@ export interface DashboardStatus {
targets: number;
total_events: number;
recent_events: EventLog[];
command_trackers?: number;
}
+10 -5
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import '../app.css';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { fade, slide } from 'svelte/transition';
import { cubicOut } from 'svelte/easing';
@@ -19,6 +20,7 @@
const theme = getTheme();
let showPasswordForm = $state(false);
let redirecting = $state(false);
let openSearch: (() => void) | undefined;
let pwdCurrent = $state('');
let pwdNew = $state('');
@@ -27,6 +29,7 @@
async function changePassword(e: SubmitEvent) {
e.preventDefault(); pwdMsg = ''; pwdSuccess = false;
if (pwdNew.length < 8) { pwdMsg = t('auth.passwordTooShort'); return; }
try {
await api('/auth/password', { method: 'PUT', body: JSON.stringify({ current_password: pwdCurrent, new_password: pwdNew }) });
pwdMsg = t('common.changePassword');
@@ -38,6 +41,7 @@
}
let collapsed = $state(false);
let isMac = $derived(typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.userAgent));
// Nav counts for badges
let navCounts = $state<Record<string, number>>({});
@@ -153,15 +157,16 @@
try {
const saved = localStorage.getItem('nav_expanded');
if (saved) expandedGroups = JSON.parse(saved);
} catch {}
} catch (e) { console.warn('Failed to parse nav_expanded:', e); }
}
await loadUser();
if (!auth.user && !isAuthPage) {
window.location.href = '/login';
redirecting = true;
goto('/login');
}
// Load nav counts
if (auth.user) {
try { navCounts = await api('/status/counts'); } catch {}
try { navCounts = await api('/status/counts'); } catch (e) { console.warn('Failed to load nav counts:', e); }
}
});
@@ -270,7 +275,7 @@
<MdiIcon name="mdiMagnify" size={16} />
{#if !collapsed}
<span class="flex-1 text-left text-xs">{t('searchPalette.placeholder')}</span>
<kbd class="text-[0.6rem] font-mono px-1 py-0.5 rounded" style="background: var(--color-background); border: 1px solid var(--color-border);">⌘K</kbd>
<kbd class="text-[0.6rem] font-mono px-1 py-0.5 rounded" style="background: var(--color-background); border: 1px solid var(--color-border);">{isMac ? '⌘' : 'Ctrl '}K</kbd>
{/if}
</button>
</div>
@@ -420,7 +425,7 @@
<div class="min-h-screen flex items-center justify-center">
<div class="flex items-center gap-3">
<div class="w-5 h-5 rounded-full border-2 border-[var(--color-primary)] border-t-transparent animate-spin"></div>
<p class="text-sm text-[var(--color-muted-foreground)]">{t('common.loading')}</p>
<p class="text-sm text-[var(--color-muted-foreground)]">{redirecting ? t('common.redirecting') : t('common.loading')}</p>
</div>
</div>
{/if}
+10 -8
View File
@@ -12,13 +12,14 @@
import EntitySelect from '$lib/components/EntitySelect.svelte';
import { eventTypeFilterItems, sortFilterItems } from '$lib/grid-items';
let status = $state<any>(null);
import type { DashboardStatus } from '$lib/types';
let status = $state<DashboardStatus | null>(null);
let providers = $derived(providersCache.items);
const providerFilterItems = $derived([
{ value: '', label: t('dashboard.allProviders'), icon: 'mdiFilterOff' },
...providers.map(p => ({ value: p.id, label: p.name, icon: p.icon || 'mdiServer', desc: p.type })),
]);
let chartDays = $state<any[]>([]);
let chartDays = $state<{ date: string; [eventType: string]: string | number }[]>([]);
let loaded = $state(false);
let error = $state('');
@@ -78,7 +79,7 @@
params.set('limit', String(eventsLimit));
params.set('offset', String(eventsOffset));
const qs = params.toString();
status = await api<any>(`/status${qs ? '?' + qs : ''}`);
status = await api<DashboardStatus>(`/status${qs ? '?' + qs : ''}`);
} catch (err: any) {
error = err.message || t('common.error');
} finally {
@@ -90,9 +91,9 @@
try {
const params = buildFilterParams();
const qs = params.toString();
const chartRes = await api<any>(`/status/chart${qs ? '?' + qs : ''}`);
const chartRes = await api<{ days: { date: string; [k: string]: string | number }[] }>(`/status/chart${qs ? '?' + qs : ''}`);
chartDays = chartRes.days || [];
} catch {}
} catch (e) { console.warn('Failed to load chart data:', e); }
}
// Auto-apply when filter values change (via IconGridSelect bind:value)
@@ -149,13 +150,14 @@
async function loadInitial() {
try {
const [statusRes, , chartRes] = await Promise.all([
api<any>(`/status?limit=${eventsLimit}`),
api<DashboardStatus>(`/status?limit=${eventsLimit}`),
providersCache.fetch(),
api<any>('/status/chart'),
api<{ days: { date: string; [k: string]: string | number }[] }>('/status/chart'),
]);
status = statusRes;
chartDays = chartRes.days || [];
setTimeout(() => {
if (!status) return;
animateCount(0, status.providers, (v) => displayProviders = v);
animateCount(0, status.trackers.active, (v) => displayActive = v);
animateCount(0, status.trackers.total, (v) => displayTotal = v);
@@ -339,7 +341,7 @@
<style>
.stat-card { position: relative; border-radius: 0.75rem; padding: 1px; background: linear-gradient(135deg, var(--accent), transparent 60%, var(--color-border)); transition: all 0.3s ease; }
.stat-card:hover { box-shadow: 0 0 24px color-mix(in srgb, var(--accent) 20%, transparent); transform: translateY(-2px); }
.stat-card:hover { box-shadow: 0 0 24px color-mix(in srgb, var(--accent) 20%, transparent); }
.stat-card-inner { background: var(--color-card); border-radius: calc(0.75rem - 1px); padding: 1.25rem; }
.stat-icon { display: flex; align-items: center; justify-content: center; width: 2.75rem; height: 2.75rem; border-radius: 0.75rem; flex-shrink: 0; }
.stat-value { font-size: 1.75rem; font-weight: 600; line-height: 1.2; animation: countUp 0.5s ease-out both; }
+1 -1
View File
@@ -18,7 +18,7 @@
let error = $state('');
// Global settings (loaded for webhook mode checks)
let settings = $state<any>({});
let settings = $state<Record<string, string>>({});
onMount(load);
async function load() {
+1 -1
View File
@@ -19,7 +19,7 @@
let editingEmail = $state<number | null>(null);
let emailSubmitting = $state(false);
let emailTesting = $state<Record<number, boolean>>({});
let confirmDeleteEmail = $state<any>(null);
let confirmDeleteEmail = $state<EmailBot | null>(null);
let error = $state('');
const defaultEmailForm = () => ({
+1 -1
View File
@@ -19,7 +19,7 @@
let editingMatrix = $state<number | null>(null);
let matrixSubmitting = $state(false);
let matrixTesting = $state<Record<number, boolean>>({});
let confirmDeleteMatrix = $state<any>(null);
let confirmDeleteMatrix = $state<MatrixBot | null>(null);
let error = $state('');
const defaultMatrixForm = () => ({
+27 -22
View File
@@ -11,9 +11,14 @@
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';
import type { TelegramBot, TelegramChat } from '$lib/types';
let { settings, onreload }: { settings: any; onreload: () => Promise<void> } = $props();
interface CommandTrackerSummary { id: number; name: string; icon?: string; enabled: boolean }
interface ListenerEntry { listener_type: string; listener_id: number }
interface WebhookStatusInfo { url?: string; pending_update_count?: number; last_error_message?: string }
interface ApiResult { success: boolean; error?: string; verified?: boolean }
let { settings, onreload }: { settings: Record<string, string>; onreload: () => Promise<void> } = $props();
let bots = $derived(telegramBotsCache.items);
let showForm = $state(false);
@@ -21,7 +26,7 @@
let form = $state({ name: '', icon: '', token: '' });
let error = $state('');
let submitting = $state(false);
let confirmDelete = $state<any>(null);
let confirmDelete = $state<{ id: number; onconfirm: () => Promise<void> } | null>(null);
// Per-bot expandable sections
let chats = $state<Record<number, TelegramChat[]>>({});
@@ -29,17 +34,17 @@
let expandedSection = $state<Record<number, string>>({});
// Webhook status per bot
let webhookStatus = $state<Record<number, any>>({});
let webhookStatus = $state<Record<number, WebhookStatusInfo>>({});
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 botListenerStatus = $state<Record<number, CommandTrackerSummary[]>>({});
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; }
function editBot(bot: TelegramBot) { form = { name: bot.name, icon: bot.icon || '', token: '' }; editing = bot.id; showForm = true; }
async function saveBot(e: SubmitEvent) {
e.preventDefault(); error = ''; submitting = true;
@@ -78,14 +83,14 @@
async function loadChats(botId: number) {
chatsLoading = { ...chatsLoading, [botId]: true };
try { chats = { ...chats, [botId]: await api(`/telegram-bots/${botId}/chats`) }; } catch { chats = { ...chats, [botId]: [] }; }
try { chats = { ...chats, [botId]: await api<TelegramChat[]>(`/telegram-bots/${botId}/chats`) }; } catch (e) { console.warn('Failed to load chats:', e); 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' }) };
chats = { ...chats, [botId]: await api<TelegramChat[]>(`/telegram-bots/${botId}/chats/discover`, { method: 'POST' }) };
snackSuccess(t('telegramBot.chatsDiscovered'));
} catch (err: any) { snackError(err.message); }
chatsLoading = { ...chatsLoading, [botId]: false };
@@ -94,7 +99,7 @@
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);
chats[botId] = (chats[botId] || []).filter((c) => c.id !== chatDbId);
snackSuccess(t('telegramBot.chatDeleted'));
} catch (err: any) { snackError(err.message); }
}
@@ -102,24 +107,24 @@
async function loadListenerStatus(botId: number) {
botListenerLoading = { ...botListenerLoading, [botId]: true };
try {
const trackers = await api('/command-trackers');
const matched: any[] = [];
const trackers = await api<CommandTrackerSummary[]>('/command-trackers');
const matched: CommandTrackerSummary[] = [];
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);
const listeners = await api<ListenerEntry[]>(`/command-trackers/${trk.id}/listeners`);
const hasBot = listeners.some((l) => l.listener_type === 'telegram_bot' && l.listener_id === botId);
if (hasBot) matched.push(trk);
} catch { /* ignore */ }
} catch (e) { console.warn('Failed to load listeners for tracker:', e); }
}
botListenerStatus = { ...botListenerStatus, [botId]: matched };
} catch { botListenerStatus = { ...botListenerStatus, [botId]: [] }; }
} catch (e) { console.warn('Failed to load listener status:', e); 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' });
const res = await api<ApiResult>(`/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); }
@@ -141,14 +146,14 @@
async function loadWebhookStatus(botId: number) {
try {
webhookStatus = { ...webhookStatus, [botId]: await api(`/telegram-bots/${botId}/webhook/status`) };
} catch { webhookStatus = { ...webhookStatus, [botId]: null }; }
webhookStatus = { ...webhookStatus, [botId]: await api<WebhookStatusInfo>(`/telegram-bots/${botId}/webhook/status`) };
} catch (e) { console.warn('Failed to load webhook status:', e); webhookStatus = { ...webhookStatus, [botId]: {} }; }
}
async function registerWebhook(botId: number) {
modeChanging = { ...modeChanging, [botId]: true };
try {
const res = await api(`/telegram-bots/${botId}/webhook/register`, { method: 'POST' });
const res = await api<ApiResult>(`/telegram-bots/${botId}/webhook/register`, { method: 'POST' });
if (res.success) {
snackSuccess(res.verified ? t('telegramBot.webhookVerified') : t('telegramBot.webhookRegistered'));
await loadWebhookStatus(botId);
@@ -162,7 +167,7 @@
async function unregisterWebhook(botId: number) {
modeChanging = { ...modeChanging, [botId]: true };
try {
const res = await api(`/telegram-bots/${botId}/webhook/unregister`, { method: 'POST' });
const res = await api<ApiResult>(`/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); }
@@ -193,7 +198,7 @@
if (chatTesting[key]) return;
chatTesting = { ...chatTesting, [key]: true };
try {
const res = await api(`/telegram-bots/${botId}/chats/${chatId}/test?locale=${getLocale()}`, { method: 'POST' });
const res = await api<ApiResult>(`/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); }
@@ -398,7 +403,7 @@
{@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}
{#if (ws.pending_update_count ?? 0) > 0}
({ws.pending_update_count} {t('telegramBot.pendingUpdates')})
{/if}
</span>
@@ -17,10 +17,11 @@
import EntitySelect from '$lib/components/EntitySelect.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { highlightFromUrl } from '$lib/highlight';
import type { CommandConfig } from '$lib/types';
function templateName(id: number | null): string {
if (!id) return '';
const tpl = cmdTemplateConfigs.find((c: any) => c.id === id);
const tpl = cmdTemplateConfigs.find((c) => c.id === id);
return tpl?.name || `#${id}`;
}
@@ -33,15 +34,15 @@
));
let cmdTemplateConfigs = $derived(commandTemplateConfigsCache.items);
const templateItems = $derived(cmdTemplateConfigs
.filter((c: any) => c.provider_type === form.provider_type)
.map((c: any) => ({ value: c.id, label: c.name + (c.user_id === 0 ? ' (System)' : ''), icon: c.icon || 'mdiCodeBracesBox', desc: c.provider_type }))
.filter((c) => c.provider_type === form.provider_type)
.map((c) => ({ value: c.id, label: c.name + (c.user_id === 0 ? ' (System)' : ''), icon: c.icon || 'mdiCodeBracesBox', desc: c.provider_type }))
);
let loaded = $state(false);
let showForm = $state(false);
let editing = $state<number | null>(null);
let error = $state('');
let submitting = $state(false);
let confirmDelete = $state<any>(null);
let confirmDelete = $state<{ id: number; onconfirm: () => Promise<void> } | null>(null);
// Immich command icons — used as fallback when capabilities don't specify icons
const commandIcons: Record<string, string> = {
@@ -54,7 +55,7 @@
let allCapabilities = $derived(capabilitiesCache.items);
let providerCommands = $derived<{key: string, icon: string}[]>(
(allCapabilities[form.provider_type]?.commands || []).map((c: any) => ({
(allCapabilities[form.provider_type]?.commands || []).map((c: { name: string }) => ({
key: c.name,
icon: commandIcons[c.name] || 'mdiConsole',
}))
@@ -88,12 +89,12 @@
function openNew() {
form = defaultForm();
// Auto-select first matching template for the default provider_type
const match = cmdTemplateConfigs.find((c: any) => c.provider_type === form.provider_type);
const match = cmdTemplateConfigs.find((c) => c.provider_type === form.provider_type);
if (match) form.command_template_config_id = match.id;
editing = null;
showForm = true;
}
function editConfig(cfg: any) {
function editConfig(cfg: CommandConfig) {
form = {
name: cfg.name,
icon: cfg.icon || '',
@@ -132,7 +133,7 @@
finally { submitting = false; }
}
function remove(cfg: any) {
function remove(cfg: CommandConfig) {
confirmDelete = {
id: cfg.id,
onconfirm: async () => {
@@ -11,9 +11,10 @@
import ConfirmModal from '$lib/components/ConfirmModal.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, TrackingConfig, TemplateConfig } from '$lib/types';
import type { Tracker, TrackerTarget, TrackingConfig, TemplateConfig, NotificationTarget } from '$lib/types';
import TrackerForm from './TrackerForm.svelte';
import LinkedTargetsSection from './LinkedTargetsSection.svelte';
@@ -34,7 +35,7 @@
let targets = $derived(targetsCache.items);
let trackingConfigs = $derived(trackingConfigsCache.items);
let templateConfigs = $derived(templateConfigsCache.items);
let collections = $state<any[]>([]);
let collections = $state<Record<string, any>[]>([]);
let showForm = $state(false);
let editing = $state<number | null>(null);
let collectionFilter = $state('');
@@ -44,7 +45,7 @@
let ttTesting = $state<Record<string, string>>({});
// Shared link validation
let linkWarning = $state<{ albums: any[], providerId: number } | null>(null);
let linkWarning = $state<{ albums: { id: string; name: string; issue: string }[], providerId: number } | null>(null);
let linkCheckLoading = $state(false);
let linkCreating = $state(false);
let previousCollectionIds = $state<string[]>([]);
@@ -83,7 +84,7 @@
];
let testMenuTrackerId = $state<number | null>(null);
let testTypes = $derived(() => {
let testTypes = $derived.by(() => {
if (!testMenuTrackerId) return defaultTestTypes;
const tracker = notificationTrackers.find(t => t.id === testMenuTrackerId);
if (!tracker) return defaultTestTypes;
@@ -98,7 +99,7 @@
loadError = '';
try {
[allNotificationTrackers] = await Promise.all([
api('/notification-trackers'),
api<Tracker[]>('/notification-trackers'),
providersCache.fetch(), targetsCache.fetch(),
trackingConfigsCache.fetch(), templateConfigsCache.fetch(),
]);
@@ -110,7 +111,7 @@
async function loadCollections() {
if (!form.provider_id) return;
try { collections = await api(`/providers/${form.provider_id}/collections`); } catch { collections = []; }
try { collections = await api(`/providers/${form.provider_id}/collections`); } catch (e) { console.warn('Failed to load collections:', e); collections = []; }
}
let _prevProviderId = 0;
@@ -123,7 +124,7 @@
function openNew() { form = defaultForm(); editing = null; showForm = true; collections = []; previousCollectionIds = []; }
async function edit(trk: any) {
async function edit(trk: Tracker) {
form = {
name: trk.name, icon: trk.icon || '', provider_id: trk.provider_id,
collection_ids: [...(trk.collection_ids || [])],
@@ -143,13 +144,14 @@
if (newAlbumIds.length > 0 && form.provider_id) {
linkCheckLoading = true;
try {
const missingAlbums: any[] = [];
const missingAlbums: { id: string; name: string; issue: string }[] = [];
for (const albumId of newAlbumIds) {
const links = await api(`/providers/${form.provider_id}/albums/${albumId}/shared-links`);
const validLink = (links as any[]).find((l: any) => l.is_accessible && !l.is_expired);
interface SharedLink { is_accessible: boolean; is_expired: boolean; has_password: boolean }
const links = await api<SharedLink[]>(`/providers/${form.provider_id}/albums/${albumId}/shared-links`);
const validLink = links.find((l) => l.is_accessible && !l.is_expired);
if (!validLink) {
const album = collections.find(c => c.id === albumId);
const problematicLink = (links as any[]).find((l: any) => l.is_expired || l.has_password);
const problematicLink = links.find((l) => l.is_expired || l.has_password);
missingAlbums.push({
id: albumId,
name: album?.albumName || album?.name || albumId,
@@ -164,7 +166,7 @@
linkCheckLoading = false;
return;
}
} catch { /* Proceed if check fails */ }
} catch (e) { console.warn('Shared link check failed, proceeding:', e); }
linkCheckLoading = false;
}
@@ -210,7 +212,7 @@
await doSave();
}
async function toggle(tracker: any) {
async function toggle(tracker: Tracker) {
if (toggling[tracker.id]) return;
toggling = { ...toggling, [tracker.id]: true };
try {
@@ -220,7 +222,7 @@
} catch (err: any) { snackError(err.message); } finally { toggling = { ...toggling, [tracker.id]: false }; }
}
function startDelete(tracker: any) { confirmDelete = tracker; }
function startDelete(tracker: Tracker) { confirmDelete = tracker; }
async function doDelete() {
if (!confirmDelete) return;
@@ -243,7 +245,7 @@
try {
const d = new Date(dateStr);
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
} catch { return ''; }
} catch (e) { console.warn('Date format error:', e); return ''; }
}
// --- Linked Targets helpers ---
@@ -252,7 +254,7 @@
expandedTracker = trackerId;
}
function getProviderType(tracker: any): string {
function getProviderType(tracker: Tracker): string {
const p = providers.find(p => p.id === tracker.provider_id);
return p?.type || '';
}
@@ -262,13 +264,13 @@
return p?.name || `#${id}`;
}
function configsForTracker(tracker: any, configs: (TrackingConfig | TemplateConfig)[]): any[] {
function configsForTracker(tracker: Tracker, configs: (TrackingConfig | TemplateConfig)[]): (TrackingConfig | TemplateConfig)[] {
const pt = getProviderType(tracker);
return pt ? configs.filter((c: any) => c.provider_type === pt) : configs;
return pt ? configs.filter((c) => c.provider_type === pt) : configs;
}
function getUnlinkedTargets(tracker: any): any[] {
const linkedIds = new Set((tracker.tracker_targets || []).map((tt: any) => tt.target_id));
function getUnlinkedTargets(tracker: Tracker): NotificationTarget[] {
const linkedIds = new Set((tracker.tracker_targets || []).map((tt: TrackerTarget) => tt.target_id));
return targets.filter(t => !linkedIds.has(t.id));
}
@@ -302,7 +304,7 @@
} catch (err: any) { snackError(err.message); }
}
async function updateTargetLink(trackerId: number, tt: any, field: string, value: any) {
async function updateTargetLink(trackerId: number, tt: TrackerTarget, field: string, value: string | number | boolean | null) {
try {
await api(`/notification-trackers/${trackerId}/targets/${tt.id}`, {
method: 'PUT',
@@ -331,12 +333,12 @@
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;`;
testMenuTrackerId = notificationTrackers.find(t => t.tracker_targets?.some((x: any) => String(x.id) === String(ttId)))?.id ?? null;
testMenuTrackerId = notificationTrackers.find(t => t.tracker_targets?.some((x: TrackerTarget) => String(x.id) === String(ttId)))?.id ?? null;
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;
const trackerId = notificationTrackers.find(t => t.tracker_targets?.some((x: TrackerTarget) => String(x.id) === String(ttId)))?.id;
if (trackerId) testTrackerTarget(trackerId, ttId, testType);
}
</script>
@@ -451,7 +453,7 @@
{testMenuOpen}
{testMenuStyle}
{ttTesting}
testTypes={testTypes()}
testTypes={testTypes}
ontest={handleTestFromMenu}
onclose={() => testMenuOpen = null}
/>
+1 -1
View File
@@ -20,7 +20,7 @@
e.preventDefault();
error = '';
if (password !== confirmPassword) { error = t('auth.passwordMismatch'); return; }
if (password.length < 6) { error = t('auth.passwordTooShort'); return; }
if (password.length < 8) { error = t('auth.passwordTooShort'); return; }
submitting = true;
try {
await setup(username, password);
+54 -224
View File
@@ -1,6 +1,5 @@
<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';
@@ -8,23 +7,22 @@
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 Hint from '$lib/components/Hint.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import CrossLink from '$lib/components/CrossLink.svelte';
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
import EntitySelect from '$lib/components/EntitySelect.svelte';
import { chatActionItems } from '$lib/grid-items';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { highlightFromUrl } from '$lib/highlight';
import type { NotificationTarget, TargetReceiver, TelegramChat } from '$lib/types';
import TargetForm from './TargetForm.svelte';
import ReceiverSection from './ReceiverSection.svelte';
// ── Helpers ──
function getBotName(target: any): string | null {
function getBotName(target: NotificationTarget): string | null {
if (target.type === 'telegram' && target.config?.bot_id) {
const bot = telegramBots.find(b => b.id === target.config.bot_id);
return bot?.name || null;
@@ -40,14 +38,14 @@
return null;
}
function getBotHref(target: any): string {
function getBotHref(target: NotificationTarget): string {
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?tab=telegram';
}
function getBotEntityId(target: any): number | null {
function getBotEntityId(target: NotificationTarget): number | null {
if (target.type === 'telegram') return target.config?.bot_id || null;
if (target.type === 'email') return target.config?.email_bot_id || null;
if (target.type === 'matrix') return target.config?.matrix_bot_id || null;
@@ -57,7 +55,7 @@
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 || '?';
return recv.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 || '?';
@@ -173,7 +171,10 @@
async function loadReceiverBotChats(botId: number) {
if (!botId) return;
try { receiverBotChats[botId] = await api(`/telegram-bots/${botId}/chats`); } catch {}
try {
const data = await api<TelegramChat[]>(`/telegram-bots/${botId}/chats`);
receiverBotChats = { ...receiverBotChats, [botId]: data };
} catch (e) { console.warn('Failed to load bot chats:', e); }
}
// ── Target CRUD ──
@@ -223,7 +224,7 @@
if (formType === 'telegram') {
let botToken = form.bot_token;
if (form.bot_id && !botToken) {
const tokenRes = await api(`/telegram-bots/${form.bot_id}/token`);
const tokenRes = await api<{ token: string }>(`/telegram-bots/${form.bot_id}/token`);
botToken = tokenRes.token;
}
config = {
@@ -265,7 +266,7 @@
async function test(id: number) {
try {
const res = await api(`/targets/${id}/test?locale=${getLocale()}`, { method: 'POST' });
const res = await api<{ success: boolean; error?: string }>(`/targets/${id}/test?locale=${getLocale()}`, { method: 'POST' });
if (res.success) snackSuccess(t('snack.targetTestSent'));
else snackError(`Failed: ${res.error}`);
} catch (err: any) { snackError(err.message); }
@@ -317,7 +318,7 @@
const target = allTargets.find(t => t.id === addingReceiverForTarget);
const botId = target?.config?.bot_id || target?.config?.telegram_bot_id;
if (botId && receiverBotChats[botId]) {
const chat = receiverBotChats[botId].find((c: any) => String(c.chat_id) === String(config.chat_id));
const chat = receiverBotChats[botId].find((c: TelegramChat) => String(c.chat_id) === String(config.chat_id));
if (chat) {
config.chat_name = chat.title || chat.username || '';
if (chat.language_code) config.language_code = chat.language_code;
@@ -369,7 +370,7 @@
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' });
const res = await api<{ success: boolean; error?: string }>(`/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); }
@@ -391,108 +392,25 @@
{/if}
{#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">
{#if !activeType}
<div>
<label class="block text-sm font-medium mb-1">{t('targets.type')}</label>
<IconGridSelect items={typeGridItems} bind:value={formType} columns={4} />
</div>
{/if}
<div>
<label for="tgt-name" class="block text-sm font-medium mb-1">{t('targets.name')}</label>
<div class="flex gap-2">
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
<input id="tgt-name" bind:value={form.name} required placeholder={t('targets.namePlaceholder')} class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
</div>
{#if formType === 'telegram'}
<div>
<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?tab=telegram" class="underline"></a></p>
{/if}
</div>
<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">
{t('targets.telegramSettings')}
<span class="text-xs transition-transform duration-200" class:rotate-180={showTelegramSettings}>▼</span>
</button>
{#if showTelegramSettings}
<div in:slide={{ duration: 150 }} class="grid grid-cols-2 gap-3 mt-3">
<div>
<label for="tgt-maxmedia" class="block text-xs mb-1">{t('targets.maxMedia')}<Hint text={t('hints.maxMedia')} /></label>
<input id="tgt-maxmedia" type="number" bind:value={form.max_media_to_send} min="0" max="50" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="tgt-groupsize" class="block text-xs mb-1">{t('targets.maxGroupSize')}<Hint text={t('hints.groupSize')} /></label>
<input id="tgt-groupsize" type="number" bind:value={form.max_media_per_group} min="2" max="10" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="tgt-delay" class="block text-xs mb-1">{t('targets.chunkDelay')}<Hint text={t('hints.chunkDelay')} /></label>
<input id="tgt-delay" type="number" bind:value={form.media_delay} min="0" max="60000" step="100" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="tgt-maxsize" class="block text-xs mb-1">{t('targets.maxAssetSize')}<Hint text={t('hints.maxAssetSize')} /></label>
<input id="tgt-maxsize" type="number" bind:value={form.max_asset_size} min="1" max="50" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div class="col-span-2">
<label class="block text-xs mb-1">{t('targets.chatAction')}</label>
<IconGridSelect items={chatActionItems()} bind:value={form.chat_action} columns={4} compact />
</div>
<label class="flex items-center gap-2 text-sm col-span-2"><input type="checkbox" bind:checked={form.disable_url_preview} /> {t('targets.disableUrlPreview')}</label>
<label class="flex items-center gap-2 text-sm col-span-2"><input type="checkbox" bind:checked={form.send_large_photos_as_documents} /> {t('targets.sendLargeAsDocuments')}</label>
</div>
{/if}
</div>
{:else if formType === 'discord' || formType === 'slack'}
<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"
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
{:else if formType === 'ntfy'}
<div>
<label for="tgt-ntfy-server" class="block text-sm font-medium mb-1">{t('targets.ntfyServer')}</label>
<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-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')}
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
{:else if formType === 'email'}
<div>
<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?tab=email" class="underline"></a></p>
{/if}
</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?tab=matrix" class="underline"></a></p>
{/if}
</div>
{/if}
{#if formType === 'telegram'}
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.ai_captions} /> {t('targets.aiCaptions')}<Hint text={t('hints.aiCaptions')} /></label>
{/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('targets.create'))}</button>
</form>
</Card>
</div>
<TargetForm
bind:form
bind:formType
{activeType}
{typeGridItems}
{telegramBotItems}
{emailBotItems}
{matrixBotItems}
chatActionItems={chatActionItems()}
telegramBotCount={telegramBots.length}
emailBotCount={emailBots.length}
matrixBotCount={matrixBots.length}
{editing}
{submitting}
{error}
bind:showTelegramSettings
onsave={save}
ontoggleTelegramSettings={() => showTelegramSettings = !showTelegramSettings}
/>
{/if}
{#if !showForm && allTargets.length > 0}
@@ -522,7 +440,7 @@
<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.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}
{#if getBotName(target)}<CrossLink href={getBotHref(target)} icon="mdiRobot" label={getBotName(target) ?? ''} entityId={getBotEntityId(target)} />{/if}
</div>
</div>
<div class="flex items-center gap-1">
@@ -533,113 +451,25 @@
</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>
{#if (recv as any).language_code || recv.config?.language_code}
<span class="text-xs px-1 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{((recv as any).language_code || recv.config.language_code).toUpperCase()}</span>
{/if}
</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 existingKeys = new Set((target.receivers || []).map((r: TargetReceiver) => r.receiver_key))}
{@const chatItems = (receiverBotChats[botId] || []).map((c: any) => ({
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.language_code ? ' · ' + c.language_code.toUpperCase() : ''} · ${c.chat_id}`,
disabled: existingKeys.has(c.chat_id),
disabledHint: existingKeys.has(c.chat_id) ? t('targets.alreadyAdded') : undefined,
}))}
{#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>
<ReceiverSection
{target}
typeIcons={TYPE_ICONS}
{addingReceiverForTarget}
bind:receiverForm
{receiverSubmitting}
{receiverHeadersError}
{receiverBotChats}
{receiverTesting}
{receiverLabel}
onopenReceiverForm={openReceiverForm}
onsaveReceiver={saveReceiver}
oncancelReceiver={() => addingReceiverForTarget = null}
ontoggleReceiver={toggleReceiver}
onremoveReceiver={(targetId, recv) => confirmDeleteReceiver = { targetId, receiver: recv }}
ontestReceiver={testReceiver}
onloadBotChats={loadReceiverBotChats}
onchangeReceiverForm={(f) => receiverForm = f}
/>
</Card>
{/each}
</div>
@@ -0,0 +1,156 @@
<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 EntitySelect from '$lib/components/EntitySelect.svelte';
import type { NotificationTarget, TargetReceiver, TelegramChat } from '$lib/types';
interface Props {
target: NotificationTarget;
typeIcons: Record<string, string>;
addingReceiverForTarget: number | null;
receiverForm: Record<string, any>;
receiverSubmitting: boolean;
receiverHeadersError: string;
receiverBotChats: Record<number, TelegramChat[]>;
receiverTesting: Record<number, boolean>;
receiverLabel: (target: NotificationTarget, recv: TargetReceiver) => string;
onopenReceiverForm: (targetId: number, targetType: string) => void;
onsaveReceiver: (targetId: number) => void;
oncancelReceiver: () => void;
ontoggleReceiver: (targetId: number, receiver: TargetReceiver) => void;
onremoveReceiver: (targetId: number, receiver: TargetReceiver) => void;
ontestReceiver: (targetId: number, receiverId: number) => void;
onloadBotChats: (botId: number) => void;
onchangeReceiverForm: (form: Record<string, any>) => void;
}
let {
target,
typeIcons,
addingReceiverForTarget,
receiverForm = $bindable(),
receiverSubmitting,
receiverHeadersError,
receiverBotChats,
receiverTesting,
receiverLabel,
onopenReceiverForm,
onsaveReceiver,
oncancelReceiver,
ontoggleReceiver,
onremoveReceiver,
ontestReceiver,
onloadBotChats,
onchangeReceiverForm,
}: Props = $props();
</script>
<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={typeIcons[target.type] || 'mdiTarget'} size={14} />
<span class="text-sm truncate">{receiverLabel(target, recv)}</span>
{#if (recv as any).language_code || recv.config?.language_code}
<span class="text-xs px-1 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{((recv as any).language_code || recv.config.language_code).toUpperCase()}</span>
{/if}
</div>
<div class="flex items-center gap-1">
<IconButton icon="mdiSend" title={t('targets.test')}
onclick={() => ontestReceiver(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={() => ontoggleReceiver(target.id, recv)}
size={16}
/>
<IconButton
icon="mdiDelete"
title={t('common.delete')}
onclick={() => onremoveReceiver(target.id, 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 existingKeys = new Set((target.receivers || []).map((r: TargetReceiver) => r.receiver_key))}
{@const chatItems = (receiverBotChats[botId] || []).map((c: any) => ({
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.language_code ? ' · ' + c.language_code.toUpperCase() : ''} · ${c.chat_id}`,
disabled: existingKeys.has(c.chat_id),
disabledHint: existingKeys.has(c.chat_id) ? t('targets.alreadyAdded') : undefined,
}))}
{#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={() => onloadBotChats(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={() => onsaveReceiver(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={oncancelReceiver}
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={() => onopenReceiverForm(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>
@@ -0,0 +1,172 @@
<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 IconGridSelect from '$lib/components/IconGridSelect.svelte';
import EntitySelect from '$lib/components/EntitySelect.svelte';
import type { EntityItem } from '$lib/components/EntitySelect.svelte';
import type { GridItem } from '$lib/components/IconGridSelect.svelte';
interface Props {
form: {
name: string;
icon: string;
bot_id: number;
bot_token: string;
max_media_to_send: number;
max_media_per_group: number;
media_delay: number;
max_asset_size: number;
disable_url_preview: boolean;
send_large_photos_as_documents: boolean;
ai_captions: boolean;
chat_action: string;
username: string;
server_url: string;
auth_token: string;
matrix_bot_id: number;
email_bot_id: number;
};
formType: string;
activeType: string | null;
typeGridItems: GridItem[];
telegramBotItems: EntityItem[];
emailBotItems: EntityItem[];
matrixBotItems: EntityItem[];
chatActionItems: GridItem[];
telegramBotCount: number;
emailBotCount: number;
matrixBotCount: number;
editing: number | null;
submitting: boolean;
error: string;
showTelegramSettings: boolean;
onsave: (e: SubmitEvent) => void;
ontoggleTelegramSettings: () => void;
}
let {
form = $bindable(),
formType = $bindable(),
activeType,
typeGridItems,
telegramBotItems,
emailBotItems,
matrixBotItems,
chatActionItems,
telegramBotCount,
emailBotCount,
matrixBotCount,
editing,
submitting,
error,
showTelegramSettings = $bindable(),
onsave,
ontoggleTelegramSettings,
}: 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">
{#if !activeType}
<div>
<label class="block text-sm font-medium mb-1">{t('targets.type')}</label>
<IconGridSelect items={typeGridItems} bind:value={formType} columns={4} />
</div>
{/if}
<div>
<label for="tgt-name" class="block text-sm font-medium mb-1">{t('targets.name')}</label>
<div class="flex gap-2">
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
<input id="tgt-name" bind:value={form.name} required placeholder={t('targets.namePlaceholder')} class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
</div>
{#if formType === 'telegram'}
<div>
<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 telegramBotCount === 0}
<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>
<div class="border border-[var(--color-border)] rounded-md p-3">
<button type="button" onclick={ontoggleTelegramSettings}
class="text-sm font-medium cursor-pointer w-full text-left flex items-center justify-between">
{t('targets.telegramSettings')}
<span class="text-xs transition-transform duration-200" class:rotate-180={showTelegramSettings}>▼</span>
</button>
{#if showTelegramSettings}
<div in:slide={{ duration: 150 }} class="grid grid-cols-2 gap-3 mt-3">
<div>
<label for="tgt-maxmedia" class="block text-xs mb-1">{t('targets.maxMedia')}<Hint text={t('hints.maxMedia')} /></label>
<input id="tgt-maxmedia" type="number" bind:value={form.max_media_to_send} min="0" max="50" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="tgt-groupsize" class="block text-xs mb-1">{t('targets.maxGroupSize')}<Hint text={t('hints.groupSize')} /></label>
<input id="tgt-groupsize" type="number" bind:value={form.max_media_per_group} min="2" max="10" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="tgt-delay" class="block text-xs mb-1">{t('targets.chunkDelay')}<Hint text={t('hints.chunkDelay')} /></label>
<input id="tgt-delay" type="number" bind:value={form.media_delay} min="0" max="60000" step="100" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="tgt-maxsize" class="block text-xs mb-1">{t('targets.maxAssetSize')}<Hint text={t('hints.maxAssetSize')} /></label>
<input id="tgt-maxsize" type="number" bind:value={form.max_asset_size} min="1" max="50" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div class="col-span-2">
<label class="block text-xs mb-1">{t('targets.chatAction')}</label>
<IconGridSelect items={chatActionItems} bind:value={form.chat_action} columns={4} compact />
</div>
<label class="flex items-center gap-2 text-sm col-span-2"><input type="checkbox" bind:checked={form.disable_url_preview} /> {t('targets.disableUrlPreview')}</label>
<label class="flex items-center gap-2 text-sm col-span-2"><input type="checkbox" bind:checked={form.send_large_photos_as_documents} /> {t('targets.sendLargeAsDocuments')}</label>
</div>
{/if}
</div>
{:else if formType === 'discord' || formType === 'slack'}
<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"
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
{:else if formType === 'ntfy'}
<div>
<label for="tgt-ntfy-server" class="block text-sm font-medium mb-1">{t('targets.ntfyServer')}</label>
<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-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')}
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
{:else if formType === 'email'}
<div>
<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 emailBotCount === 0}
<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>
{: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 matrixBotCount === 0}
<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>
{/if}
{#if formType === 'telegram'}
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.ai_captions} /> {t('targets.aiCaptions')}<Hint text={t('hints.aiCaptions')} /></label>
{/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('targets.create'))}</button>
</form>
</Card>
</div>
@@ -165,7 +165,7 @@ class TelegramClient:
"parse_mode": parse_mode,
}
if reply_to_message_id:
payload["reply_to_message_id"] = reply_to_message_id
payload["reply_parameters"] = {"message_id": reply_to_message_id}
if disable_web_page_preview:
payload["link_preview_options"] = {"is_disabled": True}
@@ -174,6 +174,14 @@ class TelegramClient:
result = await response.json()
if response.status == 200 and result.get("ok"):
return {"success": True, "message_id": result.get("result", {}).get("message_id")}
# Retry without parse_mode on parse errors
desc = str(result.get("description", ""))
if "parse" in desc.lower():
payload.pop("parse_mode", None)
async with self._session.post(telegram_url, json=payload) as retry_resp:
retry_result = await retry_resp.json()
if retry_resp.status == 200 and retry_result.get("ok"):
return {"success": True, "message_id": retry_result.get("result", {}).get("message_id")}
return {"success": False, "error": result.get("description", "Unknown Telegram error"), "error_code": result.get("error_code")}
except aiohttp.ClientError as err:
return {"success": False, "error": str(err)}
@@ -218,7 +226,7 @@ class TelegramClient:
if caption:
payload["caption"] = caption
if reply_to_message_id:
payload["reply_to_message_id"] = reply_to_message_id
payload["reply_parameters"] = {"message_id": reply_to_message_id}
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/sendPhoto"
try:
async with self._session.post(telegram_url, json=payload) as response:
@@ -251,7 +259,7 @@ class TelegramClient:
if caption:
form.add_field("caption", caption)
if reply_to_message_id:
form.add_field("reply_to_message_id", str(reply_to_message_id))
form.add_field("reply_parameters", json.dumps({"message_id": reply_to_message_id}))
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/sendPhoto"
async with self._session.post(telegram_url, data=form) as response:
@@ -286,7 +294,7 @@ class TelegramClient:
if caption:
payload["caption"] = caption
if reply_to_message_id:
payload["reply_to_message_id"] = reply_to_message_id
payload["reply_parameters"] = {"message_id": reply_to_message_id}
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/sendVideo"
try:
async with self._session.post(telegram_url, json=payload) as response:
@@ -315,7 +323,7 @@ class TelegramClient:
if caption:
form.add_field("caption", caption)
if reply_to_message_id:
form.add_field("reply_to_message_id", str(reply_to_message_id))
form.add_field("reply_parameters", json.dumps({"message_id": reply_to_message_id}))
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/sendVideo"
async with self._session.post(telegram_url, data=form) as response:
@@ -351,7 +359,7 @@ class TelegramClient:
if caption:
payload["caption"] = caption
if reply_to_message_id:
payload["reply_to_message_id"] = reply_to_message_id
payload["reply_parameters"] = {"message_id": reply_to_message_id}
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/sendDocument"
try:
async with self._session.post(telegram_url, json=payload) as response:
@@ -369,7 +377,7 @@ class TelegramClient:
if caption:
form.add_field("caption", caption)
if reply_to_message_id:
form.add_field("reply_to_message_id", str(reply_to_message_id))
form.add_field("reply_parameters", json.dumps({"message_id": reply_to_message_id}))
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/sendDocument"
async with self._session.post(telegram_url, data=form) as response:
@@ -418,7 +426,7 @@ class TelegramClient:
form = FormData()
form.add_field("chat_id", chat_id)
if reply_to_message_id and chunk_idx == 0:
form.add_field("reply_to_message_id", str(reply_to_message_id))
form.add_field("reply_parameters", json.dumps({"message_id": reply_to_message_id}))
media_json = []
upload_idx = 0
@@ -488,3 +496,96 @@ class TelegramClient:
return {"success": False, "error": str(err), "failed_at_chunk": chunk_idx + 1}
return {"success": True, "message_ids": all_message_ids, "chunks_sent": len(chunks)}
# ------------------------------------------------------------------
# Bot management methods
# ------------------------------------------------------------------
async def get_me(self) -> dict[str, Any]:
"""Call getMe to verify the bot token and get bot info."""
url = f"{TELEGRAM_API_BASE_URL}{self._token}/getMe"
try:
async with self._session.get(url) as resp:
data = await resp.json()
if data.get("ok"):
return {"success": True, "result": data.get("result", {})}
return {"success": False, "error": data.get("description", "Unknown error")}
except aiohttp.ClientError as err:
return {"success": False, "error": str(err)}
async def get_webhook_info(self) -> dict[str, Any]:
"""Call getWebhookInfo to check current webhook status."""
url = f"{TELEGRAM_API_BASE_URL}{self._token}/getWebhookInfo"
try:
async with self._session.get(url) as resp:
data = await resp.json()
if data.get("ok"):
return {"success": True, "result": data.get("result", {})}
return {"success": False, "error": data.get("description", "Unknown error")}
except aiohttp.ClientError as err:
return {"success": False, "error": str(err)}
async def set_webhook(self, webhook_url: str, secret: str | None = None) -> dict[str, Any]:
"""Register a webhook URL with Telegram."""
url = f"{TELEGRAM_API_BASE_URL}{self._token}/setWebhook"
payload: dict[str, Any] = {"url": webhook_url}
if secret:
payload["secret_token"] = secret
try:
async with self._session.post(url, json=payload) as resp:
data = await resp.json()
if data.get("ok"):
return {"success": True}
return {"success": False, "error": data.get("description", "Unknown error")}
except aiohttp.ClientError as err:
return {"success": False, "error": str(err)}
async def delete_webhook(self) -> dict[str, Any]:
"""Remove the webhook from Telegram."""
url = f"{TELEGRAM_API_BASE_URL}{self._token}/deleteWebhook"
try:
async with self._session.post(url) as resp:
data = await resp.json()
return {"success": data.get("ok", False)}
except aiohttp.ClientError as err:
return {"success": False, "error": str(err)}
async def get_updates(
self, offset: int | None = None, limit: int = 50, timeout: int = 0,
) -> dict[str, Any]:
"""Long-poll for updates via getUpdates."""
url = f"{TELEGRAM_API_BASE_URL}{self._token}/getUpdates"
params: dict[str, Any] = {
"timeout": timeout,
"limit": limit,
"allowed_updates": '["message"]',
}
if offset is not None:
params["offset"] = offset
try:
async with self._session.get(
url, params=params, timeout=aiohttp.ClientTimeout(total=max(10, timeout + 5)),
) as resp:
data = await resp.json()
if data.get("ok"):
return {"success": True, "result": data.get("result", [])}
return {"success": False, "error": data.get("description", "Unknown error")}
except aiohttp.ClientError as err:
return {"success": False, "error": str(err)}
async def set_my_commands(
self, commands: list[dict[str, str]], language_code: str | None = None,
) -> dict[str, Any]:
"""Register bot commands with BotFather API."""
url = f"{TELEGRAM_API_BASE_URL}{self._token}/setMyCommands"
payload: dict[str, Any] = {"commands": commands}
if language_code:
payload["language_code"] = language_code
try:
async with self._session.post(url, json=payload) as resp:
data = await resp.json()
if data.get("ok"):
return {"success": True}
return {"success": False, "error": data.get("description", "Unknown error")}
except aiohttp.ClientError as err:
return {"success": False, "error": str(err)}
@@ -71,20 +71,29 @@ IMMICH_CAPABILITIES = ProviderCapabilities(
{"name": "memory", "description": "/memory On This Day photos"},
{"name": "rate_limited", "description": "Rate limit warning message"},
{"name": "no_results", "description": "Empty results fallback"},
{"name": "desc_help", "description": "Menu description for /help"},
{"name": "desc_status", "description": "Menu description for /status"},
{"name": "desc_albums", "description": "Menu description for /albums"},
{"name": "desc_events", "description": "Menu description for /events"},
{"name": "usage_events", "description": "Usage example for /events"},
{"name": "desc_summary", "description": "Menu description for /summary"},
{"name": "desc_latest", "description": "Menu description for /latest"},
{"name": "usage_latest", "description": "Usage example for /latest"},
{"name": "desc_memory", "description": "Menu description for /memory"},
{"name": "usage_memory", "description": "Usage example for /memory"},
{"name": "desc_random", "description": "Menu description for /random"},
{"name": "usage_random", "description": "Usage example for /random"},
{"name": "desc_search", "description": "Menu description for /search"},
{"name": "usage_search", "description": "Usage example for /search"},
{"name": "desc_find", "description": "Menu description for /find"},
{"name": "usage_find", "description": "Usage example for /find"},
{"name": "desc_person", "description": "Menu description for /person"},
{"name": "usage_person", "description": "Usage example for /person"},
{"name": "desc_place", "description": "Menu description for /place"},
{"name": "usage_place", "description": "Usage example for /place"},
{"name": "desc_favorites", "description": "Menu description for /favorites"},
{"name": "usage_favorites", "description": "Usage example for /favorites"},
{"name": "desc_people", "description": "Menu description for /people"},
{"name": "desc_help", "description": "Menu description for /help"},
],
events=[
{"name": "assets_added", "description": "New assets detected in album"},
@@ -237,6 +237,8 @@ class ImmichClient:
limit: int = 10,
) -> list[dict[str, Any]]:
payload: dict[str, Any] = {"query": query, "page": 1, "size": limit}
if album_ids:
payload["albumIds"] = album_ids
try:
async with self._session.post(
f"{self._url}/api/search/smart",
@@ -246,15 +248,6 @@ class ImmichClient:
if response.status == 200:
data = await response.json()
items = data.get("assets", {}).get("items", [])
if album_ids:
tracked = set(album_ids)
items = [
a for a in items
if any(
alb.get("id") in tracked
for alb in a.get("albums", [])
)
]
return items[:limit]
except aiohttp.ClientError:
pass
@@ -267,6 +260,8 @@ class ImmichClient:
limit: int = 10,
) -> list[dict[str, Any]]:
payload: dict[str, Any] = {"originalFileName": query, "page": 1, "size": limit}
if album_ids:
payload["albumIds"] = album_ids
try:
async with self._session.post(
f"{self._url}/api/search/metadata",
@@ -276,12 +271,6 @@ class ImmichClient:
if response.status == 200:
data = await response.json()
items = data.get("assets", {}).get("items", [])
if album_ids:
tracked = set(album_ids)
items = [
a for a in items
if any(alb.get("id") in tracked for alb in a.get("albums", []))
]
return items[:limit]
except aiohttp.ClientError:
pass
@@ -1,4 +1,5 @@
Available commands:
{%- for cmd in commands %}
/{{ cmd.name }} — {{ cmd.description }}
{%- if cmd.usage %} ↳ {{ cmd.usage }}{% endif %}
{%- endfor %}
@@ -0,0 +1 @@
/search sunset at the beach
@@ -19,6 +19,10 @@ PROVIDER_COMMAND_SLOTS: dict[str, list[str]] = {
"desc_latest", "desc_memory", "desc_random", "desc_search",
"desc_find", "desc_person", "desc_place", "desc_favorites",
"desc_people", "desc_help",
# Usage example slots
"usage_search", "usage_find", "usage_person", "usage_place",
"usage_latest", "usage_random", "usage_favorites", "usage_events",
"usage_memory",
],
"gitea": [
# Response templates
@@ -1,4 +1,5 @@
Доступные команды:
{%- for cmd in commands %}
/{{ cmd.name }} — {{ cmd.description }}
{%- if cmd.usage %} ↳ {{ cmd.usage }}{% endif %}
{%- endfor %}
@@ -0,0 +1 @@
/person Алиса
@@ -0,0 +1 @@
/place Париж
@@ -0,0 +1 @@
/search закат на пляже
+2
View File
@@ -18,6 +18,8 @@ dependencies = [
"apscheduler>=3.10,<4",
"aiohttp>=3.9",
"pydantic-settings>=2.0",
"slowapi>=0.1.9",
"cachetools>=5.3",
]
[project.optional-dependencies]
@@ -57,7 +57,11 @@ async def get_settings(
"""Return all app settings."""
result = {}
for key in _SETTING_KEYS:
result[key] = await get_setting(session, key)
value = await get_setting(session, key)
if key == "telegram_webhook_secret" and value:
result[key] = f"***{value[-4:]}" if len(value) > 4 else "***"
else:
result[key] = value
return result
@@ -124,6 +124,7 @@ async def get_command_variables():
command_fields = {
"name": "Command name (e.g. status, albums)",
"description": "Command description text",
"usage": "Usage example (e.g. /search sunset) — only for commands that take arguments",
}
event_fields = {
"type": "Event type (assets_added, assets_removed, etc.)",
@@ -197,6 +198,31 @@ async def get_command_variables():
"description": "Empty results fallback",
"variables": {**common_vars, "command": "Command name", "query": "Search query (empty for non-search commands)"},
},
# --- Description slots (shown in /help listing) ---
"desc_help": {"description": "Description for /help command", "variables": common_vars},
"desc_status": {"description": "Description for /status command", "variables": common_vars},
"desc_albums": {"description": "Description for /albums command", "variables": common_vars},
"desc_events": {"description": "Description for /events command", "variables": common_vars},
"desc_summary": {"description": "Description for /summary command", "variables": common_vars},
"desc_latest": {"description": "Description for /latest command", "variables": common_vars},
"desc_memory": {"description": "Description for /memory command", "variables": common_vars},
"desc_random": {"description": "Description for /random command", "variables": common_vars},
"desc_search": {"description": "Description for /search command", "variables": common_vars},
"desc_find": {"description": "Description for /find command", "variables": common_vars},
"desc_person": {"description": "Description for /person command", "variables": common_vars},
"desc_place": {"description": "Description for /place command", "variables": common_vars},
"desc_favorites": {"description": "Description for /favorites command", "variables": common_vars},
"desc_people": {"description": "Description for /people command", "variables": common_vars},
# --- Usage example slots (shown in /help listing) ---
"usage_search": {"description": "Usage example for /search (e.g. '/search sunset')", "variables": common_vars},
"usage_find": {"description": "Usage example for /find", "variables": common_vars},
"usage_person": {"description": "Usage example for /person", "variables": common_vars},
"usage_place": {"description": "Usage example for /place", "variables": common_vars},
"usage_latest": {"description": "Usage example for /latest", "variables": common_vars},
"usage_random": {"description": "Usage example for /random", "variables": common_vars},
"usage_favorites": {"description": "Usage example for /favorites", "variables": common_vars},
"usage_events": {"description": "Usage example for /events", "variables": common_vars},
"usage_memory": {"description": "Usage example for /memory", "variables": common_vars},
}
@@ -256,6 +282,8 @@ async def update_config(
session: AsyncSession = Depends(get_session),
):
config = await _get(session, config_id, user.id)
if config.user_id == 0 and user.role != "admin":
raise HTTPException(status_code=403, detail="Cannot modify system default configs")
for field, value in body.model_dump(exclude_unset=True, exclude={"slots"}).items():
if value is not None:
setattr(config, field, value)
@@ -275,6 +303,8 @@ async def delete_config(
):
from .delete_protection import check_command_template_config, raise_if_used
config = await _get(session, config_id, user.id)
if config.user_id == 0 and user.role != "admin":
raise HTTPException(status_code=403, detail="Cannot delete system default configs")
raise_if_used(await check_command_template_config(session, config.id), config.name)
slot_result = await session.exec(
select(CommandTemplateSlot).where(CommandTemplateSlot.config_id == config.id)
@@ -306,9 +336,10 @@ async def preview_raw(
"last_event": "2026-03-19 14:30",
# /help
"commands": [
{"name": "status", "description": "Show tracker status"},
{"name": "albums", "description": "List tracked albums"},
{"name": "latest", "description": "Show latest photos"},
{"name": "status", "description": "Show tracker status", "usage": ""},
{"name": "albums", "description": "List tracked albums", "usage": ""},
{"name": "latest", "description": "Show latest photos", "usage": "/latest 10"},
{"name": "search", "description": "Smart search (AI)", "usage": "/search sunset at the beach"},
],
# /albums, /summary
"albums": [
@@ -3,7 +3,7 @@
import logging
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from pydantic import BaseModel, ValidationError
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from typing import Any
@@ -42,6 +42,48 @@ class ProviderResponse(BaseModel):
created_at: str
# -- Per-provider config validation models --
class ImmichProviderConfig(BaseModel):
url: str
api_key: str
external_domain: str | None = None
class GiteaProviderConfig(BaseModel):
url: str
webhook_secret: str
api_token: str | None = None
class SchedulerProviderConfig(BaseModel):
"""Scheduler is a virtual provider — no required fields."""
pass
_PROVIDER_CONFIG_MODELS: dict[str, type[BaseModel]] = {
"immich": ImmichProviderConfig,
"gitea": GiteaProviderConfig,
"scheduler": SchedulerProviderConfig,
}
def _validate_provider_config(provider_type: str, config: dict[str, Any]) -> None:
"""Validate provider config against the schema for the given type."""
config_model = _PROVIDER_CONFIG_MODELS.get(provider_type)
if config_model is None:
return
try:
config_model.model_validate(config)
except ValidationError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid config for '{provider_type}' provider: {exc}",
)
@router.get("")
async def list_providers(
user: User = Depends(get_current_user),
@@ -62,6 +104,8 @@ async def create_provider(
session: AsyncSession = Depends(get_session),
):
"""Add a new service provider (validates connection for known types)."""
_validate_provider_config(body.type, body.config)
# Validate connection for known provider types
if body.type == "immich":
from notify_bridge_core.providers.immich import ImmichServiceProvider
@@ -177,6 +221,7 @@ async def update_provider(
config_changed = body.config is not None and body.config != provider.config
if body.config is not None:
_validate_provider_config(provider.type, body.config)
provider.config = body.config
# Re-validate connection when config changes for known provider types
@@ -17,18 +17,11 @@ from notify_bridge_core.providers.gitea.event_parser import parse_webhook as par
from ..database.engine import get_engine
from ..database.models import (
EmailBot,
EventLog,
MatrixBot,
NotificationTarget,
NotificationTracker,
NotificationTrackerTarget,
ServiceProvider,
TargetReceiver,
TemplateConfig,
TemplateSlot,
TrackingConfig,
)
from ..services.dispatch_helpers import event_allowed_by_config, load_link_data
_LOGGER = logging.getLogger(__name__)
@@ -93,10 +86,15 @@ async def gitea_webhook(provider_id: int, request: Request):
# Read raw body for HMAC check
raw_body = await request.body()
if webhook_secret:
signature = request.headers.get("X-Gitea-Signature", "")
if not signature or not _verify_gitea_signature(webhook_secret, raw_body, signature):
raise HTTPException(status_code=403, detail="Invalid signature")
if not webhook_secret:
raise HTTPException(
status_code=403,
detail="Webhook secret not configured on this provider",
)
signature = request.headers.get("X-Gitea-Signature", "")
if not signature or not _verify_gitea_signature(webhook_secret, raw_body, signature):
raise HTTPException(status_code=403, detail="Invalid signature")
# Parse event header + payload
event_header = request.headers.get("X-Gitea-Event", "")
@@ -133,7 +131,7 @@ async def gitea_webhook(provider_id: int, request: Request):
continue
# Load tracker-target links
link_data = await _load_link_data(session, tracker.id)
link_data = await load_link_data(session, tracker.id)
if not link_data:
continue
@@ -176,122 +174,6 @@ async def gitea_webhook(provider_id: int, request: Request):
return {"ok": True, "dispatched": dispatched}
# ---------------------------------------------------------------------------
# Shared dispatch helpers (extracted from watcher pattern)
# ---------------------------------------------------------------------------
async def _load_link_data(
session: AsyncSession,
tracker_id: int,
) -> list[dict[str, Any]]:
"""Load tracker-target link data for dispatch (same pattern as watcher)."""
tt_result = await session.exec(
select(NotificationTrackerTarget).where(
NotificationTrackerTarget.tracker_id == tracker_id
)
)
tracker_targets = tt_result.all()
link_data: list[dict[str, Any]] = []
for tt in tracker_targets:
if not tt.enabled:
continue
target = await session.get(NotificationTarget, tt.target_id)
if not target:
continue
# Load receivers
recv_result = await session.exec(
select(TargetReceiver).where(
TargetReceiver.target_id == target.id,
TargetReceiver.enabled == True,
)
)
receivers = [dict(r.config) for r in recv_result.all()]
tracking_config = None
if tt.tracking_config_id:
tracking_config = await session.get(TrackingConfig, tt.tracking_config_id)
template_config = None
template_slots: dict[str, str] | None = None
if tt.template_config_id:
template_config = await session.get(TemplateConfig, tt.template_config_id)
if template_config:
slot_result = await session.exec(
select(TemplateSlot).where(TemplateSlot.config_id == template_config.id)
)
raw_slots = {s.slot_name: s.template for s in slot_result.all()}
template_slots = {}
for slot_name, tmpl_text in raw_slots.items():
event_key = slot_name.removeprefix("message_") if slot_name.startswith("message_") else slot_name
template_slots[event_key] = tmpl_text
target_config = dict(target.config)
# Inject chat_action for Telegram targets
if hasattr(target, 'chat_action') and target.chat_action:
target_config["chat_action"] = target.chat_action
# Inject bot credentials
if target.type == "email":
email_bot_id = target.config.get("email_bot_id")
if email_bot_id:
email_bot = await session.get(EmailBot, email_bot_id)
if email_bot:
target_config["smtp"] = {
"host": email_bot.smtp_host,
"port": email_bot.smtp_port,
"username": email_bot.smtp_username,
"password": email_bot.smtp_password,
"from_address": email_bot.email,
"from_name": email_bot.name,
"use_tls": email_bot.smtp_use_tls,
}
elif target.type == "matrix":
matrix_bot_id = target.config.get("matrix_bot_id")
if matrix_bot_id:
matrix_bot = await session.get(MatrixBot, matrix_bot_id)
if matrix_bot:
target_config["homeserver_url"] = matrix_bot.homeserver_url
target_config["access_token"] = matrix_bot.access_token
link_data.append({
"target_type": target.type,
"target_config": target_config,
"receivers": receivers,
"tracking_config": tracking_config,
"template_config": template_config,
"template_slots": template_slots,
})
return link_data
def _event_allowed_by_tracking_config(event: ServiceEvent, tc: TrackingConfig) -> bool:
"""Check if an event type is allowed by tracking config flags."""
event_type = event.event_type.value
flag_map = {
"push": tc.track_push,
"issue_opened": tc.track_issue_opened,
"issue_closed": tc.track_issue_closed,
"issue_commented": tc.track_issue_commented,
"pr_opened": tc.track_pr_opened,
"pr_closed": tc.track_pr_closed,
"pr_merged": tc.track_pr_merged,
"pr_commented": tc.track_pr_commented,
"release_published": tc.track_release_published,
# Scheduler events
"scheduled_message": tc.track_scheduled_message,
# Immich events
"assets_added": tc.track_assets_added,
"assets_removed": tc.track_assets_removed,
"collection_renamed": tc.track_collection_renamed,
"collection_deleted": tc.track_collection_deleted,
"sharing_changed": tc.track_sharing_changed,
}
return flag_map.get(event_type, True)
def _build_target_configs(
event: ServiceEvent,
link_data: list[dict[str, Any]],
@@ -301,7 +183,7 @@ def _build_target_configs(
target_configs: list[TargetConfig] = []
for ld in link_data:
tc = ld["tracking_config"]
if tc and not _event_allowed_by_tracking_config(event, tc):
if tc and not event_allowed_by_config(event, tc):
continue
tmpl = ld["template_config"]
@@ -1,7 +1,9 @@
"""Authentication API routes."""
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel
from slowapi import Limiter
from slowapi.util import get_remote_address
from sqlmodel import func, select
from sqlmodel.ext.asyncio.session import AsyncSession
@@ -14,6 +16,8 @@ from .jwt import create_access_token, create_refresh_token, decode_token
router = APIRouter(prefix="/api/auth", tags=["auth"])
limiter = Limiter(key_func=get_remote_address)
class SetupRequest(BaseModel):
username: str
@@ -50,14 +54,15 @@ def _verify_password(password: str, hashed: str) -> bool:
@router.post("/setup", response_model=TokenResponse)
async def setup(body: SetupRequest, session: AsyncSession = Depends(get_session)):
@limiter.limit("3/minute")
async def setup(request: Request, body: SetupRequest, session: AsyncSession = Depends(get_session)):
result = await session.exec(select(func.count()).select_from(User))
count = result.one()
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")
if len(body.password) < 8:
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
user = User(username=body.username, hashed_password=_hash_password(body.password), role="admin")
session.add(user)
await session.commit()
@@ -70,7 +75,8 @@ async def setup(body: SetupRequest, session: AsyncSession = Depends(get_session)
@router.post("/login", response_model=TokenResponse)
async def login(body: LoginRequest, session: AsyncSession = Depends(get_session)):
@limiter.limit("5/minute")
async def login(request: Request, body: LoginRequest, session: AsyncSession = Depends(get_session)):
result = await session.exec(select(User).where(User.username == body.username))
user = result.first()
if not user or not _verify_password(body.password, user.hashed_password):
@@ -121,8 +127,8 @@ async def change_password(
):
if not _verify_password(body.current_password, user.hashed_password):
raise HTTPException(status_code=400, detail="Current password is incorrect")
if len(body.new_password) < 6:
raise HTTPException(status_code=400, detail="New password must be at least 6 characters")
if len(body.new_password) < 8:
raise HTTPException(status_code=400, detail="New password must be at least 8 characters")
user.hashed_password = _hash_password(body.new_password)
session.add(user)
await session.commit()
@@ -29,7 +29,7 @@ def get_all_handlers() -> dict[str, ProviderCommandHandler]:
def _auto_register() -> None:
"""Auto-register all built-in handlers."""
from .immich_handler import ImmichCommandHandler
from .immich import ImmichCommandHandler
from .gitea_handler import GiteaCommandHandler
register_handler(ImmichCommandHandler())
@@ -7,10 +7,12 @@ import time
from typing import Any
import aiohttp
from cachetools import TTLCache
from jinja2.sandbox import SandboxedEnvironment
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from notify_bridge_core.notifications.telegram.media import TELEGRAM_API_BASE_URL
from notify_bridge_core.notifications.telegram.client import TelegramClient
from ..database.engine import get_engine
from ..database.models import (
CommandConfig,
@@ -28,8 +30,11 @@ from .registry import get_rate_category
_LOGGER = logging.getLogger(__name__)
# Rate limit state: { (bot_id, chat_id, category): last_used_timestamp }
_rate_limits: dict[tuple[int, str, str], float] = {}
# Singleton Jinja2 environment for template rendering (Phase 4d)
_JINJA_ENV = SandboxedEnvironment(autoescape=False)
# Rate limit state with automatic TTL expiry (Phase 4e)
_rate_limits: TTLCache = TTLCache(maxsize=10000, ttl=3600)
def _check_rate_limit(bot_id: int, chat_id: str, cmd: str, limits: dict[str, int]) -> int | None:
@@ -65,9 +70,7 @@ def _render_cmd_template(
_LOGGER.warning("No command template found for slot '%s' locale '%s'", slot_name, locale)
return f"[No template: {slot_name}]"
try:
from jinja2.sandbox import SandboxedEnvironment
env = SandboxedEnvironment(autoescape=False)
tmpl = env.from_string(template_str)
tmpl = _JINJA_ENV.from_string(template_str)
return tmpl.render(**context)
except Exception as e:
_LOGGER.warning("Failed to render command template '%s': %s", slot_name, e)
@@ -95,15 +98,46 @@ async def _resolve_command_context(
if not listeners:
return [], {}
# Batch-fetch all referenced entities in 3 queries instead of N*3
tracker_ids = list({l.command_tracker_id for l in listeners})
tracker_result = await session.exec(
select(CommandTracker).where(CommandTracker.id.in_(tracker_ids))
)
trackers_by_id = {t.id: t for t in tracker_result.all()}
config_ids = list({
t.command_config_id for t in trackers_by_id.values()
if t.enabled and t.command_config_id
})
if config_ids:
config_result = await session.exec(
select(CommandConfig).where(CommandConfig.id.in_(config_ids))
)
configs_by_id = {c.id: c for c in config_result.all()}
else:
configs_by_id = {}
provider_ids = list({
t.provider_id for t in trackers_by_id.values()
if t.enabled and t.provider_id
})
if provider_ids:
provider_result = await session.exec(
select(ServiceProvider).where(ServiceProvider.id.in_(provider_ids))
)
providers_by_id = {p.id: p for p in provider_result.all()}
else:
providers_by_id = {}
tuples: list[tuple[CommandTracker, CommandConfig, ServiceProvider]] = []
for listener in listeners:
tracker = await session.get(CommandTracker, listener.command_tracker_id)
tracker = trackers_by_id.get(listener.command_tracker_id)
if not tracker or not tracker.enabled:
continue
config = await session.get(CommandConfig, tracker.command_config_id)
config = configs_by_id.get(tracker.command_config_id)
if not config:
continue
provider = await session.get(ServiceProvider, tracker.provider_id)
provider = providers_by_id.get(tracker.provider_id)
if not provider:
continue
tuples.append((tracker, config, provider))
@@ -220,7 +254,11 @@ def _cmd_help(
commands = []
for cmd in enabled:
desc_text = _resolve_template(templates, f"desc_{cmd}", locale) or cmd
commands.append({"name": cmd, "description": desc_text})
entry: dict[str, str] = {"name": cmd, "description": desc_text}
usage_text = _resolve_template(templates, f"usage_{cmd}", locale)
if usage_text:
entry["usage"] = usage_text
commands.append(entry)
return {"commands": commands}
@@ -240,128 +278,93 @@ async def _get_notification_trackers_for_providers(
return list(result.all())
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_reply(
bot_token: str, chat_id: str, text: str, reply_to_message_id: int | None = None,
session: aiohttp.ClientSession | None = None,
) -> None:
"""Send a text reply via TelegramClient."""
async def _send(http: aiohttp.ClientSession) -> None:
client = TelegramClient(http, bot_token)
result = await client.send_message(chat_id, text, reply_to_message_id=reply_to_message_id)
if not result.get("success"):
_LOGGER.warning("Telegram reply failed: %s", result.get("error"))
if session is not None:
await _send(session)
else:
async with aiohttp.ClientSession() as http:
await _send(http)
async def send_media_group(
bot_token: str, chat_id: str, media_items: list[dict[str, Any]],
reply_to_message_id: int | None = None,
session: aiohttp.ClientSession | None = None,
) -> None:
"""Send media items as a Telegram media group (album)."""
"""Send media items via TelegramClient.send_notification."""
if not media_items:
return
async with aiohttp.ClientSession() as http:
downloaded: list[tuple[bytes, str, str]] = []
for item in media_items:
asset_id = item.get("asset_id", "")
caption = item.get("caption", "")
thumb_url = item.get("thumbnail_url", "")
api_key = item.get("api_key", "")
try:
async with http.get(thumb_url, headers={"x-api-key": api_key}) as resp:
if resp.status != 200:
_LOGGER.warning("Failed to download thumbnail for %s: HTTP %d", asset_id, resp.status)
continue
photo_bytes = await resp.read()
downloaded.append((photo_bytes, asset_id, caption))
except aiohttp.ClientError:
continue
# Convert command handler media format to TelegramClient asset format
assets = []
for item in media_items:
assets.append({
"type": "photo",
"url": item.get("thumbnail_url", ""),
"cache_key": item.get("asset_id", ""),
"headers": {"x-api-key": item.get("api_key", "")},
})
if not downloaded:
return
# Build caption from first item
captions = [item.get("caption", "") for item in media_items if item.get("caption")]
caption = "\n".join(captions) if captions else None
for i in range(0, len(downloaded), 10):
chunk = downloaded[i:i + 10]
async def _send(http: aiohttp.ClientSession) -> None:
client = TelegramClient(http, bot_token)
result = await client.send_notification(
chat_id, assets=assets, caption=caption,
reply_to_message_id=reply_to_message_id,
chat_action=None,
)
if not result.get("success"):
_LOGGER.warning("Telegram media group failed: %s", result.get("error"))
if len(chunk) == 1:
photo_bytes, asset_id, caption = chunk[0]
data = aiohttp.FormData()
data.add_field("chat_id", chat_id)
data.add_field("photo", photo_bytes, filename=f"{asset_id}.jpg", content_type="image/jpeg")
if caption:
data.add_field("caption", caption)
try:
async with http.post(f"{TELEGRAM_API_BASE_URL}{bot_token}/sendPhoto", data=data) as resp:
if resp.status != 200:
result = await resp.json()
_LOGGER.warning("Failed to send photo: %s", result.get("description"))
except aiohttp.ClientError as err:
_LOGGER.warning("Failed to send photo: %s", err)
else:
import json as _json
data = aiohttp.FormData()
data.add_field("chat_id", chat_id)
media_array = []
for idx, (photo_bytes, asset_id, caption) in enumerate(chunk):
attach_key = f"photo_{idx}"
media_obj: dict[str, Any] = {"type": "photo", "media": f"attach://{attach_key}"}
if caption:
media_obj["caption"] = caption
media_array.append(media_obj)
data.add_field(attach_key, photo_bytes, filename=f"{asset_id}.jpg", content_type="image/jpeg")
data.add_field("media", _json.dumps(media_array))
try:
async with http.post(f"{TELEGRAM_API_BASE_URL}{bot_token}/sendMediaGroup", data=data) as resp:
if resp.status != 200:
result = await resp.json()
_LOGGER.warning("Failed to send media group: %s", result.get("description"))
except aiohttp.ClientError as err:
_LOGGER.warning("Failed to send media group: %s", err)
if session is not None:
await _send(session)
else:
async with aiohttp.ClientSession() as http:
await _send(http)
async def register_commands_with_telegram(bot: TelegramBot) -> bool:
"""Register enabled commands with Telegram BotFather API."""
"""Register enabled commands with Telegram BotFather API via TelegramClient."""
ctx_tuples, templates = await _resolve_command_context(bot)
enabled, _, _, _ = _merge_command_context(ctx_tuples)
async with aiohttp.ClientSession() as http:
url = f"{TELEGRAM_API_BASE_URL}{bot.token}/setMyCommands"
client = TelegramClient(http, bot.token)
success = False
# Register per-locale commands
for locale in ("en", "ru"):
commands = []
for cmd in enabled:
desc = _resolve_template(templates, f"desc_{cmd}", locale) or cmd
commands.append({"command": cmd, "description": desc})
result = await client.set_my_commands(commands, language_code=locale)
if result.get("success"):
success = True
else:
_LOGGER.warning("Failed to register commands for locale '%s': %s", locale, result.get("error"))
payload: dict[str, Any] = {"commands": commands, "language_code": locale}
try:
async with http.post(url, json=payload) as resp:
result = await resp.json()
if result.get("ok"):
success = True
else:
_LOGGER.warning("Failed to register commands for locale '%s': %s", locale, result.get("description"))
except aiohttp.ClientError as err:
_LOGGER.error("Failed to register commands for locale '%s': %s", locale, err)
# Register default (no language_code) with EN descriptions
en_commands = []
for cmd in enabled:
desc = _resolve_template(templates, f"desc_{cmd}", "en") or cmd
en_commands.append({"command": cmd, "description": desc})
try:
async with http.post(url, json={"commands": en_commands}) as resp:
result = await resp.json()
if result.get("ok"):
_LOGGER.info("Registered %d commands for bot @%s (all locales)", len(en_commands), bot.bot_username)
success = True
except aiohttp.ClientError as err:
_LOGGER.error("Failed to register default commands: %s", err)
result = await client.set_my_commands(en_commands)
if result.get("success"):
_LOGGER.info("Registered %d commands for bot @%s (all locales)", len(en_commands), bot.bot_username)
success = True
return success
@@ -0,0 +1,5 @@
"""Immich command handler subpackage."""
from .handler import ImmichCommandHandler
__all__ = ["ImmichCommandHandler"]
@@ -0,0 +1,113 @@
"""Album-related Immich bot commands: albums, favorites, summary."""
from __future__ import annotations
import asyncio
import logging
from typing import Any
import aiohttp
from ...database.models import ServiceProvider, TelegramBot
from ...services import make_immich_provider
from ..handler import _get_notification_trackers_for_providers, _render_cmd_template
from .common import _format_assets
_LOGGER = logging.getLogger(__name__)
async def _cmd_albums(
bot: TelegramBot, providers_map: dict[int, ServiceProvider], locale: str,
) -> dict[str, Any]:
provider_ids = set(providers_map.keys())
trackers = await _get_notification_trackers_for_providers(provider_ids)
if not trackers:
return {"albums": []}
albums_data: list[dict] = []
async with aiohttp.ClientSession() as http:
for tracker in trackers:
provider = providers_map.get(tracker.provider_id)
if not provider or provider.type != "immich":
continue
immich = make_immich_provider(http, provider)
album_ids = tracker.collection_ids or []
if not album_ids:
continue
results = await asyncio.gather(
*[immich.client.get_album(aid) for aid in album_ids],
return_exceptions=True,
)
for album_id, result in zip(album_ids, results):
if isinstance(result, Exception):
_LOGGER.warning("Failed to fetch album %s: %s", album_id, result)
albums_data.append({
"name": f"{album_id[:8]}...", "asset_count": "?", "id": album_id,
})
elif result:
albums_data.append({
"name": result.name, "asset_count": result.asset_count, "id": album_id,
})
return {"albums": albums_data}
async def cmd_favorites(
bot: TelegramBot, providers_map: dict[int, ServiceProvider],
all_album_ids: list[str], count: int, locale: str,
response_mode: str, client: Any,
cmd_templates: dict[str, dict[str, str]],
) -> str | list[dict[str, Any]]:
"""Handle /favorites command with concurrent album fetching."""
album_ids = all_album_ids[:10]
if not album_ids:
return _format_assets([], "favorites", "", locale, response_mode, client, cmd_templates)
results = await asyncio.gather(
*[client.get_album(aid) for aid in album_ids],
return_exceptions=True,
)
fav_assets: list[dict[str, Any]] = []
for album_id, result in zip(album_ids, results):
if isinstance(result, Exception):
_LOGGER.warning("Failed to fetch album %s: %s", album_id, result)
continue
if result:
for aid, asset in list(result.assets.items())[:50]:
if asset.is_favorite and len(fav_assets) < count:
fav_assets.append({
"id": asset.id, "originalFileName": asset.filename,
"type": asset.type,
})
if len(fav_assets) >= count:
break
return _format_assets(fav_assets, "favorites", "", locale, response_mode, client, cmd_templates)
async def cmd_summary(
client: Any, all_album_ids: list[str], locale: str,
cmd_templates: dict[str, dict[str, str]],
) -> str:
"""Handle /summary command with concurrent album fetching."""
if not all_album_ids:
return _render_cmd_template(cmd_templates, "summary", locale, {"albums": []})
results = await asyncio.gather(
*[client.get_album(aid) for aid in all_album_ids],
return_exceptions=True,
)
albums_data: list[dict] = []
for album_id, result in zip(all_album_ids, results):
if isinstance(result, Exception):
_LOGGER.warning("Failed to fetch album %s: %s", album_id, result)
continue
if result:
albums_data.append({
"name": result.name, "asset_count": result.asset_count, "id": album_id,
})
return _render_cmd_template(cmd_templates, "summary", locale, {"albums": albums_data})
@@ -0,0 +1,49 @@
"""Shared helpers, imports, and constants for Immich command handlers."""
from __future__ import annotations
import logging
from typing import Any
from ...services import make_immich_provider
from ..handler import _render_cmd_template
_LOGGER = logging.getLogger(__name__)
_IMMICH_COMMANDS = {
"status", "albums", "events", "people",
"search", "find", "person", "place",
"latest", "random", "favorites", "summary", "memory",
}
def _format_assets(
assets: list[dict[str, Any]], cmd: str, query: str,
locale: str, response_mode: str, client: Any,
cmd_templates: dict[str, dict[str, str]],
) -> str | list[dict[str, Any]]:
"""Format asset results as text or media payload."""
if not assets:
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": cmd, "query": query})
if response_mode == "media":
media_items = []
for asset in assets:
asset_id = asset.get("id", "")
filename = asset.get("originalFileName", "")
year = asset.get("year", "")
caption = f"{filename} ({year})" if year else filename
media_items.append({
"type": "photo",
"asset_id": asset_id,
"caption": caption,
"thumbnail_url": f"{client.url}/api/assets/{asset_id}/thumbnail?size=preview",
"api_key": client.api_key,
})
return media_items
slot_map = {"find": "search", "person": "search", "place": "search"}
slot_name = slot_map.get(cmd, cmd)
return _render_cmd_template(cmd_templates, slot_name, locale, {
"assets": assets, "query": query, "command": cmd, "count": len(assets),
})
@@ -0,0 +1,199 @@
"""Event-related Immich bot commands: events, latest, memory, random."""
from __future__ import annotations
import asyncio
import logging
import random as rng
from datetime import datetime, timezone
from typing import Any
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from ...database.engine import get_engine
from ...database.models import (
EventLog, NotificationTarget, NotificationTrackerTarget,
ServiceProvider, TelegramBot, TrackingConfig,
)
from ..handler import _get_notification_trackers_for_providers, _render_cmd_template
from .common import _format_assets
_LOGGER = logging.getLogger(__name__)
async def _cmd_events(
bot: TelegramBot, providers_map: dict[int, ServiceProvider],
count: int, locale: str,
) -> dict[str, Any]:
provider_ids = set(providers_map.keys())
trackers = await _get_notification_trackers_for_providers(provider_ids)
tracker_ids = [t.id for t in trackers]
if not tracker_ids:
return {"events": []}
engine = get_engine()
async with AsyncSession(engine) as session:
result = await session.exec(
select(EventLog)
.where(EventLog.tracker_id.in_(tracker_ids))
.order_by(EventLog.created_at.desc())
.limit(count)
)
events = result.all()
events_data = [
{"type": e.event_type, "album": e.collection_name,
"count": e.assets_count, "date": e.created_at.strftime("%m/%d %H:%M")}
for e in events
]
return {"events": events_data}
async def cmd_latest(
client: Any, all_album_ids: list[str], count: int,
locale: str, response_mode: str,
cmd_templates: dict[str, dict[str, str]],
) -> str | list[dict[str, Any]]:
"""Handle /latest command with concurrent album fetching."""
album_ids = all_album_ids[:10]
if not album_ids:
return _format_assets([], "latest", "", locale, response_mode, client, cmd_templates)
results = await asyncio.gather(
*[client.get_album(aid) for aid in album_ids],
return_exceptions=True,
)
latest_assets: list[dict[str, Any]] = []
for album_id, result in zip(album_ids, results):
if isinstance(result, Exception):
_LOGGER.warning("Failed to fetch album %s: %s", album_id, result)
continue
if result:
for aid, asset in list(result.assets.items())[:count]:
latest_assets.append({
"id": asset.id, "originalFileName": asset.filename,
"type": asset.type, "createdAt": asset.created_at,
})
latest_assets.sort(key=lambda a: a.get("createdAt", ""), reverse=True)
return _format_assets(latest_assets[:count], "latest", "", locale, response_mode, client, cmd_templates)
async def cmd_random(
client: Any, all_album_ids: list[str], count: int,
locale: str, response_mode: str,
cmd_templates: dict[str, dict[str, str]],
) -> str | list[dict[str, Any]]:
"""Handle /random command with concurrent album fetching."""
album_ids = all_album_ids[:10]
if not album_ids:
return _format_assets([], "random", "", locale, response_mode, client, cmd_templates)
results = await asyncio.gather(
*[client.get_album(aid) for aid in album_ids],
return_exceptions=True,
)
random_assets: list[dict[str, Any]] = []
for album_id, result in zip(album_ids, results):
if isinstance(result, Exception):
_LOGGER.warning("Failed to fetch album %s: %s", album_id, result)
continue
if result:
asset_list = list(result.assets.values())
sampled = rng.sample(asset_list, min(count, len(asset_list)))
for asset in sampled:
random_assets.append({
"id": asset.id, "originalFileName": asset.filename,
"type": asset.type,
})
rng.shuffle(random_assets)
return _format_assets(random_assets[:count], "random", "", locale, response_mode, client, cmd_templates)
async def _check_native_memory(bot: TelegramBot) -> bool:
"""Check if any tracker-target linked to this bot uses native memory source."""
engine = get_engine()
async with AsyncSession(engine) as session:
result = await session.exec(
select(NotificationTarget).where(
NotificationTarget.type == "telegram",
NotificationTarget.user_id == bot.user_id,
)
)
targets = result.all()
bot_target_ids = {t.id for t in targets if t.config.get("bot_token") == bot.token}
if not bot_target_ids:
return False
tt_result = await session.exec(
select(NotificationTrackerTarget).where(
NotificationTrackerTarget.target_id.in_(bot_target_ids)
)
)
for tt in tt_result.all():
if tt.tracking_config_id:
tc = await session.get(TrackingConfig, tt.tracking_config_id)
if tc and tc.memory_source == "native":
return True
return False
async def cmd_memory(
bot: TelegramBot, client: Any, all_album_ids: list[str], count: int,
locale: str, response_mode: str,
cmd_templates: dict[str, dict[str, str]],
) -> str | list[dict[str, Any]]:
"""Handle /memory command with concurrent album fetching."""
use_native = await _check_native_memory(bot)
today = datetime.now(timezone.utc)
memory_assets: list[dict[str, Any]] = []
if use_native:
memories = await client.get_memories()
tracked_ids = set(all_album_ids) if all_album_ids else None
for mem in memories:
year = mem.get("data", {}).get("year")
for raw_asset in mem.get("assets", []):
if tracked_ids:
asset_albums = raw_asset.get("albums", [])
if not any(a.get("id") in tracked_ids for a in asset_albums):
continue
memory_assets.append({
"id": raw_asset.get("id", ""),
"originalFileName": raw_asset.get("originalFileName", ""),
"type": raw_asset.get("type", "IMAGE"),
"createdAt": raw_asset.get("fileCreatedAt", raw_asset.get("createdAt", "")),
"year": year,
})
else:
album_ids = all_album_ids[:10]
if album_ids:
results = await asyncio.gather(
*[client.get_album(aid) for aid in album_ids],
return_exceptions=True,
)
month_day = (today.month, today.day)
for album_id, result in zip(album_ids, results):
if isinstance(result, Exception):
_LOGGER.warning("Failed to fetch album %s: %s", album_id, result)
continue
if result:
for aid, asset in result.assets.items():
try:
dt = datetime.fromisoformat(asset.created_at.replace("Z", "+00:00"))
if (dt.month, dt.day) == month_day and dt.year != today.year:
memory_assets.append({
"id": asset.id, "originalFileName": asset.filename,
"type": asset.type, "createdAt": asset.created_at,
"year": dt.year,
})
except (ValueError, AttributeError):
pass
memory_assets = memory_assets[:count]
if not memory_assets:
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": "memory", "query": ""})
return _format_assets(memory_assets, "memory", "", locale, response_mode, client, cmd_templates)
@@ -0,0 +1,168 @@
"""Immich-specific bot command handler — main dispatch class."""
from __future__ import annotations
import logging
from typing import Any
import aiohttp
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from ...database.engine import get_engine
from ...database.models import (
CommandConfig, CommandTracker, EventLog,
ServiceProvider, TelegramBot,
)
from ...services import make_immich_provider
from ..base import ProviderCommandHandler
from ..handler import _get_notification_trackers_for_providers, _render_cmd_template
from .albums import _cmd_albums, cmd_favorites, cmd_summary
from .common import _IMMICH_COMMANDS
from .events import _cmd_events, cmd_latest, cmd_memory, cmd_random
from .search import cmd_find, cmd_person, cmd_place, cmd_search
_LOGGER = logging.getLogger(__name__)
async def _cmd_status(
bot: TelegramBot, providers_map: dict[int, ServiceProvider], locale: str,
) -> dict[str, Any]:
provider_ids = set(providers_map.keys())
trackers = await _get_notification_trackers_for_providers(provider_ids)
active = sum(1 for t in trackers if t.enabled)
total = len(trackers)
total_albums = sum(len(t.collection_ids or []) for t in trackers)
engine = get_engine()
async with AsyncSession(engine) as session:
result = await session.exec(
select(EventLog).order_by(EventLog.created_at.desc()).limit(1)
)
last_event = result.first()
last_str = last_event.created_at.strftime("%Y-%m-%d %H:%M") if last_event else "-"
return {
"trackers_active": active, "trackers_total": total,
"total_albums": total_albums, "last_event": last_str,
}
async def _cmd_people(
providers_map: dict[int, ServiceProvider], locale: str,
) -> dict[str, Any]:
all_people: dict[str, str] = {}
async with aiohttp.ClientSession() as http:
for provider in providers_map.values():
if provider.type != "immich":
continue
immich = make_immich_provider(http, provider)
people = await immich.client.get_people()
all_people.update(people)
names = sorted(all_people.values())
return {"people": names}
class ImmichCommandHandler(ProviderCommandHandler):
"""Handles all Immich-specific bot commands."""
provider_type = "immich"
def get_provider_commands(self) -> set[str]:
return _IMMICH_COMMANDS
def get_rate_categories(self) -> dict[str, str]:
return {
"search": "search", "find": "search", "person": "search",
"place": "search", "favorites": "search", "people": "search",
}
async def handle(
self,
cmd: str,
args: str,
count: int,
locale: str,
response_mode: str,
providers_map: dict[int, ServiceProvider],
cmd_templates: dict[str, dict[str, str]],
bot: TelegramBot,
ctx_tuples: list[tuple[CommandTracker, CommandConfig, ServiceProvider]],
) -> str | list[dict[str, Any]] | None:
if cmd == "status":
ctx = await _cmd_status(bot, providers_map, locale)
return _render_cmd_template(cmd_templates, "status", locale, ctx)
if cmd == "albums":
ctx = await _cmd_albums(bot, providers_map, locale)
return _render_cmd_template(cmd_templates, "albums", locale, ctx)
if cmd == "events":
ctx = await _cmd_events(bot, providers_map, count, locale)
return _render_cmd_template(cmd_templates, "events", locale, ctx)
if cmd == "people":
ctx = await _cmd_people(providers_map, locale)
return _render_cmd_template(cmd_templates, "people", locale, ctx)
if cmd in ("search", "find", "person", "place", "latest",
"random", "favorites", "summary", "memory"):
return await _cmd_immich(
bot, cmd, args, count, locale, response_mode,
providers_map, cmd_templates,
)
return None
async def _cmd_immich(
bot: TelegramBot, cmd: str, args: str, count: int, locale: str,
response_mode: str, providers_map: dict[int, ServiceProvider],
cmd_templates: dict[str, dict[str, str]],
) -> str | list[dict[str, Any]]:
"""Handle commands that need Immich API access and may return media."""
if not providers_map:
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": cmd, "query": args})
provider_ids = set(providers_map.keys())
notification_trackers = await _get_notification_trackers_for_providers(provider_ids)
all_album_ids: list[str] = []
for t in notification_trackers:
all_album_ids.extend(t.collection_ids or [])
provider: ServiceProvider | None = None
for p in providers_map.values():
if p.type == "immich":
provider = p
break
if not provider:
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": cmd, "query": args})
async with aiohttp.ClientSession() as http:
immich = make_immich_provider(http, provider)
client = immich.client
if cmd == "search":
return await cmd_search(client, args, all_album_ids, count, locale, response_mode, cmd_templates)
if cmd == "find":
return await cmd_find(client, args, all_album_ids, count, locale, response_mode, cmd_templates)
if cmd == "person":
return await cmd_person(client, args, count, locale, response_mode, cmd_templates)
if cmd == "place":
return await cmd_place(client, args, all_album_ids, count, locale, response_mode, cmd_templates)
if cmd == "favorites":
return await cmd_favorites(bot, providers_map, all_album_ids, count, locale, response_mode, client, cmd_templates)
if cmd == "latest":
return await cmd_latest(client, all_album_ids, count, locale, response_mode, cmd_templates)
if cmd == "random":
return await cmd_random(client, all_album_ids, count, locale, response_mode, cmd_templates)
if cmd == "summary":
return await cmd_summary(client, all_album_ids, locale, cmd_templates)
if cmd == "memory":
return await cmd_memory(bot, client, all_album_ids, count, locale, response_mode, cmd_templates)
return None
@@ -0,0 +1,66 @@
"""Search-related Immich bot commands: search, find, person, place."""
from __future__ import annotations
from typing import Any
from ..handler import _render_cmd_template
from .common import _format_assets
async def cmd_search(
client: Any, args: str, all_album_ids: list[str], count: int,
locale: str, response_mode: str,
cmd_templates: dict[str, dict[str, str]],
) -> str | list[dict[str, Any]]:
"""Handle /search command."""
if not args:
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": "search", "query": ""})
assets = await client.search_smart(args, album_ids=all_album_ids, limit=count)
return _format_assets(assets, "search", args, locale, response_mode, client, cmd_templates)
async def cmd_find(
client: Any, args: str, all_album_ids: list[str], count: int,
locale: str, response_mode: str,
cmd_templates: dict[str, dict[str, str]],
) -> str | list[dict[str, Any]]:
"""Handle /find command."""
if not args:
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": "find", "query": ""})
assets = await client.search_metadata(args, album_ids=all_album_ids, limit=count)
return _format_assets(assets, "find", args, locale, response_mode, client, cmd_templates)
async def cmd_person(
client: Any, args: str, count: int,
locale: str, response_mode: str,
cmd_templates: dict[str, dict[str, str]],
) -> str | list[dict[str, Any]]:
"""Handle /person command."""
if not args:
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": "person", "query": ""})
people = await client.get_people()
person_id = None
for pid, pname in people.items():
if args.lower() in pname.lower():
person_id = pid
break
if not person_id:
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": "person", "query": args})
assets = await client.search_by_person(person_id, limit=count)
return _format_assets(assets, "person", args, locale, response_mode, client, cmd_templates)
async def cmd_place(
client: Any, args: str, all_album_ids: list[str], count: int,
locale: str, response_mode: str,
cmd_templates: dict[str, dict[str, str]],
) -> str | list[dict[str, Any]]:
"""Handle /place command."""
if not args:
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": "place", "query": ""})
assets = await client.search_smart(
f"photos taken in {args}", album_ids=all_album_ids, limit=count
)
return _format_assets(assets, "place", args, locale, response_mode, client, cmd_templates)
@@ -1,414 +0,0 @@
"""Immich-specific bot command handler."""
from __future__ import annotations
import logging
import random as rng
from datetime import datetime, timezone
from typing import Any
import aiohttp
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from ..database.engine import get_engine
from ..database.models import (
CommandConfig, CommandTracker, NotificationTarget,
NotificationTracker, NotificationTrackerTarget,
ServiceProvider, TelegramBot, TrackingConfig,
)
from ..services import make_immich_provider
from .base import ProviderCommandHandler
from .handler import _render_cmd_template, _get_notification_trackers_for_providers
_LOGGER = logging.getLogger(__name__)
_IMMICH_COMMANDS = {
"status", "albums", "events", "people",
"search", "find", "person", "place",
"latest", "random", "favorites", "summary", "memory",
}
class ImmichCommandHandler(ProviderCommandHandler):
"""Handles all Immich-specific bot commands."""
provider_type = "immich"
def get_provider_commands(self) -> set[str]:
return _IMMICH_COMMANDS
def get_rate_categories(self) -> dict[str, str]:
return {
"search": "search", "find": "search", "person": "search",
"place": "search", "favorites": "search", "people": "search",
}
async def handle(
self,
cmd: str,
args: str,
count: int,
locale: str,
response_mode: str,
providers_map: dict[int, ServiceProvider],
cmd_templates: dict[str, dict[str, str]],
bot: TelegramBot,
ctx_tuples: list[tuple[CommandTracker, CommandConfig, ServiceProvider]],
) -> str | list[dict[str, Any]] | None:
if cmd == "status":
ctx = await _cmd_status(bot, providers_map, locale)
return _render_cmd_template(cmd_templates, "status", locale, ctx)
if cmd == "albums":
ctx = await _cmd_albums(bot, providers_map, locale)
return _render_cmd_template(cmd_templates, "albums", locale, ctx)
if cmd == "events":
ctx = await _cmd_events(bot, providers_map, count, locale)
return _render_cmd_template(cmd_templates, "events", locale, ctx)
if cmd == "people":
ctx = await _cmd_people(providers_map, locale)
return _render_cmd_template(cmd_templates, "people", locale, ctx)
if cmd in ("search", "find", "person", "place", "latest",
"random", "favorites", "summary", "memory"):
return await _cmd_immich(
bot, cmd, args, count, locale, response_mode,
providers_map, cmd_templates,
)
return None
# --- Immich command implementations (moved from handler.py) ---
async def _cmd_status(
bot: TelegramBot, providers_map: dict[int, ServiceProvider], locale: str,
) -> dict[str, Any]:
from ..database.models import EventLog
provider_ids = set(providers_map.keys())
trackers = await _get_notification_trackers_for_providers(provider_ids)
active = sum(1 for t in trackers if t.enabled)
total = len(trackers)
total_albums = sum(len(t.collection_ids or []) for t in trackers)
engine = get_engine()
async with AsyncSession(engine) as session:
result = await session.exec(
select(EventLog).order_by(EventLog.created_at.desc()).limit(1)
)
last_event = result.first()
last_str = last_event.created_at.strftime("%Y-%m-%d %H:%M") if last_event else "-"
return {
"trackers_active": active, "trackers_total": total,
"total_albums": total_albums, "last_event": last_str,
}
async def _cmd_albums(
bot: TelegramBot, providers_map: dict[int, ServiceProvider], locale: str,
) -> dict[str, Any]:
provider_ids = set(providers_map.keys())
trackers = await _get_notification_trackers_for_providers(provider_ids)
if not trackers:
return {"albums": []}
albums_data: list[dict] = []
async with aiohttp.ClientSession() as http:
for tracker in trackers:
provider = providers_map.get(tracker.provider_id)
if not provider or provider.type != "immich":
continue
immich = make_immich_provider(http, provider)
for album_id in (tracker.collection_ids or []):
try:
album = await immich.client.get_album(album_id)
if album:
albums_data.append({
"name": album.name, "asset_count": album.asset_count, "id": album_id,
})
except Exception:
albums_data.append({
"name": f"{album_id[:8]}...", "asset_count": "?", "id": album_id,
})
return {"albums": albums_data}
async def _cmd_events(
bot: TelegramBot, providers_map: dict[int, ServiceProvider],
count: int, locale: str,
) -> dict[str, Any]:
from ..database.models import EventLog
provider_ids = set(providers_map.keys())
trackers = await _get_notification_trackers_for_providers(provider_ids)
tracker_ids = [t.id for t in trackers]
if not tracker_ids:
return {"events": []}
engine = get_engine()
async with AsyncSession(engine) as session:
result = await session.exec(
select(EventLog)
.where(EventLog.tracker_id.in_(tracker_ids))
.order_by(EventLog.created_at.desc())
.limit(count)
)
events = result.all()
events_data = [
{"type": e.event_type, "album": e.collection_name,
"count": e.assets_count, "date": e.created_at.strftime("%m/%d %H:%M")}
for e in events
]
return {"events": events_data}
async def _cmd_people(
providers_map: dict[int, ServiceProvider], locale: str,
) -> dict[str, Any]:
all_people: dict[str, str] = {}
async with aiohttp.ClientSession() as http:
for provider in providers_map.values():
if provider.type != "immich":
continue
immich = make_immich_provider(http, provider)
people = await immich.client.get_people()
all_people.update(people)
names = sorted(all_people.values())
return {"people": names}
async def _check_native_memory(bot: TelegramBot) -> bool:
"""Check if any tracker-target linked to this bot uses native memory source."""
engine = get_engine()
async with AsyncSession(engine) as session:
result = await session.exec(
select(NotificationTarget).where(
NotificationTarget.type == "telegram",
NotificationTarget.user_id == bot.user_id,
)
)
targets = result.all()
bot_target_ids = {t.id for t in targets if t.config.get("bot_token") == bot.token}
if not bot_target_ids:
return False
tt_result = await session.exec(
select(NotificationTrackerTarget).where(
NotificationTrackerTarget.target_id.in_(bot_target_ids)
)
)
for tt in tt_result.all():
if tt.tracking_config_id:
tc = await session.get(TrackingConfig, tt.tracking_config_id)
if tc and tc.memory_source == "native":
return True
return False
async def _cmd_immich(
bot: TelegramBot, cmd: str, args: str, count: int, locale: str,
response_mode: str, providers_map: dict[int, ServiceProvider],
cmd_templates: dict[str, dict[str, str]],
) -> str | list[dict[str, Any]]:
"""Handle commands that need Immich API access and may return media."""
if not providers_map:
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": cmd, "query": args})
provider_ids = set(providers_map.keys())
notification_trackers = await _get_notification_trackers_for_providers(provider_ids)
all_album_ids: list[str] = []
for t in notification_trackers:
all_album_ids.extend(t.collection_ids or [])
provider: ServiceProvider | None = None
for p in providers_map.values():
if p.type == "immich":
provider = p
break
if not provider:
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": cmd, "query": args})
async with aiohttp.ClientSession() as http:
immich = make_immich_provider(http, provider)
client = immich.client
if cmd == "search":
if not args:
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": cmd, "query": ""})
assets = await client.search_smart(args, album_ids=all_album_ids, limit=count)
return _format_assets(assets, cmd, args, locale, response_mode, client, cmd_templates)
if cmd == "find":
if not args:
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": cmd, "query": ""})
assets = await client.search_metadata(args, album_ids=all_album_ids, limit=count)
return _format_assets(assets, cmd, args, locale, response_mode, client, cmd_templates)
if cmd == "person":
if not args:
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": "person", "query": ""})
people = await client.get_people()
person_id = None
for pid, pname in people.items():
if args.lower() in pname.lower():
person_id = pid
break
if not person_id:
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": "person", "query": args})
assets = await client.search_by_person(person_id, limit=count)
return _format_assets(assets, cmd, args, locale, response_mode, client, cmd_templates)
if cmd == "place":
if not args:
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": "place", "query": ""})
assets = await client.search_smart(
f"photos taken in {args}", album_ids=all_album_ids, limit=count
)
return _format_assets(assets, cmd, args, locale, response_mode, client, cmd_templates)
if cmd == "favorites":
fav_assets: list[dict[str, Any]] = []
for album_id in all_album_ids[:10]:
try:
album = await client.get_album(album_id)
if album:
for aid, asset in list(album.assets.items())[:50]:
if asset.is_favorite and len(fav_assets) < count:
fav_assets.append({
"id": asset.id, "originalFileName": asset.filename,
"type": asset.type,
})
except Exception:
pass
if len(fav_assets) >= count:
break
return _format_assets(fav_assets, cmd, "", locale, response_mode, client, cmd_templates)
if cmd == "latest":
latest_assets: list[dict[str, Any]] = []
for album_id in all_album_ids[:10]:
try:
album = await client.get_album(album_id)
if album:
for aid, asset in list(album.assets.items())[:count]:
latest_assets.append({
"id": asset.id, "originalFileName": asset.filename,
"type": asset.type, "createdAt": asset.created_at,
})
except Exception:
pass
latest_assets.sort(key=lambda a: a.get("createdAt", ""), reverse=True)
return _format_assets(latest_assets[:count], cmd, "", locale, response_mode, client, cmd_templates)
if cmd == "random":
random_assets: list[dict[str, Any]] = []
for album_id in all_album_ids[:10]:
try:
album = await client.get_album(album_id)
if album:
asset_list = list(album.assets.values())
sampled = rng.sample(asset_list, min(count, len(asset_list)))
for asset in sampled:
random_assets.append({
"id": asset.id, "originalFileName": asset.filename,
"type": asset.type,
})
except Exception:
pass
rng.shuffle(random_assets)
return _format_assets(random_assets[:count], cmd, "", locale, response_mode, client, cmd_templates)
if cmd == "summary":
albums_data: list[dict] = []
for album_id in all_album_ids:
try:
album = await client.get_album(album_id)
if album:
albums_data.append({
"name": album.name, "asset_count": album.asset_count, "id": album_id,
})
except Exception:
pass
return _render_cmd_template(cmd_templates, "summary", locale, {"albums": albums_data})
if cmd == "memory":
use_native = await _check_native_memory(bot)
today = datetime.now(timezone.utc)
memory_assets: list[dict[str, Any]] = []
if use_native:
memories = await client.get_memories()
tracked_ids = set(all_album_ids) if all_album_ids else None
for mem in memories:
year = mem.get("data", {}).get("year")
for raw_asset in mem.get("assets", []):
if tracked_ids:
asset_albums = raw_asset.get("albums", [])
if not any(a.get("id") in tracked_ids for a in asset_albums):
continue
memory_assets.append({
"id": raw_asset.get("id", ""),
"originalFileName": raw_asset.get("originalFileName", ""),
"type": raw_asset.get("type", "IMAGE"),
"createdAt": raw_asset.get("fileCreatedAt", raw_asset.get("createdAt", "")),
"year": year,
})
else:
month_day = (today.month, today.day)
for album_id in all_album_ids[:10]:
try:
album = await client.get_album(album_id)
if album:
for aid, asset in album.assets.items():
try:
dt = datetime.fromisoformat(asset.created_at.replace("Z", "+00:00"))
if (dt.month, dt.day) == month_day and dt.year != today.year:
memory_assets.append({
"id": asset.id, "originalFileName": asset.filename,
"type": asset.type, "createdAt": asset.created_at,
"year": dt.year,
})
except (ValueError, AttributeError):
pass
except Exception:
pass
memory_assets = memory_assets[:count]
if not memory_assets:
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": "memory", "query": ""})
return _format_assets(memory_assets, cmd, "", locale, response_mode, client, cmd_templates)
return None
def _format_assets(
assets: list[dict[str, Any]], cmd: str, query: str,
locale: str, response_mode: str, client: Any,
cmd_templates: dict[str, dict[str, str]],
) -> str | list[dict[str, Any]]:
"""Format asset results as text or media payload."""
if not assets:
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": cmd, "query": query})
if response_mode == "media":
media_items = []
for asset in assets:
asset_id = asset.get("id", "")
filename = asset.get("originalFileName", "")
year = asset.get("year", "")
caption = f"{filename} ({year})" if year else filename
media_items.append({
"type": "photo",
"asset_id": asset_id,
"caption": caption,
"thumbnail_url": f"{client.url}/api/assets/{asset_id}/thumbnail?size=preview",
"api_key": client.api_key,
})
return media_items
slot_map = {"find": "search", "person": "search", "place": "search"}
slot_name = slot_map.get(cmd, cmd)
return _render_cmd_template(cmd_templates, slot_name, locale, {
"assets": assets, "query": query, "command": cmd, "count": len(assets),
})
@@ -12,7 +12,7 @@ def parse_command(text: str) -> tuple[str, str, int | None]:
"/events 10" -> ("events", "", 10)
"/help@mybot" -> ("help", "", None)
"""
text = text.strip()
text = text[:512].strip()
if not text.startswith("/"):
return ("", text, None)
@@ -33,6 +33,9 @@ class Settings(BaseSettings):
telegram_webhook_secret: str = ""
cors_allowed_origins: str = "*"
"""Comma-separated allowed origins for CORS (e.g. 'http://localhost:5173,https://myapp.com'). Use '*' for dev."""
model_config = {"env_prefix": "NOTIFY_BRIDGE_"}
@property
@@ -207,7 +207,12 @@ class TemplateSlot(SQLModel, table=True):
)
id: int | None = Field(default=None, primary_key=True)
config_id: int = Field(foreign_key="template_config.id", index=True)
config_id: int = Field(
foreign_key="template_config.id",
index=True,
)
slot_name: str
template: str = Field(default="", sa_column=Column(Text, default=""))
@@ -245,7 +250,12 @@ class TargetReceiver(SQLModel, table=True):
)
id: int | None = Field(default=None, primary_key=True)
target_id: int = Field(foreign_key="notification_target.id", index=True)
target_id: int = Field(
foreign_key="notification_target.id",
index=True,
)
name: str = Field(default="")
config: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
receiver_key: str = Field(default="") # dedup key (e.g. chat_id, url, email)
@@ -283,7 +293,12 @@ class NotificationTrackerTarget(SQLModel, table=True):
index=True,
sa_column_kwargs={"name": "notification_tracker_id"},
)
target_id: int = Field(foreign_key="notification_target.id", index=True)
target_id: int = Field(
foreign_key="notification_target.id",
index=True,
)
tracking_config_id: int | None = Field(
default=None, foreign_key="tracking_config.id"
)
@@ -366,7 +381,12 @@ class CommandTemplateSlot(SQLModel, table=True):
)
id: int | None = Field(default=None, primary_key=True)
config_id: int = Field(foreign_key="command_template_config.id", index=True)
config_id: int = Field(
foreign_key="command_template_config.id",
index=True,
)
slot_name: str
locale: str = Field(default="en")
template: str = Field(default="", sa_column=Column(Text, default=""))
@@ -399,7 +419,11 @@ class CommandTrackerListener(SQLModel, table=True):
)
id: int | None = Field(default=None, primary_key=True)
command_tracker_id: int = Field(foreign_key="command_tracker.id")
command_tracker_id: int = Field(
foreign_key="command_tracker.id",
)
listener_type: str # e.g. "telegram_bot"
listener_id: int
created_at: datetime = Field(default_factory=_utcnow)
@@ -0,0 +1,324 @@
"""Database seed functions — create/update system-owned defaults on startup."""
import json
import logging
from datetime import datetime, timezone
from sqlalchemy import text
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from .engine import get_engine
from .models import (
CommandConfig,
CommandTemplateConfig,
CommandTemplateSlot,
TemplateConfig,
TemplateSlot,
TrackingConfig,
)
_LOGGER = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
async def _seed_provider_template(
session: AsyncSession,
provider_type: str,
label: str,
) -> None:
"""Seed templates for a single provider type across all locales."""
from notify_bridge_core.templates.defaults import load_default_templates
result = await session.exec(
select(TemplateConfig).where(
TemplateConfig.user_id == 0,
TemplateConfig.provider_type == provider_type,
)
)
configs = result.all()
existing_locales = {
(c.locale if c.locale else ("ru" if "(RU)" in c.name else "en")): c
for c in configs
}
for locale in ("en", "ru"):
slots = load_default_templates(locale, provider_type=provider_type)
if not slots:
continue
if locale not in existing_locales:
now = datetime.now(timezone.utc).isoformat()
name = f"Default {label} ({locale.upper()})"
desc = f"Default {label} templates ({locale.upper()})"
# Get column names to build INSERT with defaults for legacy cols
col_info = (await session.execute(
text("PRAGMA table_info(template_config)")
)).fetchall()
col_names = [c[1] for c in col_info if c[1] != "id"]
values: dict[str, object] = {}
for col in col_names:
if col == "user_id":
values[col] = 0
elif col == "provider_type":
values[col] = provider_type
elif col == "name":
values[col] = name
elif col == "description":
values[col] = desc
elif col == "created_at":
values[col] = now
elif col == "date_format":
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())
placeholders = ", ".join(f":{k}" for k in values.keys())
await session.execute(
text(f"INSERT INTO template_config ({cols_str}) VALUES ({placeholders})"),
values,
)
config_id = (await session.execute(
text("SELECT last_insert_rowid()")
)).scalar()
for slot_name, template_text in slots.items():
session.add(TemplateSlot(
config_id=config_id,
slot_name=slot_name,
template=template_text,
))
else:
config = existing_locales[locale]
for slot_name, template_text in slots.items():
slot_result = await session.exec(
select(TemplateSlot).where(
TemplateSlot.config_id == config.id,
TemplateSlot.slot_name == slot_name,
)
)
existing = slot_result.first()
if existing:
existing.template = template_text
session.add(existing)
else:
session.add(TemplateSlot(
config_id=config.id,
slot_name=slot_name,
template=template_text,
))
async def _seed_provider_command_template(
session: AsyncSession,
provider_type: str,
name: str,
description: str,
) -> None:
"""Seed command templates for a single provider type across all locales."""
from notify_bridge_core.templates.command_defaults import load_default_command_templates
result = await session.exec(
select(CommandTemplateConfig).where(
CommandTemplateConfig.user_id == 0,
CommandTemplateConfig.provider_type == provider_type,
)
)
configs = result.all()
if not configs:
config = CommandTemplateConfig(
user_id=0,
provider_type=provider_type,
name=name,
description=description,
)
session.add(config)
await session.flush()
else:
config = configs[0]
for locale in ("en", "ru"):
slots = load_default_command_templates(locale, provider_type=provider_type)
if not slots:
continue
for slot_name, template_text in slots.items():
slot_result = await session.exec(
select(CommandTemplateSlot).where(
CommandTemplateSlot.config_id == config.id,
CommandTemplateSlot.slot_name == slot_name,
CommandTemplateSlot.locale == locale,
)
)
existing = slot_result.first()
if existing:
existing.template = template_text
session.add(existing)
else:
session.add(CommandTemplateSlot(
config_id=config.id,
slot_name=slot_name,
locale=locale,
template=template_text,
))
# ---------------------------------------------------------------------------
# Top-level seed functions
# ---------------------------------------------------------------------------
async def _seed_default_templates() -> None:
"""Seed or update default (system-owned) templates on startup.
Uses TemplateSlot child rows for template content.
"""
engine = get_engine()
async with AsyncSession(engine) as session:
await _seed_provider_template(session, "immich", "Immich")
await _seed_provider_template(session, "gitea", "Gitea")
await _seed_provider_template(session, "scheduler", "Scheduler")
await session.commit()
async def _seed_default_command_templates() -> None:
"""Seed or update default command response templates on startup.
Creates a single config per provider with locale-aware slots
(each slot has an EN and RU version stored as separate rows).
"""
engine = get_engine()
async with AsyncSession(engine) as session:
await _seed_provider_command_template(
session, "immich", "Default Commands", "Default Immich command templates",
)
await _seed_provider_command_template(
session, "gitea", "Default Gitea Commands", "Default Gitea command templates",
)
await session.commit()
async def _seed_default_tracking_configs() -> None:
"""Seed system-owned default tracking configs for each provider type."""
engine = get_engine()
async with AsyncSession(engine) as session:
result = await session.exec(
select(TrackingConfig).where(TrackingConfig.user_id == 0)
)
existing = {c.provider_type: c for c in result.all()}
defaults = [
{
"provider_type": "gitea",
"name": "Default Gitea",
"track_push": True,
"track_issue_opened": True,
"track_issue_closed": True,
"track_issue_commented": False,
"track_pr_opened": True,
"track_pr_closed": True,
"track_pr_merged": True,
"track_pr_commented": False,
"track_release_published": True,
},
{
"provider_type": "scheduler",
"name": "Default Scheduler",
"track_scheduled_message": True,
},
]
for cfg in defaults:
ptype = cfg["provider_type"]
if ptype in existing:
continue
session.add(TrackingConfig(user_id=0, **cfg))
await session.commit()
async def _seed_default_command_configs() -> None:
"""Seed system-owned default command configs for each provider type."""
engine = get_engine()
async with AsyncSession(engine) as session:
result = await session.exec(
select(CommandConfig).where(CommandConfig.user_id == 0)
)
existing = {c.provider_type: c for c in result.all()}
# Find system command template configs to link
tmpl_result = await session.exec(
select(CommandTemplateConfig).where(CommandTemplateConfig.user_id == 0)
)
tmpl_by_type = {t.provider_type: t.id for t in tmpl_result.all()}
defaults = [
{
"provider_type": "immich",
"name": "Default Immich",
"enabled_commands": [
"help", "status", "albums", "events", "latest",
"random", "favorites", "summary", "memory",
],
"response_mode": "media",
"default_count": 5,
"rate_limits": {"search": 30, "default": 10},
},
{
"provider_type": "gitea",
"name": "Default Gitea",
"enabled_commands": [
"help", "status", "repos", "issues", "prs", "commits",
],
"response_mode": "text",
"default_count": 10,
"rate_limits": {"api": 15, "default": 10},
},
]
for cfg in defaults:
ptype = cfg["provider_type"]
if ptype in existing:
continue
cmd_tmpl_id = tmpl_by_type.get(ptype)
await session.execute(
text(
"INSERT INTO command_config "
"(user_id, provider_type, name, icon, enabled_commands, locale, "
"response_mode, default_count, rate_limits, command_template_config_id, created_at) "
"VALUES (:uid, :pt, :name, :icon, :cmds, :locale, :rm, :dc, :rl, :ctid, :ca)"
),
{
"uid": 0,
"pt": ptype,
"name": cfg["name"],
"icon": "",
"cmds": json.dumps(cfg["enabled_commands"]),
"locale": "en",
"rm": cfg["response_mode"],
"dc": cfg["default_count"],
"rl": json.dumps(cfg["rate_limits"]),
"ctid": cmd_tmpl_id,
"ca": datetime.now(timezone.utc).isoformat(),
},
)
await session.commit()
# ---------------------------------------------------------------------------
# Public entry point
# ---------------------------------------------------------------------------
async def seed_all() -> None:
"""Run all seed functions in order."""
await _seed_default_templates()
await _seed_default_command_templates()
await _seed_default_tracking_configs()
await _seed_default_command_configs()
+23 -494
View File
@@ -4,6 +4,10 @@ import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware
# Ensure app-level loggers are visible
logging.basicConfig(level=logging.INFO)
@@ -50,10 +54,8 @@ async def lifespan(app: FastAPI):
await migrate_template_locale(engine)
await migrate_receivers_from_config(engine)
await migrate_command_slot_locale(engine)
await _seed_default_templates()
await _seed_default_command_templates()
await _seed_default_tracking_configs()
await _seed_default_command_configs()
from .database.seeds import seed_all
await seed_all()
# Configure webhook secret from DB setting (falls back to env var)
from sqlmodel.ext.asyncio.session import AsyncSession as _AS
from .api.app_settings import get_setting as _get_setting
@@ -71,6 +73,23 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="Notify Bridge", version="0.1.0", lifespan=lifespan)
# --- Rate limiting ---
from .auth.routes import limiter
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
app.add_middleware(SlowAPIMiddleware)
# --- CORS ---
from .config import settings as _cfg
_origins = [o.strip() for o in _cfg.cors_allowed_origins.split(",") if o.strip()]
app.add_middleware(
CORSMiddleware,
allow_origins=_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Register routes — static paths before parameterized
app.include_router(auth_router)
app.include_router(template_vars_router)
@@ -99,496 +118,6 @@ async def health():
return {"status": "ok"}
async def _seed_default_templates():
"""Seed or update default (system-owned) templates on startup.
Uses TemplateSlot child rows for template content.
"""
from sqlalchemy import text
from sqlmodel import func, select
from sqlmodel.ext.asyncio.session import AsyncSession
from .database.engine import get_engine
from .database.models import TemplateConfig, TemplateSlot
from notify_bridge_core.templates.defaults import load_default_templates
engine = get_engine()
async with AsyncSession(engine) as session:
# Find existing system-owned templates
result = await session.exec(
select(TemplateConfig).where(TemplateConfig.user_id == 0)
)
system_configs = result.all()
existing_locales = {
(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"):
slots = load_default_templates(locale, provider_type="immich")
if not slots:
continue
if locale not in existing_locales:
# Create missing system template via raw SQL
# (legacy NOT NULL columns may still exist in the DB)
name = f"Default ({locale.upper()})"
desc = f"Default Immich templates ({locale.upper()})"
# Get column names to build INSERT with defaults for legacy cols
col_info = (await session.execute(
text("PRAGMA table_info(template_config)")
)).fetchall()
col_names = [c[1] for c in col_info if c[1] != "id"]
values = {}
from datetime import datetime, timezone
now = datetime.now(timezone.utc).isoformat()
for col in col_names:
if col == "user_id":
values[col] = 0
elif col == "provider_type":
values[col] = "immich"
elif col == "name":
values[col] = name
elif col == "description":
values[col] = desc
elif col == "created_at":
values[col] = now
elif col == "date_format":
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())
placeholders = ", ".join(f":{k}" for k in values.keys())
await session.execute(
text(f"INSERT INTO template_config ({cols_str}) VALUES ({placeholders})"),
values,
)
# Get the inserted ID
row = (await session.execute(text("SELECT last_insert_rowid()"))).scalar()
config_id = row
for slot_name, template_text in slots.items():
session.add(TemplateSlot(
config_id=config_id,
slot_name=slot_name,
template=template_text,
))
else:
# Update existing system template slots
config = existing_locales[locale]
for slot_name, template_text in slots.items():
slot_result = await session.exec(
select(TemplateSlot).where(
TemplateSlot.config_id == config.id,
TemplateSlot.slot_name == slot_name,
)
)
existing = slot_result.first()
if existing:
existing.template = template_text
session.add(existing)
else:
session.add(TemplateSlot(
config_id=config.id,
slot_name=slot_name,
template=template_text,
))
# --- Seed Gitea default templates ---
gitea_result = await session.exec(
select(TemplateConfig).where(
TemplateConfig.user_id == 0,
TemplateConfig.provider_type == "gitea",
)
)
gitea_configs = gitea_result.all()
gitea_existing_locales = {
(c.locale if c.locale else "en"): c for c in gitea_configs
}
for locale in ("en", "ru"):
gitea_slots = load_default_templates(locale, provider_type="gitea")
if not gitea_slots:
continue
if locale not in gitea_existing_locales:
from datetime import datetime as _dt, timezone as _tz
now = _dt.now(_tz.utc).isoformat()
name = f"Default Gitea ({locale.upper()})"
desc = f"Default Gitea templates ({locale.upper()})"
col_info = (await session.execute(
text("PRAGMA table_info(template_config)")
)).fetchall()
col_names = [c[1] for c in col_info if c[1] != "id"]
values = {}
for col in col_names:
if col == "user_id":
values[col] = 0
elif col == "provider_type":
values[col] = "gitea"
elif col == "name":
values[col] = name
elif col == "description":
values[col] = desc
elif col == "created_at":
values[col] = now
elif col == "date_format":
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] = ""
cols_str = ", ".join(values.keys())
placeholders = ", ".join(f":{k}" for k in values.keys())
await session.execute(
text(f"INSERT INTO template_config ({cols_str}) VALUES ({placeholders})"),
values,
)
row = (await session.execute(text("SELECT last_insert_rowid()"))).scalar()
gitea_config_id = row
for slot_name, template_text in gitea_slots.items():
session.add(TemplateSlot(
config_id=gitea_config_id,
slot_name=slot_name,
template=template_text,
))
else:
config = gitea_existing_locales[locale]
for slot_name, template_text in gitea_slots.items():
slot_result = await session.exec(
select(TemplateSlot).where(
TemplateSlot.config_id == config.id,
TemplateSlot.slot_name == slot_name,
)
)
existing = slot_result.first()
if existing:
existing.template = template_text
session.add(existing)
else:
session.add(TemplateSlot(
config_id=config.id,
slot_name=slot_name,
template=template_text,
))
# --- Seed Scheduler default templates ---
sched_result = await session.exec(
select(TemplateConfig).where(
TemplateConfig.user_id == 0,
TemplateConfig.provider_type == "scheduler",
)
)
sched_configs = sched_result.all()
sched_existing_locales = {
(c.locale if c.locale else "en"): c for c in sched_configs
}
for locale in ("en", "ru"):
sched_slots = load_default_templates(locale, provider_type="scheduler")
if not sched_slots:
continue
if locale not in sched_existing_locales:
from datetime import datetime as _dt2, timezone as _tz2
now2 = _dt2.now(_tz2.utc).isoformat()
name2 = f"Default Scheduler ({locale.upper()})"
desc2 = f"Default Scheduler templates ({locale.upper()})"
col_info2 = (await session.execute(
text("PRAGMA table_info(template_config)")
)).fetchall()
col_names2 = [c[1] for c in col_info2 if c[1] != "id"]
values2 = {}
for col in col_names2:
if col == "user_id":
values2[col] = 0
elif col == "provider_type":
values2[col] = "scheduler"
elif col == "name":
values2[col] = name2
elif col == "description":
values2[col] = desc2
elif col == "created_at":
values2[col] = now2
elif col == "date_format":
values2[col] = "%d.%m.%Y, %H:%M UTC"
elif col == "date_only_format":
values2[col] = "%d.%m.%Y"
elif col == "locale":
values2[col] = locale
else:
values2[col] = ""
cols_str2 = ", ".join(values2.keys())
placeholders2 = ", ".join(f":{k}" for k in values2.keys())
await session.execute(
text(f"INSERT INTO template_config ({cols_str2}) VALUES ({placeholders2})"),
values2,
)
row2 = (await session.execute(text("SELECT last_insert_rowid()"))).scalar()
for slot_name, template_text in sched_slots.items():
session.add(TemplateSlot(
config_id=row2,
slot_name=slot_name,
template=template_text,
))
else:
config = sched_existing_locales[locale]
for slot_name, template_text in sched_slots.items():
slot_result = await session.exec(
select(TemplateSlot).where(
TemplateSlot.config_id == config.id,
TemplateSlot.slot_name == slot_name,
)
)
existing = slot_result.first()
if existing:
existing.template = template_text
session.add(existing)
else:
session.add(TemplateSlot(
config_id=config.id,
slot_name=slot_name,
template=template_text,
))
await session.commit()
async def _seed_default_command_templates():
"""Seed or update default command response templates on startup.
Creates a single 'Default Commands' config with locale-aware slots
(each slot has an EN and RU version stored as separate rows).
"""
from sqlmodel import func, select
from sqlmodel.ext.asyncio.session import AsyncSession
from .database.engine import get_engine
from .database.models import CommandTemplateConfig, CommandTemplateSlot
from notify_bridge_core.templates.command_defaults import load_default_command_templates
engine = get_engine()
async with AsyncSession(engine) as session:
# Find or create the system-owned config
result = await session.exec(
select(CommandTemplateConfig).where(CommandTemplateConfig.user_id == 0)
)
system_configs = result.all()
if not system_configs:
# First startup — create single merged config
config = CommandTemplateConfig(
user_id=0,
provider_type="immich",
name="Default Commands",
description="Default Immich command templates",
)
session.add(config)
await session.flush()
else:
config = system_configs[0]
# Upsert slots for each locale
for locale in ("en", "ru"):
slots = load_default_command_templates(locale, provider_type="immich")
if not slots:
continue
for slot_name, template_text in slots.items():
slot_result = await session.exec(
select(CommandTemplateSlot).where(
CommandTemplateSlot.config_id == config.id,
CommandTemplateSlot.slot_name == slot_name,
CommandTemplateSlot.locale == locale,
)
)
existing = slot_result.first()
if existing:
existing.template = template_text
session.add(existing)
else:
session.add(CommandTemplateSlot(
config_id=config.id,
slot_name=slot_name,
locale=locale,
template=template_text,
))
# --- Seed Gitea default command templates ---
gitea_cmd_result = await session.exec(
select(CommandTemplateConfig).where(
CommandTemplateConfig.user_id == 0,
CommandTemplateConfig.provider_type == "gitea",
)
)
gitea_cmd_configs = gitea_cmd_result.all()
if not gitea_cmd_configs:
gitea_cmd_config = CommandTemplateConfig(
user_id=0,
provider_type="gitea",
name="Default Gitea Commands",
description="Default Gitea command templates",
)
session.add(gitea_cmd_config)
await session.flush()
else:
gitea_cmd_config = gitea_cmd_configs[0]
for locale in ("en", "ru"):
gitea_cmd_slots = load_default_command_templates(locale, provider_type="gitea")
if not gitea_cmd_slots:
continue
for slot_name, template_text in gitea_cmd_slots.items():
slot_result = await session.exec(
select(CommandTemplateSlot).where(
CommandTemplateSlot.config_id == gitea_cmd_config.id,
CommandTemplateSlot.slot_name == slot_name,
CommandTemplateSlot.locale == locale,
)
)
existing = slot_result.first()
if existing:
existing.template = template_text
session.add(existing)
else:
session.add(CommandTemplateSlot(
config_id=gitea_cmd_config.id,
slot_name=slot_name,
locale=locale,
template=template_text,
))
await session.commit()
async def _seed_default_tracking_configs():
"""Seed system-owned default tracking configs for each provider type."""
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from .database.engine import get_engine
from .database.models import TrackingConfig
engine = get_engine()
async with AsyncSession(engine) as session:
# Find existing system-owned tracking configs
result = await session.exec(
select(TrackingConfig).where(TrackingConfig.user_id == 0)
)
existing = {c.provider_type: c for c in result.all()}
defaults = [
{
"provider_type": "gitea",
"name": "Default Gitea",
"track_push": True,
"track_issue_opened": True,
"track_issue_closed": True,
"track_issue_commented": False,
"track_pr_opened": True,
"track_pr_closed": True,
"track_pr_merged": True,
"track_pr_commented": False,
"track_release_published": True,
},
{
"provider_type": "scheduler",
"name": "Default Scheduler",
"track_scheduled_message": True,
},
]
for cfg in defaults:
ptype = cfg["provider_type"]
if ptype in existing:
continue
session.add(TrackingConfig(user_id=0, **cfg))
await session.commit()
async def _seed_default_command_configs():
"""Seed system-owned default command configs for each provider type."""
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from .database.engine import get_engine
from .database.models import CommandConfig, CommandTemplateConfig
engine = get_engine()
async with AsyncSession(engine) as session:
# Find existing system-owned command configs
result = await session.exec(
select(CommandConfig).where(CommandConfig.user_id == 0)
)
existing = {c.provider_type: c for c in result.all()}
# Find system command template configs to link
tmpl_result = await session.exec(
select(CommandTemplateConfig).where(CommandTemplateConfig.user_id == 0)
)
tmpl_by_type = {t.provider_type: t.id for t in tmpl_result.all()}
defaults = [
{
"provider_type": "immich",
"name": "Default Immich",
"enabled_commands": [
"help", "status", "albums", "events", "latest",
"random", "favorites", "summary", "memory",
],
"response_mode": "media",
"default_count": 5,
"rate_limits": {"search": 30, "default": 10},
},
{
"provider_type": "gitea",
"name": "Default Gitea",
"enabled_commands": [
"help", "status", "repos", "issues", "prs", "commits",
],
"response_mode": "text",
"default_count": 10,
"rate_limits": {"api": 15, "default": 10},
},
]
for cfg in defaults:
ptype = cfg["provider_type"]
if ptype in existing:
continue
cmd_tmpl_id = tmpl_by_type.get(ptype)
# Use raw SQL to handle legacy NOT NULL columns
import json as _json2
from sqlalchemy import text as _text2
from datetime import datetime as _dt3, timezone as _tz3
await session.execute(
_text2(
"INSERT INTO command_config "
"(user_id, provider_type, name, icon, enabled_commands, locale, "
"response_mode, default_count, rate_limits, command_template_config_id, created_at) "
"VALUES (:uid, :pt, :name, :icon, :cmds, :locale, :rm, :dc, :rl, :ctid, :ca)"
),
{
"uid": 0,
"pt": ptype,
"name": cfg["name"],
"icon": "",
"cmds": _json2.dumps(cfg["enabled_commands"]),
"locale": "en",
"rm": cfg["response_mode"],
"dc": cfg["default_count"],
"rl": _json2.dumps(cfg["rate_limits"]),
"ctid": cmd_tmpl_id,
"ca": _dt3.now(_tz3.utc).isoformat(),
},
)
await session.commit()
def run():
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8420)
@@ -0,0 +1,165 @@
"""Shared dispatch helpers used by both watcher and webhook handlers."""
from __future__ import annotations
import logging
from datetime import datetime, time, timezone
from typing import Any
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from notify_bridge_core.models.events import ServiceEvent
from ..database.models import (
EmailBot,
MatrixBot,
NotificationTarget,
NotificationTrackerTarget,
TargetReceiver,
TemplateConfig,
TemplateSlot,
TrackingConfig,
)
_LOGGER = logging.getLogger(__name__)
def in_quiet_hours(start: str | None, end: str | None) -> bool:
"""Check if the current UTC time is within the quiet hours window."""
if not start or not end:
return False
try:
now = datetime.now(timezone.utc).time()
t_start = time.fromisoformat(start)
t_end = time.fromisoformat(end)
if t_start <= t_end:
return t_start <= now <= t_end
else:
# Overnight window (e.g., 22:00 - 06:00)
return now >= t_start or now <= t_end
except (ValueError, TypeError):
return False
def event_allowed_by_config(event: ServiceEvent, tc: TrackingConfig) -> bool:
"""Check if an event type is allowed by the tracking config's flags."""
event_type = event.event_type.value
flag_map = {
# Immich events
"assets_added": tc.track_assets_added,
"assets_removed": tc.track_assets_removed,
"collection_renamed": tc.track_collection_renamed,
"collection_deleted": tc.track_collection_deleted,
"sharing_changed": tc.track_sharing_changed,
# Gitea events
"push": tc.track_push,
"issue_opened": tc.track_issue_opened,
"issue_closed": tc.track_issue_closed,
"issue_commented": tc.track_issue_commented,
"pr_opened": tc.track_pr_opened,
"pr_closed": tc.track_pr_closed,
"pr_merged": tc.track_pr_merged,
"pr_commented": tc.track_pr_commented,
"release_published": tc.track_release_published,
# Scheduler events
"scheduled_message": tc.track_scheduled_message,
}
return flag_map.get(event_type, True)
async def load_link_data(
session: AsyncSession,
tracker_id: int,
*,
check_quiet_hours: bool = False,
) -> list[dict[str, Any]]:
"""Load tracker-target link data for dispatch.
Args:
session: Active database session.
tracker_id: ID of the tracker whose links to load.
check_quiet_hours: If True, skip links currently in quiet hours.
"""
tt_result = await session.exec(
select(NotificationTrackerTarget).where(
NotificationTrackerTarget.tracker_id == tracker_id
)
)
tracker_targets = tt_result.all()
link_data: list[dict[str, Any]] = []
for tt in tracker_targets:
if not tt.enabled:
continue
if check_quiet_hours and in_quiet_hours(tt.quiet_hours_start, tt.quiet_hours_end):
continue
target = await session.get(NotificationTarget, tt.target_id)
if not target:
continue
# Load receivers
recv_result = await session.exec(
select(TargetReceiver).where(
TargetReceiver.target_id == target.id,
TargetReceiver.enabled == True,
)
)
receivers = [dict(r.config) for r in recv_result.all()]
tracking_config = None
if tt.tracking_config_id:
tracking_config = await session.get(TrackingConfig, tt.tracking_config_id)
template_config = None
template_slots: dict[str, str] | None = None
if tt.template_config_id:
template_config = await session.get(TemplateConfig, tt.template_config_id)
if template_config:
slot_result = await session.exec(
select(TemplateSlot).where(TemplateSlot.config_id == template_config.id)
)
raw_slots = {s.slot_name: s.template for s in slot_result.all()}
template_slots = {}
for slot_name, tmpl_text in raw_slots.items():
event_key = slot_name.removeprefix("message_") if slot_name.startswith("message_") else slot_name
template_slots[event_key] = tmpl_text
target_config = dict(target.config)
# Inject chat_action for Telegram targets
if hasattr(target, 'chat_action') and target.chat_action:
target_config["chat_action"] = target.chat_action
# Inject bot credentials for bot-backed target types
if target.type == "email":
email_bot_id = target.config.get("email_bot_id")
if email_bot_id:
email_bot = await session.get(EmailBot, email_bot_id)
if email_bot:
target_config["smtp"] = {
"host": email_bot.smtp_host,
"port": email_bot.smtp_port,
"username": email_bot.smtp_username,
"password": email_bot.smtp_password,
"from_address": email_bot.email,
"from_name": email_bot.name,
"use_tls": email_bot.smtp_use_tls,
}
elif target.type == "matrix":
matrix_bot_id = target.config.get("matrix_bot_id")
if matrix_bot_id:
matrix_bot = await session.get(MatrixBot, matrix_bot_id)
if matrix_bot:
target_config["homeserver_url"] = matrix_bot.homeserver_url
target_config["access_token"] = matrix_bot.access_token
link_data.append({
"target_type": target.type,
"target_config": target_config,
"receivers": receivers,
"tracking_config": tracking_config,
"template_config": template_config,
"template_slots": template_slots,
})
return link_data
@@ -3,7 +3,6 @@
from __future__ import annotations
import logging
from datetime import datetime, time, timezone
from typing import Any
import aiohttp
@@ -17,19 +16,12 @@ from notify_bridge_core.storage import JsonFileBackend
from ..database.engine import get_engine
from ..database.models import (
EmailBot,
EventLog,
MatrixBot,
NotificationTarget,
NotificationTracker,
NotificationTrackerState,
NotificationTrackerTarget,
ServiceProvider,
TargetReceiver,
TemplateConfig,
TemplateSlot,
TrackingConfig,
)
from .dispatch_helpers import event_allowed_by_config, load_link_data
_LOGGER = logging.getLogger(__name__)
@@ -57,49 +49,6 @@ async def _get_telegram_caches() -> tuple[TelegramFileCache | None, TelegramFile
return _url_cache, _asset_cache
def _in_quiet_hours(start: str | None, end: str | None) -> bool:
"""Check if the current UTC time is within the quiet hours window."""
if not start or not end:
return False
try:
now = datetime.now(timezone.utc).time()
t_start = time.fromisoformat(start)
t_end = time.fromisoformat(end)
if t_start <= t_end:
return t_start <= now <= t_end
else:
# Overnight window (e.g., 22:00 - 06:00)
return now >= t_start or now <= t_end
except (ValueError, TypeError):
return False
def _event_allowed_by_config(event: ServiceEvent, tc: TrackingConfig) -> bool:
"""Check if an event type is allowed by the tracking config's flags."""
event_type = event.event_type.value
flag_map = {
# Immich events
"assets_added": tc.track_assets_added,
"assets_removed": tc.track_assets_removed,
"collection_renamed": tc.track_collection_renamed,
"collection_deleted": tc.track_collection_deleted,
"sharing_changed": tc.track_sharing_changed,
# Gitea events
"push": tc.track_push,
"issue_opened": tc.track_issue_opened,
"issue_closed": tc.track_issue_closed,
"issue_commented": tc.track_issue_commented,
"pr_opened": tc.track_pr_opened,
"pr_closed": tc.track_pr_closed,
"pr_merged": tc.track_pr_merged,
"pr_commented": tc.track_pr_commented,
"release_published": tc.track_release_published,
# Scheduler events
"scheduled_message": tc.track_scheduled_message,
}
return flag_map.get(event_type, True)
async def check_tracker(tracker_id: int) -> dict[str, Any]:
"""Poll a tracker's provider for changes and dispatch notifications."""
engine = get_engine()
@@ -128,88 +77,8 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
"shared": bool(s.shared),
}
# Load tracker-target links (replaces old target_ids JSON array)
tt_result = await session.exec(
select(NotificationTrackerTarget).where(NotificationTrackerTarget.tracker_id == tracker_id)
)
tracker_targets = tt_result.all()
# For each link, load target + tracking config + template config
link_data: list[dict[str, Any]] = []
for tt in tracker_targets:
if not tt.enabled:
continue
if _in_quiet_hours(tt.quiet_hours_start, tt.quiet_hours_end):
continue
target = await session.get(NotificationTarget, tt.target_id)
if not target:
continue
# Load receivers for this target
recv_result = await session.exec(
select(TargetReceiver).where(
TargetReceiver.target_id == target.id,
TargetReceiver.enabled == True,
)
)
receivers = [dict(r.config) for r in recv_result.all()]
tracking_config = None
if tt.tracking_config_id:
tracking_config = await session.get(TrackingConfig, tt.tracking_config_id)
template_config = None
template_slots: dict[str, str] | None = None
if tt.template_config_id:
template_config = await session.get(TemplateConfig, tt.template_config_id)
if template_config:
slot_result = await session.exec(
select(TemplateSlot).where(TemplateSlot.config_id == template_config.id)
)
raw_slots = {s.slot_name: s.template for s in slot_result.all()}
# Map slot names to event_type values for dispatcher lookup
template_slots = {}
for slot_name, tmpl_text in raw_slots.items():
# Strip "message_" prefix for event-type slots
event_key = slot_name.removeprefix("message_") if slot_name.startswith("message_") else slot_name
template_slots[event_key] = tmpl_text
target_config = dict(target.config)
# Inject chat_action for Telegram targets
if hasattr(target, 'chat_action') and target.chat_action:
target_config["chat_action"] = target.chat_action
# Inject bot credentials for bot-backed target types
if target.type == "email":
email_bot_id = target.config.get("email_bot_id")
if email_bot_id:
email_bot = await session.get(EmailBot, email_bot_id)
if email_bot:
target_config["smtp"] = {
"host": email_bot.smtp_host,
"port": email_bot.smtp_port,
"username": email_bot.smtp_username,
"password": email_bot.smtp_password,
"from_address": email_bot.email,
"from_name": email_bot.name,
"use_tls": email_bot.smtp_use_tls,
}
elif target.type == "matrix":
matrix_bot_id = target.config.get("matrix_bot_id")
if matrix_bot_id:
matrix_bot = await session.get(MatrixBot, matrix_bot_id)
if matrix_bot:
target_config["homeserver_url"] = matrix_bot.homeserver_url
target_config["access_token"] = matrix_bot.access_token
link_data.append({
"target_type": target.type,
"target_config": target_config,
"receivers": receivers,
"tracking_config": tracking_config,
"template_config": template_config,
"template_slots": template_slots,
})
# Load tracker-target links
link_data = await load_link_data(session, tracker_id, check_quiet_hours=True)
# Snapshot the data we need
provider_type = provider.type
@@ -327,7 +196,7 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
for ld in link_data:
# Apply per-link event filtering from tracking config
tc = ld["tracking_config"]
if tc and not _event_allowed_by_config(event, tc):
if tc and not event_allowed_by_config(event, tc):
_LOGGER.info(" Skipped by tracking config filter")
continue