feat: entity relationship refactor — notification trackers, command system, chat actions

Rework entity schema: rename Tracker→NotificationTracker, add CommandConfig/
CommandTracker/CommandTrackerListener entities for decoupled command handling.
Commands now resolve through CommandTracker→CommandConfig instead of
TelegramBot.commands_config. Smart ref-counted bot polling based on active
listeners. Add chat_action to telegram targets. Full frontend CRUD pages
for command configs and command trackers. Idempotent SQLite migrations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-21 01:27:20 +03:00
parent 0dcca2fbe6
commit 1d445f3980
34 changed files with 2777 additions and 582 deletions
@@ -1,8 +1,10 @@
"""Data migrations for schema changes.
Handles converting legacy JSON-array relationships to proper junction tables.
Handles converting legacy JSON-array relationships to proper junction tables,
and the Phase 1 entity refactor (tracker → notification_tracker, etc.).
"""
import json
import logging
from sqlalchemy import text
@@ -11,97 +13,133 @@ from sqlalchemy.ext.asyncio import AsyncEngine
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
async def _has_column(conn, table: str, column: str) -> bool:
"""Check if a column exists in a SQLite table."""
cols = await conn.run_sync(
lambda sync_conn: [
row[1]
for row in sync_conn.execute(
text(f"PRAGMA table_info('{table}')")
).fetchall()
]
)
return column in cols
async def _has_table(conn, table: str) -> bool:
"""Check if a table exists in the SQLite database."""
result = await conn.run_sync(
lambda sync_conn: sync_conn.execute(
text(
"SELECT name FROM sqlite_master "
"WHERE type='table' AND name=:name"
),
{"name": table},
).fetchone()
)
return result is not None
# ---------------------------------------------------------------------------
# Legacy schema migrations (pre-Phase 1)
# ---------------------------------------------------------------------------
async def migrate_schema(engine: AsyncEngine) -> None:
"""Add missing columns to existing tables (SQLite ALTER TABLE ADD COLUMN)."""
async with engine.begin() as conn:
# Helper to check if column exists
async def _has_column(table: str, column: str) -> bool:
cols = await conn.run_sync(
lambda sync_conn: [
row[1]
for row in sync_conn.execute(
text(f"PRAGMA table_info('{table}')")
).fetchall()
]
)
return column in cols
# --- Tracker table (may still be named "tracker" or already renamed) ---
tracker_table = "notification_tracker" if await _has_table(conn, "notification_tracker") else "tracker"
# Add batch_duration to tracker if missing
if not await _has_column("tracker", "batch_duration"):
await conn.execute(
text("ALTER TABLE tracker ADD COLUMN batch_duration INTEGER DEFAULT 0")
)
logger.info("Added batch_duration column to tracker table")
if await _has_table(conn, tracker_table):
if not await _has_column(conn, tracker_table, "batch_duration"):
await conn.execute(
text(f"ALTER TABLE {tracker_table} ADD COLUMN batch_duration INTEGER DEFAULT 0")
)
logger.info("Added batch_duration column to %s table", tracker_table)
# Add enriched fields to event_log if missing
for col, sql in [
("tracker_name", "ALTER TABLE event_log ADD COLUMN tracker_name TEXT DEFAULT ''"),
("provider_id", "ALTER TABLE event_log ADD COLUMN provider_id INTEGER"),
("provider_name", "ALTER TABLE event_log ADD COLUMN provider_name TEXT DEFAULT ''"),
("assets_count", "ALTER TABLE event_log ADD COLUMN assets_count INTEGER DEFAULT 0"),
]:
if not await _has_column("event_log", col):
await conn.execute(text(sql))
logger.info("Added %s column to event_log table", col)
if await _has_table(conn, "event_log"):
for col, sql in [
("tracker_name", "ALTER TABLE event_log ADD COLUMN tracker_name TEXT DEFAULT ''"),
("provider_id", "ALTER TABLE event_log ADD COLUMN provider_id INTEGER"),
("provider_name", "ALTER TABLE event_log ADD COLUMN provider_name TEXT DEFAULT ''"),
("assets_count", "ALTER TABLE event_log ADD COLUMN assets_count INTEGER DEFAULT 0"),
]:
if not await _has_column(conn, "event_log", col):
await conn.execute(text(sql))
logger.info("Added %s column to event_log table", col)
# Add commands_config to telegram_bot if missing
if not await _has_column("telegram_bot", "commands_config"):
await conn.execute(
text("ALTER TABLE telegram_bot ADD COLUMN commands_config TEXT DEFAULT '{}'")
)
logger.info("Added commands_config column to telegram_bot table")
# Add webhook_path_id to telegram_bot if missing
if not await _has_column("telegram_bot", "webhook_path_id"):
await conn.execute(
text("ALTER TABLE telegram_bot ADD COLUMN webhook_path_id TEXT DEFAULT ''")
)
logger.info("Added webhook_path_id column to telegram_bot table")
# Backfill existing bots with unique IDs
import uuid
bots = (await conn.execute(text("SELECT id FROM telegram_bot"))).fetchall()
for bot in bots:
if await _has_table(conn, "telegram_bot"):
if not await _has_column(conn, "telegram_bot", "commands_config"):
await conn.execute(
text("UPDATE telegram_bot SET webhook_path_id = :wid WHERE id = :bid"),
{"wid": uuid.uuid4().hex, "bid": bot[0]},
text("ALTER TABLE telegram_bot ADD COLUMN commands_config TEXT DEFAULT '{}'")
)
if bots:
logger.info("Backfilled webhook_path_id for %d existing bots", len(bots))
logger.info("Added commands_config column to telegram_bot table")
# Add webhook_path_id to telegram_bot if missing
if not await _has_column(conn, "telegram_bot", "webhook_path_id"):
await conn.execute(
text("ALTER TABLE telegram_bot ADD COLUMN webhook_path_id TEXT DEFAULT ''")
)
logger.info("Added webhook_path_id column to telegram_bot table")
# Backfill existing bots with unique IDs
import uuid
bots = (await conn.execute(text("SELECT id FROM telegram_bot"))).fetchall()
for bot in bots:
await conn.execute(
text("UPDATE telegram_bot SET webhook_path_id = :wid WHERE id = :bid"),
{"wid": uuid.uuid4().hex, "bid": bot[0]},
)
if bots:
logger.info("Backfilled webhook_path_id for %d existing bots", len(bots))
# Add update_mode to telegram_bot if missing
if not await _has_column(conn, "telegram_bot", "update_mode"):
await conn.execute(
text("ALTER TABLE telegram_bot ADD COLUMN update_mode TEXT DEFAULT 'polling'")
)
logger.info("Added update_mode column to telegram_bot table")
# Add date_only_format to template_config if missing
if not await _has_column("template_config", "date_only_format"):
await conn.execute(
text("ALTER TABLE template_config ADD COLUMN date_only_format TEXT DEFAULT '%d.%m.%Y'")
)
logger.info("Added date_only_format column to template_config table")
# Add update_mode to telegram_bot if missing
if not await _has_column("telegram_bot", "update_mode"):
await conn.execute(
text("ALTER TABLE telegram_bot ADD COLUMN update_mode TEXT DEFAULT 'polling'")
)
logger.info("Added update_mode column to telegram_bot table")
if await _has_table(conn, "template_config"):
if not await _has_column(conn, "template_config", "date_only_format"):
await conn.execute(
text("ALTER TABLE template_config ADD COLUMN date_only_format TEXT DEFAULT '%d.%m.%Y'")
)
logger.info("Added date_only_format column to template_config table")
# Add memory_source to tracking_config if missing
if not await _has_column("tracking_config", "memory_source"):
await conn.execute(
text("ALTER TABLE tracking_config ADD COLUMN memory_source TEXT DEFAULT 'albums'")
)
logger.info("Added memory_source column to tracking_config table")
if await _has_table(conn, "tracking_config"):
if not await _has_column(conn, "tracking_config", "memory_source"):
await conn.execute(
text("ALTER TABLE tracking_config ADD COLUMN memory_source TEXT DEFAULT 'albums'")
)
logger.info("Added memory_source column to tracking_config table")
# Add collection_name and shared to tracker_state if missing
if not await _has_column("tracker_state", "collection_name"):
await conn.execute(
text("ALTER TABLE tracker_state ADD COLUMN collection_name TEXT DEFAULT ''")
)
logger.info("Added collection_name column to tracker_state table")
if not await _has_column("tracker_state", "shared"):
await conn.execute(
text("ALTER TABLE tracker_state ADD COLUMN shared INTEGER DEFAULT 0")
)
logger.info("Added shared column to tracker_state table")
state_table = "notification_tracker_state" if await _has_table(conn, "notification_tracker_state") else "tracker_state"
if await _has_table(conn, state_table):
if not await _has_column(conn, state_table, "collection_name"):
await conn.execute(
text(f"ALTER TABLE {state_table} ADD COLUMN collection_name TEXT DEFAULT ''")
)
logger.info("Added collection_name column to %s table", state_table)
if not await _has_column(conn, state_table, "shared"):
await conn.execute(
text(f"ALTER TABLE {state_table} ADD COLUMN shared INTEGER DEFAULT 0")
)
logger.info("Added shared column to %s table", state_table)
# ---------------------------------------------------------------------------
# Legacy tracker_target migration (pre-Phase 1)
# ---------------------------------------------------------------------------
async def migrate_tracker_targets(engine: AsyncEngine) -> None:
"""Migrate legacy Tracker.target_ids JSON arrays to TrackerTarget rows.
@@ -114,36 +152,42 @@ async def migrate_tracker_targets(engine: AsyncEngine) -> None:
Idempotent: skips if legacy columns don't exist or data already migrated.
"""
async with engine.begin() as conn:
# Check if legacy target_ids column exists on tracker table
columns = await conn.run_sync(
lambda sync_conn: [
row[1]
for row in sync_conn.execute(
text("PRAGMA table_info('tracker')")
).fetchall()
]
)
if "target_ids" not in columns:
# Determine which table name exists (pre- or post-rename)
if await _has_table(conn, "tracker"):
tracker_table = "tracker"
tt_table = "tracker_target"
tracker_id_col = "tracker_id"
elif await _has_table(conn, "notification_tracker"):
tracker_table = "notification_tracker"
tt_table = "notification_tracker_target"
tracker_id_col = "notification_tracker_id"
else:
logger.debug("No tracker table found — skipping migration")
return
# Check if legacy target_ids column exists
if not await _has_column(conn, tracker_table, "target_ids"):
logger.debug("No legacy target_ids column found — skipping migration")
return
# Check if tracker_target table already has data (previous migration ran)
tt_count = (
await conn.execute(text("SELECT COUNT(*) FROM tracker_target"))
).scalar()
if tt_count and tt_count > 0:
logger.debug(
"tracker_target table already has %d rows — skipping migration",
tt_count,
)
return
# Check if junction table already has data
if await _has_table(conn, tt_table):
tt_count = (
await conn.execute(text(f"SELECT COUNT(*) FROM {tt_table}"))
).scalar()
if tt_count and tt_count > 0:
logger.debug(
"%s table already has %d rows — skipping migration",
tt_table, tt_count,
)
return
# Load legacy data
trackers = (
await conn.execute(
text(
"SELECT id, target_ids, tracking_config_id, "
"quiet_hours_start, quiet_hours_end FROM tracker"
f"SELECT id, target_ids, tracking_config_id, "
f"quiet_hours_start, quiet_hours_end FROM {tracker_table}"
)
)
).fetchall()
@@ -154,20 +198,10 @@ async def migrate_tracker_targets(engine: AsyncEngine) -> None:
# Load template_config_id from targets (legacy field)
target_template_map: dict[int, int | None] = {}
target_cols = await conn.run_sync(
lambda sync_conn: [
row[1]
for row in sync_conn.execute(
text("PRAGMA table_info('notification_target')")
).fetchall()
]
)
if "template_config_id" in target_cols:
if await _has_column(conn, "notification_target", "template_config_id"):
targets = (
await conn.execute(
text(
"SELECT id, template_config_id FROM notification_target"
)
text("SELECT id, template_config_id FROM notification_target")
)
).fetchall()
for t in targets:
@@ -175,15 +209,7 @@ async def migrate_tracker_targets(engine: AsyncEngine) -> None:
# Load commands_config from telegram_bots (legacy field)
bot_commands_map: dict[int, str | None] = {}
bot_cols = await conn.run_sync(
lambda sync_conn: [
row[1]
for row in sync_conn.execute(
text("PRAGMA table_info('telegram_bot')")
).fetchall()
]
)
if "commands_config" in bot_cols:
if await _has_column(conn, "telegram_bot", "commands_config"):
bots = (
await conn.execute(
text("SELECT id, commands_config FROM telegram_bot")
@@ -195,8 +221,6 @@ async def migrate_tracker_targets(engine: AsyncEngine) -> None:
# Build target → bot mapping for commands_config migration
target_bot_map: dict[int, int] = {}
if bot_commands_map:
import json
tgt_rows = (
await conn.execute(
text("SELECT id, config FROM notification_target WHERE type='telegram'")
@@ -207,35 +231,21 @@ async def migrate_tracker_targets(engine: AsyncEngine) -> None:
cfg = json.loads(tgt[1]) if isinstance(tgt[1], str) else tgt[1]
if cfg and "bot_token" in cfg:
for bot_id, _ in bot_commands_map.items():
bot_row = (
bot_token_row = (
await conn.execute(
text("SELECT id FROM telegram_bot WHERE id=:bid"),
text("SELECT token FROM telegram_bot WHERE id=:bid"),
{"bid": bot_id},
)
).fetchone()
if bot_row:
# Match by checking if this target uses this bot's token
bot_token_row = (
await conn.execute(
text(
"SELECT token FROM telegram_bot WHERE id=:bid"
),
{"bid": bot_id},
)
).fetchone()
if bot_token_row and bot_token_row[0] == cfg.get(
"bot_token"
):
target_bot_map[tgt[0]] = bot_id
if bot_token_row and bot_token_row[0] == cfg.get("bot_token"):
target_bot_map[tgt[0]] = bot_id
except Exception:
logger.warning(
"Failed to match bot token for target %s", tgt[0],
exc_info=True,
)
# Create TrackerTarget rows
import json
# Create junction rows
migrated = 0
for tracker in trackers:
tracker_id = tracker[0]
@@ -244,7 +254,6 @@ async def migrate_tracker_targets(engine: AsyncEngine) -> None:
quiet_hours_start = tracker[3]
quiet_hours_end = tracker[4]
# Parse target_ids JSON
if isinstance(raw_target_ids, str):
try:
target_ids = json.loads(raw_target_ids)
@@ -258,25 +267,22 @@ async def migrate_tracker_targets(engine: AsyncEngine) -> None:
for target_id in target_ids:
template_config_id = target_template_map.get(target_id)
# Get commands_config if this is a telegram target with a known bot
commands_config = None
if target_id in target_bot_map:
bot_id = target_bot_map[target_id]
raw_cmd = bot_commands_map.get(bot_id)
if raw_cmd:
commands_config = (
raw_cmd
if isinstance(raw_cmd, str)
else json.dumps(raw_cmd)
raw_cmd if isinstance(raw_cmd, str) else json.dumps(raw_cmd)
)
await conn.execute(
text(
"INSERT INTO tracker_target "
"(tracker_id, target_id, tracking_config_id, "
"template_config_id, enabled, quiet_hours_start, "
"quiet_hours_end, commands_config) "
"VALUES (:tid, :tgtid, :tcid, :tmplid, 1, :qhs, :qhe, :cmd)"
f"INSERT INTO {tt_table} "
f"({tracker_id_col}, target_id, tracking_config_id, "
f"template_config_id, enabled, quiet_hours_start, "
f"quiet_hours_end, commands_config) "
f"VALUES (:tid, :tgtid, :tcid, :tmplid, 1, :qhs, :qhe, :cmd)"
),
{
"tid": tracker_id,
@@ -291,3 +297,243 @@ async def migrate_tracker_targets(engine: AsyncEngine) -> None:
migrated += 1
logger.info("Migrated %d tracker-target links", migrated)
# ---------------------------------------------------------------------------
# Phase 1: Entity refactor migration
# ---------------------------------------------------------------------------
async def migrate_entity_refactor(engine: AsyncEngine) -> None:
"""Phase 1 entity refactor — rename tables, add columns, create new tables.
Fully idempotent: every operation checks preconditions before acting.
"""
async with engine.begin() as conn:
# ------------------------------------------------------------------
# 1. Rename table: tracker → notification_tracker
# ------------------------------------------------------------------
if await _has_table(conn, "tracker") and not await _has_table(conn, "notification_tracker"):
await conn.execute(text("ALTER TABLE tracker RENAME TO notification_tracker"))
logger.info("Renamed table tracker → notification_tracker")
# ------------------------------------------------------------------
# 2. Rename table: tracker_target → notification_tracker_target
# and rename column tracker_id → notification_tracker_id
# ------------------------------------------------------------------
if await _has_table(conn, "tracker_target") and not await _has_table(conn, "notification_tracker_target"):
# SQLite doesn't support RENAME COLUMN in older versions, so we
# recreate the table with the new column name.
await conn.execute(text(
"CREATE TABLE notification_tracker_target ("
" id INTEGER PRIMARY KEY,"
" notification_tracker_id INTEGER REFERENCES notification_tracker(id),"
" target_id INTEGER REFERENCES notification_target(id),"
" tracking_config_id INTEGER REFERENCES tracking_config(id),"
" template_config_id INTEGER REFERENCES template_config(id),"
" enabled INTEGER DEFAULT 1,"
" quiet_hours_start TEXT,"
" quiet_hours_end TEXT,"
" commands_config TEXT,"
" created_at TIMESTAMP"
")"
))
await conn.execute(text(
"INSERT INTO notification_tracker_target "
"(id, notification_tracker_id, target_id, tracking_config_id, "
"template_config_id, enabled, quiet_hours_start, quiet_hours_end, "
"commands_config, created_at) "
"SELECT id, tracker_id, target_id, tracking_config_id, "
"template_config_id, enabled, quiet_hours_start, quiet_hours_end, "
"commands_config, created_at "
"FROM tracker_target"
))
await conn.execute(text("DROP TABLE tracker_target"))
logger.info("Renamed table tracker_target → notification_tracker_target (with column rename tracker_id → notification_tracker_id)")
# ------------------------------------------------------------------
# 3. Rename table: tracker_state → notification_tracker_state
# and rename column tracker_id → notification_tracker_id
# ------------------------------------------------------------------
if await _has_table(conn, "tracker_state") and not await _has_table(conn, "notification_tracker_state"):
await conn.execute(text(
"CREATE TABLE notification_tracker_state ("
" id INTEGER PRIMARY KEY,"
" notification_tracker_id INTEGER REFERENCES notification_tracker(id),"
" collection_id TEXT,"
" collection_name TEXT DEFAULT '',"
" shared INTEGER DEFAULT 0,"
" asset_ids TEXT,"
" pending_asset_ids TEXT,"
" last_updated TIMESTAMP"
")"
))
await conn.execute(text(
"INSERT INTO notification_tracker_state "
"(id, notification_tracker_id, collection_id, collection_name, "
"shared, asset_ids, pending_asset_ids, last_updated) "
"SELECT id, tracker_id, collection_id, collection_name, "
"shared, asset_ids, pending_asset_ids, last_updated "
"FROM tracker_state"
))
await conn.execute(text("DROP TABLE tracker_state"))
logger.info("Renamed table tracker_state → notification_tracker_state (with column rename tracker_id → notification_tracker_id)")
# ------------------------------------------------------------------
# 4. Add chat_action column to notification_target
# ------------------------------------------------------------------
if await _has_table(conn, "notification_target"):
if not await _has_column(conn, "notification_target", "chat_action"):
await conn.execute(
text("ALTER TABLE notification_target ADD COLUMN chat_action TEXT")
)
logger.info("Added chat_action column to notification_target table")
# ------------------------------------------------------------------
# 5. Rename tracker_id → notification_tracker_id in event_log
# ------------------------------------------------------------------
if await _has_table(conn, "event_log"):
if await _has_column(conn, "event_log", "tracker_id") and not await _has_column(conn, "event_log", "notification_tracker_id"):
# Recreate event_log with renamed column
await conn.execute(text(
"CREATE TABLE event_log_new ("
" id INTEGER PRIMARY KEY,"
" notification_tracker_id INTEGER REFERENCES notification_tracker(id),"
" tracker_name TEXT DEFAULT '',"
" provider_id INTEGER,"
" provider_name TEXT DEFAULT '',"
" event_type TEXT,"
" collection_id TEXT,"
" collection_name TEXT,"
" assets_count INTEGER DEFAULT 0,"
" details TEXT,"
" created_at TIMESTAMP"
")"
))
await conn.execute(text(
"INSERT INTO event_log_new "
"(id, notification_tracker_id, tracker_name, provider_id, "
"provider_name, event_type, collection_id, collection_name, "
"assets_count, details, created_at) "
"SELECT id, tracker_id, tracker_name, provider_id, "
"provider_name, event_type, collection_id, collection_name, "
"assets_count, details, created_at "
"FROM event_log"
))
await conn.execute(text("DROP TABLE event_log"))
await conn.execute(text("ALTER TABLE event_log_new RENAME TO event_log"))
logger.info("Renamed column tracker_id → notification_tracker_id in event_log")
# ------------------------------------------------------------------
# 6. Create command_config table
# ------------------------------------------------------------------
if not await _has_table(conn, "command_config"):
await conn.execute(text(
"CREATE TABLE command_config ("
" id INTEGER PRIMARY KEY,"
" user_id INTEGER NOT NULL REFERENCES user(id),"
" provider_type TEXT NOT NULL,"
" name TEXT NOT NULL,"
" icon TEXT DEFAULT '',"
" enabled_commands TEXT DEFAULT '[]',"
" locale TEXT DEFAULT 'en',"
" response_mode TEXT DEFAULT 'media',"
" default_count INTEGER DEFAULT 5,"
" rate_limits TEXT DEFAULT '{}',"
" created_at TIMESTAMP"
")"
))
logger.info("Created command_config table")
# ------------------------------------------------------------------
# 7. Create command_tracker table
# ------------------------------------------------------------------
if not await _has_table(conn, "command_tracker"):
await conn.execute(text(
"CREATE TABLE command_tracker ("
" id INTEGER PRIMARY KEY,"
" user_id INTEGER NOT NULL REFERENCES user(id),"
" provider_id INTEGER NOT NULL REFERENCES service_provider(id),"
" command_config_id INTEGER NOT NULL REFERENCES command_config(id),"
" name TEXT NOT NULL,"
" icon TEXT DEFAULT '',"
" enabled INTEGER DEFAULT 1,"
" created_at TIMESTAMP"
")"
))
logger.info("Created command_tracker table")
# ------------------------------------------------------------------
# 8. Create command_tracker_listener table
# ------------------------------------------------------------------
if not await _has_table(conn, "command_tracker_listener"):
await conn.execute(text(
"CREATE TABLE command_tracker_listener ("
" id INTEGER PRIMARY KEY,"
" command_tracker_id INTEGER NOT NULL REFERENCES command_tracker(id),"
" listener_type TEXT NOT NULL,"
" listener_id INTEGER NOT NULL,"
" created_at TIMESTAMP,"
" UNIQUE(command_tracker_id, listener_type, listener_id)"
")"
))
logger.info("Created command_tracker_listener table")
# ------------------------------------------------------------------
# 9. Migrate TelegramBot.commands_config → CommandConfig rows
# ------------------------------------------------------------------
if await _has_table(conn, "telegram_bot") and await _has_column(conn, "telegram_bot", "commands_config"):
# Only migrate if command_config table is empty (idempotent)
cc_count = (await conn.execute(text("SELECT COUNT(*) FROM command_config"))).scalar()
if cc_count == 0:
bots = (await conn.execute(text(
"SELECT id, user_id, commands_config FROM telegram_bot"
))).fetchall()
migrated = 0
for bot in bots:
bot_id, user_id, raw_config = bot[0], bot[1], bot[2]
if not raw_config:
continue
try:
cfg = json.loads(raw_config) if isinstance(raw_config, str) else raw_config
except (json.JSONDecodeError, TypeError):
continue
# Skip empty/default configs
if not cfg or cfg == {}:
continue
# Extract fields from legacy commands_config
enabled_commands = json.dumps(cfg.get("enabled_commands", []))
locale = cfg.get("locale", "en")
response_mode = cfg.get("response_mode", "media")
default_count = cfg.get("default_count", 5)
rate_limits = json.dumps(cfg.get("rate_limits", {}))
provider_type = cfg.get("provider_type", "immich")
await conn.execute(
text(
"INSERT INTO command_config "
"(user_id, provider_type, name, enabled_commands, locale, "
"response_mode, default_count, rate_limits, created_at) "
"VALUES (:uid, :pt, :name, :ec, :locale, :rm, :dc, :rl, CURRENT_TIMESTAMP)"
),
{
"uid": user_id,
"pt": provider_type,
"name": f"Bot #{bot_id} Commands",
"ec": enabled_commands,
"locale": locale,
"rm": response_mode,
"dc": default_count,
"rl": rate_limits,
},
)
migrated += 1
if migrated:
logger.info("Migrated %d bot commands_config → command_config rows", migrated)
# NOTE: We intentionally do NOT drop commands_config from telegram_bot
# 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.
@@ -6,6 +6,7 @@ from datetime import datetime, timezone
from typing import Any
from uuid import uuid4
from sqlalchemy import UniqueConstraint
from sqlmodel import JSON, Column, Field, SQLModel
@@ -47,7 +48,8 @@ class TelegramBot(SQLModel, table=True):
bot_id: int = Field(default=0)
webhook_path_id: str = Field(default_factory=lambda: uuid4().hex)
update_mode: str = Field(default="polling") # "polling" or "webhook"
commands_config: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
# NOTE: commands_config column remains in the DB for backward compat,
# but is no longer part of the SQLModel class. Data migrated to CommandConfig.
created_at: datetime = Field(default_factory=_utcnow)
@@ -162,13 +164,14 @@ class NotificationTarget(SQLModel, table=True):
name: str
icon: str = Field(default="")
config: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
chat_action: str | None = Field(default=None) # e.g. "typing", "upload_photo"
created_at: datetime = Field(default_factory=_utcnow)
class Tracker(SQLModel, table=True):
class NotificationTracker(SQLModel, table=True):
"""Watches a provider's collections for changes."""
__tablename__ = "tracker"
__tablename__ = "notification_tracker"
id: int | None = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="user.id")
@@ -182,13 +185,18 @@ class Tracker(SQLModel, table=True):
created_at: datetime = Field(default_factory=_utcnow)
class TrackerTarget(SQLModel, table=True):
"""Junction between Tracker and NotificationTarget with per-link config."""
class NotificationTrackerTarget(SQLModel, table=True):
"""Junction between NotificationTracker and NotificationTarget with per-link config."""
__tablename__ = "tracker_target"
__tablename__ = "notification_tracker_target"
id: int | None = Field(default=None, primary_key=True)
tracker_id: int = Field(foreign_key="tracker.id", index=True)
# Python attr stays as tracker_id for backward compat; DB column is notification_tracker_id
tracker_id: int = Field(
foreign_key="notification_tracker.id",
index=True,
sa_column_kwargs={"name": "notification_tracker_id"},
)
target_id: int = Field(foreign_key="notification_target.id", index=True)
tracking_config_id: int | None = Field(
default=None, foreign_key="tracking_config.id"
@@ -199,19 +207,22 @@ class TrackerTarget(SQLModel, table=True):
enabled: bool = Field(default=True)
quiet_hours_start: str | None = None
quiet_hours_end: str | None = None
commands_config: dict[str, Any] | None = Field(
default=None, sa_column=Column(JSON)
)
# NOTE: commands_config column remains in the DB for backward compat,
# but is no longer part of the SQLModel class. Data migrated to CommandConfig.
created_at: datetime = Field(default_factory=_utcnow)
class TrackerState(SQLModel, table=True):
class NotificationTrackerState(SQLModel, table=True):
"""Persisted state for change detection."""
__tablename__ = "tracker_state"
__tablename__ = "notification_tracker_state"
id: int | None = Field(default=None, primary_key=True)
tracker_id: int = Field(foreign_key="tracker.id")
# Python attr stays as tracker_id for backward compat; DB column is notification_tracker_id
tracker_id: int = Field(
foreign_key="notification_tracker.id",
sa_column_kwargs={"name": "notification_tracker_id"},
)
collection_id: str
collection_name: str = Field(default="")
shared: bool = Field(default=False)
@@ -220,13 +231,70 @@ class TrackerState(SQLModel, table=True):
last_updated: datetime = Field(default_factory=_utcnow)
class CommandConfig(SQLModel, table=True):
"""Configuration for bot commands (e.g., which commands are enabled, rate limits)."""
__tablename__ = "command_config"
id: int | None = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="user.id")
provider_type: str
name: str
icon: str = Field(default="")
enabled_commands: list[str] = Field(default_factory=list, sa_column=Column(JSON))
locale: str = Field(default="en")
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))
created_at: datetime = Field(default_factory=_utcnow)
class CommandTracker(SQLModel, table=True):
"""Links a provider to a command config for interactive bot commands."""
__tablename__ = "command_tracker"
id: int | None = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="user.id")
provider_id: int = Field(foreign_key="service_provider.id")
command_config_id: int = Field(foreign_key="command_config.id")
name: str
icon: str = Field(default="")
enabled: bool = Field(default=True)
created_at: datetime = Field(default_factory=_utcnow)
class CommandTrackerListener(SQLModel, table=True):
"""Links a CommandTracker to a listener (e.g., a telegram bot chat)."""
__tablename__ = "command_tracker_listener"
__table_args__ = (
UniqueConstraint(
"command_tracker_id", "listener_type", "listener_id",
name="uq_command_tracker_listener",
),
)
id: int | None = Field(default=None, primary_key=True)
command_tracker_id: int = Field(foreign_key="command_tracker.id")
listener_type: str # e.g. "telegram_bot"
listener_id: int
created_at: datetime = Field(default_factory=_utcnow)
class EventLog(SQLModel, table=True):
"""Log of detected events."""
__tablename__ = "event_log"
id: int | None = Field(default=None, primary_key=True)
tracker_id: int | None = Field(default=None, foreign_key="tracker.id", index=True)
# Python attr stays as tracker_id for backward compat; DB column is notification_tracker_id
tracker_id: int | None = Field(
default=None,
foreign_key="notification_tracker.id",
index=True,
sa_column_kwargs={"name": "notification_tracker_id"},
)
tracker_name: str = Field(default="")
provider_id: int | None = Field(default=None, index=True)
provider_name: str = Field(default="")