diff --git a/app/bot/handlers/add_group.py b/app/bot/handlers/add_group.py index 47130e1..11dc4f5 100644 --- a/app/bot/handlers/add_group.py +++ b/app/bot/handlers/add_group.py @@ -9,6 +9,8 @@ from app.bot.messages import ( NEED_ADD_FIRST, NO_RIGHTS_CHANNEL, NO_RIGHTS_GROUP, + ONLY_ADMINS_CAN_BIND, + ALREADY_BOUND, ) from app.db.session import get_session 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" - 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) if fwd_chat: return fwd_chat.id - - # Новая схема Bot API: forward_origin с типами MessageOrigin* origin = getattr(message, "forward_origin", None) if origin: - chat = getattr(origin, "chat", None) # у MessageOriginChat/Channel есть .chat + chat = getattr(origin, "chat", None) if chat: return chat.id - return None - async def add_group_cmd(update: Update, ctx: ContextTypes.DEFAULT_TYPE): - # ensure user exists with get_session() as s: u = s.query(User).filter_by(tg_id=update.effective_user.id).first() if not u: u = User(tg_id=update.effective_user.id, name=update.effective_user.full_name) - s.add(u) - s.commit() + s.add(u); s.commit() ctx.user_data[STATE_KEY] = True await update.effective_message.reply_text(ASK_ADD_GROUP, parse_mode=ParseMode.MARKDOWN) - async def add_group_capture(update: Update, ctx: ContextTypes.DEFAULT_TYPE): if update.effective_chat.type != ChatType.PRIVATE: return @@ -59,19 +45,12 @@ async def add_group_capture(update: Update, ctx: ContextTypes.DEFAULT_TYPE): return msg = update.effective_message - raw = None - - # 1) Пересланное сообщение из чата/канала - fwd_cid = _get_forwarded_chat_id(msg) - if fwd_cid: - raw = fwd_cid - else: - # 2) Ввод в виде текста или подписи (caption) + raw = _get_forwarded_chat_id(msg) + if not raw: txt = (getattr(msg, "text", None) or getattr(msg, "caption", None) or "").strip() if not txt: await msg.reply_text("Вставьте chat_id или перешлите сообщение из группы/канала.") return - if txt.startswith("@") or "t.me/" in txt: raw = txt else: @@ -81,21 +60,39 @@ async def add_group_capture(update: Update, ctx: ContextTypes.DEFAULT_TYPE): return raw = cid - # Проверяем, что бот в чате и что у него есть право постить (если нужно) + # Проверяем наличие бота в чате и его право постить 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: await msg.reply_text("Такого чата не существует или у меня нет доступа. Проверьте chat_id/username.") 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))) 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: me = s.query(User).filter_by(tg_id=update.effective_user.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: row = Chat( chat_id=chat.id, @@ -108,8 +105,7 @@ async def add_group_capture(update: Update, ctx: ContextTypes.DEFAULT_TYPE): else: row.title = chat.title row.type = chat.type - if row.owner_user_id is None: - row.owner_user_id = me.id + row.owner_user_id = row.owner_user_id or me.id row.can_post = can_post s.commit() diff --git a/app/bot/handlers/chat_id_cmd.py b/app/bot/handlers/chat_id_cmd.py new file mode 100644 index 0000000..4347c57 --- /dev/null +++ b/app/bot/handlers/chat_id_cmd.py @@ -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 diff --git a/app/bot/handlers/join_info.py b/app/bot/handlers/join_info.py index 4fa3ce1..bbb94db 100644 --- a/app/bot/handlers/join_info.py +++ b/app/bot/handlers/join_info.py @@ -1,12 +1,27 @@ +import asyncio from telegram import Update from telegram.constants import ChatMemberStatus, ChatType, ParseMode 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): """ - Сообщаем инструкцию и 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 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) chat_id = chat.id - # Текст подсказки - if chat.type in (ChatType.GROUP, ChatType.SUPERGROUP): - text = JOIN_INFO_GROUP.format(title=title, chat_id=chat_id) - # Пытаемся написать прямо в группу + # 1) Пытаемся отправить DM тому, кто совершил действие + actor = getattr(mcm, "from_user", None) + dm_sent = False + if actor: try: - await ctx.bot.send_message(chat_id=chat_id, text=text, parse_mode=ParseMode.MARKDOWN) - except Exception: - # как запасной вариант — в ЛС пользователю, который добавил - if update.effective_user: - try: - await ctx.bot.send_message(update.effective_user.id, text=text, parse_mode=ParseMode.MARKDOWN) - except Exception: - 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 + 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 + try: + await ctx.bot.send_message(actor.id, NEED_START_DM) + except Exception: + pass except Exception: pass - if not sent and update.effective_user: - try: - await ctx.bot.send_message(update.effective_user.id, text=text, parse_mode=ParseMode.MARKDOWN) - 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)) + except Exception: + # Если и сюда не можем — увы, остаётся ручной путь: /id, /add_group и ЛС + pass diff --git a/app/bot/messages.py b/app/bot/messages.py index 4c359bb..159f215 100644 --- a/app/bot/messages.py +++ b/app/bot/messages.py @@ -52,21 +52,59 @@ NEED_ADD_FIRST = "Я не добавлен в «{title_or_id}». Сначала NO_RIGHTS_CHANNEL = "⚠️ Я в канале не администратор. Дайте боту право «Публиковать сообщения» и повторите /add_group." NO_RIGHTS_GROUP = "⚠️ Похоже, я не могу публиковать. Проверьте права чата." -# --- Новое: инструкции при добавлении бота в чат/канал --- -JOIN_INFO_GROUP = ( - "Спасибо, что добавили меня в группу «{title}»!\n" +# --- Новое: защита при добавлении и привязке --- +ONLY_ADMINS_CAN_BIND = "Привязку может выполнять только администратор этого чата." +ALREADY_BOUND = "Этот чат уже привязан другим пользователем. Попросите владельца выдать доступ или отвязать." + +# Инструкции при добавлении: DM (с chat_id) и публичная подсказка без chat_id +JOIN_DM_GROUP = ( + "Вы добавили меня в группу «{title}».\n" "ID группы: `{chat_id}`\n\n" - "Чтобы привязать эту группу к своему аккаунту:\n" - "1) Откройте ЛС со мной\n" - "2) Выполните команду /add_group\n" - "3) Вставьте ID выше *или* просто перешлите сюда любое сообщение из этой группы." + "Чтобы привязать:\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" + "⚠️ Для публикации дайте боту право «Публиковать сообщения» (сделайте администратором)." +) +JOIN_PUBLIC_HINT = ( + "Спасибо за добавление! Чтобы активировать отправку постов, напишите мне в ЛС и выполните /add_group.\n" + "Это служебное сообщение будет удалено через 30 секунд." ) -JOIN_INFO_CHANNEL = ( - "Спасибо, что добавили меня в канал «{title}»!\n" - "ID канала: `{chat_id}`\n\n" - "Чтобы привязать канал к своему аккаунту:\n" - "1) Откройте ЛС со мной и выполните /add_group\n" - "2) Вставьте ID выше *или* перешлите сюда сообщение из канала\n\n" - "⚠️ Для публикации сообщений мне нужно право «Публиковать сообщения» (сделайте бота администратором с этим правом)." +# Инструкции при добавлении: DM (с chat_id) +JOIN_DM_GROUP = ( + "Вы добавили меня в группу «{title}».\n" + "ID группы: `{chat_id}`\n\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." +) \ No newline at end of file diff --git a/app/main.py b/app/main.py index c563e3d..205e3ec 100644 --- a/app/main.py +++ b/app/main.py @@ -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.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.join_info import on_my_chat_member +from app.bot.handlers.chat_id_cmd import chat_id_cmd def main(): cfg = load_config() @@ -19,6 +20,7 @@ def main(): app.add_handler(CommandHandler("groups", groups_cmd)) app.add_handler(CommandHandler("add_group", add_group_cmd)) app.add_handler(CommandHandler("new", new_cmd)) + app.add_handler(CommandHandler("id", chat_id_cmd)) # Callback queries 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.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.run_polling(allowed_updates=None)