1 Commits

Author SHA1 Message Date
7b5bba2a17 no message 2026-01-10 19:53:17 +03:00
17 changed files with 286 additions and 153 deletions

View File

@@ -0,0 +1 @@
from .main import MailOrderBotException

View File

@@ -1,4 +1,6 @@
# Настройки обработки =================================================================
folder: "spareparts"
clients:
lesha.spb@gmail.com:
enabled: true
@@ -6,8 +8,8 @@ clients:
pipeline:
- ExcelExtractor
- OrderExtractor
- DeliveryPeriodFromConfig
- OrderExtractor
- StockSelector
- UpdateExcelFile
- SaveOrderToTelegram
@@ -39,7 +41,6 @@ clients:
update_interval: 1
work_interval: 60
email_dir: "spareparts"
# Логирование =================================================================
log:
version: 1

View File

@@ -1,3 +1,27 @@
"""
Структура контекста
email : Электронное письмо в формате EmailObject
config : Конфиг для текущего клиента
attachments : [ Список вложений
{
name : Имя файла
isOrder : Признак что файл является валидным файлом заказа
bytes : содержимое файла в формате BytesIO
deliveryPeriod: Int
sheet : Распарсенный лист в формате ExcelFileParcer
order : Файл заказа в формате AutopartOrder
log : Лог сообщений по обработке файла в формате LogMessage
}
]
status:
"""
import threading
from typing import Any, Dict
import logging

View File

