diff --git a/.env.dev b/.env.dev index a141c001..f796bdd6 100644 --- a/.env.dev +++ b/.env.dev @@ -5,6 +5,7 @@ MAIL_PORT=587 MAIL_USE_TLS=True MAIL_USERNAME= MAIL_DEFAULT_SENDER= +URL_PROCHARITY= EMAIL_PROCHARRITY= HOST_NAME= ACCESS_TOKEN_FOR_PROCHARITY= diff --git a/.env.example b/.env.example index 51cca82d..36fa8974 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,7 @@ MAIL_PORT= MAIL_USE_TLS= MAIL_USERNAME= MAIL_DEFAULT_SENDER= +URL_PROCHARITY= EMAIL_PROCHARRITY= DATABASE_URL= HOST_NAME= diff --git a/.env.prod b/.env.prod index 70627687..9374b108 100644 --- a/.env.prod +++ b/.env.prod @@ -5,6 +5,7 @@ MAIL_PORT= MAIL_USE_TLS= MAIL_USERNAME= MAIL_DEFAULT_SENDER= +URL_PROCHARITY= EMAIL_PROCHARRITY= DATABASE_URL= HOST_NAME= diff --git a/.github/workflows/develop_bot_ci.yml b/.github/workflows/develop_bot_ci.yml index 86caa892..354e27ef 100644 --- a/.github/workflows/develop_bot_ci.yml +++ b/.github/workflows/develop_bot_ci.yml @@ -100,6 +100,7 @@ jobs: echo MAIL_USE_TLS=${{ secrets.MAIL_USE_TLS }} >> .env echo MAIL_USERNAME=${{ secrets.MAIL_USERNAME }} >> .env echo MAIL_DEFAULT_SENDER=${{ secrets.MAIL_DEFAULT_SENDER }} >> .env + echo URL_PROCHARITY=${{ secrets.URL_PROCHARITY }} >> .env echo EMAIL_PROCHARRITY=${{ secrets.EMAIL_PROCHARRITY }} >> .env echo HOST_NAME=${{ secrets.HOST_NAME }} >> .env echo ACCESS_TOKEN_FOR_PROCHARITY=${{ secrets.ACCESS_TOKEN_FOR_PROCHARITY }} >> .env diff --git a/.github/workflows/master_bot_ci.yml b/.github/workflows/master_bot_ci.yml index 7011a543..3b00f2f1 100644 --- a/.github/workflows/master_bot_ci.yml +++ b/.github/workflows/master_bot_ci.yml @@ -100,6 +100,7 @@ jobs: echo MAIL_USE_TLS=${{ secrets.MAIL_USE_TLS }} >> .env echo MAIL_USERNAME=${{ secrets.MAIL_USERNAME }} >> .env echo MAIL_DEFAULT_SENDER=${{ secrets.MAIL_DEFAULT_SENDER }} >> .env + echo URL_PROCHARITY=${{ secrets.URL_PROCHARITY }} >> .env echo EMAIL_PROCHARRITY=${{ secrets.EMAIL_PROCHARRITY }} >> .env echo HOST_NAME=${{ secrets.HOST_NAME_PROD }} >> .env echo ACCESS_TOKEN_FOR_PROCHARITY=${{ secrets.ACCESS_TOKEN_FOR_PROCHARITY_PROD }} >> .env diff --git a/app/__init__.py b/app/__init__.py index 8bc93400..1100fb5d 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -9,6 +9,7 @@ from app import config from app.config import TELEGRAM_TOKEN + jwt = JWTManager() mail = Mail() cors = CORS() @@ -26,12 +27,14 @@ def create_app(): from app.webhooks import swagger_webhooks from app.auth import auth_bp + from app.error_handlers import invalid_api_usage, InvalidAPIUsage from app.front import front_bp from app.webhooks import webhooks_bp app.register_blueprint(webhooks_bp) app.register_blueprint(auth_bp) app.register_blueprint(front_bp) + app.register_error_handler(InvalidAPIUsage, invalid_api_usage) jwt.init_app(app) mail.init_app(app) diff --git a/app/auth/external_users_registration.py b/app/auth/external_users_registration.py index 2c2c80b7..b85adf2a 100644 --- a/app/auth/external_users_registration.py +++ b/app/auth/external_users_registration.py @@ -32,7 +32,7 @@ class ExternalUserRegistration(MethodResource, Resource): } ) @use_kwargs( - {'id': fields.Int(required=True), + {'user_id': fields.Int(required=True), 'id_hash': fields.Str(description='md5 hash of external_id', required=True), 'first_name': fields.Str(required=True), 'last_name': fields.Str(required=True), @@ -40,7 +40,7 @@ class ExternalUserRegistration(MethodResource, Resource): 'specializations': fields.Str(required=True)} ) def post(self, **kwargs): - external_id = kwargs.get('id') + external_id = kwargs.get('user_id') user = ExternalSiteUser.query.options(load_only('external_id')).filter_by(external_id=external_id).first() if user: @@ -63,7 +63,7 @@ def post(self, **kwargs): categories = [] for specialization in specializations: - user.categories.append(specialization.name) + categories.append(specialization.name) try: db_session.commit() diff --git a/app/config.py b/app/config.py index 96b5c8e5..1f7e6e99 100644 --- a/app/config.py +++ b/app/config.py @@ -29,6 +29,8 @@ 'lowercase': 1, 'max_length': 32, } + +URL_PROCHARITY = os.getenv('URL_PROCHARITY') # procharity send email settings PROCHARRITY_TEMPLATE = 'email_templates/send_question.html' diff --git a/app/error_handlers.py b/app/error_handlers.py new file mode 100644 index 00000000..9be295b4 --- /dev/null +++ b/app/error_handlers.py @@ -0,0 +1,20 @@ +class InvalidAPIUsage(Exception): + """ + Special class for custom Exceptions raised where it is necessary. + If Exception is not explicitly defined raises HTTP 400 Bad Request. + Error Message is required to clarify erroneous program behavior. + """ + status_code = 400 + + def __init__(self, message, status_code=None): + super().__init__() + self.message = message + if status_code is not None: + self.status_code = status_code + + +def invalid_api_usage(error): + """ + InvalidAPIUsage handler function. + """ + return error.message, error.status_code diff --git a/app/front/__init__.py b/app/front/__init__.py index f55a36c5..31e95d09 100644 --- a/app/front/__init__.py +++ b/app/front/__init__.py @@ -7,12 +7,15 @@ from . import analytics from . import send_tg_notification +from . import send_tg_message_to_user from . import users from . import download_log_files front_api.add_resource(analytics.Analytics, '/api/v1/analytics/') front_api.add_resource(send_tg_notification.SendTelegramNotification, - '/api/v1/send_telegram_notification/') + '/api/v1/messages/') +front_api.add_resource(send_tg_message_to_user.SendTelegramMessage, + '/api/v1/messages//') front_api.add_resource(users.UsersList, '/api/v1/users/') front_api.add_resource(users.UserItem, '/api/v1/users//') front_api.add_resource(download_log_files.DownloadLogs, '/api/v1/download_logs/') diff --git a/app/front/send_tg_message_to_user.py b/app/front/send_tg_message_to_user.py new file mode 100644 index 00000000..17f321db --- /dev/null +++ b/app/front/send_tg_message_to_user.py @@ -0,0 +1,97 @@ +import datetime + +from flask import jsonify, make_response +from flask_apispec import doc, use_kwargs +from flask_apispec.views import MethodResource +from flask_restful import Resource +from marshmallow import Schema, fields +from sqlalchemy.exc import SQLAlchemyError + +from app.database import db_session +from app.error_handlers import InvalidAPIUsage +from app.logger import app_logger as logger +from app.models import Notification +from app.webhooks.check_webhooks_token import check_webhooks_token +from bot.messages import TelegramMessage + + +class TelegramMessageSchema(Schema): + message = fields.String(required=True) + + +class SendTelegramMessage(Resource, MethodResource): + method_decorators = {'post': [check_webhooks_token]} + + @doc(description='Sends message to the telegram user.' + 'Requires "message" and "telegram_id" parameters.', + summary='Send telegram messages to the bot chat', + tags=['Messages'], + responses={ + 200: {'description': 'The message has been sent'}, + 400: {'description': 'The message can not be empty'}, + }, + params={ + 'message': { + 'description': 'Message to user. Max len 4096', + 'in': 'query', + 'type': 'string' + }, + 'telegram_id': { + 'description': ( + 'Sending notification to user with this telegram id' + ), + 'in': 'path', + 'type': 'integer', + 'required': True + }, + 'token': { + 'description': 'webhooks token', + 'in': 'header', + 'type': 'string', + 'required': True + } + }) + @use_kwargs(TelegramMessageSchema) + def post(self, telegram_id, **kwargs): + message = kwargs.get('message').replace(' ', '') + + if not message: + logger.info( + 'Messages: The parameter have not been passed' + ) + return make_response( + jsonify( + result=( + 'Необходимо указать параметр .' + ) + ), 400 + ) + + message = Notification(message=message) + db_session.add(message) + try: + db_session.commit() + mes = TelegramMessage(telegram_id) + mes.send_message(message=message.message) + message.was_sent = True + message.sent_date = datetime.datetime.now() + db_session.commit() + except InvalidAPIUsage as ex: + db_session.rollback() + return make_response( + jsonify(result=ex.message), ex.status_code + ) + except SQLAlchemyError as ex: + logger.error(f'Messages: Database commit error "{str(ex)}"') + db_session.rollback() + return make_response( + jsonify(message=f'Bad request: {str(ex)}'), 400 + ) + + logger.info(f'Messages: The message "{message.message}" ' + 'has been successfully sent to user') + return make_response( + jsonify( + result="Сообщение успешно отправлено пользователю." + ), 200 + ) diff --git a/app/front/send_tg_notification.py b/app/front/send_tg_notification.py index 82119056..36d519c5 100644 --- a/app/front/send_tg_notification.py +++ b/app/front/send_tg_notification.py @@ -16,17 +16,18 @@ from bot.messages import TelegramNotification - class TelegramNotificationSchema(Schema): message = fields.String(required=True) - has_mailing = fields.String(required=True) + mode = fields.String(required=True) class SendTelegramNotification(Resource, MethodResource): - @doc(description='Sends message to the Telegram chat. Requires "message" parameter.' - ' Messages can be sent either to subscribed users or not.To do this,' - ' specify the "has_mailing" parameter.Default value "True".', + @doc(description=( + 'Sends message to the Telegram chat. Requires "message" parameter.' + ' Messages can be sent either to subscribed users or not.To do ' + 'this, specify the "mode" parameter.Default value "subscribed".' + ), summary='Send messages to the bot chat', tags=['Messages'], responses={ @@ -40,8 +41,9 @@ class SendTelegramNotification(Resource, MethodResource): 'type': 'string', 'required': True }, - 'has_mailing': { - 'description': ('Sending notifications to users by the type of permission to mailing.' + 'mode': { + 'description': ('Sending notifications to users by the type ' + 'of permission to mailing.' 'subscribed - user has enabled a mailing.' 'unsubscribed - user has disabled a mailing.' 'all - send to all users'), @@ -49,30 +51,47 @@ class SendTelegramNotification(Resource, MethodResource): 'type': 'string', 'required': True }, - 'Authorization': config.PARAM_HEADER_AUTH, # Only if request requires authorization + # Only if request requires authorization + 'Authorization': config.PARAM_HEADER_AUTH, } ) @use_kwargs(TelegramNotificationSchema) @jwt_required() def post(self, **kwargs): message = kwargs.get('message').replace(' ', '') - has_mailing = kwargs.get('has_mailing') + mode = kwargs.get('mode') - if not message or not has_mailing: - logger.info("Messages: The and parameters have not been passed") - return make_response(jsonify(result="Необходимо указать параметры и ."), 400) + if not message or not mode: + logger.info( + 'Messages: The and ' + 'parameters have not been passed' + ) + return make_response( + jsonify( + result='Необходимо указать параметры и .' + ), + 400 + ) authorized_user = get_jwt_identity() message = Notification(message=message, sent_by=authorized_user) db_session.add(message) try: db_session.commit() - job_queue = TelegramNotification(has_mailing) + job_queue = TelegramNotification(mode) if not job_queue.send_notification(message=message.message): - logger.info(f"Messages: Passed invalid parameter. Passed: {has_mailing}") - return make_response(jsonify(result=f"Неверно указан параметр . " - f"Сообщение не отправлено."), 400) + logger.info( + 'Messages: Passed invalid parameter. ' + f'Passed: {mode}' + ) + return make_response( + jsonify( + result=('Неверно указан параметр . ' + 'Сообщение не отправлено.') + ), + 400 + ) message.was_sent = True message.sent_date = datetime.datetime.now() @@ -81,8 +100,15 @@ def post(self, **kwargs): except SQLAlchemyError as ex: logger.error(f'Messages: Database commit error "{str(ex)}"') db_session.rollback() - return make_response(jsonify(message=f'Bad request: {str(ex)}'), 400) + return make_response( + jsonify(message=f'Bad request: {str(ex)}'), 400 + ) - logger.info(f"Messages: The message '{message.message[0:30]}...' " - f"has been successfully added to the mailing list.") - return make_response(jsonify(result=f"Сообщение успешно добавлено в очередь рассылки."), 200) + logger.info(f'Messages: The message "{message.message[0:30]}..." ' + f'has been successfully added to the mailing list.') + return make_response( + jsonify( + result='Сообщение успешно добавлено в очередь рассылки.' + ), + 200 + ) diff --git a/app/front/swagger_front.py b/app/front/swagger_front.py index 5ebcaa21..d73460b2 100644 --- a/app/front/swagger_front.py +++ b/app/front/swagger_front.py @@ -3,10 +3,12 @@ from app.front.download_log_files import DownloadLogs, GetListLogFiles from app.front.users import UsersList, UserItem from app.front.send_tg_notification import SendTelegramNotification +from app.front.send_tg_message_to_user import SendTelegramMessage docs.register(Analytics, blueprint='front_bp') docs.register(SendTelegramNotification, blueprint='front_bp') +docs.register(SendTelegramMessage, blueprint='front_bp') docs.register(UsersList, blueprint='front_bp') docs.register(UserItem, blueprint='front_bp') docs.register(DownloadLogs, blueprint='front_bp') diff --git a/app/logger.py b/app/logger.py index 7a61dc62..9b76b475 100644 --- a/app/logger.py +++ b/app/logger.py @@ -32,7 +32,7 @@ def add_handler(path_name): def app_logging(): module_logger = logging.getLogger('app') - module_logger.setLevel(logging.INFO) + module_logger.setLevel(logging.DEBUG) app_handler = add_handler('app_logs.txt') app_loggers = [ module_logger, @@ -47,7 +47,7 @@ def app_logging(): def bot_logging(): bot_logger = logging.getLogger("telegram") - bot_logger.setLevel(logging.INFO) + bot_logger.setLevel(logging.DEBUG) bot_handler = add_handler('bot_logs.txt') bot_logger.addHandler(bot_handler) app_loggers = [ @@ -63,7 +63,7 @@ def bot_logging(): def webhooks_logging(): webhooks_logger = logging.getLogger("webhooks") - webhooks_logger.setLevel(logging.INFO) + webhooks_logger.setLevel(logging.DEBUG) webhooks_handler = add_handler('webhooks_logs.txt') webhooks_logger.addHandler(webhooks_handler) diff --git a/app/models.py b/app/models.py index f2dc710c..f6de85d2 100644 --- a/app/models.py +++ b/app/models.py @@ -122,7 +122,7 @@ class Notification(Base): message = Column(String(4096), nullable=False) was_sent = Column(Boolean, default=False) sent_date = Column(TIMESTAMP) - sent_by = Column(String(48), nullable=False) + sent_by = Column(String(48), nullable=True) def __repr__(self): return f'' diff --git a/bot/common_comands.py b/bot/common_comands.py index 86842a26..7cb592fd 100644 --- a/bot/common_comands.py +++ b/bot/common_comands.py @@ -2,8 +2,9 @@ InlineKeyboardMarkup, ParseMode) from telegram.ext import CallbackContext, CallbackQueryHandler, CommandHandler - from telegram import InlineKeyboardButton + +from app import config from bot.constants import states from bot.constants import command_constants from bot.constants import constants @@ -13,6 +14,7 @@ from core.services.user_service import UserService from app.database import db_session + MENU_BUTTONS = [ [ InlineKeyboardButton( @@ -61,12 +63,14 @@ def start(update: Update, context: CallbackContext) -> int: callback_data = (command_constants.COMMAND__GREETING_REGISTERED_USER if user.categories else command_constants.COMMAND__GREETING) - button = [ - [ - InlineKeyboardButton(text='Начнем', callback_data=callback_data) - ] + buttons = [ + [InlineKeyboardButton(text='Начнем', callback_data=callback_data)], + [InlineKeyboardButton( + text='Связать аккаунт с ботом', + url=f'{config.URL_PROCHARITY}/auth/bot_procharity.php?user_id={user.external_id}&telegram_id={user.telegram_id}' + )] ] - keyboard = InlineKeyboardMarkup(button) + keyboard = InlineKeyboardMarkup(buttons) context.bot.send_message( chat_id=update.effective_chat.id, text='Привет! 👋 \n\n' diff --git a/bot/messages.py b/bot/messages.py index 7c23a53c..95b145e7 100644 --- a/bot/messages.py +++ b/bot/messages.py @@ -1,13 +1,13 @@ -from telegram import Bot, ParseMode, error -from telegram.error import Unauthorized -import datetime import time from dataclasses import dataclass from typing import List -import pytz + +from telegram import Bot, ParseMode, error +from telegram.error import Unauthorized from app import config from app.database import db_session +from app.error_handlers import InvalidAPIUsage from app.logger import bot_logger as logger from app.models import User from bot.charity_bot import dispatcher @@ -16,7 +16,7 @@ @dataclass -class SendUserMessageContext : +class SendUserMessageContext: message: str telegram_id: int @@ -28,11 +28,12 @@ class SendUserNotificationsContext: class TelegramNotification: """ - This class describes the functionality for working with notifications in Telegram. + This class describes the functionality + for working with notifications in Telegram. """ - def __init__(self, has_mailing: str = 'subscribed') -> None: - self.has_mailing = has_mailing + def __init__(self, mode: str = 'subscribed') -> None: + self.mode = mode # TODO refactoring https://github.com/python-telegram-bot/python-telegram-bot/wiki/Avoiding-flood-limits def send_notification(self, message): @@ -43,58 +44,81 @@ def send_notification(self, message): :param telegram_chats: Users query :return: """ - if self.has_mailing not in ('all', 'subscribed', 'unsubscribed'): + if self.mode not in ('all', 'subscribed', 'unsubscribed'): return False chats_list = [] query = db_session.query(User.telegram_id).filter(User.banned.is_(False)) - if self.has_mailing == 'subscribed': + if self.mode == 'subscribed': chats_list = query.filter(User.has_mailing.is_(True)) - if self.has_mailing == 'unsubscribed': + if self.mode == 'unsubscribed': chats_list = query.filter(User.has_mailing.is_(False)) - if self.has_mailing == 'all': + if self.mode == 'all': chats_list = query user_notification_context = SendUserNotificationsContext([]) for user in chats_list: - user_message_context = SendUserMessageContext(message=message, telegram_id=user.telegram_id) - user_notification_context.user_message_context.append(user_message_context) - + user_message_context = SendUserMessageContext( + message=message, + telegram_id=user.telegram_id + ) + user_notification_context.user_message_context.append( + user_message_context + ) + seconds = 1 - dispatcher.job_queue.run_once(self.send_batch_messages, seconds, context=user_notification_context, - name=f'Sending: {message[0:10]}') + dispatcher.job_queue.run_once( + self.send_batch_messages, + seconds, + context=user_notification_context, + name=f'Sending: {message[0:10]}' + ) return True def send_batch_messages(self, user_notification_context): job = user_notification_context.job user_message_context = job.context.user_message_context - for send_set in self.__split_chats(user_message_context, config.MAILING_BATCH_SIZE): - + for send_set in self.__split_chats( + user_message_context, + config.MAILING_BATCH_SIZE + ): for user_message_context in send_set: self.__send_message_context(user_message_context) time.sleep(1) - + def __send_message_context(self, user_message_context): tries = 3 for i in range(tries): try: - bot.send_message(chat_id=user_message_context.telegram_id, text=user_message_context.message, - parse_mode=ParseMode.HTML, disable_web_page_preview=True) - logger.info(f"Sent message to {user_message_context.telegram_id}") + bot.send_message( + chat_id=user_message_context.telegram_id, + text=user_message_context.message, + parse_mode=ParseMode.HTML, + disable_web_page_preview=True + ) + logger.info( + f"Sent message to {user_message_context.telegram_id}" + ) return except error.BadRequest as ex: - logger.error(f'{str(ex.message)}, telegram_id: {user_message_context.telegram_id}') + logger.error( + f'{str(ex.message)}, telegram_id: ' + f'{user_message_context.telegram_id}') if i < tries: logger.info(f"Retry to send after {i}") time.sleep(i) except Unauthorized as ex: - logger.error(f'{str(ex.message)}: {user_message_context.telegram_id}') - User.query.filter_by(telegram_id=user_message_context.telegram_id).update({'banned': True, 'has_mailing': False}) + logger.error( + f'{str(ex.message)}: {user_message_context.telegram_id}' + ) + User.query.filter_by( + telegram_id=user_message_context.telegram_id + ).update({'banned': True, 'has_mailing': False}) db_session.commit() @staticmethod @@ -107,3 +131,48 @@ def __split_chats(array, size): array = array[size:] arrs.append(array) return arrs + + +class TelegramMessage: + """ + This class describes the functionality for + working with message to user in Telegram. + """ + + def __init__(self, telegram_id: int) -> None: + self.telegram_id = telegram_id + + def send_message(self, message) -> None: + """ + Send telegram message to user. + + :param message: Message to send + :return: + """ + + if not db_session.query( + User + ).filter(User.telegram_id == self.telegram_id): + logger.error( + f'User with telegram id "{self.telegram_id}" does not exist' + ) + raise InvalidAPIUsage(f'Пользователь с таким telegram id ' + f'"{self.telegram_id}" не существует' + ) + + try: + bot.send_message( + chat_id=self.telegram_id, text=message, + parse_mode=ParseMode.HTML, disable_web_page_preview=True) + logger.info(f'Sent message to {self.telegram_id}') + + except error.BadRequest as ex: + logger.error(f'{str(ex.message)}, telegram_id: {self.telegram_id}') + raise InvalidAPIUsage('Неверно указан параметр . Сообщение не отправлено.') + except Unauthorized as ex: + logger.error(f'{str(ex.message)}: {self.telegram_id}') + User.query.filter_by( + telegram_id=self.telegram_id + ).update({'banned': True, 'has_mailing': False}) + db_session.commit() + raise InvalidAPIUsage(f'{str(ex.message)}: {self.telegram_id}') diff --git a/front b/front index 5348e363..50ccf3b0 160000 --- a/front +++ b/front @@ -1 +1 @@ -Subproject commit 5348e3632d0db5bd29791e5a5c4f6ce6fd9a2a3f +Subproject commit 50ccf3b0981195a09bf092309e38eab13ca57fd5 diff --git a/migrations/versions/0e74b21e97f4_notification_sent_by_set_nullable.py b/migrations/versions/0e74b21e97f4_notification_sent_by_set_nullable.py new file mode 100644 index 00000000..3fefc4f8 --- /dev/null +++ b/migrations/versions/0e74b21e97f4_notification_sent_by_set_nullable.py @@ -0,0 +1,32 @@ +"""Notification sent_by set nullable + +Revision ID: 0e74b21e97f4 +Revises: 87c7d29e3ddb +Create Date: 2023-07-03 17:44:30.800774 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '0e74b21e97f4' +down_revision = '87c7d29e3ddb' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('notifications', 'sent_by', + existing_type=sa.VARCHAR(length=48), + nullable=True) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('notifications', 'sent_by', + existing_type=sa.VARCHAR(length=48), + nullable=False) + # ### end Alembic commands ###