SilverElixir commited on
Commit
0e3aacf
·
1 Parent(s): aebc099

Add owner-only /selftest route-head probe with quota accounting

Browse files
README.md CHANGED
@@ -115,12 +115,13 @@ All three endpoints below require `Authorization: Bearer <ADMIN_PANEL_KEY>` (`/a
115
  | `/tts [text]` | anyone | Reads text out loud. |
116
  | `/lang` | anyone in DMs; group admins/owner in groups | Bot language menu (system messages; AI answers always follow your language). |
117
 
118
- Two more owner-only commands stay out of the menu on purpose, since they surface internal details that shouldn't be visible in a group chat:
119
 
120
  | Command | Access | Purpose |
121
  |---|---|---|
122
  | `/stats` | bot owner, DMs only | Active chat count, process uptime, per-model quota usage. |
123
  | `/logs` | bot owner, DMs only | Sends `bot.log` with secrets redacted. |
 
124
 
125
  In groups the bot keeps recent messages (up to 100) as context and sends them to the model providers together with the question; `/reset` (group admins/owner) clears them.
126
 
 
115
  | `/tts [text]` | anyone | Reads text out loud. |
116
  | `/lang` | anyone in DMs; group admins/owner in groups | Bot language menu (system messages; AI answers always follow your language). |
117
 
118
+ Three more owner-only commands stay out of the menu on purpose, since they surface internal details that shouldn't be visible in a group chat:
119
 
120
  | Command | Access | Purpose |
121
  |---|---|---|
122
  | `/stats` | bot owner, DMs only | Active chat count, process uptime, per-model quota usage. |
123
  | `/logs` | bot owner, DMs only | Sends `bot.log` with secrets redacted. |
124
+ | `/selftest [gemini]` | bot owner, DMs only, max once a minute | Live probe of route heads (Groq, light OpenRouter, Telegram proxy, TikWM; Gemini only with the `gemini` argument). Successful probes count against quota. |
125
 
126
  In groups the bot keeps recent messages (up to 100) as context and sends them to the model providers together with the question; `/reset` (group admins/owner) clears them.
127
 
bot.py CHANGED
@@ -665,6 +665,7 @@ from lumen_admin import (
665
  get_webhook_url,
666
  webhook_handler,
667
  network_diagnostics,
 
668
  export_state,
669
  )
670
 
@@ -731,6 +732,7 @@ __all__ = [
731
  "get_webhook_url",
732
  "webhook_handler",
733
  "network_diagnostics",
 
734
  "export_state",
735
  # Имена из lumen_streaming.py код bot.py сам не читает — они нужны как `bot.X`
736
  # существующим тестам и `_run_route` ниже.
@@ -789,6 +791,7 @@ __all__ = [
789
  "ask_openrouter_text",
790
  "ask_openrouter_multimodal",
791
  "ask_groq_text",
 
792
  "_transcribe_audio",
793
  "_gemini_history_contents",
794
  "_build_gemma_identity_contents",
@@ -1040,6 +1043,7 @@ from lumen_routes import (
1040
  ask_openrouter_text,
1041
  ask_openrouter_multimodal,
1042
  ask_groq_text,
 
1043
  _transcribe_audio,
1044
  _gemini_history_contents,
1045
  _build_gemma_identity_contents,
@@ -1190,6 +1194,7 @@ from lumen_commands import (
1190
  handle_lang_callback,
1191
  cmd_logs,
1192
  cmd_stats,
 
1193
  _build_version,
1194
  _webhook_info_text,
1195
  _process_memory_text,
@@ -1205,6 +1210,7 @@ dp.message.register(cmd_lang, Command("lang"))
1205
  dp.callback_query.register(handle_lang_callback, F.data.startswith("lang:"))
1206
  dp.message.register(cmd_logs, Command("logs"))
1207
  dp.message.register(cmd_stats, Command("stats"))
 
1208
  dp.callback_query.register(handle_pick_callback, F.data.startswith("pick:"))
1209
 
1210
 
 
665
  get_webhook_url,
666
  webhook_handler,
667
  network_diagnostics,
668
+ probe_url,
669
  export_state,
670
  )
671
 
 
732
  "get_webhook_url",
733
  "webhook_handler",
734
  "network_diagnostics",
735
+ "probe_url",
736
  "export_state",
737
  # Имена из lumen_streaming.py код bot.py сам не читает — они нужны как `bot.X`
738
  # существующим тестам и `_run_route` ниже.
 
791
  "ask_openrouter_text",
792
  "ask_openrouter_multimodal",
793
  "ask_groq_text",
794
+ "selftest_llm_head",
795
  "_transcribe_audio",
796
  "_gemini_history_contents",
797
  "_build_gemma_identity_contents",
 
1043
  ask_openrouter_text,
1044
  ask_openrouter_multimodal,
1045
  ask_groq_text,
1046
+ selftest_llm_head,
1047
  _transcribe_audio,
1048
  _gemini_history_contents,
1049
  _build_gemma_identity_contents,
 
1194
  handle_lang_callback,
1195
  cmd_logs,
1196
  cmd_stats,
1197
+ cmd_selftest,
1198
  _build_version,
1199
  _webhook_info_text,
1200
  _process_memory_text,
 
1210
  dp.callback_query.register(handle_lang_callback, F.data.startswith("lang:"))
1211
  dp.message.register(cmd_logs, Command("logs"))
1212
  dp.message.register(cmd_stats, Command("stats"))
1213
+ dp.message.register(cmd_selftest, Command("selftest"))
1214
  dp.callback_query.register(handle_pick_callback, F.data.startswith("pick:"))
1215
 
1216
 
lumen_admin.py CHANGED
@@ -121,6 +121,19 @@ async def webhook_handler(request: Request) -> Any:
121
  log.warning("[webhook] Failed to process incoming update: %s", exc)
122
  return {"ok": True}
123
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
  @app.get("/diag")
125
  async def network_diagnostics(request: Request) -> dict[str, Any]:
126
  """Проверяет исходящую сетевую доступность различных хостов из контейнера.
@@ -157,16 +170,7 @@ async def network_diagnostics(request: Request) -> dict[str, Any]:
157
  diag_budget = float(os.getenv("DIAG_TOTAL_BUDGET_SEC", "25"))
158
 
159
  async def _probe(name: str, url: str) -> tuple[str, dict[str, Any]]:
160
- start = time.time()
161
- try:
162
- async with session.get(url, timeout=aiohttp.ClientTimeout(total=6.0)) as resp:
163
- return name, {"status": resp.status, "elapsed_sec": round(time.time() - start, 2), "ok": True}
164
- except Exception as exc:
165
- elapsed = round(time.time() - start, 2)
166
- exc_str = str(exc) or repr(exc) or type(exc).__name__
167
- if bot_token:
168
- exc_str = exc_str.replace(bot_token, "<TOKEN>")
169
- return name, {"error": exc_str, "elapsed_sec": elapsed, "ok": False}
170
 
171
  tasks = {asyncio.create_task(_probe(name, url)): name for name, url in targets.items()}
172
  done, pending = await asyncio.wait(tasks, timeout=diag_budget)
 
121
  log.warning("[webhook] Failed to process incoming update: %s", exc)
122
  return {"ok": True}
123
 
124
+ async def probe_url(session: Any, url: str, *, timeout_sec: float = 6.0, redact: str = "") -> dict[str, Any]:
125
+ """Один GET-зонд: та же логика и тот же формат результата для /diag и /selftest."""
126
+ start = time.time()
127
+ try:
128
+ async with session.get(url, timeout=aiohttp.ClientTimeout(total=timeout_sec)) as resp:
129
+ return {"status": resp.status, "elapsed_sec": round(time.time() - start, 2), "ok": True}
130
+ except Exception as exc:
131
+ elapsed = round(time.time() - start, 2)
132
+ exc_str = str(exc) or repr(exc) or type(exc).__name__
133
+ if redact:
134
+ exc_str = exc_str.replace(redact, "<TOKEN>")
135
+ return {"error": exc_str, "elapsed_sec": elapsed, "ok": False}
136
+
137
  @app.get("/diag")
138
  async def network_diagnostics(request: Request) -> dict[str, Any]:
139
  """Проверяет исходящую сетевую доступность различных хостов из контейнера.
 
170
  diag_budget = float(os.getenv("DIAG_TOTAL_BUDGET_SEC", "25"))
171
 
172
  async def _probe(name: str, url: str) -> tuple[str, dict[str, Any]]:
173
+ return name, await probe_url(session, url, timeout_sec=6.0, redact=bot_token or "")
 
 
 
 
 
 
 
 
 
174
 
175
  tasks = {asyncio.create_task(_probe(name, url)): name for name, url in targets.items()}
176
  done, pending = await asyncio.wait(tasks, timeout=diag_budget)
lumen_commands.py CHANGED
@@ -648,6 +648,70 @@ async def cmd_stats(message: Message) -> None:
648
  await bot._tg_call(message.reply, text, parse_mode=ParseMode.HTML)
649
 
650
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
651
  _BUILD_VERSION_CACHED: str | None = None
652
 
653
 
 
648
  await bot._tg_call(message.reply, text, parse_mode=ParseMode.HTML)
649
 
650
 
651
+ _SELFTEST_LAST_RUN_MONOTONIC = 0.0
652
+ SELFTEST_COOLDOWN_SEC = 60.0
653
+
654
+ def _selftest_row(label: str, ok: bool, detail: str, elapsed: float) -> str:
655
+ """Одна строка итога без ID моделей (в группы команда не ходит вовсе)."""
656
+ base = f"{label}: {'OK' if ok else 'FAIL'} ({elapsed:.1f}s)"
657
+ return base if ok or not detail else f"{base} — {detail}"
658
+
659
+ async def cmd_selftest(message: Message) -> None:
660
+ """Живая проверка голов маршрутов: по крошечной пробе в Groq, лёгкий OpenRouter
661
+ и сеть (прокси Telegram, TikWM) логикой /diag. Gemini — только по явному
662
+ аргументу, квота самая дефицитная. Только владелец, только личка, не чаще
663
+ раза в минуту. Успешные пробы честно пишутся в квоту."""
664
+ import bot
665
+ global _SELFTEST_LAST_RUN_MONOTONIC
666
+ if not bot._is_owner(message.from_user.id if message.from_user else None):
667
+ await bot._tg_call(message.reply, bot._t(message.chat.id, "stats_deny"))
668
+ return
669
+ if message.chat.type != ChatType.PRIVATE:
670
+ await bot._tg_call(message.reply, bot._t(message.chat.id, "selftest_group_only"))
671
+ return
672
+ now = time.monotonic()
673
+ wait = SELFTEST_COOLDOWN_SEC - (now - _SELFTEST_LAST_RUN_MONOTONIC)
674
+ if wait > 0:
675
+ await bot._tg_call(message.reply, bot._t(message.chat.id, "selftest_cooldown", sec=int(wait) + 1))
676
+ return
677
+ # Метку ставим до проб: повторный вызов во время долгой проверки тоже ждёт.
678
+ _SELFTEST_LAST_RUN_MONOTONIC = now
679
+ args = (getattr(message, "text", "") or "").split()
680
+ want_gemini = len(args) > 1 and args[1].split("@")[0].lower() == "gemini"
681
+ rows: list[str] = []
682
+ summary: list[str] = []
683
+ for provider, label in (("groq", "Groq"), ("openrouter", "OpenRouter")):
684
+ ok, detail, elapsed = await bot.selftest_llm_head(provider, chat_id=message.chat.id)
685
+ rows.append(_selftest_row(label, ok, detail, elapsed))
686
+ summary.append(f"{provider}={'ok' if ok else 'FAIL'}/{elapsed:.1f}s")
687
+ if want_gemini:
688
+ ok, detail, elapsed = await bot.selftest_llm_head("gemini", chat_id=message.chat.id)
689
+ rows.append(_selftest_row("Gemini", ok, detail, elapsed))
690
+ summary.append(f"gemini={'ok' if ok else 'FAIL'}/{elapsed:.1f}s")
691
+ try:
692
+ session = await bot._get_http_session()
693
+ token = bot.BOT_TOKEN or ""
694
+ proxy_url = bot.TELEGRAM_API_BASE_URL + "/bot" + (token[:6] if token else "x") + "/getMe"
695
+ net = await asyncio.gather(
696
+ bot.probe_url(session, proxy_url, timeout_sec=6.0, redact=token),
697
+ bot.probe_url(session, "https://www.tikwm.com", timeout_sec=6.0),
698
+ )
699
+ except Exception as exc:
700
+ net = [
701
+ {"ok": False, "elapsed_sec": 0.0, "error": f"{exc.__class__.__name__}"},
702
+ {"ok": False, "elapsed_sec": 0.0, "error": f"{exc.__class__.__name__}"},
703
+ ]
704
+ for label, res in (("TG-proxy", net[0]), ("TikWM", net[1])):
705
+ detail = "" if res.get("ok") else str(res.get("error") or res.get("status") or "error")[:120]
706
+ rows.append(_selftest_row(label, bool(res.get("ok")), detail, float(res.get("elapsed_sec") or 0.0)))
707
+ summary.append(f"{label}={'ok' if res.get('ok') else 'FAIL'}")
708
+ text = bot._t(message.chat.id, "selftest_header") + "\n" + "\n".join(rows)
709
+ if not want_gemini:
710
+ text += "\n" + bot._t(message.chat.id, "selftest_gemini_skipped")
711
+ await bot._tg_call(message.reply, text)
712
+ log.info("[selftest] chat=%s gemini_arg=%s %s", message.chat.id, want_gemini, " ".join(summary))
713
+
714
+
715
  _BUILD_VERSION_CACHED: str | None = None
716
 
717
 
lumen_lang.py CHANGED
@@ -537,6 +537,38 @@ STRINGS: dict[str, dict[str, str]] = {
537
  "ru": "Эта команда показывает техническую статистику — доступна только в личных сообщениях с ботом, не в группах.",
538
  "uk": "Ця команда показує технічну статистику — доступна лише в особистих повідомленнях з ботом, не в групах.",
539
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
540
  "stats_no_data": {
541
  "be": " няма даных",
542
  "en": " no data",
@@ -801,6 +833,10 @@ PACK_PT: dict[str, str] = {
801
  "logs_send_error": "Erro ao enviar os registros: {error}",
802
  "stats_deny": "Sem acesso a este comando.",
803
  "stats_group_only": "Este comando mostra estatísticas técnicas — disponível apenas em mensagens diretas com o bot, não em grupos.",
 
 
 
 
804
  "stats_no_data": " sem dados",
805
  "stats_limit_used": " (limite esgotado)",
806
  "tiktok_sound_recognized": "Link de som do TikTok reconhecido.",
@@ -890,6 +926,10 @@ PACK_AR: dict[str, str] = {
890
  "logs_send_error": "خطأ أثناء إرسال السجلات: {error}",
891
  "stats_deny": "لا صلاحية لهذا الأمر.",
892
  "stats_group_only": "يعرض هذا الأمر الإحصاءات التقنية — متاح فقط في الرسائل الخاصة مع البوت وليس في المجموعات.",
 
 
 
 
893
  "stats_no_data": " لا بيانات",
894
  "stats_limit_used": " (الحد مستنفد)",
895
  "tiktok_sound_recognized": "تم التعرف على رابط صوت TikTok.",
@@ -979,6 +1019,10 @@ PACK_TR: dict[str, str] = {
979
  "logs_send_error": "Loglar gönderilirken hata: {error}",
980
  "stats_deny": "Bu komuta erişimin yok.",
981
  "stats_group_only": "Bu komut teknik istatistikleri gösterir — yalnızca botla direkt mesajlarda kullanılabilir, gruplarda değil.",
 
 
 
 
982
  "stats_no_data": " veri yok",
983
  "stats_limit_used": " (limit doldu)",
984
  "tiktok_sound_recognized": "TikTok ses bağlantısı tanındı.",
@@ -1068,6 +1112,10 @@ PACK_DE: dict[str, str] = {
1068
  "logs_send_error": "Fehler beim Senden der Logs: {error}",
1069
  "stats_deny": "Kein Zugriff auf diesen Befehl.",
1070
  "stats_group_only": "Dieser Befehl zeigt technische Statistiken — nur in Direktnachrichten mit dem Bot verfügbar, nicht in Gruppen.",
 
 
 
 
1071
  "stats_no_data": " keine Daten",
1072
  "stats_limit_used": " (Limit erschöpft)",
1073
  "tiktok_sound_recognized": "TikTok-Soundlink erkannt.",
@@ -1157,6 +1205,10 @@ PACK_FR: dict[str, str] = {
1157
  "logs_send_error": "Erreur d'envoi des logs : {error}",
1158
  "stats_deny": "Pas d'accès à cette commande.",
1159
  "stats_group_only": "Cette commande affiche des statistiques techniques — disponible uniquement en messages privés avec le bot, pas en groupes.",
 
 
 
 
1160
  "stats_no_data": " pas de données",
1161
  "stats_limit_used": " (limite épuisée)",
1162
  "tiktok_sound_recognized": "Lien de son TikTok reconnu.",
@@ -1246,6 +1298,10 @@ PACK_IT: dict[str, str] = {
1246
  "logs_send_error": "Errore di invio dei log: {error}",
1247
  "stats_deny": "Nessun accesso a questo comando.",
1248
  "stats_group_only": "Questo comando mostra statistiche tecniche — disponibile solo nei messaggi diretti con il bot, non nei gruppi.",
 
 
 
 
1249
  "stats_no_data": " nessun dato",
1250
  "stats_limit_used": " (limite esaurito)",
1251
  "tiktok_sound_recognized": "Link audio di TikTok riconosciuto.",
@@ -1335,6 +1391,10 @@ PACK_HI: dict[str, str] = {
1335
  "logs_send_error": "लॉग भेजने में त्रुटि: {error}",
1336
  "stats_deny": "इस कमांड तक पहुंच नहीं है।",
1337
  "stats_group_only": "यह कमांड तकनीकी आंकड़े दिखाती है — केवल बॉट के साथ डायरेक्ट संदेशों में उपलब्ध है, ग्रुप में नहीं।",
 
 
 
 
1338
  "stats_no_data": " कोई डेटा नहीं",
1339
  "stats_limit_used": " (सीमा समाप्त)",
1340
  "tiktok_sound_recognized": "TikTok ध्वनि लिंक पहचानी गई।",
@@ -1424,6 +1484,10 @@ PACK_ID: dict[str, str] = {
1424
  "logs_send_error": "Gagal mengirim log: {error}",
1425
  "stats_deny": "Tidak ada akses ke perintah ini.",
1426
  "stats_group_only": "Perintah ini menampilkan statistik teknis — hanya tersedia di pesan langsung dengan bot, bukan di grup.",
 
 
 
 
1427
  "stats_no_data": " tidak ada data",
1428
  "stats_limit_used": " (batas habis)",
1429
  "tiktok_sound_recognized": "Tautan suara TikTok dikenali.",
@@ -1513,6 +1577,10 @@ PACK_MS: dict[str, str] = {
1513
  "logs_send_error": "Gagal menghantar log: {error}",
1514
  "stats_deny": "Tiada akses ke perintah ini.",
1515
  "stats_group_only": "Perintah ini memaparkan statistik teknikal — hanya tersedia dalam mesej langsung dengan bot, bukan dalam kumpulan.",
 
 
 
 
1516
  "stats_no_data": " tiada data",
1517
  "stats_limit_used": " (had habis)",
1518
  "tiktok_sound_recognized": "Pautan bunyi TikTok dikenal pasti.",
@@ -1602,6 +1670,10 @@ PACK_VI: dict[str, str] = {
1602
  "logs_send_error": "Lỗi khi gửi log: {error}",
1603
  "stats_deny": "Không có quyền dùng lệnh này.",
1604
  "stats_group_only": "Lệnh này hiện số liệu kỹ thuật — chỉ dùng được trong tin nhắn riêng với bot, không trong nhóm.",
 
 
 
 
1605
  "stats_no_data": " không có dữ liệu",
1606
  "stats_limit_used": " (hết hạn mức)",
1607
  "tiktok_sound_recognized": "Đã nhận ra liên kết âm thanh TikTok.",
@@ -1691,6 +1763,10 @@ PACK_TH: dict[str, str] = {
1691
  "logs_send_error": "เกิดข้อผิดพลาดขณะส่งล็อก: {error}",
1692
  "stats_deny": "ไม่มีสิทธิ์ใช้คำสั่งนี้",
1693
  "stats_group_only": "คำสั่งนี้แสดงสถิติทางเทคนิค — ใช้ได้เฉพาะในแชทส่วนตัวกับบอท ไม่ใช่ในกลุ่ม",
 
 
 
 
1694
  "stats_no_data": " ไม่มีข้อมูล",
1695
  "stats_limit_used": " (โควต้าหมด)",
1696
  "tiktok_sound_recognized": "รู้จักลิงก์เสียง TikTok แล้ว",
@@ -1780,6 +1856,10 @@ PACK_FA: dict[str, str] = {
1780
  "logs_send_error": "خطا در ارسال لاگ‌ها: {error}",
1781
  "stats_deny": "به این دستور دسترسی نداری.",
1782
  "stats_group_only": "این دستور آمار فنی را نشان می‌دهد — فقط در پیام‌های خصوصی با ربات در دسترس است، نه در گروه‌ها.",
 
 
 
 
1783
  "stats_no_data": " داده‌ای نیست",
1784
  "stats_limit_used": " (سقف تمام شد)",
1785
  "tiktok_sound_recognized": "لینک صدای TikTok شناخته شد.",
@@ -1869,6 +1949,10 @@ PACK_UR: dict[str, str] = {
1869
  "logs_send_error": "لاگز بھیجنے میں خرابی: {error}",
1870
  "stats_deny": "اس کمانڈ تک رسائی نہیں ہے۔",
1871
  "stats_group_only": "یہ کمانڈ تکنیکی اعداد دکھاتی ہے — صرف بوٹ کے ساتھ ڈائریکٹ پیغامات میں دستیاب ہے، گروپ میں نہیں۔",
 
 
 
 
1872
  "stats_no_data": " کوئی ڈیٹا نہیں",
1873
  "stats_limit_used": " (حد ختم)",
1874
  "tiktok_sound_recognized": "TikTok آواز کا لنک پہچانا گیا۔",
@@ -1958,6 +2042,10 @@ PACK_UZ: dict[str, str] = {
1958
  "logs_send_error": "Loglarni yuborishda xato: {error}",
1959
  "stats_deny": "Bu buyruqqa kirish yo'q.",
1960
  "stats_group_only": "Bu buyruq texnik statistikani ko'rsatadi — faqat bot bilan shaxsiy xabarlarda ochiq, guruhlarda emas.",
 
 
 
 
1961
  "stats_no_data": " ma'lumot yo'q",
1962
  "stats_limit_used": " (limit tugadi)",
1963
  "tiktok_sound_recognized": "TikTok tovush havolasi tanildi.",
@@ -2047,6 +2135,10 @@ PACK_BN: dict[str, str] = {
2047
  "logs_send_error": "লগ পাঠাতে ত্রুটি: {error}",
2048
  "stats_deny": "এই কমান্ডে প্রবেশাধিকার নেই।",
2049
  "stats_group_only": "এই কমান্ড প্রযুক্তিগত পরিসংখ্যান দেখায় — কেবল বটের সাথে ডাইরেক্ট মেসেজে উপলব্ধ, গ্রুপে নয়।",
 
 
 
 
2050
  "stats_no_data": " কোনো তথ্য নেই",
2051
  "stats_limit_used": " (সীমা শেষ)",
2052
  "tiktok_sound_recognized": "TikTok সাউন্ড লিংক চেনা গেছে।",
@@ -2136,6 +2228,10 @@ PACK_FIL: dict[str, str] = {
2136
  "logs_send_error": "Error sa pag-send ng mga log: {error}",
2137
  "stats_deny": "Walang access sa command na ito.",
2138
  "stats_group_only": "Pinapakita ng command na ito ang mga technical stat — sa direct message lang sa bot available, hindi sa grupo.",
 
 
 
 
2139
  "stats_no_data": " walang data",
2140
  "stats_limit_used": " (ubos ang limit)",
2141
  "tiktok_sound_recognized": "Nakilala ang TikTok sound link.",
@@ -2225,6 +2321,10 @@ PACK_PL: dict[str, str] = {
2225
  "logs_send_error": "Błąd wysyłania logów: {error}",
2226
  "stats_deny": "Brak dostępu do tej komendy.",
2227
  "stats_group_only": "Ta komenda pokazuje statystyki techniczne — dostępna tylko w wiadomościach prywatnych z botem, nie w grupach.",
 
 
 
 
2228
  "stats_no_data": " brak danych",
2229
  "stats_limit_used": " (limit wyczerpany)",
2230
  "tiktok_sound_recognized": "Rozpoznano link do dźwięku TikToka.",
@@ -2314,6 +2414,10 @@ PACK_NL: dict[str, str] = {
2314
  "logs_send_error": "Fout bij verzenden van logs: {error}",
2315
  "stats_deny": "Geen toegang tot dit commando.",
2316
  "stats_group_only": "Dit commando toont technische statistieken — alleen beschikbaar in directe berichten met de bot, niet in groepen.",
 
 
 
 
2317
  "stats_no_data": " geen data",
2318
  "stats_limit_used": " (limiet op)",
2319
  "tiktok_sound_recognized": "TikTok-geluidslink herkend.",
@@ -2403,6 +2507,10 @@ PACK_ZU: dict[str, str] = {
2403
  "logs_send_error": "Iphutha ekuthumeleni ama-log: {error}",
2404
  "stats_deny": "Alikho ilungelo lalo myalo.",
2405
  "stats_group_only": "Lo myalo ubonisa izibalo zobuchwepheshe — utholakala kuphela emiyalezo eqondile ne-bot, hhayi eqenjini.",
 
 
 
 
2406
  "stats_no_data": " ayikho idatha",
2407
  "stats_limit_used": " (umkhawulo uphelile)",
2408
  "tiktok_sound_recognized": "Isixhumanisi somsindo se-TikTok saziwe.",
 
537
  "ru": "Эта команда показывает техническую статистику — доступна только в личных сообщениях с ботом, не в группах.",
538
  "uk": "Ця команда показує технічну статистику — доступна лише в особистих повідомленнях з ботом, не в групах.",
539
  },
540
+ "selftest_group_only": {
541
+ "be": "Гэтая каманда правярае тэхнічны стан бота — даступна толькі ў асабістых паведамленнях з ботам, не ў групах.",
542
+ "en": "This command checks the bot's health — available only in direct messages with the bot, not in groups.",
543
+ "es": "Este comando comprueba el estado técnico del bot — disponible solo en mensajes directos con el bot, no en grupos.",
544
+ "kk": "Бұл команда боттың техникалық күйін тексереді — тек ботпен жеке хабарламаларда қолжетімді, топтарда емес.",
545
+ "ru": "Эта команда проверяет техническое состояние бота — доступна только в личных сообщениях с ботом, не в группах.",
546
+ "uk": "Ця команда перевіряє технічний стан бота — доступна лише в особистих повідомленнях з ботом, не в групах.",
547
+ },
548
+ "selftest_cooldown": {
549
+ "be": "Пачакай {sec} с перад наступным запускам.",
550
+ "en": "Wait {sec}s before running it again.",
551
+ "es": "Espera {sec} s antes de volver a ejecutarlo.",
552
+ "kk": "Келесі іске қосу алдында {sec} с күт.",
553
+ "ru": "Подожди {sec} с перед следующим запуском.",
554
+ "uk": "Зачекай {sec} с перед наступним запуском.",
555
+ },
556
+ "selftest_header": {
557
+ "be": "Самаправерка:",
558
+ "en": "Self-test:",
559
+ "es": "Autoprueba:",
560
+ "kk": "Өзін-өзі тексеру:",
561
+ "ru": "Самопроверка:",
562
+ "uk": "Самоперевірка:",
563
+ },
564
+ "selftest_gemini_skipped": {
565
+ "be": "(Gemini прапушчаны — дапішы gemini, квота дэфіцытная)",
566
+ "en": "(Gemini skipped — add the gemini argument, its quota is scarce)",
567
+ "es": "(Gemini omitido — añade el argumento gemini, su cuota es escasa)",
568
+ "kk": "(Gemini өткізілді — gemini аргументін қос, квота тапшы)",
569
+ "ru": "(Gemini пропущен — допиши gemini, квота дефицитная)",
570
+ "uk": "(Gemini пропущено — допиши gemini, квота дефіцитна)",
571
+ },
572
  "stats_no_data": {
573
  "be": " няма даных",
574
  "en": " no data",
 
833
  "logs_send_error": "Erro ao enviar os registros: {error}",
834
  "stats_deny": "Sem acesso a este comando.",
835
  "stats_group_only": "Este comando mostra estatísticas técnicas — disponível apenas em mensagens diretas com o bot, não em grupos.",
836
+ "selftest_group_only": "Este comando verifica o estado técnico do bot — disponível apenas em mensagens diretas com o bot, não em grupos.",
837
+ "selftest_cooldown": "Espera {sec}s antes da próxima execução.",
838
+ "selftest_header": "Autoteste:",
839
+ "selftest_gemini_skipped": "(Gemini ignorado — adiciona o argumento gemini, a quota dele é escassa)",
840
  "stats_no_data": " sem dados",
841
  "stats_limit_used": " (limite esgotado)",
842
  "tiktok_sound_recognized": "Link de som do TikTok reconhecido.",
 
926
  "logs_send_error": "خطأ أثناء إرسال السجلات: {error}",
927
  "stats_deny": "لا صلاحية لهذا الأمر.",
928
  "stats_group_only": "يعرض هذا الأمر الإحصاءات التقنية — متاح فقط في الرسائل الخاصة مع البوت وليس في المجموعات.",
929
+ "selftest_group_only": "هذا الأمر يفحص الحالة التقنية للبوت — متاح فقط في الرسائل الخاصة مع البوت وليس في المجموعات.",
930
+ "selftest_cooldown": "انتظر {sec} ث قبل التشغيل التالي.",
931
+ "selftest_header": "فحص ذاتي:",
932
+ "selftest_gemini_skipped": "(تم تخطي Gemini — أضف الوسيط gemini، حصته شحيحة)",
933
  "stats_no_data": " لا بيانات",
934
  "stats_limit_used": " (الحد مستنفد)",
935
  "tiktok_sound_recognized": "تم التعرف على رابط صوت TikTok.",
 
1019
  "logs_send_error": "Loglar gönderilirken hata: {error}",
1020
  "stats_deny": "Bu komuta erişimin yok.",
1021
  "stats_group_only": "Bu komut teknik istatistikleri gösterir — yalnızca botla direkt mesajlarda kullanılabilir, gruplarda değil.",
1022
+ "selftest_group_only": "Bu komut botun teknik durumunu kontrol eder — yalnızca botla direkt mesajlarda kullanılabilir, gruplarda değil.",
1023
+ "selftest_cooldown": "Sonraki çalıştırmadan önce {sec} sn bekle.",
1024
+ "selftest_header": "Öz test:",
1025
+ "selftest_gemini_skipped": "(Gemini atlandı — gemini argümanını ekle, kotası kıt)",
1026
  "stats_no_data": " veri yok",
1027
  "stats_limit_used": " (limit doldu)",
1028
  "tiktok_sound_recognized": "TikTok ses bağlantısı tanındı.",
 
1112
  "logs_send_error": "Fehler beim Senden der Logs: {error}",
1113
  "stats_deny": "Kein Zugriff auf diesen Befehl.",
1114
  "stats_group_only": "Dieser Befehl zeigt technische Statistiken — nur in Direktnachrichten mit dem Bot verfügbar, nicht in Gruppen.",
1115
+ "selftest_group_only": "Dieser Befehl prüft den technischen Zustand des Bots — nur in Direktnachrichten mit dem Bot verfügbar, nicht in Gruppen.",
1116
+ "selftest_cooldown": "Warte {sec} s vor dem nächsten Start.",
1117
+ "selftest_header": "Selbsttest:",
1118
+ "selftest_gemini_skipped": "(Gemini übersprungen — füge das Argument gemini hinzu, sein Kontingent ist knapp)",
1119
  "stats_no_data": " keine Daten",
1120
  "stats_limit_used": " (Limit erschöpft)",
1121
  "tiktok_sound_recognized": "TikTok-Soundlink erkannt.",
 
1205
  "logs_send_error": "Erreur d'envoi des logs : {error}",
1206
  "stats_deny": "Pas d'accès à cette commande.",
1207
  "stats_group_only": "Cette commande affiche des statistiques techniques — disponible uniquement en messages privés avec le bot, pas en groupes.",
1208
+ "selftest_group_only": "Cette commande vérifie l'état technique du bot — disponible uniquement en messages privés avec le bot, pas en groupes.",
1209
+ "selftest_cooldown": "Attends {sec} s avant le prochain lancement.",
1210
+ "selftest_header": "Auto-test :",
1211
+ "selftest_gemini_skipped": "(Gemini ignoré — ajoute l'argument gemini, son quota est limité)",
1212
  "stats_no_data": " pas de données",
1213
  "stats_limit_used": " (limite épuisée)",
1214
  "tiktok_sound_recognized": "Lien de son TikTok reconnu.",
 
1298
  "logs_send_error": "Errore di invio dei log: {error}",
1299
  "stats_deny": "Nessun accesso a questo comando.",
1300
  "stats_group_only": "Questo comando mostra statistiche tecniche — disponibile solo nei messaggi diretti con il bot, non nei gruppi.",
1301
+ "selftest_group_only": "Questo comando verifica lo stato tecnico del bot — disponibile solo nei messaggi diretti con il bot, non nei gruppi.",
1302
+ "selftest_cooldown": "Attendi {sec} s prima del prossimo avvio.",
1303
+ "selftest_header": "Autotest:",
1304
+ "selftest_gemini_skipped": "(Gemini saltato — aggiungi l'argomento gemini, la sua quota è scarsa)",
1305
  "stats_no_data": " nessun dato",
1306
  "stats_limit_used": " (limite esaurito)",
1307
  "tiktok_sound_recognized": "Link audio di TikTok riconosciuto.",
 
1391
  "logs_send_error": "लॉग भेजने में त्रुटि: {error}",
1392
  "stats_deny": "इस कमांड तक पहुंच नहीं है।",
1393
  "stats_group_only": "यह कमांड तकनीकी आंकड़े दिखाती है — केवल बॉट के साथ डायरेक्ट संदेशों में उपलब्ध है, ग्रुप में नहीं।",
1394
+ "selftest_group_only": "यह कमांड बॉट की तकनीकी स्थिति जांचता है — केवल बॉट के साथ सीधे संदेशों में, समूहों में नहीं।",
1395
+ "selftest_cooldown": "अगली बार चलाने से पहले {sec} सेकंड रुको।",
1396
+ "selftest_header": "स्व-जांच:",
1397
+ "selftest_gemini_skipped": "(Gemini छोड़ा गया — gemini आर्गुमेंट जोड़ो, उसका कोटा सीमित है)",
1398
  "stats_no_data": " कोई डेटा नहीं",
1399
  "stats_limit_used": " (सीमा समाप्त)",
1400
  "tiktok_sound_recognized": "TikTok ध्वनि लिंक पहचानी गई।",
 
1484
  "logs_send_error": "Gagal mengirim log: {error}",
1485
  "stats_deny": "Tidak ada akses ke perintah ini.",
1486
  "stats_group_only": "Perintah ini menampilkan statistik teknis — hanya tersedia di pesan langsung dengan bot, bukan di grup.",
1487
+ "selftest_group_only": "Perintah ini memeriksa kondisi teknis bot — hanya tersedia di pesan langsung dengan bot, bukan di grup.",
1488
+ "selftest_cooldown": "Tunggu {sec} dtk sebelum menjalankan lagi.",
1489
+ "selftest_header": "Uji mandiri:",
1490
+ "selftest_gemini_skipped": "(Gemini dilewati — tambahkan argumen gemini, kuotanya terbatas)",
1491
  "stats_no_data": " tidak ada data",
1492
  "stats_limit_used": " (batas habis)",
1493
  "tiktok_sound_recognized": "Tautan suara TikTok dikenali.",
 
1577
  "logs_send_error": "Gagal menghantar log: {error}",
1578
  "stats_deny": "Tiada akses ke perintah ini.",
1579
  "stats_group_only": "Perintah ini memaparkan statistik teknikal — hanya tersedia dalam mesej langsung dengan bot, bukan dalam kumpulan.",
1580
+ "selftest_group_only": "Perintah ini memeriksa keadaan teknikal bot — hanya tersedia dalam mesej langsung dengan bot, bukan dalam kumpulan.",
1581
+ "selftest_cooldown": "Tunggu {sec}s sebelum larian seterusnya.",
1582
+ "selftest_header": "Ujian kendiri:",
1583
+ "selftest_gemini_skipped": "(Gemini dilangkau — tambah argumen gemini, kuotanya terhad)",
1584
  "stats_no_data": " tiada data",
1585
  "stats_limit_used": " (had habis)",
1586
  "tiktok_sound_recognized": "Pautan bunyi TikTok dikenal pasti.",
 
1670
  "logs_send_error": "Lỗi khi gửi log: {error}",
1671
  "stats_deny": "Không có quyền dùng lệnh này.",
1672
  "stats_group_only": "Lệnh này hiện số liệu kỹ thuật — chỉ dùng được trong tin nhắn riêng với bot, không trong nhóm.",
1673
+ "selftest_group_only": "Lệnh này kiểm tra tình trạng kỹ thuật của bot — chỉ dùng được trong tin nhắn riêng với bot, không dùng trong nhóm.",
1674
+ "selftest_cooldown": "Đợi {sec} giây trước lần chạy tiếp theo.",
1675
+ "selftest_header": "Tự kiểm tra:",
1676
+ "selftest_gemini_skipped": "(Bỏ qua Gemini — thêm đối số gemini, hạn ngạch của nó khan hiếm)",
1677
  "stats_no_data": " không có dữ liệu",
1678
  "stats_limit_used": " (hết hạn mức)",
1679
  "tiktok_sound_recognized": "Đã nhận ra liên kết âm thanh TikTok.",
 
1763
  "logs_send_error": "เกิดข้อผิดพลาดขณะส่งล็อก: {error}",
1764
  "stats_deny": "ไม่มีสิทธิ์ใช้คำสั่งนี้",
1765
  "stats_group_only": "คำสั่งนี้แสดงสถิติทางเทคนิค — ใช้ได้เฉพาะในแชทส่วนตัวกับบอท ไม่ใช่ในกลุ่ม",
1766
+ "selftest_group_only": "คำสั่งนี้ตรวจสอบสถานะทางเทคนิคของบอท — ใช้ได้เฉพาะในข้อความส่วนตัวกับบอท ไม่ใช่ในกลุ่ม",
1767
+ "selftest_cooldown": "รอ {sec} วินาทีก่อนรันครั้งถัดไป",
1768
+ "selftest_header": "ทดสอบตัวเอง:",
1769
+ "selftest_gemini_skipped": "(ข้าม Gemini — เติมอาร์กิวเมนต์ gemini โควต้าของมันมีจำกัด)",
1770
  "stats_no_data": " ไม่มีข้อมูล",
1771
  "stats_limit_used": " (โควต้าหมด)",
1772
  "tiktok_sound_recognized": "รู้จักลิงก์เสียง TikTok แล้ว",
 
1856
  "logs_send_error": "خطا در ارسال لاگ‌ها: {error}",
1857
  "stats_deny": "به این دستور دسترسی نداری.",
1858
  "stats_group_only": "این دستور آمار فنی را نشان می‌دهد — فقط در پیام‌های خصوصی با ربات در دسترس است، نه در گروه‌ها.",
1859
+ "selftest_group_only": "این دستور وضعیت فنی ربات را بررسی می‌کند — فقط در پیام‌های خصوصی با ربات، نه در گروه‌ها.",
1860
+ "selftest_cooldown": "{sec} ثانیه تا اجرای بعدی صبر کن.",
1861
+ "selftest_header": "خودآزمایی:",
1862
+ "selftest_gemini_skipped": "(Gemini رد شد — آرگومان gemini را اضافه کن، سهمیه‌اش کم است)",
1863
  "stats_no_data": " داده‌ای نیست",
1864
  "stats_limit_used": " (سقف تمام شد)",
1865
  "tiktok_sound_recognized": "لینک صدای TikTok شناخته شد.",
 
1949
  "logs_send_error": "لاگز بھیجنے میں خرابی: {error}",
1950
  "stats_deny": "اس کمانڈ تک رسائی نہیں ہے۔",
1951
  "stats_group_only": "یہ کمانڈ تکنیکی اعداد دکھاتی ہے — صرف بوٹ کے ساتھ ڈائریکٹ پیغامات میں دستیاب ہے، گروپ میں نہیں۔",
1952
+ "selftest_group_only": "یہ کمانڈ بوٹ کی تکنیکی حالت چیک کرتی ہے — صرف بوٹ کے ساتھ براہ راست پیغامات میں، گروپس میں نہیں۔",
1953
+ "selftest_cooldown": "اگلی بار چلانے سے پہلے {sec} سیکنڈ انتظار کرو۔",
1954
+ "selftest_header": "خود جانچ:",
1955
+ "selftest_gemini_skipped": "(Gemini چھوڑ دیا گیا — gemini دلیل شامل کرو، اس کا کوٹہ محدود ہے)",
1956
  "stats_no_data": " کوئی ڈیٹا نہیں",
1957
  "stats_limit_used": " (حد ختم)",
1958
  "tiktok_sound_recognized": "TikTok آواز کا لنک پہچانا گیا۔",
 
2042
  "logs_send_error": "Loglarni yuborishda xato: {error}",
2043
  "stats_deny": "Bu buyruqqa kirish yo'q.",
2044
  "stats_group_only": "Bu buyruq texnik statistikani ko'rsatadi — faqat bot bilan shaxsiy xabarlarda ochiq, guruhlarda emas.",
2045
+ "selftest_group_only": "Bu buyruq botning texnik holatini tekshiradi — faqat bot bilan shaxsiy xabarlarda, guruhlarda emas.",
2046
+ "selftest_cooldown": "Keyingi ishga tushirishdan oldin {sec} s kuting.",
2047
+ "selftest_header": "O‘z-o‘zini tekshirish:",
2048
+ "selftest_gemini_skipped": "(Gemini o‘tkazib yuborildi — gemini argumentini qo‘shing, kvotasi tanqis)",
2049
  "stats_no_data": " ma'lumot yo'q",
2050
  "stats_limit_used": " (limit tugadi)",
2051
  "tiktok_sound_recognized": "TikTok tovush havolasi tanildi.",
 
2135
  "logs_send_error": "লগ পাঠাতে ত্রুটি: {error}",
2136
  "stats_deny": "এই কমান্ডে প্রবেশাধিকার নেই।",
2137
  "stats_group_only": "এই কমান্ড প্রযুক্তিগত পরিসংখ্যান দেখায় — কেবল বটের সাথে ডাইরেক্ট মেসেজে উপলব্ধ, গ্রুপে নয়।",
2138
+ "selftest_group_only": "এই কমান্ড বটের কারিগরি অবস্থা পরীক্ষা করে — শুধু বটের সাথে ব্যক্তিগত বার্তায়, গ্রুপে নয়।",
2139
+ "selftest_cooldown": "পরের বার চালানোর আগে {sec} সেকেন্ড অপেক্ষা করো।",
2140
+ "selftest_header": "স্ব-পরীক্ষা:",
2141
+ "selftest_gemini_skipped": "(Gemini বাদ দেওয়া হয়েছে — gemini আর্গুমেন্ট যোগ করো, কোটা সীমিত)",
2142
  "stats_no_data": " কোনো তথ্য নেই",
2143
  "stats_limit_used": " (সীমা শেষ)",
2144
  "tiktok_sound_recognized": "TikTok সাউন্ড লিংক চেনা গেছে।",
 
2228
  "logs_send_error": "Error sa pag-send ng mga log: {error}",
2229
  "stats_deny": "Walang access sa command na ito.",
2230
  "stats_group_only": "Pinapakita ng command na ito ang mga technical stat — sa direct message lang sa bot available, hindi sa grupo.",
2231
+ "selftest_group_only": "Sinusuri ng command na ito ang teknikal na kalagayan ng bot — sa direktang mensahe lang sa bot, hindi sa mga grupo.",
2232
+ "selftest_cooldown": "Maghintay ng {sec}s bago sa susunod na pag-run.",
2233
+ "selftest_header": "Self-test:",
2234
+ "selftest_gemini_skipped": "(Nilaktawan ang Gemini — idagdag ang argument na gemini, kakaunti ang quota nito)",
2235
  "stats_no_data": " walang data",
2236
  "stats_limit_used": " (ubos ang limit)",
2237
  "tiktok_sound_recognized": "Nakilala ang TikTok sound link.",
 
2321
  "logs_send_error": "Błąd wysyłania logów: {error}",
2322
  "stats_deny": "Brak dostępu do tej komendy.",
2323
  "stats_group_only": "Ta komenda pokazuje statystyki techniczne — dostępna tylko w wiadomościach prywatnych z botem, nie w grupach.",
2324
+ "selftest_group_only": "Ta komenda sprawdza stan techniczny bota — dostępna tylko w wiadomościach prywatnych z botem, nie w grupach.",
2325
+ "selftest_cooldown": "Poczekaj {sec} s przed kolejnym uruchomieniem.",
2326
+ "selftest_header": "Autotest:",
2327
+ "selftest_gemini_skipped": "(Gemini pominięte — dopisz argument gemini, limit jest niski)",
2328
  "stats_no_data": " brak danych",
2329
  "stats_limit_used": " (limit wyczerpany)",
2330
  "tiktok_sound_recognized": "Rozpoznano link do dźwięku TikToka.",
 
2414
  "logs_send_error": "Fout bij verzenden van logs: {error}",
2415
  "stats_deny": "Geen toegang tot dit commando.",
2416
  "stats_group_only": "Dit commando toont technische statistieken — alleen beschikbaar in directe berichten met de bot, niet in groepen.",
2417
+ "selftest_group_only": "Deze opdracht controleert de technische staat van de bot — alleen beschikbaar in directe berichten met de bot, niet in groepen.",
2418
+ "selftest_cooldown": "Wacht {sec} s voor de volgende run.",
2419
+ "selftest_header": "Zelftest:",
2420
+ "selftest_gemini_skipped": "(Gemini overgeslagen — voeg het argument gemini toe, zijn quotum is schaars)",
2421
  "stats_no_data": " geen data",
2422
  "stats_limit_used": " (limiet op)",
2423
  "tiktok_sound_recognized": "TikTok-geluidslink herkend.",
 
2507
  "logs_send_error": "Iphutha ekuthumeleni ama-log: {error}",
2508
  "stats_deny": "Alikho ilungelo lalo myalo.",
2509
  "stats_group_only": "Lo myalo ubonisa izibalo zobuchwepheshe — utholakala kuphela emiyalezo eqondile ne-bot, hhayi eqenjini.",
2510
+ "selftest_group_only": "Lo myalo uhlola isimo sobuchwepheshe se-bot — utholakala kuphela emilayezweni eqondile ne-bot, hhayi emaqenjini.",
2511
+ "selftest_cooldown": "Linda u-{sec}s ngaphambi kokusebenza okulandelayo.",
2512
+ "selftest_header": "Ukuzihlola:",
2513
+ "selftest_gemini_skipped": "(I-Gemini yeqiwe — engeza i-agumenti ethi gemini, i-quota yayo yincane)",
2514
  "stats_no_data": " ayikho idatha",
2515
  "stats_limit_used": " (umkhawulo uphelile)",
2516
  "tiktok_sound_recognized": "Isixhumanisi somsindo se-TikTok saziwe.",
lumen_routes.py CHANGED
@@ -357,6 +357,77 @@ async def ask_groq_text(chat_id: int, user_text: str, model_chain: list[str], *,
357
  bot._record_quota_usage("groq", model_trial)
358
  return answer
359
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
360
  async def _transcribe_audio(audio_bytes: bytes, mime: str, chat_id: int, deadline: float | None = None) -> str | None:
361
  """Голос/аудио в текст через Groq Whisper (дешевле Gemini-квоты на порядок). None при
362
  любой неудаче — вызывающий код молча идёт прежним путём (аудио напрямую в Gemini).
 
357
  bot._record_quota_usage("groq", model_trial)
358
  return answer
359
 
360
+ _SELFTEST_PING_TEXT = "Reply with exactly: ok"
361
+
362
+ def _selftest_short_error(exc: BaseException) -> str:
363
+ """Короткий однострочник для строки /selftest: без ID моделей и простыней."""
364
+ text = f"{exc.__class__.__name__}: {exc}".strip()
365
+ return " ".join(text.split())[:120] or exc.__class__.__name__
366
+
367
+ async def selftest_llm_head(provider: str, *, chat_id: Any = None) -> tuple[bool, str, float]:
368
+ """Одна крошечная проба в голову маршрута (groq/openrouter/gemini) для /selftest.
369
+
370
+ Успех честно пишется в квоту тем же _record_quota_usage, что у настоящих
371
+ ответов; неуспех квоту не трогает (как в ask_*). Историю чата не трогаем —
372
+ проба не должна оседать в контексте владельца. Никогда не кидает: неуспех
373
+ возвращается кортежем (False, причина, секунды)."""
374
+ import bot
375
+ from lumen_router_config import _GROQ_LIGHT_ORDER
376
+ start = time.monotonic()
377
+ try:
378
+ if provider == "groq":
379
+ model = _GROQ_LIGHT_ORDER[0]
380
+ messages = [{"role": "system", "content": ""}, {"role": "user", "content": _SELFTEST_PING_TEXT}]
381
+
382
+ async def _groq_probe_request(payload: dict, dl: float | None):
383
+ return await bot._groq_request("chat/completions", "POST", json_body=payload, deadline=dl)
384
+
385
+ _ans, used = await _chat_completion_chain(
386
+ messages, [model], model, request_fn=_groq_probe_request,
387
+ provider="groq", deadline=start + bot.ROUTE_MODEL_TIMEOUT_SEC,
388
+ log_label="groq", chat_id=chat_id,
389
+ )
390
+ bot._record_quota_usage("groq", used)
391
+ return True, "", round(time.monotonic() - start, 2)
392
+ if provider == "openrouter":
393
+ model = _OR_LIGHT_ORDER[0]
394
+ messages = [{"role": "system", "content": ""}, {"role": "user", "content": _SELFTEST_PING_TEXT}]
395
+
396
+ async def _or_probe_request(payload: dict, dl: float | None):
397
+ return await bot._or_request("chat/completions", "POST", json_body=payload, deadline=dl)
398
+
399
+ _ans, used = await _chat_completion_chain(
400
+ messages, [model], model, request_fn=_or_probe_request,
401
+ provider="openrouter", deadline=start + bot.ROUTE_MODEL_TIMEOUT_SEC,
402
+ log_label="or", chat_id=chat_id,
403
+ )
404
+ bot._record_quota_usage("openrouter", used)
405
+ return True, "", round(time.monotonic() - start, 2)
406
+ if provider == "gemini":
407
+ # Квота самая дефицитная — проба минимальная: голый текст без системного
408
+ # промпта и истории (в отличие от ask_gemini, чистит только живость).
409
+ model = GEMINI_DEFAULT_CHAIN[0]
410
+ deadline = start + bot.ROUTE_MODEL_TIMEOUT_SEC
411
+ contents = [types.Content(role="user", parts=[types.Part.from_text(text=_SELFTEST_PING_TEXT)])]
412
+ call_contents, gconfig = bot._build_gemini_call_config(model, contents)
413
+ resp = await asyncio.wait_for(
414
+ bot.client.aio.models.generate_content(model=model, contents=call_contents, config=gconfig),
415
+ timeout=max(1.0, deadline - time.monotonic()),
416
+ )
417
+ ans = await bot._extract_gemini_answer_text(
418
+ resp, model_id=model, call_contents=call_contents, gconfig=gconfig, deadline=deadline)
419
+ if not ans.strip():
420
+ raise RuntimeError(f"Model {model} returned an empty response")
421
+ _gpt, _gct, _gtt = _usage_from_gemini_response(resp)
422
+ _log_llm_usage(chat_id=chat_id, provider="gemini", model_id=model,
423
+ prompt=_gpt, completion=_gct, total=_gtt)
424
+ _record_model_latency(_model_speed_key("gemini", model), total_sec=time.monotonic() - start)
425
+ bot._record_quota_usage("gemini", model)
426
+ return True, "", round(time.monotonic() - start, 2)
427
+ raise ValueError(f"unknown selftest provider: {provider}")
428
+ except Exception as exc:
429
+ return False, _selftest_short_error(exc), round(time.monotonic() - start, 2)
430
+
431
  async def _transcribe_audio(audio_bytes: bytes, mime: str, chat_id: int, deadline: float | None = None) -> str | None:
432
  """Голос/аудио в текст через Groq Whisper (дешевле Gemini-квоты на порядок). None при
433
  любой неудаче — вызывающий код молча идёт прежним путём (аудио напрямую в Gemini).
tests/test_bot_admin.py CHANGED
@@ -514,3 +514,29 @@ def test_webhook_setup_succeeds_after_retries(monkeypatch):
514
  assert calls["hook"] == 3
515
  assert sleeps == [1.5, 5.0, 10.0, 3600]
516
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
514
  assert calls["hook"] == 3
515
  assert sleeps == [1.5, 5.0, 10.0, 3600]
516
 
517
+
518
+ def test_probe_url_returns_ok_shape_and_redacts_secret_on_error():
519
+ # Общий зонд /diag и /selftest: успех — статус, неуспех — текст без секрета.
520
+ class _FakeResp:
521
+ status = 200
522
+
523
+ async def __aenter__(self):
524
+ return self
525
+
526
+ async def __aexit__(self, *args):
527
+ return False
528
+
529
+ class _FakeSession:
530
+ def get(self, url, **kwargs):
531
+ return _FakeResp()
532
+
533
+ class _BoomSession:
534
+ def get(self, url, **kwargs):
535
+ raise RuntimeError("net down, token abc123")
536
+
537
+ ok = asyncio.run(bot.probe_url(_FakeSession(), "http://x"))
538
+ assert ok["ok"] is True and ok["status"] == 200 and ok["elapsed_sec"] >= 0
539
+ err = asyncio.run(bot.probe_url(_BoomSession(), "http://x", redact="abc123"))
540
+ assert err["ok"] is False
541
+ assert "abc123" not in err["error"] and "<TOKEN>" in err["error"]
542
+
tests/test_bot_commands.py CHANGED
@@ -1278,3 +1278,151 @@ def test_inline_tts_send_failure_raises(monkeypatch):
1278
  asyncio.run(bot.inline_tts(incoming, "привет"))
1279
  finally:
1280
  bot.chat_state.pop(chat_id, None)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1278
  asyncio.run(bot.inline_tts(incoming, "привет"))
1279
  finally:
1280
  bot.chat_state.pop(chat_id, None)
1281
+
1282
+
1283
+ def _make_selftest_message(chat_id, user_id, text="/selftest", group=False):
1284
+ incoming = _FakeIncomingMessage(chat_id)
1285
+ if group:
1286
+ incoming.chat.type = bot.ChatType.GROUP
1287
+ incoming.from_user = SimpleNamespace(id=user_id)
1288
+ incoming.text = text
1289
+ return incoming
1290
+
1291
+
1292
+ def _setup_selftest_fakes(monkeypatch, fail_heads=()):
1293
+ # Фейковые пробы голов и сети + захват ответа. Возвращает (calls, fake_tg_call).
1294
+ import lumen_commands
1295
+ calls = []
1296
+
1297
+ async def fake_head(provider, *, chat_id=None):
1298
+ calls.append(provider)
1299
+ if provider in fail_heads:
1300
+ return False, "boom-500", 0.5
1301
+ return True, "", 0.1
1302
+
1303
+ async def fake_session():
1304
+ return SimpleNamespace()
1305
+
1306
+ async def fake_probe(session, url, *, timeout_sec=6.0, redact=""):
1307
+ return {"status": 200, "elapsed_sec": 0.2, "ok": True}
1308
+
1309
+ async def fake_tg_call(method, *args, **kwargs):
1310
+ fake_tg_call.text = args[0] if args else kwargs.get("text", "")
1311
+ return SimpleNamespace()
1312
+
1313
+ monkeypatch.setattr(bot, "selftest_llm_head", fake_head)
1314
+ monkeypatch.setattr(bot, "_get_http_session", fake_session)
1315
+ monkeypatch.setattr(bot, "probe_url", fake_probe)
1316
+ monkeypatch.setattr(bot, "_tg_call", fake_tg_call)
1317
+ monkeypatch.setattr(lumen_commands, "_SELFTEST_LAST_RUN_MONOTONIC", 0.0)
1318
+ return calls, fake_tg_call
1319
+
1320
+
1321
+ def test_cmd_selftest_denies_non_owner_without_probing(monkeypatch):
1322
+ # Чужой не должен даже запускать пробы: иначе любой сливал бы квоту и видел внутрянку.
1323
+ monkeypatch.setattr(bot, "OWNER_ID", 111001)
1324
+ calls, fake_tg_call = _setup_selftest_fakes(monkeypatch)
1325
+ incoming = _make_selftest_message(999701, 222002)
1326
+ asyncio.run(bot.cmd_selftest(incoming))
1327
+ assert fake_tg_call.text == bot._t(999701, "stats_deny")
1328
+ assert calls == []
1329
+
1330
+
1331
+ def test_cmd_selftest_group_only_for_owner(monkeypatch):
1332
+ # ID моделей в группы не отдаём: команда в группах только отказывает, проб нет.
1333
+ from lumen_router_config import _GROQ_LIGHT_ORDER, _OR_LIGHT_ORDER, GEMINI_DEFAULT_CHAIN
1334
+ monkeypatch.setattr(bot, "OWNER_ID", 333001)
1335
+ calls, fake_tg_call = _setup_selftest_fakes(monkeypatch)
1336
+ incoming = _make_selftest_message(999702, 333001, group=True)
1337
+ asyncio.run(bot.cmd_selftest(incoming))
1338
+ assert fake_tg_call.text == bot._t(999702, "selftest_group_only")
1339
+ assert calls == []
1340
+ for mid in list(_GROQ_LIGHT_ORDER) + list(_OR_LIGHT_ORDER) + list(GEMINI_DEFAULT_CHAIN):
1341
+ assert mid not in fake_tg_call.text
1342
+
1343
+
1344
+ def test_cmd_selftest_cooldown_blocks_second_run(monkeypatch):
1345
+ # Без паузы владелец случайно выжигал бы квоту повторными тапами.
1346
+ monkeypatch.setattr(bot, "OWNER_ID", 444001)
1347
+ calls, fake_tg_call = _setup_selftest_fakes(monkeypatch)
1348
+ asyncio.run(bot.cmd_selftest(_make_selftest_message(999703, 444001)))
1349
+ assert calls == ["groq", "openrouter"]
1350
+ asyncio.run(bot.cmd_selftest(_make_selftest_message(999703, 444001)))
1351
+ assert calls == ["groq", "openrouter"]
1352
+ assert fake_tg_call.text == bot._t(999703, "selftest_cooldown", sec=60)
1353
+
1354
+
1355
+ def test_cmd_selftest_default_skips_gemini_and_hides_model_ids(monkeypatch):
1356
+ # Дефолт без gemini бережёт самую дефицитную квоту; ID моделей в ответе нет.
1357
+ from lumen_router_config import _GROQ_LIGHT_ORDER, _OR_LIGHT_ORDER, GEMINI_DEFAULT_CHAIN
1358
+ monkeypatch.setattr(bot, "OWNER_ID", 555001)
1359
+ calls, fake_tg_call = _setup_selftest_fakes(monkeypatch)
1360
+ asyncio.run(bot.cmd_selftest(_make_selftest_message(999704, 555001)))
1361
+ assert calls == ["groq", "openrouter"]
1362
+ assert bot._t(999704, "selftest_gemini_skipped") in fake_tg_call.text
1363
+ for mid in list(_GROQ_LIGHT_ORDER) + list(_OR_LIGHT_ORDER) + list(GEMINI_DEFAULT_CHAIN):
1364
+ assert mid not in fake_tg_call.text
1365
+
1366
+
1367
+ def test_cmd_selftest_with_gemini_arg_probes_gemini(monkeypatch):
1368
+ # Явный аргумент включает пробу Gemini.
1369
+ monkeypatch.setattr(bot, "OWNER_ID", 666001)
1370
+ calls, fake_tg_call = _setup_selftest_fakes(monkeypatch)
1371
+ asyncio.run(bot.cmd_selftest(_make_selftest_message(999705, 666001, "/selftest gemini")))
1372
+ assert calls == ["groq", "openrouter", "gemini"]
1373
+ assert bot._t(999705, "selftest_gemini_skipped") not in fake_tg_call.text
1374
+
1375
+
1376
+ def test_cmd_selftest_reports_failed_head(monkeypatch):
1377
+ # Упавшая голова — строка FAIL с причиной, а не молчание или падение команды.
1378
+ monkeypatch.setattr(bot, "OWNER_ID", 777001)
1379
+ calls, fake_tg_call = _setup_selftest_fakes(monkeypatch, fail_heads=("groq",))
1380
+ asyncio.run(bot.cmd_selftest(_make_selftest_message(999706, 777001)))
1381
+ assert "Groq: FAIL" in fake_tg_call.text
1382
+ assert "boom-500" in fake_tg_call.text
1383
+ assert "OpenRouter: OK" in fake_tg_call.text
1384
+
1385
+
1386
+ def test_selftest_llm_head_openrouter_success_targets_head_and_counts_quota(monkeypatch):
1387
+ # Честный учёт: успешная проба идёт ровно в голову лёгкого маршрута и +1 в квоту.
1388
+ import lumen_model_speed
1389
+ import lumen_routes
1390
+ from lumen_router_config import _OR_LIGHT_ORDER
1391
+ head = _OR_LIGHT_ORDER[0]
1392
+ seen = {}
1393
+
1394
+ async def fake_or(path, method="GET", *, json_body=None, deadline=None):
1395
+ seen["model"] = (json_body or {}).get("model")
1396
+ return {"choices": [{"message": {"content": "ok"}}],
1397
+ "usage": {"prompt_tokens": 4001, "completion_tokens": 2, "total_tokens": 4003}}
1398
+
1399
+ monkeypatch.setattr(bot, "_or_request", fake_or)
1400
+ before = ((bot.GLOBAL_QUOTA.get("openrouter") or {}).get(head) or {}).get("used") or 0
1401
+ ema_before = dict(lumen_model_speed._latency_ema)
1402
+ try:
1403
+ ok, detail, elapsed = asyncio.run(lumen_routes.selftest_llm_head("openrouter", chat_id=1))
1404
+ finally:
1405
+ lumen_model_speed._latency_ema.clear()
1406
+ lumen_model_speed._latency_ema.update(ema_before)
1407
+ assert ok is True and detail == "" and elapsed >= 0
1408
+ assert seen["model"] == head
1409
+ after = ((bot.GLOBAL_QUOTA.get("openrouter") or {}).get(head) or {}).get("used") or 0
1410
+ assert after == before + 1
1411
+ bot.GLOBAL_QUOTA["openrouter"][head]["used"] = before
1412
+
1413
+
1414
+ def test_selftest_llm_head_openrouter_error_returns_false_without_quota(monkeypatch):
1415
+ # Неуспех квоту не трогает (как в ask_*): иначе ошибка стоила бы как ответ.
1416
+ import lumen_routes
1417
+ from lumen_router_config import _OR_LIGHT_ORDER
1418
+ head = _OR_LIGHT_ORDER[0]
1419
+
1420
+ async def boom(path, method="GET", *, json_body=None, deadline=None):
1421
+ raise RuntimeError("boom-429")
1422
+
1423
+ monkeypatch.setattr(bot, "_or_request", boom)
1424
+ before = ((bot.GLOBAL_QUOTA.get("openrouter") or {}).get(head) or {}).get("used") or 0
1425
+ ok, detail, elapsed = asyncio.run(lumen_routes.selftest_llm_head("openrouter", chat_id=1))
1426
+ assert ok is False and "boom-429" in detail and elapsed >= 0
1427
+ after = ((bot.GLOBAL_QUOTA.get("openrouter") or {}).get(head) or {}).get("used") or 0
1428
+ assert after == before