SilverElixir commited on
Commit
eb80878
·
1 Parent(s): 398ccda

Add owner ban commands, dependabot PR cap, seed docs

Browse files
.github/dependabot.yml CHANGED
@@ -7,14 +7,18 @@ updates:
7
  directory: "/"
8
  schedule:
9
  interval: "weekly"
 
10
  groups:
11
  actions:
12
  patterns:
13
  - "*"
 
 
14
  - package-ecosystem: "pip"
15
  directory: "/"
16
  schedule:
17
  interval: "weekly"
 
18
  groups:
19
  pip-minor:
20
  patterns:
 
7
  directory: "/"
8
  schedule:
9
  interval: "weekly"
10
+ open-pull-requests-limit: 3
11
  groups:
12
  actions:
13
  patterns:
14
  - "*"
15
+ # pip смотрит все requirements*.txt в каталоге: requirements.txt и
16
+ # requirements-dev.txt оба покрыты, отдельных записей не нужно.
17
  - package-ecosystem: "pip"
18
  directory: "/"
19
  schedule:
20
  interval: "weekly"
21
+ open-pull-requests-limit: 3
22
  groups:
23
  pip-minor:
24
  patterns:
README.md CHANGED
@@ -115,13 +115,15 @@ 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
- 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
 
 
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
+ 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
+ | `/ban <ID>` / `/unban <ID>` (or as a reply) | bot owner, DMs only | Block/unblock a user by Telegram ID. Banned users are fully ignored: no answers, no group context, no limit counters. Survives restarts. |
126
+ | `/banlist` | bot owner, DMs only | Shows banned user IDs. |
127
 
128
  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.
129
 
