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:
@@ -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."""
|
||||
|
||||
+7
@@ -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 %}
|
||||
+1
@@ -0,0 +1 @@
|
||||
Recent commits
|
||||
+1
@@ -0,0 +1 @@
|
||||
Show available commands
|
||||
+1
@@ -0,0 +1 @@
|
||||
Recent open issues
|
||||
+1
@@ -0,0 +1 @@
|
||||
Open pull requests
|
||||
+1
@@ -0,0 +1 @@
|
||||
List tracked repositories
|
||||
+1
@@ -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 %}
|
||||
+1
@@ -0,0 +1 @@
|
||||
No results found.
|
||||
@@ -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 %}
|
||||
+1
@@ -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
|
||||
|
||||
+7
@@ -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 %}
|
||||
+1
@@ -0,0 +1 @@
|
||||
Последние коммиты
|
||||
+1
@@ -0,0 +1 @@
|
||||
Показать доступные команды
|
||||
+1
@@ -0,0 +1 @@
|
||||
Открытые задачи
|
||||
+1
@@ -0,0 +1 @@
|
||||
Открытые пулл-реквесты
|
||||
+1
@@ -0,0 +1 @@
|
||||
Список репозиториев
|
||||
+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 %}
|
||||
+1
@@ -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 %}
|
||||
+1
@@ -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 }}
|
||||
Reference in New Issue
Block a user