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
@@ -16,12 +16,15 @@ from notify_bridge_core.notifications.telegram.media import TELEGRAM_API_BASE_UR
from ..database.engine import get_engine
from ..services import make_immich_provider
from ..database.models import (
CommandConfig,
CommandTracker,
CommandTrackerListener,
EventLog,
NotificationTarget,
NotificationTracker,
NotificationTrackerTarget,
ServiceProvider,
TelegramBot,
Tracker,
TrackerTarget,
TrackingConfig,
)
from .parser import parse_command
@@ -48,6 +51,70 @@ def _check_rate_limit(bot_id: int, chat_id: str, cmd: str, limits: dict[str, int
return None
async def _resolve_command_context(
bot: TelegramBot,
) -> list[tuple[CommandTracker, CommandConfig, ServiceProvider]]:
"""Resolve all enabled command trackers, configs, and providers for a bot.
Finds CommandTrackerListener rows where listener_type="telegram_bot"
and listener_id=bot.id, then loads the full chain:
CommandTrackerListener -> CommandTracker (enabled) -> CommandConfig + ServiceProvider.
"""
engine = get_engine()
async with AsyncSession(engine) as session:
# Find all listeners for this bot
result = await session.exec(
select(CommandTrackerListener).where(
CommandTrackerListener.listener_type == "telegram_bot",
CommandTrackerListener.listener_id == bot.id,
)
)
listeners = result.all()
if not listeners:
return []
tuples: list[tuple[CommandTracker, CommandConfig, ServiceProvider]] = []
for listener in listeners:
tracker = await session.get(CommandTracker, listener.command_tracker_id)
if not tracker or not tracker.enabled:
continue
config = await session.get(CommandConfig, tracker.command_config_id)
if not config:
continue
provider = await session.get(ServiceProvider, tracker.provider_id)
if not provider:
continue
tuples.append((tracker, config, provider))
return tuples
def _merge_command_context(
ctx: list[tuple[CommandTracker, CommandConfig, ServiceProvider]],
) -> tuple[list[str], str, str, int, dict[str, Any]]:
"""Merge enabled_commands from all configs and pick defaults from first config.
Returns (enabled_commands, locale, response_mode, default_count, rate_limits).
"""
if not ctx:
return [], "en", "media", 5, {}
# Union of all enabled commands across configs
enabled: set[str] = set()
for _, config, _ in ctx:
enabled.update(config.enabled_commands or [])
# Use first config's settings as defaults
first_config = ctx[0][1]
locale = first_config.locale or "en"
response_mode = first_config.response_mode or "media"
default_count = first_config.default_count or 5
rate_limits = first_config.rate_limits or {}
return sorted(enabled), locale, response_mode, default_count, rate_limits
async def handle_command(
bot: TelegramBot,
chat_id: str,
@@ -58,11 +125,8 @@ async def handle_command(
if not cmd:
return None
config = bot.commands_config or {}
enabled = config.get("enabled", [])
default_count = min(config.get("default_count", 5), 20)
locale = config.get("locale", "en")
rate_limits = config.get("rate_limits", {})
ctx = await _resolve_command_context(bot)
enabled, locale, response_mode, default_count, rate_limits = _merge_command_context(ctx)
if cmd == "start":
msgs = {
@@ -85,20 +149,25 @@ async def handle_command(
count = min(count_override or default_count, 20)
# Build providers map from command context
providers_map: dict[int, ServiceProvider] = {}
for _, _, provider in ctx:
providers_map[provider.id] = provider
# Dispatch
if cmd == "help":
return _cmd_help(enabled, locale)
if cmd == "status":
return await _cmd_status(bot, locale)
return await _cmd_status(bot, providers_map, locale)
if cmd == "albums":
return await _cmd_albums(bot, locale)
return await _cmd_albums(bot, providers_map, locale)
if cmd == "events":
return await _cmd_events(bot, count, locale)
return await _cmd_events(bot, providers_map, count, locale)
if cmd == "people":
return await _cmd_people(bot, locale)
return await _cmd_people(providers_map, locale)
if cmd in ("search", "find", "person", "place", "latest", "random",
"favorites", "summary", "memory"):
return await _cmd_immich(bot, cmd, args, count, locale)
return await _cmd_immich(bot, cmd, args, count, locale, response_mode, providers_map)
return None
@@ -112,50 +181,24 @@ def _cmd_help(enabled: list[str], locale: str) -> str:
return header.get(locale, header["en"]) + "\n" + "\n".join(lines)
async def _get_bot_context(bot: TelegramBot) -> tuple[
list[Tracker], dict[int, ServiceProvider]
]:
"""Get trackers and providers associated with a bot via its targets."""
async def _get_notification_trackers_for_providers(
provider_ids: set[int],
) -> list[NotificationTracker]:
"""Get notification trackers for the given provider IDs.
Used by commands like albums, events, status that need notification
tracker data (collection_ids, event logs).
"""
if not provider_ids:
return []
engine = get_engine()
async with AsyncSession(engine) as session:
# Find targets that use this bot's token
result = await session.exec(
select(NotificationTarget).where(
NotificationTarget.type == "telegram",
NotificationTarget.user_id == bot.user_id,
select(NotificationTracker).where(
NotificationTracker.provider_id.in_(provider_ids)
)
)
targets = result.all()
bot_target_ids = {t.id for t in targets if t.config.get("bot_token") == bot.token}
if not bot_target_ids:
return [], {}
# Find trackers linked to these targets via TrackerTarget
tt_result = await session.exec(
select(TrackerTarget).where(TrackerTarget.target_id.in_(bot_target_ids))
)
all_links = tt_result.all()
tracker_ids = {tt.tracker_id for tt in all_links}
if not tracker_ids:
return [], {}
trackers = []
provider_ids = set()
for tid in tracker_ids:
tracker = await session.get(Tracker, tid)
if tracker:
trackers.append(tracker)
provider_ids.add(tracker.provider_id)
providers_map: dict[int, ServiceProvider] = {}
for pid in provider_ids:
provider = await session.get(ServiceProvider, pid)
if provider:
providers_map[pid] = provider
return trackers, providers_map
return list(result.all())
async def _check_native_memory(bot: TelegramBot) -> bool:
@@ -173,7 +216,7 @@ async def _check_native_memory(bot: TelegramBot) -> bool:
if not bot_target_ids:
return False
tt_result = await session.exec(
select(TrackerTarget).where(TrackerTarget.target_id.in_(bot_target_ids))
select(NotificationTrackerTarget).where(NotificationTrackerTarget.target_id.in_(bot_target_ids))
)
for tt in tt_result.all():
if tt.tracking_config_id:
@@ -183,8 +226,9 @@ async def _check_native_memory(bot: TelegramBot) -> bool:
return False
async def _cmd_status(bot: TelegramBot, locale: str) -> str:
trackers, _ = await _get_bot_context(bot)
async def _cmd_status(bot: TelegramBot, providers_map: dict[int, ServiceProvider], locale: str) -> str:
provider_ids = set(providers_map.keys())
trackers = await _get_notification_trackers_for_providers(provider_ids)
active = sum(1 for t in trackers if t.enabled)
total = len(trackers)
total_albums = sum(len(t.collection_ids or []) for t in trackers)
@@ -212,8 +256,9 @@ async def _cmd_status(bot: TelegramBot, locale: str) -> str:
)
async def _cmd_albums(bot: TelegramBot, locale: str) -> str:
trackers, providers_map = await _get_bot_context(bot)
async def _cmd_albums(bot: TelegramBot, providers_map: dict[int, ServiceProvider], locale: str) -> str:
provider_ids = set(providers_map.keys())
trackers = await _get_notification_trackers_for_providers(provider_ids)
if not trackers:
return "No tracked albums." if locale == "en" else "Нет отслеживаемых альбомов."
@@ -236,8 +281,9 @@ async def _cmd_albums(bot: TelegramBot, locale: str) -> str:
return header + "\n" + "\n".join(lines) if lines else header + "\n (none)"
async def _cmd_events(bot: TelegramBot, count: int, locale: str) -> str:
trackers, _ = await _get_bot_context(bot)
async def _cmd_events(bot: TelegramBot, providers_map: dict[int, ServiceProvider], count: int, locale: str) -> str:
provider_ids = set(providers_map.keys())
trackers = await _get_notification_trackers_for_providers(provider_ids)
tracker_ids = [t.id for t in trackers]
if not tracker_ids:
return "No events." if locale == "en" else "Нет событий."
@@ -263,8 +309,7 @@ async def _cmd_events(bot: TelegramBot, count: int, locale: str) -> str:
return header + "\n" + "\n".join(lines)
async def _cmd_people(bot: TelegramBot, locale: str) -> str:
_, providers_map = await _get_bot_context(bot)
async def _cmd_people(providers_map: dict[int, ServiceProvider], locale: str) -> str:
all_people: dict[str, str] = {}
async with aiohttp.ClientSession() as http:
@@ -285,23 +330,28 @@ async def _cmd_people(bot: TelegramBot, locale: str) -> str:
async def _cmd_immich(
bot: TelegramBot, cmd: str, args: str, count: int, locale: str,
response_mode: str, providers_map: dict[int, ServiceProvider],
) -> str | list[dict[str, Any]]:
"""Handle commands that need Immich API access and may return media."""
trackers, providers_map = await _get_bot_context(bot)
if not trackers:
if not providers_map:
return "No trackers configured." if locale == "en" else "Трекеры не настроены."
# Get notification trackers for album data
provider_ids = set(providers_map.keys())
notification_trackers = await _get_notification_trackers_for_providers(provider_ids)
all_album_ids: list[str] = []
for t in trackers:
for t in notification_trackers:
all_album_ids.extend(t.collection_ids or [])
first_tracker = trackers[0]
provider = providers_map.get(first_tracker.provider_id)
if not provider or provider.type != "immich":
# Pick the first immich provider
provider: ServiceProvider | None = None
for p in providers_map.values():
if p.type == "immich":
provider = p
break
if not provider:
return "Server not found." if locale == "en" else "Сервер не найден."
config = bot.commands_config or {}
response_mode = config.get("response_mode", "media")
async with aiohttp.ClientSession() as http:
immich = make_immich_provider(http, provider)
client = immich.client
@@ -578,10 +628,13 @@ async def send_media_group(
async def register_commands_with_telegram(bot: TelegramBot) -> bool:
"""Register enabled commands with Telegram BotFather API."""
config = bot.commands_config or {}
enabled = config.get("enabled", [])
locale = config.get("locale", "en")
"""Register enabled commands with Telegram BotFather API.
Resolves all command trackers and configs for this bot, merges
enabled commands (union), and calls setMyCommands.
"""
ctx = await _resolve_command_context(bot)
enabled, locale, _, _, _ = _merge_command_context(ctx)
commands = []
for cmd in enabled: