Bot become a Community Guard & Post send manager

added: dictionary support for censore
message/user management with dict triggers
This commit is contained in:
2025-08-22 21:44:14 +09:00
parent efdafb0efa
commit c16ec54891
27 changed files with 1746 additions and 184 deletions

View File

@@ -14,3 +14,13 @@ DB_PASSWORD=postgres
# Logging level: DEBUG, INFO, WARNING, ERROR
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

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

View File

@@ -1,14 +1,12 @@
from telegram import Update, InputMediaPhoto, InputMediaVideo, InputMediaAnimation
from telegram.constants import ParseMode, ChatType
from telegram.constants import ParseMode
from telegram.ext import ContextTypes
from app.db.session import get_session
from app.db.models import Draft, Chat, Delivery
from app.bot.keyboards.common import kb_choose_chat
from app.bot.messages import READY_SELECT_CHAT, SENT_OK, SEND_ERR, NEED_MEDIA_BEFORE_NEXT, CANCELLED
from .drafts import STATE_DRAFT, KEY_DRAFT_ID, STATE_AWAIT_TEXT, STATE_CONFIRM
from .add_group import STATE_KEY # чтобы не конфликтовать по user_data ключам
from app.db.models import Draft, Chat, Delivery, User
from app.bot.keyboards.common import kb_multiselect # ← только мультивыбор
from app.bot.messages import NEED_MEDIA_BEFORE_NEXT, NO_SELECTION, SENT_SUMMARY
from app.moderation.engine import check_message_allowed
async def on_callback(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
q = update.callback_query
@@ -26,11 +24,11 @@ async def on_callback(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
if len(d.media) == 0:
await q.edit_message_text(NEED_MEDIA_BEFORE_NEXT)
return
ctx.user_data[KEY_DRAFT_ID] = draft_id
ctx.user_data[STATE_DRAFT] = STATE_AWAIT_TEXT
ctx.user_data["draft_id"] = draft_id
ctx.user_data["draft_state"] = "await_text"
await q.edit_message_text("Шаг 2/3 — текст.\nОтправьте текст поста.")
# --- Подтверждение (Отправить) -> выбор чата ---
# --- Подтверждение -> мультивыбор чатов ---
elif data.startswith("draft_confirm_send:"):
draft_id = int(data.split(":")[1])
with get_session() as s:
@@ -40,32 +38,59 @@ async def on_callback(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
return
d.status = "ready"
s.commit()
# Разрешённые чаты владельца черновика
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]
kb = kb_choose_chat(draft_id, buttons)
if not kb:
await q.edit_message_text("Нет чатов с правом публикации. Добавьте/обновите через /add_group.")
return
ctx.user_data[STATE_DRAFT] = STATE_CONFIRM
await q.edit_message_text(READY_SELECT_CHAT, reply_markup=kb)
chat_rows = [(c.title or str(c.chat_id), c.chat_id) for c in chats]
sel_key = f"sel:{draft_id}"
ctx.user_data[sel_key] = set()
await q.edit_message_text("Выберите чаты:", reply_markup=kb_multiselect(draft_id, chat_rows, ctx.user_data[sel_key]))
# --- Тоггл чекбокса ---
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:
d = s.get(Draft, draft_id)
if d:
d.status = "cancelled"
s.commit()
ctx.user_data.pop(KEY_DRAFT_ID, None)
ctx.user_data.pop(STATE_DRAFT, None)
await q.edit_message_text(CANCELLED)
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]
# --- Отправка в выбранный чат ---
elif data.startswith("send:"):
_, draft_id, chat_id = data.split(":")
draft_id = int(draft_id); chat_id = int(chat_id)
sel: set[int] = set(ctx.user_data.get(sel_key, set()))
if chat_id in sel:
sel.remove(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:
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("Черновик не найден.")
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))
sent_ids = []
try:
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))
elif m.kind == "animation":
im.append(InputMediaAnimation(media=m.file_id, caption=cap, parse_mode=ParseMode.HTML))
msgs = await ctx.bot.send_media_group(chat_id=chat_id, media=im)
sent_ids = [str(m.message_id) for m in msgs]
else:
m = media[0]
if m.kind == "photo":
msg = await ctx.bot.send_photo(chat_id=chat_id, photo=m.file_id, caption=d.text, parse_mode=ParseMode.HTML)
elif m.kind == "video":
msg = await ctx.bot.send_video(chat_id=chat_id, video=m.file_id, caption=d.text, parse_mode=ParseMode.HTML)
media_ids = [m.file_id for m in media]
text_val = d.text or ""
owner = s.get(User, d.user_id)
for cid in list(selected):
allowed, reasons, content_hash = check_message_allowed(
s, cid, owner_user_id=owner.id, text=text_val, media_ids=media_ids
)
if not allowed:
s.add(Delivery(draft_id=d.id, chat_id=cid, status="failed", error="; ".join(reasons), content_hash=content_hash))
s.commit()
fail += 1
continue
try:
if media:
if len(media) > 1:
im = []
for i, m in enumerate(media):
cap = text_val 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 ctx.bot.send_media_group(chat_id=cid, media=im)
else:
msg = await ctx.bot.send_animation(chat_id=chat_id, animation=m.file_id, caption=d.text, parse_mode=ParseMode.HTML)
sent_ids = [str(msg.message_id)]
else:
msg = await ctx.bot.send_message(chat_id=chat_id, text=d.text or "(пусто)", parse_mode=ParseMode.HTML)
sent_ids = [str(msg.message_id)]
m = media[0]
if m.kind == "photo":
await ctx.bot.send_photo(chat_id=cid, photo=m.file_id, caption=text_val, parse_mode=ParseMode.HTML)
elif m.kind == "video":
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(deliv)
d.status = "sent"
s.add(Delivery(draft_id=d.id, chat_id=cid, status="sent", content_hash=content_hash))
s.commit()
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()
# Сбрасываем пользовательское состояние редактора
ctx.user_data.pop(KEY_DRAFT_ID, None)
ctx.user_data.pop(STATE_DRAFT, None)
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))
ctx.user_data.pop(f"sel:{draft_id}", None)
ctx.user_data.pop("draft_id", None)
ctx.user_data.pop("draft_state", None)
await q.edit_message_text("Черновик отменён.")

