diff --git a/kairon/__init__.py b/kairon/__init__.py index 7fcd49789..cb4cc9f2d 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 parser = ArgumentParser( prog="kairon", @@ -62,6 +63,7 @@ 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.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..d267a3b84 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.data_objects import MailClassificationConfig 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 @@ -33,6 +34,7 @@ from kairon.shared.data.audit.processor import AuditDataProcessor from kairon.shared.data.constant import ENDPOINT_TYPE, ModelTestType, \ AuditlogActions +from kairon.shared.data.data_models import MailConfigRequest from kairon.shared.data.data_objects import TrainingExamples, ModelTraining, Rules from kairon.shared.data.model_processor import ModelProcessor from kairon.shared.data.processor import MongoProcessor @@ -1658,3 +1660,60 @@ 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/config", response_model=Response) +async def get_all_mail_configs( + current_user: User = Security(Authentication.get_current_user_and_bot, scopes=TESTER_ACCESS)): + """ + Fetches mail config + """ + data = MailClassificationConfig.objects(bot=current_user.get_bot(), status=True) + formatted_data = [ + {key: value for key, value in item.to_mongo().items() if key not in {"_id", "user"}} + for item in data + ] + + return {"data": formatted_data} + + + +@router.post("/mail/config", response_model=Response) +async def set_mail_config( + request_data: MailConfigRequest, + current_user: User = Security(Authentication.get_current_user_and_bot, scopes=DESIGNER_ACCESS) +): + """ + Applies the mail config + """ + request_dict = request_data.dict() + MailClassificationConfig.create_doc(**request_dict, bot=current_user.get_bot(), user=current_user.get_user()) + return {"message": "Config applied!"} + + +@router.put("/mail/config", response_model=Response) +async def update_mail_config( + request_data: MailConfigRequest, + current_user: User = Security(Authentication.get_current_user_and_bot, scopes=DESIGNER_ACCESS) +): + """ + update the mail config + """ + request_dict = request_data.dict() + MailClassificationConfig.update_doc(**request_dict, bot=current_user.get_bot()) + return {"message": "Config updated!"} + + + +@router.delete("/mail/config/{intent}", response_model=Response) +async def del_soft_mail_config( + intent: str, + current_user: User = Security(Authentication.get_current_user_and_bot, scopes=DESIGNER_ACCESS) +): + """ + delete the mail config + """ + MailClassificationConfig.soft_delete_doc(current_user.get_bot(), intent) + return {"message": "Config deleted!"} + + + 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.py b/kairon/cli/mail_channel.py new file mode 100644 index 000000000..589b4c8c0 --- /dev/null +++ b/kairon/cli/mail_channel.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_schedule import MailChannelScheduleEvent + + +def process_channel_mails(args): + mails = json.loads(args.mails) + if not isinstance(mails, list): + raise ValueError("Mails should be a list") + MailChannelScheduleEvent(args.bot, args.user).execute(mails=mails) + + +def add_subparser(subparsers: SubParsersAction, parents: List[ArgumentParser]): + mail_parser = subparsers.add_parser( + "mail-channel", + conflict_handler="resolve", + formatter_class=ArgumentDefaultsHelpFormatter, + parents=parents, + help="Mail channel" + ) + 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/events/definitions/factory.py b/kairon/events/definitions/factory.py index 9ff4128f0..dd754a92f 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_schedule import MailChannelScheduleEvent 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,8 @@ class EventFactory: EventClass.multilingual: MultilingualEvent, EventClass.faq_importer: FaqDataImporterEvent, EventClass.message_broadcast: MessageBroadcastEvent, - EventClass.content_importer: DocContentImporterEvent + EventClass.content_importer: DocContentImporterEvent, + EventClass.email_channel_scheduler: MailChannelScheduleEvent } @staticmethod diff --git a/kairon/events/definitions/mail_channel_schedule.py b/kairon/events/definitions/mail_channel_schedule.py new file mode 100644 index 000000000..6212f4740 --- /dev/null +++ b/kairon/events/definitions/mail_channel_schedule.py @@ -0,0 +1,51 @@ +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 MailChannelScheduleEvent(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_smpt_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.email_channel_scheduler, 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) \ No newline at end of file diff --git a/kairon/events/server.py b/kairon/events/server.py index 344e8528f..7aef6fffe 100644 --- a/kairon/events/server.py +++ b/kairon/events/server.py @@ -56,6 +56,8 @@ async def lifespan(app: FastAPI): """ MongoDB is connected on the bot trainer startup """ config: dict = Utility.mongoengine_connection(Utility.environment['database']["url"]) connect(**config) + from kairon.shared.channels.mail.scheduler import MailScheduler + MailScheduler.epoch() yield disconnect() @@ -144,3 +146,10 @@ 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/request_epoch', response_model=Response) +def request_epoch(): + from kairon.shared.channels.mail.scheduler import MailScheduler + MailScheduler.epoch() + return {"data": None, "message": "Mail scheduler epoch request!"} \ 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..a4775f15c --- /dev/null +++ b/kairon/shared/channels/mail/constants.py @@ -0,0 +1,25 @@ + + +class MailConstants: + DEFAULT_SMTP_SERVER = 'smtp.gmail.com' + DEFAULT_IMAP_SERVER = 'imap.gmail.com' + DEFAULT_SMTP_PORT = 587 + DEFAULT_LLM_TYPE = "openai" + DEFAULT_HYPERPARAMETERS = { + "frequency_penalty": 0, + "logit_bias": {}, + "max_tokens": 300, + "model": "gpt-4o-mini", + "n": 1, + "presence_penalty": 0, + "stop": None, + "stream": False, + "temperature": 0, + "top_p": 0 + } + DEFAULT_TEMPLATE = "

Dear {name},

{bot_response}



Generated by kAIron AI.\n" + DEFAULT_SYSTEM_PROMPT = 'Classify into one of the intents and extract entities as given in the context.' \ + 'If the mail does not belong to any of the intents, classify intent as null.' + + 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..513c95c43 --- /dev/null +++ b/kairon/shared/channels/mail/data_objects.py @@ -0,0 +1,125 @@ +import time +from mongoengine import Document, StringField, ListField, FloatField, BooleanField +from kairon.exceptions import AppException + + + + +class MailClassificationConfig(Document): + intent: str = StringField(required=True) + entities: list[str] = ListField(StringField()) + subjects: list[str] = ListField(StringField()) + classification_prompt: str = StringField() + reply_template: str = StringField() + bot: str = StringField(required=True) + user: str = StringField() + timestamp: float = FloatField() + status: bool = BooleanField(default=True) + + + @staticmethod + def create_doc( + intent: str, + entities: list[str], + subjects: list[str], + classification_prompt: str, + reply_template: str, + bot: str, + user: str + ): + mail_config = None + try: + exists = MailClassificationConfig.objects(bot=bot, intent=intent).first() + if exists and exists.status: + raise AppException(f"Mail configuration already exists for intent [{intent}]") + elif exists and not exists.status: + exists.update( + entities=entities, + subjects=subjects, + classification_prompt=classification_prompt, + reply_template=reply_template, + timestamp=time.time(), + status=True, + user=user + ) + mail_config = exists + else: + mail_config = MailClassificationConfig( + intent=intent, + entities=entities, + subjects=subjects, + classification_prompt=classification_prompt, + reply_template=reply_template, + bot=bot, + timestamp=time.time(), + status=True, + user=user + ) + mail_config.save() + + except Exception as e: + raise AppException(str(e)) + + return mail_config + + @staticmethod + def get_docs(bot: str): + try: + objs = MailClassificationConfig.objects(bot=bot, status=True) + return_data = [] + for obj in objs: + data = obj.to_mongo().to_dict() + data.pop('_id') + data.pop('timestamp') + data.pop('status') + data.pop('user') + return_data.append(data) + return return_data + except Exception as e: + raise AppException(str(e)) + + @staticmethod + def get_doc(bot: str, intent: str): + try: + obj = MailClassificationConfig.objects(bot=bot, intent=intent, status=True).first() + if not obj: + raise AppException(f"Mail configuration does not exist for intent [{intent}]") + data = obj.to_mongo().to_dict() + data.pop('_id') + data.pop('timestamp') + data.pop('status') + data.pop('user') + return data + except Exception as e: + raise AppException(str(e)) + + + @staticmethod + def delete_doc(bot: str, intent: str): + try: + MailClassificationConfig.objects(bot=bot, intent=intent).delete() + except Exception as e: + raise AppException(str(e)) + + @staticmethod + def soft_delete_doc(bot: str, intent: str): + try: + MailClassificationConfig.objects(bot=bot, intent=intent).update(status=False) + except Exception as e: + raise AppException(str(e)) + + @staticmethod + def update_doc(bot: str, intent: str, **kwargs): + keys = ['entities', 'subjects', 'classification_prompt', 'reply_template'] + for key in kwargs.keys(): + if key not in keys: + raise AppException(f"Invalid key [{key}] provided for updating mail config") + try: + MailClassificationConfig.objects(bot=bot, intent=intent).update(**kwargs) + except Exception as e: + raise AppException(str(e)) + + + + + diff --git a/kairon/shared/channels/mail/processor.py b/kairon/shared/channels/mail/processor.py new file mode 100644 index 000000000..821b6cd28 --- /dev/null +++ b/kairon/shared/channels/mail/processor.py @@ -0,0 +1,272 @@ +import asyncio +import re + +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 MailClassificationConfig +from kairon.shared.chat.processor import ChatDataProcessor +from kairon.shared.constants import ChannelTypes +from kairon.shared.data.data_objects import BotSettings +from kairon.shared.llm.processor import LLMProcessor +import json +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +import smtplib + + + +class MailProcessor: + def __init__(self, bot): + self.config = ChatDataProcessor.get_channel_config(ChannelTypes.MAIL, bot, False)['config'] + self.llm_type = self.config.get('llm_type', "openai") + self.hyperparameters = self.config.get('hyperparameters', MailConstants.DEFAULT_HYPERPARAMETERS) + self.bot = bot + bot_info = Bot.objects.get(id=bot) + self.account = bot_info.account + self.llm_processor = LLMProcessor(self.bot, self.llm_type) + self.mail_configs = list(MailClassificationConfig.objects(bot=self.bot)) + self.mail_configs_dict = {item.intent: item for item in self.mail_configs} + self.bot_settings = BotSettings.objects(bot=self.bot).get() + self.mailbox = None + self.smtp = None + + + 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_smpt_connection(bot): + try: + mp = MailProcessor(bot) + mp.login_smtp() + mp.logout_smtp() + return True + except Exception as e: + logger.error(str(e)) + return False + + async def send_mail(self, to: str, subject: str, body: str): + try: + 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)}") + + def process_mail(self, intent: str, rasa_chat_response: dict): + 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', [])) + slots['bot_response'] = responses + mail_template = self.mail_configs_dict.get(intent, None) + if mail_template and mail_template.reply_template: + mail_template = mail_template.reply_template + else: + mail_template = MailConstants.DEFAULT_TEMPLATE + + return mail_template.format(**{key: str(value) for key, value in slots.items()}) + + async def classify_messages(self, messages: [dict]) -> [dict]: + if self.bot_settings.llm_settings['enable_faq']: + try: + system_prompt = self.config.get('system_prompt', MailConstants.DEFAULT_SYSTEM_PROMPT) + system_prompt += '\n return json format: [{"intent": "intent_name", "entities": {"entity_name": "value"}, "mail_id": "mail_id", "subject": "subject"}], if not classifiable set intent and not-found entity values as null' + context_prompt = self.get_context_prompt() + messages = json.dumps(messages) + info = await self.llm_processor.predict(messages, + self.bot_settings.user, + system_prompt=system_prompt, + context_prompt=context_prompt, + similarity_prompt=[], + hyperparameters=self.hyperparameters) + classifications = MailProcessor.extract_jsons_from_text(info["content"])[0] + return classifications + except Exception as e: + logger.error(str(e)) + raise AppException(str(e)) + + + @staticmethod + async def process_messages(bot: str, batch: [dict]): + """ + classify and respond to a batch of messages + """ + try: + from kairon.chat.utils import ChatUtils + mp = MailProcessor(bot) + classifications = await mp.classify_messages(batch) + user_messages: [str] = [] + responses = [] + intents = [] + for classification in classifications: + try: + intent = classification['intent'] + if not intent or intent == 'null': + continue + entities = classification['entities'] + sender_id = classification['mail_id'] + subject = f"{classification['subject']}" + + # mail_id is in the format "name " or "email" + if '<' in sender_id: + sender_id = sender_id.split('<')[1].split('>')[0] + + entities_str = ', '.join([f'"{key}": "{value}"' for key, value in entities.items() if value and value != 'null']) + user_msg = f'/{intent}{{{entities_str}}}' + logger.info(user_msg) + + user_messages.append(user_msg) + responses.append({ + 'to': sender_id, + 'subject': subject, + }) + intents.append(intent) + except Exception as e: + logger.exception(e) + logger.info(responses) + + chat_responses = await ChatUtils.process_messages_via_bot(user_messages, + mp.account, + bot, + mp.bot_settings.user, + False, + { + 'channel': ChannelTypes.MAIL.value + }) + logger.info(chat_responses) + + for index, response in enumerate(chat_responses): + responses[index]['body'] = mp.process_mail(intents[index], response) + + 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)) + + def get_context_prompt(self) -> str: + context_prompt = "" + for item in self.mail_configs: + context_prompt += f"intent: {item['intent']} \n" + context_prompt += f"entities: {item['entities']} \n" + context_prompt += "\nclassification criteria: \n" + context_prompt += f"subjects: {item['subjects']} \n" + context_prompt += f"rule: {item['classification_prompt']} \n" + context_prompt += "\n\n" + return context_prompt + + + @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 + - user + - time_shift + + """ + 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 + try: + mp.login_imap() + is_logged_in = True + msgs = mp.mailbox.fetch(AND(seen=False, date_gte=last_read_timestamp.date())) + for msg in msgs: + subject = msg.subject + sender_id = msg.from_ + date = msg.date + body = msg.text or msg.html or "" + logger.info(subject, sender_id, date) + message_entry = { + 'mail_id': sender_id, + 'subject': subject, + 'date': str(date), + 'body': body + } + messages.append(message_entry) + mp.logout_imap() + 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 + + @staticmethod + def extract_jsons_from_text(text) -> list: + """ + Extract json objects from text as a list + """ + json_pattern = re.compile(r'(\{.*?\}|\[.*?\])', re.DOTALL) + jsons = [] + for match in json_pattern.findall(text): + try: + json_obj = json.loads(match) + jsons.append(json_obj) + except json.JSONDecodeError: + continue + return jsons diff --git a/kairon/shared/channels/mail/scheduler.py b/kairon/shared/channels/mail/scheduler.py new file mode 100644 index 000000000..4d447bb0a --- /dev/null +++ b/kairon/shared/channels/mail/scheduler.py @@ -0,0 +1,94 @@ +from datetime import datetime, timedelta +from urllib.parse import urljoin + +from apscheduler.jobstores.mongodb import MongoDBJobStore +from apscheduler.schedulers.background import BackgroundScheduler +from pymongo import MongoClient + +from kairon import Utility +from kairon.events.definitions.mail_channel_schedule import MailChannelScheduleEvent +from kairon.exceptions import AppException +from kairon.shared.channels.mail.processor import MailProcessor +from kairon.shared.chat.data_objects import Channels +from kairon.shared.constants import ChannelTypes +from loguru import logger + + +class MailScheduler: + scheduler = None + scheduled_bots = set() + + @staticmethod + def epoch(): + is_initialized = False + if not MailScheduler.scheduler: + is_initialized = True + client = MongoClient(Utility.environment['database']['url']) + events_db = Utility.environment['events']['queue']['mail_queue_name'] + job_store_name = Utility.environment['events']['scheduler']['mail_scheduler_collection'] + + MailScheduler.scheduler = BackgroundScheduler( + jobstores={job_store_name: MongoDBJobStore(events_db, job_store_name, client)}, + job_defaults={'coalesce': True, 'misfire_grace_time': 7200}) + + bots = Channels.objects(connector_type= ChannelTypes.MAIL) + bots_list = [bot['bot'] for bot in bots] + bots = set(bots_list) + + unscheduled_bots = bots - MailScheduler.scheduled_bots + for bot in unscheduled_bots: + first_schedule_time = datetime.now() + timedelta(seconds=5) + MailScheduler.scheduler.add_job(MailScheduler.process_mails, + 'date', args=[bot, MailScheduler.scheduler], run_date=first_schedule_time) + MailScheduler.scheduled_bots.add(bot) + + MailScheduler.scheduled_bots = MailScheduler.scheduled_bots.intersection(bots) + if is_initialized: + MailScheduler.scheduler.start() + return True + return False + + @staticmethod + def request_epoch(): + event_server_url = Utility.get_event_server_url() + resp = Utility.execute_http_request( + "GET", + urljoin( + event_server_url, + "/api/mail/request_epoch", + ), + err_msg="Failed to request epoch", + ) + if not resp['success']: + raise AppException("Failed to request email channel epoch") + + @staticmethod + def process_mails(bot, scheduler: BackgroundScheduler = None): + + if bot not in MailScheduler.scheduled_bots: + return + logger.info(f"MailScheduler: Processing mails for bot {bot}") + next_timestamp = MailScheduler.read_mailbox_and_schedule_events(bot) + MailScheduler.scheduler.add_job(MailScheduler.process_mails, 'date', args=[bot, scheduler], run_date=next_timestamp) + MailScheduler.epoch() + + @staticmethod + def read_mailbox_and_schedule_events(bot) -> datetime: + vals = MailProcessor.read_mails(bot) + print(vals) + emails, user, next_delay = vals + for email in emails: + ev = MailChannelScheduleEvent(bot, user) + ev.validate() + ev.enqueue(mails=[email]) + next_timestamp = datetime.now() + timedelta(seconds=next_delay) + return next_timestamp + + + + + + + + + diff --git a/kairon/shared/chat/processor.py b/kairon/shared/chat/processor.py index 43d136e50..208331b35 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() 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..66419bde0 100644 --- a/kairon/shared/constants.py +++ b/kairon/shared/constants.py @@ -80,6 +80,7 @@ class EventClass(str, Enum): web_search = "web_search" scheduler_evaluator = "scheduler_evaluator" content_importer = "content_importer" + email_channel_scheduler = "email_channel_scheduler" class EventRequestType(str, Enum): @@ -115,7 +116,7 @@ class ChannelTypes(str, Enum): INSTAGRAM = "instagram" BUSINESS_MESSAGES = "business_messages" LINE = "line" - + MAIL = "mail" class ElementTypes(str, Enum): LINK = "link" diff --git a/kairon/shared/data/data_models.py b/kairon/shared/data/data_models.py index 346e0707f..1fcc76791 100644 --- a/kairon/shared/data/data_models.py +++ b/kairon/shared/data/data_models.py @@ -1341,3 +1341,11 @@ def validate_name(cls, values): raise ValueError("Schedule action can not be empty, it is needed to execute on schedule time") return values + + +class MailConfigRequest(BaseModel): + intent: str + entities: list[str] = [] + subjects: list[str] = [] + classification_prompt: str + reply_template: str = None \ No newline at end of file diff --git a/metadata/integrations.yml b/metadata/integrations.yml index c767e5c18..f2792000c 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 + - llm_type actions: pipedrive: diff --git a/requirements/prod.txt b/requirements/prod.txt index 1e8ad7217..f3e71dffd 100644 --- a/requirements/prod.txt +++ b/requirements/prod.txt @@ -67,4 +67,5 @@ jsonschema_rs==0.18.1 mongoengine-jsonschema==0.1.3 fernet==1.0.1 google-generativeai -huggingface-hub==0.25.2 \ No newline at end of file +huggingface-hub==0.25.2 +imap-tools==1.7.4 \ No newline at end of file diff --git a/system.yaml b/system.yaml index 5c98fb7a5..a6d73183e 100644 --- a/system.yaml +++ b/system.yaml @@ -127,6 +127,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} @@ -141,6 +142,7 @@ events: content_importer: ${DOC_CONTENT_IMPORTER_TASK_DEFINITION} 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} audit_logs: @@ -153,7 +155,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..35f50340c 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,121 +11755,6 @@ 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): - from uuid6 import uuid7 - llm_type = "perplexity" - action_name = "test_prompt_action_response_action_with_prompt_question_from_slot" - bot = "5f50fd0a56b69s8ca10d35d2l" - user = "udit.pandey" - value = "keyvalue" - user_msg = "What kind of language is python?" - bot_content = "Python is a high-level, general-purpose programming language. Its design philosophy emphasizes code readability with the use of significant indentation. Python is dynamically typed and garbage-collected." - generated_text = "Python is dynamically typed, garbage-collected, high level, general purpose programming." - llm_prompts = [ - {'name': 'System Prompt', - 'data': 'You are a personal assistant. Answer question based on the context below.', - 'type': 'system', 'source': 'static', 'is_enabled': True}, - {'name': 'History Prompt', 'type': 'user', 'source': 'history', 'is_enabled': True}, - {'name': 'Query Prompt', 'data': "What kind of language is python?", 'instructions': 'Rephrase the query.', - 'type': 'query', 'source': 'static', 'is_enabled': False}, - {'name': 'Similarity Prompt', - 'instructions': 'Answer question based on the context above, if answer is not in the context go check previous logs.', - 'type': 'user', 'source': 'bot_content', 'data': 'python', - 'hyperparameters': {"top_results": 10, "similarity_threshold": 0.70}, - 'is_enabled': True} - ] - mock_execute_request_async.return_value = ( - { - 'formatted_response': 'Python is dynamically typed, garbage-collected, high level, general purpose programming.', - 'response': 'Python is dynamically typed, garbage-collected, high level, general purpose programming.'}, - 200, - mock.ANY, - mock.ANY - ) - embedding = list(np.random.random(OPENAI_EMBEDDING_OUTPUT)) - mock_embedding.return_value = litellm.EmbeddingResponse(**{'data': [{'embedding': embedding}]}) - expected_body = {'messages': [ - {'role': 'system', 'content': 'You are a personal assistant. Answer question based on the context below.\n'}, - {'role': 'user', 'content': 'hello'}, {'role': 'assistant', 'content': 'how are you'}, {'role': 'user', - 'content': "\nInstructions on how to use Similarity Prompt:\n['Python is a high-level, general-purpose programming language. Its design philosophy emphasizes code readability with the use of significant indentation. Python is dynamically typed and garbage-collected.']\nAnswer question based on the context above, if answer is not in the context go check previous logs.\n \nQ: What kind of language is python? \nA:"}], - 'metadata': {'user': 'udit.pandey', 'bot': '5f50fd0a56b698ca10d35d2l', 'invocation': 'prompt_action'}, - 'api_key': 'keyvalue', - 'num_retries': 3, 'temperature': 0.0, 'max_tokens': 300, 'model': 'gpt-4o-mini', 'top_p': 0.0, 'n': 1, - 'stop': None, 'presence_penalty': 0.0, 'frequency_penalty': 0.0, 'logit_bias': {}} - aioresponses.add( - url=urljoin(Utility.environment['llm']['url'], - f"/{bot}/completion/{llm_type}"), - method="POST", - status=200, - payload={'formatted_response': generated_text, 'response': generated_text}, - body=json.dumps(expected_body) - ) - aioresponses.add( - url=f"{Utility.environment['vector']['db']}/collections/{bot}_python_faq_embd/points/search", - body={'vector': embedding}, - payload={'result': [{'id': uuid7().__str__(), 'score': 0.80, 'payload': {'content': bot_content}}]}, - method="POST", - status=200 - ) - hyperparameters = Utility.get_llm_hyperparameters("perplexity") - hyperparameters['search_domain_filter'] = ["domain1.com", "domain2.com"] - Actions(name=action_name, type=ActionType.prompt_action.value, bot=bot, user=user).save() - BotSettings(llm_settings=LLMSettings(enable_faq=True), bot=bot, user=user).save() - PromptAction(name=action_name, bot=bot, user=user, num_bot_responses=2, llm_prompts=llm_prompts, llm_type="perplexity", hyperparameters = hyperparameters, - user_question=UserQuestion(type="from_slot", value="prompt_question")).save() - llm_secret = LLMSecret( - llm_type=llm_type, - api_key=value, - models=["perplexity/llama-3.1-sonar-small-128k-online", "perplexity/llama-3.1-sonar-large-128k-online", "perplexity/llama-3.1-sonar-huge-128k-online"], - bot=bot, - user=user - ) - llm_secret.save() - llm_secret = LLMSecret( - llm_type="openai", - api_key="api_key", - models=["gpt-3.5-turbo", "gpt-4o-mini"], - bot=bot, - user=user - ) - llm_secret.save() - request_object = json.load(open("tests/testing_data/actions/action-request.json")) - request_object["tracker"]["slots"] = {"bot": bot, "prompt_question": user_msg} - request_object["next_action"] = action_name - request_object["tracker"]["sender_id"] = user - request_object['tracker']['events'] = [{"event": "user", 'text': 'hello', - "data": {"elements": '', "quick_replies": '', "buttons": '', - "attachment": '', "image": '', "custom": ''}}, - {'event': 'bot', "text": "how are you", - "data": {"elements": '', "quick_replies": '', "buttons": '', - "attachment": '', "image": '', "custom": ''}}] - response = client.post("/webhook", json=request_object) - response_json = response.json() - mock_execute_request_async.assert_called_once_with( - http_url=f"{Utility.environment['llm']['url']}/{urllib.parse.quote(bot)}/completion/{llm_type}", - request_method="POST", - request_body={ - 'messages': [{'role': 'system', 'content': 'You are a personal assistant. Answer question based on the context below.\n'}, - {'role': 'user', 'content': 'hello'}, - {'role': 'assistant', 'content': 'how are you'}, - {'role': 'user', 'content': "\nInstructions on how to use Similarity Prompt:\n['Python is a high-level, general-purpose programming language. Its design philosophy emphasizes code readability with the use of significant indentation. Python is dynamically typed and garbage-collected.']\nAnswer question based on the context above, if answer is not in the context go check previous logs.\n \nQ: What kind of language is python? inurl:domain1.com|domain2.com \nA:"}], - 'hyperparameters': hyperparameters, - 'user': user, - 'invocation': "prompt_action" - }, - timeout=Utility.environment['llm'].get('request_timeout', 30) - ) - called_args = mock_execute_request_async.call_args - user_message = called_args.kwargs['request_body']['messages'][-1]['content'] - assert "inurl:domain1.com|domain2.com" in user_message - assert response_json['events'] == [ - {'event': 'slot', 'timestamp': None, 'name': 'kairon_action_response', 'value': generated_text}] - assert response_json['responses'] == [ - {'text': generated_text, 'buttons': [], 'elements': [], 'custom': {}, 'template': None, - 'response': None, 'image': None, 'attachment': None} - ] @mock.patch.object(litellm, "aembedding", autospec=True) def test_prompt_action_response_action_with_prompt_question_from_slot_different_embedding_completion(mock_embedding, aioresponses): diff --git a/tests/integration_test/event_service_test.py b/tests/integration_test/event_service_test.py index 46135d175..55e859ace 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.shared.channels.mail.scheduler.MailScheduler.epoch') +def test_request_epoch(mock_epoch): + response = client.get('/api/mail/request_epoch') + 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 86d94605e..92124f90e 100644 --- a/tests/integration_test/services_test.py +++ b/tests/integration_test/services_test.py @@ -4406,6 +4406,129 @@ def test_get_live_agent_after_disabled(): assert actual["success"] + + +def test_add_mail_config(): + data = { + "intent": "greet", + "entities": ["name", "subject", "summery"], + "classification_prompt": "any personal mail of greeting" + } + + response = client.post( + f"/api/bot/{pytest.bot}/mail/config", + json=data, + headers={"Authorization": pytest.token_type + " " + pytest.access_token}, + ) + actual = response.json() + print(actual) + assert actual["success"] + assert actual['message'] == 'Config applied!' + assert actual['error_code'] == 0 + +def test_add_mail_config_missing_field(): + data = { + "entities": ["name", "subject", "summery"], + } + + response = client.post( + f"/api/bot/{pytest.bot}/mail/config", + json=data, + headers={"Authorization": pytest.token_type + " " + pytest.access_token}, + ) + actual = response.json() + print(actual) + assert not actual["success"] + assert len(actual['message']) + assert actual['error_code'] == 422 + +def test_add_mail_config_same_intent(): + data = { + "intent": "greet", + "entities": ["name", "subject", "summery"], + "classification_prompt": "any personal mail of greeting" + } + + response = client.post( + f"/api/bot/{pytest.bot}/mail/config", + json=data, + headers={"Authorization": pytest.token_type + " " + pytest.access_token}, + ) + actual = response.json() + print(actual) + assert not actual["success"] + assert actual['message'] == 'Mail configuration already exists for intent [greet]' + assert actual['error_code'] == 422 + + +def test_get_mail_config(): + + response = client.get( + f"/api/bot/{pytest.bot}/mail/config", + headers={"Authorization": pytest.token_type + " " + pytest.access_token}, + ) + actual = response.json() + print(actual) + assert actual["success"] + assert len(actual['data']) == 1 + assert actual['data'][0]['intent'] == 'greet' + assert actual['error_code'] == 0 + + + +def test_update_mail_config(): + data = { + "intent": "greet", + "entities": ["name", "subject", "summery"], + "classification_prompt": "any personal email of greeting" + } + + response = client.put( + f"/api/bot/{pytest.bot}/mail/config", + json=data, + headers={"Authorization": pytest.token_type + " " + pytest.access_token}, + ) + actual = response.json() + print(actual) + assert actual["success"] + assert actual['message'] == 'Config updated!' + assert actual['error_code'] == 0 + + response = client.get( + f"/api/bot/{pytest.bot}/mail/config", + headers={"Authorization": pytest.token_type + " " + pytest.access_token}, + ) + actual = response.json() + print(actual) + assert actual["success"] + assert len(actual['data']) == 1 + assert actual['data'][0]['intent'] == 'greet' + assert actual['data'][0]['classification_prompt'] == 'any personal email of greeting' + assert actual['error_code'] == 0 + + +def test_delete_mail_config(): + response = client.delete( + f"/api/bot/{pytest.bot}/mail/config/greet", + headers={"Authorization": pytest.token_type + " " + pytest.access_token}, + ) + + actual = response.json() + assert actual['success'] + assert actual['message'] == 'Config deleted!' + + response = client.get( + f"/api/bot/{pytest.bot}/mail/config", + headers={"Authorization": pytest.token_type + " " + pytest.access_token}, + ) + actual = response.json() + print(actual) + assert actual["success"] + assert len(actual['data']) == 0 + + + + def test_callback_config_add_syntax_error(): request_body = { "name": "callback_1", @@ -4746,6 +4869,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 = { @@ -23867,6 +23991,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 d9dffe97b..22d24b5b2 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..b8ad61c5d --- /dev/null +++ b/tests/unit_test/channels/mail_channel_test.py @@ -0,0 +1,740 @@ +import asyncio +import os +from unittest.mock import patch, MagicMock + +import pytest +from imap_tools import MailMessage + +from mongoengine import connect, disconnect + +from kairon import Utility +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.chat.processor import ChatDataProcessor +from kairon.shared.data.data_objects import BotSettings + +from kairon.shared.channels.mail.data_objects import MailClassificationConfig +from kairon.exceptions import AppException +from kairon.shared.constants import ChannelTypes + + + +class TestMailChannel: + @pytest.fixture(autouse=True, scope='class') + def setup(self): + connect(**Utility.mongoengine_connection(Utility.environment['database']["url"])) + + yield + + self.remove_basic_data() + disconnect() + + def create_basic_data(self): + 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) + b = BotSettings.objects.create(bot=pytest.mail_test_bot, user="mail_channel_test_user_acc") + # b.llm_settings.enable_faq = True + b.save() + ChatDataProcessor.save_channel_config( + { + "connector_type": ChannelTypes.MAIL.value, + "config": { + 'email_account': "mail_channel_test_user_acc@testuser.com", + 'email_password': "password", + 'imap_server': "imap.testuser.com", + 'smtp_server': "smtp.testuser.com", + 'smtp_port': "587", + } + }, + pytest.mail_test_bot, + user="mail_channel_test_user_acc", + ) + + def remove_basic_data(self): + MailClassificationConfig.objects.delete() + 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() + + @patch("kairon.shared.utils.Utility.execute_http_request") + def test_create_doc_new_entry(self, execute_http_request): + self.create_basic_data() + execute_http_request.return_value = {"success": True} + print(pytest.mail_test_bot) + doc = MailClassificationConfig.create_doc( + intent="greeting", + entities=["user_name"], + subjects=["hello"], + classification_prompt="Classify this email as a greeting.", + reply_template="Hi, how can I help?", + bot=pytest.mail_test_bot, + user="mail_channel_test_user_acc" + ) + assert doc.intent == "greeting" + assert doc.bot == pytest.mail_test_bot + assert doc.status is True + MailClassificationConfig.objects.delete() + + + + def test_create_doc_existing_active_entry(self): + MailClassificationConfig.create_doc( + intent="greeting", + entities=["user_name"], + subjects=["hello"], + classification_prompt="Classify this email as a greeting.", + reply_template="Hi, how can I help?", + bot=pytest.mail_test_bot, + user="mail_channel_test_user_acc" + ) + with pytest.raises(AppException, match=r"Mail configuration already exists for intent \[greeting\]"): + MailClassificationConfig.create_doc( + intent="greeting", + entities=["user_email"], + subjects=["hi"], + classification_prompt="Another greeting.", + reply_template="Hello!", + bot=pytest.mail_test_bot, + user="mail_channel_test_user_acc" + ) + MailClassificationConfig.objects.delete() + + + + def test_get_docs(self): + MailClassificationConfig.create_doc( + intent="greeting", + entities=["user_name"], + subjects=["hello"], + classification_prompt="Classify this email as a greeting.", + reply_template="Hi, how can I help?", + bot=pytest.mail_test_bot, + user="mail_channel_test_user_acc" + ) + MailClassificationConfig.create_doc( + intent="goodbye", + entities=["farewell"], + subjects=["bye"], + classification_prompt="Classify this email as a goodbye.", + reply_template="Goodbye!", + bot=pytest.mail_test_bot, + user="mail_channel_test_user_acc" + ) + docs = MailClassificationConfig.get_docs(bot=pytest.mail_test_bot) + assert len(docs) == 2 + assert docs[0]["intent"] == "greeting" + assert docs[1]["intent"] == "goodbye" + MailClassificationConfig.objects.delete() + + + + def test_get_doc(self): + MailClassificationConfig.create_doc( + intent="greeting", + entities=["user_name"], + subjects=["hello"], + classification_prompt="Classify this email as a greeting.", + reply_template="Hi, how can I help?", + bot=pytest.mail_test_bot, + user="mail_channel_test_user_acc" + ) + doc = MailClassificationConfig.get_doc(bot=pytest.mail_test_bot, intent="greeting") + assert doc["intent"] == "greeting" + assert doc["classification_prompt"] == "Classify this email as a greeting." + MailClassificationConfig.objects.delete() + + + def test_get_doc_nonexistent(self): + """Test retrieving a non-existent document.""" + with pytest.raises(AppException, match=r"Mail configuration does not exist for intent \[greeting\]"): + MailClassificationConfig.get_doc(bot=pytest.mail_test_bot, intent="greeting") + + MailClassificationConfig.objects.delete() + + + def test_delete_doc(self): + """Test deleting a document.""" + MailClassificationConfig.create_doc( + intent="greeting", + entities=["user_name"], + subjects=["hello"], + classification_prompt="Classify this email as a greeting.", + reply_template="Hi, how can I help?", + bot=pytest.mail_test_bot, + user="mail_channel_test_user_acc" + ) + MailClassificationConfig.delete_doc(bot=pytest.mail_test_bot, intent="greeting") + with pytest.raises(AppException, match=r"Mail configuration does not exist for intent \[greeting\]"): + MailClassificationConfig.get_doc(bot=pytest.mail_test_bot, intent="greeting") + + MailClassificationConfig.objects.delete() + + + def test_soft_delete_doc(self): + MailClassificationConfig.create_doc( + intent="greeting", + entities=["user_name"], + subjects=["hello"], + classification_prompt="Classify this email as a greeting.", + reply_template="Hi, how can I help?", + bot=pytest.mail_test_bot, + user="mail_channel_test_user_acc" + ) + MailClassificationConfig.soft_delete_doc(bot=pytest.mail_test_bot, intent="greeting") + with pytest.raises(AppException, match=r"Mail configuration does not exist for intent \[greeting\]"): + MailClassificationConfig.get_doc(bot=pytest.mail_test_bot, intent="greeting") + + MailClassificationConfig.objects.delete() + + + + def test_update_doc(self): + MailClassificationConfig.create_doc( + intent="greeting", + entities=["user_name"], + subjects=["hello"], + classification_prompt="Classify this email as a greeting.", + reply_template="Hi, how can I help?", + bot=pytest.mail_test_bot, + user="mail_channel_test_user_acc" + ) + MailClassificationConfig.update_doc( + bot=pytest.mail_test_bot, + intent="greeting", + entities=["user_name", "greeting"], + reply_template="Hello there!" + ) + doc = MailClassificationConfig.get_doc(bot=pytest.mail_test_bot, intent="greeting") + assert doc["entities"] == ["user_name", "greeting"] + assert doc["reply_template"] == "Hello there!" + + MailClassificationConfig.objects.delete() + + def test_update_doc_invalid_key(self): + MailClassificationConfig.create_doc( + intent="greeting", + entities=["user_name"], + subjects=["hello"], + classification_prompt="Classify this email as a greeting.", + reply_template="Hi, how can I help?", + bot=pytest.mail_test_bot, + user="mail_channel_test_user_acc" + ) + with pytest.raises(AppException, match=r"Invalid key \[invalid_key\] provided for updating mail config"): + MailClassificationConfig.update_doc( + bot=pytest.mail_test_bot, + intent="greeting", + invalid_key="value" + ) + + MailClassificationConfig.objects.delete() + + + @patch("kairon.shared.channels.mail.processor.LLMProcessor") + @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, mock_llm_processor): + self.create_basic_data() + 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_llm_processor_instance = MagicMock() + mock_llm_processor.return_value = mock_llm_processor_instance + + 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") + mock_llm_processor.assert_called_once_with(bot_id, mp.llm_type) + + + + @patch("kairon.shared.channels.mail.processor.LLMProcessor") + @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, mock_llm_processor): + self.create_basic_data() + 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_llm_processor_instance = MagicMock() + mock_llm_processor.return_value = mock_llm_processor_instance + + 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.channels.mail.processor.LLMProcessor") + @patch("kairon.shared.chat.processor.ChatDataProcessor.get_channel_config") + def test_login_smtp(self, mock_get_channel_config, mock_llm_processor, mock_smtp): + # Arrange + mock_smtp_instance = MagicMock() + mock_smtp.return_value = mock_smtp_instance + + mock_llm_processor_instance = MagicMock() + mock_llm_processor.return_value = mock_llm_processor_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.channels.mail.processor.LLMProcessor") + @patch("kairon.shared.chat.processor.ChatDataProcessor.get_channel_config") + def test_logout_smtp(self, mock_get_channel_config, mock_llm_processor, mock_smtp): + mock_smtp_instance = MagicMock() + mock_smtp.return_value = mock_smtp_instance + + mock_llm_processor_instance = MagicMock() + mock_llm_processor.return_value = mock_llm_processor_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.channels.mail.processor.LLMProcessor") + @patch("kairon.shared.chat.processor.ChatDataProcessor.get_channel_config") + @pytest.mark.asyncio + async def test_send_mail(self, mock_get_channel_config, mock_llm_processor, mock_smtp): + mock_smtp_instance = MagicMock() + mock_smtp.return_value = mock_smtp_instance + + mock_llm_processor_instance = MagicMock() + mock_llm_processor.return_value = mock_llm_processor_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() + + await mp.send_mail("recipient@test.com", "Test Subject", "Test Body") + + 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.MailClassificationConfig") + @patch("kairon.shared.channels.mail.processor.LLMProcessor") + @patch("kairon.shared.channels.mail.processor.ChatDataProcessor.get_channel_config") + def test_process_mail(self, mock_get_channel_config, llm_processor, mock_mail_classification_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" + } + } + + bot_id = pytest.mail_test_bot + mp = MailProcessor(bot=bot_id) + mp.mail_configs_dict = { + "greeting": MagicMock(reply_template="Hello {name}, {bot_response}") + } + + rasa_chat_response = { + "slots": ["name: John Doe"], + "response": [{"text": "How can I help you today?"}] + } + + result = mp.process_mail("greeting", rasa_chat_response) + + assert result == "Hello John Doe, How can I help you today?" + + rasa_chat_response = { + "slots": ["name: John Doe"], + "response": [{"text": "How can I help you today?"}] + } + mp.mail_configs_dict = {} # No template for the intent + result = mp.process_mail("greeting", rasa_chat_response) + assert result == MailConstants.DEFAULT_TEMPLATE.format(name="John Doe", bot_response="How can I help you today?") + + + + @patch("kairon.shared.channels.mail.processor.LLMProcessor") + @patch("kairon.shared.channels.mail.processor.ChatDataProcessor.get_channel_config") + @patch("kairon.shared.channels.mail.processor.BotSettings.objects") + @patch("kairon.shared.channels.mail.processor.MailClassificationConfig.objects") + @patch("kairon.shared.channels.mail.processor.Bot.objects") + @pytest.mark.asyncio + async def test_classify_messages(self, mock_bot_objects, mock_mail_classification_config_objects, + mock_bot_settings_objects, mock_get_channel_config, mock_llm_processor): + mock_get_channel_config.return_value = { + 'config': { + 'email_account': "mail_channel_test_user_acc@testuser.com", + 'email_password': "password", + 'imap_server': "imap.testuser.com", + 'llm_type': "openai", + 'hyperparameters': MailConstants.DEFAULT_HYPERPARAMETERS, + 'system_prompt': "Test system prompt" + } + } + + mock_bot_settings = MagicMock() + mock_bot_settings.llm_settings = {'enable_faq': True} + mock_bot_settings_objects.get.return_value = mock_bot_settings + + mock_bot = MagicMock() + mock_bot_objects.get.return_value = mock_bot + + mock_llm_processor_instance = MagicMock() + mock_llm_processor.return_value = mock_llm_processor_instance + + future = asyncio.Future() + future.set_result({"content": '[{"intent": "greeting", "entities": {"name": "John Doe"}, "mail_id": "123", "subject": "Hello"}]'}) + mock_llm_processor_instance.predict.return_value = future + + bot_id = pytest.mail_test_bot + mp = MailProcessor(bot=bot_id) + + messages = [{"mail_id": "123", "subject": "Hello", "body": "Hi there"}] + + result = await mp.classify_messages(messages) + + assert result == [{"intent": "greeting", "entities": {"name": "John Doe"}, "mail_id": "123", "subject": "Hello"}] + mock_llm_processor_instance.predict.assert_called_once() + + + @patch("kairon.shared.channels.mail.processor.LLMProcessor") + def test_get_context_prompt(self, llm_processor): + bot_id = pytest.mail_test_bot + mail_configs = [ + { + 'intent': 'greeting', + 'entities': 'name', + 'subjects': 'Hello', + 'classification_prompt': 'If the email says hello, classify it as greeting' + }, + { + 'intent': 'farewell', + 'entities': 'name', + 'subjects': 'Goodbye', + 'classification_prompt': 'If the email says goodbye, classify it as farewell' + } + ] + + mp = MailProcessor(bot=bot_id) + mp.mail_configs = mail_configs + + expected_context_prompt = ( + "intent: greeting \n" + "entities: name \n" + "\nclassification criteria: \n" + "subjects: Hello \n" + "rule: If the email says hello, classify it as greeting \n\n\n" + "intent: farewell \n" + "entities: name \n" + "\nclassification criteria: \n" + "subjects: Goodbye \n" + "rule: If the email says goodbye, classify it as farewell \n\n\n" + ) + + context_prompt = mp.get_context_prompt() + + assert context_prompt == expected_context_prompt + + + def test_extract_jsons_from_text(self): + text = ''' + Here is some text with JSON objects. + {"key1": "value1", "key2": "value2"} + Some more text. + [{"key3": "value3"}, {"key4": "value4"}] + And some final text. + ''' + expected_output = [ + {"key1": "value1", "key2": "value2"}, + [{"key3": "value3"}, {"key4": "value4"}] + ] + + result = MailProcessor.extract_jsons_from_text(text) + + assert result == expected_output + + + + + + @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.channels.mail.processor.LLMProcessor") + @patch("kairon.shared.chat.processor.ChatDataProcessor.get_channel_config") + @pytest.mark.asyncio + async def test_read_mails(self, mock_get_channel_config, mock_llm_processor, + 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", + 'llm_type': "openai", + 'hyperparameters': MailConstants.DEFAULT_HYPERPARAMETERS, + } + } + + mock_llm_processor_instance = MagicMock() + mock_llm_processor.return_value = mock_llm_processor_instance + + + 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.channels.mail.processor.LLMProcessor") + @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_llm_processor, + 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", + 'llm_type': "openai", + 'hyperparameters': MailConstants.DEFAULT_HYPERPARAMETERS, + } + } + + mock_llm_processor_instance = MagicMock() + mock_llm_processor.return_value = mock_llm_processor_instance + + + 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.channels.mail.processor.LLMProcessor") + @patch("kairon.shared.chat.processor.ChatDataProcessor.get_channel_config") + @pytest.mark.asyncio + async def test_classify_messages_invalid_llm_response(self, mock_get_channel_config, mock_llm_processor): + mock_llm_processor_instance = MagicMock() + mock_llm_processor.return_value = mock_llm_processor_instance + + future = asyncio.Future() + future.set_result({"content": 'invalid json content'}) + mock_llm_processor_instance.predict.return_value = future + + mp = MailProcessor(bot=pytest.mail_test_bot) + messages = [{"mail_id": "123", "subject": "Hello", "body": "Hi there"}] + + + ans = await mp.classify_messages(messages) + assert not ans + + + @patch("kairon.shared.channels.mail.processor.MailProcessor.classify_messages") + @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") + @patch("kairon.shared.channels.mail.processor.LLMProcessor") + @pytest.mark.asyncio + async def test_process_messages(self, mock_llm_processor, mock_process_messages_via_bot, mock_send_mail, mock_logout_smtp, mock_login_smtp, + mock_classify_messages): + + + # Arrange + bot = pytest.mail_test_bot + batch = [{"mail_id": "test@example.com", "subject": "Test Subject", "date": "2023-10-10", "body": "Test Body"}] + mock_classify_messages.return_value = [{ + "intent": "test_intent", + "entities": {"entity_name": "value"}, + "mail_id": "test@example.com", + "subject": "Test Subject", + "name": "spandan" + }] + mock_process_messages_via_bot.return_value = [{ + "slots": ["name: spandan"], + "response": [{"text": "Test Response"}] + }] + + mock_llm_processor_instance = MagicMock() + mock_llm_processor.return_value = mock_llm_processor_instance + + # Act + await MailProcessor.process_messages(bot, batch) + + # Assert + mock_classify_messages.assert_called_once_with(batch) + 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() + + @patch("kairon.shared.channels.mail.processor.MailProcessor.classify_messages") + @pytest.mark.asyncio + async def test_process_messages_exception(self, mock_classify_messages): + # Arrange + bot = "test_bot" + batch = [{"mail_id": "test@example.com", "subject": "Test Subject", "date": "2023-10-10", "body": "Test Body"}] + mock_classify_messages.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): + # Mock the login and logout methods to avoid actual SMTP server interaction + mp.return_value = None + mock_login_smtp.return_value = None + mock_logout_smtp.return_value = None + + # Call the static method validate_smpt_connection + result = MailProcessor.validate_smpt_connection('test_bot_id') + + # Assert that the method returns True + assert result + + # Assert that login_smtp and logout_smtp were called once + 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 the login method to raise an exception + mock_login_smtp.side_effect = Exception("SMTP login failed") + + # Call the static method validate_smpt_connection + result = MailProcessor.validate_smpt_connection('test_bot_id') + + # Assert that the method returns False + assert not result + + + + + + + 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..656d46093 --- /dev/null +++ b/tests/unit_test/channels/mail_scheduler_test.py @@ -0,0 +1,124 @@ +from datetime import datetime, timedelta + +import pytest +from unittest.mock import patch, MagicMock, AsyncMock +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 + } + +@pytest.mark.asyncio +async def test_mail_scheduler_epoch(setup_environment): + # Arrange + mock_scheduler = setup_environment['mock_scheduler'] + # MailScheduler.mail_queue_name = "test_queue" + MailScheduler.scheduler = mock_scheduler + + # Act + MailScheduler.epoch() + + # Assert + mock_scheduler.add_job.assert_called() + +def test_mail_scheduler_process_mails(setup_environment): + mock_read_mails = setup_environment['mock_read_mails'] + mock_scheduler = setup_environment['mock_scheduler'] + MailScheduler.scheduled_bots.add("test_bot_1") + MailScheduler.scheduler = mock_scheduler + + MailScheduler.process_mails("test_bot_1", mock_scheduler) + + mock_read_mails.assert_called_once_with('test_bot_1') + assert "test_bot_1" in MailScheduler.scheduled_bots + + +@pytest.fixture +def setup_environment2(): + 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", new_callable=AsyncMock) as mock_read_mails, \ + patch("apscheduler.jobstores.mongodb.MongoDBJobStore.__init__", return_value=None) as mock_jobstore_init: + + mock_client_instance = mock_client.return_value + mock_channels.return_value = MagicMock(values_list=MagicMock(return_value=[{'bot': 'test_bot_1'}, {'bot': 'test_bot_2'}])) + mock_read_mails.return_value = ([], 60) + + yield { + 'mock_client': mock_client_instance, + 'mock_channels': mock_channels, + 'mock_read_mails': mock_read_mails, + 'mock_jobstore_init': mock_jobstore_init, + } + + +@pytest.mark.asyncio +async def test_mail_scheduler_epoch_creates_scheduler(setup_environment2): + with patch("apscheduler.schedulers.background.BackgroundScheduler.start", autospec=True) as mock_start, \ + patch("apscheduler.schedulers.background.BackgroundScheduler.add_job", autospec=True) as mock_add_job: + MailScheduler.scheduler = None + + started = MailScheduler.epoch() + + assert started + assert MailScheduler.scheduler is not None + mock_start.assert_called_once() + + +@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_get_event_server_url.return_value = "http://localhost" + mock_execute_http_request.return_value = {'success': True} + + try: + MailScheduler.request_epoch() + except AppException: + pytest.fail("request_epoch() raised AppException unexpectedly!") + +@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() + + +@patch("kairon.shared.channels.mail.processor.MailProcessor.read_mails") +@patch("kairon.shared.channels.mail.scheduler.MailChannelScheduleEvent.enqueue") +@patch("kairon.shared.channels.mail.scheduler.datetime") +def test_read_mailbox_and_schedule_events(mock_datetime, mock_enqueue, mock_read_mails): + bot = "test_bot" + fixed_now = datetime(2024, 12, 1, 20, 41, 55, 390288) + mock_datetime.now.return_value = fixed_now + mock_read_mails.return_value = ([ + {"subject": "Test Subject", "mail_id": "test@example.com", "date": "2023-10-10", "body": "Test Body"} + ], "mail_channel_test_user_acc", 1200) + next_timestamp = MailScheduler.read_mailbox_and_schedule_events(bot) + mock_read_mails.assert_called_once_with(bot) + mock_enqueue.assert_called_once() + assert next_timestamp == fixed_now + timedelta(seconds=1200) \ 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..5cf37b51f 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,39 @@ 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.MailChannelScheduleEvent.execute") + def test_start_mail_channel(self, mock_execute): + from kairon.cli.mail_channel 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.MailChannelScheduleEvent.execute") + def test_start_mail_channel_wrong_format(self, mock_execute): + from kairon.cli.mail_channel 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() 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/events/definitions_test.py b/tests/unit_test/events/definitions_test.py index 020517f51..fccef2737 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,91 @@ 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_schedule import MailChannelScheduleEvent + bot = "test_add_schedule_event" + user = "test_user" + url = f"http://localhost:5001/api/events/execute/{EventClass.email_channel_scheduler}?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 = MailChannelScheduleEvent(bot, user) + status = event.validate() + assert status + + @responses.activate + def test_validate_mail_channel_schedule_event_fail(self): + from kairon.events.definitions.mail_channel_schedule import MailChannelScheduleEvent + bot = "test_add_schedule_event" + user = "test_user" + url = f"http://localhost:5001/api/events/execute/{EventClass.email_channel_scheduler}?is_scheduled=False" + responses.add( + "POST", url, + json={"message": "test msg", "success": True, "error_code": 400, "data": None} + ) + event = MailChannelScheduleEvent(bot, user) + status = event.validate() + assert not status + + + + @responses.activate + def test_trigger_mail_channel_schedule_event_enqueue(self): + from kairon.events.definitions.mail_channel_schedule import MailChannelScheduleEvent + bot = "test_add_schedule_event" + user = "test_user" + url = f"http://localhost:5001/api/events/execute/{EventClass.email_channel_scheduler}?is_scheduled=False" + responses.add( + "POST", url, + json={"message": "test msg", "success": True, "error_code": 400, "data": None} + ) + event = MailChannelScheduleEvent(bot, user) + try: + event.enqueue() + except AppException as e: + pytest.fail(f"Unexpected exception: {e}") + + @responses.activate + def test_trigger_mail_channel_schedule_event_enqueue_exception(self): + from kairon.events.definitions.mail_channel_schedule import MailChannelScheduleEvent + 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.email_channel_scheduler}?is_scheduled=False" + responses.add( + "POST", url, + json={"message": "test msg", "success": False, "error_code": 400, "data": None} + ) + event = MailChannelScheduleEvent(bot, user) + with pytest.raises(AppException, match="Failed to trigger email_channel_scheduler event: test msg"): + event.enqueue() + + @responses.activate + def test_trigger_mail_channel_schedule_event_execute(self): + from kairon.events.definitions.mail_channel_schedule import MailChannelScheduleEvent + try: + MailChannelScheduleEvent("", "").execute() + except AppException as e: + pytest.fail(f"Unexpected exception: {e}") + + @responses.activate + def test_trigger_mail_channel_schedule_event_execute_exception(self): + from kairon.events.definitions.mail_channel_schedule import MailChannelScheduleEvent + 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"): + MailChannelScheduleEvent("", "").execute(mails=["test@mail.com"]) \ 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