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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user