Bot become a Community Guard & Post send manager
added: dictionary support for censore message/user management with dict triggers
This commit is contained in:
10
.env.example
10
.env.example
@@ -14,3 +14,13 @@ DB_PASSWORD=postgres
|
|||||||
|
|
||||||
# Logging level: DEBUG, INFO, WARNING, ERROR
|
# Logging level: DEBUG, INFO, WARNING, ERROR
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_URL=redis://redis:6379/0
|
||||||
|
REDIS_PORT=6379
|
||||||
|
|
||||||
|
# Метрики бота (Prometheus будет ходить на bot:9100 внутри сети)
|
||||||
|
METRICS_PORT=9100
|
||||||
|
|
||||||
|
# (если где-то используешь обращения к Prometheus из веба)
|
||||||
|
PROMETHEUS_URL=http://prometheus:9090
|
||||||
80
app/bot/handlers/bind_chat.py
Normal file
80
app/bot/handlers/bind_chat.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
from telegram import Update
|
||||||
|
from telegram.constants import ChatType
|
||||||
|
from telegram.ext import ContextTypes
|
||||||
|
from app.db.session import get_session
|
||||||
|
from app.db.models import User, Chat
|
||||||
|
from app.bot.messages import BIND_OK, BIND_FAIL_NOT_ADMIN, BIND_FAIL_BOT_RIGHTS, BIND_FAIL_GENERIC
|
||||||
|
|
||||||
|
async def bind_chat_cb(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||||
|
q = update.callback_query
|
||||||
|
await q.answer()
|
||||||
|
data = q.data or ""
|
||||||
|
if not data.startswith("bind:"):
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
chat_id = int(data.split(":", 1)[1])
|
||||||
|
except Exception:
|
||||||
|
await q.edit_message_text(BIND_FAIL_GENERIC)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 1) Проверим, что кликнувший — админ канала
|
||||||
|
user = update.effective_user
|
||||||
|
if not user:
|
||||||
|
await q.edit_message_text(BIND_FAIL_GENERIC)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
member_user = await ctx.bot.get_chat_member(chat_id, user.id)
|
||||||
|
if member_user.status not in ("administrator", "creator"):
|
||||||
|
await q.edit_message_text(BIND_FAIL_NOT_ADMIN)
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
await q.edit_message_text(BIND_FAIL_NOT_ADMIN)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 2) Проверим права бота в канале (должен быть админ с правом постинга)
|
||||||
|
try:
|
||||||
|
member_bot = await ctx.bot.get_chat_member(chat_id, ctx.bot.id)
|
||||||
|
can_post = False
|
||||||
|
if member_bot.status in ("administrator", "creator"):
|
||||||
|
flag = getattr(member_bot, "can_post_messages", None)
|
||||||
|
can_post = True if (flag is None or flag is True) else False
|
||||||
|
if not can_post:
|
||||||
|
await q.edit_message_text(BIND_FAIL_BOT_RIGHTS)
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
await q.edit_message_text(BIND_FAIL_BOT_RIGHTS)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 3) Получим инфо о канале и запишем в БД как привязанный
|
||||||
|
tg_chat = await ctx.bot.get_chat(chat_id)
|
||||||
|
|
||||||
|
with get_session() as s:
|
||||||
|
u = s.query(User).filter_by(tg_id=user.id).first()
|
||||||
|
if not u:
|
||||||
|
from app.db.models import User as U
|
||||||
|
u = U(tg_id=user.id, name=user.full_name)
|
||||||
|
s.add(u); s.commit(); s.refresh(u)
|
||||||
|
|
||||||
|
row = s.query(Chat).filter_by(chat_id=chat_id).first()
|
||||||
|
if not row:
|
||||||
|
row = Chat(
|
||||||
|
chat_id=chat_id,
|
||||||
|
type=tg_chat.type,
|
||||||
|
title=tg_chat.title,
|
||||||
|
owner_user_id=u.id,
|
||||||
|
can_post=True,
|
||||||
|
)
|
||||||
|
s.add(row)
|
||||||
|
else:
|
||||||
|
row.title = tg_chat.title
|
||||||
|
row.type = tg_chat.type
|
||||||
|
row.owner_user_id = row.owner_user_id or u.id
|
||||||
|
row.can_post = True
|
||||||
|
s.commit()
|
||||||
|
|
||||||
|
await q.edit_message_text(BIND_OK.format(title=tg_chat.title or chat_id))
|
||||||
|
except Exception:
|
||||||
|
await q.edit_message_text(BIND_FAIL_GENERIC)
|
||||||
@@ -1,14 +1,12 @@
|
|||||||
from telegram import Update, InputMediaPhoto, InputMediaVideo, InputMediaAnimation
|
from telegram import Update, InputMediaPhoto, InputMediaVideo, InputMediaAnimation
|
||||||
from telegram.constants import ParseMode, ChatType
|
from telegram.constants import 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 Draft, Chat, Delivery
|
from app.db.models import Draft, Chat, Delivery, User
|
||||||
from app.bot.keyboards.common import kb_choose_chat
|
from app.bot.keyboards.common import kb_multiselect # ← только мультивыбор
|
||||||
from app.bot.messages import READY_SELECT_CHAT, SENT_OK, SEND_ERR, NEED_MEDIA_BEFORE_NEXT, CANCELLED
|
from app.bot.messages import NEED_MEDIA_BEFORE_NEXT, NO_SELECTION, SENT_SUMMARY
|
||||||
from .drafts import STATE_DRAFT, KEY_DRAFT_ID, STATE_AWAIT_TEXT, STATE_CONFIRM
|
from app.moderation.engine import check_message_allowed
|
||||||
from .add_group import STATE_KEY # чтобы не конфликтовать по user_data ключам
|
|
||||||
|
|
||||||
|
|
||||||
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
|
||||||
@@ -26,11 +24,11 @@ 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[KEY_DRAFT_ID] = draft_id
|
ctx.user_data["draft_id"] = draft_id
|
||||||
ctx.user_data[STATE_DRAFT] = STATE_AWAIT_TEXT
|
ctx.user_data["draft_state"] = "await_text"
|
||||||
await q.edit_message_text("Шаг 2/3 — текст.\nОтправьте текст поста.")
|
await q.edit_message_text("Шаг 2/3 — текст.\nОтправьте текст поста.")
|
||||||
|
|
||||||
# --- Подтверждение (Отправить) -> выбор чата ---
|
# --- Подтверждение -> мультивыбор чатов ---
|
||||||
elif data.startswith("draft_confirm_send:"):
|
elif data.startswith("draft_confirm_send:"):
|
||||||
draft_id = int(data.split(":")[1])
|
draft_id = int(data.split(":")[1])
|
||||||
with get_session() as s:
|
with get_session() as s:
|
||||||
@@ -40,32 +38,59 @@ async def on_callback(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
|||||||
return
|
return
|
||||||
d.status = "ready"
|
d.status = "ready"
|
||||||
s.commit()
|
s.commit()
|
||||||
# Разрешённые чаты владельца черновика
|
|
||||||
chats = s.query(Chat).filter_by(owner_user_id=d.user_id, can_post=True).all()
|
chats = s.query(Chat).filter_by(owner_user_id=d.user_id, can_post=True).all()
|
||||||
buttons = [(c.title or str(c.chat_id), c.chat_id) for c in chats]
|
chat_rows = [(c.title or str(c.chat_id), c.chat_id) for c in chats]
|
||||||
kb = kb_choose_chat(draft_id, buttons)
|
sel_key = f"sel:{draft_id}"
|
||||||
if not kb:
|
ctx.user_data[sel_key] = set()
|
||||||
await q.edit_message_text("Нет чатов с правом публикации. Добавьте/обновите через /add_group.")
|
await q.edit_message_text("Выберите чаты:", reply_markup=kb_multiselect(draft_id, chat_rows, ctx.user_data[sel_key]))
|
||||||
return
|
|
||||||
ctx.user_data[STATE_DRAFT] = STATE_CONFIRM
|
# --- Тоггл чекбокса ---
|
||||||
await q.edit_message_text(READY_SELECT_CHAT, reply_markup=kb)
|
elif data.startswith("tgl:"):
|
||||||
|
_, draft_id, chat_id = data.split(":")
|
||||||
|
draft_id = int(draft_id)
|
||||||
|
chat_id = int(chat_id)
|
||||||
|
sel_key = f"sel:{draft_id}"
|
||||||
|
|
||||||
# --- Отмена черновика ---
|
|
||||||
elif data.startswith("draft_cancel:"):
|
|
||||||
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 d:
|
chats = s.query(Chat).filter_by(owner_user_id=d.user_id, can_post=True).all()
|
||||||
d.status = "cancelled"
|
rows = [(c.title or str(c.chat_id), c.chat_id) for c in chats]
|
||||||
s.commit()
|
|
||||||
ctx.user_data.pop(KEY_DRAFT_ID, None)
|
|
||||||
ctx.user_data.pop(STATE_DRAFT, None)
|
|
||||||
await q.edit_message_text(CANCELLED)
|
|
||||||
|
|
||||||
# --- Отправка в выбранный чат ---
|
sel: set[int] = set(ctx.user_data.get(sel_key, set()))
|
||||||
elif data.startswith("send:"):
|
if chat_id in sel:
|
||||||
_, draft_id, chat_id = data.split(":")
|
sel.remove(chat_id)
|
||||||
draft_id = int(draft_id); chat_id = int(chat_id)
|
else:
|
||||||
|
sel.add(chat_id)
|
||||||
|
ctx.user_data[sel_key] = sel
|
||||||
|
|
||||||
|
await q.edit_message_reply_markup(reply_markup=kb_multiselect(draft_id, rows, sel))
|
||||||
|
|
||||||
|
# --- Выбрать все ---
|
||||||
|
elif data.startswith("selall:"):
|
||||||
|
draft_id = int(data.split(":")[1])
|
||||||
|
sel_key = f"sel:{draft_id}"
|
||||||
|
with get_session() as s:
|
||||||
|
d = s.get(Draft, draft_id)
|
||||||
|
chats = s.query(Chat).filter_by(owner_user_id=d.user_id, can_post=True).all()
|
||||||
|
rows = [(c.title or str(c.chat_id), c.chat_id) for c in chats]
|
||||||
|
ctx.user_data[sel_key] = {cid for _, cid in rows}
|
||||||
|
await q.edit_message_reply_markup(reply_markup=kb_multiselect(draft_id, rows, ctx.user_data[sel_key]))
|
||||||
|
|
||||||
|
# --- Сброс ---
|
||||||
|
elif data.startswith("clear:"):
|
||||||
|
draft_id = int(data.split(":")[1])
|
||||||
|
sel_key = f"sel:{draft_id}"
|
||||||
|
with get_session() as s:
|
||||||
|
d = s.get(Draft, draft_id)
|
||||||
|
chats = s.query(Chat).filter_by(owner_user_id=d.user_id, can_post=True).all()
|
||||||
|
rows = [(c.title or str(c.chat_id), c.chat_id) for c in chats]
|
||||||
|
ctx.user_data[sel_key] = set()
|
||||||
|
await q.edit_message_reply_markup(reply_markup=kb_multiselect(draft_id, rows, set()))
|
||||||
|
|
||||||
|
# --- Отправка выбранных ---
|
||||||
|
elif data.startswith("sendmulti:"):
|
||||||
|
draft_id = int(data.split(":")[1])
|
||||||
|
sel_key = f"sel:{draft_id}"
|
||||||
|
|
||||||
with get_session() as s:
|
with get_session() as s:
|
||||||
d = s.get(Draft, draft_id)
|
d = s.get(Draft, draft_id)
|
||||||
@@ -73,46 +98,75 @@ async def on_callback(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
|||||||
await q.edit_message_text("Черновик не найден.")
|
await q.edit_message_text("Черновик не найден.")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
chats = s.query(Chat).filter_by(owner_user_id=d.user_id, can_post=True).all()
|
||||||
|
rows = [(c.title or str(c.chat_id), c.chat_id) for c in chats]
|
||||||
|
selected: set[int] = ctx.user_data.get(sel_key, set())
|
||||||
|
|
||||||
|
if not selected:
|
||||||
|
await q.edit_message_text(NO_SELECTION, reply_markup=kb_multiselect(draft_id, rows, selected))
|
||||||
|
return
|
||||||
|
|
||||||
|
ok = 0
|
||||||
|
fail = 0
|
||||||
media = list(sorted(d.media, key=lambda m: m.order))
|
media = list(sorted(d.media, key=lambda m: m.order))
|
||||||
sent_ids = []
|
media_ids = [m.file_id for m in media]
|
||||||
try:
|
text_val = d.text or ""
|
||||||
if media:
|
owner = s.get(User, d.user_id)
|
||||||
if len(media) > 1:
|
|
||||||
im = []
|
for cid in list(selected):
|
||||||
for i, m in enumerate(media):
|
allowed, reasons, content_hash = check_message_allowed(
|
||||||
cap = d.text if i == 0 else None
|
s, cid, owner_user_id=owner.id, text=text_val, media_ids=media_ids
|
||||||
if m.kind == "photo":
|
)
|
||||||
im.append(InputMediaPhoto(media=m.file_id, caption=cap, parse_mode=ParseMode.HTML))
|
if not allowed:
|
||||||
elif m.kind == "video":
|
s.add(Delivery(draft_id=d.id, chat_id=cid, status="failed", error="; ".join(reasons), content_hash=content_hash))
|
||||||
im.append(InputMediaVideo(media=m.file_id, caption=cap, parse_mode=ParseMode.HTML))
|
s.commit()
|
||||||
elif m.kind == "animation":
|
fail += 1
|
||||||
im.append(InputMediaAnimation(media=m.file_id, caption=cap, parse_mode=ParseMode.HTML))
|
continue
|
||||||
msgs = await ctx.bot.send_media_group(chat_id=chat_id, media=im)
|
|
||||||
sent_ids = [str(m.message_id) for m in msgs]
|
try:
|
||||||
else:
|
if media:
|
||||||
m = media[0]
|
if len(media) > 1:
|
||||||
if m.kind == "photo":
|
im = []
|
||||||
msg = await ctx.bot.send_photo(chat_id=chat_id, photo=m.file_id, caption=d.text, parse_mode=ParseMode.HTML)
|
for i, m in enumerate(media):
|
||||||
elif m.kind == "video":
|
cap = text_val if i == 0 else None
|
||||||
msg = await ctx.bot.send_video(chat_id=chat_id, video=m.file_id, caption=d.text, parse_mode=ParseMode.HTML)
|
if m.kind == "photo":
|
||||||
|
im.append(InputMediaPhoto(media=m.file_id, caption=cap, parse_mode=ParseMode.HTML))
|
||||||
|
elif m.kind == "video":
|
||||||
|
im.append(InputMediaVideo(media=m.file_id, caption=cap, parse_mode=ParseMode.HTML))
|
||||||
|
else:
|
||||||
|
im.append(InputMediaAnimation(media=m.file_id, caption=cap, parse_mode=ParseMode.HTML))
|
||||||
|
await ctx.bot.send_media_group(chat_id=cid, media=im)
|
||||||
else:
|
else:
|
||||||
msg = await ctx.bot.send_animation(chat_id=chat_id, animation=m.file_id, caption=d.text, parse_mode=ParseMode.HTML)
|
m = media[0]
|
||||||
sent_ids = [str(msg.message_id)]
|
if m.kind == "photo":
|
||||||
else:
|
await ctx.bot.send_photo(chat_id=cid, photo=m.file_id, caption=text_val, parse_mode=ParseMode.HTML)
|
||||||
msg = await ctx.bot.send_message(chat_id=chat_id, text=d.text or "(пусто)", parse_mode=ParseMode.HTML)
|
elif m.kind == "video":
|
||||||
sent_ids = [str(msg.message_id)]
|
await ctx.bot.send_video(chat_id=cid, video=m.file_id, caption=text_val, parse_mode=ParseMode.HTML)
|
||||||
|
else:
|
||||||
|
await ctx.bot.send_animation(chat_id=cid, animation=m.file_id, caption=text_val, parse_mode=ParseMode.HTML)
|
||||||
|
else:
|
||||||
|
await ctx.bot.send_message(chat_id=cid, text=text_val or "(пусто)", parse_mode=ParseMode.HTML)
|
||||||
|
|
||||||
deliv = Delivery(draft_id=d.id, chat_id=chat_id, status="sent", message_ids=",".join(sent_ids))
|
s.add(Delivery(draft_id=d.id, chat_id=cid, status="sent", content_hash=content_hash))
|
||||||
s.add(deliv)
|
s.commit()
|
||||||
d.status = "sent"
|
ok += 1
|
||||||
|
except Exception as e:
|
||||||
|
s.add(Delivery(draft_id=d.id, chat_id=cid, status="failed", error=str(e), content_hash=content_hash))
|
||||||
|
s.commit()
|
||||||
|
fail += 1
|
||||||
|
|
||||||
|
ctx.user_data.pop(sel_key, None)
|
||||||
|
await q.edit_message_text(SENT_SUMMARY.format(ok=ok, fail=fail))
|
||||||
|
|
||||||
|
# --- Отмена ---
|
||||||
|
elif data.startswith("draft_cancel:"):
|
||||||
|
draft_id = int(data.split(":")[1])
|
||||||
|
with get_session() as s:
|
||||||
|
d = s.get(Draft, draft_id)
|
||||||
|
if d:
|
||||||
|
d.status = "cancelled"
|
||||||
s.commit()
|
s.commit()
|
||||||
|
ctx.user_data.pop(f"sel:{draft_id}", None)
|
||||||
# Сбрасываем пользовательское состояние редактора
|
ctx.user_data.pop("draft_id", None)
|
||||||
ctx.user_data.pop(KEY_DRAFT_ID, None)
|
ctx.user_data.pop("draft_state", None)
|
||||||
ctx.user_data.pop(STATE_DRAFT, None)
|
await q.edit_message_text("Черновик отменён.")
|
||||||
|
|
||||||
await q.edit_message_text(SENT_OK)
|
|
||||||
except Exception as e:
|
|
||||||
deliv = Delivery(draft_id=d.id, chat_id=chat_id, status="failed", error=str(e))
|
|
||||||
s.add(deliv); s.commit()
|
|
||||||
await q.edit_message_text(SEND_ERR.format(e=e))
|
|
||||||
|
|||||||
@@ -1,51 +1,41 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from telegram import Update
|
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 (
|
from app.bot.messages import ASK_MEDIA, ASK_TEXT, CONFIRM, NEED_START_NEW
|
||||||
ASK_MEDIA, ASK_TEXT, TEXT_ADDED, CONFIRM, ALREADY_AT_TEXT, ALREADY_READY, NEED_START_NEW
|
|
||||||
)
|
|
||||||
from app.bot.keyboards.common import kb_next_text, kb_confirm
|
from app.bot.keyboards.common import kb_next_text, kb_confirm
|
||||||
from .add_group import add_group_capture, STATE_KEY # /add_group ожидание
|
from .add_group import add_group_capture, STATE_KEY
|
||||||
|
|
||||||
# Состояния пошагового редактора
|
|
||||||
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 _start_new_draft(tg_id: int) -> Draft:
|
def _start_new_draft(tg_id: int) -> Draft:
|
||||||
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="")
|
u = User(tg_id=tg_id, name=""); s.add(u); s.commit(); s.refresh(u)
|
||||||
s.add(u); s.commit(); s.refresh(u)
|
|
||||||
# Закрываем предыдущие "editing" как cancelled (по желанию)
|
|
||||||
s.query(Draft).filter(Draft.user_id == u.id, Draft.status == "editing").update({"status": "cancelled"})
|
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
|
||||||
|
|
||||||
|
|
||||||
def _get_draft(draft_id: int) -> Draft | None:
|
|
||||||
with get_session() as s:
|
|
||||||
return s.get(Draft, draft_id)
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
d = _start_new_draft(update.effective_user.id)
|
||||||
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
|
||||||
await update.effective_message.reply_text(ASK_MEDIA, reply_markup=kb_next_text(d.id))
|
# Кнопку «Дальше — текст» теперь показываем после добавления медиа,
|
||||||
|
# поэтому здесь — только инструкция
|
||||||
|
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, отдаём управление туда
|
# Если ждём chat_id для /add_group — передаём управление
|
||||||
|
if ctx.user_data.get("await_dict_file"):
|
||||||
|
return
|
||||||
if ctx.user_data.get(STATE_KEY):
|
if ctx.user_data.get(STATE_KEY):
|
||||||
return await add_group_capture(update, ctx)
|
return await add_group_capture(update, ctx)
|
||||||
|
|
||||||
@@ -60,20 +50,48 @@ async def on_text(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if state == STATE_AWAIT_MEDIA:
|
if state == STATE_AWAIT_MEDIA:
|
||||||
# Сначала медиа
|
await update.effective_message.reply_text("Сначала добавьте медиа и нажмите «Дальше — текст».")
|
||||||
await update.effective_message.reply_text("Сначала пришлите медиа и нажмите «Дальше — текст».")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
if state == STATE_CONFIRM:
|
if state == STATE_CONFIRM:
|
||||||
await update.effective_message.reply_text(ALREADY_READY)
|
await update.effective_message.reply_text("Пост уже готов — нажмите «Отправить» или «Отменить».")
|
||||||
return
|
return
|
||||||
|
|
||||||
if state == STATE_AWAIT_TEXT:
|
if state == STATE_AWAIT_TEXT:
|
||||||
|
# Сохраняем текст
|
||||||
with get_session() as s:
|
with get_session() as s:
|
||||||
d = s.get(Draft, draft_id)
|
d = s.get(Draft, draft_id)
|
||||||
d.text = update.effective_message.text_html_urled
|
d.text = update.effective_message.text_html_urled
|
||||||
d.updated_at = datetime.utcnow()
|
d.updated_at = datetime.utcnow()
|
||||||
s.commit()
|
s.commit()
|
||||||
|
|
||||||
|
media = sorted(d.media, key=lambda m: m.order)
|
||||||
|
|
||||||
|
# Предпросмотр
|
||||||
|
if media:
|
||||||
|
if len(media) > 1:
|
||||||
|
im = []
|
||||||
|
for i, m in enumerate(media):
|
||||||
|
cap = d.text if i == 0 else None
|
||||||
|
if m.kind == "photo":
|
||||||
|
im.append(InputMediaPhoto(media=m.file_id, caption=cap, parse_mode=ParseMode.HTML))
|
||||||
|
elif m.kind == "video":
|
||||||
|
im.append(InputMediaVideo(media=m.file_id, caption=cap, parse_mode=ParseMode.HTML))
|
||||||
|
else:
|
||||||
|
im.append(InputMediaAnimation(media=m.file_id, caption=cap, parse_mode=ParseMode.HTML))
|
||||||
|
await update.effective_chat.send_media_group(media=im)
|
||||||
|
else:
|
||||||
|
m = media[0]
|
||||||
|
if m.kind == "photo":
|
||||||
|
await update.effective_chat.send_photo(photo=m.file_id, caption=d.text, parse_mode=ParseMode.HTML)
|
||||||
|
elif m.kind == "video":
|
||||||
|
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)
|
||||||
|
else:
|
||||||
|
await update.effective_chat.send_message(text=d.text or "(пусто)", parse_mode=ParseMode.HTML)
|
||||||
|
|
||||||
|
# Переходим к подтверждению и показываем кнопки
|
||||||
ctx.user_data[STATE_DRAFT] = STATE_CONFIRM
|
ctx.user_data[STATE_DRAFT] = STATE_CONFIRM
|
||||||
await update.effective_message.reply_text(TEXT_ADDED, parse_mode=ParseMode.HTML)
|
|
||||||
await update.effective_message.reply_text(CONFIRM, reply_markup=kb_confirm(draft_id))
|
await update.effective_message.reply_text(CONFIRM, reply_markup=kb_confirm(draft_id))
|
||||||
|
|
||||||
|
|||||||
16
app/bot/handlers/errors.py
Normal file
16
app/bot/handlers/errors.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import traceback
|
||||||
|
from telegram.ext import ContextTypes
|
||||||
|
from app.db.session import get_session
|
||||||
|
from app.db.models import ModerationLog
|
||||||
|
|
||||||
|
async def on_error(update: object, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
|
try:
|
||||||
|
chat_id = getattr(getattr(update, "effective_chat", None), "id", 0)
|
||||||
|
user_id = getattr(getattr(update, "effective_user", None), "id", 0)
|
||||||
|
err = "".join(traceback.format_exception(context.error))[-4000:]
|
||||||
|
with get_session() as s:
|
||||||
|
s.add(ModerationLog(chat_id=chat_id or 0, tg_user_id=user_id or 0,
|
||||||
|
message_id=None, reason=err, action="error"))
|
||||||
|
s.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
@@ -1,77 +1,50 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
from telegram import Update
|
from telegram import Update, InlineKeyboardMarkup, InlineKeyboardButton
|
||||||
from telegram.constants import ChatMemberStatus, ChatType, ParseMode
|
from telegram.constants import ChatMemberStatus, ChatType, ParseMode
|
||||||
from telegram.ext import ContextTypes
|
from telegram.ext import ContextTypes
|
||||||
from telegram.error import Forbidden
|
from app.bot.messages import JOIN_PUBLIC_WITH_ID, NEED_START_DM, BIND_CHANNEL_BTN
|
||||||
from app.bot.messages import (
|
|
||||||
JOIN_DM_GROUP, JOIN_DM_CHANNEL, JOIN_PUBLIC_WITH_ID, NEED_START_DM
|
|
||||||
)
|
|
||||||
|
|
||||||
TTL_SEC = 30 # через столько секунд удаляем публичную подсказку
|
TTL_SEC = 30
|
||||||
|
|
||||||
async def _auto_delete(ctx: ContextTypes.DEFAULT_TYPE, chat_id: int, message_id: int, delay: int = TTL_SEC):
|
async def _autodel(ctx: ContextTypes.DEFAULT_TYPE, chat_id: int, mid: int, delay: int = TTL_SEC):
|
||||||
try:
|
try:
|
||||||
await asyncio.sleep(delay)
|
await asyncio.sleep(delay)
|
||||||
await ctx.bot.delete_message(chat_id=chat_id, message_id=message_id)
|
await ctx.bot.delete_message(chat_id=chat_id, message_id=mid)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
async def on_my_chat_member(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
async def on_my_chat_member(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||||
"""
|
|
||||||
При добавлении/повышении прав.
|
|
||||||
1) Пробуем DM актёру (my_chat_member.from_user) с chat_id и инструкцией.
|
|
||||||
2) Если DM не вышел (нет from_user или нет Start/Forbidden) — пишем в чат
|
|
||||||
подсказку с chat_id и удаляем её через TTL_SEC.
|
|
||||||
"""
|
|
||||||
mcm = update.my_chat_member
|
mcm = update.my_chat_member
|
||||||
if not mcm:
|
if not mcm:
|
||||||
return
|
return
|
||||||
|
|
||||||
chat = mcm.chat
|
chat = mcm.chat
|
||||||
new_status = mcm.new_chat_member.status
|
new_status = mcm.new_chat_member.status
|
||||||
if new_status not in (ChatMemberStatus.MEMBER, ChatMemberStatus.ADMINISTRATOR):
|
if new_status not in (ChatMemberStatus.MEMBER, ChatMemberStatus.ADMINISTRATOR):
|
||||||
return
|
return
|
||||||
|
|
||||||
title = chat.title or str(chat.id)
|
kb = None
|
||||||
chat_id = chat.id
|
if chat.type == ChatType.CHANNEL:
|
||||||
|
kb = InlineKeyboardMarkup([[InlineKeyboardButton(BIND_CHANNEL_BTN, callback_data=f"bind:{chat.id}")]])
|
||||||
|
|
||||||
# 1) Пытаемся отправить DM тому, кто совершил действие
|
|
||||||
actor = getattr(mcm, "from_user", None)
|
actor = getattr(mcm, "from_user", None)
|
||||||
dm_sent = False
|
|
||||||
if actor:
|
if actor:
|
||||||
try:
|
try:
|
||||||
if chat.type in (ChatType.GROUP, ChatType.SUPERGROUP):
|
await ctx.bot.send_message(actor.id,
|
||||||
await ctx.bot.send_message(
|
JOIN_PUBLIC_WITH_ID.format(chat_id=chat.id, ttl=TTL_SEC),
|
||||||
actor.id, JOIN_DM_GROUP.format(title=title, chat_id=chat_id),
|
parse_mode=ParseMode.MARKDOWN,
|
||||||
parse_mode=ParseMode.MARKDOWN
|
reply_markup=kb)
|
||||||
)
|
return
|
||||||
elif chat.type == ChatType.CHANNEL:
|
except Exception:
|
||||||
await ctx.bot.send_message(
|
|
||||||
actor.id, JOIN_DM_CHANNEL.format(title=title, chat_id=chat_id),
|
|
||||||
parse_mode=ParseMode.MARKDOWN
|
|
||||||
)
|
|
||||||
dm_sent = True
|
|
||||||
except Forbidden:
|
|
||||||
# пользователь не нажал Start — подсказка про Start
|
|
||||||
try:
|
try:
|
||||||
await ctx.bot.send_message(actor.id, NEED_START_DM)
|
await ctx.bot.send_message(actor.id, NEED_START_DM)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if dm_sent:
|
|
||||||
return
|
|
||||||
|
|
||||||
# 2) DM не удался — публикуем в чат краткий хинт с chat_id, удаляем через TTL
|
|
||||||
# (для каналов сработает только если бот уже админ и может постить)
|
|
||||||
try:
|
try:
|
||||||
msg = await ctx.bot.send_message(
|
msg = await ctx.bot.send_message(chat_id=chat.id,
|
||||||
chat_id=chat_id,
|
text=JOIN_PUBLIC_WITH_ID.format(chat_id=chat.id, ttl=TTL_SEC),
|
||||||
text=JOIN_PUBLIC_WITH_ID.format(chat_id=chat_id, ttl=TTL_SEC),
|
parse_mode=ParseMode.MARKDOWN,
|
||||||
parse_mode=ParseMode.MARKDOWN
|
reply_markup=kb)
|
||||||
)
|
ctx.application.create_task(_autodel(ctx, chat.id, msg.message_id, delay=TTL_SEC))
|
||||||
ctx.application.create_task(_auto_delete(ctx, chat_id, msg.message_id, delay=TTL_SEC))
|
|
||||||
except Exception:
|
except Exception:
|
||||||
# Если и сюда не можем — увы, остаётся ручной путь: /id, /add_group и ЛС
|
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -2,19 +2,18 @@ from datetime import datetime
|
|||||||
from telegram import Update
|
from telegram import Update
|
||||||
from telegram.constants import ChatType
|
from telegram.constants import ChatType
|
||||||
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 Draft, DraftMedia
|
from app.db.models import Draft, DraftMedia
|
||||||
from app.bot.messages import MEDIA_ADDED, ALREADY_AT_TEXT, NEED_START_NEW
|
from app.bot.messages import MEDIA_ADDED, NEED_START_NEW
|
||||||
from .drafts import KEY_DRAFT_ID, STATE_DRAFT, STATE_AWAIT_MEDIA, STATE_AWAIT_TEXT, STATE_CONFIRM
|
from app.bot.keyboards.common import kb_next_text
|
||||||
from .add_group import add_group_capture, STATE_KEY # перехват для /add_group
|
from .drafts import KEY_DRAFT_ID, STATE_DRAFT, STATE_AWAIT_MEDIA
|
||||||
|
from .add_group import add_group_capture, STATE_KEY
|
||||||
|
|
||||||
async def on_media(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
async def on_media(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||||
if update.effective_chat.type != ChatType.PRIVATE:
|
if update.effective_chat.type != ChatType.PRIVATE:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Если пользователь сейчас привязывает чат — используем пересланное медиа для извлечения chat_id
|
# Если сейчас идёт привязка чата — используем пересланное сообщение
|
||||||
if ctx.user_data.get(STATE_KEY):
|
if ctx.user_data.get(STATE_KEY):
|
||||||
return await add_group_capture(update, ctx)
|
return await add_group_capture(update, ctx)
|
||||||
|
|
||||||
@@ -26,8 +25,7 @@ async def on_media(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if state != STATE_AWAIT_MEDIA:
|
if state != STATE_AWAIT_MEDIA:
|
||||||
# Уже перешли к тексту/подтверждению — блокируем добавление медиа
|
await update.effective_message.reply_text("Медиа можно добавлять только на шаге 1.")
|
||||||
await update.effective_message.reply_text(ALREADY_AT_TEXT if state == STATE_AWAIT_TEXT else "Редактор на шаге подтверждения.")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
kind = None
|
kind = None
|
||||||
@@ -44,8 +42,12 @@ async def on_media(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
|||||||
with get_session() as s:
|
with get_session() as s:
|
||||||
d = s.get(Draft, draft_id)
|
d = s.get(Draft, draft_id)
|
||||||
order = len(d.media)
|
order = len(d.media)
|
||||||
m = DraftMedia(draft_id=d.id, kind=kind, file_id=file_id, order=order)
|
s.add(DraftMedia(draft_id=d.id, kind=kind, file_id=file_id, order=order))
|
||||||
s.add(m); d.updated_at = datetime.utcnow()
|
d.updated_at = datetime.utcnow()
|
||||||
s.commit()
|
s.commit()
|
||||||
|
|
||||||
await update.effective_message.reply_text(MEDIA_ADDED.format(kind=kind))
|
# Показываем кнопку «Дальше — текст» прямо под сообщением «Медиа добавлено»
|
||||||
|
await update.effective_message.reply_text(
|
||||||
|
MEDIA_ADDED.format(kind=kind),
|
||||||
|
reply_markup=kb_next_text(draft_id)
|
||||||
|
)
|
||||||
|
|||||||
68
app/bot/handlers/mod_status.py
Normal file
68
app/bot/handlers/mod_status.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
from telegram import Update
|
||||||
|
from telegram.constants import ChatType
|
||||||
|
from telegram.ext import ContextTypes
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
from app.db.session import get_session
|
||||||
|
from app.db.models import ChatSecurity, SecurityPolicy, PolicyDictionaryLink, SpamDictionary, DictionaryEntry, ModerationLog
|
||||||
|
|
||||||
|
async def mod_status_cmd(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||||
|
chat = update.effective_chat
|
||||||
|
if chat.type not in (ChatType.GROUP, ChatType.SUPERGROUP):
|
||||||
|
await update.effective_message.reply_text("Команду /mod_status нужно запускать в группе.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Только админам группы
|
||||||
|
try:
|
||||||
|
m = await ctx.bot.get_chat_member(chat.id, update.effective_user.id)
|
||||||
|
if m.status not in ("administrator","creator"):
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
with get_session() as s:
|
||||||
|
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)
|
||||||
|
if not p:
|
||||||
|
await update.effective_message.reply_text("Политика не найдена (policy_id устарел). Перепривяжите через /security.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# словари, доступные через links (глобальные не считаем, просто ориентир)
|
||||||
|
linked_dicts = (
|
||||||
|
s.query(SpamDictionary)
|
||||||
|
.join(PolicyDictionaryLink, PolicyDictionaryLink.dictionary_id == SpamDictionary.id)
|
||||||
|
.filter(PolicyDictionaryLink.policy_id == p.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
rules_count = 0
|
||||||
|
if linked_dicts:
|
||||||
|
d_ids = [d.id for d in linked_dicts]
|
||||||
|
rules_count = s.query(func.count(DictionaryEntry.id)).filter(DictionaryEntry.dictionary_id.in_(d_ids)).scalar() or 0
|
||||||
|
|
||||||
|
# блокировки за 15 минут
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
since = datetime.utcnow() - timedelta(minutes=15)
|
||||||
|
blocked_15m = (s.query(func.count(ModerationLog.id))
|
||||||
|
.filter(ModerationLog.chat_id == chat.id,
|
||||||
|
ModerationLog.created_at >= since,
|
||||||
|
ModerationLog.action.in_(("delete","warn","timeout","ban")))
|
||||||
|
.scalar() or 0)
|
||||||
|
|
||||||
|
# права бота
|
||||||
|
bot_member = await ctx.bot.get_chat_member(chat.id, ctx.bot.id)
|
||||||
|
can_delete = getattr(bot_member, "can_delete_messages", False)
|
||||||
|
can_restrict = getattr(bot_member, "can_restrict_members", False)
|
||||||
|
|
||||||
|
txt = (
|
||||||
|
f"Чат: {chat.title or chat.id}\n"
|
||||||
|
f"Модерация: {'ON' if cs.enabled else 'OFF'} (policy: {p.name})\n"
|
||||||
|
f"Категории: Profanity={'ON' if p.block_profanity else 'OFF'}, Spam={'ON' if p.block_spam else 'OFF'}, Adult={'ON' if p.block_adult else 'OFF'}, Scam={'ON' if p.block_scam else 'OFF'}\n"
|
||||||
|
f"Лимиты: links≤{p.max_links}, mentions≤{p.max_mentions}, rate={p.user_msg_per_minute}/min, duplicate={p.duplicate_window_seconds}s\n"
|
||||||
|
f"Права бота: delete={'yes' if can_delete else 'no'}, restrict={'yes' if can_restrict else 'no'}\n"
|
||||||
|
f"Привязанных словарей: {len(linked_dicts)} (правил: {rules_count})\n"
|
||||||
|
f"Заблокировано за 15 мин: {blocked_15m}\n"
|
||||||
|
"Если блокировок 0: проверьте privacy mode, права бота и что mod=ON."
|
||||||
|
)
|
||||||
|
await update.effective_message.reply_text(txt)
|
||||||
152
app/bot/handlers/moderation.py
Normal file
152
app/bot/handlers/moderation.py
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import asyncio, re
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from telegram import Update, ChatPermissions
|
||||||
|
from telegram.constants import ChatType, ParseMode
|
||||||
|
from telegram.ext import ContextTypes
|
||||||
|
|
||||||
|
from app.db.session import get_session
|
||||||
|
from app.db.models import MessageEvent, ModerationLog
|
||||||
|
from app.moderation.engine import (
|
||||||
|
get_policy_for_chat, compute_content_hash, redis_rate_check, redis_dupe_check,
|
||||||
|
add_strike_and_decide_action, _domain_sets, dict_cache, _compile_dicts
|
||||||
|
)
|
||||||
|
from app.infra.metrics import MSG_PROCESSED, MSG_BLOCKED, MOD_LAT
|
||||||
|
|
||||||
|
WARN_TTL = 20
|
||||||
|
|
||||||
|
async def _autodel(ctx: ContextTypes.DEFAULT_TYPE, chat_id: int, mid: int, ttl: int = WARN_TTL):
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(ttl)
|
||||||
|
await ctx.bot.delete_message(chat_id=chat_id, message_id=mid)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _extract_media_ids(msg) -> list[str]:
|
||||||
|
if msg.photo: return [msg.photo[-1].file_id]
|
||||||
|
if msg.video: return [msg.video.file_id]
|
||||||
|
if msg.animation: return [msg.animation.file_id]
|
||||||
|
if msg.document: return [msg.document.file_id]
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def moderate_message(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||||
|
msg = update.effective_message
|
||||||
|
chat = update.effective_chat
|
||||||
|
user = update.effective_user
|
||||||
|
if chat.type not in (ChatType.GROUP, ChatType.SUPERGROUP):
|
||||||
|
return
|
||||||
|
if not user or user.is_bot:
|
||||||
|
return
|
||||||
|
|
||||||
|
MSG_PROCESSED.inc()
|
||||||
|
with MOD_LAT.time():
|
||||||
|
text = (getattr(msg, "text", None) or getattr(msg, "caption", None) or "")
|
||||||
|
media_ids = _extract_media_ids(msg)
|
||||||
|
h = compute_content_hash(text, media_ids)
|
||||||
|
reasons = []
|
||||||
|
|
||||||
|
with get_session() as s:
|
||||||
|
policy = get_policy_for_chat(s, chat.id)
|
||||||
|
if not policy:
|
||||||
|
return
|
||||||
|
|
||||||
|
# лог события для аналитики
|
||||||
|
s.add(MessageEvent(chat_id=chat.id, tg_user_id=user.id, message_id=msg.message_id, content_hash=h))
|
||||||
|
s.commit()
|
||||||
|
|
||||||
|
# rate-limit и дубликаты через Redis (если есть)
|
||||||
|
if policy.user_msg_per_minute > 0:
|
||||||
|
ok = await redis_rate_check(chat.id, user.id, policy.user_msg_per_minute)
|
||||||
|
if not ok:
|
||||||
|
reasons.append(f"ratelimit>{policy.user_msg_per_minute}/min")
|
||||||
|
if policy.duplicate_window_seconds > 0:
|
||||||
|
ok2 = await redis_dupe_check(chat.id, user.id, h, policy.duplicate_window_seconds)
|
||||||
|
if not ok2:
|
||||||
|
reasons.append("duplicate")
|
||||||
|
|
||||||
|
# дешёвые проверки
|
||||||
|
URL_RE = re.compile(r'https?://[^\s)]+', re.IGNORECASE)
|
||||||
|
MENTION_RE = re.compile(r'@\w+', re.UNICODE)
|
||||||
|
links = URL_RE.findall(text or "")
|
||||||
|
if policy.max_links >= 0 and len(links) > policy.max_links:
|
||||||
|
reasons.append(f"links>{policy.max_links}")
|
||||||
|
mentions = MENTION_RE.findall(text or "")
|
||||||
|
if policy.max_mentions >= 0 and len(mentions) > policy.max_mentions:
|
||||||
|
reasons.append(f"mentions>{policy.max_mentions}")
|
||||||
|
|
||||||
|
wl, bl = _domain_sets(s, policy)
|
||||||
|
from app.moderation.engine import _find_domains
|
||||||
|
doms = _find_domains(text or "")
|
||||||
|
if policy.use_whitelist and wl:
|
||||||
|
bad = [d for d in doms if d not in wl]
|
||||||
|
if bad:
|
||||||
|
reasons.append("not_whitelisted:" + ",".join(sorted(set(bad))))
|
||||||
|
else:
|
||||||
|
bad = [d for d in doms if d in bl]
|
||||||
|
if bad:
|
||||||
|
reasons.append("blacklisted:" + ",".join(sorted(set(bad))))
|
||||||
|
|
||||||
|
# словари (только если дешёвые проверки не сработали)
|
||||||
|
if not reasons:
|
||||||
|
ac, reg = dict_cache.get(policy.id) or _compile_dicts(s, policy)
|
||||||
|
lo = (text or "").lower()
|
||||||
|
matched = False
|
||||||
|
if ac is not None:
|
||||||
|
for _, _ in ac.iter(lo):
|
||||||
|
matched = True
|
||||||
|
break
|
||||||
|
if not matched:
|
||||||
|
for r in reg:
|
||||||
|
if r.search(text or ""):
|
||||||
|
matched = True
|
||||||
|
break
|
||||||
|
if matched:
|
||||||
|
reasons.append("dictionary_match")
|
||||||
|
|
||||||
|
if not reasons:
|
||||||
|
return
|
||||||
|
|
||||||
|
action = add_strike_and_decide_action(s, policy, chat.id, user.id)
|
||||||
|
|
||||||
|
# Применение наказания
|
||||||
|
MSG_BLOCKED.inc()
|
||||||
|
performed = "none"
|
||||||
|
try:
|
||||||
|
if action in ("delete", "warn", "timeout", "ban"):
|
||||||
|
try:
|
||||||
|
await ctx.bot.delete_message(chat_id=chat.id, message_id=msg.message_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if action == "warn":
|
||||||
|
warn = await msg.reply_text(
|
||||||
|
f"⚠️ Сообщение удалено по правилам чата.\nПричины: {', '.join(reasons)}",
|
||||||
|
parse_mode=ParseMode.HTML
|
||||||
|
)
|
||||||
|
ctx.application.create_task(_autodel(ctx, chat.id, warn.message_id))
|
||||||
|
performed = "warn"
|
||||||
|
elif action == "timeout":
|
||||||
|
until = datetime.utcnow() + timedelta(minutes=policy.timeout_minutes)
|
||||||
|
await ctx.bot.restrict_chat_member(chat_id=chat.id, user_id=user.id,
|
||||||
|
permissions=ChatPermissions(can_send_messages=False),
|
||||||
|
until_date=until)
|
||||||
|
info = await msg.reply_text(
|
||||||
|
f"⏳ Пользователь ограничен на {policy.timeout_minutes} минут. Причины: {', '.join(reasons)}"
|
||||||
|
)
|
||||||
|
ctx.application.create_task(_autodel(ctx, chat.id, info.message_id))
|
||||||
|
performed = "timeout"
|
||||||
|
elif action == "ban":
|
||||||
|
await ctx.bot.ban_chat_member(chat_id=chat.id, user_id=user.id)
|
||||||
|
info = await msg.reply_text(f"🚫 Пользователь забанен. Причины: {', '.join(reasons)}")
|
||||||
|
ctx.application.create_task(_autodel(ctx, chat.id, info.message_id))
|
||||||
|
performed = "ban"
|
||||||
|
elif action == "delete":
|
||||||
|
performed = "delete"
|
||||||
|
finally:
|
||||||
|
# Лог модерации
|
||||||
|
try:
|
||||||
|
with get_session() as s2:
|
||||||
|
s2.add(ModerationLog(chat_id=chat.id, tg_user_id=user.id,
|
||||||
|
message_id=msg.message_id,
|
||||||
|
reason="; ".join(reasons), action=performed))
|
||||||
|
s2.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
247
app/bot/handlers/security.py
Normal file
247
app/bot/handlers/security.py
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
import io
|
||||||
|
from telegram import Update
|
||||||
|
from telegram.constants import ChatType
|
||||||
|
from telegram.ext import ContextTypes
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
|
||||||
|
from app.db.session import get_session
|
||||||
|
from app.db.models import (
|
||||||
|
User, SecurityPolicy, ChatSecurity,
|
||||||
|
SpamDictionary, DictionaryEntry, PolicyDictionaryLink, # NEW: PolicyDictionaryLink
|
||||||
|
)
|
||||||
|
from app.bot.keyboards.security import kb_policy
|
||||||
|
from telegram import InlineKeyboardMarkup, InlineKeyboardButton
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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)
|
||||||
|
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")
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
# словарь
|
||||||
|
d = SpamDictionary(
|
||||||
|
owner_user_id=u.id,
|
||||||
|
name=params["name"], category=params["category"],
|
||||||
|
kind=params["kind"], lang=params.get("lang"),
|
||||||
|
)
|
||||||
|
session.add(d); session.commit(); session.refresh(d)
|
||||||
|
|
||||||
|
# NEW: авто-привязка к дефолт-политике владельца (Balanced)
|
||||||
|
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:
|
||||||
|
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")))
|
||||||
|
n += 1
|
||||||
|
session.commit()
|
||||||
|
return n
|
||||||
|
|
||||||
|
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
|
||||||
|
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))
|
||||||
|
|
||||||
|
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
|
||||||
|
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
|
||||||
|
if action == "toggle":
|
||||||
|
field = parts[2]; setattr(p, field, not getattr(p, field)); s.commit()
|
||||||
|
elif action == "adj":
|
||||||
|
field, delta = parts[2], int(parts[3]); val = getattr(p, field); setattr(p, field, max(0, val+delta)); s.commit()
|
||||||
|
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()
|
||||||
|
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
|
||||||
|
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
|
||||||
|
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
|
||||||
|
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
|
||||||
|
await q.edit_message_reply_markup(reply_markup=kb_policy(p, chat_bound=bound, enabled=enabled))
|
||||||
|
|
||||||
|
# === Импорт словаря ===
|
||||||
|
async def spam_import_cmd(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||||
|
log.info("spam_import_cmd from %s", update.effective_user.id)
|
||||||
|
ctx.user_data["await_dict_file"] = True
|
||||||
|
ctx.user_data.pop("dict_params", None)
|
||||||
|
await update.effective_message.reply_text(
|
||||||
|
"Пришлите .txt/.csv ФАЙЛОМ — один паттерн на строку.\n"
|
||||||
|
"Подпись (необязательно): 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"):
|
||||||
|
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()
|
||||||
|
bio = io.BytesIO()
|
||||||
|
await file.download_to_memory(out=bio)
|
||||||
|
bio.seek(0)
|
||||||
|
text = _decode_bytes(bio.read())
|
||||||
|
lines = [l.strip() for l in text.splitlines() if l.strip()]
|
||||||
|
if not lines:
|
||||||
|
await update.effective_message.reply_text("Файл пуст. Добавьте строки с паттернами.")
|
||||||
|
return
|
||||||
|
params = _parse_params(update.message.caption, doc.file_name or "dict")
|
||||||
|
with get_session() as s:
|
||||||
|
n = _import_entries(s, update.effective_user.id, params, lines)
|
||||||
|
ctx.user_data.pop("await_dict_file", None)
|
||||||
|
await update.effective_message.reply_text(f"Импортировано {n} записей в словарь «{params['name']}».")
|
||||||
|
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"):
|
||||||
|
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:
|
||||||
|
with get_session() as s:
|
||||||
|
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']}».")
|
||||||
|
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))
|
||||||
@@ -6,6 +6,7 @@ def kb_next_text(draft_id: int):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def kb_confirm(draft_id: int):
|
def kb_confirm(draft_id: int):
|
||||||
|
# Кнопка «Отправить» ведёт к мультивыбору чатов
|
||||||
return InlineKeyboardMarkup(
|
return InlineKeyboardMarkup(
|
||||||
[
|
[
|
||||||
[
|
[
|
||||||
@@ -15,7 +16,21 @@ def kb_confirm(draft_id: int):
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def kb_multiselect(draft_id: int, chats: list[tuple[str, int]], selected: set[int]):
|
||||||
|
rows = []
|
||||||
|
for title, chat_id in chats:
|
||||||
|
mark = "✅ " if chat_id in selected else "▫️ "
|
||||||
|
rows.append([InlineKeyboardButton(f"{mark}{title}", callback_data=f"tgl:{draft_id}:{chat_id}")])
|
||||||
|
|
||||||
|
rows.append([
|
||||||
|
InlineKeyboardButton("Выбрать все", callback_data=f"selall:{draft_id}"),
|
||||||
|
InlineKeyboardButton("Сбросить", callback_data=f"clear:{draft_id}"),
|
||||||
|
])
|
||||||
|
rows.append([
|
||||||
|
InlineKeyboardButton("Отправить выбранные", callback_data=f"sendmulti:{draft_id}"),
|
||||||
|
InlineKeyboardButton("Отменить", callback_data=f"draft_cancel:{draft_id}"),
|
||||||
|
])
|
||||||
|
return InlineKeyboardMarkup(rows)
|
||||||
|
|
||||||
def kb_choose_chat(draft_id: int, chats: list[tuple[str, int]]):
|
def kb_choose_chat(draft_id: int, chats: list[tuple[str, int]]):
|
||||||
# chats: list of (title, chat_id)
|
return kb_multiselect(draft_id, chats, selected=set())
|
||||||
rows = [[InlineKeyboardButton(title, callback_data=f"send:{draft_id}:{chat_id}")] for title, chat_id in chats]
|
|
||||||
return InlineKeyboardMarkup(rows) if rows else None
|
|
||||||
28
app/bot/keyboards/security.py
Normal file
28
app/bot/keyboards/security.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
from telegram import InlineKeyboardMarkup, InlineKeyboardButton
|
||||||
|
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"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}"),
|
||||||
|
InlineKeyboardButton("(+30)", callback_data=f"pol:adj:duplicate_window_seconds:+30:{p.id}")],
|
||||||
|
[InlineKeyboardButton(f"Max links {p.max_links} (-1)", callback_data=f"pol:adj:max_links:-1:{p.id}"),
|
||||||
|
InlineKeyboardButton("(+1)", callback_data=f"pol:adj:max_links:+1:{p.id}")],
|
||||||
|
[InlineKeyboardButton(f"Max @ {p.max_mentions} (-1)", callback_data=f"pol:adj:max_mentions:-1:{p.id}"),
|
||||||
|
InlineKeyboardButton("(+1)", callback_data=f"pol:adj:max_mentions:+1:{p.id}")],
|
||||||
|
[InlineKeyboardButton(f"Whitelist mode: {'ON' if p.use_whitelist else 'OFF'}", callback_data=f"pol:toggle:use_whitelist:{p.id}")],
|
||||||
|
[InlineKeyboardButton(f"Action: {p.enforce_action_default}", callback_data=f"pol:cycle_action:{p.id}")],
|
||||||
|
[InlineKeyboardButton(f"Timeout {p.timeout_minutes}m (-5)", callback_data=f"pol:adj:timeout_minutes:-5:{p.id}"),
|
||||||
|
InlineKeyboardButton("(+5)", callback_data=f"pol:adj:timeout_minutes:+5:{p.id}")],
|
||||||
|
]
|
||||||
|
if chat_bound:
|
||||||
|
rows.append([InlineKeyboardButton(f"Moderation: {'ON' if enabled else 'OFF'}", callback_data=f"pol:toggle_chat:{p.id}")])
|
||||||
|
else:
|
||||||
|
rows.append([InlineKeyboardButton("Привязать к этому чату", callback_data=f"pol:bind_here:{p.id}")])
|
||||||
|
return InlineKeyboardMarkup(rows)
|
||||||
@@ -1,19 +1,17 @@
|
|||||||
START = (
|
START = (
|
||||||
"Привет! Я помогу отправлять сообщения в ваши группы и каналы.\n\n"
|
"Привет! Я помогу отправлять сообщения в ваши группы и каналы.\n\n"
|
||||||
"1) Добавьте меня в группу/канал (в канале — дайте право публиковать).\n"
|
"1) Добавьте меня в группу/канал (в канале — дайте право публиковать).\n"
|
||||||
"2) В ЛС боту нажмите /add_group и вставьте chat_id или перешлите сюда сообщение из этого чата.\n"
|
"2) В ЛС выполните /add_group и вставьте chat_id или перешлите сообщение из этого чата.\n"
|
||||||
"3) Создайте черновик /new и отправьте в выбранный чат.\n\n"
|
"3) Создайте черновик /new и отправьте. Также доступна модерация чатов через /security."
|
||||||
"Команды:\n"
|
|
||||||
"/add_group — привязать группу/канал вручную\n"
|
|
||||||
"/groups — список моих чатов\n"
|
|
||||||
"/new — создать черновик поста\n"
|
|
||||||
"/help — справка"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
HELP = (
|
HELP = (
|
||||||
"1) В Telegram добавьте бота в группу/канал (для каналов — админ с правом «Публиковать сообщения»).\n"
|
"Команды:\n"
|
||||||
"2) В ЛС — /add_group: вставьте chat_id (например, -100123...) или перешлите сюда любое сообщение из чата.\n"
|
"/add_group — привязать группу/канал вручно\n"
|
||||||
"3) Создайте черновик /new и отправьте его в выбранный чат."
|
"/groups — список ваших чатов\n"
|
||||||
|
"/new — конструктор поста (медиа→текст→подтверждение)\n"
|
||||||
|
"/security — политика безопасности, словари, включение модерации\n"
|
||||||
|
"/spam_import — импорт словаря (txt/csv) в ЛС\n"
|
||||||
|
"/id — показать chat_id (только для админов)"
|
||||||
)
|
)
|
||||||
|
|
||||||
ASK_ADD_GROUP = (
|
ASK_ADD_GROUP = (
|
||||||
@@ -24,16 +22,16 @@ ASK_ADD_GROUP = (
|
|||||||
NO_CHATS = "Пока ни одного чата не привязано. Нажмите /add_group для привязки."
|
NO_CHATS = "Пока ни одного чата не привязано. Нажмите /add_group для привязки."
|
||||||
|
|
||||||
# Пошаговый редактор
|
# Пошаговый редактор
|
||||||
|
# Шаги редактора
|
||||||
ASK_MEDIA = (
|
ASK_MEDIA = (
|
||||||
"Шаг 1/3 — медиа.\nПришлите фото/видео/гиф. Можно несколько (альбом).\n"
|
"Шаг 1/3 — медиа.\nПришлите фото/видео/гиф. Можно несколько (альбом).\n"
|
||||||
"Когда закончите — нажмите «Дальше — текст»."
|
"Кнопка «Дальше — текст» появится под сообщением «Медиа добавлено»."
|
||||||
)
|
|
||||||
ASK_TEXT = (
|
|
||||||
"Шаг 2/3 — текст.\nОтправьте текст поста. Он станет подписью к медиа (или отдельным сообщением, если медиа нет)."
|
|
||||||
)
|
|
||||||
CONFIRM = (
|
|
||||||
"Шаг 3/3 — подтверждение.\nПроверьте пост и нажмите «Отправить» или «Отменить»."
|
|
||||||
)
|
)
|
||||||
|
ASK_TEXT = "Шаг 2/3 — текст.\nОтправьте текст поста."
|
||||||
|
CONFIRM = "Шаг 3/3 — подтверждение.\nПроверьте пост и нажмите «Отправить» или «Отменить»."
|
||||||
|
|
||||||
|
# Сообщения
|
||||||
|
|
||||||
|
|
||||||
TEXT_ADDED = "Текст добавлен в черновик."
|
TEXT_ADDED = "Текст добавлен в черновик."
|
||||||
MEDIA_ADDED = "Медиа добавлено ({kind})."
|
MEDIA_ADDED = "Медиа добавлено ({kind})."
|
||||||
@@ -45,7 +43,9 @@ CANCELLED = "Черновик отменён."
|
|||||||
|
|
||||||
READY_SELECT_CHAT = "Куда отправить?"
|
READY_SELECT_CHAT = "Куда отправить?"
|
||||||
SENT_OK = "✅ Отправлено."
|
SENT_OK = "✅ Отправлено."
|
||||||
|
SENT_SUMMARY = "✅ Готово. Успешно: {ok} Ошибок: {fail}"
|
||||||
SEND_ERR = "❌ Ошибка отправки: {e}"
|
SEND_ERR = "❌ Ошибка отправки: {e}"
|
||||||
|
NO_SELECTION = "❌ Не выбрано ни одного чата."
|
||||||
|
|
||||||
GROUP_BOUND = "Чат «{title_or_id}» привязан.\n{rights}"
|
GROUP_BOUND = "Чат «{title_or_id}» привязан.\n{rights}"
|
||||||
NEED_ADD_FIRST = "Я не добавлен в «{title_or_id}». Сначала добавьте бота в этот чат и повторите /add_group."
|
NEED_ADD_FIRST = "Я не добавлен в «{title_or_id}». Сначала добавьте бота в этот чат и повторите /add_group."
|
||||||
@@ -107,4 +107,12 @@ JOIN_PUBLIC_WITH_ID = (
|
|||||||
NEED_START_DM = (
|
NEED_START_DM = (
|
||||||
"Не удалось отправить ЛС: Telegram запрещает писать до нажатия «Start».\n"
|
"Не удалось отправить ЛС: Telegram запрещает писать до нажатия «Start».\n"
|
||||||
"Откройте мой профиль и нажмите Start, затем /add_group."
|
"Откройте мой профиль и нажмите Start, затем /add_group."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Кнопка быстрой привязки канала
|
||||||
|
BIND_CHANNEL_BTN = "Привязать этот канал"
|
||||||
|
BIND_OK = "Канал «{title}» привязан. ✅"
|
||||||
|
BIND_FAIL_NOT_ADMIN = "Привязку может выполнять только администратор этого канала."
|
||||||
|
BIND_FAIL_BOT_RIGHTS = "Я не администратор в канале или у меня нет права публиковать сообщения."
|
||||||
|
BIND_FAIL_GENERIC = "Не получилось привязать канал. Попробуйте /add_group."
|
||||||
@@ -5,6 +5,7 @@ from dataclasses import dataclass
|
|||||||
class Config:
|
class Config:
|
||||||
bot_token: str
|
bot_token: str
|
||||||
database_url: str
|
database_url: str
|
||||||
|
metrics_port: int = 8000
|
||||||
log_level: str = os.getenv("LOG_LEVEL", "INFO")
|
log_level: str = os.getenv("LOG_LEVEL", "INFO")
|
||||||
|
|
||||||
def load_config() -> "Config":
|
def load_config() -> "Config":
|
||||||
@@ -20,4 +21,5 @@ def load_config() -> "Config":
|
|||||||
user = os.getenv("DB_USER", "postgres")
|
user = os.getenv("DB_USER", "postgres")
|
||||||
pwd = os.getenv("DB_PASSWORD", "postgres")
|
pwd = os.getenv("DB_PASSWORD", "postgres")
|
||||||
db_url = f"postgresql+psycopg://{user}:{pwd}@{host}:{port}/{name}"
|
db_url = f"postgresql+psycopg://{user}:{pwd}@{host}:{port}/{name}"
|
||||||
return Config(bot_token=bot_token, database_url=db_url)
|
metric_port = int(os.getenv("METRICS_PORT", 9010))
|
||||||
|
return Config(bot_token=bot_token, database_url=db_url, metrics_port=metric_port)
|
||||||
169
app/db/models.py
169
app/db/models.py
@@ -1,20 +1,35 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from sqlalchemy import Column, Integer, BigInteger, String, DateTime, ForeignKey, Text, Boolean
|
from sqlalchemy import (
|
||||||
|
Column,
|
||||||
|
Integer,
|
||||||
|
BigInteger,
|
||||||
|
String,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
Text,
|
||||||
|
Boolean,
|
||||||
|
UniqueConstraint,
|
||||||
|
)
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
from app.db.base import Base
|
from app.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
class User(Base):
|
class User(Base):
|
||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
tg_id = Column(BigInteger, unique=True, nullable=False, index=True)
|
tg_id = Column(BigInteger, unique=True, nullable=False, index=True)
|
||||||
name = Column(String(255))
|
name = Column(String(255))
|
||||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
class Chat(Base):
|
class Chat(Base):
|
||||||
__tablename__ = "chats"
|
__tablename__ = "chats"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
chat_id = Column(BigInteger, unique=True, nullable=False, index=True)
|
chat_id = Column(BigInteger, unique=True, nullable=False, index=True)
|
||||||
type = Column(String(32)) # "group" | "supergroup" | "channel"
|
type = Column(String(32)) # group | supergroup | channel
|
||||||
title = Column(String(255))
|
title = Column(String(255))
|
||||||
owner_user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
|
owner_user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
|
||||||
can_post = Column(Boolean, default=False, nullable=False)
|
can_post = Column(Boolean, default=False, nullable=False)
|
||||||
@@ -22,35 +37,179 @@ class Chat(Base):
|
|||||||
|
|
||||||
owner = relationship("User")
|
owner = relationship("User")
|
||||||
|
|
||||||
|
|
||||||
class Draft(Base):
|
class Draft(Base):
|
||||||
__tablename__ = "drafts"
|
__tablename__ = "drafts"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
user_id = Column(Integer, ForeignKey("users.id"), index=True)
|
user_id = Column(Integer, ForeignKey("users.id"), index=True)
|
||||||
text = Column(Text, nullable=True)
|
text = Column(Text, nullable=True)
|
||||||
status = Column(String(16), default="editing", index=True) # editing | ready | sent
|
status = Column(String(16), default="editing", index=True) # editing|ready|sent|cancelled
|
||||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
updated_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||||
|
|
||||||
user = relationship("User")
|
user = relationship("User")
|
||||||
media = relationship("DraftMedia", cascade="all, delete-orphan", back_populates="draft")
|
media = relationship("DraftMedia", cascade="all, delete-orphan", back_populates="draft")
|
||||||
|
|
||||||
|
|
||||||
class DraftMedia(Base):
|
class DraftMedia(Base):
|
||||||
__tablename__ = "draft_media"
|
__tablename__ = "draft_media"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
draft_id = Column(Integer, ForeignKey("drafts.id"), index=True)
|
draft_id = Column(Integer, ForeignKey("drafts.id"), index=True)
|
||||||
kind = Column(String(16)) # photo | video | animation
|
kind = Column(String(16)) # photo | video | animation
|
||||||
file_id = Column(String(255))
|
file_id = Column(String(255))
|
||||||
"""Ordering inside album."""
|
|
||||||
order = Column(Integer, default=0)
|
order = Column(Integer, default=0)
|
||||||
|
|
||||||
draft = relationship("Draft", back_populates="media")
|
draft = relationship("Draft", back_populates="media")
|
||||||
|
|
||||||
|
|
||||||
class Delivery(Base):
|
class Delivery(Base):
|
||||||
__tablename__ = "deliveries"
|
__tablename__ = "deliveries"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
draft_id = Column(Integer, ForeignKey("drafts.id"), index=True)
|
draft_id = Column(Integer, ForeignKey("drafts.id"), index=True)
|
||||||
chat_id = Column(BigInteger, index=True)
|
chat_id = Column(BigInteger, index=True)
|
||||||
status = Column(String(16), default="new", index=True) # new | sent | failed
|
status = Column(String(16), default="new", index=True) # new | sent | failed
|
||||||
error = Column(Text, nullable=True)
|
error = Column(Text, nullable=True)
|
||||||
message_ids = Column(Text, nullable=True) # CSV for album parts
|
message_ids = Column(Text, nullable=True) # csv для альбомов/нескольких сообщений
|
||||||
|
content_hash = Column(String(128), index=True, nullable=True) # анти-дубликаты
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
# --- учёт участников (для аналитики/модерации) ---
|
||||||
|
class ChatMember(Base):
|
||||||
|
__tablename__ = "chat_members"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
chat_id = Column(BigInteger, index=True, nullable=False)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id"), index=True, nullable=False)
|
||||||
|
tg_user_id = Column(BigInteger, index=True, nullable=False)
|
||||||
|
username = Column(String(255))
|
||||||
|
first_name = Column(String(255))
|
||||||
|
last_name = Column(String(255))
|
||||||
|
status = Column(String(32), index=True) # member | administrator | creator | left | kicked ...
|
||||||
|
is_admin = Column(Boolean, default=False, nullable=False)
|
||||||
|
first_seen_at = Column(DateTime, nullable=False, default=datetime.utcnow)
|
||||||
|
last_seen_at = Column(DateTime, nullable=False, default=datetime.utcnow)
|
||||||
|
|
||||||
|
user = relationship("User")
|
||||||
|
|
||||||
|
__table_args__ = (UniqueConstraint("chat_id", "tg_user_id", name="uq_chat_members_chat_user"),)
|
||||||
|
|
||||||
|
|
||||||
|
# --- политики безопасности и словари ---
|
||||||
|
class SecurityPolicy(Base):
|
||||||
|
__tablename__ = "security_policies"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
owner_user_id = Column(Integer, ForeignKey("users.id"), index=True, nullable=True) # NULL = глобальная
|
||||||
|
name = Column(String(100), nullable=False)
|
||||||
|
|
||||||
|
# категории словарей (True = блокировать)
|
||||||
|
block_adult = Column(Boolean, default=True, nullable=False)
|
||||||
|
block_spam = Column(Boolean, default=True, nullable=False)
|
||||||
|
block_scam = Column(Boolean, default=True, nullable=False)
|
||||||
|
block_profanity = Column(Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
|
# лимиты и режимы
|
||||||
|
cooldown_seconds = Column(Integer, default=30, nullable=False) # пауза между постами/чат
|
||||||
|
duplicate_window_seconds = Column(Integer, default=120, nullable=False) # окно дублей (сек)
|
||||||
|
max_links = Column(Integer, default=3, nullable=False)
|
||||||
|
max_mentions = Column(Integer, default=5, nullable=False)
|
||||||
|
use_whitelist = Column(Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
|
# наказания/эскалация (для входящих сообщений в группе)
|
||||||
|
enforce_action_default = Column(String(16), default="delete", nullable=False) # delete|warn|timeout|ban|none
|
||||||
|
timeout_minutes = Column(Integer, default=10, nullable=False)
|
||||||
|
strikes_to_warn = Column(Integer, default=1, nullable=False)
|
||||||
|
strikes_to_timeout = Column(Integer, default=2, nullable=False)
|
||||||
|
strikes_to_ban = Column(Integer, default=3, nullable=False)
|
||||||
|
user_msg_per_minute = Column(Integer, default=0, nullable=False) # 0 = выключено
|
||||||
|
|
||||||
|
|
||||||
|
class ChatSecurity(Base):
|
||||||
|
__tablename__ = "chat_security"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
chat_id = Column(BigInteger, index=True, nullable=False, unique=True)
|
||||||
|
policy_id = Column(Integer, ForeignKey("security_policies.id"), index=True, nullable=False)
|
||||||
|
enabled = Column(Boolean, default=False, nullable=False) # включена ли модерация для этого чата
|
||||||
|
|
||||||
|
|
||||||
|
class SpamDictionary(Base):
|
||||||
|
__tablename__ = "spam_dictionaries"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
owner_user_id = Column(Integer, ForeignKey("users.id"), index=True, nullable=True) # NULL = глобальная
|
||||||
|
name = Column(String(120), nullable=False)
|
||||||
|
category = Column(String(32), nullable=False) # adult | spam | scam | profanity | custom
|
||||||
|
kind = Column(String(16), nullable=False) # plain | regex
|
||||||
|
lang = Column(String(8), nullable=True)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class DictionaryEntry(Base):
|
||||||
|
__tablename__ = "dictionary_entries"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
dictionary_id = Column(Integer, ForeignKey("spam_dictionaries.id"), index=True, nullable=False)
|
||||||
|
pattern = Column(Text, nullable=False) # слово/фраза или регулярка
|
||||||
|
is_regex = Column(Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class PolicyDictionaryLink(Base):
|
||||||
|
__tablename__ = "policy_dict_links"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
policy_id = Column(Integer, ForeignKey("security_policies.id"), index=True, nullable=False)
|
||||||
|
dictionary_id = Column(Integer, ForeignKey("spam_dictionaries.id"), index=True, nullable=False)
|
||||||
|
|
||||||
|
__table_args__ = (UniqueConstraint("policy_id", "dictionary_id", name="uq_policy_dict"),)
|
||||||
|
|
||||||
|
|
||||||
|
class DomainRule(Base):
|
||||||
|
__tablename__ = "domain_rules"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
policy_id = Column(Integer, ForeignKey("security_policies.id"), index=True, nullable=False)
|
||||||
|
domain = Column(String(255), nullable=False)
|
||||||
|
kind = Column(String(16), nullable=False) # whitelist | blacklist
|
||||||
|
|
||||||
|
__table_args__ = (UniqueConstraint("policy_id", "domain", "kind", name="uq_domain_rule"),)
|
||||||
|
|
||||||
|
|
||||||
|
# --- журнал модерации/событий ---
|
||||||
|
class ModerationLog(Base):
|
||||||
|
__tablename__ = "moderation_logs"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
chat_id = Column(BigInteger, index=True, nullable=False)
|
||||||
|
tg_user_id = Column(BigInteger, index=True, nullable=False)
|
||||||
|
message_id = Column(BigInteger, nullable=True)
|
||||||
|
reason = Column(Text, nullable=False) # причины (через '; '), либо текст ошибки
|
||||||
|
action = Column(String(16), nullable=False) # delete|warn|timeout|ban|error|none
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class UserStrike(Base):
|
||||||
|
__tablename__ = "user_strikes"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
chat_id = Column(BigInteger, index=True, nullable=False)
|
||||||
|
tg_user_id = Column(BigInteger, index=True, nullable=False)
|
||||||
|
strikes = Column(Integer, default=0, nullable=False)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||||
|
|
||||||
|
__table_args__ = (UniqueConstraint("chat_id", "tg_user_id", name="uq_strikes_chat_user"),)
|
||||||
|
|
||||||
|
|
||||||
|
class MessageEvent(Base):
|
||||||
|
__tablename__ = "message_events"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
chat_id = Column(BigInteger, index=True, nullable=False)
|
||||||
|
tg_user_id = Column(BigInteger, index=True, nullable=False)
|
||||||
|
message_id = Column(BigInteger, nullable=True)
|
||||||
|
content_hash = Column(String(128), index=True, nullable=True)
|
||||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||||
|
|||||||
0
app/infra/__init__.py
Normal file
0
app/infra/__init__.py
Normal file
15
app/infra/metrics.py
Normal file
15
app/infra/metrics.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import os
|
||||||
|
from prometheus_client import Counter, Histogram, start_http_server
|
||||||
|
|
||||||
|
# Счётчики/гистограммы для модерации и команд
|
||||||
|
MSG_PROCESSED = Counter("mod_messages_processed_total", "Incoming messages processed")
|
||||||
|
MSG_BLOCKED = Counter("mod_messages_blocked_total", "Incoming messages blocked")
|
||||||
|
MOD_LAT = Histogram("moderation_latency_seconds", "Latency of moderation checks",
|
||||||
|
buckets=(0.02, 0.05, 0.1, 0.2, 0.5, 1, 2))
|
||||||
|
|
||||||
|
def start_metrics_server(port: int):
|
||||||
|
try:
|
||||||
|
start_http_server(port)
|
||||||
|
except Exception:
|
||||||
|
# не валим бота, если порт занят/метрики не взлетели
|
||||||
|
pass
|
||||||
25
app/infra/redis_client.py
Normal file
25
app/infra/redis_client.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import os
|
||||||
|
try:
|
||||||
|
from redis import asyncio as aioredis
|
||||||
|
except Exception:
|
||||||
|
aioredis = None
|
||||||
|
|
||||||
|
_redis = None
|
||||||
|
|
||||||
|
async def get_redis():
|
||||||
|
"""
|
||||||
|
Возвращает подключение к Redis (async) или None, если REDIS_URL не задан
|
||||||
|
или библиотека не установлена.
|
||||||
|
"""
|
||||||
|
global _redis
|
||||||
|
if _redis is not None:
|
||||||
|
return _redis
|
||||||
|
url = os.getenv("REDIS_URL", "").strip()
|
||||||
|
if not url or aioredis is None:
|
||||||
|
return None
|
||||||
|
_redis = aioredis.from_url(url, encoding="utf-8", decode_responses=True)
|
||||||
|
try:
|
||||||
|
await _redis.ping()
|
||||||
|
except Exception:
|
||||||
|
_redis = None
|
||||||
|
return _redis
|
||||||
41
app/main.py
41
app/main.py
@@ -8,6 +8,14 @@ from app.bot.handlers.media import on_media
|
|||||||
from app.bot.handlers.callbacks import on_callback
|
from app.bot.handlers.callbacks import on_callback
|
||||||
from app.bot.handlers.join_info import on_my_chat_member
|
from app.bot.handlers.join_info import on_my_chat_member
|
||||||
from app.bot.handlers.chat_id_cmd import chat_id_cmd
|
from app.bot.handlers.chat_id_cmd import chat_id_cmd
|
||||||
|
from app.bot.handlers.bind_chat import bind_chat_cb
|
||||||
|
from app.bot.handlers.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
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
@@ -21,18 +29,43 @@ def main():
|
|||||||
app.add_handler(CommandHandler("add_group", add_group_cmd))
|
app.add_handler(CommandHandler("add_group", add_group_cmd))
|
||||||
app.add_handler(CommandHandler("new", new_cmd))
|
app.add_handler(CommandHandler("new", new_cmd))
|
||||||
app.add_handler(CommandHandler("id", chat_id_cmd))
|
app.add_handler(CommandHandler("id", chat_id_cmd))
|
||||||
|
app.add_handler(CommandHandler("mod_status", mod_status_cmd))
|
||||||
|
# команды
|
||||||
|
app.add_handler(CommandHandler("dicts", dicts_cmd))
|
||||||
|
|
||||||
# Callback queries
|
# коллбэки словарей
|
||||||
app.add_handler(CallbackQueryHandler(on_callback))
|
app.add_handler(CallbackQueryHandler(dicts_cb, pattern=r"^dict:"))
|
||||||
|
|
||||||
|
# Callbacks (order matters!)
|
||||||
|
app.add_handler(CallbackQueryHandler(security_cb, pattern=r"^pol:"))
|
||||||
|
app.add_handler(CallbackQueryHandler(bind_chat_cb, pattern=r"^bind:"))
|
||||||
|
app.add_handler(CallbackQueryHandler(on_callback, pattern=r"^(draft_|tgl:|selall:|clear:|sendmulti:)"))
|
||||||
|
|
||||||
# Private chat handlers
|
# Private chat helpers
|
||||||
app.add_handler(MessageHandler(filters.ChatType.PRIVATE & filters.TEXT, on_text))
|
|
||||||
app.add_handler(MessageHandler(filters.ChatType.PRIVATE & filters.FORWARDED, add_group_capture))
|
app.add_handler(MessageHandler(filters.ChatType.PRIVATE & filters.FORWARDED, add_group_capture))
|
||||||
app.add_handler(MessageHandler(filters.ChatType.PRIVATE & (filters.PHOTO | filters.VIDEO | filters.ANIMATION), on_media))
|
app.add_handler(MessageHandler(filters.ChatType.PRIVATE & (filters.PHOTO | filters.VIDEO | filters.ANIMATION), on_media))
|
||||||
|
|
||||||
# Join/rights updates
|
# Join/rights updates
|
||||||
app.add_handler(ChatMemberHandler(on_my_chat_member, chat_member_types=ChatMemberHandler.MY_CHAT_MEMBER))
|
app.add_handler(ChatMemberHandler(on_my_chat_member, chat_member_types=ChatMemberHandler.MY_CHAT_MEMBER))
|
||||||
|
|
||||||
|
# Security / Dict
|
||||||
|
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__":
|
||||||
|
|||||||
185
app/migrations/versions/492141c83560_wip.py
Normal file
185
app/migrations/versions/492141c83560_wip.py
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
"""wip
|
||||||
|
|
||||||
|
Revision ID: 492141c83560
|
||||||
|
Revises: 0001_init
|
||||||
|
Create Date: 2025-08-22 09:31:45.682385
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '492141c83560'
|
||||||
|
down_revision = '0001_init'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('message_events',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('chat_id', sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column('tg_user_id', sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column('message_id', sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column('content_hash', sa.String(length=128), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_message_events_chat_id'), 'message_events', ['chat_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_message_events_content_hash'), 'message_events', ['content_hash'], unique=False)
|
||||||
|
op.create_index(op.f('ix_message_events_tg_user_id'), 'message_events', ['tg_user_id'], unique=False)
|
||||||
|
op.create_table('moderation_logs',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('chat_id', sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column('tg_user_id', sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column('message_id', sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column('reason', sa.Text(), nullable=False),
|
||||||
|
sa.Column('action', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_moderation_logs_chat_id'), 'moderation_logs', ['chat_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_moderation_logs_tg_user_id'), 'moderation_logs', ['tg_user_id'], unique=False)
|
||||||
|
op.create_table('user_strikes',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('chat_id', sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column('tg_user_id', sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column('strikes', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('chat_id', 'tg_user_id', name='uq_strikes_chat_user')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_user_strikes_chat_id'), 'user_strikes', ['chat_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_user_strikes_tg_user_id'), 'user_strikes', ['tg_user_id'], unique=False)
|
||||||
|
op.create_table('chat_members',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('chat_id', sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('tg_user_id', sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column('username', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('first_name', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('last_name', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('status', sa.String(length=32), nullable=True),
|
||||||
|
sa.Column('is_admin', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('first_seen_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('last_seen_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('chat_id', 'tg_user_id', name='uq_chat_members_chat_user')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_chat_members_chat_id'), 'chat_members', ['chat_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_chat_members_status'), 'chat_members', ['status'], unique=False)
|
||||||
|
op.create_index(op.f('ix_chat_members_tg_user_id'), 'chat_members', ['tg_user_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_chat_members_user_id'), 'chat_members', ['user_id'], unique=False)
|
||||||
|
op.create_table('security_policies',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('owner_user_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('name', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('block_adult', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('block_spam', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('block_scam', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('block_profanity', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('cooldown_seconds', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('duplicate_window_seconds', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('max_links', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('max_mentions', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('use_whitelist', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('enforce_action_default', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('timeout_minutes', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('strikes_to_warn', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('strikes_to_timeout', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('strikes_to_ban', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('user_msg_per_minute', sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['owner_user_id'], ['users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_security_policies_owner_user_id'), 'security_policies', ['owner_user_id'], unique=False)
|
||||||
|
op.create_table('spam_dictionaries',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('owner_user_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('name', sa.String(length=120), nullable=False),
|
||||||
|
sa.Column('category', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('kind', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('lang', sa.String(length=8), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['owner_user_id'], ['users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_spam_dictionaries_owner_user_id'), 'spam_dictionaries', ['owner_user_id'], unique=False)
|
||||||
|
op.create_table('chat_security',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('chat_id', sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column('policy_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('enabled', sa.Boolean(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['policy_id'], ['security_policies.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_chat_security_chat_id'), 'chat_security', ['chat_id'], unique=True)
|
||||||
|
op.create_index(op.f('ix_chat_security_policy_id'), 'chat_security', ['policy_id'], unique=False)
|
||||||
|
op.create_table('dictionary_entries',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('dictionary_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('pattern', sa.Text(), nullable=False),
|
||||||
|
sa.Column('is_regex', sa.Boolean(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['dictionary_id'], ['spam_dictionaries.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_dictionary_entries_dictionary_id'), 'dictionary_entries', ['dictionary_id'], unique=False)
|
||||||
|
op.create_table('domain_rules',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('policy_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('domain', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('kind', sa.String(length=16), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['policy_id'], ['security_policies.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('policy_id', 'domain', 'kind', name='uq_domain_rule')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_domain_rules_policy_id'), 'domain_rules', ['policy_id'], unique=False)
|
||||||
|
op.create_table('policy_dict_links',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('policy_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('dictionary_id', sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['dictionary_id'], ['spam_dictionaries.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['policy_id'], ['security_policies.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('policy_id', 'dictionary_id', name='uq_policy_dict')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_policy_dict_links_dictionary_id'), 'policy_dict_links', ['dictionary_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_policy_dict_links_policy_id'), 'policy_dict_links', ['policy_id'], unique=False)
|
||||||
|
op.add_column('deliveries', sa.Column('content_hash', sa.String(length=128), nullable=True))
|
||||||
|
op.create_index(op.f('ix_deliveries_content_hash'), 'deliveries', ['content_hash'], unique=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index(op.f('ix_deliveries_content_hash'), table_name='deliveries')
|
||||||
|
op.drop_column('deliveries', 'content_hash')
|
||||||
|
op.drop_index(op.f('ix_policy_dict_links_policy_id'), table_name='policy_dict_links')
|
||||||
|
op.drop_index(op.f('ix_policy_dict_links_dictionary_id'), table_name='policy_dict_links')
|
||||||
|
op.drop_table('policy_dict_links')
|
||||||
|
op.drop_index(op.f('ix_domain_rules_policy_id'), table_name='domain_rules')
|
||||||
|
op.drop_table('domain_rules')
|
||||||
|
op.drop_index(op.f('ix_dictionary_entries_dictionary_id'), table_name='dictionary_entries')
|
||||||
|
op.drop_table('dictionary_entries')
|
||||||
|
op.drop_index(op.f('ix_chat_security_policy_id'), table_name='chat_security')
|
||||||
|
op.drop_index(op.f('ix_chat_security_chat_id'), table_name='chat_security')
|
||||||
|
op.drop_table('chat_security')
|
||||||
|
op.drop_index(op.f('ix_spam_dictionaries_owner_user_id'), table_name='spam_dictionaries')
|
||||||
|
op.drop_table('spam_dictionaries')
|
||||||
|
op.drop_index(op.f('ix_security_policies_owner_user_id'), table_name='security_policies')
|
||||||
|
op.drop_table('security_policies')
|
||||||
|
op.drop_index(op.f('ix_chat_members_user_id'), table_name='chat_members')
|
||||||
|
op.drop_index(op.f('ix_chat_members_tg_user_id'), table_name='chat_members')
|
||||||
|
op.drop_index(op.f('ix_chat_members_status'), table_name='chat_members')
|
||||||
|
op.drop_index(op.f('ix_chat_members_chat_id'), table_name='chat_members')
|
||||||
|
op.drop_table('chat_members')
|
||||||
|
op.drop_index(op.f('ix_user_strikes_tg_user_id'), table_name='user_strikes')
|
||||||
|
op.drop_index(op.f('ix_user_strikes_chat_id'), table_name='user_strikes')
|
||||||
|
op.drop_table('user_strikes')
|
||||||
|
op.drop_index(op.f('ix_moderation_logs_tg_user_id'), table_name='moderation_logs')
|
||||||
|
op.drop_index(op.f('ix_moderation_logs_chat_id'), table_name='moderation_logs')
|
||||||
|
op.drop_table('moderation_logs')
|
||||||
|
op.drop_index(op.f('ix_message_events_tg_user_id'), table_name='message_events')
|
||||||
|
op.drop_index(op.f('ix_message_events_content_hash'), table_name='message_events')
|
||||||
|
op.drop_index(op.f('ix_message_events_chat_id'), table_name='message_events')
|
||||||
|
op.drop_table('message_events')
|
||||||
|
# ### end Alembic commands ###
|
||||||
0
app/moderation/__init__.py
Normal file
0
app/moderation/__init__.py
Normal file
42
app/moderation/cache.py
Normal file
42
app/moderation/cache.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import time
|
||||||
|
from typing import Any, Optional, Dict
|
||||||
|
|
||||||
|
class TTLCache:
|
||||||
|
"""
|
||||||
|
Простой in-memory кеш с TTL и грубой политикой вытеснения (удаляем самый старый ключ).
|
||||||
|
Не потокобезопасен — вызывайте из одного потока/процесса.
|
||||||
|
"""
|
||||||
|
def __init__(self, ttl_seconds: int = 60, max_size: int = 1024):
|
||||||
|
self.ttl = ttl_seconds
|
||||||
|
self.max = max_size
|
||||||
|
self._data: Dict[Any, Any] = {}
|
||||||
|
self._ts: Dict[Any, float] = {}
|
||||||
|
|
||||||
|
def get(self, key: Any) -> Optional[Any]:
|
||||||
|
now = time.time()
|
||||||
|
ts = self._ts.get(key)
|
||||||
|
if ts is None:
|
||||||
|
return None
|
||||||
|
if now - ts > self.ttl:
|
||||||
|
# истёк TTL
|
||||||
|
self._data.pop(key, None)
|
||||||
|
self._ts.pop(key, None)
|
||||||
|
return None
|
||||||
|
return self._data.get(key)
|
||||||
|
|
||||||
|
def set(self, key: Any, value: Any):
|
||||||
|
# простое вытеснение — удаляем самый старый
|
||||||
|
if len(self._data) >= self.max and self._ts:
|
||||||
|
oldest_key = min(self._ts.items(), key=lambda kv: kv[1])[0]
|
||||||
|
self._data.pop(oldest_key, None)
|
||||||
|
self._ts.pop(oldest_key, None)
|
||||||
|
self._data[key] = value
|
||||||
|
self._ts[key] = time.time()
|
||||||
|
|
||||||
|
def invalidate(self, key: Any):
|
||||||
|
self._data.pop(key, None)
|
||||||
|
self._ts.pop(key, None)
|
||||||
|
|
||||||
|
def clear(self):
|
||||||
|
self._data.clear()
|
||||||
|
self._ts.clear()
|
||||||
338
app/moderation/engine.py
Normal file
338
app/moderation/engine.py
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
# app/moderation/engine.py
|
||||||
|
import re
|
||||||
|
from typing import List, Optional
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
# Быстрый и стабильный хеш контента
|
||||||
|
try:
|
||||||
|
import xxhash
|
||||||
|
def _hash_init():
|
||||||
|
return xxhash.xxh3_128()
|
||||||
|
def _hash_hex(h): # noqa: ANN001
|
||||||
|
return h.hexdigest()
|
||||||
|
except Exception: # fallback на hashlib
|
||||||
|
import hashlib
|
||||||
|
def _hash_init():
|
||||||
|
return hashlib.sha256()
|
||||||
|
def _hash_hex(h): # noqa: ANN001
|
||||||
|
return h.hexdigest()
|
||||||
|
|
||||||
|
# Быстрый поиск plain-терминов
|
||||||
|
try:
|
||||||
|
import ahocorasick # pyahocorasick
|
||||||
|
except Exception:
|
||||||
|
ahocorasick = None # не критично — останутся только regex-словаря
|
||||||
|
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
|
||||||
|
from app.moderation.cache import TTLCache
|
||||||
|
from app.infra.redis_client import get_redis
|
||||||
|
from app.db.models import (
|
||||||
|
SecurityPolicy, ChatSecurity, Delivery,
|
||||||
|
SpamDictionary, DictionaryEntry, PolicyDictionaryLink, DomainRule,
|
||||||
|
ModerationLog, UserStrike, MessageEvent,
|
||||||
|
)
|
||||||
|
|
||||||
|
URL_RE = re.compile(r'https?://[^\s)]+', re.IGNORECASE)
|
||||||
|
MENTION_RE = re.compile(r'@\w+', re.UNICODE)
|
||||||
|
|
||||||
|
# Кеши (уменьшают число запросов к БД и компиляций)
|
||||||
|
policy_cache = TTLCache(ttl_seconds=60, max_size=4096) # chat_id -> snapshot(dict)
|
||||||
|
dict_cache = TTLCache(ttl_seconds=60, max_size=512) # policy_id -> (ac_automaton|None, [regex...])
|
||||||
|
domain_cache = TTLCache(ttl_seconds=60, max_size=1024) # policy_id -> (whitelist_set, blacklist_set)
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot_policy(p: SecurityPolicy) -> dict:
|
||||||
|
return {
|
||||||
|
"id": p.id,
|
||||||
|
"cooldown_seconds": p.cooldown_seconds,
|
||||||
|
"duplicate_window_seconds": p.duplicate_window_seconds,
|
||||||
|
"max_links": p.max_links,
|
||||||
|
"max_mentions": p.max_mentions,
|
||||||
|
"use_whitelist": p.use_whitelist,
|
||||||
|
"block_adult": p.block_adult,
|
||||||
|
"block_spam": p.block_spam,
|
||||||
|
"block_scam": p.block_scam,
|
||||||
|
"block_profanity": p.block_profanity,
|
||||||
|
"enforce_action_default": p.enforce_action_default,
|
||||||
|
"timeout_minutes": p.timeout_minutes,
|
||||||
|
"strikes_to_warn": p.strikes_to_warn,
|
||||||
|
"strikes_to_timeout": p.strikes_to_timeout,
|
||||||
|
"strikes_to_ban": p.strikes_to_ban,
|
||||||
|
"user_msg_per_minute": p.user_msg_per_minute,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def compute_content_hash(text: str, media_ids: List[str]) -> str:
|
||||||
|
h = _hash_init()
|
||||||
|
h.update(text or "")
|
||||||
|
for m in media_ids or []:
|
||||||
|
h.update("|")
|
||||||
|
h.update(m or "")
|
||||||
|
return _hash_hex(h)
|
||||||
|
|
||||||
|
|
||||||
|
def _find_domains(text: str) -> list[str]:
|
||||||
|
domains = []
|
||||||
|
for m in URL_RE.findall(text or ""):
|
||||||
|
try:
|
||||||
|
d = urlparse(m).netloc.lower()
|
||||||
|
if d.startswith("www."):
|
||||||
|
d = d[4:]
|
||||||
|
if d:
|
||||||
|
domains.append(d)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return domains
|
||||||
|
|
||||||
|
|
||||||
|
def get_policy_for_chat(session, chat_id: int) -> Optional[SecurityPolicy]:
|
||||||
|
"""Возвращает активную (enabled) политику для чата или None."""
|
||||||
|
snap = policy_cache.get(chat_id)
|
||||||
|
if snap:
|
||||||
|
return session.get(SecurityPolicy, snap["id"])
|
||||||
|
cs = session.execute(
|
||||||
|
select(ChatSecurity).where(
|
||||||
|
ChatSecurity.chat_id == chat_id,
|
||||||
|
ChatSecurity.enabled.is_(True),
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if not cs:
|
||||||
|
return None
|
||||||
|
p = session.get(SecurityPolicy, cs.policy_id)
|
||||||
|
if p:
|
||||||
|
policy_cache.set(chat_id, snapshot_policy(p))
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def _active_dicts(session, policy: SecurityPolicy) -> list[SpamDictionary]:
|
||||||
|
# Явно привязанные к политике словари
|
||||||
|
linked = session.execute(
|
||||||
|
select(SpamDictionary)
|
||||||
|
.join(PolicyDictionaryLink, PolicyDictionaryLink.dictionary_id == SpamDictionary.id)
|
||||||
|
.where(PolicyDictionaryLink.policy_id == policy.id)
|
||||||
|
).scalars().all()
|
||||||
|
|
||||||
|
# Глобальные словари включённых категорий
|
||||||
|
cats = []
|
||||||
|
if policy.block_adult: cats.append("adult")
|
||||||
|
if policy.block_spam: cats.append("spam")
|
||||||
|
if policy.block_scam: cats.append("scam")
|
||||||
|
if policy.block_profanity: cats.append("profanity")
|
||||||
|
globals_by_cat = session.execute(
|
||||||
|
select(SpamDictionary).where(
|
||||||
|
SpamDictionary.owner_user_id.is_(None),
|
||||||
|
SpamDictionary.category.in_(cats) if cats else False # если пусто — не брать
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
|
||||||
|
# unique по id
|
||||||
|
got = {d.id: d for d in (linked + globals_by_cat)}
|
||||||
|
return list(got.values())
|
||||||
|
|
||||||
|
|
||||||
|
def _compile_dicts(session, policy: SecurityPolicy):
|
||||||
|
cached = dict_cache.get(policy.id)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
dicts = _active_dicts(session, policy)
|
||||||
|
if not dicts:
|
||||||
|
dict_cache.set(policy.id, (None, []))
|
||||||
|
return (None, [])
|
||||||
|
|
||||||
|
ids = [d.id for d in dicts]
|
||||||
|
entries = session.execute(
|
||||||
|
select(DictionaryEntry).where(DictionaryEntry.dictionary_id.in_(ids))
|
||||||
|
).scalars().all()
|
||||||
|
|
||||||
|
ac = None
|
||||||
|
regex_list: list[re.Pattern] = []
|
||||||
|
if entries and ahocorasick is not None:
|
||||||
|
ac = ahocorasick.Automaton()
|
||||||
|
|
||||||
|
plain_count = 0
|
||||||
|
kinds = {d.id: d.kind for d in dicts}
|
||||||
|
for e in entries:
|
||||||
|
use_regex = e.is_regex or kinds.get(e.dictionary_id) == "regex"
|
||||||
|
if use_regex:
|
||||||
|
try:
|
||||||
|
regex_list.append(re.compile(e.pattern, re.IGNORECASE))
|
||||||
|
except re.error:
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
if ac is not None:
|
||||||
|
try:
|
||||||
|
term = (e.pattern or "").lower()
|
||||||
|
if term:
|
||||||
|
ac.add_word(term, term)
|
||||||
|
plain_count += 1
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if ac is not None and plain_count > 0:
|
||||||
|
ac.make_automaton()
|
||||||
|
else:
|
||||||
|
ac = None
|
||||||
|
|
||||||
|
dict_cache.set(policy.id, (ac, regex_list))
|
||||||
|
return ac, regex_list
|
||||||
|
|
||||||
|
|
||||||
|
def _domain_sets(session, policy: SecurityPolicy) -> tuple[set[str], set[str]]:
|
||||||
|
cached = domain_cache.get(policy.id)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
wl = session.execute(
|
||||||
|
select(DomainRule).where(DomainRule.policy_id == policy.id, DomainRule.kind == "whitelist")
|
||||||
|
).scalars().all()
|
||||||
|
bl = session.execute(
|
||||||
|
select(DomainRule).where(DomainRule.policy_id == policy.id, DomainRule.kind == "blacklist")
|
||||||
|
).scalars().all()
|
||||||
|
|
||||||
|
wl_set = {r.domain for r in wl}
|
||||||
|
bl_set = {r.domain for r in bl}
|
||||||
|
domain_cache.set(policy.id, (wl_set, bl_set))
|
||||||
|
return wl_set, bl_set
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================
|
||||||
|
# Outgoing: проверка рассылки
|
||||||
|
# ==========================
|
||||||
|
def check_message_allowed(session, chat_id: int, owner_user_id: int, text: str, media_ids: List[str]):
|
||||||
|
"""
|
||||||
|
Проверка перед отправкой сообщения в конкретный чат по привязанной политике.
|
||||||
|
|
||||||
|
Возвращает кортеж:
|
||||||
|
(ok: bool, reasons: list[str], content_hash: str)
|
||||||
|
|
||||||
|
Где reasons — причины блокировки (если ok == False), а content_hash — хеш
|
||||||
|
контента (текст + список media_id) для анти-дубликатов.
|
||||||
|
"""
|
||||||
|
policy = get_policy_for_chat(session, chat_id)
|
||||||
|
reasons: list[str] = []
|
||||||
|
content_hash = compute_content_hash(text or "", media_ids or [])
|
||||||
|
|
||||||
|
# Если к чату не привязана политика — разрешаем
|
||||||
|
if not policy:
|
||||||
|
return True, reasons, content_hash
|
||||||
|
|
||||||
|
# 1) Кулдаун между отправками в этот чат
|
||||||
|
last = session.execute(
|
||||||
|
select(Delivery)
|
||||||
|
.where(Delivery.chat_id == chat_id, Delivery.status == "sent")
|
||||||
|
.order_by(Delivery.created_at.desc())
|
||||||
|
).scalars().first()
|
||||||
|
if last:
|
||||||
|
delta = datetime.utcnow() - last.created_at
|
||||||
|
if delta.total_seconds() < policy.cooldown_seconds:
|
||||||
|
reasons.append(f"cooldown<{policy.cooldown_seconds}s")
|
||||||
|
|
||||||
|
# 2) Дубликаты за окно
|
||||||
|
if policy.duplicate_window_seconds > 0:
|
||||||
|
since = datetime.utcnow() - timedelta(seconds=policy.duplicate_window_seconds)
|
||||||
|
dupe = session.execute(
|
||||||
|
select(Delivery).where(
|
||||||
|
Delivery.chat_id == chat_id,
|
||||||
|
Delivery.content_hash == content_hash,
|
||||||
|
Delivery.created_at >= since,
|
||||||
|
Delivery.status == "sent",
|
||||||
|
)
|
||||||
|
).scalars().first()
|
||||||
|
if dupe:
|
||||||
|
reasons.append("duplicate")
|
||||||
|
|
||||||
|
# 3) Лимиты ссылок и упоминаний
|
||||||
|
links = URL_RE.findall(text or "")
|
||||||
|
if policy.max_links >= 0 and len(links) > policy.max_links:
|
||||||
|
reasons.append(f"links>{policy.max_links}")
|
||||||
|
|
||||||
|
mentions = MENTION_RE.findall(text or "")
|
||||||
|
if policy.max_mentions >= 0 and len(mentions) > policy.max_mentions:
|
||||||
|
reasons.append(f"mentions>{policy.max_mentions}")
|
||||||
|
|
||||||
|
# 4) Доменные правила
|
||||||
|
wl, bl = _domain_sets(session, policy)
|
||||||
|
domains = _find_domains(text or "")
|
||||||
|
if policy.use_whitelist and wl:
|
||||||
|
bad = [d for d in domains if d not in wl]
|
||||||
|
if bad:
|
||||||
|
reasons.append("not_whitelisted:" + ",".join(sorted(set(bad))))
|
||||||
|
else:
|
||||||
|
bad = [d for d in domains if d in bl]
|
||||||
|
if bad:
|
||||||
|
reasons.append("blacklisted:" + ",".join(sorted(set(bad))))
|
||||||
|
|
||||||
|
# 5) Словари (plain + regex)
|
||||||
|
ac, regex_list = _compile_dicts(session, policy)
|
||||||
|
if ac is not None:
|
||||||
|
lo = (text or "").lower()
|
||||||
|
for _, _term in ac.iter(lo):
|
||||||
|
reasons.append("dictionary_match")
|
||||||
|
break
|
||||||
|
if not reasons and regex_list:
|
||||||
|
for r in regex_list:
|
||||||
|
if r.search(text or ""):
|
||||||
|
reasons.append("dictionary_match")
|
||||||
|
break
|
||||||
|
|
||||||
|
return (len(reasons) == 0), reasons, content_hash
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================
|
||||||
|
# Ниже — helpers для модерации входящих (если используете «страж» в группе)
|
||||||
|
# ==========================
|
||||||
|
async def redis_rate_check(chat_id: int, user_id: int, per_minute: int) -> bool:
|
||||||
|
"""True — если укладывается в лимит per_minute сообщений/минуту."""
|
||||||
|
if per_minute <= 0:
|
||||||
|
return True
|
||||||
|
r = await get_redis()
|
||||||
|
if r is None:
|
||||||
|
return True
|
||||||
|
key = f"rl:{chat_id}:{user_id}"
|
||||||
|
pipe = r.pipeline()
|
||||||
|
pipe.incr(key, 1)
|
||||||
|
pipe.expire(key, 60)
|
||||||
|
cnt, _ = await pipe.execute()
|
||||||
|
return int(cnt) <= per_minute
|
||||||
|
|
||||||
|
|
||||||
|
async def redis_dupe_check(chat_id: int, user_id: int, content_hash: str, window_s: int) -> bool:
|
||||||
|
"""True — если не дубликат за окно window_s (сек.), иначе False."""
|
||||||
|
if window_s <= 0:
|
||||||
|
return True
|
||||||
|
r = await get_redis()
|
||||||
|
if r is None:
|
||||||
|
return True
|
||||||
|
key = f"dupe:{chat_id}:{user_id}:{content_hash}"
|
||||||
|
ok = await r.set(key, "1", ex=window_s, nx=True)
|
||||||
|
return ok is True
|
||||||
|
|
||||||
|
|
||||||
|
def add_strike_and_decide_action(session, policy: SecurityPolicy, chat_id: int, tg_user_id: int) -> str:
|
||||||
|
"""
|
||||||
|
Увеличивает страйки и возвращает действие: warn|timeout|ban|delete|none
|
||||||
|
(эскалация по порогам политики).
|
||||||
|
"""
|
||||||
|
us = session.execute(
|
||||||
|
select(UserStrike).where(UserStrike.chat_id == chat_id, UserStrike.tg_user_id == tg_user_id)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if not us:
|
||||||
|
us = UserStrike(chat_id=chat_id, tg_user_id=tg_user_id, strikes=0)
|
||||||
|
session.add(us)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(us)
|
||||||
|
|
||||||
|
us.strikes += 1
|
||||||
|
us.updated_at = datetime.utcnow()
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
if us.strikes >= policy.strikes_to_ban:
|
||||||
|
return "ban"
|
||||||
|
if us.strikes >= policy.strikes_to_timeout:
|
||||||
|
return "timeout"
|
||||||
|
if us.strikes >= policy.strikes_to_warn:
|
||||||
|
return "warn"
|
||||||
|
return policy.enforce_action_default or "delete"
|
||||||
58
app/moderation/logger_task.py
Normal file
58
app/moderation/logger_task.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import asyncio
|
||||||
|
from typing import Dict, Any, List
|
||||||
|
from app.db.session import get_session
|
||||||
|
from app.db.models import ModerationLog, MessageEvent
|
||||||
|
|
||||||
|
async def moderation_writer(
|
||||||
|
queue: asyncio.Queue,
|
||||||
|
flush_interval: float = 0.3,
|
||||||
|
max_batch: int = 200,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Фоновый воркер: получает события из очереди и записывает в БД пачками.
|
||||||
|
Ожидаемые элементы очереди — dict со схемой:
|
||||||
|
- {'type':'log', 'chat_id':..., 'user_id':..., 'message_id':..., 'reason':..., 'action':...}
|
||||||
|
- {'type':'event', 'chat_id':..., 'user_id':..., 'message_id':..., 'content_hash':...}
|
||||||
|
"""
|
||||||
|
buf: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
item = await asyncio.wait_for(queue.get(), timeout=flush_interval)
|
||||||
|
buf.append(item)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not buf:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# ограничим размер пачки
|
||||||
|
batch, buf = (buf[:max_batch], buf[max_batch:]) if len(buf) > max_batch else (buf[:], [])
|
||||||
|
|
||||||
|
try:
|
||||||
|
with get_session() as s:
|
||||||
|
for ev in batch:
|
||||||
|
t = ev.get("type")
|
||||||
|
if t == "log":
|
||||||
|
s.add(
|
||||||
|
ModerationLog(
|
||||||
|
chat_id=ev.get("chat_id", 0),
|
||||||
|
tg_user_id=ev.get("user_id", 0),
|
||||||
|
message_id=ev.get("message_id"),
|
||||||
|
reason=ev.get("reason", "")[:4000],
|
||||||
|
action=ev.get("action", "")[:32],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif t == "event":
|
||||||
|
s.add(
|
||||||
|
MessageEvent(
|
||||||
|
chat_id=ev.get("chat_id", 0),
|
||||||
|
tg_user_id=ev.get("user_id", 0),
|
||||||
|
message_id=ev.get("message_id"),
|
||||||
|
content_hash=ev.get("content_hash"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
s.commit()
|
||||||
|
except Exception:
|
||||||
|
# на ошибке просто пропускаем пачку, чтобы не зациклиться
|
||||||
|
pass
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
version: "3.9"
|
|
||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
@@ -23,6 +22,29 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
env_file: .env
|
env_file: .env
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
command: ["redis-server","--save","", "--appendonly","no"]
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
ports:
|
||||||
|
- "${REDIS_PORT:-6379}:6379"
|
||||||
|
|
||||||
|
prometheus:
|
||||||
|
image: prom/prometheus:v2.55.1
|
||||||
|
command: ["--config.file=/etc/prometheus/prometheus.yml"]
|
||||||
|
volumes:
|
||||||
|
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||||
|
depends_on:
|
||||||
|
- bot
|
||||||
|
ports:
|
||||||
|
- "9090:9090"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
|
|||||||
8
prometheus/prometheus.yml
Normal file
8
prometheus/prometheus.yml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
global:
|
||||||
|
scrape_interval: 5s
|
||||||
|
evaluation_interval: 15s
|
||||||
|
|
||||||
|
scrape_configs:
|
||||||
|
- job_name: "tg_bot"
|
||||||
|
static_configs:
|
||||||
|
- targets: ["bot:9100"] # бот слушает метрики на 9100 внутри сети compose
|
||||||
@@ -3,3 +3,7 @@ SQLAlchemy==2.0.36
|
|||||||
alembic==1.13.2
|
alembic==1.13.2
|
||||||
psycopg[binary]==3.2.1
|
psycopg[binary]==3.2.1
|
||||||
python-dotenv==1.0.1
|
python-dotenv==1.0.1
|
||||||
|
redis>=5.0.0
|
||||||
|
prometheus-client==0.20.0
|
||||||
|
pyahocorasick==2.1.0
|
||||||
|
xxhash==3.4.1
|
||||||
|
|||||||
Reference in New Issue
Block a user