feat: provider-strict configs, slot-based templates, broadcast targets, email bots, command templates

Major architectural improvements:
- Provider-type enforcement: configs validated against provider type at assignment
- TemplateConfig migrated to slot-based pattern (TemplateSlot child table)
- Broadcast targets: TargetReceiver child table for multi-receiver dispatch
- EmailBot: first-class email sender entity with SMTP config, test connection
- CommandTemplateConfig: generic slot-based command response templates
- Provider capability registry: dynamic slot/event/command definitions per provider
- CommandTracker play/pause button matches NotificationTracker style
This commit is contained in:
2026-03-21 16:33:24 +03:00
parent 371ea70756
commit 846d480d38
27 changed files with 2355 additions and 205 deletions
+1
View File
@@ -10,6 +10,7 @@ requires-python = ">=3.12"
dependencies = [
"aiohttp>=3.9",
"jinja2>=3.1",
"aiosmtplib>=3.0",
]
[project.optional-dependencies]
@@ -3,7 +3,7 @@
from __future__ import annotations
import logging
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Any
import aiohttp
@@ -25,14 +25,16 @@ DEFAULT_TEMPLATE = '{{ event_type }}: "{{ collection_name }}"'
class TargetConfig:
"""Configuration for a notification target."""
type: str # "telegram" or "webhook"
config: dict[str, Any] # type-specific config
type: str # "telegram", "webhook", or "email"
config: dict[str, Any] # target-level config (bot_token, settings, etc.)
template_slots: dict[str, str] | None = None # event_type -> template string
date_format: str = "%d.%m.%Y, %H:%M UTC"
date_only_format: str = "%d.%m.%Y"
provider_api_key: str | None = None # API key for downloading assets from provider
provider_internal_url: str | None = None # Internal provider URL for API key scoping
provider_external_url: str | None = None # External domain for API key scoping
# Broadcast receivers — if non-empty, sends to each receiver instead of config
receivers: list[dict[str, Any]] = field(default_factory=list)
class NotificationDispatcher:
@@ -69,7 +71,7 @@ class NotificationDispatcher:
async def _send_to_target(
self, event: ServiceEvent, target: TargetConfig
) -> dict[str, Any]:
"""Send event to a single target."""
"""Send event to a single target (potentially multiple receivers)."""
# Select template
template_str = DEFAULT_TEMPLATE
if target.template_slots:
@@ -77,7 +79,7 @@ class NotificationDispatcher:
if slot:
template_str = slot
# Build context and render
# Build context and render ONCE
ctx = build_template_context(
event, target_type=target.type,
date_format=target.date_format,
@@ -89,13 +91,14 @@ class NotificationDispatcher:
return await self._send_telegram(target, message, event)
elif target.type == "webhook":
return await self._send_webhook(target, message, event)
elif target.type == "email":
return await self._send_email(target, message, event)
return {"success": False, "error": f"Unknown target type: {target.type}"}
async def _send_telegram(
self, target: TargetConfig, message: str, event: ServiceEvent
) -> dict[str, Any]:
bot_token = target.config.get("bot_token")
chat_id = target.config.get("chat_id")
disable_preview = target.config.get("disable_url_preview", False)
max_media = target.config.get("max_media_to_send", 50)
max_group = target.config.get("max_media_per_group", 10)
@@ -105,9 +108,29 @@ class NotificationDispatcher:
max_size = max_size * 1024 * 1024 # MB to bytes
send_large_as_docs = target.config.get("send_large_photos_as_documents", False)
if not bot_token or not chat_id:
return {"success": False, "error": "Missing bot_token or chat_id"}
if not bot_token:
return {"success": False, "error": "Missing bot_token"}
# Resolve receivers — broadcast to each, or fall back to legacy chat_id in config
receivers = target.receivers or [{"chat_id": target.config.get("chat_id")}]
# Prepare assets list once (shared across receivers)
provider_urls = []
if target.provider_internal_url:
provider_urls.append(target.provider_internal_url)
if target.provider_external_url:
provider_urls.append(target.provider_external_url)
assets = []
for asset in event.added_assets[:max_media]:
url = asset.full_url or asset.thumbnail_url
if url:
asset_type = "video" if asset.type.value == "video" else "photo"
asset_headers = {}
if target.provider_api_key and any(url.startswith(u) for u in provider_urls):
asset_headers["x-api-key"] = target.provider_api_key
assets.append({"url": url, "type": asset_type, "headers": asset_headers})
results: list[dict[str, Any]] = []
async with aiohttp.ClientSession() as session:
client = TelegramClient(
session, bot_token,
@@ -115,55 +138,55 @@ class NotificationDispatcher:
asset_cache=self._asset_cache,
)
# Step 1: Send the text message first
text_result = await client.send_message(
chat_id=str(chat_id),
text=message,
disable_web_page_preview=bool(disable_preview),
)
if not text_result.get("success"):
return text_result
for receiver in receivers:
chat_id = receiver.get("chat_id")
if not chat_id:
results.append({"success": False, "error": "Missing chat_id in receiver"})
continue
# Step 2: Send assets as reply to the text message
provider_urls = []
if target.provider_internal_url:
provider_urls.append(target.provider_internal_url)
if target.provider_external_url:
provider_urls.append(target.provider_external_url)
assets = []
for asset in event.added_assets[:max_media]:
url = asset.full_url or asset.thumbnail_url
if url:
asset_type = "video" if asset.type.value == "video" else "photo"
asset_headers = {}
if target.provider_api_key and any(url.startswith(u) for u in provider_urls):
asset_headers["x-api-key"] = target.provider_api_key
assets.append({"url": url, "type": asset_type, "headers": asset_headers})
if assets:
reply_to = text_result.get("message_id")
media_result = await client.send_notification(
# Step 1: Send the text message
text_result = await client.send_message(
chat_id=str(chat_id),
assets=assets,
reply_to_message_id=reply_to,
max_group_size=max_group,
chunk_delay=chunk_delay,
max_asset_data_size=max_size,
send_large_photos_as_documents=send_large_as_docs,
text=message,
disable_web_page_preview=bool(disable_preview),
)
if not media_result.get("success"):
_LOGGER.warning("Text sent OK but media failed: %s", media_result.get("error"))
if not text_result.get("success"):
_LOGGER.warning("Failed to send to chat %s: %s", chat_id, text_result.get("error"))
results.append(text_result)
continue
return text_result
# Step 2: Send assets as reply
if assets:
reply_to = text_result.get("message_id")
media_result = await client.send_notification(
chat_id=str(chat_id),
assets=assets,
reply_to_message_id=reply_to,
max_group_size=max_group,
chunk_delay=chunk_delay,
max_asset_data_size=max_size,
send_large_photos_as_documents=send_large_as_docs,
)
if not media_result.get("success"):
_LOGGER.warning("Text sent OK but media failed for chat %s: %s", chat_id, media_result.get("error"))
results.append(text_result)
# Return aggregate result
successes = sum(1 for r in results if r.get("success"))
if successes == len(results):
return {"success": True, "receivers": len(results)}
elif successes > 0:
return {"success": True, "receivers": len(results), "partial_failures": len(results) - successes}
elif results:
return results[0] # All failed — return first error
return {"success": False, "error": "No receivers configured"}
async def _send_webhook(
self, target: TargetConfig, message: str, event: ServiceEvent
) -> dict[str, Any]:
url = target.config.get("url")
headers = target.config.get("headers", {})
if not url:
return {"success": False, "error": "Missing url in target config"}
# Resolve receivers — broadcast to each, or fall back to legacy url in config
receivers = target.receivers or [{"url": target.config.get("url"), "headers": target.config.get("headers", {})}]
payload = {
"message": message,
@@ -174,6 +197,68 @@ class NotificationDispatcher:
"timestamp": event.timestamp.isoformat(),
}
results: list[dict[str, Any]] = []
async with aiohttp.ClientSession() as session:
client = WebhookClient(session, url, headers)
return await client.send(payload)
for receiver in receivers:
url = receiver.get("url")
headers = receiver.get("headers", {})
if not url:
results.append({"success": False, "error": "Missing url in receiver"})
continue
client = WebhookClient(session, url, headers)
results.append(await client.send(payload))
successes = sum(1 for r in results if r.get("success"))
if successes == len(results):
return {"success": True, "receivers": len(results)}
elif successes > 0:
return {"success": True, "receivers": len(results), "partial_failures": len(results) - successes}
elif results:
return results[0]
return {"success": False, "error": "No receivers configured"}
async def _send_email(
self, target: TargetConfig, message: str, event: ServiceEvent
) -> dict[str, Any]:
from .email.client import EmailClient, SmtpConfig
smtp_cfg = target.config.get("smtp", {})
if not smtp_cfg.get("host"):
return {"success": False, "error": "SMTP not configured"}
client = EmailClient(SmtpConfig(
host=smtp_cfg["host"],
port=int(smtp_cfg.get("port", 587)),
username=smtp_cfg.get("username", ""),
password=smtp_cfg.get("password", ""),
from_address=smtp_cfg.get("from_address", ""),
from_name=smtp_cfg.get("from_name", "Notify Bridge"),
use_tls=smtp_cfg.get("use_tls", True),
))
# Resolve receivers
receivers = target.receivers or [{"email": target.config.get("email", "")}]
subject = f"[Notify Bridge] {event.event_type.value}: {event.collection_name}"
results: list[dict[str, Any]] = []
for receiver in receivers:
email = receiver.get("email")
if not email:
results.append({"success": False, "error": "Missing email in receiver"})
continue
result = await client.send(
to_email=email,
subject=subject,
body_text=message,
to_name=receiver.get("name", ""),
)
results.append(result)
successes = sum(1 for r in results if r.get("success"))
if successes == len(results) and results:
return {"success": True, "receivers": len(results)}
elif successes > 0:
return {"success": True, "receivers": len(results), "partial_failures": len(results) - successes}
elif results:
return results[0]
return {"success": False, "error": "No receivers configured"}
@@ -0,0 +1,5 @@
"""Email notification client."""
from .client import EmailClient
__all__ = ["EmailClient"]
@@ -0,0 +1,76 @@
"""Async email client using aiosmtplib."""
from __future__ import annotations
import logging
from dataclasses import dataclass
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from typing import Any
_LOGGER = logging.getLogger(__name__)
@dataclass
class SmtpConfig:
"""SMTP connection settings."""
host: str
port: int = 587
username: str = ""
password: str = ""
from_address: str = ""
from_name: str = "Notify Bridge"
use_tls: bool = True
class EmailClient:
"""Sends email notifications via SMTP."""
def __init__(self, smtp_config: SmtpConfig) -> None:
self._config = smtp_config
async def send(
self,
to_email: str,
subject: str,
body_text: str,
body_html: str | None = None,
to_name: str = "",
) -> dict[str, Any]:
"""Send an email. Returns {"success": True} or {"success": False, "error": "..."}."""
try:
import aiosmtplib
except ImportError:
return {"success": False, "error": "aiosmtplib not installed. Run: pip install aiosmtplib"}
cfg = self._config
if not cfg.host or not cfg.from_address:
return {"success": False, "error": "SMTP not configured (missing host or from_address)"}
# Build email message
msg = MIMEMultipart("alternative")
msg["From"] = f"{cfg.from_name} <{cfg.from_address}>" if cfg.from_name else cfg.from_address
msg["To"] = f"{to_name} <{to_email}>" if to_name else to_email
msg["Subject"] = subject
msg.attach(MIMEText(body_text, "plain", "utf-8"))
if body_html:
msg.attach(MIMEText(body_html, "html", "utf-8"))
try:
await aiosmtplib.send(
msg,
hostname=cfg.host,
port=cfg.port,
username=cfg.username or None,
password=cfg.password or None,
use_tls=cfg.use_tls,
start_tls=not cfg.use_tls and cfg.port != 25,
)
_LOGGER.info("Email sent to %s", to_email)
return {"success": True}
except Exception as e:
_LOGGER.error("Failed to send email to %s: %s", to_email, e)
return {"success": False, "error": str(e)}
@@ -0,0 +1,107 @@
"""Provider capability registry.
Defines what events, template slots, commands, and variables each provider type supports.
Used by the frontend to dynamically show relevant UI elements.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
@dataclass
class ProviderCapabilities:
"""What a provider type supports."""
provider_type: str
display_name: str
# Notification template slots (used in TemplateConfig)
notification_slots: list[dict[str, str]] = field(default_factory=list)
# Command template slots (used in CommandTemplateConfig)
command_slots: list[dict[str, str]] = field(default_factory=list)
# Events the provider can generate
events: list[dict[str, str]] = field(default_factory=list)
# Commands the provider supports
commands: list[dict[str, str]] = field(default_factory=list)
# ---------------------------------------------------------------------------
# Immich provider capabilities
# ---------------------------------------------------------------------------
IMMICH_CAPABILITIES = ProviderCapabilities(
provider_type="immich",
display_name="Immich",
notification_slots=[
{"name": "message_assets_added", "description": "New assets added to album"},
{"name": "message_assets_removed", "description": "Assets removed from album"},
{"name": "message_collection_renamed", "description": "Album renamed"},
{"name": "message_collection_deleted", "description": "Album deleted"},
{"name": "message_sharing_changed", "description": "Sharing status changed"},
{"name": "periodic_summary_message", "description": "Periodic album summary"},
{"name": "scheduled_assets_message", "description": "Scheduled asset delivery"},
{"name": "memory_mode_message", "description": "On This Day memories"},
],
command_slots=[
{"name": "start", "description": "/start greeting message"},
{"name": "help", "description": "/help command listing"},
{"name": "status", "description": "/status tracker summary"},
{"name": "albums", "description": "/albums tracked albums list"},
{"name": "events", "description": "/events recent events"},
{"name": "people", "description": "/people detected people"},
{"name": "search", "description": "/search results (also /find, /person, /place)"},
{"name": "latest", "description": "/latest recent photos"},
{"name": "favorites", "description": "/favorites starred items"},
{"name": "random", "description": "/random random photos"},
{"name": "summary", "description": "/summary album summary"},
{"name": "memory", "description": "/memory On This Day photos"},
{"name": "rate_limited", "description": "Rate limit warning message"},
{"name": "no_results", "description": "Empty results fallback"},
],
events=[
{"name": "assets_added", "description": "New assets detected in album"},
{"name": "assets_removed", "description": "Assets removed from album"},
{"name": "collection_renamed", "description": "Album was renamed"},
{"name": "collection_deleted", "description": "Album was deleted"},
{"name": "sharing_changed", "description": "Album sharing status changed"},
],
commands=[
{"name": "status", "description": "Show tracker status"},
{"name": "albums", "description": "List tracked albums"},
{"name": "events", "description": "Show recent events"},
{"name": "summary", "description": "Send album summary"},
{"name": "latest", "description": "Show latest photos"},
{"name": "memory", "description": "On This Day memories"},
{"name": "random", "description": "Random photos"},
{"name": "search", "description": "Search assets"},
{"name": "find", "description": "Find assets by name"},
{"name": "person", "description": "Find photos by person"},
{"name": "place", "description": "Find photos by location"},
{"name": "favorites", "description": "Show favorites"},
{"name": "people", "description": "List detected people"},
{"name": "help", "description": "Show commands"},
],
)
# ---------------------------------------------------------------------------
# Registry
# ---------------------------------------------------------------------------
_REGISTRY: dict[str, ProviderCapabilities] = {
"immich": IMMICH_CAPABILITIES,
}
def get_capabilities(provider_type: str) -> ProviderCapabilities | None:
"""Get capabilities for a provider type."""
return _REGISTRY.get(provider_type)
def get_all_capabilities() -> dict[str, ProviderCapabilities]:
"""Get all registered provider capabilities."""
return dict(_REGISTRY)