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

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

View File

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

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