Spaces:
Running
Running
SilverElixir commited on
Commit ·
0e3aacf
1
Parent(s): aebc099
Add owner-only /selftest route-head probe with quota accounting
Browse files- README.md +2 -1
- bot.py +6 -0
- lumen_admin.py +14 -10
- lumen_commands.py +64 -0
- lumen_lang.py +108 -0
- lumen_routes.py +71 -0
- tests/test_bot_admin.py +26 -0
- tests/test_bot_commands.py +148 -0
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 |
-
|
| 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 |
-
|
| 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
|