Compare commits

..

2 Commits

Author SHA1 Message Date
430101eb25 Merge pull request 'post management returned' (#1) from refactor into main
Reviewed-on: #1
2025-08-27 07:43:27 +00:00
8b554f5968 post management returned 2025-08-26 06:01:18 +09:00
6 changed files with 598 additions and 180 deletions

View File

@@ -7,15 +7,24 @@ from app.db.models import Draft, Chat, Delivery, User
from app.bot.keyboards.common import kb_multiselect # ← только мультивыбор from app.bot.keyboards.common import kb_multiselect # ← только мультивыбор
from app.bot.messages import NEED_MEDIA_BEFORE_NEXT, NO_SELECTION, SENT_SUMMARY from app.bot.messages import NEED_MEDIA_BEFORE_NEXT, NO_SELECTION, SENT_SUMMARY
from app.moderation.engine import check_message_allowed 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): async def on_callback(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
q = update.callback_query q = update.callback_query
await q.answer() await q.answer()
data = q.data data = q.data
log.debug("callbacks:on_callback data=%s", data)
# --- Переход с медиа на текст --- # --- Переход с медиа на текст ---
if data.startswith("draft_next_text:"): if data.startswith("draft_next_text:"):
draft_id = int(data.split(":")[1]) draft_id = int(data.split(":")[1])
with get_session() as s: with get_session() as s:
d = s.get(Draft, draft_id) d = s.get(Draft, draft_id)
if not d: if not d:
@@ -24,9 +33,23 @@ async def on_callback(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
if len(d.media) == 0: if len(d.media) == 0:
await q.edit_message_text(NEED_MEDIA_BEFORE_NEXT) await q.edit_message_text(NEED_MEDIA_BEFORE_NEXT)
return 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Отправьте текст поста.") await q.edit_message_text("Шаг 2/3 — текст.\nОтправьте текст поста.")
return
# --- Подтверждение -> мультивыбор чатов --- # --- Подтверждение -> мультивыбор чатов ---
elif data.startswith("draft_confirm_send:"): 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_id", None)
ctx.user_data.pop("draft_state", None) ctx.user_data.pop("draft_state", None)
await q.edit_message_text("Черновик отменён.") await q.edit_message_text("Черновик отменён.")
else:
await q.edit_message_text("Неизвестная команда.")
log.warning("callbacks:unhandled data=%s", data)

View File

@@ -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")

13
app/bot/handlers/debug.py Normal file
View File

@@ -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)

View File

@@ -1,73 +1,143 @@
# app/bot/handlers/drafts.py
from datetime import datetime from datetime import datetime
from html import escape as html_escape
import logging
from telegram import Update, InputMediaPhoto, InputMediaVideo, InputMediaAnimation from telegram import Update, InputMediaPhoto, InputMediaVideo, InputMediaAnimation
from telegram.constants import ChatType, ParseMode from telegram.constants import ChatType, ParseMode
from telegram.ext import ContextTypes from telegram.ext import ContextTypes
from app.db.session import get_session from app.db.session import get_session
from app.db.models import User, Draft from app.db.models import User, Draft
from app.bot.messages import ASK_MEDIA, ASK_TEXT, CONFIRM, NEED_START_NEW from app.bot.messages import ASK_MEDIA, CONFIRM, NEED_START_NEW
from app.bot.keyboards.common import kb_next_text, kb_confirm from app.bot.keyboards.common import kb_confirm
from .add_group import add_group_capture, STATE_KEY 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" STATE_DRAFT = "draft_state"
KEY_DRAFT_ID = "draft_id" KEY_DRAFT_ID = "draft_id"
STATE_AWAIT_MEDIA = "await_media" STATE_AWAIT_MEDIA = "await_media"
STATE_AWAIT_TEXT = "await_text" STATE_AWAIT_TEXT = "await_text"
STATE_CONFIRM = "confirm" 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: def _start_new_draft(tg_id: int) -> Draft:
"""Создать новый черновик и пометить предыдущие editing как cancelled."""
with get_session() as s: with get_session() as s:
u = s.query(User).filter_by(tg_id=tg_id).first() u = s.query(User).filter_by(tg_id=tg_id).first()
if not u: if not u:
u = User(tg_id=tg_id, name=""); s.add(u); s.commit(); s.refresh(u) u = User(tg_id=tg_id, name="")
s.query(Draft).filter(Draft.user_id == u.id, Draft.status == "editing").update({"status": "cancelled"}) 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") d = Draft(user_id=u.id, status="editing")
s.add(d); s.commit(); s.refresh(d) s.add(d); s.commit(); s.refresh(d)
return d return d
async def new_cmd(update: Update, ctx: ContextTypes.DEFAULT_TYPE): 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[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) await update.effective_message.reply_text(ASK_MEDIA)
async def on_text(update: Update, ctx: ContextTypes.DEFAULT_TYPE): 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"): 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 return
# В процессе /add_group — делегируем туда
if ctx.user_data.get(STATE_KEY): 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) return await add_group_capture(update, ctx)
if update.effective_chat.type != ChatType.PRIVATE: if update.effective_chat.type != ChatType.PRIVATE:
log.debug("drafts:on_text ignored_non_private user=%s chat=%s", uid, cid)
return return
draft_id = ctx.user_data.get(KEY_DRAFT_ID) 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: 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) await update.effective_message.reply_text(NEED_START_NEW)
return return
if state == STATE_AWAIT_MEDIA: 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 return
if state == STATE_CONFIRM: 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 return
if state == STATE_AWAIT_TEXT: if state != STATE_AWAIT_TEXT:
# Сохраняем текст log.debug("drafts:on_text unknown_state user=%s chat=%s state=%s draft=%s",
with get_session() as s: uid, cid, state, draft_id)
d = s.get(Draft, draft_id) return
d.text = update.effective_message.text_html_urled
d.updated_at = datetime.utcnow()
s.commit()
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 media:
if len(media) > 1: if len(media) > 1:
im = [] im = []
@@ -80,6 +150,8 @@ async def on_text(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
else: else:
im.append(InputMediaAnimation(media=m.file_id, caption=cap, parse_mode=ParseMode.HTML)) im.append(InputMediaAnimation(media=m.file_id, caption=cap, parse_mode=ParseMode.HTML))
await update.effective_chat.send_media_group(media=im) 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: else:
m = media[0] m = media[0]
if m.kind == "photo": 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) await update.effective_chat.send_video(video=m.file_id, caption=d.text, parse_mode=ParseMode.HTML)
else: else:
await update.effective_chat.send_animation(animation=m.file_id, caption=d.text, parse_mode=ParseMode.HTML) 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: else:
await update.effective_chat.send_message(text=d.text or "(пусто)", parse_mode=ParseMode.HTML) 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 ctx.user_data[STATE_DRAFT] = STATE_CONFIRM
await update.effective_message.reply_text(CONFIRM, reply_markup=kb_confirm(draft_id)) 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)

