Compare commits

..

2 Commits

Author SHA1 Message Date
00b00f5bf3 soome changes 2025-08-28 04:28:37 +09:00
8b554f5968 post management returned 2025-08-26 06:01:18 +09:00
13 changed files with 791 additions and 211 deletions

View File

@@ -7,15 +7,24 @@ from app.db.models import Draft, Chat, Delivery, User
from app.bot.keyboards.common import kb_multiselect # ← только мультивыбор from app.bot.keyboards.common import kb_multiselect # ← только мультивыбор
from app.bot.messages import NEED_MEDIA_BEFORE_NEXT, NO_SELECTION, SENT_SUMMARY from app.bot.messages import NEED_MEDIA_BEFORE_NEXT, NO_SELECTION, SENT_SUMMARY
from app.moderation.engine import check_message_allowed from app.moderation.engine import check_message_allowed
import logging
# ИМПОРТИРУЕМ КОНСТАНТЫ ИЗ drafts.py, чтобы не промахнуться с ключами
from app.bot.handlers.drafts import (
STATE_DRAFT, STATE_AWAIT_TEXT, STATE_CONFIRM,
KEY_DRAFT_ID,
)
log = logging.getLogger(__name__)
async def on_callback(update: Update, ctx: ContextTypes.DEFAULT_TYPE): async def on_callback(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
q = update.callback_query q = update.callback_query
await q.answer() await q.answer()
data = q.data data = q.data
log.debug("callbacks:on_callback data=%s", data)
# --- Переход с медиа на текст --- # --- Переход с медиа на текст ---
if data.startswith("draft_next_text:"): if data.startswith("draft_next_text:"):
draft_id = int(data.split(":")[1]) draft_id = int(data.split(":")[1])
with get_session() as s: with get_session() as s:
d = s.get(Draft, draft_id) d = s.get(Draft, draft_id)
if not d: if not d:
@@ -24,9 +33,23 @@ async def on_callback(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
if len(d.media) == 0: if len(d.media) == 0:
await q.edit_message_text(NEED_MEDIA_BEFORE_NEXT) await q.edit_message_text(NEED_MEDIA_BEFORE_NEXT)
return return
ctx.user_data["draft_id"] = draft_id
ctx.user_data["draft_state"] = "await_text" # Сброс «залипших» режимов (если были)
for k in ("await_dict_file", "dict_params", "await_chat_id"):
ctx.user_data.pop(k, None)
# ЯВНО ПРОСТАВЛЯЕМ draft_id и состояние await_text через общие константы
ctx.user_data[KEY_DRAFT_ID] = draft_id
ctx.user_data[STATE_DRAFT] = STATE_AWAIT_TEXT
log.info("callbacks:draft_next_text user=%s chat=%s -> draft_id=%s state=%s",
getattr(update.effective_user, "id", None),
getattr(update.effective_chat, "id", None),
draft_id, ctx.user_data.get(STATE_DRAFT))
await q.edit_message_text("Шаг 2/3 — текст.\nОтправьте текст поста.") await q.edit_message_text("Шаг 2/3 — текст.\nОтправьте текст поста.")
return
# --- Подтверждение -> мультивыбор чатов --- # --- Подтверждение -> мультивыбор чатов ---
elif data.startswith("draft_confirm_send:"): elif data.startswith("draft_confirm_send:"):
@@ -170,3 +193,6 @@ async def on_callback(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
ctx.user_data.pop("draft_id", None) ctx.user_data.pop("draft_id", None)
ctx.user_data.pop("draft_state", None) ctx.user_data.pop("draft_state", None)
await q.edit_message_text("Черновик отменён.") await q.edit_message_text("Черновик отменён.")
else:
await q.edit_message_text("Неизвестная команда.")
log.warning("callbacks:unhandled data=%s", data)

View File

@@ -0,0 +1,11 @@
from telegram import Update
from telegram.ext import ContextTypes
# те же ключи, что используются в редакторе/импорте/привязке
DRAFT_KEYS = ("draft_state", "draft_id")
OTHER_KEYS = ("await_dict_file", "dict_params", "await_chat_id")
async def cancel_cmd(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
for k in DRAFT_KEYS + OTHER_KEYS:
ctx.user_data.pop(k, None)
await update.effective_message.reply_text("Состояние сброшено. Можно начать заново: /new")

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

@@ -0,0 +1,13 @@
from telegram import Update
from telegram.ext import ContextTypes
from app.bot.handlers.drafts import STATE_DRAFT, KEY_DRAFT_ID
async def dbg_state_cmd(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
ud = ctx.user_data
s = (
f"draft_id={ud.get(KEY_DRAFT_ID)!r}, "
f"state={ud.get(STATE_DRAFT)!r}, "
f"await_dict={bool(ud.get('await_dict_file'))}, "
f"await_chat_id={bool(ud.get('await_chat_id'))}"
)
await update.effective_message.reply_text("user_data: " + s)

View File

@@ -0,0 +1,249 @@
# app/bot/handlers/dict_commands.py
from telegram import Update
from telegram.constants import ChatType
from telegram.ext import ContextTypes
from sqlalchemy import func
from app.db.session import get_session
from app.db.models import (
User, SecurityPolicy, ChatSecurity,
SpamDictionary, PolicyDictionaryLink, DictionaryEntry,
)
ALLOWED_CATEGORIES = {"spam", "scam", "adult", "profanity", "custom"}
def _get_or_create_policy(session, owner_tg_id: int) -> SecurityPolicy:
u = session.query(User).filter_by(tg_id=owner_tg_id).first()
if not u:
u = User(tg_id=owner_tg_id, name="")
session.add(u); session.commit(); session.refresh(u)
p = session.query(SecurityPolicy).filter_by(owner_user_id=u.id, name="Balanced").first()
if p:
return p
p = SecurityPolicy(owner_user_id=u.id, name="Balanced")
session.add(p); session.commit(); session.refresh(p)
return p
async def _is_group_admin(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> bool:
"""В группе/канале разрешаем только администраторам."""
chat = update.effective_chat
if chat.type in (ChatType.GROUP, ChatType.SUPERGROUP, ChatType.CHANNEL):
try:
m = await ctx.bot.get_chat_member(chat.id, update.effective_user.id)
return m.status in ("administrator", "creator")
except Exception:
return False
return True
def _resolve_policy_and_owner(session, update: Update) -> tuple[SecurityPolicy, User, str]:
"""
В группе/канале: берём политику, привязанную к чату.
В ЛС: дефолтную политику Balanced владельца.
"""
chat = update.effective_chat
user = session.query(User).filter_by(tg_id=update.effective_user.id).first()
if not user:
user = User(tg_id=update.effective_user.id, name=update.effective_user.full_name or "")
session.add(user); session.commit(); session.refresh(user)
if chat.type in (ChatType.GROUP, ChatType.SUPERGROUP, ChatType.CHANNEL):
cs = session.query(ChatSecurity).filter_by(chat_id=chat.id).first()
if not cs:
raise RuntimeError("not_bound")
p = session.get(SecurityPolicy, cs.policy_id)
if not p:
raise RuntimeError("policy_missing")
return p, user, f"политика чата «{chat.title or chat.id}»"
else:
p = _get_or_create_policy(session, update.effective_user.id)
return p, user, "ваша политика Balanced (ЛС)"
def _find_dict(session, owner: User, token: str) -> SpamDictionary | None:
"""token — numeric id или точное имя словаря владельца (без глобальных)."""
token = (token or "").strip()
if not token:
return None
if token.isdigit():
d = session.get(SpamDictionary, int(token))
return d if d and d.owner_user_id == owner.id else None
return (
session.query(SpamDictionary)
.filter(SpamDictionary.owner_user_id == owner.id, SpamDictionary.name == token)
.first()
)
# ===================== Команды =====================
async def dicts_link_cmd(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
if not await _is_group_admin(update, ctx):
return
args = ctx.args or []
if not args:
await update.effective_message.reply_text("Использование: /dicts_link <id|имя_словаря>")
return
token = " ".join(args).strip()
with get_session() as s:
try:
p, owner, scope = _resolve_policy_and_owner(s, update)
except RuntimeError as e:
if str(e) == "not_bound":
await update.effective_message.reply_text("Политика не привязана. В группе откройте /security → «Привязать к этому чату».")
return
await update.effective_message.reply_text("Политика недоступна. Привяжите её через /security.")
return
d = _find_dict(s, owner, token)
if not d:
await update.effective_message.reply_text(f"Словарь не найден среди ваших: «{token}». Проверьте id/имя (см. /dicts).")
return
link = s.query(PolicyDictionaryLink).filter_by(policy_id=p.id, dictionary_id=d.id).first()
if link:
await update.effective_message.reply_text(f"Уже привязан: «{d.name}» ({scope}).")
return
s.add(PolicyDictionaryLink(policy_id=p.id, dictionary_id=d.id)); s.commit();
from app.moderation.engine import dict_cache
dict_cache.invalidate(p.id)
rules = s.query(DictionaryEntry).filter_by(dictionary_id=d.id).count()
await update.effective_message.reply_text(f"Привязал «{d.name}» ({d.category}/{d.kind}, правил: {rules}) к {scope}.")
async def dicts_unlink_cmd(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
if not await _is_group_admin(update, ctx):
return
args = ctx.args or []
if not args:
await update.effective_message.reply_text("Использование: /dicts_unlink <id|имя_словаря>")
return
token = " ".join(args).strip()
with get_session() as s:
try:
p, owner, scope = _resolve_policy_and_owner(s, update)
except RuntimeError:
await update.effective_message.reply_text("Политика не привязана. В группе откройте /security → «Привязать к этому чату».")
return
d = _find_dict(s, owner, token)
if not d:
await update.effective_message.reply_text(f"Словарь не найден: «{token}».")
return
link = s.query(PolicyDictionaryLink).filter_by(policy_id=p.id, dictionary_id=d.id).first()
if not link:
await update.effective_message.reply_text(f"Словарь «{d.name}» и так не привязан к {scope}.")
return
s.delete(link); s.commit()
from app.moderation.engine import dict_cache
dict_cache.invalidate(p.id)
await update.effective_message.reply_text(f"Отвязал «{d.name}» от {scope}.")
async def dicts_link_all_cmd(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
if not await _is_group_admin(update, ctx):
return
args = ctx.args or []
cat = (args[0].lower() if args else None)
if cat and cat not in ALLOWED_CATEGORIES:
await update.effective_message.reply_text(f"Категория неизвестна. Используйте: {', '.join(sorted(ALLOWED_CATEGORIES))} (или без аргумента).")
return
with get_session() as s:
try:
p, owner, scope = _resolve_policy_and_owner(s, update)
except RuntimeError:
await update.effective_message.reply_text("Политика не привязана. В группе откройте /security → «Привязать к этому чату».")
return
q = s.query(SpamDictionary).filter_by(owner_user_id=owner.id)
if cat:
q = q.filter(SpamDictionary.category == cat)
ds = q.all()
if not ds:
await update.effective_message.reply_text("У вас нет словарей для привязки (см. /spam_import).")
return
existing = {x.dictionary_id for x in s.query(PolicyDictionaryLink).filter_by(policy_id=p.id).all()}
added = 0
for d in ds:
if d.id in existing:
continue
s.add(PolicyDictionaryLink(policy_id=p.id, dictionary_id=d.id)); added += 1
s.commit()
from app.moderation.engine import dict_cache
dict_cache.invalidate(p.id)
await update.effective_message.reply_text(f"Привязал {added} словарей к {scope}." + (f" (категория: {cat})" if cat else ""))
async def dicts_unlink_all_cmd(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
if not await _is_group_admin(update, ctx):
return
args = ctx.args or []
cat = (args[0].lower() if args else None)
if cat and cat not in ALLOWED_CATEGORIES:
await update.effective_message.reply_text(f"Категория неизвестна. Используйте: {', '.join(sorted(ALLOWED_CATEGORIES))} (или без аргумента).")
return
with get_session() as s:
try:
p, owner, scope = _resolve_policy_and_owner(s, update)
except RuntimeError:
await update.effective_message.reply_text("Политика не привязана. В группе откройте /security → «Привязать к этому чату».")
return
q = (
s.query(SpamDictionary)
.join(PolicyDictionaryLink, PolicyDictionaryLink.dictionary_id == SpamDictionary.id)
.filter(PolicyDictionaryLink.policy_id == p.id, SpamDictionary.owner_user_id == owner.id)
)
if cat:
q = q.filter(SpamDictionary.category == cat)
ds = q.all()
if not ds:
await update.effective_message.reply_text("Нет привязанных словарей для отвязки.")
return
deleted = 0
for d in ds:
link = s.query(PolicyDictionaryLink).filter_by(policy_id=p.id, dictionary_id=d.id).first()
if link:
s.delete(link); deleted += 1
s.commit()
from app.moderation.engine import dict_cache
dict_cache.invalidate(p.id)
await update.effective_message.reply_text(f"Отвязал {deleted} словарей от {scope}." + (f" (категория: {cat})" if cat else ""))
async def dicts_linked_cmd(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
if not await _is_group_admin(update, ctx):
return
with get_session() as s:
try:
p, owner, scope = _resolve_policy_and_owner(s, update)
except RuntimeError:
await update.effective_message.reply_text("Политика не привязана. В группе откройте /security → «Привязать к этому чату».")
return
links = s.query(PolicyDictionaryLink).filter_by(policy_id=p.id).all()
if not links:
await update.effective_message.reply_text(f"К {scope} пока ничего не привязано.")
return
d_ids = [lnk.dictionary_id for lnk in links]
dicts = s.query(SpamDictionary).filter(SpamDictionary.id.in_(d_ids)).all()
counts = {
d.id: s.query(func.count(DictionaryEntry.id)).filter(DictionaryEntry.dictionary_id == d.id).scalar() or 0
for d in dicts
}
lines = [f"Привязано к {scope}:"]
for d in dicts:
lines.append(f"• [{d.id}] {d.name} ({d.category}/{d.kind}) — правил: {counts.get(d.id, 0)}")
await update.effective_message.reply_text("\n".join(lines))

View File

@@ -1,73 +1,143 @@
# app/bot/handlers/drafts.py
from datetime import datetime from datetime import datetime
from html import escape as html_escape
import logging
from telegram import Update, InputMediaPhoto, InputMediaVideo, InputMediaAnimation from telegram import Update, InputMediaPhoto, InputMediaVideo, InputMediaAnimation
from telegram.constants import ChatType, ParseMode from telegram.constants import ChatType, ParseMode
from telegram.ext import ContextTypes from telegram.ext import ContextTypes
from app.db.session import get_session from app.db.session import get_session
from app.db.models import User, Draft from app.db.models import User, Draft
from app.bot.messages import ASK_MEDIA, ASK_TEXT, CONFIRM, NEED_START_NEW from app.bot.messages import ASK_MEDIA, CONFIRM, NEED_START_NEW
from app.bot.keyboards.common import kb_next_text, kb_confirm from app.bot.keyboards.common import kb_confirm
from .add_group import add_group_capture, STATE_KEY from app.bot.handlers.add_group import add_group_capture, STATE_KEY # "await_chat_id"
log = logging.getLogger(__name__)
# Состояния редактора в user_data
STATE_DRAFT = "draft_state" STATE_DRAFT = "draft_state"
KEY_DRAFT_ID = "draft_id" KEY_DRAFT_ID = "draft_id"
STATE_AWAIT_MEDIA = "await_media" STATE_AWAIT_MEDIA = "await_media"
STATE_AWAIT_TEXT = "await_text" STATE_AWAIT_TEXT = "await_text"
STATE_CONFIRM = "confirm" STATE_CONFIRM = "confirm"
def _ud_state(ctx: ContextTypes.DEFAULT_TYPE) -> str:
"""Короткая сводка по user_data для логов."""
ud = ctx.user_data
return (
f"ud(draft_id={ud.get(KEY_DRAFT_ID)}, state={ud.get(STATE_DRAFT)}, "
f"await_dict={bool(ud.get('await_dict_file'))}, await_chat_id={bool(ud.get(STATE_KEY))})"
)
def _start_new_draft(tg_id: int) -> Draft: def _start_new_draft(tg_id: int) -> Draft:
"""Создать новый черновик и пометить предыдущие editing как cancelled."""
with get_session() as s: with get_session() as s:
u = s.query(User).filter_by(tg_id=tg_id).first() u = s.query(User).filter_by(tg_id=tg_id).first()
if not u: if not u:
u = User(tg_id=tg_id, name=""); s.add(u); s.commit(); s.refresh(u) u = User(tg_id=tg_id, name="")
s.query(Draft).filter(Draft.user_id == u.id, Draft.status == "editing").update({"status": "cancelled"}) s.add(u); s.commit(); s.refresh(u)
# Закрываем старые "editing"
s.query(Draft).filter(
Draft.user_id == u.id, Draft.status == "editing"
).update({"status": "cancelled"})
# Создаём новый
d = Draft(user_id=u.id, status="editing") d = Draft(user_id=u.id, status="editing")
s.add(d); s.commit(); s.refresh(d) s.add(d); s.commit(); s.refresh(d)
return d return d
async def new_cmd(update: Update, ctx: ContextTypes.DEFAULT_TYPE): async def new_cmd(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
d = _start_new_draft(update.effective_user.id) """Старт редактора: сначала медиа, потом текст, потом подтверждение."""
uid = getattr(update.effective_user, "id", None)
cid = getattr(update.effective_chat, "id", None)
log.info("drafts:new_cmd start user=%s chat=%s %s", uid, cid, _ud_state(ctx))
# ЖЁСТКИЙ СБРОС конфликтующих режимов
for k in ("await_dict_file", "dict_params", STATE_KEY):
if ctx.user_data.pop(k, None) is not None:
log.debug("drafts:new_cmd cleared flag=%s user=%s chat=%s", k, uid, cid)
d = _start_new_draft(uid)
ctx.user_data[KEY_DRAFT_ID] = d.id ctx.user_data[KEY_DRAFT_ID] = d.id
ctx.user_data[STATE_DRAFT] = STATE_AWAIT_MEDIA ctx.user_data[STATE_DRAFT] = STATE_AWAIT_MEDIA
# Кнопку «Дальше — текст» теперь показываем после добавления медиа, log.info("drafts:new_cmd created draft_id=%s -> state=%s user=%s chat=%s",
# поэтому здесь — только инструкция d.id, STATE_AWAIT_MEDIA, uid, cid)
# Кнопку «Дальше — текст» показываем под сообщением «Медиа добавлено» (см. on_media).
await update.effective_message.reply_text(ASK_MEDIA) await update.effective_message.reply_text(ASK_MEDIA)
async def on_text(update: Update, ctx: ContextTypes.DEFAULT_TYPE): async def on_text(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
# Если ждём chat_id для /add_group — передаём управление """
Обработка текста на шаге 2/3.
"""
uid = getattr(update.effective_user, "id", None)
cid = getattr(update.effective_chat, "id", None)
msg_text = (update.effective_message.text or "")
log.info("drafts:on_text received user=%s chat=%s len=%s %s",
uid, cid, len(msg_text), _ud_state(ctx))
# Не мешаем /spam_import (ждём файл/текст словаря)
if ctx.user_data.get("await_dict_file"): if ctx.user_data.get("await_dict_file"):
log.info("drafts:on_text blocked_by_import user=%s chat=%s", uid, cid)
await update.effective_message.reply_text(
"Сейчас активен импорт словаря. Пришлите .txt/.csv как документ "
"или введите /cancel (или /new) для выхода из импорта."
)
return return
# В процессе /add_group — делегируем туда
if ctx.user_data.get(STATE_KEY): if ctx.user_data.get(STATE_KEY):
log.info("drafts:on_text delegated_to_add_group user=%s chat=%s", uid, cid)
return await add_group_capture(update, ctx) return await add_group_capture(update, ctx)
if update.effective_chat.type != ChatType.PRIVATE: if update.effective_chat.type != ChatType.PRIVATE:
log.debug("drafts:on_text ignored_non_private user=%s chat=%s", uid, cid)
return return
draft_id = ctx.user_data.get(KEY_DRAFT_ID) draft_id = ctx.user_data.get(KEY_DRAFT_ID)
state = ctx.user_data.get(STATE_DRAFT) state = ctx.user_data.get(STATE_DRAFT)
if not draft_id or not state: if not draft_id or not state:
log.warning("drafts:on_text no_draft_or_state user=%s chat=%s %s", uid, cid, _ud_state(ctx))
await update.effective_message.reply_text(NEED_START_NEW) await update.effective_message.reply_text(NEED_START_NEW)
return return
if state == STATE_AWAIT_MEDIA: if state == STATE_AWAIT_MEDIA:
await update.effective_message.reply_text("Сначала добавьте медиа и нажмите «Дальше — текст».") log.info("drafts:on_text wrong_state_await_media user=%s chat=%s draft=%s", uid, cid, draft_id)
await update.effective_message.reply_text(
"Сначала добавьте медиа и нажмите «Дальше — текст» (кнопка под сообщением «Медиа добавлено»)."
)
return return
if state == STATE_CONFIRM: if state == STATE_CONFIRM:
await update.effective_message.reply_text("Пост уже готов — нажмите «Отправить» или «Отменить».") log.info("drafts:on_text already_confirm user=%s chat=%s draft=%s", uid, cid, draft_id)
await update.effective_message.reply_text(
"Пост уже готов — нажмите «Отправить» или «Отменить»."
)
return return
if state == STATE_AWAIT_TEXT: if state != STATE_AWAIT_TEXT:
# Сохраняем текст log.debug("drafts:on_text unknown_state user=%s chat=%s state=%s draft=%s",
with get_session() as s: uid, cid, state, draft_id)
d = s.get(Draft, draft_id) return
d.text = update.effective_message.text_html_urled
d.updated_at = datetime.utcnow()
s.commit()
media = sorted(d.media, key=lambda m: m.order) # ШАГ 2/3 — сохраняем текст
safe_html = html_escape(msg_text)
with get_session() as s:
d = s.get(Draft, draft_id)
before_len = len(d.text or "") if d and d.text else 0
d.text = safe_html
d.updated_at = datetime.utcnow()
s.commit()
media = sorted(d.media, key=lambda m: m.order)
log.info("drafts:on_text saved_text user=%s chat=%s draft=%s old_len=%s new_len=%s media_count=%s",
uid, cid, draft_id, before_len, len(safe_html), len(media))
# Предпросмотр # Предпросмотр
try:
if media: if media:
if len(media) > 1: if len(media) > 1:
im = [] im = []
@@ -80,6 +150,8 @@ async def on_text(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
else: else:
im.append(InputMediaAnimation(media=m.file_id, caption=cap, parse_mode=ParseMode.HTML)) im.append(InputMediaAnimation(media=m.file_id, caption=cap, parse_mode=ParseMode.HTML))
await update.effective_chat.send_media_group(media=im) await update.effective_chat.send_media_group(media=im)
log.info("drafts:on_text preview_album user=%s chat=%s draft=%s parts=%s",
uid, cid, draft_id, len(im))
else: else:
m = media[0] m = media[0]
if m.kind == "photo": if m.kind == "photo":
@@ -88,10 +160,36 @@ async def on_text(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
await update.effective_chat.send_video(video=m.file_id, caption=d.text, parse_mode=ParseMode.HTML) await update.effective_chat.send_video(video=m.file_id, caption=d.text, parse_mode=ParseMode.HTML)
else: else:
await update.effective_chat.send_animation(animation=m.file_id, caption=d.text, parse_mode=ParseMode.HTML) await update.effective_chat.send_animation(animation=m.file_id, caption=d.text, parse_mode=ParseMode.HTML)
log.info("drafts:on_text preview_single user=%s chat=%s draft=%s kind=%s",
uid, cid, draft_id, m.kind)
else: else:
await update.effective_chat.send_message(text=d.text or "(пусто)", parse_mode=ParseMode.HTML) await update.effective_chat.send_message(text=d.text or "(пусто)", parse_mode=ParseMode.HTML)
log.info("drafts:on_text preview_text user=%s chat=%s draft=%s", uid, cid, draft_id)
except Exception as e:
log.exception("drafts:on_text preview_error user=%s chat=%s draft=%s err=%s",
uid, cid, draft_id, e)
# Переходим к подтверждению и показываем кнопки # Переход к подтверждению
ctx.user_data[STATE_DRAFT] = STATE_CONFIRM ctx.user_data[STATE_DRAFT] = STATE_CONFIRM
await update.effective_message.reply_text(CONFIRM, reply_markup=kb_confirm(draft_id)) log.info("drafts:on_text -> state=%s user=%s chat=%s draft=%s",
STATE_CONFIRM, uid, cid, draft_id)
await update.effective_message.reply_text(
"Шаг 3/3 — подтверждение.\nПроверьте пост и нажмите «Отправить» или «Отменить».",
reply_markup=kb_confirm(draft_id),
)
async def on_text_gate(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
if update.effective_chat.type != ChatType.PRIVATE:
return
if ctx.user_data.get("await_dict_file"):
return
if ctx.user_data.get(STATE_DRAFT) == STATE_AWAIT_TEXT:
log.info(
"drafts:on_text_gate capture user=%s chat=%s state=%s draft_id=%s",
getattr(update.effective_user, "id", None),
getattr(update.effective_chat, "id", None),
ctx.user_data.get(STATE_DRAFT),
ctx.user_data.get(KEY_DRAFT_ID),
)
return await on_text(update, ctx)

View File

@@ -1,52 +1,64 @@
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 from app.moderation.engine import dict_cache
# ---------- 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 +66,223 @@ 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()
dict_cache.invalidate(cs.policy_id)
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(); dict_cache.invalidate(policy_id)
else:
s.add(PolicyDictionaryLink(policy_id=policy_id, dictionary_id=dict_id)); s.commit(); dict_cache.invalidate(policy_id)
u = s.query(User).filter_by(tg_id=update.effective_user.id).first()
dicts = s.query(SpamDictionary).filter_by(owner_user_id=u.id).order_by(SpamDictionary.created_at.desc()).all()
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 +290,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 +316,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 +338,8 @@ async def spam_import_text_capture(update: Update, ctx: ContextTypes.DEFAULT_TYP
n = _import_entries(s, update.effective_user.id, params, lines) n = _import_entries(s, update.effective_user.id, params, lines)
ctx.user_data.pop("await_dict_file", None) ctx.user_data.pop("await_dict_file", None)
ctx.user_data.pop("dict_params", None) ctx.user_data.pop("dict_params", None)
await update.effective_message.reply_text(f"Импортировано {n} записей (из текста) в словарь «{params['name']}».") await update.effective_message.reply_text(
f"Импортировано {n} записей (из текста) в словарь «{params['name']}»."
)
except Exception as e: except Exception as e:
await update.effective_message.reply_text(f"Ошибка импорта: {e}") await update.effective_message.reply_text(f"Ошибка импорта: {e}")
def _kb_dicts(policy_id: int, rows: list[tuple[int,str,bool]]):
# rows: [(dict_id, "Имя (категория/kind)", is_linked)]
kbd = []
for did, title, linked in rows:
mark = "" if linked else "▫️"
kbd.append([InlineKeyboardButton(f"{mark} {title}", callback_data=f"dict:toggle:{policy_id}:{did}")])
return InlineKeyboardMarkup(kbd) if kbd else None
async def dicts_cmd(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
chat = update.effective_chat
with get_session() as s:
# Выбираем политику: в группе — привязанную к чату, иначе — дефолт владельца
if chat.type in (ChatType.GROUP, ChatType.SUPERGROUP, ChatType.CHANNEL):
cs = s.query(ChatSecurity).filter_by(chat_id=chat.id).first()
if not cs:
await update.effective_message.reply_text("Политика не привязана. Откройте /security и привяжите к чату.")
return
p = s.get(SecurityPolicy, cs.policy_id)
else:
p = _get_or_create_policy(s, update.effective_user.id)
# словари владельца
u = s.query(User).filter_by(tg_id=update.effective_user.id).first()
dicts = s.query(SpamDictionary).filter_by(owner_user_id=u.id).order_by(SpamDictionary.created_at.desc()).all()
linked = {x.dictionary_id for x in s.query(PolicyDictionaryLink).filter_by(policy_id=p.id).all()}
rows = []
for d in dicts[:50]: # первые 50
title = f"{d.name} ({d.category}/{d.kind})"
rows.append((d.id, title, d.id in linked))
kb = _kb_dicts(p.id, rows)
if not rows:
await update.effective_message.reply_text("У вас пока нет словарей. Импортируйте через /spam_import.")
return
await update.effective_message.reply_text(f"Словари для политики «{p.name}» (нажмите, чтобы прикрепить/открепить):", reply_markup=kb)
async def dicts_cb(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
q = update.callback_query; await q.answer()
data = q.data.split(":")
if len(data) != 4 or data[0] != "dict" or data[1] != "toggle":
return
policy_id = int(data[2]); dict_id = int(data[3])
with get_session() as s:
p = s.get(SecurityPolicy, policy_id)
if not p:
await q.edit_message_text("Политика не найдена.")
return
link = s.query(PolicyDictionaryLink).filter_by(policy_id=policy_id, dictionary_id=dict_id).first()
if link:
s.delete(link); s.commit()
else:
s.add(PolicyDictionaryLink(policy_id=policy_id, dictionary_id=dict_id)); s.commit()
# перерисовать список
u = s.query(User).filter_by(tg_id=update.effective_user.id).first()
dicts = s.query(SpamDictionary).filter_by(owner_user_id=u.id).order_by(SpamDictionary.created_at.desc()).all()
linked = {x.dictionary_id for x in s.query(PolicyDictionaryLink).filter_by(policy_id=p.id).all()}
rows = []
for d in dicts[:50]:
title = f"{d.name} ({d.category}/{d.kind})"
rows.append((d.id, title, d.id in linked))
await q.edit_message_reply_markup(reply_markup=_kb_dicts(p.id, rows))

View File

@@ -4,10 +4,10 @@ from app.db.models import SecurityPolicy
def kb_policy(p: SecurityPolicy, chat_bound: bool = False, enabled: bool = False): def kb_policy(p: SecurityPolicy, chat_bound: bool = False, enabled: bool = False):
def onoff(b): return "" if b else "" def onoff(b): return "" if b else ""
rows = [ rows = [
[InlineKeyboardButton(f"Adult {onoff(p.block_adult)}", callback_data=f"pol:toggle:block_adult:{p.id}"), [InlineKeyboardButton(f"18+ {onoff(p.block_adult)}", callback_data=f"pol:toggle:block_adult:{p.id}"),
InlineKeyboardButton(f"Spam {onoff(p.block_spam)}", callback_data=f"pol:toggle:block_spam:{p.id}")], InlineKeyboardButton(f"Спам {onoff(p.block_spam)}", callback_data=f"pol:toggle:block_spam:{p.id}")],
[InlineKeyboardButton(f"Scam {onoff(p.block_scam)}", callback_data=f"pol:toggle:block_scam:{p.id}"), [InlineKeyboardButton(f"Скам {onoff(p.block_scam)}", callback_data=f"pol:toggle:block_scam:{p.id}"),
InlineKeyboardButton(f"Profanity {onoff(p.block_profanity)}", callback_data=f"pol:toggle:block_profanity:{p.id}")], InlineKeyboardButton(f"Брань {onoff(p.block_profanity)}", callback_data=f"pol:toggle:block_profanity:{p.id}")],
[InlineKeyboardButton(f"Cooldown {p.cooldown_seconds}s (-5)", callback_data=f"pol:adj:cooldown_seconds:-5:{p.id}"), [InlineKeyboardButton(f"Cooldown {p.cooldown_seconds}s (-5)", callback_data=f"pol:adj:cooldown_seconds:-5:{p.id}"),
InlineKeyboardButton("(+5)", callback_data=f"pol:adj:cooldown_seconds:+5:{p.id}")], InlineKeyboardButton("(+5)", callback_data=f"pol:adj:cooldown_seconds:+5:{p.id}")],
[InlineKeyboardButton(f"Dupe {p.duplicate_window_seconds}s (-30)", callback_data=f"pol:adj:duplicate_window_seconds:-30:{p.id}"), [InlineKeyboardButton(f"Dupe {p.duplicate_window_seconds}s (-30)", callback_data=f"pol:adj:duplicate_window_seconds:-30:{p.id}"),

View File

View File

@@ -0,0 +1,18 @@
# app/bot/routers/channel.py
from telegram.ext import CommandHandler, filters
async def channel_redirect(update, ctx):
try:
await update.effective_message.reply_text(
"⚠️ В канале команды не поддерживаются. "
"Откройте ЛС со мной и выполните:\n"
"• /bind @username (или -100…)\n"
"• или /add_group и перешлите сюда пост из канала.",
disable_web_page_preview=True
)
except Exception:
pass
def register_channel_handlers(app):
# В канале любые «команды настроек» переводим в ЛС
app.add_handler(CommandHandler(["start","settings","security","dicts","spam_import"], channel_redirect, filters.ChatType.CHANNEL))

28
app/bot/routers/group.py Normal file
View File

@@ -0,0 +1,28 @@
# app/bot/routers/group.py
from telegram.ext import CommandHandler, MessageHandler, ChatMemberHandler, filters
from app.bot.handlers.join_info import on_my_chat_member
from app.bot.handlers.moderation import moderate_message
from app.bot.handlers.security import security_cmd, dicts_cmd
from app.bot.handlers.mod_status import mod_status_cmd
from app.bot.handlers.chat_id_cmd import chat_id_cmd
async def spam_import_redirect(update, ctx):
await update.effective_message.reply_text(
"Импорт словаря выполняется в ЛС. Откройте чат со мной и пришлите /spam_import.",
disable_web_page_preview=True
)
def register_group_handlers(app):
# Команды в группах/супергруппах
app.add_handler(CommandHandler("security", security_cmd, filters.ChatType.GROUPS))
app.add_handler(CommandHandler("dicts", dicts_cmd, filters.ChatType.GROUPS))
app.add_handler(CommandHandler("mod_status", mod_status_cmd, filters.ChatType.GROUPS))
app.add_handler(CommandHandler("id", chat_id_cmd, filters.ChatType.GROUPS))
# /spam_import — редирект в ЛС
app.add_handler(CommandHandler("spam_import", spam_import_redirect, filters.ChatType.GROUPS))
# Модерация всех сообщений (privacy mode должен быть Disabled)
app.add_handler(MessageHandler(filters.ChatType.GROUPS & ~filters.COMMAND, moderate_message))
# my_chat_member для уведомлений о добавлении бота
app.add_handler(ChatMemberHandler(on_my_chat_member, chat_member_types=ChatMemberHandler.MY_CHAT_MEMBER))

View File

@@ -0,0 +1,40 @@
# app/bot/routers/private.py
from telegram.ext import CommandHandler, MessageHandler, ChatMemberHandler, filters
from app.bot.handlers.start import start, help_cmd, groups_cmd
from app.bot.handlers.add_group import add_group_cmd, add_group_capture
from app.bot.handlers.drafts import new_cmd, on_text, on_text_gate
from app.bot.handlers.media import on_media
from app.bot.handlers.chat_id_cmd import chat_id_cmd
from app.bot.handlers.security import (
security_cmd, spam_import_cmd, spam_import_capture, spam_import_text_capture, dicts_cmd
)
from app.bot.handlers.cancel import cancel_cmd
from app.bot.handlers.debug import dbg_state_cmd
def register_private_handlers(app):
# Команды в ЛС
app.add_handler(CommandHandler("start", start, filters.ChatType.PRIVATE))
app.add_handler(CommandHandler("help", help_cmd, filters.ChatType.PRIVATE))
app.add_handler(CommandHandler("groups", groups_cmd, filters.ChatType.PRIVATE))
app.add_handler(CommandHandler("add_group", add_group_cmd, filters.ChatType.PRIVATE))
app.add_handler(CommandHandler("new", new_cmd, filters.ChatType.PRIVATE))
app.add_handler(CommandHandler("id", chat_id_cmd, filters.ChatType.PRIVATE))
app.add_handler(CommandHandler("security", security_cmd, filters.ChatType.PRIVATE))
app.add_handler(CommandHandler("dicts", dicts_cmd, filters.ChatType.PRIVATE))
app.add_handler(CommandHandler("spam_import", spam_import_cmd, filters.ChatType.PRIVATE))
app.add_handler(CommandHandler("cancel", cancel_cmd, filters.ChatType.PRIVATE))
app.add_handler(CommandHandler("dbg_state", dbg_state_cmd, filters.ChatType.PRIVATE))
# Импорт словаря — файл
app.add_handler(MessageHandler(filters.ChatType.PRIVATE & filters.Document.ALL, spam_import_capture))
# Импорт словаря — текст (не блокирует цепочку)
app.add_handler(MessageHandler(filters.ChatType.PRIVATE & filters.TEXT & ~filters.COMMAND, spam_import_text_capture, block=False))
# Привязка чата пересланным сообщением
app.add_handler(MessageHandler(filters.ChatType.PRIVATE & filters.FORWARDED, add_group_capture))
# Медиа для черновика
app.add_handler(MessageHandler(filters.ChatType.PRIVATE & (filters.PHOTO | filters.VIDEO | filters.ANIMATION), on_media))
# Приватный текст: шлюз (высший приоритет) → редактор
app.add_handler(MessageHandler(filters.ChatType.PRIVATE & filters.TEXT & ~filters.COMMAND, on_text_gate, block=True))
app.add_handler(MessageHandler(filters.ChatType.PRIVATE & filters.TEXT & ~filters.COMMAND, on_text))

View File

@@ -1,72 +1,71 @@
# app/main.py
import logging import logging
from telegram.ext import ApplicationBuilder, CommandHandler, MessageHandler, CallbackQueryHandler, ChatMemberHandler, filters from telegram.ext import ApplicationBuilder
from app.config import load_config from app.config import load_config
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
from app.bot.handlers.moderation import moderate_message
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.infra.metrics import start_metrics_server
from app.bot.handlers.security import dicts_cmd, dicts_cb
# глобальные колбэки (узкие паттерны) и ошибки — держим в main
from app.bot.handlers.callbacks import on_callback
from app.bot.handlers.bind_chat import bind_chat_cb
from app.bot.handlers.security import security_cb
from app.bot.handlers.dict_commands import (
dicts_link_cmd, dicts_unlink_cmd,
dicts_link_all_cmd, dicts_unlink_all_cmd,
dicts_linked_cmd,
)
from app.bot.handlers.errors import on_error
# роутеры
from app.bot.routers.private import register_private_handlers
from app.bot.routers.group import register_group_handlers
from app.bot.routers.channel import register_channel_handlers
def main(): 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 # === РОУТЕРЫ по типам чатов ===
app.add_handler(CommandHandler("start", start)) register_private_handlers(app) # ЛС
app.add_handler(CommandHandler("help", help_cmd)) register_group_handlers(app) # группы/супергруппы
app.add_handler(CommandHandler("groups", groups_cmd)) register_channel_handlers(app) # каналы
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("dicts", dicts_cmd))
# коллбэки словарей # === УЗКИЕ callback-и, общий для редактора ===
app.add_handler(CallbackQueryHandler(dicts_cb, pattern=r"^dict:")) from telegram.ext import CallbackQueryHandler
# 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(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:)")) # редактор/мультивыбор (включаем все draft_* и тумблеры)
app.add_handler(CallbackQueryHandler(
on_callback,
pattern=r"^(draft_.*|tgl:|selall:|clear:|sendmulti:)"
))
# Private chat helpers # (опционально) текстовые команды управления словарями (и в ЛС, и в группе)
app.add_handler(MessageHandler(filters.ChatType.PRIVATE & filters.FORWARDED, add_group_capture)) from telegram.ext import CommandHandler
app.add_handler(MessageHandler(filters.ChatType.PRIVATE & (filters.PHOTO | filters.VIDEO | filters.ANIMATION), on_media)) app.add_handler(CommandHandler("dicts_link", dicts_link_cmd))
app.add_handler(CommandHandler("dicts_unlink", dicts_unlink_cmd))
app.add_handler(CommandHandler("dicts_link_all", dicts_link_all_cmd))
app.add_handler(CommandHandler("dicts_unlink_all", dicts_unlink_all_cmd))
app.add_handler(CommandHandler("dicts_linked", dicts_linked_cmd))
# Join/rights updates # ошибки
app.add_handler(ChatMemberHandler(on_my_chat_member, chat_member_types=ChatMemberHandler.MY_CHAT_MEMBER)) app.add_error_handler(on_error)
# Security / Dict
app.add_handler(CommandHandler("security", security_cmd))
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
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)
app.run_polling(allowed_updates=None) app.run_polling(allowed_updates=None)
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View File

@@ -5,4 +5,4 @@ global:
scrape_configs: scrape_configs:
- job_name: "tg_bot" - job_name: "tg_bot"
static_configs: static_configs:
- targets: ["bot:9100"] # бот слушает метрики на 9100 внутри сети compose - targets: ["bot:9100"]