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

View File

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

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

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

View File

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

View File

@@ -1,52 +1,64 @@
import io
from telegram import Update
from datetime import datetime, timedelta
from telegram import Update, InlineKeyboardMarkup, InlineKeyboardButton
from telegram.constants import ChatType
from telegram.ext import ContextTypes
from sqlalchemy import select, func
from sqlalchemy import func
from app.db.session import get_session
from app.db.models import (
User, SecurityPolicy, ChatSecurity,
SpamDictionary, DictionaryEntry, PolicyDictionaryLink, # NEW: PolicyDictionaryLink
SpamDictionary, DictionaryEntry, PolicyDictionaryLink,
)
from app.bot.keyboards.security import kb_policy
from telegram import InlineKeyboardMarkup, InlineKeyboardButton
from app.moderation.engine import dict_cache
# ---------- helpers ----------
def _get_or_create_policy(session, owner_tg_id: int) -> SecurityPolicy:
u = session.query(User).filter_by(tg_id=owner_tg_id).first()
if not u:
u = User(tg_id=owner_tg_id, name=""); session.add(u); session.commit(); session.refresh(u)
u = User(tg_id=owner_tg_id, name="")
session.add(u); session.commit(); session.refresh(u)
p = session.query(SecurityPolicy).filter_by(owner_user_id=u.id, name="Balanced").first()
if p: return p
if p:
return p
p = SecurityPolicy(owner_user_id=u.id, name="Balanced")
session.add(p); session.commit(); session.refresh(p)
return p
def _parse_params(raw: str|None, fallback_name: str) -> dict:
params = {"name": fallback_name or "dict", "category":"custom", "kind":"plain", "lang":None}
def _parse_params(raw: str | None, fallback_name: str) -> dict:
params = {"name": fallback_name or "dict", "category": "custom", "kind": "plain", "lang": None}
if raw:
for kv in raw.split(";"):
if "=" in kv:
k,v = kv.strip().split("=",1)
k, v = kv.strip().split("=", 1)
params[k.strip()] = v.strip()
params["name"] = (params["name"] or "dict")[:120]
params["category"] = (params.get("category") or "custom").lower()
params["kind"] = (params.get("kind") or "plain").lower()
return params
def _decode_bytes(b: bytes) -> str:
for enc in ("utf-8","cp1251","latin-1"):
try: return b.decode(enc)
except Exception: pass
return b.decode("utf-8","ignore")
for enc in ("utf-8", "cp1251", "latin-1"):
try:
return b.decode(enc)
except Exception:
continue
return b.decode("utf-8", "ignore")
def _import_entries(session, owner_tg_id: int, params: dict, entries: list[str]) -> int:
# ensure owner
u = session.query(User).filter_by(tg_id=owner_tg_id).first()
if not u:
u = User(tg_id=owner_tg_id, name=""); session.add(u); session.commit(); session.refresh(u)
u = User(tg_id=owner_tg_id, name="")
session.add(u)
session.commit()
session.refresh(u)
# словарь
# создать словарь
d = SpamDictionary(
owner_user_id=u.id,
name=params["name"], category=params["category"],
@@ -54,69 +66,223 @@ def _import_entries(session, owner_tg_id: int, params: dict, entries: list[str])
)
session.add(d); session.commit(); session.refresh(d)
# NEW: авто-привязка к дефолт-политике владельца (Balanced)
# автопривязка к дефолт-политике владельца (Balanced), чтобы потом /dicts работал сразу
p = _get_or_create_policy(session, owner_tg_id)
exists = session.query(PolicyDictionaryLink).filter_by(policy_id=p.id, dictionary_id=d.id).first()
if not exists:
if not session.query(PolicyDictionaryLink).filter_by(policy_id=p.id, dictionary_id=d.id).first():
session.add(PolicyDictionaryLink(policy_id=p.id, dictionary_id=d.id))
session.commit()
# записи
# добавить записи
n = 0
for pat in entries:
pat = pat.strip()
if not pat or pat.startswith("#"): continue
session.add(DictionaryEntry(dictionary_id=d.id, pattern=pat, is_regex=(params["kind"]=="regex")))
if not pat or pat.startswith("#"):
continue
session.add(DictionaryEntry(dictionary_id=d.id, pattern=pat, is_regex=(params["kind"] == "regex")))
n += 1
session.commit()
return n
# ---------- /security ----------
async def security_cmd(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
with get_session() as s:
p = _get_or_create_policy(s, update.effective_user.id)
chat = update.effective_chat
bound=enabled=False
bound = enabled = False
if chat.type in (ChatType.GROUP, ChatType.SUPERGROUP, ChatType.CHANNEL):
cs = s.query(ChatSecurity).filter_by(chat_id=chat.id).first()
if cs and cs.policy_id == p.id: bound, enabled = True, cs.enabled
await update.effective_message.reply_text(f"Политика «{p.name}»", reply_markup=kb_policy(p, chat_bound=bound, enabled=enabled))
if cs and cs.policy_id == p.id:
bound, enabled = True, cs.enabled
# показать панель
msg = await update.effective_message.reply_text(
f"Политика «{p.name}».",
reply_markup=kb_policy(p, chat_bound=bound, enabled=enabled)
)
# ----- ЗАМОК панели: только инициатор может нажимать -----
locks = ctx.chat_data.setdefault("security_locks", {})
locks[msg.message_id] = update.effective_user.id
# TTL/очистку можно сделать по таймеру, если нужно
async def security_cb(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
q = update.callback_query; await q.answer()
parts = q.data.split(":")
if parts[0] != "pol": return
q = update.callback_query
await q.answer()
# замок на сообщение
lock_owner = ctx.chat_data.get("security_locks", {}).get(q.message.message_id)
if lock_owner and lock_owner != update.effective_user.id:
await q.answer("Эта панель открыта другим админом. Запустите /security для своей.", show_alert=True)
return
# должен быть админом чата
try:
m = await ctx.bot.get_chat_member(update.effective_chat.id, update.effective_user.id)
if m.status not in ("administrator", "creator"):
await q.answer("Недостаточно прав.", show_alert=True); return
except Exception:
await q.answer("Недостаточно прав.", show_alert=True); return
parts = (q.data or "").split(":")
if len(parts) < 2 or parts[0] != "pol":
return
action = parts[1]
with get_session() as s:
pid = int(parts[-1]); p = s.get(SecurityPolicy, pid)
if not p: await q.edit_message_text("Политика не найдена."); return
pid = int(parts[-1])
p = s.get(SecurityPolicy, pid)
if not p:
await q.edit_message_text("Политика не найдена.")
return
if action == "toggle":
field = parts[2]; setattr(p, field, not getattr(p, field)); s.commit()
field = parts[2]
setattr(p, field, not getattr(p, field))
s.commit()
dict_cache.invalidate(cs.policy_id)
elif action == "adj":
field, delta = parts[2], int(parts[3]); val = getattr(p, field); setattr(p, field, max(0, val+delta)); s.commit()
field, delta = parts[2], int(parts[3])
val = getattr(p, field)
setattr(p, field, max(0, val + delta))
s.commit()
elif action == "cycle_action":
order = ["delete","warn","timeout","ban","none"]; cur=p.enforce_action_default
p.enforce_action_default = order[(order.index(cur)+1)%len(order)] if cur in order else "delete"; s.commit()
order = ["delete", "warn", "timeout", "ban", "none"]
cur = p.enforce_action_default
p.enforce_action_default = order[(order.index(cur) + 1) % len(order)] if cur in order else "delete"
s.commit()
elif action == "bind_here":
chat = update.effective_chat
if chat.type not in (ChatType.GROUP, ChatType.SUPERGROUP, ChatType.CHANNEL):
await q.edit_message_text("Жмите в группе/канале."); return
await q.edit_message_text("Жмите в группе/канале.")
return
cs = s.query(ChatSecurity).filter_by(chat_id=chat.id).first()
if not cs: cs = ChatSecurity(chat_id=chat.id, policy_id=p.id, enabled=False); s.add(cs)
else: cs.policy_id = p.id
if not cs:
cs = ChatSecurity(chat_id=chat.id, policy_id=p.id, enabled=False)
s.add(cs)
else:
cs.policy_id = p.id
s.commit()
elif action == "toggle_chat":
chat = update.effective_chat; cs = s.query(ChatSecurity).filter_by(chat_id=chat.id, policy_id=pid).first()
if cs: cs.enabled = not cs.enabled; s.commit()
# обновить клавиатуру
chat = update.effective_chat; bound=enabled=False
chat = update.effective_chat
cs = s.query(ChatSecurity).filter_by(chat_id=chat.id, policy_id=pid).first()
if cs:
cs.enabled = not cs.enabled
s.commit()
# перерисовать клавиатуру с учётом чата
chat = update.effective_chat
bound = enabled = False
if chat and chat.type in (ChatType.GROUP, ChatType.SUPERGROUP, ChatType.CHANNEL):
cs = s.query(ChatSecurity).filter_by(chat_id=chat.id).first()
if cs and cs.policy_id == p.id: bound, enabled = True, cs.enabled
if cs and cs.policy_id == p.id:
bound, enabled = True, cs.enabled
await q.edit_message_reply_markup(reply_markup=kb_policy(p, chat_bound=bound, enabled=enabled))
# === Импорт словаря ===
# ---------- словари: список и тоггл ----------
def _kb_dicts(policy_id: int, rows: list[tuple[int, str, bool]]):
# rows: [(dict_id, "Имя (категория/kind)", is_linked)]
kbd = []
for did, title, linked in rows:
mark = "" if linked else "▫️"
kbd.append([InlineKeyboardButton(f"{mark} {title}", callback_data=f"dict:toggle:{policy_id}:{did}")])
return InlineKeyboardMarkup(kbd) if kbd else None
async def dicts_cmd(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
chat = update.effective_chat
user_id = update.effective_user.id
# админ-проверка в группах
if chat.type in (ChatType.GROUP, ChatType.SUPERGROUP):
try:
m = await ctx.bot.get_chat_member(chat.id, user_id)
if m.status not in ("administrator", "creator"):
return
except Exception:
return
with get_session() as s:
# выбрать политику: в группе — политика чата; в ЛС — дефолт владельца
if chat.type in (ChatType.GROUP, ChatType.SUPERGROUP, ChatType.CHANNEL):
cs = s.query(ChatSecurity).filter_by(chat_id=chat.id).first()
if not cs:
await update.effective_message.reply_text("Политика не привязана. Откройте /security и привяжите к чату.")
return
p = s.get(SecurityPolicy, cs.policy_id)
else:
p = _get_or_create_policy(s, user_id)
u = s.query(User).filter_by(tg_id=user_id).first()
dicts = s.query(SpamDictionary).filter_by(owner_user_id=u.id).order_by(SpamDictionary.created_at.desc()).all()
linked = {x.dictionary_id for x in s.query(PolicyDictionaryLink).filter_by(policy_id=p.id).all()}
rows = []
for d in dicts[:100]:
title = f"{d.name} ({d.category}/{d.kind})"
rows.append((d.id, title, d.id in linked))
kb = _kb_dicts(p.id, rows)
if not rows:
await update.effective_message.reply_text("У вас пока нет словарей. Импортируйте через /spam_import в ЛС.")
return
await update.effective_message.reply_text(
f"Словари для политики «{p.name}» (нажмите для прикрепления/открепления):",
reply_markup=kb
)
async def dicts_cb(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
q = update.callback_query
await q.answer()
parts = (q.data or "").split(":")
if len(parts) != 4 or parts[0] != "dict" or parts[1] != "toggle":
return
policy_id = int(parts[2]); dict_id = int(parts[3])
# только админ
try:
m = await ctx.bot.get_chat_member(update.effective_chat.id, update.effective_user.id)
if m.status not in ("administrator", "creator"):
await q.answer("Недостаточно прав.", show_alert=True); return
except Exception:
await q.answer("Недостаточно прав.", show_alert=True); return
with get_session() as s:
p = s.get(SecurityPolicy, policy_id)
if not p:
await q.edit_message_text("Политика не найдена.")
return
link = s.query(PolicyDictionaryLink).filter_by(policy_id=policy_id, dictionary_id=dict_id).first()
if link:
s.delete(link); s.commit(); 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):
log.info("spam_import_cmd from %s", update.effective_user.id)
# только в ЛС; в main.py уже стоит фильтр, но на всякий
if update.effective_chat.type != ChatType.PRIVATE:
await update.effective_message.reply_text("Эту команду нужно выполнить в личке со мной.")
return
ctx.user_data["await_dict_file"] = True
ctx.user_data.pop("dict_params", None)
await update.effective_message.reply_text(
@@ -124,22 +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"
)
async def spam_import_capture(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
log.info("spam_import_capture: doc=%s caption=%s",
bool(update.message and update.message.document),
update.message.caption if update.message else None)
# Обрабатываем только когда ждём файл
if not ctx.user_data.get("await_dict_file"):
if update.effective_chat.type != ChatType.PRIVATE or not ctx.user_data.get("await_dict_file"):
return
doc = update.message.document if update.message else None
if not doc:
return
# ACK сразу, чтобы было видно, что бот работает
await update.effective_message.reply_text(f"Файл получен: {doc.file_name or 'без имени'} — импортирую…")
try:
file = await doc.get_file()
f = await doc.get_file()
bio = io.BytesIO()
await file.download_to_memory(out=bio)
await f.download_to_memory(out=bio)
bio.seek(0)
text = _decode_bytes(bio.read())
lines = [l.strip() for l in text.splitlines() if l.strip()]
@@ -154,22 +316,21 @@ async def spam_import_capture(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
except Exception as e:
await update.effective_message.reply_text(f"Ошибка импорта: {e}")
# === (опционально) Импорт словаря из текста, если прислали без файла ===
async def spam_import_text_capture(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
log.info("spam_import_text_capture: await=%s text_len=%s",
ctx.user_data.get("await_dict_file"),
len(update.effective_message.text or ""))
if not ctx.user_data.get("await_dict_file"):
# перехватываем только в ЛС и только если ждём словарь — И НЕ блокируем цепочку (block=False в main.py)
if update.effective_chat.type != ChatType.PRIVATE or not ctx.user_data.get("await_dict_file"):
return
txt = (update.effective_message.text or "").strip()
if not txt:
return
# Если похоже на «подпись» с параметрами — просто запомним и попросим файл
# если текст похож на "параметры", просто запомним и попросим файл
if ("=" in txt) and (";" in txt) and (len(txt.split()) <= 6):
ctx.user_data["dict_params"] = txt
await update.effective_message.reply_text("Параметры принял. Теперь пришлите .txt/.csv ФАЙЛОМ со словарём.")
return
# Иначе трактуем как словарь одной «пачкой»
# иначе считаем, что прислан словарь "в лоб"
lines = [l.strip() for l in txt.splitlines() if l.strip()]
params = _parse_params(ctx.user_data.get("dict_params"), "inline_dict")
try:
@@ -177,71 +338,8 @@ async def spam_import_text_capture(update: Update, ctx: ContextTypes.DEFAULT_TYP
n = _import_entries(s, update.effective_user.id, params, lines)
ctx.user_data.pop("await_dict_file", None)
ctx.user_data.pop("dict_params", None)
await update.effective_message.reply_text(f"Импортировано {n} записей (из текста) в словарь «{params['name']}».")
await update.effective_message.reply_text(
f"Импортировано {n} записей (из текста) в словарь «{params['name']}»."
)
except Exception as e:
await update.effective_message.reply_text(f"Ошибка импорта: {e}")
def _kb_dicts(policy_id: int, rows: list[tuple[int,str,bool]]):
# rows: [(dict_id, "Имя (категория/kind)", is_linked)]
kbd = []
for did, title, linked in rows:
mark = "" if linked else "▫️"
kbd.append([InlineKeyboardButton(f"{mark} {title}", callback_data=f"dict:toggle:{policy_id}:{did}")])
return InlineKeyboardMarkup(kbd) if kbd else None
async def dicts_cmd(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
chat = update.effective_chat
with get_session() as s:
# Выбираем политику: в группе — привязанную к чату, иначе — дефолт владельца
if chat.type in (ChatType.GROUP, ChatType.SUPERGROUP, ChatType.CHANNEL):
cs = s.query(ChatSecurity).filter_by(chat_id=chat.id).first()
if not cs:
await update.effective_message.reply_text("Политика не привязана. Откройте /security и привяжите к чату.")
return
p = s.get(SecurityPolicy, cs.policy_id)
else:
p = _get_or_create_policy(s, update.effective_user.id)
# словари владельца
u = s.query(User).filter_by(tg_id=update.effective_user.id).first()
dicts = s.query(SpamDictionary).filter_by(owner_user_id=u.id).order_by(SpamDictionary.created_at.desc()).all()
linked = {x.dictionary_id for x in s.query(PolicyDictionaryLink).filter_by(policy_id=p.id).all()}
rows = []
for d in dicts[:50]: # первые 50
title = f"{d.name} ({d.category}/{d.kind})"
rows.append((d.id, title, d.id in linked))
kb = _kb_dicts(p.id, rows)
if not rows:
await update.effective_message.reply_text("У вас пока нет словарей. Импортируйте через /spam_import.")
return
await update.effective_message.reply_text(f"Словари для политики «{p.name}» (нажмите, чтобы прикрепить/открепить):", reply_markup=kb)
async def dicts_cb(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
q = update.callback_query; await q.answer()
data = q.data.split(":")
if len(data) != 4 or data[0] != "dict" or data[1] != "toggle":
return
policy_id = int(data[2]); dict_id = int(data[3])
with get_session() as s:
p = s.get(SecurityPolicy, policy_id)
if not p:
await q.edit_message_text("Политика не найдена.")
return
link = s.query(PolicyDictionaryLink).filter_by(policy_id=policy_id, dictionary_id=dict_id).first()
if link:
s.delete(link); s.commit()
else:
s.add(PolicyDictionaryLink(policy_id=policy_id, dictionary_id=dict_id)); s.commit()
# перерисовать список
u = s.query(User).filter_by(tg_id=update.effective_user.id).first()
dicts = s.query(SpamDictionary).filter_by(owner_user_id=u.id).order_by(SpamDictionary.created_at.desc()).all()
linked = {x.dictionary_id for x in s.query(PolicyDictionaryLink).filter_by(policy_id=p.id).all()}
rows = []
for d in dicts[:50]:
title = f"{d.name} ({d.category}/{d.kind})"
rows.append((d.id, title, d.id in linked))
await q.edit_message_reply_markup(reply_markup=_kb_dicts(p.id, rows))

View File

@@ -4,10 +4,10 @@ from app.db.models import SecurityPolicy
def kb_policy(p: SecurityPolicy, chat_bound: bool = False, enabled: bool = False):
def onoff(b): return "" if b else ""
rows = [
[InlineKeyboardButton(f"Adult {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"Scam {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"18+ {onoff(p.block_adult)}", callback_data=f"pol:toggle:block_adult:{p.id}"),
InlineKeyboardButton(f"Спам {onoff(p.block_spam)}", callback_data=f"pol:toggle:block_spam:{p.id}")],
[InlineKeyboardButton(f"Скам {onoff(p.block_scam)}", callback_data=f"pol:toggle:block_scam:{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("(+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}"),

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
from telegram.ext import ApplicationBuilder, CommandHandler, MessageHandler, CallbackQueryHandler, ChatMemberHandler, filters
from telegram.ext import ApplicationBuilder
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.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():
cfg = load_config()
logging.basicConfig(level=getattr(logging, cfg.log_level.upper(), logging.INFO))
# приглушим шум httpx/httpcore
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpx").propagate = False
logging.getLogger("httpcore").setLevel(logging.WARNING)
logging.getLogger("httpcore").propagate = False
# метрики Prometheus
start_metrics_server(cfg.metrics_port)
app = ApplicationBuilder().token(cfg.bot_token).build()
# Commands
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("dicts", dicts_cmd))
# === РОУТЕРЫ по типам чатов ===
register_private_handlers(app) # ЛС
register_group_handlers(app) # группы/супергруппы
register_channel_handlers(app) # каналы
# коллбэки словарей
app.add_handler(CallbackQueryHandler(dicts_cb, pattern=r"^dict:"))
# Callbacks (order matters!)
# === УЗКИЕ callback-и, общий для редактора ===
from telegram.ext import CallbackQueryHandler
# настройки защитника
app.add_handler(CallbackQueryHandler(security_cb, pattern=r"^pol:"))
# привязка канала кнопкой «Привязать этот канал»
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))
app.add_handler(MessageHandler(filters.ChatType.PRIVATE & (filters.PHOTO | filters.VIDEO | filters.ANIMATION), on_media))
# (опционально) текстовые команды управления словарями (и в ЛС, и в группе)
from telegram.ext import CommandHandler
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))
# 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.add_error_handler(on_error)
app.run_polling(allowed_updates=None)
if __name__ == "__main__":
main()

View File

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