security features

This commit is contained in:
2025-08-20 21:53:40 +09:00
parent 745046c638
commit efdafb0efa
5 changed files with 173 additions and 77 deletions

View File

@@ -9,6 +9,8 @@ from app.bot.messages import (
NEED_ADD_FIRST, NEED_ADD_FIRST,
NO_RIGHTS_CHANNEL, NO_RIGHTS_CHANNEL,
NO_RIGHTS_GROUP, NO_RIGHTS_GROUP,
ONLY_ADMINS_CAN_BIND,
ALREADY_BOUND,
) )
from app.db.session import get_session from app.db.session import get_session
from app.db.models import User, Chat from app.db.models import User, Chat
@@ -16,42 +18,26 @@ from .utils import parse_chat_id, verify_and_fetch_chat
STATE_KEY = "await_chat_id" STATE_KEY = "await_chat_id"
def _get_forwarded_chat_id(message) -> int | None: def _get_forwarded_chat_id(message) -> int | None:
"""
Универсально достаём chat_id источника пересылки.
Поддерживает старые поля (forward_from_chat) и новые (forward_origin.chat).
"""
if not message:
return None
# Старое поле (иногда ещё присутствует)
fwd_chat = getattr(message, "forward_from_chat", None) fwd_chat = getattr(message, "forward_from_chat", None)
if fwd_chat: if fwd_chat:
return fwd_chat.id return fwd_chat.id
# Новая схема Bot API: forward_origin с типами MessageOrigin*
origin = getattr(message, "forward_origin", None) origin = getattr(message, "forward_origin", None)
if origin: if origin:
chat = getattr(origin, "chat", None) # у MessageOriginChat/Channel есть .chat chat = getattr(origin, "chat", None)
if chat: if chat:
return chat.id return chat.id
return None return None
async def add_group_cmd(update: Update, ctx: ContextTypes.DEFAULT_TYPE): async def add_group_cmd(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
# ensure user exists
with get_session() as s: with get_session() as s:
u = s.query(User).filter_by(tg_id=update.effective_user.id).first() u = s.query(User).filter_by(tg_id=update.effective_user.id).first()
if not u: if not u:
u = User(tg_id=update.effective_user.id, name=update.effective_user.full_name) u = User(tg_id=update.effective_user.id, name=update.effective_user.full_name)
s.add(u) s.add(u); s.commit()
s.commit()
ctx.user_data[STATE_KEY] = True ctx.user_data[STATE_KEY] = True
await update.effective_message.reply_text(ASK_ADD_GROUP, parse_mode=ParseMode.MARKDOWN) await update.effective_message.reply_text(ASK_ADD_GROUP, parse_mode=ParseMode.MARKDOWN)
async def add_group_capture(update: Update, ctx: ContextTypes.DEFAULT_TYPE): async def add_group_capture(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
if update.effective_chat.type != ChatType.PRIVATE: if update.effective_chat.type != ChatType.PRIVATE:
return return
@@ -59,19 +45,12 @@ async def add_group_capture(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
return return
msg = update.effective_message msg = update.effective_message
raw = None raw = _get_forwarded_chat_id(msg)
if not raw:
# 1) Пересланное сообщение из чата/канала
fwd_cid = _get_forwarded_chat_id(msg)
if fwd_cid:
raw = fwd_cid
else:
# 2) Ввод в виде текста или подписи (caption)
txt = (getattr(msg, "text", None) or getattr(msg, "caption", None) or "").strip() txt = (getattr(msg, "text", None) or getattr(msg, "caption", None) or "").strip()
if not txt: if not txt:
await msg.reply_text("Вставьте chat_id или перешлите сообщение из группы/канала.") await msg.reply_text("Вставьте chat_id или перешлите сообщение из группы/канала.")
return return
if txt.startswith("@") or "t.me/" in txt: if txt.startswith("@") or "t.me/" in txt:
raw = txt raw = txt
else: else:
@@ -81,21 +60,39 @@ async def add_group_capture(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
return return
raw = cid raw = cid
# Проверяем, что бот в чате и что у него есть право постить (если нужно) # Проверяем наличие бота в чате и его право постить
try: try:
chat, member, can_post = await verify_and_fetch_chat(ctx, raw) chat, member_bot, can_post = await verify_and_fetch_chat(ctx, raw)
except BadRequest: except BadRequest:
await msg.reply_text("Такого чата не существует или у меня нет доступа. Проверьте chat_id/username.") await msg.reply_text("Такого чата не существует или у меня нет доступа. Проверьте chat_id/username.")
return return
if member is None: if member_bot is None:
await msg.reply_text(NEED_ADD_FIRST.format(title_or_id=(chat.title or chat.id))) await msg.reply_text(NEED_ADD_FIRST.format(title_or_id=(chat.title or chat.id)))
return return
# Сохраняем привязку # --- Новое: проверяем, что привязку делает АДМИН этого чата/канала ---
try:
user_member = await ctx.bot.get_chat_member(chat.id, update.effective_user.id)
user_status = getattr(user_member, "status", "")
is_admin = user_status in ("administrator", "creator")
except Exception:
is_admin = False
if not is_admin:
await msg.reply_text(ONLY_ADMINS_CAN_BIND)
return
with get_session() as s: with get_session() as s:
me = s.query(User).filter_by(tg_id=update.effective_user.id).first() me = s.query(User).filter_by(tg_id=update.effective_user.id).first()
row = s.query(Chat).filter_by(chat_id=chat.id).first() row = s.query(Chat).filter_by(chat_id=chat.id).first()
# Если уже привязан к другому владельцу — запрещаем
if row and row.owner_user_id and row.owner_user_id != me.id:
await msg.reply_text(ALREADY_BOUND)
ctx.user_data.pop(STATE_KEY, None)
return
if not row: if not row:
row = Chat( row = Chat(
chat_id=chat.id, chat_id=chat.id,
@@ -108,8 +105,7 @@ async def add_group_capture(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
else: else:
row.title = chat.title row.title = chat.title
row.type = chat.type row.type = chat.type
if row.owner_user_id is None: row.owner_user_id = row.owner_user_id or me.id
row.owner_user_id = me.id
row.can_post = can_post row.can_post = can_post
s.commit() s.commit()

View File

@@ -0,0 +1,34 @@
import asyncio
from telegram import Update
from telegram.constants import ChatType, ParseMode
from telegram.ext import ContextTypes
TTL_SEC = 20
async def _auto_delete(ctx: ContextTypes.DEFAULT_TYPE, chat_id: int, message_id: int, delay: int = TTL_SEC):
try:
await asyncio.sleep(delay)
await ctx.bot.delete_message(chat_id=chat_id, message_id=message_id)
except Exception:
pass
async def chat_id_cmd(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
chat = update.effective_chat
if chat.type not in (ChatType.GROUP, ChatType.SUPERGROUP, ChatType.CHANNEL):
await update.effective_message.reply_text("Эта команда работает в группе/канале.")
return
# Только админы могут увидеть ID (снижаем риск утечки)
try:
member = await ctx.bot.get_chat_member(chat.id, update.effective_user.id)
if member.status not in ("administrator", "creator"):
return # молча игнорируем для не-админов
except Exception:
return
text = f"ID этого чата: `{chat.id}`\nСообщение удалится через {TTL_SEC} сек."
try:
msg = await update.effective_message.reply_text(text, parse_mode=ParseMode.MARKDOWN)
ctx.application.create_task(_auto_delete(ctx, chat.id, msg.message_id, delay=TTL_SEC))
except Exception:
pass

View File

@@ -1,12 +1,27 @@
import asyncio
from telegram import Update from telegram import Update
from telegram.constants import ChatMemberStatus, ChatType, ParseMode from telegram.constants import ChatMemberStatus, ChatType, ParseMode
from telegram.ext import ContextTypes from telegram.ext import ContextTypes
from app.bot.messages import JOIN_INFO_GROUP, JOIN_INFO_CHANNEL from telegram.error import Forbidden
from app.bot.messages import (
JOIN_DM_GROUP, JOIN_DM_CHANNEL, JOIN_PUBLIC_WITH_ID, NEED_START_DM
)
TTL_SEC = 30 # через столько секунд удаляем публичную подсказку
async def _auto_delete(ctx: ContextTypes.DEFAULT_TYPE, chat_id: int, message_id: int, delay: int = TTL_SEC):
try:
await asyncio.sleep(delay)
await ctx.bot.delete_message(chat_id=chat_id, message_id=message_id)
except Exception:
pass
async def on_my_chat_member(update: Update, ctx: ContextTypes.DEFAULT_TYPE): async def on_my_chat_member(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
""" """
Сообщаем инструкцию и chat_id, когда бота добавили в группу/канал При добавлении/повышении прав.
или повысили до администратора. 1) Пробуем DM актёру (my_chat_member.from_user) с chat_id и инструкцией.
2) Если DM не вышел (нет from_user или нет Start/Forbidden) — пишем в чат
подсказку с chat_id и удаляем её через TTL_SEC.
""" """
mcm = update.my_chat_member mcm = update.my_chat_member
if not mcm: if not mcm:
@@ -20,32 +35,43 @@ async def on_my_chat_member(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
title = chat.title or str(chat.id) title = chat.title or str(chat.id)
chat_id = chat.id chat_id = chat.id
# Текст подсказки # 1) Пытаемся отправить DM тому, кто совершил действие
actor = getattr(mcm, "from_user", None)
dm_sent = False
if actor:
try:
if chat.type in (ChatType.GROUP, ChatType.SUPERGROUP): if chat.type in (ChatType.GROUP, ChatType.SUPERGROUP):
text = JOIN_INFO_GROUP.format(title=title, chat_id=chat_id) 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
try: try:
await ctx.bot.send_message(chat_id=chat_id, text=text, parse_mode=ParseMode.MARKDOWN) await ctx.bot.send_message(actor.id, NEED_START_DM)
except Exception:
# как запасной вариант — в ЛС пользователю, который добавил
if update.effective_user:
try:
await ctx.bot.send_message(update.effective_user.id, text=text, parse_mode=ParseMode.MARKDOWN)
except Exception: except Exception:
pass pass
elif chat.type == ChatType.CHANNEL:
text = JOIN_INFO_CHANNEL.format(title=title, chat_id=chat_id)
# В канале можем не иметь права постинга — пробуем ЛС добавившему
sent = False
try:
# если права даны, сообщим прямо в канал
await ctx.bot.send_message(chat_id=chat_id, text=text, parse_mode=ParseMode.MARKDOWN)
sent = True
except Exception: except Exception:
pass pass
if not sent and update.effective_user: if dm_sent:
return
# 2) DM не удался — публикуем в чат краткий хинт с chat_id, удаляем через TTL
# (для каналов сработает только если бот уже админ и может постить)
try: try:
await ctx.bot.send_message(update.effective_user.id, text=text, parse_mode=ParseMode.MARKDOWN) 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))
except Exception: except Exception:
# Если и сюда не можем — увы, остаётся ручной путь: /id, /add_group и ЛС
pass pass

View File

@@ -52,21 +52,59 @@ NEED_ADD_FIRST = "Я не добавлен в «{title_or_id}». Сначала
NO_RIGHTS_CHANNEL = "⚠️ Я в канале не администратор. Дайте боту право «Публиковать сообщения» и повторите /add_group." NO_RIGHTS_CHANNEL = "⚠️ Я в канале не администратор. Дайте боту право «Публиковать сообщения» и повторите /add_group."
NO_RIGHTS_GROUP = "⚠️ Похоже, я не могу публиковать. Проверьте права чата." NO_RIGHTS_GROUP = "⚠️ Похоже, я не могу публиковать. Проверьте права чата."
# --- Новое: инструкции при добавлении бота в чат/канал --- # --- Новое: защита при добавлении и привязке ---
JOIN_INFO_GROUP = ( ONLY_ADMINS_CAN_BIND = "Привязку может выполнять только администратор этого чата."
"Спасибо, что добавили меня в группу «{title}»!\n" ALREADY_BOUND = "Этот чат уже привязан другим пользователем. Попросите владельца выдать доступ или отвязать."
# Инструкции при добавлении: DM (с chat_id) и публичная подсказка без chat_id
JOIN_DM_GROUP = (
"Вы добавили меня в группу «{title}».\n"
"ID группы: `{chat_id}`\n\n" "ID группы: `{chat_id}`\n\n"
"Чтобы привязать эту группу к своему аккаунту:\n" "Чтобы привязать:\n"
"1) Откройте ЛС со мной\n" "1) Откройте мой ЛС\n"
"2) Выполните команду /add_group\n" "2) Выполните /add_group\n"
"3) Вставьте ID выше *или* просто перешлите сюда любое сообщение из этой группы." "3) Вставьте ID выше *или* перешлите сюда сообщение из этой группы."
)
JOIN_DM_CHANNEL = (
"Вы добавили меня в канал «{title}».\n"
"ID канала: `{chat_id}`\n\n"
"Чтобы привязать:\n"
"1) Откройте мой ЛС и выполните /add_group\n"
"2) Вставьте ID выше *или* перешлите сюда сообщение из канала\n\n"
"⚠️ Для публикации дайте боту право «Публиковать сообщения» (сделайте администратором)."
)
JOIN_PUBLIC_HINT = (
"Спасибо за добавление! Чтобы активировать отправку постов, напишите мне в ЛС и выполните /add_group.\n"
"Это служебное сообщение будет удалено через 30 секунд."
) )
JOIN_INFO_CHANNEL = ( # Инструкции при добавлении: DM (с chat_id)
"Спасибо, что добавили меня в канал «{title}»!\n" JOIN_DM_GROUP = (
"ID канала: `{chat_id}`\n\n" "Вы добавили меня в группу «{title}».\n"
"Чтобы привязать канал к своему аккаунту:\n" "ID группы: `{chat_id}`\n\n"
"1) Откройте ЛС со мной и выполните /add_group\n" "Чтобы привязать:\n"
"2) Вставьте ID выше *или* перешлите сюда сообщение из канала\n\n" "1) Откройте мой ЛС\n"
"⚠️ Для публикации сообщений мне нужно право «Публиковать сообщения» (сделайте бота администратором с этим правом)." "2) Выполните /add_group\n"
"3) Вставьте ID выше *или* перешлите сюда сообщение из этой группы."
)
JOIN_DM_CHANNEL = (
"Вы добавили меня в канал «{title}».\n"
"ID канала: `{chat_id}`\n\n"
"Чтобы привязать:\n"
"1) Откройте мой ЛС и выполните /add_group\n"
"2) Вставьте ID выше *или* перешлите сюда сообщение из канала\n\n"
"⚠️ Для публикации дайте боту право «Публиковать сообщения» (сделайте администратором)."
)
# Публичный хинт (если DM не удался) — с chat_id и автосносом
JOIN_PUBLIC_WITH_ID = (
"Спасибо за добавление! ID этого чата: `{chat_id}`.\n"
"Напишите мне в ЛС и выполните /add_group, вставив ID.\n"
"Сообщение удалится через {ttl} сек."
)
# Подсказка, если пользователь не нажимал Start
NEED_START_DM = (
"Не удалось отправить ЛС: Telegram запрещает писать до нажатия «Start».\n"
"Откройте мой профиль и нажмите Start, затем /add_group."
) )

View File

@@ -6,7 +6,8 @@ from app.bot.handlers.add_group import add_group_cmd, add_group_capture
from app.bot.handlers.drafts import new_cmd, on_text from app.bot.handlers.drafts import new_cmd, on_text
from app.bot.handlers.media import on_media from app.bot.handlers.media import on_media
from app.bot.handlers.callbacks import on_callback from app.bot.handlers.callbacks import on_callback
from app.bot.handlers.join_info import on_my_chat_member # ← ново from app.bot.handlers.join_info import on_my_chat_member
from app.bot.handlers.chat_id_cmd import chat_id_cmd
def main(): def main():
cfg = load_config() cfg = load_config()
@@ -19,6 +20,7 @@ def main():
app.add_handler(CommandHandler("groups", groups_cmd)) app.add_handler(CommandHandler("groups", groups_cmd))
app.add_handler(CommandHandler("add_group", add_group_cmd)) app.add_handler(CommandHandler("add_group", add_group_cmd))
app.add_handler(CommandHandler("new", new_cmd)) app.add_handler(CommandHandler("new", new_cmd))
app.add_handler(CommandHandler("id", chat_id_cmd))
# Callback queries # Callback queries
app.add_handler(CallbackQueryHandler(on_callback)) app.add_handler(CallbackQueryHandler(on_callback))
@@ -28,7 +30,7 @@ def main():
app.add_handler(MessageHandler(filters.ChatType.PRIVATE & filters.FORWARDED, add_group_capture)) app.add_handler(MessageHandler(filters.ChatType.PRIVATE & filters.FORWARDED, add_group_capture))
app.add_handler(MessageHandler(filters.ChatType.PRIVATE & (filters.PHOTO | filters.VIDEO | filters.ANIMATION), on_media)) app.add_handler(MessageHandler(filters.ChatType.PRIVATE & (filters.PHOTO | filters.VIDEO | filters.ANIMATION), on_media))
# NEW: реагируем, когда бота добавили/изменили права в чате # Join/rights updates
app.add_handler(ChatMemberHandler(on_my_chat_member, chat_member_types=ChatMemberHandler.MY_CHAT_MEMBER)) app.add_handler(ChatMemberHandler(on_my_chat_member, chat_member_types=ChatMemberHandler.MY_CHAT_MEMBER))
app.run_polling(allowed_updates=None) app.run_polling(allowed_updates=None)