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
@@ -22,7 +22,6 @@ class CommandConfigCreate(BaseModel):
name: str
icon: str = ""
enabled_commands: list[str] = []
locale: str = "en"
response_mode: str = "media"
default_count: int = 5
rate_limits: dict[str, Any] = {}
@@ -33,7 +32,6 @@ class CommandConfigUpdate(BaseModel):
name: str | None = None
icon: str | None = None
enabled_commands: list[str] | None = None
locale: str | None = None
response_mode: str | None = None
default_count: int | None = None
rate_limits: dict[str, Any] | None = None
@@ -70,11 +68,8 @@ async def create_command_config(
data = body.model_dump()
# Auto-assign system default template if none specified
if not data.get("command_template_config_id"):
locale = data.get("locale", "en")
provider_type = data.get("provider_type", "immich")
default_tpl = await _find_system_default_template(
session, provider_type, locale
)
default_tpl = await _find_system_default_template(session, provider_type)
if default_tpl:
data["command_template_config_id"] = default_tpl.id
@@ -114,6 +109,11 @@ async def update_command_config(
session.add(config)
await session.commit()
await session.refresh(config)
# Mark affected bots dirty for debounced auto-sync
from ..services.command_sync import mark_dirty_for_config
await mark_dirty_for_config(config.id)
return _config_response(config)
@@ -127,6 +127,11 @@ async def delete_command_config(
from .delete_protection import check_command_config, raise_if_used
config = await _get_user_config(session, config_id, user.id)
raise_if_used(await check_command_config(session, config.id), config.name)
# Mark affected bots dirty before deleting
from ..services.command_sync import mark_dirty_for_config
await mark_dirty_for_config(config.id)
await session.delete(config)
await session.commit()
@@ -142,7 +147,6 @@ def _config_response(c: CommandConfig) -> dict:
"name": c.name,
"icon": c.icon,
"enabled_commands": c.enabled_commands or [],
"locale": c.locale,
"response_mode": c.response_mode,
"default_count": c.default_count,
"rate_limits": c.rate_limits or {},
@@ -161,11 +165,9 @@ async def _get_user_config(
async def _find_system_default_template(
session: AsyncSession, provider_type: str, locale: str
session: AsyncSession, provider_type: str,
) -> CommandTemplateConfig | None:
"""Find a system default (user_id=0) command template matching provider + locale."""
# Try exact locale match first (e.g. "Default Commands (EN)" for locale "en")
locale_upper = locale.upper()
"""Find a system default (user_id=0) command template matching provider."""
result = await session.exec(
select(CommandTemplateConfig).where(
CommandTemplateConfig.user_id == 0,
@@ -173,13 +175,4 @@ async def _find_system_default_template(
)
)
templates = result.all()
# Match by locale column first, fall back to name suffix
locale_lower = locale_upper.lower()
for tpl in templates:
if tpl.locale == locale_lower:
return tpl
for tpl in templates:
if f"({locale_upper})" in tpl.name:
return tpl
# Fallback: return first system template for this provider
return templates[0] if templates else None
@@ -29,45 +29,53 @@ class CommandTemplateConfigCreate(BaseModel):
name: str
description: str | None = None
icon: str | None = None
slots: dict[str, str] = {} # slot_name -> template text
slots: dict[str, dict[str, str]] = {} # slot_name -> {locale -> template}
class CommandTemplateConfigUpdate(BaseModel):
name: str | None = None
description: str | None = None
icon: str | None = None
slots: dict[str, str] | None = None
slots: dict[str, dict[str, str]] | None = None
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
async def _load_slots(session: AsyncSession, config_id: int) -> dict[str, str]:
async def _load_slots(session: AsyncSession, config_id: int) -> dict[str, dict[str, str]]:
"""Load slots as {slot_name: {locale: template}}."""
result = await session.exec(
select(CommandTemplateSlot).where(CommandTemplateSlot.config_id == config_id)
)
return {s.slot_name: s.template for s in result.all()}
nested: dict[str, dict[str, str]] = {}
for s in result.all():
nested.setdefault(s.slot_name, {})[s.locale] = s.template
return nested
async def _save_slots(session: AsyncSession, config_id: int, slots: dict[str, str]) -> None:
for slot_name, template_text in slots.items():
result = await session.exec(
select(CommandTemplateSlot).where(
CommandTemplateSlot.config_id == config_id,
CommandTemplateSlot.slot_name == slot_name,
async def _save_slots(session: AsyncSession, config_id: int, slots: dict[str, dict[str, str]]) -> None:
"""Save slots from {slot_name: {locale: template}} format."""
for slot_name, locale_map in slots.items():
for locale, template_text in locale_map.items():
result = await session.exec(
select(CommandTemplateSlot).where(
CommandTemplateSlot.config_id == config_id,
CommandTemplateSlot.slot_name == slot_name,
CommandTemplateSlot.locale == locale,
)
)
)
existing = result.first()
if existing:
existing.template = template_text
session.add(existing)
else:
session.add(CommandTemplateSlot(
config_id=config_id,
slot_name=slot_name,
template=template_text,
))
existing = result.first()
if existing:
existing.template = template_text
session.add(existing)
else:
session.add(CommandTemplateSlot(
config_id=config_id,
slot_name=slot_name,
locale=locale,
template=template_text,
))
async def _response(session: AsyncSession, c: CommandTemplateConfig) -> dict[str, Any]:
@@ -87,6 +87,11 @@ async def create_command_tracker(
session.add(tracker)
await session.commit()
await session.refresh(tracker)
# Mark affected bots dirty for debounced auto-sync
from ..services.command_sync import mark_dirty_for_tracker
await mark_dirty_for_tracker(tracker.id)
return await _tracker_response(session, tracker)
@@ -130,6 +135,11 @@ async def update_command_tracker(
session.add(tracker)
await session.commit()
await session.refresh(tracker)
# Mark affected bots dirty for debounced auto-sync
from ..services.command_sync import mark_dirty_for_tracker
await mark_dirty_for_tracker(tracker.id)
return await _tracker_response(session, tracker)
@@ -142,6 +152,10 @@ async def delete_command_tracker(
"""Delete a command tracker and cascade delete its listeners."""
tracker = await _get_user_tracker(session, tracker_id, user.id)
# Mark affected bots dirty before deleting (chain breaks after deletion)
from ..services.command_sync import mark_dirty_for_tracker
await mark_dirty_for_tracker(tracker.id)
# Delete associated listeners, collecting bot IDs for polling cleanup
result = await session.exec(
select(CommandTrackerListener).where(
@@ -177,6 +191,10 @@ async def enable_command_tracker(
await session.commit()
await session.refresh(tracker)
# Mark affected bots dirty for debounced auto-sync
from ..services.command_sync import mark_dirty_for_tracker
await mark_dirty_for_tracker(tracker.id)
# Start polling for any telegram bot listeners
lr = await session.exec(
select(CommandTrackerListener).where(
@@ -204,6 +222,10 @@ async def disable_command_tracker(
await session.commit()
await session.refresh(tracker)
# Mark affected bots dirty for debounced auto-sync
from ..services.command_sync import mark_dirty_for_tracker
await mark_dirty_for_tracker(tracker.id)
# Stop polling for any telegram bot listeners that are no longer needed
lr = await session.exec(
select(CommandTrackerListener).where(
@@ -286,6 +308,10 @@ async def add_listener(
from ..services.telegram_poller import start_bot_if_needed
await start_bot_if_needed(body.listener_id)
# Mark bot dirty for debounced auto-sync
from ..services.command_sync import mark_bot_dirty
mark_bot_dirty(body.listener_id)
return _listener_response(listener)
@@ -313,6 +339,10 @@ async def remove_listener(
from ..services.telegram_poller import stop_bot_if_unused
await stop_bot_if_unused(removed_id)
# Mark bot dirty for debounced auto-sync
from ..services.command_sync import mark_bot_dirty
mark_bot_dirty(removed_id)
# --- Helpers ---