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
@@ -150,7 +150,6 @@ GITEA_CAPABILITIES = ProviderCapabilities(
{"name": "message_pr_commented", "description": "Comment on pull request"},
{"name": "message_release_published", "description": "Release published"},
],
command_slots=[],
events=[
{"name": "push", "description": "Code pushed to repository"},
{"name": "issue_opened", "description": "Issue opened"},
@@ -162,7 +161,31 @@ GITEA_CAPABILITIES = ProviderCapabilities(
{"name": "pr_commented", "description": "Comment on pull request"},
{"name": "release_published", "description": "Release published"},
],
commands=[],
command_slots=[
{"name": "start", "description": "/start greeting message"},
{"name": "help", "description": "/help command listing"},
{"name": "status", "description": "/status tracker summary"},
{"name": "repos", "description": "/repos tracked repositories"},
{"name": "issues", "description": "/issues open issues"},
{"name": "prs", "description": "/prs open pull requests"},
{"name": "commits", "description": "/commits recent commits"},
{"name": "rate_limited", "description": "Rate limit warning message"},
{"name": "no_results", "description": "Empty results fallback"},
{"name": "desc_help", "description": "Menu description for /help"},
{"name": "desc_status", "description": "Menu description for /status"},
{"name": "desc_repos", "description": "Menu description for /repos"},
{"name": "desc_issues", "description": "Menu description for /issues"},
{"name": "desc_prs", "description": "Menu description for /prs"},
{"name": "desc_commits", "description": "Menu description for /commits"},
],
commands=[
{"name": "status", "description": "Show tracker status"},
{"name": "repos", "description": "List tracked repositories"},
{"name": "issues", "description": "Recent open issues"},
{"name": "prs", "description": "Open pull requests"},
{"name": "commits", "description": "Recent commits"},
{"name": "help", "description": "Show commands"},
],
)
# ---------------------------------------------------------------------------
@@ -85,5 +85,57 @@ class GiteaClient:
return repos
async def get_repo_issues(
self, owner: str, repo: str, state: str = "open", limit: int = 10,
) -> list[dict[str, Any]]:
"""Fetch issues for a repository."""
try:
async with self._session.get(
f"{self._url}/api/v1/repos/{owner}/{repo}/issues",
headers=self._headers,
params={"type": "issues", "state": state, "limit": str(limit)},
) as response:
if response.status == 200:
return await response.json()
_LOGGER.warning("Failed to fetch issues for %s/%s: HTTP %s", owner, repo, response.status)
except aiohttp.ClientError as err:
_LOGGER.warning("Failed to fetch issues for %s/%s: %s", owner, repo, err)
return []
async def get_repo_pulls(
self, owner: str, repo: str, state: str = "open", limit: int = 10,
) -> list[dict[str, Any]]:
"""Fetch pull requests for a repository."""
try:
async with self._session.get(
f"{self._url}/api/v1/repos/{owner}/{repo}/pulls",
headers=self._headers,
params={"state": state, "limit": str(limit)},
) as response:
if response.status == 200:
return await response.json()
_LOGGER.warning("Failed to fetch PRs for %s/%s: HTTP %s", owner, repo, response.status)
except aiohttp.ClientError as err:
_LOGGER.warning("Failed to fetch PRs for %s/%s: %s", owner, repo, err)
return []
async def get_repo_commits(
self, owner: str, repo: str, limit: int = 10,
) -> list[dict[str, Any]]:
"""Fetch recent commits for a repository."""
try:
async with self._session.get(
f"{self._url}/api/v1/repos/{owner}/{repo}/commits",
headers=self._headers,
params={"limit": str(limit)},
) as response:
if response.status == 200:
return await response.json()
_LOGGER.warning("Failed to fetch commits for %s/%s: HTTP %s", owner, repo, response.status)
except aiohttp.ClientError as err:
_LOGGER.warning("Failed to fetch commits for %s/%s: %s", owner, repo, err)
return []
class GiteaApiError(Exception):
"""Raised when a Gitea API call fails."""
@@ -0,0 +1,7 @@
📝 <b>Recent Commits</b>
{%- for c in commits %}
• <b>{{ c.repo }}</b> <code>{{ c.short_id }}</code>: {{ c.message }} ({{ c.author }})
{%- endfor %}
{%- if not commits %}
No recent commits found.
{%- endif %}
@@ -0,0 +1 @@
Show available commands
@@ -0,0 +1 @@
Open pull requests
@@ -0,0 +1 @@
List tracked repositories
@@ -0,0 +1 @@
Show tracker status
@@ -0,0 +1,4 @@
📋 <b>Available commands:</b>
{%- for cmd in commands %}
/{{ cmd.name }} — {{ cmd.description }}
{%- endfor %}
@@ -0,0 +1,7 @@
🐛 <b>Open Issues</b>
{%- for issue in issues %}
• <b>{{ issue.repo }}</b> <a href="{{ issue.url }}">#{{ issue.number }}</a>: {{ issue.title }} ({{ issue.user }})
{%- endfor %}
{%- if not issues %}
No open issues found.
{%- endif %}
@@ -0,0 +1,7 @@
🔀 <b>Open Pull Requests</b>
{%- for pr in prs %}
• <b>{{ pr.repo }}</b> <a href="{{ pr.url }}">#{{ pr.number }}</a>: {{ pr.title }} ({{ pr.user }})
{%- endfor %}
{%- if not prs %}
No open pull requests found.
{%- endif %}
@@ -0,0 +1 @@
⏳ Please wait {{ wait }}s before using this command again.
@@ -0,0 +1,7 @@
📦 <b>Tracked Repositories</b>
{%- for repo in repos %}
• <b>{{ repo.full_name }}</b>{% if repo.description %} — {{ repo.description }}{% endif %}
{%- endfor %}
{%- if not repos %}
No repositories tracked.
{%- endif %}
@@ -0,0 +1,2 @@
👋 Hi! I'm your Notify Bridge bot for <b>Gitea</b>.
Use /help to see available commands.
@@ -0,0 +1,4 @@
📊 <b>Gitea Status</b>
Repositories tracked: {{ repos_count }}
Server: Gitea v{{ server_version }}
Last event: {{ last_event }}
@@ -7,41 +7,71 @@ _LOGGER = logging.getLogger(__name__)
_DEFAULTS_DIR = Path(__file__).parent
# Response template slot names (file stem = slot name)
# Per-provider slot names
PROVIDER_COMMAND_SLOTS: dict[str, list[str]] = {
"immich": [
# Response templates
"start", "help", "status", "albums", "events", "people",
"search", "latest", "favorites", "random", "summary", "memory",
"rate_limited", "no_results",
# Description slots
"desc_status", "desc_albums", "desc_events", "desc_summary",
"desc_latest", "desc_memory", "desc_random", "desc_search",
"desc_find", "desc_person", "desc_place", "desc_favorites",
"desc_people", "desc_help",
],
"gitea": [
# Response templates
"start", "help", "status", "repos", "issues", "prs", "commits",
"rate_limited", "no_results",
# Description slots
"desc_help", "desc_status", "desc_repos", "desc_issues",
"desc_prs", "desc_commits",
],
}
# Backward-compatible aliases
COMMAND_SLOT_NAMES = [
"start", "help", "status", "albums", "events", "people",
"search", "latest", "favorites", "random", "summary", "memory",
"rate_limited", "no_results",
]
# Description slots for Telegram command menu (desc_{cmd} -> short text)
COMMAND_DESC_SLOT_NAMES = [
"desc_status", "desc_albums", "desc_events", "desc_summary",
"desc_latest", "desc_memory", "desc_random", "desc_search",
"desc_find", "desc_person", "desc_place", "desc_favorites",
"desc_people", "desc_help",
]
# All slot names (response + description)
ALL_SLOT_NAMES = COMMAND_SLOT_NAMES + COMMAND_DESC_SLOT_NAMES
def load_default_command_templates(locale: str = "en") -> dict[str, str]:
"""Load default command template strings for a locale.
def load_default_command_templates(
locale: str = "en",
provider_type: str = "immich",
) -> dict[str, str]:
"""Load default command template strings for a locale and provider type.
For "immich", templates are in {locale}/ (root, backward compat).
For other providers, templates are in {locale}/{provider_type}/.
Returns dict mapping slot_name -> template string.
"""
locale_dir = _DEFAULTS_DIR / locale
if provider_type == "immich":
locale_dir = _DEFAULTS_DIR / locale
else:
locale_dir = _DEFAULTS_DIR / locale / provider_type
if not locale_dir.is_dir():
_LOGGER.warning("No default command templates for locale '%s'", locale)
_LOGGER.warning("No default command templates for locale '%s' provider '%s'", locale, provider_type)
return {}
slot_names = PROVIDER_COMMAND_SLOTS.get(provider_type, ALL_SLOT_NAMES)
templates: dict[str, str] = {}
for slot_name in ALL_SLOT_NAMES:
for slot_name in slot_names:
filepath = locale_dir / f"{slot_name}.jinja2"
if filepath.exists():
templates[slot_name] = filepath.read_text(encoding="utf-8").strip()
else:
_LOGGER.debug("Missing default command template: %s/%s.jinja2", locale, slot_name)
_LOGGER.debug("Missing default command template: %s/%s.jinja2", locale_dir.name, slot_name)
return templates
@@ -0,0 +1,7 @@
📝 <b>Последние коммиты</b>
{%- for c in commits %}
• <b>{{ c.repo }}</b> <code>{{ c.short_id }}</code>: {{ c.message }} ({{ c.author }})
{%- endfor %}
{%- if not commits %}
Коммитов не найдено.
{%- endif %}
@@ -0,0 +1 @@
Последние коммиты
@@ -0,0 +1 @@
Показать доступные команды
@@ -0,0 +1 @@
Открытые задачи
@@ -0,0 +1 @@
Открытые пулл-реквесты
@@ -0,0 +1 @@
Список репозиториев
@@ -0,0 +1 @@
Статус трекера
@@ -0,0 +1,4 @@
📋 <b>Доступные команды:</b>
{%- for cmd in commands %}
/{{ cmd.name }} — {{ cmd.description }}
{%- endfor %}
@@ -0,0 +1,7 @@
🐛 <b>Открытые задачи</b>
{%- for issue in issues %}
• <b>{{ issue.repo }}</b> <a href="{{ issue.url }}">#{{ issue.number }}</a>: {{ issue.title }} ({{ issue.user }})
{%- endfor %}
{%- if not issues %}
Открытых задач не найдено.
{%- endif %}
@@ -0,0 +1 @@
Результатов не найдено.
@@ -0,0 +1,7 @@
🔀 <b>Открытые пулл-реквесты</b>
{%- for pr in prs %}
• <b>{{ pr.repo }}</b> <a href="{{ pr.url }}">#{{ pr.number }}</a>: {{ pr.title }} ({{ pr.user }})
{%- endfor %}
{%- if not prs %}
Открытых пулл-реквестов не найдено.
{%- endif %}
@@ -0,0 +1 @@
⏳ Подождите {{ wait }} сек. перед повторным использованием команды.
@@ -0,0 +1,7 @@
📦 <b>Отслеживаемые репозитории</b>
{%- for repo in repos %}
• <b>{{ repo.full_name }}</b>{% if repo.description %} — {{ repo.description }}{% endif %}
{%- endfor %}
{%- if not repos %}
Репозитории не отслеживаются.
{%- endif %}
@@ -0,0 +1,2 @@
👋 Привет! Я ваш бот Notify Bridge для <b>Gitea</b>.
Используйте /help для списка команд.
@@ -0,0 +1,4 @@
📊 <b>Статус Gitea</b>
Отслеживаемые репозитории: {{ repos_count }}
Сервер: Gitea v{{ server_version }}
Последнее событие: {{ last_event }}