refactor: provider-agnostic bot command system + Gitea commands

Refactored the monolithic command handler (707 lines) into a pluggable
provider-handler architecture:

- Abstract ProviderCommandHandler interface (base.py)
- Handler dispatch registry routes commands by provider type
- Extracted all Immich logic into ImmichCommandHandler
- New GiteaCommandHandler with /status, /repos, /issues, /prs, /commits
- Multi-provider routing: groups context by provider type, finds handler
- handler.py reduced to ~280 line thin orchestrator

Gitea commands:
- Extended GiteaClient with get_repo_issues, get_repo_pulls, get_repo_commits
- 30 Jinja2 command templates (15 EN + 15 RU)
- Gitea capabilities updated with 6 commands + 15 command_slots
- Default command config + command template config seeded on startup
- Rate limiting: Gitea API commands share "api" category (15s cooldown)

Also:
- Command configs API accepts "gitea" provider type
- System command configs (user_id=0) visible to all users
- Webhook URL shown on Gitea provider card and edit form
- Scan interval hidden for webhook-based providers
This commit is contained in:
2026-03-22 17:44:47 +03:00
parent 0562f78b35
commit 63437c1841
45 changed files with 1175 additions and 397 deletions
@@ -43,9 +43,12 @@ async def list_command_configs(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""List all command configs for the current user."""
"""List all command configs for the current user (including system defaults)."""
from sqlmodel import or_
result = await session.exec(
select(CommandConfig).where(CommandConfig.user_id == user.id)
select(CommandConfig).where(
or_(CommandConfig.user_id == user.id, CommandConfig.user_id == 0)
)
)
return [_config_response(c) for c in result.all()]
@@ -58,7 +61,7 @@ async def create_command_config(
):
"""Create a new command config."""
# Validate provider_type
valid_types = ("immich",)
valid_types = ("immich", "gitea")
if body.provider_type not in valid_types:
raise HTTPException(
status_code=400,
@@ -159,7 +162,7 @@ async def _get_user_config(
session: AsyncSession, config_id: int, user_id: int
) -> CommandConfig:
config = await session.get(CommandConfig, config_id)
if not config or config.user_id != user_id:
if not config or (config.user_id != user_id and config.user_id != 0):
raise HTTPException(status_code=404, detail="Command config not found")
return config