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