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
@@ -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="")