"""Command tracker and listener management API routes.""" import logging from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel from sqlmodel import func, select from sqlmodel.ext.asyncio.session import AsyncSession from ..auth.dependencies import get_current_user from ..database.engine import get_session from ..database.models import ( CommandConfig, CommandTracker, CommandTrackerListener, ServiceProvider, TelegramBot, User, ) from .helpers import get_owned_entity _LOGGER = logging.getLogger(__name__) router = APIRouter(prefix="/api/command-trackers", tags=["command-trackers"]) class CommandTrackerCreate(BaseModel): provider_id: int command_config_id: int name: str icon: str = "" enabled: bool = True class CommandTrackerUpdate(BaseModel): name: str | None = None icon: str | None = None enabled: bool | None = None command_config_id: int | None = None class ListenerCreate(BaseModel): listener_type: str listener_id: int # --- Command Tracker CRUD --- @router.get("") async def list_command_trackers( user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session), ): """List all command trackers for the current user, with listener counts.""" result = await session.exec( select(CommandTracker).where(CommandTracker.user_id == user.id) ) trackers = result.all() return [await _tracker_response(session, t) for t in trackers] @router.post("", status_code=status.HTTP_201_CREATED) async def create_command_tracker( body: CommandTrackerCreate, user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session), ): """Create a new command tracker.""" # Validate provider exists and user owns it provider = await session.get(ServiceProvider, body.provider_id) if not provider or provider.user_id != user.id: raise HTTPException(status_code=404, detail="Provider not found") # Validate command config exists and user owns it config = await session.get(CommandConfig, body.command_config_id) if not config or config.user_id != user.id: raise HTTPException(status_code=404, detail="Command config not found") # Validate provider_type matches if config.provider_type != provider.type: raise HTTPException( status_code=400, detail=f"Provider type mismatch: provider is '{provider.type}' but command config is for '{config.provider_type}'", ) tracker = CommandTracker(user_id=user.id, **body.model_dump()) session.add(tracker) await session.commit() await session.refresh(tracker) # Mark affected bots dirty for debounced auto-sync from ..services.command_sync import mark_dirty_for_tracker await mark_dirty_for_tracker(tracker.id) return await _tracker_response(session, tracker) @router.get("/{tracker_id}") async def get_command_tracker( tracker_id: int, user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session), ): """Get a single command tracker with its listeners.""" tracker = await _get_user_tracker(session, tracker_id, user.id) return await _tracker_response(session, tracker, include_listeners=True) @router.put("/{tracker_id}") async def update_command_tracker( tracker_id: int, body: CommandTrackerUpdate, user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session), ): """Update a command tracker.""" tracker = await _get_user_tracker(session, tracker_id, user.id) updates = body.model_dump(exclude_unset=True) # If changing command_config_id, validate ownership and provider_type match if "command_config_id" in updates and updates["command_config_id"] is not None: config = await session.get(CommandConfig, updates["command_config_id"]) if not config or config.user_id != user.id: raise HTTPException(status_code=404, detail="Command config not found") provider = await session.get(ServiceProvider, tracker.provider_id) if provider and config.provider_type != provider.type: raise HTTPException( status_code=400, detail=f"Provider type mismatch: provider is '{provider.type}' but command config is for '{config.provider_type}'", ) for field, value in updates.items(): setattr(tracker, field, value) session.add(tracker) await session.commit() await session.refresh(tracker) # Mark affected bots dirty for debounced auto-sync from ..services.command_sync import mark_dirty_for_tracker await mark_dirty_for_tracker(tracker.id) return await _tracker_response(session, tracker) @router.delete("/{tracker_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_command_tracker( tracker_id: int, user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session), ): """Delete a command tracker and cascade delete its listeners.""" tracker = await _get_user_tracker(session, tracker_id, user.id) # Mark affected bots dirty before deleting (chain breaks after deletion) from ..services.command_sync import mark_dirty_for_tracker await mark_dirty_for_tracker(tracker.id) # Delete associated listeners, collecting bot IDs for polling cleanup result = await session.exec( select(CommandTrackerListener).where( CommandTrackerListener.command_tracker_id == tracker_id ) ) bot_ids_to_check: set[int] = set() for listener in result.all(): if listener.listener_type == "telegram_bot": bot_ids_to_check.add(listener.listener_id) await session.delete(listener) await session.delete(tracker) await session.commit() # Stop polling for bots that may no longer be needed if bot_ids_to_check: from ..services.telegram_poller import stop_bot_if_unused for bot_id in bot_ids_to_check: await stop_bot_if_unused(bot_id) @router.post("/{tracker_id}/enable") async def enable_command_tracker( tracker_id: int, user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session), ): """Enable a command tracker.""" tracker = await _get_user_tracker(session, tracker_id, user.id) tracker.enabled = True session.add(tracker) await session.commit() await session.refresh(tracker) # Mark affected bots dirty for debounced auto-sync from ..services.command_sync import mark_dirty_for_tracker await mark_dirty_for_tracker(tracker.id) # Start polling for any telegram bot listeners lr = await session.exec( select(CommandTrackerListener).where( CommandTrackerListener.command_tracker_id == tracker_id, CommandTrackerListener.listener_type == "telegram_bot", ) ) from ..services.telegram_poller import start_bot_if_needed for listener in lr.all(): await start_bot_if_needed(listener.listener_id) return await _tracker_response(session, tracker) @router.post("/{tracker_id}/disable") async def disable_command_tracker( tracker_id: int, user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session), ): """Disable a command tracker.""" tracker = await _get_user_tracker(session, tracker_id, user.id) tracker.enabled = False session.add(tracker) await session.commit() await session.refresh(tracker) # Mark affected bots dirty for debounced auto-sync from ..services.command_sync import mark_dirty_for_tracker await mark_dirty_for_tracker(tracker.id) # Stop polling for any telegram bot listeners that are no longer needed lr = await session.exec( select(CommandTrackerListener).where( CommandTrackerListener.command_tracker_id == tracker_id, CommandTrackerListener.listener_type == "telegram_bot", ) ) from ..services.telegram_poller import stop_bot_if_unused for listener in lr.all(): await stop_bot_if_unused(listener.listener_id) return await _tracker_response(session, tracker) # --- Listener Management --- @router.get("/{tracker_id}/listeners") async def list_listeners( tracker_id: int, user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session), ): """List all listeners for a command tracker.""" await _get_user_tracker(session, tracker_id, user.id) result = await session.exec( select(CommandTrackerListener).where( CommandTrackerListener.command_tracker_id == tracker_id ) ) return [await _listener_response(session, l) for l in result.all()] @router.post("/{tracker_id}/listeners", status_code=status.HTTP_201_CREATED) async def add_listener( tracker_id: int, body: ListenerCreate, user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session), ): """Add a listener to a command tracker.""" await _get_user_tracker(session, tracker_id, user.id) # Validate listener exists and user owns it if body.listener_type == "telegram_bot": bot = await session.get(TelegramBot, body.listener_id) if not bot or bot.user_id != user.id: raise HTTPException(status_code=404, detail="Telegram bot not found") else: raise HTTPException( status_code=400, detail=f"Unsupported listener type: {body.listener_type}", ) # Check for duplicate result = await session.exec( select(CommandTrackerListener).where( CommandTrackerListener.command_tracker_id == tracker_id, CommandTrackerListener.listener_type == body.listener_type, CommandTrackerListener.listener_id == body.listener_id, ) ) if result.first(): raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="Listener is already linked to this command tracker", ) listener = CommandTrackerListener( command_tracker_id=tracker_id, listener_type=body.listener_type, listener_id=body.listener_id, ) session.add(listener) await session.commit() await session.refresh(listener) # Start polling for this bot if needed if body.listener_type == "telegram_bot": from ..services.telegram_poller import start_bot_if_needed await start_bot_if_needed(body.listener_id) # Mark bot dirty for debounced auto-sync from ..services.command_sync import mark_bot_dirty mark_bot_dirty(body.listener_id) return await _listener_response(session, listener) @router.delete("/{tracker_id}/listeners/{listener_id}", status_code=status.HTTP_204_NO_CONTENT) async def remove_listener( tracker_id: int, listener_id: int, user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session), ): """Remove a listener from a command tracker.""" await _get_user_tracker(session, tracker_id, user.id) listener = await session.get(CommandTrackerListener, listener_id) if not listener or listener.command_tracker_id != tracker_id: raise HTTPException(status_code=404, detail="Listener not found") removed_type = listener.listener_type removed_id = listener.listener_id await session.delete(listener) await session.commit() # Stop polling for this bot if no longer needed if removed_type == "telegram_bot": from ..services.telegram_poller import stop_bot_if_unused await stop_bot_if_unused(removed_id) # Mark bot dirty for debounced auto-sync from ..services.command_sync import mark_bot_dirty mark_bot_dirty(removed_id) # --- Helpers --- async def _tracker_response( session: AsyncSession, t: CommandTracker, include_listeners: bool = False ) -> dict: """Build command tracker response.""" # Get listener count result = await session.exec( select(func.count()).select_from(CommandTrackerListener).where( CommandTrackerListener.command_tracker_id == t.id ) ) listeners_count = result.one() resp = { "id": t.id, "user_id": t.user_id, "provider_id": t.provider_id, "command_config_id": t.command_config_id, "name": t.name, "icon": t.icon, "enabled": t.enabled, "listeners_count": listeners_count, "created_at": t.created_at.isoformat(), } if include_listeners: lr = await session.exec( select(CommandTrackerListener).where( CommandTrackerListener.command_tracker_id == t.id ) ) resp["listeners"] = [await _listener_response(session, l) for l in lr.all()] return resp async def _listener_response(session: AsyncSession, l: CommandTrackerListener) -> dict: name = "" if l.listener_type == "telegram_bot": bot = await session.get(TelegramBot, l.listener_id) if bot: name = bot.name return { "id": l.id, "command_tracker_id": l.command_tracker_id, "listener_type": l.listener_type, "listener_id": l.listener_id, "name": name, "created_at": l.created_at.isoformat(), } async def _get_user_tracker( session: AsyncSession, tracker_id: int, user_id: int ) -> CommandTracker: return await get_owned_entity( session, CommandTracker, tracker_id, user_id, not_found_msg="Command tracker not found", )