bot.py CHANGED
@@ -481,6 +481,11 @@ from lumen_chat_state import (
481
  _evict_orphan_chat_locks,
482
  _is_owner,
483
  _notify_owner,
 
 
 
 
 
484
  _is_privileged_in_chat,
485
  _quota_entry,
486
  _mark_quota_exhausted,
@@ -888,6 +893,11 @@ __all__ = [
888
  "GLOBAL_QUOTA",
889
  "_is_owner",
890
  "_is_privileged_in_chat",
 
 
 
 
 
891
  "_mark_quota_exhausted",
892
  "_mark_rate_limited",
893
  "_record_quota_usage",
@@ -1201,6 +1211,9 @@ from lumen_commands import (
1201
  cmd_logs,
1202
  cmd_stats,
1203
  cmd_selftest,
 
 
 
1204
  _build_version,
1205
  _webhook_info_text,
1206
  _process_memory_text,
@@ -1217,6 +1230,9 @@ dp.callback_query.register(handle_lang_callback, F.data.startswith("lang:"))
1217
  dp.message.register(cmd_logs, Command("logs"))
1218
  dp.message.register(cmd_stats, Command("stats"))
1219
  dp.message.register(cmd_selftest, Command("selftest"))
 
 
 
1220
  dp.callback_query.register(handle_pick_callback, F.data.startswith("pick:"))
1221
 
1222
 
@@ -1248,6 +1264,11 @@ async def global_error_handler(event: Any) -> bool:
1248
 
1249
  @dp.message()
1250
  async def handle_message(message: Message) -> None:
 
 
 
 
 
1251
  if message.media_group_id:
1252
  mgid = message.media_group_id
1253
  _mg_buffers.setdefault(mgid, []).append(message)
 
481
  _evict_orphan_chat_locks,
482
  _is_owner,
483
  _notify_owner,
484
+ BANNED_KEY,
485
+ _is_banned,
486
+ _ban_user,
487
+ _unban_user,
488
+ _banned_list,
489
  _is_privileged_in_chat,
490
  _quota_entry,
491
  _mark_quota_exhausted,
 
893
  "GLOBAL_QUOTA",
894
  "_is_owner",
895
  "_is_privileged_in_chat",
896
+ "BANNED_KEY",
897
+ "_is_banned",
898
+ "_ban_user",
899
+ "_unban_user",
900
+ "_banned_list",
901
  "_mark_quota_exhausted",
902
  "_mark_rate_limited",
903
  "_record_quota_usage",
 
1211
  cmd_logs,
1212
  cmd_stats,
1213
  cmd_selftest,
1214
+ cmd_ban,
1215
+ cmd_unban,
1216
+ cmd_banlist,
1217
  _build_version,
1218
  _webhook_info_text,
1219
  _process_memory_text,
 
1230
  dp.message.register(cmd_logs, Command("logs"))
1231
  dp.message.register(cmd_stats, Command("stats"))
1232
  dp.message.register(cmd_selftest, Command("selftest"))
1233
+ dp.message.register(cmd_ban, Command("ban"))
1234
+ dp.message.register(cmd_unban, Command("unban"))
1235
+ dp.message.register(cmd_banlist, Command("banlist"))
1236
  dp.callback_query.register(handle_pick_callback, F.data.startswith("pick:"))
1237
 
1238
 
 
1264
 
1265
  @dp.message()
1266
  async def handle_message(message: Message) -> None:
1267
+ # Владельческая блокировка: ни ответа, ни контекста группы, ни счётчиков —
1268
+ # отсекаем раньше всего, включая медиагруппы и пассивный фон.
1269
+ if message.from_user is not None and _is_banned(message.from_user.id):
1270
+ return
1271
+
1272
  if message.media_group_id:
1273
  mgid = message.media_group_id
1274
  _mg_buffers.setdefault(mgid, []).append(message)
docs/ENVIRONMENT.md CHANGED
@@ -132,7 +132,7 @@ These ceilings are fixed in code; change them only with a code edit:
132
  | `BOT_LOG_PATH` | `/app/bot.log` | Log file path. Mainly relevant for tests; leave it alone in production. |
133
  | `LOG_LEVEL` | `INFO` | Standard logging level (`DEBUG`/`INFO`/`WARNING`/...). |
134
  | `DIAG_TOTAL_BUDGET_SEC` | `25s` | Total budget for the `/diag` network check across all probed hosts, so a hanging host cannot hang the diagnostic. |
135
- | `ADMIN_SECRET_SEED` | — | Secret seed for deriving `WEBHOOK_SECRET` and `ADMIN_PANEL_KEY`. **Set it.** Without it both keys are derived from `BOT_TOKEN`, so a leaked token also exposes `/export_state` and `/webhook` (the bot logs a warning at startup). Any change here rotates both keys; `WEBHOOK_SECRET` re-registers itself, `ADMIN_PANEL_KEY` is retrieved with `GET /admin_keys` (Bearer `BOT_TOKEN`). |
136
 
137
  Sentry setup: create a free Python project at [sentry.io](https://sentry.io) (Developer tier: 5,000 events/month), copy the DSN from the project settings, and add it as a Space secret.
138
 
 
132
  | `BOT_LOG_PATH` | `/app/bot.log` | Log file path. Mainly relevant for tests; leave it alone in production. |
133
  | `LOG_LEVEL` | `INFO` | Standard logging level (`DEBUG`/`INFO`/`WARNING`/...). |
134
  | `DIAG_TOTAL_BUDGET_SEC` | `25s` | Total budget for the `/diag` network check across all probed hosts, so a hanging host cannot hang the diagnostic. |
135
+ | `ADMIN_SECRET_SEED` | — | Secret seed for deriving `WEBHOOK_SECRET` and `ADMIN_PANEL_KEY`. **Set it to its own random value, separate from `BOT_TOKEN`.** Without it both keys are derived from `BOT_TOKEN`, so a leaked token also exposes `/export_state` and `/webhook` (the bot logs a warning at startup). Changing the seed (or setting it for the first time) rotates both keys at once: on restart the bot re-registers the Telegram webhook with the new `WEBHOOK_SECRET` by itself, but anything using the old `ADMIN_PANEL_KEY` (browser bookmarks, cron export scripts) gets `401` until updated — fetch the new key via `GET /admin_keys` with Bearer `BOT_TOKEN`. |
136
 
137
  Sentry setup: create a free Python project at [sentry.io](https://sentry.io) (Developer tier: 5,000 events/month), copy the DSN from the project settings, and add it as a Space secret.
138
 
lumen_chat_state.py CHANGED
@@ -294,6 +294,16 @@ def load_global_quota() -> None:
294
  except (TypeError, ValueError):
295
  clean_stats[field] = 0
296
  GLOBAL_QUOTA[STATS_KEY] = clean_stats
 
 
 
 
 
 
 
 
 
 
297
  raw_ts = loaded.get("last_storage_write_ts")
298
  if isinstance(raw_ts, (int, float)) and not isinstance(raw_ts, bool) and raw_ts > 0:
299
  GLOBAL_QUOTA["last_storage_write_ts"] = float(raw_ts)
@@ -835,6 +845,55 @@ def _is_owner(user_id: int | None) -> bool:
835
  import bot
836
  return bot.OWNER_ID is not None and user_id is not None and user_id == bot.OWNER_ID
837
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
838
  async def _notify_owner(text: str) -> None:
839
  """ЛС владельцу о редких важных событиях; с троттлингом у вызывателя, никогда не кидает."""
840
  import bot
 
294
  except (TypeError, ValueError):
295
  clean_stats[field] = 0
296
  GLOBAL_QUOTA[STATS_KEY] = clean_stats
297
+ # Владельческая блокировка переживает рестарт тем же файлом/ключом.
298
+ raw_banned = loaded.get(BANNED_KEY)
299
+ if isinstance(raw_banned, dict):
300
+ clean_banned: dict[str, Any] = {}
301
+ for uid, ts in raw_banned.items():
302
+ try:
303
+ clean_banned[str(int(uid))] = max(0, int(ts or 0))
304
+ except (TypeError, ValueError):
305
+ continue
306
+ GLOBAL_QUOTA[BANNED_KEY] = clean_banned
307
  raw_ts = loaded.get("last_storage_write_ts")
308
  if isinstance(raw_ts, (int, float)) and not isinstance(raw_ts, bool) and raw_ts > 0:
309
  GLOBAL_QUOTA["last_storage_write_ts"] = float(raw_ts)
 
845
  import bot
846
  return bot.OWNER_ID is not None and user_id is not None and user_id == bot.OWNER_ID
847
 
848
+ # ── Владельческая блокировка (/ban): тот же персистентный файл/ключ, что квота.
849
+ BANNED_KEY = "banned_users"
850
+
851
+ def _banned_map() -> dict[str, Any]:
852
+ import bot
853
+ raw = bot.GLOBAL_QUOTA.get(BANNED_KEY)
854
+ if not isinstance(raw, dict):
855
+ raw = {}
856
+ bot.GLOBAL_QUOTA[BANNED_KEY] = raw
857
+ return raw
858
+
859
+ def _is_banned(user_id: int | None) -> bool:
860
+ """Заблокирован ли пользователь. Владельца забанить нельзя — всегда False."""
861
+ import bot
862
+ if user_id is None or bot._is_owner(user_id):
863
+ return False
864
+ return str(user_id) in _banned_map()
865
+
866
+ def _ban_user(user_id: int) -> bool:
867
+ """True если добавился сейчас, False если уже был (или это владелец)."""
868
+ import bot
869
+ if bot._is_owner(user_id):
870
+ return False
871
+ entry = _banned_map()
872
+ if str(user_id) in entry:
873
+ return False
874
+ entry[str(user_id)] = int(time.time())
875
+ bot.mark_quota_dirty()
876
+ return True
877
+
878
+ def _unban_user(user_id: int) -> bool:
879
+ """True если был в списке и убран."""
880
+ import bot
881
+ if str(user_id) not in _banned_map():
882
+ return False
883
+ del _banned_map()[str(user_id)]
884
+ bot.mark_quota_dirty()
885
+ return True
886
+
887
+ def _banned_list() -> list[int]:
888
+ """ID по возрастанию — для /banlist."""
889
+ ids = []
890
+ for key in _banned_map():
891
+ try:
892
+ ids.append(int(key))
893
+ except (TypeError, ValueError):
894
+ continue
895
+ return sorted(ids)
896
+
897
  async def _notify_owner(text: str) -> None:
898
  """ЛС владельцу о редких важных событиях; с троттлингом у вызывателя, никогда не кидает."""
899
  import bot
lumen_commands.py CHANGED
@@ -720,6 +720,76 @@ async def cmd_selftest(message: Message) -> None:
720
  log.info("[selftest] chat=%s gemini_arg=%s %s", message.chat.id, want_gemini, " ".join(summary))
721
 
722
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
723
  _BUILD_VERSION_CACHED: str | None = None
724
 
725
 
 
720
  log.info("[selftest] chat=%s gemini_arg=%s %s", message.chat.id, want_gemini, " ".join(summary))
721
 
722
 
723
+ def _ban_target_id(message: Message) -> int | None:
724
+ """ID цели /ban//unban: ответ на сообщение или числовой аргумент."""
725
+ replied = getattr(message, "reply_to_message", None)
726
+ replied_user = getattr(replied, "from_user", None) if replied is not None else None
727
+ replied_id = getattr(replied_user, "id", None)
728
+ if isinstance(replied_id, int) and not isinstance(replied_id, bool):
729
+ return replied_id
730
+ args = (getattr(message, "text", "") or "").split()[1:]
731
+ if args:
732
+ raw = args[0].split("@")[0].lstrip("+")
733
+ if raw.isdigit():
734
+ return int(raw)
735
+ return None
736
+
737
+ async def cmd_ban(message: Message) -> None:
738
+ """Владельческая блокировка (только владелец, только личка)."""
739
+ import bot
740
+ if not bot._is_owner(message.from_user.id if message.from_user else None):
741
+ await bot._tg_call(message.reply, bot._t(message.chat.id, "stats_deny"))
742
+ return
743
+ if message.chat.type != ChatType.PRIVATE:
744
+ await bot._tg_call(message.reply, bot._t(message.chat.id, "ban_group_only"))
745
+ return
746
+ target = _ban_target_id(message)
747
+ if target is None:
748
+ await bot._tg_call(message.reply, bot._t(message.chat.id, "ban_usage"))
749
+ return
750
+ if bot._is_owner(target):
751
+ await bot._tg_call(message.reply, bot._t(message.chat.id, "ban_owner_refuse"))
752
+ return
753
+ bot._ban_user(target)
754
+ await bot._tg_call(message.reply, bot._t(message.chat.id, "ban_done", user_id=target))
755
+ log.info("[ban] Owner banned user %s", target)
756
+
757
+ async def cmd_unban(message: Message) -> None:
758
+ """Снятие владельческой блокировки (только владелец, только личка)."""
759
+ import bot
760
+ if not bot._is_owner(message.from_user.id if message.from_user else None):
761
+ await bot._tg_call(message.reply, bot._t(message.chat.id, "stats_deny"))
762
+ return
763
+ if message.chat.type != ChatType.PRIVATE:
764
+ await bot._tg_call(message.reply, bot._t(message.chat.id, "ban_group_only"))
765
+ return
766
+ target = _ban_target_id(message)
767
+ if target is None:
768
+ await bot._tg_call(message.reply, bot._t(message.chat.id, "ban_usage"))
769
+ return
770
+ if bot._unban_user(target):
771
+ await bot._tg_call(message.reply, bot._t(message.chat.id, "unban_done", user_id=target))
772
+ log.info("[ban] Owner unbanned user %s", target)
773
+ else:
774
+ await bot._tg_call(message.reply, bot._t(message.chat.id, "unban_missing", user_id=target))
775
+
776
+ async def cmd_banlist(message: Message) -> None:
777
+ """Список заблокированных (только владелец, только личка)."""
778
+ import bot
779
+ if not bot._is_owner(message.from_user.id if message.from_user else None):
780
+ await bot._tg_call(message.reply, bot._t(message.chat.id, "stats_deny"))
781
+ return
782
+ if message.chat.type != ChatType.PRIVATE:
783
+ await bot._tg_call(message.reply, bot._t(message.chat.id, "ban_group_only"))
784
+ return
785
+ ids = bot._banned_list()
786
+ if not ids:
787
+ await bot._tg_call(message.reply, bot._t(message.chat.id, "banlist_empty"))
788
+ return
789
+ text = bot._t(message.chat.id, "banlist_header") + "\n" + "\n".join(f"• {uid}" for uid in ids)
790
+ await bot._tg_call(message.reply, text)
791
+
792
+
793
  _BUILD_VERSION_CACHED: str | None = None
794
 
795
 
lumen_lang.py CHANGED
@@ -569,6 +569,70 @@ STRINGS: dict[str, dict[str, str]] = {
569
  "ru": "(Gemini пропущен — допиши gemini, квота дефицитная)",
570
  "uk": "(Gemini пропущено — допиши gemini, квота дефіцитна)",
571
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
572
  "stats_no_data": {
573
  "be": " няма даных",
574
  "en": " no data",
@@ -837,6 +901,14 @@ PACK_PT: dict[str, str] = {
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.",
@@ -930,6 +1002,14 @@ PACK_AR: dict[str, str] = {
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.",
@@ -1023,6 +1103,14 @@ PACK_TR: dict[str, str] = {
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ı.",
@@ -1116,6 +1204,14 @@ PACK_DE: dict[str, str] = {
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.",
@@ -1209,6 +1305,14 @@ PACK_FR: dict[str, str] = {
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.",
@@ -1302,6 +1406,14 @@ PACK_IT: dict[str, str] = {
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.",
@@ -1395,6 +1507,14 @@ PACK_HI: dict[str, str] = {
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 ध्वनि लिंक पहचानी गई।",
@@ -1488,6 +1608,14 @@ PACK_ID: dict[str, str] = {
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.",
@@ -1581,6 +1709,14 @@ PACK_MS: dict[str, str] = {
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.",
@@ -1674,6 +1810,14 @@ PACK_VI: dict[str, str] = {
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.",
@@ -1767,6 +1911,14 @@ PACK_TH: dict[str, str] = {
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 แล้ว",
@@ -1860,6 +2012,14 @@ PACK_FA: dict[str, str] = {
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 شناخته شد.",
@@ -1953,6 +2113,14 @@ PACK_UR: dict[str, str] = {
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 آواز کا لنک پہچانا گیا۔",
@@ -2046,6 +2214,14 @@ PACK_UZ: dict[str, str] = {
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.",
@@ -2139,6 +2315,14 @@ PACK_BN: dict[str, str] = {
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 সাউন্ড লিংক চেনা গেছে।",
@@ -2232,6 +2416,14 @@ PACK_FIL: dict[str, str] = {
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.",
@@ -2325,6 +2517,14 @@ PACK_PL: dict[str, str] = {
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.",
@@ -2418,6 +2618,14 @@ PACK_NL: dict[str, str] = {
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.",
@@ -2511,6 +2719,14 @@ PACK_ZU: dict[str, str] = {
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.",
 
569
  "ru": "(Gemini пропущен — допиши gemini, квота дефицитная)",
570
  "uk": "(Gemini пропущено — допиши gemini, квота дефіцитна)",
571
  },
572
+ "ban_group_only": {
573
+ "be": "Гэтая каманда кіруе блакіроўкай — даступна толькі ў асабістых паведамленнях з ботам, не ў групах.",
574
+ "en": "This command manages the blocklist — available only in direct messages with the bot, not in groups.",
575
+ "es": "Este comando gestiona la lista de bloqueados — disponible solo en mensajes directos con el bot, no en grupos.",
576
+ "kk": "Бұл команда бұғаттау тізімін басқарады — тек ботпен жеке хабарламаларда қолжетімді, топтарда емес.",
577
+ "ru": "Эта команда управляет блокировкой — доступна только в личных сообщениях с ботом, не в группах.",
578
+ "uk": "Ця команда керує блокуванням — доступна лише в особистих повідомленнях з ботом, не в групах.",
579
+ },
580
+ "ban_usage": {
581
+ "be": "Выкарыстанне: /ban <ID> або адказам на паведамленне; /unban <ID> або адказам.",
582
+ "en": "Usage: /ban <ID> or as a reply; /unban <ID> or as a reply.",
583
+ "es": "Uso: /ban <ID> o como respuesta; /unban <ID> o como respuesta.",
584
+ "kk": "Қолдану: /ban <ID> немесе хабарламаға жауап ретінде; /unban <ID> немесе жауап ретінде.",
585
+ "ru": "Использование: /ban <ID> или ответом на сообщение; /unban <ID> или ответом.",
586
+ "uk": "Використання: /ban <ID> або відповіддю на повідомлення; /unban <ID> або відповіддю.",
587
+ },
588
+ "ban_owner_refuse": {
589
+ "be": "Уладальніка заблакаваць нельга.",
590
+ "en": "The owner cannot be banned.",
591
+ "es": "No se puede bloquear al dueño.",
592
+ "kk": "Иесін бұғаттау мүмкін емес.",
593
+ "ru": "Владельца заблокировать нельзя.",
594
+ "uk": "Власника заблокувати не можна.",
595
+ },
596
+ "ban_done": {
597
+ "be": "Карыстальнік {user_id} заблакаваны.",
598
+ "en": "User {user_id} banned.",
599
+ "es": "Usuario {user_id} bloqueado.",
600
+ "kk": "Пайдаланушы {user_id} бұғатталды.",
601
+ "ru": "Пользователь {user_id} заблокирован.",
602
+ "uk": "Користувача {user_id} заблоковано.",
603
+ },
604
+ "unban_done": {
605
+ "be": "Карыстальнік {user_id} разблакаваны.",
606
+ "en": "User {user_id} unbanned.",
607
+ "es": "Usuario {user_id} desbloqueado.",
608
+ "kk": "Пайдаланушы {user_id} бұғаттан шығарылды.",
609
+ "ru": "Пользователь {user_id} разблокирован.",
610
+ "uk": "Користувача {user_id} розблоковано.",
611
+ },
612
+ "unban_missing": {
613
+ "be": "Карыстальніка {user_id} няма ў спісе.",
614
+ "en": "User {user_id} is not on the list.",
615
+ "es": "El usuario {user_id} no está en la lista.",
616
+ "kk": "Пайдаланушы {user_id} тізімде жоқ.",
617
+ "ru": "Пользователя {user_id} нет в списке.",
618
+ "uk": "Користувача {user_id} немає у списку.",
619
+ },
620
+ "banlist_empty": {
621
+ "be": "Спіс пусты — нікога не заблакавана.",
622
+ "en": "The list is empty — nobody is banned.",
623
+ "es": "La lista está vacía — nadie bloqueado.",
624
+ "kk": "Тізім бос — ешкім бұғатталмаған.",
625
+ "ru": "Список пуст — никто не заблокирован.",
626
+ "uk": "Список порожній — нікого не заблоковано.",
627
+ },
628
+ "banlist_header": {
629
+ "be": "Заблакаваныя:",
630
+ "en": "Banned:",
631
+ "es": "Bloqueados:",
632
+ "kk": "Бұғатталғандар:",
633
+ "ru": "Заблокированные:",
634
+ "uk": "Заблоковані:",
635
+ },
636
  "stats_no_data": {
637
  "be": " няма даных",
638
  "en": " no data",
 
901
  "selftest_cooldown": "Espera {sec}s antes da próxima execução.",
902
  "selftest_header": "Autoteste:",
903
  "selftest_gemini_skipped": "(Gemini ignorado — adiciona o argumento gemini, a quota dele é escassa)",
904
+ "ban_group_only": "Este comando gere a lista de bloqueados — disponível apenas em mensagens diretas com o bot, não em grupos.",
905
+ "ban_usage": "Uso: /ban <ID> ou como resposta; /unban <ID> ou como resposta.",
906
+ "ban_owner_refuse": "O dono não pode ser bloqueado.",
907
+ "ban_done": "Utilizador {user_id} bloqueado.",
908
+ "unban_done": "Utilizador {user_id} desbloqueado.",
909
+ "unban_missing": "O utilizador {user_id} não está na lista.",
910
+ "banlist_empty": "A lista está vazia — ninguém bloqueado.",
911
+ "banlist_header": "Bloqueados:",
912
  "stats_no_data": " sem dados",
913
  "stats_limit_used": " (limite esgotado)",
914
  "tiktok_sound_recognized": "Link de som do TikTok reconhecido.",
 
1002
  "selftest_cooldown": "انتظر {sec} ث قبل التشغيل التالي.",
1003
  "selftest_header": "فحص ذاتي:",
1004
  "selftest_gemini_skipped": "(تم تخطي Gemini — أضف الوسيط gemini، حصته شحيحة)",
1005
+ "ban_group_only": "هذا الأمر يدير قائمة الحظر — متاح فقط في الرسائل الخاصة مع البوت وليس في المجموعات.",
1006
+ "ban_usage": "الاستخدام: /ban <ID> أو بالرد؛ /unban <ID> أو بالرد.",
1007
+ "ban_owner_refuse": "لا يمكن حظر المالك.",
1008
+ "ban_done": "تم حظر المستخدم {user_id}.",
1009
+ "unban_done": "تم إلغاء حظر المستخدم {user_id}.",
1010
+ "unban_missing": "المستخدم {user_id} ليس في القائمة.",
1011
+ "banlist_empty": "القائمة فارغة — لا أحد محظور.",
1012
+ "banlist_header": "محظورون:",
1013
  "stats_no_data": " لا بيانات",
1014
  "stats_limit_used": " (الحد مستنفد)",
1015
  "tiktok_sound_recognized": "تم التعرف على رابط صوت TikTok.",
 
1103
  "selftest_cooldown": "Sonraki çalıştırmadan önce {sec} sn bekle.",
1104
  "selftest_header": "Öz test:",
1105
  "selftest_gemini_skipped": "(Gemini atlandı — gemini argümanını ekle, kotası kıt)",
1106
+ "ban_group_only": "Bu komut engel listesini yönetir — yalnızca botla direkt mesajlarda kullanılabilir, gruplarda değil.",
1107
+ "ban_usage": "Kullanım: /ban <ID> veya yanıt olarak; /unban <ID> veya yanıt olarak.",
1108
+ "ban_owner_refuse": "Sahip engellenemez.",
1109
+ "ban_done": "Kullanıcı {user_id} engellendi.",
1110
+ "unban_done": "Kullanıcı {user_id} engeli kaldırıldı.",
1111
+ "unban_missing": "Kullanıcı {user_id} listede yok.",
1112
+ "banlist_empty": "Liste boş — kimse engelli değil.",
1113
+ "banlist_header": "Engellenenler:",
1114
  "stats_no_data": " veri yok",
1115
  "stats_limit_used": " (limit doldu)",
1116
  "tiktok_sound_recognized": "TikTok ses bağlantısı tanındı.",
 
1204
  "selftest_cooldown": "Warte {sec} s vor dem nächsten Start.",
1205
  "selftest_header": "Selbsttest:",
1206
  "selftest_gemini_skipped": "(Gemini übersprungen — füge das Argument gemini hinzu, sein Kontingent ist knapp)",
1207
+ "ban_group_only": "Dieser Befehl verwaltet die Sperrliste — nur in Direktnachrichten mit dem Bot verfügbar, nicht in Gruppen.",
1208
+ "ban_usage": "Verwendung: /ban <ID> oder als Antwort; /unban <ID> oder als Antwort.",
1209
+ "ban_owner_refuse": "Der Besitzer kann nicht gesperrt werden.",
1210
+ "ban_done": "Benutzer {user_id} gesperrt.",
1211
+ "unban_done": "Benutzer {user_id} entsperrt.",
1212
+ "unban_missing": "Benutzer {user_id} steht nicht auf der Liste.",
1213
+ "banlist_empty": "Die Liste ist leer — niemand gesperrt.",
1214
+ "banlist_header": "Gesperrt:",
1215
  "stats_no_data": " keine Daten",
1216
  "stats_limit_used": " (Limit erschöpft)",
1217
  "tiktok_sound_recognized": "TikTok-Soundlink erkannt.",
 
1305
  "selftest_cooldown": "Attends {sec} s avant le prochain lancement.",
1306
  "selftest_header": "Auto-test :",
1307
  "selftest_gemini_skipped": "(Gemini ignoré — ajoute l'argument gemini, son quota est limité)",
1308
+ "ban_group_only": "Cette commande gère la liste des bloqués — disponible uniquement en messages privés avec le bot, pas en groupes.",
1309
+ "ban_usage": "Usage : /ban <ID> ou en réponse ; /unban <ID> ou en réponse.",
1310
+ "ban_owner_refuse": "Le propriétaire ne peut pas être bloqué.",
1311
+ "ban_done": "Utilisateur {user_id} bloqué.",
1312
+ "unban_done": "Utilisateur {user_id} débloqué.",
1313
+ "unban_missing": "L'utilisateur {user_id} n'est pas dans la liste.",
1314
+ "banlist_empty": "La liste est vide — personne n'est bloqué.",
1315
+ "banlist_header": "Bloqués :",
1316
  "stats_no_data": " pas de données",
1317
  "stats_limit_used": " (limite épuisée)",
1318
  "tiktok_sound_recognized": "Lien de son TikTok reconnu.",
 
1406
  "selftest_cooldown": "Attendi {sec} s prima del prossimo avvio.",
1407
  "selftest_header": "Autotest:",
1408
  "selftest_gemini_skipped": "(Gemini saltato — aggiungi l'argomento gemini, la sua quota è scarsa)",
1409
+ "ban_group_only": "Questo comando gestisce la lista dei bloccati — disponibile solo nei messaggi diretti con il bot, non nei gruppi.",
1410
+ "ban_usage": "Uso: /ban <ID> o come risposta; /unban <ID> o come risposta.",
1411
+ "ban_owner_refuse": "Il proprietario non può essere bloccato.",
1412
+ "ban_done": "Utente {user_id} bloccato.",
1413
+ "unban_done": "Utente {user_id} sbloccato.",
1414
+ "unban_missing": "L'utente {user_id} non è nella lista.",
1415
+ "banlist_empty": "La lista è vuota — nessuno bloccato.",
1416
+ "banlist_header": "Bloccati:",
1417
  "stats_no_data": " nessun dato",
1418
  "stats_limit_used": " (limite esaurito)",
1419
  "tiktok_sound_recognized": "Link audio di TikTok riconosciuto.",
 
1507
  "selftest_cooldown": "अगली बार चलाने से पहले {sec} सेकंड रुको।",
1508
  "selftest_header": "स्व-जांच:",
1509
  "selftest_gemini_skipped": "(Gemini छोड़ा गया — gemini आर्गुमेंट जोड़ो, उसका कोटा सीमित है)",
1510
+ "ban_group_only": "यह कमांड ब्लॉक सूची संभालता है — केवल बॉट के साथ सीधे संदेशों में, समूहों में नहीं।",
1511
+ "ban_usage": "उपयोग: /ban <ID> या जवाब में; /unban <ID> या जवाब में।",
1512
+ "ban_owner_refuse": "मालिक को बैन नहीं किया जा सकता।",
1513
+ "ban_done": "उपयोगकर्ता {user_id} बैन हुआ।",
1514
+ "unban_done": "उपयोगकर्ता {user_id} अनबैन हुआ।",
1515
+ "unban_missing": "उपयोगकर्ता {user_id} सूची में नहीं है।",
1516
+ "banlist_empty": "सूची खाली है — कोई बैन नहीं है।",
1517
+ "banlist_header": "बैन किए गए:",
1518
  "stats_no_data": " कोई डेटा नहीं",
1519
  "stats_limit_used": " (सीमा समाप्त)",
1520
  "tiktok_sound_recognized": "TikTok ध्वनि लिंक पहचानी गई।",
 
1608
  "selftest_cooldown": "Tunggu {sec} dtk sebelum menjalankan lagi.",
1609
  "selftest_header": "Uji mandiri:",
1610
  "selftest_gemini_skipped": "(Gemini dilewati — tambahkan argumen gemini, kuotanya terbatas)",
1611
+ "ban_group_only": "Perintah ini mengelola daftar blokir — hanya tersedia di pesan langsung dengan bot, bukan di grup.",
1612
+ "ban_usage": "Penggunaan: /ban <ID> atau sebagai balasan; /unban <ID> atau sebagai balasan.",
1613
+ "ban_owner_refuse": "Pemilik tidak bisa diblokir.",
1614
+ "ban_done": "Pengguna {user_id} diblokir.",
1615
+ "unban_done": "Pengguna {user_id} dibuka blokirnya.",
1616
+ "unban_missing": "Pengguna {user_id} tidak ada dalam daftar.",
1617
+ "banlist_empty": "Daftarnya kosong — tidak ada yang diblokir.",
1618
+ "banlist_header": "Diblokir:",
1619
  "stats_no_data": " tidak ada data",
1620
  "stats_limit_used": " (batas habis)",
1621
  "tiktok_sound_recognized": "Tautan suara TikTok dikenali.",
 
1709
  "selftest_cooldown": "Tunggu {sec}s sebelum larian seterusnya.",
1710
  "selftest_header": "Ujian kendiri:",
1711
  "selftest_gemini_skipped": "(Gemini dilangkau — tambah argumen gemini, kuotanya terhad)",
1712
+ "ban_group_only": "Perintah ini mengurus senarai sekat — hanya tersedia dalam mesej langsung dengan bot, bukan dalam kumpulan.",
1713
+ "ban_usage": "Penggunaan: /ban <ID> atau sebagai balasan; /unban <ID> atau sebagai balasan.",
1714
+ "ban_owner_refuse": "Pemilik tidak boleh disekat.",
1715
+ "ban_done": "Pengguna {user_id} disekat.",
1716
+ "unban_done": "Pengguna {user_id} dinyahsekat.",
1717
+ "unban_missing": "Pengguna {user_id} tiada dalam senarai.",
1718
+ "banlist_empty": "Senarai kosong — tiada siapa disekat.",
1719
+ "banlist_header": "Disekat:",
1720
  "stats_no_data": " tiada data",
1721
  "stats_limit_used": " (had habis)",
1722
  "tiktok_sound_recognized": "Pautan bunyi TikTok dikenal pasti.",
 
1810
  "selftest_cooldown": "Đợi {sec} giây trước lần chạy tiếp theo.",
1811
  "selftest_header": "Tự kiểm tra:",
1812
  "selftest_gemini_skipped": "(Bỏ qua Gemini — thêm đối số gemini, hạn ngạch của nó khan hiếm)",
1813
+ "ban_group_only": "Lệnh này quản lý danh sách chặn — chỉ dùng được trong tin nhắn riêng với bot, không dùng trong nhóm.",
1814
+ "ban_usage": "Cách dùng: /ban <ID> hoặc trả lời tin nhắn; /unban <ID> hoặc trả lời tin nhắn.",
1815
+ "ban_owner_refuse": "Không thể chặn chủ sở hữu.",
1816
+ "ban_done": "Đã chặn người dùng {user_id}.",
1817
+ "unban_done": "Đã bỏ chặn người dùng {user_id}.",
1818
+ "unban_missing": "Người dùng {user_id} không có trong danh sách.",
1819
+ "banlist_empty": "Danh sách trống — không ai bị chặn.",
1820
+ "banlist_header": "Đã chặn:",
1821
  "stats_no_data": " không có dữ liệu",
1822
  "stats_limit_used": " (hết hạn mức)",
1823
  "tiktok_sound_recognized": "Đã nhận ra liên kết âm thanh TikTok.",
 
1911
  "selftest_cooldown": "รอ {sec} วินาทีก่อนรันครั้งถัดไป",
1912
  "selftest_header": "ทดสอบตัวเอง:",
1913
  "selftest_gemini_skipped": "(ข้าม Gemini — เติมอาร์กิวเมนต์ gemini โควต้าของมันมีจำกัด)",
1914
+ "ban_group_only": "คำสั่งนี้จัดการรายชื่อที่ถูกบล็อก — ใช้ได้เฉพาะในข้อความส่วนตัวกับบอท ไม่ใช่ในกลุ่ม",
1915
+ "ban_usage": "วิธีใช้: /ban <ID> หรือตอบกลับ; /unban <ID> หรือตอบกลับ",
1916
+ "ban_owner_refuse": "บล็อกเจ้าของไม่ได้",
1917
+ "ban_done": "แบนผู้ใช้ {user_id} แล้ว",
1918
+ "unban_done": "ปลดบล็อกผู้ใช้ {user_id} แล้ว",
1919
+ "unban_missing": "ผู้ใช้ {user_id} ไม่ได้อยู่ในรายชื่อ",
1920
+ "banlist_empty": "รายชื่อว่าง — ไม่มีใครถูกบล็อก",
1921
+ "banlist_header": "ถูกบล็อก:",
1922
  "stats_no_data": " ไม่มีข้อมูล",
1923
  "stats_limit_used": " (โควต้าหมด)",
1924
  "tiktok_sound_recognized": "รู้จักลิงก์เสียง TikTok แล้ว",
 
2012
  "selftest_cooldown": "{sec} ثانیه تا اجرای بعدی صبر کن.",
2013
  "selftest_header": "خودآزمایی:",
2014
  "selftest_gemini_skipped": "(Gemini رد شد — آرگومان gemini را اضافه کن، سهمیه‌اش کم است)",
2015
+ "ban_group_only": "این دستور فهرست مسدودی‌ها را مدیریت می‌کند — فقط در پیام‌های خصوصی با ربات، نه در گروه‌ها.",
2016
+ "ban_usage": "کاربرد: /ban <ID> یا با پاسخ؛ /unban <ID> یا با پاسخ.",
2017
+ "ban_owner_refuse": "مالک را نمی‌توان مسدود کرد.",
2018
+ "ban_done": "کاربر {user_id} مسدود شد.",
2019
+ "unban_done": "کاربر {user_id} رفع مسدودیت شد.",
2020
+ "unban_missing": "کاربر {user_id} در فهرست نیست.",
2021
+ "banlist_empty": "فهرست خالی است — هیچ‌کس مسدود نیست.",
2022
+ "banlist_header": "مسدودشده‌ها:",
2023
  "stats_no_data": " داده‌ای نیست",
2024
  "stats_limit_used": " (سقف تمام شد)",
2025
  "tiktok_sound_recognized": "لینک صدای TikTok شناخته شد.",
 
2113
  "selftest_cooldown": "اگلی بار چلانے سے پہلے {sec} سیکنڈ انتظار کرو۔",
2114
  "selftest_header": "خود جانچ:",
2115
  "selftest_gemini_skipped": "(Gemini چھوڑ دیا گیا — gemini دلیل شامل کرو، اس کا کوٹہ محدود ہے)",
2116
+ "ban_group_only": "یہ کمانڈ بلاک لسٹ کا نظم کرتی ہے — صرف بوٹ کے ساتھ براہ راست پیغامات میں، گروپس میں نہیں۔",
2117
+ "ban_usage": "استعمال: /ban <ID> یا جواب میں؛ /unban <ID> یا جواب میں۔",
2118
+ "ban_owner_refuse": "مالک کو بین نہیں کیا جا سکتا۔",
2119
+ "ban_done": "صارف {user_id} بین ہو گیا۔",
2120
+ "unban_done": "صارف {user_id} ان بین ہو گیا۔",
2121
+ "unban_missing": "صارف {user_id} فہرست میں نہیں ہے۔",
2122
+ "banlist_empty": "فہرست خالی ہے — کوئی بین نہیں ہے۔",
2123
+ "banlist_header": "بین شدہ:",
2124
  "stats_no_data": " کوئی ڈیٹا نہیں",
2125
  "stats_limit_used": " (حد ختم)",
2126
  "tiktok_sound_recognized": "TikTok آواز کا لنک پہچانا گیا۔",
 
2214
  "selftest_cooldown": "Keyingi ishga tushirishdan oldin {sec} s kuting.",
2215
  "selftest_header": "O‘z-o‘zini tekshirish:",
2216
  "selftest_gemini_skipped": "(Gemini o‘tkazib yuborildi — gemini argumentini qo‘shing, kvotasi tanqis)",
2217
+ "ban_group_only": "Bu buyruq bloklanganlar ro‘yxatini boshqaradi — faqat bot bilan shaxsiy xabarlarda, guruhlarda emas.",
2218
+ "ban_usage": "Foydalanish: /ban <ID> yoki javob tariqasida; /unban <ID> yoki javob tariqasida.",
2219
+ "ban_owner_refuse": "Egani bloklab bo‘lmaydi.",
2220
+ "ban_done": "Foydalanuvchi {user_id} bloklandi.",
2221
+ "unban_done": "Foydalanuvchi {user_id} blokdan chiqarildi.",
2222
+ "unban_missing": "Foydalanuvchi {user_id} ro‘yxatda yo‘q.",
2223
+ "banlist_empty": "Ro‘yxat bo‘sh — hech kim bloklanmagan.",
2224
+ "banlist_header": "Bloklanganlar:",
2225
  "stats_no_data": " ma'lumot yo'q",
2226
  "stats_limit_used": " (limit tugadi)",
2227
  "tiktok_sound_recognized": "TikTok tovush havolasi tanildi.",
 
2315
  "selftest_cooldown": "পরের বার চালানোর আগে {sec} সেকেন্ড অপেক্ষা করো।",
2316
  "selftest_header": "স্ব-পরীক্ষা:",
2317
  "selftest_gemini_skipped": "(Gemini বাদ দেওয়া হয়েছে — gemini আর্গুমেন্ট যোগ করো, কোটা সীমিত)",
2318
+ "ban_group_only": "এই কমান্ড ব্লকলিস্ট পরিচালনা করে — শুধু বটের সাথে ব্যক্তিগত বার্তায়, গ্রুপে নয়।",
2319
+ "ban_usage": "ব্যবহার: /ban <ID> বা উত্তরে; /unban <ID> বা উত্তরে।",
2320
+ "ban_owner_refuse": "মালিককে ব্যান করা যাবে না।",
2321
+ "ban_done": "ব্যবহারকারী {user_id} ব্যান হয়েছে।",
2322
+ "unban_done": "ব্যবহারকারী {user_id} আনব্যান হয়েছে।",
2323
+ "unban_missing": "ব্যবহারকারী {user_id} তালিকায় নেই।",
2324
+ "banlist_empty": "তালিকা খালি — কেউ ব্যান নয়।",
2325
+ "banlist_header": "ব্যানড:",
2326
  "stats_no_data": " কোনো তথ্য নেই",
2327
  "stats_limit_used": " (সীমা শেষ)",
2328
  "tiktok_sound_recognized": "TikTok সাউন্ড লিংক চেনা গেছে।",
 
2416
  "selftest_cooldown": "Maghintay ng {sec}s bago sa susunod na pag-run.",
2417
  "selftest_header": "Self-test:",
2418
  "selftest_gemini_skipped": "(Nilaktawan ang Gemini — idagdag ang argument na gemini, kakaunti ang quota nito)",
2419
+ "ban_group_only": "Pinamamahalaan ng command na ito ang blocklist — sa direktang mensahe lang sa bot, hindi sa mga grupo.",
2420
+ "ban_usage": "Paggamit: /ban <ID> o bilang reply; /unban <ID> o bilang reply.",
2421
+ "ban_owner_refuse": "Hindi puwedeng i-ban ang may-ari.",
2422
+ "ban_done": "Na-ban ang user na {user_id}.",
2423
+ "unban_done": "Na-unban ang user na {user_id}.",
2424
+ "unban_missing": "Wala sa listahan ang user na {user_id}.",
2425
+ "banlist_empty": "Walang laman ang listahan — walang naka-ban.",
2426
+ "banlist_header": "Naka-ban:",
2427
  "stats_no_data": " walang data",
2428
  "stats_limit_used": " (ubos ang limit)",
2429
  "tiktok_sound_recognized": "Nakilala ang TikTok sound link.",
 
2517
  "selftest_cooldown": "Poczekaj {sec} s przed kolejnym uruchomieniem.",
2518
  "selftest_header": "Autotest:",
2519
  "selftest_gemini_skipped": "(Gemini pominięte — dopisz argument gemini, limit jest niski)",
2520
+ "ban_group_only": "Ta komenda zarządza listą zablokowanych — dostępna tylko w wiadomościach prywatnych z botem, nie w grupach.",
2521
+ "ban_usage": "Użycie: /ban <ID> lub w odpowiedzi; /unban <ID> lub w odpowiedzi.",
2522
+ "ban_owner_refuse": "Właściciela nie można zablokować.",
2523
+ "ban_done": "Użytkownik {user_id} zablokowany.",
2524
+ "unban_done": "Użytkownik {user_id} odblokowany.",
2525
+ "unban_missing": "Użytkownika {user_id} nie ma na liście.",
2526
+ "banlist_empty": "Lista jest pusta — nikt nie jest zablokowany.",
2527
+ "banlist_header": "Zablokowani:",
2528
  "stats_no_data": " brak danych",
2529
  "stats_limit_used": " (limit wyczerpany)",
2530
  "tiktok_sound_recognized": "Rozpoznano link do dźwięku TikToka.",
 
2618
  "selftest_cooldown": "Wacht {sec} s voor de volgende run.",
2619
  "selftest_header": "Zelftest:",
2620
  "selftest_gemini_skipped": "(Gemini overgeslagen — voeg het argument gemini toe, zijn quotum is schaars)",
2621
+ "ban_group_only": "Deze opdracht beheert de blokkeerlijst — alleen beschikbaar in directe berichten met de bot, niet in groepen.",
2622
+ "ban_usage": "Gebruik: /ban <ID> of als antwoord; /unban <ID> of als antwoord.",
2623
+ "ban_owner_refuse": "De eigenaar kan niet worden geblokkeerd.",
2624
+ "ban_done": "Gebruiker {user_id} geblokkeerd.",
2625
+ "unban_done": "Gebruiker {user_id} gedeblokkeerd.",
2626
+ "unban_missing": "Gebruiker {user_id} staat niet op de lijst.",
2627
+ "banlist_empty": "De lijst is leeg — niemand geblokkeerd.",
2628
+ "banlist_header": "Geblokkeerd:",
2629
  "stats_no_data": " geen data",
2630
  "stats_limit_used": " (limiet op)",
2631
  "tiktok_sound_recognized": "TikTok-geluidslink herkend.",
 
2719
  "selftest_cooldown": "Linda u-{sec}s ngaphambi kokusebenza okulandelayo.",
2720
  "selftest_header": "Ukuzihlola:",
2721
  "selftest_gemini_skipped": "(I-Gemini yeqiwe — engeza i-agumenti ethi gemini, i-quota yayo yincane)",
2722
+ "ban_group_only": "Lo myalo uphatha uhlu lwavinjiwe — utholakala kuphela emilayezweni eqondile ne-bot, hhayi emaqenjini.",
2723
+ "ban_usage": "Ukusebenzisa: /ban <ID> noma ngempendulo; /unban <ID> noma ngempendulo.",
2724
+ "ban_owner_refuse": "Umnikazi akakwazi ukuvinjwa.",
2725
+ "ban_done": "Umsebenzisi {user_id} uvinjiwe.",
2726
+ "unban_done": "Umsebenzisi {user_id} ukhTshirwe.",
2727
+ "unban_missing": "Umsebenzisi {user_id} akekho ohlwini.",
2728
+ "banlist_empty": "Uhlu alunamuntu — akekho ovinjiwe.",
2729
+ "banlist_header": "Abavinjiwe:",
2730
  "stats_no_data": " ayikho idatha",
2731
  "stats_limit_used": " (umkhawulo uphelile)",
2732
  "tiktok_sound_recognized": "Isixhumanisi somsindo se-TikTok saziwe.",
tests/test_bot_commands.py CHANGED
@@ -1445,3 +1445,105 @@ def test_cmd_stats_shows_quarantine(monkeypatch):
1445
  asyncio.run(bot.cmd_stats(incoming))
1446
  assert "Карантин моделей" in sent["text"]
1447
  assert "qs-model:free" in sent["text"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1445
  asyncio.run(bot.cmd_stats(incoming))
1446
  assert "Карантин моделей" in sent["text"]
1447
  assert "qs-model:free" in sent["text"]
1448
+
1449
+
1450
+ def _make_ban_message(chat_id, user_id, text, group=False, reply_to=None):
1451
+ incoming = _FakeIncomingMessage(chat_id)
1452
+ if group:
1453
+ incoming.chat.type = bot.ChatType.GROUP
1454
+ incoming.from_user = SimpleNamespace(id=user_id)
1455
+ incoming.text = text
1456
+ incoming.reply_to_message = reply_to
1457
+ return incoming
1458
+
1459
+
1460
+ def _capture_replies(monkeypatch):
1461
+ sent = {}
1462
+
1463
+ async def fake_tg_call(method, *args, **kwargs):
1464
+ sent["text"] = args[0] if args else kwargs.get("text", "")
1465
+ return SimpleNamespace()
1466
+
1467
+ monkeypatch.setattr(bot, "_tg_call", fake_tg_call)
1468
+ return sent
1469
+
1470
+
1471
+ def test_ban_flow_by_id_unban_and_list(monkeypatch):
1472
+ # Полный цикл: бан по ID, список, разбан, повторный разбан — мимо.
1473
+ monkeypatch.setattr(bot, "OWNER_ID", 101001)
1474
+ sent = _capture_replies(monkeypatch)
1475
+ assert bot._is_banned(777002) is False
1476
+ asyncio.run(bot.cmd_ban(_make_ban_message(999801, 101001, "/ban 777002")))
1477
+ assert sent["text"] == bot._t(999801, "ban_done", user_id=777002)
1478
+ assert bot._is_banned(777002) is True
1479
+ asyncio.run(bot.cmd_banlist(_make_ban_message(999801, 101001, "/banlist")))
1480
+ assert "777002" in sent["text"]
1481
+ asyncio.run(bot.cmd_unban(_make_ban_message(999801, 101001, "/unban 777002")))
1482
+ assert sent["text"] == bot._t(999801, "unban_done", user_id=777002)
1483
+ assert bot._is_banned(777002) is False
1484
+ asyncio.run(bot.cmd_unban(_make_ban_message(999801, 101001, "/unban 777002")))
1485
+ assert sent["text"] == bot._t(999801, "unban_missing", user_id=777002)
1486
+ asyncio.run(bot.cmd_banlist(_make_ban_message(999801, 101001, "/banlist")))
1487
+ assert sent["text"] == bot._t(999801, "banlist_empty")
1488
+
1489
+
1490
+ def test_ban_by_reply_and_usage_and_owner_refuse(monkeypatch):
1491
+ # Цель ответом на сообщение; мусор вместо ID — подсказка; владельца — отказ.
1492
+ monkeypatch.setattr(bot, "OWNER_ID", 102001)
1493
+ sent = _capture_replies(monkeypatch)
1494
+ replied = SimpleNamespace(from_user=SimpleNamespace(id=555003))
1495
+ asyncio.run(bot.cmd_ban(_make_ban_message(999802, 102001, "/ban", reply_to=replied)))
1496
+ assert bot._is_banned(555003) is True
1497
+ asyncio.run(bot.cmd_ban(_make_ban_message(999802, 102001, "/ban вообще")))
1498
+ assert sent["text"] == bot._t(999802, "ban_usage")
1499
+ asyncio.run(bot.cmd_ban(_make_ban_message(999802, 102001, "/ban 102001")))
1500
+ assert sent["text"] == bot._t(999802, "ban_owner_refuse")
1501
+ assert bot._is_banned(102001) is False
1502
+
1503
+
1504
+ def test_ban_denies_non_owner_and_group(monkeypatch):
1505
+ # Чужой и группа: только отказ, список не меняется.
1506
+ monkeypatch.setattr(bot, "OWNER_ID", 103001)
1507
+ sent = _capture_replies(monkeypatch)
1508
+ asyncio.run(bot.cmd_ban(_make_ban_message(999803, 999999, "/ban 123")))
1509
+ assert sent["text"] == bot._t(999803, "stats_deny")
1510
+ asyncio.run(bot.cmd_ban(_make_ban_message(999803, 103001, "/ban 123", group=True)))
1511
+ assert sent["text"] == bot._t(999803, "ban_group_only")
1512
+ assert bot._is_banned(123) is False
1513
+
1514
+
1515
+ def test_handle_message_ignores_banned_user(monkeypatch):
1516
+ # Заблокированный: ни ответа, ни ядра (там контекст группы и счётчики лимитов).
1517
+ from unittest.mock import AsyncMock
1518
+ monkeypatch.setattr(bot, "OWNER_ID", 104001)
1519
+ bot._ban_user(605001)
1520
+ fake_core = AsyncMock()
1521
+ monkeypatch.setattr(bot, "_handle_message_core", fake_core)
1522
+
1523
+ def _msg(chat_type, text="привет"):
1524
+ return SimpleNamespace(
1525
+ media_group_id=None,
1526
+ chat=SimpleNamespace(id=999804, type=chat_type),
1527
+ from_user=SimpleNamespace(id=605001),
1528
+ text=text, caption=None, reply_to_message=None,
1529
+ )
1530
+
1531
+ asyncio.run(bot.handle_message(_msg(bot.ChatType.PRIVATE)))
1532
+ asyncio.run(bot.handle_message(_msg(bot.ChatType.GROUP)))
1533
+ assert fake_core.await_count == 0
1534
+ bot._unban_user(605001)
1535
+ asyncio.run(bot.handle_message(_msg(bot.ChatType.GROUP)))
1536
+ assert fake_core.await_count == 1
1537
+
1538
+
1539
+ def test_banned_list_survives_quota_reload(monkeypatch):
1540
+ # Список живёт в том же файле, что квота: рестарт его не сбрасывает.
1541
+ import json
1542
+ monkeypatch.setattr(bot, "OWNER_ID", 105001)
1543
+ bot._ban_user(606001)
1544
+ snapshot = json.dumps(dict(bot.GLOBAL_QUOTA), ensure_ascii=False)
1545
+ monkeypatch.setattr(bot, "_storage_read_text", lambda *args, **kwargs: snapshot)
1546
+ bot.GLOBAL_QUOTA.pop(bot.BANNED_KEY, None)
1547
+ assert bot._is_banned(606001) is False
1548
+ bot.load_global_quota()
1549
+ assert bot._is_banned(606001) is True