View File

@@ -1,51 +1,41 @@
from datetime import datetime
from telegram import Update
from telegram import Update, InputMediaPhoto, InputMediaVideo, InputMediaAnimation
from telegram.constants import ChatType, ParseMode
from telegram.ext import ContextTypes
from app.db.session import get_session
from app.db.models import User, Draft
from app.bot.messages import (
ASK_MEDIA, ASK_TEXT, TEXT_ADDED, CONFIRM, ALREADY_AT_TEXT, ALREADY_READY, NEED_START_NEW
)
from app.bot.messages import ASK_MEDIA, ASK_TEXT, CONFIRM, NEED_START_NEW
from app.bot.keyboards.common import kb_next_text, kb_confirm
from .add_group import add_group_capture, STATE_KEY # /add_group ожидание
from .add_group import add_group_capture, STATE_KEY
# Состояния пошагового редактора
STATE_DRAFT = "draft_state"
KEY_DRAFT_ID = "draft_id"
STATE_AWAIT_MEDIA = "await_media"
STATE_AWAIT_TEXT = "await_text"
STATE_CONFIRM = "confirm"
def _start_new_draft(tg_id: int) -> Draft:
with get_session() as s:
u = s.query(User).filter_by(tg_id=tg_id).first()
if not u:
u = User(tg_id=tg_id, name="")
s.add(u); s.commit(); s.refresh(u)
# Закрываем предыдущие "editing" как cancelled (по желанию)
u = User(tg_id=tg_id, name=""); s.add(u); s.commit(); s.refresh(u)
s.query(Draft).filter(Draft.user_id == u.id, Draft.status == "editing").update({"status": "cancelled"})
d = Draft(user_id=u.id, status="editing")
s.add(d); s.commit(); s.refresh(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):
d = _start_new_draft(update.effective_user.id)
ctx.user_data[KEY_DRAFT_ID] = d.id
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):
# Если ждём chat_id для /add_group, отдаём управление туда
# Если ждём chat_id для /add_group — передаём управление
if ctx.user_data.get("await_dict_file"):
return
if ctx.user_data.get(STATE_KEY):
return await add_group_capture(update, ctx)
@@ -60,20 +50,48 @@ async def on_text(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
return
if state == STATE_AWAIT_MEDIA:
# Сначала медиа
await update.effective_message.reply_text("Сначала пришлите медиа и нажмите «Дальше — текст».")
await update.effective_message.reply_text("Сначала добавьте медиа и нажмите «Дальше — текст».")
return
if state == STATE_CONFIRM:
await update.effective_message.reply_text(ALREADY_READY)
await update.effective_message.reply_text("Пост уже готов — нажмите «Отправить» или «Отменить».")
return
if state == STATE_AWAIT_TEXT:
# Сохраняем текст
with get_session() as s:
d = s.get(Draft, draft_id)
d.text = update.effective_message.text_html_urled
d.updated_at = datetime.utcnow()
s.commit()
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
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))

