Compare commits
2 Commits
c16ec54891
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 430101eb25 | |||
| 8b554f5968 |
@@ -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)
|
||||||
11
app/bot/handlers/cancel.py
Normal file
11
app/bot/handlers/cancel.py
Normal 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
13
app/bot/handlers/debug.py
Normal 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)
|
||||||
@@ -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)
|
||||||
@@ -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))
|
|
||||||
227
app/main.py
227
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
|
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()
|
||||||
|
|||||||
Reference in New Issue
Block a user