feat: locale-aware command templates, debounced auto-sync, entity pickers

- Locale-aware templates: CommandTemplateSlot now has a locale column,
  allowing each slot to have per-language variants (EN/RU). Templates
  are resolved at runtime from the Telegram user's language_code.

- Merged system configs: "Default Commands (EN)" and "(RU)" merged
  into a single "Default Commands" config with locale-aware slots.
  Migration handles existing data automatically.

- Configurable command descriptions: hardcoded COMMAND_DESCRIPTIONS
  replaced with desc_* template slots (desc_status, desc_help, etc.)
  that users can customize per locale. setMyCommands registers all
  locales explicitly.

- Removed locale from CommandConfig: no longer needed since locale
  is derived from the Telegram user's language at runtime.

- Debounced command auto-sync: after command config/tracker changes,
  affected bots are marked dirty and synced after a 30s debounce
  window. Manual "Sync with Telegram" button still works.

- Entity pickers in LinkedTargetsSection: replaced 6 plain <select>
  elements with EntitySelect components (search, icons, keyboard nav).
  Added onselect callback and size="sm" props to EntitySelect.
This commit is contained in:
2026-03-22 03:14:51 +03:00
parent 751097b347
commit 1167d138a3
47 changed files with 604 additions and 230 deletions
@@ -30,7 +30,7 @@ from ..database.models import (
TrackingConfig,
)
from .parser import parse_command
from .registry import COMMAND_DESCRIPTIONS, get_rate_category
from .registry import get_rate_category
_LOGGER = logging.getLogger(__name__)
@@ -53,13 +53,22 @@ def _check_rate_limit(bot_id: int, chat_id: str, cmd: str, limits: dict[str, int
return None
def _resolve_template(
templates: dict[str, dict[str, str]], slot_name: str, locale: str,
) -> str | None:
"""Pick a template string for slot+locale, falling back to 'en'."""
locale_map = templates.get(slot_name, {})
return locale_map.get(locale) or locale_map.get("en")
def _render_cmd_template(
templates: dict[str, str], slot_name: str, context: dict[str, Any]
templates: dict[str, dict[str, str]], slot_name: str, locale: str,
context: dict[str, Any],
) -> str:
"""Render a command template. Returns rendered string or error placeholder."""
template_str = templates.get(slot_name)
"""Render a locale-aware command template. Falls back to 'en'."""
template_str = _resolve_template(templates, slot_name, locale)
if not template_str:
_LOGGER.warning("No command template found for slot '%s'", slot_name)
_LOGGER.warning("No command template found for slot '%s' locale '%s'", slot_name, locale)
return f"[No template: {slot_name}]"
try:
from jinja2.sandbox import SandboxedEnvironment
@@ -73,10 +82,11 @@ def _render_cmd_template(
async def _resolve_command_context(
bot: TelegramBot,
) -> tuple[list[tuple[CommandTracker, CommandConfig, ServiceProvider]], dict[str, str]]:
) -> tuple[list[tuple[CommandTracker, CommandConfig, ServiceProvider]], 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}}.
"""
engine = get_engine()
async with AsyncSession(engine) as session:
@@ -106,7 +116,7 @@ async def _resolve_command_context(
tuples.append((tracker, config, provider))
# Load command template slots from the first config that has one
cmd_template_slots: dict[str, str] = {}
cmd_template_slots: dict[str, dict[str, str]] = {}
for _, config, _ in tuples:
if config.command_template_config_id:
slot_result = await session.exec(
@@ -114,7 +124,8 @@ async def _resolve_command_context(
CommandTemplateSlot.config_id == config.command_template_config_id
)
)
cmd_template_slots = {s.slot_name: s.template for s in slot_result.all()}
for s in slot_result.all():
cmd_template_slots.setdefault(s.slot_name, {})[s.locale] = s.template
if cmd_template_slots:
break
@@ -123,13 +134,13 @@ async def _resolve_command_context(
def _merge_command_context(
ctx: list[tuple[CommandTracker, CommandConfig, ServiceProvider]],
) -> tuple[list[str], str, str, int, dict[str, Any]]:
) -> tuple[list[str], str, int, dict[str, Any]]:
"""Merge enabled_commands from all configs and pick defaults from first config.
Returns (enabled_commands, locale, response_mode, default_count, rate_limits).
Returns (enabled_commands, response_mode, default_count, rate_limits).
"""
if not ctx:
return [], "en", "media", 5, {}
return [], "media", 5, {}
# Union of all enabled commands across configs
enabled: set[str] = set()
@@ -138,29 +149,40 @@ def _merge_command_context(
# Use first config's settings as defaults
first_config = ctx[0][1]
locale = first_config.locale or "en"
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), locale, response_mode, default_count, rate_limits
return sorted(enabled), response_mode, default_count, rate_limits
async def handle_command(
bot: TelegramBot,
chat_id: str,
text: str,
language_code: str = "",
) -> str | list[dict[str, Any]] | None:
"""Handle a bot command. Returns text response, media list, or None."""
"""Handle a bot command. Returns text response, media list, or None.
language_code is the Telegram user's language (from message.from.language_code).
Used to pick the right locale for template rendering.
"""
cmd, args, count_override = parse_command(text)
if not cmd:
return None
ctx_tuples, cmd_templates = await _resolve_command_context(bot)
enabled, locale, response_mode, default_count, rate_limits = _merge_command_context(ctx_tuples)
enabled, response_mode, default_count, rate_limits = _merge_command_context(ctx_tuples)
# Derive locale from Telegram user language, falling back to "en"
locale = language_code[:2].lower() if language_code else "en"
# Only use locale if we actually have templates for it, otherwise fall back
# (_render_cmd_template handles per-slot fallback, but let's normalize)
if locale not in ("en", "ru"):
locale = "en"
if cmd == "start":
return _render_cmd_template(cmd_templates, "start", {"locale": locale, "bot_name": bot.name})
return _render_cmd_template(cmd_templates, "start", locale, {"bot_name": bot.name})
if cmd not in enabled and cmd != "start":
return None # Silently ignore disabled commands
@@ -168,7 +190,7 @@ async def handle_command(
# Rate limit check
wait = _check_rate_limit(bot.id, chat_id, cmd, rate_limits)
if wait is not None:
return _render_cmd_template(cmd_templates, "rate_limited", {"wait": wait, "locale": locale})
return _render_cmd_template(cmd_templates, "rate_limited", locale, {"wait": wait})
count = min(count_override or default_count, 20)
@@ -179,7 +201,7 @@ async def handle_command(
# Dispatch — each handler returns template context dict
if cmd == "help":
ctx = _cmd_help(enabled, locale)
ctx = _cmd_help(enabled, locale, cmd_templates)
elif cmd == "status":
ctx = await _cmd_status(bot, providers_map, locale)
elif cmd == "albums":
@@ -194,14 +216,15 @@ async def handle_command(
else:
return None
return _render_cmd_template(cmd_templates, cmd, {**ctx, "locale": locale})
return _render_cmd_template(cmd_templates, cmd, locale, {**ctx})
def _cmd_help(enabled: list[str], locale: str) -> dict[str, Any]:
def _cmd_help(
enabled: list[str], locale: str, templates: dict[str, dict[str, str]],
) -> dict[str, Any]:
commands = []
for cmd in enabled:
desc = COMMAND_DESCRIPTIONS.get(cmd, {})
desc_text = desc.get(locale, desc.get("en", ""))
desc_text = _resolve_template(templates, f"desc_{cmd}", locale) or cmd
commands.append({"name": cmd, "description": desc_text})
return {"commands": commands}
@@ -330,11 +353,11 @@ async def _cmd_people(providers_map: dict[int, ServiceProvider], locale: str) ->
async def _cmd_immich(
bot: TelegramBot, cmd: str, args: str, count: int, locale: str,
response_mode: str, providers_map: dict[int, ServiceProvider],
cmd_templates: dict[str, str],
cmd_templates: dict[str, dict[str, str]],
) -> str | list[dict[str, Any]]:
"""Handle commands that need Immich API access and may return media."""
if not providers_map:
return _render_cmd_template(cmd_templates, "no_results", {"command": cmd, "query": args, "locale": locale})
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": cmd, "query": args})
# Get notification trackers for album data
provider_ids = set(providers_map.keys())
@@ -351,7 +374,7 @@ async def _cmd_immich(
provider = p
break
if not provider:
return _render_cmd_template(cmd_templates, "no_results", {"command": cmd, "query": args, "locale": locale})
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": cmd, "query": args})
async with aiohttp.ClientSession() as http:
immich = make_immich_provider(http, provider)
@@ -359,19 +382,19 @@ async def _cmd_immich(
if cmd == "search":
if not args:
return _render_cmd_template(cmd_templates, "no_results", {"command": cmd, "query": "", "locale": locale})
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": cmd, "query": ""})
assets = await client.search_smart(args, album_ids=all_album_ids, limit=count)
return _format_assets(assets, cmd, args, locale, response_mode, client, cmd_templates)
if cmd == "find":
if not args:
return _render_cmd_template(cmd_templates, "no_results", {"command": cmd, "query": "", "locale": locale})
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": cmd, "query": ""})
assets = await client.search_metadata(args, album_ids=all_album_ids, limit=count)
return _format_assets(assets, cmd, args, locale, response_mode, client, cmd_templates)
if cmd == "person":
if not args:
return _render_cmd_template(cmd_templates, "no_results", {"command": "person", "query": "", "locale": locale})
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": "person", "query": ""})
people = await client.get_people()
person_id = None
for pid, pname in people.items():
@@ -379,13 +402,13 @@ async def _cmd_immich(
person_id = pid
break
if not person_id:
return _render_cmd_template(cmd_templates, "no_results", {"command": "person", "query": args, "locale": locale})
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": "person", "query": args})
assets = await client.search_by_person(person_id, limit=count)
return _format_assets(assets, cmd, args, locale, response_mode, client, cmd_templates)
if cmd == "place":
if not args:
return _render_cmd_template(cmd_templates, "no_results", {"command": "place", "query": "", "locale": locale})
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": "place", "query": ""})
assets = await client.search_smart(
f"photos taken in {args}", album_ids=all_album_ids, limit=count
)
@@ -452,7 +475,7 @@ async def _cmd_immich(
albums_data.append({"name": album.name, "asset_count": album.asset_count, "id": album_id})
except Exception:
pass
return _render_cmd_template(cmd_templates, "summary", {"albums": albums_data, "locale": locale})
return _render_cmd_template(cmd_templates, "summary", locale, {"albums": albums_data})
if cmd == "memory":
# Check if any linked tracking config uses native memories
@@ -502,7 +525,7 @@ async def _cmd_immich(
memory_assets = memory_assets[:count]
if not memory_assets:
return _render_cmd_template(cmd_templates, "no_results", {"command": "memory", "query": "", "locale": locale})
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": "memory", "query": ""})
return _format_assets(memory_assets, cmd, "", locale, response_mode, client, cmd_templates)
return None
@@ -511,11 +534,11 @@ async def _cmd_immich(
def _format_assets(
assets: list[dict[str, Any]], cmd: str, query: str,
locale: str, response_mode: str, client: Any,
cmd_templates: dict[str, str],
cmd_templates: dict[str, dict[str, str]],
) -> str | list[dict[str, Any]]:
"""Format asset results as text or media payload."""
if not assets:
return _render_cmd_template(cmd_templates, "no_results", {"command": cmd, "query": query, "locale": locale})
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": cmd, "query": query})
if response_mode == "media":
media_items = []
@@ -536,8 +559,8 @@ def _format_assets(
# Text mode — render via template
slot_map = {"find": "search", "person": "search", "place": "search"}
slot_name = slot_map.get(cmd, cmd)
return _render_cmd_template(cmd_templates, slot_name, {
"assets": assets, "query": query, "command": cmd, "count": len(assets), "locale": locale,
return _render_cmd_template(cmd_templates, slot_name, locale, {
"assets": assets, "query": query, "command": cmd, "count": len(assets),
})
@@ -635,37 +658,49 @@ async def send_media_group(
async def register_commands_with_telegram(bot: TelegramBot) -> bool:
"""Register enabled commands with Telegram BotFather API."""
ctx_tuples, _ = await _resolve_command_context(bot)
enabled, locale, _, _, _ = _merge_command_context(ctx_tuples)
"""Register enabled commands with Telegram BotFather API.
commands = []
for cmd in enabled:
desc = COMMAND_DESCRIPTIONS.get(cmd, {})
commands.append({
"command": cmd,
"description": desc.get(locale, desc.get("en", cmd)),
})
Registers all supported locales explicitly with language_code,
plus English as the default fallback (no language_code).
Descriptions are read from desc_* template slots.
"""
ctx_tuples, templates = await _resolve_command_context(bot)
enabled, _, _, _ = _merge_command_context(ctx_tuples)
async with aiohttp.ClientSession() as http:
url = f"{TELEGRAM_API_BASE_URL}{bot.token}/setMyCommands"
payload: dict[str, Any] = {"commands": commands}
success = False
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})
# Register with explicit language_code
payload: dict[str, Any] = {"commands": commands, "language_code": locale}
try:
async with http.post(url, json=payload) as resp:
result = await resp.json()
if result.get("ok"):
success = True
else:
_LOGGER.warning("Failed to register commands for locale '%s': %s", locale, result.get("description"))
except aiohttp.ClientError as err:
_LOGGER.error("Failed to register commands for locale '%s': %s", locale, err)
# Also register English as the default (no language_code) for unsupported langs
en_commands = []
for cmd in enabled:
desc = _resolve_template(templates, f"desc_{cmd}", "en") or cmd
en_commands.append({"command": cmd, "description": desc})
try:
async with http.post(url, json=payload) as resp:
async with http.post(url, json={"commands": en_commands}) as resp:
result = await resp.json()
if result.get("ok"):
_LOGGER.info("Registered %d commands for bot @%s", len(commands), bot.bot_username)
# Also register for the other locale
other_locale = "ru" if locale == "en" else "en"
other_commands = [
{"command": c, "description": COMMAND_DESCRIPTIONS.get(c, {}).get(other_locale, c)}
for c in enabled
]
async with http.post(url, json={"commands": other_commands, "language_code": other_locale}) as r2:
pass
return True
_LOGGER.warning("Failed to register commands: %s", result.get("description"))
return False
_LOGGER.info("Registered %d commands for bot @%s (all locales)", len(en_commands), bot.bot_username)
success = True
except aiohttp.ClientError as err:
_LOGGER.error("Failed to register commands: %s", err)
return False
_LOGGER.error("Failed to register default commands: %s", err)
return success