diff --git a/kairon/__init__.py b/kairon/__init__.py index 7fcd49789..7f04b40b1 100644 --- a/kairon/__init__.py +++ b/kairon/__init__.py @@ -44,7 +44,8 @@ def create_argument_parser(): - from kairon.cli import importer, training, testing, conversations_deletion, translator, delete_logs, message_broadcast,content_importer + from kairon.cli import importer, training, testing, conversations_deletion, translator, delete_logs,\ + message_broadcast,content_importer, mail_channel_process, mail_channel_read parser = ArgumentParser( prog="kairon", @@ -62,6 +63,8 @@ def create_argument_parser(): delete_logs.add_subparser(subparsers, parents=parent_parsers) message_broadcast.add_subparser(subparsers, parents=parent_parsers) content_importer.add_subparser(subparsers, parents=parent_parsers) + mail_channel_process.add_subparser(subparsers, parents=parent_parsers) + mail_channel_read.add_subparser(subparsers, parents=parent_parsers) return parser diff --git a/kairon/api/app/routers/bot/bot.py b/kairon/api/app/routers/bot/bot.py index c2e2c8590..1e1580598 100644 --- a/kairon/api/app/routers/bot/bot.py +++ b/kairon/api/app/routers/bot/bot.py @@ -25,6 +25,7 @@ from kairon.shared.account.activity_log import UserActivityLogger from kairon.shared.actions.data_objects import ActionServerLogs from kairon.shared.auth import Authentication +from kairon.shared.channels.mail.processor import MailProcessor from kairon.shared.constants import TESTER_ACCESS, DESIGNER_ACCESS, CHAT_ACCESS, UserActivityType, ADMIN_ACCESS, \ EventClass, AGENT_ACCESS from kairon.shared.content_importer.content_processor import ContentImporterLogProcessor @@ -1658,3 +1659,14 @@ async def get_slot_actions( llm_models = MongoProcessor.get_slot_mapped_actions(current_user.get_bot(), slot_name) return Response(data=llm_models) + + +@router.get("/mail_channel/logs", response_model=Response) +async def get_mail_channel_logs(start_idx: int = 0, page_size: int = 10, + current_user: User = Security(Authentication.get_current_user_and_bot, + scopes=TESTER_ACCESS)): + """ + Retrieves mail channel related logs for the bot. + """ + data = MailProcessor.get_log(current_user.get_bot(), start_idx, page_size) + return Response(data=data) diff --git a/kairon/chat/agent_processor.py b/kairon/chat/agent_processor.py index a88f27614..78c083bf3 100644 --- a/kairon/chat/agent_processor.py +++ b/kairon/chat/agent_processor.py @@ -78,3 +78,22 @@ async def handle_channel_message(bot: Text, userdata: UserMessage): if not is_live_agent_enabled: return await AgentProcessor.get_agent(bot).handle_message(userdata) return await LiveAgentHandler.process_live_agent(bot, userdata) + + @staticmethod + def get_agent_without_cache(bot: str, use_store: bool = True) -> Agent: + endpoint = AgentProcessor.mongo_processor.get_endpoints( + bot, raise_exception=False + ) + action_endpoint = Utility.get_action_url(endpoint) + model_path = Utility.get_latest_model(bot) + domain = AgentProcessor.mongo_processor.load_domain(bot) + if use_store: + mongo_store = Utility.get_local_mongo_store(bot, domain) + lock_store_endpoint = Utility.get_lock_store(bot) + agent = KaironAgent.load(model_path, action_endpoint=action_endpoint, tracker_store=mongo_store, + lock_store=lock_store_endpoint) + else: + agent = KaironAgent.load(model_path, action_endpoint=action_endpoint) + + agent.model_ver = model_path.split("/")[-1] + return agent diff --git a/kairon/chat/utils.py b/kairon/chat/utils.py index 6c7c7a51c..c0e909355 100644 --- a/kairon/chat/utils.py +++ b/kairon/chat/utils.py @@ -43,6 +43,37 @@ async def chat( ) return chat_response + @staticmethod + async def process_messages_via_bot( + messages: [str], + account: int, + bot: str, + user: str, + is_integration_user: bool = False, + metadata: Dict = None, + ): + """Process a list of messages through the bot. + Args: + messages: List of messages to process + account: Account ID + bot: Bot ID + user: User ID + is_integration_user: Flag indicating if user is integration user + metadata: Additional metadata + + Returns: + List of responses from the bot + """ + responses = [] + uncached_model = AgentProcessor.get_agent_without_cache(bot, False) + metadata = ChatUtils.get_metadata(account, bot, is_integration_user, metadata) + for message in messages: + msg = UserMessage(message, sender_id=user, metadata=metadata) + chat_response = await uncached_model.handle_message(msg) + responses.append(chat_response) + return responses + + @staticmethod def reload(bot: Text, user: Text): exc = None diff --git a/kairon/cli/mail_channel_process.py b/kairon/cli/mail_channel_process.py new file mode 100644 index 000000000..21a734b32 --- /dev/null +++ b/kairon/cli/mail_channel_process.py @@ -0,0 +1,36 @@ +import json +from argparse import ArgumentParser, ArgumentDefaultsHelpFormatter +from typing import List +from rasa.cli import SubParsersAction + +from kairon.events.definitions.mail_channel import MailProcessEvent + + +def process_channel_mails(args): + mails = json.loads(args.mails) + if not isinstance(mails, list): + raise ValueError("Mails should be a list") + MailProcessEvent(args.bot, args.user).execute(mails=mails) + + +def add_subparser(subparsers: SubParsersAction, parents: List[ArgumentParser]): + mail_parser = subparsers.add_parser( + "mail-channel-process", + conflict_handler="resolve", + formatter_class=ArgumentDefaultsHelpFormatter, + parents=parents, + help="Mail channel process mails" + ) + mail_parser.add_argument('bot', + type=str, + help="Bot id for which command is executed", action='store') + + mail_parser.add_argument('user', + type=str, + help="Kairon user who is initiating the command", action='store') + + mail_parser.add_argument('mails', + type=str, + help="json representing List of mails to be processed", action='store') + + mail_parser.set_defaults(func=process_channel_mails) \ No newline at end of file diff --git a/kairon/cli/mail_channel_read.py b/kairon/cli/mail_channel_read.py new file mode 100644 index 000000000..2ee345955 --- /dev/null +++ b/kairon/cli/mail_channel_read.py @@ -0,0 +1,28 @@ +from argparse import ArgumentParser, ArgumentDefaultsHelpFormatter +from typing import List +from rasa.cli import SubParsersAction + +from kairon.events.definitions.mail_channel import MailReadEvent + + +def read_channel_mails(args): + MailReadEvent(args.bot, args.user).execute() + + +def add_subparser(subparsers: SubParsersAction, parents: List[ArgumentParser]): + mail_parser = subparsers.add_parser( + "mail-channel-read", + conflict_handler="resolve", + formatter_class=ArgumentDefaultsHelpFormatter, + parents=parents, + help="Mail channel initiate reading" + ) + mail_parser.add_argument('bot', + type=str, + help="Bot id for which command is executed", action='store') + + mail_parser.add_argument('user', + type=str, + help="Kairon user who is initiating the command", action='store') + + mail_parser.set_defaults(func=read_channel_mails) \ No newline at end of file diff --git a/kairon/events/definitions/factory.py b/kairon/events/definitions/factory.py index 9ff4128f0..31adda787 100644 --- a/kairon/events/definitions/factory.py +++ b/kairon/events/definitions/factory.py @@ -2,6 +2,7 @@ from kairon.events.definitions.data_importer import TrainingDataImporterEvent from kairon.events.definitions.faq_importer import FaqDataImporterEvent from kairon.events.definitions.history_delete import DeleteHistoryEvent +from kairon.events.definitions.mail_channel import MailProcessEvent, MailReadEvent from kairon.events.definitions.message_broadcast import MessageBroadcastEvent from kairon.events.definitions.model_testing import ModelTestingEvent from kairon.events.definitions.model_training import ModelTrainingEvent @@ -20,7 +21,9 @@ class EventFactory: EventClass.multilingual: MultilingualEvent, EventClass.faq_importer: FaqDataImporterEvent, EventClass.message_broadcast: MessageBroadcastEvent, - EventClass.content_importer: DocContentImporterEvent + EventClass.content_importer: DocContentImporterEvent, + EventClass.mail_channel_read_mails: MailReadEvent, + EventClass.mail_channel_process_mails: MailProcessEvent } @staticmethod diff --git a/kairon/events/definitions/mail_channel.py b/kairon/events/definitions/mail_channel.py new file mode 100644 index 000000000..61b09fd7e --- /dev/null +++ b/kairon/events/definitions/mail_channel.py @@ -0,0 +1,98 @@ +from typing import Text +from loguru import logger +from kairon import Utility +from kairon.events.definitions.base import EventsBase +from kairon.exceptions import AppException +from kairon.shared.channels.mail.processor import MailProcessor +from kairon.shared.constants import EventClass + + +class MailProcessEvent(EventsBase): + """ + Event to start mail channel scheduler if not already running. + """ + + def __init__(self, bot: Text, user: Text, **kwargs): + """ + Initialise event. + """ + self.bot = bot + self.user = user + + def validate(self): + """ + validate mail channel exists + """ + return MailProcessor.validate_smtp_connection(self.bot) + + + def enqueue(self, **kwargs): + """ + Send event to event server. + """ + try: + mails: list = kwargs.get('mails', []) + payload = {'bot': self.bot, 'user': self.user, 'mails': mails} + Utility.request_event_server(EventClass.mail_channel_process_mails, payload) + except Exception as e: + logger.error(str(e)) + raise AppException(e) + + def execute(self, **kwargs): + """ + Execute the event. + """ + try: + mails = kwargs.get('mails') + if mails: + MailProcessor.process_message_task(self.bot, mails) + except Exception as e: + logger.error(str(e)) + raise AppException(e) + + + +class MailReadEvent(EventsBase): + """ + Event to read mails from mail channel and create events for each mail tp process them via bot + """ + + def __init__(self, bot: Text, user: Text, **kwargs): + """ + Initialise event. + """ + self.bot = bot + self.user = user + + def validate(self): + """ + validate mail channel exists + """ + return MailProcessor.validate_imap_connection(self.bot) + + def enqueue(self, **kwargs): + """ + Send event to event server. + """ + try: + payload = {'bot': self.bot, 'user': self.user} + Utility.request_event_server(EventClass.mail_channel_read_mails, payload) + except Exception as e: + logger.error(str(e)) + raise AppException(e) + + def execute(self, **kwargs): + """ + Execute the event. + """ + try: + vals = MailProcessor.read_mails(self.bot) + print(vals) + emails, _, _ = vals + for email in emails: + ev = MailProcessEvent(self.bot, self.user) + ev.validate() + ev.enqueue(mails=[email]) + + except Exception as e: + raise AppException(f"Failed to schedule mail reading for bot {self.bot}. Error: {str(e)}") diff --git a/kairon/events/server.py b/kairon/events/server.py index 344e8528f..781c5c2fc 100644 --- a/kairon/events/server.py +++ b/kairon/events/server.py @@ -56,6 +56,7 @@ async def lifespan(app: FastAPI): """ MongoDB is connected on the bot trainer startup """ config: dict = Utility.mongoengine_connection(Utility.environment['database']["url"]) connect(**config) + EventUtility.reschedule_all_bots_channel_mail_reading() yield disconnect() @@ -144,3 +145,9 @@ def delete_scheduled_event(event_id: Text = Path(description="Event id")): def dispatch_scheduled_event(event_id: Text = Path(description="Event id")): KScheduler().dispatch_event(event_id) return {"data": None, "message": "Scheduled event dispatch!"} + + +@app.get('/api/mail/schedule/{bot}', response_model=Response) +def request_epoch(bot: Text = Path(description="Bot id")): + EventUtility.schedule_channel_mail_reading(bot) + return {"data": None, "message": "Mail scheduler epoch request!"} \ No newline at end of file diff --git a/kairon/events/utility.py b/kairon/events/utility.py index 2d9202b36..1d4f0be24 100644 --- a/kairon/events/utility.py +++ b/kairon/events/utility.py @@ -1,9 +1,14 @@ from typing import Dict, Text +from uuid6 import uuid7 + from kairon.events.executors.factory import ExecutorFactory from kairon.events.scheduler.kscheduler import KScheduler from kairon.exceptions import AppException +from kairon.shared.chat.data_objects import Channels +from kairon.shared.constants import EventClass, ChannelTypes from kairon.shared.data.constant import TASK_TYPE +from loguru import logger class EventUtility: @@ -32,3 +37,36 @@ def update_job(event_type: Text, request_data: Dict, is_scheduled: bool): KScheduler().update_job(event_class=event_type, event_id=event_id, task_type=TASK_TYPE.EVENT.value, **request_data) return None, 'Scheduled event updated!' + + @staticmethod + def schedule_channel_mail_reading(bot: str): + from kairon.shared.channels.mail.processor import MailProcessor + + try: + mail_processor = MailProcessor(bot) + interval = mail_processor.config.get("interval", 60) + event_id = mail_processor.state.event_id + if event_id: + KScheduler().update_job(event_id, + TASK_TYPE.EVENT, + f"*/{interval} * * * *", + EventClass.mail_channel_read_mails, {"bot": bot, "user": mail_processor.bot_settings.user}) + else: + event_id = uuid7().hex + mail_processor.update_event_id(event_id) + KScheduler().add_job(event_id, + TASK_TYPE.EVENT, + f"*/{interval} * * * *", + EventClass.mail_channel_read_mails, {"bot": bot, "user": mail_processor.bot_settings.user}) + except Exception as e: + raise AppException(f"Failed to schedule mail reading for bot {bot}. Error: {str(e)}") + + @staticmethod + def reschedule_all_bots_channel_mail_reading(): + try: + bots = list(Channels.objects(connector_type= ChannelTypes.MAIL.value).distinct("bot")) + for bot in bots: + logger.info(f"Rescheduling mail reading for bot {bot}") + EventUtility.schedule_channel_mail_reading(bot) + except Exception as e: + raise AppException(f"Failed to reschedule mail reading events. Error: {str(e)}") \ No newline at end of file diff --git a/kairon/shared/channels/mail/__init__.py b/kairon/shared/channels/mail/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/kairon/shared/channels/mail/constants.py b/kairon/shared/channels/mail/constants.py new file mode 100644 index 000000000..737448543 --- /dev/null +++ b/kairon/shared/channels/mail/constants.py @@ -0,0 +1,11 @@ + + +class MailConstants: + DEFAULT_SMTP_SERVER = 'smtp.gmail.com' + DEFAULT_IMAP_SERVER = 'imap.gmail.com' + DEFAULT_SMTP_PORT = 587 + + DEFAULT_TEMPLATE = "

{bot_response}



Generated by kAIron AI.\n" + + PROCESS_MESSAGE_BATCH_SIZE = 4 + diff --git a/kairon/shared/channels/mail/data_objects.py b/kairon/shared/channels/mail/data_objects.py new file mode 100644 index 000000000..ab387e0eb --- /dev/null +++ b/kairon/shared/channels/mail/data_objects.py @@ -0,0 +1,44 @@ +import time +from enum import Enum + +from mongoengine import Document, StringField, ListField, FloatField, DictField, IntField +from kairon.shared.data.audit.data_objects import Auditlog + + + +class MailChannelStateData(Document): + event_id = StringField() + last_email_uid = IntField(default=0) + bot = StringField(required=True) + timestamp = FloatField(default=time.time()) + + meta = {"indexes": ["bot"]} + + def save(self, *args, **kwargs): + self.timestamp = time.time() + super(MailChannelStateData, self).save(*args, **kwargs) + +class MailStatus(Enum): + Processing = "processing" + SUCCESS = "success" + FAILED = "failed" + +class MailResponseLog(Auditlog): + """ + Mail response log + """ + sender_id = StringField(required=True) + subject = StringField() + body = StringField() + responses = ListField() + slots = DictField() + bot = StringField(required=True) + user = StringField(required=True) + timestamp = FloatField(required=True) + status = StringField(required=True, default=MailStatus.Processing.value) + + meta = {"indexes": ["bot"]} + + def save(self, *args, **kwargs): + self.timestamp = time.time() + super(MailResponseLog, self).save(*args, **kwargs) diff --git a/kairon/shared/channels/mail/processor.py b/kairon/shared/channels/mail/processor.py new file mode 100644 index 000000000..f68f286bb --- /dev/null +++ b/kairon/shared/channels/mail/processor.py @@ -0,0 +1,298 @@ +import asyncio +import time + +from loguru import logger +from pydantic.schema import timedelta +from pydantic.validators import datetime +from imap_tools import MailBox, AND +from kairon.exceptions import AppException +from kairon.shared.account.data_objects import Bot +from kairon.shared.channels.mail.constants import MailConstants +from kairon.shared.channels.mail.data_objects import MailResponseLog, MailStatus, MailChannelStateData +from kairon.shared.chat.processor import ChatDataProcessor +from kairon.shared.constants import ChannelTypes +from kairon.shared.data.data_objects import BotSettings +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +import smtplib + + + +class MailProcessor: + def __init__(self, bot): + self.bot = bot + self.config = ChatDataProcessor.get_channel_config(ChannelTypes.MAIL, bot, False)['config'] + self.intent = self.config.get('intent') + self.mail_template = self.config.get('mail_template', MailConstants.DEFAULT_TEMPLATE) + self.bot_settings = BotSettings.objects(bot=self.bot).get() + self.state = MailProcessor.get_mail_channel_state_data(bot) + bot_info = Bot.objects.get(id=bot) + self.account = bot_info.account + self.mailbox = None + self.smtp = None + + def update_event_id(self, event_id): + self.state.event_id = event_id + self.state.save() + + @staticmethod + def get_mail_channel_state_data(bot): + """ + Get mail channel state data + """ + try: + state = MailChannelStateData.objects(bot=bot).first() + if not state: + state = MailChannelStateData(bot=bot) + state.save() + return state + except Exception as e: + raise AppException(str(e)) + + def login_imap(self): + if self.mailbox: + return + email_account = self.config['email_account'] + email_password = self.config['email_password'] + imap_server = self.config.get('imap_server', MailConstants.DEFAULT_IMAP_SERVER) + self.mailbox = MailBox(imap_server).login(email_account, email_password) + + def logout_imap(self): + if self.mailbox: + self.mailbox.logout() + self.mailbox = None + + def login_smtp(self): + if self.smtp: + return + email_account = self.config['email_account'] + email_password = self.config['email_password'] + smtp_server = self.config.get('smtp_server', MailConstants.DEFAULT_SMTP_SERVER) + smtp_port = self.config.get('smtp_port', MailConstants.DEFAULT_SMTP_PORT) + smtp_port = int(smtp_port) + self.smtp = smtplib.SMTP(smtp_server, smtp_port, timeout=30) + self.smtp.starttls() + self.smtp.login(email_account, email_password) + + def logout_smtp(self): + if self.smtp: + self.smtp.quit() + self.smtp = None + + + @staticmethod + def validate_smtp_connection(bot): + try: + mp = MailProcessor(bot) + mp.login_smtp() + mp.logout_smtp() + return True + except Exception as e: + logger.error(str(e)) + return False + + @staticmethod + def validate_imap_connection(bot): + try: + mp = MailProcessor(bot) + mp.login_imap() + mp.logout_imap() + return True + except Exception as e: + logger.error(str(e)) + return False + + async def send_mail(self, to: str, subject: str, body: str, log_id: str): + exception = None + try: + if body and len(body) > 0: + email_account = self.config['email_account'] + msg = MIMEMultipart() + msg['From'] = email_account + msg['To'] = to + msg['Subject'] = subject + msg.attach(MIMEText(body, 'html')) + self.smtp.sendmail(email_account, to, msg.as_string()) + except Exception as e: + logger.error(f"Error sending mail to {to}: {str(e)}") + exception = str(e) + finally: + mail_log = MailResponseLog.objects.get(id=log_id) + mail_log.status = MailStatus.FAILED.value if exception else MailStatus.SUCCESS.value + if exception: + mail_log.responses.append(exception) + mail_log.save() + + def process_mail(self, rasa_chat_response: dict, log_id: str): + slots = rasa_chat_response.get('slots', []) + slots = {key.strip(): value.strip() for slot_str in slots + for split_result in [slot_str.split(":", 1)] + if len(split_result) == 2 + for key, value in [split_result]} + + responses = '

'.join(response.get('text', '') for response in rasa_chat_response.get('response', [])) + if len(responses) == 0: + return '' + slots['bot_response'] = responses + mail_template = self.mail_template + mail_log = MailResponseLog.objects.get(id=log_id) + mail_log.responses = rasa_chat_response.get('response', []) + mail_log.slots = slots + mail_log.save() + return mail_template.format(**{key: str(value) for key, value in slots.items()}) + + + @staticmethod + def get_log(bot_id: str, offset: int, limit: int) -> dict: + """ + Get logs for a bot + """ + try: + count = MailResponseLog.objects(bot=bot_id).count() + logs = MailResponseLog.objects(bot=bot_id).order_by('-timestamp').skip(offset).limit(limit) + result = [] + for log in logs: + log = log.to_mongo().to_dict() + log.pop('_id') + log.pop('bot') + log.pop('user') + result.append(log) + return { + "logs": result, + "count": count + } + except Exception as e: + raise AppException(str(e)) + + @staticmethod + async def process_messages(bot: str, batch: [dict]): + """ + Pass messages to bot and send responses + """ + try: + from kairon.chat.utils import ChatUtils + mp = MailProcessor(bot) + user_messages: [str] = [] + responses = [] + for mail in batch: + try: + entities = { + 'mail_id': mail['mail_id'], + 'subject': mail['subject'], + 'date': mail['date'], + 'body': mail['body'] + } + entities_str = ', '.join([f'"{key}": "{value}"' for key, value in entities.items() if value and value != 'null']) + user_msg = f'/{mp.intent}{{{entities_str}}}' + user_messages.append(user_msg) + subject = mail.get('subject', 'Reply') + if not subject.startswith('Re:'): + subject = f"Re: {subject}" + + responses.append({ + 'to': mail['mail_id'], + 'subject': subject, + 'body': '', + 'log_id': mail['log_id'] + }) + except Exception as e: + logger.error(str(e)) + + chat_responses = await ChatUtils.process_messages_via_bot(user_messages, + mp.account, + bot, + mp.bot_settings.user, + False, + { + 'channel': ChannelTypes.MAIL.value + }) + + for index, response in enumerate(chat_responses): + responses[index]['body'] = mp.process_mail(response, log_id=batch[index]['log_id']) + + mp.login_smtp() + tasks = [mp.send_mail(**response) for response in responses] + await asyncio.gather(*tasks) + mp.logout_smtp() + + except Exception as e: + raise AppException(str(e)) + + + @staticmethod + def process_message_task(bot: str, message_batch: [dict]): + """ + Process a batch of messages + used for execution by executor + """ + asyncio.run(MailProcessor.process_messages(bot, message_batch)) + + + @staticmethod + def read_mails(bot: str) -> ([dict], str, int): + """ + Read mails from the mailbox + Parameters: + - bot: str - bot id + Returns: + - list of messages - each message is a dict with the following + - mail_id + - subject + - date + - body + - log_id + - user + - time_shift + + """ + logger.info(f"reading mails for {bot}") + mp = MailProcessor(bot) + time_shift = int(mp.config.get('interval', 20 * 60)) + last_read_timestamp = datetime.now() - timedelta(seconds=time_shift) + messages = [] + is_logged_in = False + last_processed_uid = mp.state.last_email_uid + query = f'{int(last_processed_uid) + 1}:*' + try: + mp.login_imap() + is_logged_in = True + msgs = mp.mailbox.fetch(AND(date_gte=last_read_timestamp.date(), uid=query), mark_seen=False) + for msg in msgs: + if int(msg.uid) <= last_processed_uid: + continue + last_processed_uid = int(msg.uid) + subject = msg.subject + sender_id = msg.from_ + date = msg.date + body = msg.text or msg.html or "" + #attachments = msg.attachments + mail_log = MailResponseLog(sender_id = sender_id, + subject = subject, + body = body, + bot = bot, + user = mp.bot_settings.user, + status=MailStatus.Processing.value, + timestamp = time.time()) + mail_log.save() + message_entry = { + 'mail_id': sender_id, + 'subject': subject, + 'date': str(date), + 'body': body, + 'log_id': str(mail_log.id) + } + messages.append(message_entry) + mp.logout_imap() + + mp.state.last_email_uid = last_processed_uid + mp.state.save() + + is_logged_in = False + return messages, mp.bot_settings.user, time_shift + except Exception as e: + logger.exception(e) + if is_logged_in: + mp.logout_imap() + return [], mp.bot_settings.user, time_shift + + diff --git a/kairon/shared/channels/mail/scheduler.py b/kairon/shared/channels/mail/scheduler.py new file mode 100644 index 000000000..adff58319 --- /dev/null +++ b/kairon/shared/channels/mail/scheduler.py @@ -0,0 +1,38 @@ +from urllib.parse import urljoin + + +from kairon import Utility +from kairon.exceptions import AppException +from kairon.shared.channels.mail.processor import MailProcessor + + +class MailScheduler: + + @staticmethod + def request_epoch(bot: str): + if not MailProcessor.validate_smtp_connection(bot): + raise AppException("Failed to validate smtp connection, please revise mail channel configuration") + + if not MailProcessor.validate_imap_connection(bot): + raise AppException("Failed to validate imap connection, please revise mail channel configuration") + + event_server_url = Utility.get_event_server_url() + resp = Utility.execute_http_request( + "GET", + urljoin( + event_server_url, + f"/api/mail/schedule/{bot}", + ), + err_msg="Failed to request epoch", + ) + if not resp['success']: + raise AppException("Failed to request email channel epoch") + + + + + + + + + diff --git a/kairon/shared/chat/processor.py b/kairon/shared/chat/processor.py index 43d136e50..77fc10500 100644 --- a/kairon/shared/chat/processor.py +++ b/kairon/shared/chat/processor.py @@ -39,6 +39,9 @@ def save_channel_config(configuration: Dict, bot: Text, user: Text): channel.user = user channel.timestamp = datetime.utcnow() channel.save() + if configuration['connector_type'] == ChannelTypes.MAIL.value: + from kairon.shared.channels.mail.scheduler import MailScheduler + MailScheduler.request_epoch(bot) if primary_slack_config_changed: ChatDataProcessor.delete_channel_config(bot, connector_type="slack", config__is_primary=False) channel_endpoint = DataUtility.get_channel_endpoint(channel) diff --git a/kairon/shared/constants.py b/kairon/shared/constants.py index 3068384df..b4151ea8d 100644 --- a/kairon/shared/constants.py +++ b/kairon/shared/constants.py @@ -80,6 +80,8 @@ class EventClass(str, Enum): web_search = "web_search" scheduler_evaluator = "scheduler_evaluator" content_importer = "content_importer" + mail_channel_process_mails = "email_channel_process_mails" + mail_channel_read_mails = "email_channel_read_mails" class EventRequestType(str, Enum): @@ -115,7 +117,7 @@ class ChannelTypes(str, Enum): INSTAGRAM = "instagram" BUSINESS_MESSAGES = "business_messages" LINE = "line" - + MAIL = "mail" class ElementTypes(str, Enum): LINK = "link" @@ -156,6 +158,10 @@ class KaironSystemSlots(str, Enum): flow_reply = "flow_reply" quick_reply = "quick_reply" http_status_code = "http_status_code" + mail_id = "mail_id" + subject = "subject" + body = "body" + class VectorEmbeddingsDatabases(str, Enum): diff --git a/kairon/shared/data/data_models.py b/kairon/shared/data/data_models.py index 346e0707f..8be28e7e7 100644 --- a/kairon/shared/data/data_models.py +++ b/kairon/shared/data/data_models.py @@ -1341,3 +1341,4 @@ def validate_name(cls, values): raise ValueError("Schedule action can not be empty, it is needed to execute on schedule time") return values + diff --git a/metadata/integrations.yml b/metadata/integrations.yml index c767e5c18..d433d18f4 100644 --- a/metadata/integrations.yml +++ b/metadata/integrations.yml @@ -75,6 +75,16 @@ channels: required_fields: - channel_secret - channel_access_token + mail: + required_fields: + - email_account + - email_password + - imap_server + - smtp_server + - smtp_port + optional_fields: + - interval + - intent actions: pipedrive: diff --git a/requirements/prod.txt b/requirements/prod.txt index 504571aad..6dec99fc5 100644 --- a/requirements/prod.txt +++ b/requirements/prod.txt @@ -67,5 +67,6 @@ mongoengine-jsonschema==0.1.3 fernet==1.0.1 google-generativeai huggingface-hub==0.25.2 +imap-tools==1.7.4 more-itertools python-multipart>=0.0.18 \ No newline at end of file diff --git a/system.yaml b/system.yaml index eec7ce823..3f4596832 100644 --- a/system.yaml +++ b/system.yaml @@ -153,7 +153,7 @@ evaluator: url: ${EXPRESSION_EVALUATOR_ENDPOINT:"http://192.168.100.109:8085/evaluate"} pyscript: trigger_task: ${PYSCRIPT_TRIGGER_TASK:false} - url: ${PYSCRIPT_EVALUATOR_ENDPOINT:"http://192.168.100.109:8087/evaluate"} + url: ${PYSCRIPT_EVALUATOR_ENDPOINT:"http://localhost:8087/evaluate"} multilingual: enable: ${ENABLE_MULTILINGUAL_BOTS:false} diff --git a/tests/integration_test/action_service_test.py b/tests/integration_test/action_service_test.py index 3f4551f45..0de5a2fde 100644 --- a/tests/integration_test/action_service_test.py +++ b/tests/integration_test/action_service_test.py @@ -2,6 +2,10 @@ import datetime import os from urllib.parse import urlencode, urljoin +from kairon.shared.utils import Utility +os.environ["system_file"] = "./tests/testing_data/system.yaml" +Utility.load_environment() +Utility.load_system_metadata() import urllib import litellm @@ -11,18 +15,17 @@ import responses import ujson as json from apscheduler.util import obj_to_ref -from cycler import U from deepdiff import DeepDiff from fastapi.testclient import TestClient from jira import JIRAError from litellm import embedding from mongoengine import connect + + from kairon.events.executors.factory import ExecutorFactory from kairon.shared.callback.data_objects import CallbackConfig, encrypt_secret -from kairon.shared.utils import Utility -Utility.load_system_metadata() from kairon.actions.definitions.live_agent import ActionLiveAgent from kairon.actions.definitions.set_slot import ActionSetSlot @@ -11752,6 +11755,7 @@ def test_prompt_action_response_action_with_prompt_question_from_slot(mock_embed 'response': None, 'image': None, 'attachment': None} ] + @mock.patch.object(litellm, "aembedding", autospec=True) @mock.patch.object(ActionUtility, 'execute_request_async', autospec=True) def test_prompt_action_response_action_with_prompt_question_from_slot_perplexity(mock_execute_request_async, mock_embedding, aioresponses): diff --git a/tests/integration_test/event_service_test.py b/tests/integration_test/event_service_test.py index 46135d175..bfe20d5cc 100644 --- a/tests/integration_test/event_service_test.py +++ b/tests/integration_test/event_service_test.py @@ -533,4 +533,15 @@ def test_scheduled_event_request_dispatch(mock_dispatch_event): assert isinstance(args[1], JobEvent) assert isinstance(args[0], BackgroundScheduler) -os_patch.stop() \ No newline at end of file + +@patch('kairon.events.utility.EventUtility.schedule_channel_mail_reading') +def test_request_epoch(mock_epoch): + response = client.get('/api/mail/schedule/test_bot') + mock_epoch.assert_called_once() + assert response.status_code == 200 + resp = response.json() + assert resp['data'] is None + assert resp['success'] + +os_patch.stop() + diff --git a/tests/integration_test/services_test.py b/tests/integration_test/services_test.py index f9be29a12..bdf533494 100644 --- a/tests/integration_test/services_test.py +++ b/tests/integration_test/services_test.py @@ -4406,6 +4406,8 @@ def test_get_live_agent_after_disabled(): assert actual["success"] + + def test_callback_config_add_syntax_error(): request_body = { "name": "callback_1", @@ -4746,6 +4748,7 @@ def test_callback_action_delete(): assert actual == {'success': True, 'message': 'Callback action deleted successfully!', 'data': None, 'error_code': 0} + def test_add_pyscript_action_empty_name(): script = "bot_response='hello world'" request_body = { @@ -8295,7 +8298,7 @@ def test_list_entities_empty(): ) actual = response.json() assert actual["error_code"] == 0 - assert len(actual['data']) == 14 + assert len(actual['data']) == 17 assert actual["success"] @@ -9062,7 +9065,8 @@ def test_list_entities(): expected = {'bot', 'file', 'category', 'file_text', 'ticketid', 'file_error', 'priority', 'requested_slot', 'fdresponse', 'kairon_action_response', 'audio', 'image', 'doc_url', 'document', 'video', 'order', 'payment', 'latitude', - 'longitude', 'flow_reply', 'http_status_code', 'name', 'quick_reply'} + 'longitude', 'flow_reply', 'http_status_code', 'name', 'quick_reply', 'mail_id', + 'subject', 'body'} assert not DeepDiff({item['name'] for item in actual['data']}, expected, ignore_order=True) assert actual["success"] @@ -9700,12 +9704,12 @@ def test_get_slots(): ) actual = response.json() assert "data" in actual - assert len(actual["data"]) == 21 + assert len(actual["data"]) == 24 assert actual["success"] assert actual["error_code"] == 0 assert Utility.check_empty_string(actual["message"]) default_slots_count = sum(slot.get('is_default') for slot in actual["data"]) - assert default_slots_count == 14 + assert default_slots_count == 17 def test_add_slots(): @@ -23862,6 +23866,9 @@ def test_add_channel_config_error(): ) + + + def test_add_bot_with_template_name(monkeypatch): from kairon.shared.admin.data_objects import BotSecrets diff --git a/tests/testing_data/system.yaml b/tests/testing_data/system.yaml index 25ad50573..5baaf8daf 100644 --- a/tests/testing_data/system.yaml +++ b/tests/testing_data/system.yaml @@ -121,6 +121,7 @@ events: type: ${EVENTS_QUEUE_TYPE:"mongo"} url: ${EVENTS_QUEUE_URL:"mongodb://localhost:27017/events"} name: ${EVENTS_DB_NAME:"kairon_events"} + mail_queue_name: ${EVENTS_MAIL_QUEUE_NAME:"mail_queue"} task_definition: model_training: ${MODEL_TRAINING_TASK_DEFINITION} model_testing: ${MODEL_TESTING_TASK_DEFINITION} @@ -140,6 +141,7 @@ events: - bot scheduler: collection: ${EVENT_SCHEDULER_COLLECTION:"kscheduler"} + mail_scheduler_collection: ${MAIL_SCHEDULER_COLLECTION:"mail_scheduler"} type: ${EVENT_SCHEDULER_TYPE:"kscheduler"} min_trigger_interval: ${MIN_SCHDULER_TRIGGER_INTERVAL:86340} diff --git a/tests/unit_test/channels/mail_channel_test.py b/tests/unit_test/channels/mail_channel_test.py new file mode 100644 index 000000000..c4cd2f127 --- /dev/null +++ b/tests/unit_test/channels/mail_channel_test.py @@ -0,0 +1,547 @@ +import asyncio +import os +from unittest.mock import patch, MagicMock + +import pytest +from imap_tools import MailMessage + +from mongoengine import connect, disconnect +from uuid6 import uuid7 + +from kairon import Utility +from kairon.shared.channels.mail.data_objects import MailResponseLog, MailChannelStateData, MailStatus + +os.environ["system_file"] = "./tests/testing_data/system.yaml" +Utility.load_environment() +Utility.load_system_metadata() + +from kairon.shared.account.data_objects import Bot, Account +from kairon.shared.channels.mail.constants import MailConstants +from kairon.shared.channels.mail.processor import MailProcessor +from kairon.shared.chat.data_objects import Channels +from kairon.shared.data.data_objects import BotSettings + +from kairon.exceptions import AppException +from kairon.shared.constants import ChannelTypes + + +bot_data_created = False + + +class TestMailChannel: + @pytest.fixture(autouse=True, scope='class') + def setup(self): + global bot_data_created + connect(**Utility.mongoengine_connection(Utility.environment['database']["url"])) + a = Account.objects.create(name="mail_channel_test_user_acc", user="mail_channel_test_user_acc") + bot = Bot.objects.create(name="mail_channel_test_bot", user="mail_channel_test_user_acc", status=True, + account=a.id) + pytest.mail_test_bot = str(bot.id) + BotSettings(bot=pytest.mail_test_bot, user="mail_channel_test_user_acc").save() + yield + + BotSettings.objects(user="mail_channel_test_user_acc").delete() + Bot.objects(user="mail_channel_test_user_acc").delete() + Account.objects(user="mail_channel_test_user_acc").delete() + Channels.objects(connector_type=ChannelTypes.MAIL.value).delete() + + + disconnect() + + + + + @patch("kairon.shared.channels.mail.processor.MailBox") + @patch("kairon.shared.chat.processor.ChatDataProcessor.get_channel_config") + @patch("kairon.shared.utils.Utility.execute_http_request") + def test_login_imap(self, execute_http_req, mock_get_channel_config, mock_mailbox): + execute_http_req.return_value = {"success": True} + mock_mailbox_instance = MagicMock() + mock_mailbox.return_value = mock_mailbox_instance + mock_mailbox_instance.login.return_value = ("OK", ["Logged in"]) + mock_mailbox_instance._simple_command.return_value = ("OK", ["Logged in"]) + mock_mailbox_instance.select.return_value = ("OK", ["INBOX"]) + + mock_get_channel_config.return_value = { + 'config': { + 'email_account': "mail_channel_test_user_acc@testuser.com", + 'email_password': "password", + 'imap_server': "imap.testuser.com" + } + } + + bot_id = pytest.mail_test_bot + mp = MailProcessor(bot=bot_id) + mp.login_imap() + + mock_get_channel_config.assert_called_once_with(ChannelTypes.MAIL, bot_id, False) + mock_mailbox.assert_called_once_with("imap.testuser.com") + mock_mailbox_instance.login.assert_called_once_with("mail_channel_test_user_acc@testuser.com", "password") + + + + @patch("kairon.shared.channels.mail.processor.MailBox") + @patch("kairon.shared.chat.processor.ChatDataProcessor.get_channel_config") + @patch("kairon.shared.utils.Utility.execute_http_request") + def test_login_imap_logout(self,execute_http_request, mock_get_channel_config, mock_mailbox): + execute_http_request.return_value = {"success": True} + mock_mailbox_instance = MagicMock() + mock_mailbox.return_value = mock_mailbox_instance + mock_mailbox_instance.login.return_value = mock_mailbox_instance # Ensure login returns the instance + mock_mailbox_instance._simple_command.return_value = ("OK", ["Logged in"]) + mock_mailbox_instance.select.return_value = ("OK", ["INBOX"]) + + + mock_get_channel_config.return_value = { + 'config': { + 'email_account': "mail_channel_test_user_acc@testuser.com", + 'email_password': "password", + 'imap_server': "imap.testuser.com" + } + } + + bot_id = pytest.mail_test_bot + mp = MailProcessor(bot=bot_id) + + mp.login_imap() + mp.logout_imap() + + mock_mailbox_instance.logout.assert_called_once() + + + @patch("kairon.shared.channels.mail.processor.smtplib.SMTP") + @patch("kairon.shared.chat.processor.ChatDataProcessor.get_channel_config") + def test_login_smtp(self, mock_get_channel_config, mock_smtp): + # Arrange + mock_smtp_instance = MagicMock() + mock_smtp.return_value = mock_smtp_instance + + mock_get_channel_config.return_value = { + 'config': { + 'email_account': "mail_channel_test_user_acc@testuser.com", + 'email_password': "password", + 'smtp_server': "smtp.testuser.com", + 'smtp_port': 587 + } + } + + bot_id = pytest.mail_test_bot + mp = MailProcessor(bot=bot_id) + + mp.login_smtp() + + mock_get_channel_config.assert_called_once_with(ChannelTypes.MAIL, bot_id, False) + mock_smtp.assert_called_once_with("smtp.testuser.com", 587, timeout=30) + mock_smtp_instance.starttls.assert_called_once() + mock_smtp_instance.login.assert_called_once_with("mail_channel_test_user_acc@testuser.com", "password") + + + @patch("kairon.shared.channels.mail.processor.smtplib.SMTP") + @patch("kairon.shared.chat.processor.ChatDataProcessor.get_channel_config") + def test_logout_smtp(self, mock_get_channel_config, mock_smtp): + mock_smtp_instance = MagicMock() + mock_smtp.return_value = mock_smtp_instance + + mock_get_channel_config.return_value = { + 'config': { + 'email_account': "mail_channel_test_user_acc@testuser.com", + 'email_password': "password", + 'smtp_server': "smtp.testuser.com", + 'smtp_port': 587 + } + } + + bot_id = pytest.mail_test_bot + mp = MailProcessor(bot=bot_id) + + mp.login_smtp() + mp.logout_smtp() + + mock_smtp_instance.quit.assert_called_once() + assert mp.smtp is None + + + + @patch("kairon.shared.channels.mail.processor.smtplib.SMTP") + @patch("kairon.shared.chat.processor.ChatDataProcessor.get_channel_config") + @pytest.mark.asyncio + async def test_send_mail(self, mock_get_channel_config, mock_smtp): + mock_smtp_instance = MagicMock() + mock_smtp.return_value = mock_smtp_instance + + mail_response_log = MailResponseLog(bot=pytest.mail_test_bot, + sender_id="recipient@test.com", + user="mail_channel_test_user_acc", + subject="Test Subject", + body="Test Body", + ) + mail_response_log.save() + mail_response_log.save() + + mock_get_channel_config.return_value = { + 'config': { + 'email_account': "mail_channel_test_user_acc@testuser.com", + 'email_password': "password", + 'smtp_server': "smtp.testuser.com", + 'smtp_port': 587 + } + } + bot_id = pytest.mail_test_bot + mp = MailProcessor(bot=bot_id) + mp.login_smtp() + + await mp.send_mail("recipient@test.com", "Test Subject", "Test Body", mail_response_log.id) + + MailResponseLog.objects().delete() + + mock_smtp_instance.sendmail.assert_called_once() + assert mock_smtp_instance.sendmail.call_args[0][0] == "mail_channel_test_user_acc@testuser.com" + assert mock_smtp_instance.sendmail.call_args[0][1] == "recipient@test.com" + assert "Test Subject" in mock_smtp_instance.sendmail.call_args[0][2] + assert "Test Body" in mock_smtp_instance.sendmail.call_args[0][2] + + @patch("kairon.shared.channels.mail.processor.smtplib.SMTP") + @patch("kairon.shared.chat.processor.ChatDataProcessor.get_channel_config") + @pytest.mark.asyncio + async def test_send_mail_exception(self, mock_get_channel_config, mock_smtp): + mock_smtp_instance = MagicMock() + mock_smtp.return_value = mock_smtp_instance + + mail_response_log = MailResponseLog(bot=pytest.mail_test_bot, + sender_id="recipient@test.com", + user="mail_channel_test_user_acc", + subject="Test Subject", + body="Test Body", + ) + mail_response_log.save() + + mock_get_channel_config.return_value = { + 'config': { + 'email_account': "mail_channel_test_user_acc@testuser.com", + 'email_password': "password", + 'smtp_server': "smtp.testuser.com", + 'smtp_port': 587 + } + } + + bot_id = pytest.mail_test_bot + mp = MailProcessor(bot=bot_id) + mp.login_smtp() + + mock_smtp_instance.sendmail.side_effect = Exception("SMTP error") + + await mp.send_mail("recipient@test.com", "Test Subject", "Test Body", mail_response_log.id) + + log = MailResponseLog.objects.get(id=mail_response_log.id) + print(log.to_mongo()) + assert log.status == MailStatus.FAILED.value + assert log.responses == ['SMTP error'] + MailResponseLog.objects().delete() + + + + @patch("kairon.shared.channels.mail.processor.ChatDataProcessor.get_channel_config") + def test_process_mail(self, mock_get_channel_config): + mock_get_channel_config.return_value = { + 'config': { + 'email_account': "mail_channel_test_user_acc@testuser.com", + 'email_password': "password", + 'imap_server': "imap.testuser.com" + } + } + + mail_response_log = MailResponseLog(bot=pytest.mail_test_bot, + sender_id="recipient@test.com", + user="mail_channel_test_user_acc", + subject="Test Subject", + body="Test Body", + ) + mail_response_log.save() + + bot_id = pytest.mail_test_bot + mp = MailProcessor(bot=bot_id) + + rasa_chat_response = { + "slots": ["name: John Doe"], + "response": [{"text": "How can I help you today?"}] + } + result = mp.process_mail( rasa_chat_response, mail_response_log.id) + assert result == MailConstants.DEFAULT_TEMPLATE.format(bot_response="How can I help you today?") + + + rasa_chat_response = { + "slots": ["name: John Doe"], + "response": [{"text": "How can I help you today?"}] + } + mp.mail_template = "Hello {name}, {bot_response}" + result = mp.process_mail(rasa_chat_response, mail_response_log.id) + MailResponseLog.objects().delete() + assert result == "Hello John Doe, How can I help you today?" + + + @patch("kairon.shared.channels.mail.processor.MailProcessor.logout_imap") + @patch("kairon.shared.channels.mail.processor.MailProcessor.process_message_task") + @patch("kairon.shared.channels.mail.processor.MailBox") + @patch("kairon.shared.chat.processor.ChatDataProcessor.get_channel_config") + @pytest.mark.asyncio + async def test_read_mails(self, mock_get_channel_config, + mock_mailbox, mock_process_message_task, + mock_logout_imap): + bot_id = pytest.mail_test_bot + + mock_get_channel_config.return_value = { + 'config': { + 'email_account': "mail_channel_test_user_acc@testuser.com", + 'email_password': "password", + 'imap_server': "imap.testuser.com", + } + } + + + mock_mailbox_instance = MagicMock() + mock_mailbox.return_value = mock_mailbox_instance + + mock_mail_message = MagicMock(spec=MailMessage) + mock_mail_message.subject = "Test Subject" + mock_mail_message.from_ = "test@example.com" + mock_mail_message.date = "2023-10-10" + mock_mail_message.text = "Test Body" + mock_mail_message.html = None + + mock_mailbox_instance.login.return_value = mock_mailbox_instance + mock_mailbox_instance.fetch.return_value = [mock_mail_message] + + mails, user, time_shift = MailProcessor.read_mails(bot_id) + print(mails) + assert len(mails) == 1 + assert mails[0]["subject"] == "Test Subject" + assert mails[0]["mail_id"] == "test@example.com" + assert mails[0]["date"] == "2023-10-10" + assert mails[0]["body"] == "Test Body" + assert user == 'mail_channel_test_user_acc' + assert time_shift == 1200 + + + + + @patch("kairon.shared.channels.mail.processor.MailProcessor.logout_imap") + @patch("kairon.shared.channels.mail.processor.MailProcessor.process_message_task") + @patch("kairon.shared.channels.mail.processor.MailBox") + @patch("kairon.shared.chat.processor.ChatDataProcessor.get_channel_config") + @pytest.mark.asyncio + async def test_read_mails_no_messages(self, mock_get_channel_config, + mock_mailbox, mock_process_message_task, + mock_logout_imap): + bot_id = pytest.mail_test_bot + + mock_get_channel_config.return_value = { + 'config': { + 'email_account': "mail_channel_test_user_acc@testuser.com", + 'email_password': "password", + 'imap_server': "imap.testuser.com", + } + } + + + mock_mailbox_instance = MagicMock() + mock_mailbox.return_value = mock_mailbox_instance + + mock_mailbox_instance.login.return_value = mock_mailbox_instance + mock_mailbox_instance.fetch.return_value = [] + + mails, user, time_shift = MailProcessor.read_mails(bot_id) + assert len(mails) == 0 + assert user == 'mail_channel_test_user_acc' + assert time_shift == 1200 + + mock_logout_imap.assert_called_once() + + + + @patch("kairon.shared.chat.processor.ChatDataProcessor.get_channel_config") + @patch("kairon.shared.channels.mail.processor.MailProcessor.login_smtp") + @patch("kairon.shared.channels.mail.processor.MailProcessor.logout_smtp") + @patch("kairon.shared.channels.mail.processor.MailProcessor.send_mail") + @patch("kairon.chat.utils.ChatUtils.process_messages_via_bot") + @pytest.mark.asyncio + async def test_process_messages(self, mock_process_messages_via_bot, mock_send_mail, mock_logout_smtp, mock_login_smtp, mock_get_channel_config): + + mail_response_log = MailResponseLog(bot=pytest.mail_test_bot, + sender_id="recipient@test.com", + user="mail_channel_test_user_acc", + subject="Test Subject", + body="Test Body", + ) + mail_response_log.save() + + + mock_get_channel_config.return_value = { + 'config': { + 'email_account': "mail_channel_test_user_acc@testuser.com", + 'email_password': "password", + 'imap_server': "imap.testuser.com", + } + } + + bot = pytest.mail_test_bot + batch = [{"mail_id": "test@example.com", "subject": "Test Subject", "date": "2023-10-10", "body": "Test Body", "log_id": str(mail_response_log.id)}] + + mock_process_messages_via_bot.return_value = [{ + "slots": ["name: spandan"], + "response": [{"text": "Test Response"}] + }] + + await MailProcessor.process_messages(bot, batch) + + # Assert + mock_process_messages_via_bot.assert_called_once() + mock_login_smtp.assert_called_once() + mock_send_mail.assert_called_once() + mock_logout_smtp.assert_called_once() + MailResponseLog.objects().delete() + + + @patch("kairon.shared.channels.mail.processor.MailProcessor.login_smtp") + @pytest.mark.asyncio + async def test_process_messages_exception(self, mock_exc): + # Arrange + bot = "test_bot" + batch = [{"mail_id": "test@example.com", "subject": "Test Subject", "date": "2023-10-10", "body": "Test Body"}] + mock_exc.side_effect = Exception("Test Exception") + + # Act & Assert + with pytest.raises(AppException): + await MailProcessor.process_messages(bot, batch) + + @patch('kairon.shared.channels.mail.processor.MailProcessor.__init__') + @patch('kairon.shared.channels.mail.processor.MailProcessor.login_smtp') + @patch('kairon.shared.channels.mail.processor.MailProcessor.logout_smtp') + def test_validate_smpt_connection(self, mp, mock_logout_smtp, mock_login_smtp): + mp.return_value = None + mock_login_smtp.return_value = None + mock_logout_smtp.return_value = None + + result = MailProcessor.validate_smtp_connection('test_bot_id') + + assert result + + mock_login_smtp.assert_called_once() + mock_logout_smtp.assert_called_once() + + @patch('kairon.shared.channels.mail.processor.MailProcessor.login_smtp') + @patch('kairon.shared.channels.mail.processor.MailProcessor.logout_smtp') + def test_validate_smpt_connection_failure(self, mock_logout_smtp, mock_login_smtp): + mock_login_smtp.side_effect = Exception("SMTP login failed") + + result = MailProcessor.validate_smtp_connection('test_bot_id') + + assert not result + + @patch('kairon.shared.channels.mail.processor.MailProcessor.__init__') + @patch('kairon.shared.channels.mail.processor.MailProcessor.login_imap') + @patch('kairon.shared.channels.mail.processor.MailProcessor.logout_imap') + def test_validate_imap_connection(self, mp, mock_logout_imap, mock_login_imap): + mp.return_value = None + mock_login_imap.return_value = None + mock_logout_imap.return_value = None + + result = MailProcessor.validate_imap_connection('test_bot_id') + + assert result + + mock_login_imap.assert_called_once() + mock_logout_imap.assert_called_once() + + @patch('kairon.shared.channels.mail.processor.MailProcessor.login_imap') + @patch('kairon.shared.channels.mail.processor.MailProcessor.logout_imap') + def test_validate_imap_connection_failure(self, mock_logout_imap, mock_login_imap): + mock_login_imap.side_effect = Exception("imap login failed") + + result = MailProcessor.validate_imap_connection('test_bot_id') + + assert not result + + def test_get_mail_channel_state_data_existing_state(self): + bot_id = pytest.mail_test_bot + mock_state = MagicMock() + + with patch.object(MailChannelStateData, 'objects') as mock_objects: + mock_objects.return_value.first.return_value = mock_state + result = MailProcessor.get_mail_channel_state_data(bot_id) + + assert result == mock_state + mock_objects.return_value.first.assert_called_once() + + def test_get_mail_channel_state_data_new_state(self): + bot_id = pytest.mail_test_bot + mock_state = MagicMock() + mock_state.bot = bot_id + mock_state.state = "some_state" + mock_state.timestamp = "some_timestamp" + + with patch.object(MailChannelStateData, 'objects') as mock_objects: + mock_objects.return_value.first.return_value = None + with patch.object(MailChannelStateData, 'save', return_value=None) as mock_save: + with patch('kairon.shared.channels.mail.data_objects.MailChannelStateData', return_value=mock_state): + result = MailProcessor.get_mail_channel_state_data(bot_id) + + assert result.bot == mock_state.bot + + + def test_get_mail_channel_state_data_exception(self): + bot_id = "test_bot" + + with patch.object(MailChannelStateData, 'objects') as mock_objects: + mock_objects.side_effect = Exception("Test Exception") + with pytest.raises(AppException) as excinfo: + MailProcessor.get_mail_channel_state_data(bot_id) + + assert str(excinfo.value) == "Test Exception" + + + def test_get_log(self): + bot_id = "test_bot" + offset = 0 + limit = 10 + + mock_log = MagicMock() + mock_log.to_mongo.return_value.to_dict.return_value = { + '_id': 'some_id', + 'bot': bot_id, + 'user': 'test_user', + 'timestamp': 1234567890, + 'subject': 'Test Subject', + 'body': 'Test Body', + 'status': 'SUCCESS' + } + + with patch.object(MailResponseLog, 'objects') as mock_objects: + mock_objects.return_value.count.return_value = 1 + mock_objects.return_value.order_by.return_value.skip.return_value.limit.return_value = [mock_log] + + result = MailProcessor.get_log(bot_id, offset, limit) + + assert result['count'] == 1 + assert len(result['logs']) == 1 + assert result['logs'][0]['timestamp'] == 1234567890 + assert result['logs'][0]['subject'] == 'Test Subject' + assert result['logs'][0]['body'] == 'Test Body' + assert result['logs'][0]['status'] == 'SUCCESS' + + def test_get_log_exception(self): + bot_id = "test_bot" + offset = 0 + limit = 10 + + with patch.object(MailResponseLog, 'objects') as mock_objects: + mock_objects.side_effect = Exception("Test Exception") + + with pytest.raises(AppException) as excinfo: + MailProcessor.get_log(bot_id, offset, limit) + + assert str(excinfo.value) == "Test Exception" + + BotSettings.objects(user="mail_channel_test_user_acc").delete() + Bot.objects(user="mail_channel_test_user_acc").delete() + Account.objects(user="mail_channel_test_user_acc").delete() + Channels.objects(connector_type=ChannelTypes.MAIL.value).delete() \ No newline at end of file diff --git a/tests/unit_test/channels/mail_scheduler_test.py b/tests/unit_test/channels/mail_scheduler_test.py new file mode 100644 index 000000000..8df57f86d --- /dev/null +++ b/tests/unit_test/channels/mail_scheduler_test.py @@ -0,0 +1,139 @@ + +import pytest +from unittest.mock import patch +import os +from kairon import Utility +from kairon.exceptions import AppException + +os.environ["system_file"] = "./tests/testing_data/system.yaml" +Utility.load_environment() +Utility.load_system_metadata() + +from kairon.shared.channels.mail.scheduler import MailScheduler + +@pytest.fixture +def setup_environment(): + with patch("pymongo.MongoClient") as mock_client, \ + patch("kairon.shared.chat.data_objects.Channels.objects") as mock_channels, \ + patch("kairon.shared.channels.mail.processor.MailProcessor.read_mails") as mock_read_mails, \ + patch("apscheduler.schedulers.background.BackgroundScheduler", autospec=True) as mock_scheduler: + + mock_client_instance = mock_client.return_value + mock_channels.return_value = [{'bot': 'test_bot_1'}, {'bot': 'test_bot_2'}] + mock_read_mails.return_value = ([], 'test@user.com', 60) # Mock responses and next_delay + mock_scheduler_instance = mock_scheduler.return_value + yield { + 'mock_client': mock_client_instance, + 'mock_channels': mock_channels, + 'mock_read_mails': mock_read_mails, + 'mock_scheduler': mock_scheduler_instance + } + + +@patch('kairon.shared.channels.mail.processor.MailProcessor.validate_smtp_connection') +@patch('kairon.shared.channels.mail.processor.MailProcessor.validate_imap_connection') +@patch('kairon.shared.channels.mail.scheduler.Utility.get_event_server_url') +@patch('kairon.shared.channels.mail.scheduler.Utility.execute_http_request') +def test_request_epoch_success(mock_execute_http_request, mock_get_event_server_url, mock_imp, mock_smpt): + mock_get_event_server_url.return_value = "http://localhost" + mock_execute_http_request.return_value = {'success': True} + bot = "test_bot" + try: + MailScheduler.request_epoch(bot) + except AppException: + pytest.fail("request_epoch() raised AppException unexpectedly!") + +@patch('kairon.shared.channels.mail.processor.MailProcessor.validate_smtp_connection') +@patch('kairon.shared.channels.mail.processor.MailProcessor.validate_imap_connection') +@patch('kairon.shared.channels.mail.scheduler.Utility.get_event_server_url') +@patch('kairon.shared.channels.mail.scheduler.Utility.execute_http_request') +def test_request_epoch__response_not_success(mock_execute_http_request, mock_get_event_server_url, mock_imp, mock_smpt): + mock_get_event_server_url.return_value = "http://localhost" + mock_execute_http_request.return_value = {'success': False} + bot = "test_bot" + with pytest.raises(AppException): + MailScheduler.request_epoch(bot) + + +@patch('kairon.shared.channels.mail.scheduler.Utility.get_event_server_url') +@patch('kairon.shared.channels.mail.scheduler.Utility.execute_http_request') +def test_request_epoch_failure(mock_execute_http_request, mock_get_event_server_url): + mock_get_event_server_url.return_value = "http://localhost" + mock_execute_http_request.return_value = {'success': False} + + with pytest.raises(AppException): + MailScheduler.request_epoch("test_bot") + + + +@patch('kairon.events.utility.KScheduler.add_job') +@patch('kairon.events.utility.KScheduler.update_job') +@patch('kairon.events.utility.KScheduler.__init__', return_value=None) +@patch('kairon.shared.channels.mail.processor.MailProcessor') +@patch('pymongo.MongoClient', autospec=True) +def test_schedule_channel_mail_reading(mock_mongo, mock_mail_processor, mock_kscheduler, mock_update_job, mock_add_job): + from kairon.events.utility import EventUtility + + bot = "test_bot" + mock_mail_processor_instance = mock_mail_processor.return_value + mock_mail_processor_instance.config = {"interval": 1} + mock_mail_processor_instance.state.event_id = None + mock_mail_processor_instance.bot_settings.user = "test_user" + +# # Test case when event_id is None + EventUtility.schedule_channel_mail_reading(bot) + mock_add_job.assert_called_once() + mock_update_job.assert_not_called() + + mock_add_job.reset_mock() + mock_update_job.reset_mock() + mock_mail_processor_instance.state.event_id = "existing_event_id" + + # Test case when event_id exists + EventUtility.schedule_channel_mail_reading(bot) + mock_update_job.assert_called_once() + mock_add_job.assert_not_called() + +@patch('kairon.events.utility.KScheduler.add_job') +@patch('kairon.events.utility.KScheduler', autospec=True) +@patch('kairon.shared.channels.mail.processor.MailProcessor') +@patch('pymongo.MongoClient', autospec=True) +def test_schedule_channel_mail_reading_exception(mock_mongo_client, mock_mail_processor, mock_kscheduler, mock_add_job): + from kairon.events.utility import EventUtility + + bot = "test_bot" + mock_mail_processor.side_effect = Exception("Test Exception") + + with pytest.raises(AppException) as excinfo: + EventUtility.schedule_channel_mail_reading(bot) + assert str(excinfo.value) == f"Failed to schedule mail reading for bot {bot}. Error: Test Exception" + + +@patch('kairon.events.utility.EventUtility.schedule_channel_mail_reading') +@patch('kairon.shared.chat.data_objects.Channels.objects') +def test_reschedule_all_bots_channel_mail_reading(mock_channels_objects, mock_schedule_channel_mail_reading): + from kairon.events.utility import EventUtility + + mock_channels_objects.return_value.distinct.return_value = ['bot1', 'bot2'] + + EventUtility.reschedule_all_bots_channel_mail_reading() + + mock_channels_objects.return_value.distinct.assert_called_once_with("bot") + assert mock_schedule_channel_mail_reading.call_count == 2 + mock_schedule_channel_mail_reading.assert_any_call('bot1') + mock_schedule_channel_mail_reading.assert_any_call('bot2') + +@patch('kairon.events.utility.EventUtility.schedule_channel_mail_reading') +@patch('kairon.shared.chat.data_objects.Channels.objects') +def test_reschedule_all_bots_channel_mail_reading_exception(mock_channels_objects, mock_schedule_channel_mail_reading): + from kairon.events.utility import EventUtility + + mock_channels_objects.return_value.distinct.return_value = ['bot1', 'bot2'] + mock_schedule_channel_mail_reading.side_effect = Exception("Test Exception") + + with pytest.raises(AppException) as excinfo: + EventUtility.reschedule_all_bots_channel_mail_reading() + + assert str(excinfo.value) == "Failed to reschedule mail reading events. Error: Test Exception" + mock_channels_objects.return_value.distinct.assert_called_once_with("bot") + assert mock_schedule_channel_mail_reading.call_count == 1 \ No newline at end of file diff --git a/tests/unit_test/chat/chat_test.py b/tests/unit_test/chat/chat_test.py index 36f5b1000..94451e754 100644 --- a/tests/unit_test/chat/chat_test.py +++ b/tests/unit_test/chat/chat_test.py @@ -1,4 +1,5 @@ import time +from unittest.mock import MagicMock, patch, AsyncMock import ujson as json import os @@ -933,4 +934,33 @@ async def test_mongotracker_save(self): data = list(store.client.get_database(config['db']).get_collection(bot).find({'type': 'flattened'})) assert len(data) == 1 assert data[0]['tag'] == 'tracker_store' - assert data[0]['type'] == 'flattened' \ No newline at end of file + assert data[0]['type'] == 'flattened' + + + + +@pytest.mark.asyncio +@patch("kairon.chat.utils.AgentProcessor.get_agent_without_cache") +@patch("kairon.chat.utils.ChatUtils.get_metadata") +async def test_process_messages_via_bot(mock_get_metadata, mock_get_agent_without_cache): + messages = ["/greet", "/bye"] + account = 1 + bot = "test_bot" + user = "test_user" + is_integration_user = False + metadata = {"key": "value"} + + mock_get_metadata.return_value = metadata + mock_model = MagicMock() + mock_get_agent_without_cache.return_value = mock_model + mock_model.handle_message = AsyncMock(side_effect=[{"text": "Hello"}, {"text": "Goodbye"}]) + from kairon.chat.utils import ChatUtils + + responses = await ChatUtils.process_messages_via_bot(messages, account, bot, user, is_integration_user, metadata) + + assert len(responses) == 2 + assert responses[0] == {"text": "Hello"} + assert responses[1] == {"text": "Goodbye"} + mock_get_metadata.assert_called_once_with(account, bot, is_integration_user, metadata) + mock_get_agent_without_cache.assert_called_once_with(bot, False) + assert mock_model.handle_message.call_count == 2 diff --git a/tests/unit_test/cli_test.py b/tests/unit_test/cli_test.py index b5c4ad361..3dce1d99f 100644 --- a/tests/unit_test/cli_test.py +++ b/tests/unit_test/cli_test.py @@ -1,7 +1,9 @@ import argparse +import json import os from datetime import datetime from unittest import mock +from unittest.mock import patch import pytest from mongoengine import connect @@ -314,6 +316,49 @@ def init_connection(self): def test_delete_logs(self, mock_args): cli() +class TestMailChannelCli: + + @pytest.fixture(autouse=True, scope='class') + def init_connection(self): + os.environ["system_file"] = "./tests/testing_data/system.yaml" + Utility.load_environment() + connect(**Utility.mongoengine_connection(Utility.environment['database']["url"])) + + @mock.patch("kairon.cli.mail_channel_process.MailProcessEvent.execute") + def test_start_mail_channel_process(self, mock_execute): + from kairon.cli.mail_channel_process import process_channel_mails + data = [{"mail": "test_mail"}] + data = json.dumps(data) + with patch('argparse.ArgumentParser.parse_args', + return_value=argparse.Namespace(func=process_channel_mails, + bot="test_bot", + user="test_user", mails=data)): + cli() + mock_execute.assert_called_once() + + + @mock.patch("kairon.cli.mail_channel_process.MailProcessEvent.execute") + def test_start_mail_channel_process_wrong_format(self, mock_execute): + from kairon.cli.mail_channel_process import process_channel_mails + data = {"mail": "test_mail"} + data = json.dumps(data) + with patch('argparse.ArgumentParser.parse_args', + return_value=argparse.Namespace(func=process_channel_mails, + bot="test_bot", + user="test_user", mails=data)): + with pytest.raises(ValueError): + cli() + mock_execute.assert_not_called() + + @mock.patch("kairon.cli.mail_channel_read.MailReadEvent.execute") + def test_start_mail_channel_read(self, mock_execute): + from kairon.cli.mail_channel_read import read_channel_mails + with patch('argparse.ArgumentParser.parse_args', + return_value=argparse.Namespace(func=read_channel_mails, + bot="test_bot", + user="test_user")): + cli() + mock_execute.assert_called_once() class TestMessageBroadcastCli: diff --git a/tests/unit_test/data_processor/agent_processor_test.py b/tests/unit_test/data_processor/agent_processor_test.py index 8d37b1541..7d53cb630 100644 --- a/tests/unit_test/data_processor/agent_processor_test.py +++ b/tests/unit_test/data_processor/agent_processor_test.py @@ -135,6 +135,13 @@ def test_get_agent(self): assert len(list(ModelProcessor.get_training_history(pytest.bot))) == 1 assert not Utility.check_empty_string(model.model_ver) + def test_get_agent_no_cache(self): + model = AgentProcessor.get_agent_without_cache(pytest.bot, False) + assert model + assert len(list(ModelProcessor.get_training_history(pytest.bot))) == 1 + assert not Utility.check_empty_string(model.model_ver) + + def test_get_agent_not_cached(self): assert AgentProcessor.get_agent(pytest.bot) diff --git a/tests/unit_test/data_processor/data_processor_test.py b/tests/unit_test/data_processor/data_processor_test.py index 22c2846ec..0cefe9571 100644 --- a/tests/unit_test/data_processor/data_processor_test.py +++ b/tests/unit_test/data_processor/data_processor_test.py @@ -1350,7 +1350,7 @@ async def test_save_from_path_yml(self): assert len(list(Intents.objects(bot="test_load_yml", user="testUser", use_entities=False))) == 5 assert len(list(Intents.objects(bot="test_load_yml", user="testUser", use_entities=True))) == 27 assert len( - list(Slots.objects(bot="test_load_yml", user="testUser", influence_conversation=True, status=True))) == 12 + list(Slots.objects(bot="test_load_yml", user="testUser", influence_conversation=True, status=True))) == 15 assert len( list(Slots.objects(bot="test_load_yml", user="testUser", influence_conversation=False, status=True))) == 10 multiflow_stories = processor.load_multiflow_stories_yaml(bot='test_load_yml') @@ -3880,13 +3880,13 @@ async def test_upload_case_insensitivity(self): assert all(slot.name in ['user', 'location', 'email_id', 'application_name', 'bot', 'kairon_action_response', 'order', 'payment', 'http_status_code', 'image', 'audio', 'video', 'document', 'doc_url', 'longitude', 'latitude', 'flow_reply', 'quick_reply', - 'session_started_metadata', 'requested_slot'] for slot in domain.slots) + 'session_started_metadata', 'requested_slot', 'mail_id', 'subject', 'body'] for slot in domain.slots) assert not DeepDiff(list(domain.responses.keys()), ['utter_please_rephrase', 'utter_greet', 'utter_goodbye', 'utter_default'], ignore_order=True) assert not DeepDiff(domain.entities, ['user', 'location', 'email_id', 'application_name', 'bot', 'kairon_action_response', 'order', 'payment', 'http_status_code', 'image', 'audio', 'video', 'document', 'doc_url', - 'longitude', 'latitude', 'flow_reply', 'quick_reply'], ignore_order=True) + 'longitude', 'latitude', 'flow_reply', 'quick_reply', 'mail_id', 'subject', 'body'], ignore_order=True) assert domain.forms == {'ask_user': {'required_slots': ['user', 'email_id']}, 'ask_location': {'required_slots': ['location', 'application_name']}} assert domain.user_actions == ['ACTION_GET_GOOGLE_APPLICATION', 'ACTION_GET_MICROSOFT_APPLICATION', @@ -3985,8 +3985,8 @@ async def test_load_from_path_yml_training_files(self): assert story_graph.story_steps[15].events[2].entities[0]['entity'] == 'fdresponse' domain = processor.load_domain("test_load_from_path_yml_training_files") assert isinstance(domain, Domain) - assert domain.slots.__len__() == 24 - assert len([slot for slot in domain.slots if slot.influence_conversation is True]) == 12 + assert domain.slots.__len__() == 27 + assert len([slot for slot in domain.slots if slot.influence_conversation is True]) == 15 assert len([slot for slot in domain.slots if slot.influence_conversation is False]) == 12 assert domain.intent_properties.__len__() == 32 assert len([intent for intent in domain.intent_properties.keys() if @@ -3994,7 +3994,7 @@ async def test_load_from_path_yml_training_files(self): assert len([intent for intent in domain.intent_properties.keys() if not domain.intent_properties.get(intent)['used_entities']]) == 5 assert domain.responses.keys().__len__() == 29 - assert domain.entities.__len__() == 24 + assert domain.entities.__len__() == 27 assert domain.forms.__len__() == 2 assert domain.forms.__len__() == 2 assert domain.forms['ticket_attributes_form'] == { @@ -4056,11 +4056,11 @@ async def test_load_from_path_all_scenario(self): assert story_graph.story_steps[15].events[2].entities[0]['entity'] == 'fdresponse' domain = processor.load_domain("all") assert isinstance(domain, Domain) - assert domain.slots.__len__() == 23 + assert domain.slots.__len__() == 26 assert all(slot.mappings[0]['type'] == 'from_entity' and slot.mappings[0]['entity'] == slot.name for slot in domain.slots if slot.name not in ['requested_slot', 'session_started_metadata']) assert domain.responses.keys().__len__() == 27 - assert domain.entities.__len__() == 23 + assert domain.entities.__len__() == 26 assert domain.forms.__len__() == 2 assert domain.forms['ticket_attributes_form'] == {'required_slots': {}} assert isinstance(domain.forms, dict) @@ -4099,9 +4099,9 @@ async def test_load_from_path_all_scenario_append(self): assert story_graph.story_steps[15].events[2].entities[0]['entity'] == 'fdresponse' domain = processor.load_domain("all") assert isinstance(domain, Domain) - assert domain.slots.__len__() == 23 + assert domain.slots.__len__() == 26 assert domain.responses.keys().__len__() == 27 - assert domain.entities.__len__() == 23 + assert domain.entities.__len__() == 26 assert domain.forms.__len__() == 2 assert isinstance(domain.forms, dict) assert domain.user_actions.__len__() == 27 @@ -4126,10 +4126,10 @@ def test_load_domain(self): processor = MongoProcessor() domain = processor.load_domain("tests") assert isinstance(domain, Domain) - assert domain.slots.__len__() == 15 + assert domain.slots.__len__() == 18 assert [s.name for s in domain.slots if s.name == 'kairon_action_response' and s.value is None] assert domain.responses.keys().__len__() == 11 - assert domain.entities.__len__() == 14 + assert domain.entities.__len__() == 17 assert domain.form_names.__len__() == 0 assert domain.user_actions.__len__() == 11 assert domain.intents.__len__() == 14 @@ -4376,7 +4376,7 @@ def test_add_training_example_with_entity(self): ) slots = Slots.objects(bot="tests") new_slot = slots.get(name="priority") - assert slots.__len__() == 15 + assert slots.__len__() == 18 assert new_slot.name == "priority" assert new_slot.type == "text" assert new_training_example.text == "Log a critical issue" @@ -4409,7 +4409,7 @@ def test_get_training_examples_with_entities(self): for value in actual ] ) - assert slots.__len__() == 16 + assert slots.__len__() == 19 assert new_slot.name == "ticketid" assert new_slot.type == "text" expected = ["hey", "hello", "hi", "good morning", "good evening", "hey there"] @@ -4453,7 +4453,7 @@ def test_get_entities(self): processor = MongoProcessor() expected = ["bot", "priority", "file_text", "ticketid", 'kairon_action_response', 'image', 'video', 'audio', 'doc_url', 'document', 'order', 'payment', 'quick_reply', 'longitude', 'latitude', 'flow_reply', - 'http_status_code'] + 'http_status_code', 'mail_id', 'subject', 'body'] actual = processor.get_entities("tests") assert actual.__len__() == expected.__len__() assert all(item["name"] in expected for item in actual) @@ -7130,8 +7130,8 @@ def _mock_bot_info(*args, **kwargs): assert story_graph.story_steps[15].events[2].entities[0]['entity'] == 'fdresponse' domain = mongo_processor.load_domain(bot) assert isinstance(domain, Domain) - assert domain.slots.__len__() == 24 - assert len([slot for slot in domain.slots if slot.influence_conversation is True]) == 12 + assert domain.slots.__len__() == 27 + assert len([slot for slot in domain.slots if slot.influence_conversation is True]) == 15 assert len([slot for slot in domain.slots if slot.influence_conversation is False]) == 12 assert domain.intent_properties.__len__() == 32 assert len([intent for intent in domain.intent_properties.keys() if @@ -7139,7 +7139,7 @@ def _mock_bot_info(*args, **kwargs): assert len([intent for intent in domain.intent_properties.keys() if not domain.intent_properties.get(intent)['used_entities']]) == 5 assert domain.responses.keys().__len__() == 29 - assert domain.entities.__len__() == 24 + assert domain.entities.__len__() == 27 assert domain.form_names.__len__() == 2 assert domain.user_actions.__len__() == 48 assert domain.intents.__len__() == 32 @@ -7195,9 +7195,9 @@ def _mock_bot_info(*args, **kwargs): assert story_graph.story_steps[15].events[2].entities[0]['entity'] == 'fdresponse' domain = mongo_processor.load_domain(bot) assert isinstance(domain, Domain) - assert domain.slots.__len__() == 23 + assert domain.slots.__len__() == 26 assert domain.responses.keys().__len__() == 27 - assert domain.entities.__len__() == 23 + assert domain.entities.__len__() == 26 assert domain.form_names.__len__() == 2 assert domain.user_actions.__len__() == 27 assert domain.intents.__len__() == 29 @@ -7275,8 +7275,8 @@ def _mock_bot_info(*args, **kwargs): assert story_graph.story_steps[15].events[2].entities[0]['entity'] == 'fdresponse' domain = mongo_processor.load_domain(bot) assert isinstance(domain, Domain) - assert domain.slots.__len__() == 24 - assert len([slot for slot in domain.slots if slot.influence_conversation is True]) == 12 + assert domain.slots.__len__() == 27 + assert len([slot for slot in domain.slots if slot.influence_conversation is True]) == 15 assert len([slot for slot in domain.slots if slot.influence_conversation is False]) == 12 assert domain.intent_properties.__len__() == 32 assert len([intent for intent in domain.intent_properties.keys() if @@ -7284,7 +7284,7 @@ def _mock_bot_info(*args, **kwargs): assert len([intent for intent in domain.intent_properties.keys() if not domain.intent_properties.get(intent)['used_entities']]) == 5 assert domain.responses.keys().__len__() == 29 - assert domain.entities.__len__() == 24 + assert domain.entities.__len__() == 27 assert domain.form_names.__len__() == 2 assert domain.user_actions.__len__() == 48 assert domain.intents.__len__() == 32 @@ -7340,8 +7340,8 @@ def _mock_bot_info(*args, **kwargs): assert story_graph.story_steps[15].events[2].entities[0]['entity'] == 'fdresponse' domain = mongo_processor.load_domain(bot) assert isinstance(domain, Domain) - assert domain.slots.__len__() == 24 - assert len([slot for slot in domain.slots if slot.influence_conversation is True]) == 12 + assert domain.slots.__len__() == 27 + assert len([slot for slot in domain.slots if slot.influence_conversation is True]) == 15 assert len([slot for slot in domain.slots if slot.influence_conversation is False]) == 12 assert domain.intent_properties.__len__() == 33 assert len([intent for intent in domain.intent_properties.keys() if @@ -7349,7 +7349,7 @@ def _mock_bot_info(*args, **kwargs): assert len([intent for intent in domain.intent_properties.keys() if not domain.intent_properties.get(intent)['used_entities']]) == 6 assert domain.responses.keys().__len__() == 31 - assert domain.entities.__len__() == 24 + assert domain.entities.__len__() == 27 assert domain.form_names.__len__() == 2 assert domain.user_actions.__len__() == 50 assert domain.intents.__len__() == 33 @@ -7390,8 +7390,8 @@ def test_delete_nlu_only(self): assert story_graph.story_steps[15].events[2].entities[0]['entity'] == 'fdresponse' domain = mongo_processor.load_domain(bot) assert isinstance(domain, Domain) - assert domain.slots.__len__() == 24 - assert len([slot for slot in domain.slots if slot.influence_conversation is True]) == 12 + assert domain.slots.__len__() == 27 + assert len([slot for slot in domain.slots if slot.influence_conversation is True]) == 15 assert len([slot for slot in domain.slots if slot.influence_conversation is False]) == 12 assert domain.intent_properties.__len__() == 33 assert len([intent for intent in domain.intent_properties.keys() if @@ -7399,7 +7399,7 @@ def test_delete_nlu_only(self): assert len([intent for intent in domain.intent_properties.keys() if not domain.intent_properties.get(intent)['used_entities']]) == 6 assert domain.responses.keys().__len__() == 31 - assert domain.entities.__len__() == 24 + assert domain.entities.__len__() == 27 assert domain.form_names.__len__() == 2 assert domain.user_actions.__len__() == 50 assert domain.intents.__len__() == 33 @@ -7448,8 +7448,8 @@ def test_delete_stories_only(self): assert story_graph.story_steps.__len__() == 0 domain = mongo_processor.load_domain(bot) assert isinstance(domain, Domain) - assert domain.slots.__len__() == 24 - assert len([slot for slot in domain.slots if slot.influence_conversation is True]) == 12 + assert domain.slots.__len__() == 27 + assert len([slot for slot in domain.slots if slot.influence_conversation is True]) == 15 assert len([slot for slot in domain.slots if slot.influence_conversation is False]) == 12 assert domain.intent_properties.__len__() == 33 assert len([intent for intent in domain.intent_properties.keys() if @@ -7457,7 +7457,7 @@ def test_delete_stories_only(self): assert len([intent for intent in domain.intent_properties.keys() if not domain.intent_properties.get(intent)['used_entities']]) == 6 assert domain.responses.keys().__len__() == 31 - assert domain.entities.__len__() == 24 + assert domain.entities.__len__() == 27 assert domain.form_names.__len__() == 2 assert domain.user_actions.__len__() == 50 assert domain.intents.__len__() == 33 @@ -7493,8 +7493,8 @@ def test_delete_multiflow_stories_only(self): assert story_graph.story_steps.__len__() == 0 domain = mongo_processor.load_domain(bot) assert isinstance(domain, Domain) - assert domain.slots.__len__() == 24 - assert len([slot for slot in domain.slots if slot.influence_conversation is True]) == 12 + assert domain.slots.__len__() == 27 + assert len([slot for slot in domain.slots if slot.influence_conversation is True]) == 15 assert len([slot for slot in domain.slots if slot.influence_conversation is False]) == 12 assert domain.intent_properties.__len__() == 33 assert len([intent for intent in domain.intent_properties.keys() if @@ -7502,7 +7502,7 @@ def test_delete_multiflow_stories_only(self): assert len([intent for intent in domain.intent_properties.keys() if not domain.intent_properties.get(intent)['used_entities']]) == 6 assert domain.responses.keys().__len__() == 31 - assert domain.entities.__len__() == 24 + assert domain.entities.__len__() == 27 assert domain.form_names.__len__() == 2 assert domain.user_actions.__len__() == 50 assert domain.intents.__len__() == 33 @@ -7548,10 +7548,10 @@ def test_delete_config_and_actions_only(self): assert story_graph.story_steps.__len__() == 16 domain = mongo_processor.load_domain(bot) assert isinstance(domain, Domain) - assert domain.slots.__len__() == 24 + assert domain.slots.__len__() == 27 assert domain.intent_properties.__len__() == 33 assert domain.responses.keys().__len__() == 31 - assert domain.entities.__len__() == 24 + assert domain.entities.__len__() == 27 assert domain.form_names.__len__() == 2 assert domain.user_actions.__len__() == 31 assert domain.intents.__len__() == 33 @@ -7626,10 +7626,10 @@ async def test_save_rules_and_domain_only(self, get_training_data): assert len(rules) == 3 domain = mongo_processor.load_domain(bot) assert isinstance(domain, Domain) - assert domain.slots.__len__() == 24 + assert domain.slots.__len__() == 27 assert domain.intent_properties.__len__() == 32 assert domain.responses.keys().__len__() == 27 - assert domain.entities.__len__() == 24 + assert domain.entities.__len__() == 27 assert domain.form_names.__len__() == 2 assert domain.user_actions.__len__() == 46 assert domain.intents.__len__() == 32 @@ -9375,9 +9375,16 @@ def test_get_slot(self): {'name': 'age', 'type': 'float', 'max_value': 1.0, 'min_value': 0.0, 'influence_conversation': True, '_has_been_set': False, 'is_default': False}, {'name': 'occupation', 'type': 'text', 'influence_conversation': True, '_has_been_set': False, 'is_default': False}, - {'name': 'quick_reply', 'type': 'text', 'influence_conversation': True, '_has_been_set': False, 'is_default': True} + {'name': 'quick_reply', 'type': 'text', 'influence_conversation': True, '_has_been_set': False, 'is_default': True}, + {'name': 'mail_id', 'type': 'text', 'influence_conversation': True, '_has_been_set': False, + 'is_default': True}, + {'name': 'subject', 'type': 'text', 'influence_conversation': True, '_has_been_set': False, + 'is_default': True}, + {'name': 'body', 'type': 'text', 'influence_conversation': True, '_has_been_set': False, + 'is_default': True}, + ] - assert len(slots) == 24 + assert len(slots) == 27 assert not DeepDiff(slots, expected, ignore_order=True) def test_update_slot_add_value_intent_and_not_intent(self): diff --git a/tests/unit_test/events/definitions_test.py b/tests/unit_test/events/definitions_test.py index 020517f51..da5d630a7 100644 --- a/tests/unit_test/events/definitions_test.py +++ b/tests/unit_test/events/definitions_test.py @@ -11,7 +11,6 @@ from unittest.mock import patch from mongoengine import connect -from augmentation.utils import WebsiteParser from kairon import Utility from kairon.events.definitions.data_importer import TrainingDataImporterEvent from kairon.events.definitions.faq_importer import FaqDataImporterEvent @@ -1199,3 +1198,136 @@ def test_delete_message_broadcast(self): assert len(list(MessageBroadcastProcessor.list_settings(bot))) == 1 with pytest.raises(AppException, match="Notification settings not found!"): MessageBroadcastProcessor.get_settings(setting_id, bot) + + + + @responses.activate + def test_validate_mail_channel_schedule_event(self): + from kairon.events.definitions.mail_channel import MailProcessEvent + bot = "test_add_schedule_event" + user = "test_user" + url = f"http://localhost:5001/api/events/execute/{EventClass.mail_channel_process_mails}?is_scheduled=False" + responses.add( + "POST", url, + json={"message": "test msg", "success": True, "error_code": 400, "data": None} + ) + with patch('kairon.shared.channels.mail.processor.MailProcessor.__init__', return_value=None) as mp: + with patch('kairon.shared.channels.mail.processor.MailProcessor.login_smtp', return_value=None) as mock_login: + with patch('kairon.shared.channels.mail.processor.MailProcessor.logout_smtp', return_value=None) as mock_logout: + + event = MailProcessEvent(bot, user) + status = event.validate() + assert status + + @responses.activate + def test_validate_mail_channel_schedule_event_fail(self): + from kairon.events.definitions.mail_channel import MailProcessEvent + bot = "test_add_schedule_event" + user = "test_user" + url = f"http://localhost:5001/api/events/execute/{EventClass.mail_channel_process_mails}?is_scheduled=False" + responses.add( + "POST", url, + json={"message": "test msg", "success": True, "error_code": 400, "data": None} + ) + event = MailProcessEvent(bot, user) + status = event.validate() + assert not status + + + + @responses.activate + def test_trigger_mail_channel_process_event_enqueue(self): + from kairon.events.definitions.mail_channel import MailProcessEvent + bot = "test_add_schedule_event" + user = "test_user" + url = f"http://localhost:5001/api/events/execute/{EventClass.mail_channel_process_mails}?is_scheduled=False" + responses.add( + "POST", url, + json={"message": "test msg", "success": True, "error_code": 400, "data": None} + ) + event = MailProcessEvent(bot, user) + try: + event.enqueue() + except AppException as e: + pytest.fail(f"Unexpected exception: {e}") + + @responses.activate + def test_trigger_mail_channel_process_event_enqueue_exception(self): + from kairon.events.definitions.mail_channel import MailProcessEvent + from kairon.exceptions import AppException + from unittest.mock import patch + + bot = "test_add_schedule_event" + user = "test_user" + url = f"http://localhost:5001/api/events/execute/{EventClass.mail_channel_process_mails}?is_scheduled=False" + responses.add( + "POST", url, + json={"message": "test msg", "success": False, "error_code": 400, "data": None} + ) + event = MailProcessEvent(bot, user) + with pytest.raises(AppException, match="Failed to trigger email_channel_process_mails event: test msg"): + event.enqueue() + + + @responses.activate + def test_trigger_mail_channel_process_event_execute(self): + from kairon.events.definitions.mail_channel import MailProcessEvent + try: + MailProcessEvent("", "").execute() + except AppException as e: + pytest.fail(f"Unexpected exception: {e}") + + @responses.activate + def test_trigger_mail_channel_process_event_execute_exception(self): + from kairon.events.definitions.mail_channel import MailProcessEvent + from kairon.exceptions import AppException + from unittest.mock import patch + + with patch("kairon.shared.channels.mail.processor.MailProcessor.process_message_task", + side_effect=Exception("Test")): + with pytest.raises(AppException, match="Test"): + MailProcessEvent("", "").execute(mails=["test@mail.com"]) + + @responses.activate + def test_mail_channel_read_event_enqueue(self): + from kairon.events.definitions.mail_channel import MailReadEvent + bot = "test_add_schedule_event" + user = "test_user" + url = f"http://localhost:5001/api/events/execute/{EventClass.mail_channel_read_mails}?is_scheduled=False" + responses.add( + "POST", url, + json={"message": "test msg", "success": True, "error_code": 400, "data": None} + ) + event = MailReadEvent(bot, user) + try: + event.enqueue() + except AppException as e: + pytest.fail(f"Unexpected exception: {e}") + + @patch('kairon.shared.channels.mail.processor.MailProcessor.read_mails') + @patch('kairon.events.definitions.mail_channel.MailProcessEvent.enqueue') + @patch('kairon.events.definitions.mail_channel.MailProcessEvent.validate') + def test_mail_read_event_execute(self, mock_validate, mock_enqueue, mock_read_mails): + from kairon.events.definitions.mail_channel import MailReadEvent + bot = "test_add_schedule_event" + user = "test_user" + mock_read_mails.return_value = (["test@mail.com"], user, 10) + mock_validate.return_value = True + + event = MailReadEvent(bot, user) + event.execute() + + mock_read_mails.assert_called_once_with(bot) + mock_validate.assert_called_once() + mock_enqueue.assert_called_once_with(mails=["test@mail.com"]) + + def test_mail_read_event_execute_exception(self): + bot = "test_add_schedule_event" + user = "test_user" + + with patch('kairon.shared.channels.mail.processor.MailProcessor.read_mails', + side_effect=Exception("Test error")): + from kairon.events.definitions.mail_channel import MailReadEvent + event = MailReadEvent(bot, user) + with pytest.raises(AppException, match=f"Failed to schedule mail reading for bot {bot}. Error: Test error"): + event.execute() \ No newline at end of file diff --git a/tests/unit_test/utility_test.py b/tests/unit_test/utility_test.py index 7b7bf4c47..5427328aa 100644 --- a/tests/unit_test/utility_test.py +++ b/tests/unit_test/utility_test.py @@ -2848,7 +2848,8 @@ def test_get_channels(self): "messenger", "instagram", "whatsapp", - "line" + "line", + "mail" ] channels = Utility.get_channels() assert channels == expected_channels