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
@@ -17,6 +17,8 @@ from ..database.engine import get_engine
from ..services import make_immich_provider
from ..database.models import (
CommandConfig,
CommandTemplateConfig,
CommandTemplateSlot,
CommandTracker,
CommandTrackerListener,
EventLog,
@@ -51,6 +53,23 @@ def _check_rate_limit(bot_id: int, chat_id: str, cmd: str, limits: dict[str, int
return None
def _render_cmd_template(
templates: dict[str, str], slot_name: str, context: dict[str, Any]
) -> str | None:
"""Try to render a command template. Returns None if no template or error."""
template_str = templates.get(slot_name)
if not template_str:
return None
try:
from jinja2.sandbox import SandboxedEnvironment
env = SandboxedEnvironment(autoescape=False)
tmpl = env.from_string(template_str)
return tmpl.render(**context)
except Exception as e:
_LOGGER.warning("Failed to render command template '%s': %s", slot_name, e)
return None
async def _resolve_command_context(
bot: TelegramBot,
) -> list[tuple[CommandTracker, CommandConfig, ServiceProvider]]:
@@ -87,7 +106,20 @@ async def _resolve_command_context(
continue
tuples.append((tracker, config, provider))
return tuples
# Load command template slots from the first config that has one
cmd_template_slots: dict[str, str] = {}
for _, config, _ in tuples:
if config.command_template_config_id:
slot_result = await session.exec(
select(CommandTemplateSlot).where(
CommandTemplateSlot.config_id == config.command_template_config_id
)
)
cmd_template_slots = {s.slot_name: s.template for s in slot_result.all()}
if cmd_template_slots:
break
return tuples, cmd_template_slots
def _merge_command_context(
@@ -125,10 +157,13 @@ async def handle_command(
if not cmd:
return None
ctx = await _resolve_command_context(bot)
enabled, locale, response_mode, default_count, rate_limits = _merge_command_context(ctx)
ctx_tuples, cmd_templates = await _resolve_command_context(bot)
enabled, locale, response_mode, default_count, rate_limits = _merge_command_context(ctx_tuples)
if cmd == "start":
result = _render_cmd_template(cmd_templates, "start", {"locale": locale, "bot_name": bot.name})
if result:
return result
msgs = {
"en": "Hi! I'm your Notify Bridge bot. Use /help to see available commands.",
"ru": "Привет! Я бот Notify Bridge. Используйте /help для списка команд.",
@@ -141,6 +176,9 @@ async def handle_command(
# Rate limit check
wait = _check_rate_limit(bot.id, chat_id, cmd, rate_limits)
if wait is not None:
result = _render_cmd_template(cmd_templates, "rate_limited", {"wait": wait, "locale": locale})
if result:
return result
msgs = {
"en": f"Please wait {wait}s before using this command again.",
"ru": f"Подождите {wait} сек. перед повторным использованием.",
@@ -151,7 +189,7 @@ async def handle_command(
# Build providers map from command context
providers_map: dict[int, ServiceProvider] = {}
for _, _, provider in ctx:
for _, _, provider in ctx_tuples:
providers_map[provider.id] = provider
# Dispatch