diff --git a/app/bot/handlers/dict_commands.py b/app/bot/handlers/dict_commands.py new file mode 100644 index 0000000..5d7c214 --- /dev/null +++ b/app/bot/handlers/dict_commands.py @@ -0,0 +1,249 @@ +# app/bot/handlers/dict_commands.py +from telegram import Update +from telegram.constants import ChatType +from telegram.ext import ContextTypes +from sqlalchemy import func + +from app.db.session import get_session +from app.db.models import ( + User, SecurityPolicy, ChatSecurity, + SpamDictionary, PolicyDictionaryLink, DictionaryEntry, +) + +ALLOWED_CATEGORIES = {"spam", "scam", "adult", "profanity", "custom"} + + +def _get_or_create_policy(session, owner_tg_id: int) -> SecurityPolicy: + u = session.query(User).filter_by(tg_id=owner_tg_id).first() + if not u: + u = User(tg_id=owner_tg_id, name="") + session.add(u); session.commit(); session.refresh(u) + p = session.query(SecurityPolicy).filter_by(owner_user_id=u.id, name="Balanced").first() + if p: + return p + p = SecurityPolicy(owner_user_id=u.id, name="Balanced") + session.add(p); session.commit(); session.refresh(p) + return p + + +async def _is_group_admin(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> bool: + """В группе/канале разрешаем только администраторам.""" + chat = update.effective_chat + if chat.type in (ChatType.GROUP, ChatType.SUPERGROUP, ChatType.CHANNEL): + try: + m = await ctx.bot.get_chat_member(chat.id, update.effective_user.id) + return m.status in ("administrator", "creator") + except Exception: + return False + return True + + +def _resolve_policy_and_owner(session, update: Update) -> tuple[SecurityPolicy, User, str]: + """ + В группе/канале: берём политику, привязанную к чату. + В ЛС: дефолтную политику Balanced владельца. + """ + chat = update.effective_chat + user = session.query(User).filter_by(tg_id=update.effective_user.id).first() + if not user: + user = User(tg_id=update.effective_user.id, name=update.effective_user.full_name or "") + session.add(user); session.commit(); session.refresh(user) + + if chat.type in (ChatType.GROUP, ChatType.SUPERGROUP, ChatType.CHANNEL): + cs = session.query(ChatSecurity).filter_by(chat_id=chat.id).first() + if not cs: + raise RuntimeError("not_bound") + p = session.get(SecurityPolicy, cs.policy_id) + if not p: + raise RuntimeError("policy_missing") + return p, user, f"политика чата «{chat.title or chat.id}»" + else: + p = _get_or_create_policy(session, update.effective_user.id) + return p, user, "ваша политика Balanced (ЛС)" + + +def _find_dict(session, owner: User, token: str) -> SpamDictionary | None: + """token — numeric id или точное имя словаря владельца (без глобальных).""" + token = (token or "").strip() + if not token: + return None + if token.isdigit(): + d = session.get(SpamDictionary, int(token)) + return d if d and d.owner_user_id == owner.id else None + return ( + session.query(SpamDictionary) + .filter(SpamDictionary.owner_user_id == owner.id, SpamDictionary.name == token) + .first() + ) + + +# ===================== Команды ===================== + +async def dicts_link_cmd(update: Update, ctx: ContextTypes.DEFAULT_TYPE): + if not await _is_group_admin(update, ctx): + return + args = ctx.args or [] + if not args: + await update.effective_message.reply_text("Использование: /dicts_link ") + return + token = " ".join(args).strip() + + with get_session() as s: + try: + p, owner, scope = _resolve_policy_and_owner(s, update) + except RuntimeError as e: + if str(e) == "not_bound": + await update.effective_message.reply_text("Политика не привязана. В группе откройте /security → «Привязать к этому чату».") + return + await update.effective_message.reply_text("Политика недоступна. Привяжите её через /security.") + return + + d = _find_dict(s, owner, token) + if not d: + await update.effective_message.reply_text(f"Словарь не найден среди ваших: «{token}». Проверьте id/имя (см. /dicts).") + return + + link = s.query(PolicyDictionaryLink).filter_by(policy_id=p.id, dictionary_id=d.id).first() + if link: + await update.effective_message.reply_text(f"Уже привязан: «{d.name}» ({scope}).") + return + + s.add(PolicyDictionaryLink(policy_id=p.id, dictionary_id=d.id)); s.commit(); + from app.moderation.engine import dict_cache + dict_cache.invalidate(p.id) + rules = s.query(DictionaryEntry).filter_by(dictionary_id=d.id).count() + await update.effective_message.reply_text(f"Привязал «{d.name}» ({d.category}/{d.kind}, правил: {rules}) к {scope}.") + + +async def dicts_unlink_cmd(update: Update, ctx: ContextTypes.DEFAULT_TYPE): + if not await _is_group_admin(update, ctx): + return + args = ctx.args or [] + if not args: + await update.effective_message.reply_text("Использование: /dicts_unlink ") + return + token = " ".join(args).strip() + + with get_session() as s: + try: + p, owner, scope = _resolve_policy_and_owner(s, update) + except RuntimeError: + await update.effective_message.reply_text("Политика не привязана. В группе откройте /security → «Привязать к этому чату».") + return + + d = _find_dict(s, owner, token) + if not d: + await update.effective_message.reply_text(f"Словарь не найден: «{token}».") + return + + link = s.query(PolicyDictionaryLink).filter_by(policy_id=p.id, dictionary_id=d.id).first() + if not link: + await update.effective_message.reply_text(f"Словарь «{d.name}» и так не привязан к {scope}.") + return + + s.delete(link); s.commit() + from app.moderation.engine import dict_cache + dict_cache.invalidate(p.id) + await update.effective_message.reply_text(f"Отвязал «{d.name}» от {scope}.") + + +async def dicts_link_all_cmd(update: Update, ctx: ContextTypes.DEFAULT_TYPE): + if not await _is_group_admin(update, ctx): + return + args = ctx.args or [] + cat = (args[0].lower() if args else None) + if cat and cat not in ALLOWED_CATEGORIES: + await update.effective_message.reply_text(f"Категория неизвестна. Используйте: {', '.join(sorted(ALLOWED_CATEGORIES))} (или без аргумента).") + return + + with get_session() as s: + try: + p, owner, scope = _resolve_policy_and_owner(s, update) + except RuntimeError: + await update.effective_message.reply_text("Политика не привязана. В группе откройте /security → «Привязать к этому чату».") + return + + q = s.query(SpamDictionary).filter_by(owner_user_id=owner.id) + if cat: + q = q.filter(SpamDictionary.category == cat) + ds = q.all() + if not ds: + await update.effective_message.reply_text("У вас нет словарей для привязки (см. /spam_import).") + return + + existing = {x.dictionary_id for x in s.query(PolicyDictionaryLink).filter_by(policy_id=p.id).all()} + added = 0 + for d in ds: + if d.id in existing: + continue + s.add(PolicyDictionaryLink(policy_id=p.id, dictionary_id=d.id)); added += 1 + s.commit() + from app.moderation.engine import dict_cache + dict_cache.invalidate(p.id) + await update.effective_message.reply_text(f"Привязал {added} словарей к {scope}." + (f" (категория: {cat})" if cat else "")) + + +async def dicts_unlink_all_cmd(update: Update, ctx: ContextTypes.DEFAULT_TYPE): + if not await _is_group_admin(update, ctx): + return + args = ctx.args or [] + cat = (args[0].lower() if args else None) + if cat and cat not in ALLOWED_CATEGORIES: + await update.effective_message.reply_text(f"Категория неизвестна. Используйте: {', '.join(sorted(ALLOWED_CATEGORIES))} (или без аргумента).") + return + + with get_session() as s: + try: + p, owner, scope = _resolve_policy_and_owner(s, update) + except RuntimeError: + await update.effective_message.reply_text("Политика не привязана. В группе откройте /security → «Привязать к этому чату».") + return + + q = ( + s.query(SpamDictionary) + .join(PolicyDictionaryLink, PolicyDictionaryLink.dictionary_id == SpamDictionary.id) + .filter(PolicyDictionaryLink.policy_id == p.id, SpamDictionary.owner_user_id == owner.id) + ) + if cat: + q = q.filter(SpamDictionary.category == cat) + ds = q.all() + if not ds: + await update.effective_message.reply_text("Нет привязанных словарей для отвязки.") + return + + deleted = 0 + for d in ds: + link = s.query(PolicyDictionaryLink).filter_by(policy_id=p.id, dictionary_id=d.id).first() + if link: + s.delete(link); deleted += 1 + s.commit() + from app.moderation.engine import dict_cache + dict_cache.invalidate(p.id) + await update.effective_message.reply_text(f"Отвязал {deleted} словарей от {scope}." + (f" (категория: {cat})" if cat else "")) + + +async def dicts_linked_cmd(update: Update, ctx: ContextTypes.DEFAULT_TYPE): + if not await _is_group_admin(update, ctx): + return + with get_session() as s: + try: + p, owner, scope = _resolve_policy_and_owner(s, update) + except RuntimeError: + await update.effective_message.reply_text("Политика не привязана. В группе откройте /security → «Привязать к этому чату».") + return + + links = s.query(PolicyDictionaryLink).filter_by(policy_id=p.id).all() + if not links: + await update.effective_message.reply_text(f"К {scope} пока ничего не привязано.") + return + + d_ids = [lnk.dictionary_id for lnk in links] + dicts = s.query(SpamDictionary).filter(SpamDictionary.id.in_(d_ids)).all() + counts = { + d.id: s.query(func.count(DictionaryEntry.id)).filter(DictionaryEntry.dictionary_id == d.id).scalar() or 0 + for d in dicts + } + lines = [f"Привязано к {scope}:"] + for d in dicts: + lines.append(f"• [{d.id}] {d.name} ({d.category}/{d.kind}) — правил: {counts.get(d.id, 0)}") + await update.effective_message.reply_text("\n".join(lines)) diff --git a/app/bot/handlers/security.py b/app/bot/handlers/security.py index e255965..7572afc 100644 --- a/app/bot/handlers/security.py +++ b/app/bot/handlers/security.py @@ -11,7 +11,7 @@ from app.db.models import ( SpamDictionary, DictionaryEntry, PolicyDictionaryLink, ) from app.bot.keyboards.security import kb_policy - +from app.moderation.engine import dict_cache # ---------- helpers ---------- def _get_or_create_policy(session, owner_tg_id: int) -> SecurityPolicy: @@ -54,7 +54,9 @@ def _import_entries(session, owner_tg_id: int, params: dict, entries: list[str]) u = session.query(User).filter_by(tg_id=owner_tg_id).first() if not u: u = User(tg_id=owner_tg_id, name="") - session.add(u); session.commit(); session.refresh(u) + session.add(u) + session.commit() + session.refresh(u) # создать словарь d = SpamDictionary( @@ -139,6 +141,7 @@ async def security_cb(update: Update, ctx: ContextTypes.DEFAULT_TYPE): field = parts[2] setattr(p, field, not getattr(p, field)) s.commit() + dict_cache.invalidate(cs.policy_id) elif action == "adj": field, delta = parts[2], int(parts[3]) @@ -259,9 +262,9 @@ async def dicts_cb(update: Update, ctx: ContextTypes.DEFAULT_TYPE): return link = s.query(PolicyDictionaryLink).filter_by(policy_id=policy_id, dictionary_id=dict_id).first() if link: - s.delete(link); s.commit() + s.delete(link); s.commit(); dict_cache.invalidate(policy_id) else: - s.add(PolicyDictionaryLink(policy_id=policy_id, dictionary_id=dict_id)); s.commit() + s.add(PolicyDictionaryLink(policy_id=policy_id, dictionary_id=dict_id)); s.commit(); dict_cache.invalidate(policy_id) u = s.query(User).filter_by(tg_id=update.effective_user.id).first() dicts = s.query(SpamDictionary).filter_by(owner_user_id=u.id).order_by(SpamDictionary.created_at.desc()).all() diff --git a/app/bot/keyboards/security.py b/app/bot/keyboards/security.py index e9535e5..7aa72a0 100644 --- a/app/bot/keyboards/security.py +++ b/app/bot/keyboards/security.py @@ -4,10 +4,10 @@ from app.db.models import SecurityPolicy def kb_policy(p: SecurityPolicy, chat_bound: bool = False, enabled: bool = False): def onoff(b): return "✅" if b else "❌" rows = [ - [InlineKeyboardButton(f"Adult {onoff(p.block_adult)}", callback_data=f"pol:toggle:block_adult:{p.id}"), - InlineKeyboardButton(f"Spam {onoff(p.block_spam)}", callback_data=f"pol:toggle:block_spam:{p.id}")], - [InlineKeyboardButton(f"Scam {onoff(p.block_scam)}", callback_data=f"pol:toggle:block_scam:{p.id}"), - InlineKeyboardButton(f"Profanity {onoff(p.block_profanity)}", callback_data=f"pol:toggle:block_profanity:{p.id}")], + [InlineKeyboardButton(f"18+ {onoff(p.block_adult)}", callback_data=f"pol:toggle:block_adult:{p.id}"), + InlineKeyboardButton(f"Спам {onoff(p.block_spam)}", callback_data=f"pol:toggle:block_spam:{p.id}")], + [InlineKeyboardButton(f"Скам {onoff(p.block_scam)}", callback_data=f"pol:toggle:block_scam:{p.id}"), + InlineKeyboardButton(f"Брань {onoff(p.block_profanity)}", callback_data=f"pol:toggle:block_profanity:{p.id}")], [InlineKeyboardButton(f"Cooldown {p.cooldown_seconds}s (-5)", callback_data=f"pol:adj:cooldown_seconds:-5:{p.id}"), InlineKeyboardButton("(+5)", callback_data=f"pol:adj:cooldown_seconds:+5:{p.id}")], [InlineKeyboardButton(f"Dupe {p.duplicate_window_seconds}s (-30)", callback_data=f"pol:adj:duplicate_window_seconds:-30:{p.id}"), diff --git a/app/bot/routers/__init__.py b/app/bot/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/bot/routers/channel.py b/app/bot/routers/channel.py new file mode 100644 index 0000000..3a1d79d --- /dev/null +++ b/app/bot/routers/channel.py @@ -0,0 +1,18 @@ +# app/bot/routers/channel.py +from telegram.ext import CommandHandler, filters + +async def channel_redirect(update, ctx): + try: + await update.effective_message.reply_text( + "⚠️ В канале команды не поддерживаются. " + "Откройте ЛС со мной и выполните:\n" + "• /bind @username (или -100…)\n" + "• или /add_group и перешлите сюда пост из канала.", + disable_web_page_preview=True + ) + except Exception: + pass + +def register_channel_handlers(app): + # В канале любые «команды настроек» переводим в ЛС + app.add_handler(CommandHandler(["start","settings","security","dicts","spam_import"], channel_redirect, filters.ChatType.CHANNEL)) diff --git a/app/bot/routers/group.py b/app/bot/routers/group.py new file mode 100644 index 0000000..c280a0b --- /dev/null +++ b/app/bot/routers/group.py @@ -0,0 +1,28 @@ +# app/bot/routers/group.py +from telegram.ext import CommandHandler, MessageHandler, ChatMemberHandler, filters +from app.bot.handlers.join_info import on_my_chat_member +from app.bot.handlers.moderation import moderate_message +from app.bot.handlers.security import security_cmd, dicts_cmd +from app.bot.handlers.mod_status import mod_status_cmd +from app.bot.handlers.chat_id_cmd import chat_id_cmd + +async def spam_import_redirect(update, ctx): + await update.effective_message.reply_text( + "Импорт словаря выполняется в ЛС. Откройте чат со мной и пришлите /spam_import.", + disable_web_page_preview=True + ) + +def register_group_handlers(app): + # Команды в группах/супергруппах + app.add_handler(CommandHandler("security", security_cmd, filters.ChatType.GROUPS)) + app.add_handler(CommandHandler("dicts", dicts_cmd, filters.ChatType.GROUPS)) + app.add_handler(CommandHandler("mod_status", mod_status_cmd, filters.ChatType.GROUPS)) + app.add_handler(CommandHandler("id", chat_id_cmd, filters.ChatType.GROUPS)) + # /spam_import — редирект в ЛС + app.add_handler(CommandHandler("spam_import", spam_import_redirect, filters.ChatType.GROUPS)) + + # Модерация всех сообщений (privacy mode должен быть Disabled) + app.add_handler(MessageHandler(filters.ChatType.GROUPS & ~filters.COMMAND, moderate_message)) + + # my_chat_member для уведомлений о добавлении бота + app.add_handler(ChatMemberHandler(on_my_chat_member, chat_member_types=ChatMemberHandler.MY_CHAT_MEMBER)) diff --git a/app/bot/routers/private.py b/app/bot/routers/private.py new file mode 100644 index 0000000..c5a5b9e --- /dev/null +++ b/app/bot/routers/private.py @@ -0,0 +1,40 @@ +# app/bot/routers/private.py +from telegram.ext import CommandHandler, MessageHandler, ChatMemberHandler, filters +from app.bot.handlers.start import start, help_cmd, groups_cmd +from app.bot.handlers.add_group import add_group_cmd, add_group_capture +from app.bot.handlers.drafts import new_cmd, on_text, on_text_gate +from app.bot.handlers.media import on_media +from app.bot.handlers.chat_id_cmd import chat_id_cmd +from app.bot.handlers.security import ( + security_cmd, spam_import_cmd, spam_import_capture, spam_import_text_capture, dicts_cmd +) +from app.bot.handlers.cancel import cancel_cmd +from app.bot.handlers.debug import dbg_state_cmd + +def register_private_handlers(app): + # Команды в ЛС + app.add_handler(CommandHandler("start", start, filters.ChatType.PRIVATE)) + app.add_handler(CommandHandler("help", help_cmd, filters.ChatType.PRIVATE)) + app.add_handler(CommandHandler("groups", groups_cmd, filters.ChatType.PRIVATE)) + app.add_handler(CommandHandler("add_group", add_group_cmd, filters.ChatType.PRIVATE)) + app.add_handler(CommandHandler("new", new_cmd, filters.ChatType.PRIVATE)) + app.add_handler(CommandHandler("id", chat_id_cmd, filters.ChatType.PRIVATE)) + app.add_handler(CommandHandler("security", security_cmd, filters.ChatType.PRIVATE)) + app.add_handler(CommandHandler("dicts", dicts_cmd, filters.ChatType.PRIVATE)) + app.add_handler(CommandHandler("spam_import", spam_import_cmd, filters.ChatType.PRIVATE)) + app.add_handler(CommandHandler("cancel", cancel_cmd, filters.ChatType.PRIVATE)) + app.add_handler(CommandHandler("dbg_state", dbg_state_cmd, filters.ChatType.PRIVATE)) + + # Импорт словаря — файл + app.add_handler(MessageHandler(filters.ChatType.PRIVATE & filters.Document.ALL, spam_import_capture)) + # Импорт словаря — текст (не блокирует цепочку) + app.add_handler(MessageHandler(filters.ChatType.PRIVATE & filters.TEXT & ~filters.COMMAND, spam_import_text_capture, block=False)) + + # Привязка чата пересланным сообщением + app.add_handler(MessageHandler(filters.ChatType.PRIVATE & filters.FORWARDED, add_group_capture)) + # Медиа для черновика + app.add_handler(MessageHandler(filters.ChatType.PRIVATE & (filters.PHOTO | filters.VIDEO | filters.ANIMATION), on_media)) + + # Приватный текст: шлюз (высший приоритет) → редактор + app.add_handler(MessageHandler(filters.ChatType.PRIVATE & filters.TEXT & ~filters.COMMAND, on_text_gate, block=True)) + app.add_handler(MessageHandler(filters.ChatType.PRIVATE & filters.TEXT & ~filters.COMMAND, on_text)) diff --git a/app/main.py b/app/main.py index a3dcbb8..d07639a 100644 --- a/app/main.py +++ b/app/main.py @@ -1,171 +1,30 @@ -# import logging -# from telegram.ext import ( -# ApplicationBuilder, CommandHandler, MessageHandler, -# CallbackQueryHandler, ChatMemberHandler, filters -# ) - -# from app.config import load_config -# from app.infra.metrics import start_metrics_server - -# # базовые хэндлеры -# from app.bot.handlers.start import start, help_cmd, groups_cmd -# from app.bot.handlers.add_group import add_group_cmd, add_group_capture -# from app.bot.handlers.drafts import new_cmd, on_text -# from app.bot.handlers.media import on_media -# from app.bot.handlers.callbacks import on_callback -# from app.bot.handlers.join_info import on_my_chat_member -# from app.bot.handlers.chat_id_cmd import chat_id_cmd -# from app.bot.handlers.bind_chat import bind_chat_cb - -# # безопасность/словарь -# from app.bot.handlers.security import ( -# security_cmd, security_cb, -# spam_import_cmd, spam_import_capture, spam_import_text_capture, -# dicts_cmd, dicts_cb, -# ) - -# # модерация и диагностика -# from app.bot.handlers.moderation import moderate_message -# from app.bot.handlers.mod_status import mod_status_cmd # если нет — можете закомментировать -# from app.bot.handlers.errors import on_error - -# from app.bot.handlers.cancel import cancel_cmd -# from app.bot.handlers.drafts import on_text_gate -# from app.bot.handlers.debug import dbg_state_cmd - -# async def spam_import_redirect(update, ctx): -# await update.effective_message.reply_text( -# "Эту команду нужно выполнять в личке. " -# "Откройте чат со мной и пришлите /spam_import." -# ) - - -# def main(): -# cfg = load_config() -# logging.basicConfig(level=getattr(logging, cfg.log_level.upper(), logging.INFO)) - -# # ← СКРОЕМ «HTTP Request: …» от httpx/httpcore -# logging.getLogger("httpx").setLevel(logging.WARNING) -# logging.getLogger("httpx").propagate = False -# logging.getLogger("httpcore").setLevel(logging.WARNING) -# logging.getLogger("httpcore").propagate = False - -# # (опционально) если лишний шум от urllib3/telegram: -# # logging.getLogger("urllib3").setLevel(logging.WARNING) -# # logging.getLogger("telegram").setLevel(logging.INFO) - -# # запустить эндпоинт метрик (Prometheus будет ходить на порт cfg.metrics_port) -# start_metrics_server(cfg.metrics_port) - -# app = ApplicationBuilder().token(cfg.bot_token).build() - -# # --- Commands --- -# app.add_handler(CommandHandler("start", start)) -# app.add_handler(CommandHandler("help", help_cmd)) -# app.add_handler(CommandHandler("groups", groups_cmd)) -# app.add_handler(CommandHandler("add_group", add_group_cmd)) -# app.add_handler(CommandHandler("new", new_cmd)) -# app.add_handler(CommandHandler("id", chat_id_cmd)) -# app.add_handler(CommandHandler("mod_status", mod_status_cmd)) -# app.add_handler(CommandHandler("cancel", cancel_cmd)) -# app.add_handler(CommandHandler("dbg_state", dbg_state_cmd)) - -# # --- Callbacks (порядок важен) --- -# app.add_handler(CallbackQueryHandler(security_cb, pattern=r"^pol:")) # настройки защитника -# app.add_handler(CallbackQueryHandler(dicts_cb, pattern=r"^dict:")) # привязка словарей -# app.add_handler(CallbackQueryHandler(bind_chat_cb, pattern=r"^bind:")) # привязка канала кнопкой -# app.add_handler(CallbackQueryHandler(on_callback, pattern=r"^(draft_next_text:|tgl:|selall:|clear:|sendmulti:)")) - - -# # --- Private chat helpers --- -# app.add_handler(MessageHandler(filters.ChatType.PRIVATE & filters.FORWARDED, add_group_capture)) -# app.add_handler(MessageHandler(filters.ChatType.PRIVATE & (filters.PHOTO | filters.VIDEO | filters.ANIMATION), on_media)) - -# # --- Join/rights updates --- -# app.add_handler(ChatMemberHandler(on_my_chat_member, chat_member_types=ChatMemberHandler.MY_CHAT_MEMBER)) - -# # --- Security / Dict --- -# app.add_handler(CommandHandler("security", security_cmd)) -# # /spam_import — ТОЛЬКО в ЛС -# app.add_handler(CommandHandler("spam_import", spam_import_cmd, filters.ChatType.PRIVATE)) -# # редирект в группах -# app.add_handler(CommandHandler("spam_import", spam_import_redirect, filters.ChatType.GROUPS)) -# # файл словаря -# app.add_handler(MessageHandler(filters.ChatType.PRIVATE & filters.Document.ALL, spam_import_capture)) -# # текст словаря/параметров — ДО редактора, и не блокируем цепочку -# app.add_handler(MessageHandler( -# filters.ChatType.PRIVATE & filters.TEXT & ~filters.COMMAND, -# spam_import_text_capture, -# block=False -# )) -# # обзор словарей для политики -# app.add_handler(CommandHandler("dicts", dicts_cmd)) - -# # --- Moderation in groups --- -# app.add_handler(MessageHandler(filters.ChatType.GROUPS & ~filters.COMMAND, moderate_message)) - -# app.add_handler(MessageHandler( -# filters.ChatType.PRIVATE & filters.TEXT & ~filters.COMMAND, -# on_text_gate, -# block=True -# )) - -# # --- Draft editor (ПОСЛЕ импорт-хэндлеров) --- -# app.add_handler(MessageHandler(filters.ChatType.PRIVATE & filters.TEXT & ~filters.COMMAND, on_text)) - -# app.add_error_handler(on_error) -# app.run_polling(allowed_updates=None) - - -# if __name__ == "__main__": -# main() - - +# app/main.py import logging -from telegram.ext import ( - ApplicationBuilder, CommandHandler, MessageHandler, - CallbackQueryHandler, ChatMemberHandler, filters -) - +from telegram.ext import ApplicationBuilder from app.config import load_config from app.infra.metrics import start_metrics_server -# базовые хэндлеры -from app.bot.handlers.start import start, help_cmd, groups_cmd -from app.bot.handlers.add_group import add_group_cmd, add_group_capture -from app.bot.handlers.drafts import new_cmd, on_text, on_text_gate # ← шлюз + редактор -from app.bot.handlers.media import on_media +# глобальные колбэки (узкие паттерны) и ошибки — держим в main from app.bot.handlers.callbacks import on_callback -from app.bot.handlers.join_info import on_my_chat_member -from app.bot.handlers.chat_id_cmd import chat_id_cmd from app.bot.handlers.bind_chat import bind_chat_cb - -# безопасность / словари -from app.bot.handlers.security import ( - security_cmd, security_cb, - spam_import_cmd, spam_import_capture, spam_import_text_capture, - dicts_cmd, dicts_cb, +from app.bot.handlers.security import security_cb +from app.bot.handlers.dict_commands import ( + dicts_link_cmd, dicts_unlink_cmd, + dicts_link_all_cmd, dicts_unlink_all_cmd, + dicts_linked_cmd, ) - -# модерация и диагностика -from app.bot.handlers.moderation import moderate_message -from app.bot.handlers.mod_status import mod_status_cmd # если нет — закомментировать from app.bot.handlers.errors import on_error -from app.bot.handlers.cancel import cancel_cmd -from app.bot.handlers.debug import dbg_state_cmd - - -async def spam_import_redirect(update, ctx): - await update.effective_message.reply_text( - "Эту команду нужно выполнить в личке со мной. Откройте чат со мной и пришлите /spam_import." - ) +# роутеры +from app.bot.routers.private import register_private_handlers +from app.bot.routers.group import register_group_handlers +from app.bot.routers.channel import register_channel_handlers def main(): cfg = load_config() logging.basicConfig(level=getattr(logging, cfg.log_level.upper(), logging.INFO)) - # скрыть шум httpx/httpcore + # приглушим шум httpx/httpcore logging.getLogger("httpx").setLevel(logging.WARNING) logging.getLogger("httpx").propagate = False logging.getLogger("httpcore").setLevel(logging.WARNING) @@ -176,68 +35,33 @@ def main(): app = ApplicationBuilder().token(cfg.bot_token).build() - # --- Commands --- - app.add_handler(CommandHandler("start", start)) - app.add_handler(CommandHandler("help", help_cmd)) - app.add_handler(CommandHandler("groups", groups_cmd)) - app.add_handler(CommandHandler("add_group", add_group_cmd)) - app.add_handler(CommandHandler("new", new_cmd)) - app.add_handler(CommandHandler("id", chat_id_cmd)) - app.add_handler(CommandHandler("mod_status", mod_status_cmd)) - app.add_handler(CommandHandler("cancel", cancel_cmd)) - app.add_handler(CommandHandler("dbg_state", dbg_state_cmd)) + # === РОУТЕРЫ по типам чатов === + register_private_handlers(app) # ЛС + register_group_handlers(app) # группы/супергруппы + register_channel_handlers(app) # каналы - # --- Callbacks (от узких к широким) --- + # === УЗКИЕ callback-и, общий для редактора === + from telegram.ext import CallbackQueryHandler + + # настройки защитника app.add_handler(CallbackQueryHandler(security_cb, pattern=r"^pol:")) - app.add_handler(CallbackQueryHandler(dicts_cb, pattern=r"^dict:")) + # привязка канала кнопкой «Привязать этот канал» app.add_handler(CallbackQueryHandler(bind_chat_cb, pattern=r"^bind:")) + # редактор/мультивыбор (включаем все draft_* и тумблеры) app.add_handler(CallbackQueryHandler( on_callback, pattern=r"^(draft_.*|tgl:|selall:|clear:|sendmulti:)" )) + # (опционально) текстовые команды управления словарями (и в ЛС, и в группе) + from telegram.ext import CommandHandler + app.add_handler(CommandHandler("dicts_link", dicts_link_cmd)) + app.add_handler(CommandHandler("dicts_unlink", dicts_unlink_cmd)) + app.add_handler(CommandHandler("dicts_link_all", dicts_link_all_cmd)) + app.add_handler(CommandHandler("dicts_unlink_all", dicts_unlink_all_cmd)) + app.add_handler(CommandHandler("dicts_linked", dicts_linked_cmd)) - # --- Private chat helpers --- - app.add_handler(MessageHandler(filters.ChatType.PRIVATE & filters.FORWARDED, add_group_capture)) - app.add_handler(MessageHandler(filters.ChatType.PRIVATE & (filters.PHOTO | filters.VIDEO | filters.ANIMATION), on_media)) - - # --- Join/rights updates --- - app.add_handler(ChatMemberHandler(on_my_chat_member, chat_member_types=ChatMemberHandler.MY_CHAT_MEMBER)) - - # --- Security / Dict --- - app.add_handler(CommandHandler("security", security_cmd)) - # /spam_import — только в ЛС - app.add_handler(CommandHandler("spam_import", spam_import_cmd, filters.ChatType.PRIVATE)) - # редирект из групп - app.add_handler(CommandHandler("spam_import", spam_import_redirect, filters.ChatType.GROUPS)) - # файл словаря - app.add_handler(MessageHandler(filters.ChatType.PRIVATE & filters.Document.ALL, spam_import_capture)) - - # --- Private text handlers (ПОРЯДОК КРИТИЧЕН!) --- - # 1) ШЛЮЗ: если ждём текст поста (await_text) — перехватываем и зовём on_text, блокируя цепочку - app.add_handler(MessageHandler( - filters.ChatType.PRIVATE & filters.TEXT & ~filters.COMMAND, - on_text_gate, - block=True - )) - - # 2) Импорт словаря текстом — НЕ блокирует цепочку - app.add_handler(MessageHandler( - filters.ChatType.PRIVATE & filters.TEXT & ~filters.COMMAND, - spam_import_text_capture, - block=False - )) - - # 3) Обычный редактор — последним - app.add_handler(MessageHandler( - filters.ChatType.PRIVATE & filters.TEXT & ~filters.COMMAND, - on_text - )) - - # --- Moderation in groups --- - app.add_handler(MessageHandler(filters.ChatType.GROUPS & ~filters.COMMAND, moderate_message)) - - # --- Errors --- + # ошибки app.add_error_handler(on_error) app.run_polling(allowed_updates=None) diff --git a/prometheus/prometheus.yml b/prometheus/prometheus.yml index aa36623..682180d 100644 --- a/prometheus/prometheus.yml +++ b/prometheus/prometheus.yml @@ -5,4 +5,4 @@ global: scrape_configs: - job_name: "tg_bot" static_configs: - - targets: ["bot:9100"] # бот слушает метрики на 9100 внутри сети compose + - targets: ["bot:9100"]