View File

@@ -1,52 +1,62 @@
import io import io
from telegram import Update from datetime import datetime, timedelta
from telegram import Update, InlineKeyboardMarkup, InlineKeyboardButton
from telegram.constants import ChatType from telegram.constants import ChatType
from telegram.ext import ContextTypes from telegram.ext import ContextTypes
from sqlalchemy import select, func from sqlalchemy import func
from app.db.session import get_session from app.db.session import get_session
from app.db.models import ( from app.db.models import (
User, SecurityPolicy, ChatSecurity, User, SecurityPolicy, ChatSecurity,
SpamDictionary, DictionaryEntry, PolicyDictionaryLink, # NEW: PolicyDictionaryLink SpamDictionary, DictionaryEntry, PolicyDictionaryLink,
) )
from app.bot.keyboards.security import kb_policy 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: def _get_or_create_policy(session, owner_tg_id: int) -> SecurityPolicy:
u = session.query(User).filter_by(tg_id=owner_tg_id).first() u = session.query(User).filter_by(tg_id=owner_tg_id).first()
if not u: 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() 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") p = SecurityPolicy(owner_user_id=u.id, name="Balanced")
session.add(p); session.commit(); session.refresh(p) session.add(p); session.commit(); session.refresh(p)
return 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: if raw:
for kv in raw.split(";"): for kv in raw.split(";"):
if "=" in kv: if "=" in kv:
k,v = kv.strip().split("=",1) k, v = kv.strip().split("=", 1)
params[k.strip()] = v.strip() params[k.strip()] = v.strip()
params["name"] = (params["name"] or "dict")[:120] params["name"] = (params["name"] or "dict")[:120]
params["category"] = (params.get("category") or "custom").lower() params["category"] = (params.get("category") or "custom").lower()
params["kind"] = (params.get("kind") or "plain").lower() params["kind"] = (params.get("kind") or "plain").lower()
return params return params
def _decode_bytes(b: bytes) -> str: def _decode_bytes(b: bytes) -> str:
for enc in ("utf-8","cp1251","latin-1"): for enc in ("utf-8", "cp1251", "latin-1"):
try: return b.decode(enc) try:
except Exception: pass return b.decode(enc)
return b.decode("utf-8","ignore") except Exception:
continue
return b.decode("utf-8", "ignore")
def _import_entries(session, owner_tg_id: int, params: dict, entries: list[str]) -> int: def _import_entries(session, owner_tg_id: int, params: dict, entries: list[str]) -> int:
# ensure owner # ensure owner
u = session.query(User).filter_by(tg_id=owner_tg_id).first() u = session.query(User).filter_by(tg_id=owner_tg_id).first()
if not u: 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( d = SpamDictionary(
owner_user_id=u.id, owner_user_id=u.id,
name=params["name"], category=params["category"], 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) session.add(d); session.commit(); session.refresh(d)
# NEW: авто-привязка к дефолт-политике владельца (Balanced) # автопривязка к дефолт-политике владельца (Balanced), чтобы потом /dicts работал сразу
p = _get_or_create_policy(session, owner_tg_id) 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 session.query(PolicyDictionaryLink).filter_by(policy_id=p.id, dictionary_id=d.id).first():
if not exists:
session.add(PolicyDictionaryLink(policy_id=p.id, dictionary_id=d.id)) session.add(PolicyDictionaryLink(policy_id=p.id, dictionary_id=d.id))
session.commit() session.commit()
# записи # добавить записи
n = 0 n = 0
for pat in entries: for pat in entries:
pat = pat.strip() pat = pat.strip()
if not pat or pat.startswith("#"): continue if not pat or pat.startswith("#"):
session.add(DictionaryEntry(dictionary_id=d.id, pattern=pat, is_regex=(params["kind"]=="regex"))) continue
session.add(DictionaryEntry(dictionary_id=d.id, pattern=pat, is_regex=(params["kind"] == "regex")))
n += 1 n += 1
session.commit() session.commit()
return n return n
# ---------- /security ----------
async def security_cmd(update: Update, ctx: ContextTypes.DEFAULT_TYPE): async def security_cmd(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
with get_session() as s: with get_session() as s:
p = _get_or_create_policy(s, update.effective_user.id) p = _get_or_create_policy(s, update.effective_user.id)
chat = update.effective_chat chat = update.effective_chat
bound=enabled=False bound = enabled = False
if chat.type in (ChatType.GROUP, ChatType.SUPERGROUP, ChatType.CHANNEL): if chat.type in (ChatType.GROUP, ChatType.SUPERGROUP, ChatType.CHANNEL):
cs = s.query(ChatSecurity).filter_by(chat_id=chat.id).first() 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:
await update.effective_message.reply_text(f"Политика «{p.name}»", reply_markup=kb_policy(p, chat_bound=bound, enabled=enabled)) 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): async def security_cb(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
q = update.callback_query; await q.answer() q = update.callback_query
parts = q.data.split(":") await q.answer()
if parts[0] != "pol": return
# замок на сообщение
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] action = parts[1]
with get_session() as s: with get_session() as s:
pid = int(parts[-1]); p = s.get(SecurityPolicy, pid) pid = int(parts[-1])
if not p: await q.edit_message_text("Политика не найдена."); return p = s.get(SecurityPolicy, pid)
if not p:
await q.edit_message_text("Политика не найдена.")
return
if action == "toggle": 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": 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": elif action == "cycle_action":
order = ["delete","warn","timeout","ban","none"]; cur=p.enforce_action_default order = ["delete", "warn", "timeout", "ban", "none"]
p.enforce_action_default = order[(order.index(cur)+1)%len(order)] if cur in order else "delete"; s.commit() 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": elif action == "bind_here":
chat = update.effective_chat chat = update.effective_chat
if chat.type not in (ChatType.GROUP, ChatType.SUPERGROUP, ChatType.CHANNEL): 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() 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) if not cs:
else: cs.policy_id = p.id cs = ChatSecurity(chat_id=chat.id, policy_id=p.id, enabled=False)
s.add(cs)
else:
cs.policy_id = p.id
s.commit() s.commit()
elif action == "toggle_chat": elif action == "toggle_chat":
chat = update.effective_chat; cs = s.query(ChatSecurity).filter_by(chat_id=chat.id, policy_id=pid).first() chat = update.effective_chat
if cs: cs.enabled = not cs.enabled; s.commit() cs = s.query(ChatSecurity).filter_by(chat_id=chat.id, policy_id=pid).first()
# обновить клавиатуру if cs:
chat = update.effective_chat; bound=enabled=False 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): if chat and chat.type in (ChatType.GROUP, ChatType.SUPERGROUP, ChatType.CHANNEL):
cs = s.query(ChatSecurity).filter_by(chat_id=chat.id).first() 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)) 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): 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["await_dict_file"] = True
ctx.user_data.pop("dict_params", None) ctx.user_data.pop("dict_params", None)
await update.effective_message.reply_text( 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" "Подпись (необязательно): 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): async def spam_import_capture(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
log.info("spam_import_capture: doc=%s caption=%s", if update.effective_chat.type != ChatType.PRIVATE or not ctx.user_data.get("await_dict_file"):
bool(update.message and update.message.document),
update.message.caption if update.message else None)
# Обрабатываем только когда ждём файл
if not ctx.user_data.get("await_dict_file"):
return return
doc = update.message.document if update.message else None doc = update.message.document if update.message else None
if not doc: if not doc:
return return
# ACK сразу, чтобы было видно, что бот работает
await update.effective_message.reply_text(f"Файл получен: {doc.file_name or 'без имени'} — импортирую…") await update.effective_message.reply_text(f"Файл получен: {doc.file_name or 'без имени'} — импортирую…")
try: try:
file = await doc.get_file() f = await doc.get_file()
bio = io.BytesIO() bio = io.BytesIO()
await file.download_to_memory(out=bio) await f.download_to_memory(out=bio)
bio.seek(0) bio.seek(0)
text = _decode_bytes(bio.read()) text = _decode_bytes(bio.read())
lines = [l.strip() for l in text.splitlines() if l.strip()] 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: except Exception as e:
await update.effective_message.reply_text(f"Ошибка импорта: {e}") await update.effective_message.reply_text(f"Ошибка импорта: {e}")
# === (опционально) Импорт словаря из текста, если прислали без файла ===
async def spam_import_text_capture(update: Update, ctx: ContextTypes.DEFAULT_TYPE): async def spam_import_text_capture(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
log.info("spam_import_text_capture: await=%s text_len=%s", # перехватываем только в ЛС и только если ждём словарь — И НЕ блокируем цепочку (block=False в main.py)
ctx.user_data.get("await_dict_file"), if update.effective_chat.type != ChatType.PRIVATE or not ctx.user_data.get("await_dict_file"):
len(update.effective_message.text or ""))
if not ctx.user_data.get("await_dict_file"):
return return
txt = (update.effective_message.text or "").strip() txt = (update.effective_message.text or "").strip()
if not txt: if not txt:
return return
# Если похоже на «подпись» с параметрами — просто запомним и попросим файл # если текст похож на "параметры", просто запомним и попросим файл
if ("=" in txt) and (";" in txt) and (len(txt.split()) <= 6): if ("=" in txt) and (";" in txt) and (len(txt.split()) <= 6):
ctx.user_data["dict_params"] = txt ctx.user_data["dict_params"] = txt
await update.effective_message.reply_text("Параметры принял. Теперь пришлите .txt/.csv ФАЙЛОМ со словарём.") await update.effective_message.reply_text("Параметры принял. Теперь пришлите .txt/.csv ФАЙЛОМ со словарём.")
return return
# Иначе трактуем как словарь одной «пачкой»
# иначе считаем, что прислан словарь "в лоб"
lines = [l.strip() for l in txt.splitlines() if l.strip()] lines = [l.strip() for l in txt.splitlines() if l.strip()]
params = _parse_params(ctx.user_data.get("dict_params"), "inline_dict") params = _parse_params(ctx.user_data.get("dict_params"), "inline_dict")
try: 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) n = _import_entries(s, update.effective_user.id, params, lines)
ctx.user_data.pop("await_dict_file", None) ctx.user_data.pop("await_dict_file", None)
ctx.user_data.pop("dict_params", 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: except Exception as e:
await update.effective_message.reply_text(f"Ошибка импорта: {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))

View File

@@ -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 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.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.start import start, help_cmd, groups_cmd
from app.bot.handlers.add_group import add_group_cmd, add_group_capture 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.media import on_media
from app.bot.handlers.callbacks import on_callback from app.bot.handlers.callbacks import on_callback
from app.bot.handlers.join_info import on_my_chat_member 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.chat_id_cmd import chat_id_cmd
from app.bot.handlers.bind_chat import bind_chat_cb 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.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.errors import on_error
from app.bot.handlers.mod_status import mod_status_cmd from app.bot.handlers.cancel import cancel_cmd
from app.infra.metrics import start_metrics_server from app.bot.handlers.debug import dbg_state_cmd
from app.bot.handlers.security import dicts_cmd, dicts_cb
async def spam_import_redirect(update, ctx):
await update.effective_message.reply_text(
"Эту команду нужно выполнить в личке со мной. Откройте чат со мной и пришлите /spam_import."
)
def main(): def main():
cfg = load_config() cfg = load_config()
logging.basicConfig(level=getattr(logging, cfg.log_level.upper(), logging.INFO)) 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() app = ApplicationBuilder().token(cfg.bot_token).build()
# Commands # --- Commands ---
app.add_handler(CommandHandler("start", start)) app.add_handler(CommandHandler("start", start))
app.add_handler(CommandHandler("help", help_cmd)) app.add_handler(CommandHandler("help", help_cmd))
app.add_handler(CommandHandler("groups", groups_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("new", new_cmd))
app.add_handler(CommandHandler("id", chat_id_cmd)) app.add_handler(CommandHandler("id", chat_id_cmd))
app.add_handler(CommandHandler("mod_status", mod_status_cmd)) app.add_handler(CommandHandler("mod_status", mod_status_cmd))
# команды app.add_handler(CommandHandler("cancel", cancel_cmd))
app.add_handler(CommandHandler("dicts", dicts_cmd)) app.add_handler(CommandHandler("dbg_state", dbg_state_cmd))
# коллбэки словарей # --- Callbacks (от узких к широким) ---
app.add_handler(CallbackQueryHandler(dicts_cb, pattern=r"^dict:"))
# Callbacks (order matters!)
app.add_handler(CallbackQueryHandler(security_cb, pattern=r"^pol:")) 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(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.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.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)) 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)) 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_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(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.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)) app.add_handler(MessageHandler(filters.ChatType.GROUPS & ~filters.COMMAND, moderate_message))
# Draft editor (after import handlers) # --- Errors ---
app.add_handler(MessageHandler(filters.ChatType.PRIVATE & filters.TEXT & ~filters.COMMAND, on_text)) app.add_error_handler(on_error)
start_metrics_server(cfg.metrics_port)
app.run_polling(allowed_updates=None) app.run_polling(allowed_updates=None)
if __name__ == "__main__": if __name__ == "__main__":
main() main()