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
@@ -6,7 +6,7 @@ from datetime import datetime, timezone
from typing import Any
from uuid import uuid4
from sqlalchemy import UniqueConstraint
from sqlalchemy import UniqueConstraint, Text
from sqlmodel import JSON, Column, Field, SQLModel
@@ -53,6 +53,24 @@ class TelegramBot(SQLModel, table=True):
created_at: datetime = Field(default_factory=_utcnow)
class EmailBot(SQLModel, table=True):
"""Email sender — SMTP connection for sending email notifications."""
__tablename__ = "email_bot"
id: int | None = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="user.id")
name: str
icon: str = Field(default="")
email: str # From address
smtp_host: str
smtp_port: int = Field(default=587)
smtp_username: str = Field(default="")
smtp_password: str = Field(default="")
smtp_use_tls: bool = Field(default=True)
created_at: datetime = Field(default_factory=_utcnow)
class TelegramChat(SQLModel, table=True):
__tablename__ = "telegram_chat"
@@ -124,7 +142,10 @@ class TrackingConfig(SQLModel, table=True):
class TemplateConfig(SQLModel, table=True):
"""Jinja2 message templates. Tied to a provider type."""
"""Jinja2 message templates. Tied to a provider type.
Template content is stored in TemplateSlot child rows (one per slot).
"""
__tablename__ = "template_config"
@@ -135,32 +156,41 @@ class TemplateConfig(SQLModel, table=True):
description: str = Field(default="")
icon: str = Field(default="")
# Event-driven notification templates
message_assets_added: str = Field(default="")
message_assets_removed: str = Field(default="")
message_collection_renamed: str = Field(default="")
message_collection_deleted: str = Field(default="")
message_sharing_changed: str = Field(default="")
# Scheduled notification templates
periodic_summary_message: str = Field(default="")
scheduled_assets_message: str = Field(default="")
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)
class TemplateSlot(SQLModel, table=True):
"""One Jinja2 template for a specific slot within a TemplateConfig.
Slot names are provider-specific (e.g. 'message_assets_added' for Immich).
"""
__tablename__ = "template_slot"
__table_args__ = (
UniqueConstraint("config_id", "slot_name", name="uq_template_slot"),
)
id: int | None = Field(default=None, primary_key=True)
config_id: int = Field(foreign_key="template_config.id", index=True)
slot_name: str
template: str = Field(default="", sa_column=Column(Text, default=""))
class NotificationTarget(SQLModel, table=True):
"""Where to send notifications. Pure delivery endpoint."""
"""Where to send notifications. Pure delivery endpoint.
Target-level config holds connection/display settings (e.g. bot_token,
disable_url_preview). Actual delivery endpoints live in TargetReceiver rows.
"""
__tablename__ = "notification_target"
id: int | None = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="user.id")
type: str # "telegram" or "webhook"
type: str # "telegram", "webhook", or "email"
name: str
icon: str = Field(default="")
config: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
@@ -168,6 +198,28 @@ class NotificationTarget(SQLModel, table=True):
created_at: datetime = Field(default_factory=_utcnow)
class TargetReceiver(SQLModel, table=True):
"""One delivery endpoint within a NotificationTarget (broadcast support).
For Telegram: config = {"chat_id": "12345"}
For Webhook: config = {"url": "https://...", "headers": {...}}
For Email: config = {"email": "user@example.com", "name": "..."}
"""
__tablename__ = "target_receiver"
__table_args__ = (
UniqueConstraint("target_id", "receiver_key", name="uq_target_receiver"),
)
id: int | None = Field(default=None, primary_key=True)
target_id: int = Field(foreign_key="notification_target.id", index=True)
name: str = Field(default="")
config: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
receiver_key: str = Field(default="") # dedup key (e.g. chat_id, url, email)
enabled: bool = Field(default=True)
created_at: datetime = Field(default_factory=_utcnow)
class NotificationTracker(SQLModel, table=True):
"""Watches a provider's collections for changes."""
@@ -246,9 +298,43 @@ class CommandConfig(SQLModel, table=True):
response_mode: str = Field(default="media") # "media" or "text"
default_count: int = Field(default=5)
rate_limits: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
command_template_config_id: int | None = Field(
default=None, foreign_key="command_template_config.id"
)
created_at: datetime = Field(default_factory=_utcnow)
class CommandTemplateConfig(SQLModel, table=True):
"""Jinja2 templates for command responses. Provider-specific via slots."""
__tablename__ = "command_template_config"
id: int | None = Field(default=None, primary_key=True)
user_id: int = Field(default=0) # 0 = system-owned
provider_type: str
name: str
description: str = Field(default="")
icon: str = Field(default="")
created_at: datetime = Field(default_factory=_utcnow)
class CommandTemplateSlot(SQLModel, table=True):
"""One Jinja2 template for a specific command response slot.
Slot names match command names (e.g. 'status', 'help', 'albums').
"""
__tablename__ = "command_template_slot"
__table_args__ = (
UniqueConstraint("config_id", "slot_name", name="uq_command_template_slot"),
)
id: int | None = Field(default=None, primary_key=True)
config_id: int = Field(foreign_key="command_template_config.id", index=True)
slot_name: str
template: str = Field(default="", sa_column=Column(Text, default=""))
class CommandTracker(SQLModel, table=True):
"""Links a provider to a command config for interactive bot commands."""