-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathbot.py
More file actions
1545 lines (1317 loc) · 61.6 KB
/
Copy pathbot.py
File metadata and controls
1545 lines (1317 loc) · 61.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""
Telegram Moderator + Interactive Duel + Reputation Bot
With configuration via bot private chat.
"""
import asyncio
import logging
import html
import random
import time as _time
from functools import wraps
from typing import Dict
from telegram.error import BadRequest
from telegram import (
Update,
ChatPermissions,
InlineKeyboardButton,
InlineKeyboardMarkup,
)
from telegram.ext import (
Application,
CommandHandler,
MessageHandler,
CallbackQueryHandler,
ChatMemberHandler,
ConversationHandler,
ContextTypes,
filters,
)
from telegram.constants import ChatMemberStatus
from config import (
BOT_TOKEN, DB_PATH, OWNER_ID, DEFAULTS,
SETTING_DESCRIPTIONS, SETTING_LIMITS,
)
from database import Database
# Keep logging minimal for the public repository:
# only real bot/library errors are written to console.
logging.basicConfig(
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
level=logging.ERROR # ERROR and CRITICAL only
)
# Project logger used across handlers and background jobs.
log = logging.getLogger("modbot")
log.setLevel(logging.ERROR)
# Reduce dependency noise so failures are easier to spot in logs.
logging.getLogger("httpx").setLevel(logging.CRITICAL)
logging.getLogger("telegram").setLevel(logging.ERROR)
logging.getLogger("apscheduler").setLevel(logging.ERROR)
if not BOT_TOKEN or BOT_TOKEN.startswith("YOUR"):
print("ОШИБКА: Откройте config.py и вставьте токен бота!")
exit(1)
# Database is initialized at startup; schema is created automatically.
db = Database(DB_PATH)
# Bot owner automatically gets access to global settings.
if OWNER_ID and OWNER_ID != 0:
db.add_bot_admin(OWNER_ID)
# ══════════════════════════════════════════════
# DYNAMIC SETTINGS
# ══════════════════════════════════════════════
def get_cfg(key: str) -> int:
"""Read a setting from DB first, then fall back to DEFAULTS."""
val = db.get_setting(key)
if val is not None:
try:
return int(val)
except ValueError:
pass
return DEFAULTS.get(key, 0)
# Convenience wrappers
def DUEL_TIMEOUT(): return get_cfg("DUEL_TIMEOUT")
def DUEL_MUTE_SECONDS(): return get_cfg("DUEL_MUTE_SECONDS")
def DUEL_MAX_ROUNDS(): return get_cfg("DUEL_MAX_ROUNDS")
def DUEL_BASE_AIM(): return get_cfg("DUEL_BASE_AIM")
def DUEL_AIM_STEP(): return get_cfg("DUEL_AIM_STEP")
def DUEL_TURN_TIMEOUT(): return get_cfg("DUEL_TURN_TIMEOUT")
def MAX_WARNS(): return get_cfg("MAX_WARNS")
# ══════════════════════════════════════════════
# PERMISSIONS
# ══════════════════════════════════════════════
FULL_PERMISSIONS = ChatPermissions(
can_send_messages=True, can_send_audios=True,
can_send_documents=True, can_send_photos=True,
can_send_videos=True, can_send_video_notes=True,
can_send_voice_notes=True, can_send_polls=True,
can_send_other_messages=True, can_add_web_page_previews=True,
can_change_info=False, can_invite_users=True,
can_pin_messages=True, can_manage_topics=False,
)
MUTED_PERMISSIONS = ChatPermissions(
can_send_messages=False, can_send_audios=False,
can_send_documents=False, can_send_photos=False,
can_send_videos=False, can_send_video_notes=False,
can_send_voice_notes=False, can_send_polls=False,
can_send_other_messages=False, can_add_web_page_previews=False,
can_change_info=False, can_invite_users=False,
can_pin_messages=False, can_manage_topics=False,
)
TEXT_ONLY_PERMISSIONS = ChatPermissions(
can_send_messages=True, can_send_audios=False,
can_send_documents=False, can_send_photos=False,
can_send_videos=False, can_send_video_notes=False,
can_send_voice_notes=False, can_send_polls=False,
can_send_other_messages=False, can_add_web_page_previews=False,
can_change_info=False, can_invite_users=True,
can_pin_messages=True, can_manage_topics=False,
)
NO_PIN_PERMISSIONS = ChatPermissions(
can_send_messages=True, can_send_audios=True,
can_send_documents=True, can_send_photos=True,
can_send_videos=True, can_send_video_notes=True,
can_send_voice_notes=True, can_send_polls=True,
can_send_other_messages=True, can_add_web_page_previews=True,
can_change_info=False, can_invite_users=True,
can_pin_messages=False, can_manage_topics=False,
)
# ══════════════════════════════════════════════
# RUNTIME STATE
# ══════════════════════════════════════════════
_recently_joined: Dict[str, float] = {}
active_challenges: Dict[str, dict] = {}
active_fights: Dict[str, dict] = {}
# User who selected a /config key is temporarily waiting for value input.
_waiting_setting_value: Dict[int, str] = {}
# ══════════════════════════════════════════════
# DUEL TEXTS
# ══════════════════════════════════════════════
WEAPONS = [
"⚔️ мечом", "🔫 бластером", "🏹 луком", "🪓 топором",
"🔨 молотом", "🗡️ катаной", "💣 гранатой", "🧨 динамитом",
"🪃 бумерангом", "🍳 сковородкой", "🧹 шваброй", "📱 смартфоном",
"🎸 гитарой", "🐟 тухлой рыбой", "🌵 кактусом", "🪑 табуреткой",
"📚 учебником", "🧲 магнитом", "🔧 гаечным ключом", "🎤 микрофоном",
"💩 какашкой", "🧦 носком", "🎹 пианино", "🛹 скейтбордом",
"🧊 льдом", "🌶️ перцем чили", "🥊 перчаткой", "🎯 дротиком", "🪚 пилой",
]
AIM_TEXTS = [
"🎯 {name} тщательно прицеливается…",
"🔭 {name} наводит прицел…",
"👁 {name} сощурился и целится…",
"🎯 {name} выбирает момент…",
]
DISRUPT_TEXTS = [
"💨 {name} толкает {target}, сбивая прицел!",
"🗣 {name} кричит {target} в ухо!",
"🦶 {name} наступает {target} на ногу!",
"🪨 {name} кидает песок в глаза {target}!",
"🤡 {name} корчит рожу — {target} отвлёкся!",
]
SHOOT_HIT_TEXTS = [
"💥 {name} стреляет {weapon} — ПОПАДАНИЕ!",
"🎯 {name} точно бьёт {weapon}!",
"⚡ {name} метко попал {weapon}!",
]
SHOOT_LUCKY_TEXTS = [
"🍀 {name} случайно попал {weapon}!",
"😲 {name} вслепую попал {weapon}!",
]
SHOOT_MISS_TEXTS = [
"💨 {name} стреляет {weapon} — мимо!",
"🌀 {name} бьёт {weapon}, промах!",
"😅 {name} выстрелил {weapon} — не попал!",
]
KILL_TEXTS = [
"☠️ {target} повержен! {name} побеждает!",
"💀 {target} падает! Победа за {name}!",
"🏆 {name} наносит финальный удар!",
]
DRAW_TEXTS = [
"🤝 Ничья! Оба без сил!",
"⚖️ Силы равны — никто не победил!",
"🎭 Дуэлянты заключили перемирие!",
]
# ══════════════════════════════════════════════
# UTILITIES
# ══════════════════════════════════════════════
async def is_admin(chat_id, user_id, context):
try:
m = await context.bot.get_chat_member(chat_id, user_id)
return m.status in (ChatMemberStatus.ADMINISTRATOR, ChatMemberStatus.OWNER)
except Exception:
return False
def is_bot_admin_user(user_id: int) -> bool:
"""Check whether user is a bot admin (owner or explicitly added)."""
if OWNER_ID and user_id == OWNER_ID:
return True
return db.is_bot_admin(user_id)
def user_link(uid, name):
return f"<a href='tg://user?id={uid}'>{html.escape(name or str(uid))}</a>"
def admin_only(func):
@wraps(func)
async def wrapper(update: Update, context: ContextTypes.DEFAULT_TYPE):
if update.effective_chat.type == "private":
await update.message.reply_text("❌ Только в группах.")
return
if not await is_admin(update.effective_chat.id, update.effective_user.id, context):
await update.message.reply_text("❌ Только для администраторов.")
return
return await func(update, context)
return wrapper
def bot_admin_only(func):
"""Decorator: allow bot admins only (private chat)."""
@wraps(func)
async def wrapper(update: Update, context: ContextTypes.DEFAULT_TYPE):
uid = update.effective_user.id
if not is_bot_admin_user(uid):
await update.message.reply_text(
"❌ У вас нет прав для управления настройками бота.\n"
"Обратитесь к владельцу бота.")
return
return await func(update, context)
return wrapper
async def resolve_target(update, context):
# Unified target resolution for moderation commands:
# reply, mention, text_mention, or numeric ID in args.
msg = update.message
if msg.reply_to_message and msg.reply_to_message.from_user:
u = msg.reply_to_message.from_user
return u.id, u.first_name
if msg.entities:
for ent in msg.entities:
if ent.type == "text_mention" and ent.user:
return ent.user.id, ent.user.first_name
if ent.type == "mention":
username = msg.text[ent.offset + 1: ent.offset + ent.length]
row = db.find_by_username(msg.chat.id, username)
if row:
return row["user_id"], row["first_name"] or username
return None, None
if context.args:
for arg in context.args:
try:
uid = int(arg)
row = db.find_by_id(msg.chat.id, uid)
return uid, (row["first_name"] if row else str(uid))
except ValueError:
continue
return None, None
def parse_minutes(args, default=15):
for a in (args or []):
try:
return max(1, int(a))
except ValueError:
continue
return default
# ══════════════════════════════════════════════
# /help
# ══════════════════════════════════════════════
def _build_help_group():
"""Help text for group chats (without private settings)."""
return f"""
🤖 <b>Бот-модератор + Дуэли + Репутация</b>
<b>🛡 Авто-модерация:</b>
Новые участники ограничены до одобрения.
<b>👮 Админ-команды:</b>
/lock /unlock — чат
/lockmedia /unlockmedia — медиа
/lockpin /unlockpin — закрепление
/mute [мин] — замутить (15 по умолч.)
/unmute — размутить
/kick /ban /unban — кик/бан
/warn — предупреждение ({MAX_WARNS()} = бан)
/resetwarns — сброс варнов (всем или @ник)
/pending — ожидающие
/settings — настройки чата
/banduel /unbanduel — дуэли вкл/откл
<b>⚔️ Интерактивные дуэли ({DUEL_MAX_ROUNDS()} раундов):</b>
/duel — вызвать (ответом / @ник)
/duelstats — топ /myduel — моя стата
Каждый раунд ТЫ выбираешь:
🎯 Прицелиться (+{DUEL_AIM_STEP()}%)
💨 Сбить прицел врагу (−{DUEL_AIM_STEP()}%)
🔫 Выстрелить (шанс = прицел%)
<b>⭐ Репутация (1 голос/день):</b>
/rep + / /rep - (ответом / @ник)
/myrep — моя /toprep — топ
"""
def _build_help_private():
"""Help text for private chat (includes settings commands)."""
return _build_help_group() + """
<b>⚙️ Настройки бота (только здесь, в ЛС):</b>
/config — меню настроек бота
/addadmin ID — добавить админа бота
/removeadmin ID — убрать админа бота
/admins — список админов бота
💡 <i>Настройки бота можно менять только в ЛС.</i>
"""
async def cmd_help(update, context):
if update.effective_chat.type == "private":
await update.message.reply_text(_build_help_private(), parse_mode="HTML")
else:
await update.message.reply_text(_build_help_group(), parse_mode="HTML")
# ══════════════════════════════════════════════
# ⚙️ PRIVATE SETTINGS
# ══════════════════════════════════════════════
def _build_config_text():
"""Render current settings text."""
lines = ["⚙️ <b>Настройки бота</b>\n"]
for key in DEFAULTS:
val = get_cfg(key)
desc = SETTING_DESCRIPTIONS.get(key, key)
default = DEFAULTS[key]
is_custom = db.get_setting(key) is not None
marker = "✏️" if is_custom else "📋"
lines.append(f"{marker} {desc}: <b>{val}</b>"
+ (f" (по умолч. {default})" if is_custom else ""))
lines.append("\n📝 Нажмите кнопку для изменения:")
return "\n".join(lines)
def _build_config_keyboard():
"""Build settings keyboard."""
buttons = []
keys = list(DEFAULTS.keys())
for i in range(0, len(keys), 2):
row = []
for key in keys[i:i+2]:
desc = SETTING_DESCRIPTIONS.get(key, key)
# Short label for button text
short = desc.split("(")[0].strip()
row.append(InlineKeyboardButton(short, callback_data=f"cfg_edit:{key}"))
buttons.append(row)
buttons.append([InlineKeyboardButton("🔄 Сброс всех", callback_data="cfg_reset_all")])
return InlineKeyboardMarkup(buttons)
@bot_admin_only
async def cmd_config(update, context):
"""Settings menu: private chat only."""
if update.effective_chat.type != "private":
await update.message.reply_text(
"⚙️ Настройки можно менять только в ЛС бота.\n"
"👉 Напишите мне в личные сообщения и введите /config")
return
text = _build_config_text()
kb = _build_config_keyboard()
await update.message.reply_text(text, parse_mode="HTML", reply_markup=kb)
async def callback_config(update, context):
"""Handle settings button callbacks."""
q = update.callback_query
uid = q.from_user.id
if not is_bot_admin_user(uid):
return await q.answer("❌ Нет доступа!", show_alert=True)
data = q.data
if data == "cfg_reset_all":
# Check whether there is anything to reset.
had_custom = False
for key in DEFAULTS:
if db.get_setting(key) is not None:
had_custom = True
break
if not had_custom:
return await q.answer("ℹ️ Все настройки уже по умолчанию!", show_alert=True)
for key in DEFAULTS:
with db.lock, db._conn() as c:
c.execute("DELETE FROM global_settings WHERE key=?", (key,))
_waiting_setting_value.pop(uid, None)
text = _build_config_text()
text += "\n\n✅ <b>Все настройки сброшены к значениям по умолчанию!</b>"
kb = _build_config_keyboard()
try:
await q.edit_message_text(text, parse_mode="HTML", reply_markup=kb)
except BadRequest as e:
if "not modified" not in str(e).lower():
raise
return await q.answer("✅ Сброшено!")
if data.startswith("cfg_edit:"):
key = data.split(":", 1)[1]
if key not in DEFAULTS:
return await q.answer("❌ Неизвестный параметр!", show_alert=True)
desc = SETTING_DESCRIPTIONS.get(key, key)
current = get_cfg(key)
limits = SETTING_LIMITS.get(key, (0, 99999))
_waiting_setting_value[uid] = key
kb = InlineKeyboardMarkup([
[InlineKeyboardButton("⬅️ Назад", callback_data="cfg_back")],
[InlineKeyboardButton(f"🔄 Сброс ({DEFAULTS[key]})", callback_data=f"cfg_reset:{key}")],
])
try:
await q.edit_message_text(
f"✏️ <b>{desc}</b>\n\n"
f"Текущее значение: <b>{current}</b>\n"
f"По умолчанию: <b>{DEFAULTS[key]}</b>\n"
f"Допустимый диапазон: <b>{limits[0]} — {limits[1]}</b>\n\n"
f"📝 <b>Отправьте новое значение числом:</b>",
parse_mode="HTML", reply_markup=kb)
except BadRequest as e:
if "not modified" not in str(e).lower():
raise
return await q.answer()
if data == "cfg_back":
_waiting_setting_value.pop(uid, None)
text = _build_config_text()
kb = _build_config_keyboard()
try:
await q.edit_message_text(text, parse_mode="HTML", reply_markup=kb)
except BadRequest as e:
if "not modified" not in str(e).lower():
raise
return await q.answer()
if data.startswith("cfg_reset:"):
key = data.split(":", 1)[1]
# Check whether this key has an overridden value.
if db.get_setting(key) is None:
return await q.answer(
f"ℹ️ {SETTING_DESCRIPTIONS.get(key, key)} уже по умолчанию!",
show_alert=True)
with db.lock, db._conn() as c:
c.execute("DELETE FROM global_settings WHERE key=?", (key,))
_waiting_setting_value.pop(uid, None)
text = _build_config_text()
text += f"\n\n✅ <b>{SETTING_DESCRIPTIONS.get(key, key)}</b> сброшен к {DEFAULTS.get(key)}!"
kb = _build_config_keyboard()
try:
await q.edit_message_text(text, parse_mode="HTML", reply_markup=kb)
except BadRequest as e:
if "not modified" not in str(e).lower():
raise
return await q.answer("✅ Сброшено!")
async def handle_setting_value(update, context):
"""Handle text input for a new settings value in private chat."""
if update.effective_chat.type != "private":
return
uid = update.effective_user.id
key = _waiting_setting_value.get(uid)
if not key:
return # Not waiting for input, ignore message.
if not is_bot_admin_user(uid):
_waiting_setting_value.pop(uid, None)
return
text = update.message.text.strip()
try:
value = int(text)
except ValueError:
return await update.message.reply_text(
"❌ Введите <b>целое число</b>.", parse_mode="HTML")
limits = SETTING_LIMITS.get(key, (0, 99999))
if value < limits[0] or value > limits[1]:
return await update.message.reply_text(
f"❌ Значение должно быть от <b>{limits[0]}</b> до <b>{limits[1]}</b>.",
parse_mode="HTML")
db.set_setting(key, str(value))
_waiting_setting_value.pop(uid, None)
desc = SETTING_DESCRIPTIONS.get(key, key)
config_text = _build_config_text()
config_text += f"\n\n✅ <b>{desc}</b> изменён на <b>{value}</b>!"
kb = _build_config_keyboard()
await update.message.reply_text(config_text, parse_mode="HTML", reply_markup=kb)
# ══════════════════════════════════════════════
# BOT ADMIN MANAGEMENT
# ══════════════════════════════════════════════
async def cmd_addadmin(update, context):
"""Add a bot admin. Owner only, private chat only."""
if update.effective_chat.type != "private":
return await update.message.reply_text("⚙️ Эта команда работает только в ЛС бота.")
uid = update.effective_user.id
if OWNER_ID == 0:
return await update.message.reply_text(
"❌ OWNER_ID не задан в config.py!\n"
"Откройте config.py и впишите свой Telegram ID.")
if uid != OWNER_ID:
return await update.message.reply_text("❌ Только владелец бота может добавлять админов.")
if not context.args:
return await update.message.reply_text("Использование: /addadmin <user_id>")
try:
target_id = int(context.args[0])
except ValueError:
return await update.message.reply_text("❌ Укажите числовой ID.")
db.add_bot_admin(target_id)
await update.message.reply_text(
f"✅ Пользователь <code>{target_id}</code> добавлен как админ бота.\n"
f"Теперь он может менять настройки через /config в ЛС.",
parse_mode="HTML")
async def cmd_removeadmin(update, context):
"""Remove a bot admin. Owner only, private chat only."""
if update.effective_chat.type != "private":
return await update.message.reply_text("⚙️ Эта команда работает только в ЛС бота.")
uid = update.effective_user.id
if uid != OWNER_ID:
return await update.message.reply_text("❌ Только владелец бота.")
if not context.args:
return await update.message.reply_text("Использование: /removeadmin <user_id>")
try:
target_id = int(context.args[0])
except ValueError:
return await update.message.reply_text("❌ Укажите числовой ID.")
if target_id == OWNER_ID:
return await update.message.reply_text("❌ Нельзя удалить владельца!")
db.remove_bot_admin(target_id)
await update.message.reply_text(
f"✅ Пользователь <code>{target_id}</code> удалён из админов бота.",
parse_mode="HTML")
async def cmd_admins(update, context):
"""List bot admins. Private chat only."""
if update.effective_chat.type != "private":
return await update.message.reply_text("⚙️ Эта команда работает только в ЛС бота.")
uid = update.effective_user.id
if not is_bot_admin_user(uid):
return await update.message.reply_text("❌ Нет доступа.")
admins = db.get_bot_admins()
lines = ["👑 <b>Админы бота:</b>\n"]
if OWNER_ID and OWNER_ID != 0:
lines.append(f"👑 <code>{OWNER_ID}</code> (владелец)")
for aid in admins:
if aid != OWNER_ID:
lines.append(f"🔧 <code>{aid}</code>")
if len(admins) == 0 and (not OWNER_ID or OWNER_ID == 0):
lines.append("— нет администраторов —")
await update.message.reply_text("\n".join(lines), parse_mode="HTML")
# ══════════════════════════════════════════════
# TRACKING
# ══════════════════════════════════════════════
async def track_messages(update, context):
chat = update.effective_chat
user = update.effective_user
if not chat or not user or chat.type == "private" or user.is_bot:
return
db.upsert_user(chat.id, user.id, user.username, user.first_name)
# ══════════════════════════════════════════════
# NEW MEMBERS
# ══════════════════════════════════════════════
async def _process_new_member(chat_id, user, context):
# Restrict newcomers immediately until an admin reviews them.
if user.is_bot or user.id == context.bot.id:
return
key = f"{chat_id}:{user.id}"
now = _time.time()
if now - _recently_joined.get(key, 0) < 30:
return
_recently_joined[key] = now
for k in list(_recently_joined):
if now - _recently_joined[k] > 120:
del _recently_joined[k]
try:
await context.bot.restrict_chat_member(chat_id, user.id, MUTED_PERMISSIONS)
except Exception as e:
log.error("Restrict %s: %s", user.id, e)
return
db.add_pending(chat_id, user.id, user.username, user.first_name)
kb = InlineKeyboardMarkup([[
InlineKeyboardButton("✅ Принять", callback_data=f"approve:{chat_id}:{user.id}"),
InlineKeyboardButton("❌ Бан", callback_data=f"ban:{chat_id}:{user.id}"),
]])
link = user_link(user.id, user.first_name)
uname = f" (@{user.username})" if user.username else ""
await context.bot.send_message(
chat_id,
f"👤 Новый участник: {link}{uname}\n"
f"🆔 <code>{user.id}</code>\n\n"
f"🔇 <i>Не может писать до одобрения.</i>",
reply_markup=kb, parse_mode="HTML")
async def on_new_member(update, context):
msg = update.message
if not msg or not msg.new_chat_members:
return
for m in msg.new_chat_members:
if m.id == context.bot.id:
await msg.reply_text(
"👋 Привет! Назначьте меня админом.\n/help — команды",
parse_mode="HTML")
continue
await _process_new_member(msg.chat.id, m, context)
async def on_chat_member_update(update, context):
mu = update.chat_member
if not mu:
return
old, new = mu.old_chat_member, mu.new_chat_member
# Handle re-join the same way as a regular join.
if old.status in (ChatMemberStatus.LEFT, ChatMemberStatus.BANNED) \
and new.status in (ChatMemberStatus.MEMBER, ChatMemberStatus.RESTRICTED):
await _process_new_member(mu.chat.id, new.user, context)
# Cleanup user-related records on leave/ban to avoid stale DB state.
elif old.status in (ChatMemberStatus.MEMBER, ChatMemberStatus.RESTRICTED,
ChatMemberStatus.ADMINISTRATOR, ChatMemberStatus.OWNER) \
and new.status in (ChatMemberStatus.LEFT, ChatMemberStatus.BANNED):
if not new.user.is_bot:
uid = new.user.id
cid = mu.chat.id
db.purge_user(cid, uid)
log.info("Purged all data for user %s in chat %s", uid, cid)
async def on_left_member(update, context):
msg = update.message
if msg and msg.left_chat_member and not msg.left_chat_member.is_bot:
uid = msg.left_chat_member.id
cid = msg.chat.id
db.purge_user(cid, uid)
log.info("Purged all data for user %s in chat %s (left_chat_member)", uid, cid)
# ══════════════════════════════════════════════
# MODERATION BUTTONS
# ══════════════════════════════════════════════
async def callback_moderation(update, context):
# Buttons under "new member" messages are admin-only.
q = update.callback_query
parts = q.data.split(":")
if len(parts) != 3:
return await q.answer("❌")
action, chat_id, user_id = parts[0], int(parts[1]), int(parts[2])
if not await is_admin(chat_id, q.from_user.id, context):
return await q.answer("❌ Только администраторы!", show_alert=True)
aname = html.escape(q.from_user.first_name)
link = user_link(user_id, str(user_id))
if action == "approve":
try:
await context.bot.restrict_chat_member(chat_id, user_id, FULL_PERMISSIONS)
db.approve_user(chat_id, user_id)
await q.edit_message_text(f"✅ {link} одобрен ({aname})", parse_mode="HTML")
except Exception as e:
await q.answer(f"Ошибка: {e}", show_alert=True)
elif action == "ban":
try:
await context.bot.ban_chat_member(chat_id, user_id)
db.purge_user(chat_id, user_id)
await q.edit_message_text(f"🚫 <code>{user_id}</code> забанен ({aname})", parse_mode="HTML")
except Exception as e:
await q.answer(f"Ошибка: {e}", show_alert=True)
await q.answer()
# ══════════════════════════════════════════════
# LOCK / UNLOCK
# ══════════════════════════════════════════════
@admin_only
async def cmd_lock(u, c):
try:
await c.bot.set_chat_permissions(u.effective_chat.id, MUTED_PERMISSIONS)
await u.message.reply_text("🔒 Чат закрыт.")
except Exception as e:
await u.message.reply_text(f"❌ {e}")
@admin_only
async def cmd_unlock(u, c):
try:
await c.bot.set_chat_permissions(u.effective_chat.id, FULL_PERMISSIONS)
await u.message.reply_text("🔓 Чат открыт.")
except Exception as e:
await u.message.reply_text(f"❌ {e}")
@admin_only
async def cmd_lockmedia(u, c):
try:
await c.bot.set_chat_permissions(u.effective_chat.id, TEXT_ONLY_PERMISSIONS)
await u.message.reply_text("🔒 Медиа отключены.")
except Exception as e:
await u.message.reply_text(f"❌ {e}")
@admin_only
async def cmd_unlockmedia(u, c):
try:
await c.bot.set_chat_permissions(u.effective_chat.id, FULL_PERMISSIONS)
await u.message.reply_text("🔓 Медиа разрешены.")
except Exception as e:
await u.message.reply_text(f"❌ {e}")
@admin_only
async def cmd_lockpin(u, c):
try:
await c.bot.set_chat_permissions(u.effective_chat.id, NO_PIN_PERMISSIONS)
await u.message.reply_text("📌 Закрепление отключено.")
except Exception as e:
await u.message.reply_text(f"❌ {e}")
@admin_only
async def cmd_unlockpin(u, c):
try:
await c.bot.set_chat_permissions(u.effective_chat.id, FULL_PERMISSIONS)
await u.message.reply_text("📌 Закрепление разрешено.")
except Exception as e:
await u.message.reply_text(f"❌ {e}")
# ══════════════════════════════════════════════
# MUTE / UNMUTE / KICK / BAN / WARN
# ══════════════════════════════════════════════
@admin_only
async def cmd_mute(update, context):
uid, name = await resolve_target(update, context)
if not uid:
return await update.message.reply_text("Ответом: /mute 30\nПо нику: /mute @ник 30")
minutes = parse_minutes(context.args, 15)
until = int(_time.time()) + minutes * 60
try:
await context.bot.restrict_chat_member(
update.effective_chat.id, uid, MUTED_PERMISSIONS, until_date=until)
await update.message.reply_text(
f"🔇 {user_link(uid, name)} замучен на {minutes} мин.", parse_mode="HTML")
except Exception as e:
await update.message.reply_text(f"❌ {e}")
@admin_only
async def cmd_unmute(update, context):
uid, name = await resolve_target(update, context)
if not uid:
return await update.message.reply_text("Ответьте или: /unmute @ник")
try:
await context.bot.restrict_chat_member(update.effective_chat.id, uid, FULL_PERMISSIONS)
await update.message.reply_text(f"🔊 {user_link(uid, name)} размучен.", parse_mode="HTML")
except Exception as e:
await update.message.reply_text(f"❌ {e}")
@admin_only
async def cmd_kick(update, context):
uid, name = await resolve_target(update, context)
if not uid:
return await update.message.reply_text("Ответьте или: /kick @ник")
try:
cid = update.effective_chat.id
await context.bot.ban_chat_member(cid, uid)
await context.bot.unban_chat_member(cid, uid)
db.purge_user(cid, uid)
await update.message.reply_text(f"👢 {user_link(uid, name)} кикнут.", parse_mode="HTML")
except Exception as e:
await update.message.reply_text(f"❌ {e}")
@admin_only
async def cmd_ban(update, context):
uid, name = await resolve_target(update, context)
if not uid:
return await update.message.reply_text("Ответьте или: /ban @ник")
if uid == context.bot.id:
return await update.message.reply_text("🤖 Нельзя забанить самого бота!")
try:
cid = update.effective_chat.id
await context.bot.ban_chat_member(cid, uid)
db.purge_user(cid, uid)
await update.message.reply_text(f"🚫 {user_link(uid, name)} забанен.", parse_mode="HTML")
except Exception as e:
await update.message.reply_text(f"❌ {e}")
@admin_only
async def cmd_unban(update, context):
uid, name = await resolve_target(update, context)
if not uid:
return await update.message.reply_text("/unban @ник или ID")
if uid == context.bot.id:
return await update.message.reply_text("❌ Нельзя применить /unban к самому боту!")
try:
await context.bot.unban_chat_member(update.effective_chat.id, uid)
await update.message.reply_text(f"✅ <code>{uid}</code> разбанен.", parse_mode="HTML")
except Exception as e:
await update.message.reply_text(f"❌ {e}")
@admin_only
async def cmd_warn(update, context):
uid, name = await resolve_target(update, context)
if not uid:
return await update.message.reply_text("Ответьте или: /warn @ник")
cid = update.effective_chat.id
try:
member = await context.bot.get_chat_member(cid, uid)
if member.user.is_bot:
return await update.message.reply_text("🤖 Нельзя выдать предупреждение боту!")
except Exception:
pass
max_warns = MAX_WARNS()
count = db.add_warn(cid, uid)
link = user_link(uid, name)
if count >= max_warns:
try:
await context.bot.ban_chat_member(cid, uid)
db.purge_user(cid, uid)
await update.message.reply_text(
f"🚫 {link} — {count}/{max_warns} — забанен!", parse_mode="HTML")
except Exception as e:
await update.message.reply_text(f"❌ {e}")
else:
await update.message.reply_text(
f"⚠️ {link} — ({count}/{max_warns})", parse_mode="HTML")
@admin_only
async def cmd_resetwarns(update, context):
cid = update.effective_chat.id
uid, name = await resolve_target(update, context)
if uid:
old = db.get_warns(cid, uid)
db.reset_warns(cid, uid)
await update.message.reply_text(
f"✅ Варны {user_link(uid, name)} сброшены ({old} → 0).", parse_mode="HTML")
else:
count = db.reset_all_warns(cid)
await update.message.reply_text(
f"✅ Все предупреждения сброшены.\n🗑 Очищено записей: <b>{count}</b>",
parse_mode="HTML")
# ══════════════════════════════════════════════
# PENDING / SETTINGS
# ══════════════════════════════════════════════
@admin_only
async def cmd_pending(update, context):
rows = db.get_pending(update.effective_chat.id)
if not rows:
return await update.message.reply_text("✅ Нет ожидающих.")
lines = ["⏳ <b>Ожидают одобрения:</b>\n"]
for r in rows:
n = html.escape(r["first_name"] or "—")
u = f" @{r['username']}" if r["username"] else ""
lines.append(f"• {n}{u} — <code>{r['user_id']}</code>")
await update.message.reply_text("\n".join(lines), parse_mode="HTML")
@admin_only
async def cmd_settings(update, context):
cid = update.effective_chat.id
chat = await context.bot.get_chat(cid)
p = chat.permissions
yn = lambda v: "✅" if v else "❌"
duels = "✅" if db.are_duels_enabled(cid) else "❌"
await update.message.reply_text(
f"⚙️ <b>Настройки чата</b>\n\n"
f"💬 Сообщения: {yn(p.can_send_messages)}\n"
f"🖼 Медиа: {yn(p.can_send_photos)}\n"
f"📌 Закрепление: {yn(p.can_pin_messages)}\n"
f"⚔️ Дуэли: {duels}\n"
f"\n"
f"👤 Известных: <b>{db.user_count(cid)}</b>\n"
f"⏳ Ожидающих: <b>{db.pending_count(cid)}</b>\n\n"
f"🔧 Глобальные: /config (в ЛС боту)",
parse_mode="HTML")
# ══════════════════════════════════════════════
# ⚔️ INTERACTIVE DUELS
# ══════════════════════════════════════════════
def _fight_key(chat_id, p1_id, p2_id):
return f"fight:{chat_id}:{min(p1_id, p2_id)}:{max(p1_id, p2_id)}"
def _build_action_kb(fight_key, round_num):
return InlineKeyboardMarkup([[
InlineKeyboardButton("🎯 Прицел", callback_data=f"fa:{fight_key}:{round_num}:aim"),
InlineKeyboardButton("💨 Сбить", callback_data=f"fa:{fight_key}:{round_num}:disrupt"),
InlineKeyboardButton("🔫 Выстрел", callback_data=f"fa:{fight_key}:{round_num}:shoot"),
]])
def _render_status(fight):
p1, p2 = fight["p1"], fight["p2"]
# Duel settings are read dynamically so /config changes apply
# without restarting the bot.
max_rounds = DUEL_MAX_ROUNDS()
text = (
f"⚔️ <b>Раунд {fight['round']}/{max_rounds}</b>\n\n"
f"🔴 {p1['link']} — 🎯 <b>{p1['aim']}%</b>"
f" {'✅' if p1['chose'] else '⏳'}\n"
f"🔵 {p2['link']} — 🎯 <b>{p2['aim']}%</b>"
f" {'✅' if p2['chose'] else '⏳'}\n"
)
if fight["log"]:
text += "\n" + "\n".join(fight["log"])
return text
async def _start_round(fight, context):
p1, p2 = fight["p1"], fight["p2"]
p1["chose"] = p2["chose"] = False
p1["action"] = p2["action"] = None
text = _render_status(fight) + "\n\n⬇️ <b>Выберите действие!</b>"
kb = _build_action_kb(fight["key"], fight["round"])
try:
if fight.get("message_id"):
await context.bot.edit_message_text(
text, chat_id=fight["chat_id"],
message_id=fight["message_id"],
parse_mode="HTML", reply_markup=kb)
else:
msg = await context.bot.send_message(
fight["chat_id"], text, parse_mode="HTML", reply_markup=kb)
fight["message_id"] = msg.message_id
except Exception as e:
log.error("Start round: %s", e)
turn_timeout = DUEL_TURN_TIMEOUT()
context.job_queue.run_once(
_turn_timeout, turn_timeout,
data={"key": fight["key"], "round": fight["round"]},
name=f"ft_{fight['key']}_{fight['round']}")
async def _turn_timeout(context):
d = context.job.data
fight = active_fights.get(d["key"])
if not fight or fight["round"] != d["round"]:
return
for p in [fight["p1"], fight["p2"]]:
if not p["chose"]:
p["action"] = random.choice(["aim", "disrupt", "shoot"])
p["chose"] = True
await _process_round(fight, context)
def _process_actions(fight):