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 asyncio
import logging
import sqlite3
from pathlib import Path
from telegram import Update
from telegram import Bot, Update
from telegram.ext import Application, CommandHandler, ContextTypes, MessageHandler, filters
from telegram.request import HTTPXRequest
@ -20,6 +21,10 @@ logger = logging.getLogger(__name__)
# Токен админ бота из переменных окружения
ADMIN_BOT_TOKEN = os.getenv('ADMIN_BOT_TOKEN')
# Токен клиентского бота — рассылка должна идти от его имени, иначе сообщения
# уходят в чат с админ-ботом, а не в чат с клиентским ботом, который видит пользователь
TELEGRAM_BOT_TOKEN = os.getenv('TELEGRAM_BOT_TOKEN')
# Базовая директория проекта
BASE_DIR = Path(__file__).resolve().parent
DATA_DIR = BASE_DIR / 'data'
@ -69,6 +74,20 @@ def get_error_stats() -> dict[str, int]:
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):
"""Сохраняет chat_id админа в файл"""
try:
@ -93,6 +112,33 @@ def get_admin_chat_id() -> int | 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):
"""Обрабатывает команду /start"""
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(
"✅ Админ бот активирован! Теперь вы будете получать все скачанные видео.\n\n"
"Доступные команды:\n"
"/stat — статистика бота"
"/stat — статистика бота\n"
"/send_messages — рассылка сообщения всем пользователям"
)
else:
await update.message.reply_text(
"Это админский бот.\n\n"
"Доступные команды:\n"
"/stat — статистика бота"
"/stat — статистика бота\n"
"/send_messages — рассылка сообщения всем пользователям"
)
else:
await update.message.reply_text(
"Доступные команды:\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)
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):
"""Обрабатывает все сообщения (на случай если админ отправит что-то)"""
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 = update.message.chat_id
saved_chat_id = get_admin_chat_id()
if saved_chat_id != chat_id:
save_admin_chat_id(chat_id)
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:
await update.message.reply_text("Это админский бот. Доступные команды: /stat")
await update.message.reply_text("Это админский бот. Доступные команды: /stat, /send_messages")
else:
if update.message and update.message.text:
await update.message.reply_text("Это админский бот. Доступные команды: /stat")
await update.message.reply_text("Это админский бот. Доступные команды: /stat, /send_messages")
def main():
@ -199,6 +279,8 @@ def main():
# Регистрируем обработчики
application.add_handler(CommandHandler("start", start_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))
# Запускаем бота