View 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

View File

@@ -1,77 +1,50 @@
import asyncio
from telegram import Update
from telegram import Update, InlineKeyboardMarkup, InlineKeyboardButton
from telegram.constants import ChatMemberStatus, ChatType, ParseMode
from telegram.ext import ContextTypes
from telegram.error import Forbidden
from app.bot.messages import (
JOIN_DM_GROUP, JOIN_DM_CHANNEL, JOIN_PUBLIC_WITH_ID, NEED_START_DM
)
from app.bot.messages import JOIN_PUBLIC_WITH_ID, NEED_START_DM, BIND_CHANNEL_BTN
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:
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:
pass
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
if not mcm:
return
chat = mcm.chat
new_status = mcm.new_chat_member.status
if new_status not in (ChatMemberStatus.MEMBER, ChatMemberStatus.ADMINISTRATOR):
return
title = chat.title or str(chat.id)
chat_id = chat.id
kb = None
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)
dm_sent = False
if actor:
try:
if chat.type in (ChatType.GROUP, ChatType.SUPERGROUP):
await ctx.bot.send_message(
actor.id, JOIN_DM_GROUP.format(title=title, chat_id=chat_id),
parse_mode=ParseMode.MARKDOWN
)
elif chat.type == ChatType.CHANNEL:
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
await ctx.bot.send_message(actor.id,
JOIN_PUBLIC_WITH_ID.format(chat_id=chat.id, ttl=TTL_SEC),
parse_mode=ParseMode.MARKDOWN,
reply_markup=kb)
return
except Exception:
try:
await ctx.bot.send_message(actor.id, NEED_START_DM)
except Exception:
pass
except Exception:
pass
if dm_sent:
return
# 2) DM не удался — публикуем в чат краткий хинт с chat_id, удаляем через TTL
# (для каналов сработает только если бот уже админ и может постить)
try:
msg = await ctx.bot.send_message(
chat_id=chat_id,
text=JOIN_PUBLIC_WITH_ID.format(chat_id=chat_id, ttl=TTL_SEC),
parse_mode=ParseMode.MARKDOWN
)
ctx.application.create_task(_auto_delete(ctx, chat_id, msg.message_id, delay=TTL_SEC))
msg = await ctx.bot.send_message(chat_id=chat.id,
text=JOIN_PUBLIC_WITH_ID.format(chat_id=chat.id, ttl=TTL_SEC),
parse_mode=ParseMode.MARKDOWN,
reply_markup=kb)
ctx.application.create_task(_autodel(ctx, chat.id, msg.message_id, delay=TTL_SEC))
except Exception:
# Если и сюда не можем — увы, остаётся ручной путь: /id, /add_group и ЛС
pass

View File

@@ -2,19 +2,18 @@ from datetime import datetime
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 Draft, DraftMedia
from app.bot.messages import MEDIA_ADDED, ALREADY_AT_TEXT, NEED_START_NEW
from .drafts import KEY_DRAFT_ID, STATE_DRAFT, STATE_AWAIT_MEDIA, STATE_AWAIT_TEXT, STATE_CONFIRM
from .add_group import add_group_capture, STATE_KEY # перехват для /add_group
from app.bot.messages import MEDIA_ADDED, NEED_START_NEW
from app.bot.keyboards.common import kb_next_text
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):
if update.effective_chat.type != ChatType.PRIVATE:
return
# Если пользователь сейчас привязывает чат — используем пересланное медиа для извлечения chat_id
# Если сейчас идёт привязка чата — используем пересланное сообщение
if ctx.user_data.get(STATE_KEY):
return await add_group_capture(update, ctx)
@@ -26,8 +25,7 @@ async def on_media(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
return
if state != STATE_AWAIT_MEDIA:
# Уже перешли к тексту/подтверждению — блокируем добавление медиа
await update.effective_message.reply_text(ALREADY_AT_TEXT if state == STATE_AWAIT_TEXT else "Редактор на шаге подтверждения.")
await update.effective_message.reply_text("Медиа можно добавлять только на шаге 1.")
return
kind = None
@@ -44,8 +42,12 @@ async def on_media(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
with get_session() as s:
d = s.get(Draft, draft_id)
order = len(d.media)
m = DraftMedia(draft_id=d.id, kind=kind, file_id=file_id, order=order)
s.add(m); d.updated_at = datetime.utcnow()
s.add(DraftMedia(draft_id=d.id, kind=kind, file_id=file_id, order=order))
d.updated_at = datetime.utcnow()
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)
)

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

