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