Files
tg_post_min/app/bot/handlers/moderation.py
Andrey K. Choi c16ec54891 Bot become a Community Guard & Post send manager
added: dictionary support for censore
message/user management with dict triggers
2025-08-22 21:44:14 +09:00

153 lines
6.5 KiB
Python

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