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