From 8b554f59685be7023e139c18987ab0025bef4f2b Mon Sep 17 00:00:00 2001 From: "Andrey K. Choi" Date: Tue, 26 Aug 2025 06:01:18 +0900 Subject: [PATCH] post management returned --- app/bot/handlers/callbacks.py | 32 +++- app/bot/handlers/cancel.py | 11 ++ app/bot/handlers/debug.py | 13 ++ app/bot/handlers/drafts.py | 152 ++++++++++++--- app/bot/handlers/security.py | 343 ++++++++++++++++++++++------------ app/main.py | 227 +++++++++++++++++++--- 6 files changed, 598 insertions(+), 180 deletions(-) create mode 100644 app/bot/handlers/cancel.py create mode 100644 app/bot/handlers/debug.py diff --git a/app/bot/handlers/callbacks.py b/app/bot/handlers/callbacks.py index 1d9acdf..46cde56 100644 --- a/app/bot/handlers/callbacks.py +++ b/app/bot/handlers/callbacks.py @@ -7,15 +7,24 @@ from app.db.models import Draft, Chat, Delivery, User from app.bot.keyboards.common import kb_multiselect # ← только мультивыбор from app.bot.messages import NEED_MEDIA_BEFORE_NEXT, NO_SELECTION, SENT_SUMMARY from app.moderation.engine import check_message_allowed +import logging +# ИМПОРТИРУЕМ КОНСТАНТЫ ИЗ drafts.py, чтобы не промахнуться с ключами +from app.bot.handlers.drafts import ( + STATE_DRAFT, STATE_AWAIT_TEXT, STATE_CONFIRM, + KEY_DRAFT_ID, +) + +log = logging.getLogger(__name__) async def on_callback(update: Update, ctx: ContextTypes.DEFAULT_TYPE): q = update.callback_query await q.answer() data = q.data - + log.debug("callbacks:on_callback data=%s", data) # --- Переход с медиа на текст --- if data.startswith("draft_next_text:"): draft_id = int(data.split(":")[1]) + with get_session() as s: d = s.get(Draft, draft_id) if not d: @@ -24,9 +33,23 @@ async def on_callback(update: Update, ctx: ContextTypes.DEFAULT_TYPE): if len(d.media) == 0: await q.edit_message_text(NEED_MEDIA_BEFORE_NEXT) return - ctx.user_data["draft_id"] = draft_id - ctx.user_data["draft_state"] = "await_text" + + # Сброс «залипших» режимов (если были) + for k in ("await_dict_file", "dict_params", "await_chat_id"): + ctx.user_data.pop(k, None) + + # ЯВНО ПРОСТАВЛЯЕМ draft_id и состояние await_text через общие константы + ctx.user_data[KEY_DRAFT_ID] = draft_id + ctx.user_data[STATE_DRAFT] = STATE_AWAIT_TEXT + + log.info("callbacks:draft_next_text user=%s chat=%s -> draft_id=%s state=%s", + getattr(update.effective_user, "id", None), + getattr(update.effective_chat, "id", None), + draft_id, ctx.user_data.get(STATE_DRAFT)) + await q.edit_message_text("Шаг 2/3 — текст.\nОтправьте текст поста.") + return + # --- Подтверждение -> мультивыбор чатов --- elif data.startswith("draft_confirm_send:"): @@ -170,3 +193,6 @@ async def on_callback(update: Update, ctx: ContextTypes.DEFAULT_TYPE): ctx.user_data.pop("draft_id", None) ctx.user_data.pop("draft_state", None) await q.edit_message_text("Черновик отменён.") + else: + await q.edit_message_text("Неизвестная команда.") + log.warning("callbacks:unhandled data=%s", data) \ No newline at end of file diff --git a/app/bot/handlers/cancel.py b/app/bot/handlers/cancel.py new file mode 100644 index 0000000..de465fe --- /dev/null +++ b/app/bot/handlers/cancel.py @@ -0,0 +1,11 @@ +from telegram import Update +from telegram.ext import ContextTypes + +# те же ключи, что используются в редакторе/импорте/привязке +DRAFT_KEYS = ("draft_state", "draft_id") +OTHER_KEYS = ("await_dict_file", "dict_params", "await_chat_id") + +async def cancel_cmd(update: Update, ctx: ContextTypes.DEFAULT_TYPE): + for k in DRAFT_KEYS + OTHER_KEYS: + ctx.user_data.pop(k, None) + await update.effective_message.reply_text("Состояние сброшено. Можно начать заново: /new") diff --git a/app/bot/handlers/debug.py b/app/bot/handlers/debug.py new file mode 100644 index 0000000..66421a3 --- /dev/null +++ b/app/bot/handlers/debug.py @@ -0,0 +1,13 @@ +from telegram import Update +from telegram.ext import ContextTypes +from app.bot.handlers.drafts import STATE_DRAFT, KEY_DRAFT_ID + +async def dbg_state_cmd(update: Update, ctx: ContextTypes.DEFAULT_TYPE): + ud = ctx.user_data + s = ( + f"draft_id={ud.get(KEY_DRAFT_ID)!r}, " + f"state={ud.get(STATE_DRAFT)!r}, " + f"await_dict={bool(ud.get('await_dict_file'))}, " + f"await_chat_id={bool(ud.get('await_chat_id'))}" + ) + await update.effective_message.reply_text("user_data: " + s) diff --git a/app/bot/handlers/drafts.py b/app/bot/handlers/drafts.py index e300c07..071c045 100644 --- a/app/bot/handlers/drafts.py +++ b/app/bot/handlers/drafts.py @@ -1,73 +1,143 @@ +# app/bot/handlers/drafts.py from datetime import datetime +from html import escape as html_escape +import logging from telegram import Update, InputMediaPhoto, InputMediaVideo, InputMediaAnimation from telegram.constants import ChatType, ParseMode from telegram.ext import ContextTypes + from app.db.session import get_session from app.db.models import User, Draft -from app.bot.messages import ASK_MEDIA, ASK_TEXT, CONFIRM, NEED_START_NEW -from app.bot.keyboards.common import kb_next_text, kb_confirm -from .add_group import add_group_capture, STATE_KEY +from app.bot.messages import ASK_MEDIA, CONFIRM, NEED_START_NEW +from app.bot.keyboards.common import kb_confirm +from app.bot.handlers.add_group import add_group_capture, STATE_KEY # "await_chat_id" +log = logging.getLogger(__name__) + +# Состояния редактора в user_data STATE_DRAFT = "draft_state" KEY_DRAFT_ID = "draft_id" STATE_AWAIT_MEDIA = "await_media" -STATE_AWAIT_TEXT = "await_text" -STATE_CONFIRM = "confirm" +STATE_AWAIT_TEXT = "await_text" +STATE_CONFIRM = "confirm" + + +def _ud_state(ctx: ContextTypes.DEFAULT_TYPE) -> str: + """Короткая сводка по user_data для логов.""" + ud = ctx.user_data + return ( + f"ud(draft_id={ud.get(KEY_DRAFT_ID)}, state={ud.get(STATE_DRAFT)}, " + f"await_dict={bool(ud.get('await_dict_file'))}, await_chat_id={bool(ud.get(STATE_KEY))})" + ) + def _start_new_draft(tg_id: int) -> Draft: + """Создать новый черновик и пометить предыдущие editing как cancelled.""" with get_session() as s: u = s.query(User).filter_by(tg_id=tg_id).first() if not u: - u = User(tg_id=tg_id, name=""); s.add(u); s.commit(); s.refresh(u) - s.query(Draft).filter(Draft.user_id == u.id, Draft.status == "editing").update({"status": "cancelled"}) + u = User(tg_id=tg_id, name="") + s.add(u); s.commit(); s.refresh(u) + # Закрываем старые "editing" + s.query(Draft).filter( + Draft.user_id == u.id, Draft.status == "editing" + ).update({"status": "cancelled"}) + # Создаём новый d = Draft(user_id=u.id, status="editing") s.add(d); s.commit(); s.refresh(d) return d + async def new_cmd(update: Update, ctx: ContextTypes.DEFAULT_TYPE): - d = _start_new_draft(update.effective_user.id) + """Старт редактора: сначала медиа, потом текст, потом подтверждение.""" + uid = getattr(update.effective_user, "id", None) + cid = getattr(update.effective_chat, "id", None) + log.info("drafts:new_cmd start user=%s chat=%s %s", uid, cid, _ud_state(ctx)) + + # ЖЁСТКИЙ СБРОС конфликтующих режимов + for k in ("await_dict_file", "dict_params", STATE_KEY): + if ctx.user_data.pop(k, None) is not None: + log.debug("drafts:new_cmd cleared flag=%s user=%s chat=%s", k, uid, cid) + + d = _start_new_draft(uid) ctx.user_data[KEY_DRAFT_ID] = d.id - ctx.user_data[STATE_DRAFT] = STATE_AWAIT_MEDIA - # Кнопку «Дальше — текст» теперь показываем после добавления медиа, - # поэтому здесь — только инструкция + ctx.user_data[STATE_DRAFT] = STATE_AWAIT_MEDIA + log.info("drafts:new_cmd created draft_id=%s -> state=%s user=%s chat=%s", + d.id, STATE_AWAIT_MEDIA, uid, cid) + + # Кнопку «Дальше — текст» показываем под сообщением «Медиа добавлено» (см. on_media). await update.effective_message.reply_text(ASK_MEDIA) + async def on_text(update: Update, ctx: ContextTypes.DEFAULT_TYPE): - # Если ждём chat_id для /add_group — передаём управление + """ + Обработка текста на шаге 2/3. + """ + uid = getattr(update.effective_user, "id", None) + cid = getattr(update.effective_chat, "id", None) + msg_text = (update.effective_message.text or "") + log.info("drafts:on_text received user=%s chat=%s len=%s %s", + uid, cid, len(msg_text), _ud_state(ctx)) + + # Не мешаем /spam_import (ждём файл/текст словаря) if ctx.user_data.get("await_dict_file"): + log.info("drafts:on_text blocked_by_import user=%s chat=%s", uid, cid) + await update.effective_message.reply_text( + "Сейчас активен импорт словаря. Пришлите .txt/.csv как документ " + "или введите /cancel (или /new) для выхода из импорта." + ) return + + # В процессе /add_group — делегируем туда if ctx.user_data.get(STATE_KEY): + log.info("drafts:on_text delegated_to_add_group user=%s chat=%s", uid, cid) return await add_group_capture(update, ctx) if update.effective_chat.type != ChatType.PRIVATE: + log.debug("drafts:on_text ignored_non_private user=%s chat=%s", uid, cid) return draft_id = ctx.user_data.get(KEY_DRAFT_ID) - state = ctx.user_data.get(STATE_DRAFT) + state = ctx.user_data.get(STATE_DRAFT) if not draft_id or not state: + log.warning("drafts:on_text no_draft_or_state user=%s chat=%s %s", uid, cid, _ud_state(ctx)) await update.effective_message.reply_text(NEED_START_NEW) return if state == STATE_AWAIT_MEDIA: - await update.effective_message.reply_text("Сначала добавьте медиа и нажмите «Дальше — текст».") + log.info("drafts:on_text wrong_state_await_media user=%s chat=%s draft=%s", uid, cid, draft_id) + await update.effective_message.reply_text( + "Сначала добавьте медиа и нажмите «Дальше — текст» (кнопка под сообщением «Медиа добавлено»)." + ) return if state == STATE_CONFIRM: - await update.effective_message.reply_text("Пост уже готов — нажмите «Отправить» или «Отменить».") + log.info("drafts:on_text already_confirm user=%s chat=%s draft=%s", uid, cid, draft_id) + await update.effective_message.reply_text( + "Пост уже готов — нажмите «Отправить» или «Отменить»." + ) return - if state == STATE_AWAIT_TEXT: - # Сохраняем текст - with get_session() as s: - d = s.get(Draft, draft_id) - d.text = update.effective_message.text_html_urled - d.updated_at = datetime.utcnow() - s.commit() + if state != STATE_AWAIT_TEXT: + log.debug("drafts:on_text unknown_state user=%s chat=%s state=%s draft=%s", + uid, cid, state, draft_id) + return - media = sorted(d.media, key=lambda m: m.order) + # ШАГ 2/3 — сохраняем текст + safe_html = html_escape(msg_text) + with get_session() as s: + d = s.get(Draft, draft_id) + before_len = len(d.text or "") if d and d.text else 0 + d.text = safe_html + d.updated_at = datetime.utcnow() + s.commit() + media = sorted(d.media, key=lambda m: m.order) + log.info("drafts:on_text saved_text user=%s chat=%s draft=%s old_len=%s new_len=%s media_count=%s", + uid, cid, draft_id, before_len, len(safe_html), len(media)) - # Предпросмотр + # Предпросмотр + try: if media: if len(media) > 1: im = [] @@ -80,6 +150,8 @@ async def on_text(update: Update, ctx: ContextTypes.DEFAULT_TYPE): else: im.append(InputMediaAnimation(media=m.file_id, caption=cap, parse_mode=ParseMode.HTML)) await update.effective_chat.send_media_group(media=im) + log.info("drafts:on_text preview_album user=%s chat=%s draft=%s parts=%s", + uid, cid, draft_id, len(im)) else: m = media[0] if m.kind == "photo": @@ -88,10 +160,36 @@ async def on_text(update: Update, ctx: ContextTypes.DEFAULT_TYPE): await update.effective_chat.send_video(video=m.file_id, caption=d.text, parse_mode=ParseMode.HTML) else: await update.effective_chat.send_animation(animation=m.file_id, caption=d.text, parse_mode=ParseMode.HTML) + log.info("drafts:on_text preview_single user=%s chat=%s draft=%s kind=%s", + uid, cid, draft_id, m.kind) else: await update.effective_chat.send_message(text=d.text or "(пусто)", parse_mode=ParseMode.HTML) + log.info("drafts:on_text preview_text user=%s chat=%s draft=%s", uid, cid, draft_id) + except Exception as e: + log.exception("drafts:on_text preview_error user=%s chat=%s draft=%s err=%s", + uid, cid, draft_id, e) - # Переходим к подтверждению и показываем кнопки - ctx.user_data[STATE_DRAFT] = STATE_CONFIRM - await update.effective_message.reply_text(CONFIRM, reply_markup=kb_confirm(draft_id)) + # Переход к подтверждению + ctx.user_data[STATE_DRAFT] = STATE_CONFIRM + log.info("drafts:on_text -> state=%s user=%s chat=%s draft=%s", + STATE_CONFIRM, uid, cid, draft_id) + await update.effective_message.reply_text( + "Шаг 3/3 — подтверждение.\nПроверьте пост и нажмите «Отправить» или «Отменить».", + reply_markup=kb_confirm(draft_id), + ) + +async def on_text_gate(update: Update, ctx: ContextTypes.DEFAULT_TYPE): + if update.effective_chat.type != ChatType.PRIVATE: + return + if ctx.user_data.get("await_dict_file"): + return + if ctx.user_data.get(STATE_DRAFT) == STATE_AWAIT_TEXT: + log.info( + "drafts:on_text_gate capture user=%s chat=%s state=%s draft_id=%s", + getattr(update.effective_user, "id", None), + getattr(update.effective_chat, "id", None), + ctx.user_data.get(STATE_DRAFT), + ctx.user_data.get(KEY_DRAFT_ID), + ) + return await on_text(update, ctx) \ No newline at end of file diff --git a/app/bot/handlers/security.py b/app/bot/handlers/security.py index a0f9088..e255965 100644 --- a/app/bot/handlers/security.py +++ b/app/bot/handlers/security.py @@ -1,52 +1,62 @@ import io -from telegram import Update +from datetime import datetime, timedelta +from telegram import Update, InlineKeyboardMarkup, InlineKeyboardButton from telegram.constants import ChatType from telegram.ext import ContextTypes -from sqlalchemy import select, func +from sqlalchemy import func from app.db.session import get_session from app.db.models import ( User, SecurityPolicy, ChatSecurity, - SpamDictionary, DictionaryEntry, PolicyDictionaryLink, # NEW: PolicyDictionaryLink + SpamDictionary, DictionaryEntry, PolicyDictionaryLink, ) from app.bot.keyboards.security import kb_policy -from telegram import InlineKeyboardMarkup, InlineKeyboardButton + +# ---------- helpers ---------- 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) + 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 + if p: + return p p = SecurityPolicy(owner_user_id=u.id, name="Balanced") session.add(p); session.commit(); session.refresh(p) return p -def _parse_params(raw: str|None, fallback_name: str) -> dict: - params = {"name": fallback_name or "dict", "category":"custom", "kind":"plain", "lang":None} + +def _parse_params(raw: str | None, fallback_name: str) -> dict: + params = {"name": fallback_name or "dict", "category": "custom", "kind": "plain", "lang": None} if raw: for kv in raw.split(";"): if "=" in kv: - k,v = kv.strip().split("=",1) + k, v = kv.strip().split("=", 1) params[k.strip()] = v.strip() params["name"] = (params["name"] or "dict")[:120] params["category"] = (params.get("category") or "custom").lower() params["kind"] = (params.get("kind") or "plain").lower() return params + def _decode_bytes(b: bytes) -> str: - for enc in ("utf-8","cp1251","latin-1"): - try: return b.decode(enc) - except Exception: pass - return b.decode("utf-8","ignore") + for enc in ("utf-8", "cp1251", "latin-1"): + try: + return b.decode(enc) + except Exception: + continue + return b.decode("utf-8", "ignore") + def _import_entries(session, owner_tg_id: int, params: dict, entries: list[str]) -> int: # ensure owner 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) + u = User(tg_id=owner_tg_id, name="") + session.add(u); session.commit(); session.refresh(u) - # словарь + # создать словарь d = SpamDictionary( owner_user_id=u.id, name=params["name"], category=params["category"], @@ -54,69 +64,222 @@ def _import_entries(session, owner_tg_id: int, params: dict, entries: list[str]) ) session.add(d); session.commit(); session.refresh(d) - # NEW: авто-привязка к дефолт-политике владельца (Balanced) + # автопривязка к дефолт-политике владельца (Balanced), чтобы потом /dicts работал сразу p = _get_or_create_policy(session, owner_tg_id) - exists = session.query(PolicyDictionaryLink).filter_by(policy_id=p.id, dictionary_id=d.id).first() - if not exists: + if not session.query(PolicyDictionaryLink).filter_by(policy_id=p.id, dictionary_id=d.id).first(): session.add(PolicyDictionaryLink(policy_id=p.id, dictionary_id=d.id)) session.commit() - # записи + # добавить записи n = 0 for pat in entries: pat = pat.strip() - if not pat or pat.startswith("#"): continue - session.add(DictionaryEntry(dictionary_id=d.id, pattern=pat, is_regex=(params["kind"]=="regex"))) + if not pat or pat.startswith("#"): + continue + session.add(DictionaryEntry(dictionary_id=d.id, pattern=pat, is_regex=(params["kind"] == "regex"))) n += 1 session.commit() return n + +# ---------- /security ---------- async def security_cmd(update: Update, ctx: ContextTypes.DEFAULT_TYPE): with get_session() as s: p = _get_or_create_policy(s, update.effective_user.id) chat = update.effective_chat - bound=enabled=False + bound = enabled = False if chat.type in (ChatType.GROUP, ChatType.SUPERGROUP, ChatType.CHANNEL): cs = s.query(ChatSecurity).filter_by(chat_id=chat.id).first() - if cs and cs.policy_id == p.id: bound, enabled = True, cs.enabled - await update.effective_message.reply_text(f"Политика «{p.name}»", reply_markup=kb_policy(p, chat_bound=bound, enabled=enabled)) + if cs and cs.policy_id == p.id: + bound, enabled = True, cs.enabled + + # показать панель + msg = await update.effective_message.reply_text( + f"Политика «{p.name}».", + reply_markup=kb_policy(p, chat_bound=bound, enabled=enabled) + ) + + # ----- ЗАМОК панели: только инициатор может нажимать ----- + locks = ctx.chat_data.setdefault("security_locks", {}) + locks[msg.message_id] = update.effective_user.id + # TTL/очистку можно сделать по таймеру, если нужно + async def security_cb(update: Update, ctx: ContextTypes.DEFAULT_TYPE): - q = update.callback_query; await q.answer() - parts = q.data.split(":") - if parts[0] != "pol": return + q = update.callback_query + await q.answer() + + # замок на сообщение + lock_owner = ctx.chat_data.get("security_locks", {}).get(q.message.message_id) + if lock_owner and lock_owner != update.effective_user.id: + await q.answer("Эта панель открыта другим админом. Запустите /security для своей.", show_alert=True) + return + + # должен быть админом чата + try: + m = await ctx.bot.get_chat_member(update.effective_chat.id, update.effective_user.id) + if m.status not in ("administrator", "creator"): + await q.answer("Недостаточно прав.", show_alert=True); return + except Exception: + await q.answer("Недостаточно прав.", show_alert=True); return + + parts = (q.data or "").split(":") + if len(parts) < 2 or parts[0] != "pol": + return action = parts[1] + with get_session() as s: - pid = int(parts[-1]); p = s.get(SecurityPolicy, pid) - if not p: await q.edit_message_text("Политика не найдена."); return + pid = int(parts[-1]) + p = s.get(SecurityPolicy, pid) + if not p: + await q.edit_message_text("Политика не найдена.") + return + if action == "toggle": - field = parts[2]; setattr(p, field, not getattr(p, field)); s.commit() + field = parts[2] + setattr(p, field, not getattr(p, field)) + s.commit() + elif action == "adj": - field, delta = parts[2], int(parts[3]); val = getattr(p, field); setattr(p, field, max(0, val+delta)); s.commit() + field, delta = parts[2], int(parts[3]) + val = getattr(p, field) + setattr(p, field, max(0, val + delta)) + s.commit() + elif action == "cycle_action": - order = ["delete","warn","timeout","ban","none"]; cur=p.enforce_action_default - p.enforce_action_default = order[(order.index(cur)+1)%len(order)] if cur in order else "delete"; s.commit() + order = ["delete", "warn", "timeout", "ban", "none"] + cur = p.enforce_action_default + p.enforce_action_default = order[(order.index(cur) + 1) % len(order)] if cur in order else "delete" + s.commit() + elif action == "bind_here": chat = update.effective_chat if chat.type not in (ChatType.GROUP, ChatType.SUPERGROUP, ChatType.CHANNEL): - await q.edit_message_text("Жмите в группе/канале."); return + await q.edit_message_text("Жмите в группе/канале.") + return cs = s.query(ChatSecurity).filter_by(chat_id=chat.id).first() - if not cs: cs = ChatSecurity(chat_id=chat.id, policy_id=p.id, enabled=False); s.add(cs) - else: cs.policy_id = p.id + if not cs: + cs = ChatSecurity(chat_id=chat.id, policy_id=p.id, enabled=False) + s.add(cs) + else: + cs.policy_id = p.id s.commit() + elif action == "toggle_chat": - chat = update.effective_chat; cs = s.query(ChatSecurity).filter_by(chat_id=chat.id, policy_id=pid).first() - if cs: cs.enabled = not cs.enabled; s.commit() - # обновить клавиатуру - chat = update.effective_chat; bound=enabled=False + chat = update.effective_chat + cs = s.query(ChatSecurity).filter_by(chat_id=chat.id, policy_id=pid).first() + if cs: + cs.enabled = not cs.enabled + s.commit() + + # перерисовать клавиатуру с учётом чата + chat = update.effective_chat + bound = enabled = False if chat and chat.type in (ChatType.GROUP, ChatType.SUPERGROUP, ChatType.CHANNEL): cs = s.query(ChatSecurity).filter_by(chat_id=chat.id).first() - if cs and cs.policy_id == p.id: bound, enabled = True, cs.enabled + if cs and cs.policy_id == p.id: + bound, enabled = True, cs.enabled + await q.edit_message_reply_markup(reply_markup=kb_policy(p, chat_bound=bound, enabled=enabled)) -# === Импорт словаря === + +# ---------- словари: список и тоггл ---------- +def _kb_dicts(policy_id: int, rows: list[tuple[int, str, bool]]): + # rows: [(dict_id, "Имя (категория/kind)", is_linked)] + kbd = [] + for did, title, linked in rows: + mark = "✅" if linked else "▫️" + kbd.append([InlineKeyboardButton(f"{mark} {title}", callback_data=f"dict:toggle:{policy_id}:{did}")]) + return InlineKeyboardMarkup(kbd) if kbd else None + + +async def dicts_cmd(update: Update, ctx: ContextTypes.DEFAULT_TYPE): + chat = update.effective_chat + user_id = update.effective_user.id + + # админ-проверка в группах + if chat.type in (ChatType.GROUP, ChatType.SUPERGROUP): + try: + m = await ctx.bot.get_chat_member(chat.id, user_id) + if m.status not in ("administrator", "creator"): + return + except Exception: + return + + with get_session() as s: + # выбрать политику: в группе — политика чата; в ЛС — дефолт владельца + if chat.type in (ChatType.GROUP, ChatType.SUPERGROUP, ChatType.CHANNEL): + cs = s.query(ChatSecurity).filter_by(chat_id=chat.id).first() + if not cs: + await update.effective_message.reply_text("Политика не привязана. Откройте /security и привяжите к чату.") + return + p = s.get(SecurityPolicy, cs.policy_id) + else: + p = _get_or_create_policy(s, user_id) + + u = s.query(User).filter_by(tg_id=user_id).first() + dicts = s.query(SpamDictionary).filter_by(owner_user_id=u.id).order_by(SpamDictionary.created_at.desc()).all() + linked = {x.dictionary_id for x in s.query(PolicyDictionaryLink).filter_by(policy_id=p.id).all()} + + rows = [] + for d in dicts[:100]: + title = f"{d.name} ({d.category}/{d.kind})" + rows.append((d.id, title, d.id in linked)) + + kb = _kb_dicts(p.id, rows) + if not rows: + await update.effective_message.reply_text("У вас пока нет словарей. Импортируйте через /spam_import в ЛС.") + return + await update.effective_message.reply_text( + f"Словари для политики «{p.name}» (нажмите для прикрепления/открепления):", + reply_markup=kb + ) + + +async def dicts_cb(update: Update, ctx: ContextTypes.DEFAULT_TYPE): + q = update.callback_query + await q.answer() + parts = (q.data or "").split(":") + if len(parts) != 4 or parts[0] != "dict" or parts[1] != "toggle": + return + policy_id = int(parts[2]); dict_id = int(parts[3]) + + # только админ + try: + m = await ctx.bot.get_chat_member(update.effective_chat.id, update.effective_user.id) + if m.status not in ("administrator", "creator"): + await q.answer("Недостаточно прав.", show_alert=True); return + except Exception: + await q.answer("Недостаточно прав.", show_alert=True); return + + with get_session() as s: + p = s.get(SecurityPolicy, policy_id) + if not p: + await q.edit_message_text("Политика не найдена.") + return + link = s.query(PolicyDictionaryLink).filter_by(policy_id=policy_id, dictionary_id=dict_id).first() + if link: + s.delete(link); s.commit() + else: + s.add(PolicyDictionaryLink(policy_id=policy_id, dictionary_id=dict_id)); s.commit() + + 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() + linked = {x.dictionary_id for x in s.query(PolicyDictionaryLink).filter_by(policy_id=p.id).all()} + rows = [] + for d in dicts[:100]: + title = f"{d.name} ({d.category}/{d.kind})" + rows.append((d.id, title, d.id in linked)) + + await q.edit_message_reply_markup(reply_markup=_kb_dicts(p.id, rows)) + + +# ---------- импорт словаря ---------- async def spam_import_cmd(update: Update, ctx: ContextTypes.DEFAULT_TYPE): - log.info("spam_import_cmd from %s", update.effective_user.id) + # только в ЛС; в main.py уже стоит фильтр, но на всякий + if update.effective_chat.type != ChatType.PRIVATE: + await update.effective_message.reply_text("Эту команду нужно выполнить в личке со мной.") + return ctx.user_data["await_dict_file"] = True ctx.user_data.pop("dict_params", None) await update.effective_message.reply_text( @@ -124,22 +287,18 @@ async def spam_import_cmd(update: Update, ctx: ContextTypes.DEFAULT_TYPE): "Подпись (необязательно): name=RU_spam; category=spam|scam|adult|profanity|custom; kind=plain|regex; lang=ru" ) + async def spam_import_capture(update: Update, ctx: ContextTypes.DEFAULT_TYPE): - log.info("spam_import_capture: doc=%s caption=%s", - bool(update.message and update.message.document), - update.message.caption if update.message else None) - # Обрабатываем только когда ждём файл - if not ctx.user_data.get("await_dict_file"): + if update.effective_chat.type != ChatType.PRIVATE or not ctx.user_data.get("await_dict_file"): return doc = update.message.document if update.message else None if not doc: return - # ACK сразу, чтобы было видно, что бот работает await update.effective_message.reply_text(f"Файл получен: {doc.file_name or 'без имени'} — импортирую…") try: - file = await doc.get_file() + f = await doc.get_file() bio = io.BytesIO() - await file.download_to_memory(out=bio) + await f.download_to_memory(out=bio) bio.seek(0) text = _decode_bytes(bio.read()) lines = [l.strip() for l in text.splitlines() if l.strip()] @@ -154,22 +313,21 @@ async def spam_import_capture(update: Update, ctx: ContextTypes.DEFAULT_TYPE): except Exception as e: await update.effective_message.reply_text(f"Ошибка импорта: {e}") -# === (опционально) Импорт словаря из текста, если прислали без файла === + async def spam_import_text_capture(update: Update, ctx: ContextTypes.DEFAULT_TYPE): - log.info("spam_import_text_capture: await=%s text_len=%s", - ctx.user_data.get("await_dict_file"), - len(update.effective_message.text or "")) - if not ctx.user_data.get("await_dict_file"): + # перехватываем только в ЛС и только если ждём словарь — И НЕ блокируем цепочку (block=False в main.py) + if update.effective_chat.type != ChatType.PRIVATE or not ctx.user_data.get("await_dict_file"): return txt = (update.effective_message.text or "").strip() if not txt: return - # Если похоже на «подпись» с параметрами — просто запомним и попросим файл + # если текст похож на "параметры", просто запомним и попросим файл if ("=" in txt) and (";" in txt) and (len(txt.split()) <= 6): ctx.user_data["dict_params"] = txt await update.effective_message.reply_text("Параметры принял. Теперь пришлите .txt/.csv ФАЙЛОМ со словарём.") return - # Иначе трактуем как словарь одной «пачкой» + + # иначе считаем, что прислан словарь "в лоб" lines = [l.strip() for l in txt.splitlines() if l.strip()] params = _parse_params(ctx.user_data.get("dict_params"), "inline_dict") try: @@ -177,71 +335,8 @@ async def spam_import_text_capture(update: Update, ctx: ContextTypes.DEFAULT_TYP n = _import_entries(s, update.effective_user.id, params, lines) ctx.user_data.pop("await_dict_file", None) ctx.user_data.pop("dict_params", None) - await update.effective_message.reply_text(f"Импортировано {n} записей (из текста) в словарь «{params['name']}».") + await update.effective_message.reply_text( + f"Импортировано {n} записей (из текста) в словарь «{params['name']}»." + ) except Exception as e: await update.effective_message.reply_text(f"Ошибка импорта: {e}") - - -def _kb_dicts(policy_id: int, rows: list[tuple[int,str,bool]]): - # rows: [(dict_id, "Имя (категория/kind)", is_linked)] - kbd = [] - for did, title, linked in rows: - mark = "✅" if linked else "▫️" - kbd.append([InlineKeyboardButton(f"{mark} {title}", callback_data=f"dict:toggle:{policy_id}:{did}")]) - return InlineKeyboardMarkup(kbd) if kbd else None - -async def dicts_cmd(update: Update, ctx: ContextTypes.DEFAULT_TYPE): - chat = update.effective_chat - with get_session() as s: - # Выбираем политику: в группе — привязанную к чату, иначе — дефолт владельца - if chat.type in (ChatType.GROUP, ChatType.SUPERGROUP, ChatType.CHANNEL): - cs = s.query(ChatSecurity).filter_by(chat_id=chat.id).first() - if not cs: - await update.effective_message.reply_text("Политика не привязана. Откройте /security и привяжите к чату.") - return - p = s.get(SecurityPolicy, cs.policy_id) - else: - p = _get_or_create_policy(s, update.effective_user.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() - linked = {x.dictionary_id for x in s.query(PolicyDictionaryLink).filter_by(policy_id=p.id).all()} - - rows = [] - for d in dicts[:50]: # первые 50 - title = f"{d.name} ({d.category}/{d.kind})" - rows.append((d.id, title, d.id in linked)) - - kb = _kb_dicts(p.id, rows) - if not rows: - await update.effective_message.reply_text("У вас пока нет словарей. Импортируйте через /spam_import.") - return - await update.effective_message.reply_text(f"Словари для политики «{p.name}» (нажмите, чтобы прикрепить/открепить):", reply_markup=kb) - -async def dicts_cb(update: Update, ctx: ContextTypes.DEFAULT_TYPE): - q = update.callback_query; await q.answer() - data = q.data.split(":") - if len(data) != 4 or data[0] != "dict" or data[1] != "toggle": - return - policy_id = int(data[2]); dict_id = int(data[3]) - with get_session() as s: - p = s.get(SecurityPolicy, policy_id) - if not p: - await q.edit_message_text("Политика не найдена.") - return - link = s.query(PolicyDictionaryLink).filter_by(policy_id=policy_id, dictionary_id=dict_id).first() - if link: - s.delete(link); s.commit() - else: - s.add(PolicyDictionaryLink(policy_id=policy_id, dictionary_id=dict_id)); s.commit() - - # перерисовать список - 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() - linked = {x.dictionary_id for x in s.query(PolicyDictionaryLink).filter_by(policy_id=p.id).all()} - rows = [] - for d in dicts[:50]: - title = f"{d.name} ({d.category}/{d.kind})" - rows.append((d.id, title, d.id in linked)) - await q.edit_message_reply_markup(reply_markup=_kb_dicts(p.id, rows)) \ No newline at end of file diff --git a/app/main.py b/app/main.py index 9e5a7fb..a3dcbb8 100644 --- a/app/main.py +++ b/app/main.py @@ -1,28 +1,182 @@ +# 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() + + import logging -from telegram.ext import ApplicationBuilder, CommandHandler, MessageHandler, CallbackQueryHandler, ChatMemberHandler, filters +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.drafts import new_cmd, on_text, on_text_gate # ← шлюз + редактор 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 + +# безопасность / словари +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.mod_status import mod_status_cmd -from app.infra.metrics import start_metrics_server -from app.bot.handlers.security import dicts_cmd, dicts_cb +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." + ) def main(): cfg = load_config() logging.basicConfig(level=getattr(logging, cfg.log_level.upper(), logging.INFO)) + + # скрыть шум httpx/httpcore + logging.getLogger("httpx").setLevel(logging.WARNING) + logging.getLogger("httpx").propagate = False + logging.getLogger("httpcore").setLevel(logging.WARNING) + logging.getLogger("httpcore").propagate = False + + # метрики Prometheus + start_metrics_server(cfg.metrics_port) + app = ApplicationBuilder().token(cfg.bot_token).build() - # Commands + # --- Commands --- app.add_handler(CommandHandler("start", start)) app.add_handler(CommandHandler("help", help_cmd)) app.add_handler(CommandHandler("groups", groups_cmd)) @@ -30,43 +184,64 @@ def main(): 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("dicts", dicts_cmd)) + app.add_handler(CommandHandler("cancel", cancel_cmd)) + app.add_handler(CommandHandler("dbg_state", dbg_state_cmd)) - # коллбэки словарей - app.add_handler(CallbackQueryHandler(dicts_cb, pattern=r"^dict:")) - - # Callbacks (order matters!) + # --- 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_|tgl:|selall:|clear:|sendmulti:)")) + app.add_handler(CallbackQueryHandler( + on_callback, + pattern=r"^(draft_.*|tgl:|selall:|clear:|sendmulti:)" + )) - # Private chat helpers + + # --- 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 + # --- Join/rights updates --- app.add_handler(ChatMemberHandler(on_my_chat_member, chat_member_types=ChatMemberHandler.MY_CHAT_MEMBER)) - # Security / Dict + # --- Security / Dict --- app.add_handler(CommandHandler("security", security_cmd)) + # /spam_import — только в ЛС app.add_handler(CommandHandler("spam_import", spam_import_cmd, filters.ChatType.PRIVATE)) - async def spam_import_redirect(update, ctx): - await update.effective_message.reply_text("Эту команду нужно выполнять в ЛС. Откройте чат со мной и пришлите /spam_import.") + # редирект из групп 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)) - from app.bot.handlers.security import spam_import_text_capture - app.add_handler(MessageHandler(filters.ChatType.PRIVATE & filters.TEXT & ~filters.COMMAND, spam_import_text_capture, block=False)) - # Moderation + # --- 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)) - # Draft editor (after import handlers) - app.add_handler(MessageHandler(filters.ChatType.PRIVATE & filters.TEXT & ~filters.COMMAND, on_text)) - - start_metrics_server(cfg.metrics_port) + # --- Errors --- + app.add_error_handler(on_error) app.run_polling(allowed_updates=None) + if __name__ == "__main__": main()