feat: telegram commands, app settings, bot polling, webhook handling, UI improvements
Adds telegram bot command system with 13 commands (search, latest, random, etc.), webhook/polling handlers, rate limiting, app settings page, and various UI/UX improvements across all entity pages. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
"""App-level settings API (admin only)."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from ..auth.dependencies import get_current_user
|
||||
from ..database.engine import get_session
|
||||
from ..database.models import AppSetting, TelegramBot, User
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||
|
||||
# Keys exposed to the frontend with their env-var fallbacks
|
||||
_SETTING_KEYS = {
|
||||
"external_url": "NOTIFY_BRIDGE_EXTERNAL_URL",
|
||||
"telegram_webhook_secret": "NOTIFY_BRIDGE_TELEGRAM_WEBHOOK_SECRET",
|
||||
"telegram_cache_ttl_hours": None, # no env fallback, default 48
|
||||
}
|
||||
|
||||
_DEFAULTS = {
|
||||
"external_url": "",
|
||||
"telegram_webhook_secret": "",
|
||||
"telegram_cache_ttl_hours": "48",
|
||||
}
|
||||
|
||||
|
||||
async def get_setting(session: AsyncSession, key: str) -> str:
|
||||
"""Read a setting from DB, falling back to env var then default."""
|
||||
row = await session.get(AppSetting, key)
|
||||
if row and row.value:
|
||||
return row.value
|
||||
env_key = _SETTING_KEYS.get(key)
|
||||
if env_key:
|
||||
env_val = os.environ.get(env_key, "")
|
||||
if env_val:
|
||||
return env_val
|
||||
return _DEFAULTS.get(key, "")
|
||||
|
||||
|
||||
class SettingsUpdate(BaseModel):
|
||||
external_url: str | None = None
|
||||
telegram_webhook_secret: str | None = None
|
||||
telegram_cache_ttl_hours: str | None = None
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_settings(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Return all app settings."""
|
||||
result = {}
|
||||
for key in _SETTING_KEYS:
|
||||
result[key] = await get_setting(session, key)
|
||||
return result
|
||||
|
||||
|
||||
@router.put("")
|
||||
async def update_settings(
|
||||
body: SettingsUpdate,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Update app settings (admin). Re-registers webhooks when base URL changes."""
|
||||
old_base_url = await get_setting(session, "external_url")
|
||||
old_secret = await get_setting(session, "telegram_webhook_secret")
|
||||
|
||||
for key in _SETTING_KEYS:
|
||||
value = getattr(body, key, None)
|
||||
if value is None:
|
||||
continue
|
||||
row = await session.get(AppSetting, key)
|
||||
if row:
|
||||
row.value = value
|
||||
else:
|
||||
row = AppSetting(key=key, value=value)
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
|
||||
new_base_url = await get_setting(session, "external_url")
|
||||
new_secret = await get_setting(session, "telegram_webhook_secret")
|
||||
|
||||
# Update webhook secret in the webhook handler module
|
||||
if new_secret != old_secret:
|
||||
from ..commands.webhook import set_webhook_secret
|
||||
set_webhook_secret(new_secret or None)
|
||||
|
||||
# Re-register webhooks for all bots in webhook mode if URL or secret changed
|
||||
if new_base_url and (new_base_url != old_base_url or new_secret != old_secret):
|
||||
await _reregister_webhooks(session, new_base_url, new_secret)
|
||||
|
||||
result = {}
|
||||
for key in _SETTING_KEYS:
|
||||
result[key] = await get_setting(session, key)
|
||||
return result
|
||||
|
||||
|
||||
async def _reregister_webhooks(
|
||||
session: AsyncSession, base_url: str, secret: str
|
||||
) -> None:
|
||||
"""Re-register webhooks for all bots in webhook mode."""
|
||||
from ..commands.webhook import register_webhook
|
||||
|
||||
result = await session.exec(
|
||||
select(TelegramBot).where(TelegramBot.update_mode == "webhook")
|
||||
)
|
||||
bots = result.all()
|
||||
for bot in bots:
|
||||
webhook_url = f"{base_url.rstrip('/')}/api/telegram/webhook/{bot.webhook_path_id}"
|
||||
res = await register_webhook(bot.token, webhook_url, secret or None)
|
||||
if res.get("success"):
|
||||
_LOGGER.info("Re-registered webhook for bot %d (%s)", bot.id, bot.name)
|
||||
else:
|
||||
_LOGGER.warning(
|
||||
"Failed to re-register webhook for bot %d: %s",
|
||||
bot.id, res.get("error"),
|
||||
)
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Service provider management API routes."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import select
|
||||
@@ -11,6 +13,9 @@ import aiohttp
|
||||
from ..auth.dependencies import get_current_user
|
||||
from ..database.engine import get_session
|
||||
from ..database.models import ServiceProvider, User
|
||||
from ..services import make_immich_provider
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/providers", tags=["providers"])
|
||||
|
||||
@@ -63,11 +68,8 @@ async def create_provider(
|
||||
config = body.config
|
||||
async with aiohttp.ClientSession() as http_session:
|
||||
immich = ImmichServiceProvider(
|
||||
http_session,
|
||||
config.get("url", ""),
|
||||
config.get("api_key", ""),
|
||||
config.get("external_domain"),
|
||||
body.name,
|
||||
http_session, config.get("url", ""), config.get("api_key", ""),
|
||||
config.get("external_domain"), body.name,
|
||||
)
|
||||
test_result = await immich.test_connection()
|
||||
if not test_result.get("ok"):
|
||||
@@ -124,16 +126,8 @@ async def update_provider(
|
||||
# Re-validate connection when config changes for known provider types
|
||||
if config_changed and provider.type == "immich":
|
||||
try:
|
||||
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,
|
||||
)
|
||||
immich = make_immich_provider(http_session, provider)
|
||||
test_result = await immich.test_connection()
|
||||
if not test_result.get("ok"):
|
||||
raise HTTPException(
|
||||
@@ -176,16 +170,8 @@ async def test_provider(
|
||||
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,
|
||||
)
|
||||
immich = make_immich_provider(http_session, provider)
|
||||
return await immich.test_connection()
|
||||
|
||||
return {"ok": False, "message": f"Unknown provider type: {provider.type}"}
|
||||
@@ -201,16 +187,8 @@ async def list_collections(
|
||||
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,
|
||||
)
|
||||
immich = make_immich_provider(http_session, provider)
|
||||
return await immich.list_collections()
|
||||
|
||||
return []
|
||||
@@ -227,16 +205,8 @@ async def get_album_shared_links(
|
||||
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,
|
||||
)
|
||||
immich = make_immich_provider(http_session, provider)
|
||||
links = await immich.client.get_shared_links(album_id)
|
||||
return [
|
||||
{
|
||||
@@ -263,16 +233,8 @@ async def create_album_shared_link(
|
||||
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,
|
||||
)
|
||||
immich = make_immich_provider(http_session, provider)
|
||||
success = await immich.client.create_shared_link(album_id)
|
||||
if success:
|
||||
return {"success": True}
|
||||
|
||||
@@ -108,7 +108,7 @@ async def get_event_chart(
|
||||
select(
|
||||
day_col.label("day"),
|
||||
EventLog.event_type,
|
||||
func.count().label("count"),
|
||||
func.count().label("total"),
|
||||
)
|
||||
.join(Tracker, EventLog.tracker_id == Tracker.id)
|
||||
.where(Tracker.user_id == user.id, EventLog.created_at >= cutoff)
|
||||
@@ -118,13 +118,13 @@ async def get_event_chart(
|
||||
|
||||
rows = (await session.exec(query)).all()
|
||||
|
||||
# Build a dict: { "2026-03-15": { "assets_added": 3, ... }, ... }
|
||||
# Build a dict: { "2026-03-15": { "assets_added": 18, ... }, ... }
|
||||
by_day: dict[str, dict[str, int]] = {}
|
||||
for row in rows:
|
||||
day_str = str(row.day)
|
||||
if day_str not in by_day:
|
||||
by_day[day_str] = {}
|
||||
by_day[day_str][row.event_type] = row.count
|
||||
by_day[day_str][row.event_type] = row.total
|
||||
|
||||
# Fill in missing days so the frontend gets a continuous series
|
||||
result = []
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Notification target management API routes."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import select
|
||||
@@ -9,6 +11,9 @@ from typing import Any
|
||||
from ..auth.dependencies import get_current_user
|
||||
from ..database.engine import get_session
|
||||
from ..database.models import NotificationTarget, TelegramBot, TelegramChat, TrackerTarget, User
|
||||
from ..services.notifier import send_test_notification
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/targets", tags=["targets"])
|
||||
|
||||
@@ -137,7 +142,6 @@ async def test_target(
|
||||
):
|
||||
"""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, locale=locale)
|
||||
return result
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Telegram bot management API routes."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import select
|
||||
@@ -10,8 +12,15 @@ import aiohttp
|
||||
from notify_bridge_core.notifications.telegram.media import TELEGRAM_API_BASE_URL
|
||||
|
||||
from ..auth.dependencies import get_current_user
|
||||
from ..commands.handler import register_commands_with_telegram
|
||||
from ..commands.webhook import register_webhook, unregister_webhook
|
||||
from ..database.engine import get_session
|
||||
from ..database.models import TelegramBot, TelegramChat, User
|
||||
from ..database.models import AppSetting, TelegramBot, TelegramChat, User
|
||||
from ..services.notifier import _get_test_message
|
||||
from ..services.telegram_poller import schedule_bot_polling, unschedule_bot_polling
|
||||
from .app_settings import get_setting
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/telegram-bots", tags=["telegram-bots"])
|
||||
|
||||
@@ -24,6 +33,8 @@ class BotCreate(BaseModel):
|
||||
class BotUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
icon: str | None = None
|
||||
update_mode: str | None = None
|
||||
commands_config: dict | None = None
|
||||
|
||||
|
||||
@router.get("")
|
||||
@@ -69,12 +80,41 @@ async def update_bot(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Update a bot's display name and icon."""
|
||||
"""Update a bot's display name, icon, commands config, and update mode."""
|
||||
bot = await _get_user_bot(session, bot_id, user.id)
|
||||
if body.name is not None:
|
||||
bot.name = body.name
|
||||
if body.icon is not None:
|
||||
bot.icon = body.icon
|
||||
if body.commands_config is not None:
|
||||
bot.commands_config = body.commands_config
|
||||
|
||||
# Handle mode switching
|
||||
if body.update_mode is not None and body.update_mode != bot.update_mode:
|
||||
if body.update_mode == "webhook":
|
||||
# Validate and register webhook BEFORE stopping polling
|
||||
base_url = await get_setting(session, "external_url")
|
||||
if not base_url:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot switch to webhook: external domain URL not configured. Set it in Settings.",
|
||||
)
|
||||
webhook_url = f"{base_url.rstrip('/')}/api/telegram/webhook/{bot.webhook_path_id}"
|
||||
secret = await get_setting(session, "telegram_webhook_secret")
|
||||
result = await register_webhook(bot.token, webhook_url, secret or None)
|
||||
if not result.get("success"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Webhook registration failed: {result.get('error', 'unknown')}",
|
||||
)
|
||||
# Webhook registered successfully — now stop polling
|
||||
unschedule_bot_polling(bot.id)
|
||||
elif body.update_mode == "polling":
|
||||
# Switching to polling: unregister webhook, start polling
|
||||
await unregister_webhook(bot.token)
|
||||
schedule_bot_polling(bot.id)
|
||||
bot.update_mode = body.update_mode
|
||||
|
||||
session.add(bot)
|
||||
await session.commit()
|
||||
await session.refresh(bot)
|
||||
@@ -173,6 +213,75 @@ async def discover_chats(
|
||||
return [_chat_response(c) for c in result.all()]
|
||||
|
||||
|
||||
@router.post("/{bot_id}/sync-commands")
|
||||
async def sync_commands(
|
||||
bot_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Register enabled commands with Telegram BotFather API."""
|
||||
bot = await _get_user_bot(session, bot_id, user.id)
|
||||
success = await register_commands_with_telegram(bot)
|
||||
return {"success": success}
|
||||
|
||||
|
||||
@router.post("/{bot_id}/webhook/register")
|
||||
async def register_bot_webhook(
|
||||
bot_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Register Telegram webhook for this bot."""
|
||||
bot = await _get_user_bot(session, bot_id, user.id)
|
||||
base_url = await get_setting(session, "external_url")
|
||||
if not base_url:
|
||||
return {"success": False, "error": "External domain URL not configured. Set it in Telegram Settings."}
|
||||
webhook_url = f"{base_url.rstrip('/')}/api/telegram/webhook/{bot.webhook_path_id}"
|
||||
secret = await get_setting(session, "telegram_webhook_secret")
|
||||
result = await register_webhook(bot.token, webhook_url, secret or None)
|
||||
if not result.get("success"):
|
||||
return result
|
||||
# Verify with getWebhookInfo
|
||||
info = await _get_webhook_info(bot.token)
|
||||
if info and info.get("url") == webhook_url:
|
||||
result["verified"] = True
|
||||
result["webhook_url"] = webhook_url
|
||||
else:
|
||||
result["verified"] = False
|
||||
result["warning"] = "Webhook set but verification failed"
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/{bot_id}/webhook/unregister")
|
||||
async def unregister_bot_webhook(
|
||||
bot_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Unregister Telegram webhook for this bot."""
|
||||
bot = await _get_user_bot(session, bot_id, user.id)
|
||||
result = await unregister_webhook(bot.token)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/{bot_id}/webhook/status")
|
||||
async def get_webhook_status(
|
||||
bot_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Get current webhook status from Telegram."""
|
||||
bot = await _get_user_bot(session, bot_id, user.id)
|
||||
info = await _get_webhook_info(bot.token)
|
||||
return {
|
||||
"url": info.get("url", "") if info else "",
|
||||
"has_custom_certificate": info.get("has_custom_certificate", False) if info else False,
|
||||
"pending_update_count": info.get("pending_update_count", 0) if info else 0,
|
||||
"last_error_message": info.get("last_error_message", "") if info else "",
|
||||
"last_error_date": info.get("last_error_date", 0) if info else 0,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{bot_id}/chats/{chat_id}/test")
|
||||
async def test_chat(
|
||||
bot_id: int,
|
||||
@@ -182,8 +291,6 @@ async def test_chat(
|
||||
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:
|
||||
@@ -222,6 +329,19 @@ async def delete_chat(
|
||||
|
||||
# --- Helpers ---
|
||||
|
||||
async def _get_webhook_info(token: str) -> dict | None:
|
||||
"""Call Telegram getWebhookInfo to check current webhook state."""
|
||||
try:
|
||||
async with aiohttp.ClientSession() as http:
|
||||
async with http.get(f"{TELEGRAM_API_BASE_URL}{token}/getWebhookInfo") as resp:
|
||||
data = await resp.json()
|
||||
if data.get("ok"):
|
||||
return data.get("result", {})
|
||||
except aiohttp.ClientError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
async def _get_me(token: str) -> dict | None:
|
||||
"""Call Telegram getMe to validate token and get bot info."""
|
||||
try:
|
||||
@@ -281,6 +401,9 @@ def _bot_response(b: TelegramBot) -> dict:
|
||||
"icon": b.icon,
|
||||
"bot_username": b.bot_username,
|
||||
"bot_id": b.bot_id,
|
||||
"webhook_path_id": b.webhook_path_id,
|
||||
"update_mode": b.update_mode or "polling",
|
||||
"commands_config": b.commands_config or {},
|
||||
"token_preview": f"{b.token[:8]}...{b.token[-4:]}" if len(b.token) > 12 else "***",
|
||||
"created_at": b.created_at.isoformat(),
|
||||
}
|
||||
@@ -293,38 +416,6 @@ async def _get_user_bot(session: AsyncSession, bot_id: int, user_id: int) -> Tel
|
||||
return bot
|
||||
|
||||
|
||||
async def save_chat_from_webhook(
|
||||
session: AsyncSession, bot_id: int, chat_data: dict
|
||||
) -> None:
|
||||
"""Save or update a chat entry from an incoming webhook message.
|
||||
|
||||
Called by the webhook handler to auto-persist chats.
|
||||
"""
|
||||
chat_id = str(chat_data.get("id", ""))
|
||||
if not chat_id:
|
||||
return
|
||||
|
||||
result = await session.exec(
|
||||
select(TelegramChat).where(
|
||||
TelegramChat.bot_id == bot_id,
|
||||
TelegramChat.chat_id == chat_id,
|
||||
)
|
||||
)
|
||||
existing = result.first()
|
||||
|
||||
title = chat_data.get("title") or (
|
||||
chat_data.get("first_name", "") + (" " + chat_data.get("last_name", "")).strip()
|
||||
)
|
||||
|
||||
if existing:
|
||||
existing.title = title
|
||||
existing.username = chat_data.get("username", existing.username)
|
||||
session.add(existing)
|
||||
else:
|
||||
session.add(TelegramChat(
|
||||
bot_id=bot_id,
|
||||
chat_id=chat_id,
|
||||
title=title,
|
||||
chat_type=chat_data.get("type", "private"),
|
||||
username=chat_data.get("username", ""),
|
||||
))
|
||||
# Re-export for backward compatibility
|
||||
from ..services.telegram import save_chat_from_webhook # noqa: F401
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Template configuration CRUD API routes."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import select
|
||||
@@ -11,103 +13,12 @@ from jinja2 import TemplateSyntaxError, UndefinedError, StrictUndefined
|
||||
from ..auth.dependencies import get_current_user
|
||||
from ..database.engine import get_session
|
||||
from ..database.models import TemplateConfig, User
|
||||
from ..services.sample_context import _SAMPLE_CONTEXT
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/template-configs", tags=["template-configs"])
|
||||
|
||||
# Sample asset matching what build_asset_detail() actually returns
|
||||
_SAMPLE_ASSET = {
|
||||
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
"filename": "IMG_001.jpg",
|
||||
"type": "IMAGE",
|
||||
"created_at": "2026-03-19T10:30:00",
|
||||
"owner": "Alice",
|
||||
"owner_id": "user-uuid-1",
|
||||
"description": "Family picnic",
|
||||
"people": ["Alice", "Bob"],
|
||||
"is_favorite": True,
|
||||
"rating": 5,
|
||||
"latitude": 48.8566,
|
||||
"longitude": 2.3522,
|
||||
"city": "Paris",
|
||||
"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",
|
||||
}
|
||||
|
||||
_SAMPLE_VIDEO_ASSET = {
|
||||
**_SAMPLE_ASSET,
|
||||
"id": "d4e5f6a7-b8c9-0123-defg-456789abcdef",
|
||||
"filename": "VID_002.mp4",
|
||||
"type": "VIDEO",
|
||||
"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,
|
||||
}
|
||||
|
||||
# Full context covering ALL possible template variables
|
||||
_SAMPLE_CONTEXT = {
|
||||
# Core event fields (always present)
|
||||
"collection_id": "b2eeeaa4-bba0-477a-a06f-5cb9e21818e8",
|
||||
"collection_name": "Family Photos",
|
||||
"collection_url": "https://immich.example.com/share/abc123",
|
||||
"event_type": "assets_added",
|
||||
"timestamp": "2026-03-19T10:30:00+00:00",
|
||||
"service_name": "Immich",
|
||||
"service_type": "immich",
|
||||
# Immich aliases (always present alongside collection_*)
|
||||
"album_name": "Family Photos",
|
||||
"album_id": "b2eeeaa4-bba0-477a-a06f-5cb9e21818e8",
|
||||
"album_url": "https://immich.example.com/share/abc123",
|
||||
"old_album_name": "Old Album",
|
||||
"new_album_name": "New Album",
|
||||
"change_type": "assets_added",
|
||||
"added_count": 3,
|
||||
"removed_count": 1,
|
||||
"added_assets": [_SAMPLE_ASSET, _SAMPLE_VIDEO_ASSET],
|
||||
"removed_assets": ["asset-id-1", "asset-id-2"],
|
||||
"people": ["Alice", "Bob"],
|
||||
"shared": True,
|
||||
"target_type": "telegram",
|
||||
"has_videos": True,
|
||||
"has_photos": True,
|
||||
# Rename fields (always present, empty for non-rename events)
|
||||
"old_name": "Old Album",
|
||||
"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}],
|
||||
"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",
|
||||
}
|
||||
|
||||
|
||||
class TemplateConfigCreate(BaseModel):
|
||||
provider_type: str
|
||||
@@ -335,6 +246,32 @@ async def preview_config(
|
||||
raise HTTPException(status_code=400, detail=f"Template error: {e}")
|
||||
|
||||
|
||||
class DateFormatPreviewRequest(BaseModel):
|
||||
date_format: str = "%d.%m.%Y, %H:%M UTC"
|
||||
date_only_format: str = "%d.%m.%Y"
|
||||
|
||||
|
||||
@router.post("/preview-date-format")
|
||||
async def preview_date_format(
|
||||
body: DateFormatPreviewRequest,
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Preview what date/datetime formats look like with sample data."""
|
||||
from datetime import datetime, timezone
|
||||
sample_dt = datetime(2026, 3, 19, 14, 30, 0, tzinfo=timezone.utc)
|
||||
sample_date = datetime(2026, 3, 19)
|
||||
result: dict[str, str | None] = {}
|
||||
for key, fmt, sample in [
|
||||
("date_format", body.date_format, sample_dt),
|
||||
("date_only_format", body.date_only_format, sample_date),
|
||||
]:
|
||||
try:
|
||||
result[key] = sample.strftime(fmt)
|
||||
except (ValueError, TypeError):
|
||||
result[key] = None
|
||||
return result
|
||||
|
||||
|
||||
class PreviewRequest(BaseModel):
|
||||
template: str
|
||||
target_type: str = "telegram" # "telegram" or "webhook"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Tracker-Target link management API routes."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
@@ -18,6 +19,9 @@ from ..database.models import (
|
||||
TrackingConfig,
|
||||
User,
|
||||
)
|
||||
from ..services.notifier import send_real_data_notification, send_test_notification
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/trackers/{tracker_id}/targets", tags=["tracker-targets"])
|
||||
|
||||
@@ -176,7 +180,6 @@ async def test_tracker_target(
|
||||
raise HTTPException(status_code=404, detail="Target not found")
|
||||
|
||||
if test_type == "basic":
|
||||
from ..services.notifier import send_test_notification
|
||||
r = await send_test_notification(target, locale=locale)
|
||||
return {"target": target.name, **r}
|
||||
|
||||
@@ -199,8 +202,14 @@ async def test_tracker_target(
|
||||
provider_config = dict(provider.config)
|
||||
collection_ids = list(tracker.collection_ids or [])
|
||||
|
||||
# Load tracking config to get memory_source
|
||||
memory_source = "albums"
|
||||
if tt.tracking_config_id:
|
||||
tracking_config = await session.get(TrackingConfig, tt.tracking_config_id)
|
||||
if tracking_config:
|
||||
memory_source = tracking_config.memory_source or "albums"
|
||||
|
||||
# Fetch real data from provider
|
||||
from ..services.notifier import send_real_data_notification
|
||||
r = await send_real_data_notification(
|
||||
target=target,
|
||||
template_str=template_str,
|
||||
@@ -209,7 +218,8 @@ async def test_tracker_target(
|
||||
provider_config=provider_config,
|
||||
collection_ids=collection_ids,
|
||||
date_format=template_config.date_format if template_config else "%d.%m.%Y, %H:%M UTC",
|
||||
date_only_format=template_config.date_only_format if template_config and hasattr(template_config, "date_only_format") else "%d.%m.%Y",
|
||||
date_only_format=template_config.date_only_format if template_config and template_config.date_only_format else "%d.%m.%Y",
|
||||
memory_source=memory_source,
|
||||
)
|
||||
return {"target": target.name, **r}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Tracker management API routes."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import select
|
||||
@@ -9,13 +11,17 @@ from ..auth.dependencies import get_current_user
|
||||
from ..database.engine import get_session
|
||||
from ..database.models import (
|
||||
EventLog,
|
||||
NotificationTarget,
|
||||
ServiceProvider,
|
||||
Tracker,
|
||||
TrackerState,
|
||||
TrackerTarget,
|
||||
User,
|
||||
)
|
||||
from ..services.scheduler import schedule_tracker, unschedule_tracker
|
||||
from ..services.watcher import check_tracker
|
||||
from .tracker_targets import _tt_response
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/trackers", tags=["trackers"])
|
||||
|
||||
@@ -66,7 +72,6 @@ async def create_tracker(
|
||||
await session.commit()
|
||||
await session.refresh(tracker)
|
||||
if tracker.enabled:
|
||||
from ..services.scheduler import schedule_tracker
|
||||
await schedule_tracker(tracker.id, tracker.scan_interval)
|
||||
return await _tracker_response(session, tracker)
|
||||
|
||||
@@ -94,7 +99,6 @@ async def update_tracker(
|
||||
session.add(tracker)
|
||||
await session.commit()
|
||||
await session.refresh(tracker)
|
||||
from ..services.scheduler import schedule_tracker, unschedule_tracker
|
||||
if tracker.enabled:
|
||||
await schedule_tracker(tracker.id, tracker.scan_interval)
|
||||
else:
|
||||
@@ -130,7 +134,6 @@ async def delete_tracker(
|
||||
session.add(el)
|
||||
await session.delete(tracker)
|
||||
await session.commit()
|
||||
from ..services.scheduler import unschedule_tracker
|
||||
await unschedule_tracker(tracker_id)
|
||||
|
||||
|
||||
@@ -141,71 +144,10 @@ async def trigger_tracker(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
tracker = await _get_user_tracker(session, tracker_id, user.id)
|
||||
from ..services.watcher import check_tracker
|
||||
result = await check_tracker(tracker.id)
|
||||
return {"triggered": True, "result": result}
|
||||
|
||||
|
||||
@router.post("/{tracker_id}/test-periodic")
|
||||
async def test_periodic(
|
||||
tracker_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Send a test periodic summary notification using actual templates."""
|
||||
from ..services.notifier import send_test_template_notification
|
||||
from ..database.models import TemplateConfig
|
||||
tracker = await _get_user_tracker(session, tracker_id, user.id)
|
||||
result = await session.exec(
|
||||
select(TrackerTarget).where(
|
||||
TrackerTarget.tracker_id == tracker.id,
|
||||
TrackerTarget.enabled == True,
|
||||
)
|
||||
)
|
||||
results = []
|
||||
for tt in result.all():
|
||||
target = await session.get(NotificationTarget, tt.target_id)
|
||||
if not target:
|
||||
continue
|
||||
template_config = None
|
||||
if tt.template_config_id:
|
||||
template_config = await session.get(TemplateConfig, tt.template_config_id)
|
||||
template_str = (template_config.periodic_summary_message if template_config else "") or ""
|
||||
r = await send_test_template_notification(target, "periodic_summary", template_str)
|
||||
results.append({"target": target.name, **r})
|
||||
return {"test": "periodic_summary", "results": results}
|
||||
|
||||
|
||||
@router.post("/{tracker_id}/test-memory")
|
||||
async def test_memory(
|
||||
tracker_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Send a test memory/on-this-day notification using actual templates."""
|
||||
from ..services.notifier import send_test_template_notification
|
||||
from ..database.models import TemplateConfig
|
||||
tracker = await _get_user_tracker(session, tracker_id, user.id)
|
||||
result = await session.exec(
|
||||
select(TrackerTarget).where(
|
||||
TrackerTarget.tracker_id == tracker.id,
|
||||
TrackerTarget.enabled == True,
|
||||
)
|
||||
)
|
||||
results = []
|
||||
for tt in result.all():
|
||||
target = await session.get(NotificationTarget, tt.target_id)
|
||||
if not target:
|
||||
continue
|
||||
template_config = None
|
||||
if tt.template_config_id:
|
||||
template_config = await session.get(TemplateConfig, tt.template_config_id)
|
||||
template_str = (template_config.memory_mode_message if template_config else "") or ""
|
||||
r = await send_test_template_notification(target, "memory_mode", template_str)
|
||||
results.append({"target": target.name, **r})
|
||||
return {"test": "memory_mode", "results": results}
|
||||
|
||||
|
||||
@router.get("/{tracker_id}/history")
|
||||
async def tracker_history(
|
||||
tracker_id: int,
|
||||
@@ -238,23 +180,7 @@ async def _tracker_response(session: AsyncSession, t: Tracker) -> dict:
|
||||
result = await session.exec(
|
||||
select(TrackerTarget).where(TrackerTarget.tracker_id == t.id)
|
||||
)
|
||||
tracker_targets = []
|
||||
for tt in result.all():
|
||||
target = await session.get(NotificationTarget, tt.target_id)
|
||||
tracker_targets.append({
|
||||
"id": tt.id,
|
||||
"target_id": tt.target_id,
|
||||
"target_name": target.name if target else None,
|
||||
"target_type": target.type if target else None,
|
||||
"target_icon": target.icon if target else None,
|
||||
"tracking_config_id": tt.tracking_config_id,
|
||||
"template_config_id": tt.template_config_id,
|
||||
"enabled": tt.enabled,
|
||||
"quiet_hours_start": tt.quiet_hours_start,
|
||||
"quiet_hours_end": tt.quiet_hours_end,
|
||||
"commands_config": tt.commands_config,
|
||||
"created_at": tt.created_at.isoformat(),
|
||||
})
|
||||
tracker_targets = [await _tt_response(session, tt) for tt in result.all()]
|
||||
|
||||
return {
|
||||
"id": t.id,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Tracking configuration CRUD API routes."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import select
|
||||
@@ -9,6 +11,8 @@ from ..auth.dependencies import get_current_user
|
||||
from ..database.engine import get_session
|
||||
from ..database.models import TrackingConfig, User
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/tracking-configs", tags=["tracking-configs"])
|
||||
|
||||
|
||||
@@ -43,6 +47,7 @@ class TrackingConfigCreate(BaseModel):
|
||||
scheduled_order_by: str = "random"
|
||||
scheduled_order: str = "descending"
|
||||
memory_enabled: bool = False
|
||||
memory_source: str = "albums"
|
||||
memory_times: str = "09:00"
|
||||
memory_collection_mode: str = "combined"
|
||||
memory_limit: int = 10
|
||||
@@ -81,6 +86,7 @@ class TrackingConfigUpdate(BaseModel):
|
||||
scheduled_order_by: str | None = None
|
||||
scheduled_order: str | None = None
|
||||
memory_enabled: bool | None = None
|
||||
memory_source: str | None = None
|
||||
memory_times: str | None = None
|
||||
memory_collection_mode: str | None = None
|
||||
memory_limit: int | None = None
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""User management API routes (admin only)."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import select
|
||||
@@ -11,6 +13,8 @@ from ..auth.dependencies import require_admin
|
||||
from ..database.engine import get_session
|
||||
from ..database.models import User
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/users", tags=["users"])
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user