feat(admin_bot): add /send_messages broadcast command
All checks were successful
CI/CD Pipeline / build-and-deploy (push) Successful in 1m47s

Admin sends /send_messages, the next text message is broadcast to
every user in the users table. Sends via TELEGRAM_BOT_TOKEN (the
client bot), not the admin bot's own token — broadcasting through
context.bot would land in the user's chat with the admin bot, which
they've never opened, instead of the client bot they actually use.
This commit is contained in:
vrubelroman 2026-06-30 20:46:11 +00:00
parent ac2bd8f644
commit 3ffdbbcdaf

View file

@ -3,10 +3,11 @@ Admin Telegram Bot
Админский бот для получения статистики и всех скачанных видео Админский бот для получения статистики и всех скачанных видео
""" """
import os import os
import asyncio
import logging import logging
import sqlite3 import sqlite3
from pathlib import Path from pathlib import Path
from telegram import Update from telegram import Bot, Update
from telegram.ext import Application, CommandHandler, ContextTypes, MessageHandler, filters from telegram.ext import Application, CommandHandler, ContextTypes, MessageHandler, filters
from telegram.request import HTTPXRequest from telegram.request import HTTPXRequest
@ -20,6 +21,10 @@ logger = logging.getLogger(__name__)
# Токен админ бота из переменных окружения # Токен админ бота из переменных окружения
ADMIN_BOT_TOKEN = os.getenv('ADMIN_BOT_TOKEN') ADMIN_BOT_TOKEN = os.getenv('ADMIN_BOT_TOKEN')
# Токен клиентского бота — рассылка должна идти от его имени, иначе сообщения
# уходят в чат с админ-ботом, а не в чат с клиентским ботом, который видит пользователь
TELEGRAM_BOT_TOKEN = os.getenv('TELEGRAM_BOT_TOKEN')
# Базовая директория проекта # Базовая директория проекта
BASE_DIR = Path(__file__).resolve().parent BASE_DIR = Path(__file__).resolve().parent
DATA_DIR = BASE_DIR / 'data' DATA_DIR = BASE_DIR / 'data'
@ -69,6 +74,20 @@ def get_error_stats() -> dict[str, int]:
return {} return {}
def get_all_users() -> list[int]:
"""Возвращает chat_id всех пользователей бота"""
try:
conn = sqlite3.connect(str(DB_FILE))
cursor = conn.cursor()
cursor.execute('SELECT chat_id FROM users')
users = [row[0] for row in cursor.fetchall()]
conn.close()
return users
except Exception as e:
logger.error(f"Ошибка при получении списка пользователей: {e}")
return []
def save_admin_chat_id(chat_id: int): def save_admin_chat_id(chat_id: int):
"""Сохраняет chat_id админа в файл""" """Сохраняет chat_id админа в файл"""
try: try:
@ -93,6 +112,33 @@ def get_admin_chat_id() -> int | None:
return None return None
async def do_broadcast(text: str) -> tuple[int, int, int]:
"""Рассылает текстовое сообщение всем пользователям клиентского бота.
Отправлять нужно от имени клиентского бота (TELEGRAM_BOT_TOKEN), а не
админского пользователи переписываются именно с клиентским ботом, и
письмо от админ-бота уйдёт в отдельный, незнакомый им чат.
"""
users = get_all_users()
success = 0
failed = 0
async with Bot(token=TELEGRAM_BOT_TOKEN) as client_bot:
for chat_id in users:
try:
await client_bot.send_message(chat_id=chat_id, text=text)
success += 1
except Exception as e:
error_str = str(e).lower()
if 'blocked' not in error_str and 'chat not found' not in error_str:
logger.warning(f"Не удалось отправить сообщение {chat_id}: {e}")
failed += 1
await asyncio.sleep(0.05)
return success, failed, len(users)
async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE): async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Обрабатывает команду /start""" """Обрабатывает команду /start"""
chat_id = update.message.chat_id chat_id = update.message.chat_id
@ -103,18 +149,21 @@ async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
await update.message.reply_text( await update.message.reply_text(
"✅ Админ бот активирован! Теперь вы будете получать все скачанные видео.\n\n" "✅ Админ бот активирован! Теперь вы будете получать все скачанные видео.\n\n"
"Доступные команды:\n" "Доступные команды:\n"
"/stat — статистика бота" "/stat — статистика бота\n"
"/send_messages — рассылка сообщения всем пользователям"
) )
else: else:
await update.message.reply_text( await update.message.reply_text(
"Это админский бот.\n\n" "Это админский бот.\n\n"
"Доступные команды:\n" "Доступные команды:\n"
"/stat — статистика бота" "/stat — статистика бота\n"
"/send_messages — рассылка сообщения всем пользователям"
) )
else: else:
await update.message.reply_text( await update.message.reply_text(
"Доступные команды:\n" "Доступные команды:\n"
"/stat — статистика бота" "/stat — статистика бота\n"
"/send_messages — рассылка сообщения всем пользователям"
) )
@ -161,20 +210,51 @@ async def stat_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
await update.message.reply_text(stat_message) await update.message.reply_text(stat_message)
async def send_messages_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Обрабатывает команду /send_messages — запрашивает текст для рассылки"""
if not TELEGRAM_BOT_TOKEN:
await update.message.reply_text("❌ TELEGRAM_BOT_TOKEN не задан, рассылка недоступна")
return
context.user_data['awaiting_broadcast'] = True
await update.message.reply_text(
"Введите текст сообщения для рассылки всем пользователям.\n"
"Чтобы отменить — /cancel"
)
async def cancel_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Обрабатывает команду /cancel — отменяет ожидание текста рассылки"""
if context.user_data.pop('awaiting_broadcast', None):
await update.message.reply_text("❌ Рассылка отменена")
else:
await update.message.reply_text("Нечего отменять")
async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE): async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Обрабатывает все сообщения (на случай если админ отправит что-то)""" """Обрабатывает все сообщения (на случай если админ отправит что-то)"""
if context.user_data.pop('awaiting_broadcast', False):
text = update.message.text
success, failed, total = await do_broadcast(text)
await update.message.reply_text(
f"📤 Рассылка завершена\n\n"
f"✅ Отправлено: {success}\n"
f"Не доставлено: {failed}\n"
f"👥 Всего: {total}"
)
return
# Сохраняем chat_id админа при первом сообщении # Сохраняем chat_id админа при первом сообщении
chat_id = update.message.chat_id chat_id = update.message.chat_id
saved_chat_id = get_admin_chat_id() saved_chat_id = get_admin_chat_id()
if saved_chat_id != chat_id: if saved_chat_id != chat_id:
save_admin_chat_id(chat_id) save_admin_chat_id(chat_id)
if saved_chat_id is None: if saved_chat_id is None:
await update.message.reply_text("✅ Админ бот активирован! Теперь вы будете получать все скачанные видео.\n\nДоступные команды: /stat") await update.message.reply_text("✅ Админ бот активирован! Теперь вы будете получать все скачанные видео.\n\nДоступные команды: /stat, /send_messages")
else: else:
await update.message.reply_text("Это админский бот. Доступные команды: /stat") await update.message.reply_text("Это админский бот. Доступные команды: /stat, /send_messages")
else: else:
if update.message and update.message.text: if update.message and update.message.text:
await update.message.reply_text("Это админский бот. Доступные команды: /stat") await update.message.reply_text("Это админский бот. Доступные команды: /stat, /send_messages")
def main(): def main():
@ -199,6 +279,8 @@ def main():
# Регистрируем обработчики # Регистрируем обработчики
application.add_handler(CommandHandler("start", start_command)) application.add_handler(CommandHandler("start", start_command))
application.add_handler(CommandHandler("stat", stat_command)) application.add_handler(CommandHandler("stat", stat_command))
application.add_handler(CommandHandler("send_messages", send_messages_command))
application.add_handler(CommandHandler("cancel", cancel_command))
application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message)) application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message))
# Запускаем бота # Запускаем бота