feat: UX & notification improvements — icons, events, chat names, link validation, templates

- Show entity icons on all cards with fallback defaults (providers, trackers, targets, bots)
- Enrich EventLog with provider_name, tracker_name, assets_count; add DB migration
- Dashboard events: filtering (type, provider, search), sorting, pagination, dynamic page size
- Friendly chat names on telegram target cards (resolve from TelegramChat table)
- Test message button on bot chat items with locale-aware messages
- Album public link validation on tracker save with auto-create dialog
- Support albums without public links: conditional <a href> in templates
- Fetch shared links during poll, enrich events with public_url/protected_url
- Per-asset public_url in template context ({share_url}/photos/{asset_id})
- Common date/location detection: common_date + common_location context vars
- Dual date formats: date_format (datetime) + date_only_format (date only)
- Template clone button, HTML link rendering in template preview
- Fix Telegram asset download 401: pass x-api-key headers through client
- Fix provider external_url matching for API key scoping
- Fix event timestamp timezone (append Z suffix for UTC)
- Localize event filter controls, test messages (EN/RU)
- Template variable UI helpers updated with all new fields
- CLAUDE.md: template system sync rules documentation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-20 16:18:03 +03:00
parent 91e5cd58e9
commit 03c5c66eed
41 changed files with 1424 additions and 132 deletions
@@ -216,6 +216,73 @@ async def list_collections(
return []
@router.get("/{provider_id}/albums/{album_id}/shared-links")
async def get_album_shared_links(
provider_id: int,
album_id: str,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Check shared links for a specific album."""
provider = await _get_user_provider(session, provider_id, user.id)
if provider.type == "immich":
from notify_bridge_core.providers.immich import ImmichServiceProvider
config = provider.config
async with aiohttp.ClientSession() as http_session:
immich = ImmichServiceProvider(
http_session,
config.get("url", ""),
config.get("api_key", ""),
config.get("external_domain"),
provider.name,
)
links = await immich.client.get_shared_links(album_id)
return [
{
"id": link.id,
"key": link.key,
"has_password": link.has_password,
"is_expired": link.is_expired,
"is_accessible": link.is_accessible,
}
for link in links
]
return []
@router.post("/{provider_id}/albums/{album_id}/shared-links")
async def create_album_shared_link(
provider_id: int,
album_id: str,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Auto-create a public shared link for an album."""
provider = await _get_user_provider(session, provider_id, user.id)
if provider.type == "immich":
from notify_bridge_core.providers.immich import ImmichServiceProvider
config = provider.config
async with aiohttp.ClientSession() as http_session:
immich = ImmichServiceProvider(
http_session,
config.get("url", ""),
config.get("api_key", ""),
config.get("external_domain"),
provider.name,
)
success = await immich.client.create_shared_link(album_id)
if success:
return {"success": True}
from fastapi import HTTPException
raise HTTPException(status_code=400, detail="Failed to create shared link")
from fastapi import HTTPException
raise HTTPException(status_code=400, detail="Provider type does not support shared links")
def _provider_response(p: ServiceProvider) -> dict:
"""Build a safe response dict for a provider."""
config = dict(p.config)
@@ -1,6 +1,6 @@
"""Status/dashboard API route."""
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, Query
from sqlmodel import func, select
from sqlmodel.ext.asyncio.session import AsyncSession
@@ -15,8 +15,15 @@ router = APIRouter(prefix="/api/status", tags=["status"])
async def get_status(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
# Event filtering
event_type: str | None = Query(None),
provider_id: int | None = Query(None),
search: str | None = Query(None),
sort: str = Query("newest"),
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
):
"""Get dashboard status data."""
"""Get dashboard status data with enriched events."""
providers_count = (await session.exec(
select(func.count()).select_from(ServiceProvider).where(ServiceProvider.user_id == user.id)
)).one()
@@ -31,24 +38,53 @@ async def get_status(
select(func.count()).select_from(NotificationTarget).where(NotificationTarget.user_id == user.id)
)).one()
recent_events = await session.exec(
# Build events query with filters
events_query = (
select(EventLog)
.join(Tracker, EventLog.tracker_id == Tracker.id)
.where(Tracker.user_id == user.id)
.order_by(EventLog.created_at.desc())
.limit(10)
)
if event_type:
events_query = events_query.where(EventLog.event_type == event_type)
if provider_id is not None:
events_query = events_query.where(EventLog.provider_id == provider_id)
if search:
events_query = events_query.where(
EventLog.collection_name.contains(search)
| EventLog.tracker_name.contains(search)
| EventLog.provider_name.contains(search)
)
# Count total matching events (for pagination)
count_query = select(func.count()).select_from(events_query.subquery())
total_events = (await session.exec(count_query)).one()
# Sort
if sort == "oldest":
events_query = events_query.order_by(EventLog.created_at.asc())
else:
events_query = events_query.order_by(EventLog.created_at.desc())
events_query = events_query.offset(offset).limit(limit)
recent_events = await session.exec(events_query)
return {
"providers": providers_count,
"trackers": {"total": len(trackers), "active": active_count},
"targets": targets_count,
"total_events": total_events,
"recent_events": [
{
"id": e.id,
"event_type": e.event_type,
"collection_name": e.collection_name,
"created_at": e.created_at.isoformat(),
"tracker_name": e.tracker_name or "",
"provider_name": e.provider_name or "",
"provider_id": e.provider_id,
"assets_count": e.assets_count or 0,
"created_at": e.created_at.isoformat() + ("Z" if not e.created_at.tzinfo else ""),
"details": e.details or {},
}
for e in recent_events.all()
],
@@ -1,6 +1,6 @@
"""Notification target management API routes."""
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
@@ -8,7 +8,7 @@ from typing import Any
from ..auth.dependencies import get_current_user
from ..database.engine import get_session
from ..database.models import NotificationTarget, User
from ..database.models import NotificationTarget, TelegramBot, TelegramChat, TrackerTarget, User
router = APIRouter(prefix="/api/targets", tags=["targets"])
@@ -18,14 +18,12 @@ class TargetCreate(BaseModel):
name: str
icon: str = ""
config: dict[str, Any] = {}
template_config_id: int | None = None
class TargetUpdate(BaseModel):
name: str | None = None
icon: str | None = None
config: dict[str, Any] | None = None
template_config_id: int | None = None
@router.get("")
@@ -37,18 +35,26 @@ async def list_targets(
result = await session.exec(
select(NotificationTarget).where(NotificationTarget.user_id == user.id)
)
return [
{
"id": t.id,
"type": t.type,
"name": t.name,
"icon": t.icon,
"config": _safe_config(t),
"template_config_id": t.template_config_id,
"created_at": t.created_at.isoformat(),
}
for t in result.all()
]
targets = result.all()
# Resolve chat names for telegram targets
chat_names: dict[str, str] = {}
for tgt in targets:
if tgt.type == "telegram" and tgt.config.get("chat_id"):
bot_id = tgt.config.get("bot_id")
chat_id = str(tgt.config["chat_id"])
if bot_id:
chat_result = await session.exec(
select(TelegramChat).where(
TelegramChat.bot_id == bot_id,
TelegramChat.chat_id == chat_id,
)
)
chat = chat_result.first()
if chat:
chat_names[f"{bot_id}_{chat_id}"] = chat.title or chat.username or ""
return [_target_response(t, chat_names) for t in targets]
@router.post("", status_code=status.HTTP_201_CREATED)
@@ -69,7 +75,6 @@ async def create_target(
name=body.name,
icon=body.icon,
config=body.config,
template_config_id=body.template_config_id,
)
session.add(target)
await session.commit()
@@ -85,14 +90,7 @@ async def get_target(
):
"""Get a specific notification target."""
target = await _get_user_target(session, target_id, user.id)
return {
"id": target.id,
"type": target.type,
"name": target.name,
"icon": target.icon,
"config": _safe_config(target),
"template_config_id": target.template_config_id,
}
return _target_response(target)
@router.put("/{target_id}")
@@ -104,14 +102,8 @@ async def update_target(
):
"""Update a notification target."""
target = await _get_user_target(session, target_id, user.id)
if body.name is not None:
target.name = body.name
if body.icon is not None:
target.icon = body.icon
if body.config is not None:
target.config = body.config
if body.template_config_id is not None:
target.template_config_id = body.template_config_id
for field, value in body.model_dump(exclude_unset=True).items():
setattr(target, field, value)
session.add(target)
await session.commit()
await session.refresh(target)
@@ -124,8 +116,14 @@ async def delete_target(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Delete a notification target."""
"""Delete a notification target and its tracker links."""
target = await _get_user_target(session, target_id, user.id)
# Delete associated tracker-target links
result = await session.exec(
select(TrackerTarget).where(TrackerTarget.target_id == target_id)
)
for tt in result.all():
await session.delete(tt)
await session.delete(target)
await session.commit()
@@ -133,16 +131,36 @@ async def delete_target(
@router.post("/{target_id}/test")
async def test_target(
target_id: int,
locale: str = Query("en"),
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Send a test notification to a target."""
target = await _get_user_target(session, target_id, user.id)
from ..services.notifier import send_test_notification
result = await send_test_notification(target)
result = await send_test_notification(target, locale=locale)
return result
def _target_response(target: NotificationTarget, chat_names: dict[str, str] | None = None) -> dict:
resp = {
"id": target.id,
"type": target.type,
"name": target.name,
"icon": target.icon,
"config": _safe_config(target),
"created_at": target.created_at.isoformat(),
}
# Attach resolved chat name for telegram targets
if target.type == "telegram" and chat_names:
bot_id = target.config.get("bot_id")
chat_id = str(target.config.get("chat_id", ""))
key = f"{bot_id}_{chat_id}"
if key in chat_names:
resp["chat_name"] = chat_names[key]
return resp
def _safe_config(target: NotificationTarget) -> dict:
"""Return config with sensitive fields masked."""
config = dict(target.config)
@@ -1,6 +1,6 @@
"""Telegram bot management API routes."""
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
@@ -23,7 +23,7 @@ class BotCreate(BaseModel):
class BotUpdate(BaseModel):
name: str | None = None
commands_config: dict | None = None
icon: str | None = None
@router.get("")
@@ -69,12 +69,12 @@ async def update_bot(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Update a bot's display name and/or commands config."""
"""Update a bot's display name and icon."""
bot = await _get_user_bot(session, bot_id, user.id)
if body.name is not None:
bot.name = body.name
if body.commands_config is not None:
bot.commands_config = body.commands_config
if body.icon is not None:
bot.icon = body.icon
session.add(bot)
await session.commit()
await session.refresh(bot)
@@ -173,6 +173,37 @@ async def discover_chats(
return [_chat_response(c) for c in result.all()]
@router.post("/{bot_id}/chats/{chat_id}/test")
async def test_chat(
bot_id: int,
chat_id: str,
locale: str = Query("en"),
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Send a test message to a chat via the bot."""
from ..services.notifier import _get_test_message
bot = await _get_user_bot(session, bot_id, user.id)
message = _get_test_message(locale, "telegram")
try:
async with aiohttp.ClientSession() as http:
async with http.post(
f"{TELEGRAM_API_BASE_URL}{bot.token}/sendMessage",
json={
"chat_id": chat_id,
"text": message,
"parse_mode": "HTML",
},
) 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 e:
return {"success": False, "error": str(e)}
@router.delete("/{bot_id}/chats/{chat_db_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_chat(
bot_id: int,
@@ -247,10 +278,10 @@ def _bot_response(b: TelegramBot) -> dict:
return {
"id": b.id,
"name": b.name,
"icon": b.icon,
"bot_username": b.bot_username,
"bot_id": b.bot_id,
"token_preview": f"{b.token[:8]}...{b.token[-4:]}" if len(b.token) > 12 else "***",
"commands_config": b.commands_config,
"created_at": b.created_at.isoformat(),
}
@@ -32,6 +32,7 @@ _SAMPLE_ASSET = {
"state": "Ile-de-France",
"country": "France",
"url": "https://immich.example.com/photos/abc123",
"public_url": "https://immich.example.com/share/abc123/photos/a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"download_url": "https://immich.example.com/api/assets/abc123/original",
"photo_url": "https://immich.example.com/api/assets/abc123/thumbnail",
}
@@ -44,12 +45,14 @@ _SAMPLE_VIDEO_ASSET = {
"is_favorite": False,
"rating": None,
"photo_url": None,
"public_url": "https://immich.example.com/share/abc123/photos/d4e5f6a7-b8c9-0123-defg-456789abcdef",
"playback_url": "https://immich.example.com/api/assets/def456/video",
}
_SAMPLE_COLLECTION = {
"name": "Family Photos",
"url": "https://immich.example.com/share/abc123",
"public_url": "https://immich.example.com/share/abc123",
"asset_count": 42,
"shared": True,
}
@@ -85,10 +88,24 @@ _SAMPLE_CONTEXT = {
"new_name": "New Album",
"old_shared": False,
"new_shared": True,
# Public share URLs (may be empty if no shared link exists)
"public_url": "https://immich.example.com/share/abc123",
"protected_url": "",
"album_url": "https://immich.example.com/albums/b2eeeaa4",
# Common date/location (set when all assets share the same value)
"common_date": "19.03.2026",
"common_location": "Paris, France",
# Date format strings (from template config)
"date_format": "%d.%m.%Y, %H:%M UTC",
"date_only_format": "%d.%m.%Y",
# Scheduled/periodic variables (for those templates)
"collections": [_SAMPLE_COLLECTION, {**_SAMPLE_COLLECTION, "name": "Vacation 2025", "asset_count": 120}],
"assets": [_SAMPLE_ASSET, {**_SAMPLE_ASSET, "filename": "IMG_002.jpg", "city": "London", "country": "UK"}],
"albums": [_SAMPLE_COLLECTION, {**_SAMPLE_COLLECTION, "name": "Vacation 2025", "asset_count": 120}],
"assets": [_SAMPLE_ASSET, {**_SAMPLE_ASSET, "id": "x1y2z3", "filename": "IMG_002.jpg", "city": "London", "country": "UK", "public_url": "https://immich.example.com/share/abc123/photos/x1y2z3"}],
"date": "2026-03-19",
"photo_count": 30,
"video_count": 5,
"owner": "Alice",
}
@@ -106,6 +123,7 @@ class TemplateConfigCreate(BaseModel):
scheduled_assets_message: str | None = None
memory_mode_message: str | None = None
date_format: str | None = None
date_only_format: str | None = None
TemplateConfigUpdate = TemplateConfigCreate # Same shape, all optional
@@ -142,10 +160,15 @@ async def get_template_variables():
"collection_id": "Collection ID (UUID)",
"collection_name": "Collection name",
"collection_url": "Public share URL (empty if not shared)",
"public_url": "Public share link URL (empty if no link exists)",
"protected_url": "Password-protected share link URL (empty if none)",
"added_count": "Number of assets added",
"removed_count": "Number of assets removed",
"people": "Detected people names (list, use {{ people | join(', ') }})",
"shared": "Whether collection is shared (boolean)",
"photo_count": "Total photo count in album",
"video_count": "Total video count in album",
"owner": "Album owner name",
"target_type": "Target type: 'telegram' or 'webhook'",
"has_videos": "Whether added assets contain videos (boolean)",
"has_photos": "Whether added assets contain photos (boolean)",
@@ -177,6 +200,7 @@ async def get_template_variables():
"city": "City name",
"state": "State/region name",
"country": "Country name",
"public_url": "Per-asset public share URL (empty if no album link)",
"url": "Public viewer URL (if shared)",
"download_url": "Direct download URL (if shared)",
"photo_url": "Preview image URL (images only, if shared)",
@@ -185,6 +209,7 @@ async def get_template_variables():
album_fields = {
"name": "Collection/album name",
"url": "Share URL",
"public_url": "Public share link URL",
"asset_count": "Total assets in collection",
"shared": "Whether collection is shared",
}
@@ -196,7 +221,12 @@ async def get_template_variables():
return {
"message_assets_added": {
"description": "Notification when new assets are added to a collection",
"variables": {**event_vars, "added_assets": "List of asset dicts (use {% for asset in added_assets %})"},
"variables": {
**event_vars,
"added_assets": "List of asset dicts (use {% for asset in added_assets %})",
"common_date": "Shared date if all assets have the same date (formatted via date_only_format, empty otherwise)",
"common_location": "Shared location if all assets are from the same place (e.g. 'Paris, France', empty otherwise)",
},
"asset_fields": asset_fields,
},
"message_assets_removed": {
@@ -308,6 +338,8 @@ async def preview_config(
class PreviewRequest(BaseModel):
template: str
target_type: str = "telegram" # "telegram" or "webhook"
date_format: str = "%d.%m.%Y, %H:%M UTC"
date_only_format: str = "%d.%m.%Y"
@router.post("/preview-raw")
@@ -334,7 +366,14 @@ async def preview_raw(
# Pass 2: render with strict undefined to catch unknown variables
try:
ctx = {**_SAMPLE_CONTEXT, "target_type": body.target_type}
from datetime import datetime
ctx = {**_SAMPLE_CONTEXT, "target_type": body.target_type,
"date_format": body.date_format, "date_only_format": body.date_only_format}
# Format common_date using the provided date_only_format
try:
ctx["common_date"] = datetime(2026, 3, 19).strftime(body.date_only_format)
except (ValueError, TypeError):
ctx["common_date"] = "19.03.2026"
strict_env = SandboxedEnvironment(autoescape=False, undefined=StrictUndefined)
tmpl = strict_env.from_string(body.template)
rendered = tmpl.render(**ctx)
@@ -2,7 +2,7 @@
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
@@ -152,6 +152,7 @@ async def delete_tracker_target(
async def test_tracker_target(
tracker_id: int,
tracker_target_id: int,
locale: str = Query("en"),
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
@@ -166,7 +167,7 @@ async def test_tracker_target(
raise HTTPException(status_code=404, detail="Target not found")
from ..services.notifier import send_test_notification
r = await send_test_notification(target)
r = await send_test_notification(target, locale=locale)
return {"target": target.name, **r}
@@ -227,7 +227,7 @@ async def tracker_history(
"collection_id": e.collection_id,
"collection_name": e.collection_name,
"details": e.details,
"created_at": e.created_at.isoformat(),
"created_at": e.created_at.isoformat() + ("Z" if not e.created_at.tzinfo else ""),
}
for e in result.all()
]
@@ -0,0 +1,252 @@
"""Data migrations for schema changes.
Handles converting legacy JSON-array relationships to proper junction tables.
"""
import logging
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncEngine
logger = logging.getLogger(__name__)
async def migrate_schema(engine: AsyncEngine) -> None:
"""Add missing columns to existing tables (SQLite ALTER TABLE ADD COLUMN)."""
async with engine.begin() as conn:
# Helper to check if column exists
async def _has_column(table: str, column: str) -> bool:
cols = await conn.run_sync(
lambda sync_conn: [
row[1]
for row in sync_conn.execute(
text(f"PRAGMA table_info('{table}')")
).fetchall()
]
)
return column in cols
# Add batch_duration to tracker if missing
if not await _has_column("tracker", "batch_duration"):
await conn.execute(
text("ALTER TABLE tracker ADD COLUMN batch_duration INTEGER DEFAULT 0")
)
logger.info("Added batch_duration column to tracker table")
# Add enriched fields to event_log if missing
for col, sql in [
("tracker_name", "ALTER TABLE event_log ADD COLUMN tracker_name TEXT DEFAULT ''"),
("provider_id", "ALTER TABLE event_log ADD COLUMN provider_id INTEGER"),
("provider_name", "ALTER TABLE event_log ADD COLUMN provider_name TEXT DEFAULT ''"),
("assets_count", "ALTER TABLE event_log ADD COLUMN assets_count INTEGER DEFAULT 0"),
]:
if not await _has_column("event_log", col):
await conn.execute(text(sql))
logger.info("Added %s column to event_log table", col)
# Add date_only_format to template_config if missing
if not await _has_column("template_config", "date_only_format"):
await conn.execute(
text("ALTER TABLE template_config ADD COLUMN date_only_format TEXT DEFAULT '%d.%m.%Y'")
)
logger.info("Added date_only_format column to template_config table")
# Add collection_name and shared to tracker_state if missing
if not await _has_column("tracker_state", "collection_name"):
await conn.execute(
text("ALTER TABLE tracker_state ADD COLUMN collection_name TEXT DEFAULT ''")
)
logger.info("Added collection_name column to tracker_state table")
if not await _has_column("tracker_state", "shared"):
await conn.execute(
text("ALTER TABLE tracker_state ADD COLUMN shared INTEGER DEFAULT 0")
)
logger.info("Added shared column to tracker_state table")
async def migrate_tracker_targets(engine: AsyncEngine) -> None:
"""Migrate legacy Tracker.target_ids JSON arrays to TrackerTarget rows.
Also migrates:
- Tracker.tracking_config_id → TrackerTarget.tracking_config_id
- Tracker.quiet_hours_* → TrackerTarget.quiet_hours_*
- NotificationTarget.template_config_id → TrackerTarget.template_config_id
- TelegramBot.commands_config → TrackerTarget.commands_config (for telegram targets)
Idempotent: skips if legacy columns don't exist or data already migrated.
"""
async with engine.begin() as conn:
# Check if legacy target_ids column exists on tracker table
columns = await conn.run_sync(
lambda sync_conn: [
row[1]
for row in sync_conn.execute(
text("PRAGMA table_info('tracker')")
).fetchall()
]
)
if "target_ids" not in columns:
logger.debug("No legacy target_ids column found — skipping migration")
return
# Check if tracker_target table already has data (previous migration ran)
tt_count = (
await conn.execute(text("SELECT COUNT(*) FROM tracker_target"))
).scalar()
if tt_count and tt_count > 0:
logger.debug(
"tracker_target table already has %d rows — skipping migration",
tt_count,
)
return
# Load legacy data
trackers = (
await conn.execute(
text(
"SELECT id, target_ids, tracking_config_id, "
"quiet_hours_start, quiet_hours_end FROM tracker"
)
)
).fetchall()
if not trackers:
logger.debug("No trackers to migrate")
return
# Load template_config_id from targets (legacy field)
target_template_map: dict[int, int | None] = {}
target_cols = await conn.run_sync(
lambda sync_conn: [
row[1]
for row in sync_conn.execute(
text("PRAGMA table_info('notification_target')")
).fetchall()
]
)
if "template_config_id" in target_cols:
targets = (
await conn.execute(
text(
"SELECT id, template_config_id FROM notification_target"
)
)
).fetchall()
for t in targets:
target_template_map[t[0]] = t[1]
# Load commands_config from telegram_bots (legacy field)
bot_commands_map: dict[int, str | None] = {}
bot_cols = await conn.run_sync(
lambda sync_conn: [
row[1]
for row in sync_conn.execute(
text("PRAGMA table_info('telegram_bot')")
).fetchall()
]
)
if "commands_config" in bot_cols:
bots = (
await conn.execute(
text("SELECT id, commands_config FROM telegram_bot")
)
).fetchall()
for b in bots:
bot_commands_map[b[0]] = b[1]
# Build target → bot mapping for commands_config migration
target_bot_map: dict[int, int] = {}
if bot_commands_map:
import json
tgt_rows = (
await conn.execute(
text("SELECT id, config FROM notification_target WHERE type='telegram'")
)
).fetchall()
for tgt in tgt_rows:
try:
cfg = json.loads(tgt[1]) if isinstance(tgt[1], str) else tgt[1]
if cfg and "bot_token" in cfg:
for bot_id, _ in bot_commands_map.items():
bot_row = (
await conn.execute(
text("SELECT id FROM telegram_bot WHERE id=:bid"),
{"bid": bot_id},
)
).fetchone()
if bot_row:
# Match by checking if this target uses this bot's token
bot_token_row = (
await conn.execute(
text(
"SELECT token FROM telegram_bot WHERE id=:bid"
),
{"bid": bot_id},
)
).fetchone()
if bot_token_row and bot_token_row[0] == cfg.get(
"bot_token"
):
target_bot_map[tgt[0]] = bot_id
except Exception:
pass
# Create TrackerTarget rows
import json
migrated = 0
for tracker in trackers:
tracker_id = tracker[0]
raw_target_ids = tracker[1]
tracking_config_id = tracker[2]
quiet_hours_start = tracker[3]
quiet_hours_end = tracker[4]
# Parse target_ids JSON
if isinstance(raw_target_ids, str):
try:
target_ids = json.loads(raw_target_ids)
except (json.JSONDecodeError, TypeError):
target_ids = []
elif isinstance(raw_target_ids, list):
target_ids = raw_target_ids
else:
target_ids = []
for target_id in target_ids:
template_config_id = target_template_map.get(target_id)
# Get commands_config if this is a telegram target with a known bot
commands_config = None
if target_id in target_bot_map:
bot_id = target_bot_map[target_id]
raw_cmd = bot_commands_map.get(bot_id)
if raw_cmd:
commands_config = (
raw_cmd
if isinstance(raw_cmd, str)
else json.dumps(raw_cmd)
)
await conn.execute(
text(
"INSERT INTO tracker_target "
"(tracker_id, target_id, tracking_config_id, "
"template_config_id, enabled, quiet_hours_start, "
"quiet_hours_end, commands_config) "
"VALUES (:tid, :tgtid, :tcid, :tmplid, 1, :qhs, :qhe, :cmd)"
),
{
"tid": tracker_id,
"tgtid": target_id,
"tcid": tracking_config_id,
"tmplid": template_config_id,
"qhs": quiet_hours_start,
"qhe": quiet_hours_end,
"cmd": commands_config,
},
)
migrated += 1
logger.info("Migrated %d tracker-target links", migrated)
@@ -141,6 +141,7 @@ class TemplateConfig(SQLModel, table=True):
memory_mode_message: str = Field(default="")
date_format: str = Field(default="%d.%m.%Y, %H:%M UTC")
date_only_format: str = Field(default="%d.%m.%Y")
created_at: datetime = Field(default_factory=_utcnow)
@@ -221,8 +222,12 @@ class EventLog(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
tracker_id: int | None = Field(default=None, foreign_key="tracker.id")
tracker_name: str = Field(default="")
provider_id: int | None = Field(default=None)
provider_name: str = Field(default="")
event_type: str
collection_id: str
collection_name: str
assets_count: int = Field(default=0)
details: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
created_at: datetime = Field(default_factory=_utcnow)
@@ -1,15 +1,22 @@
"""Notify Bridge Server — FastAPI application entry point."""
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
# Ensure app-level loggers are visible
logging.basicConfig(level=logging.INFO)
logging.getLogger("notify_bridge_server").setLevel(logging.DEBUG)
logging.getLogger("notify_bridge_core").setLevel(logging.DEBUG)
from .database.engine import init_db
from .database.models import * # noqa: F401,F403 — ensure all models registered
from .auth.routes import router as auth_router
from .api.providers import router as providers_router
from .api.trackers import router as trackers_router
from .api.tracker_targets import router as tracker_targets_router
from .api.tracking_configs import router as tracking_configs_router
from .api.template_configs import router as template_configs_router
from .api.targets import router as targets_router
@@ -22,6 +29,12 @@ from .api.template_vars import router as template_vars_router
@asynccontextmanager
async def lifespan(app: FastAPI):
await init_db()
# Run data migrations (idempotent)
from .database.engine import get_engine
from .database.migrations import migrate_schema, migrate_tracker_targets
engine = get_engine()
await migrate_schema(engine)
await migrate_tracker_targets(engine)
await _seed_default_templates()
from .services.scheduler import start_scheduler
await start_scheduler()
@@ -35,6 +48,7 @@ app.include_router(auth_router)
app.include_router(template_vars_router)
app.include_router(providers_router)
app.include_router(trackers_router)
app.include_router(tracker_targets_router)
app.include_router(tracking_configs_router)
app.include_router(template_configs_router)
app.include_router(targets_router)
@@ -49,7 +63,7 @@ async def health():
async def _seed_default_templates():
"""Seed default templates on first startup if no templates exist."""
"""Seed default templates on first startup if none exist."""
from sqlmodel import func, select
from sqlmodel.ext.asyncio.session import AsyncSession
from .database.engine import get_engine
@@ -8,21 +8,37 @@ from ..database.models import NotificationTarget
_LOGGER = logging.getLogger(__name__)
_TEST_MESSAGES: dict[str, dict[str, str]] = {
"en": {
"telegram": "\u2705 Test message from <b>Notify Bridge</b>",
"webhook": "Test notification from Notify Bridge",
},
"ru": {
"telegram": "\u2705 Тестовое сообщение от <b>Notify Bridge</b>",
"webhook": "Тестовое уведомление от Notify Bridge",
},
}
async def send_test_notification(target: NotificationTarget) -> dict:
def _get_test_message(locale: str, target_type: str) -> str:
msgs = _TEST_MESSAGES.get(locale, _TEST_MESSAGES["en"])
return msgs.get(target_type, msgs.get("webhook", "Test"))
async def send_test_notification(target: NotificationTarget, locale: str = "en") -> dict:
"""Send a simple test message to a notification target."""
try:
if target.type == "telegram":
return await _test_telegram(target)
return await _test_telegram(target, locale)
elif target.type == "webhook":
return await _test_webhook(target)
return await _test_webhook(target, locale)
return {"success": False, "error": f"Unknown target type: {target.type}"}
except Exception as e:
_LOGGER.error("Test notification failed: %s", e)
return {"success": False, "error": str(e)}
async def _test_telegram(target: NotificationTarget) -> dict:
async def _test_telegram(target: NotificationTarget, locale: str = "en") -> dict:
from notify_bridge_core.notifications.telegram.client import TelegramClient
bot_token = target.config.get("bot_token")
@@ -34,7 +50,7 @@ async def _test_telegram(target: NotificationTarget) -> dict:
client = TelegramClient(session, bot_token)
return await client.send_notification(
chat_id=str(chat_id),
caption="Test notification from Notify Bridge",
caption=_get_test_message(locale, "telegram"),
)
@@ -88,7 +104,7 @@ async def _test_webhook_with_message(target: NotificationTarget, message: str) -
return await client.send({"message": message, "event_type": "test_template"})
async def _test_webhook(target: NotificationTarget) -> dict:
async def _test_webhook(target: NotificationTarget, locale: str = "en") -> dict:
from notify_bridge_core.notifications.webhook.client import WebhookClient
url = target.config.get("url")
@@ -99,6 +115,6 @@ async def _test_webhook(target: NotificationTarget) -> dict:
async with aiohttp.ClientSession() as session:
client = WebhookClient(session, url, headers)
return await client.send({
"message": "Test notification from Notify Bridge",
"message": _get_test_message(locale, "webhook"),
"event_type": "test",
})
@@ -174,11 +174,16 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
session.add(new_ts)
for event in events:
assets_count = event.added_count or event.removed_count or 0
log = EventLog(
tracker_id=tracker_id,
tracker_name=tracker.name,
provider_id=provider.id,
provider_name=provider_name,
event_type=event.event_type.value,
collection_id=event.collection_id,
collection_name=event.collection_name,
assets_count=assets_count,
details={
"added_count": event.added_count,
"removed_count": event.removed_count,
@@ -233,8 +238,11 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
type=ld["target_type"],
config=ld["target_config"],
template_slots=slots,
date_format=tmpl.date_format if tmpl else "%d.%m.%Y, %H:%M UTC",
date_only_format=tmpl.date_only_format if tmpl and hasattr(tmpl, "date_only_format") else "%d.%m.%Y",
provider_api_key=provider_config.get("api_key"),
provider_internal_url=provider_config.get("url", ""),
provider_external_url=provider_config.get("external_domain", ""),
))
if target_configs: