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:
@@ -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)
|
||||
Reference in New Issue
Block a user