diff --git a/modules/channels/widget/events.py b/modules/channels/widget/events.py new file mode 100644 index 0000000..a384f02 --- /dev/null +++ b/modules/channels/widget/events.py @@ -0,0 +1,44 @@ +"""Widget channel domain events.""" + +from dataclasses import dataclass, field + +from modules.core.events import BaseEvent + + +@dataclass +class WidgetSessionCreated(BaseEvent): + """Emitted on first user message in a widget chat session. + + CRM domain subscribes to auto-create a lead in amoCRM. + """ + + session_id: str = "" + first_message: str = "" + visitor_metadata: dict = field(default_factory=dict) + + +@dataclass +class WidgetMessageSent(BaseEvent): + """Emitted after each conversation turn in a widget chat session. + + CRM domain subscribes to append notes to an amoCRM lead. + """ + + session_id: str = "" + lead_id: int = 0 + user_message: str = "" + assistant_response: str = "" + + +@dataclass +class WidgetContactSubmitted(BaseEvent): + """Emitted when a visitor submits contact info via widget lead form. + + CRM domain subscribes to create an amoCRM contact and link to a lead. + """ + + session_id: str = "" + contact_name: str = "" + phone: str = "" + email: str = "" + visitor_metadata: dict = field(default_factory=dict) diff --git a/modules/channels/widget/router_public.py b/modules/channels/widget/router_public.py index 06ae0bf..1f686fa 100644 --- a/modules/channels/widget/router_public.py +++ b/modules/channels/widget/router_public.py @@ -1,7 +1,8 @@ """Public widget endpoints — no authentication required. Serves the embeddable widget JS, chat sessions, streaming responses, -and contact form submissions with amoCRM integration. +and contact form submissions. CRM integration (amoCRM lead/contact +creation) is handled reactively via EventBus — see modules/crm/startup.py. """ import json @@ -14,7 +15,6 @@ from fastapi.responses import Response, StreamingResponse from cloud_llm_service import CloudLLMService -from db.retry import retry_on_busy from modules.channels.widget.service import widget_instance_service from modules.chat.service import chat_service from modules.core.service import config_service @@ -34,103 +34,6 @@ def _escape_js_string(s: str) -> str: return s.replace("\\", "\\\\").replace("'", "\\'").replace("\n", "\\n").replace("\r", "\\r") -@retry_on_busy() -async def _save_amocrm_lead_id(session_id: str, lead_id: int) -> None: - """Persist amoCRM lead_id on a chat session (retryable).""" - from sqlalchemy import update as sa_update - - from db.database import AsyncSessionLocal - from db.models import ChatSession as ChatSessionModel - - async with AsyncSessionLocal() as db_session: - await db_session.execute( - sa_update(ChatSessionModel) - .where(ChatSessionModel.id == session_id) - .values(amocrm_lead_id=lead_id) - ) - await db_session.commit() - - -async def _widget_create_amocrm_lead(session_id: str, session_data: dict, content: str): - """Fire-and-forget: create amoCRM lead for a widget session.""" - try: - from app.services import amocrm_service - from modules.crm.service import amocrm_service as amocrm_db_service - - config = await amocrm_db_service.get_config_with_secrets() - if not config or not config.get("access_token") or not config.get("auto_create_lead"): - return - - subdomain = config["subdomain"] - access_token = config["access_token"] - pipeline_id = config.get("lead_pipeline_id") - status_id = config.get("lead_status_id") - - # Build lead name from visitor metadata - metadata = session_data.get("visitor_metadata") or {} - if isinstance(metadata, str): - metadata = json.loads(metadata) - page_title = metadata.get("page_title", "") - page_url = metadata.get("page_url", "") - lead_name = f"Виджет: {page_title or page_url or session_id[:8]}" - - result = await amocrm_service.create_lead( - subdomain, access_token, lead_name, pipeline_id, status_id - ) - - lead_id = None - if result and "_embedded" in result and "leads" in result["_embedded"]: - lead_id = result["_embedded"]["leads"][0].get("id") - - if not lead_id: - logger.warning("amoCRM create_lead returned no lead ID: %s", result) - return - - # Save lead_id to session - await _save_amocrm_lead_id(session_id, lead_id) - - # Add first message + metadata as note - note_parts = [f"Первое сообщение: {content}"] - if metadata.get("page_url"): - note_parts.append(f"Страница: {metadata['page_url']}") - if metadata.get("ip"): - note_parts.append(f"IP: {metadata['ip']}") - utm_parts = [] - for key in ("utm_source", "utm_medium", "utm_campaign"): - if metadata.get(key): - utm_parts.append(f"{key}={metadata[key]}") - if utm_parts: - note_parts.append(f"UTM: {', '.join(utm_parts)}") - if metadata.get("referrer"): - note_parts.append(f"Referrer: {metadata['referrer']}") - - await amocrm_service.add_note_to_lead( - subdomain, access_token, lead_id, "\n".join(note_parts) - ) - logger.info("Created amoCRM lead %s for widget session %s", lead_id, session_id) - - except Exception: - logger.debug("Failed to create amoCRM lead for session %s", session_id, exc_info=True) - - -async def _widget_add_note_to_lead(session_id: str, lead_id: int, user_text: str, ai_text: str): - """Fire-and-forget: append conversation turn to amoCRM lead notes.""" - try: - from app.services import amocrm_service - from modules.crm.service import amocrm_service as amocrm_db_service - - config = await amocrm_db_service.get_config_with_secrets() - if not config or not config.get("access_token"): - return - - note = f"Пользователь: {user_text}\nAI: {ai_text}" - await amocrm_service.add_note_to_lead( - config["subdomain"], config["access_token"], lead_id, note - ) - except Exception: - logger.debug("Failed to add note to lead %s", lead_id, exc_info=True) - - # ============== Endpoints ============== @@ -385,11 +288,25 @@ async def widget_stream_message(request: Request, session_id: str): user_msg = await chat_service.add_message(session_id, "user", content) - # Auto-create amoCRM lead on first user message (fire-and-forget) + # Publish WidgetSessionCreated on first message (fire-and-forget → CRM creates lead) if not session.get("amocrm_lead_id"): import asyncio - asyncio.create_task(_widget_create_amocrm_lead(session_id, session, content)) + from app.dependencies import get_container + from modules.channels.widget.events import WidgetSessionCreated + + metadata = session.get("visitor_metadata") or {} + if isinstance(metadata, str): + metadata = json.loads(metadata) + asyncio.create_task( + get_container().event_bus.publish( + WidgetSessionCreated( + session_id=session_id, + first_message=content, + visitor_metadata=metadata, + ) + ) + ) default_prompt = custom_prompt if not default_prompt and hasattr(active_llm, "get_system_prompt"): @@ -411,12 +328,22 @@ async def generate_stream(): yield f"data: {json.dumps({'type': 'assistant_message', 'message': assistant_msg}, ensure_ascii=False)}\n\n" yield "data: [DONE]\n\n" - # Append conversation turn to amoCRM lead notes (fire-and-forget) + # Publish WidgetMessageSent (fire-and-forget → CRM appends note) if lead_id and response_text: import asyncio + from app.dependencies import get_container + from modules.channels.widget.events import WidgetMessageSent + asyncio.create_task( - _widget_add_note_to_lead(session_id, lead_id, content, response_text) + get_container().event_bus.publish( + WidgetMessageSent( + session_id=session_id, + lead_id=lead_id, + user_message=content, + assistant_response=response_text, + ) + ) ) except Exception as e: logger.error(f"❌ Widget chat stream error: {e}") @@ -431,7 +358,11 @@ async def generate_stream(): @router.post("/widget/chat/session/{session_id}/contacts") async def widget_submit_contacts(request: Request, session_id: str): - """Public: submit contact info from widget lead form → create/update amoCRM contact.""" + """Public: submit contact info from widget lead form. + + Publishes WidgetContactSubmitted event; CRM domain handles amoCRM + contact/lead creation reactively. + """ body = await request.json() name = body.get("name", "").strip() phone = body.get("phone", "").strip() @@ -446,98 +377,28 @@ async def widget_submit_contacts(request: Request, session_id: str): if not session or session.get("source") != "widget": raise HTTPException(status_code=404, detail="Session not found") - try: - from app.services import amocrm_service - from modules.crm.service import amocrm_service as amocrm_db_service - - config = await amocrm_db_service.get_config_with_secrets() - if not config or not config.get("access_token"): - return {"ok": True, "crm": False, "reason": "amoCRM not connected"} - - subdomain = config["subdomain"] - access_token = config["access_token"] - - # Build custom_fields for phone/email - custom_fields = [] - if phone: - custom_fields.append({"field_code": "PHONE", "values": [{"value": phone}]}) - if email: - custom_fields.append({"field_code": "EMAIL", "values": [{"value": email}]}) - - # Create contact - result = await amocrm_service.create_contact(subdomain, access_token, name, custom_fields) - - contact_id = None - if result and "_embedded" in result and "contacts" in result["_embedded"]: - contact_id = result["_embedded"]["contacts"][0].get("id") - - if not contact_id: - logger.warning("amoCRM create_contact returned no contact ID: %s", result) - return {"ok": True, "crm": False, "reason": "Contact creation failed"} - - # Save contact_id to session - from sqlalchemy import update as sa_update - - from db.database import AsyncSessionLocal - from db.models import ChatSession as ChatSessionModel + from app.dependencies import get_container + from modules.channels.widget.events import WidgetContactSubmitted - lead_id = session.get("amocrm_lead_id") + metadata = session.get("visitor_metadata") or {} + if isinstance(metadata, str): + metadata = json.loads(metadata) - async with AsyncSessionLocal() as db_session: - await db_session.execute( - sa_update(ChatSessionModel) - .where(ChatSessionModel.id == session_id) - .values(amocrm_contact_id=contact_id) - ) - await db_session.commit() - - # Link contact to existing lead - if lead_id: - await amocrm_service.link_contact_to_lead(subdomain, access_token, lead_id, contact_id) - # Add note about contact info - note_parts = [f"Контакт оставлен: {name}"] - if phone: - note_parts.append(f"Телефон: {phone}") - if email: - note_parts.append(f"Email: {email}") - await amocrm_service.add_note_to_lead( - subdomain, access_token, lead_id, "\n".join(note_parts) - ) - else: - # No lead yet — create one with this contact - pipeline_id = config.get("lead_pipeline_id") - status_id = config.get("lead_status_id") - metadata = session.get("visitor_metadata") or {} - if isinstance(metadata, str): - metadata = json.loads(metadata) - page_title = metadata.get("page_title", "") - page_url = metadata.get("page_url", "") - lead_name = f"Виджет: {name} ({page_title or page_url or session_id[:8]})" - - lead_result = await amocrm_service.create_lead( - subdomain, access_token, lead_name, pipeline_id, status_id, contact_id + try: + await get_container().event_bus.publish( + WidgetContactSubmitted( + session_id=session_id, + contact_name=name, + phone=phone, + email=email, + visitor_metadata=metadata, ) - new_lead_id = None - if lead_result and "_embedded" in lead_result and "leads" in lead_result["_embedded"]: - new_lead_id = lead_result["_embedded"]["leads"][0].get("id") - - if new_lead_id: - async with AsyncSessionLocal() as db_session: - await db_session.execute( - sa_update(ChatSessionModel) - .where(ChatSessionModel.id == session_id) - .values(amocrm_lead_id=new_lead_id) - ) - await db_session.commit() - - logger.info( - "Widget contact submitted: %s (contact=%s, lead=%s)", - name, - contact_id, - lead_id, ) - return {"ok": True, "crm": True} - except Exception: - logger.error("Failed to create amoCRM contact for session %s", session_id, exc_info=True) - return {"ok": True, "crm": False, "reason": "CRM error"} + logger.error( + "Failed to publish WidgetContactSubmitted for session %s", + session_id, + exc_info=True, + ) + + return {"ok": True} diff --git a/modules/core/startup.py b/modules/core/startup.py index f48658d..8f6fcde 100644 --- a/modules/core/startup.py +++ b/modules/core/startup.py @@ -192,9 +192,11 @@ async def on_session_revoked(event: SessionRevoked) -> None: event_bus.subscribe(SessionRevoked, on_session_revoked) # Domain-specific subscriptions + from modules.crm.startup import setup_crm_event_subscriptions from modules.knowledge.startup import setup_knowledge_event_subscriptions from modules.llm.startup import setup_llm_event_subscriptions + await setup_crm_event_subscriptions(event_bus) await setup_knowledge_event_subscriptions(event_bus) await setup_llm_event_subscriptions(event_bus) diff --git a/modules/crm/startup.py b/modules/crm/startup.py new file mode 100644 index 0000000..311d5f3 --- /dev/null +++ b/modules/crm/startup.py @@ -0,0 +1,221 @@ +"""CRM domain startup: event subscriptions for widget → amoCRM integration.""" + +import logging + + +logger = logging.getLogger(__name__) + + +async def setup_crm_event_subscriptions(event_bus) -> None: + """Register CRM event handlers for widget integration.""" + from modules.channels.widget.events import ( + WidgetContactSubmitted, + WidgetMessageSent, + WidgetSessionCreated, + ) + + async def on_widget_session_created(event: WidgetSessionCreated) -> None: + """Create amoCRM lead on first widget message.""" + await _handle_widget_session_created(event) + + async def on_widget_message_sent(event: WidgetMessageSent) -> None: + """Append conversation turn to amoCRM lead notes.""" + await _handle_widget_message_sent(event) + + async def on_widget_contact_submitted(event: WidgetContactSubmitted) -> None: + """Create amoCRM contact and link to lead.""" + await _handle_widget_contact_submitted(event) + + event_bus.subscribe(WidgetSessionCreated, on_widget_session_created) + event_bus.subscribe(WidgetMessageSent, on_widget_message_sent) + event_bus.subscribe(WidgetContactSubmitted, on_widget_contact_submitted) + logger.info("CRM event subscriptions registered (Widget → amoCRM)") + + +async def _get_amocrm_config() -> dict | None: + """Load amoCRM config with secrets. Returns None if not connected.""" + from modules.crm.service import amocrm_service + + config = await amocrm_service.get_config_with_secrets() + if not config or not config.get("access_token"): + return None + return config + + +async def _save_session_field(session_id: str, **fields) -> None: + """Persist amoCRM IDs on a chat session.""" + from sqlalchemy import update as sa_update + + from db.database import AsyncSessionLocal + from db.models import ChatSession as ChatSessionModel + + async with AsyncSessionLocal() as db_session: + await db_session.execute( + sa_update(ChatSessionModel).where(ChatSessionModel.id == session_id).values(**fields) + ) + await db_session.commit() + + +async def _handle_widget_session_created(event) -> None: + """Create amoCRM lead for a new widget session.""" + try: + from app.services import amocrm_service + + config = await _get_amocrm_config() + if not config or not config.get("auto_create_lead"): + return + + subdomain = config["subdomain"] + access_token = config["access_token"] + pipeline_id = config.get("lead_pipeline_id") + status_id = config.get("lead_status_id") + + metadata = event.visitor_metadata or {} + page_title = metadata.get("page_title", "") + page_url = metadata.get("page_url", "") + lead_name = f"Виджет: {page_title or page_url or event.session_id[:8]}" + + result = await amocrm_service.create_lead( + subdomain, access_token, lead_name, pipeline_id, status_id + ) + + lead_id = None + if result and "_embedded" in result and "leads" in result["_embedded"]: + lead_id = result["_embedded"]["leads"][0].get("id") + + if not lead_id: + logger.warning("amoCRM create_lead returned no lead ID: %s", result) + return + + await _save_session_field(event.session_id, amocrm_lead_id=lead_id) + + # Add first message + metadata as note + note_parts = [f"Первое сообщение: {event.first_message}"] + if metadata.get("page_url"): + note_parts.append(f"Страница: {metadata['page_url']}") + if metadata.get("ip"): + note_parts.append(f"IP: {metadata['ip']}") + utm_parts = [] + for key in ("utm_source", "utm_medium", "utm_campaign"): + if metadata.get(key): + utm_parts.append(f"{key}={metadata[key]}") + if utm_parts: + note_parts.append(f"UTM: {', '.join(utm_parts)}") + if metadata.get("referrer"): + note_parts.append(f"Referrer: {metadata['referrer']}") + + await amocrm_service.add_note_to_lead( + subdomain, access_token, lead_id, "\n".join(note_parts) + ) + logger.info("Created amoCRM lead %s for widget session %s", lead_id, event.session_id) + + except Exception: + logger.debug( + "Failed to create amoCRM lead for session %s", + event.session_id, + exc_info=True, + ) + + +async def _handle_widget_message_sent(event) -> None: + """Append conversation turn to amoCRM lead notes.""" + try: + from app.services import amocrm_service + + config = await _get_amocrm_config() + if not config: + return + + note = f"Пользователь: {event.user_message}\nAI: {event.assistant_response}" + await amocrm_service.add_note_to_lead( + config["subdomain"], config["access_token"], event.lead_id, note + ) + except Exception: + logger.debug("Failed to add note to lead %s", event.lead_id, exc_info=True) + + +async def _handle_widget_contact_submitted(event) -> None: + """Create amoCRM contact, link to lead, or create lead with contact.""" + try: + from app.services import amocrm_service + + config = await _get_amocrm_config() + if not config: + return + + subdomain = config["subdomain"] + access_token = config["access_token"] + + # Build custom_fields for phone/email + custom_fields = [] + if event.phone: + custom_fields.append({"field_code": "PHONE", "values": [{"value": event.phone}]}) + if event.email: + custom_fields.append({"field_code": "EMAIL", "values": [{"value": event.email}]}) + + # Create contact + result = await amocrm_service.create_contact( + subdomain, access_token, event.contact_name, custom_fields + ) + + contact_id = None + if result and "_embedded" in result and "contacts" in result["_embedded"]: + contact_id = result["_embedded"]["contacts"][0].get("id") + + if not contact_id: + logger.warning("amoCRM create_contact returned no contact ID: %s", result) + return + + await _save_session_field(event.session_id, amocrm_contact_id=contact_id) + + # Get current session to check for existing lead + from modules.chat.service import chat_service + + session = await chat_service.get_session(event.session_id) + lead_id = session.get("amocrm_lead_id") if session else None + + if lead_id: + # Link contact to existing lead + await amocrm_service.link_contact_to_lead(subdomain, access_token, lead_id, contact_id) + note_parts = [f"Контакт оставлен: {event.contact_name}"] + if event.phone: + note_parts.append(f"Телефон: {event.phone}") + if event.email: + note_parts.append(f"Email: {event.email}") + await amocrm_service.add_note_to_lead( + subdomain, access_token, lead_id, "\n".join(note_parts) + ) + else: + # No lead yet — create one with this contact + pipeline_id = config.get("lead_pipeline_id") + status_id = config.get("lead_status_id") + metadata = event.visitor_metadata or {} + page_title = metadata.get("page_title", "") + page_url = metadata.get("page_url", "") + lead_name = ( + f"Виджет: {event.contact_name} ({page_title or page_url or event.session_id[:8]})" + ) + + lead_result = await amocrm_service.create_lead( + subdomain, access_token, lead_name, pipeline_id, status_id, contact_id + ) + new_lead_id = None + if lead_result and "_embedded" in lead_result and "leads" in lead_result["_embedded"]: + new_lead_id = lead_result["_embedded"]["leads"][0].get("id") + + if new_lead_id: + await _save_session_field(event.session_id, amocrm_lead_id=new_lead_id) + + logger.info( + "Widget contact submitted: %s (contact=%s, lead=%s)", + event.contact_name, + contact_id, + lead_id, + ) + + except Exception: + logger.error( + "Failed to create amoCRM contact for session %s", + event.session_id, + exc_info=True, + ) diff --git a/tests/unit/test_widget_crm_events.py b/tests/unit/test_widget_crm_events.py new file mode 100644 index 0000000..88d439b --- /dev/null +++ b/tests/unit/test_widget_crm_events.py @@ -0,0 +1,227 @@ +"""Tests for Widget → CRM event handlers.""" + +from unittest.mock import AsyncMock, patch + +from modules.channels.widget.events import ( + WidgetContactSubmitted, + WidgetMessageSent, + WidgetSessionCreated, +) +from modules.core.events import EventBus +from modules.crm.startup import setup_crm_event_subscriptions + + +# Patch target for amoCRM API functions (lazy-imported as module in handlers) +_AMO = "app.services.amocrm_service" + + +async def test_widget_session_created_creates_lead(): + """WidgetSessionCreated creates amoCRM lead and saves lead_id to session.""" + bus = EventBus() + await setup_crm_event_subscriptions(bus) + + mock_config = { + "access_token": "tok", + "subdomain": "test", + "auto_create_lead": True, + "lead_pipeline_id": 1, + "lead_status_id": 2, + } + + with ( + patch( + "modules.crm.service.amocrm_service.get_config_with_secrets", + AsyncMock(return_value=mock_config), + ), + patch( + f"{_AMO}.create_lead", AsyncMock(return_value={"_embedded": {"leads": [{"id": 777}]}}) + ) as mock_create, + patch(f"{_AMO}.add_note_to_lead", AsyncMock()) as mock_note, + patch("modules.crm.startup._save_session_field", AsyncMock()) as mock_save, + ): + await bus.publish( + WidgetSessionCreated( + session_id="sess-123", + first_message="Привет!", + visitor_metadata={"page_url": "https://example.com", "ip": "1.2.3.4"}, + ) + ) + + mock_create.assert_awaited_once() + mock_save.assert_awaited_once_with("sess-123", amocrm_lead_id=777) + mock_note.assert_awaited_once() + note_text = mock_note.call_args[0][3] + assert "Привет!" in note_text + assert "https://example.com" in note_text + + +async def test_widget_session_created_skips_when_auto_create_disabled(): + """WidgetSessionCreated does nothing when auto_create_lead is disabled.""" + bus = EventBus() + await setup_crm_event_subscriptions(bus) + + mock_config = { + "access_token": "tok", + "subdomain": "test", + "auto_create_lead": False, + } + + with ( + patch( + "modules.crm.service.amocrm_service.get_config_with_secrets", + AsyncMock(return_value=mock_config), + ), + patch(f"{_AMO}.create_lead", AsyncMock()) as mock_create, + ): + await bus.publish(WidgetSessionCreated(session_id="sess-456", first_message="Hi")) + + mock_create.assert_not_awaited() + + +async def test_widget_session_created_skips_when_no_config(): + """WidgetSessionCreated does nothing when amoCRM is not configured.""" + bus = EventBus() + await setup_crm_event_subscriptions(bus) + + with patch( + "modules.crm.service.amocrm_service.get_config_with_secrets", + AsyncMock(return_value=None), + ): + # Should not raise + await bus.publish(WidgetSessionCreated(session_id="sess-789", first_message="Hello")) + + +async def test_widget_message_sent_adds_note(): + """WidgetMessageSent appends conversation turn as note to lead.""" + bus = EventBus() + await setup_crm_event_subscriptions(bus) + + mock_config = {"access_token": "tok", "subdomain": "test"} + + with ( + patch( + "modules.crm.service.amocrm_service.get_config_with_secrets", + AsyncMock(return_value=mock_config), + ), + patch(f"{_AMO}.add_note_to_lead", AsyncMock()) as mock_note, + ): + await bus.publish( + WidgetMessageSent( + session_id="sess-123", + lead_id=777, + user_message="Сколько стоит?", + assistant_response="От 1000 руб.", + ) + ) + + mock_note.assert_awaited_once() + args = mock_note.call_args[0] + assert args[0] == "test" # subdomain + assert args[2] == 777 # lead_id + assert "Сколько стоит?" in args[3] + assert "От 1000 руб." in args[3] + + +async def test_widget_contact_submitted_creates_contact_and_links(): + """WidgetContactSubmitted creates contact and links to existing lead.""" + bus = EventBus() + await setup_crm_event_subscriptions(bus) + + mock_config = {"access_token": "tok", "subdomain": "test"} + mock_session = {"amocrm_lead_id": 777, "visitor_metadata": {}} + + with ( + patch( + "modules.crm.service.amocrm_service.get_config_with_secrets", + AsyncMock(return_value=mock_config), + ), + patch( + f"{_AMO}.create_contact", + AsyncMock(return_value={"_embedded": {"contacts": [{"id": 555}]}}), + ) as mock_create_contact, + patch(f"{_AMO}.link_contact_to_lead", AsyncMock()) as mock_link, + patch(f"{_AMO}.add_note_to_lead", AsyncMock()), + patch("modules.crm.startup._save_session_field", AsyncMock()) as mock_save, + patch( + "modules.chat.service.chat_service.get_session", + AsyncMock(return_value=mock_session), + ), + ): + await bus.publish( + WidgetContactSubmitted( + session_id="sess-123", + contact_name="Иван", + phone="+79001234567", + email="ivan@test.com", + ) + ) + + mock_create_contact.assert_awaited_once() + mock_save.assert_awaited_once_with("sess-123", amocrm_contact_id=555) + mock_link.assert_awaited_once_with("test", "tok", 777, 555) + + +async def test_widget_contact_submitted_creates_lead_when_no_existing(): + """WidgetContactSubmitted creates new lead when session has no lead_id.""" + bus = EventBus() + await setup_crm_event_subscriptions(bus) + + mock_config = { + "access_token": "tok", + "subdomain": "test", + "lead_pipeline_id": 1, + "lead_status_id": 2, + } + mock_session = {"amocrm_lead_id": None, "visitor_metadata": {}} + + with ( + patch( + "modules.crm.service.amocrm_service.get_config_with_secrets", + AsyncMock(return_value=mock_config), + ), + patch( + f"{_AMO}.create_contact", + AsyncMock(return_value={"_embedded": {"contacts": [{"id": 555}]}}), + ), + patch( + f"{_AMO}.create_lead", + AsyncMock(return_value={"_embedded": {"leads": [{"id": 888}]}}), + ) as mock_create_lead, + patch("modules.crm.startup._save_session_field", AsyncMock()) as mock_save, + patch( + "modules.chat.service.chat_service.get_session", + AsyncMock(return_value=mock_session), + ), + ): + await bus.publish( + WidgetContactSubmitted( + session_id="sess-456", + contact_name="Мария", + phone="+79005556677", + email="", + ) + ) + + # Should have saved contact_id, then lead_id + assert mock_save.await_count == 2 + mock_save.assert_any_await("sess-456", amocrm_contact_id=555) + mock_save.assert_any_await("sess-456", amocrm_lead_id=888) + mock_create_lead.assert_awaited_once() + + +async def test_widget_events_have_fields(): + """Widget events have expected fields.""" + e1 = WidgetSessionCreated( + session_id="s1", first_message="hi", visitor_metadata={"ip": "1.1.1.1"} + ) + assert e1.session_id == "s1" + assert e1.first_message == "hi" + assert e1.visitor_metadata == {"ip": "1.1.1.1"} + assert e1.timestamp > 0 + + e2 = WidgetMessageSent(session_id="s2", lead_id=42, user_message="q", assistant_response="a") + assert e2.lead_id == 42 + + e3 = WidgetContactSubmitted(session_id="s3", contact_name="Test", phone="+7", email="t@t.com") + assert e3.contact_name == "Test" + assert e3.phone == "+7"