View 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

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

View File

@@ -6,6 +6,7 @@ def kb_next_text(draft_id: int):
)
def kb_confirm(draft_id: int):
# Кнопка «Отправить» ведёт к мультивыбору чатов
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]]):
# chats: list of (title, chat_id)
rows = [[InlineKeyboardButton(title, callback_data=f"send:{draft_id}:{chat_id}")] for title, chat_id in chats]
return InlineKeyboardMarkup(rows) if rows else None
return kb_multiselect(draft_id, chats, selected=set())

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

View File

@@ -1,19 +1,17 @@
START = (
"Привет! Я помогу отправлять сообщения в ваши группы и каналы.\n\n"
"1) Добавьте меня в группу/канал (в канале — дайте право публиковать).\n"
"2) В ЛС боту нажмите /add_group и вставьте chat_id или перешлите сюда сообщение из этого чата.\n"
"3) Создайте черновик /new и отправьте в выбранный чат.\n\n"
"Команды:\n"
"/add_group — привязать группу/канал вручную\n"
"/groups — список моих чатов\n"
"/new — создать черновик поста\n"
"/help — справка"
"2) В ЛС выполните /add_group и вставьте chat_id или перешлите сообщение из этого чата.\n"
"3) Создайте черновик /new и отправьте. Также доступна модерация чатов через /security."
)
HELP = (
"1) В Telegram добавьте бота в группу/канал (для каналов — админ с правом «Публиковать сообщения»).\n"
"2) В ЛС — /add_group: вставьте chat_id (например, -100123...) или перешлите сюда любое сообщение из чата.\n"
"3) Создайте черновик /new и отправьте его в выбранный чат."
"Команды:\n"
"/add_group — привязать группу/канал вручно\n"
"/groups — список ваших чатов\n"
"/new — конструктор поста (медиа→текст→подтверждение)\n"
"/security — политика безопасности, словари, включение модерации\n"
"/spam_import — импорт словаря (txt/csv) в ЛС\n"
"/id — показать chat_id (только для админов)"
)
ASK_ADD_GROUP = (
@@ -24,16 +22,16 @@ ASK_ADD_GROUP = (
NO_CHATS = "Пока ни одного чата не привязано. Нажмите /add_group для привязки."
# Пошаговый редактор
# Шаги редактора
ASK_MEDIA = (
"Шаг 1/3 — медиа.\nПришлите фото/видео/гиф. Можно несколько (альбом).\n"
"Когда закончите — нажмите «Дальше — текст»."
)
ASK_TEXT = (
"Шаг 2/3 — текст.\nОтправьте текст поста. Он станет подписью к медиа (или отдельным сообщением, если медиа нет)."
)
CONFIRM = (
"Шаг 3/3 — подтверждение.\nПроверьте пост и нажмите «Отправить» или «Отменить»."
"Кнопка «Дальше — текст» появится под сообщением «Медиа добавлено»."
)
ASK_TEXT = "Шаг 2/3 — текст.\nОтправьте текст поста."
CONFIRM = "Шаг 3/3 — подтверждение.\nПроверьте пост и нажмите «Отправить» или «Отменить»."
# Сообщения
TEXT_ADDED = "Текст добавлен в черновик."
MEDIA_ADDED = "Медиа добавлено ({kind})."
@@ -45,7 +43,9 @@ CANCELLED = "Черновик отменён."
READY_SELECT_CHAT = "Куда отправить?"
SENT_OK = "✅ Отправлено."
SENT_SUMMARY = "✅ Готово. Успешно: {ok} Ошибок: {fail}"
SEND_ERR = "❌ Ошибка отправки: {e}"
NO_SELECTION = "Не выбрано ни одного чата."
GROUP_BOUND = "Чат «{title_or_id}» привязан.\n{rights}"
NEED_ADD_FIRST = "Я не добавлен в «{title_or_id}». Сначала добавьте бота в этот чат и повторите /add_group."
@@ -107,4 +107,12 @@ JOIN_PUBLIC_WITH_ID = (
NEED_START_DM = (
"Не удалось отправить ЛС: Telegram запрещает писать до нажатия «Start».\n"
"Откройте мой профиль и нажмите Start, затем /add_group."
)
)
# Кнопка быстрой привязки канала
BIND_CHANNEL_BTN = "Привязать этот канал"
BIND_OK = "Канал «{title}» привязан. ✅"
BIND_FAIL_NOT_ADMIN = "Привязку может выполнять только администратор этого канала."
BIND_FAIL_BOT_RIGHTS = "Я не администратор в канале или у меня нет права публиковать сообщения."
BIND_FAIL_GENERIC = "Не получилось привязать канал. Попробуйте /add_group."

View File

@@ -5,6 +5,7 @@ from dataclasses import dataclass
class Config:
bot_token: str
database_url: str
metrics_port: int = 8000
log_level: str = os.getenv("LOG_LEVEL", "INFO")
def load_config() -> "Config":
@@ -20,4 +21,5 @@ def load_config() -> "Config":
user = os.getenv("DB_USER", "postgres")
pwd = os.getenv("DB_PASSWORD", "postgres")
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)

View File

@@ -1,20 +1,35 @@
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 app.db.base import Base
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
tg_id = Column(BigInteger, unique=True, nullable=False, index=True)
name = Column(String(255))
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
class Chat(Base):
__tablename__ = "chats"
id = Column(Integer, primary_key=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))
owner_user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
can_post = Column(Boolean, default=False, nullable=False)
@@ -22,35 +37,179 @@ class Chat(Base):
owner = relationship("User")
class Draft(Base):
__tablename__ = "drafts"
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"), index=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)
updated_at = Column(DateTime, default=datetime.utcnow, nullable=False)
user = relationship("User")
media = relationship("DraftMedia", cascade="all, delete-orphan", back_populates="draft")
class DraftMedia(Base):
__tablename__ = "draft_media"
id = Column(Integer, primary_key=True)
draft_id = Column(Integer, ForeignKey("drafts.id"), index=True)
kind = Column(String(16)) # photo | video | animation
file_id = Column(String(255))
"""Ordering inside album."""
order = Column(Integer, default=0)
draft = relationship("Draft", back_populates="media")
class Delivery(Base):
__tablename__ = "deliveries"
id = Column(Integer, primary_key=True)
draft_id = Column(Integer, ForeignKey("drafts.id"), index=True)
chat_id = Column(BigInteger, index=True)
status = Column(String(16), default="new", index=True) # new | sent | failed
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)

