refactor: comprehensive codebase review — security, performance, quality, UX
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
This commit is contained in:
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from functools import lru_cache
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
@@ -25,17 +26,21 @@ from ..database.models import (
|
||||
ServiceProvider,
|
||||
TelegramBot,
|
||||
)
|
||||
from .base import CommandResponse
|
||||
from .parser import parse_command
|
||||
from .registry import get_rate_category
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Singleton Jinja2 environment for template rendering (Phase 4d)
|
||||
_JINJA_ENV = SandboxedEnvironment(autoescape=False)
|
||||
_JINJA_ENV = SandboxedEnvironment(autoescape=True)
|
||||
|
||||
# Rate limit state with automatic TTL expiry (Phase 4e)
|
||||
_rate_limits: TTLCache = TTLCache(maxsize=10000, ttl=3600)
|
||||
|
||||
# Maximum responses per command to avoid Telegram rate limits
|
||||
_MAX_RESPONSES_PER_COMMAND = 5
|
||||
|
||||
|
||||
def _check_rate_limit(bot_id: int, chat_id: str, cmd: str, limits: dict[str, int]) -> int | None:
|
||||
"""Check rate limit. Returns seconds to wait, or None if OK."""
|
||||
@@ -60,6 +65,12 @@ def _resolve_template(
|
||||
return locale_map.get(locale) or locale_map.get("en")
|
||||
|
||||
|
||||
@lru_cache(maxsize=256)
|
||||
def _compile_template(template_str: str):
|
||||
"""Cache compiled Jinja2 templates to avoid re-parsing identical strings."""
|
||||
return _JINJA_ENV.from_string(template_str)
|
||||
|
||||
|
||||
def _render_cmd_template(
|
||||
templates: dict[str, dict[str, str]], slot_name: str, locale: str,
|
||||
context: dict[str, Any],
|
||||
@@ -70,20 +81,28 @@ def _render_cmd_template(
|
||||
_LOGGER.warning("No command template found for slot '%s' locale '%s'", slot_name, locale)
|
||||
return f"[No template: {slot_name}]"
|
||||
try:
|
||||
tmpl = _JINJA_ENV.from_string(template_str)
|
||||
tmpl = _compile_template(template_str)
|
||||
return tmpl.render(**context)
|
||||
except Exception as e:
|
||||
_LOGGER.warning("Failed to render command template '%s': %s", slot_name, e)
|
||||
return f"[Template error: {slot_name}]"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Context resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _resolve_command_context(
|
||||
bot: TelegramBot,
|
||||
) -> tuple[list[tuple[CommandTracker, CommandConfig, ServiceProvider]], dict[str, dict[str, str]]]:
|
||||
) -> tuple[
|
||||
list[tuple[CommandTracker, CommandConfig, ServiceProvider]],
|
||||
dict[int, dict[str, dict[str, str]]],
|
||||
]:
|
||||
"""Resolve all enabled command trackers, configs, and providers for a bot.
|
||||
|
||||
Returns (context_tuples, cmd_template_slots).
|
||||
cmd_template_slots is {slot_name: {locale: template}}.
|
||||
Returns:
|
||||
(context_tuples, templates_by_config_id)
|
||||
templates_by_config_id is {command_template_config_id: {slot_name: {locale: template}}}.
|
||||
"""
|
||||
engine = get_engine()
|
||||
async with AsyncSession(engine) as session:
|
||||
@@ -142,8 +161,8 @@ async def _resolve_command_context(
|
||||
continue
|
||||
tuples.append((tracker, config, provider))
|
||||
|
||||
# Load command template slots — merge from all configs
|
||||
cmd_template_slots: dict[str, dict[str, str]] = {}
|
||||
# Load command template slots per config (not merged)
|
||||
templates_by_config_id: dict[int, dict[str, dict[str, str]]] = {}
|
||||
seen_config_ids: set[int] = set()
|
||||
for _, config, _ in tuples:
|
||||
cfg_id = config.command_template_config_id
|
||||
@@ -154,98 +173,136 @@ async def _resolve_command_context(
|
||||
CommandTemplateSlot.config_id == cfg_id
|
||||
)
|
||||
)
|
||||
slots: dict[str, dict[str, str]] = {}
|
||||
for s in slot_result.all():
|
||||
cmd_template_slots.setdefault(s.slot_name, {})[s.locale] = s.template
|
||||
slots.setdefault(s.slot_name, {})[s.locale] = s.template
|
||||
templates_by_config_id[cfg_id] = slots
|
||||
|
||||
return tuples, cmd_template_slots
|
||||
return tuples, templates_by_config_id
|
||||
|
||||
|
||||
def _merge_command_context(
|
||||
def _templates_for_config(
|
||||
templates_by_config_id: dict[int, dict[str, dict[str, str]]],
|
||||
config: CommandConfig,
|
||||
) -> dict[str, dict[str, str]]:
|
||||
"""Get template slots for a specific command config."""
|
||||
cfg_id = config.command_template_config_id
|
||||
if cfg_id and cfg_id in templates_by_config_id:
|
||||
return templates_by_config_id[cfg_id]
|
||||
return {}
|
||||
|
||||
|
||||
def _merge_all_templates(
|
||||
templates_by_config_id: dict[int, dict[str, dict[str, str]]],
|
||||
) -> dict[str, dict[str, str]]:
|
||||
"""Merge all template config slots into one dict (for universal commands)."""
|
||||
merged: dict[str, dict[str, str]] = {}
|
||||
for slots in templates_by_config_id.values():
|
||||
for slot_name, locale_map in slots.items():
|
||||
merged.setdefault(slot_name, {}).update(locale_map)
|
||||
return merged
|
||||
|
||||
|
||||
def _merge_enabled_commands(
|
||||
ctx: list[tuple[CommandTracker, CommandConfig, ServiceProvider]],
|
||||
) -> tuple[list[str], str, int, dict[str, Any]]:
|
||||
"""Merge enabled_commands from all configs and pick defaults from first config."""
|
||||
) -> tuple[list[str], dict[str, Any]]:
|
||||
"""Merge enabled_commands (union) and rate_limits from all configs.
|
||||
|
||||
Rate limits use the most restrictive (minimum) cooldown per category.
|
||||
"""
|
||||
if not ctx:
|
||||
return [], "media", 5, {}
|
||||
return [], {}
|
||||
|
||||
enabled: set[str] = set()
|
||||
merged_limits: dict[str, int] = {}
|
||||
for _, config, _ in ctx:
|
||||
enabled.update(config.enabled_commands or [])
|
||||
for category, cooldown in (config.rate_limits or {}).items():
|
||||
if category not in merged_limits:
|
||||
merged_limits[category] = cooldown
|
||||
else:
|
||||
merged_limits[category] = min(merged_limits[category], cooldown)
|
||||
|
||||
first_config = ctx[0][1]
|
||||
response_mode = first_config.response_mode or "media"
|
||||
default_count = first_config.default_count or 5
|
||||
rate_limits = first_config.rate_limits or {}
|
||||
return sorted(enabled), merged_limits
|
||||
|
||||
return sorted(enabled), response_mode, default_count, rate_limits
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main dispatcher
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def handle_command(
|
||||
bot: TelegramBot,
|
||||
chat_id: str,
|
||||
text: str,
|
||||
language_code: str = "",
|
||||
) -> str | list[dict[str, Any]] | None:
|
||||
) -> list[CommandResponse] | None:
|
||||
"""Handle a bot command. Routes to provider-specific handlers.
|
||||
|
||||
Returns text response, media list, or None.
|
||||
Returns a list of CommandResponse objects (one per tracker), or None.
|
||||
Universal commands (/start, /help) return a single-element list.
|
||||
Provider-specific commands dispatch per-tracker with per-tracker config.
|
||||
"""
|
||||
cmd, args, count_override = parse_command(text)
|
||||
if not cmd:
|
||||
return None
|
||||
|
||||
ctx_tuples, cmd_templates = await _resolve_command_context(bot)
|
||||
enabled, response_mode, default_count, rate_limits = _merge_command_context(ctx_tuples)
|
||||
ctx_tuples, templates_by_config_id = await _resolve_command_context(bot)
|
||||
enabled, rate_limits = _merge_enabled_commands(ctx_tuples)
|
||||
|
||||
locale = language_code[:2].lower() if language_code else "en"
|
||||
if locale not in ("en", "ru"):
|
||||
locale = "en"
|
||||
|
||||
# Merged templates for universal commands
|
||||
merged_templates = _merge_all_templates(templates_by_config_id)
|
||||
|
||||
if cmd == "start":
|
||||
return _render_cmd_template(cmd_templates, "start", locale, {"bot_name": bot.name})
|
||||
text_resp = _render_cmd_template(merged_templates, "start", locale, {"bot_name": bot.name})
|
||||
return [CommandResponse(text=text_resp)]
|
||||
|
||||
if cmd not in enabled and cmd != "start":
|
||||
return None
|
||||
|
||||
# Rate limit check
|
||||
# Rate limit check (once per command, shared across all trackers)
|
||||
wait = _check_rate_limit(bot.id, chat_id, cmd, rate_limits)
|
||||
if wait is not None:
|
||||
return _render_cmd_template(cmd_templates, "rate_limited", locale, {"wait": wait})
|
||||
text_resp = _render_cmd_template(merged_templates, "rate_limited", locale, {"wait": wait})
|
||||
return [CommandResponse(text=text_resp)]
|
||||
|
||||
count = min(count_override or default_count, 20)
|
||||
|
||||
# Build providers map from command context
|
||||
providers_map: dict[int, ServiceProvider] = {}
|
||||
for _, _, provider in ctx_tuples:
|
||||
providers_map[provider.id] = provider
|
||||
|
||||
# Universal commands
|
||||
# Universal commands — single merged response
|
||||
if cmd == "help":
|
||||
ctx = _cmd_help(enabled, locale, cmd_templates)
|
||||
return _render_cmd_template(cmd_templates, "help", locale, ctx)
|
||||
ctx = _cmd_help(enabled, locale, merged_templates)
|
||||
text_resp = _render_cmd_template(merged_templates, "help", locale, ctx)
|
||||
return [CommandResponse(text=text_resp)]
|
||||
|
||||
# Provider-specific dispatch
|
||||
# Provider-specific dispatch — per-tracker
|
||||
from .dispatch import get_handler
|
||||
|
||||
# Group ctx_tuples by provider type
|
||||
by_type: dict[str, list[tuple[CommandTracker, CommandConfig, ServiceProvider]]] = {}
|
||||
for t in ctx_tuples:
|
||||
ptype = t[2].type
|
||||
by_type.setdefault(ptype, []).append(t)
|
||||
|
||||
# Find which handler claims this command
|
||||
for ptype, ptuples in by_type.items():
|
||||
handler = get_handler(ptype)
|
||||
if handler and cmd in handler.get_provider_commands():
|
||||
# Build provider map filtered to this provider type
|
||||
pmap = {p.id: p for _, _, p in ptuples}
|
||||
result = await handler.handle(
|
||||
cmd, args, count, locale, response_mode,
|
||||
pmap, cmd_templates, bot, ptuples,
|
||||
responses: list[CommandResponse] = []
|
||||
for tracker, config, provider in ctx_tuples:
|
||||
if len(responses) >= _MAX_RESPONSES_PER_COMMAND:
|
||||
_LOGGER.warning(
|
||||
"Truncated command responses at %d for bot %d cmd /%s",
|
||||
_MAX_RESPONSES_PER_COMMAND, bot.id, cmd,
|
||||
)
|
||||
if result is not None:
|
||||
return result
|
||||
break
|
||||
|
||||
return None
|
||||
handler = get_handler(provider.type)
|
||||
if not handler or cmd not in handler.get_provider_commands():
|
||||
continue
|
||||
|
||||
tracker_templates = _templates_for_config(templates_by_config_id, config)
|
||||
count = min(count_override or config.default_count or 5, 20)
|
||||
response_mode = config.response_mode or "media"
|
||||
|
||||
result = await handler.handle(
|
||||
cmd, args, count, locale, response_mode,
|
||||
provider, tracker_templates, bot, tracker, config,
|
||||
)
|
||||
if result is not None:
|
||||
responses.append(result)
|
||||
|
||||
return responses if responses else None
|
||||
|
||||
|
||||
def _cmd_help(
|
||||
@@ -283,17 +340,13 @@ async def send_reply(
|
||||
session: aiohttp.ClientSession | None = None,
|
||||
) -> None:
|
||||
"""Send a text reply via TelegramClient."""
|
||||
async def _send(http: aiohttp.ClientSession) -> None:
|
||||
client = TelegramClient(http, bot_token)
|
||||
result = await client.send_message(chat_id, text, reply_to_message_id=reply_to_message_id)
|
||||
if not result.get("success"):
|
||||
_LOGGER.warning("Telegram reply failed: %s", result.get("error"))
|
||||
|
||||
if session is not None:
|
||||
await _send(session)
|
||||
else:
|
||||
async with aiohttp.ClientSession() as http:
|
||||
await _send(http)
|
||||
if session is None:
|
||||
from ..services.http_session import get_http_session
|
||||
session = await get_http_session()
|
||||
client = TelegramClient(session, bot_token)
|
||||
result = await client.send_message(chat_id, text, reply_to_message_id=reply_to_message_id)
|
||||
if not result.get("success"):
|
||||
_LOGGER.warning("Telegram reply failed: %s", result.get("error"))
|
||||
|
||||
|
||||
async def send_media_group(
|
||||
@@ -319,52 +372,50 @@ async def send_media_group(
|
||||
captions = [item.get("caption", "") for item in media_items if item.get("caption")]
|
||||
caption = "\n".join(captions) if captions else None
|
||||
|
||||
async def _send(http: aiohttp.ClientSession) -> None:
|
||||
client = TelegramClient(http, bot_token)
|
||||
result = await client.send_notification(
|
||||
chat_id, assets=assets, caption=caption,
|
||||
reply_to_message_id=reply_to_message_id,
|
||||
chat_action=None,
|
||||
)
|
||||
if not result.get("success"):
|
||||
_LOGGER.warning("Telegram media group failed: %s", result.get("error"))
|
||||
|
||||
if session is not None:
|
||||
await _send(session)
|
||||
else:
|
||||
async with aiohttp.ClientSession() as http:
|
||||
await _send(http)
|
||||
if session is None:
|
||||
from ..services.http_session import get_http_session
|
||||
session = await get_http_session()
|
||||
client = TelegramClient(session, bot_token)
|
||||
result = await client.send_notification(
|
||||
chat_id, assets=assets, caption=caption,
|
||||
reply_to_message_id=reply_to_message_id,
|
||||
chat_action=None,
|
||||
)
|
||||
if not result.get("success"):
|
||||
_LOGGER.warning("Telegram media group failed: %s", result.get("error"))
|
||||
|
||||
|
||||
async def register_commands_with_telegram(bot: TelegramBot) -> bool:
|
||||
"""Register enabled commands with Telegram BotFather API via TelegramClient."""
|
||||
ctx_tuples, templates = await _resolve_command_context(bot)
|
||||
enabled, _, _, _ = _merge_command_context(ctx_tuples)
|
||||
ctx_tuples, templates_by_config_id = await _resolve_command_context(bot)
|
||||
enabled, _ = _merge_enabled_commands(ctx_tuples)
|
||||
templates = _merge_all_templates(templates_by_config_id)
|
||||
|
||||
async with aiohttp.ClientSession() as http:
|
||||
client = TelegramClient(http, bot.token)
|
||||
success = False
|
||||
from ..services.http_session import get_http_session
|
||||
http = await get_http_session()
|
||||
client = TelegramClient(http, bot.token)
|
||||
success = False
|
||||
|
||||
# Register per-locale commands
|
||||
for locale in ("en", "ru"):
|
||||
commands = []
|
||||
for cmd in enabled:
|
||||
desc = _resolve_template(templates, f"desc_{cmd}", locale) or cmd
|
||||
commands.append({"command": cmd, "description": desc})
|
||||
result = await client.set_my_commands(commands, language_code=locale)
|
||||
if result.get("success"):
|
||||
success = True
|
||||
else:
|
||||
_LOGGER.warning("Failed to register commands for locale '%s': %s", locale, result.get("error"))
|
||||
|
||||
# Register default (no language_code) with EN descriptions
|
||||
en_commands = []
|
||||
# Register per-locale commands
|
||||
for locale in ("en", "ru"):
|
||||
commands = []
|
||||
for cmd in enabled:
|
||||
desc = _resolve_template(templates, f"desc_{cmd}", "en") or cmd
|
||||
en_commands.append({"command": cmd, "description": desc})
|
||||
result = await client.set_my_commands(en_commands)
|
||||
desc = _resolve_template(templates, f"desc_{cmd}", locale) or cmd
|
||||
commands.append({"command": cmd, "description": desc})
|
||||
result = await client.set_my_commands(commands, language_code=locale)
|
||||
if result.get("success"):
|
||||
_LOGGER.info("Registered %d commands for bot @%s (all locales)", len(en_commands), bot.bot_username)
|
||||
success = True
|
||||
else:
|
||||
_LOGGER.warning("Failed to register commands for locale '%s': %s", locale, result.get("error"))
|
||||
|
||||
return success
|
||||
# Register default (no language_code) with EN descriptions
|
||||
en_commands = []
|
||||
for cmd in enabled:
|
||||
desc = _resolve_template(templates, f"desc_{cmd}", "en") or cmd
|
||||
en_commands.append({"command": cmd, "description": desc})
|
||||
result = await client.set_my_commands(en_commands)
|
||||
if result.get("success"):
|
||||
_LOGGER.info("Registered %d commands for bot @%s (all locales)", len(en_commands), bot.bot_username)
|
||||
success = True
|
||||
|
||||
return success
|
||||
|
||||
Reference in New Issue
Block a user