Compare commits

..

5 Commits

Author SHA1 Message Date
e55e330c50 chore: remove unused placeholder file for settings functions
All checks were successful
Build and Push Docker Images / build (push) Successful in 44s
Signed-off-by: UdoChudo <stream@udochudo.ru>
2025-06-19 23:54:44 +05:00
8b34d79f4e fix(config): update environment variable retrieval for MAILING_MAX_WORKERS, MAILING_RATE_LIMIT, and MAILING_MAX_RETRIES
Signed-off-by: UdoChudo <stream@udochudo.ru>
2025-06-19 23:54:04 +05:00
60f77b39eb feat(subscription): add "subscribe all" and "unsubscribe all" buttons
feat(subscription): add check on unsubscribe to notify user if no active subscriptions

Signed-off-by: UdoChudo <stream@udochudo.ru>
2025-06-19 23:52:48 +05:00
55510a4379 chore(notification mode switch): update icon in final message for notification importance change
Signed-off-by: UdoChudo <stream@udochudo.ru>
2025-06-19 23:50:43 +05:00
604957f1a7 feat(notification): improve processing, formatting, and sending of user notifications
Signed-off-by: UdoChudo <stream@udochudo.ru>
2025-06-19 23:49:22 +05:00
12 changed files with 267 additions and 99 deletions

View File

@ -1,13 +1,13 @@
from . import subscribe, active_triggers
from . import unsubscribe
from . import my_subscriptions
from . import cancel_input from . import cancel_input
from . import notification_switch_mode from . import debug
from . import help from . import help
from . import my_subscriptions
from . import notification_switch_mode
from . import registration from . import registration
from . import settings from . import settings
from . import start from . import start
from . import debug from . import subscribe, active_triggers
from . import unsubscribe
from ..states import UserStateManager from ..states import UserStateManager
state_manager = UserStateManager() state_manager = UserStateManager()
@ -31,4 +31,6 @@ def register_handlers(bot, app):
def register_callbacks(bot, app): def register_callbacks(bot, app):
notification_switch_mode.register_callback_notification(bot, app, state_manager) notification_switch_mode.register_callback_notification(bot, app, state_manager)
active_triggers.register_callbacks_active_triggers(bot, app, state_manager) active_triggers.register_callbacks_active_triggers(bot, app, state_manager)
cancel_input.register_callback_cancel_input(bot,state_manager) cancel_input.register_callback_cancel_input(bot,state_manager)
subscribe.register_callback_subscribe(bot, app, state_manager)
unsubscribe.register_callback_unsubscribe(bot, app, state_manager)

View File

@ -1,8 +1,9 @@
import telebot import telebot
from telebot.types import Message, InlineKeyboardMarkup, InlineKeyboardButton from telebot.types import Message, InlineKeyboardMarkup, InlineKeyboardButton
from app.bot.keyboards.main_menu import get_main_menu
from app import Subscriptions, db from app import Subscriptions, db
from app.bot.constants import UserStates from app.bot.constants import UserStates
from app.bot.keyboards.main_menu import get_main_menu
from app.bot.keyboards.settings_menu import get_settings_menu from app.bot.keyboards.settings_menu import get_settings_menu
from app.bot.utils.auth import auth from app.bot.utils.auth import auth
from app.bot.utils.tg_audit import log_user_event from app.bot.utils.tg_audit import log_user_event
@ -60,7 +61,7 @@ def register_callback_notification(bot, app, state_manager):
mode_text_emoji = "⛔️ Критические события" if disaster_only else "⚠️ Все события" mode_text_emoji = "⛔️ Критические события" if disaster_only else "⚠️ Все события"
mode_text = "Критические события" if disaster_only else "Все события" mode_text = "Критические события" if disaster_only else "Все события"
bot.send_message(chat_id, f" Режим уведомлений успешно изменён на:\n {mode_text_emoji}",reply_markup=get_settings_menu()) bot.send_message(chat_id, f"🔄 Режим уведомлений успешно изменён на:\n {mode_text_emoji}",reply_markup=get_settings_menu())
log_user_event(chat_id, app, username, f"Режим уведомлений изменился на: {mode_text}") log_user_event(chat_id, app, username, f"Режим уведомлений изменился на: {mode_text}")
state_manager.set_state(chat_id, UserStates.SETTINGS_MENU) state_manager.set_state(chat_id, UserStates.SETTINGS_MENU)

