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: clients:
lesha.spb@gmail.com: lesha.spb@gmail.com:
enabled: true enabled: true
@@ -6,8 +8,8 @@ clients:
pipeline: pipeline:
- ExcelExtractor - ExcelExtractor
- OrderExtractor
- DeliveryPeriodFromConfig - DeliveryPeriodFromConfig
- OrderExtractor
- StockSelector - StockSelector
- UpdateExcelFile - UpdateExcelFile
- SaveOrderToTelegram - SaveOrderToTelegram
@@ -39,7 +41,6 @@ clients:
update_interval: 1 update_interval: 1
work_interval: 60 work_interval: 60
email_dir: "spareparts" email_dir: "spareparts"
# Логирование ================================================================= # Логирование =================================================================
log: log:
version: 1 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 import threading
from typing import Any, Dict from typing import Any, Dict
import logging import logging

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,8 +2,8 @@
from .attachment_handler.attachment_handler import AttachmentHandler from .attachment_handler.attachment_handler import AttachmentHandler
from .abcp.api_get_stock import APIGetStock from .abcp.api_get_stock import APIGetStock
from .destination_time.local_store import DeliveryPeriodLocalStore from .delivery_time.local_store import DeliveryPeriodLocalStore
from .destination_time.from_config import DeliveryPeriodFromConfig from .delivery_time.from_config import DeliveryPeriodFromConfig
from .notifications.test_notifier import TestNotifier from .notifications.test_notifier import TestNotifier
from .excel_parcers.excel_extractor import ExcelExtractor from .excel_parcers.excel_extractor import ExcelExtractor
from .excel_parcers.order_extractor import OrderExtractor 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: for attachment in attachments:
delivery_period = self.config.get("delivery_period", 0) delivery_period = self.config.get("delivery_period", 0)
attachment["delivery_period"] = delivery_period attachment["delivery_period"] = delivery_period
logger.info(f"Срок доставки для файла {attachment["name"]} установлен из конфига - {delivery_period}")
logger.info(f"Доставка только с локального склада, срок 1 день.")

View File

@@ -26,25 +26,30 @@ class DeliveryPeriodFromSubject(AbstractTask):
- Срок переводится в часы (умножается на 24) - Срок переводится в часы (умножается на 24)
""" """
# Получаем тему письма # Получаем тему письма
email_subj = self.context.data.get("email_subj", "")
if not email_subj:
logger.warning("Тема письма не найдена в контексте")
email_subj = ""
# Парсим срок доставки try:
delivery_days = self._parse_delivery_period(email_subj) email_subj = self.context.data.get("email_subj", "")
if not email_subj:
logger.warning("Тема письма не найдена в контексте")
email_subj = ""
# Переводим в часы # Парсим срок доставки
delivery_time = delivery_days * 24 delivery_days = self._parse_delivery_period(email_subj)
logger.info(f"Извлечен срок доставки из темы: {delivery_days} дней ({delivery_time} часов)") # Переводим в часы
delivery_time = delivery_days * 24
# Сохраняем в каждый элемент attachments logger.info(f"Извлечен срок доставки из темы: {delivery_days} дней ({delivery_time} часов)")
attachments = self.context.data.get("attachments", [])
for attachment in attachments: # Сохраняем в каждый элемент attachments
attachment["delivery_time"] = delivery_time 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: def _parse_delivery_period(self, subject: str) -> int:
""" """

View File

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

View File

