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
@@ -0,0 +1,66 @@
"""Abstract provider command handler interface."""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any
from ..database.models import CommandTracker, CommandConfig, ServiceProvider, TelegramBot
class ProviderCommandHandler(ABC):
"""Base class for provider-specific bot command handlers.
Each provider (Immich, Gitea, etc.) implements this interface to handle
its own set of commands. The dispatch layer routes commands to the
correct handler based on the provider type.
"""
provider_type: str
@abstractmethod
def get_provider_commands(self) -> set[str]:
"""Return the set of command names this handler owns.
These are provider-specific commands (e.g., 'albums' for Immich,
'repos' for Gitea). Universal commands like 'help' and 'start'
are handled by the main dispatcher.
"""
@abstractmethod
async def handle(
self,
cmd: str,
args: str,
count: int,
locale: str,
response_mode: str,
providers_map: dict[int, ServiceProvider],
cmd_templates: dict[str, dict[str, str]],
bot: TelegramBot,
ctx_tuples: list[tuple[CommandTracker, CommandConfig, ServiceProvider]],
) -> str | list[dict[str, Any]] | None:
"""Handle a provider-specific command.
Args:
cmd: The command name (without '/').
args: Arguments after the command.
count: Number of results to return.
locale: User's locale ('en', 'ru').
response_mode: 'media' or 'text'.
providers_map: Provider instances keyed by ID.
cmd_templates: Template slots {slot_name: {locale: template}}.
bot: The Telegram bot instance.
ctx_tuples: Command context tuples for this provider type.
Returns:
Text response, media list, or None if unhandled.
"""
def get_rate_categories(self) -> dict[str, str]:
"""Return rate limit category mapping for this provider's commands.
Keys are command names, values are category strings.
Commands not listed default to 'default' category.
"""
return {}