0
app/infra/__init__.py Normal file
View File

15
app/infra/metrics.py Normal file
View 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
View 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

View File

@@ -8,6 +8,14 @@ from app.bot.handlers.media import on_media
from app.bot.handlers.callbacks import on_callback
from app.bot.handlers.join_info import on_my_chat_member
from app.bot.handlers.chat_id_cmd import chat_id_cmd
from app.bot.handlers.bind_chat import bind_chat_cb
from app.bot.handlers.security import security_cmd, security_cb, spam_import_cmd, spam_import_capture
from app.bot.handlers.moderation import moderate_message
from app.bot.handlers.errors import on_error
from app.bot.handlers.mod_status import mod_status_cmd
from app.infra.metrics import start_metrics_server
from app.bot.handlers.security import dicts_cmd, dicts_cb
def main():
cfg = load_config()
@@ -21,18 +29,43 @@ def main():
app.add_handler(CommandHandler("add_group", add_group_cmd))
app.add_handler(CommandHandler("new", new_cmd))
app.add_handler(CommandHandler("id", chat_id_cmd))
app.add_handler(CommandHandler("mod_status", mod_status_cmd))
# команды
app.add_handler(CommandHandler("dicts", dicts_cmd))
# Callback 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
app.add_handler(MessageHandler(filters.ChatType.PRIVATE & filters.TEXT, on_text))
# Private chat helpers
app.add_handler(MessageHandler(filters.ChatType.PRIVATE & filters.FORWARDED, add_group_capture))
app.add_handler(MessageHandler(filters.ChatType.PRIVATE & (filters.PHOTO | filters.VIDEO | filters.ANIMATION), on_media))
# Join/rights updates
app.add_handler(ChatMemberHandler(on_my_chat_member, chat_member_types=ChatMemberHandler.MY_CHAT_MEMBER))
# Security / Dict
app.add_handler(CommandHandler("security", security_cmd))
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)
if __name__ == "__main__":

View 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 ###

View File

42
app/moderation/cache.py Normal file
View 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
View 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"

View 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

View File

@@ -1,4 +1,3 @@
version: "3.9"
services:
db:
image: postgres:16-alpine
@@ -23,6 +22,29 @@ services:
condition: service_healthy
env_file: .env
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:
pgdata:

View 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

View File

@@ -3,3 +3,7 @@ SQLAlchemy==2.0.36
alembic==1.13.2
psycopg[binary]==3.2.1
python-dotenv==1.0.1
redis>=5.0.0
prometheus-client==0.20.0
pyahocorasick==2.1.0
xxhash==3.4.1