@@ -6,38 +6,61 @@ import logging
from mail_order_bot.task_processor.abstract_task import AbstractTask from mail_order_bot.task_processor.abstract_task import AbstractTask
from mail_order_bot.email_client.utils import EmailUtils 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__) logger = logging.getLogger(__name__)
class EmailParcerException(MailOrderBotException):
pass
class EmailParcer(AbstractTask): class EmailParcer(AbstractTask):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
def do(self) -> None: def do(self) -> None:
# Определить клиента # Определить клиента
email = self.context.data.get("email", None) try:
if email is not None: email = self.context.data.get("email", None)
email_body = EmailUtils.extract_body(email) if email is not None:
self.context.data["email_body"] = email_body email_body = EmailUtils.extract_body(email)
self.context.data["email_body"] = email_body
# todo при переводе на основной ящик переделать на другую функцию # todo при переводе на основной ящик переделать на другую функцию
header_from = EmailUtils.extract_header(email, "From") header_from = EmailUtils.extract_header(email, "From")
email_from = EmailUtils.extract_email(header_from) email_from = EmailUtils.extract_email(header_from)
#email_from = EmailUtils.extract_first_sender(email_body) #email_from = EmailUtils.extract_first_sender(email_body)
self.context.data["email_from"] = email_from self.context.data["email_from"] = email_from
email_from_domain = EmailUtils.extract_domain(email_from) email_from_domain = EmailUtils.extract_domain(email_from)
self.context.data["email_from_domain"] = email_from_domain self.context.data["email_from_domain"] = email_from_domain
email_subj = EmailUtils.extract_header(email, "subj") email_subj = EmailUtils.extract_header(email, "subj")
self.context.data["email_subj"] = email_subj self.context.data["email_subj"] = email_subj
client = EmailUtils.extract_domain(email_from) client = EmailUtils.extract_domain(email_from)
self.context.data["client"] = client self.context.data["client"] = client
attachments = EmailUtils.extract_attachments(email) attachments = EmailUtils.extract_attachments(email)
self.context.data["attachments"] = attachments
logger.info(f"Извлечено вложений: {len(attachments)} ") 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,50 +1,57 @@
import logging
import smtplib
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.mime.base import MIMEBase from email.mime.base import MIMEBase
from email.utils import formatdate from email.utils import formatdate
from email import encoders 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 from mail_order_bot.task_processor.abstract_task import AbstractTask
logger = logging.getLogger(__name__)
class EmailReplyTaskException(MailOrderBotException):
pass
class EmailReplyTask(AbstractTask): class EmailReplyTask(AbstractTask):
"""Формирует ответ на входящее письмо с запросом на заказ°""" """Формирует ответ на входящее письмо с запросом на заказ°"""
EMAIl = "zosimovaa@yandex.ru" #"noreply@zapchastiya.ru" EMAIl = "zosimovaa@yandex.ru" #"noreply@zapchastiya.ru"
def do(self): def do(self):
email = self.context.data.get("email") try:
email = self.context.data.get("email")
if not email:
raise ValueError("В контексте нет входящего сообщения")
email_from = self.context.data.get("email_from")
if not email_from:
raise ValueError("В контексте не определен адрес отправителя")
if not email: reply_message = MIMEMultipart()
raise ValueError("В контексте нет входящего сообщения")
email_from = self.context.data.get("email_from") email_subj = self.context.data.get("email_subj")
if not email_from:
raise ValueError("В контексте не определен адрес отправителя")
reply_message["From"] = self.EMAIl
reply_message["To"] = email_from
#reply_message["Cc"] = self.config.get("reply_to", "")
reply_message["Subject"] = f"Re: {email_subj}"
reply_message["Date"] = formatdate(localtime=True)
reply_message = MIMEMultipart() body = "Автоматический ответ на создание заказа"
reply_message.attach(MIMEText(body, "plain", "utf-8"))
email_subj = self.context.data.get("email_subj") attachments = self.context.data.get("attachments")
for attachment in attachments:
self._attach_file(reply_message, attachment)
reply_message["From"] = self.EMAIl self.context.email_client.send_email(reply_message)
reply_message["To"] = email_from except Exception as e:
#reply_message["Cc"] = self.config.get("reply_to", "") pass
reply_message["Subject"] = f"Re: {email_subj}"
reply_message["Date"] = formatdate(localtime=True)
body = "Автоматический ответ на создание заказа"
reply_message.attach(MIMEText(body, "plain", "utf-8"))
attachments = self.context.data.get("attachments")
for attachment in attachments:
self._attach_file(reply_message, attachment)
self.context.email_client.send_email(reply_message)
def _attach_file(self, reply_message, attachment): def _attach_file(self, reply_message, attachment):
""" """

View File

@@ -1,6 +1,8 @@
import logging import logging
import pandas as pd import pandas as pd
from io import BytesIO 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.handlers.order_position import OrderPosition
from mail_order_bot.task_processor.abstract_task import AbstractTask from mail_order_bot.task_processor.abstract_task import AbstractTask
@@ -22,12 +24,18 @@ class ExcelExtractor(AbstractTask):
# todo сделать проверку на наличие файла и его тип # todo сделать проверку на наличие файла и его тип
attachments = self.context.data.get("attachments", []) attachments = self.context.data.get("attachments", [])
for attachment in attachments: for attachment in attachments:
file_bytes = BytesIO(attachment['bytes']) try:
file_bytes = BytesIO(attachment['bytes'])
excel_file = ExcelFileParcer(file_bytes, self.excel_config) excel_file = ExcelFileParcer(file_bytes, self.excel_config)
attachment["excel"] = excel_file
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 os
import yaml import yaml
import logging import logging
@@ -9,9 +41,10 @@ from mail_order_bot.email_client.utils import EmailUtils
from enum import Enum from enum import Enum
from mail_order_bot.task_processor.handlers import * 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.task_processor.handlers.email.email_parcer import EmailParcer
from mail_order_bot import MailOrderBotException
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -38,34 +71,26 @@ class TaskProcessor:
self.context.clear() self.context.clear()
self.context.data["email"] = email self.context.data["email"] = email
# Парсинг письма
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
try: try:
# Парсинг письма
email_parcer = EmailParcer()
email_parcer.do()
email_sender = self.context.data.get("email_from")
# Определить конфиг для пайплайна # Определить конфиг для пайплайна
config = self._load_config(email_from) config = self._load_config(email_sender)
self.context.data["config"] = config self.context.data["config"] = config
# Запустить обработку пайплайна # Запустить обработку пайплайна
pipeline = config["pipeline"] pipeline = config["pipeline"]
for stage in pipeline: for handler_name in pipeline:
handler_name = stage
logger.info(f"Processing handler: {handler_name}") logger.info(f"Processing handler: {handler_name}")
task = globals()[handler_name]() task = globals()[handler_name]()
task.do() task.do()
except FileNotFoundError: except Exception as e:
logger.error(f"Конфиг для клиента {email_from} не найден") logger.error(f"Произошла ошибка: {e}")
for attachment in self.context.data["attachments"]:
print(attachment["order"].__dict__)
#except Exception as e:
# logger.error(f"Произошла другая ошибка: {e}")
def _load_config(self, email_from) -> Dict[str, Any]: def _load_config(self, email_from) -> Dict[str, Any]:
@@ -73,11 +98,8 @@ class TaskProcessor:
return self.config[email_from] return self.config[email_from]
email_from_domain = EmailUtils.extract_domain(email_from) email_from_domain = EmailUtils.extract_domain(email_from)
if email_from_domain in self.config: if email_from_domain in self.config:
return self.config[email_from_domain] return self.config[email_from_domain]
raise FileNotFoundError 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)