View File

@ -1,13 +1,13 @@
from telebot.types import Message, InlineKeyboardMarkup, InlineKeyboardButton from telebot import TeleBot, logger
from telebot.types import Message, InlineKeyboardMarkup, InlineKeyboardButton, CallbackQuery
from app import Subscriptions from app import Subscriptions
from app.bot.constants import UserStates from app.bot.constants import UserStates
from app.bot.processors.subscribe_processor import process_subscription_button from app.bot.processors.subscribe_processor import process_subscription_button, process_subscribe_all_regions
from app.bot.states import UserStateManager from app.bot.states import UserStateManager
from app.bot.utils.auth import auth from app.bot.utils.auth import auth
from app.bot.utils.regions import get_sorted_regions, format_regions_list, format_regions_list_marked from app.bot.utils.regions import get_sorted_regions, format_regions_list_marked
from telebot import TeleBot, logger
def register_handlers(bot: TeleBot, app, state_manager: UserStateManager): def register_handlers(bot: TeleBot, app, state_manager: UserStateManager):
@ -36,6 +36,7 @@ def register_handlers(bot: TeleBot, app, state_manager: UserStateManager):
regions_text = format_regions_list_marked(regions, subscribed) regions_text = format_regions_list_marked(regions, subscribed)
markup = InlineKeyboardMarkup() markup = InlineKeyboardMarkup()
markup.add(InlineKeyboardButton("Подписаться на все регионы", callback_data="subscribe_all"))
markup.add(InlineKeyboardButton(text="Отмена", callback_data="cancel_input")) markup.add(InlineKeyboardButton(text="Отмена", callback_data="cancel_input"))
bot_message = bot.send_message(chat_id, bot_message = bot.send_message(chat_id,
@ -44,3 +45,17 @@ def register_handlers(bot: TeleBot, app, state_manager: UserStateManager):
bot.register_next_step_handler(message, process_subscription_button, app, bot, chat_id, state_manager, bot_message.message_id) bot.register_next_step_handler(message, process_subscription_button, app, bot, chat_id, state_manager, bot_message.message_id)
def register_callback_subscribe(bot: TeleBot, app, state_manager):
@bot.callback_query_handler(func=lambda call: call.data == "subscribe_all")
def handle_subscribe_all_button(call: CallbackQuery):
chat_id = call.message.chat.id
username = f"{call.from_user.username}" if call.from_user.username else "N/A"
with app.app_context():
if not auth(chat_id, app):
bot.send_message(chat_id, "Вы не авторизованы для использования этого бота.")
logger.warning(f"Неавторизованный пользователь {chat_id} @{username}")
state_manager.set_state(chat_id, UserStates.REGISTRATION)
return
process_subscribe_all_regions(call, app, bot, chat_id, state_manager)

View File

@ -1,15 +0,0 @@
# app/bot/handlers/settings.py
from telebot.types import Message
from app.bot.keyboards.main_menu import get_main_menu
from app.bot.keyboards.settings_menu import get_settings_menu
def register_handlers(bot,app):
@bot.message_handler(func=lambda msg: msg.text == "Режим уведомлений")
def handle_notify_mode(message: Message):
bot.send_message(message.chat.id, "⚙️ Настройка режима уведомлений пока не реализована.")
@bot.message_handler(func=lambda msg: msg.text == "Назад")
def handle_back(message: Message):
bot.send_message(message.chat.id, "Возврат в главное меню", reply_markup=get_main_menu())

View File

@ -1,10 +1,12 @@
from telebot.types import InlineKeyboardMarkup, InlineKeyboardButton from telebot import logger, TeleBot
from telebot import logger from telebot.types import InlineKeyboardMarkup, InlineKeyboardButton, CallbackQuery
from app.bot.constants import UserStates from app.bot.constants import UserStates
from app.bot.keyboards.settings_menu import get_settings_menu
from app.bot.processors.unsubscribe_processor import process_unsubscribe_button, process_unsubscribe_all_regions
from app.bot.utils.auth import auth from app.bot.utils.auth import auth
from app.bot.utils.helpers import get_user_subscribed_regions from app.bot.utils.helpers import get_user_subscribed_regions
from app.bot.utils.regions import format_regions_list from app.bot.utils.regions import format_regions_list
from app.bot.processors.unsubscribe_processor import process_unsubscribe_button
def register_handlers(bot, app, state_manager): def register_handlers(bot, app, state_manager):
@ -14,22 +16,48 @@ def register_handlers(bot, app, state_manager):
with app.app_context(): with app.app_context():
chat_id = message.chat.id chat_id = message.chat.id
username = f"{message.from_user.username}" if message.from_user.username else "N/A" username = f"{message.from_user.username}" if message.from_user.username else "N/A"
if not auth(chat_id, app): if not auth(chat_id, app):
bot.send_message(chat_id, "Вы не авторизованы для использования этого бота.") bot.send_message(chat_id, "Вы не авторизованы для использования этого бота.")
logger.warning(f"Неавторизованный пользователь {chat_id} @{username}") logger.warning(f"Неавторизованный пользователь {chat_id} @{username}")
state_manager.set_state(chat_id, UserStates.REGISTRATION) state_manager.set_state(chat_id, UserStates.REGISTRATION)
return return
else:
state_manager.set_state(chat_id, UserStates.WAITING_INPUT)
user_subscriptions = get_user_subscribed_regions(chat_id) user_subscriptions = get_user_subscribed_regions(chat_id)
# ✅ Предварительная проверка: есть ли подписки
if not user_subscriptions:
bot.send_message(chat_id, " У вас нет активных подписок для отписки.",reply_markup=get_settings_menu())
state_manager.set_state(chat_id, UserStates.SETTINGS_MENU)
return
# Есть подписки — предлагаем меню отписки
state_manager.set_state(chat_id, UserStates.WAITING_INPUT)
formated_user_subscriptions = format_regions_list(user_subscriptions) formated_user_subscriptions = format_regions_list(user_subscriptions)
markup = InlineKeyboardMarkup() markup = InlineKeyboardMarkup()
markup.add(InlineKeyboardButton("Отписаться от всех регионов", callback_data="unsubscribe_all"))
markup.add(InlineKeyboardButton("Отмена", callback_data="cancel_input")) markup.add(InlineKeyboardButton("Отмена", callback_data="cancel_input"))
bot.send_message(chat_id, bot.send_message(chat_id,
f"Введите номер(а) региона(ов) через запятую подписки которых вы хотите удалить:\n\n{formated_user_subscriptions}", f"Введите номер(а) региона(ов) через запятую, от которых вы хотите отписаться:\n\n{formated_user_subscriptions}",
reply_markup=markup) reply_markup=markup)
bot.register_next_step_handler(message, process_unsubscribe_button, app, bot, chat_id, state_manager) bot.register_next_step_handler(message, process_unsubscribe_button, app, bot, chat_id, state_manager)
def register_callback_unsubscribe(bot: TeleBot, app, state_manager):
@bot.callback_query_handler(func=lambda call: call.data == "unsubscribe_all")
def handle_subscribe_all_button(call: CallbackQuery):
chat_id = call.message.chat.id
username = f"{call.from_user.username}" if call.from_user.username else "N/A"
with app.app_context():
if not auth(chat_id, app):
bot.send_message(chat_id, "Вы не авторизованы для использования этого бота.")
logger.warning(f"Неавторизованный пользователь {chat_id} @{username}")
state_manager.set_state(chat_id, UserStates.REGISTRATION)
return
process_unsubscribe_all_regions(call, app, bot, state_manager)

View File

@ -75,3 +75,50 @@ def process_subscription_button(message: Message, app, bot, chat_id: int, state_
# Показываем меню # Показываем меню
bot.send_message(chat_id, f"✅ Подписка на регионы: {', '.join(subbed_regions)} оформлена.", reply_markup=get_settings_menu()) bot.send_message(chat_id, f"✅ Подписка на регионы: {', '.join(subbed_regions)} оформлена.", reply_markup=get_settings_menu())
def process_subscribe_all_regions(call, app, bot, chat_id: int, state_manager):
try:
with app.app_context():
username = f"{call.from_user.username}" if call.from_user.username else "N/A"
# Получаем все активные регионы
active_regions = Regions.query.filter_by(active=True).all()
active_region_ids = {r.region_id for r in active_regions}
# Получаем уже подписанные регионы
existing_subs = Subscriptions.query.filter_by(chat_id=chat_id).all()
existing_region_ids = {sub.region_id for sub in existing_subs if sub.active}
newly_added = []
for region in active_regions:
if region.region_id in existing_region_ids:
continue
existing = next((sub for sub in existing_subs if sub.region_id == region.region_id), None)
if existing:
existing.active = True
db.session.add(existing)
else:
new_sub = Subscriptions(chat_id=chat_id, region_id=region.region_id, active=True)
db.session.add(new_sub)
newly_added.append(str(region.region_id))
db.session.commit()
# Логирование действия
if newly_added:
log_user_event(chat_id, app, username, f"Подписался на все регионы: {', '.join(newly_added)}")
bot.answer_callback_query(call.id)
bot.clear_step_handler_by_chat_id(chat_id)
bot.delete_message(chat_id, call.message.message_id)
bot.send_message(chat_id,
"✅ Вы подписались на все доступные регионы.",
reply_markup=get_settings_menu())
state_manager.set_state(chat_id, UserStates.SETTINGS_MENU)
except Exception as e:
bot.send_message(chat_id, "⚠️ Не удалось выполнить подписку. Попробуйте позже.")

View File

@ -1,13 +1,15 @@
from flask import Flask from flask import Flask
from telebot import TeleBot, logger from telebot import TeleBot, logger
from telebot.types import Message, InlineKeyboardMarkup, InlineKeyboardButton from telebot.types import Message, InlineKeyboardMarkup, InlineKeyboardButton, CallbackQuery
from app.bot.keyboards.settings_menu import get_settings_menu
from app.bot.utils.helpers import get_user_subscribed_regions
from app import Subscriptions from app import Subscriptions
from app.bot.constants import UserStates
from app.bot.keyboards.settings_menu import get_settings_menu
from app.bot.states import UserStateManager
from app.bot.utils.helpers import get_user_subscribed_regions
from app.bot.utils.tg_audit import log_user_event from app.bot.utils.tg_audit import log_user_event
from app.extensions.db import db from app.extensions.db import db
from app.bot.states import UserStateManager
from app.bot.constants import UserStates
def process_unsubscribe_button(message: Message, app: Flask, bot: TeleBot, chat_id: int, state_manager: UserStateManager): def process_unsubscribe_button(message: Message, app: Flask, bot: TeleBot, chat_id: int, state_manager: UserStateManager):
unsubbed_regions = [] unsubbed_regions = []
@ -62,4 +64,39 @@ def process_unsubscribe_button(message: Message, app: Flask, bot: TeleBot, chat_
f"⚠ Регионы с ID {', '.join(invalid_regions)} не найдены среди ваших подписок и не были изменены.") f"⚠ Регионы с ID {', '.join(invalid_regions)} не найдены среди ваших подписок и не были изменены.")
state_manager.set_state(chat_id, UserStates.SETTINGS_MENU) state_manager.set_state(chat_id, UserStates.SETTINGS_MENU)
bot.send_message(chat_id, "⚙ Вернулись в меню настроек.", reply_markup=get_settings_menu()) bot.send_message(chat_id, "⚙ Вернулись в меню настроек.", reply_markup=get_settings_menu())
def process_unsubscribe_all_regions(call: CallbackQuery, app: Flask, bot: TeleBot, state_manager: UserStateManager):
chat_id = call.message.chat.id
username = f"{call.from_user.username}" if call.from_user.username else "N/A"
try:
with app.app_context():
subscriptions = Subscriptions.query.filter_by(chat_id=chat_id, active=True).all()
if not subscriptions:
bot.answer_callback_query(call.id, text="У вас нет активных подписок.")
return
for sub in subscriptions:
sub.active = False
db.session.add(sub)
db.session.commit()
log_user_event(chat_id, app, username, "Отписался от всех регионов")
bot.answer_callback_query(call.id)
bot.clear_step_handler_by_chat_id(chat_id)
bot.delete_message(chat_id, call.message.message_id)
bot.send_message(chat_id,
"✅ Вы успешно отписались от всех регионов.",
reply_markup=get_settings_menu())
state_manager.set_state(chat_id, UserStates.SETTINGS_MENU)
except Exception as e:
state_manager.set_state(chat_id, UserStates.SETTINGS_MENU)
bot.send_message(chat_id, "⚠ Произошла ошибка при отписке. Попробуйте позже.",reply_markup=get_settings_menu())
return

View File

@ -1,17 +1,21 @@
import asyncio import asyncio
import json import json
import logging import logging
from typing import Optional
from telebot import apihelper
from aio_pika import connect_robust, Message, DeliveryMode from aio_pika import connect_robust, Message, DeliveryMode
from aio_pika.abc import AbstractIncomingMessage from aio_pika.abc import AbstractIncomingMessage
from app.bot.services.mailing_service.parser import parse_region_id from telebot import apihelper
from app.bot.services.mailing_service.composer import compose_telegram_message from app.bot.services.mailing_service.composer import compose_telegram_message
from app.bot.services.mailing_service.db_utils import get_recipients_by_region from app.bot.services.mailing_service.db_utils import get_recipients_by_region
from config import RABBITMQ_URL_FULL, RABBITMQ_QUEUE, RABBITMQ_NOTIFICATIONS_QUEUE from app.bot.services.mailing_service.parser import parse_region_id
from app.bot.services.mailing_service.utils.link_button import create_link_button
from app.bot.services.mailing_service.utils.tg_get_user import get_telegram_id_by_chat_id
from config import RABBITMQ_URL_FULL, RABBITMQ_QUEUE, RABBITMQ_NOTIFICATIONS_QUEUE, MAILING_RATE_LIMIT
logger = logging.getLogger("TeleBot") logger = logging.getLogger("TeleBot")
rate_limit_semaphore = asyncio.Semaphore(25) rate_limit_semaphore = asyncio.Semaphore(MAILING_RATE_LIMIT)
class AsyncMailingService: class AsyncMailingService:
def __init__(self, flask_app, bot): def __init__(self, flask_app, bot):
@ -28,7 +32,7 @@ class AsyncMailingService:
async def consume_raw_messages(self): async def consume_raw_messages(self):
while True: while True:
try: try:
logger.info("[MailingService] Подключение к RabbitMQ (сырые сообщения)...") logger.info("[MailingService][raw] Подключение к RabbitMQ (сырые сообщения)...")
connection = await connect_robust(RABBITMQ_URL_FULL, loop=self.loop) connection = await connect_robust(RABBITMQ_URL_FULL, loop=self.loop)
async with connection: async with connection:
channel = await connection.channel() channel = await connection.channel()
@ -36,14 +40,14 @@ class AsyncMailingService:
raw_queue = await channel.declare_queue(RABBITMQ_QUEUE, durable=True) raw_queue = await channel.declare_queue(RABBITMQ_QUEUE, durable=True)
notifications_queue = await channel.declare_queue(RABBITMQ_NOTIFICATIONS_QUEUE, durable=True) notifications_queue = await channel.declare_queue(RABBITMQ_NOTIFICATIONS_QUEUE, durable=True)
logger.info("[MailingService] Ожидание сообщений из очереди...") logger.info("[MailingService][raw] Ожидание сообщений из очереди...")
async with raw_queue.iterator() as queue_iter: async with raw_queue.iterator() as queue_iter:
async for message in queue_iter: async for message in queue_iter:
await self.handle_raw_message(message, channel, notifications_queue) await self.handle_raw_message(message, channel, notifications_queue)
except Exception as e: except Exception as e:
logger.error(f"[MailingService] Ошибка подключения или обработки: {e}") logger.error(f"[MailingService][raw] Ошибка подключения или обработки: {e}")
logger.info("[MailingService] Повторное подключение через 5 секунд...") logger.info("[MailingService][raw] Повторное подключение через 5 секунд...")
await asyncio.sleep(5) await asyncio.sleep(5)
async def handle_raw_message(self, message: AbstractIncomingMessage, channel, notifications_queue): async def handle_raw_message(self, message: AbstractIncomingMessage, channel, notifications_queue):
@ -51,9 +55,9 @@ class AsyncMailingService:
with self.flask_app.app_context(): with self.flask_app.app_context():
try: try:
data = json.loads(message.body.decode("utf-8")) data = json.loads(message.body.decode("utf-8"))
logger.info(f"[MailingService] Получено сообщение: {json.dumps(data, ensure_ascii=False)}") logger.info(f"[MailingService][raw] Получены данные: {json.dumps(data, ensure_ascii=False)}")
region_id = parse_region_id(data.get("host")) region_id = int(parse_region_id(data.get("hostgroups")))
severity = data.get("severity", "") severity = data.get("severity", "")
# Получатели и сообщение параллельно # Получатели и сообщение параллельно
@ -61,12 +65,8 @@ class AsyncMailingService:
message_task = asyncio.to_thread(compose_telegram_message, data) message_task = asyncio.to_thread(compose_telegram_message, data)
recipients, (final_message, link) = await asyncio.gather(recipients_task, message_task) recipients, (final_message, link) = await asyncio.gather(recipients_task, message_task)
logger.info(f"[MailingService][raw] Формирование списка получателей ({len(recipients)})")
logger.info(f"[MailingService] Получатели: {recipients}") logger.debug(f"[MailingService][raw] Список получателей: {', '.join(map(str, recipients))}")
if link:
logger.info(f"[MailingService] Сообщение для Telegram: {final_message} {link}")
else:
logger.info(f"[MailingService] Сообщение для Telegram: {final_message}")
# Формируем и публикуем индивидуальные уведомления в очередь отправки # Формируем и публикуем индивидуальные уведомления в очередь отправки
for chat_id in recipients: for chat_id in recipients:
@ -79,29 +79,29 @@ class AsyncMailingService:
msg = Message(body, delivery_mode=DeliveryMode.PERSISTENT) msg = Message(body, delivery_mode=DeliveryMode.PERSISTENT)
await channel.default_exchange.publish(msg, routing_key=notifications_queue.name) await channel.default_exchange.publish(msg, routing_key=notifications_queue.name)
logger.info(f"[MailingService] Поставлено в очередь уведомлений: {len(recipients)} сообщений") logger.info(f"[MailingService][raw] Поставлено в очередь уведомлений: {len(recipients)} сообщений")
except Exception as e: except Exception as e:
logger.exception(f"[MailingService] Ошибка обработки сообщения: {e}") logger.exception(f"[MailingService][raw] Ошибка обработки сообщения: {e}")
async def consume_notifications(self): async def consume_notifications(self):
while True: while True:
try: try:
logger.info("[MailingService] Подключение к RabbitMQ (уведомления для отправки)...") logger.info("[MailingService][formated] Подключение к RabbitMQ (уведомления для отправки)...")
connection = await connect_robust(RABBITMQ_URL_FULL, loop=self.loop) connection = await connect_robust(RABBITMQ_URL_FULL, loop=self.loop)
async with connection: async with connection:
channel = await connection.channel() channel = await connection.channel()
await channel.set_qos(prefetch_count=5) await channel.set_qos(prefetch_count=5)
notif_queue = await channel.declare_queue(RABBITMQ_NOTIFICATIONS_QUEUE, durable=True) notif_queue = await channel.declare_queue(RABBITMQ_NOTIFICATIONS_QUEUE, durable=True)
logger.info("[MailingService] Ожидание сообщений из очереди уведомлений...") logger.info("[MailingService][formated] Ожидание сообщений из очереди уведомлений...")
async with notif_queue.iterator() as queue_iter: async with notif_queue.iterator() as queue_iter:
async for message in queue_iter: async for message in queue_iter:
await self.handle_notification_message(message) await self.handle_notification_message(message)
except Exception as e: except Exception as e:
logger.error(f"[MailingService] Ошибка подключения или обработки уведомлений: {e}") logger.error(f"[MailingService][formated] Ошибка подключения или обработки уведомлений: {e}")
logger.info("[MailingService] Повторное подключение через 5 секунд...") logger.info("[MailingService][formated] Повторное подключение через 5 секунд...")
await asyncio.sleep(5) await asyncio.sleep(5)
async def handle_notification_message(self, message: AbstractIncomingMessage): async def handle_notification_message(self, message: AbstractIncomingMessage):
@ -111,41 +111,60 @@ class AsyncMailingService:
chat_id = data.get("chat_id") chat_id = data.get("chat_id")
message_text = data.get("message") message_text = data.get("message")
link = data.get("link") link = data.get("link")
if link:
message_text = f"{message_text} {link}"
# TODO: расширить логику отправки с кнопкой, если нужно
await self.send_message(chat_id, message_text) await self.send_message(chat_id, message_text, link)
except Exception as e: except Exception as e:
logger.error(f"[MailingService] Ошибка отправки уведомления: {e}") logger.error(f"[MailingService][tg message] Ошибка отправки уведомления: {e}")
# Можно реализовать message.nack() для повторной попытки # Можно реализовать message.nack() для повторной попытки
async def send_message(self, chat_id, message): async def send_message(self, chat_id, message, link: Optional[str] = None):
telegram_id = "unknown" telegram_id = "unknown"
try: try:
await rate_limit_semaphore.acquire() await rate_limit_semaphore.acquire()
def get_telegram_id(): # Получаем telegram_id в отдельном потоке
with self.flask_app.app_context(): telegram_id = await asyncio.to_thread(get_telegram_id_by_chat_id, self.flask_app, chat_id)
from app.models.users import Users
user = Users.query.filter_by(chat_id=chat_id).first()
return user.telegram_id if user else "unknown"
telegram_id = await asyncio.to_thread(get_telegram_id) # Формируем клавиатуру с кнопкой
link_button = create_link_button(link)
await asyncio.to_thread(self.bot.send_message, chat_id, message, parse_mode="HTML") await asyncio.to_thread(
self.bot.send_message,
chat_id,
message,
parse_mode="HTML",
reply_markup=link_button
)
formatted_message = message.replace("\n", " ").replace("\r", "") formatted_message = message.replace("\n", " ").replace("\r", "")
logger.info(f"[MailingService] Отправлено уведомление {telegram_id} ({chat_id}): {formatted_message}") logger.info(f"[MailingService][tg message] Отправлено уведомление {telegram_id} ({chat_id}): {formatted_message}")
except apihelper.ApiTelegramException as e: except apihelper.ApiTelegramException as e:
if "429" in str(e): # Попытка получить error_code и описание из исключения
logger.warning(f"[MailingService] Rate limit для {telegram_id} ({chat_id}), ждем и повторяем...") error_code = None
description = None
try:
if hasattr(e, "result") and e.result:
error_code = e.result.get("error_code")
description = e.result.get("description")
except Exception:
pass
if error_code == 429:
logger.warning(f"[MailingService][tg message] Rate limit для {telegram_id} ({chat_id}), ждем и повторяем...")
await asyncio.sleep(1) await asyncio.sleep(1)
await self.send_message(chat_id, message) await self.send_message(chat_id, message, link)
elif error_code in (400, 403) and description and (
"user is deactivated" in description.lower() or
"bot was blocked by the user" in description.lower() or
"user not found" in description.lower()
):
logger.warning(f"[MailingService][tg message] Ошибка {error_code}: {description}. Пользователь {telegram_id} ({chat_id}) заблокировал бота или не существует. Отправка пропущена.")
# Не повторяем отправку
else: else:
logger.error(f"[MailingService] Ошибка отправки сообщения {telegram_id} ({chat_id}): {e}") logger.error(f"[MailingService][tg message] Ошибка отправки сообщения {telegram_id} ({chat_id}): {e}")
except Exception as e: except Exception as e:
logger.error(f"[MailingService] Неожиданная ошибка отправки сообщения {telegram_id} ({chat_id}): {e}") logger.error(f"[MailingService][tg message] Неожиданная ошибка отправки сообщения {telegram_id} ({chat_id}): {e}")
finally: finally:
rate_limit_semaphore.release() rate_limit_semaphore.release()

View File

@ -1,19 +1,31 @@
# parser.py # parser.py
import re
def parse_region_id(host: str) -> int | None: # def parse_region_id(host: str) -> int | None:
""" # """
Извлекает region_id из строки host. # Извлекает region_id из строки host.
Формат: p<region><...>, например p18ecpapp01 region_id = 18 # Формат: p<region><...>, например p18ecpapp01 → region_id = 18
#
# Returns:
# int | None: номер региона или None
# """
# if not host or not host.startswith("p"):
# return None
#
# match = re.match(r"^p(\d+)", host)
# if match:
# return int(match.group(1))
# return None
Returns: def parse_region_id(hostgroups: str | None) -> str:
int | None: номер региона или None if not hostgroups:
""" return "0"
if not host or not host.startswith("p"):
return None
match = re.match(r"^p(\d+)", host) if hostgroups == "p00rtmis":
if match: return "0"
return int(match.group(1))
return None parts = hostgroups.split("_")
if len(parts) >= 2:
return parts[1]
return "0"

View File

@ -0,0 +1,17 @@
from typing import Optional
from telebot.types import InlineKeyboardMarkup, InlineKeyboardButton
def create_link_button(link: Optional[str]) -> Optional[InlineKeyboardMarkup]:
"""
Создаёт InlineKeyboardMarkup с кнопкой-ссылкой.
Если ссылка не передана, возвращает None.
"""
if not link:
return None
markup = InlineKeyboardMarkup()
button = InlineKeyboardButton(text="График", url=link)
markup.add(button)
return markup

View File

@ -0,0 +1,6 @@
def get_telegram_id_by_chat_id(flask_app, chat_id: int) -> str:
from app.models.users import Users # импорт здесь, чтобы избежать циклических зависимостей
with flask_app.app_context():
user = Users.query.filter_by(chat_id=chat_id).first()
return user.telegram_id if user else "unknown"

View File

@ -27,14 +27,13 @@ RABBITMQ_QUEUE = os.environ.get("RABBITMQ_QUEUE", "telegram_notifications")
RABBITMQ_NOTIFICATIONS_QUEUE = os.environ.get("RABBITMQ_NOTIFICATIONS_QUEUE", "notifications_queue") RABBITMQ_NOTIFICATIONS_QUEUE = os.environ.get("RABBITMQ_NOTIFICATIONS_QUEUE", "notifications_queue")
RABBITMQ_VHOST = os.getenv("RABBITMQ_VHOST", "/") RABBITMQ_VHOST = os.getenv("RABBITMQ_VHOST", "/")
RABBITMQ_VHOST_ENCODED = quote_plus(RABBITMQ_VHOST) RABBITMQ_VHOST_ENCODED = quote_plus(RABBITMQ_VHOST)
RABBITMQ_URL_FULL = ( RABBITMQ_URL_FULL = (
f"amqp://{RABBITMQ_LOGIN}:{RABBITMQ_PASS}@{RABBITMQ_HOST}:{RABBITMQ_PORT}/{RABBITMQ_VHOST_ENCODED}" f"amqp://{RABBITMQ_LOGIN}:{RABBITMQ_PASS}@{RABBITMQ_HOST}:{RABBITMQ_PORT}/{RABBITMQ_VHOST_ENCODED}"
) )
# Mailing settings # Mailing settings
MAILING_MAX_WORKERS = int(os.environ.get("MAILING_MAX_WORKERS", "16")) MAILING_MAX_WORKERS = int(os.getenv("MAILING_MAX_WORKERS", "16"))
MAILING_RATE_LIMIT = int(os.environ.get("MAILING_RATE_LIMIT", "25")) MAILING_RATE_LIMIT = int(os.getenv("MAILING_RATE_LIMIT", "25"))
MAILING_MAX_RETRIES = int(os.getenv("MAILING_MAX_RETRIES", "5"))
# Настройки Flask-LDAP3-login # Настройки Flask-LDAP3-login
LDAP_HOST = os.getenv('LDAP_HOST', 'localhost') LDAP_HOST = os.getenv('LDAP_HOST', 'localhost')
LDAP_PORT = int(os.getenv('LDAP_PORT', 389)) LDAP_PORT = int(os.getenv('LDAP_PORT', 389))