diff --git a/.env.example b/.env.example index 80870f9..91e3579 100644 --- a/.env.example +++ b/.env.example @@ -14,3 +14,13 @@ DB_PASSWORD=postgres # Logging level: DEBUG, INFO, WARNING, ERROR LOG_LEVEL=INFO + +# Redis +REDIS_URL=redis://redis:6379/0 +REDIS_PORT=6379 + +# Метрики бота (Prometheus будет ходить на bot:9100 внутри сети) +METRICS_PORT=9100 + +# (если где-то используешь обращения к Prometheus из веба) +PROMETHEUS_URL=http://prometheus:9090 \ No newline at end of file diff --git a/app/bot/handlers/bind_chat.py b/app/bot/handlers/bind_chat.py new file mode 100644 index 0000000..a011e7f --- /dev/null +++ b/app/bot/handlers/bind_chat.py @@ -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) diff --git a/app/bot/handlers/callbacks.py b/app/bot/handlers/callbacks.py index a8b6eca..1d9acdf 100644 --- a/app/bot/handlers/callbacks.py +++ b/app/bot/handlers/callbacks.py @@ -1,14 +1,12 @@ from telegram import Update, InputMediaPhoto, InputMediaVideo, InputMediaAnimation -from telegram.constants import ParseMode, ChatType +from telegram.constants import ParseMode from telegram.ext import ContextTypes from app.db.session import get_session -from app.db.models import Draft, Chat, Delivery -from app.bot.keyboards.common import kb_choose_chat -from app.bot.messages import READY_SELECT_CHAT, SENT_OK, SEND_ERR, NEED_MEDIA_BEFORE_NEXT, CANCELLED -from .drafts import STATE_DRAFT, KEY_DRAFT_ID, STATE_AWAIT_TEXT, STATE_CONFIRM -from .add_group import STATE_KEY # чтобы не конфликтовать по user_data ключам - +from app.db.models import Draft, Chat, Delivery, User +from app.bot.keyboards.common import kb_multiselect # ← только мультивыбор +from app.bot.messages import NEED_MEDIA_BEFORE_NEXT, NO_SELECTION, SENT_SUMMARY +from app.moderation.engine import check_message_allowed async def on_callback(update: Update, ctx: ContextTypes.DEFAULT_TYPE): q = update.callback_query @@ -26,11 +24,11 @@ async def on_callback(update: Update, ctx: ContextTypes.DEFAULT_TYPE): if len(d.media) == 0: await q.edit_message_text(NEED_MEDIA_BEFORE_NEXT) return - ctx.user_data[KEY_DRAFT_ID] = draft_id - ctx.user_data[STATE_DRAFT] = STATE_AWAIT_TEXT + ctx.user_data["draft_id"] = draft_id + ctx.user_data["draft_state"] = "await_text" await q.edit_message_text("Шаг 2/3 — текст.\nОтправьте текст поста.") - # --- Подтверждение (Отправить) -> выбор чата --- + # --- Подтверждение -> мультивыбор чатов --- elif data.startswith("draft_confirm_send:"): draft_id = int(data.split(":")[1]) with get_session() as s: @@ -40,32 +38,59 @@ async def on_callback(update: Update, ctx: ContextTypes.DEFAULT_TYPE): return d.status = "ready" s.commit() - # Разрешённые чаты владельца черновика chats = s.query(Chat).filter_by(owner_user_id=d.user_id, can_post=True).all() - buttons = [(c.title or str(c.chat_id), c.chat_id) for c in chats] - kb = kb_choose_chat(draft_id, buttons) - if not kb: - await q.edit_message_text("Нет чатов с правом публикации. Добавьте/обновите через /add_group.") - return - ctx.user_data[STATE_DRAFT] = STATE_CONFIRM - await q.edit_message_text(READY_SELECT_CHAT, reply_markup=kb) + chat_rows = [(c.title or str(c.chat_id), c.chat_id) for c in chats] + sel_key = f"sel:{draft_id}" + ctx.user_data[sel_key] = set() + await q.edit_message_text("Выберите чаты:", reply_markup=kb_multiselect(draft_id, chat_rows, ctx.user_data[sel_key])) + + # --- Тоггл чекбокса --- + elif data.startswith("tgl:"): + _, draft_id, chat_id = data.split(":") + draft_id = int(draft_id) + chat_id = int(chat_id) + sel_key = f"sel:{draft_id}" - # --- Отмена черновика --- - elif data.startswith("draft_cancel:"): - draft_id = int(data.split(":")[1]) with get_session() as s: d = s.get(Draft, draft_id) - if d: - d.status = "cancelled" - s.commit() - ctx.user_data.pop(KEY_DRAFT_ID, None) - ctx.user_data.pop(STATE_DRAFT, None) - await q.edit_message_text(CANCELLED) + chats = s.query(Chat).filter_by(owner_user_id=d.user_id, can_post=True).all() + rows = [(c.title or str(c.chat_id), c.chat_id) for c in chats] - # --- Отправка в выбранный чат --- - elif data.startswith("send:"): - _, draft_id, chat_id = data.split(":") - draft_id = int(draft_id); chat_id = int(chat_id) + sel: set[int] = set(ctx.user_data.get(sel_key, set())) + if chat_id in sel: + sel.remove(chat_id) + else: + sel.add(chat_id) + ctx.user_data[sel_key] = sel + + await q.edit_message_reply_markup(reply_markup=kb_multiselect(draft_id, rows, sel)) + + # --- Выбрать все --- + elif data.startswith("selall:"): + draft_id = int(data.split(":")[1]) + sel_key = f"sel:{draft_id}" + with get_session() as s: + d = s.get(Draft, draft_id) + chats = s.query(Chat).filter_by(owner_user_id=d.user_id, can_post=True).all() + rows = [(c.title or str(c.chat_id), c.chat_id) for c in chats] + ctx.user_data[sel_key] = {cid for _, cid in rows} + await q.edit_message_reply_markup(reply_markup=kb_multiselect(draft_id, rows, ctx.user_data[sel_key])) + + # --- Сброс --- + elif data.startswith("clear:"): + draft_id = int(data.split(":")[1]) + sel_key = f"sel:{draft_id}" + with get_session() as s: + d = s.get(Draft, draft_id) + chats = s.query(Chat).filter_by(owner_user_id=d.user_id, can_post=True).all() + rows = [(c.title or str(c.chat_id), c.chat_id) for c in chats] + ctx.user_data[sel_key] = set() + await q.edit_message_reply_markup(reply_markup=kb_multiselect(draft_id, rows, set())) + + # --- Отправка выбранных --- + elif data.startswith("sendmulti:"): + draft_id = int(data.split(":")[1]) + sel_key = f"sel:{draft_id}" with get_session() as s: d = s.get(Draft, draft_id) @@ -73,46 +98,75 @@ async def on_callback(update: Update, ctx: ContextTypes.DEFAULT_TYPE): await q.edit_message_text("Черновик не найден.") return + chats = s.query(Chat).filter_by(owner_user_id=d.user_id, can_post=True).all() + rows = [(c.title or str(c.chat_id), c.chat_id) for c in chats] + selected: set[int] = ctx.user_data.get(sel_key, set()) + + if not selected: + await q.edit_message_text(NO_SELECTION, reply_markup=kb_multiselect(draft_id, rows, selected)) + return + + ok = 0 + fail = 0 media = list(sorted(d.media, key=lambda m: m.order)) - sent_ids = [] - try: - if media: - if len(media) > 1: - im = [] - for i, m in enumerate(media): - cap = d.text if i == 0 else None - if m.kind == "photo": - im.append(InputMediaPhoto(media=m.file_id, caption=cap, parse_mode=ParseMode.HTML)) - elif m.kind == "video": - im.append(InputMediaVideo(media=m.file_id, caption=cap, parse_mode=ParseMode.HTML)) - elif m.kind == "animation": - im.append(InputMediaAnimation(media=m.file_id, caption=cap, parse_mode=ParseMode.HTML)) - msgs = await ctx.bot.send_media_group(chat_id=chat_id, media=im) - sent_ids = [str(m.message_id) for m in msgs] - else: - m = media[0] - if m.kind == "photo": - msg = await ctx.bot.send_photo(chat_id=chat_id, photo=m.file_id, caption=d.text, parse_mode=ParseMode.HTML) - elif m.kind == "video": - msg = await ctx.bot.send_video(chat_id=chat_id, video=m.file_id, caption=d.text, parse_mode=ParseMode.HTML) + media_ids = [m.file_id for m in media] + text_val = d.text or "" + owner = s.get(User, d.user_id) + + for cid in list(selected): + allowed, reasons, content_hash = check_message_allowed( + s, cid, owner_user_id=owner.id, text=text_val, media_ids=media_ids + ) + if not allowed: + s.add(Delivery(draft_id=d.id, chat_id=cid, status="failed", error="; ".join(reasons), content_hash=content_hash)) + s.commit() + fail += 1 + continue + + try: + if media: + if len(media) > 1: + im = [] + for i, m in enumerate(media): + cap = text_val if i == 0 else None + if m.kind == "photo": + im.append(InputMediaPhoto(media=m.file_id, caption=cap, parse_mode=ParseMode.HTML)) + elif m.kind == "video": + im.append(InputMediaVideo(media=m.file_id, caption=cap, parse_mode=ParseMode.HTML)) + else: + im.append(InputMediaAnimation(media=m.file_id, caption=cap, parse_mode=ParseMode.HTML)) + await ctx.bot.send_media_group(chat_id=cid, media=im) else: - msg = await ctx.bot.send_animation(chat_id=chat_id, animation=m.file_id, caption=d.text, parse_mode=ParseMode.HTML) - sent_ids = [str(msg.message_id)] - else: - msg = await ctx.bot.send_message(chat_id=chat_id, text=d.text or "(пусто)", parse_mode=ParseMode.HTML) - sent_ids = [str(msg.message_id)] + m = media[0] + if m.kind == "photo": + await ctx.bot.send_photo(chat_id=cid, photo=m.file_id, caption=text_val, parse_mode=ParseMode.HTML) + elif m.kind == "video": + await ctx.bot.send_video(chat_id=cid, video=m.file_id, caption=text_val, parse_mode=ParseMode.HTML) + else: + await ctx.bot.send_animation(chat_id=cid, animation=m.file_id, caption=text_val, parse_mode=ParseMode.HTML) + else: + await ctx.bot.send_message(chat_id=cid, text=text_val or "(пусто)", parse_mode=ParseMode.HTML) - deliv = Delivery(draft_id=d.id, chat_id=chat_id, status="sent", message_ids=",".join(sent_ids)) - s.add(deliv) - d.status = "sent" + s.add(Delivery(draft_id=d.id, chat_id=cid, status="sent", content_hash=content_hash)) + s.commit() + ok += 1 + except Exception as e: + s.add(Delivery(draft_id=d.id, chat_id=cid, status="failed", error=str(e), content_hash=content_hash)) + s.commit() + fail += 1 + + ctx.user_data.pop(sel_key, None) + await q.edit_message_text(SENT_SUMMARY.format(ok=ok, fail=fail)) + + # --- Отмена --- + elif data.startswith("draft_cancel:"): + draft_id = int(data.split(":")[1]) + with get_session() as s: + d = s.get(Draft, draft_id) + if d: + d.status = "cancelled" s.commit() - - # Сбрасываем пользовательское состояние редактора - ctx.user_data.pop(KEY_DRAFT_ID, None) - ctx.user_data.pop(STATE_DRAFT, None) - - await q.edit_message_text(SENT_OK) - except Exception as e: - deliv = Delivery(draft_id=d.id, chat_id=chat_id, status="failed", error=str(e)) - s.add(deliv); s.commit() - await q.edit_message_text(SEND_ERR.format(e=e)) + ctx.user_data.pop(f"sel:{draft_id}", None) + ctx.user_data.pop("draft_id", None) + ctx.user_data.pop("draft_state", None) + await q.edit_message_text("Черновик отменён.") diff --git a/app/bot/handlers/drafts.py b/app/bot/handlers/drafts.py index 4795ddd..e300c07 100644 --- a/app/bot/handlers/drafts.py +++ b/app/bot/handlers/drafts.py @@ -1,51 +1,41 @@ from datetime import datetime -from telegram import Update +from telegram import Update, InputMediaPhoto, InputMediaVideo, InputMediaAnimation from telegram.constants import ChatType, ParseMode from telegram.ext import ContextTypes - from app.db.session import get_session from app.db.models import User, Draft -from app.bot.messages import ( - ASK_MEDIA, ASK_TEXT, TEXT_ADDED, CONFIRM, ALREADY_AT_TEXT, ALREADY_READY, NEED_START_NEW -) +from app.bot.messages import ASK_MEDIA, ASK_TEXT, CONFIRM, NEED_START_NEW from app.bot.keyboards.common import kb_next_text, kb_confirm -from .add_group import add_group_capture, STATE_KEY # /add_group ожидание +from .add_group import add_group_capture, STATE_KEY -# Состояния пошагового редактора STATE_DRAFT = "draft_state" KEY_DRAFT_ID = "draft_id" STATE_AWAIT_MEDIA = "await_media" STATE_AWAIT_TEXT = "await_text" STATE_CONFIRM = "confirm" - def _start_new_draft(tg_id: int) -> Draft: with get_session() as s: u = s.query(User).filter_by(tg_id=tg_id).first() if not u: - u = User(tg_id=tg_id, name="") - s.add(u); s.commit(); s.refresh(u) - # Закрываем предыдущие "editing" как cancelled (по желанию) + u = User(tg_id=tg_id, name=""); s.add(u); s.commit(); s.refresh(u) s.query(Draft).filter(Draft.user_id == u.id, Draft.status == "editing").update({"status": "cancelled"}) d = Draft(user_id=u.id, status="editing") s.add(d); s.commit(); s.refresh(d) return d - -def _get_draft(draft_id: int) -> Draft | None: - with get_session() as s: - return s.get(Draft, draft_id) - - async def new_cmd(update: Update, ctx: ContextTypes.DEFAULT_TYPE): d = _start_new_draft(update.effective_user.id) ctx.user_data[KEY_DRAFT_ID] = d.id ctx.user_data[STATE_DRAFT] = STATE_AWAIT_MEDIA - await update.effective_message.reply_text(ASK_MEDIA, reply_markup=kb_next_text(d.id)) - + # Кнопку «Дальше — текст» теперь показываем после добавления медиа, + # поэтому здесь — только инструкция + await update.effective_message.reply_text(ASK_MEDIA) async def on_text(update: Update, ctx: ContextTypes.DEFAULT_TYPE): - # Если ждём chat_id для /add_group, отдаём управление туда + # Если ждём chat_id для /add_group — передаём управление + if ctx.user_data.get("await_dict_file"): + return if ctx.user_data.get(STATE_KEY): return await add_group_capture(update, ctx) @@ -60,20 +50,48 @@ async def on_text(update: Update, ctx: ContextTypes.DEFAULT_TYPE): return if state == STATE_AWAIT_MEDIA: - # Сначала медиа - await update.effective_message.reply_text("Сначала пришлите медиа и нажмите «Дальше — текст».") + await update.effective_message.reply_text("Сначала добавьте медиа и нажмите «Дальше — текст».") return if state == STATE_CONFIRM: - await update.effective_message.reply_text(ALREADY_READY) + await update.effective_message.reply_text("Пост уже готов — нажмите «Отправить» или «Отменить».") return if state == STATE_AWAIT_TEXT: + # Сохраняем текст with get_session() as s: d = s.get(Draft, draft_id) d.text = update.effective_message.text_html_urled d.updated_at = datetime.utcnow() s.commit() + + media = sorted(d.media, key=lambda m: m.order) + + # Предпросмотр + if media: + if len(media) > 1: + im = [] + for i, m in enumerate(media): + cap = d.text if i == 0 else None + if m.kind == "photo": + im.append(InputMediaPhoto(media=m.file_id, caption=cap, parse_mode=ParseMode.HTML)) + elif m.kind == "video": + im.append(InputMediaVideo(media=m.file_id, caption=cap, parse_mode=ParseMode.HTML)) + else: + im.append(InputMediaAnimation(media=m.file_id, caption=cap, parse_mode=ParseMode.HTML)) + await update.effective_chat.send_media_group(media=im) + else: + m = media[0] + if m.kind == "photo": + await update.effective_chat.send_photo(photo=m.file_id, caption=d.text, parse_mode=ParseMode.HTML) + elif m.kind == "video": + await update.effective_chat.send_video(video=m.file_id, caption=d.text, parse_mode=ParseMode.HTML) + else: + await update.effective_chat.send_animation(animation=m.file_id, caption=d.text, parse_mode=ParseMode.HTML) + else: + await update.effective_chat.send_message(text=d.text or "(пусто)", parse_mode=ParseMode.HTML) + + # Переходим к подтверждению и показываем кнопки ctx.user_data[STATE_DRAFT] = STATE_CONFIRM - await update.effective_message.reply_text(TEXT_ADDED, parse_mode=ParseMode.HTML) await update.effective_message.reply_text(CONFIRM, reply_markup=kb_confirm(draft_id)) + diff --git a/app/bot/handlers/errors.py b/app/bot/handlers/errors.py new file mode 100644 index 0000000..873defc --- /dev/null +++ b/app/bot/handlers/errors.py @@ -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 diff --git a/app/bot/handlers/join_info.py b/app/bot/handlers/join_info.py index bbb94db..5dbfc90 100644 --- a/app/bot/handlers/join_info.py +++ b/app/bot/handlers/join_info.py @@ -1,77 +1,50 @@ import asyncio -from telegram import Update +from telegram import Update, InlineKeyboardMarkup, InlineKeyboardButton from telegram.constants import ChatMemberStatus, ChatType, ParseMode from telegram.ext import ContextTypes -from telegram.error import Forbidden -from app.bot.messages import ( - JOIN_DM_GROUP, JOIN_DM_CHANNEL, JOIN_PUBLIC_WITH_ID, NEED_START_DM -) +from app.bot.messages import JOIN_PUBLIC_WITH_ID, NEED_START_DM, BIND_CHANNEL_BTN -TTL_SEC = 30 # через столько секунд удаляем публичную подсказку +TTL_SEC = 30 -async def _auto_delete(ctx: ContextTypes.DEFAULT_TYPE, chat_id: int, message_id: int, delay: int = TTL_SEC): +async def _autodel(ctx: ContextTypes.DEFAULT_TYPE, chat_id: int, mid: int, delay: int = TTL_SEC): try: await asyncio.sleep(delay) - await ctx.bot.delete_message(chat_id=chat_id, message_id=message_id) + await ctx.bot.delete_message(chat_id=chat_id, message_id=mid) except Exception: pass async def on_my_chat_member(update: Update, ctx: ContextTypes.DEFAULT_TYPE): - """ - При добавлении/повышении прав. - 1) Пробуем DM актёру (my_chat_member.from_user) с chat_id и инструкцией. - 2) Если DM не вышел (нет from_user или нет Start/Forbidden) — пишем в чат - подсказку с chat_id и удаляем её через TTL_SEC. - """ mcm = update.my_chat_member if not mcm: return - chat = mcm.chat new_status = mcm.new_chat_member.status if new_status not in (ChatMemberStatus.MEMBER, ChatMemberStatus.ADMINISTRATOR): return - title = chat.title or str(chat.id) - chat_id = chat.id + kb = None + if chat.type == ChatType.CHANNEL: + kb = InlineKeyboardMarkup([[InlineKeyboardButton(BIND_CHANNEL_BTN, callback_data=f"bind:{chat.id}")]]) - # 1) Пытаемся отправить DM тому, кто совершил действие actor = getattr(mcm, "from_user", None) - dm_sent = False if actor: try: - if chat.type in (ChatType.GROUP, ChatType.SUPERGROUP): - await ctx.bot.send_message( - actor.id, JOIN_DM_GROUP.format(title=title, chat_id=chat_id), - parse_mode=ParseMode.MARKDOWN - ) - elif chat.type == ChatType.CHANNEL: - await ctx.bot.send_message( - actor.id, JOIN_DM_CHANNEL.format(title=title, chat_id=chat_id), - parse_mode=ParseMode.MARKDOWN - ) - dm_sent = True - except Forbidden: - # пользователь не нажал Start — подсказка про Start + await ctx.bot.send_message(actor.id, + JOIN_PUBLIC_WITH_ID.format(chat_id=chat.id, ttl=TTL_SEC), + parse_mode=ParseMode.MARKDOWN, + reply_markup=kb) + return + except Exception: try: await ctx.bot.send_message(actor.id, NEED_START_DM) except Exception: pass - except Exception: - pass - if dm_sent: - return - - # 2) DM не удался — публикуем в чат краткий хинт с chat_id, удаляем через TTL - # (для каналов сработает только если бот уже админ и может постить) try: - msg = await ctx.bot.send_message( - chat_id=chat_id, - text=JOIN_PUBLIC_WITH_ID.format(chat_id=chat_id, ttl=TTL_SEC), - parse_mode=ParseMode.MARKDOWN - ) - ctx.application.create_task(_auto_delete(ctx, chat_id, msg.message_id, delay=TTL_SEC)) + msg = await ctx.bot.send_message(chat_id=chat.id, + text=JOIN_PUBLIC_WITH_ID.format(chat_id=chat.id, ttl=TTL_SEC), + parse_mode=ParseMode.MARKDOWN, + reply_markup=kb) + ctx.application.create_task(_autodel(ctx, chat.id, msg.message_id, delay=TTL_SEC)) except Exception: - # Если и сюда не можем — увы, остаётся ручной путь: /id, /add_group и ЛС pass diff --git a/app/bot/handlers/media.py b/app/bot/handlers/media.py index dd201eb..3bdc791 100644 --- a/app/bot/handlers/media.py +++ b/app/bot/handlers/media.py @@ -2,19 +2,18 @@ from datetime import datetime from telegram import Update from telegram.constants import ChatType from telegram.ext import ContextTypes - from app.db.session import get_session from app.db.models import Draft, DraftMedia -from app.bot.messages import MEDIA_ADDED, ALREADY_AT_TEXT, NEED_START_NEW -from .drafts import KEY_DRAFT_ID, STATE_DRAFT, STATE_AWAIT_MEDIA, STATE_AWAIT_TEXT, STATE_CONFIRM -from .add_group import add_group_capture, STATE_KEY # перехват для /add_group - +from app.bot.messages import MEDIA_ADDED, NEED_START_NEW +from app.bot.keyboards.common import kb_next_text +from .drafts import KEY_DRAFT_ID, STATE_DRAFT, STATE_AWAIT_MEDIA +from .add_group import add_group_capture, STATE_KEY async def on_media(update: Update, ctx: ContextTypes.DEFAULT_TYPE): if update.effective_chat.type != ChatType.PRIVATE: return - # Если пользователь сейчас привязывает чат — используем пересланное медиа для извлечения chat_id + # Если сейчас идёт привязка чата — используем пересланное сообщение if ctx.user_data.get(STATE_KEY): return await add_group_capture(update, ctx) @@ -26,8 +25,7 @@ async def on_media(update: Update, ctx: ContextTypes.DEFAULT_TYPE): return if state != STATE_AWAIT_MEDIA: - # Уже перешли к тексту/подтверждению — блокируем добавление медиа - await update.effective_message.reply_text(ALREADY_AT_TEXT if state == STATE_AWAIT_TEXT else "Редактор на шаге подтверждения.") + await update.effective_message.reply_text("Медиа можно добавлять только на шаге 1.") return kind = None @@ -44,8 +42,12 @@ async def on_media(update: Update, ctx: ContextTypes.DEFAULT_TYPE): with get_session() as s: d = s.get(Draft, draft_id) order = len(d.media) - m = DraftMedia(draft_id=d.id, kind=kind, file_id=file_id, order=order) - s.add(m); d.updated_at = datetime.utcnow() + s.add(DraftMedia(draft_id=d.id, kind=kind, file_id=file_id, order=order)) + d.updated_at = datetime.utcnow() s.commit() - await update.effective_message.reply_text(MEDIA_ADDED.format(kind=kind)) + # Показываем кнопку «Дальше — текст» прямо под сообщением «Медиа добавлено» + await update.effective_message.reply_text( + MEDIA_ADDED.format(kind=kind), + reply_markup=kb_next_text(draft_id) + ) diff --git a/app/bot/handlers/mod_status.py b/app/bot/handlers/mod_status.py new file mode 100644 index 0000000..e772286 --- /dev/null +++ b/app/bot/handlers/mod_status.py @@ -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) diff --git a/app/bot/handlers/moderation.py b/app/bot/handlers/moderation.py new file mode 100644 index 0000000..7cd7488 --- /dev/null +++ b/app/bot/handlers/moderation.py @@ -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 diff --git a/app/bot/handlers/security.py b/app/bot/handlers/security.py new file mode 100644 index 0000000..a0f9088 --- /dev/null +++ b/app/bot/handlers/security.py @@ -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)) \ No newline at end of file diff --git a/app/bot/keyboards/common.py b/app/bot/keyboards/common.py index a39f481..83432c6 100644 --- a/app/bot/keyboards/common.py +++ b/app/bot/keyboards/common.py @@ -6,6 +6,7 @@ def kb_next_text(draft_id: int): ) def kb_confirm(draft_id: int): + # Кнопка «Отправить» ведёт к мультивыбору чатов return InlineKeyboardMarkup( [ [ @@ -15,7 +16,21 @@ def kb_confirm(draft_id: int): ] ) +def kb_multiselect(draft_id: int, chats: list[tuple[str, int]], selected: set[int]): + rows = [] + for title, chat_id in chats: + mark = "✅ " if chat_id in selected else "▫️ " + rows.append([InlineKeyboardButton(f"{mark}{title}", callback_data=f"tgl:{draft_id}:{chat_id}")]) + + rows.append([ + InlineKeyboardButton("Выбрать все", callback_data=f"selall:{draft_id}"), + InlineKeyboardButton("Сбросить", callback_data=f"clear:{draft_id}"), + ]) + rows.append([ + InlineKeyboardButton("Отправить выбранные", callback_data=f"sendmulti:{draft_id}"), + InlineKeyboardButton("Отменить", callback_data=f"draft_cancel:{draft_id}"), + ]) + return InlineKeyboardMarkup(rows) + def kb_choose_chat(draft_id: int, chats: list[tuple[str, int]]): - # chats: list of (title, chat_id) - rows = [[InlineKeyboardButton(title, callback_data=f"send:{draft_id}:{chat_id}")] for title, chat_id in chats] - return InlineKeyboardMarkup(rows) if rows else None + return kb_multiselect(draft_id, chats, selected=set()) \ No newline at end of file diff --git a/app/bot/keyboards/security.py b/app/bot/keyboards/security.py new file mode 100644 index 0000000..e9535e5 --- /dev/null +++ b/app/bot/keyboards/security.py @@ -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) diff --git a/app/bot/messages.py b/app/bot/messages.py index 159f215..1a8adbe 100644 --- a/app/bot/messages.py +++ b/app/bot/messages.py @@ -1,19 +1,17 @@ START = ( "Привет! Я помогу отправлять сообщения в ваши группы и каналы.\n\n" "1) Добавьте меня в группу/канал (в канале — дайте право публиковать).\n" - "2) В ЛС боту нажмите /add_group и вставьте chat_id или перешлите сюда сообщение из этого чата.\n" - "3) Создайте черновик /new и отправьте в выбранный чат.\n\n" - "Команды:\n" - "/add_group — привязать группу/канал вручную\n" - "/groups — список моих чатов\n" - "/new — создать черновик поста\n" - "/help — справка" + "2) В ЛС выполните /add_group и вставьте chat_id или перешлите сообщение из этого чата.\n" + "3) Создайте черновик /new и отправьте. Также доступна модерация чатов через /security." ) - HELP = ( - "1) В Telegram добавьте бота в группу/канал (для каналов — админ с правом «Публиковать сообщения»).\n" - "2) В ЛС — /add_group: вставьте chat_id (например, -100123...) или перешлите сюда любое сообщение из чата.\n" - "3) Создайте черновик /new и отправьте его в выбранный чат." + "Команды:\n" + "/add_group — привязать группу/канал вручно\n" + "/groups — список ваших чатов\n" + "/new — конструктор поста (медиа→текст→подтверждение)\n" + "/security — политика безопасности, словари, включение модерации\n" + "/spam_import — импорт словаря (txt/csv) в ЛС\n" + "/id — показать chat_id (только для админов)" ) ASK_ADD_GROUP = ( @@ -24,16 +22,16 @@ ASK_ADD_GROUP = ( NO_CHATS = "Пока ни одного чата не привязано. Нажмите /add_group для привязки." # Пошаговый редактор +# Шаги редактора ASK_MEDIA = ( "Шаг 1/3 — медиа.\nПришлите фото/видео/гиф. Можно несколько (альбом).\n" - "Когда закончите — нажмите «Дальше — текст»." -) -ASK_TEXT = ( - "Шаг 2/3 — текст.\nОтправьте текст поста. Он станет подписью к медиа (или отдельным сообщением, если медиа нет)." -) -CONFIRM = ( - "Шаг 3/3 — подтверждение.\nПроверьте пост и нажмите «Отправить» или «Отменить»." + "Кнопка «Дальше — текст» появится под сообщением «Медиа добавлено»." ) +ASK_TEXT = "Шаг 2/3 — текст.\nОтправьте текст поста." +CONFIRM = "Шаг 3/3 — подтверждение.\nПроверьте пост и нажмите «Отправить» или «Отменить»." + +# Сообщения + TEXT_ADDED = "Текст добавлен в черновик." MEDIA_ADDED = "Медиа добавлено ({kind})." @@ -45,7 +43,9 @@ CANCELLED = "Черновик отменён." READY_SELECT_CHAT = "Куда отправить?" SENT_OK = "✅ Отправлено." +SENT_SUMMARY = "✅ Готово. Успешно: {ok} Ошибок: {fail}" SEND_ERR = "❌ Ошибка отправки: {e}" +NO_SELECTION = "❌ Не выбрано ни одного чата." GROUP_BOUND = "Чат «{title_or_id}» привязан.\n{rights}" NEED_ADD_FIRST = "Я не добавлен в «{title_or_id}». Сначала добавьте бота в этот чат и повторите /add_group." @@ -107,4 +107,12 @@ JOIN_PUBLIC_WITH_ID = ( NEED_START_DM = ( "Не удалось отправить ЛС: Telegram запрещает писать до нажатия «Start».\n" "Откройте мой профиль и нажмите Start, затем /add_group." -) \ No newline at end of file +) + + +# Кнопка быстрой привязки канала +BIND_CHANNEL_BTN = "Привязать этот канал" +BIND_OK = "Канал «{title}» привязан. ✅" +BIND_FAIL_NOT_ADMIN = "Привязку может выполнять только администратор этого канала." +BIND_FAIL_BOT_RIGHTS = "Я не администратор в канале или у меня нет права публиковать сообщения." +BIND_FAIL_GENERIC = "Не получилось привязать канал. Попробуйте /add_group." \ No newline at end of file diff --git a/app/config.py b/app/config.py index fb7abc5..d715a3f 100644 --- a/app/config.py +++ b/app/config.py @@ -5,6 +5,7 @@ from dataclasses import dataclass class Config: bot_token: str database_url: str + metrics_port: int = 8000 log_level: str = os.getenv("LOG_LEVEL", "INFO") def load_config() -> "Config": @@ -20,4 +21,5 @@ def load_config() -> "Config": user = os.getenv("DB_USER", "postgres") pwd = os.getenv("DB_PASSWORD", "postgres") db_url = f"postgresql+psycopg://{user}:{pwd}@{host}:{port}/{name}" - return Config(bot_token=bot_token, database_url=db_url) + metric_port = int(os.getenv("METRICS_PORT", 9010)) + return Config(bot_token=bot_token, database_url=db_url, metrics_port=metric_port) \ No newline at end of file diff --git a/app/db/models.py b/app/db/models.py index 6cfcbc5..030bfeb 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -1,20 +1,35 @@ from datetime import datetime -from sqlalchemy import Column, Integer, BigInteger, String, DateTime, ForeignKey, Text, Boolean +from sqlalchemy import ( + Column, + Integer, + BigInteger, + String, + DateTime, + ForeignKey, + Text, + Boolean, + UniqueConstraint, +) from sqlalchemy.orm import relationship + from app.db.base import Base + class User(Base): __tablename__ = "users" + id = Column(Integer, primary_key=True) tg_id = Column(BigInteger, unique=True, nullable=False, index=True) name = Column(String(255)) created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + class Chat(Base): __tablename__ = "chats" + id = Column(Integer, primary_key=True) chat_id = Column(BigInteger, unique=True, nullable=False, index=True) - type = Column(String(32)) # "group" | "supergroup" | "channel" + type = Column(String(32)) # group | supergroup | channel title = Column(String(255)) owner_user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True) can_post = Column(Boolean, default=False, nullable=False) @@ -22,35 +37,179 @@ class Chat(Base): owner = relationship("User") + class Draft(Base): __tablename__ = "drafts" + id = Column(Integer, primary_key=True) user_id = Column(Integer, ForeignKey("users.id"), index=True) text = Column(Text, nullable=True) - status = Column(String(16), default="editing", index=True) # editing | ready | sent + status = Column(String(16), default="editing", index=True) # editing|ready|sent|cancelled created_at = Column(DateTime, default=datetime.utcnow, nullable=False) updated_at = Column(DateTime, default=datetime.utcnow, nullable=False) user = relationship("User") media = relationship("DraftMedia", cascade="all, delete-orphan", back_populates="draft") + class DraftMedia(Base): __tablename__ = "draft_media" + id = Column(Integer, primary_key=True) draft_id = Column(Integer, ForeignKey("drafts.id"), index=True) kind = Column(String(16)) # photo | video | animation file_id = Column(String(255)) - """Ordering inside album.""" order = Column(Integer, default=0) draft = relationship("Draft", back_populates="media") + class Delivery(Base): __tablename__ = "deliveries" + id = Column(Integer, primary_key=True) draft_id = Column(Integer, ForeignKey("drafts.id"), index=True) chat_id = Column(BigInteger, index=True) status = Column(String(16), default="new", index=True) # new | sent | failed error = Column(Text, nullable=True) - message_ids = Column(Text, nullable=True) # CSV for album parts + message_ids = Column(Text, nullable=True) # csv для альбомов/нескольких сообщений + content_hash = Column(String(128), index=True, nullable=True) # анти-дубликаты + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + + +# --- учёт участников (для аналитики/модерации) --- +class ChatMember(Base): + __tablename__ = "chat_members" + + id = Column(Integer, primary_key=True) + chat_id = Column(BigInteger, index=True, nullable=False) + user_id = Column(Integer, ForeignKey("users.id"), index=True, nullable=False) + tg_user_id = Column(BigInteger, index=True, nullable=False) + username = Column(String(255)) + first_name = Column(String(255)) + last_name = Column(String(255)) + status = Column(String(32), index=True) # member | administrator | creator | left | kicked ... + is_admin = Column(Boolean, default=False, nullable=False) + first_seen_at = Column(DateTime, nullable=False, default=datetime.utcnow) + last_seen_at = Column(DateTime, nullable=False, default=datetime.utcnow) + + user = relationship("User") + + __table_args__ = (UniqueConstraint("chat_id", "tg_user_id", name="uq_chat_members_chat_user"),) + + +# --- политики безопасности и словари --- +class SecurityPolicy(Base): + __tablename__ = "security_policies" + + id = Column(Integer, primary_key=True) + owner_user_id = Column(Integer, ForeignKey("users.id"), index=True, nullable=True) # NULL = глобальная + name = Column(String(100), nullable=False) + + # категории словарей (True = блокировать) + block_adult = Column(Boolean, default=True, nullable=False) + block_spam = Column(Boolean, default=True, nullable=False) + block_scam = Column(Boolean, default=True, nullable=False) + block_profanity = Column(Boolean, default=False, nullable=False) + + # лимиты и режимы + cooldown_seconds = Column(Integer, default=30, nullable=False) # пауза между постами/чат + duplicate_window_seconds = Column(Integer, default=120, nullable=False) # окно дублей (сек) + max_links = Column(Integer, default=3, nullable=False) + max_mentions = Column(Integer, default=5, nullable=False) + use_whitelist = Column(Boolean, default=False, nullable=False) + + # наказания/эскалация (для входящих сообщений в группе) + enforce_action_default = Column(String(16), default="delete", nullable=False) # delete|warn|timeout|ban|none + timeout_minutes = Column(Integer, default=10, nullable=False) + strikes_to_warn = Column(Integer, default=1, nullable=False) + strikes_to_timeout = Column(Integer, default=2, nullable=False) + strikes_to_ban = Column(Integer, default=3, nullable=False) + user_msg_per_minute = Column(Integer, default=0, nullable=False) # 0 = выключено + + +class ChatSecurity(Base): + __tablename__ = "chat_security" + + id = Column(Integer, primary_key=True) + chat_id = Column(BigInteger, index=True, nullable=False, unique=True) + policy_id = Column(Integer, ForeignKey("security_policies.id"), index=True, nullable=False) + enabled = Column(Boolean, default=False, nullable=False) # включена ли модерация для этого чата + + +class SpamDictionary(Base): + __tablename__ = "spam_dictionaries" + + id = Column(Integer, primary_key=True) + owner_user_id = Column(Integer, ForeignKey("users.id"), index=True, nullable=True) # NULL = глобальная + name = Column(String(120), nullable=False) + category = Column(String(32), nullable=False) # adult | spam | scam | profanity | custom + kind = Column(String(16), nullable=False) # plain | regex + lang = Column(String(8), nullable=True) + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + + +class DictionaryEntry(Base): + __tablename__ = "dictionary_entries" + + id = Column(Integer, primary_key=True) + dictionary_id = Column(Integer, ForeignKey("spam_dictionaries.id"), index=True, nullable=False) + pattern = Column(Text, nullable=False) # слово/фраза или регулярка + is_regex = Column(Boolean, default=False, nullable=False) + + +class PolicyDictionaryLink(Base): + __tablename__ = "policy_dict_links" + + id = Column(Integer, primary_key=True) + policy_id = Column(Integer, ForeignKey("security_policies.id"), index=True, nullable=False) + dictionary_id = Column(Integer, ForeignKey("spam_dictionaries.id"), index=True, nullable=False) + + __table_args__ = (UniqueConstraint("policy_id", "dictionary_id", name="uq_policy_dict"),) + + +class DomainRule(Base): + __tablename__ = "domain_rules" + + id = Column(Integer, primary_key=True) + policy_id = Column(Integer, ForeignKey("security_policies.id"), index=True, nullable=False) + domain = Column(String(255), nullable=False) + kind = Column(String(16), nullable=False) # whitelist | blacklist + + __table_args__ = (UniqueConstraint("policy_id", "domain", "kind", name="uq_domain_rule"),) + + +# --- журнал модерации/событий --- +class ModerationLog(Base): + __tablename__ = "moderation_logs" + + id = Column(Integer, primary_key=True) + chat_id = Column(BigInteger, index=True, nullable=False) + tg_user_id = Column(BigInteger, index=True, nullable=False) + message_id = Column(BigInteger, nullable=True) + reason = Column(Text, nullable=False) # причины (через '; '), либо текст ошибки + action = Column(String(16), nullable=False) # delete|warn|timeout|ban|error|none + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + + +class UserStrike(Base): + __tablename__ = "user_strikes" + + id = Column(Integer, primary_key=True) + chat_id = Column(BigInteger, index=True, nullable=False) + tg_user_id = Column(BigInteger, index=True, nullable=False) + strikes = Column(Integer, default=0, nullable=False) + updated_at = Column(DateTime, default=datetime.utcnow, nullable=False) + + __table_args__ = (UniqueConstraint("chat_id", "tg_user_id", name="uq_strikes_chat_user"),) + + +class MessageEvent(Base): + __tablename__ = "message_events" + + id = Column(Integer, primary_key=True) + chat_id = Column(BigInteger, index=True, nullable=False) + tg_user_id = Column(BigInteger, index=True, nullable=False) + message_id = Column(BigInteger, nullable=True) + content_hash = Column(String(128), index=True, nullable=True) created_at = Column(DateTime, default=datetime.utcnow, nullable=False) diff --git a/app/infra/__init__.py b/app/infra/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/infra/metrics.py b/app/infra/metrics.py new file mode 100644 index 0000000..26ad4d0 --- /dev/null +++ b/app/infra/metrics.py @@ -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 diff --git a/app/infra/redis_client.py b/app/infra/redis_client.py new file mode 100644 index 0000000..4cd4d7f --- /dev/null +++ b/app/infra/redis_client.py @@ -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 diff --git a/app/main.py b/app/main.py index 205e3ec..9e5a7fb 100644 --- a/app/main.py +++ b/app/main.py @@ -8,6 +8,14 @@ from app.bot.handlers.media import on_media from app.bot.handlers.callbacks import on_callback from app.bot.handlers.join_info import on_my_chat_member from app.bot.handlers.chat_id_cmd import chat_id_cmd +from app.bot.handlers.bind_chat import bind_chat_cb +from app.bot.handlers.security import security_cmd, security_cb, spam_import_cmd, spam_import_capture +from app.bot.handlers.moderation import moderate_message +from app.bot.handlers.errors import on_error +from app.bot.handlers.mod_status import mod_status_cmd +from app.infra.metrics import start_metrics_server +from app.bot.handlers.security import dicts_cmd, dicts_cb + def main(): cfg = load_config() @@ -21,18 +29,43 @@ def main(): app.add_handler(CommandHandler("add_group", add_group_cmd)) app.add_handler(CommandHandler("new", new_cmd)) app.add_handler(CommandHandler("id", chat_id_cmd)) + app.add_handler(CommandHandler("mod_status", mod_status_cmd)) + # команды + app.add_handler(CommandHandler("dicts", dicts_cmd)) - # Callback queries - app.add_handler(CallbackQueryHandler(on_callback)) + # коллбэки словарей + app.add_handler(CallbackQueryHandler(dicts_cb, pattern=r"^dict:")) + + # Callbacks (order matters!) + app.add_handler(CallbackQueryHandler(security_cb, pattern=r"^pol:")) + app.add_handler(CallbackQueryHandler(bind_chat_cb, pattern=r"^bind:")) + app.add_handler(CallbackQueryHandler(on_callback, pattern=r"^(draft_|tgl:|selall:|clear:|sendmulti:)")) - # Private chat handlers - app.add_handler(MessageHandler(filters.ChatType.PRIVATE & filters.TEXT, on_text)) + # Private chat helpers app.add_handler(MessageHandler(filters.ChatType.PRIVATE & filters.FORWARDED, add_group_capture)) app.add_handler(MessageHandler(filters.ChatType.PRIVATE & (filters.PHOTO | filters.VIDEO | filters.ANIMATION), on_media)) # Join/rights updates app.add_handler(ChatMemberHandler(on_my_chat_member, chat_member_types=ChatMemberHandler.MY_CHAT_MEMBER)) + # Security / Dict + app.add_handler(CommandHandler("security", security_cmd)) + app.add_handler(CommandHandler("spam_import", spam_import_cmd, filters.ChatType.PRIVATE)) + async def spam_import_redirect(update, ctx): + await update.effective_message.reply_text("Эту команду нужно выполнять в ЛС. Откройте чат со мной и пришлите /spam_import.") + app.add_handler(CommandHandler("spam_import", spam_import_redirect, filters.ChatType.GROUPS)) + app.add_handler(MessageHandler(filters.ChatType.PRIVATE & filters.Document.ALL, spam_import_capture)) + from app.bot.handlers.security import spam_import_text_capture + app.add_handler(MessageHandler(filters.ChatType.PRIVATE & filters.TEXT & ~filters.COMMAND, spam_import_text_capture, block=False)) + + # Moderation + app.add_handler(MessageHandler(filters.ChatType.GROUPS & ~filters.COMMAND, moderate_message)) + + # Draft editor (after import handlers) + app.add_handler(MessageHandler(filters.ChatType.PRIVATE & filters.TEXT & ~filters.COMMAND, on_text)) + + start_metrics_server(cfg.metrics_port) + app.run_polling(allowed_updates=None) if __name__ == "__main__": diff --git a/app/migrations/versions/492141c83560_wip.py b/app/migrations/versions/492141c83560_wip.py new file mode 100644 index 0000000..2cbfab0 --- /dev/null +++ b/app/migrations/versions/492141c83560_wip.py @@ -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 ### diff --git a/app/moderation/__init__.py b/app/moderation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/moderation/cache.py b/app/moderation/cache.py new file mode 100644 index 0000000..ec74e94 --- /dev/null +++ b/app/moderation/cache.py @@ -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() diff --git a/app/moderation/engine.py b/app/moderation/engine.py new file mode 100644 index 0000000..a2539ca --- /dev/null +++ b/app/moderation/engine.py @@ -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" diff --git a/app/moderation/logger_task.py b/app/moderation/logger_task.py new file mode 100644 index 0000000..37c3f63 --- /dev/null +++ b/app/moderation/logger_task.py @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 3141a93..6161fec 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,3 @@ -version: "3.9" services: db: image: postgres:16-alpine @@ -23,6 +22,29 @@ services: condition: service_healthy env_file: .env restart: unless-stopped + volumes: + - .:/app + + redis: + image: redis:7-alpine + command: ["redis-server","--save","", "--appendonly","no"] + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 10 + ports: + - "${REDIS_PORT:-6379}:6379" + + prometheus: + image: prom/prometheus:v2.55.1 + command: ["--config.file=/etc/prometheus/prometheus.yml"] + volumes: + - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro + depends_on: + - bot + ports: + - "9090:9090" volumes: pgdata: diff --git a/prometheus/prometheus.yml b/prometheus/prometheus.yml new file mode 100644 index 0000000..aa36623 --- /dev/null +++ b/prometheus/prometheus.yml @@ -0,0 +1,8 @@ +global: + scrape_interval: 5s + evaluation_interval: 15s + +scrape_configs: + - job_name: "tg_bot" + static_configs: + - targets: ["bot:9100"] # бот слушает метрики на 9100 внутри сети compose diff --git a/requirements.txt b/requirements.txt index 3ddceaf..1395eae 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,7 @@ SQLAlchemy==2.0.36 alembic==1.13.2 psycopg[binary]==3.2.1 python-dotenv==1.0.1 +redis>=5.0.0 +prometheus-client==0.20.0 +pyahocorasick==2.1.0 +xxhash==3.4.1