b803d004e1
Security: - Fix NUT protocol command injection (validate names against safe regex) - Enable Jinja2 autoescape=True to prevent HTML injection via external data - Add WebhookProviderConfig validation model Performance: - Shared aiohttp.ClientSession singleton (replaces 40+ per-request sessions) - Fix 4 N+1 queries with batch IN loads (poller, scheduler, memory, broadcast) - asyncio.gather for Gitea commands and notification dispatcher - Add DB indexes on NotificationTrackerState.tracker_id, CommandTrackerListener - LRU cache for compiled Jinja2 templates - Daily EventLog cleanup job (90-day retention) - 30s HTTP timeout on all external calls - GROUP BY for target type counts (replaces 7 sequential queries) Code quality: - Extract get_owned_entity() helper (replaces 11 duplicate functions) - Extract slot_helpers.py (load_slots, save_slots, render_template_preview) - Extract command_utils.py (tracker lookup, last event, collection IDs) - Extract http_session.py (shared session lifecycle) - Provider connection validation dedup (3x → 1 helper) - Command dispatch tables replacing if/elif chains - Album+links fetch helper (fetch_albums_with_links) - Provider dispatch polymorphism (list_provider_collections) - Immutable _enrich_assets (no longer mutates in-place) - Fix _format_assets return type + handler unpacking Frontend: - Fix 18+ hardcoded English strings → t() with new i18n keys (en + ru) - Mobile "More" nav panel with provider filter and search - Shared Button.svelte component (4 variants, 2 sizes) - Shared ErrorBanner.svelte component (8 pages updated) - SvelteKit goto() replacing window.location.href - Dashboard grid fixed for 4 cards, paginator opacity consistency Functionality: - max_instances=1 on scheduler jobs (prevents duplicate events) - Webhook provider in watcher (prevents error spam) - Fix stale SQLModel reference in poller - Gitea get_repo() direct API call
248 lines
8.9 KiB
Python
248 lines
8.9 KiB
Python
"""Gitea-specific bot command handler."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
from collections.abc import Callable, Coroutine
|
|
from typing import Any
|
|
|
|
from ..database.models import (
|
|
CommandConfig, CommandTracker, ServiceProvider, TelegramBot,
|
|
)
|
|
from ..services import make_gitea_provider
|
|
from ..services.http_session import get_http_session
|
|
from .base import CommandResponse, ProviderCommandHandler
|
|
from .command_utils import get_last_event_str, get_tracked_collection_ids, get_trackers_for_provider
|
|
from .handler import _render_cmd_template
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
_GITEA_COMMANDS = {"status", "repos", "issues", "prs", "commits"}
|
|
|
|
|
|
def _get_tracked_repos(
|
|
provider: ServiceProvider,
|
|
trackers: list,
|
|
) -> list[tuple[ServiceProvider, str, str]]:
|
|
"""Get (provider, owner, repo) tuples from tracked collection_ids."""
|
|
if not provider.config.get("api_token"):
|
|
return []
|
|
collection_ids = get_tracked_collection_ids(provider, trackers)
|
|
repos: list[tuple[ServiceProvider, str, str]] = []
|
|
for full_name in collection_ids:
|
|
parts = full_name.split("/", 1)
|
|
if len(parts) == 2:
|
|
repos.append((provider, parts[0], parts[1]))
|
|
return repos
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Command dispatch table
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_TEXT_COMMANDS: dict[str, Callable[..., Coroutine[Any, Any, dict[str, Any]]]] = {}
|
|
|
|
|
|
def _text_cmd(fn: Callable[..., Coroutine[Any, Any, dict[str, Any]]]) -> Callable[..., Coroutine[Any, Any, dict[str, Any]]]:
|
|
"""Register a function in the text command dispatch table."""
|
|
name = fn.__name__.removeprefix("_cmd_")
|
|
_TEXT_COMMANDS[name] = fn
|
|
return fn
|
|
|
|
|
|
class GiteaCommandHandler(ProviderCommandHandler):
|
|
"""Handles Gitea-specific bot commands."""
|
|
|
|
provider_type = "gitea"
|
|
|
|
def get_provider_commands(self) -> set[str]:
|
|
return _GITEA_COMMANDS
|
|
|
|
def get_rate_categories(self) -> dict[str, str]:
|
|
return {
|
|
"repos": "api", "issues": "api",
|
|
"prs": "api", "commits": "api",
|
|
}
|
|
|
|
async def handle(
|
|
self,
|
|
cmd: str,
|
|
args: str,
|
|
count: int,
|
|
locale: str,
|
|
response_mode: str,
|
|
provider: ServiceProvider,
|
|
cmd_templates: dict[str, dict[str, str]],
|
|
bot: TelegramBot,
|
|
tracker: CommandTracker,
|
|
config: CommandConfig,
|
|
) -> CommandResponse | None:
|
|
fn = _TEXT_COMMANDS.get(cmd)
|
|
if fn is None:
|
|
return None
|
|
ctx = await fn(provider, count)
|
|
return CommandResponse(text=_render_cmd_template(cmd_templates, cmd, locale, ctx))
|
|
|
|
|
|
@_text_cmd
|
|
async def _cmd_status(provider: ServiceProvider, count: int) -> dict[str, Any]:
|
|
trackers = await get_trackers_for_provider(provider.id)
|
|
tracked_repos = _get_tracked_repos(provider, trackers)
|
|
|
|
# Get server version
|
|
server_version = "unknown"
|
|
if provider.config.get("api_token"):
|
|
http = await get_http_session()
|
|
gitea = make_gitea_provider(http, provider)
|
|
version = await gitea.client.get_server_version()
|
|
if version:
|
|
server_version = version
|
|
|
|
tracker_ids = [t.id for t in trackers]
|
|
last_str = await get_last_event_str(tracker_ids)
|
|
|
|
return {
|
|
"repos_count": len(tracked_repos),
|
|
"server_version": server_version,
|
|
"last_event": last_str,
|
|
}
|
|
|
|
|
|
@_text_cmd
|
|
async def _cmd_repos(provider: ServiceProvider, count: int) -> dict[str, Any]:
|
|
trackers = await get_trackers_for_provider(provider.id)
|
|
tracked_repos = _get_tracked_repos(provider, trackers)
|
|
|
|
repos_data: list[dict[str, Any]] = []
|
|
http = await get_http_session()
|
|
|
|
async def _fetch_repo(prov: ServiceProvider, owner: str, repo: str) -> dict[str, Any]:
|
|
gitea = make_gitea_provider(http, prov)
|
|
# Use direct get_repo endpoint instead of listing all repos
|
|
r = await gitea.client.get_repo(owner, repo)
|
|
if r:
|
|
return {
|
|
"full_name": r.get("full_name", ""),
|
|
"description": r.get("description", ""),
|
|
"stars": r.get("stars_count", 0),
|
|
"url": r.get("html_url", ""),
|
|
}
|
|
return {
|
|
"full_name": f"{owner}/{repo}",
|
|
"description": "",
|
|
"stars": 0,
|
|
"url": "",
|
|
}
|
|
|
|
tasks = [_fetch_repo(prov, owner, repo) for prov, owner, repo in tracked_repos]
|
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
for (prov, owner, repo), result in zip(tracked_repos, results):
|
|
if isinstance(result, Exception):
|
|
_LOGGER.warning("Failed to fetch repo %s/%s: %s", owner, repo, result)
|
|
repos_data.append({
|
|
"full_name": f"{owner}/{repo}",
|
|
"description": "?",
|
|
"stars": 0,
|
|
"url": "",
|
|
})
|
|
else:
|
|
repos_data.append(result)
|
|
|
|
return {"repos": repos_data}
|
|
|
|
|
|
@_text_cmd
|
|
async def _cmd_issues(provider: ServiceProvider, count: int) -> dict[str, Any]:
|
|
trackers = await get_trackers_for_provider(provider.id)
|
|
tracked_repos = _get_tracked_repos(provider, trackers)
|
|
|
|
all_issues: list[dict[str, Any]] = []
|
|
http = await get_http_session()
|
|
|
|
async def _fetch_issues(prov: ServiceProvider, owner: str, repo: str) -> list[dict[str, Any]]:
|
|
gitea = make_gitea_provider(http, prov)
|
|
return await gitea.client.get_repo_issues(owner, repo, limit=count)
|
|
|
|
tasks = [_fetch_issues(prov, owner, repo) for prov, owner, repo in tracked_repos]
|
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
for (prov, owner, repo), result in zip(tracked_repos, results):
|
|
if isinstance(result, Exception):
|
|
_LOGGER.warning("Failed to fetch issues for %s/%s: %s", owner, repo, result)
|
|
continue
|
|
for issue in result:
|
|
all_issues.append({
|
|
"repo": f"{owner}/{repo}",
|
|
"number": issue.get("number", 0),
|
|
"title": issue.get("title", ""),
|
|
"url": issue.get("html_url", ""),
|
|
"user": issue.get("user", {}).get("login", ""),
|
|
"state": issue.get("state", ""),
|
|
})
|
|
|
|
all_issues.sort(key=lambda i: i.get("number", 0), reverse=True)
|
|
return {"issues": all_issues[:count]}
|
|
|
|
|
|
@_text_cmd
|
|
async def _cmd_prs(provider: ServiceProvider, count: int) -> dict[str, Any]:
|
|
trackers = await get_trackers_for_provider(provider.id)
|
|
tracked_repos = _get_tracked_repos(provider, trackers)
|
|
|
|
all_prs: list[dict[str, Any]] = []
|
|
http = await get_http_session()
|
|
|
|
async def _fetch_prs(prov: ServiceProvider, owner: str, repo: str) -> list[dict[str, Any]]:
|
|
gitea = make_gitea_provider(http, prov)
|
|
return await gitea.client.get_repo_pulls(owner, repo, limit=count)
|
|
|
|
tasks = [_fetch_prs(prov, owner, repo) for prov, owner, repo in tracked_repos]
|
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
for (prov, owner, repo), result in zip(tracked_repos, results):
|
|
if isinstance(result, Exception):
|
|
_LOGGER.warning("Failed to fetch PRs for %s/%s: %s", owner, repo, result)
|
|
continue
|
|
for pr in result:
|
|
all_prs.append({
|
|
"repo": f"{owner}/{repo}",
|
|
"number": pr.get("number", 0),
|
|
"title": pr.get("title", ""),
|
|
"url": pr.get("html_url", ""),
|
|
"user": pr.get("user", {}).get("login", ""),
|
|
"state": pr.get("state", ""),
|
|
})
|
|
|
|
all_prs.sort(key=lambda p: p.get("number", 0), reverse=True)
|
|
return {"prs": all_prs[:count]}
|
|
|
|
|
|
@_text_cmd
|
|
async def _cmd_commits(provider: ServiceProvider, count: int) -> dict[str, Any]:
|
|
trackers = await get_trackers_for_provider(provider.id)
|
|
tracked_repos = _get_tracked_repos(provider, trackers)
|
|
|
|
all_commits: list[dict[str, Any]] = []
|
|
http = await get_http_session()
|
|
|
|
async def _fetch_commits(prov: ServiceProvider, owner: str, repo: str) -> list[dict[str, Any]]:
|
|
gitea = make_gitea_provider(http, prov)
|
|
return await gitea.client.get_repo_commits(owner, repo, limit=count)
|
|
|
|
tasks = [_fetch_commits(prov, owner, repo) for prov, owner, repo in tracked_repos]
|
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
for (prov, owner, repo), result in zip(tracked_repos, results):
|
|
if isinstance(result, Exception):
|
|
_LOGGER.warning("Failed to fetch commits for %s/%s: %s", owner, repo, result)
|
|
continue
|
|
for c in result:
|
|
commit_data = c.get("commit", {})
|
|
all_commits.append({
|
|
"repo": f"{owner}/{repo}",
|
|
"short_id": c.get("sha", "")[:7],
|
|
"message": commit_data.get("message", "").split("\n")[0][:80],
|
|
"author": commit_data.get("author", {}).get("name", ""),
|
|
"url": c.get("html_url", ""),
|
|
})
|
|
|
|
return {"commits": all_commits[:count]}
|