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

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 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]
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: try:
if media: if media:
if len(media) > 1: if len(media) > 1:
im = [] im = []
for i, m in enumerate(media): for i, m in enumerate(media):
cap = d.text if i == 0 else None cap = text_val if i == 0 else None
if m.kind == "photo": if m.kind == "photo":
im.append(InputMediaPhoto(media=m.file_id, caption=cap, parse_mode=ParseMode.HTML)) im.append(InputMediaPhoto(media=m.file_id, caption=cap, parse_mode=ParseMode.HTML))
elif m.kind == "video": elif m.kind == "video":
im.append(InputMediaVideo(media=m.file_id, caption=cap, parse_mode=ParseMode.HTML)) im.append(InputMediaVideo(media=m.file_id, caption=cap, parse_mode=ParseMode.HTML))
elif m.kind == "animation": else:
im.append(InputMediaAnimation(media=m.file_id, caption=cap, parse_mode=ParseMode.HTML)) im.append(InputMediaAnimation(media=m.file_id, caption=cap, parse_mode=ParseMode.HTML))
msgs = await ctx.bot.send_media_group(chat_id=chat_id, media=im) await ctx.bot.send_media_group(chat_id=cid, media=im)
sent_ids = [str(m.message_id) for m in msgs]
else: else:
m = media[0] m = media[0]
if m.kind == "photo": 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) await ctx.bot.send_photo(chat_id=cid, photo=m.file_id, caption=text_val, parse_mode=ParseMode.HTML)
elif m.kind == "video": 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) await ctx.bot.send_video(chat_id=cid, video=m.file_id, caption=text_val, parse_mode=ParseMode.HTML)
else: else:
msg = await ctx.bot.send_animation(chat_id=chat_id, animation=m.file_id, caption=d.text, parse_mode=ParseMode.HTML) await ctx.bot.send_animation(chat_id=cid, animation=m.file_id, caption=text_val, parse_mode=ParseMode.HTML)
sent_ids = [str(msg.message_id)]
else: else:
msg = await ctx.bot.send_message(chat_id=chat_id, text=d.text or "(пусто)", parse_mode=ParseMode.HTML) await ctx.bot.send_message(chat_id=cid, text=text_val or "(пусто)", parse_mode=ParseMode.HTML)
sent_ids = [str(msg.message_id)]
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)
d.status = "sent"
s.commit() s.commit()
ok += 1
# Сбрасываем пользовательское состояние редактора
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: except Exception as e:
deliv = Delivery(draft_id=d.id, chat_id=chat_id, status="failed", error=str(e)) s.add(Delivery(draft_id=d.id, chat_id=cid, status="failed", error=str(e), content_hash=content_hash))
s.add(deliv); s.commit() s.commit()
await q.edit_message_text(SEND_ERR.format(e=e)) 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(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 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))

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

View File

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

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

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 = ( 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."
@@ -108,3 +108,11 @@ 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."

View File

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

View File

@@ -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
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.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:"))
# Private chat handlers # Callbacks (order matters!)
app.add_handler(MessageHandler(filters.ChatType.PRIVATE & filters.TEXT, on_text)) 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 helpers
app.add_handler(MessageHandler(filters.ChatType.PRIVATE & filters.FORWARDED, add_group_capture)) app.add_handler(MessageHandler(filters.ChatType.PRIVATE & filters.FORWARDED, add_group_capture))
app.add_handler(MessageHandler(filters.ChatType.PRIVATE & (filters.PHOTO | filters.VIDEO | filters.ANIMATION), on_media)) app.add_handler(MessageHandler(filters.ChatType.PRIVATE & (filters.PHOTO | filters.VIDEO | filters.ANIMATION), on_media))
# Join/rights updates # Join/rights updates
app.add_handler(ChatMemberHandler(on_my_chat_member, chat_member_types=ChatMemberHandler.MY_CHAT_MEMBER)) app.add_handler(ChatMemberHandler(on_my_chat_member, chat_member_types=ChatMemberHandler.MY_CHAT_MEMBER))
# Security / Dict
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__":

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: 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:

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