@@ -14,11 +14,17 @@ import smtplib
import logging
from mail_order_bot import MailOrderBotException
logger = logging.getLogger(__name__)
# from .objects import EmailMessage, EmailAttachment
class EmailClientException(MailOrderBotException):
pass
class EmailClient:
def __init__(self, imap_host: str, smtp_host: str, email: str, password: str,
@@ -32,7 +38,7 @@ class EmailClient:
self.imap_conn = None
def connect(self):
"""Установkение IMAP соединения"""
"""Установление IMAP соединения"""
if self.imap_conn is None:
self.imap_conn = imaplib.IMAP4_SSL(self.imap_host, self.imap_port)
self.imap_conn.login(self.email, self.password)
@@ -58,6 +64,7 @@ class EmailClient:
def get_emails_id(self, folder: str = "INBOX", only_unseen: bool = True) -> List[int]:
"""Получить список новых электронных писем."""
try:
self.connect()
self.imap_conn.select(folder, readonly=False)
# Ищем письма
@@ -69,8 +76,16 @@ class EmailClient:
return []
email_ids = messages[0].split()
except Exception as e:
logger.error(e)
raise
else:
return email_ids
def get_email(self, email_id, mark_as_read: bool = True):
"""Получить список новых электронных писем."""
self.connect()

View File

@@ -7,6 +7,7 @@ import os
from dotenv import load_dotenv
from email_client import EmailClient
from mail_order_bot.task_processor.abstract_task import AbstractTask
from task_processor import TaskProcessor
from mail_order_bot.context import Context
@@ -14,6 +15,8 @@ from mail_order_bot.context import Context
logger = logging.getLogger()
class MailOrderBotException(Exception):
pass
class MailOrderBot(ConfigManager):
def __init__(self, *agrs, **kwargs):
@@ -33,35 +36,45 @@ class MailOrderBot(ConfigManager):
self.context = Context()
self.context.email_client = self.email_client
# Обработчик писем
#self.email_processor = TaskProcessor("./configs")
config = self.config.get("clients")
self.email_processor = TaskProcessor(config)
logger.warning("MailOrderBot инициализирован")
def execute(self):
# Получить список айдишников письма
logger.critical("Запуск приложения critical !!!!!!!!")
unread_email_ids = self.email_client.get_emails_id(folder="spareparts")
folder = self.config.get("folder")
try:
unread_email_ids = self.email_client.get_emails_id(folder=folder)
logger.info(f"Новых писем - {len(unread_email_ids)}")
# Обработать каждое письмо по идентификатору
for email_id in unread_email_ids:
logger.debug(f"==================================================")
logger.debug(f"Обработка письма с идентификатором {email_id}")
try:
logger.info(f"Обработка письма с идентификатором {email_id}")
# Получить письмо по идентификатору и запустить его обработку
email = self.email_client.get_email(email_id, mark_as_read=False)
self.email_processor.process_email(email)
except MailOrderBotException as e:
logger.error(f"Произошла ошибка {e}")
except MailOrderBotException as e:
logger.error(f"Произошла ошибка {e}")
except Exception as e:
logger.error(f"Произошла непредвиденная ошибка {e}")
logger = logging.getLogger()
async def main():
logger.critical("Запуск приложения")
app = MailOrderBot("config.yml")
await app.start()

View File

@@ -1 +1,2 @@
from .processor import TaskProcessor
from .message import LogMessage, LogMessageLevel, LogMessageStorage

View File

@@ -23,3 +23,6 @@ class AbstractTask():
"""
raise NotImplementedError
def get_name(self) -> str:
pass

View File

@@ -2,8 +2,8 @@
from .attachment_handler.attachment_handler import AttachmentHandler
from .abcp.api_get_stock import APIGetStock
from .destination_time.local_store import DeliveryPeriodLocalStore
from .destination_time.from_config import DeliveryPeriodFromConfig
from .delivery_time.local_store import DeliveryPeriodLocalStore
from .delivery_time.from_config import DeliveryPeriodFromConfig
from .notifications.test_notifier import TestNotifier
from .excel_parcers.excel_extractor import ExcelExtractor
from .excel_parcers.order_extractor import OrderExtractor

View File

@@ -1,29 +0,0 @@
"""
Извлекает вложения из имейла и складывает их в контекст
Использует EmailUtils
"""
import logging
from mail_order_bot.task_processor.abstract_task import AbstractTask
from mail_order_bot.email_client.utils import EmailUtils
logger = logging.getLogger(__name__)
class AttachmentHandler(AbstractTask):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def do(self) -> None:
try:
email = self.context.data["email"]
attachments = EmailUtils.extract_attachments(email)
except Exception as e:
logger.error(e)
self.context.data["error"] = str(e)
else:
self.context.data["attachments"] = attachments
logger.info(f"Извлечено вложений: {len(attachments)} ")

View File

@@ -18,8 +18,7 @@ class DeliveryPeriodFromConfig(AbstractTask):
for attachment in attachments:
delivery_period = self.config.get("delivery_period", 0)
attachment["delivery_period"] = delivery_period
logger.info(f"Доставка только с локального склада, срок 1 день.")
logger.info(f"Срок доставки для файла {attachment["name"]} установлен из конфига - {delivery_period}")

View File

@@ -26,6 +26,8 @@ class DeliveryPeriodFromSubject(AbstractTask):
- Срок переводится в часы (умножается на 24)
"""
# Получаем тему письма
try:
email_subj = self.context.data.get("email_subj", "")
if not email_subj:
logger.warning("Тема письма не найдена в контексте")
@@ -43,8 +45,11 @@ class DeliveryPeriodFromSubject(AbstractTask):
attachments = self.context.data.get("attachments", [])
for attachment in attachments:
attachment["delivery_time"] = delivery_time
logger.debug(f"Срок доставки для файла {attachment["name"]} установлен как {delivery_time}")
except Exception as e:
logger.error(e)
logger.debug(f"Срок доставки сохранен в {len(attachments)} вложений")
def _parse_delivery_period(self, subject: str) -> int:
"""

View File

@@ -17,8 +17,4 @@ class DeliveryPeriodLocalStore(AbstractTask):
attachments = self.context.data["attachments"]
for attachment in attachments:
attachment["delivery_period"] = 0
logger.info(f"Доставка только с локального склада, срок 1 день.")
logger.info(f"Срок доставки для файла {attachment["name"]} - только из наличия")

View File

@@ -6,15 +6,23 @@ import logging
from mail_order_bot.task_processor.abstract_task import AbstractTask
from mail_order_bot.email_client.utils import EmailUtils
from mail_order_bot import MailOrderBotException
from mail_order_bot.task_processor import LogMessage, LogMessageLevel
logger = logging.getLogger(__name__)
class EmailParcerException(MailOrderBotException):
pass
class EmailParcer(AbstractTask):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def do(self) -> None:
# Определить клиента
try:
email = self.context.data.get("email", None)
if email is not None:
email_body = EmailUtils.extract_body(email)
@@ -36,9 +44,24 @@ class EmailParcer(AbstractTask):
self.context.data["client"] = client
attachments = EmailUtils.extract_attachments(email)
self.context.data["attachments"] = attachments
logger.info(f"Извлечено вложений: {len(attachments)} ")
except Exception as e:
logger.error(e)
self.context.data["error"].add(
LogMessage(
handler="EmailParcer",
level=LogMessageLevel.ERROR,
message="Возникла ошибка при парсинге письма",
error_data=str(e)
)
)
#raise EmailParcerException(f"Ошибка при парсинге письма {e}") from e

View File

@@ -1,24 +1,29 @@
import logging
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.mime.base import MIMEBase
from email.utils import formatdate
from email import encoders
from abc import ABC, abstractmethod
import os
from mail_order_bot import MailOrderBotException
from mail_order_bot.task_processor.abstract_task import AbstractTask
logger = logging.getLogger(__name__)
class EmailReplyTaskException(MailOrderBotException):
pass
class EmailReplyTask(AbstractTask):
"""Формирует ответ на входящее письмо с запросом на заказ°"""
EMAIl = "zosimovaa@yandex.ru" #"noreply@zapchastiya.ru"
def do(self):
try:
email = self.context.data.get("email")
if not email:
raise ValueError("В контексте нет входящего сообщения")
@@ -45,6 +50,8 @@ class EmailReplyTask(AbstractTask):
self._attach_file(reply_message, attachment)
self.context.email_client.send_email(reply_message)
except Exception as e:
pass
def _attach_file(self, reply_message, attachment):
"""

View File

@@ -1,6 +1,8 @@
import logging
import pandas as pd
from io import BytesIO
from mail_order_bot.email_client import EmailUtils
#from mail_order_bot.task_processor.handlers.order_position import OrderPosition
from mail_order_bot.task_processor.abstract_task import AbstractTask
@@ -22,14 +24,20 @@ class ExcelExtractor(AbstractTask):
# todo сделать проверку на наличие файла и его тип
attachments = self.context.data.get("attachments", [])
for attachment in attachments:
file_bytes = BytesIO(attachment['bytes'])
for attachment in attachments:
try:
file_bytes = BytesIO(attachment['bytes'])
excel_file = ExcelFileParcer(file_bytes, self.excel_config)
except Exception as e:
logger.warning(f"Не удалось прочитать файл {attachment['name']}: {e}")
attachment["excel"] = None
else:
attachment["excel"] = excel_file

View File

@@ -0,0 +1,44 @@
from enum import Enum
class LogMessageLevel(Enum):
SUCCESS = "SUCCESS"
WARNING = "WARNING"
ERROR = "ERROR"
class LogMessage:
def __init__(self, handler=None, level=None, message=None, error_data=None):
self.handler = handler
self.level = level
self.message = message
self.error_data = error_data
def __str__(self):
return self.message
class LogMessageStorage:
def __init__(self, filename=None):
self.filename = filename
self.messages = []
def append(self, message):
self.messages.append(message)
def check_errors(self) -> bool:
fatal_statuses = [message.level == LogMessageLevel.ERROR for message in self.messages]
return bool(sum(fatal_statuses))
def get_messages_log(self) -> str:
response = ""
if self.filename is not None:
response += f" Лог обработки файла: {self.filename}"
for message in self.messages:
if len(response):
response += "\n"
response += f"{message.handler} [{message.level}]: {message.message}"
if message.error_data is not None:
response += f"\n{message.error_data}"

View File

@@ -1,3 +1,35 @@
"""
Общая логика обработки писем следующая
1. Общая часть
- скачиваем письмо
- складываем в контекст
- обработчик и парсим данные - тело, тема, отправитель
2. Запускаем паплайн
- прогоняем обработчик для каждого вложения
- каждый обработчик для вложения докидывает результат своей работы
- каждый обработчик анализирует общий лог на наличие фатальных ошибок. Если есть - пропускаем шаг.
Последний обработчик направляет лог ошибок на администратора
Ограничения:
- каждое вложение воспринимается как "отдельное письмо", т.е. если клиент в одном письме направит несколько вложений,
то они будут обрабатываться как отдельные письма, и на каждое будет дан ответ (если он требуется).
Исключительные ситуации:
- При невозможности создать заказ - пересылаем письмо на администратора с логом обработки вложения
- Вложения, которые не являются файлами заказа игнорируем.
todo
[ ] Нужен класс, который будет хранить сообщения от обработчиков
- метод для добавления сообщения
- метод для проверки фатальных ошибок
- метод для извлечения лога
"""
import os
import yaml
import logging
@@ -9,9 +41,10 @@ from mail_order_bot.email_client.utils import EmailUtils
from enum import Enum
from mail_order_bot.task_processor.handlers import *
from mail_order_bot.task_processor.handlers import AttachmentHandler
from mail_order_bot.task_processor.handlers.email.email_parcer import EmailParcer
from mail_order_bot import MailOrderBotException
logger = logging.getLogger(__name__)
@@ -38,34 +71,26 @@ class TaskProcessor:
self.context.clear()
self.context.data["email"] = email
try:
# Парсинг письма
email_parcer = EmailParcer()
email_parcer.do()
email_from = self.context.data.get("email_from")
#client = EmailUtils.extract_domain(email_from)
#self.context.data["client"] = client
email_sender = self.context.data.get("email_from")
try:
# Определить конфиг для пайплайна
config = self._load_config(email_from)
config = self._load_config(email_sender)
self.context.data["config"] = config
# Запустить обработку пайплайна
pipeline = config["pipeline"]
for stage in pipeline:
handler_name = stage
for handler_name in pipeline:
logger.info(f"Processing handler: {handler_name}")
task = globals()[handler_name]()
task.do()
except FileNotFoundError:
logger.error(f"Конфиг для клиента {email_from} не найден")
for attachment in self.context.data["attachments"]:
print(attachment["order"].__dict__)
#except Exception as e:
# logger.error(f"Произошла другая ошибка: {e}")
except Exception as e:
logger.error(f"Произошла ошибка: {e}")
def _load_config(self, email_from) -> Dict[str, Any]:
@@ -73,11 +98,8 @@ class TaskProcessor:
return self.config[email_from]
email_from_domain = EmailUtils.extract_domain(email_from)
if email_from_domain in self.config:
return self.config[email_from_domain]
raise FileNotFoundError
#path = os.path.join(self.configs_path, client + '.yml')
#with open(path, 'r', encoding='utf-8') as f:
# return yaml.safe_load(f)