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
@@ -0,0 +1,126 @@
"""Debounced auto-sync of Telegram bot commands.
When a CommandConfig, CommandTracker, or CommandTrackerListener changes,
the affected bot(s) are marked dirty. A periodic scheduler job checks
for bots that have been dirty longer than the debounce window and syncs
their commands with the Telegram API.
The manual "Sync with Telegram" button remains available and is unaffected.
"""
from __future__ import annotations
import logging
import time
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from ..database.engine import get_engine
from ..database.models import CommandConfig, CommandTracker, CommandTrackerListener, TelegramBot
_LOGGER = logging.getLogger(__name__)
# bot_id -> timestamp when it was first marked dirty
_dirty_bots: dict[int, float] = {}
# Seconds to wait after the last dirty mark before syncing
DEBOUNCE_SECONDS = 30
def mark_bot_dirty(bot_id: int) -> None:
"""Mark a bot as needing a command sync with Telegram."""
if bot_id not in _dirty_bots:
_dirty_bots[bot_id] = time.time()
_LOGGER.debug("Marked bot %d dirty for command sync", bot_id)
else:
# Reset the debounce timer on each new change
_dirty_bots[bot_id] = time.time()
async def mark_dirty_for_config(config_id: int) -> None:
"""Find all bots linked to a CommandConfig and mark them dirty.
Chain: CommandConfig -> CommandTracker -> CommandTrackerListener -> TelegramBot
"""
engine = get_engine()
async with AsyncSession(engine) as session:
# Find trackers using this config
result = await session.exec(
select(CommandTracker.id).where(CommandTracker.command_config_id == config_id)
)
tracker_ids = list(result.all())
if not tracker_ids:
return
# Find telegram bot listeners for those trackers
result = await session.exec(
select(CommandTrackerListener.listener_id).where(
CommandTrackerListener.command_tracker_id.in_(tracker_ids),
CommandTrackerListener.listener_type == "telegram_bot",
)
)
for bot_id in result.all():
mark_bot_dirty(bot_id)
async def mark_dirty_for_tracker(tracker_id: int) -> None:
"""Find all bots linked to a CommandTracker and mark them dirty."""
engine = get_engine()
async with AsyncSession(engine) as session:
result = await session.exec(
select(CommandTrackerListener.listener_id).where(
CommandTrackerListener.command_tracker_id == tracker_id,
CommandTrackerListener.listener_type == "telegram_bot",
)
)
for bot_id in result.all():
mark_bot_dirty(bot_id)
async def _flush_dirty_bots() -> None:
"""Check for bots that have been dirty past the debounce window and sync them."""
if not _dirty_bots:
return
now = time.time()
ready = [bid for bid, ts in _dirty_bots.items() if now - ts >= DEBOUNCE_SECONDS]
if not ready:
return
from ..commands.handler import register_commands_with_telegram
engine = get_engine()
for bot_id in ready:
_dirty_bots.pop(bot_id, None)
try:
async with AsyncSession(engine) as session:
bot = await session.get(TelegramBot, bot_id)
if not bot:
continue
success = await register_commands_with_telegram(bot)
if success:
_LOGGER.info("Auto-synced commands for bot %d (@%s)", bot_id, bot.bot_username)
else:
_LOGGER.warning("Auto-sync failed for bot %d", bot_id)
except Exception:
_LOGGER.error("Error auto-syncing commands for bot %d", bot_id, exc_info=True)
def start_sync_scheduler() -> None:
"""Register the periodic flush job with APScheduler."""
from .scheduler import get_scheduler
scheduler = get_scheduler()
job_id = "command_sync_flush"
if scheduler.get_job(job_id):
return
scheduler.add_job(
_flush_dirty_bots,
"interval",
seconds=10,
id=job_id,
replace_existing=True,
max_instances=1,
)
_LOGGER.info("Command auto-sync scheduler started (debounce=%ds)", DEBOUNCE_SECONDS)
@@ -30,6 +30,10 @@ async def start_scheduler() -> None:
from .telegram_poller import start_command_listener_polling
await start_command_listener_polling()
# Start debounced command auto-sync scheduler
from .command_sync import start_sync_scheduler
start_sync_scheduler()
async def _load_tracker_jobs() -> None:
"""Load enabled trackers and schedule polling jobs."""
@@ -205,7 +205,8 @@ async def _poll_bot(bot_id: int) -> None:
# Dispatch commands
if text and text.startswith("/"):
try:
cmd_response = await handle_command(bot_obj, chat_id, text)
language_code = message.get("from", {}).get("language_code", "")
cmd_response = await handle_command(bot_obj, chat_id, text, language_code=language_code)
if cmd_response is not None:
if isinstance(cmd_response, list):
await send_media_group(bot_token, chat_id, cmd_response)