Spaces:
Running
Running
SilverElixir commited on
Commit ·
14ad7cf
1
Parent(s): da6a2d2
Show typing before media downloads and register localized command menus in parallel
Browse files- bot.py +18 -7
- lumen_message_core.py +10 -10
- tests/test_bot_state.py +20 -0
bot.py
CHANGED
|
@@ -1611,17 +1611,28 @@ async def _webhook_startup() -> None:
|
|
| 1611 |
}
|
| 1612 |
for code, cmds in localized_commands
|
| 1613 |
]
|
| 1614 |
-
|
| 1615 |
-
|
| 1616 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1617 |
await asyncio.wait_for(
|
| 1618 |
telegram_api_call("setMyCommands", cmd_payload, request_timeout=15.0),
|
| 1619 |
-
timeout=18.0
|
| 1620 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1621 |
commands_ok = True
|
| 1622 |
-
except Exception as exc:
|
| 1623 |
-
log.warning("[webhook] setMyCommands failed (%s): %s", cmd_payload.get("language_code", "default"), exc)
|
| 1624 |
-
# for-else без break врал бы об успехе при провале всех языков.
|
| 1625 |
if commands_ok:
|
| 1626 |
log.info("[webhook] Bot commands set successfully.")
|
| 1627 |
else:
|
|
|
|
| 1611 |
}
|
| 1612 |
for code, cmds in localized_commands
|
| 1613 |
]
|
| 1614 |
+
# 24 локали последовательно — это 5-12 с холодного старта через прокси.
|
| 1615 |
+
# Регистрируем с ограниченной параллельностью: меню не влияет на ответы,
|
| 1616 |
+
# но не хочется упираться в flood-контроль Telegram.
|
| 1617 |
+
_commands_sem = asyncio.Semaphore(4)
|
| 1618 |
+
|
| 1619 |
+
async def _register_commands(cmd_payload: dict) -> None:
|
| 1620 |
+
async with _commands_sem:
|
| 1621 |
await asyncio.wait_for(
|
| 1622 |
telegram_api_call("setMyCommands", cmd_payload, request_timeout=15.0),
|
| 1623 |
+
timeout=18.0,
|
| 1624 |
)
|
| 1625 |
+
|
| 1626 |
+
results = await asyncio.gather(
|
| 1627 |
+
*(_register_commands(payload) for payload in cmd_payloads),
|
| 1628 |
+
return_exceptions=True,
|
| 1629 |
+
)
|
| 1630 |
+
commands_ok = False
|
| 1631 |
+
for cmd_payload, result in zip(cmd_payloads, results):
|
| 1632 |
+
if isinstance(result, BaseException):
|
| 1633 |
+
log.warning("[webhook] setMyCommands failed (%s): %s", cmd_payload.get("language_code", "default"), result)
|
| 1634 |
+
else:
|
| 1635 |
commands_ok = True
|
|
|
|
|
|
|
|
|
|
| 1636 |
if commands_ok:
|
| 1637 |
log.info("[webhook] Bot commands set successfully.")
|
| 1638 |
else:
|
lumen_message_core.py
CHANGED
|
@@ -658,6 +658,16 @@ async def _handle_message_core(
|
|
| 658 |
await bot._send_pick_question(message, pick_scenario, clean_prompt)
|
| 659 |
return
|
| 660 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 661 |
media_too_big_mb = 0
|
| 662 |
try:
|
| 663 |
med_name, media_tuple = await bot._resolve_incoming_media(
|
|
@@ -718,16 +728,6 @@ async def _handle_message_core(
|
|
| 718 |
await bot._tg_call(message.reply, bot._t(message.chat.id, "status_listening"))
|
| 719 |
return
|
| 720 |
|
| 721 |
-
# Отправка typing экшена (bot.bot — инстанс aiogram Bot, не модуль).
|
| 722 |
-
# Не ждём ответа: это отдельный круг к Telegram через прокси, и вызов
|
| 723 |
-
# модели стартовал бы на 0.1-0.3с позже. Индикатор косметический.
|
| 724 |
-
if not is_guest:
|
| 725 |
-
async def _send_typing() -> None:
|
| 726 |
-
with contextlib.suppress(Exception):
|
| 727 |
-
await bot.bot.send_chat_action(chat_id=message.chat.id, action="typing")
|
| 728 |
-
|
| 729 |
-
bot._track_inflight_task(asyncio.create_task(_send_typing()))
|
| 730 |
-
|
| 731 |
media_mime = media_tuple[1] if media_tuple else None
|
| 732 |
# Смешанный альбом (фото + видео-слайды): достаточно одного видео — весь набор едет
|
| 733 |
# в Gemini-ветку, иначе видео-слайды молча терялись (ревью ветки: смотрели только media[0]).
|
|
|
|
| 658 |
await bot._send_pick_question(message, pick_scenario, clean_prompt)
|
| 659 |
return
|
| 660 |
|
| 661 |
+
# «Печатает…» показываем сразу, ДО скачивания вложения и транскрибации:
|
| 662 |
+
# иначе видео на 20 МБ несколько секунд выглядело как молчание (аудит 29.10.2026).
|
| 663 |
+
# Не ждём ответа: круг к Telegram не должен задерживать модель.
|
| 664 |
+
if not is_guest:
|
| 665 |
+
async def _send_typing() -> None:
|
| 666 |
+
with contextlib.suppress(Exception):
|
| 667 |
+
await bot.bot.send_chat_action(chat_id=message.chat.id, action="typing")
|
| 668 |
+
|
| 669 |
+
bot._track_inflight_task(asyncio.create_task(_send_typing()))
|
| 670 |
+
|
| 671 |
media_too_big_mb = 0
|
| 672 |
try:
|
| 673 |
med_name, media_tuple = await bot._resolve_incoming_media(
|
|
|
|
| 728 |
await bot._tg_call(message.reply, bot._t(message.chat.id, "status_listening"))
|
| 729 |
return
|
| 730 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 731 |
media_mime = media_tuple[1] if media_tuple else None
|
| 732 |
# Смешанный альбом (фото + видео-слайды): достаточно одного видео — весь набор едет
|
| 733 |
# в Gemini-ветку, иначе видео-слайды молча терялись (ревью ветки: смотрели только media[0]).
|
tests/test_bot_state.py
CHANGED
|
@@ -1680,6 +1680,26 @@ def test_message_core_sends_typing_indicator(rate_guard_setup, monkeypatch):
|
|
| 1680 |
fake_bot.send_chat_action.assert_awaited_once_with(chat_id=123, action="typing")
|
| 1681 |
|
| 1682 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1683 |
def test_message_core_does_not_wait_for_typing_before_model(rate_guard_setup, monkeypatch):
|
| 1684 |
# Что защищает: вызов модели не ждёт сетевого ответа на "печатает".
|
| 1685 |
# Регрессия: вернуть await перед send_chat_action — модель стартует на один
|
|
|
|
| 1680 |
fake_bot.send_chat_action.assert_awaited_once_with(chat_id=123, action="typing")
|
| 1681 |
|
| 1682 |
|
| 1683 |
+
def test_typing_starts_before_media_download(rate_guard_setup, monkeypatch):
|
| 1684 |
+
# Аудит 29.10.2026: "печатает" уходил только после скачивания вложения —
|
| 1685 |
+
# большое видео несколько секунд выглядело как молчание.
|
| 1686 |
+
message = rate_guard_setup()
|
| 1687 |
+
message.text = "что на фото?"
|
| 1688 |
+
fake_bot = SimpleNamespace(send_chat_action=AsyncMock())
|
| 1689 |
+
monkeypatch.setattr(bot, "bot", fake_bot)
|
| 1690 |
+
seen = {}
|
| 1691 |
+
|
| 1692 |
+
async def resolve(*args, **kwargs):
|
| 1693 |
+
for _ in range(3):
|
| 1694 |
+
await asyncio.sleep(0)
|
| 1695 |
+
seen["typing_before_download"] = fake_bot.send_chat_action.await_count
|
| 1696 |
+
return "", None
|
| 1697 |
+
|
| 1698 |
+
monkeypatch.setattr(bot, "_resolve_incoming_media", resolve)
|
| 1699 |
+
_run_core_capturing_prompt(message, monkeypatch)
|
| 1700 |
+
assert seen.get("typing_before_download") == 1
|
| 1701 |
+
|
| 1702 |
+
|
| 1703 |
def test_message_core_does_not_wait_for_typing_before_model(rate_guard_setup, monkeypatch):
|
| 1704 |
# Что защищает: вызов модели не ждёт сетевого ответа на "печатает".
|
| 1705 |
# Регрессия: вернуть await перед send_chat_action — модель стартует на один
|