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
@@ -105,6 +105,14 @@ async def migrate_schema(engine: AsyncEngine) -> None:
)
logger.info("Added update_mode column to telegram_bot table")
# Add command_template_config_id to command_config if missing
if await _has_table(conn, "command_config"):
if not await _has_column(conn, "command_config", "command_template_config_id"):
await conn.execute(
text("ALTER TABLE command_config ADD COLUMN command_template_config_id INTEGER")
)
logger.info("Added command_template_config_id column to command_config table")
# Add date_only_format to template_config if missing
if await _has_table(conn, "template_config"):
if not await _has_column(conn, "template_config", "date_only_format"):
@@ -537,3 +545,171 @@ async def migrate_entity_refactor(engine: AsyncEngine) -> None:
# or notification_tracker_target. SQLite doesn't support DROP COLUMN in
# all versions, and SQLModel will simply ignore columns not defined on
# the model class. The columns will remain in the DB but are unused.
# ---------------------------------------------------------------------------
# Template slot migration
# ---------------------------------------------------------------------------
# Old column names that existed on template_config before the slot refactor
_LEGACY_TEMPLATE_COLUMNS = [
"message_assets_added",
"message_assets_removed",
"message_collection_renamed",
"message_collection_deleted",
"message_sharing_changed",
"periodic_summary_message",
"scheduled_assets_message",
"memory_mode_message",
]
async def migrate_template_slots(engine: AsyncEngine) -> None:
"""Migrate legacy TemplateConfig column-based templates to TemplateSlot rows.
Reads the old per-column template values via raw SQL (since they're no longer
on the SQLModel class) and inserts them as TemplateSlot rows.
Idempotent: skips if template_slot table already has data or legacy columns
don't exist.
"""
async with engine.begin() as conn:
if not await _has_table(conn, "template_config"):
return
# Check if the legacy columns still exist in the DB
has_legacy = await _has_column(conn, "template_config", "message_assets_added")
if not has_legacy:
logger.debug("No legacy template columns found — skipping slot migration")
return
# Check if template_slot table exists and already has data
if await _has_table(conn, "template_slot"):
slot_count = (await conn.execute(text("SELECT COUNT(*) FROM template_slot"))).scalar()
if slot_count and slot_count > 0:
logger.debug("template_slot table already has %d rows — skipping migration", slot_count)
return
# Create template_slot table if it doesn't exist yet
# (SQLModel.metadata.create_all may have already created it, but be safe)
if not await _has_table(conn, "template_slot"):
await conn.execute(text(
"CREATE TABLE template_slot ("
" id INTEGER PRIMARY KEY,"
" config_id INTEGER NOT NULL REFERENCES template_config(id),"
" slot_name TEXT NOT NULL,"
" template TEXT DEFAULT '',"
" UNIQUE(config_id, slot_name)"
")"
))
logger.info("Created template_slot table")
# Read all template configs with their legacy column values
col_list = ", ".join(_LEGACY_TEMPLATE_COLUMNS)
rows = (await conn.execute(
text(f"SELECT id, {col_list} FROM template_config")
)).fetchall()
migrated = 0
for row in rows:
config_id = row[0]
for i, col_name in enumerate(_LEGACY_TEMPLATE_COLUMNS):
template_text = row[i + 1] or ""
if template_text.strip():
await conn.execute(
text(
"INSERT INTO template_slot (config_id, slot_name, template) "
"VALUES (:cid, :sn, :tmpl)"
),
{"cid": config_id, "sn": col_name, "tmpl": template_text},
)
migrated += 1
if migrated:
logger.info("Migrated %d template slots from legacy columns", migrated)
# ---------------------------------------------------------------------------
# Target receiver migration
# ---------------------------------------------------------------------------
async def migrate_target_receivers(engine: AsyncEngine) -> None:
"""Migrate single chat_id/url from NotificationTarget.config to TargetReceiver rows.
For each existing target that has a chat_id or url in its config JSON and
no receivers yet, creates a TargetReceiver row.
Idempotent: skips targets that already have receivers.
"""
async with engine.begin() as conn:
if not await _has_table(conn, "notification_target"):
return
# Create target_receiver table if it doesn't exist yet
if not await _has_table(conn, "target_receiver"):
await conn.execute(text(
"CREATE TABLE target_receiver ("
" id INTEGER PRIMARY KEY,"
" target_id INTEGER NOT NULL REFERENCES notification_target(id),"
" name TEXT DEFAULT '',"
" config TEXT DEFAULT '{}',"
" receiver_key TEXT DEFAULT '',"
" enabled INTEGER DEFAULT 1,"
" created_at TIMESTAMP,"
" UNIQUE(target_id, receiver_key)"
")"
))
logger.info("Created target_receiver table")
# Check if any receivers already exist
if await _has_table(conn, "target_receiver"):
recv_count = (await conn.execute(text("SELECT COUNT(*) FROM target_receiver"))).scalar()
if recv_count and recv_count > 0:
logger.debug("target_receiver already has %d rows — skipping migration", recv_count)
return
# Read all targets
targets = (await conn.execute(
text("SELECT id, type, config FROM notification_target")
)).fetchall()
migrated = 0
for row in targets:
target_id, target_type, raw_config = row[0], row[1], row[2]
try:
cfg = json.loads(raw_config) if isinstance(raw_config, str) else (raw_config or {})
except (json.JSONDecodeError, TypeError):
cfg = {}
receiver_key = ""
receiver_config = {}
receiver_name = ""
if target_type == "telegram":
chat_id = cfg.get("chat_id", "")
if chat_id:
receiver_key = str(chat_id)
receiver_config = {"chat_id": str(chat_id)}
receiver_name = f"Chat {chat_id}"
elif target_type == "webhook":
url = cfg.get("url", "")
if url:
receiver_key = url
receiver_config = {"url": url, "headers": cfg.get("headers", {})}
receiver_name = url[:50]
if receiver_key:
await conn.execute(
text(
"INSERT INTO target_receiver (target_id, name, config, receiver_key, enabled, created_at) "
"VALUES (:tid, :name, :cfg, :rk, 1, CURRENT_TIMESTAMP)"
),
{
"tid": target_id,
"name": receiver_name,
"cfg": json.dumps(receiver_config),
"rk": receiver_key,
},
)
migrated += 1
if migrated:
logger.info("Migrated %d target receivers from legacy config", migrated)