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