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
@@ -53,6 +53,7 @@ async def lifespan(app: FastAPI):
await _seed_default_templates()
await _seed_default_command_templates()
await _seed_default_tracking_configs()
await _seed_default_command_configs()
# Configure webhook secret from DB setting (falls back to env var)
from sqlmodel.ext.asyncio.session import AsyncSession as _AS
from .api.app_settings import get_setting as _get_setting
@@ -393,7 +394,7 @@ async def _seed_default_command_templates():
# Upsert slots for each locale
for locale in ("en", "ru"):
slots = load_default_command_templates(locale)
slots = load_default_command_templates(locale, provider_type="immich")
if not slots:
continue
for slot_name, template_text in slots.items():
@@ -416,6 +417,51 @@ async def _seed_default_command_templates():
template=template_text,
))
# --- Seed Gitea default command templates ---
gitea_cmd_result = await session.exec(
select(CommandTemplateConfig).where(
CommandTemplateConfig.user_id == 0,
CommandTemplateConfig.provider_type == "gitea",
)
)
gitea_cmd_configs = gitea_cmd_result.all()
if not gitea_cmd_configs:
gitea_cmd_config = CommandTemplateConfig(
user_id=0,
provider_type="gitea",
name="Default Gitea Commands",
description="Default Gitea command templates",
)
session.add(gitea_cmd_config)
await session.flush()
else:
gitea_cmd_config = gitea_cmd_configs[0]
for locale in ("en", "ru"):
gitea_cmd_slots = load_default_command_templates(locale, provider_type="gitea")
if not gitea_cmd_slots:
continue
for slot_name, template_text in gitea_cmd_slots.items():
slot_result = await session.exec(
select(CommandTemplateSlot).where(
CommandTemplateSlot.config_id == gitea_cmd_config.id,
CommandTemplateSlot.slot_name == slot_name,
CommandTemplateSlot.locale == locale,
)
)
existing = slot_result.first()
if existing:
existing.template = template_text
session.add(existing)
else:
session.add(CommandTemplateSlot(
config_id=gitea_cmd_config.id,
slot_name=slot_name,
locale=locale,
template=template_text,
))
await session.commit()
@@ -464,6 +510,85 @@ async def _seed_default_tracking_configs():
await session.commit()
async def _seed_default_command_configs():
"""Seed system-owned default command configs for each provider type."""
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from .database.engine import get_engine
from .database.models import CommandConfig, CommandTemplateConfig
engine = get_engine()
async with AsyncSession(engine) as session:
# Find existing system-owned command configs
result = await session.exec(
select(CommandConfig).where(CommandConfig.user_id == 0)
)
existing = {c.provider_type: c for c in result.all()}
# Find system command template configs to link
tmpl_result = await session.exec(
select(CommandTemplateConfig).where(CommandTemplateConfig.user_id == 0)
)
tmpl_by_type = {t.provider_type: t.id for t in tmpl_result.all()}
defaults = [
{
"provider_type": "immich",
"name": "Default Immich",
"enabled_commands": [
"help", "status", "albums", "events", "latest",
"random", "favorites", "summary", "memory",
],
"response_mode": "media",
"default_count": 5,
"rate_limits": {"search": 30, "default": 10},
},
{
"provider_type": "gitea",
"name": "Default Gitea",
"enabled_commands": [
"help", "status", "repos", "issues", "prs", "commits",
],
"response_mode": "text",
"default_count": 10,
"rate_limits": {"api": 15, "default": 10},
},
]
for cfg in defaults:
ptype = cfg["provider_type"]
if ptype in existing:
continue
cmd_tmpl_id = tmpl_by_type.get(ptype)
# Use raw SQL to handle legacy NOT NULL columns
import json as _json2
from sqlalchemy import text as _text2
from datetime import datetime as _dt3, timezone as _tz3
await session.execute(
_text2(
"INSERT INTO command_config "
"(user_id, provider_type, name, icon, enabled_commands, locale, "
"response_mode, default_count, rate_limits, command_template_config_id, created_at) "
"VALUES (:uid, :pt, :name, :icon, :cmds, :locale, :rm, :dc, :rl, :ctid, :ca)"
),
{
"uid": 0,
"pt": ptype,
"name": cfg["name"],
"icon": "",
"cmds": _json2.dumps(cfg["enabled_commands"]),
"locale": "en",
"rm": cfg["response_mode"],
"dc": cfg["default_count"],
"rl": _json2.dumps(cfg["rate_limits"]),
"ctid": cmd_tmpl_id,
"ca": _dt3.now(_tz3.utc).isoformat(),
},
)
await session.commit()
def run():
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8420)