dhisana 0.0.1.dev304__tar.gz → 0.0.1.dev306__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/PKG-INFO +1 -1
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/setup.py +1 -1
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/schemas/common.py +7 -2
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/schemas/sales.py +2 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/generate_content.py +3 -0
- dhisana-0.0.1.dev306/src/dhisana/utils/generate_sms_whatsapp.py +253 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/google_oauth_tools.py +21 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/microsoft365_tools.py +29 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/smtp_email_tools.py +36 -1
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana.egg-info/PKG-INFO +1 -1
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana.egg-info/SOURCES.txt +3 -0
- dhisana-0.0.1.dev306/tests/test_generate_sms_whatsapp.py +260 -0
- dhisana-0.0.1.dev306/tests/test_send_email_recipients.py +403 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/README.md +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/pyproject.toml +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/setup.cfg +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/__init__.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/cli/__init__.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/cli/cli.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/cli/datasets.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/cli/models.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/cli/predictions.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/schemas/__init__.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/ui/__init__.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/ui/components.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/__init__.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/add_mapping.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/agent_tools.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/apollo_tools.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/assistant_tool_tag.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/built_with_api_tools.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/cache_output_tools.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/cache_output_tools_local.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/check_email_validity_tools.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/check_for_intent_signal.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/check_linkedin_url_validity.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/clay_tools.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/clean_properties.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/company_utils.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/compose_salesnav_query.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/compose_search_query.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/compose_three_step_workflow.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/composite_tools.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/dataframe_tools.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/domain_parser.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/email_body_utils.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/email_parse_helpers.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/email_provider.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/enrich_lead_information.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/extract_email_content_for_llm.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/fetch_openai_config.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/field_validators.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/g2_tools.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/generate_custom_message.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/generate_email.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/generate_email_response.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/generate_flow.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/generate_leads_salesnav.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/generate_linkedin_connect_message.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/generate_linkedin_response_message.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/generate_structured_output_internal.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/google_custom_search.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/google_workspace_tools.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/hubspot_clearbit.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/hubspot_crm_tools.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/instantly_tools.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/linkedin_crawler.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/lusha_tools.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/mailgun_tools.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/mailreach_tools.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/openai_assistant_and_file_utils.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/openai_helpers.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/openapi_spec_to_tools.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/openapi_tool/__init__.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/openapi_tool/api_models.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/openapi_tool/convert_openai_spec_to_tool.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/openapi_tool/openapi_tool.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/parse_linkedin_messages_txt.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/profile.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/proxy_curl_tools.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/proxycurl_search_leads.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/python_function_to_tools.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/research_lead.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/sales_navigator_crawler.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/salesforce_crm_tools.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/search_router.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/search_router_jobs.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/sendgrid_tools.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/serarch_router_local_business.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/serpapi_additional_tools.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/serpapi_google_jobs.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/serpapi_google_search.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/serpapi_local_business_search.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/serpapi_search_tools.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/serperdev_google_jobs.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/serperdev_local_business.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/serperdev_search.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/test_connect.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/trasform_json.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/web_download_parse_tools.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/workflow_code_model.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/zoominfo_tools.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/workflow/__init__.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/workflow/agent.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/workflow/flow.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/workflow/task.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/workflow/test.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana.egg-info/dependency_links.txt +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana.egg-info/entry_points.txt +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana.egg-info/requires.txt +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana.egg-info/top_level.txt +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/tests/test_agent_tools.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/tests/test_apollo_company_search.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/tests/test_apollo_lead_search.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/tests/test_connectivity.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/tests/test_email_body_utils.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/tests/test_generate_email.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/tests/test_google_document.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/tests/test_hubspot_call_logs.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/tests/test_linkedin_serper.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/tests/test_mailreach.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/tests/test_mcp_connectivity.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/tests/test_normalize_graph_datetime.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/tests/test_proxycurl_get_company_search_id.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/tests/test_proxycurl_job_count.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/tests/test_reply_thread_fallback.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/tests/test_structured_output_with_mcp.py +0 -0
|
@@ -380,19 +380,24 @@ class SendEmailContext(BaseModel):
|
|
|
380
380
|
body_format: BodyFormat = BodyFormat.AUTO
|
|
381
381
|
headers: Optional[Dict[str, str]] = None
|
|
382
382
|
email_open_token: Optional[str] = None
|
|
383
|
-
|
|
383
|
+
to_recipients: Optional[List["EmailRecipient"]] = None
|
|
384
|
+
cc_recipients: Optional[List["EmailRecipient"]] = None
|
|
385
|
+
bcc_recipients: Optional[List["EmailRecipient"]] = None
|
|
386
|
+
|
|
384
387
|
class QueryEmailContext(BaseModel):
|
|
385
388
|
start_time: str
|
|
386
389
|
end_time: str
|
|
387
390
|
sender_email: str
|
|
388
391
|
unread_only: bool = True
|
|
389
392
|
labels: Optional[List[str]] = None
|
|
390
|
-
|
|
393
|
+
|
|
391
394
|
|
|
392
395
|
class EmailRecipient(BaseModel):
|
|
393
396
|
email: str
|
|
394
397
|
name: Optional[str] = None
|
|
395
398
|
|
|
399
|
+
SendEmailContext.model_rebuild()
|
|
400
|
+
|
|
396
401
|
|
|
397
402
|
class ReplyEmailContext(BaseModel):
|
|
398
403
|
"""Context for replying to or forwarding an email."""
|
|
@@ -6,6 +6,7 @@ from dhisana.utils.assistant_tool_tag import assistant_tool
|
|
|
6
6
|
from dhisana.utils.generate_email import generate_personalized_email
|
|
7
7
|
from dhisana.utils.generate_linkedin_response_message import get_linkedin_response_message_variations
|
|
8
8
|
from dhisana.utils.generate_custom_message import generate_custom_message
|
|
9
|
+
from dhisana.utils.generate_sms_whatsapp import generate_sms_whatsapp_message
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
@assistant_tool
|
|
@@ -36,6 +37,8 @@ async def generate_content(
|
|
|
36
37
|
return await generate_inbound_email_response_variations(generation_context, number_of_variations, tool_config)
|
|
37
38
|
elif generation_context.target_channel_type == ChannelType.LINKEDIN_USER_MESSAGE.value:
|
|
38
39
|
return await get_linkedin_response_message_variations(generation_context, number_of_variations, tool_config)
|
|
40
|
+
elif generation_context.target_channel_type in (ChannelType.SMS.value, ChannelType.WHATSAPP.value):
|
|
41
|
+
return await generate_sms_whatsapp_message(generation_context, number_of_variations, tool_config)
|
|
39
42
|
else:
|
|
40
43
|
# Default to CUSTOM_MESSAGE for any unrecognized channel type
|
|
41
44
|
return await generate_custom_message(generation_context, number_of_variations, tool_config)
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from datetime import datetime, timezone
|
|
3
|
+
from typing import Dict, List, Optional
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict
|
|
6
|
+
|
|
7
|
+
from dhisana.schemas.sales import (
|
|
8
|
+
CampaignContext,
|
|
9
|
+
ChannelType,
|
|
10
|
+
ContentGenerationContext,
|
|
11
|
+
ConversationContext,
|
|
12
|
+
Lead,
|
|
13
|
+
MessageGenerationInstructions,
|
|
14
|
+
MessageItem,
|
|
15
|
+
SenderInfo,
|
|
16
|
+
)
|
|
17
|
+
from dhisana.utils.generate_email import _sanitize_lead_for_prompt
|
|
18
|
+
from dhisana.utils.generate_structured_output_internal import (
|
|
19
|
+
get_structured_output_internal,
|
|
20
|
+
get_structured_output_with_assistant_and_vector_store,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
DEFAULT_SMS_GEN_MODEL = "gpt-5.4"
|
|
24
|
+
|
|
25
|
+
SMS_CHAR_LIMIT = 160
|
|
26
|
+
WHATSAPP_CHAR_LIMIT = 1000
|
|
27
|
+
|
|
28
|
+
# Standard opt-out suffix appended to every generated message.
|
|
29
|
+
OPT_OUT_SUFFIX_SMS = "\nReply STOP to opt out."
|
|
30
|
+
OPT_OUT_SUFFIX_WHATSAPP = "\nReply STOP to opt out."
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SMSWhatsAppCopy(BaseModel):
|
|
34
|
+
body: str
|
|
35
|
+
|
|
36
|
+
model_config = ConfigDict(extra="forbid")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _effective_char_limit(channel_type: ChannelType) -> int:
|
|
40
|
+
if channel_type == ChannelType.WHATSAPP:
|
|
41
|
+
return WHATSAPP_CHAR_LIMIT
|
|
42
|
+
return SMS_CHAR_LIMIT
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _opt_out_suffix(channel_type: ChannelType) -> str:
|
|
46
|
+
if channel_type == ChannelType.WHATSAPP:
|
|
47
|
+
return OPT_OUT_SUFFIX_WHATSAPP
|
|
48
|
+
return OPT_OUT_SUFFIX_SMS
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# Matches explicit opt-out instructions like "Reply STOP", "Text STOP to …",
|
|
52
|
+
# "opt out", or "unsubscribe" — requires an action verb before "stop" so
|
|
53
|
+
# normal prose like "stop wasting time" does not suppress the footer.
|
|
54
|
+
_OPT_OUT_RE = re.compile(
|
|
55
|
+
r"(?:reply|text|send|message|say)\s+stop\b"
|
|
56
|
+
r"|\bopt[\s\-]?out\b"
|
|
57
|
+
r"|\bunsubscribe\b",
|
|
58
|
+
re.IGNORECASE,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _ensure_opt_out(body: str, channel_type: ChannelType) -> str:
|
|
63
|
+
suffix = _opt_out_suffix(channel_type)
|
|
64
|
+
if _OPT_OUT_RE.search(body):
|
|
65
|
+
return body
|
|
66
|
+
return body.rstrip() + suffix
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _cleanup_context(
|
|
70
|
+
ctx: ContentGenerationContext,
|
|
71
|
+
) -> ContentGenerationContext:
|
|
72
|
+
clone = ctx.model_copy(deep=True)
|
|
73
|
+
if clone.external_known_data:
|
|
74
|
+
clone.external_known_data.external_openai_vector_store_id = None
|
|
75
|
+
return clone
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
async def generate_sms_whatsapp_copy(
|
|
79
|
+
message_context: ContentGenerationContext,
|
|
80
|
+
message_instructions: MessageGenerationInstructions,
|
|
81
|
+
variation_text: str,
|
|
82
|
+
channel_type: ChannelType,
|
|
83
|
+
tool_config: Optional[List[Dict]] = None,
|
|
84
|
+
) -> dict:
|
|
85
|
+
cleaned = _cleanup_context(message_context)
|
|
86
|
+
|
|
87
|
+
user_instructions = (
|
|
88
|
+
message_instructions.instructions_to_generate_message or ""
|
|
89
|
+
).strip()
|
|
90
|
+
selected_instructions = user_instructions if user_instructions else variation_text
|
|
91
|
+
|
|
92
|
+
lead_data = cleaned.lead_info or Lead()
|
|
93
|
+
sender_data = cleaned.sender_info or SenderInfo()
|
|
94
|
+
campaign_data = cleaned.campaign_context or CampaignContext()
|
|
95
|
+
conversation_data = (
|
|
96
|
+
cleaned.current_conversation_context or ConversationContext()
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
char_limit = _effective_char_limit(channel_type)
|
|
100
|
+
opt_out_suffix = _opt_out_suffix(channel_type)
|
|
101
|
+
usable_chars = char_limit - len(opt_out_suffix)
|
|
102
|
+
channel_label = "WhatsApp" if channel_type == ChannelType.WHATSAPP else "SMS"
|
|
103
|
+
|
|
104
|
+
prompt = f"""You are an AI sales copywriter. Write exactly one personalized {channel_label} message on behalf of the sender to the lead described below.
|
|
105
|
+
|
|
106
|
+
## Channel Rules — {channel_label}
|
|
107
|
+
|
|
108
|
+
1. The message body (excluding the compliance footer) MUST be {usable_chars} characters or fewer.
|
|
109
|
+
2. Plain text only — no HTML, no markdown, no emojis unless the instructions ask for them.
|
|
110
|
+
3. No subject line — output only a "body" field.
|
|
111
|
+
4. Be concise and conversational. Lead with value, not a greeting. Avoid filler.
|
|
112
|
+
5. Do NOT include any opt-out / STOP language — the system appends it automatically.
|
|
113
|
+
6. Do NOT include phone numbers, email addresses, or links unless the writing instructions explicitly request a specific provided URL.
|
|
114
|
+
7. Ground every claim in the provided context. Never fabricate information.
|
|
115
|
+
8. Do not use em dashes.
|
|
116
|
+
|
|
117
|
+
## Lead
|
|
118
|
+
|
|
119
|
+
Name: {lead_data.first_name or ''} {lead_data.last_name or ''}
|
|
120
|
+
Organization: {lead_data.organization_name or ''}
|
|
121
|
+
Title: {lead_data.job_title or ''}
|
|
122
|
+
|
|
123
|
+
## Sender (closed set — use ONLY these facts)
|
|
124
|
+
|
|
125
|
+
- Full Name: {sender_data.sender_full_name or ''}
|
|
126
|
+
- First Name: {sender_data.sender_first_name or ''}
|
|
127
|
+
- Last Name: {sender_data.sender_last_name or ''}
|
|
128
|
+
- Bio: {sender_data.sender_bio or ''}
|
|
129
|
+
|
|
130
|
+
## Campaign
|
|
131
|
+
|
|
132
|
+
- Product: {campaign_data.product_name or ''}
|
|
133
|
+
- Value Proposition: {campaign_data.value_prop or ''}
|
|
134
|
+
- Call to Action: {campaign_data.call_to_action or ''}
|
|
135
|
+
- Pain Points: {campaign_data.pain_points or []}
|
|
136
|
+
- Proof Points: {campaign_data.proof_points or []}
|
|
137
|
+
|
|
138
|
+
## Writing Instructions
|
|
139
|
+
|
|
140
|
+
{selected_instructions}
|
|
141
|
+
|
|
142
|
+
## Conversation History
|
|
143
|
+
|
|
144
|
+
{conversation_data.current_email_thread or ''}
|
|
145
|
+
{conversation_data.current_linkedin_thread or ''}
|
|
146
|
+
|
|
147
|
+
## Output Format
|
|
148
|
+
|
|
149
|
+
Return JSON with a single field: "body" (string, plain text, max {usable_chars} chars).
|
|
150
|
+
|
|
151
|
+
## Additional Lead Context (reference only)
|
|
152
|
+
|
|
153
|
+
{_sanitize_lead_for_prompt(lead_data)}
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
vector_store_id = (
|
|
157
|
+
message_context.external_known_data.external_openai_vector_store_id
|
|
158
|
+
if message_context.external_known_data
|
|
159
|
+
else None
|
|
160
|
+
)
|
|
161
|
+
use_cache = (
|
|
162
|
+
message_context.message_instructions.use_cache
|
|
163
|
+
if message_context.message_instructions
|
|
164
|
+
else True
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
if vector_store_id:
|
|
168
|
+
response, status = (
|
|
169
|
+
await get_structured_output_with_assistant_and_vector_store(
|
|
170
|
+
prompt=prompt,
|
|
171
|
+
response_format=SMSWhatsAppCopy,
|
|
172
|
+
vector_store_id=vector_store_id,
|
|
173
|
+
model=DEFAULT_SMS_GEN_MODEL,
|
|
174
|
+
effort="medium",
|
|
175
|
+
tool_config=tool_config,
|
|
176
|
+
use_cache=use_cache,
|
|
177
|
+
)
|
|
178
|
+
)
|
|
179
|
+
else:
|
|
180
|
+
response, status = await get_structured_output_internal(
|
|
181
|
+
prompt=prompt,
|
|
182
|
+
response_format=SMSWhatsAppCopy,
|
|
183
|
+
model=DEFAULT_SMS_GEN_MODEL,
|
|
184
|
+
effort="medium",
|
|
185
|
+
tool_config=tool_config,
|
|
186
|
+
use_cache=use_cache,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
if status != "SUCCESS":
|
|
190
|
+
raise Exception(
|
|
191
|
+
f"Error: Could not generate {channel_label} message."
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
body = (response.body or "").strip()
|
|
195
|
+
# Truncate if model exceeded the limit
|
|
196
|
+
if len(body) > usable_chars:
|
|
197
|
+
body = body[:usable_chars].rsplit(" ", 1)[0]
|
|
198
|
+
body = _ensure_opt_out(body, channel_type)
|
|
199
|
+
|
|
200
|
+
response_item = MessageItem(
|
|
201
|
+
message_id="",
|
|
202
|
+
thread_id="",
|
|
203
|
+
sender_name=sender_data.sender_full_name or "",
|
|
204
|
+
sender_email=sender_data.sender_email or "",
|
|
205
|
+
receiver_name=(message_context.lead_info.full_name or "") if message_context.lead_info else "",
|
|
206
|
+
receiver_email=(message_context.lead_info.email or "") if message_context.lead_info else "",
|
|
207
|
+
iso_datetime=datetime.now(timezone.utc).isoformat(),
|
|
208
|
+
subject="",
|
|
209
|
+
body=body,
|
|
210
|
+
html_body=None,
|
|
211
|
+
)
|
|
212
|
+
return response_item.model_dump()
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
FRAMEWORK_VARIATIONS_SMS = [
|
|
216
|
+
"Write a brief, personalized outreach message. Lead with a relevant insight about the prospect's company.",
|
|
217
|
+
"Write a short value-first message — mention one pain point and how the product helps.",
|
|
218
|
+
"Write a concise, curiosity-driven message with a clear call to action.",
|
|
219
|
+
]
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
async def generate_sms_whatsapp_message(
|
|
223
|
+
generation_context: ContentGenerationContext,
|
|
224
|
+
number_of_variations: int = 3,
|
|
225
|
+
tool_config: Optional[List[Dict]] = None,
|
|
226
|
+
) -> List[dict]:
|
|
227
|
+
channel_type = generation_context.target_channel_type or ChannelType.SMS
|
|
228
|
+
message_instructions = generation_context.message_instructions
|
|
229
|
+
user_instructions_exist = bool(
|
|
230
|
+
(message_instructions.instructions_to_generate_message or "").strip()
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
variations: List[dict] = []
|
|
234
|
+
for i in range(number_of_variations):
|
|
235
|
+
if user_instructions_exist:
|
|
236
|
+
variation_text = (
|
|
237
|
+
message_instructions.instructions_to_generate_message or ""
|
|
238
|
+
)
|
|
239
|
+
else:
|
|
240
|
+
variation_text = FRAMEWORK_VARIATIONS_SMS[
|
|
241
|
+
i % len(FRAMEWORK_VARIATIONS_SMS)
|
|
242
|
+
]
|
|
243
|
+
|
|
244
|
+
copy = await generate_sms_whatsapp_copy(
|
|
245
|
+
message_context=generation_context,
|
|
246
|
+
message_instructions=message_instructions,
|
|
247
|
+
variation_text=variation_text,
|
|
248
|
+
channel_type=channel_type,
|
|
249
|
+
tool_config=tool_config,
|
|
250
|
+
)
|
|
251
|
+
variations.append(copy)
|
|
252
|
+
|
|
253
|
+
return variations
|
|
@@ -210,6 +210,27 @@ async def send_email_using_google_oauth_async(
|
|
|
210
210
|
message["from"] = f"{send_email_context.sender_name} <{send_email_context.sender_email}>"
|
|
211
211
|
message["subject"] = send_email_context.subject
|
|
212
212
|
|
|
213
|
+
# Add extra To recipients
|
|
214
|
+
extra_to = getattr(send_email_context, "to_recipients", None) or []
|
|
215
|
+
if extra_to:
|
|
216
|
+
additional = [r.email if hasattr(r, "email") else (r.get("email") if isinstance(r, dict) else str(r)) for r in extra_to]
|
|
217
|
+
additional = [a for a in additional if a and a.lower() != send_email_context.recipient.strip().lower()]
|
|
218
|
+
if additional:
|
|
219
|
+
del message["to"]
|
|
220
|
+
message["to"] = ", ".join([send_email_context.recipient] + additional)
|
|
221
|
+
|
|
222
|
+
# Add CC recipients
|
|
223
|
+
cc_list = getattr(send_email_context, "cc_recipients", None) or []
|
|
224
|
+
if cc_list:
|
|
225
|
+
cc_addrs = [r.email if hasattr(r, "email") else (r.get("email") if isinstance(r, dict) else str(r)) for r in cc_list]
|
|
226
|
+
message["cc"] = ", ".join(a for a in cc_addrs if a)
|
|
227
|
+
|
|
228
|
+
# Add BCC recipients (header set for envelope; Gmail strips it before delivery)
|
|
229
|
+
bcc_list = getattr(send_email_context, "bcc_recipients", None) or []
|
|
230
|
+
if bcc_list:
|
|
231
|
+
bcc_addrs = [r.email if hasattr(r, "email") else (r.get("email") if isinstance(r, dict) else str(r)) for r in bcc_list]
|
|
232
|
+
message["bcc"] = ", ".join(a for a in bcc_addrs if a)
|
|
233
|
+
|
|
213
234
|
extra_headers = getattr(send_email_context, "headers", None) or {}
|
|
214
235
|
for header, value in extra_headers.items():
|
|
215
236
|
if not header or value is None:
|
|
@@ -227,6 +227,35 @@ async def send_email_using_microsoft_graph_async(
|
|
|
227
227
|
],
|
|
228
228
|
}
|
|
229
229
|
|
|
230
|
+
# Merge additional To recipients from the new field
|
|
231
|
+
extra_to = getattr(send_email_context, "to_recipients", None) or []
|
|
232
|
+
for r in extra_to:
|
|
233
|
+
addr = r.email if hasattr(r, "email") else (r.get("email") if isinstance(r, dict) else str(r))
|
|
234
|
+
if addr and addr.lower() != send_email_context.recipient.strip().lower():
|
|
235
|
+
message_payload["toRecipients"].append({"emailAddress": {"address": addr}})
|
|
236
|
+
|
|
237
|
+
# CC recipients
|
|
238
|
+
cc_list = getattr(send_email_context, "cc_recipients", None) or []
|
|
239
|
+
if cc_list:
|
|
240
|
+
cc_entries = []
|
|
241
|
+
for r in cc_list:
|
|
242
|
+
addr = r.email if hasattr(r, "email") else (r.get("email") if isinstance(r, dict) else str(r))
|
|
243
|
+
if addr:
|
|
244
|
+
cc_entries.append({"emailAddress": {"address": addr}})
|
|
245
|
+
if cc_entries:
|
|
246
|
+
message_payload["ccRecipients"] = cc_entries
|
|
247
|
+
|
|
248
|
+
# BCC recipients
|
|
249
|
+
bcc_list = getattr(send_email_context, "bcc_recipients", None) or []
|
|
250
|
+
if bcc_list:
|
|
251
|
+
bcc_entries = []
|
|
252
|
+
for r in bcc_list:
|
|
253
|
+
addr = r.email if hasattr(r, "email") else (r.get("email") if isinstance(r, dict) else str(r))
|
|
254
|
+
if addr:
|
|
255
|
+
bcc_entries.append({"emailAddress": {"address": addr}})
|
|
256
|
+
if bcc_entries:
|
|
257
|
+
message_payload["bccRecipients"] = bcc_entries
|
|
258
|
+
|
|
230
259
|
extra_headers = getattr(send_email_context, "headers", None) or {}
|
|
231
260
|
if extra_headers:
|
|
232
261
|
message_payload["internetMessageHeaders"] = [
|
|
@@ -226,6 +226,35 @@ async def send_email_via_smtp_async(
|
|
|
226
226
|
msg["To"] = ctx.recipient
|
|
227
227
|
msg["Subject"] = ctx.subject
|
|
228
228
|
|
|
229
|
+
# Build envelope recipient list starting with primary
|
|
230
|
+
envelope_recipients = [ctx.recipient]
|
|
231
|
+
|
|
232
|
+
# Add extra To recipients
|
|
233
|
+
extra_to = getattr(ctx, "to_recipients", None) or []
|
|
234
|
+
if extra_to:
|
|
235
|
+
additional = [r.email if hasattr(r, "email") else (r.get("email") if isinstance(r, dict) else str(r)) for r in extra_to]
|
|
236
|
+
additional = [a for a in additional if a and a.lower() != ctx.recipient.strip().lower()]
|
|
237
|
+
if additional:
|
|
238
|
+
del msg["To"]
|
|
239
|
+
msg["To"] = ", ".join([ctx.recipient] + additional)
|
|
240
|
+
envelope_recipients.extend(additional)
|
|
241
|
+
|
|
242
|
+
# Add CC recipients
|
|
243
|
+
cc_list = getattr(ctx, "cc_recipients", None) or []
|
|
244
|
+
if cc_list:
|
|
245
|
+
cc_addrs = [r.email if hasattr(r, "email") else (r.get("email") if isinstance(r, dict) else str(r)) for r in cc_list]
|
|
246
|
+
cc_addrs = [a for a in cc_addrs if a]
|
|
247
|
+
if cc_addrs:
|
|
248
|
+
msg["Cc"] = ", ".join(cc_addrs)
|
|
249
|
+
envelope_recipients.extend(cc_addrs)
|
|
250
|
+
|
|
251
|
+
# Add BCC recipients (envelope only, no header)
|
|
252
|
+
bcc_list = getattr(ctx, "bcc_recipients", None) or []
|
|
253
|
+
if bcc_list:
|
|
254
|
+
bcc_addrs = [r.email if hasattr(r, "email") else (r.get("email") if isinstance(r, dict) else str(r)) for r in bcc_list]
|
|
255
|
+
bcc_addrs = [a for a in bcc_addrs if a]
|
|
256
|
+
envelope_recipients.extend(bcc_addrs)
|
|
257
|
+
|
|
229
258
|
# Generate a real RFC 5322 Message-ID
|
|
230
259
|
domain_part = ctx.sender_email.split("@", 1)[-1] or "local"
|
|
231
260
|
generated_id = f"<{uuid.uuid4()}@{domain_part}>"
|
|
@@ -236,6 +265,12 @@ async def send_email_via_smtp_async(
|
|
|
236
265
|
if not header or value is None:
|
|
237
266
|
continue
|
|
238
267
|
msg[header] = str(value)
|
|
268
|
+
# Preserve envelope delivery for Cc/Bcc/To addresses set via extra headers,
|
|
269
|
+
# since explicit recipients= overrides aiosmtplib's header extraction.
|
|
270
|
+
if header.lower() in ("to", "cc", "bcc"):
|
|
271
|
+
for _, addr in email.utils.getaddresses([str(value)]):
|
|
272
|
+
if addr and addr.lower() not in {a.lower() for a in envelope_recipients}:
|
|
273
|
+
envelope_recipients.append(addr)
|
|
239
274
|
|
|
240
275
|
smtp_kwargs = dict(
|
|
241
276
|
hostname=smtp_server,
|
|
@@ -253,7 +288,7 @@ async def send_email_via_smtp_async(
|
|
|
253
288
|
try:
|
|
254
289
|
# aiosmtplib.send returns a (code, response) tuple, but no server message ID.
|
|
255
290
|
# We rely on the real Message-ID we have just set.
|
|
256
|
-
await aiosmtplib.send(msg, **smtp_kwargs)
|
|
291
|
+
await aiosmtplib.send(msg, recipients=envelope_recipients, **smtp_kwargs)
|
|
257
292
|
logging.info("SMTP send OK – msg id %s", generated_id)
|
|
258
293
|
return generated_id
|
|
259
294
|
except Exception:
|
|
@@ -54,6 +54,7 @@ src/dhisana/utils/generate_flow.py
|
|
|
54
54
|
src/dhisana/utils/generate_leads_salesnav.py
|
|
55
55
|
src/dhisana/utils/generate_linkedin_connect_message.py
|
|
56
56
|
src/dhisana/utils/generate_linkedin_response_message.py
|
|
57
|
+
src/dhisana/utils/generate_sms_whatsapp.py
|
|
57
58
|
src/dhisana/utils/generate_structured_output_internal.py
|
|
58
59
|
src/dhisana/utils/google_custom_search.py
|
|
59
60
|
src/dhisana/utils/google_oauth_tools.py
|
|
@@ -110,6 +111,7 @@ tests/test_apollo_lead_search.py
|
|
|
110
111
|
tests/test_connectivity.py
|
|
111
112
|
tests/test_email_body_utils.py
|
|
112
113
|
tests/test_generate_email.py
|
|
114
|
+
tests/test_generate_sms_whatsapp.py
|
|
113
115
|
tests/test_google_document.py
|
|
114
116
|
tests/test_hubspot_call_logs.py
|
|
115
117
|
tests/test_linkedin_serper.py
|
|
@@ -119,4 +121,5 @@ tests/test_normalize_graph_datetime.py
|
|
|
119
121
|
tests/test_proxycurl_get_company_search_id.py
|
|
120
122
|
tests/test_proxycurl_job_count.py
|
|
121
123
|
tests/test_reply_thread_fallback.py
|
|
124
|
+
tests/test_send_email_recipients.py
|
|
122
125
|
tests/test_structured_output_with_mcp.py
|